mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +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:
@@ -258,6 +258,11 @@ type FileTreeSelectEntry = {
|
||||
item?: NormalizedFileTreeSelectItem
|
||||
}
|
||||
|
||||
type FileTreeSelectionRuleNode = {
|
||||
selected?: boolean
|
||||
children: Map<string, FileTreeSelectionRuleNode>
|
||||
}
|
||||
|
||||
type FileTreeSelectSortField = 'name' | 'size' | 'modified'
|
||||
|
||||
const formatBytes = useFormatBytes()
|
||||
@@ -273,19 +278,22 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
items: FileTreeSelectItem[]
|
||||
modelValue: string[]
|
||||
excludedPaths: string[]
|
||||
lazy?: boolean
|
||||
showSize?: boolean
|
||||
showModified?: boolean
|
||||
}>(),
|
||||
{
|
||||
items: () => [],
|
||||
modelValue: () => [],
|
||||
lazy: false,
|
||||
showSize: true,
|
||||
showModified: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string[]): void
|
||||
(e: 'update:modelValue' | 'update:excludedPaths', value: string[]): void
|
||||
(e: 'navigate', path: string): void
|
||||
}>()
|
||||
|
||||
@@ -312,7 +320,23 @@ const normalizedItems = computed(() => {
|
||||
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 paths = new Set<string>()
|
||||
@@ -361,24 +385,14 @@ const entries = computed<FileTreeSelectEntry[]>(() => {
|
||||
|
||||
const visibleSelectableEntries = computed(() => entries.value.filter((entry) => !entry.disabled))
|
||||
|
||||
const visibleSelectablePaths = computed(() => {
|
||||
const paths = new Set<string>()
|
||||
for (const entry of visibleSelectableEntries.value) {
|
||||
for (const path of getEntrySelectablePaths(entry)) {
|
||||
paths.add(path)
|
||||
}
|
||||
}
|
||||
return [...paths]
|
||||
})
|
||||
|
||||
const allVisibleSelected = computed(
|
||||
() =>
|
||||
visibleSelectablePaths.value.length > 0 &&
|
||||
visibleSelectablePaths.value.every((path) => selectedPaths.value.has(path)),
|
||||
visibleSelectableEntries.value.length > 0 &&
|
||||
visibleSelectableEntries.value.every((entry) => entry.checked && !entry.indeterminate),
|
||||
)
|
||||
|
||||
const someVisibleSelected = computed(() =>
|
||||
visibleSelectablePaths.value.some((path) => selectedPaths.value.has(path)),
|
||||
visibleSelectableEntries.value.some((entry) => entry.checked || entry.indeterminate),
|
||||
)
|
||||
|
||||
const visibleRowCount = computed(
|
||||
@@ -392,7 +406,7 @@ const fillerRowCount = computed(() =>
|
||||
watch(
|
||||
normalizedItems,
|
||||
() => {
|
||||
if (currentPath.value && !folderPaths.value.has(currentPath.value)) {
|
||||
if (!props.lazy && currentPath.value && !folderPaths.value.has(currentPath.value)) {
|
||||
currentPath.value = ''
|
||||
}
|
||||
},
|
||||
@@ -427,6 +441,57 @@ function getName(path: string) {
|
||||
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[]) {
|
||||
if (segments.length <= currentSegments.length) return false
|
||||
return currentSegments.every((segment, index) => segments[index] === segment)
|
||||
@@ -505,22 +570,21 @@ function buildDirectoryEntry(
|
||||
name: string,
|
||||
item?: NormalizedFileTreeSelectItem,
|
||||
): FileTreeSelectEntry {
|
||||
const descendants = getFolderDescendants(path).filter((item) => !item.disabled)
|
||||
const selectedCount = descendants.filter((item) =>
|
||||
selectedPaths.value.has(item.normalizedPath),
|
||||
).length
|
||||
const selected = selectedPaths.value.has(path)
|
||||
const descendants = item ? [] : getFolderDescendants(path)
|
||||
const selectableDescendants = descendants.filter((item) => !item.disabled)
|
||||
const selected = isPathSelected(path)
|
||||
const indeterminate = hasDescendantSelectionRule(path)
|
||||
|
||||
return {
|
||||
path,
|
||||
name,
|
||||
type: 'directory',
|
||||
icon: getDirectoryIcon(name),
|
||||
checked: selected || (descendants.length > 0 && selectedCount === descendants.length),
|
||||
indeterminate: !selected && selectedCount > 0 && selectedCount < descendants.length,
|
||||
disabled: item?.disabled ?? descendants.length === 0,
|
||||
checked: selected && !indeterminate,
|
||||
indeterminate,
|
||||
disabled: item ? (item.disabled ?? false) : selectableDescendants.length === 0,
|
||||
modified: item?.modified ?? getLatestModified(descendants),
|
||||
count: item?.count ?? getFolderChildCount(path),
|
||||
count: item?.count ?? (item ? undefined : getFolderChildCount(path)),
|
||||
item,
|
||||
}
|
||||
}
|
||||
@@ -531,7 +595,7 @@ function buildFileEntry(item: NormalizedFileTreeSelectItem): FileTreeSelectEntry
|
||||
name: item.name,
|
||||
type: 'file',
|
||||
icon: getFileIcon(item.name),
|
||||
checked: selectedPaths.value.has(item.normalizedPath),
|
||||
checked: isPathSelected(item.normalizedPath),
|
||||
indeterminate: false,
|
||||
disabled: item.disabled ?? false,
|
||||
size: item.size,
|
||||
@@ -564,48 +628,89 @@ function selectEntry(entry: FileTreeSelectEntry) {
|
||||
function toggleEntry(entry: FileTreeSelectEntry, selected: boolean) {
|
||||
if (entry.disabled) return
|
||||
|
||||
const nextSelectedPaths = new Set(selectedPaths.value)
|
||||
const paths = getEntrySelectablePaths(entry)
|
||||
|
||||
for (const path of paths) {
|
||||
if (selected) {
|
||||
nextSelectedPaths.add(path)
|
||||
} else {
|
||||
nextSelectedPaths.delete(path)
|
||||
}
|
||||
}
|
||||
|
||||
emit('update:modelValue', [...nextSelectedPaths])
|
||||
updateSelection(entry.path, selected)
|
||||
}
|
||||
|
||||
function getEntrySelectablePaths(entry: FileTreeSelectEntry) {
|
||||
if (entry.type === 'directory') {
|
||||
return entry.item?.type === 'directory'
|
||||
? [entry.path]
|
||||
: getFolderDescendants(entry.path)
|
||||
.filter((item) => !item.disabled)
|
||||
.map((item) => item.normalizedPath)
|
||||
function updateSelection(path: string, selected: boolean) {
|
||||
const nextSelectedPaths = new Set(includedPaths.value)
|
||||
const nextExcludedPaths = new Set(excludedPaths.value)
|
||||
applySelection(nextSelectedPaths, nextExcludedPaths, path, selected)
|
||||
emitSelection(nextSelectedPaths, nextExcludedPaths)
|
||||
}
|
||||
|
||||
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) {
|
||||
const nextSelectedPaths = new Set(selectedPaths.value)
|
||||
for (const path of visibleSelectablePaths.value) {
|
||||
if (selected) {
|
||||
nextSelectedPaths.add(path)
|
||||
} else {
|
||||
nextSelectedPaths.delete(path)
|
||||
const nextSelectedPaths = new Set(includedPaths.value)
|
||||
const nextExcludedPaths = new Set(excludedPaths.value)
|
||||
|
||||
if (currentPath.value) {
|
||||
applySelection(nextSelectedPaths, nextExcludedPaths, currentPath.value, selected)
|
||||
} else {
|
||||
for (const entry of visibleSelectableEntries.value) {
|
||||
applySelection(nextSelectedPaths, nextExcludedPaths, entry.path, selected)
|
||||
}
|
||||
}
|
||||
|
||||
emit('update:modelValue', [...nextSelectedPaths])
|
||||
emitSelection(nextSelectedPaths, nextExcludedPaths)
|
||||
}
|
||||
|
||||
function formatSize(entry: FileTreeSelectEntry) {
|
||||
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 ''
|
||||
|
||||
@@ -39,30 +39,37 @@ export const ModpackExport: StoryObj = {
|
||||
render: () => ({
|
||||
components: { FileTreeSelect },
|
||||
setup() {
|
||||
const selected = ref([
|
||||
'config/fabric_loader_dependencies.json',
|
||||
'config/crash_assistant/settings.toml',
|
||||
'config/defaultoptions/options.txt',
|
||||
'mods/sodium-fabric-0.6.13+mc1.21.6.jar',
|
||||
'mods/iris-fabric-1.8.8+mc1.21.6.jar',
|
||||
'resourcepacks/FreshAnimations_v1.9.3.zip',
|
||||
'shaderpacks/ComplementaryUnbound_r5.5.1.zip',
|
||||
])
|
||||
const selectedLabel = computed(() => `${selected.value.length} selected`)
|
||||
const included = ref(['config', 'mods'])
|
||||
const excluded = ref(['config/defaultoptions'])
|
||||
const selectedLabel = computed(
|
||||
() => `${included.value.length} includes, ${excluded.value.length} exclusions`,
|
||||
)
|
||||
|
||||
return {
|
||||
excluded,
|
||||
included,
|
||||
items: MODPACK_FILES,
|
||||
selected,
|
||||
selectedLabel,
|
||||
}
|
||||
},
|
||||
template: /*html*/ `
|
||||
<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="font-semibold text-contrast">{{ selectedLabel }}</div>
|
||||
<div class="mt-2 flex flex-col gap-1">
|
||||
<span v-for="path in selected" :key="path" class="truncate">{{ path }}</span>
|
||||
<div class="mt-2 grid grid-cols-2 gap-4">
|
||||
<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>
|
||||
@@ -74,12 +81,17 @@ export const EmptyRoot: StoryObj = {
|
||||
render: () => ({
|
||||
components: { FileTreeSelect },
|
||||
setup() {
|
||||
const selected = ref<string[]>([])
|
||||
return { selected }
|
||||
const excluded = ref<string[]>([])
|
||||
const included = ref<string[]>([])
|
||||
return { excluded, included }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div class="max-w-2xl">
|
||||
<FileTreeSelect v-model="selected" :items="[]" />
|
||||
<FileTreeSelect
|
||||
v-model="included"
|
||||
v-model:excluded-paths="excluded"
|
||||
:items="[]"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user