From 20eb90e86e9d1a21a4d3d4926b74aae7b17d1177 Mon Sep 17 00:00:00 2001 From: James Manuel Date: Thu, 23 Jul 2026 16:20:20 +0200 Subject: [PATCH 1/5] feat: show preview thumbnails in the list view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill the NcListItem icon slot with a small, lazy-loaded preview thumbnail, matching the existing grid view. Extracts the preview image + error-to-icon fallback (previously duplicated between the grid and, in the original PR, a second inline copy for the list) into a shared FilePreview component, with each instance owning its own failure state instead of one map shared across both views keyed only by fileid — a failure at one requested size can no longer suppress the thumbnail at a different size. Sizes the list thumbnail via var(--default-clickable-area), matching NcListItemIcon's own convention, rather than a hardcoded 40px, and requests previews at 2x that for hidpi rendering. Rebased here onto current main (the file-filtering logic this PR's predecessor was based on has since moved to src/utils/fileFilters.ts) with FilePreview.spec.ts covering the new component directly and two wiring tests in OfficeOverview.spec.ts confirming each view passes it the right props. Original feature and diagnosis by Frank Karlitschek (#83); rebased here with the shared-state fix and tests applied. Co-authored-by: Frank Karlitschek Co-Authored-By: Claude Sonnet 5 Signed-off-by: James Manuel --- src/components/FilePreview.spec.ts | 57 +++++++++++++++++++++++ src/components/FilePreview.vue | 72 ++++++++++++++++++++++++++++++ src/views/OfficeOverview.spec.ts | 49 +++++++++++++++++++- src/views/OfficeOverview.vue | 48 +++++++------------- 4 files changed, 194 insertions(+), 32 deletions(-) create mode 100644 src/components/FilePreview.spec.ts create mode 100644 src/components/FilePreview.vue diff --git a/src/components/FilePreview.spec.ts b/src/components/FilePreview.spec.ts new file mode 100644 index 0000000..bc2fe8a --- /dev/null +++ b/src/components/FilePreview.spec.ts @@ -0,0 +1,57 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { makeNode } from '../test-utils/fixtures.ts' +import FilePreview from './FilePreview.vue' + +describe('FilePreview', () => { + it('requests a preview at the given size, keyed by fileid and etag', () => { + const file = makeNode({ basename: 'report.odt' }) + // `attributes` is a getter returning a live reference — mutate in place, + // it has no setter (Node/File from @nextcloud/files). + Object.assign(file.attributes, { etag: 'abcdef1234567890' }) + + const wrapper = mount(FilePreview, { props: { file, size: 96 } }) + + const img = wrapper.find('img') + expect(img.attributes('src')).toContain(`fileId=${file.fileid}`) + expect(img.attributes('src')).toContain('x=96') + expect(img.attributes('src')).toContain('y=96') + // Only the first 6 chars of the etag are used for cache-busting. + expect(img.attributes('src')).toContain('v=abcdef') + expect(img.attributes('src')).not.toContain('1234567890') + }) + + it('passes the alt text through, defaulting to empty (decorative)', () => { + const withoutAlt = mount(FilePreview, { props: { file: makeNode() } }) + expect(withoutAlt.find('img').attributes('alt')).toBe('') + + const withAlt = mount(FilePreview, { props: { file: makeNode(), alt: 'report.odt' } }) + expect(withAlt.find('img').attributes('alt')).toBe('report.odt') + }) + + it('falls back to a document icon on image load failure, sized via fallbackIconSize', async () => { + const wrapper = mount(FilePreview, { props: { file: makeNode(), fallbackIconSize: 32 } }) + + await wrapper.find('img').trigger('error') + + expect(wrapper.find('img').exists()).toBe(false) + const fallback = wrapper.findComponent({ name: 'NcIconSvgWrapper' }) + expect(fallback.exists()).toBe(true) + expect(fallback.props('size')).toBe(32) + }) + + it('gives each instance independent failure state (no cross-instance/cross-size leakage)', async () => { + const file = makeNode() + const small = mount(FilePreview, { props: { file, size: 96 } }) + const large = mount(FilePreview, { props: { file, size: 300 } }) + + await small.find('img').trigger('error') + + expect(small.find('img').exists()).toBe(false) + expect(large.find('img').exists()).toBe(true) + }) +}) diff --git a/src/components/FilePreview.vue b/src/components/FilePreview.vue new file mode 100644 index 0000000..90d74cf --- /dev/null +++ b/src/components/FilePreview.vue @@ -0,0 +1,72 @@ + + + + + + + diff --git a/src/views/OfficeOverview.spec.ts b/src/views/OfficeOverview.spec.ts index 43f8c67..e5db6ed 100644 --- a/src/views/OfficeOverview.spec.ts +++ b/src/views/OfficeOverview.spec.ts @@ -58,7 +58,10 @@ const NC_DIALOG_STUB = { // lookups below always match by { name } rather than by imported reference: // an object-identity match (e.g. findComponent(NcButton)) silently fails // because "our" NcButton import and OfficeOverview's are different instances. -async function mountOverview() { +// extraStubs merges in on top of the defaults below — used by tests that need +// a named slot rendered on a component that's normally left as a plain +// shallow stub (e.g. NcListItem's #icon, FileCard's #preview). +async function mountOverview(extraStubs: Record = {}) { vi.resetModules() const { default: OfficeOverview } = await import('./OfficeOverview.vue') const wrapper = shallowMount(OfficeOverview, { @@ -67,6 +70,7 @@ async function mountOverview() { NcDialog: NC_DIALOG_STUB, NcAppNavigation: stubRenderingAllSlots('NcAppNavigation'), NcEmptyContent: stubRenderingAllSlots('NcEmptyContent', ['name']), + ...extraStubs, }, }, }) @@ -200,6 +204,49 @@ describe('OfficeOverview > rendering states', () => { }) }) +describe('OfficeOverview > preview thumbnails', () => { + // FilePreview's own rendering (image src, error-to-icon fallback) is + // covered by FilePreview.spec.ts in isolation. These tests are wiring + // only: does each view pass FilePreview the props it's supposed to? + // NcListItem/FileCard's default shallow stub only renders the default + // slot (see stubRenderingAllSlots' comment above), so their #icon/#preview + // named slots — where FilePreview lives — need it rendered explicitly. + const LIST_ITEM_STUB = stubRenderingAllSlots('NcListItem', ['name', 'active']) + const FILE_CARD_STUB = stubRenderingAllSlots('FileCard', []) + + it('passes list view a small thumbnail size and the file, decorative (no alt)', async () => { + getTemplatesMock.mockResolvedValue([makeCreator()]) + const file = makeNode({ owner: 'alice', basename: 'report.odt' }) + getAllOfficeFilesMock.mockResolvedValue([file]) + + const wrapper = await mountOverview({ NcListItem: LIST_ITEM_STUB }) + + const preview = wrapper.findComponent({ name: 'FilePreview' }) + // Vue wraps allFiles.value in a reactive proxy, so the prop is a proxied + // copy, not the exact same reference as `file` — compare by fileid. + expect(preview.props('file').fileid).toBe(file.fileid) + expect(preview.props('size')).toBe(96) + expect(preview.props('fallbackIconSize')).toBe(32) + expect(preview.props('alt')).toBeFalsy() + expect(preview.classes()).toContain('office-overview__list-thumb') + }) + + it('passes grid view the file\'s basename as alt text (not decorative)', async () => { + localStorage.setItem('office.overview.gridView', 'true') + getTemplatesMock.mockResolvedValue([makeCreator()]) + const file = makeNode({ owner: 'alice', basename: 'report.odt' }) + getAllOfficeFilesMock.mockResolvedValue([file]) + + const wrapper = await mountOverview({ FileCard: FILE_CARD_STUB }) + + const preview = wrapper.findComponent({ name: 'FilePreview' }) + // Vue wraps allFiles.value in a reactive proxy, so the prop is a proxied + // copy, not the exact same reference as `file` — compare by fileid. + expect(preview.props('file').fileid).toBe(file.fileid) + expect(preview.props('alt')).toBe('report.odt') + }) +}) + describe('OfficeOverview > openFile', () => { it('navigates to the WOPI editor URL with fileId when editorUrl is set', async () => { vi.mocked(loadState).mockReturnValue('/apps/office/editor') diff --git a/src/views/OfficeOverview.vue b/src/views/OfficeOverview.vue index 75e8c96..d0791b1 100644 --- a/src/views/OfficeOverview.vue +++ b/src/views/OfficeOverview.vue @@ -30,6 +30,7 @@ import { mdiViewList, } from '@mdi/js' import FileCard from '../components/FileCard.vue' +import FilePreview from '../components/FilePreview.vue' import TemplateSection from '../components/TemplateSection.vue' import { getAllOfficeFiles, invalidateOfficeFilesCache, MAX_DISPLAY_FILES } from '../services/officeFiles.ts' import { getTemplates, createFromTemplate } from '../services/templates.ts' @@ -59,7 +60,6 @@ const pendingCreator = ref(null) const pendingTemplate = ref(null) const creating = ref(false) const createError = ref('') -const failedPreviews = ref>({}) const createInput = ref | null>(null) watch(activeCreator, () => { @@ -102,16 +102,6 @@ function toggleViewMode() { setOverviewGridView(mode === 'grid') } -function getPreviewUrl(file: Node): string { - const etag = (file.attributes?.etag as string | undefined ?? '').slice(0, 6) - return generateUrl('/core/preview?fileId={fileid}&x={x}&y={y}&v={v}&a=1&mimeFallback=true', { - fileid: file.fileid, - x: 300, - y: 300, - v: etag, - }) -} - // Provided by PageController::index() — set to the editor open URL when a WOPI // backend is active, null otherwise. const editorUrl = loadState('office', 'editor-url', null) @@ -309,16 +299,7 @@ fetchAll() :key="file.fileid" @click="openFile(file)">