From 4c74cd48cba11fa171b9a147ac7d4df536ee2ca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:03:34 +0200 Subject: [PATCH] feat(handlers): let a handler say comparing its files is worth offering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old viewer asked each handler through a canCompare flag, and the versions tab offered "Compare" only for those. The flag did not come along, so the server now offers it for images alone, and Text, which set it, lost it. canCompare is back on IHandler, set by the image handler, and canCompare(node) tells a caller whether to offer it. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- README.md | 3 ++- __tests__/canView.spec.ts | 26 +++++++++++++++++++++++++- __tests__/entry.spec.ts | 1 + __tests__/models.spec.ts | 3 +++ lib/handlers.ts | 26 ++++++++++++++++++++++++++ lib/index.ts | 2 +- lib/models/images.ts | 2 ++ 7 files changed, 60 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 701582a..b9955c6 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,7 @@ The full handler shape (see the `IHandler` interface): | `group` | `string` | no | Group used to combine handlers when opening a folder | | `preload` | `(node: File) => Promise` | no | Preload data for neighbouring files | | `theme` | `'dark' \| 'light' \| 'default'` | no | Viewer modal theme | +| `canCompare` | `boolean` | no | Comparing two versions is worth offering, see `canCompare(node)` | | `supportsEndToEndEncryption` | `boolean` | no | Whether the handler supports end-to-end encrypted files | Gotchas: @@ -453,7 +454,7 @@ instead, and the viewer works with `@nextcloud/files` nodes rather than the | `OCA.Viewer.mimetypesCompare.includes(node.mime)` | `canView(node)` | | `OCA.Viewer.availableHandlers` | `getHandlers()`, or `canView(node)` to test one file | | `OCA.Viewer.registerHandler({ component })` | `registerHandler({ tagName })`, see above | -| `canCompare: true` on a handler | nothing, any handler can be compared | +| `canCompare: true` on a handler | the same, and `canCompare(node)` to decide whether to offer it | | `\OCP\Util::addScript` for the registration | `\OCP\Util::addInitScript` | | A listener for `OCA\Viewer\Event\LoadViewer` | a listener for `BeforeTemplateRenderedEvent`, see the [tutorial](#4-load-it-on-every-page) | | Dispatching `OCA\Viewer\Event\LoadViewer` | nothing, the viewer is on every page already | diff --git a/__tests__/canView.spec.ts b/__tests__/canView.spec.ts index d3d5b3a..bb7acb4 100644 --- a/__tests__/canView.spec.ts +++ b/__tests__/canView.spec.ts @@ -4,7 +4,7 @@ */ import { Folder, Permission } from '@nextcloud/files' import { describe, expect, it } from 'vitest' -import { canView, registerHandler } from '../lib/index.ts' +import { canCompare, canView, registerHandler } from '../lib/index.ts' import { makeFile, makeHandler } from './factories.ts' describe('canView', () => { @@ -44,3 +44,27 @@ describe('canView', () => { expect(canView([image, video])).toBe(false) }) }) + +describe('canCompare', () => { + it('is true for a file whose handler says comparing it is worth it', () => { + registerHandler(makeHandler({ id: 'images', canCompare: true, enabled: (nodes) => nodes.every((node) => node.mime === 'image/jpeg') })) + registerHandler(makeHandler({ id: 'videos', enabled: (nodes) => nodes.every((node) => node.mime === 'video/mp4') })) + + expect(canCompare(makeFile({ mime: 'image/jpeg' }))).toBe(true) + expect(canCompare(makeFile({ mime: 'video/mp4' }))).toBe(false) + }) + + // The handler the viewer would show it with, not any that takes it + it('asks the first handler to take the file', () => { + registerHandler(makeHandler({ id: 'first', enabled: () => true })) + registerHandler(makeHandler({ id: 'second', canCompare: true, enabled: () => true })) + + expect(canCompare(makeFile())).toBe(false) + }) + + it('is false for a file that cannot be viewed', () => { + registerHandler(makeHandler({ id: 'everything', canCompare: true, enabled: () => true })) + + expect(canCompare(makeFile({ permissions: Permission.NONE }))).toBe(false) + }) +}) diff --git a/__tests__/entry.spec.ts b/__tests__/entry.spec.ts index 0ecaa1b..7389583 100644 --- a/__tests__/entry.spec.ts +++ b/__tests__/entry.spec.ts @@ -53,6 +53,7 @@ describe('importing @nextcloud/viewer', () => { expect(Object.keys(entry).sort()).toEqual([ 'Viewer', + 'canCompare', 'canView', 'getHandlers', 'getViewer', diff --git a/__tests__/models.spec.ts b/__tests__/models.spec.ts index 1af0d03..1203313 100644 --- a/__tests__/models.spec.ts +++ b/__tests__/models.spec.ts @@ -55,6 +55,9 @@ describe('handler groups', () => { expect(handlerById('videos').group).toBe('media') expect(handlerById('audios').group).toBe('media') expect(handlerById('images').group).toBe('media') + // Two versions of a picture side by side show what changed + expect(handlerById('images').canCompare).toBe(true) + expect(handlerById('videos').canCompare).toBeUndefined() }) }) diff --git a/lib/handlers.ts b/lib/handlers.ts index f2bed6b..ce00bd0 100644 --- a/lib/handlers.ts +++ b/lib/handlers.ts @@ -74,6 +74,14 @@ export interface IHandler { */ canEdit?: boolean + /** + * Whether comparing two versions of a file side by side is worth offering + * for this handler's files. It is a hint for callers deciding whether to + * offer it at all (see `canCompare()`): `getViewer().compare()` shows any + * pair either way. + */ + canCompare?: boolean + /** * Whether this handler works with end-to-end encrypted files. * @@ -103,6 +111,24 @@ export function canView(nodes: INode | INode[]): boolean { return countEnabledHandlers(Array.isArray(nodes) ? nodes : [nodes], 1) } +/** + * Whether comparing two versions of a file is worth offering. + * + * For a caller deciding whether to show a "Compare" action, like the + * versions tab of the Files sidebar: the file has to be viewable, and the + * handler the viewer would open it with has to say so with `canCompare`. + * + * @param node - The file to compare versions of + */ +export function canCompare(node: INode): boolean { + if (!canView(node)) { + return false + } + // The one the viewer would show it with: the first to take it + const handler = [...getHandlers().values()].find((candidate) => isHandlerEnabled(candidate, [node as IFile])) + return handler?.canCompare === true +} + /** * Whether a handler accepts the given files. * diff --git a/lib/index.ts b/lib/index.ts index 8334a1a..cb6cdc7 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -26,7 +26,7 @@ registerImplementation({ // It is an empty shell until the viewer is mounted. getViewer() -export { canView, getHandlers, registerHandler } from './handlers.ts' +export { canCompare, canView, getHandlers, registerHandler } from './handlers.ts' export type { IHandler } from './handlers.ts' export { getViewer, Viewer } from './viewer.ts' export type { ViewerAPI, ViewerEmits, ViewerOptions, ViewerProps } from './viewer.ts' diff --git a/lib/models/images.ts b/lib/models/images.ts index e0f75fa..50dc417 100644 --- a/lib/models/images.ts +++ b/lib/models/images.ts @@ -103,6 +103,8 @@ export function registerImageHandler() { group: 'media', supportsEndToEndEncryption: true, canEdit: true, + // Side by side is how two versions of a picture show what changed + canCompare: true, enabled: (nodes) => { if (nodes.length === 0) {