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: [], + })) +} 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.