Merge branch 'feat/do-ux-pim-files' into feat/do-ux-pim-fixes

This commit is contained in:
Linus Rath
2026-11-04 11:09:45 +01:00
51 changed files with 1834 additions and 61 deletions
@@ -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');
});
@@ -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(
<FileGridView
resources={[resource]}
selected={new Set()}
focusedName={null}
tabStopName={null}
dropTarget={null}
cutNames={new Set()}
starredPaths={new Set()}
pathOf={() => '/'}
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');
});
});
@@ -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);
});
@@ -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<void> }) => (
<div>
<button type="button" onClick={() => void onShare('p-new', { mayRead: true })}>add new</button>
<button type="button" onClick={() => void onShare('p-old', { mayRead: true, mayWrite: true })}>change old</button>
<button type="button" onClick={() => void onShare('p-new', null)}>remove</button>
</div>
),
}));
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(<UploadConflictDialog question={{ conflict, remaining: 1, answer }} />);
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(<UploadConflictDialog question={{ conflict, remaining: 3, answer }} />);
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(<UploadConflictDialog question={{ conflict: { name: 'Docs', kind: 'folder' }, remaining: 1, answer: vi.fn() }} />);
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(<FileNoPreview resource={budget} onClose={vi.fn()} onDownload={onDownload} />);
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(<FileNoPreview resource={budget} onClose={vi.fn()} onDownload={onDownload} onOpenInApp={onOpenInApp} />);
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(<EmptyFolder isRoot canCreate onUpload={vi.fn()} onCreateFolder={vi.fn()} />);
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(
<FileBrowser
currentPath="/" resources={[report]} isLoading={false} error={null} selectedResources={new Set()} uploadProgress={null} clipboard={null}
onNavigate={vi.fn()} onSetSelection={vi.fn()} onToggleSelect={vi.fn()} onPreviewFile={vi.fn()} onPreviewImage={vi.fn()} onDownload={noop}
onCreateFolder={noop} onUploadFiles={noop} onUploadFolder={noop} onCancelUpload={vi.fn()} onDelete={noop} onBatchDelete={noop}
onRename={noop} onBatchDownload={noop} onRefresh={noop} onClearSelection={vi.fn()} onCut={vi.fn()} onCopy={vi.fn()} onPaste={noop}
onMoveTo={noop} onCreateFile={noop} onDuplicate={noop} getImageUrl={async () => ''} 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');
});
});
@@ -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> = {}): 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<typeof vi.fn>;
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<string>(),
focusedName: null,
tabStopName: null,
dropTarget: null,
cutNames: new Set<string>(),
starredPaths: new Set<string>(),
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(<FileListView {...viewProps(h)} sortKey="name" sortDir="asc" onSort={vi.fn()} allSelected={false} someSelected={false} onToggleAll={vi.fn()} />);
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(<FileGridView {...viewProps(h)} />);
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(<FileListView {...viewProps(h)} touch sortKey="name" sortDir="asc" onSort={vi.fn()} allSelected={false} someSelected={false} onToggleAll={vi.fn()} />);
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(<FilePreviewModal name="notes.txt" onClose={onClose} onDownload={vi.fn()} getFileContent={() => 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<React.ComponentProps<typeof FileBrowser>> = {}) {
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(
<FileBrowser
currentPath="/"
resources={[docs, notes, budget]}
isLoading={false}
error={null}
selectedResources={new Set()}
uploadProgress={null}
clipboard={null}
onNavigate={vi.fn()}
onToggleSelect={vi.fn()}
onCreateFolder={noop}
onUploadFiles={noop}
onUploadFolder={noop}
onCancelUpload={vi.fn()}
onDelete={noop}
onBatchDelete={noop}
onRename={noop}
onBatchDownload={noop}
onRefresh={noop}
onClearSelection={vi.fn()}
onCut={vi.fn()}
onCopy={vi.fn()}
onPaste={noop}
onMoveTo={noop}
onCreateFile={noop}
onDuplicate={noop}
getImageUrl={async () => ''}
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<HTMLElement>('[data-resource="budget.xlsx"]')!;
fireEvent.focus(row);
fireEvent.keyDown(row, { key: 'Enter' });
expect(h.onShowFileInfo).toHaveBeenCalledWith('budget.xlsx');
});
});
+34 -5
View File
@@ -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<void>;
/** 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 && (
<ContextMenuItem icon={SquarePen} label={t("office_edit")} onClick={() => { closeMenu(); onEditFile(resource.name); }} />
)}
{/* Neither previewed nor edited here: Open shows what there is (pim-16). */}
{!isPreviewable(resource.name) && !(isOfficeEditable?.(resource.name) && onEditFile) && onShowFileInfo && (
<ContextMenuItem icon={File} label={t("open")} onClick={() => { closeMenu(); onShowFileInfo(resource.name); }} />
)}
{canOpenInApp?.(resource) && onOpenInApp && (
<ContextMenuItem icon={ExternalLink} label={tUx("open_in_app")} onClick={() => { 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)}
/>
+4 -2
View File
@@ -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 (
<EmptyState
className="h-full"
@@ -25,11 +27,11 @@ export function EmptyFolder({ isRoot, canCreate, onUpload, onCreateFolder }: Emp
<>
<Button size="sm" onClick={onUpload} className="gap-1.5">
<Upload className="w-4 h-4" />
{t("upload_files")}
{tMenu("files_upload_files")}
</Button>
<Button variant="outline" size="sm" onClick={onCreateFolder} className="gap-1.5">
<FolderPlus className="w-4 h-4" />
{t("new_folder")}
{tMenu("files_new_folder")}
</Button>
</>
) : undefined}
+28
View File
@@ -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 (
<span className={cn("flex min-w-0", className)} title={title ?? name} dir="auto">
<span className="truncate whitespace-pre">{head}</span>
{tail && <span className="shrink-0 whitespace-pre">{tail}</span>}
</span>
);
}
/** 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();
+61
View File
@@ -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 (
<PreviewShell name={resource.name} onClose={onClose} onDownload={onDownload} neighbours={neighbours}>
<div className="flex-1 min-h-0 flex items-center justify-center p-4">
<div
data-testid="file-no-preview"
className="bg-background rounded-lg p-8 max-w-sm w-full flex flex-col items-center gap-3 text-center"
onClick={(e) => e.stopPropagation()}
>
<ResourceIcon resource={resource} size="xl" options={ICON_OPTIONS} />
<p className="text-sm font-medium break-all" dir="auto">{resource.name}</p>
<p className="text-xs text-muted-foreground"><FileSize bytes={resource.contentLength} /></p>
<p className="text-sm text-muted-foreground">{tUx("no_preview")}</p>
<div className="flex flex-wrap justify-center gap-2 pt-1">
{onOpenInApp && (
<Button size="sm" onClick={onOpenInApp} className="gap-1.5">
<ExternalLink className="w-4 h-4" />
{tUx("open_in_app")}
</Button>
)}
{onDownload && (
<Button size="sm" variant={onOpenInApp ? "outline" : "default"} onClick={onDownload} className="gap-1.5">
<Download className="w-4 h-4" />
{t("download")}
</Button>
)}
</div>
</div>
</div>
</PreviewShell>
);
}
+3 -2
View File
@@ -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 && (
<div className="absolute inset-0" {...noMediaSavingProps(downloadsOff)}>
<ZoomableImage src={objectUrl} alt={name} onBackgroundClick={onClose} />
<ZoomableImage src={objectUrl} alt={name} onBackgroundClick={closeFromBackdrop} />
</div>
)}
+11 -32
View File
@@ -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}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 min-w-0">
<button
type="button"
data-item-action
onClick={(e) => {
e.stopPropagation();
if (touch && selecting) handlers.onToggleSelect(resource);
else if (e.ctrlKey || e.metaKey || e.shiftKey) handlers.onSelectClick(resource, e);
else handlers.onOpen(resource);
}}
onDoubleClick={(e) => e.stopPropagation()}
className={cn("truncate text-start hover:underline underline-offset-2 decoration-muted-foreground/50", resource.isDirectory && "font-medium")}
{/* Plain text, as in grid view and a file manager: a click selects
the row, a double-click or Enter opens it, a tap on touch opens
it (useTouchPress). A link here opened on the first click of a
double-click, and the second closed the preview again (pim-3). */}
<span
className={cn("truncate text-start", resource.isDirectory && "font-medium")}
title={note ? `${resource.name}\n${note}` : resource.name}
>
{resource.name}
</button>
</span>
{isStarred && <Star className="w-3.5 h-3.5 shrink-0 text-amber-400 fill-amber-400" aria-label={t("favorites")} />}
<ShareBadge resource={resource} t={t} />
</div>
@@ -427,16 +422,9 @@ function FolderTile({ resource, isSelected, isFocused, isTabStop, isDropTarget,
</span>
)}
<ResourceIcon resource={resource} size="sm" options={iconOptions} />
<button
type="button"
data-item-action
onClick={(e) => { e.stopPropagation(); if (touch && selecting) handlers.onToggleSelect(resource); else handlers.onOpen(resource); }}
onDoubleClick={(e) => e.stopPropagation()}
className="flex-1 min-w-0 truncate text-start text-sm font-medium"
title={resource.name}
>
<span className="flex-1 min-w-0 truncate text-start text-sm font-medium" title={resource.name}>
{resource.name}
</button>
</span>
{isStarred && <Star className="w-3.5 h-3.5 shrink-0 text-amber-400 fill-amber-400" aria-label={t("favorites")} />}
<ShareBadge resource={resource} t={t} />
<IconButton
@@ -493,16 +481,7 @@ function FileTile({ resource, isSelected, isFocused, isTabStop, isCut, selecting
)}
<div className="flex items-center gap-1 ps-2.5 pe-1 py-1.5 min-w-0">
<div className="min-w-0 flex-1">
<button
type="button"
data-item-action
onClick={(e) => { e.stopPropagation(); if (touch && selecting) handlers.onToggleSelect(resource); else handlers.onOpen(resource); }}
onDoubleClick={(e) => e.stopPropagation()}
className="block w-full truncate text-start text-sm"
title={resource.name}
>
{resource.name}
</button>
<MiddleCutName name={resource.name} className="w-full text-start text-sm" />
<div className="flex items-center gap-1 text-xs text-muted-foreground min-w-0">
<span className="truncate">
{!resource.isSymlink && <><FileSize bytes={resource.contentLength} /> · </>}{formatShortDate(resource.lastModified)}
+103 -5
View File
@@ -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<string | null>(null);
const [previewFile, setPreviewFile] = useState<string | null>(null);
// A file with no preview and no editor: its icon, size and Download (pim-16).
const [infoFile, setInfoFile] = useState<string | null>(null);
// WOPI document editing (#425): name of the file open in the editor overlay.
const [editFile, setEditFile] = useState<string | null>(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<UploadConflictQuestion | null>(null);
const resolveConflicts = useCallback<UploadConflictResolver>((conflicts) => resolveConflictsInTurn(
conflicts,
(conflict, index, total) => new Promise<ConflictAnswer>((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}
/>
)}
</div>
@@ -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 (
<FileNoPreview
resource={resource}
onClose={() => 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 = {}) {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} />
<UploadConflictDialog question={conflictQuestion} />
</div>
</div>
);
+3 -2
View File
@@ -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
</div>
)}
{imageUrl && !error && (
<ZoomableImage src={imageUrl} alt={name} onBackgroundClick={onClose} />
<ZoomableImage src={imageUrl} alt={name} onBackgroundClick={closeFromBackdrop} />
)}
{imageUrl && !error && <Watermark text={watermark} />}
</div>
+22 -2
View File
@@ -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. */}
+15
View File
@@ -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 "<owner account>:<node id>" (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))}`;
}
@@ -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;
}
/**
* "<name> 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<HTMLButtonElement>(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 (
<Dialog
isOpen
onClose={() => 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={
<>
<Button variant="ghost" onClick={() => answer(null)} className="sm:me-auto">{tConfirm("cancel")}</Button>
<Button variant="outline" onClick={() => choose("skip")}>{t("skip")}</Button>
<Button variant="outline" onClick={() => choose("keep")}>{t("keep_both")}</Button>
<Button ref={replaceRef} onClick={() => choose("replace")}>{t("replace")}</Button>
</>
}
>
{remaining > 1 && (
<label htmlFor={checkboxId} className="flex items-center gap-2 text-sm">
<input
id={checkboxId}
type="checkbox"
checked={applyToAll}
onChange={(e) => setApplyToAll(e.target.checked)}
className="h-4 w-4"
/>
{t("apply_to_all", { count: remaining })}
</label>
)}
</Dialog>
);
}
+230
View File
@@ -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<string, unknown> | null };
async function nodes(c: JmapClient): Promise<Node[]> {
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<Node[]> {
return (await nodes(c)).filter((n) => n.name.startsWith(PREFIX));
}
async function cleanUp(c: JmapClient): Promise<void> {
// 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<void> {
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<void> {
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<void> {
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<void> {
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();
});
});
@@ -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);
});
});
+75
View File
@@ -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<Map<string, UploadConflictChoice> | 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<string>();
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<ConflictAnswer>,
): Promise<Map<string, UploadConflictChoice> | null> {
const choices = new Map<string, UploadConflictChoice>();
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;
}
+15
View File
@@ -5677,6 +5677,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 الخاصة بك لتوقيع الرسائل وتشفيرها",
+15
View File
@@ -5677,6 +5677,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",
+15
View File
@@ -5415,6 +5415,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ů",
+15
View File
@@ -5677,6 +5677,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",
+15
View File
@@ -5415,6 +5415,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",
+15
View File
@@ -5677,6 +5677,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",
+15
View File
@@ -5415,6 +5415,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",
+15
View File
@@ -5677,6 +5677,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",
+15
View File
@@ -5677,6 +5677,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",
+15
View File
@@ -5415,6 +5415,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",
+15
View File
@@ -5308,6 +5308,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 שלך עבור חתימה והצפנה של הודעות דוא\"ל",
+15
View File
@@ -5677,6 +5677,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",
+15
View File
@@ -5415,6 +5415,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",
+15
View File
@@ -5415,6 +5415,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 証明書をインポートして管理します",
+15
View File
@@ -5415,6 +5415,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 인증서를 가져오고 관리해요",
+15
View File
@@ -5415,6 +5415,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",
+15
View File
@@ -5677,6 +5677,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 сертификатаа импортолж, удирдана",
+15
View File
@@ -5677,6 +5677,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",
+15
View File
@@ -5415,6 +5415,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",
+15
View File
@@ -5415,6 +5415,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",
+15
View File
@@ -5415,6 +5415,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",
+15
View File
@@ -5677,6 +5677,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",
+15
View File
@@ -5415,6 +5415,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 для подписи и шифрования писем",
+15
View File
@@ -5677,6 +5677,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",
+15
View File
@@ -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} หรือไม่",
+15
View File
@@ -5677,6 +5677,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",
+15
View File
@@ -5415,6 +5415,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 для підпису та шифрування електронних листів",
+15
View File
@@ -5689,6 +5689,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",
+15
View File
@@ -5677,6 +5677,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 憑證",
+15
View File
@@ -5415,6 +5415,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 证书以对邮件进行签名和加密",
@@ -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<typeof vi.fn> };
}
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);
});
});
+116 -6
View File
@@ -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<void>;
createDirectory: (name: string) => Promise<void>;
uploadFile: (file: File) => Promise<void>;
uploadFiles: (files: File[]) => Promise<UploadReport>;
uploadFolder: (files: File[]) => Promise<UploadReport>;
uploadFiles: (files: File[], options?: UploadOptions) => Promise<UploadReport>;
uploadFolder: (files: File[], options?: UploadOptions) => Promise<UploadReport>;
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<FileResource, 'name' | 'serverName' | 'isDirectory' | 'isSymlink'>[],
resolve: UploadConflictResolver | undefined,
): Promise<Map<string, UploadConflictChoice> | 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>): string {
if (!existingNames.has(name)) return name;
const dotIndex = name.lastIndexOf('.');
@@ -1083,7 +1119,7 @@ export const useFileStore = create<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<string>();
const abortController = new AbortController();
set({ uploadAbortController: abortController });
const totalFiles = files.length;
@@ -1220,6 +1313,14 @@ export const useFileStore = create<FileState>((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<FileState>((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)));