-
-
-
-
-
-
-
-
-
-
-
- {{ formatMessage(messages.newFile) }}
-
-
- {{ formatMessage(messages.newFolder) }}
-
-
- {{ formatMessage(messages.uploadFile) }}
-
-
- {{ formatMessage(messages.uploadFromZip) }}
-
-
- {{ formatMessage(messages.uploadFromZipUrl) }}
-
-
-
- {{ formatMessage(messages.installCurseForgePack) }}
-
-
-
-
-
-
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/files-tab/components/tabs/FileTabsActions.vue b/packages/ui/src/layouts/shared/files-tab/components/tabs/FileTabsActions.vue
new file mode 100644
index 0000000000..3718b418ba
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/components/tabs/FileTabsActions.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/files-tab/composables/entry-menu.ts b/packages/ui/src/layouts/shared/files-tab/composables/entry-menu.ts
new file mode 100644
index 0000000000..ccd0299de0
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/composables/entry-menu.ts
@@ -0,0 +1,204 @@
+import {
+ ClipboardCopyIcon,
+ DownloadIcon,
+ EditIcon,
+ FileIcon,
+ FolderArchiveIcon,
+ FolderOpenIcon,
+ PackageOpenIcon,
+ PlusIcon,
+ RightArrowIcon,
+ TrashIcon,
+} from '@modrinth/assets'
+import { toValue } from 'vue'
+
+import type { ButtonMenuLeafOption, ButtonMenuOption } from '#ui/components/base/buttons'
+import { defineMessages, useVIntl } from '#ui/composables/i18n'
+import { injectNotificationManager } from '#ui/providers/web-notifications'
+import { commonMessages } from '#ui/utils/common-messages'
+import { canOpenInFileEditor, getFileExtension } from '#ui/utils/file-extensions'
+
+import { injectFileBrowserUI } from '../providers/file-browser-ui'
+import type { FileInfo } from '../providers/file-manager'
+import type { FileItem } from '../types'
+import { fileIconFor, infoFrom, joinDisplayPath } from '../utils'
+import { useFileActions } from './folder-actions'
+
+const messages = defineMessages({
+ createZip: {
+ id: 'files.row.create-zip',
+ defaultMessage: 'Create ZIP',
+ },
+ openInNewTab: {
+ id: 'files.row.open-in-new-tab',
+ defaultMessage: 'Open in new tab',
+ },
+ newOrUpload: {
+ id: 'files.row.upload-or-create',
+ defaultMessage: 'New Entry',
+ },
+})
+
+export interface EntryMenuOptions {
+ /** Offers selecting the entry from the menu, for rows without a selection checkbox. */
+ selectable?: boolean
+}
+
+/**
+ * Builds the actions menu of a file or folder: the built-in actions followed by the host's
+ * {@link FileManagerContext.entryActions}. Shared by the listing rows, the sidebar tree, the
+ * breadcrumbs and the empty space of a folder.
+ */
+export function useEntryMenu() {
+ const ui = injectFileBrowserUI()
+ const { formatMessage } = useVIntl()
+ const { addNotification } = injectNotificationManager()
+
+ const { options: newEntryOptions } = useFileActions(
+ (type) => ui.showCreateModal(type),
+ () => ui.initiateFileUpload(),
+ )
+
+ function copy(text: string, title: string) {
+ navigator.clipboard.writeText(text)
+ addNotification({ title, type: 'success' })
+ }
+
+ function menuFor(entry: FileInfo | FileItem, options: EntryMenuOptions = {}): ButtonMenuOption[] {
+ const info = infoFrom(entry)
+ const isDirectory = info.type === 'directory'
+ const isRoot = isDirectory && info.path === '/'
+ const fullPath = joinDisplayPath(toValue(ui.basePath), info.path)
+
+ const readOnly = !!ui.isReadOnly?.(info)
+ const writeDisabled = ui.isBusy.value || readOnly
+ const writeTooltip = writeDisabled
+ ? readOnly
+ ? toValue(ui.readOnlyReason)
+ : ui.busyTooltip.value
+ : undefined
+ const write = { disabled: writeDisabled, tooltip: writeTooltip }
+
+ const canOpenInTab = ui.advancedView.value && (isDirectory || canOpenInFileEditor(info.name))
+ const canExtract =
+ info.type === 'file' && getFileExtension(info.name) === 'zip' && !!ui.extractFile
+ const canZip = isDirectory && !!ui.zipFolder
+
+ const customActions = (ui.entryActions ?? []).filter((action) => action.shown?.(info) ?? true)
+
+ return [
+ {
+ type: 'submenu',
+ id: 'upload-create',
+ label: formatMessage(messages.newOrUpload),
+ icon: FileIcon,
+ options: newEntryOptions.value as ButtonMenuLeafOption[],
+ },
+ {
+ id: 'select-entry',
+ label: formatMessage(commonMessages.selectEntryLabel),
+ icon: fileIconFor(info).icon,
+ shown: !!options.selectable && !isRoot,
+ action: () => ui.toggleItemSelection(entry as FileItem),
+ },
+ {
+ id: 'open-in-new-tab',
+ label: formatMessage(messages.openInNewTab),
+ icon: PlusIcon,
+ shown: canOpenInTab,
+ action: () => ui.handleOpenInNewTab(info),
+ },
+ { type: 'divider' },
+ {
+ id: 'copy-filename',
+ label: formatMessage(commonMessages.copyFilenameButton),
+ icon: ClipboardCopyIcon,
+ shown: !isRoot,
+ action: () => copy(info.name, formatMessage(commonMessages.copiedFilenameLabel)),
+ },
+ {
+ id: 'copy-full-path',
+ label: formatMessage(commonMessages.copyFullPathButton),
+ icon: ClipboardCopyIcon,
+ action: () => copy(fullPath, formatMessage(commonMessages.copiedPathLabel)),
+ },
+ {
+ id: 'open-in-folder',
+ label: formatMessage(commonMessages.openInFolderButton),
+ icon: FolderOpenIcon,
+ shown: !!ui.openInFolder,
+ action: () => ui.openInFolder?.(fullPath),
+ },
+ { type: 'divider' },
+ {
+ id: 'extract',
+ label: formatMessage(commonMessages.extractButton),
+ icon: PackageOpenIcon,
+ shown: canExtract,
+ ...write,
+ action: () => ui.handleExtractItem(info),
+ },
+ { type: 'divider', shown: canExtract },
+ {
+ id: 'zip',
+ label: formatMessage(messages.createZip),
+ icon: FolderArchiveIcon,
+ shown: canZip,
+ ...write,
+ action: () => ui.zipFolder?.(info),
+ },
+ { type: 'divider', shown: canZip },
+ ...customActions.map(
+ (action): ButtonMenuOption => ({
+ id: `custom-${action.id}`,
+ label: action.label,
+ icon: action.icon,
+ tone: action.tone,
+ ...(action.writes ? write : {}),
+ action: () => action.action(info),
+ }),
+ ),
+ { type: 'divider', shown: customActions.length > 0 },
+ {
+ id: 'rename',
+ label: formatMessage(commonMessages.renameButton),
+ icon: EditIcon,
+ shown: !isRoot,
+ ...write,
+ action: () => ui.showRenameModal(info),
+ },
+ {
+ id: 'move',
+ label: formatMessage(commonMessages.moveButton),
+ icon: RightArrowIcon,
+ shown: !isRoot,
+ ...write,
+ action: () => ui.showMoveModal(info),
+ },
+ {
+ id: 'download',
+ label: ui.downloadButtonLabel ?? formatMessage(commonMessages.downloadButton),
+ icon: DownloadIcon,
+ shown: info.type === 'file',
+ action: () => ui.downloadFile(info),
+ },
+ {
+ id: 'delete',
+ label: formatMessage(commonMessages.deleteLabel),
+ icon: TrashIcon,
+ shown: !isRoot,
+ ...write,
+ action: () => ui.showDeleteModal(info),
+ tone: 'red',
+ },
+ ]
+ }
+
+ /** Opens the entry's menu at the pointer. */
+ function openMenu(event: MouseEvent, entry: FileInfo | FileItem, options?: EntryMenuOptions) {
+ event.preventDefault()
+ ui.handleContextMenu(event, menuFor(entry, options))
+ }
+
+ return { menuFor, openMenu }
+}
diff --git a/packages/ui/src/layouts/shared/files-tab/composables/file-columns.ts b/packages/ui/src/layouts/shared/files-tab/composables/file-columns.ts
new file mode 100644
index 0000000000..b78c58b45f
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/composables/file-columns.ts
@@ -0,0 +1,95 @@
+import { useLocalStorage } from '@vueuse/core'
+import type { MaybeRefOrGetter, Ref } from 'vue'
+import { computed, toValue } from 'vue'
+
+import type { FileSortField } from '../types'
+
+export const FILE_COLUMNS_ORDER = ['size', 'items', 'created', 'modified'] as const
+
+export type FileColumn = (typeof FILE_COLUMNS_ORDER)[number]
+
+export interface FileColumnDefinition {
+ id: FileColumn
+ /** Column width in px, shared by the header and the rows so they line up. */
+ width: number
+ sortField: FileSortField
+}
+
+export const FILE_COLUMNS: FileColumnDefinition[] = [
+ { id: 'size', width: 100, sortField: 'size' },
+ { id: 'items', width: 90, sortField: 'items' },
+ { id: 'created', width: 150, sortField: 'created' },
+ { id: 'modified', width: 150, sortField: 'modified' },
+]
+
+/** The order columns are dropped in when there isn't room for them next to the name. */
+const FILE_COLUMN_DROP_ORDER: FileColumn[] = ['created', 'items', 'size', 'modified']
+
+/** Space always kept for a file's name before any detail column is shown. */
+const NAME_MIN_WIDTH = 280
+/** Row padding, selection checkbox, icon and actions menu. */
+const ROW_CHROME_WIDTH = 170
+const COLUMN_GAP = 24
+
+function columnsWidth(columns: FileColumn[]) {
+ return columns.reduce(
+ (total, id) =>
+ total + (FILE_COLUMNS.find((column) => column.id === id)?.width ?? 0) + COLUMN_GAP,
+ 0,
+ )
+}
+
+const DEFAULT_ENABLED_COLUMNS: FileColumn[] = ['size', 'items', 'modified', 'created']
+
+/**
+ * Which detail columns the file listing shows. When `adjustable`, users pick the columns
+ * (persisted to local storage) and can switch details off entirely; otherwise every column is
+ * enabled. Enabled columns are then dropped, least important first, whenever they would
+ * squeeze file names below a readable width.
+ */
+export function useFileColumns(
+ containerWidth: Ref
,
+ adjustable: MaybeRefOrGetter,
+) {
+ const storedDetailsEnabled = useLocalStorage('files-details-enabled', true)
+ const storedEnabledColumns = useLocalStorage(
+ 'files-visible-columns',
+ DEFAULT_ENABLED_COLUMNS,
+ )
+
+ const detailsEnabled = computed({
+ get: () => !toValue(adjustable) || storedDetailsEnabled.value,
+ set: (value) => (storedDetailsEnabled.value = value),
+ })
+ const enabledColumns = computed(() =>
+ toValue(adjustable) ? storedEnabledColumns.value : DEFAULT_ENABLED_COLUMNS,
+ )
+
+ const shownColumns = computed(() => {
+ if (!detailsEnabled.value || containerWidth.value == null) return []
+
+ const available = containerWidth.value - ROW_CHROME_WIDTH - NAME_MIN_WIDTH
+ let columns = FILE_COLUMNS.map((column) => column.id).filter((id) =>
+ enabledColumns.value.includes(id),
+ )
+
+ for (const id of FILE_COLUMN_DROP_ORDER) {
+ if (columnsWidth(columns) <= available) break
+ columns = columns.filter((column) => column !== id)
+ }
+ return columns
+ })
+
+ function toggleColumn(id: FileColumn) {
+ storedEnabledColumns.value = storedEnabledColumns.value.includes(id)
+ ? storedEnabledColumns.value.filter((column) => column !== id)
+ : [...storedEnabledColumns.value, id]
+ }
+
+ return {
+ detailsEnabled,
+ enabledColumns,
+ shownColumns,
+ toggleColumn,
+ }
+}
diff --git a/packages/ui/src/layouts/shared/files-tab/composables/file-drag-state.ts b/packages/ui/src/layouts/shared/files-tab/composables/file-drag-state.ts
index 9e74370553..1d148942b3 100644
--- a/packages/ui/src/layouts/shared/files-tab/composables/file-drag-state.ts
+++ b/packages/ui/src/layouts/shared/files-tab/composables/file-drag-state.ts
@@ -1,12 +1,8 @@
import { ref } from 'vue'
-export interface FileDragData {
- name: string
- type: string
- path: string
-}
+import type { FileInfo } from '#ui/layouts/shared/files-tab/providers/file-manager.ts'
-const activeDrag = ref(null)
+const activeDrag = ref(null)
const dragTarget = ref(null)
const ghostEl = ref(null)
const pointerStartX = ref(0)
@@ -84,7 +80,7 @@ function cleanup() {
}
}
-let onDropCallback: ((source: FileDragData, destination: string) => void) | null = null
+let onDropCallback: ((source: FileInfo, destination: string) => void) | null = null
function onPointerCancel() {
cleanup()
@@ -104,9 +100,9 @@ function onPointerUp() {
}
export function startFileDrag(
- data: FileDragData,
+ data: FileInfo,
e: PointerEvent,
- onDrop: (source: FileDragData, destination: string) => void,
+ onDrop: (source: FileInfo, destination: string) => void,
) {
activeDrag.value = data
pointerStartX.value = e.clientX
diff --git a/packages/ui/src/layouts/shared/files-tab/composables/file-selection.ts b/packages/ui/src/layouts/shared/files-tab/composables/file-selection.ts
index 702c6ca615..1b655f4dfe 100644
--- a/packages/ui/src/layouts/shared/files-tab/composables/file-selection.ts
+++ b/packages/ui/src/layouts/shared/files-tab/composables/file-selection.ts
@@ -3,41 +3,54 @@ import { computed, ref } from 'vue'
import type { FileItem } from '../types'
+/**
+ * Selection of file entries, keyed by path. Entries can come from any directory (e.g. the sidebar
+ * tree), so the items themselves are kept rather than looked up in the current listing.
+ * `allSelected` / `someSelected` / `toggleSelectAll` refer to the given listing `items`.
+ */
export function useFileSelection(items: Ref) {
- const selectedItems = ref>(new Set())
+ const selectedItems = ref(new Map())
- function toggleItemSelection(path: string) {
- const newSet = new Set(selectedItems.value)
- if (newSet.has(path)) {
- newSet.delete(path)
+ function toggleItemSelection(item: FileItem) {
+ const next = new Map(selectedItems.value)
+ if (next.has(item.path)) {
+ next.delete(item.path)
} else {
- newSet.add(path)
+ next.set(item.path, item)
}
- selectedItems.value = newSet
+ selectedItems.value = next
}
function selectAll() {
- selectedItems.value = new Set(items.value.map((i) => i.path))
+ const next = new Map(selectedItems.value)
+ for (const item of items.value) next.set(item.path, item)
+ selectedItems.value = next
}
function deselectAll() {
- selectedItems.value = new Set()
+ selectedItems.value = new Map()
+ }
+
+ function deselectListed() {
+ const next = new Map(selectedItems.value)
+ for (const item of items.value) next.delete(item.path)
+ selectedItems.value = next
}
function toggleSelectAll() {
if (allSelected.value) {
- deselectAll()
+ deselectListed()
} else {
selectAll()
}
}
const allSelected = computed(
- () => items.value.length > 0 && selectedItems.value.size === items.value.length,
+ () => items.value.length > 0 && items.value.every((item) => selectedItems.value.has(item.path)),
)
const someSelected = computed(
- () => selectedItems.value.size > 0 && selectedItems.value.size < items.value.length,
+ () => !allSelected.value && items.value.some((item) => selectedItems.value.has(item.path)),
)
return {
diff --git a/packages/ui/src/layouts/shared/files-tab/composables/file-sorting.ts b/packages/ui/src/layouts/shared/files-tab/composables/file-sorting.ts
index 90a800d96d..2d3ae51535 100644
--- a/packages/ui/src/layouts/shared/files-tab/composables/file-sorting.ts
+++ b/packages/ui/src/layouts/shared/files-tab/composables/file-sorting.ts
@@ -47,8 +47,13 @@ export function useFileSorting(items: Ref) {
case 'created':
return sortDesc.value ? a.created - b.created : b.created - a.created
case 'size': {
- const aValue = (a.type === 'directory' ? a.count : a.size) ?? 0
- const bValue = (b.type === 'directory' ? b.count : b.size) ?? 0
+ const aValue = (a.type === 'directory' ? 0 : a.size) ?? 0
+ const bValue = (b.type === 'directory' ? 0 : b.size) ?? 0
+ return sortDesc.value ? aValue - bValue : bValue - aValue
+ }
+ case 'items': {
+ const aValue = (a.type === 'directory' ? a.count : 0) ?? 0
+ const bValue = (b.type === 'directory' ? b.count : 0) ?? 0
return sortDesc.value ? aValue - bValue : bValue - aValue
}
default:
diff --git a/packages/ui/src/layouts/shared/files-tab/composables/file-tabs.ts b/packages/ui/src/layouts/shared/files-tab/composables/file-tabs.ts
new file mode 100644
index 0000000000..979ae3e572
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/composables/file-tabs.ts
@@ -0,0 +1,588 @@
+import type { DockviewApi, DockviewReadyEvent, IDisposable } from 'dockview-vue'
+import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'
+import { computed, ref, shallowReactive, shallowRef, toValue, watch } from 'vue'
+
+import {
+ infoFrom,
+ isSameInfo,
+ isWithinPath,
+ parentInfoFrom,
+ relocateInfo,
+} from '#ui/layouts/shared/files-tab/utils.ts'
+
+import type { FileEditorBridge } from '../providers/file-browser-ui'
+import type { FileInfo, FileManagerContext } from '../providers/file-manager'
+
+export const FILE_TAB_PANEL_COMPONENT = 'fileTabPanel'
+export const FILE_TAB_COMPONENT = 'fileTab'
+
+const MAX_HISTORY_LENGTH = 50
+const PENDING_LOCATION_TIMEOUT = 1000
+const WORKSPACE_STORAGE_PREFIX = 'files-workspace:'
+const WORKSPACE_VERSION = 1
+const MAX_RECENTLY_CLOSED = 10
+
+/** Where the workspace points while every tab is closed. */
+const ROOT_LOCATION = infoFrom('/')
+
+/** A browser-like tab: its own history of visited directories and files. */
+export interface FileTab {
+ id: string
+ history: FileInfo[]
+ index: number
+}
+
+export interface FileTabPanelParams {
+ tabId: string
+}
+
+/** The tabs of one workspace, as persisted to local storage. */
+interface FileWorkspaceState {
+ version: typeof WORKSPACE_VERSION
+ tabs: FileTab[]
+ activeTabId: string
+}
+
+export function currentLocation(tab: FileTab) {
+ return tab.history[tab.index]
+}
+
+export interface FileTabsOptions {
+ ctx: Pick
+ /**
+ * Asks the user what to do with the given editors' unsaved changes.
+ * Resolves `true` when it is safe to discard the editors.
+ */
+ confirmDiscard: (editors: FileEditorBridge[]) => Promise
+ /**
+ * Identifies the workspace the tabs are persisted under. Changing it swaps in that
+ * workspace's tabs; `null` keeps the tabs in memory only.
+ */
+ workspaceId: MaybeRefOrGetter
+ /** Whether the tab strip is shown, further tabs can be opened and every tab can be closed. */
+ enabled: MaybeRefOrGetter
+}
+
+export interface FileTabs {
+ tabs: Ref
+ activeTabId: Ref
+ /** The active tab, or `null` while every tab is closed. */
+ activeTab: ComputedRef
+ activeLocation: ComputedRef
+ canGoBack: ComputedRef
+ canGoForward: ComputedRef
+ editors: Map
+ activeEditor: ComputedRef
+ hasUnsavedChanges: ComputedRef
+ getTab: (id: string) => FileTab | undefined
+ navigate: (location: FileInfo) => Promise
+ back: () => Promise
+ forward: () => Promise
+ openTab: (location: FileInfo) => void
+ activateTab: (id: string) => void
+ closeTab: (id: string) => Promise
+ closeOtherTabs: (id: string) => Promise
+ closeAllTabs: () => Promise
+ /** Whether the tab can be closed; the last tab only can while tabs are enabled. */
+ canClose: ComputedRef
+ /** Recently closed tabs, most recent first, with their history intact. */
+ recentlyClosed: Ref
+ reopenTab: (tab: FileTab) => void
+ closeFile: (tabId: string) => void
+ /** Prompts about unsaved changes in editors of files at or beneath any of `paths`. */
+ confirmDiscardWithin: (paths: string[]) => Promise
+ /** Makes every tab follow `from` being moved or renamed to `to`. */
+ relocate: (from: FileInfo, to: FileInfo) => void
+ /** Sends every tab within the deleted `file` to its parent directory. */
+ forget: (file: FileInfo) => void
+ registerEditor: (tabId: string, bridge: FileEditorBridge) => void
+ unregisterEditor: (tabId: string, bridge: FileEditorBridge) => void
+ onReady: (event: DockviewReadyEvent) => void
+ onDispose: () => void
+}
+
+function isFileInfo(value: unknown): value is FileInfo {
+ if (typeof value !== 'object' || value === null) return false
+ const info = value as Record
+ return (
+ typeof info.path === 'string' &&
+ typeof info.name === 'string' &&
+ (info.type === 'file' || info.type === 'directory')
+ )
+}
+
+function isFileTab(value: unknown): value is FileTab {
+ if (typeof value !== 'object' || value === null) return false
+ const tab = value as Record
+ return (
+ typeof tab.id === 'string' &&
+ Array.isArray(tab.history) &&
+ tab.history.length > 0 &&
+ tab.history.every(isFileInfo) &&
+ typeof tab.index === 'number' &&
+ tab.index >= 0 &&
+ tab.index < tab.history.length
+ )
+}
+
+function loadWorkspace(workspaceId: string | null): FileWorkspaceState | null {
+ if (!workspaceId) return null
+ try {
+ const raw = localStorage.getItem(WORKSPACE_STORAGE_PREFIX + workspaceId)
+ if (!raw) return null
+ const state = JSON.parse(raw) as Partial
+ if (state.version !== WORKSPACE_VERSION || !Array.isArray(state.tabs)) return null
+ const tabs = state.tabs.filter(isFileTab)
+ const activeTabId = tabs.some((tab) => tab.id === state.activeTabId)
+ ? state.activeTabId!
+ : (tabs[0]?.id ?? '')
+ return { version: WORKSPACE_VERSION, tabs, activeTabId }
+ } catch {
+ return null
+ }
+}
+
+function saveWorkspace(workspaceId: string | null, tabs: FileTab[], activeTabId: string) {
+ if (!workspaceId) return
+ const state: FileWorkspaceState = {
+ version: WORKSPACE_VERSION,
+ tabs: tabs.map((tab) => ({
+ id: tab.id,
+ index: tab.index,
+ history: tab.history.map(({ name, type, path }) => ({ name, type, path })),
+ })),
+ activeTabId,
+ }
+ try {
+ localStorage.setItem(WORKSPACE_STORAGE_PREFIX + workspaceId, JSON.stringify(state))
+ } catch {
+ // Storage may be full or unavailable; the workspace then only lives in memory.
+ }
+}
+
+function isRootDirectory(location: FileInfo) {
+ return location.type === 'directory' && location.path === '/'
+}
+
+/**
+ * Browser-like file tabs. Each tab keeps its own history of directories and files, and the
+ * active tab's current location is mirrored into the file manager context, so the listing,
+ * editor and host URL keep working off the context. Any navigation that reaches the context
+ * from elsewhere (e.g. the browser's back button) is recorded into the active tab's history.
+ *
+ * The tabs are rendered by dockview, one panel per tab, which also keeps each tab's open
+ * editor alive while other tabs are browsed. Tabs are persisted per workspace, so each server
+ * or instance reopens with the tabs it was left with.
+ */
+export function useFileTabs(options: FileTabsOptions): FileTabs {
+ const { ctx } = options
+
+ const contextLocation = computed(() => ctx.currentFile.value)
+
+ function createTab(location: FileInfo): FileTab {
+ return {
+ id: `file-tab-${Math.random().toString(36).slice(2, 9)}`,
+ history: [location],
+ index: 0,
+ }
+ }
+
+ const initialTab = createTab(contextLocation.value)
+ const tabs = ref([initialTab])
+ const activeTabId = ref(initialTab.id)
+
+ const recentlyClosed = ref([])
+
+ const activeTab = computed(
+ () => tabs.value.find((tab) => tab.id === activeTabId.value) ?? tabs.value[0] ?? null,
+ )
+ const activeLocation = computed(() =>
+ activeTab.value ? currentLocation(activeTab.value) : ROOT_LOCATION,
+ )
+ const canGoBack = computed(() => (activeTab.value?.index ?? 0) > 0)
+ const canGoForward = computed(() => {
+ const tab = activeTab.value
+ return !!tab && tab.index < tab.history.length - 1
+ })
+ const canClose = computed(() => tabs.value.length > 1 || toValue(options.enabled))
+
+ const editors = shallowReactive(new Map())
+ const activeEditor = computed(() => editors.get(activeTabId.value) ?? null)
+ const hasUnsavedChanges = computed(() =>
+ [...editors.values()].some((editor) => editor.hasUnsavedChanges.value),
+ )
+
+ const api = shallowRef(null)
+ let disposables: IDisposable[] = []
+ /** Set while panels are being (re)built, so dockview's events don't feed back into the tabs. */
+ let renderingPanels = false
+
+ function getTab(id: string) {
+ return tabs.value.find((tab) => tab.id === id)
+ }
+
+ function pushLocation(tab: FileTab, location: FileInfo) {
+ if (isSameInfo(currentLocation(tab), location)) return
+ tab.history.splice(tab.index + 1)
+ tab.history.push(location)
+ if (tab.history.length > MAX_HISTORY_LENGTH) tab.history.shift()
+ tab.index = tab.history.length - 1
+ }
+
+ /**
+ * The location we last pushed into the context. Hosts may apply it asynchronously (e.g. via
+ * the router), so context changes are only recorded once it has been reached.
+ */
+ let pendingLocation: FileInfo | null = null
+ let pendingTimeout: ReturnType | undefined
+
+ function applyToContext(info: FileInfo) {
+ if (isSameInfo(contextLocation.value, info)) return
+
+ pendingLocation = info
+ clearTimeout(pendingTimeout)
+ pendingTimeout = setTimeout(() => (pendingLocation = null), PENDING_LOCATION_TIMEOUT)
+
+ ctx.navigateTo(info)
+ }
+
+ watch(
+ contextLocation,
+ (location, previous) => {
+ // Hosts may hand back an equal location as a new object (e.g. echoed from the router).
+ if (previous && isSameInfo(location, previous)) return
+ if (pendingLocation) {
+ if (!isSameInfo(location, pendingLocation)) return
+ pendingLocation = null
+ clearTimeout(pendingTimeout)
+ return
+ }
+ if (activeTab.value) {
+ pushLocation(activeTab.value, location)
+ } else {
+ openTab(location)
+ }
+ },
+ { flush: 'post' },
+ )
+
+ /**
+ * Swaps in a workspace's persisted tabs, or a single tab at the context's location when there
+ * are none. A location the host was opened at (e.g. a deep link) wins over the restored one.
+ */
+ function restoreWorkspace(state: FileWorkspaceState | null) {
+ if (!state) {
+ const tab = createTab(contextLocation.value)
+ tabs.value = [tab]
+ activeTabId.value = tab.id
+ return
+ }
+
+ tabs.value = state.tabs
+ activeTabId.value = state.activeTabId
+
+ const location = contextLocation.value
+ const tab = activeTab.value
+ if (!isRootDirectory(location)) {
+ if (tab) {
+ pushLocation(tab, location)
+ } else {
+ const opened = createTab(location)
+ tabs.value = [opened]
+ activeTabId.value = opened.id
+ }
+ } else if (tab) {
+ applyToContext(activeLocation.value)
+ }
+ }
+
+ restoreWorkspace(loadWorkspace(toValue(options.workspaceId)))
+
+ watch(
+ () => toValue(options.workspaceId),
+ (workspaceId) => {
+ restoreWorkspace(loadWorkspace(workspaceId))
+ if (api.value) renderPanels(api.value)
+ },
+ )
+
+ watch(
+ [tabs, activeTabId],
+ () => saveWorkspace(toValue(options.workspaceId), tabs.value, activeTabId.value),
+ { deep: true },
+ )
+
+ async function confirmLeave(tab: FileTab) {
+ const editor = editors.get(tab.id)
+ if (currentLocation(tab).type !== 'file' || !editor) return true
+ return options.confirmDiscard([editor])
+ }
+
+ async function navigate(location: FileInfo) {
+ const tab = activeTab.value
+ if (!tab) {
+ openTab(location)
+ return
+ }
+ if (!isSameInfo(currentLocation(tab), location)) {
+ if (!(await confirmLeave(tab))) return
+ pushLocation(tab, location)
+ }
+ applyToContext(location)
+ }
+
+ async function go(delta: number) {
+ const tab = activeTab.value
+ if (!tab) return
+ const target = tab.index + delta
+ if (target < 0 || target >= tab.history.length) return
+ if (!(await confirmLeave(tab))) return
+ tab.index = target
+ applyToContext(currentLocation(tab))
+ }
+
+ function applyHeaderVisibility() {
+ const hidden = !toValue(options.enabled)
+ for (const group of api.value?.groups ?? []) group.header.hidden = hidden
+ }
+
+ watch(() => toValue(options.enabled), applyHeaderVisibility)
+
+ /**
+ * Adds the tab's panel to the single tab group. Without an explicit group, panels added while
+ * no group is active (e.g. `inactive` ones) each get a new group, splitting the view.
+ */
+ function addPanel(tab: FileTab, inactive = false) {
+ const group = api.value?.groups[0]
+ api.value?.addPanel({
+ id: tab.id,
+ component: FILE_TAB_PANEL_COMPONENT,
+ tabComponent: FILE_TAB_COMPONENT,
+ title: currentLocation(tab).path,
+ params: { tabId: tab.id },
+ position: group ? { referenceGroup: group, direction: 'within' } : undefined,
+ inactive,
+ })
+ applyHeaderVisibility()
+ }
+
+ function renderPanels(dockview: DockviewApi) {
+ renderingPanels = true
+ try {
+ dockview.clear()
+ for (const tab of tabs.value) addPanel(tab, true)
+ if (tabs.value.length === 0) dockview.addGroup()
+ applyHeaderVisibility()
+ dockview.getPanel(activeTabId.value)?.api.setActive()
+ } finally {
+ renderingPanels = false
+ }
+ }
+
+ /** Keeps `tabs` in the order the user dragged them into, so it is restored that way. */
+ function syncTabOrder(dockview: DockviewApi) {
+ const order = dockview.groups.flatMap((group) => group.panels.map((panel) => panel.id))
+ const position = (tab: FileTab) => {
+ const index = order.indexOf(tab.id)
+ return index === -1 ? Number.MAX_SAFE_INTEGER : index
+ }
+ const sorted = [...tabs.value].sort((a, b) => position(a) - position(b))
+ if (sorted.some((tab, index) => tab !== tabs.value[index])) tabs.value = sorted
+ }
+
+ function activateTab(id: string) {
+ if (!getTab(id)) return
+ const panel = api.value?.getPanel(id)
+ if (panel && !panel.api.isActive) panel.api.setActive()
+ if (activeTabId.value === id) return
+ activeTabId.value = id
+ applyToContext(activeLocation.value)
+ }
+
+ function openTab(location: FileInfo) {
+ if (!toValue(options.enabled)) {
+ navigate(location)
+ return
+ }
+ const tab = createTab(location)
+ tabs.value.push(tab)
+ addPanel(tab)
+ activateTab(tab.id)
+ }
+
+ /** Removes a tab without asking about unsaved changes, remembering it to reopen later. */
+ function removeTab(tab: FileTab) {
+ const index = tabs.value.indexOf(tab)
+ if (index === -1) return
+
+ tabs.value.splice(index, 1)
+ editors.delete(tab.id)
+ api.value?.getPanel(tab.id)?.api.close()
+ recentlyClosed.value = [tab, ...recentlyClosed.value].slice(0, MAX_RECENTLY_CLOSED)
+
+ if (activeTabId.value !== tab.id) return
+ const next = tabs.value[index] ?? tabs.value[index - 1]
+ if (next) {
+ activateTab(next.id)
+ } else {
+ activeTabId.value = ''
+ applyToContext(ROOT_LOCATION)
+ }
+ }
+
+ /** Closes the given tabs, asking about all of their unsaved changes at once. */
+ async function closeTabs(ids: string[]) {
+ const closing = tabs.value.filter((tab) => ids.includes(tab.id))
+ if (closing.length === 0) return
+ if (closing.length === tabs.value.length && !toValue(options.enabled)) return
+
+ const dirty = closing.flatMap((tab) => {
+ const editor = editors.get(tab.id)
+ return currentLocation(tab).type === 'file' && editor ? [editor] : []
+ })
+ if (!(await options.confirmDiscard(dirty))) return
+
+ for (const tab of closing) removeTab(tab)
+ }
+
+ async function closeTab(id: string) {
+ const tab = getTab(id)
+ if (!tab || !canClose.value || !(await confirmLeave(tab))) return
+ removeTab(tab)
+ }
+
+ async function closeOtherTabs(id: string) {
+ await closeTabs(tabs.value.filter((tab) => tab.id !== id).map((tab) => tab.id))
+ activateTab(id)
+ }
+
+ async function closeAllTabs() {
+ await closeTabs(tabs.value.map((tab) => tab.id))
+ }
+
+ function reopenTab(tab: FileTab) {
+ recentlyClosed.value = recentlyClosed.value.filter((closed) => closed !== tab)
+ if (!toValue(options.enabled)) {
+ navigate(currentLocation(tab))
+ return
+ }
+ tabs.value.push(tab)
+ addPanel(tab)
+ activateTab(tab.id)
+ }
+
+ function closeFile(tabId: string) {
+ const tab = getTab(tabId)
+ if (!tab) return
+ const location = currentLocation(tab)
+ if (location.type !== 'file') return
+
+ const parent: FileInfo = parentInfoFrom(location)
+ pushLocation(tab, parent)
+ if (tab.id === activeTabId.value) applyToContext(parent)
+ }
+
+ async function confirmDiscardWithin(paths: string[]) {
+ const affected = tabs.value.flatMap((tab) => {
+ const location = currentLocation(tab)
+ const editor = editors.get(tab.id)
+ return location.type === 'file' &&
+ editor &&
+ paths.some((path) => isWithinPath(location.path, path))
+ ? [editor]
+ : []
+ })
+ return options.confirmDiscard(affected)
+ }
+
+ /** Rewrites every tab's history through `map`, collapsing neighbouring entries that end up the same. */
+ function remapLocations(map: (location: FileInfo) => FileInfo) {
+ for (const tab of [...tabs.value, ...recentlyClosed.value]) {
+ const history: FileInfo[] = []
+ let index = 0
+ tab.history.forEach((entry, entryIndex) => {
+ const mapped = map(entry)
+ const previous = history[history.length - 1]
+ if (!previous || !isSameInfo(previous, mapped)) history.push(mapped)
+ if (entryIndex === tab.index) index = history.length - 1
+ })
+ tab.history = history
+ tab.index = index
+ }
+ if (activeTab.value) applyToContext(activeLocation.value)
+ }
+
+ function relocate(from: FileInfo, to: FileInfo) {
+ remapLocations((location) => relocateInfo(location, from.path, to.path))
+ }
+
+ function forget(file: FileInfo) {
+ const parent = parentInfoFrom(file)
+ remapLocations((location) => (isWithinPath(location.path, file.path) ? parent : location))
+ }
+
+ function registerEditor(tabId: string, bridge: FileEditorBridge) {
+ editors.set(tabId, bridge)
+ }
+
+ function unregisterEditor(tabId: string, bridge: FileEditorBridge) {
+ if (editors.get(tabId) === bridge) editors.delete(tabId)
+ }
+
+ function onReady({ api: dockview }: DockviewReadyEvent) {
+ onDispose()
+ api.value = dockview
+
+ renderPanels(dockview)
+
+ disposables = [
+ dockview.onDidActivePanelChange(({ panel }) => {
+ if (panel && !renderingPanels) activateTab(panel.id)
+ }),
+ dockview.onDidMovePanel(() => {
+ if (!renderingPanels) syncTabOrder(dockview)
+ }),
+ dockview.onWillShowOverlay((event) => {
+ if (event.kind === 'content' || event.kind === 'edge') event.preventDefault()
+ }),
+ ]
+ }
+
+ function onDispose() {
+ for (const disposable of disposables) disposable.dispose()
+ disposables = []
+ api.value = null
+ }
+
+ return {
+ tabs,
+ activeTabId,
+ activeTab,
+ activeLocation,
+ canGoBack,
+ canGoForward,
+ editors,
+ activeEditor,
+ hasUnsavedChanges,
+ getTab,
+ navigate,
+ back: () => go(-1),
+ forward: () => go(1),
+ openTab,
+ activateTab,
+ closeTab,
+ closeOtherTabs,
+ closeAllTabs,
+ canClose,
+ recentlyClosed,
+ reopenTab,
+ closeFile,
+ confirmDiscardWithin,
+ relocate,
+ forget,
+ registerEditor,
+ unregisterEditor,
+ onReady,
+ onDispose,
+ }
+}
diff --git a/packages/ui/src/layouts/shared/files-tab/composables/file-undo-redo.ts b/packages/ui/src/layouts/shared/files-tab/composables/file-undo-redo.ts
index 8d12a47ea6..31b38cfe27 100644
--- a/packages/ui/src/layouts/shared/files-tab/composables/file-undo-redo.ts
+++ b/packages/ui/src/layouts/shared/files-tab/composables/file-undo-redo.ts
@@ -1,10 +1,12 @@
import { ref } from 'vue'
+import type { FileInfo } from '#ui/layouts/shared/files-tab/providers/file-manager.ts'
+
import type { Operation } from '../types'
export function useFileUndoRedo(
- renameItem: (path: string, newName: string) => Promise,
- moveItem: (source: string, destination: string) => Promise,
+ renameItem: (file: FileInfo, newName: string) => Promise,
+ moveItem: (source: FileInfo, destination: string) => Promise,
refresh: () => void,
notify: (title: string, text: string, type: 'success' | 'error') => void,
) {
@@ -23,16 +25,10 @@ export function useFileUndoRedo(
try {
switch (lastOperation.type) {
case 'move':
- await moveItem(
- `${lastOperation.destinationPath}/${lastOperation.fileName}`.replace('//', '/'),
- `${lastOperation.sourcePath}/${lastOperation.fileName}`.replace('//', '/'),
- )
+ await moveItem(lastOperation.newFile, lastOperation.prevFile.path)
break
case 'rename':
- await renameItem(
- `${lastOperation.path}/${lastOperation.newName}`.replace('//', '/'),
- lastOperation.oldName,
- )
+ await renameItem(lastOperation.newFile, lastOperation.prevFile.name)
break
}
@@ -40,7 +36,7 @@ export function useFileUndoRedo(
refresh()
notify(
`${lastOperation.type === 'move' ? 'Move' : 'Rename'} undone`,
- `${lastOperation.fileName} has been restored to its original ${lastOperation.type === 'move' ? 'location' : 'name'}`,
+ `${lastOperation.prevFile.name} has been restored to its original ${lastOperation.type === 'move' ? 'location' : 'name'}`,
'success',
)
} catch {
@@ -55,16 +51,10 @@ export function useFileUndoRedo(
try {
switch (lastOperation.type) {
case 'move':
- await moveItem(
- `${lastOperation.sourcePath}/${lastOperation.fileName}`.replace('//', '/'),
- `${lastOperation.destinationPath}/${lastOperation.fileName}`.replace('//', '/'),
- )
+ await moveItem(lastOperation.prevFile, lastOperation.newFile.path)
break
case 'rename':
- await renameItem(
- `${lastOperation.path}/${lastOperation.oldName}`.replace('//', '/'),
- lastOperation.newName,
- )
+ await renameItem(lastOperation.prevFile, lastOperation.newFile.name)
break
}
@@ -72,7 +62,7 @@ export function useFileUndoRedo(
refresh()
notify(
`${lastOperation.type === 'move' ? 'Move' : 'Rename'} redone`,
- `${lastOperation.fileName} has been ${lastOperation.type === 'move' ? 'moved' : 'renamed'} again`,
+ `${lastOperation.prevFile.name} has been ${lastOperation.type === 'move' ? 'moved' : 'renamed'} again`,
'success',
)
} catch {
@@ -81,11 +71,14 @@ export function useFileUndoRedo(
}
function onKeydown(e: KeyboardEvent) {
- if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') {
+ // Note: Using raw key will have capitalization issues due
+ // to how shift works in keyboard event it seems... ):
+ const key = e.key.toLowerCase()
+ if ((e.ctrlKey || e.metaKey) && !e.shiftKey && key === 'z') {
e.preventDefault()
undo()
}
- if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') {
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && key === 'z') {
e.preventDefault()
redo()
}
diff --git a/packages/ui/src/layouts/shared/files-tab/composables/folder-actions.ts b/packages/ui/src/layouts/shared/files-tab/composables/folder-actions.ts
new file mode 100644
index 0000000000..2431e9eaee
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/composables/folder-actions.ts
@@ -0,0 +1,108 @@
+import {
+ BoxIcon,
+ CurseForgeIcon,
+ FolderOpenIcon,
+ LinkIcon,
+ RefreshCwIcon,
+ UploadIcon,
+} from '@modrinth/assets'
+import { computed, type ComputedRef } from 'vue'
+
+import type { ButtonMenuOption } from '#ui/components/index.ts'
+import { defineMessages, useVIntl } from '#ui/composables/i18n.ts'
+import { commonMessages } from '#ui/utils/common-messages.ts'
+
+export function useFileActions(
+ createFile: (type: 'file' | 'directory') => void,
+ upload?: (type: 'file' | 'zip') => void,
+ unzip?: (type: 'cf' | 'zip') => void,
+ refresh?: { handleRefresh: () => void; isRefreshing: ComputedRef },
+): { options: ComputedRef } {
+ const { formatMessage } = useVIntl()
+
+ const messages = defineMessages({
+ createNew: {
+ id: 'files.navbar.create-new',
+ defaultMessage: 'Create new...',
+ },
+ newFile: {
+ id: 'files.navbar.new-file',
+ defaultMessage: 'New file',
+ },
+ newFolder: {
+ id: 'files.navbar.new-folder',
+ defaultMessage: 'New folder',
+ },
+ uploadFile: {
+ id: 'files.navbar.upload-file',
+ defaultMessage: 'Upload file',
+ },
+ uploadFromZip: {
+ id: 'files.navbar.upload-from-zip',
+ defaultMessage: 'Upload from .zip file',
+ },
+ uploadFromZipUrl: {
+ id: 'files.navbar.upload-from-zip-url',
+ defaultMessage: 'Upload from .zip URL',
+ },
+ installCurseForgePack: {
+ id: 'files.navbar.install-curseforge-pack',
+ defaultMessage: 'Install CurseForge pack',
+ },
+ })
+
+ const canHandleZips = unzip != null
+ const canHandleUpload = upload != null
+ const canRefresh = refresh != null
+
+ return {
+ options: computed(() => {
+ return [
+ {
+ id: 'file',
+ icon: BoxIcon,
+ label: formatMessage(messages.newFile),
+ action: () => createFile('file'),
+ },
+ {
+ id: 'directory',
+ icon: FolderOpenIcon,
+ label: formatMessage(messages.newFolder),
+ action: () => createFile('directory'),
+ },
+ {
+ id: 'upload',
+ icon: UploadIcon,
+ label: formatMessage(messages.uploadFile),
+ shown: canHandleUpload,
+ action: () => upload?.('file'),
+ },
+ { type: 'divider', shown: canHandleZips },
+ {
+ id: 'install-from-url',
+ icon: LinkIcon,
+ label: formatMessage(messages.uploadFromZipUrl),
+ shown: canHandleZips,
+ action: () => unzip?.('zip'),
+ },
+ {
+ id: 'install-cf-pack',
+ icon: CurseForgeIcon,
+ label: formatMessage(messages.installCurseForgePack),
+ shown: canHandleZips,
+ action: () => unzip?.('cf'),
+ },
+ { type: 'divider', shown: canRefresh },
+ {
+ id: 'refresh',
+ icon: RefreshCwIcon,
+ iconClass: computed(() => (refresh?.isRefreshing.value ? 'animate-spin' : '')),
+ label: formatMessage(commonMessages.refreshButton),
+ shown: canRefresh,
+ action: () => refresh?.handleRefresh(),
+ disabled: refresh?.isRefreshing.value,
+ },
+ ]
+ }),
+ }
+}
diff --git a/packages/ui/src/layouts/shared/files-tab/composables/index.ts b/packages/ui/src/layouts/shared/files-tab/composables/index.ts
index 15db11f9cc..0e884f09d0 100644
--- a/packages/ui/src/layouts/shared/files-tab/composables/index.ts
+++ b/packages/ui/src/layouts/shared/files-tab/composables/index.ts
@@ -2,3 +2,4 @@ export { useFileSearch } from './file-search'
export { useFileSelection } from './file-selection'
export { useFileSorting } from './file-sorting'
export { useFileUndoRedo } from './file-undo-redo'
+export { useFileActions } from './folder-actions'
diff --git a/packages/ui/src/layouts/shared/files-tab/index.ts b/packages/ui/src/layouts/shared/files-tab/index.ts
index c6e724d864..8ae49ac16b 100644
--- a/packages/ui/src/layouts/shared/files-tab/index.ts
+++ b/packages/ui/src/layouts/shared/files-tab/index.ts
@@ -1,3 +1,4 @@
export { default as FilePageLayout } from './layout.vue'
export * from './providers'
export * from './types'
+export * from './utils'
diff --git a/packages/ui/src/layouts/shared/files-tab/layout.vue b/packages/ui/src/layouts/shared/files-tab/layout.vue
index 31633527de..3e1cbf86e5 100644
--- a/packages/ui/src/layouts/shared/files-tab/layout.vue
+++ b/packages/ui/src/layouts/shared/files-tab/layout.vue
@@ -4,7 +4,7 @@
@@ -19,138 +19,81 @@
-
-
-
-
navigateToSegment(-1)"
- @prefetch-home="handlePrefetchHome"
- @update:search-query="searchQuery = $event"
- @create="showCreateModal"
- @upload="initiateFileUpload"
- @upload-zip="() => {}"
- @unzip-from-url="showUnzipFromUrlModal"
- @refresh="ctx.refresh"
- @share="() => fileEditorRef?.shareToMclogs()"
- @find="() => fileEditorRef?.toggleFind()"
- />
-
-
-
-
-
-
- handleExtractItem(item)"
- @delete="() => showDeleteModal(item)"
- @rename="() => showRenameModal(item)"
- @download="() => handleDownload(item)"
- @zip="() => handleZip(item)"
- @move="() => showMoveModal(item)"
- @move-direct-to="handleDirectMove"
- @edit="() => handleEditFile(item)"
- @navigate="() => handleNavigateToFolder(item)"
- @hover="() => handleItemHover(item)"
- @contextmenu="
- (event, options) => contextMenuRef?.open(event as MouseEvent, options)
- "
- @toggle-select="() => toggleItemSelection(item.path)"
- />
-
-
-
-
-
-
- {{ formatMessage(messages.emptyFolderTitle) }}
-
-
- {{ formatMessage(messages.emptyFolderDescription) }}
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
{{ formatMessage(messages.unsavedChanges) }}
-