mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 09:04:55 +00:00
refactor: instance export modal (#6543)
* refactor: export modal w the design system * fix: lint * fix: rev * fix: lint * fix: qa
This commit is contained in:
@@ -0,0 +1,604 @@
|
||||
<template>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<div
|
||||
class="flex w-full min-w-0 flex-col rounded-[20px] border border-solid border-surface-4 shadow-sm overflow-clip"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-full min-w-0 select-none flex-row items-center justify-between bg-surface-3 px-3 text-sm font-medium"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Checkbox
|
||||
:model-value="allVisibleSelected"
|
||||
:indeterminate="someVisibleSelected && !allVisibleSelected"
|
||||
:disabled="visibleSelectableEntries.length === 0"
|
||||
@update:model-value="toggleAllVisible"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 appearance-none items-center gap-1.5 border-0 bg-transparent p-0 font-semibold hover:text-primary"
|
||||
:class="sortField === 'name' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="handleSort('name')"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ formatMessage(messages.name) }}</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'name' && !sortDesc"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'name' && sortDesc"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="hidden w-[92px] appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left font-semibold hover:text-primary sm:flex"
|
||||
:class="sortField === 'size' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="handleSort('size')"
|
||||
>
|
||||
<span>{{ formatMessage(messages.size) }}</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'size' && !sortDesc"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'size' && sortDesc"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="hidden w-[132px] appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left font-semibold hover:text-primary sm:flex"
|
||||
:class="sortField === 'modified' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="handleSort('modified')"
|
||||
>
|
||||
<span>{{ formatMessage(messages.modified) }}</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'modified' && !sortDesc"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'modified' && sortDesc"
|
||||
class="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<span class="size-4 shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isHomePath"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="group flex w-full min-w-0 cursor-pointer select-none items-center gap-2 border-0 border-t border-solid border-surface-4 bg-surface-2 px-3 py-2 text-left hover:bg-surface-2.5 focus:!outline-none"
|
||||
@click="navigateTo(parentPath)"
|
||||
@keydown="(event) => event.key === 'Enter' && navigateTo(parentPath)"
|
||||
>
|
||||
<span class="size-5 shrink-0" aria-hidden="true" />
|
||||
<div class="flex size-4 shrink-0 items-center justify-center text-secondary">
|
||||
<UndoIcon class="size-4 group-hover:text-contrast group-focus:text-contrast" />
|
||||
</div>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-sm font-medium text-primary group-hover:text-contrast group-focus:text-contrast"
|
||||
>
|
||||
{{ formatMessage(messages.parentFolder) }}
|
||||
</span>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-4">
|
||||
<span class="hidden w-[92px] text-left text-sm text-secondary sm:block" />
|
||||
<span class="hidden w-[132px] text-left text-sm text-secondary sm:block" />
|
||||
<span class="size-4 shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="entries.length === 0"
|
||||
class="flex items-center gap-2 bg-surface-2 px-3 py-2 text-sm text-secondary"
|
||||
>
|
||||
<FileIcon class="size-4 shrink-0" />
|
||||
<span>{{ formatMessage(messages.emptyFolderTitle) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(entry, i) in entries"
|
||||
:key="`${entry.type}:${entry.path}`"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="group flex w-full min-w-0 select-none items-center gap-2 border-0 border-t border-solid border-surface-4 px-3 py-2 text-left first:border-t-0 focus:!outline-none"
|
||||
:class="[
|
||||
getRowBackgroundClass(i + (isHomePath ? 0 : 1)),
|
||||
entry.disabled && entry.type === 'file'
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer hover:bg-surface-2.5',
|
||||
]"
|
||||
@click="selectEntry(entry)"
|
||||
@keydown="(event) => event.key === 'Enter' && selectEntry(entry)"
|
||||
>
|
||||
<Checkbox
|
||||
class="shrink-0"
|
||||
:model-value="entry.checked"
|
||||
:indeterminate="entry.indeterminate"
|
||||
:disabled="entry.disabled"
|
||||
:description="entry.name"
|
||||
@click.stop
|
||||
@update:model-value="toggleEntry(entry, $event)"
|
||||
/>
|
||||
<div class="flex size-4 shrink-0 items-center justify-center text-secondary">
|
||||
<component
|
||||
:is="entry.icon"
|
||||
class="size-4 group-hover:text-contrast group-focus:text-contrast"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
:ref="(element) => setEntryNameRef(entry.path, element)"
|
||||
v-tooltip="truncatedTooltip(entryNameRefs[entry.path], entry.name)"
|
||||
class="min-w-0 flex-1 truncate text-sm font-medium text-primary group-hover:text-contrast group-focus:text-contrast"
|
||||
>
|
||||
{{ entry.name }}
|
||||
</span>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-4">
|
||||
<span class="hidden w-[92px] truncate text-left text-sm text-secondary sm:block">
|
||||
{{ formatSize(entry) }}
|
||||
</span>
|
||||
<span class="hidden w-[132px] truncate text-left text-sm text-secondary sm:block">
|
||||
{{ formatModified(entry) }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
class="size-4 shrink-0 text-secondary group-hover:text-contrast group-focus:text-contrast"
|
||||
:class="{ invisible: entry.type !== 'directory' }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in fillerRowCount"
|
||||
:key="`filler:${row}`"
|
||||
class="flex w-full min-w-0 select-none items-center gap-2 border-0 border-t border-solid border-surface-4 px-3 py-2 text-left"
|
||||
:class="getRowBackgroundClass(visibleRowCount + row - 1)"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="size-5 shrink-0" />
|
||||
<span class="size-4 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium opacity-0">.</span>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-4">
|
||||
<span class="hidden w-[92px] text-left text-sm sm:block" />
|
||||
<span class="hidden w-[132px] text-left text-sm sm:block" />
|
||||
<span class="size-4 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronUpIcon,
|
||||
FileIcon,
|
||||
UndoIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { type Component, type ComponentPublicInstance, computed, ref, watch } from 'vue'
|
||||
|
||||
import { useFormatBytes } from '../../composables/format-bytes'
|
||||
import { useFormatDateTime } from '../../composables/format-date-time'
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import { getDirectoryIcon, getFileIcon } from '../../utils/auto-icons'
|
||||
import { truncatedTooltip } from '../../utils/truncate'
|
||||
import Checkbox from './Checkbox.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
name: {
|
||||
id: 'files.table-header.name',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
size: {
|
||||
id: 'files.table-header.size',
|
||||
defaultMessage: 'Size',
|
||||
},
|
||||
modified: {
|
||||
id: 'files.table-header.modified',
|
||||
defaultMessage: 'Modified',
|
||||
},
|
||||
itemCount: {
|
||||
id: 'files.row.item-count',
|
||||
defaultMessage: '{count, plural, one {# item} other {# items}}',
|
||||
},
|
||||
parentFolder: {
|
||||
id: 'files.row.parent-folder',
|
||||
defaultMessage: 'Parent folder',
|
||||
},
|
||||
emptyFolderTitle: {
|
||||
id: 'files.layout.empty-folder-title',
|
||||
defaultMessage: 'This folder is empty',
|
||||
},
|
||||
})
|
||||
|
||||
export type FileTreeSelectItem = {
|
||||
path: string
|
||||
type?: 'directory' | 'file'
|
||||
disabled?: boolean
|
||||
size?: number
|
||||
modified?: number
|
||||
count?: number
|
||||
}
|
||||
|
||||
type NormalizedFileTreeSelectItem = FileTreeSelectItem & {
|
||||
name: string
|
||||
normalizedPath: string
|
||||
}
|
||||
|
||||
type FileTreeSelectEntry = {
|
||||
path: string
|
||||
name: string
|
||||
type: 'directory' | 'file'
|
||||
icon: Component
|
||||
checked: boolean
|
||||
indeterminate: boolean
|
||||
disabled: boolean
|
||||
size?: number
|
||||
modified?: number
|
||||
count?: number
|
||||
item?: NormalizedFileTreeSelectItem
|
||||
}
|
||||
|
||||
type FileTreeSelectSortField = 'name' | 'size' | 'modified'
|
||||
|
||||
const formatBytes = useFormatBytes()
|
||||
const formatDateTime = useFormatDateTime({
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: FileTreeSelectItem[]
|
||||
modelValue: string[]
|
||||
}>(),
|
||||
{
|
||||
items: () => [],
|
||||
modelValue: () => [],
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string[]): void
|
||||
(e: 'navigate', path: string): void
|
||||
}>()
|
||||
|
||||
const currentPath = ref('')
|
||||
const sortField = ref<FileTreeSelectSortField>('name')
|
||||
const sortDesc = ref(false)
|
||||
const entryNameRefs = ref<Record<string, HTMLElement | null>>({})
|
||||
const isHomePath = computed(() => currentPath.value === '')
|
||||
const parentPath = computed(() => currentPath.value.split('/').slice(0, -1).join('/'))
|
||||
const initialMinimumRowCount = ref(0)
|
||||
|
||||
const normalizedItems = computed(() => {
|
||||
const items = new Map<string, NormalizedFileTreeSelectItem>()
|
||||
for (const item of props.items) {
|
||||
const normalizedPath = normalizePath(item.path)
|
||||
if (!normalizedPath) continue
|
||||
items.set(normalizedPath, {
|
||||
...item,
|
||||
path: item.path,
|
||||
name: getName(normalizedPath),
|
||||
normalizedPath,
|
||||
})
|
||||
}
|
||||
return [...items.values()]
|
||||
})
|
||||
|
||||
const selectedPaths = computed(() => new Set(props.modelValue.map((path) => normalizePath(path))))
|
||||
|
||||
const folderPaths = computed(() => {
|
||||
const paths = new Set<string>()
|
||||
for (const item of normalizedItems.value) {
|
||||
const segments = item.normalizedPath.split('/')
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
paths.add(segments.slice(0, i).join('/'))
|
||||
}
|
||||
if (item.type === 'directory') {
|
||||
paths.add(item.normalizedPath)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
})
|
||||
|
||||
const entries = computed<FileTreeSelectEntry[]>(() => {
|
||||
const directories = new Map<string, FileTreeSelectEntry>()
|
||||
const files: FileTreeSelectEntry[] = []
|
||||
const currentSegments = currentPath.value ? currentPath.value.split('/') : []
|
||||
|
||||
for (const item of normalizedItems.value) {
|
||||
const segments = item.normalizedPath.split('/')
|
||||
if (!isInCurrentPath(segments, currentSegments)) continue
|
||||
|
||||
const remaining = segments.slice(currentSegments.length)
|
||||
if (remaining.length > 1) {
|
||||
const directoryName = remaining[0]
|
||||
const directoryPath = [...currentSegments, directoryName].join('/')
|
||||
if (!directories.has(directoryPath)) {
|
||||
directories.set(directoryPath, buildDirectoryEntry(directoryPath, directoryName))
|
||||
}
|
||||
} else if (remaining.length === 1) {
|
||||
if (item.type === 'directory') {
|
||||
directories.set(
|
||||
item.normalizedPath,
|
||||
buildDirectoryEntry(item.normalizedPath, item.name, item),
|
||||
)
|
||||
} else {
|
||||
files.push(buildFileEntry(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...directories.values(), ...files].sort(compareEntries)
|
||||
})
|
||||
|
||||
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)),
|
||||
)
|
||||
|
||||
const someVisibleSelected = computed(() =>
|
||||
visibleSelectablePaths.value.some((path) => selectedPaths.value.has(path)),
|
||||
)
|
||||
|
||||
const visibleRowCount = computed(
|
||||
() => entries.value.length + (isHomePath.value ? 0 : 1) + (entries.value.length === 0 ? 1 : 0),
|
||||
)
|
||||
|
||||
const fillerRowCount = computed(() =>
|
||||
Math.max(0, initialMinimumRowCount.value - visibleRowCount.value),
|
||||
)
|
||||
|
||||
watch(
|
||||
normalizedItems,
|
||||
() => {
|
||||
if (currentPath.value && !folderPaths.value.has(currentPath.value)) {
|
||||
currentPath.value = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
visibleRowCount,
|
||||
(rowCount) => {
|
||||
if (isHomePath.value && rowCount > 1 && initialMinimumRowCount.value === 0) {
|
||||
initialMinimumRowCount.value = rowCount
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
isHomePath,
|
||||
() => {
|
||||
if (isHomePath.value && visibleRowCount.value > 1 && initialMinimumRowCount.value === 0) {
|
||||
initialMinimumRowCount.value = visibleRowCount.value
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
function normalizePath(path: string) {
|
||||
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/')
|
||||
}
|
||||
|
||||
function getName(path: string) {
|
||||
return path.split('/').pop() ?? path
|
||||
}
|
||||
|
||||
function isInCurrentPath(segments: string[], currentSegments: string[]) {
|
||||
if (segments.length <= currentSegments.length) return false
|
||||
return currentSegments.every((segment, index) => segments[index] === segment)
|
||||
}
|
||||
|
||||
function handleSort(field: FileTreeSelectSortField) {
|
||||
if (sortField.value === field) {
|
||||
sortDesc.value = !sortDesc.value
|
||||
} else {
|
||||
sortField.value = field
|
||||
sortDesc.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getRowBackgroundClass(index: number) {
|
||||
return index % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'
|
||||
}
|
||||
|
||||
function compareEntries(a: FileTreeSelectEntry, b: FileTreeSelectEntry) {
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
|
||||
|
||||
switch (sortField.value) {
|
||||
case 'modified':
|
||||
return sortDesc.value
|
||||
? getModifiedSortValue(a) - getModifiedSortValue(b)
|
||||
: getModifiedSortValue(b) - getModifiedSortValue(a)
|
||||
case 'size':
|
||||
return sortDesc.value
|
||||
? getSizeSortValue(a) - getSizeSortValue(b)
|
||||
: getSizeSortValue(b) - getSizeSortValue(a)
|
||||
default:
|
||||
return sortDesc.value
|
||||
? b.name.localeCompare(a.name, undefined, { numeric: true, sensitivity: 'base' })
|
||||
: a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
|
||||
}
|
||||
}
|
||||
|
||||
function getSizeSortValue(entry: FileTreeSelectEntry) {
|
||||
return entry.type === 'directory' ? (entry.count ?? 0) : (entry.size ?? 0)
|
||||
}
|
||||
|
||||
function getModifiedSortValue(entry: FileTreeSelectEntry) {
|
||||
return entry.modified ?? 0
|
||||
}
|
||||
|
||||
function getFolderDescendants(path: string) {
|
||||
return normalizedItems.value.filter((item) => item.normalizedPath.startsWith(`${path}/`))
|
||||
}
|
||||
|
||||
function getFolderChildCount(path: string) {
|
||||
const children = new Set<string>()
|
||||
const prefix = `${path}/`
|
||||
for (const item of normalizedItems.value) {
|
||||
if (!item.normalizedPath.startsWith(prefix)) continue
|
||||
|
||||
const relativePath = item.normalizedPath.slice(prefix.length)
|
||||
const [childName] = relativePath.split('/')
|
||||
if (childName) {
|
||||
children.add(childName)
|
||||
}
|
||||
}
|
||||
return children.size
|
||||
}
|
||||
|
||||
function getLatestModified(items: NormalizedFileTreeSelectItem[]) {
|
||||
const modified = items
|
||||
.map((item) => item.modified)
|
||||
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
|
||||
|
||||
if (modified.length === 0) return undefined
|
||||
return Math.max(...modified)
|
||||
}
|
||||
|
||||
function buildDirectoryEntry(
|
||||
path: string,
|
||||
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)
|
||||
|
||||
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,
|
||||
modified: item?.modified ?? getLatestModified(descendants),
|
||||
count: item?.count ?? getFolderChildCount(path),
|
||||
item,
|
||||
}
|
||||
}
|
||||
|
||||
function buildFileEntry(item: NormalizedFileTreeSelectItem): FileTreeSelectEntry {
|
||||
return {
|
||||
path: item.normalizedPath,
|
||||
name: item.name,
|
||||
type: 'file',
|
||||
icon: getFileIcon(item.name),
|
||||
checked: selectedPaths.value.has(item.normalizedPath),
|
||||
indeterminate: false,
|
||||
disabled: item.disabled ?? false,
|
||||
size: item.size,
|
||||
modified: item.modified,
|
||||
item,
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
currentPath.value = path
|
||||
emit('navigate', path)
|
||||
}
|
||||
|
||||
function setEntryNameRef(path: string, element: Element | ComponentPublicInstance | null) {
|
||||
if (element instanceof HTMLElement) {
|
||||
entryNameRefs.value[path] = element
|
||||
} else {
|
||||
entryNameRefs.value[path] = null
|
||||
}
|
||||
}
|
||||
|
||||
function selectEntry(entry: FileTreeSelectEntry) {
|
||||
if (entry.type === 'directory') {
|
||||
navigateTo(entry.path)
|
||||
} else {
|
||||
toggleEntry(entry, !entry.checked)
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return [entry.path]
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
emit('update:modelValue', [...nextSelectedPaths])
|
||||
}
|
||||
|
||||
function formatSize(entry: FileTreeSelectEntry) {
|
||||
if (entry.type === 'directory') {
|
||||
return formatMessage(messages.itemCount, { count: entry.count ?? 0 })
|
||||
}
|
||||
|
||||
if (entry.size === undefined) return ''
|
||||
return formatBytes(entry.size)
|
||||
}
|
||||
|
||||
function formatModified(entry: FileTreeSelectEntry) {
|
||||
if (entry.modified === undefined) return ''
|
||||
return formatDateTime(new Date(entry.modified * 1000))
|
||||
}
|
||||
</script>
|
||||
@@ -32,6 +32,8 @@ export { default as EmptyState } from './EmptyState.vue'
|
||||
export { default as EnvironmentIndicator } from './EnvironmentIndicator.vue'
|
||||
export { default as ErrorInformationCard } from './ErrorInformationCard.vue'
|
||||
export { default as FileInput } from './FileInput.vue'
|
||||
export type { FileTreeSelectItem } from './FileTreeSelect.vue'
|
||||
export { default as FileTreeSelect } from './FileTreeSelect.vue'
|
||||
export type { FilterBarOption } from './FilterBar.vue'
|
||||
export { default as FilterBar } from './FilterBar.vue'
|
||||
export type { FilterPillOption } from './FilterPills.vue'
|
||||
|
||||
Reference in New Issue
Block a user