From 6179611bd44f5977d83be5f6ecbf8b4e03fc42fd Mon Sep 17 00:00:00 2001 From: kristian-zendato Date: Thu, 15 Oct 2026 13:16:56 +0200 Subject: [PATCH 1/2] 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.spec.ts | 514 ++++++++++++++++++ .../src/composables/useFileListLayout.ts | 136 +++++ 7 files changed, 736 insertions(+), 41 deletions(-) create mode 100644 apps/files/src/composables/useFileListLayout.spec.ts 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.spec.ts b/apps/files/src/composables/useFileListLayout.spec.ts new file mode 100644 index 0000000000000..4bf12b99204f1 --- /dev/null +++ b/apps/files/src/composables/useFileListLayout.spec.ts @@ -0,0 +1,514 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IColumn } from '@nextcloud/files' +import type { FileListLayout } from './useFileListLayout.ts' + +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { beforeEach, describe, expect, it } from 'vitest' +import { isReadonly } from 'vue' +import { + COLUMN_WIDTHS, + DROPPABLE_BUILTIN_COLUMNS, + getFileListLayout, + INLINE_ACTION_WIDTH, + KEPT_BUILTIN_COLUMNS, + reportInlineActionsWidth, + resetInlineActionsWidth, + useInlineActionsWidth, +} from './useFileListLayout.ts' + +/** + * Widths the files list layout is expected to reserve. + * These numbers are the contract: they are asserted against the stylesheet, + * the row markup, and `getFileListLayout`. Adding a column without an entry + * here fails the suite. + */ +const ROW_HEIGHT = 44 +const CELL_MARGIN = 14 +const MIN_NAME_WIDTH = 200 +const CHECKBOX_WIDTH = ROW_HEIGHT +const ACTIONS_MENU_WIDTH = ROW_HEIGHT + +const columnTests: Record = { + mime: { + cssClass: 'files-list__row-mime', + multiplier: 3.5, + behavior: 'dropped', + }, + size: { + cssClass: 'files-list__row-size', + multiplier: 2, + behavior: 'kept', + }, + mtime: { + cssClass: 'files-list__row-mtime', + multiplier: 2.5, + behavior: 'kept', + }, + custom: { + cssClass: 'files-list__row-column-custom', + multiplier: 2.5, + behavior: 'dropped', + }, +} + +const ROW_COMPONENTS = [ + '../components/FileEntry.vue', + '../components/FilesListTableHeader.vue', + '../components/FilesListTableFooter.vue', +] + +/** Row classes that are not data columns. */ +const STRUCTURAL_ROW_CLASSES = new Set([ + 'files-list__row-head', + 'files-list__row-checkbox', + 'files-list__row-name', + 'files-list__row-icon', + 'files-list__row-actions', +]) + +describe('useFileListLayout', () => { + beforeEach(() => { + resetInlineActionsWidth() + }) + + describe('column coverage', () => { + it('fails when a column is added without a matching test', () => { + expect(Object.keys(COLUMN_WIDTHS).sort()).toEqual(Object.keys(columnTests).sort()) + expect([...DROPPABLE_BUILTIN_COLUMNS, ...KEPT_BUILTIN_COLUMNS, 'custom'].sort()) + .toEqual(Object.keys(columnTests).sort()) + }) + + it('classifies every built-in column as dropped or kept', () => { + for (const column of DROPPABLE_BUILTIN_COLUMNS) { + expect(columnTests[column].behavior).toBe('dropped') + } + for (const column of KEPT_BUILTIN_COLUMNS) { + expect(columnTests[column].behavior).toBe('kept') + } + expect(columnTests.custom.behavior).toBe('dropped') + expect(DROPPABLE_BUILTIN_COLUMNS.length + KEPT_BUILTIN_COLUMNS.length) + .toBe(Object.keys(COLUMN_WIDTHS).length - 1) + }) + + it('fails when a rendered column has no matching width test', () => { + const expected = Object.values(columnTests).map(({ cssClass }) => cssClass).sort() + for (const path of ROW_COMPONENTS) { + expect(columnClasses(readVue(path)).sort(), path).toEqual(expected) + } + }) + + it('fails when the stylesheet gains a column width without a matching test', () => { + const expected = Object.fromEntries(Object.values(columnTests).map(({ cssClass, multiplier }) => [cssClass, multiplier])) + expect(stylesheetColumnMultipliers(listStylesheet())).toEqual(expected) + }) + }) + + describe('width contract', () => { + it('uses the row height and cell margin from the files list stylesheet', () => { + const style = listStylesheet() + expect(style).toMatch(/--row-height:\s*44px/) + expect(style).toMatch(/--cell-margin:\s*14px/) + expect(style).toMatch(/td\s*,\s*th\s*\{[^}]*width:\s*var\(--row-height\)/) + }) + + it('reserves the checkbox and the actions menu at one row height each', () => { + expect(CHECKBOX_WIDTH + ACTIONS_MENU_WIDTH).toBe(88) + }) + + it('sizes an inline action as the row height plus one cell margin', () => { + expect(INLINE_ACTION_WIDTH).toBe(ROW_HEIGHT + CELL_MARGIN) + expect(INLINE_ACTION_WIDTH).toBe(58) + }) + + it.each(Object.entries(columnTests))('column %s content width is the row height times its multiplier', (column, spec) => { + expect(COLUMN_WIDTHS[column]).toBe(ROW_HEIGHT * spec.multiplier) + }) + + it('adds the cell margin on both sides of every column', () => { + expect(cellWidth(COLUMN_WIDTHS.mime)).toBe(182) + expect(cellWidth(COLUMN_WIDTHS.size)).toBe(116) + expect(cellWidth(COLUMN_WIDTHS.mtime)).toBe(138) + expect(cellWidth(COLUMN_WIDTHS.custom)).toBe(138) + for (const column of Object.keys(columnTests) as (keyof typeof columnTests)[]) { + expect(cellWidth(COLUMN_WIDTHS[column])).toBe(COLUMN_WIDTHS[column] + 2 * CELL_MARGIN) + } + }) + }) + + describe('getFileListLayout', () => { + it('keeps inline actions when the name column has exactly its minimum width', () => { + const width = CHECKBOX_WIDTH + ACTIONS_MENU_WIDTH + MIN_NAME_WIDTH + expect(width).toBe(288) + expect(getFileListLayout(width, available({ inlineActions: true }), 0).inlineActions).toBe(true) + expect(getFileListLayout(width - 1, available({ inlineActions: true }), 0).inlineActions).toBe(false) + }) + + it('adds the reported inline actions width on top of the actions menu', () => { + const inlineWidth = 2 * INLINE_ACTION_WIDTH + expect(inlineWidth).toBe(116) + const width = CHECKBOX_WIDTH + ACTIONS_MENU_WIDTH + inlineWidth + MIN_NAME_WIDTH + expect(width).toBe(404) + expect(getFileListLayout(width, available({ inlineActions: true }), inlineWidth).inlineActions).toBe(true) + expect(getFileListLayout(width - 1, available({ inlineActions: true }), inlineWidth).inlineActions).toBe(false) + }) + + it('hides inline actions before the mime column', () => { + const inlineWidth = INLINE_ACTION_WIDTH + const row = available({ inlineActions: true, mime: true }) + const withInline = CHECKBOX_WIDTH + ACTIONS_MENU_WIDTH + inlineWidth + cellWidth(COLUMN_WIDTHS.mime) + MIN_NAME_WIDTH + expect(withInline).toBe(528) + expect(visible(getFileListLayout(withInline, row, inlineWidth))).toEqual(visible(row)) + expect(visible(getFileListLayout(withInline - 1, row, inlineWidth))).toEqual({ + inlineActions: false, + mime: true, + size: false, + mtime: false, + columns: [], + }) + }) + + it.each(DROPPABLE_BUILTIN_COLUMNS)('hides the %s column one pixel below the width it needs', (column) => { + const threshold = CHECKBOX_WIDTH + ACTIONS_MENU_WIDTH + cellWidth(COLUMN_WIDTHS[column]) + MIN_NAME_WIDTH + const row = available({ [column]: true }) + expect(getFileListLayout(threshold, row, 0)[column]).toBe(true) + expect(getFileListLayout(threshold - 1, row, 0)[column]).toBe(false) + }) + + it.each(KEPT_BUILTIN_COLUMNS)('keeps the %s column when nothing else fits', (column) => { + expect(getFileListLayout(0, available({ [column]: true }), 0)[column]).toBe(true) + expect(getFileListLayout(-1, available({ [column]: true }), 0)[column]).toBe(true) + }) + + it.each(KEPT_BUILTIN_COLUMNS)('counts the %s column when deciding if the mime column fits', (column) => { + const mimeCell = cellWidth(COLUMN_WIDTHS.mime) + const keptCell = cellWidth(COLUMN_WIDTHS[column]) + const mimeAlone = CHECKBOX_WIDTH + ACTIONS_MENU_WIDTH + mimeCell + MIN_NAME_WIDTH + const withKept = mimeAlone + keptCell + const row = available({ mime: true, [column]: true }) + + expect(getFileListLayout(withKept, row, 0).mime).toBe(true) + expect(getFileListLayout(withKept, row, 0)[column]).toBe(true) + expect(getFileListLayout(withKept - 1, row, 0).mime).toBe(false) + expect(getFileListLayout(withKept - 1, row, 0)[column]).toBe(true) + // The kept column occupies space the mime column would otherwise use. + expect(getFileListLayout(mimeAlone, row, 0).mime).toBe(false) + }) + + it('distinguishes the size and mtime widths', () => { + // mime + size = 88 + 182 + 116 + 200 = 586 + // mime + mtime = 88 + 182 + 138 + 200 = 608 + const mimeAndSize = available({ mime: true, size: true }) + const mimeAndMtime = available({ mime: true, mtime: true }) + + expect(getFileListLayout(608, mimeAndMtime, 0).mime).toBe(true) + expect(getFileListLayout(607, mimeAndMtime, 0).mime).toBe(false) + expect(getFileListLayout(586, mimeAndSize, 0).mime).toBe(true) + expect(getFileListLayout(585, mimeAndSize, 0).mime).toBe(false) + // 600 fits mime beside size, and drops mime beside the wider mtime column. + expect(getFileListLayout(600, mimeAndSize, 0).mime).toBe(true) + expect(getFileListLayout(600, mimeAndMtime, 0).mime).toBe(false) + }) + + it('keeps size and mtime together down to the width of those two cells', () => { + // 88 + 182 + 116 + 138 + 200 = 724 + const row = available({ mime: true, size: true, mtime: true }) + expect(visible(getFileListLayout(724, row, 0))).toEqual(visible(row)) + expect(visible(getFileListLayout(723, row, 0))).toEqual({ + inlineActions: false, + mime: false, + size: true, + mtime: true, + columns: [], + }) + expect(visible(getFileListLayout(0, row, 0))).toEqual({ + inlineActions: false, + mime: false, + size: true, + mtime: true, + columns: [], + }) + }) + + it('hides the mime column before custom columns', () => { + const columns = [column('notes'), column('tags')] + const row = available({ mime: true, size: true, mtime: true, columns }) + // 88 + 182 + 116 + 138 + 138 + 138 + 200 = 1000 + expect(visible(getFileListLayout(1000, row, 0))).toEqual(visible(row)) + expect(visible(getFileListLayout(999, row, 0))).toEqual({ + inlineActions: false, + mime: false, + size: true, + mtime: true, + columns: ['notes', 'tags'], + }) + }) + + it('hides custom columns last first, one column at a time', () => { + const columns = [column('a'), column('b'), column('c')] + const row = available({ columns }) + // 88 + 3 * 138 + 200 = 702, then 138px per column that is hidden + expect(columnIds(getFileListLayout(702, row, 0))).toEqual(['a', 'b', 'c']) + expect(columnIds(getFileListLayout(701, row, 0))).toEqual(['a', 'b']) + expect(columnIds(getFileListLayout(564, row, 0))).toEqual(['a', 'b']) + expect(columnIds(getFileListLayout(563, row, 0))).toEqual(['a']) + expect(columnIds(getFileListLayout(426, row, 0))).toEqual(['a']) + expect(columnIds(getFileListLayout(425, row, 0))).toEqual([]) + }) + + it('returns the remaining custom columns in their original order', () => { + const columns = [column('a'), column('b'), column('c')] + const row = available({ columns }) + const layout = getFileListLayout(563, row, 0) + + expect(row.columns).toBe(columns) + expect(columnIds(row)).toEqual(['a', 'b', 'c']) + expect(layout.columns).not.toBe(columns) + expect(layout.columns).toEqual([columns[0]]) + }) + + it('leaves a column hidden when the view does not provide it', () => { + const wide = 5000 + expect(visible(getFileListLayout(wide, available(), 0))).toEqual({ + inlineActions: false, + mime: false, + size: false, + mtime: false, + columns: [], + }) + expect(getFileListLayout(wide, available({ size: true }), 200).size).toBe(true) + expect(getFileListLayout(wide, available({ size: true }), 200).mtime).toBe(false) + }) + + it('does not reserve space for a column that is unavailable', () => { + // mime + size fits at 586. Counting a hidden mtime column would drop mime. + const row = available({ mime: true, size: true, mtime: false, columns: [] }) + expect(getFileListLayout(600, row, 0).mime).toBe(true) + expect(getFileListLayout(600, row, 0).mtime).toBe(false) + }) + + it('hides inline actions that take no width when another column still overflows', () => { + const row = available({ inlineActions: true, mime: true }) + // 88 + 182 + 200 = 470. Inline width 0 does not change the total, so both are hidden together. + expect(getFileListLayout(470, row, 0).inlineActions).toBe(true) + expect(getFileListLayout(470, row, 0).mime).toBe(true) + expect(getFileListLayout(469, row, 0).inlineActions).toBe(false) + expect(getFileListLayout(469, row, 0).mime).toBe(false) + }) + + it('uses fractional widths with the same minimum name width', () => { + const row = available({ mime: true }) + expect(getFileListLayout(469.9, row, 0).mime).toBe(false) + expect(getFileListLayout(470, row, 0).mime).toBe(true) + + // 88 + 10.5 + 200 = 298.5 + const inline = available({ inlineActions: true }) + expect(getFileListLayout(298.5, inline, 10.5).inlineActions).toBe(true) + expect(getFileListLayout(298.4, inline, 10.5).inlineActions).toBe(false) + }) + + it.each([ + // chrome 88 + inline 116 + mime 182 + size 116 + mtime 138 + two custom columns 276 = 916 + // The name column needs 200 more, so the full row fits from 1116px. + [1116, { inlineActions: true, mime: true, size: true, mtime: true, columns: ['notes', 'tags'] }], + [1115, { inlineActions: false, mime: true, size: true, mtime: true, columns: ['notes', 'tags'] }], + [1000, { inlineActions: false, mime: true, size: true, mtime: true, columns: ['notes', 'tags'] }], + [999, { inlineActions: false, mime: false, size: true, mtime: true, columns: ['notes', 'tags'] }], + [900, { inlineActions: false, mime: false, size: true, mtime: true, columns: ['notes', 'tags'] }], + [818, { inlineActions: false, mime: false, size: true, mtime: true, columns: ['notes', 'tags'] }], + [817, { inlineActions: false, mime: false, size: true, mtime: true, columns: ['notes'] }], + [700, { inlineActions: false, mime: false, size: true, mtime: true, columns: ['notes'] }], + [680, { inlineActions: false, mime: false, size: true, mtime: true, columns: ['notes'] }], + [679, { inlineActions: false, mime: false, size: true, mtime: true, columns: [] }], + [542, { inlineActions: false, mime: false, size: true, mtime: true, columns: [] }], + [541, { inlineActions: false, mime: false, size: true, mtime: true, columns: [] }], + [0, { inlineActions: false, mime: false, size: true, mtime: true, columns: [] }], + ])('lays out the full row at %ipx', (width, expected) => { + const row = available({ + inlineActions: true, + mime: true, + size: true, + mtime: true, + columns: [column('notes'), column('tags')], + }) + expect(visible(getFileListLayout(width, row, 2 * INLINE_ACTION_WIDTH))).toEqual(expected) + }) + + it('hides every droppable part and keeps size and mtime when the list is narrower than the chrome', () => { + const row = available({ + inlineActions: true, + mime: true, + size: true, + mtime: true, + columns: [column('notes'), column('tags'), column('owner')], + }) + expect(visible(getFileListLayout(0, row, 500))).toEqual({ + inlineActions: false, + mime: false, + size: true, + mtime: true, + columns: [], + }) + expect(visible(getFileListLayout(-50, row, 500))).toEqual({ + inlineActions: false, + mime: false, + size: true, + mtime: true, + columns: [], + }) + }) + }) + + describe('inline actions width', () => { + it('starts at zero', () => { + expect(useInlineActionsWidth().value).toBe(0) + }) + + it('keeps the largest width reported by a row', () => { + reportInlineActionsWidth(58) + reportInlineActionsWidth(116) + reportInlineActionsWidth(30) + expect(useInlineActionsWidth().value).toBe(116) + }) + + it('ignores a width equal to the current maximum', () => { + reportInlineActionsWidth(80) + reportInlineActionsWidth(80) + expect(useInlineActionsWidth().value).toBe(80) + }) + + it('stores a smaller width after a reset', () => { + reportInlineActionsWidth(200) + resetInlineActionsWidth() + reportInlineActionsWidth(58) + expect(useInlineActionsWidth().value).toBe(58) + }) + + it('exposes a readonly width', () => { + expect(isReadonly(useInlineActionsWidth())).toBe(true) + }) + }) +}) + +/** + * Content width plus the margin on both sides of the cell. + * + * @param contentWidth - Column content width + */ +function cellWidth(contentWidth: number): number { + return contentWidth + 2 * CELL_MARGIN +} + +/** + * Layout with every optional part hidden. + * + * @param overrides - Parts to turn on + */ +function available(overrides: Partial = {}): FileListLayout { + return { + inlineActions: false, + mime: false, + size: false, + mtime: false, + columns: [], + ...overrides, + } +} + +/** + * @param id - Column id + */ +function column(id: string): IColumn { + return { + id, + title: id, + render: () => document.createElement('span'), + } +} + +/** + * @param layout - Computed layout + */ +function columnIds(layout: FileListLayout): string[] { + return layout.columns.map(({ id }) => id) +} + +/** + * @param layout - Computed layout + */ +function visible(layout: FileListLayout) { + return { + inlineActions: layout.inlineActions, + mime: layout.mime, + size: layout.size, + mtime: layout.mtime, + columns: columnIds(layout), + } +} + +/** + * @param relativePath - Path relative to this spec + */ +function readVue(relativePath: string): string { + const directory = dirname(fileURLToPath(import.meta.url)) + return readFileSync(resolve(directory, relativePath), 'utf8') +} + +/** + * List-mode stylesheet of the files list. + */ +function listStylesheet(): string { + const source = readVue('../components/FilesListVirtual.vue') + const start = source.indexOf('') + return source.slice(start, end) +} + +/** + * Static column classes rendered by a files-list row component. + * + * @param source - Vue single file component source + */ +function columnClasses(source: string): string[] { + const withoutStyle = source.replace(//g, '') + const classes = new Set() + for (const match of withoutStyle.matchAll(/files-list__row-[a-z][a-z0-9]*(?:-[a-z0-9]+)*/g)) { + const cssClass = match[0] + if (!STRUCTURAL_ROW_CLASSES.has(cssClass)) { + classes.add(cssClass) + } + } + return [...classes] +} + +/** + * Column content widths declared as a multiple of `--row-height`. + * + * @param style - List-mode stylesheet + */ +function stylesheetColumnMultipliers(style: string): Record { + const multipliers: Record = {} + for (const match of style.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + const width = match[2].match(/width:\s*calc\(\s*var\(--row-height\)\s*\*\s*([0-9.]+)\s*\)/) + if (!width) { + continue + } + const classes = [...match[1].matchAll(/\.files-list__row-[a-z][a-z0-9]*(?:-[a-z0-9]+)*/g)] + .map(([cssClass]) => cssClass.slice(1)) + expect(classes, 'a row-height column width needs a column class').not.toHaveLength(0) + for (const cssClass of classes) { + multipliers[cssClass] = Number(width[1]) + } + } + return multipliers +} diff --git a/apps/files/src/composables/useFileListLayout.ts b/apps/files/src/composables/useFileListLayout.ts new file mode 100644 index 0000000000000..5fa6630fb76a1 --- /dev/null +++ b/apps/files/src/composables/useFileListLayout.ts @@ -0,0 +1,136 @@ +/*! + * 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 + +/** + * Content widths of the columns, without the surrounding cell margin. + * Must match the files list styles. + */ +export const COLUMN_WIDTHS = { + mime: ROW_HEIGHT * 3.5, + size: ROW_HEIGHT * 2, + mtime: ROW_HEIGHT * 2.5, + custom: ROW_HEIGHT * 2.5, +} as const + +/** Built-in columns hidden, first to last, when the name column runs out of room. */ +export const DROPPABLE_BUILTIN_COLUMNS = ['mime'] as const + +/** Built-in columns kept even when the name column is below its minimum width. */ +export const KEPT_BUILTIN_COLUMNS = ['size', 'mtime'] as const + +type BuiltinColumn = typeof DROPPABLE_BUILTIN_COLUMNS[number] | typeof KEPT_BUILTIN_COLUMNS[number] + +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, parts are hidden in this order until the name column + * has at least `MIN_NAME_WIDTH` space left: inline actions, then + * `DROPPABLE_BUILTIN_COLUMNS`, then custom view columns (last first). + * `KEPT_BUILTIN_COLUMNS` stay 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 builtinColumns: readonly BuiltinColumn[] = [...DROPPABLE_BUILTIN_COLUMNS, ...KEPT_BUILTIN_COLUMNS] + const getUsedWidth = () => CHECKBOX_WIDTH + + ACTIONS_MENU_WIDTH + + (layout.inlineActions ? inlineActionsWidth : 0) + + builtinColumns.reduce((total, column) => total + (layout[column] ? cellWidth(COLUMN_WIDTHS[column]) : 0), 0) + + layout.columns.length * cellWidth(COLUMN_WIDTHS.custom) + + const reductions: (() => void)[] = [ + () => { layout.inlineActions = false }, + ...DROPPABLE_BUILTIN_COLUMNS.map((column) => () => { layout[column] = 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: [], + })) +} From 3b68c52a64a44837a923b53944be6783d0e02e62 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 26 Oct 2026 15:03:43 +0100 Subject: [PATCH 2/2] test(files): trigger row actions wherever they render Inline row actions move into the row menu when the files list runs out of room for the name column, e.g. the trashbin "restore" action at the Playwright viewport. Let `triggerActionForFile` and `triggerActionForFileId` click the inline button when it is rendered and fall back to the menu otherwise, and drop the inline-only helpers. Assisted-by: ClaudeCode:claude-fable-5-1 Signed-off-by: Ferdinand Thiessen --- .../playwright/e2e/files/live-photos.spec.ts | 4 +- .../admin-settings-user-credentials.spec.ts | 2 +- .../support/sections/FilesListPage.ts | 46 +++++++++---------- 3 files changed, 25 insertions(+), 27 deletions(-) diff --git a/tests/playwright/e2e/files/live-photos.spec.ts b/tests/playwright/e2e/files/live-photos.spec.ts index 5ee42bc255420..5f59738491edb 100644 --- a/tests/playwright/e2e/files/live-photos.spec.ts +++ b/tests/playwright/e2e/files/live-photos.spec.ts @@ -153,7 +153,7 @@ test.describe('Files: Live photos', () => { await filesListPage.triggerActionForFile(`${livePhoto.fileName}.jpg`, 'delete') await filesListPage.open('trashbin') - await filesListPage.triggerInlineActionForFileId(livePhoto.jpgFileId, 'restore') + await filesListPage.triggerActionForFileId(livePhoto.jpgFileId, 'restore') // The clicked file leaves the trashbin reactively; the linked .mov is // restored server-side, so reload to see the cascaded removal await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)).toHaveCount(0) @@ -170,7 +170,7 @@ test.describe('Files: Live photos', () => { await filesListPage.triggerActionForFile(`${livePhoto.fileName}.jpg`, 'delete') await filesListPage.open('trashbin') - await filesListPage.triggerInlineActionForFileId(livePhoto.movFileId, 'restore') + await filesListPage.triggerActionForFileId(livePhoto.movFileId, 'restore') await filesListPage.reloadCurrentFolder() // Restoring the video alone is not allowed: both stay in the trashbin diff --git a/tests/playwright/e2e/files_external/admin-settings-user-credentials.spec.ts b/tests/playwright/e2e/files_external/admin-settings-user-credentials.spec.ts index a610b3dff5f68..c4741db548be9 100644 --- a/tests/playwright/e2e/files_external/admin-settings-user-credentials.spec.ts +++ b/tests/playwright/e2e/files_external/admin-settings-user-credentials.spec.ts @@ -147,7 +147,7 @@ async function setStorageCredentials( const credentialsSet = page.waitForResponse((response) => response.request().method() === 'PUT' && response.url().includes('/apps/files_external/userglobalstorages/')) - await filesListPage.triggerInlineActionForFile(mountName, ACTION_CREDENTIALS_EXTERNAL_STORAGE) + await filesListPage.triggerActionForFile(mountName, ACTION_CREDENTIALS_EXTERNAL_STORAGE) const storageDialog = page.getByRole('dialog', { name: 'Storage credentials' }) await expect(storageDialog).toBeVisible() diff --git a/tests/playwright/support/sections/FilesListPage.ts b/tests/playwright/support/sections/FilesListPage.ts index 36fa974a5ced6..b7800ec21a552 100644 --- a/tests/playwright/support/sections/FilesListPage.ts +++ b/tests/playwright/support/sections/FilesListPage.ts @@ -206,7 +206,27 @@ export class FilesListPage { return menu } + /** + * Trigger a row action wherever it renders: as an inline button next to the + * actions menu button, or as an entry of that menu. Inline actions move into + * the menu once the list runs out of room for the name column, so where an + * action renders depends on the view's columns and the viewport. + */ private async triggerActionForRow(row: Locator, actionId: string): Promise { + await expect(row).toBeVisible({ timeout: 15_000 }) + await row.hover() + + // Inline buttons and the menu button render in the same pass, so once any + // button of the actions cell is visible the inline button is there if it + // renders at all + await expect(row.locator('[data-cy-files-list-row-actions] button').first()).toBeVisible() + + const inlineButton = row.locator(`button[data-cy-files-list-row-action="${actionId}"]`) + if (await inlineButton.isVisible()) { + await inlineButton.click() + return + } + const menu = await this.openActionsMenuForRow(row) const actionEntry = this.getActionButtonInMenu(menu, actionId) await actionEntry.waitFor({ state: 'visible' }) @@ -265,36 +285,14 @@ export class FilesListPage { /** * An inline row action rendered directly in the row's action area (an action * declared `inline: () => true`, e.g. the external-storage credentials action), - * as opposed to one nested in the overflow menu. + * as opposed to one nested in the overflow menu. Only use this to assert on + * the inline rendering itself; to click an action use {@link triggerActionForFile}. */ getInlineActionEntryForFile(filename: string, actionId: string): Locator { return this.getRowForFile(filename) .locator(`[data-cy-files-list-row-action="${actionId}"]`) } - /** - * Hover a row and click one of its inline actions. The action area only - * renders on hover, so the row must be hovered first. - */ - async triggerInlineActionForFile(filename: string, actionId: string): Promise { - await this.triggerInlineActionForRow(this.getRowForFile(filename), actionId) - } - - /** - * Like {@link triggerInlineActionForFile} but addresses the row by file id. - * Trashbin rows carry a `.dNNN` deletion suffix on their name, so they are - * keyed by id (e.g. to restore a specific trashed file). - */ - async triggerInlineActionForFileId(fileid: number, actionId: string): Promise { - await this.triggerInlineActionForRow(this.getRowForFileId(fileid), actionId) - } - - private async triggerInlineActionForRow(row: Locator, actionId: string): Promise { - await row.hover() - const button = row.locator(`button[data-cy-files-list-row-action="${actionId}"]`) - await button.click() - } - /** * Rename a file via its row action and wait for the MOVE to land. `fill` * replaces the whole name, then Enter commits the rename.