mirror of
https://github.com/modrinth/code.git
synced 2026-08-24 16:44:51 +00:00
fix: export modal path handling (#6949)
* fix: export modal path handling * fix: prepr * fix: NEVER_EXPORTABLE_PATH_SUFFIXES list
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
+31
-3
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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>
|
||||||
`,
|
`,
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user