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, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog' import { save } from '@tauri-apps/plugin-dialog'
import { readDir, stat } from '@tauri-apps/plugin-fs' import { ref, shallowRef } from 'vue'
import { ref } from 'vue'
import { PackageIcon } from '@/assets/icons' import { PackageIcon } from '@/assets/icons'
import { import { export_instance_mrpack, get_pack_export_candidates } from '@/helpers/instance'
export_instance_mrpack,
get_full_path,
get_pack_export_candidates,
} from '@/helpers/instance'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -65,30 +60,24 @@ const exportModal = ref(null)
const nameInput = ref(props.instance.name) const nameInput = ref(props.instance.name)
const exportDescription = ref('') const exportDescription = ref('')
const versionInput = ref('1.0.0') const versionInput = ref('1.0.0')
const files = ref([]) const files = shallowRef([])
const selectedFilePaths = ref([]) const includedFilePaths = ref([])
const excludedFilePaths = ref([])
const fileTreeKey = ref(0) const fileTreeKey = ref(0)
const filesLoadId = ref(0) const filesLoadId = ref(0)
const instanceRoot = ref('') const directoryEntries = new Map()
const loadedDirectories = ref(new Set()) const currentDirectory = ref('')
async function initFiles() { async function initFiles() {
const loadId = ++filesLoadId.value const loadId = ++filesLoadId.value
const [filePaths, root] = await Promise.all([ const exportCandidates = await get_pack_export_candidates(props.instance.id)
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 if (loadId !== filesLoadId.value) return
files.value = exportCandidates files.value = exportCandidates
selectedFilePaths.value = files.value directoryEntries.set('', exportCandidates)
.filter((file) => !file.disabled && isDefaultSelectedExportCandidate(file.path)) currentDirectory.value = ''
includedFilePaths.value = files.value
.filter((file) => !file.disabled && file.defaultSelected)
.map((file) => file.path) .map((file) => file.path)
} }
@@ -107,7 +96,8 @@ const exportPack = async () => {
export_instance_mrpack( export_instance_mrpack(
props.instance.id, props.instance.id,
outputPath, outputPath,
selectedFilePaths.value, includedFilePaths.value,
excludedFilePaths.value,
versionInput.value, versionInput.value,
exportDescription.value, exportDescription.value,
nameInput.value, nameInput.value,
@@ -121,115 +111,45 @@ function resetExportState() {
exportDescription.value = '' exportDescription.value = ''
versionInput.value = '1.0.0' versionInput.value = '1.0.0'
files.value = [] files.value = []
selectedFilePaths.value = [] includedFilePaths.value = []
excludedFilePaths.value = []
fileTreeKey.value += 1 fileTreeKey.value += 1
instanceRoot.value = '' directoryEntries.clear()
loadedDirectories.value = new Set() currentDirectory.value = ''
} }
async function loadExportDirectory(path) { 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 const loadId = filesLoadId.value
loadedDirectories.value.add(path) files.value = []
try { try {
const entries = await readDir(`${instanceRoot.value}/${path}`) const childItems = await get_pack_export_candidates(
const childItems = await Promise.all( props.instance.id,
entries.map((entry) => buildExportDirectoryChildItem(instanceRoot.value, path, entry)), normalizedPath || undefined,
) )
if (loadId !== filesLoadId.value) return if (loadId !== filesLoadId.value) return
appendExportItems(childItems) directoryEntries.set(normalizedPath, childItems)
} catch { if (currentDirectory.value === normalizedPath) {
loadedDirectories.value.delete(path) files.value = childItems
}
}
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 { } catch {
return buildExportFileItem(instanceRoot, path) if (currentDirectory.value === normalizedPath) files.value = []
}
}
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) { function normalizeExportPath(path) {
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/') 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> </script>
<template> <template>
@@ -278,9 +198,11 @@ function isExportCandidateDisabled(path) {
</div> </div>
<FileTreeSelect <FileTreeSelect
:key="fileTreeKey" :key="fileTreeKey"
v-model="selectedFilePaths" v-model="includedFilePaths"
v-model:excluded-paths="excludedFilePaths"
class="min-w-0" class="min-w-0"
:items="files" :items="files"
lazy
@navigate="loadExportDirectory" @navigate="loadExportDirectory"
/> />
</div> </div>
@@ -28,7 +28,8 @@
<div class="flex min-w-0 flex-col gap-3 pt-4"> <div class="flex min-w-0 flex-col gap-3 pt-4">
<div ref="configFileTreeContainer" class="max-h-[292px] overflow-y-auto rounded-[20px]"> <div ref="configFileTreeContainer" class="max-h-[292px] overflow-y-auto rounded-[20px]">
<FileTreeSelect <FileTreeSelect
v-model="selectedConfigPaths" v-model="includedConfigPaths"
v-model:excluded-paths="excludedConfigPaths"
:items="configFileItems" :items="configFileItems"
:show-size="false" :show-size="false"
:show-modified="false" :show-modified="false"
@@ -79,11 +80,20 @@ const publishReviewModal = ref<InstanceType<typeof ContentDiffModal>>()
const configFileTreeContainer = ref<HTMLElement>() const configFileTreeContainer = ref<HTMLElement>()
const publishDiffs = ref<ContentDiffItem[]>([]) const publishDiffs = ref<ContentDiffItem[]>([])
const configFilePaths = ref<string[]>([]) const configFilePaths = ref<string[]>([])
const selectedConfigPaths = ref<string[]>([]) const includedConfigPaths = ref<string[]>([])
const excludedConfigPaths = ref<string[]>([])
const state = ref<SharedInstancePublishState>('idle') const state = ref<SharedInstancePublishState>('idle')
const configFileItems = computed<FileTreeSelectItem[]>(() => const configFileItems = computed<FileTreeSelectItem[]>(() =>
configFilePaths.value.map((path) => ({ path, type: 'file' })), 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) { async function show(e?: MouseEvent) {
if (state.value !== 'idle') return if (state.value !== 'idle') return
@@ -108,7 +118,8 @@ async function show(e?: MouseEvent) {
disabled: diff.disabled, disabled: diff.disabled,
})) }))
configFilePaths.value = preview.configFiles configFilePaths.value = preview.configFiles
selectedConfigPaths.value = [] includedConfigPaths.value = []
excludedConfigPaths.value = []
if (!publishReviewModal.value) return if (!publishReviewModal.value) return
publishReviewModal.value.show(e) 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() { function scrollConfigFileTreeToTop() {
if (configFileTreeContainer.value) { if (configFileTreeContainer.value) {
configFileTreeContainer.value.scrollTop = 0 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 // 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) // Version id is optional (ie: 1.1.5)
export async function export_instance_mrpack( export async function export_instance_mrpack(
instanceId: string, instanceId: string,
exportLocation: string, exportLocation: string,
includedOverrides: string[], includedOverrides: string[],
excludedOverrides: string[],
versionId?: string, versionId?: string,
description?: string, description?: string,
name?: string, name?: string,
@@ -296,22 +297,33 @@ export async function export_instance_mrpack(
instanceId, instanceId,
exportLocation, exportLocation,
includedOverrides, includedOverrides,
excludedOverrides,
versionId, versionId,
description, description,
name, name,
}) })
} }
// Given a folder path, populate an array of all the subfolders export type PackExportCandidate = {
// Intended to be used for finding potential override folders path: string
// profile type: 'directory' | 'file'
// -- mods size?: number
// -- resourcepacks modified?: number
// -- file1 count?: number
// => [mods, resourcepacks] disabled: boolean
// allows selection for 'included_overrides' in export_instance_mrpack defaultSelected: boolean
export async function get_pack_export_candidates(instanceId: string): Promise<string[]> { }
return await invoke('plugin:instance|instance_get_pack_export_candidates', { instanceId })
// 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 // Run Minecraft using an instance
+9 -2
View File
@@ -729,6 +729,7 @@ pub async fn instance_export_mrpack(
instance_id: &str, instance_id: &str,
export_location: PathBuf, export_location: PathBuf,
included_overrides: Vec<String>, included_overrides: Vec<String>,
excluded_overrides: Vec<String>,
version_id: Option<String>, version_id: Option<String>,
description: Option<String>, description: Option<String>,
name: Option<String>, name: Option<String>,
@@ -737,6 +738,7 @@ pub async fn instance_export_mrpack(
instance_id, instance_id,
export_location, export_location,
included_overrides, included_overrides,
excluded_overrides,
version_id, version_id,
description, description,
name, name,
@@ -748,8 +750,13 @@ pub async fn instance_export_mrpack(
#[tauri::command] #[tauri::command]
pub async fn instance_get_pack_export_candidates( pub async fn instance_get_pack_export_candidates(
instance_id: &str, instance_id: &str,
) -> Result<Vec<SafeRelativeUtf8UnixPathBuf>> { parent: Option<SafeRelativeUtf8UnixPathBuf>,
Ok(theseus::instance::get_pack_export_candidates(instance_id).await?) ) -> Result<Vec<theseus::instance::PackExportCandidate>> {
Ok(theseus::instance::get_pack_export_candidates_for_parent(
instance_id,
parent,
)
.await?)
} }
#[tauri::command] #[tauri::command]
+2 -1
View File
@@ -19,7 +19,8 @@ pub use self::content::{
list_content_sets, sync_content_files, list_content_sets, sync_content_files,
}; };
pub use self::export_mrpack::{ 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::get::{get, get_many, list};
pub use self::icon::edit_icon; pub use self::icon::edit_icon;
@@ -12,17 +12,157 @@ use crate::state::{
use crate::util::io::{self, IOError}; use crate::util::io::{self, IOError};
use async_zip::tokio::write::ZipFileWriter; use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder}; use async_zip::{Compression, ZipEntryBuilder};
use futures::{StreamExt, stream};
use path_util::SafeRelativeUtf8UnixPathBuf; use path_util::SafeRelativeUtf8UnixPathBuf;
use std::collections::HashMap; use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
use std::time::UNIX_EPOCH;
use tokio::fs::File; use tokio::fs::File;
use tokio::io::AsyncReadExt; use tokio_util::compat::FuturesAsyncWriteCompatExt;
const DEFAULT_SELECTED_EXPORT_PATH_PREFIXES: &[&str] = &[
"mods",
"datapacks",
"resourcepacks",
"shaderpacks",
"config",
];
const EXPORT_CANDIDATE_METADATA_CONCURRENCY: usize = 32;
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",
];
const NEVER_EXPORTABLE_PATH_SUFFIXES: &[&str] = &[".DS_Store"];
#[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,
}
#[derive(Default)]
struct ExportSelectionNode {
selected: Option<bool>,
has_included_rule: bool,
children: HashMap<String, ExportSelectionNode>,
}
#[derive(Default)]
struct ExportSelection {
root: ExportSelectionNode,
}
impl ExportSelection {
fn new(included_paths: Vec<String>, excluded_paths: Vec<String>) -> Self {
let mut rules = HashMap::new();
for (paths, selected) in
[(included_paths, true), (excluded_paths, false)]
{
for path in paths {
let Ok(path) = SafeRelativeUtf8UnixPathBuf::try_from(path)
else {
continue;
};
if path.as_str().is_empty() || !is_path_exportable(&path) {
continue;
}
rules.insert(path.as_str().to_string(), selected);
}
}
let mut selection = Self::default();
for (path, selected) in rules {
selection.root.insert(&path, selected);
}
selection
}
fn is_included(&self, path: &SafeRelativeUtf8UnixPathBuf) -> bool {
self.resolve(path).0
}
fn should_visit_directory(
&self,
path: &SafeRelativeUtf8UnixPathBuf,
) -> bool {
let (selected, node) = self.resolve(path);
selected || node.is_some_and(|node| node.has_included_rule)
}
fn resolve(
&self,
path: &SafeRelativeUtf8UnixPathBuf,
) -> (bool, Option<&ExportSelectionNode>) {
let mut node = &self.root;
let mut selected = node.selected.unwrap_or(false);
for segment in path.as_str().split('/') {
let Some(child) = node.children.get(segment) else {
return (selected, None);
};
node = child;
selected = node.selected.unwrap_or(selected);
}
(selected, Some(node))
}
}
impl ExportSelectionNode {
fn insert(&mut self, path: &str, selected: bool) {
if selected {
self.has_included_rule = true;
}
let mut node = self;
for segment in path.split('/') {
node = node.children.entry(segment.to_string()).or_default();
if selected {
node.has_included_rule = true;
}
}
node.selected = Some(selected);
}
}
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub async fn export_mrpack( pub async fn export_mrpack(
instance_id: &str, instance_id: &str,
export_path: PathBuf, export_path: PathBuf,
included_export_candidates: Vec<String>, included_export_candidates: Vec<String>,
excluded_export_candidates: Vec<String>,
version_id: Option<String>, version_id: Option<String>,
description: Option<String>, description: Option<String>,
_name: Option<String>, _name: Option<String>,
@@ -35,17 +175,10 @@ pub async fn export_mrpack(
"Tried to export a nonexistent instance {instance_id}!" "Tried to export a nonexistent instance {instance_id}!"
)) ))
})?; })?;
let included_export_candidates = included_export_candidates let export_selection = ExportSelection::new(
.into_iter() included_export_candidates,
.filter(|x| { excluded_export_candidates,
if let Some(f) = PathBuf::from(x).file_name() );
&& f.to_string_lossy().starts_with(".DS_Store")
{
return false;
}
true
})
.collect::<Vec<_>>();
let instance_base_path = get_full_path(instance_id).await?; let instance_base_path = get_full_path(instance_id).await?;
let mut file = File::create(&export_path) let mut file = File::create(&export_path)
@@ -56,48 +189,72 @@ pub async fn export_mrpack(
let mut packfile = let mut packfile =
create_mrpack_json(&metadata, version_id, description).await?; create_mrpack_json(&metadata, version_id, description).await?;
packfile.files.retain(|f| { packfile.files.retain(|f| {
is_export_candidate_included( is_path_exportable(&f.path) && export_selection.is_included(&f.path)
f.path.as_str(),
&included_export_candidates,
)
}); });
let packfile_paths = packfile
let mut path_list = Vec::new(); .files
add_all_recursive_folder_paths(&instance_base_path, &mut path_list).await?; .iter()
.map(|file| file.path.as_str().to_string())
.collect::<HashSet<_>>();
let loading_bar = init_loading( let loading_bar = init_loading(
LoadingBarType::ZipExtract { LoadingBarType::ZipExtract {
instance_id: metadata.instance.id.clone(), instance_id: metadata.instance.id.clone(),
instance_name: metadata.instance.name.clone(), instance_name: metadata.instance.name.clone(),
}, },
path_list.len() as f64, 1.0,
"Exporting instance to .mrpack", "Exporting instance to .mrpack",
) )
.await?; .await?;
for path in path_list { let mut directories = vec![instance_base_path.clone()];
emit_loading(&loading_bar, 1.0, None)?; while let Some(directory) = directories.pop() {
let relative_path = pack_get_relative_path(&instance_base_path, &path)?; let mut read_dir = io::read_dir(&directory).await?;
while let Some(entry) = read_dir
if packfile.files.iter().any(|f| f.path == relative_path) .next_entry()
|| !is_export_candidate_included( .await
relative_path.as_str(), .map_err(|e| IOError::with_path(e, &directory))?
&included_export_candidates,
)
{ {
continue; let path = entry.path();
} let relative_path =
pack_get_relative_path(&instance_base_path, &path)?;
if !is_path_exportable(&relative_path) {
continue;
}
if path.is_file() { let file_type = entry
let mut file = File::open(&path) .file_type()
.await .await
.map_err(|e| IOError::with_path(e, &path))?; .map_err(|e| IOError::with_path(e, &path))?;
let mut data = Vec::new(); if file_type.is_dir() {
file.read_to_end(&mut data).await.map_err(IOError::from)?; if export_selection.should_visit_directory(&relative_path) {
let builder = ZipEntryBuilder::new( directories.push(path);
format!("overrides/{relative_path}").into(), }
Compression::Deflate, continue;
); }
writer.write_entry_whole(builder, &data).await?; if !file_type.is_file()
|| !export_selection.is_included(&relative_path)
|| packfile_paths.contains(relative_path.as_str())
{
continue;
}
let mut stream = writer
.write_entry_stream(
ZipEntryBuilder::new(
format!("overrides/{relative_path}").into(),
Compression::Deflate,
)
.build(),
)
.await?
.compat_write();
let mut source = File::open(&path)
.await
.map_err(|e| IOError::with_path(e, &path))?;
tokio::io::copy(&mut source, &mut stream)
.await
.map_err(IOError::from)?;
stream.into_inner().close().await?;
} }
} }
@@ -108,54 +265,136 @@ pub async fn export_mrpack(
); );
writer.write_entry_whole(builder, &data).await?; writer.write_entry_whole(builder, &data).await?;
writer.close().await?; writer.close().await?;
emit_loading(&loading_bar, 1.0, None)?;
Ok(()) Ok(())
} }
fn is_export_candidate_included( fn is_path_exportable(relative_path: &SafeRelativeUtf8UnixPathBuf) -> bool {
path: &str, let path = relative_path.as_str();
included_export_candidates: &[String],
) -> bool { !NEVER_EXPORTABLE_PATH_PREFIXES.iter().any(|prefix| {
included_export_candidates.iter().any(|candidate| { path == *prefix
path == candidate
|| path || path
.strip_prefix(candidate) .strip_prefix(prefix)
.is_some_and(|suffix| suffix.starts_with('/')) .is_some_and(|suffix| suffix.starts_with('/'))
}) }) && !NEVER_EXPORTABLE_PATH_SUFFIXES
.iter()
.any(|suffix| path.ends_with(suffix))
} }
#[tracing::instrument] #[tracing::instrument]
pub async fn get_pack_export_candidates( pub async fn get_pack_export_candidates(
instance_id: &str, instance_id: &str,
) -> crate::Result<Vec<SafeRelativeUtf8UnixPathBuf>> { ) -> crate::Result<Vec<PackExportCandidate>> {
let mut path_list = Vec::new(); 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 instance_base_dir = get_full_path(instance_id).await?; 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(Vec::new());
}
instance_base_dir.join(parent.as_str())
} else {
instance_base_dir.clone()
};
let parent_dir = io::canonicalize(parent_dir)?;
if !parent_dir.starts_with(&instance_base_dir) {
return Ok(Vec::new());
}
let mut paths = Vec::new();
let mut read_dir = io::read_dir(&parent_dir).await?;
while let Some(entry) = read_dir while let Some(entry) = read_dir
.next_entry() .next_entry()
.await .await
.map_err(|e| IOError::with_path(e, &instance_base_dir))? .map_err(|e| IOError::with_path(e, &parent_dir))?
{ {
let path = entry.path(); paths.push(entry.path());
if path.is_dir() { }
let mut read_dir = io::read_dir(&path).await?;
while let Some(entry) = read_dir let candidates = stream::iter(paths)
.next_entry() .map(|path| {
.await let instance_base_dir = &instance_base_dir;
.map_err(|e| IOError::with_path(e, &instance_base_dir))? async move {
{ build_pack_export_candidate(instance_base_dir, &path).await
path_list.push(pack_get_relative_path(
&instance_base_dir,
&entry.path(),
)?);
} }
} else { })
path_list.push(pack_get_relative_path(&instance_base_dir, &path)?); .buffer_unordered(EXPORT_CANDIDATE_METADATA_CONCURRENCY)
.collect::<Vec<_>>()
.await;
let mut path_list = Vec::with_capacity(candidates.len());
for candidate in candidates {
if let Some(candidate) = candidate? {
path_list.push(candidate);
} }
} }
Ok(path_list) 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 = tokio::fs::symlink_metadata(path)
.await
.map_err(|error| IOError::with_path(error, path))?;
if metadata.file_type().is_symlink()
|| (!metadata.is_dir() && !metadata.is_file())
{
return Ok(None);
}
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( fn pack_get_relative_path(
instance_path: &PathBuf, instance_path: &PathBuf,
path: &PathBuf, path: &PathBuf,
@@ -283,25 +522,3 @@ pub async fn create_mrpack_json(
dependencies, dependencies,
}) })
} }
#[async_recursion::async_recursion]
async fn add_all_recursive_folder_paths(
folder: &PathBuf,
output: &mut Vec<PathBuf>,
) -> crate::Result<()> {
let mut read_dir = io::read_dir(folder).await?;
while let Some(entry) = read_dir
.next_entry()
.await
.map_err(|e| IOError::with_path(e, folder))?
{
let path = entry.path();
if path.is_dir() {
add_all_recursive_folder_paths(&path, output).await?;
} else {
output.push(path);
}
}
Ok(())
}
@@ -258,6 +258,11 @@ type FileTreeSelectEntry = {
item?: NormalizedFileTreeSelectItem item?: NormalizedFileTreeSelectItem
} }
type FileTreeSelectionRuleNode = {
selected?: boolean
children: Map<string, FileTreeSelectionRuleNode>
}
type FileTreeSelectSortField = 'name' | 'size' | 'modified' type FileTreeSelectSortField = 'name' | 'size' | 'modified'
const formatBytes = useFormatBytes() const formatBytes = useFormatBytes()
@@ -273,19 +278,22 @@ const props = withDefaults(
defineProps<{ defineProps<{
items: FileTreeSelectItem[] items: FileTreeSelectItem[]
modelValue: string[] modelValue: string[]
excludedPaths: string[]
lazy?: boolean
showSize?: boolean showSize?: boolean
showModified?: boolean showModified?: boolean
}>(), }>(),
{ {
items: () => [], items: () => [],
modelValue: () => [], modelValue: () => [],
lazy: false,
showSize: true, showSize: true,
showModified: true, showModified: true,
}, },
) )
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void (e: 'update:modelValue' | 'update:excludedPaths', value: string[]): void
(e: 'navigate', path: string): void (e: 'navigate', path: string): void
}>() }>()
@@ -312,7 +320,23 @@ const normalizedItems = computed(() => {
return [...items.values()] return [...items.values()]
}) })
const selectedPaths = computed(() => new Set(props.modelValue.map((path) => normalizePath(path)))) const includedPaths = computed(() => new Set(props.modelValue.map((path) => normalizePath(path))))
const excludedPaths = computed(
() => new Set(props.excludedPaths.map((path) => normalizePath(path))),
)
const selectionRuleTree = computed(() => {
const root: FileTreeSelectionRuleNode = { children: new Map() }
for (const path of includedPaths.value) {
addSelectionRule(root, path, true)
}
for (const path of excludedPaths.value) {
addSelectionRule(root, path, false)
}
return root
})
const folderPaths = computed(() => { const folderPaths = computed(() => {
const paths = new Set<string>() const paths = new Set<string>()
@@ -361,24 +385,14 @@ const entries = computed<FileTreeSelectEntry[]>(() => {
const visibleSelectableEntries = computed(() => entries.value.filter((entry) => !entry.disabled)) 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( const allVisibleSelected = computed(
() => () =>
visibleSelectablePaths.value.length > 0 && visibleSelectableEntries.value.length > 0 &&
visibleSelectablePaths.value.every((path) => selectedPaths.value.has(path)), visibleSelectableEntries.value.every((entry) => entry.checked && !entry.indeterminate),
) )
const someVisibleSelected = computed(() => const someVisibleSelected = computed(() =>
visibleSelectablePaths.value.some((path) => selectedPaths.value.has(path)), visibleSelectableEntries.value.some((entry) => entry.checked || entry.indeterminate),
) )
const visibleRowCount = computed( const visibleRowCount = computed(
@@ -392,7 +406,7 @@ const fillerRowCount = computed(() =>
watch( watch(
normalizedItems, normalizedItems,
() => { () => {
if (currentPath.value && !folderPaths.value.has(currentPath.value)) { if (!props.lazy && currentPath.value && !folderPaths.value.has(currentPath.value)) {
currentPath.value = '' currentPath.value = ''
} }
}, },
@@ -427,6 +441,57 @@ function getName(path: string) {
return path.split('/').pop() ?? path return path.split('/').pop() ?? path
} }
function getParentPath(path: string) {
return path.split('/').slice(0, -1).join('/')
}
function addSelectionRule(root: FileTreeSelectionRuleNode, path: string, selected: boolean) {
if (!path) return
let node = root
for (const segment of path.split('/')) {
let child = node.children.get(segment)
if (!child) {
child = { children: new Map() }
node.children.set(segment, child)
}
node = child
}
node.selected = selected
}
function getSelectionRuleNode(path: string) {
let node = selectionRuleTree.value
if (!path) return node
for (const segment of path.split('/')) {
const child = node.children.get(segment)
if (!child) return undefined
node = child
}
return node
}
function isPathSelected(path: string) {
let node = selectionRuleTree.value
let selected = node.selected ?? false
for (const segment of path.split('/').filter(Boolean)) {
const child = node.children.get(segment)
if (!child) break
node = child
selected = node.selected ?? selected
}
return selected
}
function hasDescendantSelectionRule(path: string) {
const node = getSelectionRuleNode(path)
return node !== undefined && node.children.size > 0
}
function isInCurrentPath(segments: string[], currentSegments: string[]) { function isInCurrentPath(segments: string[], currentSegments: string[]) {
if (segments.length <= currentSegments.length) return false if (segments.length <= currentSegments.length) return false
return currentSegments.every((segment, index) => segments[index] === segment) return currentSegments.every((segment, index) => segments[index] === segment)
@@ -505,22 +570,21 @@ function buildDirectoryEntry(
name: string, name: string,
item?: NormalizedFileTreeSelectItem, item?: NormalizedFileTreeSelectItem,
): FileTreeSelectEntry { ): FileTreeSelectEntry {
const descendants = getFolderDescendants(path).filter((item) => !item.disabled) const descendants = item ? [] : getFolderDescendants(path)
const selectedCount = descendants.filter((item) => const selectableDescendants = descendants.filter((item) => !item.disabled)
selectedPaths.value.has(item.normalizedPath), const selected = isPathSelected(path)
).length const indeterminate = hasDescendantSelectionRule(path)
const selected = selectedPaths.value.has(path)
return { return {
path, path,
name, name,
type: 'directory', type: 'directory',
icon: getDirectoryIcon(name), icon: getDirectoryIcon(name),
checked: selected || (descendants.length > 0 && selectedCount === descendants.length), checked: selected && !indeterminate,
indeterminate: !selected && selectedCount > 0 && selectedCount < descendants.length, indeterminate,
disabled: item?.disabled ?? descendants.length === 0, disabled: item ? (item.disabled ?? false) : selectableDescendants.length === 0,
modified: item?.modified ?? getLatestModified(descendants), modified: item?.modified ?? getLatestModified(descendants),
count: item?.count ?? getFolderChildCount(path), count: item?.count ?? (item ? undefined : getFolderChildCount(path)),
item, item,
} }
} }
@@ -531,7 +595,7 @@ function buildFileEntry(item: NormalizedFileTreeSelectItem): FileTreeSelectEntry
name: item.name, name: item.name,
type: 'file', type: 'file',
icon: getFileIcon(item.name), icon: getFileIcon(item.name),
checked: selectedPaths.value.has(item.normalizedPath), checked: isPathSelected(item.normalizedPath),
indeterminate: false, indeterminate: false,
disabled: item.disabled ?? false, disabled: item.disabled ?? false,
size: item.size, size: item.size,
@@ -564,48 +628,89 @@ function selectEntry(entry: FileTreeSelectEntry) {
function toggleEntry(entry: FileTreeSelectEntry, selected: boolean) { function toggleEntry(entry: FileTreeSelectEntry, selected: boolean) {
if (entry.disabled) return if (entry.disabled) return
const nextSelectedPaths = new Set(selectedPaths.value) updateSelection(entry.path, selected)
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) { function updateSelection(path: string, selected: boolean) {
if (entry.type === 'directory') { const nextSelectedPaths = new Set(includedPaths.value)
return entry.item?.type === 'directory' const nextExcludedPaths = new Set(excludedPaths.value)
? [entry.path] applySelection(nextSelectedPaths, nextExcludedPaths, path, selected)
: getFolderDescendants(entry.path) emitSelection(nextSelectedPaths, nextExcludedPaths)
.filter((item) => !item.disabled) }
.map((item) => item.normalizedPath)
function applySelection(
nextSelectedPaths: Set<string>,
nextExcludedPaths: Set<string>,
path: string,
selected: boolean,
) {
const normalizedPath = normalizePath(path)
if (!normalizedPath) return
removePathAndDescendants(nextSelectedPaths, normalizedPath)
removePathAndDescendants(nextExcludedPaths, normalizedPath)
const inheritedSelection = resolveSelectionFromSets(
getParentPath(normalizedPath),
nextSelectedPaths,
nextExcludedPaths,
)
if (inheritedSelection !== selected) {
const targetPaths = selected ? nextSelectedPaths : nextExcludedPaths
targetPaths.add(normalizedPath)
}
}
function removePathAndDescendants(paths: Set<string>, path: string) {
const prefix = `${path}/`
for (const candidate of paths) {
if (candidate === path || candidate.startsWith(prefix)) {
paths.delete(candidate)
}
}
}
function resolveSelectionFromSets(
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 [entry.path] return selected
}
function emitSelection(includedPaths: Set<string>, excludedPaths: Set<string>) {
emit('update:modelValue', [...includedPaths])
emit('update:excludedPaths', [...excludedPaths])
} }
function toggleAllVisible(selected: boolean) { function toggleAllVisible(selected: boolean) {
const nextSelectedPaths = new Set(selectedPaths.value) const nextSelectedPaths = new Set(includedPaths.value)
for (const path of visibleSelectablePaths.value) { const nextExcludedPaths = new Set(excludedPaths.value)
if (selected) {
nextSelectedPaths.add(path) if (currentPath.value) {
} else { applySelection(nextSelectedPaths, nextExcludedPaths, currentPath.value, selected)
nextSelectedPaths.delete(path) } else {
for (const entry of visibleSelectableEntries.value) {
applySelection(nextSelectedPaths, nextExcludedPaths, entry.path, selected)
} }
} }
emit('update:modelValue', [...nextSelectedPaths]) emitSelection(nextSelectedPaths, nextExcludedPaths)
} }
function formatSize(entry: FileTreeSelectEntry) { function formatSize(entry: FileTreeSelectEntry) {
if (entry.type === 'directory') { if (entry.type === 'directory') {
return formatMessage(messages.itemCount, { count: entry.count ?? 0 }) if (entry.count === undefined) return ''
return formatMessage(messages.itemCount, { count: entry.count })
} }
if (entry.size === undefined) return '' if (entry.size === undefined) return ''
@@ -39,30 +39,37 @@ export const ModpackExport: StoryObj = {
render: () => ({ render: () => ({
components: { FileTreeSelect }, components: { FileTreeSelect },
setup() { setup() {
const selected = ref([ const included = ref(['config', 'mods'])
'config/fabric_loader_dependencies.json', const excluded = ref(['config/defaultoptions'])
'config/crash_assistant/settings.toml', const selectedLabel = computed(
'config/defaultoptions/options.txt', () => `${included.value.length} includes, ${excluded.value.length} exclusions`,
'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 { return {
excluded,
included,
items: MODPACK_FILES, items: MODPACK_FILES,
selected,
selectedLabel, selectedLabel,
} }
}, },
template: /*html*/ ` template: /*html*/ `
<div class="max-w-2xl"> <div class="max-w-2xl">
<FileTreeSelect v-model="selected" :items="items" /> <FileTreeSelect
v-model="included"
v-model:excluded-paths="excluded"
:items="items"
/>
<div class="mt-4 rounded-[20px] bg-surface-3 p-4 text-sm text-secondary"> <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="font-semibold text-contrast">{{ selectedLabel }}</div>
<div class="mt-2 flex flex-col gap-1"> <div class="mt-2 grid grid-cols-2 gap-4">
<span v-for="path in selected" :key="path" class="truncate">{{ path }}</span> <div class="flex flex-col gap-1">
<span class="font-semibold text-contrast">Included</span>
<span v-for="path in included" :key="path" class="truncate">{{ path }}</span>
</div>
<div class="flex flex-col gap-1">
<span class="font-semibold text-contrast">Excluded</span>
<span v-for="path in excluded" :key="path" class="truncate">{{ path }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -74,12 +81,17 @@ export const EmptyRoot: StoryObj = {
render: () => ({ render: () => ({
components: { FileTreeSelect }, components: { FileTreeSelect },
setup() { setup() {
const selected = ref<string[]>([]) const excluded = ref<string[]>([])
return { selected } const included = ref<string[]>([])
return { excluded, included }
}, },
template: /*html*/ ` template: /*html*/ `
<div class="max-w-2xl"> <div class="max-w-2xl">
<FileTreeSelect v-model="selected" :items="[]" /> <FileTreeSelect
v-model="included"
v-model:excluded-paths="excluded"
:items="[]"
/>
</div> </div>
`, `,
}), }),