From 07b82d573f29c9b2ebe979b4659c962b682ca6fe Mon Sep 17 00:00:00 2001 From: kristian-zendato Date: Mon, 5 Oct 2026 12:57:15 +0200 Subject: [PATCH] fix: show filenames on tight space Signed-off-by: kristian-zendato --- apps/files/src/components/FileEntry.vue | 8 +- .../components/FileEntry/FileEntryActions.vue | 63 +++++++-- .../src/components/FilesListTableFooter.vue | 12 +- .../src/components/FilesListTableHeader.vue | 12 +- .../files/src/components/FilesListVirtual.vue | 32 +++-- .../src/composables/useFileListLayout.ts | 126 ++++++++++++++++++ 6 files changed, 212 insertions(+), 41 deletions(-) create mode 100644 apps/files/src/composables/useFileListLayout.ts diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index d3f78c1dc56a2..8af0d4fb25c22 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -117,6 +117,7 @@ import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue' import FileEntryName from './FileEntry/FileEntryName.vue' import FileEntryPreview from './FileEntry/FileEntryPreview.vue' import { useFileActions } from '../composables/useFileActions.ts' +import { useFileListLayout } from '../composables/useFileListLayout.ts' import { useFileListWidth } from '../composables/useFileListWidth.ts' import { useRouteParameters } from '../composables/useRouteParameters.ts' import { useActionsMenuStore } from '../store/actionsmenu.ts' @@ -162,6 +163,7 @@ export default defineComponent({ const renamingStore = useRenamingStore() const selectionStore = useSelectionStore() const { isNarrow } = useFileListWidth() + const layout = useFileListLayout() const { fileId: currentRouteFileId, } = useRouteParameters() @@ -182,6 +184,7 @@ export default defineComponent({ activeView, currentRouteFileId, isNarrow, + layout, activeStore, actionsMenuStore, @@ -215,11 +218,10 @@ export default defineComponent({ }, columns() { - // Hide columns if the list is too small - if (this.isNarrow || this.compact) { + if (this.compact) { return [] } - return this.activeView?.columns || [] + return this.layout.columns }, mime() { diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue index 7d27e86163af2..1bf53fb99f388 100644 --- a/apps/files/src/components/FileEntry/FileEntryActions.vue +++ b/apps/files/src/components/FileEntry/FileEntryActions.vue @@ -135,7 +135,7 @@ import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue' import CustomElementRender from '../CustomElementRender.vue' -import { useFileListWidth } from '../../composables/useFileListWidth.ts' +import { INLINE_ACTION_WIDTH, reportInlineActionsWidth, useFileListLayout } from '../../composables/useFileListLayout.ts' import actionsMixins from '../../mixins/actionsMixin.ts' import { useActiveStore } from '../../store/active.ts' import { executeAction } from '../../utils/actionUtils.ts' @@ -178,16 +178,22 @@ export default defineComponent({ setup() { // The file list is guaranteed to be shown with active view - thus we can set the `loaded` flag const activeStore = useActiveStore() - const { isNarrow } = useFileListWidth() + const layout = useFileListLayout() const enabledFileActions = inject('enabledFileActions', []) return { activeStore, enabledFileActions, - isNarrow, + layout, t, } }, + data() { + return { + resizeObserver: null as ResizeObserver | null, + } + }, + computed: { isActive() { return this.activeStore.activeNode?.source === this.source.source @@ -206,9 +212,9 @@ export default defineComponent({ } }, - // Enabled action that are displayed inline - enabledInlineActions() { - if (this.isNarrow || this.gridMode) { + // Enabled actions that can be displayed inline + inlineCapableActions() { + if (this.gridMode) { return [] } return this.enabledFileActions.filter((action) => { @@ -221,9 +227,17 @@ export default defineComponent({ }) }, + // Enabled actions that are displayed inline + enabledInlineActions() { + if (!this.layout.inlineActions) { + return [] + } + return this.inlineCapableActions + }, + // Enabled action that are displayed inline with a custom render function enabledRenderActions() { - if (this.gridMode) { + if (!this.layout.inlineActions || this.gridMode) { return [] } return this.enabledFileActions.filter((action) => typeof action.renderInline === 'function') @@ -287,6 +301,10 @@ export default defineComponent({ openedMenu() { this.openedSubmenu = null }, + + inlineCapableActions() { + this.reportInlineActionsWidth() + }, }, created() { @@ -301,10 +319,39 @@ export default defineComponent({ }) }, + mounted() { + if (this.gridMode) { + return + } + // Inline rendered actions (e.g. tags) are rendered asynchronously and change the cell size + this.resizeObserver = new ResizeObserver(() => this.reportInlineActionsWidth()) + this.resizeObserver.observe(this.$el) + this.reportInlineActionsWidth() + }, + + beforeUnmount() { + this.resizeObserver?.disconnect() + }, + methods: { + /** + * Report the width needed by the inline actions of this row to the files list layout + */ + reportInlineActionsWidth() { + if (this.gridMode || !this.$el) { + return + } + + const renderActionsWidth = [...this.$el.querySelectorAll(':scope > .files-list__row-action--inline')] + .filter((element) => element.offsetWidth > 0) + .reduce((total, element) => total + element.offsetWidth + parseFloat(getComputedStyle(element).marginInlineEnd || '0'), 0) + + reportInlineActionsWidth(this.inlineCapableActions.length * INLINE_ACTION_WIDTH + renderActionsWidth) + }, + actionDisplayName(action: IFileAction) { try { - if ((this.gridMode || (this.isNarrow && action.inline)) && typeof action.title === 'function') { + if ((this.gridMode || (!this.layout.inlineActions && action.inline)) && typeof action.title === 'function') { // if an inline action is rendered in the menu for // lack of space we use the title first if defined const title = action.title(this.actionContext) diff --git a/apps/files/src/components/FilesListTableFooter.vue b/apps/files/src/components/FilesListTableFooter.vue index 05dcaad441ddf..698df43247d62 100644 --- a/apps/files/src/components/FilesListTableFooter.vue +++ b/apps/files/src/components/FilesListTableFooter.vue @@ -54,7 +54,7 @@ import type { IColumn, INode, IView } from '@nextcloud/files' import { formatFileSize } from '@nextcloud/files' import { t } from '@nextcloud/l10n' import { computed } from 'vue' -import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { useFileListLayout } from '../composables/useFileListLayout.ts' import { useActiveStore } from '../store/active.ts' const props = defineProps<{ @@ -78,17 +78,11 @@ const props = defineProps<{ }>() const activeStore = useActiveStore() -const { isNarrow } = useFileListWidth() +const layout = useFileListLayout() const currentFolder = computed(() => activeStore.activeFolder) -const columns = computed(() => { - // Hide columns if the list is too small - if (isNarrow.value) { - return [] - } - return props.currentView?.columns || [] -}) +const columns = computed(() => layout.value.columns) const totalSize = computed(() => { // If we have the size already, let's use it diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue index c6d3bcc14ca6b..5ec7421428788 100644 --- a/apps/files/src/components/FilesListTableHeader.vue +++ b/apps/files/src/components/FilesListTableHeader.vue @@ -119,7 +119,7 @@ import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwit import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue' import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue' import { useFilesSorting } from '../composables/filesSorting.ts' -import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { useFileListLayout } from '../composables/useFileListLayout.ts' import { useRouteParameters } from '../composables/useRouteParameters.ts' import { useActiveStore } from '../store/active.ts' import { useFilesStore } from '../store/files.ts' @@ -168,7 +168,7 @@ export default defineComponent({ const selectionStore = useSelectionStore() const { directory } = useRouteParameters() - const { isNarrow } = useFileListWidth() + const layout = useFileListLayout() return { activeStore, @@ -176,7 +176,7 @@ export default defineComponent({ selectionStore, directory, - isNarrow, + layout, ...useFilesSorting(), } @@ -184,11 +184,7 @@ export default defineComponent({ computed: { columns() { - // Hide columns if the list is too small - if (this.isNarrow) { - return [] - } - return this.activeStore.activeView?.columns || [] + return this.layout.columns }, dir() { diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index 813e014b04254..716dba4bb8532 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -69,7 +69,7 @@ import { showError } from '@nextcloud/dialogs' import { FileType, Folder, getSidebar, Permission, View } from '@nextcloud/files' import { t } from '@nextcloud/l10n' import { useHotKey } from '@nextcloud/vue/composables/useHotKey' -import { computed, defineComponent } from 'vue' +import { computed, defineComponent, provide, watch } from 'vue' import FileEntry from './FileEntry.vue' import FileEntryGrid from './FileEntryGrid.vue' import FileListFilterChips from './FileListFilter/FileListFilterChips.vue' @@ -80,6 +80,7 @@ import FilesListTableHeader from './FilesListTableHeader.vue' import VirtualList from './VirtualList.vue' import { useEnabledFileActions } from '../composables/useFileActions.ts' import { useFileListHeaders } from '../composables/useFileListHeaders.ts' +import { FileListLayoutKey, getFileListLayout, resetInlineActionsWidth, useInlineActionsWidth } from '../composables/useFileListLayout.ts' import { useFileListWidth } from '../composables/useFileListWidth.ts' import { useRouteParameters } from '../composables/useRouteParameters.ts' import { useActiveStore } from '../store/active.ts' @@ -125,20 +126,9 @@ export default defineComponent({ const activeStore = useActiveStore() const userConfigStore = useUserConfigStore() - const { isNarrow, isWide } = useFileListWidth() + const { width, isNarrow, isWide } = useFileListWidth() const { fileId, openDetails, openFile } = useRouteParameters() - const isMimeAvailable = computed(() => { - if (!userConfigStore.userConfig.show_mime_column) { - return false - } - if (!isWide.value) { - return false // only show on wide screens - } - return props.nodes - .some((node: INode) => node.mime !== undefined || node.mime !== 'application/octet-stream') - }) - const isMtimeAvailable = computed(() => { // Hide mtime column on narrow screens if (isNarrow.value) { @@ -155,6 +145,22 @@ export default defineComponent({ return props.nodes.some((node: INode) => node.size !== undefined) }) + const inlineActionsWidth = useInlineActionsWidth() + watch(() => [props.currentFolder, props.currentView], resetInlineActionsWidth, { immediate: true }) + + const layout = computed(() => getFileListLayout(width.value, { + inlineActions: true, + mime: userConfigStore.userConfig.show_mime_column + && isWide.value // only show on wide screens + && props.nodes.some((node: INode) => node.mime !== undefined || node.mime !== 'application/octet-stream'), + size: isSizeAvailable.value, + mtime: isMtimeAvailable.value, + columns: isNarrow.value ? [] : (props.currentView.columns ?? []), + }, inlineActionsWidth.value)) + provide(FileListLayoutKey, layout) + + const isMimeAvailable = computed(() => layout.value.mime) + return { fileId, headers: useFileListHeaders(), diff --git a/apps/files/src/composables/useFileListLayout.ts b/apps/files/src/composables/useFileListLayout.ts new file mode 100644 index 0000000000000..c3866a6c033d9 --- /dev/null +++ b/apps/files/src/composables/useFileListLayout.ts @@ -0,0 +1,126 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IColumn } from '@nextcloud/files' +import type { InjectionKey, Ref } from 'vue' + +import { inject, readonly, ref } from 'vue' + +/** Row height of the files list, must match `--row-height` */ +const ROW_HEIGHT = 44 +/** Margin around cells after the actions cell, must match `--cell-margin` */ +const CELL_MARGIN = 14 + +/** Width of the selection checkbox cell */ +const CHECKBOX_WIDTH = ROW_HEIGHT +/** Width of the actions menu button */ +const ACTIONS_MENU_WIDTH = ROW_HEIGHT +/** Width of an inline action button including its margin */ +export const INLINE_ACTION_WIDTH = ROW_HEIGHT + CELL_MARGIN +/** Minimal width reserved for the name column */ +const MIN_NAME_WIDTH = 200 + +/** Widths of the columns, must match the styles of the files list */ +const COLUMN_WIDTHS = { + mime: ROW_HEIGHT * 3.5, + size: ROW_HEIGHT * 2, + mtime: ROW_HEIGHT * 2.5, + custom: ROW_HEIGHT * 2.5, +} + +export interface FileListLayout { + /** Whether inline actions are shown, otherwise they are moved to the actions menu */ + inlineActions: boolean + /** Whether the mime column is shown */ + mime: boolean + /** Whether the size column is shown */ + size: boolean + /** Whether the mtime column is shown */ + mtime: boolean + /** The custom view columns to show */ + columns: IColumn[] +} + +export const FileListLayoutKey: InjectionKey> = Symbol('fileListLayout') + +/** The largest width needed by the inline actions of a row */ +const inlineActionsWidth = ref(0) + +/** + * Report the width needed by the inline actions of a row + * + * @param width - The width needed by the inline actions + */ +export function reportInlineActionsWidth(width: number) { + if (width > inlineActionsWidth.value) { + inlineActionsWidth.value = width + } +} + +/** + * Reset the reported inline actions width, e.g. when the shown content changes + */ +export function resetInlineActionsWidth() { + inlineActionsWidth.value = 0 +} + +/** + * Get the largest width needed by the inline actions of a row + */ +export function useInlineActionsWidth(): Readonly> { + return readonly(inlineActionsWidth) +} + +/** + * Compute which parts of the files list fit into the available width. + * If space is tight, the following parts are hidden in this order + * until the name column has at least `MIN_NAME_WIDTH` space left: + * inline actions, mime column, custom view columns (last first). + * The size and mtime columns are kept as available. + * + * @param width - The available width of the files list + * @param available - The parts of the files list available in the current view + * @param inlineActionsWidth - The width needed by the inline actions + */ +export function getFileListLayout(width: number, available: FileListLayout, inlineActionsWidth: number): FileListLayout { + const layout: FileListLayout = { ...available, columns: [...available.columns] } + + const cellWidth = (columnWidth: number) => columnWidth + 2 * CELL_MARGIN + const getUsedWidth = () => CHECKBOX_WIDTH + + ACTIONS_MENU_WIDTH + + (layout.inlineActions ? inlineActionsWidth : 0) + + (layout.mime ? cellWidth(COLUMN_WIDTHS.mime) : 0) + + (layout.size ? cellWidth(COLUMN_WIDTHS.size) : 0) + + (layout.mtime ? cellWidth(COLUMN_WIDTHS.mtime) : 0) + + layout.columns.length * cellWidth(COLUMN_WIDTHS.custom) + + const reductions: (() => void)[] = [ + () => { layout.inlineActions = false }, + () => { layout.mime = false }, + ...available.columns.map(() => () => { layout.columns.pop() }), + ] + + for (const reduce of reductions) { + if (width - getUsedWidth() >= MIN_NAME_WIDTH) { + break + } + reduce() + } + + return layout +} + +/** + * Get the current layout of the files list as provided by the files list + */ +export function useFileListLayout(): Ref { + return inject(FileListLayoutKey, ref({ + inlineActions: true, + mime: false, + size: false, + mtime: false, + columns: [], + })) +}