From e120a288176ac05987496900d5604c87b90a1d34 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:57:59 +0100 Subject: [PATCH 1/3] fix: open files on double-click so the preview stays open, and cut long names in the middle A file's name in the list was a link that opened it on the first click, so the second click of a double-click landed on the preview's backdrop and closed it again. Names are plain text now in list and grid view: a click selects, a double-click or Enter opens, a tap opens on touch. A backdrop click in the first 400 ms after a preview opens is ignored. Grid tiles cut long names in the middle and keep the end and the extension (IMG_20250...1003.png), with the full name as the tooltip. Two tests change: the shared-folders test clicked a name to open it, and the image preview test closed it on a click right after opening; both now follow the new behaviour. --- .../file-browser-shared-folders.test.tsx | 13 ++-- .../__tests__/file-name-middle-cut.test.tsx | 62 +++++++++++++++++++ .../__tests__/image-preview-modal.test.tsx | 4 ++ components/files/file-item-parts.tsx | 28 +++++++++ components/files/file-preview-modal.tsx | 5 +- components/files/file-views.tsx | 43 ++++--------- components/files/image-preview-modal.tsx | 5 +- components/files/preview-shell.tsx | 24 ++++++- 8 files changed, 141 insertions(+), 43 deletions(-) create mode 100644 components/files/__tests__/file-name-middle-cut.test.tsx diff --git a/components/files/__tests__/file-browser-shared-folders.test.tsx b/components/files/__tests__/file-browser-shared-folders.test.tsx index a035a9368..d39f33a08 100644 --- a/components/files/__tests__/file-browser-shared-folders.test.tsx +++ b/components/files/__tests__/file-browser-shared-folders.test.tsx @@ -198,20 +198,23 @@ describe('FileBrowser', () => { expect(within(tree()).getByText('Documents')).toBeInTheDocument(); }); - it('opens a folder from its name and selects it from the row', () => { + // pim-3: the name is plain text, as in a file manager. A click selects, a + // double-click opens. + it('selects a folder from its name and opens it on a double-click', () => { const { onNavigate, onSetSelection } = renderBrowser(); - const row = within(list()).getByText('Documents').closest('[role=row]')!; - fireEvent.click(row); + fireEvent.click(within(list()).getByText('Documents')); expect(onSetSelection).toHaveBeenCalledWith(new Set(['Documents'])); expect(onNavigate).not.toHaveBeenCalled(); - fireEvent.click(within(list()).getByText('Documents')); + fireEvent.doubleClick(within(list()).getByText('Documents')); expect(onNavigate).toHaveBeenCalledWith('/Documents', 'f-docs'); }); - it('previews a text file when its name is clicked', () => { + it('previews a text file when its name is double-clicked', () => { const { onPreviewFile } = renderBrowser(); fireEvent.click(within(list()).getByText('notes.txt')); + expect(onPreviewFile).not.toHaveBeenCalled(); + fireEvent.doubleClick(within(list()).getByText('notes.txt')); expect(onPreviewFile).toHaveBeenCalledWith('notes.txt'); }); diff --git a/components/files/__tests__/file-name-middle-cut.test.tsx b/components/files/__tests__/file-name-middle-cut.test.tsx new file mode 100644 index 000000000..3cfe16210 --- /dev/null +++ b/components/files/__tests__/file-name-middle-cut.test.tsx @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { FileGridView } from '../file-views'; +import { splitNameForMiddleCut } from '../file-item-parts'; +import type { FileResource } from '@/stores/file-store'; + +// pim-21: long names in the grid are cut in the middle, so the extension and +// the part that tells similar names apart stay in view. + +afterEach(cleanup); + +describe('splitNameForMiddleCut', () => { + it('keeps the end of the name and the extension', () => { + expect(splitNameForMiddleCut('IMG_20250611_1003.png')).toEqual({ head: 'IMG_20250611_', tail: '1003.png' }); + expect(splitNameForMiddleCut('meeting-notes-2026-q3.md')).toEqual({ head: 'meeting-notes-202', tail: '6-q3.md' }); + expect(splitNameForMiddleCut('Großes Angebot – final (v3).txt')).toEqual({ head: 'Großes Angebot – final ', tail: '(v3).txt' }); + }); + + it('leaves short names whole', () => { + expect(splitNameForMiddleCut('a.txt')).toEqual({ head: 'a.txt', tail: '' }); + expect(splitNameForMiddleCut('readme.txt')).toEqual({ head: 'readme.txt', tail: '' }); + }); + + it('cuts names without an extension near the end too', () => { + expect(splitNameForMiddleCut('Quarterly reports archive')).toEqual({ head: 'Quarterly reports arc', tail: 'hive' }); + }); +}); + +describe('the grid', () => { + it('cuts the start of a long name, keeps its end, and has the whole name as title', () => { + const resource = { + id: 'i', name: 'IMG_20250611_1003.png', serverName: 'IMG_20250611_1003.png', isDirectory: false, contentType: 'image/png', + contentLength: 10, lastModified: '2026-10-01T00:00:00Z', blobId: 'b', parentId: null, + } as FileResource; + render( + '/'} + touch={false} + iconOptions={{ showIcons: true, coloredIcons: true, showThumbnails: false }} + getImageUrl={async () => ''} + handlers={{ + onSelectClick: vi.fn(), onToggleSelect: vi.fn(), onOpen: vi.fn(), onContextMenu: vi.fn(), onShare: vi.fn(), + canShare: () => false, dragProps: () => ({}), onFocusItem: vi.fn(), + }} + />, + ); + const name = screen.getByTitle('IMG_20250611_1003.png'); + const [head, tail] = Array.from(name.children) as HTMLElement[]; + expect(head.textContent).toBe('IMG_20250611_'); + expect(head.className).toContain('truncate'); + expect(tail.textContent).toBe('1003.png'); + expect(tail.className).toContain('shrink-0'); + expect(name.textContent).toBe('IMG_20250611_1003.png'); + }); +}); diff --git a/components/files/__tests__/image-preview-modal.test.tsx b/components/files/__tests__/image-preview-modal.test.tsx index cba97a9b5..927d6fb4c 100644 --- a/components/files/__tests__/image-preview-modal.test.tsx +++ b/components/files/__tests__/image-preview-modal.test.tsx @@ -24,7 +24,11 @@ function tap(el: Element, x = 10, y = 10) { describe('ImagePreviewModal', () => { it('closes when the area around the image is clicked', async () => { const { onClose, box } = await renderViewer(); + // Not in the first 400 ms: that is still the click that opened it (pim-3). + const later = Date.now() + 1000; + const now = vi.spyOn(Date, 'now').mockReturnValue(later); tap(box); + now.mockRestore(); expect(onClose).toHaveBeenCalledTimes(1); }); diff --git a/components/files/file-item-parts.tsx b/components/files/file-item-parts.tsx index 37a33b43e..a2364ca85 100644 --- a/components/files/file-item-parts.tsx +++ b/components/files/file-item-parts.tsx @@ -203,6 +203,34 @@ export function readDraggedNames(e: React.DragEvent): string[] { } } +/** + * A file name split for cutting in the middle: the start gives way, the end + * stays, so "IMG_20250611_1003.png" reads "IMG_20250…1003.png" and the + * extension and the part that tells similar names apart stay in view. Short + * names and folders' names (no extension) keep a shorter tail. + */ +export function splitNameForMiddleCut(name: string): { head: string; tail: string } { + const dot = name.lastIndexOf("."); + const ext = dot > 0 && name.length - dot <= 10 ? name.slice(dot) : ""; + const tailLength = Math.min(ext.length + 4, Math.floor(name.length / 2)); + if (name.length < 12 || tailLength <= 0) return { head: name, tail: "" }; + return { head: name.slice(0, name.length - tailLength), tail: name.slice(name.length - tailLength) }; +} + +/** + * A name cut in the middle when there is no room (pim-21). The whole name is + * the title; screen readers read it as one text. + */ +export function MiddleCutName({ name, title, className }: { name: string; title?: string; className?: string }) { + const { head, tail } = splitNameForMiddleCut(name); + return ( + + {head} + {tail && {tail}} + + ); +} + /** Open a shared context menu as a dropdown under a button. */ export function anchorEvent(el: HTMLElement, align: "start" | "end" = "start"): React.MouseEvent { const rect = el.getBoundingClientRect(); diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index 89d1a114c..59f681f03 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; import { ExternalLink } from "@/components/icons"; import { Spinner } from "@/components/ui/spinner"; -import { PreviewShell, type PreviewNeighbours } from "@/components/files/preview-shell"; +import { PreviewShell, useBackdropClose, type PreviewNeighbours } from "@/components/files/preview-shell"; import { IconButton } from "@/components/ui/icon-button"; import { ZoomableImage } from "@/components/files/zoomable-image"; import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview"; @@ -233,6 +233,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi // Back closes the preview instead of leaving Files. useCloseOnBack({ onBack: onClose }); + const closeFromBackdrop = useBackdropClose(onClose); const fileType = resolvedFileType; // The browser's own PDF viewer has Download and Print buttons of its own. @@ -412,7 +413,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi {!loading && !error && fileType === "image" && objectUrl && (
- +
)} diff --git a/components/files/file-views.tsx b/components/files/file-views.tsx index 972e8cfad..7087c3d62 100644 --- a/components/files/file-views.tsx +++ b/components/files/file-views.tsx @@ -7,7 +7,7 @@ import { cn } from "@/lib/utils"; import { IconButton } from "@/components/ui/icon-button"; import type { FileResource } from "@/stores/file-store"; import { - FileSize, ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate, + FileSize, MiddleCutName, ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate, type IconOptions, } from "@/components/files/file-item-parts"; import type { SortDir, SortKey } from "@/components/files/files-header"; @@ -262,21 +262,16 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget, ) : icon}
- + {isStarred && }
@@ -427,16 +422,9 @@ function FolderTile({ resource, isSelected, isFocused, isTabStop, isDropTarget, )} - + {isStarred && }
- +
{!resource.isSymlink && <> · }{formatShortDate(resource.lastModified)} diff --git a/components/files/image-preview-modal.tsx b/components/files/image-preview-modal.tsx index eb8510498..f5dc709d8 100644 --- a/components/files/image-preview-modal.tsx +++ b/components/files/image-preview-modal.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { Spinner } from "@/components/ui/spinner"; -import { PreviewShell, type PreviewNeighbours } from "@/components/files/preview-shell"; +import { PreviewShell, useBackdropClose, type PreviewNeighbours } from "@/components/files/preview-shell"; import { useTranslations } from "next-intl"; import { ZoomableImage } from "@/components/files/zoomable-image"; import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section"; @@ -35,6 +35,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file // Back closes the preview instead of leaving Files, as for every other preview. useCloseOnBack({ onBack: onClose }); + const closeFromBackdrop = useBackdropClose(onClose); useEffect(() => { let revoke: string | null = null; @@ -76,7 +77,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file
)} {imageUrl && !error && ( - + )} {imageUrl && !error && }
diff --git a/components/files/preview-shell.tsx b/components/files/preview-shell.tsx index e349033c6..d604d63de 100644 --- a/components/files/preview-shell.tsx +++ b/components/files/preview-shell.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslations } from "next-intl"; import { ChevronLeft, ChevronRight, Download, X } from "@/components/icons"; import { IconButton } from "@/components/ui/icon-button"; @@ -14,6 +14,25 @@ export interface PreviewNeighbours { onGo: (name: string) => void; } +/** A backdrop click this soon after the preview opened is part of the click that opened it. */ +export const BACKDROP_GRACE_MS = 400; + +/** + * `onClose` for a click on the area around the content. The rest of the + * click that opened the preview (a double-click's second click, a + * triple-click) is ignored, so it can't close the preview again (pim-3). + */ +export function useBackdropClose(onClose: () => void): () => void { + const openedAt = useRef(0); + useEffect(() => { + openedAt.current = Date.now(); + }, []); + return useCallback(() => { + if (Date.now() - openedAt.current < BACKDROP_GRACE_MS) return; + onClose(); + }, [onClose]); +} + interface PreviewShellProps { name: string; onClose: () => void; @@ -34,6 +53,7 @@ interface PreviewShellProps { */ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, closeOnBackdrop = true, children }: PreviewShellProps) { const t = useTranslations("files"); + const closeFromBackdrop = useBackdropClose(onClose); // Tab stays in the preview and the focus goes back to the file that was // opened when it closes. Escape is the listener below. const shellRef = useFocusTrap({ isActive: true, restoreFocus: true }); @@ -86,7 +106,7 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c aria-modal="true" aria-label={name} className="fixed inset-0 z-50 flex flex-col bg-black/80" - onClick={closeOnBackdrop ? onClose : undefined} + onClick={closeOnBackdrop ? closeFromBackdrop : undefined} > {/* Full-screen under `viewport-fit=cover`: the bar pads itself out of the iOS status bar (#936). The insets are physical, so pl/pr. */} From e5dc1dd247f87af1889a408784d6e6322e74fa3a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:01:25 +0100 Subject: [PATCH 2/3] feat: ask Replace, Keep both or Skip when an upload's name exists in Files An upload over a name that is taken in the target folder used to add "name (1)" without a word. Every upload path (picker, drag and drop, the drop overlay, folder upload) now asks per name, with "Do this for all n conflicts" when there are several: - Replace gives the existing node the new blob, so it keeps its id, shares and links; for a folder it merges into the existing one and replaces files of the same name. - Keep both numbers the new one, as before. - Skip leaves it out; Cancel stops the whole upload. Also in Files: - A file with no preview and no office editor opens a pane with its icon, size and Download instead of downloading at once, and in the desktop app also Open in default app (the attachment Edit path). The item menu gets Open for such files, and Open in default app in the desktop app. - Enter opens the row Tab put the focus on. - After sharing with someone new, a toast offers Send a message, which opens the composer to them with a link to the file. - The empty folder's buttons read Upload files... and New folder..., as the menus do. --- .../__tests__/ux-pim-files-dialogs.test.tsx | 123 ++++++++++ .../__tests__/ux-pim-open-files.test.tsx | 211 ++++++++++++++++++ components/files/file-browser.tsx | 39 +++- components/files/file-empty-state.tsx | 6 +- components/files/file-no-preview.tsx | 61 +++++ components/files/files-app.tsx | 108 ++++++++- components/files/share-message.ts | 15 ++ components/files/upload-conflict-dialog.tsx | 65 ++++++ lib/__tests__/files-upload-conflicts.test.ts | 47 ++++ lib/files-upload-conflicts.ts | 75 +++++++ locales/ar/common.json | 15 ++ locales/ca/common.json | 15 ++ locales/cs/common.json | 15 ++ locales/da/common.json | 15 ++ locales/de/common.json | 15 ++ locales/en/common.json | 15 ++ locales/es/common.json | 15 ++ locales/et/common.json | 15 ++ locales/fa/common.json | 15 ++ locales/fr/common.json | 15 ++ locales/he/common.json | 15 ++ locales/hu/common.json | 15 ++ locales/it/common.json | 15 ++ locales/ja/common.json | 15 ++ locales/ko/common.json | 15 ++ locales/lv/common.json | 15 ++ locales/mn/common.json | 15 ++ locales/nb/common.json | 15 ++ locales/nl/common.json | 15 ++ locales/pl/common.json | 15 ++ locales/pt/common.json | 15 ++ locales/ro/common.json | 15 ++ locales/ru/common.json | 15 ++ locales/sk/common.json | 15 ++ locales/th/common.json | 15 ++ locales/tr/common.json | 15 ++ locales/uk/common.json | 15 ++ locales/vi/common.json | 15 ++ locales/zh-TW/common.json | 15 ++ locales/zh/common.json | 15 ++ .../file-store-upload-conflicts.test.ts | 159 +++++++++++++ stores/file-store.ts | 122 +++++++++- 42 files changed, 1463 insertions(+), 18 deletions(-) create mode 100644 components/files/__tests__/ux-pim-files-dialogs.test.tsx create mode 100644 components/files/__tests__/ux-pim-open-files.test.tsx create mode 100644 components/files/file-no-preview.tsx create mode 100644 components/files/share-message.ts create mode 100644 components/files/upload-conflict-dialog.tsx create mode 100644 lib/__tests__/files-upload-conflicts.test.ts create mode 100644 lib/files-upload-conflicts.ts create mode 100644 stores/__tests__/file-store-upload-conflicts.test.ts diff --git a/components/files/__tests__/ux-pim-files-dialogs.test.tsx b/components/files/__tests__/ux-pim-files-dialogs.test.tsx new file mode 100644 index 000000000..25b9b3d0b --- /dev/null +++ b/components/files/__tests__/ux-pim-files-dialogs.test.tsx @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { UploadConflictDialog } from '../upload-conflict-dialog'; +import { FileNoPreview } from '../file-no-preview'; +import { EmptyFolder } from '../file-empty-state'; +import { FileBrowser } from '../file-browser'; +import { sharedNodeLink } from '../share-message'; +import { useFileStore, type FileResource } from '@/stores/file-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +// The share dialog itself isn't ours: a stand-in that adds and changes a share. +vi.mock('@/components/settings/share-collection-dialog', () => ({ + ShareCollectionDialog: ({ onShare }: { onShare: (principalId: string, rights: unknown) => Promise }) => ( +
+ + + +
+ ), +})); + +afterEach(cleanup); + +describe('the upload conflict question (pim-10)', () => { + const conflict = { name: 'report.txt', kind: 'file' as const }; + + it('offers Replace, Keep both, Skip and Cancel, and "for all" only with more conflicts left', () => { + const answer = vi.fn(); + const { rerender } = render(); + expect(screen.getByRole('alertdialog', { name: 'conflict_title' })).toBeInTheDocument(); + expect(screen.queryByRole('checkbox')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'keep_both' })); + expect(answer).toHaveBeenLastCalledWith({ choice: 'keep', applyToAll: false }); + + rerender(); + fireEvent.click(screen.getByRole('checkbox', { name: 'apply_to_all' })); + fireEvent.click(screen.getByRole('button', { name: 'replace' })); + expect(answer).toHaveBeenLastCalledWith({ choice: 'replace', applyToAll: true }); + fireEvent.click(screen.getByRole('button', { name: 'skip' })); + expect(answer).toHaveBeenLastCalledWith({ choice: 'skip', applyToAll: true }); + fireEvent.click(screen.getByRole('button', { name: 'cancel' })); + expect(answer).toHaveBeenLastCalledWith(null); + }); + + it('explains Replace for a folder as a merge', () => { + render(); + expect(screen.getByText('conflict_folder_hint')).toBeInTheDocument(); + }); +}); + +describe('the pane for a file with no preview (pim-16)', () => { + const budget = { + id: 'x', name: 'budget-2026.xlsx', serverName: 'budget-2026.xlsx', isDirectory: false, contentType: 'application/octet-stream', + contentLength: 1600, lastModified: '', blobId: 'b', parentId: null, + } as FileResource; + + it('shows the name, size and Download, and Open in default app only when the desktop app can', () => { + const onDownload = vi.fn(); + const onOpenInApp = vi.fn(); + const { rerender } = render(); + const pane = screen.getByTestId('file-no-preview'); + expect(pane).toHaveTextContent('budget-2026.xlsx'); + expect(pane).toHaveTextContent('1.6 KB'); + expect(screen.queryByRole('button', { name: 'open_in_app' })).toBeNull(); + fireEvent.click(screen.getAllByRole('button', { name: 'download' }).at(-1)!); + expect(onDownload).toHaveBeenCalled(); + + rerender(); + fireEvent.click(screen.getByRole('button', { name: 'open_in_app' })); + expect(onOpenInApp).toHaveBeenCalled(); + }); +}); + +describe('"Upload files…" ends in "…" (pim-26)', () => { + it('in an empty folder too', () => { + render(); + expect(screen.getByRole('button', { name: 'files_upload_files' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'files_new_folder' })).toBeInTheDocument(); + }); +}); + +describe('after sharing with someone new (pim-26)', () => { + it('reports who was added, not a change or a removal', async () => { + const report = { + id: 'r1', name: 'report.pdf', serverName: 'report.pdf', isDirectory: false, contentType: 'application/pdf', contentLength: 5, + lastModified: '', blobId: 'b', parentId: null, shareWith: { 'p-old': { mayRead: true } }, + } as unknown as FileResource; + const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient; + act(() => { + useFileStore.setState({ client, sharedRoots: [], allFolders: [], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true }); + }); + const onShare = vi.fn(async () => {}); + const onShared = vi.fn(); + const noop = vi.fn(async () => {}); + render( + ''} favorites={[]} recentFiles={[]} + onToggleFavorite={vi.fn()} onOpenRecent={vi.fn()} onRenameNode={noop} onDeleteNode={noop} onDownloadNode={noop} onRevealNode={noop} + onRemoveRecent={vi.fn()} client={client} ownAccountId="a" sharingEnabled onShare={onShare} onShared={onShared} + />, + ); + fireEvent.contextMenu(screen.getByTitle('report.pdf')); + fireEvent.click(screen.getByRole('menuitem', { name: 'files_share' })); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'change old' })); }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'remove' })); }); + expect(onShared).not.toHaveBeenCalled(); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'add new' })); }); + expect(onShare).toHaveBeenLastCalledWith('r1', 'p-new', { mayRead: true }); + expect(onShared).toHaveBeenCalledWith(expect.objectContaining({ id: 'r1' }), 'p-new'); + }); + + it('links the person to the node as it is listed for them, a file with its preview', () => { + expect(sharedNodeLink('https://mail.example', 'acc1', { id: 'n7', name: 'report.pdf', isDirectory: false })) + .toBe('https://mail.example/files?shared=acc1%3An7&preview=report.pdf'); + expect(sharedNodeLink('https://mail.example', 'acc1', { id: 'd2', name: 'Team', isDirectory: true })) + .toBe('https://mail.example/files?shared=acc1%3Ad2'); + }); +}); diff --git a/components/files/__tests__/ux-pim-open-files.test.tsx b/components/files/__tests__/ux-pim-open-files.test.tsx new file mode 100644 index 000000000..5c8181728 --- /dev/null +++ b/components/files/__tests__/ux-pim-open-files.test.tsx @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { FileGridView, FileListView, type FileItemHandlers } from '../file-views'; +import { FileBrowser } from '../file-browser'; +import { FilePreviewModal } from '../file-preview-modal'; +import { BACKDROP_GRACE_MS } from '../preview-shell'; +import { useFileStore, type FileResource } from '@/stores/file-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { DEFAULT_FILES_SETTINGS } from '@/lib/files-settings'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// pim-3: a double-click opens a file and the preview stays open; the name is +// plain text in both views (a click selects). pim-16: a file with no preview +// and no editor opens a pane with Download instead of downloading at once. + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/settings/share-collection-dialog', () => ({ ShareCollectionDialog: () => null })); +vi.mock('@/lib/standalone-display', () => ({ canOpenUrlInNewWindow: () => false })); + +const res = (id: string, name: string, extra: Partial = {}): FileResource => ({ + id, name, serverName: name, isDirectory: false, contentType: 'text/plain', contentLength: 10, + lastModified: '2026-10-01T00:00:00Z', blobId: `b-${id}`, parentId: null, ...extra, +} as FileResource); +const notes = res('n', 'notes.txt'); +const budget = res('x', 'budget.xlsx', { contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); +const docs = res('d', 'Documents', { isDirectory: true, contentType: '', blobId: null }); + +type Spy = ReturnType; +function handlers(): FileItemHandlers & { onOpen: Spy; onSelectClick: Spy } { + return { + onSelectClick: vi.fn(), + onToggleSelect: vi.fn(), + onOpen: vi.fn(), + onContextMenu: vi.fn(), + onDownload: vi.fn(), + onShare: vi.fn(), + canShare: () => false, + dragProps: () => ({}), + onFocusItem: vi.fn(), + } as never; +} + +const viewProps = (h: FileItemHandlers) => ({ + resources: [docs, notes], + selected: new Set(), + focusedName: null, + tabStopName: null, + dropTarget: null, + cutNames: new Set(), + starredPaths: new Set(), + pathOf: (r: FileResource) => `/${r.name}`, + touch: false, + iconOptions: { showIcons: true, coloredIcons: true, showThumbnails: false }, + getImageUrl: async () => '', + handlers: h, +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('a click on a name selects, a double-click opens (pim-3)', () => { + it('in the list', () => { + const h = handlers(); + render(); + for (const name of ['notes.txt', 'Documents']) { + const row = screen.getByTitle(name).closest('[role="row"]')!; + expect(within(row as HTMLElement).queryByRole('button', { name })).toBeNull(); + fireEvent.click(screen.getByTitle(name)); + expect(h.onOpen).not.toHaveBeenCalled(); + expect(h.onSelectClick).toHaveBeenLastCalledWith(expect.objectContaining({ name }), expect.anything()); + fireEvent.doubleClick(screen.getByTitle(name)); + expect(h.onOpen).toHaveBeenLastCalledWith(expect.objectContaining({ name })); + h.onOpen.mockClear(); + } + }); + + it('in the grid', () => { + const h = handlers(); + render(); + for (const name of ['notes.txt', 'Documents']) { + fireEvent.click(screen.getByTitle(name)); + expect(h.onOpen).not.toHaveBeenCalled(); + expect(h.onSelectClick).toHaveBeenLastCalledWith(expect.objectContaining({ name }), expect.anything()); + fireEvent.doubleClick(screen.getByTitle(name)); + expect(h.onOpen).toHaveBeenLastCalledWith(expect.objectContaining({ name })); + h.onOpen.mockClear(); + } + }); + + it('on touch a tap on the name still opens', () => { + const h = handlers(); + render(); + fireEvent.click(screen.getByTitle('notes.txt')); + expect(h.onOpen).toHaveBeenCalledWith(expect.objectContaining({ name: 'notes.txt' })); + }); +}); + +describe('the preview stays open after the click that opened it (pim-3)', () => { + it('ignores a backdrop click in the first 400 ms', () => { + let now = 1_000_000; + vi.spyOn(Date, 'now').mockImplementation(() => now); + const onClose = vi.fn(); + render( new Promise(() => {})} />); + const backdrop = screen.getByRole('dialog'); + now += BACKDROP_GRACE_MS - 50; + fireEvent.click(backdrop); + expect(onClose).not.toHaveBeenCalled(); + now += 100; + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); + +function renderBrowser(props: Partial> = {}) { + const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient; + act(() => { + useFileStore.setState({ client, sharedRoots: [], allFolders: [docs], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true }); + }); + const h = { + onPreviewFile: vi.fn(), + onPreviewImage: vi.fn(), + onDownload: vi.fn(async () => {}), + onShowFileInfo: vi.fn(), + onSetSelection: vi.fn(), + }; + const noop = vi.fn(async () => {}); + render( + ''} + favorites={[]} + recentFiles={[]} + onToggleFavorite={vi.fn()} + onOpenRecent={vi.fn()} + onRenameNode={noop} + onDeleteNode={noop} + onDownloadNode={noop} + onRevealNode={noop} + onRemoveRecent={vi.fn()} + client={client} + ownAccountId="a" + {...h} + {...props} + />, + ); + return h; +} + +describe('a file with no preview and no editor (pim-16)', () => { + beforeEach(() => { + localStorage.clear(); + act(() => { useSettingsStore.setState({ filesSettings: DEFAULT_FILES_SETTINGS }); }); + }); + + it('opens the pane instead of downloading', () => { + const h = renderBrowser(); + fireEvent.doubleClick(screen.getByTitle('budget.xlsx')); + expect(h.onShowFileInfo).toHaveBeenCalledWith('budget.xlsx'); + expect(h.onDownload).not.toHaveBeenCalled(); + fireEvent.doubleClick(screen.getByTitle('notes.txt')); + expect(h.onPreviewFile).toHaveBeenCalledWith('notes.txt'); + }); + + it('has Open in its menu, and in the desktop app Open in default app', () => { + const onOpenInApp = vi.fn(); + const h = renderBrowser({ canOpenInApp: (r) => r.name === 'budget.xlsx', onOpenInApp }); + fireEvent.contextMenu(screen.getByTitle('budget.xlsx')); + fireEvent.click(screen.getByRole('menuitem', { name: 'open' })); + expect(h.onShowFileInfo).toHaveBeenCalledWith('budget.xlsx'); + fireEvent.contextMenu(screen.getByTitle('budget.xlsx')); + fireEvent.click(screen.getByRole('menuitem', { name: 'open_in_app' })); + expect(onOpenInApp).toHaveBeenCalledWith(expect.objectContaining({ name: 'budget.xlsx' })); + // A text file previews: no extra Open there. + fireEvent.contextMenu(screen.getByTitle('notes.txt')); + expect(screen.queryByRole('menuitem', { name: 'open' })).toBeNull(); + }); + + it('Enter opens the row Tab put the focus on', () => { + const h = renderBrowser(); + const row = document.querySelector('[data-resource="budget.xlsx"]')!; + fireEvent.focus(row); + fireEvent.keyDown(row, { key: 'Enter' }); + expect(h.onShowFileInfo).toHaveBeenCalledWith('budget.xlsx'); + }); +}); diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 2b514c5ef..b0f00c24d 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"; import { Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info, Pin, PinOff, FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen, - RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput, + RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput, ExternalLink, } from "@/components/icons"; import { useIsFullDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query"; import { useContextMenu } from "@/hooks/use-context-menu"; @@ -138,6 +138,13 @@ interface FileBrowserProps { sharingEnabled?: boolean; /** Add/update/remove a principal's share on a node. Set null rights to revoke. */ onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise; + /** A file with no preview and no editor: shows its icon, size and Download (pim-16). Left out: it downloads. */ + onShowFileInfo?: (name: string) => void; + /** The desktop app can open this file in its program. */ + canOpenInApp?: (resource: FileResource) => boolean; + onOpenInApp?: (resource: FileResource) => void; + /** A principal was given access to a node they couldn't see before. */ + onShared?: (resource: FileResource, principalId: string) => void; } function isPreviewable(name: string): boolean { @@ -220,9 +227,14 @@ export function FileBrowser({ ownAccountId, sharingEnabled, onShare, + onShowFileInfo, + canOpenInApp, + onOpenInApp, + onShared, }: FileBrowserProps) { const t = useTranslations("files"); const tMenu = useTranslations("menuAudit"); + const tUx = useTranslations("ux_pim_files"); // Menu entries that open a dialog end in "…". const newFileLabel = newFileFormats.length > 0 ? tMenu("files_new_file") : tMenu("files_new_text_file"); const fileNameRules = useMemo(() => client?.getFileNameRules?.() ?? null, [client]); @@ -517,10 +529,12 @@ export function FileBrowser({ onPreviewImage(resource.name); } else if (isPreviewable(resource.name)) { onPreviewFile(resource.name); + } else if (onShowFileInfo) { + onShowFileInfo(resource.name); } else { void onDownload(resource.name); } - }, [onNavigate, pathOf, isOfficeEditable, onEditFile, onPreviewImage, onPreviewFile, onDownload]); + }, [onNavigate, pathOf, isOfficeEditable, onEditFile, onPreviewImage, onPreviewFile, onShowFileInfo, onDownload]); const previewResource = useCallback((resource: FileResource) => { if (isImageFile(resource.name)) onPreviewImage(resource.name); @@ -810,9 +824,14 @@ export function FileBrowser({ setRenameTarget(singleSelected.name); return; } - if (e.key === "Enter" && singleSelected) { + // Enter opens the selected item, or the one Tab put the focus on. + const enterTarget = singleSelected + ?? (selected.length === 0 && focusedName && (e.target as HTMLElement | null)?.dataset?.resource === focusedName + ? displayResources.find((r) => r.name === focusedName) ?? null + : null); + if (e.key === "Enter" && enterTarget) { e.preventDefault(); - openResource(singleSelected); + openResource(enterTarget); return; } if (e.key === " " && focusedName) { @@ -852,7 +871,7 @@ export function FileBrowser({ showNewFolder, showNewTextFile, renameTarget, renameFolder, shareTargetId, moveTarget, accountPickerMode, selectedList, singleSelected, inTrash, showDetails, isNarrow, toggleDetails, selectAllVisible, onCopy, onCut, onPaste, clipboard, searchQuery, onClearSelection, deleteNames, openResource, - focusedName, onToggleSelect, currentPath, navigateUp, viewMode, gridColumns, moveFocus, + focusedName, onToggleSelect, currentPath, navigateUp, viewMode, gridColumns, moveFocus, displayResources, ]); // ---- Context menu ---------------------------------------------------------- @@ -943,6 +962,13 @@ export function FileBrowser({ {isOfficeEditable?.(resource.name) && onEditFile && ( { closeMenu(); onEditFile(resource.name); }} /> )} + {/* Neither previewed nor edited here: Open shows what there is (pim-16). */} + {!isPreviewable(resource.name) && !(isOfficeEditable?.(resource.name) && onEditFile) && onShowFileInfo && ( + { closeMenu(); onShowFileInfo(resource.name); }} /> + )} + {canOpenInApp?.(resource) && onOpenInApp && ( + { closeMenu(); onOpenInApp(resource); }} /> + )} )} {canKeepOffline(resource) && ( @@ -1501,7 +1527,10 @@ export function FileBrowser({ shareWith={shareTarget.shareWith} ownAccountId={ownAccountId || ""} onShare={async (principalId, rights) => { + const added = !!rights && !shareTarget.shareWith?.[principalId]; await onShare(shareTarget.id, principalId, rights as FileNodeRights | null); + // Someone new can see it now: offer to tell them (pim-26). + if (added) onShared?.(shareTarget, principalId); }} onClose={() => setShareTargetId(null)} /> diff --git a/components/files/file-empty-state.tsx b/components/files/file-empty-state.tsx index 87ca6a714..270155f19 100644 --- a/components/files/file-empty-state.tsx +++ b/components/files/file-empty-state.tsx @@ -15,6 +15,8 @@ interface EmptyFolderProps { /** What an empty folder shows: what to do next, and that dropping files works. */ export function EmptyFolder({ isRoot, canCreate, onUpload, onCreateFolder }: EmptyFolderProps) { const t = useTranslations("files"); + // Both open a picker or a dialog, so they end in "…" as the menus' entries do. + const tMenu = useTranslations("menuAudit"); return ( ) : undefined} diff --git a/components/files/file-no-preview.tsx b/components/files/file-no-preview.tsx new file mode 100644 index 000000000..3b8f78b6f --- /dev/null +++ b/components/files/file-no-preview.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Download, ExternalLink } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { PreviewShell, type PreviewNeighbours } from "@/components/files/preview-shell"; +import { FileSize, ResourceIcon } from "@/components/files/file-item-parts"; +import { useCloseOnBack } from "@/hooks/use-close-on-back"; +import type { FileResource } from "@/stores/file-store"; + +const ICON_OPTIONS = { showIcons: true, coloredIcons: true }; + +/** + * A file the app can't show and no office editor opens (a spreadsheet with + * no Collabora or ONLYOFFICE set up, a zip): its icon, name and size, with + * Download and, in the desktop app, Open in the program the computer uses + * for it (pim-16). It used to download at once, with no word. + */ +export function FileNoPreview({ resource, onClose, onDownload, onOpenInApp, neighbours }: { + resource: FileResource; + onClose: () => void; + /** Left out when a device policy turned downloads off. */ + onDownload?: () => void; + /** Only in the desktop app, for a file it may open. */ + onOpenInApp?: () => void; + neighbours?: PreviewNeighbours; +}) { + const t = useTranslations("files"); + const tUx = useTranslations("ux_pim_files"); + useCloseOnBack({ onBack: onClose }); + return ( + +
+
e.stopPropagation()} + > + +

{resource.name}

+

+

{tUx("no_preview")}

+
+ {onOpenInApp && ( + + )} + {onDownload && ( + + )} +
+
+
+
+ ); +} diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index a4bdf9456..8b8786e1a 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -8,7 +8,7 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store'; import { useAccountStore } from "@/stores/account-store"; import { useEmailStore } from "@/stores/email-store"; -import { useFileStore, deletesToTrash, isSharedFilesPage, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store"; +import { useFileStore, deletesToTrash, isSharedFilesPage, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type FileResource, type UploadReport } from "@/stores/file-store"; import { hasQueuedFilesUpload, setFilesDropTarget, subscribeQueuedFilesUpload, takeQueuedFilesUpload } from "@/lib/file-drop/files-target"; import { isTrashLocation } from "@/lib/files-trash"; import { fileErrorKind, isPartialCopy } from "@/lib/file-errors"; @@ -29,6 +29,15 @@ import type { FileNodeRights } from "@/lib/jmap/types"; import { ImagePreviewModal } from "@/components/files/image-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { WopiEditor } from "@/components/files/wopi-editor"; +import { UploadConflictDialog, type UploadConflictQuestion } from "@/components/files/upload-conflict-dialog"; +import { FileNoPreview } from "@/components/files/file-no-preview"; +import { sharedNodeLink } from "@/components/files/share-message"; +import { loadPrincipalDirectory } from "@/lib/jmap/principal-directory"; +import { savePendingMailto } from "@/lib/protocol-handlers/session"; +import { formatRecipient } from "@/lib/email-composer-utils"; +import { useRouter } from "@/i18n/navigation"; +import { useEditAttachment } from "@/hooks/use-edit-attachment"; +import { resolveConflictsInTurn, type ConflictAnswer, type UploadConflictResolver } from "@/lib/files-upload-conflicts"; import { useWopiStatus, canWopiOpen, fileExtension } from "@/hooks/use-wopi-status"; import { apiFetch } from "@/lib/browser-navigation"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; @@ -141,6 +150,8 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const [previewImage, setPreviewImage] = useState(null); const [previewFile, setPreviewFile] = useState(null); + // A file with no preview and no editor: its icon, size and Download (pim-16). + const [infoFile, setInfoFile] = useState(null); // WOPI document editing (#425): name of the file open in the editor overlay. const [editFile, setEditFile] = useState(null); const wopiStatus = useWopiStatus(filesEnabled); @@ -380,6 +391,22 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const maxSizeUpload = client?.getMaxSizeUpload() || 0; + // A name that is taken in the target folder: asked one at a time (pim-10). + const [conflictQuestion, setConflictQuestion] = useState(null); + const resolveConflicts = useCallback((conflicts) => resolveConflictsInTurn( + conflicts, + (conflict, index, total) => new Promise((resolve) => { + setConflictQuestion({ + conflict, + remaining: total - index, + answer: (answer) => { + setConflictQuestion(null); + resolve(answer); + }, + }); + }), + ), []); + const handleUploadFiles = useCallback(async (files: File[]) => { if (maxSizeUpload > 0) { const oversized = files.filter(f => f.size > maxSizeUpload); @@ -390,12 +417,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } if (files.length === 0) return; try { - showUploadReport(await uploadFiles(files)); + showUploadReport(await uploadFiles(files, { resolveConflicts })); } catch (err) { console.error("Failed to upload files:", err); toast.error(t("upload_error"), failureReason(err)); } - }, [uploadFiles, t, maxSizeUpload, showUploadReport, failureReason]); + }, [uploadFiles, t, maxSizeUpload, showUploadReport, failureReason, resolveConflicts]); const handleUploadFolder = useCallback(async (files: File[]) => { if (maxSizeUpload > 0) { @@ -407,12 +434,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } if (files.length === 0) return; try { - showUploadReport(await uploadFolder(files)); + showUploadReport(await uploadFolder(files, { resolveConflicts })); } catch (err) { console.error("Failed to upload folder:", err); toast.error(t("upload_error"), failureReason(err)); } - }, [uploadFolder, t, maxSizeUpload, showUploadReport, failureReason]); + }, [uploadFolder, t, maxSizeUpload, showUploadReport, failureReason, resolveConflicts]); const handleUndo = useCallback(async () => { try { @@ -648,6 +675,27 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { addRecentFile(name, findResourceId(name)); }, [addRecentFile, findResourceId]); + const handleShowFileInfo = useCallback((name: string) => { + setInfoFile(name); + addRecentFile(name, findResourceId(name)); + }, [addRecentFile, findResourceId]); + + // The desktop app opens a file in the program the computer uses for it, + // the way Edit opens an attachment (pim-16). Nothing in a browser. + const filesLocalAccount = useFileStore((s) => s.currentAccountId) ?? activeAccountId ?? ""; + const { canEdit: canOpenNameInApp, edit: openInDesktopApp } = useEditAttachment(filesLocalAccount); + const canOpenInApp = useCallback((r: FileResource) => !r.isDirectory && !r.isSymlink && canOpenNameInApp(r.name), [canOpenNameInApp]); + const handleOpenInApp = useCallback((r: FileResource) => { + void openInDesktopApp({ + key: `files:${filesLocalAccount}:${r.id}`, + name: r.name, + type: r.contentType || "application/octet-stream", + account: filesLocalAccount, + source: { kind: "file" }, + load: async () => (await getFileContent(r.name)).blob, + }); + }, [openInDesktopApp, filesLocalAccount, getFileContent]); + const handleRenameNode = useCallback(async (id: string, newName: string) => { try { if (!await renameNode(id, newName)) return; @@ -719,6 +767,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { if (isOfficeEditable(name)) setEditFile(name); else if (isImageFile(name)) setPreviewImage(name); else if (isFilePreviewable(name)) setPreviewFile(name); + else setInfoFile(name); }, [revealNode, addRecentFile, isOfficeEditable, tDeepLink]); // The permalink for the folder on screen. In the Pro shell only the focused @@ -776,6 +825,36 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { await shareResource(id, principalId, rights); }, [shareResource]); + // After sharing with someone new: "Send a message" opens the composer to + // them with a link to it (pim-26). The server doesn't tell them. + const tUx = useTranslations("ux_pim_files"); + const router = useRouter(); + const handleShared = useCallback((resource: FileResource, principalId: string) => { + if (!storeClient || !filesAccountId) return; + void loadPrincipalDirectory(storeClient).then(({ principals }) => { + const person = principals.find((p) => p.id === principalId); + const email = person?.email?.trim(); + if (!person || !email) return; + const link = sharedNodeLink(window.location.origin, filesAccountId, resource); + toast.success(tUx("shared_with", { name: resource.name, person: person.name || email }), { + action: { + label: tUx("send_message"), + onClick: () => { + savePendingMailto({ + to: [formatRecipient(person.name && person.name !== email ? person.name : "", email)], + cc: [], + bcc: [], + subject: tUx("share_mail_subject", { name: resource.name }), + body: tUx("share_mail_body", { name: resource.name, link }), + inApp: true, + }); + router.push("/"); + }, + }, + }); + }); + }, [storeClient, filesAccountId, tUx, router]); + // Pro shell only: all connected accounts are equal top-level entries at // the root. The root path "/" itself is a cross-account picker - no // account's files are shown until the user enters one. @@ -949,6 +1028,10 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { ownAccountId={filesAccountId} sharingEnabled={sharingEnabled} onShare={handleShare} + onShowFileInfo={handleShowFileInfo} + canOpenInApp={canOpenInApp} + onOpenInApp={handleOpenInApp} + onShared={handleShared} /> )}
@@ -990,6 +1073,20 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { /> )} + {/* A file with no preview (pim-16) */} + {infoFile && (() => { + const resource = resources.find((r) => r.name === infoFile); + if (!resource) return null; + return ( + setInfoFile(null)} + onDownload={filesDownloadsOff ? undefined : () => void handleDownload(infoFile)} + onOpenInApp={canOpenInApp(resource) ? () => handleOpenInApp(resource) : undefined} + /> + ); + })()} + {/* WOPI document editor overlay (#425) */} {editFile && (() => { const editResource = resources.find(r => r.name === editFile); @@ -1028,6 +1125,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { + ); diff --git a/components/files/share-message.ts b/components/files/share-message.ts new file mode 100644 index 000000000..40873ddde --- /dev/null +++ b/components/files/share-message.ts @@ -0,0 +1,15 @@ +import { appPath, buildSharedFilesPath } from "@/lib/deep-links"; + +/** + * The link a person a file or folder was shared with opens it by. The node + * is theirs to see under ":" (the id a drive shared + * with them is listed by); a file opens in its preview there. + */ +export function sharedNodeLink( + origin: string, + ownerAccountId: string, + node: { id: string; name: string; isDirectory: boolean }, +): string { + const shared = `${ownerAccountId}:${node.id}`; + return `${origin}${appPath(buildSharedFilesPath(shared, node.isDirectory ? null : node.name))}`; +} diff --git a/components/files/upload-conflict-dialog.tsx b/components/files/upload-conflict-dialog.tsx new file mode 100644 index 000000000..21431d3ac --- /dev/null +++ b/components/files/upload-conflict-dialog.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import type { ConflictAnswer, UploadConflict, UploadConflictChoice } from "@/lib/files-upload-conflicts"; + +export interface UploadConflictQuestion { + conflict: UploadConflict; + /** How many conflicts are left, this one included. */ + remaining: number; + answer: (answer: ConflictAnswer) => void; +} + +/** + * " already exists." Replace, Keep both or Skip, and with more than + * one conflict left, "Do this for all n conflicts" (pim-10). Closing it + * (Escape, Cancel) calls off the whole upload. + */ +export function UploadConflictDialog({ question }: { question: UploadConflictQuestion | null }) { + const t = useTranslations("ux_pim_files"); + const tConfirm = useTranslations("confirm_dialog"); + const [applyToAll, setApplyToAll] = useState(false); + const checkboxId = useId(); + const replaceRef = useRef(null); + // Each question starts unticked. + useEffect(() => setApplyToAll(false), [question]); + if (!question) return null; + const { conflict, remaining, answer } = question; + const choose = (choice: UploadConflictChoice) => answer({ choice, applyToAll: applyToAll && remaining > 1 }); + return ( + answer(null)} + role="alertdialog" + title={t("conflict_title", { name: conflict.name })} + description={conflict.kind === "folder" ? t("conflict_folder_hint") : t("conflict_file_hint")} + showClose={false} + initialFocus={replaceRef} + testId="upload-conflict-dialog" + footer={ + <> + + + + + + } + > + {remaining > 1 && ( + + )} + + ); +} diff --git a/lib/__tests__/files-upload-conflicts.test.ts b/lib/__tests__/files-upload-conflicts.test.ts new file mode 100644 index 000000000..2aae678de --- /dev/null +++ b/lib/__tests__/files-upload-conflicts.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; +import { findUploadConflicts, resolveConflictsInTurn, type UploadConflict } from '@/lib/files-upload-conflicts'; + +describe('findUploadConflicts', () => { + it('lists same-kind clashes once, in upload order', () => { + const incoming: UploadConflict[] = [ + { name: 'b.txt', kind: 'file' }, + { name: 'Docs', kind: 'folder' }, + { name: 'Docs', kind: 'folder' }, + { name: 'Photos', kind: 'folder' }, + { name: 'new.txt', kind: 'file' }, + ]; + const existing = [ + { name: 'Docs', isDirectory: true }, + { name: 'b.txt', isDirectory: false }, + { name: 'Photos', isDirectory: false }, + ]; + expect(findUploadConflicts(incoming, existing)).toEqual([ + { name: 'b.txt', kind: 'file' }, + { name: 'Docs', kind: 'folder' }, + ]); + }); +}); + +describe('resolveConflictsInTurn', () => { + const conflicts: UploadConflict[] = [ + { name: 'a.txt', kind: 'file' }, + { name: 'b.txt', kind: 'file' }, + { name: 'c.txt', kind: 'file' }, + ]; + + it('asks one at a time, and "Do this for all" answers the rest', async () => { + const ask = vi.fn() + .mockResolvedValueOnce({ choice: 'skip', applyToAll: false }) + .mockResolvedValueOnce({ choice: 'replace', applyToAll: true }); + const choices = await resolveConflictsInTurn(conflicts, ask); + expect(ask).toHaveBeenCalledTimes(2); + expect(ask.mock.calls.map((c) => [c[0].name, c[1], c[2]])).toEqual([['a.txt', 0, 3], ['b.txt', 1, 3]]); + expect([...choices!]).toEqual([['a.txt', 'skip'], ['b.txt', 'replace'], ['c.txt', 'replace']]); + }); + + it('stops when a question is called off', async () => { + const ask = vi.fn().mockResolvedValueOnce({ choice: 'keep', applyToAll: false }).mockResolvedValueOnce(null); + expect(await resolveConflictsInTurn(conflicts, ask)).toBeNull(); + expect(ask).toHaveBeenCalledTimes(2); + }); +}); diff --git a/lib/files-upload-conflicts.ts b/lib/files-upload-conflicts.ts new file mode 100644 index 000000000..829d135bc --- /dev/null +++ b/lib/files-upload-conflicts.ts @@ -0,0 +1,75 @@ +/* + * Uploading something whose name is already taken in the target folder + * (pim-10). The upload asks first, per name: Replace, Keep both or Skip. + * + * - A file over a file: Replace gives the existing node the new content, so + * it keeps its id, its shares and its links. Keep both adds "name (1).ext". + * - A folder over a folder (folder upload): Replace puts the folder's content + * into the existing folder and replaces files of the same name in it, as a + * file manager's "merge" does. Keep both makes "Folder (1)". + * - A file and a folder of the same name can't replace each other: the new + * one is kept beside it with a number, without asking, as before. + */ + +export type UploadConflictKind = 'file' | 'folder'; + +export interface UploadConflict { + /** The name in the target folder: a file's name, or a folder upload's top folder. */ + name: string; + kind: UploadConflictKind; +} + +export type UploadConflictChoice = 'replace' | 'keep' | 'skip'; + +/** + * Asks about the conflicts, in order. Resolves to a choice per name, or to + * null when the user called the whole upload off. + */ +export type UploadConflictResolver = (conflicts: UploadConflict[]) => Promise | null>; + +export interface ExistingEntry { + name: string; + isDirectory: boolean; +} + +/** + * What of `incoming` clashes with `existing`, in the order of `incoming`. + * Only same-kind clashes are conflicts; each name is listed once. + */ +export function findUploadConflicts(incoming: UploadConflict[], existing: ExistingEntry[]): UploadConflict[] { + const files = new Set(existing.filter((e) => !e.isDirectory).map((e) => e.name)); + const folders = new Set(existing.filter((e) => e.isDirectory).map((e) => e.name)); + const seen = new Set(); + const conflicts: UploadConflict[] = []; + for (const entry of incoming) { + const key = `${entry.kind}:${entry.name}`; + if (seen.has(key)) continue; + seen.add(key); + if ((entry.kind === 'file' ? files : folders).has(entry.name)) conflicts.push(entry); + } + return conflicts; +} + +/** A conflict's answer: the choice, whether it covers the rest, or stop. */ +export type ConflictAnswer = { choice: UploadConflictChoice; applyToAll: boolean } | null; + +/** + * Goes through the conflicts one question at a time; "Do this for all" + * answers the rest with the same choice. Null when a question was called off. + */ +export async function resolveConflictsInTurn( + conflicts: UploadConflict[], + ask: (conflict: UploadConflict, index: number, total: number) => Promise, +): Promise | null> { + const choices = new Map(); + for (let i = 0; i < conflicts.length; i++) { + const answer = await ask(conflicts[i], i, conflicts.length); + if (!answer) return null; + if (answer.applyToAll) { + for (const rest of conflicts.slice(i)) choices.set(rest.name, answer.choice); + break; + } + choices.set(conflicts[i].name, answer.choice); + } + return choices; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index f8800991e..b3fb51f67 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5583,6 +5583,21 @@ "next_file": "الملف التالي", "loading": "جارٍ التحميل…" }, + "ux_pim_files": { + "conflict_title": "{name} موجود بالفعل.", + "conflict_file_hint": "الاستبدال يضع المحتوى الجديد في الملف الموجود، فتبقى مشاركاته. الاحتفاظ بالاثنين يضيف نسخة برقم.", + "conflict_folder_hint": "الاستبدال يضيف الملفات إلى المجلد الموجود ويستبدل الملفات التي تحمل الاسم نفسه. الاحتفاظ بالاثنين ينشئ مجلدًا ثانيًا برقم.", + "replace": "استبدال", + "keep_both": "الاحتفاظ بالاثنين", + "skip": "تخطي", + "apply_to_all": "{count, plural, other {تطبيق ذلك على كل التعارضات ({count})}}", + "no_preview": "لا توجد معاينة لهذا النوع من الملفات.", + "open_in_app": "فتح في التطبيق الافتراضي", + "shared_with": "أصبح {name} مشتركًا مع {person}.", + "send_message": "إرسال رسالة", + "share_mail_subject": "شاركت {name} معك", + "share_mail_body": "مرحبًا،\n\nشاركت «{name}» معك. يمكنك فتحه من هنا:\n{link}" + }, "smime": { "your_certificates": "شهاداتك", "your_certificates_desc": "استيراد وإدارة شهادات S/MIME الخاصة بك لتوقيع الرسائل وتشفيرها", diff --git a/locales/ca/common.json b/locales/ca/common.json index 62c75d062..7803322a0 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5583,6 +5583,21 @@ "next_file": "Fitxer següent", "loading": "S'està carregant…" }, + "ux_pim_files": { + "conflict_title": "{name} ja existeix.", + "conflict_file_hint": "Substitueix posa el contingut nou al fitxer existent, i se’n mantenen les comparticions. Conserva’ls tots dos afegeix una còpia amb un número.", + "conflict_folder_hint": "Substitueix afegeix els fitxers a la carpeta existent i substitueix els que tenen el mateix nom. Conserva’ls tots dos crea una segona carpeta amb un número.", + "replace": "Substitueix", + "keep_both": "Conserva’ls tots dos", + "skip": "Omet", + "apply_to_all": "{count, plural, one {Fes el mateix amb el conflicte} other {Fes el mateix amb els # conflictes}}", + "no_preview": "No hi ha previsualització per a aquest tipus de fitxer.", + "open_in_app": "Obre amb l’aplicació predeterminada", + "shared_with": "{name} ara està compartit amb {person}.", + "send_message": "Envia un missatge", + "share_mail_subject": "He compartit {name} amb tu", + "share_mail_body": "Hola,\n\nHe compartit «{name}» amb tu. El pots obrir aquí:\n{link}" + }, "smime": { "your_certificates": "Els vostres certificats", "your_certificates_desc": "Importeu i gestioneu els certificats S/MIME per signar i xifrar correus", diff --git a/locales/cs/common.json b/locales/cs/common.json index cd765731a..2ea4b0f87 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5408,6 +5408,21 @@ "next_file": "Další soubor", "loading": "Načítání…" }, + "ux_pim_files": { + "conflict_title": "{name} už existuje.", + "conflict_file_hint": "Nahradit vloží nový obsah do existujícího souboru, takže jeho sdílení zůstane. Ponechat obojí přidá kopii s číslem.", + "conflict_folder_hint": "Nahradit přidá soubory do existující složky a nahradí soubory se stejným názvem. Ponechat obojí vytvoří druhou složku s číslem.", + "replace": "Nahradit", + "keep_both": "Ponechat obojí", + "skip": "Přeskočit", + "apply_to_all": "{count, plural, other {Udělat totéž u všech konfliktů ({count})}}", + "no_preview": "Pro tento typ souboru není náhled.", + "open_in_app": "Otevřít ve výchozí aplikaci", + "shared_with": "{name} je teď sdílen s {person}.", + "send_message": "Poslat zprávu", + "share_mail_subject": "Sdílel jsem s vámi {name}", + "share_mail_body": "Dobrý den,\n\nsdílel jsem s vámi „{name}“. Otevřete to zde:\n{link}" + }, "smime": { "your_certificates": "Vaše certifikáty", "your_certificates_desc": "Importujte a spravujte své certifikáty S/MIME pro podepisování a šifrování e-mailů", diff --git a/locales/da/common.json b/locales/da/common.json index 68c3ace47..aaa302720 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5583,6 +5583,21 @@ "next_file": "Næste fil", "loading": "Indlæser…" }, + "ux_pim_files": { + "conflict_title": "{name} findes allerede.", + "conflict_file_hint": "Erstat lægger det nye indhold i den eksisterende fil, så dens delinger bliver. Behold begge tilføjer en kopi med et nummer.", + "conflict_folder_hint": "Erstat lægger filerne i den eksisterende mappe og erstatter filer med samme navn. Behold begge opretter en ekstra mappe med et nummer.", + "replace": "Erstat", + "keep_both": "Behold begge", + "skip": "Spring over", + "apply_to_all": "{count, plural, one {Gør det samme for konflikten} other {Gør det samme for alle # konflikter}}", + "no_preview": "Der er ingen forhåndsvisning af denne filtype.", + "open_in_app": "Åbn i standardprogram", + "shared_with": "{name} er nu delt med {person}.", + "send_message": "Send en besked", + "share_mail_subject": "Jeg har delt {name} med dig", + "share_mail_body": "Hej\n\nJeg har delt \"{name}\" med dig. Du kan åbne den her:\n{link}" + }, "smime": { "your_certificates": "Dine certifikater", "your_certificates_desc": "Importér og administrér dine S/MIME-certifikater til signering og kryptering af e-mails", diff --git a/locales/de/common.json b/locales/de/common.json index 6acbe0328..af2ad75e4 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5408,6 +5408,21 @@ "next_file": "Nächste Datei", "loading": "Wird geladen…" }, + "ux_pim_files": { + "conflict_title": "{name} ist schon vorhanden.", + "conflict_file_hint": "Ersetzen legt den neuen Inhalt in die vorhandene Datei, ihre Freigaben bleiben. Beide behalten legt eine Kopie mit einer Nummer an.", + "conflict_folder_hint": "Ersetzen legt die Dateien in den vorhandenen Ordner und ersetzt Dateien mit gleichem Namen. Beide behalten legt einen zweiten Ordner mit einer Nummer an.", + "replace": "Ersetzen", + "keep_both": "Beide behalten", + "skip": "Überspringen", + "apply_to_all": "{count, plural, other {Für alle # Konflikte so machen}}", + "no_preview": "Für diese Art von Datei gibt es keine Vorschau.", + "open_in_app": "Im Standardprogramm öffnen", + "shared_with": "{name} ist jetzt für {person} freigegeben.", + "send_message": "Nachricht senden", + "share_mail_subject": "Ich habe {name} für Sie freigegeben", + "share_mail_body": "Hallo,\n\nich habe „{name}“ für Sie freigegeben. Sie können es hier öffnen:\n{link}" + }, "smime": { "your_certificates": "Ihre Zertifikate", "your_certificates_desc": "Importieren und verwalten Sie Ihre S/MIME-Zertifikate zum Signieren und Verschlüsseln von E-Mails", diff --git a/locales/en/common.json b/locales/en/common.json index 9fe28226c..4708a54ed 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5583,6 +5583,21 @@ "next_file": "Next file", "loading": "Loading…" }, + "ux_pim_files": { + "conflict_title": "{name} already exists.", + "conflict_file_hint": "Replace puts the new content into the existing file, so its shares stay. Keep both adds a copy with a number.", + "conflict_folder_hint": "Replace adds the files to the existing folder and replaces files with the same name. Keep both makes a second folder with a number.", + "replace": "Replace", + "keep_both": "Keep both", + "skip": "Skip", + "apply_to_all": "{count, plural, other {Do this for all # conflicts}}", + "no_preview": "There is no preview for this type of file.", + "open_in_app": "Open in default app", + "shared_with": "{name} is now shared with {person}.", + "send_message": "Send a message", + "share_mail_subject": "I shared {name} with you", + "share_mail_body": "Hi,\n\nI shared \"{name}\" with you. You can open it here:\n{link}" + }, "smime": { "your_certificates": "Your Certificates", "your_certificates_desc": "Import and manage your S/MIME certificates for signing and encrypting emails", diff --git a/locales/es/common.json b/locales/es/common.json index e1f5a5ebe..ea526ec98 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5408,6 +5408,21 @@ "next_file": "Archivo siguiente", "loading": "Cargando…" }, + "ux_pim_files": { + "conflict_title": "{name} ya existe.", + "conflict_file_hint": "Reemplazar pone el contenido nuevo en el archivo existente, así se mantienen sus permisos. Conservar ambos añade una copia con un número.", + "conflict_folder_hint": "Reemplazar añade los archivos a la carpeta existente y reemplaza los que tienen el mismo nombre. Conservar ambos crea una segunda carpeta con un número.", + "replace": "Reemplazar", + "keep_both": "Conservar ambos", + "skip": "Omitir", + "apply_to_all": "{count, plural, one {Hacer lo mismo con el conflicto} other {Hacer lo mismo con los # conflictos}}", + "no_preview": "No hay vista previa para este tipo de archivo.", + "open_in_app": "Abrir en la aplicación predeterminada", + "shared_with": "{name} ahora está compartido con {person}.", + "send_message": "Enviar un mensaje", + "share_mail_subject": "He compartido {name} contigo", + "share_mail_body": "Hola:\n\nHe compartido «{name}» contigo. Puedes abrirlo aquí:\n{link}" + }, "smime": { "your_certificates": "Tus certificados", "your_certificates_desc": "Importa y administra tus certificados S/MIME para firmar y cifrar correos", diff --git a/locales/et/common.json b/locales/et/common.json index f77713d19..b555a307e 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5583,6 +5583,21 @@ "next_file": "Järgmine fail", "loading": "Laadimine…" }, + "ux_pim_files": { + "conflict_title": "{name} on juba olemas.", + "conflict_file_hint": "Asenda paneb uue sisu olemasolevasse faili, nii jäävad selle jagamised alles. Jäta mõlemad lisab nummerdatud koopia.", + "conflict_folder_hint": "Asenda lisab failid olemasolevasse kausta ja asendab sama nimega failid. Jäta mõlemad loob teise, nummerdatud kausta.", + "replace": "Asenda", + "keep_both": "Jäta mõlemad", + "skip": "Jäta vahele", + "apply_to_all": "{count, plural, one {Tee sama konfliktiga} other {Tee sama kõigi # konfliktiga}}", + "no_preview": "Seda tüüpi failil pole eelvaadet.", + "open_in_app": "Ava vaikerakenduses", + "shared_with": "{name} on nüüd jagatud kasutajaga {person}.", + "send_message": "Saada sõnum", + "share_mail_subject": "Jagasin sinuga faili {name}", + "share_mail_body": "Tere\n\nJagasin sinuga „{name}“. Saad selle avada siin:\n{link}" + }, "smime": { "your_certificates": "Sinu sertifikaadid", "your_certificates_desc": "Impordi ja halda oma S/MIME sertifikaate e-kirjade allkirjastamiseks ja krüpteerimiseks", diff --git a/locales/fa/common.json b/locales/fa/common.json index 36f5febbd..c209013ac 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5583,6 +5583,21 @@ "next_file": "فایل بعدی", "loading": "در حال بارگیری…" }, + "ux_pim_files": { + "conflict_title": "{name} از قبل وجود دارد.", + "conflict_file_hint": "جایگزینی محتوای تازه را در همان فایل موجود می‌گذارد و اشتراک‌هایش می‌مانند. نگه‌داشتن هر دو یک رونوشت شماره‌دار می‌سازد.", + "conflict_folder_hint": "جایگزینی فایل‌ها را به پوشهٔ موجود اضافه می‌کند و فایل‌های هم‌نام را جایگزین می‌کند. نگه‌داشتن هر دو پوشهٔ دومی با شماره می‌سازد.", + "replace": "جایگزینی", + "keep_both": "نگه‌داشتن هر دو", + "skip": "رد شدن", + "apply_to_all": "{count, plural, other {همین کار برای همهٔ # تداخل}}", + "no_preview": "برای این نوع فایل پیش‌نمایشی وجود ندارد.", + "open_in_app": "باز کردن در برنامهٔ پیش‌فرض", + "shared_with": "{name} اکنون با {person} به اشتراک گذاشته شد.", + "send_message": "ارسال پیام", + "share_mail_subject": "{name} را با شما به اشتراک گذاشتم", + "share_mail_body": "سلام،\n\n«{name}» را با شما به اشتراک گذاشتم. می‌توانید آن را اینجا باز کنید:\n{link}" + }, "smime": { "your_certificates": "گواهی‌های شما", "your_certificates_desc": "مدیریت گواهی‌های S/MIME", diff --git a/locales/fr/common.json b/locales/fr/common.json index 96ad6b352..276a9fadb 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5408,6 +5408,21 @@ "next_file": "Fichier suivant", "loading": "Chargement…" }, + "ux_pim_files": { + "conflict_title": "{name} existe déjà.", + "conflict_file_hint": "Remplacer met le nouveau contenu dans le fichier existant, ses partages restent. Conserver les deux ajoute une copie numérotée.", + "conflict_folder_hint": "Remplacer ajoute les fichiers au dossier existant et remplace ceux qui portent le même nom. Conserver les deux crée un second dossier numéroté.", + "replace": "Remplacer", + "keep_both": "Conserver les deux", + "skip": "Ignorer", + "apply_to_all": "{count, plural, one {Faire de même pour le conflit} other {Faire de même pour les # conflits}}", + "no_preview": "Aucun aperçu pour ce type de fichier.", + "open_in_app": "Ouvrir dans l’application par défaut", + "shared_with": "{name} est maintenant partagé avec {person}.", + "send_message": "Envoyer un message", + "share_mail_subject": "J’ai partagé {name} avec vous", + "share_mail_body": "Bonjour,\n\nJ’ai partagé « {name} » avec vous. Vous pouvez l’ouvrir ici :\n{link}" + }, "smime": { "your_certificates": "Vos certificats", "your_certificates_desc": "Importez et gérez vos certificats S/MIME pour signer et chiffrer les e-mails", diff --git a/locales/he/common.json b/locales/he/common.json index 8190e738d..2ecb3fa53 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5301,6 +5301,21 @@ "next_file": "הקובץ הבא", "loading": "טוען…" }, + "ux_pim_files": { + "conflict_title": "{name} כבר קיים.", + "conflict_file_hint": "החלפה שמה את התוכן החדש בקובץ הקיים, כך שהשיתופים שלו נשמרים. שמירת שניהם מוסיפה עותק עם מספר.", + "conflict_folder_hint": "החלפה מוסיפה את הקבצים לתיקייה הקיימת ומחליפה קבצים עם אותו שם. שמירת שניהם יוצרת תיקייה שנייה עם מספר.", + "replace": "החלפה", + "keep_both": "שמירת שניהם", + "skip": "דילוג", + "apply_to_all": "{count, plural, other {לעשות כך לכל # ההתנגשויות}}", + "no_preview": "אין תצוגה מקדימה לסוג קובץ זה.", + "open_in_app": "פתיחה ביישום ברירת המחדל", + "shared_with": "{name} משותף עכשיו עם {person}.", + "send_message": "שליחת הודעה", + "share_mail_subject": "שיתפתי איתך את {name}", + "share_mail_body": "שלום,\n\nשיתפתי איתך את \"{name}\". אפשר לפתוח אותו כאן:\n{link}" + }, "smime": { "your_certificates": "התעודות שלך", "your_certificates_desc": "ייבא ונהל את אישורי ה-S/MIME שלך עבור חתימה והצפנה של הודעות דוא\"ל", diff --git a/locales/hu/common.json b/locales/hu/common.json index 9d7abd079..88215afca 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5583,6 +5583,21 @@ "next_file": "Következő fájl", "loading": "Betöltés…" }, + "ux_pim_files": { + "conflict_title": "{name} már létezik.", + "conflict_file_hint": "A Csere az új tartalmat a meglévő fájlba teszi, így a megosztásai megmaradnak. A Mindkettő megtartása számozott másolatot ad hozzá.", + "conflict_folder_hint": "A Csere a fájlokat a meglévő mappába teszi, és lecseréli az azonos nevű fájlokat. A Mindkettő megtartása egy második, számozott mappát hoz létre.", + "replace": "Csere", + "keep_both": "Mindkettő megtartása", + "skip": "Kihagyás", + "apply_to_all": "{count, plural, other {Ugyanez mind a(z) # ütközésnél}}", + "no_preview": "Ehhez a fájltípushoz nincs előnézet.", + "open_in_app": "Megnyitás az alapértelmezett alkalmazásban", + "shared_with": "{name} most már meg van osztva vele: {person}.", + "send_message": "Üzenet küldése", + "share_mail_subject": "Megosztottam veled: {name}", + "share_mail_body": "Szia!\n\nMegosztottam veled ezt: „{name}”. Itt tudod megnyitni:\n{link}" + }, "smime": { "your_certificates": "Tanúsítványaid", "your_certificates_desc": "S/MIME tanúsítványok importálása és kezelése e-mailek aláírásához és titkosításához", diff --git a/locales/it/common.json b/locales/it/common.json index c230dd475..021ccd3bf 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5408,6 +5408,21 @@ "next_file": "File successivo", "loading": "Caricamento…" }, + "ux_pim_files": { + "conflict_title": "{name} esiste già.", + "conflict_file_hint": "Sostituisci mette il nuovo contenuto nel file esistente, così le sue condivisioni restano. Mantieni entrambi aggiunge una copia numerata.", + "conflict_folder_hint": "Sostituisci aggiunge i file alla cartella esistente e sostituisce quelli con lo stesso nome. Mantieni entrambi crea una seconda cartella numerata.", + "replace": "Sostituisci", + "keep_both": "Mantieni entrambi", + "skip": "Salta", + "apply_to_all": "{count, plural, one {Fai lo stesso per il conflitto} other {Fai lo stesso per tutti i # conflitti}}", + "no_preview": "Nessuna anteprima per questo tipo di file.", + "open_in_app": "Apri con l’app predefinita", + "shared_with": "{name} ora è condiviso con {person}.", + "send_message": "Invia un messaggio", + "share_mail_subject": "Ho condiviso {name} con te", + "share_mail_body": "Ciao,\n\nho condiviso «{name}» con te. Puoi aprirlo qui:\n{link}" + }, "smime": { "your_certificates": "I tuoi certificati", "your_certificates_desc": "Importa e gestisci i tuoi certificati S/MIME per firmare e cifrare le email", diff --git a/locales/ja/common.json b/locales/ja/common.json index 859b086d6..1fe891326 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5408,6 +5408,21 @@ "next_file": "次のファイル", "loading": "読み込み中…" }, + "ux_pim_files": { + "conflict_title": "{name} は既に存在します。", + "conflict_file_hint": "置き換えると、既存のファイルに新しい内容が入り、共有設定はそのまま残ります。両方残すと、番号付きのコピーが追加されます。", + "conflict_folder_hint": "置き換えると、既存のフォルダーにファイルが追加され、同じ名前のファイルは置き換えられます。両方残すと、番号付きの別フォルダーが作られます。", + "replace": "置き換える", + "keep_both": "両方残す", + "skip": "スキップ", + "apply_to_all": "{count, plural, other {# 件すべての競合に適用}}", + "no_preview": "この種類のファイルはプレビューできません。", + "open_in_app": "既定のアプリで開く", + "shared_with": "{name} を {person} と共有しました。", + "send_message": "メッセージを送信", + "share_mail_subject": "{name} を共有しました", + "share_mail_body": "こんにちは。\n\n「{name}」を共有しました。こちらから開けます:\n{link}" + }, "smime": { "your_certificates": "あなたの証明書", "your_certificates_desc": "メールの署名と暗号化のために S/MIME 証明書をインポートして管理します", diff --git a/locales/ko/common.json b/locales/ko/common.json index b26122750..519a7d845 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5408,6 +5408,21 @@ "next_file": "다음 파일", "loading": "불러오는 중…" }, + "ux_pim_files": { + "conflict_title": "{name}이(가) 이미 있습니다.", + "conflict_file_hint": "바꾸기를 하면 기존 파일에 새 내용이 들어가고 공유 설정은 그대로 유지됩니다. 둘 다 유지하면 번호가 붙은 사본이 추가됩니다.", + "conflict_folder_hint": "바꾸기를 하면 기존 폴더에 파일이 추가되고 이름이 같은 파일은 바뀝니다. 둘 다 유지하면 번호가 붙은 두 번째 폴더가 만들어집니다.", + "replace": "바꾸기", + "keep_both": "둘 다 유지", + "skip": "건너뛰기", + "apply_to_all": "{count, plural, other {충돌 #개 모두에 적용}}", + "no_preview": "이 유형의 파일은 미리 볼 수 없습니다.", + "open_in_app": "기본 앱에서 열기", + "shared_with": "{name}을(를) 이제 {person}님과 공유합니다.", + "send_message": "메시지 보내기", + "share_mail_subject": "{name}을(를) 공유했습니다", + "share_mail_body": "안녕하세요.\n\n\"{name}\"을(를) 공유했습니다. 여기에서 열 수 있습니다:\n{link}" + }, "smime": { "your_certificates": "내 인증서", "your_certificates_desc": "이메일 서명과 암호화를 위한 S/MIME 인증서를 가져오고 관리해요", diff --git a/locales/lv/common.json b/locales/lv/common.json index 6474a45eb..3f2d468e3 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5408,6 +5408,21 @@ "next_file": "Nākamais fails", "loading": "Ielādē…" }, + "ux_pim_files": { + "conflict_title": "{name} jau pastāv.", + "conflict_file_hint": "Aizstāt ieliek jauno saturu esošajā failā, tāpēc tā koplietošana paliek. Paturēt abus pievieno numurētu kopiju.", + "conflict_folder_hint": "Aizstāt pievieno failus esošajai mapei un aizstāj failus ar tādu pašu nosaukumu. Paturēt abus izveido otru, numurētu mapi.", + "replace": "Aizstāt", + "keep_both": "Paturēt abus", + "skip": "Izlaist", + "apply_to_all": "{count, plural, other {Darīt tāpat visiem # konfliktiem}}", + "no_preview": "Šim faila tipam nav priekšskatījuma.", + "open_in_app": "Atvērt noklusējuma lietotnē", + "shared_with": "{name} tagad ir koplietots ar {person}.", + "send_message": "Sūtīt ziņu", + "share_mail_subject": "Es koplietoju ar jums {name}", + "share_mail_body": "Sveiki!\n\nEs koplietoju ar jums “{name}”. To var atvērt šeit:\n{link}" + }, "smime": { "your_certificates": "Jūsu sertifikāti", "your_certificates_desc": "Importējiet un pārvaldiet savus S/MIME sertifikātus parakstīšanai un šifrēšanai", diff --git a/locales/mn/common.json b/locales/mn/common.json index 49f47d310..23a7d550c 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5583,6 +5583,21 @@ "next_file": "Дараах файл", "loading": "Ачаалж байна…" }, + "ux_pim_files": { + "conflict_title": "{name} аль хэдийн байна.", + "conflict_file_hint": "Солих нь шинэ агуулгыг байгаа файлд хийх тул хуваалцсан тохиргоо нь хэвээр үлдэнэ. Хоёуланг нь хадгалах нь дугаартай хуулбар нэмнэ.", + "conflict_folder_hint": "Солих нь файлуудыг байгаа хавтсанд нэмж, ижил нэртэй файлуудыг солино. Хоёуланг нь хадгалах нь дугаартай хоёр дахь хавтас үүсгэнэ.", + "replace": "Солих", + "keep_both": "Хоёуланг нь хадгалах", + "skip": "Алгасах", + "apply_to_all": "{count, plural, other {Бүх # зөрчилд ингэх}}", + "no_preview": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.", + "open_in_app": "Үндсэн програмаар нээх", + "shared_with": "{name}-г одоо {person}-тэй хуваалцаж байна.", + "send_message": "Мессеж илгээх", + "share_mail_subject": "Би {name}-г тантай хуваалцлаа", + "share_mail_body": "Сайн байна уу,\n\nБи \"{name}\"-г тантай хуваалцлаа. Эндээс нээж болно:\n{link}" + }, "smime": { "your_certificates": "Таны сертификатууд", "your_certificates_desc": "Имэйлд гарын үсэг зурах болон шифрлэхэд ашиглах S/MIME сертификатаа импортолж, удирдана", diff --git a/locales/nb/common.json b/locales/nb/common.json index 6c54c14c6..bb462701d 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5583,6 +5583,21 @@ "next_file": "Neste fil", "loading": "Laster inn…" }, + "ux_pim_files": { + "conflict_title": "{name} finnes allerede.", + "conflict_file_hint": "Erstatt legger det nye innholdet i den eksisterende filen, så delingene blir. Behold begge legger til en kopi med et nummer.", + "conflict_folder_hint": "Erstatt legger filene i den eksisterende mappen og erstatter filer med samme navn. Behold begge lager en ny mappe med et nummer.", + "replace": "Erstatt", + "keep_both": "Behold begge", + "skip": "Hopp over", + "apply_to_all": "{count, plural, one {Gjør det samme for konflikten} other {Gjør det samme for alle # konfliktene}}", + "no_preview": "Det finnes ingen forhåndsvisning for denne filtypen.", + "open_in_app": "Åpne i standardprogram", + "shared_with": "{name} er nå delt med {person}.", + "send_message": "Send en melding", + "share_mail_subject": "Jeg har delt {name} med deg", + "share_mail_body": "Hei,\n\nJeg har delt «{name}» med deg. Du kan åpne den her:\n{link}" + }, "smime": { "your_certificates": "Dine sertifikater", "your_certificates_desc": "Importer og administrer S/MIME-sertifikatene dine for signering og kryptering av e-post", diff --git a/locales/nl/common.json b/locales/nl/common.json index caa6592d6..a977818cb 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5408,6 +5408,21 @@ "next_file": "Volgend bestand", "loading": "Laden…" }, + "ux_pim_files": { + "conflict_title": "{name} bestaat al.", + "conflict_file_hint": "Vervangen zet de nieuwe inhoud in het bestaande bestand, zodat het gedeeld blijft. Beide behouden voegt een kopie met een nummer toe.", + "conflict_folder_hint": "Vervangen voegt de bestanden toe aan de bestaande map en vervangt bestanden met dezelfde naam. Beide behouden maakt een tweede map met een nummer.", + "replace": "Vervangen", + "keep_both": "Beide behouden", + "skip": "Overslaan", + "apply_to_all": "{count, plural, one {Dit doen voor het conflict} other {Dit doen voor alle # conflicten}}", + "no_preview": "Er is geen voorbeeld voor dit type bestand.", + "open_in_app": "Openen in standaard-app", + "shared_with": "{name} is nu gedeeld met {person}.", + "send_message": "Bericht sturen", + "share_mail_subject": "Ik heb {name} met je gedeeld", + "share_mail_body": "Hoi,\n\nIk heb \"{name}\" met je gedeeld. Je kunt het hier openen:\n{link}" + }, "smime": { "your_certificates": "Uw certificaten", "your_certificates_desc": "Importeer en beheer uw S/MIME-certificaten voor het ondertekenen en versleutelen van e-mails", diff --git a/locales/pl/common.json b/locales/pl/common.json index a42440af6..030c5c7fa 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5408,6 +5408,21 @@ "next_file": "Następny plik", "loading": "Wczytywanie…" }, + "ux_pim_files": { + "conflict_title": "{name} już istnieje.", + "conflict_file_hint": "Zastąp wkłada nową treść do istniejącego pliku, więc jego udostępnienia zostają. Zachowaj oba dodaje kopię z numerem.", + "conflict_folder_hint": "Zastąp dodaje pliki do istniejącego folderu i zastępuje pliki o tej samej nazwie. Zachowaj oba tworzy drugi folder z numerem.", + "replace": "Zastąp", + "keep_both": "Zachowaj oba", + "skip": "Pomiń", + "apply_to_all": "{count, plural, other {Zrób tak dla wszystkich konfliktów ({count})}}", + "no_preview": "Dla tego typu pliku nie ma podglądu.", + "open_in_app": "Otwórz w domyślnej aplikacji", + "shared_with": "{name} jest teraz udostępniony dla {person}.", + "send_message": "Wyślij wiadomość", + "share_mail_subject": "Udostępniłem Ci {name}", + "share_mail_body": "Cześć,\n\nUdostępniłem Ci „{name}”. Możesz to otworzyć tutaj:\n{link}" + }, "smime": { "your_certificates": "Twoje certyfikaty", "your_certificates_desc": "Importuj i zarządzaj swoimi certyfikatami S/MIME do podpisywania i szyfrowania wiadomości e-mail", diff --git a/locales/pt/common.json b/locales/pt/common.json index bd73fa852..26e692844 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5408,6 +5408,21 @@ "next_file": "Próximo arquivo", "loading": "Carregando…" }, + "ux_pim_files": { + "conflict_title": "{name} já existe.", + "conflict_file_hint": "Substituir coloca o novo conteúdo no arquivo existente, e os compartilhamentos continuam. Manter ambos adiciona uma cópia com um número.", + "conflict_folder_hint": "Substituir adiciona os arquivos à pasta existente e substitui os que têm o mesmo nome. Manter ambos cria uma segunda pasta com um número.", + "replace": "Substituir", + "keep_both": "Manter ambos", + "skip": "Ignorar", + "apply_to_all": "{count, plural, one {Fazer o mesmo para o conflito} other {Fazer o mesmo para os # conflitos}}", + "no_preview": "Não há pré-visualização para este tipo de arquivo.", + "open_in_app": "Abrir no aplicativo padrão", + "shared_with": "{name} agora está compartilhado com {person}.", + "send_message": "Enviar uma mensagem", + "share_mail_subject": "Compartilhei {name} com você", + "share_mail_body": "Olá,\n\nCompartilhei \"{name}\" com você. Você pode abrir aqui:\n{link}" + }, "smime": { "your_certificates": "Seus certificados", "your_certificates_desc": "Importe e gerencie seus certificados S/MIME para assinar e criptografar e-mails", diff --git a/locales/ro/common.json b/locales/ro/common.json index ad9938c1a..7fba2decb 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5583,6 +5583,21 @@ "next_file": "Fișierul următor", "loading": "Se încarcă…" }, + "ux_pim_files": { + "conflict_title": "{name} există deja.", + "conflict_file_hint": "Înlocuiește pune conținutul nou în fișierul existent, iar partajările lui rămân. Păstrează-le pe ambele adaugă o copie numerotată.", + "conflict_folder_hint": "Înlocuiește adaugă fișierele în dosarul existent și înlocuiește fișierele cu același nume. Păstrează-le pe ambele creează un al doilea dosar numerotat.", + "replace": "Înlocuiește", + "keep_both": "Păstrează-le pe ambele", + "skip": "Omite", + "apply_to_all": "{count, plural, other {Fă la fel pentru toate cele # conflicte}}", + "no_preview": "Nu există previzualizare pentru acest tip de fișier.", + "open_in_app": "Deschide în aplicația implicită", + "shared_with": "{name} este acum partajat cu {person}.", + "send_message": "Trimite un mesaj", + "share_mail_subject": "Am partajat {name} cu tine", + "share_mail_body": "Salut,\n\nAm partajat „{name}” cu tine. Îl poți deschide aici:\n{link}" + }, "smime": { "your_certificates": "Certificatele dvs.", "your_certificates_desc": "Importați și gestionați certificatele S/MIME pentru semnarea și criptarea e-mailurilor", diff --git a/locales/ru/common.json b/locales/ru/common.json index e32676136..2bde2eff6 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5408,6 +5408,21 @@ "next_file": "Следующий файл", "loading": "Загрузка…" }, + "ux_pim_files": { + "conflict_title": "{name} уже существует.", + "conflict_file_hint": "«Заменить» кладёт новое содержимое в существующий файл, поэтому общий доступ к нему сохраняется. «Оставить оба» добавляет копию с номером.", + "conflict_folder_hint": "«Заменить» добавляет файлы в существующую папку и заменяет файлы с теми же именами. «Оставить оба» создаёт вторую папку с номером.", + "replace": "Заменить", + "keep_both": "Оставить оба", + "skip": "Пропустить", + "apply_to_all": "{count, plural, other {Так же для всех конфликтов ({count})}}", + "no_preview": "Для этого типа файлов нет предпросмотра.", + "open_in_app": "Открыть в приложении по умолчанию", + "shared_with": "{name}: теперь есть доступ у {person}.", + "send_message": "Отправить сообщение", + "share_mail_subject": "Я открыл вам доступ к {name}", + "share_mail_body": "Здравствуйте!\n\nЯ открыл вам доступ к «{name}». Открыть можно здесь:\n{link}" + }, "smime": { "your_certificates": "Ваши сертификаты", "your_certificates_desc": "Импортируйте и управляйте своими сертификатами S/MIME для подписи и шифрования писем", diff --git a/locales/sk/common.json b/locales/sk/common.json index 3da1ed6c4..915b8b998 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5583,6 +5583,21 @@ "next_file": "Nasledujúci súbor", "loading": "Načítava sa…" }, + "ux_pim_files": { + "conflict_title": "{name} už existuje.", + "conflict_file_hint": "Nahradiť vloží nový obsah do existujúceho súboru, takže jeho zdieľanie zostane. Ponechať oboje pridá kópiu s číslom.", + "conflict_folder_hint": "Nahradiť pridá súbory do existujúceho priečinka a nahradí súbory s rovnakým názvom. Ponechať oboje vytvorí druhý priečinok s číslom.", + "replace": "Nahradiť", + "keep_both": "Ponechať oboje", + "skip": "Preskočiť", + "apply_to_all": "{count, plural, other {Urobiť to isté pri všetkých konfliktoch ({count})}}", + "no_preview": "Pre tento typ súboru nie je náhľad.", + "open_in_app": "Otvoriť v predvolenej aplikácii", + "shared_with": "{name} je teraz zdieľaný s {person}.", + "send_message": "Poslať správu", + "share_mail_subject": "Zdieľal som s vami {name}", + "share_mail_body": "Dobrý deň,\n\nzdieľal som s vami „{name}“. Otvoríte to tu:\n{link}" + }, "smime": { "your_certificates": "Vaše certifikáty", "your_certificates_desc": "Importujte a spravujte svoje certifikáty S/MIME na podpisovanie a šifrovanie e-mailov", diff --git a/locales/th/common.json b/locales/th/common.json index d8fbce3f9..56318f784 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2636,6 +2636,21 @@ "zoom_out": "ซูมออก", "zoom_reset": "พอดีหน้าจอ" }, + "ux_pim_files": { + "conflict_title": "{name} มีอยู่แล้ว", + "conflict_file_hint": "แทนที่จะใส่เนื้อหาใหม่ลงในไฟล์เดิม การแชร์ของไฟล์จึงยังคงอยู่ เก็บทั้งสองจะเพิ่มสำเนาที่มีหมายเลข", + "conflict_folder_hint": "แทนที่จะเพิ่มไฟล์ลงในโฟลเดอร์เดิมและแทนที่ไฟล์ที่ชื่อซ้ำกัน เก็บทั้งสองจะสร้างโฟลเดอร์ที่สองที่มีหมายเลข", + "replace": "แทนที่", + "keep_both": "เก็บทั้งสอง", + "skip": "ข้าม", + "apply_to_all": "{count, plural, other {ทำแบบนี้กับข้อขัดแย้งทั้ง # รายการ}}", + "no_preview": "ไฟล์ประเภทนี้ไม่มีตัวอย่าง", + "open_in_app": "เปิดในแอปเริ่มต้น", + "shared_with": "ตอนนี้แชร์ {name} กับ {person} แล้ว", + "send_message": "ส่งข้อความ", + "share_mail_subject": "ฉันแชร์ {name} กับคุณ", + "share_mail_body": "สวัสดี\n\nฉันแชร์ \"{name}\" กับคุณแล้ว เปิดได้ที่นี่:\n{link}" + }, "protocol_handlers": { "title": "แอปพลิเคชันเริ่มต้น", "description": "เลือกว่าจะเปิดลิงก์อีเมลและปฏิทินใน {appName} หรือไม่", diff --git a/locales/tr/common.json b/locales/tr/common.json index 59e4b73db..18624745c 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5583,6 +5583,21 @@ "next_file": "Sonraki dosya", "loading": "Yükleniyor…" }, + "ux_pim_files": { + "conflict_title": "{name} zaten var.", + "conflict_file_hint": "Değiştir, yeni içeriği mevcut dosyaya koyar; paylaşımları korunur. İkisini de tut, numaralı bir kopya ekler.", + "conflict_folder_hint": "Değiştir, dosyaları mevcut klasöre ekler ve aynı adlı dosyaları değiştirir. İkisini de tut, numaralı ikinci bir klasör oluşturur.", + "replace": "Değiştir", + "keep_both": "İkisini de tut", + "skip": "Atla", + "apply_to_all": "{count, plural, other {Tüm # çakışma için bunu yap}}", + "no_preview": "Bu dosya türü için önizleme yok.", + "open_in_app": "Varsayılan uygulamada aç", + "shared_with": "{name} artık {person} ile paylaşılıyor.", + "send_message": "Mesaj gönder", + "share_mail_subject": "{name} dosyasını seninle paylaştım", + "share_mail_body": "Merhaba,\n\n\"{name}\" dosyasını seninle paylaştım. Buradan açabilirsin:\n{link}" + }, "smime": { "your_certificates": "Sertifikalarınız", "your_certificates_desc": "E-postaları imzalamak ve şifrelemek için S/MIME sertifikalarınızı içe aktarın ve yönetin", diff --git a/locales/uk/common.json b/locales/uk/common.json index be354b49c..6f607c823 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5408,6 +5408,21 @@ "next_file": "Наступний файл", "loading": "Завантаження…" }, + "ux_pim_files": { + "conflict_title": "{name} уже існує.", + "conflict_file_hint": "«Замінити» кладе новий вміст в наявний файл, тому спільний доступ до нього лишається. «Залишити обидва» додає копію з номером.", + "conflict_folder_hint": "«Замінити» додає файли в наявну папку й замінює файли з тими самими назвами. «Залишити обидва» створює другу папку з номером.", + "replace": "Замінити", + "keep_both": "Залишити обидва", + "skip": "Пропустити", + "apply_to_all": "{count, plural, other {Так само для всіх конфліктів ({count})}}", + "no_preview": "Для цього типу файлів немає попереднього перегляду.", + "open_in_app": "Відкрити в програмі за замовчуванням", + "shared_with": "{name}: тепер є доступ у {person}.", + "send_message": "Надіслати повідомлення", + "share_mail_subject": "Я надав вам доступ до {name}", + "share_mail_body": "Вітаю!\n\nЯ надав вам доступ до «{name}». Відкрити можна тут:\n{link}" + }, "smime": { "your_certificates": "Ваші сертифікати", "your_certificates_desc": "Імпортуйте та керуйте своїми сертифікатами S/MIME для підпису та шифрування електронних листів", diff --git a/locales/vi/common.json b/locales/vi/common.json index fe1be93e4..e36a21dda 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5595,6 +5595,21 @@ "next_file": "Tệp tiếp theo", "loading": "Đang tải…" }, + "ux_pim_files": { + "conflict_title": "{name} đã tồn tại.", + "conflict_file_hint": "Thay thế đưa nội dung mới vào tệp hiện có, nên các chia sẻ của tệp vẫn giữ nguyên. Giữ cả hai thêm một bản sao có đánh số.", + "conflict_folder_hint": "Thay thế thêm các tệp vào thư mục hiện có và thay các tệp trùng tên. Giữ cả hai tạo thêm một thư mục có đánh số.", + "replace": "Thay thế", + "keep_both": "Giữ cả hai", + "skip": "Bỏ qua", + "apply_to_all": "{count, plural, other {Làm vậy cho cả # xung đột}}", + "no_preview": "Không có bản xem trước cho loại tệp này.", + "open_in_app": "Mở bằng ứng dụng mặc định", + "shared_with": "{name} giờ đã được chia sẻ với {person}.", + "send_message": "Gửi tin nhắn", + "share_mail_subject": "Tôi đã chia sẻ {name} với bạn", + "share_mail_body": "Chào bạn,\n\nTôi đã chia sẻ \"{name}\" với bạn. Bạn có thể mở tại đây:\n{link}" + }, "smime": { "your_certificates": "Chứng chỉ của bạn", "your_certificates_desc": "Nhập và quản lý chứng chỉ S/MIME của bạn để ký và mã hóa email", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 205e145f6..5e50d4e40 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5583,6 +5583,21 @@ "next_file": "下一個檔案", "loading": "載入中…" }, + "ux_pim_files": { + "conflict_title": "{name} 已存在。", + "conflict_file_hint": "取代會把新內容放進現有檔案,共用設定維持不變。保留兩者會新增一個帶編號的副本。", + "conflict_folder_hint": "取代會把檔案加入現有資料夾,並取代同名檔案。保留兩者會建立一個帶編號的資料夾。", + "replace": "取代", + "keep_both": "保留兩者", + "skip": "略過", + "apply_to_all": "{count, plural, other {對全部 # 個衝突執行此動作}}", + "no_preview": "此類檔案無法預覽。", + "open_in_app": "用預設應用程式開啟", + "shared_with": "{name} 已與 {person} 共用。", + "send_message": "傳送訊息", + "share_mail_subject": "我與你共用了 {name}", + "share_mail_body": "你好,\n\n我與你共用了「{name}」。可以在這裡開啟:\n{link}" + }, "smime": { "your_certificates": "您的憑證", "your_certificates_desc": "匯入與管理用於郵件簽章與加密的 S/MIME 憑證", diff --git a/locales/zh/common.json b/locales/zh/common.json index 0e601ed20..f21504647 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5408,6 +5408,21 @@ "next_file": "下一个文件", "loading": "正在加载…" }, + "ux_pim_files": { + "conflict_title": "{name} 已存在。", + "conflict_file_hint": "替换会把新内容放进现有文件,共享设置保持不变。保留两者会添加一个带编号的副本。", + "conflict_folder_hint": "替换会把文件添加到现有文件夹,并替换同名文件。保留两者会新建一个带编号的文件夹。", + "replace": "替换", + "keep_both": "保留两者", + "skip": "跳过", + "apply_to_all": "{count, plural, other {对全部 # 个冲突执行此操作}}", + "no_preview": "此类文件无法预览。", + "open_in_app": "用默认应用打开", + "shared_with": "{name} 已与 {person} 共享。", + "send_message": "发送消息", + "share_mail_subject": "我与你共享了 {name}", + "share_mail_body": "你好,\n\n我与你共享了“{name}”。可以在这里打开:\n{link}" + }, "smime": { "your_certificates": "您的证书", "your_certificates_desc": "导入和管理您的 S/MIME 证书以对邮件进行签名和加密", diff --git a/stores/__tests__/file-store-upload-conflicts.test.ts b/stores/__tests__/file-store-upload-conflicts.test.ts new file mode 100644 index 000000000..30a8cf28d --- /dev/null +++ b/stores/__tests__/file-store-upload-conflicts.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useFileStore } from '../file-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { FileNode } from '@/lib/jmap/types'; +import type { UploadConflict, UploadConflictChoice } from '@/lib/files-upload-conflicts'; + +// pim-10: an upload whose name is taken asks Replace / Keep both / Skip. +// Replace gives the existing node new content (same id), Keep both adds +// "name (1)", Skip leaves it alone. + +function makeClient(initial: FileNode[]) { + let nodes: FileNode[] = initial.map(n => ({ ...n })); + let seq = 0; + const client = { + getCapabilities: () => ({}), + getFilesAccountId: () => 'acct', + async listAllFileNodes() { return nodes.map(n => ({ ...n })); }, + async createFileDirectory(name: string, parentId: string | null) { + const node: FileNode = { id: `n${++seq}`, parentId, name, type: '', blobId: null, size: 0, created: '', modified: '' }; + nodes.push(node); + return { ...node }; + }, + async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null) { + const node: FileNode = { id: `n${++seq}`, parentId, name, type, blobId, size, created: '', modified: '' }; + nodes.push(node); + return { ...node }; + }, + replaceFileNodeBlob: vi.fn(async (id: string, blobId: string) => { + const node = nodes.find(n => n.id === id); + if (!node) throw new Error('not found'); + node.blobId = blobId; + }), + async uploadBlob() { return { blobId: `blob${++seq}`, type: 'text/plain' }; }, + _nodes: () => nodes, + _reset: (next: FileNode[]) => { nodes = next; }, + }; + return client as unknown as IJMAPClient & { _nodes: () => FileNode[]; replaceFileNodeBlob: ReturnType }; +} + +const folder = (id: string, name: string, parentId: string | null): FileNode => ({ + id, parentId, name, type: '', blobId: null, size: 0, created: '', modified: '', +}); +const file = (id: string, name: string, parentId: string | null): FileNode => ({ + id, parentId, name, type: 'text/plain', blobId: `old-${id}`, size: 3, created: '', modified: '', +}); +const upload = (name: string, path?: string) => { + const f = new File(['new'], name, { type: 'text/plain' }); + if (path) Object.defineProperty(f, 'webkitRelativePath', { value: path }); + return f; +}; + +/** Answers every conflict with `choice`, and records what was asked. */ +function answering(choice: UploadConflictChoice | null) { + const asked: UploadConflict[][] = []; + const resolveConflicts = vi.fn(async (conflicts: UploadConflict[]) => { + asked.push(conflicts); + return choice ? new Map(conflicts.map(c => [c.name, choice])) : null; + }); + return { asked, resolveConflicts }; +} + +async function start(nodes: FileNode[]) { + const client = makeClient(nodes); + useFileStore.getState().initClient(client); + await useFileStore.getState().navigate(null); + return client; +} + +beforeEach(() => { + useFileStore.setState({ + client: null, currentParentId: null, currentPath: '/', pathStack: [{ id: null, name: '' }], + resources: [], selectedResources: new Set(), clipboard: null, lastAction: null, + }); +}); + +describe('uploading files whose name is taken (pim-10)', () => { + it('asks only about the taken names', async () => { + await start([file('r', 'report.txt', null), folder('d', 'new.txt', null)]); + const { asked, resolveConflicts } = answering('keep'); + await useFileStore.getState().uploadFiles([upload('report.txt'), upload('other.txt'), upload('new.txt')], { resolveConflicts }); + // A folder named like the file is no conflict: the file is numbered beside it. + expect(asked).toEqual([[{ name: 'report.txt', kind: 'file' }]]); + }); + + it('Replace gives the existing node the new content and keeps its id', async () => { + const client = await start([file('r', 'report.txt', null)]); + const { resolveConflicts } = answering('replace'); + const report = await useFileStore.getState().uploadFiles([upload('report.txt')], { resolveConflicts }); + expect(report.uploaded).toBe(1); + expect(client.replaceFileNodeBlob).toHaveBeenCalledWith('r', expect.stringMatching(/^blob/)); + const named = client._nodes().filter(n => n.name.startsWith('report')); + expect(named.map(n => [n.id, n.name])).toEqual([['r', 'report.txt']]); + expect(named[0].blobId).not.toBe('old-r'); + }); + + it('Keep both adds a numbered copy', async () => { + const client = await start([file('r', 'report.txt', null)]); + const { resolveConflicts } = answering('keep'); + await useFileStore.getState().uploadFiles([upload('report.txt')], { resolveConflicts }); + expect(client._nodes().map(n => n.name).sort()).toEqual(['report (1).txt', 'report.txt']); + expect(client.replaceFileNodeBlob).not.toHaveBeenCalled(); + }); + + it('Skip leaves the file alone and uploads the rest', async () => { + const client = await start([file('r', 'report.txt', null)]); + const { resolveConflicts } = answering('skip'); + const report = await useFileStore.getState().uploadFiles([upload('report.txt'), upload('other.txt')], { resolveConflicts }); + expect(report).toMatchObject({ uploaded: 1, skipped: 1 }); + expect(client._nodes().map(n => [n.name, n.blobId]).sort()).toEqual([['other.txt', expect.stringMatching(/^blob/)], ['report.txt', 'old-r']]); + }); + + it('calling it off uploads nothing', async () => { + const client = await start([file('r', 'report.txt', null)]); + const { resolveConflicts } = answering(null); + const report = await useFileStore.getState().uploadFiles([upload('report.txt'), upload('other.txt')], { resolveConflicts }); + expect(report).toMatchObject({ uploaded: 0, canceled: true }); + expect(client._nodes().map(n => n.name)).toEqual(['report.txt']); + }); +}); + +describe('uploading a folder whose name is taken (pim-10)', () => { + const tree = () => [ + folder('docs', 'Docs', null), + file('a', 'a.txt', 'docs'), + folder('sub', 'Sub', 'docs'), + file('b', 'b.txt', 'sub'), + ]; + const files = () => [upload('a.txt', 'Docs/a.txt'), upload('b.txt', 'Docs/Sub/b.txt'), upload('c.txt', 'Docs/Sub/c.txt')]; + + it('asks about the folder, and Replace merges into it', async () => { + const client = await start(tree()); + const { asked, resolveConflicts } = answering('replace'); + const report = await useFileStore.getState().uploadFolder(files(), { resolveConflicts }); + expect(asked).toEqual([[{ name: 'Docs', kind: 'folder' }]]); + expect(report.uploaded).toBe(3); + const nodes = client._nodes(); + // No second Docs or Sub; a.txt and b.txt keep their ids with new content; c.txt is new in Sub. + expect(nodes.filter(n => n.blobId === null).map(n => n.id)).toEqual(['docs', 'sub']); + expect(nodes.find(n => n.id === 'a')!.blobId).toMatch(/^blob/); + expect(nodes.find(n => n.id === 'b')!.blobId).toMatch(/^blob/); + expect(nodes.filter(n => n.parentId === 'sub').map(n => n.name).sort()).toEqual(['b.txt', 'c.txt']); + }); + + it('Keep both makes a second folder', async () => { + const client = await start(tree()); + const { resolveConflicts } = answering('keep'); + await useFileStore.getState().uploadFolder(files(), { resolveConflicts }); + expect(client._nodes().filter(n => n.parentId === null).length).toBe(2); + expect(client.replaceFileNodeBlob).not.toHaveBeenCalled(); + }); + + it('Skip leaves the folder out', async () => { + const client = await start(tree()); + const { resolveConflicts } = answering('skip'); + const report = await useFileStore.getState().uploadFolder(files(), { resolveConflicts }); + expect(report).toMatchObject({ uploaded: 0, skipped: 3 }); + expect(client._nodes()).toHaveLength(4); + }); +}); diff --git a/stores/file-store.ts b/stores/file-store.ts index fcee224eb..349dbe939 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -16,6 +16,9 @@ import { } from '@/lib/files-trash'; import { buildZip, planZip, saveBlob, type ZipProgress } from '@/lib/files-zip'; import { downloadAllowed } from '@/lib/download-guard'; +import { + findUploadConflicts, type UploadConflict, type UploadConflictChoice, type UploadConflictResolver, +} from '@/lib/files-upload-conflicts'; export interface FileResource { id: string; @@ -59,6 +62,13 @@ export interface UploadReport { uploaded: number; failed: { name: string; error: unknown }[]; canceled: boolean; + /** Left out because the name was taken and the user chose Skip. */ + skipped?: number; +} + +export interface UploadOptions { + /** Asked when a name is taken in the target folder; without it, new ones are numbered. */ + resolveConflicts?: UploadConflictResolver; } interface ClipboardState { @@ -137,8 +147,8 @@ interface FileState { refresh: () => Promise; createDirectory: (name: string) => Promise; uploadFile: (file: File) => Promise; - uploadFiles: (files: File[]) => Promise; - uploadFolder: (files: File[]) => Promise; + uploadFiles: (files: File[], options?: UploadOptions) => Promise; + uploadFolder: (files: File[], options?: UploadOptions) => Promise; cancelUpload: () => void; // Deletes, renames and moves resolve to false when a plugin stopped them // (onBeforeFileDelete / Rename / Move); nothing changed then. @@ -471,6 +481,32 @@ function resolvePathToId(nodes: FileNode[], path: string): string | null | undef return parentId; } +/** + * The answer to every name of an upload that is taken in the target folder + * (pim-10): an empty map when nothing clashes or nobody can be asked (the + * new ones are then numbered, as before), null when the user called it off. + */ +async function askUploadConflicts( + incoming: UploadConflict[], + existing: Pick[], + resolve: UploadConflictResolver | undefined, +): Promise | null> { + if (!resolve) return new Map(); + // A symlink's name is taken but it can't be replaced: kept beside it. + const entries = existing.filter(r => !r.isSymlink).map(r => ({ name: r.serverName ?? r.name, isDirectory: r.isDirectory })); + const conflicts = findUploadConflicts(incoming, entries); + if (conflicts.length === 0) return new Map(); + return resolve(conflicts); +} + +/** A file whose content an upload replaced, as plugins see it (onAfterFileUpload). */ +function replacedView(id: string, file: File, type: string | undefined, folderPath: string): FileResourceView { + return { + id, name: file.name, type: 'file', size: file.size, mimeType: type || file.type || 'application/octet-stream', + path: folderPath, modified: new Date().toISOString(), + }; +} + function getUniqueName(name: string, existingNames: Set): string { if (!existingNames.has(name)) return name; const dotIndex = name.lastIndexOf('.'); @@ -1083,7 +1119,7 @@ export const useFileStore = create((set, get) => ({ } }, - uploadFiles: async (allFiles: File[]) => { + uploadFiles: async (allFiles: File[], options?: UploadOptions) => { const { client, currentParentId, currentPath, resources } = get(); const report: UploadReport = { uploaded: 0, failed: [], canceled: false }; if (!client) return report; @@ -1098,6 +1134,22 @@ export const useFileStore = create((set, get) => ({ } if (files.length === 0) return report; + // A name that is taken: ask Replace, Keep both or Skip (pim-10). + const choices = await askUploadConflicts( + files.map(f => ({ name: f.name, kind: 'file' as const })), + resources, + options?.resolveConflicts, + ); + if (!choices) { + report.canceled = true; + return report; + } + const replaceIds = new Map(resources.filter(r => !r.isDirectory && !r.isSymlink).map(r => [r.serverName ?? r.name, r.id])); + const kept = files.filter(f => choices.get(f.name) !== 'skip'); + if (kept.length < files.length) report.skipped = files.length - kept.length; + files.splice(0, files.length, ...kept); + if (files.length === 0) return report; + const abortController = new AbortController(); set({ uploadAbortController: abortController }); const totalFiles = files.length; @@ -1106,7 +1158,8 @@ export const useFileStore = create((set, get) => ({ for (let i = 0; i < files.length; i++) { if (abortController.signal.aborted) break; const file = files[i]; - const uniqueName = getUniqueName(file.name, existingNames); + const replaceId = choices.get(file.name) === 'replace' ? replaceIds.get(file.name) : undefined; + const uniqueName = replaceId ? file.name : getUniqueName(file.name, existingNames); existingNames.add(uniqueName); set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: i + 1, totalFiles } }); @@ -1120,6 +1173,13 @@ export const useFileStore = create((set, get) => ({ }); if (abortController.signal.aborted) break; set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } }); + if (replaceId) { + // New content for the node that is there: same id, same shares. + await client.replaceFileNodeBlob(replaceId, blobId); + report.uploaded++; + void fileHooks.onAfterFileUpload.emit(replacedView(replaceId, file, type, currentPath)); + continue; + } const node = await client.createFileNode(uniqueName, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId); report.uploaded++; void fileHooks.onAfterFileUpload.emit(fileToView(node, currentPath)); @@ -1143,8 +1203,8 @@ export const useFileStore = create((set, get) => ({ } }, - uploadFolder: async (allFiles: File[]) => { - const { client, currentParentId, currentPath } = get(); + uploadFolder: async (allFiles: File[], options?: UploadOptions) => { + const { client, currentParentId, currentPath, resources } = get(); const report: UploadReport = { uploaded: 0, failed: [], canceled: false }; if (!client || allFiles.length === 0) return report; assertCanAddTo(currentParentId); @@ -1167,6 +1227,39 @@ export const useFileStore = create((set, get) => ({ } if (files.length === 0) return report; + // What lands right in the target folder: a dropped file, or the top + // folder of each path. A name that is taken there asks first (pim-10). + const topOf = (file: File): UploadConflict => { + const relativePath = relativePathOf(file); + const slash = relativePath.indexOf('/'); + return slash >= 0 ? { name: relativePath.slice(0, slash), kind: 'folder' } : { name: relativePath, kind: 'file' }; + }; + const choices = await askUploadConflicts(files.map(topOf), resources, options?.resolveConflicts); + if (!choices) { + report.canceled = true; + return report; + } + const kept = files.filter(f => choices.get(topOf(f).name) !== 'skip'); + if (kept.length < files.length) report.skipped = files.length - kept.length; + files.splice(0, files.length, ...kept); + if (files.length === 0) return report; + // Replace: a file gets new content; a folder takes the new content in, + // with its subfolders reused and files of the same name replaced. + const replacing = new Map(resources + .filter(r => !r.isSymlink && choices.get(r.serverName ?? r.name) === 'replace') + .map(r => [`${r.isDirectory ? 'folder' : 'file'}:${r.serverName ?? r.name}`, r.id])); + let tree: FileNode[] | null = null; + const loadTree = async () => { + tree ??= isCrossAccountId(currentParentId) + ? await client.listAllFileNodesAcrossAccounts() + : await client.listAllFileNodes(); + return tree; + }; + const childNamed = async (parentId: string | null, name: string, kind: 'file' | 'directory') => + (await loadTree()).find(n => (n.parentId ?? null) === parentId && n.name === name && fileNodeKind(n) === kind)?.id; + // Folder paths of the upload that are existing folders (merged into). + const mergedDirs = new Set(); + const abortController = new AbortController(); set({ uploadAbortController: abortController }); const totalFiles = files.length; @@ -1220,6 +1313,14 @@ export const useFileStore = create((set, get) => ({ continue; } try { + const existingId = parentPath === '' + ? replacing.get(`folder:${dirName}`) + : mergedDirs.has(parentPath) ? await childNamed(parentId, dirName, 'directory') : undefined; + if (existingId) { + dirIds.set(dir, existingId); + mergedDirs.add(dir); + continue; + } const created = await client.createFileDirectory(dirName, parentId); dirIds.set(dir, created.id); void fileHooks.onDirectoryCreate.emit(fileToView(created, joinPath(currentPath, parentPath))); @@ -1256,6 +1357,15 @@ export const useFileStore = create((set, get) => ({ }); if (abortController.signal.aborted) break; set({ uploadProgress: { name: relativePath, loaded: file.size, total: file.size, current: i + 1, totalFiles } }); + const replaceId = dirPath === '' + ? replacing.get(`file:${file.name}`) + : mergedDirs.has(dirPath) ? await childNamed(parentId, file.name, 'file') : undefined; + if (replaceId) { + await client.replaceFileNodeBlob(replaceId, blobId); + report.uploaded++; + void fileHooks.onAfterFileUpload.emit(replacedView(replaceId, file, type, joinPath(currentPath, dirPath))); + continue; + } const node = await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, parentId); report.uploaded++; void fileHooks.onAfterFileUpload.emit(fileToView(node, joinPath(currentPath, dirPath))); From ac696225ba71a200ac25585183dfcf2e04d4b8d2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:08:01 +0100 Subject: [PATCH 3/3] test: check upload conflicts, double-click, the no-preview pane, Undo and the share message in Files on both Stalwarts --- integration/tests/25-ux-pim-files.spec.ts | 230 ++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 integration/tests/25-ux-pim-files.spec.ts diff --git a/integration/tests/25-ux-pim-files.spec.ts b/integration/tests/25-ux-pim-files.spec.ts new file mode 100644 index 000000000..e3d4894bc --- /dev/null +++ b/integration/tests/25-ux-pim-files.spec.ts @@ -0,0 +1,230 @@ +import { test, expect, type Page } from '@playwright/test'; +import path from 'node:path'; +import { contextOptions } from './helpers/ent'; +import { JmapClient } from './helpers/jmap'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; + +/** + * Usability fixes in Files (pim-3, pim-10, pim-16, pim-26) against a VM 106 + * Stalwart, 1.0 and 0.16. FileNode differs between them (1.0: draft 18 with + * nodeType and `@type`; 0.16: blobId == null marks a folder), so the server + * state is read back over JMAP after every step. + * + * - pim-10: uploading a name that exists asks Replace / Keep both / Skip. + * Replace keeps the node id and gives it a new blob, Keep both makes the + * "(1)" copy, Skip changes nothing; "Do this for all" answers the rest. + * - pim-3: a double-click opens the preview and it stays open. + * - pim-16: a file with no preview opens a pane with Download. + * - pim-26: delete has Undo, and sharing offers "Send a message" with a link. + * + * Account dso-ux-pim (and dso-ux-pim-b to share with), password + * IT_DSO_PASSWORD. Every node made here is named "UXFIX-files …" and removed. + */ + +const PASSWORD = process.env.IT_DSO_PASSWORD ?? ''; +const A = 'dso-ux-pim@example.org'; +const B = 'dso-ux-pim-b@example.org'; +const CORE = 'urn:ietf:params:jmap:core'; +const FILENODE = 'urn:ietf:params:jmap:filenode'; +const RUN = Date.now().toString(36); +const PREFIX = `UXFIX-files ${RUN}`; +const SHOTS = process.env.IT_UXPIM_SHOTS; + +test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")'); + +type Node = { id: string; name: string; parentId: string | null; blobId: string | null; shareWith?: Record | null }; + +async function nodes(c: JmapClient): Promise { + const r = await c.request([['FileNode/get', { accountId: c.filesAccountId, ids: null }, '0']], [CORE, FILENODE]); + return r.methodResponses[0][1].list as Node[]; +} + +async function ours(c: JmapClient): Promise { + return (await nodes(c)).filter((n) => n.name.startsWith(PREFIX)); +} + +async function cleanUp(c: JmapClient): Promise { + // Our nodes wherever they are, the trash's batch folders included. + const all = await nodes(c); + const ids = all.filter((n) => n.name.startsWith(PREFIX)).map((n) => n.id); + if (ids.length) await c.request([['FileNode/set', { accountId: c.filesAccountId, destroy: ids }, '0']], [CORE, FILENODE]); + // The draft "Send a message" started. + const q = await c.request([['Email/query', { accountId: c.accountId, filter: { subject: PREFIX } }, '0']]); + const drafts = q.methodResponses[0][1].ids as string[]; + if (drafts?.length) await c.request([['Email/set', { accountId: c.accountId, destroy: drafts }, '0']]); +} + +async function login(page: Page, email: string): Promise { + await neutralizeDevOverlay(page); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 }); + await page.fill('#username', email); + await page.fill('#password', PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 }); +} + +async function openFilesRoot(page: Page): Promise { + await page.goto('/files'); + await expect(page.getByRole('grid', { name: 'Files and folders' }).first()).toBeVisible({ timeout: 60000 }); +} + +async function upload(page: Page, files: { name: string; text: string }[]): Promise { + await page.locator('input[type="file"][multiple]:not([webkitdirectory])').first().setInputFiles( + files.map((f) => ({ name: f.name, mimeType: 'text/plain', buffer: Buffer.from(f.text) })), + ); +} + +async function shot(page: Page, name: string): Promise { + if (SHOTS) await page.screenshot({ path: path.join(SHOTS, name) }); +} + +const row = (page: Page, name: string) => page.locator(`[data-resource="${name}"]`).first(); + +test.describe('Files (pim-3, pim-10, pim-16, pim-26)', () => { + test.describe.configure({ mode: 'serial' }); + + test.afterAll(async () => { + const a = await JmapClient.connect(A, PASSWORD); + await cleanUp(a); + }); + + test('an upload over an existing name asks, and each answer shows on the server', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const name = `${PREFIX}.txt`; + const copy = `${PREFIX} (1).txt`; + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await openFilesRoot(page); + + await upload(page, [{ name, text: 'one' }]); + await expect.poll(async () => (await ours(a)).map((n) => n.name), { timeout: 30000 }).toEqual([name]); + const [first] = await ours(a); + await expect(row(page, name)).toBeVisible({ timeout: 30000 }); + + // Replace: the same node, a new blob, no copy. + await upload(page, [{ name, text: 'two, longer' }]); + const dialog = page.getByRole('alertdialog', { name: `${name} already exists.` }); + await expect(dialog).toBeVisible({ timeout: 30000 }); + await expect(dialog.getByRole('checkbox')).toHaveCount(0); + await shot(page, 'pim-fix-10-after.png'); + await dialog.getByRole('button', { name: 'Replace', exact: true }).click(); + await expect.poll(async () => { + const list = await ours(a); + return list.map((n) => ({ id: n.id, name: n.name, newBlob: n.blobId !== first.blobId })); + }, { timeout: 30000 }).toEqual([{ id: first.id, name, newBlob: true }]); + const replaced = (await ours(a))[0]; + + // Keep both: the "(1)" copy, the original untouched. + await upload(page, [{ name, text: 'three' }]); + await dialog.getByRole('button', { name: 'Keep both' }).click(); + await expect.poll(async () => (await ours(a)).map((n) => n.name).sort(), { timeout: 30000 }).toEqual([copy, name].sort()); + expect((await ours(a)).find((n) => n.name === name)).toMatchObject({ id: first.id, blobId: replaced.blobId }); + const before = JSON.stringify((await ours(a)).map((n) => [n.id, n.name, n.blobId]).sort()); + + // Skip: nothing changes. + await expect(row(page, copy)).toBeVisible({ timeout: 30000 }); + await upload(page, [{ name, text: 'four' }]); + await dialog.getByRole('button', { name: 'Skip' }).click(); + await expect(dialog).toBeHidden(); + // Two conflicts: "Do this for all 2 conflicts", and Skip answers both. + await upload(page, [{ name, text: 'five' }, { name: copy, text: 'six' }]); + await expect(dialog).toBeVisible(); + await dialog.getByRole('checkbox', { name: 'Do this for all 2 conflicts' }).check(); + await dialog.getByRole('button', { name: 'Skip' }).click(); + await expect(page.getByRole('alertdialog')).toHaveCount(0); + await page.waitForTimeout(3000); + expect(JSON.stringify((await ours(a)).map((n) => [n.id, n.name, n.blobId]).sort())).toBe(before); + await context.close(); + }); + + test('double-click opens a preview that stays, a file with no preview gets a pane, delete has Undo', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const name = `${PREFIX}.txt`; + const zip = `${PREFIX}.bin`; + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await openFilesRoot(page); + await upload(page, [{ name: zip, text: 'binary-ish' }]); + await expect(row(page, zip)).toBeVisible({ timeout: 30000 }); + + // pim-3: one click selects, a double-click opens, and it stays open. + await row(page, name).getByTitle(name).click(); + await expect(row(page, name)).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('dialog', { name })).toHaveCount(0); + await row(page, name).getByTitle(name).dblclick(); + await page.waitForTimeout(1000); + await expect(page.getByRole('dialog', { name })).toBeVisible(); + await shot(page, 'pim-fix-3-after.png'); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog', { name })).toHaveCount(0); + + // pim-16: no preview, no editor: the pane, not a download. + let downloaded = false; + page.on('download', () => { downloaded = true; }); + await row(page, zip).getByTitle(zip).dblclick(); + const pane = page.getByTestId('file-no-preview'); + await expect(pane).toBeVisible(); + await expect(pane).toContainText('There is no preview for this type of file.'); + await expect(pane.getByRole('button', { name: 'Download' })).toBeVisible(); + await shot(page, 'pim-fix-16-after.png'); + expect(downloaded).toBe(false); + await page.keyboard.press('Escape'); + + // pim-26: delete goes to the trash with Undo, and Undo puts it back. + await row(page, zip).click(); + await page.keyboard.press('Delete'); + const undo = page.getByRole('button', { name: 'Undo' }).last(); + await expect(undo).toBeVisible({ timeout: 30000 }); + await shot(page, 'pim-fix-26-undo-after.png'); + const zipId = (await ours(a)).find((n) => n.name === zip)!.id; + await expect.poll(async () => (await nodes(a)).find((n) => n.id === zipId)?.parentId ?? null, { timeout: 30000 }).not.toBeNull(); + await undo.click(); + await expect.poll(async () => (await nodes(a)).find((n) => n.id === zipId)?.parentId ?? null, { timeout: 30000 }).toBeNull(); + await expect(row(page, zip)).toBeVisible({ timeout: 30000 }); + await context.close(); + }); + + test('sharing with someone offers a message with a link to the file', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const b = await JmapClient.connect(B, PASSWORD); + const name = `${PREFIX}.txt`; + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await openFilesRoot(page); + // Run on its own (-g), it makes the file the first test made. + if (!(await ours(a)).some((n) => n.name === name)) await upload(page, [{ name, text: 'one' }]); + await expect.poll(async () => (await ours(a)).some((n) => n.name === name), { timeout: 30000 }).toBe(true); + const node = (await ours(a)).find((n) => n.name === name)!; + await expect(row(page, name)).toBeVisible({ timeout: 30000 }); + + await row(page, name).getByTitle(name).click({ button: 'right' }); + await page.getByRole('menuitem', { name: /^Share/ }).click(); + const shareDialog = page.getByRole('dialog').last(); + await shareDialog.getByRole('button', { name: 'Add person or group' }).click(); + await shareDialog.getByRole('textbox', { name: 'Search by name or email…' }).fill('dso-ux-pim-b'); + await shareDialog.getByRole('button', { name: /dso-ux-pim-b/ }).first().click(); + const send = page.getByRole('button', { name: 'Send a message' }); + await expect(send).toBeVisible({ timeout: 30000 }); + await expect.poll(async () => Object.keys((await nodes(a)).find((n) => n.id === node.id)?.shareWith ?? {}).length, { timeout: 30000 }).toBe(1); + await shot(page, 'pim-fix-26-share-after.png'); + await send.click(); + + // The composer, to b, with a link the person opens it by. + const link = `?shared=${encodeURIComponent(`${a.filesAccountId}:${node.id}`)}`; + await expect(page.getByText(B).first()).toBeVisible({ timeout: 60000 }); + await expect(page.locator('[contenteditable="true"], textarea').filter({ hasText: link }).first()).toBeVisible({ timeout: 30000 }); + await shot(page, 'pim-fix-26-compose-after.png'); + + // b sees the node under that id. + const shared = await b.request([['FileNode/get', { accountId: a.filesAccountId, ids: [node.id], properties: ['id', 'name'] }, '0']], [CORE, FILENODE]); + expect(shared.methodResponses[0][1].list?.[0]?.name).toBe(name); + await context.close(); + }); +});