diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index d5823c0c4..188b27f20 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -5,6 +5,7 @@ import { addPlainLanguage } from "@/i18n/plain-language"; import { ThemeProvider } from "@/components/providers/theme-provider"; import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider"; import { PushBindingsHost } from "@/components/providers/push-bindings-host"; +import { FilesSyncPrompts } from "@/components/files-sync/files-sync-prompts"; import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider"; import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider"; import { TourProvider } from "@/components/tour/tour-provider"; @@ -34,6 +35,7 @@ import { NotificationActionsHost } from "@/components/providers/notification-act import { DesktopOsHost } from "@/components/desktop/desktop-os-host"; import { FileDropHost } from "@/components/file-drop/file-drop-host"; import { EditedFilesHost } from "@/components/desktop/edited-files-host"; +import { FilesSyncHost } from "@/components/providers/files-sync-host"; import { DevicePolicyHost } from "@/components/device/device-policy-host"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt"; @@ -97,8 +99,10 @@ export default async function LocaleLayout({ {!IS_LITE && } + {!IS_LITE && } {!IS_LITE && } + {!IS_LITE && } {!IS_LITE && } {!IS_LITE && } diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 34ac9f470..e2c8bd0a5 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -1,6 +1,8 @@ "use client"; import { freshLoginRequired } from "@/lib/app-lock/sign-out"; +import { ContextMenu, ContextMenuCheckItem } from "@/components/ui/context-menu"; +import { useContextMenu } from "@/hooks/use-context-menu"; import { Suspense, useState, useEffect, useRef, useCallback, useId, useMemo, useSyncExternalStore } from "react"; import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag"; import { forgetDesktopServer, getDesktopBridge } from "@/lib/desktop/bridge"; @@ -22,7 +24,6 @@ import { useThemeStore } from "@/stores/theme-store"; import { usePolicyStore } from "@/stores/policy-store"; import { useShallow } from "zustand/react/shallow"; import { useConfig } from "@/hooks/use-config"; -import { useMenuNavigation } from "@/hooks/use-menu-navigation"; import { apiFetch, getPathPrefix, toRouterPath, withBasePath } from "@/lib/browser-navigation"; import { cn } from "@/lib/utils"; import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy, KeyRound } from "@/components/icons"; @@ -369,7 +370,9 @@ function LoginPageContent() { const [expiredDraftsUnsaved, setExpiredDraftsUnsaved] = useState(false); const [showPassword, setShowPassword] = useState(false); const [shakeError, setShakeError] = useState(false); - const [showThemeMenu, setShowThemeMenu] = useState(false); + // The theme menu: the app's menu, as everywhere else. + const themeMenu = useContextMenu(); + const showThemeMenu = themeMenu.contextMenu.isOpen; const [savedUsernames, setSavedUsernames] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); @@ -408,12 +411,6 @@ function LoginPageContent() { const prevError = useRef(null); const themeMenuRef = useRef(null); const themeButtonRef = useRef(null); - const closeThemeMenu = useCallback(() => setShowThemeMenu(false), []); - const { menuRef: themeListRef, onKeyDown: onThemeMenuKeyDown } = useMenuNavigation({ - open: showThemeMenu, - onClose: closeThemeMenu, - triggerRef: themeButtonRef, - }); // Captured by handleSubmit when in mobile handoff mode; consumed by the // isAuthenticated effect to build the deep-link fragment. const mobileHandoffPayloadRef = useRef<{ server_url: string; username: string; password: string } | null>(null); @@ -737,9 +734,6 @@ function LoginPageContent() { inputRef.current && !inputRef.current.contains(event.target as Node)) { setShowSuggestions(false); } - if (themeMenuRef.current && !themeMenuRef.current.contains(event.target as Node)) { - setShowThemeMenu(false); - } }; document.addEventListener("mousedown", handleClickOutside); @@ -873,8 +867,8 @@ function LoginPageContent() { const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => { setTheme(newTheme); - setShowThemeMenu(false); - }, [setTheme]); + themeMenu.closeContextMenu(); + }, [setTheme, themeMenu]); // The server HTML, and every visit until /api/config answers: the card's // shape, the same one signed-out visitors of the app pages get first. Also @@ -1578,7 +1572,7 @@ function LoginPageContent() { - {showThemeMenu && ( -
- {THEME_OPTIONS.map((option) => { - const Icon = option.icon; - const isActive = theme === option.value; - return ( - - ); - })} -
- )} + + {THEME_OPTIONS.map((option) => ( + handleThemeSelect(option.value)} + /> + ))} +
@@ -1749,7 +1728,7 @@ function LoginPageContent() { - {showThemeMenu && ( -
- {THEME_OPTIONS.map((option) => { - const Icon = option.icon; - const isActive = theme === option.value; - return ( - - ); - })} -
- )} + + {THEME_OPTIONS.map((option) => ( + handleThemeSelect(option.value)} + /> + ))} +
diff --git a/app/boot-skeleton.css b/app/boot-skeleton.css index dc27f65a3..f2d87e532 100644 --- a/app/boot-skeleton.css +++ b/app/boot-skeleton.css @@ -223,6 +223,7 @@ [data-boot-settings-tab="calendar"] [data-sk-settings-tab="calendar"], [data-boot-settings-tab="contacts"] [data-sk-settings-tab="contacts"], [data-boot-settings-tab="files"] [data-sk-settings-tab="files"], + [data-boot-settings-tab="files_sync"] [data-sk-settings-tab="files_sync"], [data-boot-settings-tab="addons"] [data-sk-settings-tab="addons"], [data-boot-settings-tab="offline"] [data-sk-settings-tab="offline"], [data-boot-settings-tab="privacy"] [data-sk-settings-tab="privacy"], @@ -242,6 +243,7 @@ @custom-variant boot-no-settings-addons (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="addons"]) *)); @custom-variant boot-no-settings-offline (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="offline"]) *)); @custom-variant boot-settings-recovery (&:where([data-boot-settings-tabs~="recovery"] *)); +@custom-variant boot-settings-files-sync (&:where([data-boot-settings-tabs~="files_sync"] *)); /* Settings pages with one section have no section jump row (SectionNav). */ @custom-variant boot-settings-one-section (&:where([data-boot-settings-tab="security"] *, [data-boot-settings-tab="filters"] *, [data-boot-settings-tab="vacation"] *, [data-boot-settings-tab="recovery"] *, [data-boot-settings-tab="addons"] *)); diff --git a/components/boot-skeleton/settings-skeleton.tsx b/components/boot-skeleton/settings-skeleton.tsx index c49515063..82477c6bb 100644 --- a/components/boot-skeleton/settings-skeleton.tsx +++ b/components/boot-skeleton/settings-skeleton.tsx @@ -27,6 +27,8 @@ const OPTIONAL: Partial> = { calendar: "boot-no-calendar:hidden boot-no-settings-calendar:hidden", contacts: "boot-no-settings-contacts:hidden", files: "boot-no-files:hidden boot-no-settings-files:hidden", + // Desktop app only, while the files sync runs. + files_sync: "hidden boot-settings-files-sync:flex", addons: "boot-no-settings-addons:hidden", // Not in the static Lite build. offline: "boot-no-settings-offline:hidden", diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx index 2b48c34bd..b6dc61f6d 100644 --- a/components/calendar/__tests__/calendar-app.test.tsx +++ b/components/calendar/__tests__/calendar-app.test.tsx @@ -595,6 +595,21 @@ describe('CalendarApp task feedback', () => { }); }); +describe('CalendarApp quick editor', () => { + // Usability report 2026-11-04: Save stayed off until a title was typed. + // Google Calendar saves "(No title)"; so does the quick editor now. + it('saves an event without a title as "(No title)"', async () => { + renderApp(); + fireEvent.click(slots()[3]); + const quick = await screen.findByRole('dialog', { name: 'quick_create.aria_label' }, { timeout: 2000 }); + const save = within(quick).getByRole('button', { name: 'form.save' }); + expect(save).toBeEnabled(); + fireEvent.click(save); + await waitFor(() => expect(store.createEvent).toHaveBeenCalledTimes(1)); + expect(store.createEvent.mock.calls[0][1]).toMatchObject({ title: 'events.no_title', showWithoutTime: false }); + }); +}); + describe('CalendarApp creating in the month view', () => { it('opens the quick editor for an all-day event on a single click', async () => { setUp([], 'month'); @@ -611,6 +626,70 @@ describe('CalendarApp creating in the month view', () => { }); }); + // Usability report 2026-11-04: a drag over the month's days did nothing. + it('opens the quick editor for the days a drag picked and saves them as one all-day event', async () => { + setUp([], 'month'); + renderApp(); + // Thursday 15 to Tuesday 20 October, across two week rows. + const thursday = monthCell(/October 15, 2026/)!; + const tuesday = monthCell(/October 20, 2026/)!; + thursday.closest('[data-week]')!.getBoundingClientRect = () => ({ top: 0, bottom: 100, left: 0, right: 700 }) as DOMRect; + tuesday.closest('[data-week]')!.getBoundingClientRect = () => ({ top: 100, bottom: 200, left: 0, right: 700 }) as DOMRect; + thursday.getBoundingClientRect = () => ({ top: 0, bottom: 100, left: 300, right: 400 }) as DOMRect; + tuesday.getBoundingClientRect = () => ({ top: 100, bottom: 200, left: 100, right: 200 }) as DOMRect; + const mouse = { pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0 }; + fireEvent.pointerDown(thursday, { ...mouse, clientX: 350, clientY: 80 }); + fireEvent.pointerMove(thursday, { ...mouse, clientX: 150, clientY: 180 }); + fireEvent.pointerUp(thursday, { ...mouse, clientX: 150, clientY: 180 }); + + const quick = await screen.findByRole('dialog', { name: 'quick_create.aria_label' }); + expect(within(quick).getByText('events.all_day')).toBeInTheDocument(); + expect(within(quick).getByText(/Oct 15 2026.* – .*Oct 20 2026/)).toBeInTheDocument(); + // No title needed: it saves as "(No title)". + const save = within(quick).getByRole('button', { name: 'form.save' }); + expect(save).toBeEnabled(); + fireEvent.click(save); + await waitFor(() => expect(store.createEvent).toHaveBeenCalledTimes(1)); + expect(store.createEvent.mock.calls[0][1]).toMatchObject({ + title: 'events.no_title', start: '2026-10-15T00:00:00', duration: 'P6D', showWithoutTime: true, calendarIds: { cal: true }, + }); + }); + + it('opens the full editor on the same days from "More options"', async () => { + setUp([], 'month'); + renderApp(); + const thursday = monthCell(/October 15, 2026/)!; + const friday = monthCell(/October 16, 2026/)!; + thursday.closest('[data-week]')!.getBoundingClientRect = () => ({ top: 0, bottom: 100, left: 0, right: 700 }) as DOMRect; + thursday.getBoundingClientRect = () => ({ top: 0, bottom: 100, left: 300, right: 400 }) as DOMRect; + friday.getBoundingClientRect = () => ({ top: 0, bottom: 100, left: 400, right: 500 }) as DOMRect; + const mouse = { pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0 }; + fireEvent.pointerDown(thursday, { ...mouse, clientX: 350, clientY: 80 }); + fireEvent.pointerMove(thursday, { ...mouse, clientX: 450, clientY: 80 }); + fireEvent.pointerUp(thursday, { ...mouse, clientX: 450, clientY: 80 }); + const quick = await screen.findByRole('dialog', { name: 'quick_create.aria_label' }); + fireEvent.click(within(quick).getByRole('button', { name: 'quick_create.more_options' })); + const dates = await waitFor(() => { + const found = Array.from(document.querySelectorAll('input[type="date"]')).map((input) => input.value); + expect(found.length).toBeGreaterThan(1); + return found; + }); + expect(dates.slice(0, 2)).toEqual(['2026-10-15', '2026-10-16']); + }); + + it('offers no new event in the month view without a calendar that can take one', () => { + setUp([], 'month'); + useCalendarStore.setState({ + calendars: [{ ...calendars[0], myRights: { ...RIGHTS, mayWriteAll: false, mayWriteOwn: false } }], + } as never); + renderApp(); + const day = monthCell(/October 15, 2026/)!; + fireEvent.click(day); + fireEvent.keyDown(day.querySelector('[data-day-button]')!, { key: 'ArrowRight', shiftKey: true }); + expect(screen.queryByRole('dialog', { name: 'quick_create.aria_label' })).toBeNull(); + expect(day.closest('[role="grid"]')!.getAttribute('aria-multiselectable')).toBeNull(); + }); + it('opens the day on a phone tap instead', () => { media.mobile = true; media.desktop = false; diff --git a/components/calendar/__tests__/calendar-month-view-drag-create.test.tsx b/components/calendar/__tests__/calendar-month-view-drag-create.test.tsx new file mode 100644 index 000000000..f87ee1e85 --- /dev/null +++ b/components/calendar/__tests__/calendar-month-view-drag-create.test.tsx @@ -0,0 +1,267 @@ +import { render, fireEvent, cleanup, act, screen } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach, beforeEach, beforeAll } from 'vitest'; +import type { CalendarEvent, Calendar } from '@/lib/jmap/types'; + +// Usability report 2026-11-04: in the month view a drag over the days did +// nothing. It now picks days, as in Google Calendar and Outlook, and opens +// the quick editor for an all-day event over them. + +const announce = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/announce', async (importOriginal) => ({ + ...(await importOriginal()), + announce, +})); + +import { CalendarMonthView } from '../calendar-month-view'; + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; +const standup = { + id: 'e1', '@type': 'Event', uid: 'e1', title: 'Standup', start: '2026-11-05T00:00:00', duration: 'P1D', + showWithoutTime: true, calendarIds: { 'cal-1': true }, +} as unknown as CalendarEvent; + +type Props = React.ComponentProps; + +function renderMonth(overrides: Partial = {}) { + const props: Props = { + focus: { date: new Date(2026, 10, 4), nonce: 0 }, + windowKey: 'month:2026-11-04', + // Monday 2 Nov to Sunday 22 Nov 2026: three week rows. + rangeStart: new Date(2026, 10, 2), + rangeEnd: new Date(2026, 10, 22), + selectedDate: new Date(2026, 10, 4), + events: [standup], + calendars, + onSelectDate: vi.fn(), + onSelectEvent: vi.fn(), + onCreateAllDay: vi.fn(), + onCreateRange: vi.fn(), + onOpenDay: vi.fn(), + onContextMenuEmpty: vi.fn(), + ...overrides, + }; + const view = render(); + layOut(props.isMobile ? false : document.documentElement.dir === 'rtl'); + return { props, ...view }; +} + +/** + * jsdom lays nothing out: give the week rows and day cells the boxes a + * 700 px wide grid has (100 px per day and row), mirrored in a + * right-to-left grid. + */ +function layOut(rtl: boolean) { + const box = (left: number, top: number, width: number, height: number) => + () => ({ left, top, right: left + width, bottom: top + height, width, height, x: left, y: top, toJSON() {} }) as DOMRect; + document.querySelectorAll('[data-week]').forEach((row, r) => { + row.getBoundingClientRect = box(0, r * 100, 700, 100); + row.querySelectorAll('[data-month-day]').forEach((cell, c) => { + cell.getBoundingClientRect = box((rtl ? 6 - c : c) * 100, r * 100, 100, 100); + }); + }); +} + +const cell = (key: string) => document.querySelector(`[data-month-day="${key}"]`)!; +/** The middle of a day's box, in the layout above. */ +function pointOf(key: string) { + const rect = cell(key).getBoundingClientRect(); + return { clientX: rect.left + 50, clientY: rect.top + 80 }; +} +const picked = () => Array.from(document.querySelectorAll('[data-month-day][aria-selected="true"]')).map((el) => el.getAttribute('data-month-day')); + +const mouse = { pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0 }; +function drag(from: string, to: string, via: string[] = [], release = true) { + const grid = cell(from); + fireEvent.pointerDown(grid, { ...mouse, ...pointOf(from) }); + for (const key of [...via, to]) fireEvent.pointerMove(grid, { ...mouse, ...pointOf(key) }); + if (release) fireEvent.pointerUp(grid, { ...mouse, ...pointOf(to) }); +} + +beforeAll(() => { + Element.prototype.setPointerCapture ??= () => {}; + Element.prototype.releasePointerCapture ??= () => {}; +}); +beforeEach(() => announce.mockClear()); +afterEach(() => { + cleanup(); + document.documentElement.dir = ''; + vi.useRealTimers(); +}); + +describe('Month view: dragging over days', () => { + it('picks the days in reading order across week rows and creates over them', () => { + const { props } = renderMonth(); + drag('2026-11-05', '2026-11-10', ['2026-11-06', '2026-11-09'], false); + // Thursday to next Tuesday, wrapping at the end of the row; the + // selected day (Wednesday 4th) keeps its own mark. + expect(picked()).toEqual(['2026-11-04', '2026-11-05', '2026-11-06', '2026-11-07', '2026-11-08', '2026-11-09', '2026-11-10']); + expect(cell('2026-11-07').className).toContain('bg-primary/15'); + fireEvent.pointerUp(cell('2026-11-05'), { ...mouse, ...pointOf('2026-11-10') }); + + expect(props.onCreateRange).toHaveBeenCalledTimes(1); + expect(props.onCreateRange).toHaveBeenCalledWith(new Date(2026, 10, 5), new Date(2026, 10, 10)); + expect(props.onCreateAllDay).not.toHaveBeenCalled(); + }); + + it('works backwards too', () => { + const { props } = renderMonth(); + drag('2026-11-10', '2026-11-05', ['2026-11-09', '2026-11-06']); + expect(props.onCreateRange).toHaveBeenCalledWith(new Date(2026, 10, 5), new Date(2026, 10, 10)); + }); + + it('mirrors in a right-to-left grid: the day to the left is the next one', () => { + document.documentElement.dir = 'rtl'; + const { props } = renderMonth(); + // Thursday 5th sits at x 200-300; Saturday 7th to its left at 0-100. + expect(pointOf('2026-11-07').clientX).toBeLessThan(pointOf('2026-11-05').clientX); + drag('2026-11-05', '2026-11-07'); + expect(props.onCreateRange).toHaveBeenCalledWith(new Date(2026, 10, 5), new Date(2026, 10, 7)); + }); + + it('leaves a click on the empty space of a day to the click: one all-day event there', () => { + const { props } = renderMonth(); + const day = cell('2026-11-12'); + fireEvent.pointerDown(day, { ...mouse, ...pointOf('2026-11-12') }); + fireEvent.pointerUp(day, { ...mouse, ...pointOf('2026-11-12') }); + fireEvent.click(day); + expect(props.onCreateRange).not.toHaveBeenCalled(); + expect(props.onCreateAllDay).toHaveBeenCalledWith(new Date(2026, 10, 12)); + }); + + it("doesn't count the click that ends a drag as a click on the last day", () => { + const { props } = renderMonth(); + drag('2026-11-12', '2026-11-13'); + fireEvent.click(cell('2026-11-13')); + expect(props.onCreateRange).toHaveBeenCalledTimes(1); + expect(props.onCreateAllDay).not.toHaveBeenCalled(); + }); + + it('starts nothing from an event bar, the date number or "+N more"', () => { + // Six events on the 12th: some go under "+N more". + const busy = Array.from({ length: 6 }, (_, i) => ({ ...standup, id: `b${i}`, uid: `b${i}`, title: `Busy ${i}`, start: '2026-11-12T00:00:00' })); + const { props } = renderMonth({ events: [standup, ...busy] }); + const bar = screen.getByRole('button', { name: /Standup/ }); + const dateNumber = cell('2026-11-12').querySelector('[data-day-button]')!; + const more = screen.getByText('events.more'); + for (const target of [bar, dateNumber, more]) { + fireEvent.pointerDown(target, { ...mouse, ...pointOf('2026-11-05') }); + fireEvent.pointerMove(target, { ...mouse, ...pointOf('2026-11-06') }); + fireEvent.pointerUp(target, { ...mouse, ...pointOf('2026-11-06') }); + } + expect(props.onCreateRange).not.toHaveBeenCalled(); + expect(picked()).toEqual(['2026-11-04']); + }); + + it('picks nothing where no calendar can take a new event', () => { + const { props } = renderMonth({ onCreateRange: undefined, onCreateAllDay: undefined }); + drag('2026-11-05', '2026-11-10', ['2026-11-06'], false); + expect(picked()).toEqual(['2026-11-04']); + fireEvent.pointerUp(cell('2026-11-05'), { ...mouse, ...pointOf('2026-11-10') }); + expect(props.onOpenDay).not.toHaveBeenCalled(); + }); + + it('reads the days out while they are picked', async () => { + renderMonth(); + drag('2026-11-05', '2026-11-10', [], false); + expect(announce).toHaveBeenLastCalledWith( + expect.stringMatching(/^selected_range$/), + expect.objectContaining({ key: 'calendar-month-selection' }), + ); + }); + + it('marks the days of the quick editor and spans its placeholder over them', () => { + renderMonth({ + pendingPreview: { start: new Date(2026, 10, 5), end: new Date(2026, 10, 10), title: 'Trip', allDay: true, calendarId: 'cal-1' }, + }); + expect(picked()).toEqual(['2026-11-04', '2026-11-05', '2026-11-06', '2026-11-07', '2026-11-08', '2026-11-09', '2026-11-10']); + const bars = Array.from(document.querySelectorAll('[data-pending-preview]')); + // Thursday to Sunday in the first row, Monday and Tuesday in the next. + expect(bars.map((bar) => bar.closest('[data-month-day]')!.getAttribute('data-month-day'))).toEqual(['2026-11-05', '2026-11-09']); + expect(bars.map((bar) => (bar.parentElement as HTMLElement).style.width)).toEqual(['calc(400% + 2px)', 'calc(200% + 0px)']); + }); +}); + +describe('Month view: picking days by keyboard', () => { + const key = (el: HTMLElement, k: string, shiftKey = false) => fireEvent.keyDown(el, { key: k, shiftKey }); + const dateButton = (day: string) => cell(day).querySelector('[data-day-button]')!; + + it('grows the selection with Shift+arrows and creates over it with Enter', () => { + const { props } = renderMonth(); + key(dateButton('2026-11-04'), 'ArrowRight', true); + key(dateButton('2026-11-05'), 'ArrowDown', true); + expect(picked()).toEqual(expect.arrayContaining(['2026-11-04', '2026-11-05', '2026-11-08', '2026-11-12'])); + expect(announce).toHaveBeenLastCalledWith('selected_range enter_hint', expect.anything()); + key(dateButton('2026-11-12'), 'Enter'); + expect(props.onCreateRange).toHaveBeenCalledWith(new Date(2026, 10, 4), new Date(2026, 10, 12)); + expect(props.onOpenDay).not.toHaveBeenCalled(); + }); + + it('keeps Enter opening the day without a selection, and Escape drops one', () => { + const { props } = renderMonth(); + key(dateButton('2026-11-04'), 'ArrowRight', true); + key(dateButton('2026-11-05'), 'Escape'); + expect(picked()).toEqual(['2026-11-04']); + const enter = fireEvent.keyDown(dateButton('2026-11-05'), { key: 'Enter' }); + expect(enter).toBe(true); + expect(props.onCreateRange).not.toHaveBeenCalled(); + }); + + it('drops the selection on a plain arrow', () => { + const { props } = renderMonth(); + key(dateButton('2026-11-04'), 'ArrowRight', true); + key(dateButton('2026-11-05'), 'ArrowRight'); + key(dateButton('2026-11-06'), 'Enter'); + expect(props.onCreateRange).not.toHaveBeenCalled(); + }); +}); + +describe('Month view: picking days on a touch screen', () => { + const finger = { pointerId: 7, pointerType: 'touch', isPrimary: true, button: 0 }; + + it('starts after a long press, then follows the finger', () => { + vi.useFakeTimers(); + const { props } = renderMonth(); + const start = cell('2026-11-05'); + fireEvent.pointerDown(start, { ...finger, ...pointOf('2026-11-05') }); + act(() => { vi.advanceTimersByTime(500); }); + expect(picked()).toContain('2026-11-05'); + fireEvent.pointerMove(start, { ...finger, ...pointOf('2026-11-12') }); + // The grid doesn't scroll under a finger picking days. + const move = new Event('touchmove', { cancelable: true, bubbles: true }); + start.dispatchEvent(move); + expect(move.defaultPrevented).toBe(true); + fireEvent.pointerUp(start, { ...finger, ...pointOf('2026-11-12') }); + expect(props.onCreateRange).toHaveBeenCalledWith(new Date(2026, 10, 5), new Date(2026, 10, 12)); + // The long press's own menu doesn't open over the new event. + fireEvent.contextMenu(start); + expect(props.onContextMenuEmpty).not.toHaveBeenCalled(); + }); + + it('lets a swipe scroll: nothing is picked', () => { + vi.useFakeTimers(); + const { props } = renderMonth(); + const start = cell('2026-11-05'); + fireEvent.pointerDown(start, { ...finger, ...pointOf('2026-11-05') }); + fireEvent.pointerMove(start, { ...finger, clientX: 150, clientY: 140 }); + act(() => { vi.advanceTimersByTime(500); }); + const move = new Event('touchmove', { cancelable: true, bubbles: true }); + start.dispatchEvent(move); + expect(move.defaultPrevented).toBe(false); + fireEvent.pointerUp(start, { ...finger, ...pointOf('2026-11-12') }); + expect(picked()).toEqual(['2026-11-04']); + expect(props.onCreateRange).not.toHaveBeenCalled(); + }); + + it('keeps the long-press menu of a single day', () => { + vi.useFakeTimers(); + const { props } = renderMonth(); + const start = cell('2026-11-05'); + fireEvent.pointerDown(start, { ...finger, ...pointOf('2026-11-05') }); + act(() => { vi.advanceTimersByTime(500); }); + fireEvent.contextMenu(start); + expect(props.onContextMenuEmpty).not.toHaveBeenCalled(); + fireEvent.pointerUp(start, { ...finger, ...pointOf('2026-11-05') }); + expect(props.onCreateRange).not.toHaveBeenCalled(); + expect(props.onContextMenuEmpty).toHaveBeenCalledWith(expect.anything(), new Date(2026, 10, 5), undefined, true); + }); +}); diff --git a/components/calendar/__tests__/calendar-week-view-drag-create.test.tsx b/components/calendar/__tests__/calendar-week-view-drag-create.test.tsx new file mode 100644 index 000000000..8c8aa9331 --- /dev/null +++ b/components/calendar/__tests__/calendar-week-view-drag-create.test.tsx @@ -0,0 +1,141 @@ +import { render, fireEvent, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; +import { CalendarWeekView } from '../calendar-week-view'; +import { CalendarDayView } from '../calendar-day-view'; +import type { CalendarEvent, Calendar } from '@/lib/jmap/types'; + +// Usability report 2026-11-04: dragging over an empty stretch of a day drew +// a see-through selection the events showed through, and the new event's +// placeholder lay over the events it overlapped ("Lernblock" hidden behind +// it) instead of taking a column beside them. + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; + +function timed(id: string, start: string, duration: string): CalendarEvent { + return { + id, '@type': 'Event', uid: id, title: id, start, duration, timeZone: null, + showWithoutTime: false, calendarIds: { 'cal-1': true }, + } as unknown as CalendarEvent; +} + +const lernblock = timed('Lernblock', '2026-09-09T12:30:00', 'PT1H'); +const draft = { + start: new Date(2026, 8, 9, 11, 45), end: new Date(2026, 8, 9, 15, 45), + title: '(No title)', allDay: false, calendarId: 'cal-1', +}; + +type WeekProps = React.ComponentProps; + +function renderWeek(overrides: Partial = {}) { + const props: WeekProps = { + focus: { date: new Date(2026, 8, 9), nonce: 0 }, + windowKey: 'week:2026-09-07', + rangeStart: new Date(2026, 8, 7), + rangeEnd: new Date(2026, 8, 13), + selectedDate: new Date(2026, 8, 9), + events: [], + calendars, + onSelectDate: vi.fn(), + onSelectEvent: vi.fn(), + onCreateAtTime: vi.fn(), + ...overrides, + }; + return { props, ...render() }; +} + +/** The time column of a day: the grid cell holding its hour slots. */ +function column(key: string): HTMLElement { + const slot = document.querySelector(`[data-slot^="${key}T"]`); + const cell = slot?.closest('[role="gridcell"]'); + if (!cell) throw new Error(`no column for ${key}`); + return cell; +} + +/** The pixel offset of a time in a column whose hours are 60 px tall. */ +function yOf(key: string, minutes: number): number { + const first = Number(document.querySelector(`[data-slot^="${key}T"]`)!.getAttribute('data-slot')!.split('T')[1]); + return minutes - first * 60; +} + +/** The positioned box around the drawn placeholder of the new event. */ +function placeholderBox(): HTMLElement { + const preview = document.querySelector('[data-pending-preview]'); + if (!preview) throw new Error('no placeholder'); + return preview.style.width ? preview : preview.parentElement!; +} + +function eventBox(id: string): HTMLElement { + const box = Array.from(document.querySelectorAll('[data-calendar-event]')) + .find((el) => el.textContent?.includes(id)); + if (!box) throw new Error(`no event ${id}`); + return box; +} + +beforeAll(() => { + // jsdom has no pointer capture. + Element.prototype.setPointerCapture ??= () => {}; + Element.prototype.releasePointerCapture ??= () => {}; +}); + +afterEach(() => cleanup()); + +describe('Week view drag-to-create', () => { + it('draws the selection opaque and above the events it passes over', () => { + renderWeek({ events: [lernblock] }); + const col = column('2026-09-09'); + fireEvent.pointerDown(col, { pointerId: 1, pointerType: 'mouse', button: 0, clientY: yOf('2026-09-09', 675) }); + fireEvent.pointerMove(col, { pointerId: 1, pointerType: 'mouse', clientY: yOf('2026-09-09', 975) }); + + const selection = col.querySelector('[data-drag-selection]'); + expect(selection).not.toBeNull(); + expect(selection!.textContent).toContain('11:15 – 16:15'); + // An opaque fill: the event under it doesn't show through. + expect(selection!.style.backgroundColor).toContain('var(--color-background)'); + expect(selection!.className).toContain('z-30'); + expect(eventBox('Lernblock').className).toContain('z-10'); + }); + + it('creates the range the selection showed', () => { + const { props } = renderWeek({ events: [lernblock] }); + const col = column('2026-09-09'); + fireEvent.pointerDown(col, { pointerId: 1, pointerType: 'mouse', button: 0, clientY: yOf('2026-09-09', 675) }); + fireEvent.pointerMove(col, { pointerId: 1, pointerType: 'mouse', clientY: yOf('2026-09-09', 975) }); + fireEvent.pointerUp(col, { pointerId: 1, pointerType: 'mouse', clientY: yOf('2026-09-09', 940) }); + + expect(props.onCreateAtTime).toHaveBeenCalledWith(new Date(2026, 8, 9, 11, 15), new Date(2026, 8, 9, 16, 15)); + }); +}); + +describe('The new event beside the events it overlaps', () => { + it('shares the width with them in the week view', () => { + renderWeek({ events: [lernblock], pendingPreview: draft }); + expect(placeholderBox().style.width).toBe('50%'); + expect(eventBox('Lernblock').style.width).toBe('50%'); + // Each in its own half. + expect(placeholderBox().style.insetInlineStart).not.toBe(eventBox('Lernblock').style.insetInlineStart); + }); + + it('keeps the whole width where nothing overlaps it', () => { + renderWeek({ events: [timed('Later', '2026-09-09T17:00:00', 'PT1H')], pendingPreview: draft }); + expect(placeholderBox().style.width).toBe('100%'); + }); + + it('shares the width with them in the day view', () => { + render( + , + ); + expect(placeholderBox().style.width).toBe('50%'); + expect(eventBox('Lernblock').style.width).toBe('50%'); + }); +}); diff --git a/components/calendar/__tests__/empty-space-menu-keys.test.tsx b/components/calendar/__tests__/empty-space-menu-keys.test.tsx new file mode 100644 index 000000000..9d1dd0585 --- /dev/null +++ b/components/calendar/__tests__/empty-space-menu-keys.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useSettingsStore } from '@/stores/settings-store'; +import { EmptySpaceContextMenu } from '../empty-space-context-menu'; + +// The calendar grid's right-click menu shows the keys that do the same (N, T), as the +// mail menus do; with single keys off they do nothing, so they aren't shown. + +const props = { + position: { x: 10, y: 10 }, + isOpen: true, + onClose: vi.fn(), + menuRef: { current: null }, + onNewEvent: vi.fn(), + onNewAllDayEvent: vi.fn(), + onGoToToday: vi.fn(), +}; + +const keyOf = (testId: string) => screen.getByTestId(testId).querySelector('[data-menu-shortcut]')?.textContent ?? null; + +afterEach(() => useSettingsStore.setState({ singleKeyShortcuts: true } as never)); + +describe('the calendar grid menu', () => { + it('shows N for a new event and T for today', () => { + useSettingsStore.setState({ singleKeyShortcuts: true } as never); + render(); + expect(keyOf('grid-new-event')).toBe('N'); + expect(keyOf('grid-go-to-today')).toBe('T'); + }); + + it('shows none with single keys off', () => { + useSettingsStore.setState({ singleKeyShortcuts: false } as never); + render(); + expect(keyOf('grid-new-event')).toBeNull(); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 81ced90b4..1b7ebe31c 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -7,7 +7,7 @@ import { useTranslations } from "next-intl"; import { Plus, ListTodo, CalendarDays, Upload, Globe, RefreshCw, Search } from "@/components/icons"; import { addMonths, subMonths, addWeeks, subWeeks, addDays, subDays, - format, parseISO, startOfWeek, + differenceInCalendarDays, format, parseISO, startOfWeek, } from "date-fns"; import { useCalendarStore } from "@/stores/calendar-store"; import { isCalendarViewMode, type CalendarViewMode } from "@/stores/calendar-store"; @@ -1269,6 +1269,18 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = handleGridCreate(start, start, true); }, [handleGridCreate]); + // Days picked in the month view: an all-day event from the first to the + // last, in the same quick editor. Not offered without a calendar that can + // take it (read-only and subscribed ones only). + const canCreateEvents = writableCalendars.length > 0; + const handleGridCreateDays = useCallback((first: Date, last: Date) => { + const start = new Date(first); + start.setHours(0, 0, 0, 0); + const end = new Date(last); + end.setHours(0, 0, 0, 0); + handleGridCreate(start, end, true); + }, [handleGridCreate]); + const closeQuickCreate = useCallback(() => setQuickDraft(null), []); // The create button and `n` make what the view shows: a task in Tasks. @@ -1289,14 +1301,19 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const handleQuickCreateSave = useCallback(async () => { if (!quickDraft) return; - const { start, end, title, calendarId, allDay } = quickDraft; + const { start, end, calendarId, allDay } = quickDraft; + // Saving without a title is allowed, as in Google Calendar: the event + // is called "(No title)" in the user's language. + const title = quickDraft.title.trim() || t("events.no_title"); if (allDay) { setQuickDraft(null); + // Every day from the first to the last one picked (a drag in the month view). + const days = Math.max(1, differenceInCalendarDays(end, start) + 1); await handleSaveEvent({ uid: generateUUID(), - title: title.trim(), + title, start: format(start, "yyyy-MM-dd'T'00:00:00"), - duration: "P1D", + duration: `P${days}D`, timeZone: null, showWithoutTime: true, calendarIds: { [calendarId]: true }, @@ -1315,7 +1332,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // as the full editor's fields, so they are labelled with that zone. await handleSaveEvent({ uid: generateUUID(), - title: title.trim(), + title, start: format(start, "yyyy-MM-dd'T'HH:mm:ss"), duration, timeZone: getEffectiveTimeZone(), @@ -1325,7 +1342,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = freeBusyStatus: "busy", privacy: "public", }); - }, [quickDraft, handleSaveEvent]); + }, [quickDraft, handleSaveEvent, t]); // While the quick editor is open its placeholder takes the preview slot. const gridPreview = useMemo(() => { @@ -1984,7 +2001,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onContextMenuEvent={handleContextMenuEvent} onContextMenuEmpty={handleContextMenuEmpty} onReplyEvent={handleReplyEvent} - onCreateAllDay={handleGridCreateAllDay} + onCreateAllDay={canCreateEvents ? handleGridCreateAllDay : undefined} + onCreateRange={canCreateEvents ? handleGridCreateDays : undefined} firstDayOfWeek={firstDayOfWeek} isMobile={isMobile} pendingPreview={gridPreview} diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index 8368b0429..d5bf5c515 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -10,7 +10,7 @@ import { EventCard, eventFillClass, eventFillStyle } from "./event-card"; import { CalendarTaskChip } from "./task-chip"; import { QuickEventInput } from "./quick-event-input"; import { AllHoursToggle, HiddenEventsIndicator } from "./display-hours-controls"; -import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils"; +import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, pendingTimedMinutes, PENDING_LAYOUT_ID } from "@/lib/calendar-utils"; import { clipToDisplayHours, partitionByDisplayHours } from "@/lib/calendar-display-range"; import { displayNow, isDisplayToday } from "@/lib/timezone"; import { groupTasksByDueDay } from "@/lib/calendar-tasks"; @@ -203,6 +203,25 @@ export function CalendarDayView({ return map; }, [days, eventsByDay, hours]); + // The new event being created takes a column beside the events it + // overlaps, as it will once saved, instead of covering them. Only its + // times count: typing its title doesn't redo the layout. + const pendingStartMs = pendingPreview && !pendingPreview.allDay ? pendingPreview.start.getTime() : null; + const pendingEndMs = pendingPreview && !pendingPreview.allDay ? pendingPreview.end.getTime() : null; + const pendingLayout = useMemo(() => { + if (pendingStartMs === null || pendingEndMs === null) return null; + const preview = { start: new Date(pendingStartMs), end: new Date(pendingEndMs) }; + const day = days.find((d) => isSameDay(d, preview.start)); + const bounds = day ? pendingTimedMinutes(preview, day) : null; + if (!day || !bounds) return null; + const key = format(day, "yyyy-MM-dd"); + const partition = partitionByDisplayHours(eventsByDay.get(key)?.timed ?? [], day, hours); + const all = layoutOverlappingEvents(partition.visible, day, bounds); + const pending = all.find((layout) => layout.event.id === PENDING_LAYOUT_ID); + if (!pending) return null; + return { key, layouts: all.filter((layout) => layout.event.id !== PENDING_LAYOUT_ID), pending }; + }, [pendingStartMs, pendingEndMs, days, eventsByDay, hours]); + const scrollToFocus = useCallback(() => { const root = rootRef.current; if (!root) return; @@ -279,7 +298,7 @@ export function CalendarDayView({ }, []); const { - dragCreate, handleGridPointerDown, handleGridPointerMove, handleGridPointerUp, + dragCreate, handleGridPointerDown, handleGridPointerMove, handleGridPointerUp, handleGridPointerCancel, resizeVisual, handleResizePointerDown, handleResizePointerMove, handleResizePointerUp, quickCreate, handleSlotClick, handleSlotDoubleClick, handleQuickCreateSubmit, handleQuickCreateCancel, dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop, @@ -519,7 +538,7 @@ export function CalendarDayView({ const key = format(day, "yyyy-MM-dd"); const today = isDisplayToday(day); const dayLayout = layoutByDay.get(key); - const layouted = dayLayout?.layouts ?? []; + const layouted = (pendingLayout?.key === key ? pendingLayout.layouts : dayLayout?.layouts) ?? []; return (
handleGridPointerDown(e, key, day)} onPointerMove={handleGridPointerMove} onPointerUp={handleGridPointerUp} + onPointerCancel={handleGridPointerCancel} onDragOver={(e) => handleColumnDragOver(e, key)} onDragLeave={handleColumnDragLeave} onDrop={(e) => handleColumnDrop(e, day)} @@ -656,8 +676,11 @@ export function CalendarDayView({ {dragCreate?.dayKey === key && (
)} - {pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && ( + {pendingPreview && pendingLayout?.key === key && ( (() => { - const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes(); - const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes(); - const durationMin = Math.max(15, endMin - startMin); - if (startMin + durationMin <= hours.startMinutes || startMin >= hours.endMinutes) return null; - const clip = clipToDisplayHours(startMin, startMin + durationMin, hours); + const { startMinutes: startMin, endMinutes: endMin, column, totalColumns } = pendingLayout.pending; + if (endMin <= hours.startMinutes || startMin >= hours.endMinutes) return null; + const clip = clipToDisplayHours(startMin, endMin, hours); const cal = calendars.find(c => c.id === pendingPreview.calendarId); const color = paint(cal?.color || "#3b82f6"); - // Drawn like a saved event, so the new event shows where - // it will land while it is being created. + // Drawn like a saved event, in the column it will take, so the new + // event shows where it will land while it is being created. return (
-
- {pendingPreview.title} -
-
- {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)} +
+
+ {pendingPreview.title} +
+
+ {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(endMin, timeFormat)} +
); diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index 6ad3d854a..c5c4ef7a8 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -1,8 +1,8 @@ "use client"; -import { useMemo, useState, useCallback, useRef, useLayoutEffect, type DragEvent } from "react"; +import { useMemo, useState, useCallback, useEffect, useRef, useLayoutEffect, type DragEvent } from "react"; import { useTranslations } from "next-intl"; -import { format, parseISO, eachDayOfInterval, addDays, isSameDay } from "date-fns"; +import { format, parseISO, eachDayOfInterval, addDays, isSameDay, differenceInCalendarDays, startOfDay } from "date-fns"; import { cn } from "@/lib/utils"; import { EventCard } from "./event-card"; import { CalendarTaskChip } from "./task-chip"; @@ -26,6 +26,8 @@ import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; import type { MoveEventHandler } from "@/hooks/use-time-grid-interactions"; import { useCvdColor } from "@/hooks/use-cvd-color"; import { useEventUserEmails } from "@/hooks/use-event-user-emails"; +import { isInMonthRange, useMonthRangeSelect, type MonthRange } from "@/hooks/use-month-range-select"; +import { announce } from "@/lib/announce"; interface CalendarMonthViewProps extends ScrollWindowViewProps { selectedDate: Date; @@ -41,6 +43,12 @@ interface CalendarMonthViewProps extends ScrollWindowViewProps { onReplyEvent?: (anchor: HTMLElement, event: CalendarEvent) => void; /** A click on a day's empty space: a new all-day event there (wide screens). */ onCreateAllDay?: (date: Date) => void; + /** + * Days picked by a drag over empty day space, a long press and drag on + * touch, or Shift+arrows and Enter: a new all-day event over them. Left + * out where no calendar can take a new event. + */ + onCreateRange?: (start: Date, end: Date) => void; /** Open a day in the day view (its date number, or its "N more" link). */ onOpenDay?: (date: Date) => void; firstDayOfWeek?: number; @@ -162,6 +170,7 @@ export function CalendarMonthView({ onContextMenuEmpty, onReplyEvent, onCreateAllDay, + onCreateRange, onOpenDay, isMobile, pendingPreview, @@ -174,6 +183,7 @@ export function CalendarMonthView({ onMoveEvent, }: CalendarMonthViewProps) { const t = useTranslations("calendar"); + const tSelect = useTranslations("calendar_month_select"); const paint = useCvdColor(); const describeError = useCalendarErrorMessage(); // An event of another signed-in login is read with that login's addresses. @@ -271,6 +281,36 @@ export function CalendarMonthView({ const topSentinelRef = useRef(null); const bottomSentinelRef = useRef(null); + // Picking days to create an event over them (drag, long press, Shift+arrows). + const rangeSelect = useMonthRangeSelect({ + scrollRef, + enabled: !!onCreateRange, + onCreateRange: (start, end) => onCreateRange?.(start, end), + onContextMenuDay: onContextMenuEmpty ? (e, day) => onContextMenuEmpty(e, day, undefined, true) : undefined, + }); + // The days stay marked while the quick editor for them is open; Escape + // or a click elsewhere closes it and the mark goes with it. + const draftRange = useMemo(() => { + if (!pendingPreview?.allDay) return null; + const start = startOfDay(pendingPreview.start); + const end = startOfDay(pendingPreview.end); + return end < start ? { start, end: start } : { start, end }; + }, [pendingPreview?.allDay, pendingPreview?.start, pendingPreview?.end]); + const highlight = rangeSelect.selection ?? draftRange; + const picking = rangeSelect.selection; + const announcement = picking + ? [ + isSameDay(picking.start, picking.end) + ? tSelect("selected_day", { day: formatFullDate(picking.start) }) + : tSelect("selected_range", { start: formatFullDate(picking.start), end: formatFullDate(picking.end) }), + picking.keyboard ? tSelect("enter_hint") : "", + ].join(" ").trim() + : ""; + // The days picked, read out as they change (the last of a quick run). + useEffect(() => { + if (announcement) announce(announcement, { key: "calendar-month-selection", debounceMs: 250 }); + }, [announcement]); + // Six rows fill the viewport, as a single month used to; taller rows grow // with their chips. const [viewportHeight, setViewportHeight] = useState(0); @@ -420,8 +460,21 @@ export function CalendarMonthView({ const focusKey = dayKey(focus.date); return keys.has(focusKey) ? focusKey : (weeks[0]?.[0] ? dayKey(weeks[0][0]) : selectedKey); }, [weeks, selectedKey, focus.date]); + const { extendTo, commitKeyboard, clearKeyboard } = rangeSelect; const handleDayKeyDown = useCallback((e: React.KeyboardEvent, day: Date, dayIndex: number) => { - if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; + if (e.altKey || e.ctrlKey || e.metaKey) return; + // Enter over days picked with Shift+arrows creates there; without + // them it opens the day, as before. + if (e.key === "Enter" && !e.shiftKey && commitKeyboard()) { + e.preventDefault(); + return; + } + if (e.key === "Escape" && clearKeyboard()) { + e.preventDefault(); + e.stopPropagation(); + return; + } + if (e.shiftKey && !onCreateRange) return; const rtl = getComputedStyle(e.currentTarget).direction === "rtl"; let delta: number; switch (e.key) { @@ -438,9 +491,11 @@ export function CalendarMonthView({ const next = addDays(day, delta); const target = scrollRef.current?.querySelector(`[data-day-button="${dayKey(next)}"]`); if (!target) return; + if (e.shiftKey) extendTo(day, next); + else clearKeyboard(); onSelectDate(next); target.focus(); - }, [onSelectDate]); + }, [onSelectDate, onCreateRange, extendTo, commitKeyboard, clearKeyboard]); const [dropDayKey, setDropDayKey] = useState(null); @@ -484,13 +539,20 @@ export function CalendarMonthView({ }, [t, describeError, userEmailsFor, onMoveEvent]); return ( -
+
list.length), rowCapacity, moreInHeader) : null; const hiddenPerDay = fit?.hidden ?? null; // Rows that grow with their content make room for the new event's preview. - const previewRows = !capRows && pendingPreview && week.some((day) => checkIsSameDay(pendingPreview.start, day)) ? 1 : 0; + const previewRows = !capRows && pendingPreview && week.some((day) => checkIsSameDay(pendingPreview.start, day) || isInMonthRange(day, draftRange)) ? 1 : 0; return (
0 ? week[dayIndex - 1] : addDays(day, -1); const firstOfMonth = !checkIsSameMonth(day, previous); + const picked = isInMonthRange(day, highlight); return (
{ + // The end of a drag over days, not a click on this one. + if (rangeSelect.wasDragging.current) return; onSelectDate(day); // On phones a tap opens the day instead. if (!isMobile) onCreateAllDay?.(day); @@ -550,7 +616,8 @@ export function CalendarMonthView({ !inMonth && "bg-muted/30", "hover:bg-muted/50", selected && isMobile && "bg-primary/10", - dropDayKey === key && "ring-2 ring-inset ring-primary bg-primary/10" + dropDayKey === key && "ring-2 ring-inset ring-primary bg-primary/10", + picked && "bg-primary/15 hover:bg-primary/15" )} >
@@ -649,6 +716,7 @@ export function CalendarMonthView({ isMobile={isMobile} calendarMap={calendarMap} pendingPreview={pendingPreview} + draftRange={draftRange} lastRow={capRows ? rowCapacity - 1 : Infinity} selectedEventId={selectedEventId} currentUserEmails={currentUserEmails} @@ -693,7 +761,7 @@ type WeekSegment = ReturnType[number]; */ function MonthCellEntries({ dayIndex, week, segments, rowLimit, isShown, rowCount, dayEventRows, dayTasks, hidden, overlayTop, rowHeight, chipHeight, - isMobile, calendarMap, pendingPreview, lastRow, selectedEventId, currentUserEmails, canEditEvent, inView, timedStyle, + isMobile, calendarMap, pendingPreview, draftRange, lastRow, selectedEventId, currentUserEmails, canEditEvent, inView, timedStyle, onSelectEvent, onHoverEvent, onHoverLeave, onContextMenuEvent, onReplyEvent, onToggleTaskComplete, onSelectTask, onShowMore, moreLabel, moreInHeader = false, paint, }: { dayIndex: number; @@ -713,6 +781,8 @@ function MonthCellEntries({ isMobile?: boolean; calendarMap: Map; pendingPreview?: PendingEventPreview | null; + /** The days of a new all-day event being created: its bar spans them. */ + draftRange?: MonthRange | null; /** The last row a fixed-height week has room for; the preview must not go past it. */ lastRow: number; selectedEventId?: string | null; @@ -746,13 +816,25 @@ function MonthCellEntries({ // A click, double click or right-click on an entry is the entry's, not a // click on the empty day (which creates an all-day event there). const own = { + // Not empty day space: a drag from here doesn't pick days. + "data-month-entry": true, onClick: (e: React.MouseEvent) => e.stopPropagation(), onDoubleClick: (e: React.MouseEvent) => e.stopPropagation(), onContextMenu: (e: React.MouseEvent) => e.stopPropagation(), }; const daySegments = segments.filter((segment) => segment.startIndex === dayIndex && (isShown ? isShown(segment) : segment.row < rowLimit)); const tasks = dayTasks[dayIndex].filter((_, taskIndex) => dayEventRows[dayIndex] + taskIndex < rowLimit); - const preview = pendingPreview && isSameDay(pendingPreview.start, day) ? pendingPreview : null; + // A new all-day event spans its days, from its first day or the start + // of each week row it continues into; a timed one sits on its day. + const previewSpan = (() => { + if (!pendingPreview) return 0; + if (draftRange) { + if (!isInMonthRange(day, draftRange) || (dayIndex > 0 && !isSameDay(day, draftRange.start))) return 0; + return Math.min(differenceInCalendarDays(draftRange.end, day) + 1, week.length - dayIndex); + } + return isSameDay(pendingPreview.start, day) ? 1 : 0; + })(); + const preview = previewSpan > 0 ? pendingPreview : null; return ( <> @@ -835,10 +917,12 @@ function MonthCellEntries({ )} {preview && (() => { // On a full day it lies over the last row: below it, it landed on the next week's date. - const previewRow = Math.min(lastRow, Math.max(rowCount, dayEventRows[dayIndex] + dayTasks[dayIndex].length)); + let busiest = 0; + for (let d = dayIndex; d < dayIndex + previewSpan; d++) busiest = Math.max(busiest, dayEventRows[d] + dayTasks[d].length); + const previewRow = Math.min(lastRow, Math.max(rowCount, busiest)); const color = paint(calendarMap.get(preview.calendarId)?.color || "#3b82f6"); return ( -
+
{ closeContextMenu(); onClearCalendar(cal); }} /> )} diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 682519f87..98c99d451 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"; import { IconButton } from "@/components/ui/icon-button"; import { ToolbarButton } from "@/components/ui/toolbar"; import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical, RefreshCw, Search, Printer } from "@/components/icons"; -import { ContextMenu, ContextMenuItem, ContextMenuSeparator, MenuShortcut } from "@/components/ui/context-menu"; +import { ContextMenu, ContextMenuCheckItem, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; import { startOfWeek } from "date-fns"; import { PhoneHeaderActions } from "@/components/layout/phone-header-actions"; @@ -473,21 +473,14 @@ export function CalendarToolbar({ {menuViews.map((v) => ( - + /> ))}
diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 7051e1963..c3467c334 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -12,7 +12,7 @@ import { QuickEventInput } from "./quick-event-input"; import { CalendarTaskChip } from "./task-chip"; import { AllHoursToggle, HiddenEventsIndicator } from "./display-hours-controls"; import { groupTasksByDueDay } from "@/lib/calendar-tasks"; -import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils"; +import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments, pendingTimedMinutes, PENDING_LAYOUT_ID } from "@/lib/calendar-utils"; import { clipToDisplayHours, indexOfDayOnOrAfter, partitionByDisplayHours, remapSegmentsToShownDays, resolveWorkingDays } from "@/lib/calendar-display-range"; import { displayNow, isDisplayToday } from "@/lib/timezone"; import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types"; @@ -238,6 +238,25 @@ export function CalendarWeekView({ return map; }, [days, timedEvents, hours]); + // The new event being created takes a column beside the events it + // overlaps, as it will once saved, instead of covering them. Only its + // times count: typing its title doesn't redo the layout. + const pendingStartMs = pendingPreview && !pendingPreview.allDay ? pendingPreview.start.getTime() : null; + const pendingEndMs = pendingPreview && !pendingPreview.allDay ? pendingPreview.end.getTime() : null; + const pendingLayout = useMemo(() => { + if (pendingStartMs === null || pendingEndMs === null) return null; + const preview = { start: new Date(pendingStartMs), end: new Date(pendingEndMs) }; + const day = days.find((d) => isSameDay(d, preview.start)); + const bounds = day ? pendingTimedMinutes(preview, day) : null; + if (!day || !bounds) return null; + const key = format(day, "yyyy-MM-dd"); + const partition = partitionByDisplayHours(timedEvents.get(key) || [], day, hours); + const all = layoutOverlappingEvents(partition.visible, day, bounds); + const pending = all.find((layout) => layout.event.id === PENDING_LAYOUT_ID); + if (!pending) return null; + return { key, layouts: all.filter((layout) => layout.event.id !== PENDING_LAYOUT_ID), pending }; + }, [pendingStartMs, pendingEndMs, days, timedEvents, hours]); + // Built over the consecutive days and then moved onto the drawn columns, // so an event across a hidden weekend still spans Friday to Monday. const allDaySegments = useMemo(() => { @@ -378,7 +397,7 @@ export function CalendarWeekView({ }, []); const { - dragCreate, handleGridPointerDown, handleGridPointerMove, handleGridPointerUp, + dragCreate, handleGridPointerDown, handleGridPointerMove, handleGridPointerUp, handleGridPointerCancel, resizeVisual, handleResizePointerDown, handleResizePointerMove, handleResizePointerUp, quickCreate, handleSlotClick, handleSlotDoubleClick, handleQuickCreateSubmit, handleQuickCreateCancel, dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop, @@ -664,7 +683,7 @@ export function CalendarWeekView({ const key = format(day, "yyyy-MM-dd"); const todayCol = isDisplayToday(day); const dayLayout = layoutByDay.get(key); - const layouted = dayLayout?.layouts ?? []; + const layouted = (pendingLayout?.key === key ? pendingLayout.layouts : dayLayout?.layouts) ?? []; return (
handleGridPointerDown(e, key, day)} onPointerMove={handleGridPointerMove} onPointerUp={handleGridPointerUp} + onPointerCancel={handleGridPointerCancel} onDragOver={(e) => handleColumnDragOver(e, key)} onDragLeave={handleColumnDragLeave} onDrop={(e) => handleColumnDrop(e, day)} @@ -801,8 +821,11 @@ export function CalendarWeekView({ {dragCreate?.dayKey === key && (
)} - {pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && ( + {pendingPreview && pendingLayout?.key === key && ( (() => { - const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes(); - let endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes(); - if (endMin <= startMin) endMin = 1440; - const durationMin = Math.max(15, endMin - startMin); - if (startMin + durationMin <= hours.startMinutes || startMin >= hours.endMinutes) return null; - const clip = clipToDisplayHours(startMin, startMin + durationMin, hours); + const { startMinutes: startMin, endMinutes: endMin, column, totalColumns } = pendingLayout.pending; + if (endMin <= hours.startMinutes || startMin >= hours.endMinutes) return null; + const clip = clipToDisplayHours(startMin, endMin, hours); const cal = calendars.find(c => c.id === pendingPreview.calendarId); const color = paint(cal?.color || "#3b82f6"); - // Drawn like a saved event, so the new event shows where - // it will land while it is being created. + // Drawn like a saved event, in the column it will take, so the new + // event shows where it will land while it is being created. return (
-
- {pendingPreview.title} -
-
- {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)} +
+
+ {pendingPreview.title} +
+
+ {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(endMin, timeFormat)} +
); diff --git a/components/calendar/empty-space-context-menu.tsx b/components/calendar/empty-space-context-menu.tsx index ab2b79124..680c66356 100644 --- a/components/calendar/empty-space-context-menu.tsx +++ b/components/calendar/empty-space-context-menu.tsx @@ -7,6 +7,7 @@ import { ContextMenuSeparator, } from "@/components/ui/context-menu"; import { Plus, CalendarDays, CheckSquare, Clock } from "@/components/icons"; +import { useSettingsStore } from "@/stores/settings-store"; interface Position { x: number; @@ -37,6 +38,8 @@ export function EmptySpaceContextMenu({ showAllDayOption = true, }: EmptySpaceContextMenuProps) { const t = useTranslations("calendar"); + // The calendar's own keys (N, T), as the shortcut help lists them; gone with single keys off. + const singleKeys = useSettingsStore((state) => state.singleKeyShortcuts !== false); const handle = (fn: () => void) => () => { fn(); @@ -45,7 +48,7 @@ export function EmptySpaceContextMenu({ return ( - + {showAllDayOption && ( )} - + ); } diff --git a/components/calendar/quick-create-popover.tsx b/components/calendar/quick-create-popover.tsx index 913afae41..844bf936b 100644 --- a/components/calendar/quick-create-popover.tsx +++ b/components/calendar/quick-create-popover.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslations } from "next-intl"; -import { format } from "date-fns"; +import { format, isSameDay } from "date-fns"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Clock, X } from "@/components/icons"; @@ -58,8 +58,9 @@ export function computePosition(anchor: DOMRect | null, height: number, width = } /** - * Compact editor that opens next to an empty slot clicked in the week or - * day view: a title, the time that was picked, the calendar, and a way into + * Compact editor that opens next to an empty slot clicked or dragged over in + * the week or day view, or days picked in the month view: a title, the time + * that was picked, the calendar, and a way into * the full editor. Enter saves, Escape or a click elsewhere discards. */ export function QuickCreatePopover({ @@ -148,14 +149,19 @@ export function QuickCreatePopover({ }; }, [onClose]); - const canSave = draft.title.trim().length > 0 && !!draft.calendarId; + // An empty title saves as "(No title)", as in Google Calendar. + const canSave = !!draft.calendarId; const handleSubmit = useCallback((e: React.FormEvent) => { e.preventDefault(); if (canSave) onSave(); }, [canSave, onSave]); const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; - const dateLabel = intlFormatter.dateTime(draft.start, { weekday: "long", month: "long", day: "numeric" }); + const dayFormat = { weekday: "long", month: "long", day: "numeric" } as const; + // Days picked by a drag in the month view: the first and the last. + const dateLabel = draft.allDay && !isSameDay(draft.start, draft.end) + ? `${intlFormatter.dateTime(draft.start, dayFormat)} – ${intlFormatter.dateTime(draft.end, dayFormat)}` + : intlFormatter.dateTime(draft.start, dayFormat); const timeLabel = draft.allDay ? t("events.all_day") : `${format(draft.start, timeFmt)} – ${format(draft.end, timeFmt)}`; return createPortal( diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index 05c993c48..541c19fd6 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -745,6 +745,7 @@ export function ContactsSidebar({ {(["to", "cc", "bcc"] as const).map((field) => ( { const groupId = groupContextMenu.data!.id; diff --git a/components/email/__tests__/composer-attach-recent.test.tsx b/components/email/__tests__/composer-attach-recent.test.tsx new file mode 100644 index 000000000..8a61dcd26 --- /dev/null +++ b/components/email/__tests__/composer-attach-recent.test.tsx @@ -0,0 +1,401 @@ +import { render, screen, fireEvent, act, waitFor, within } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; +import { useAuthStore } from '@/stores/auth-store'; +import { DEFAULT_POLICY_SNAPSHOT, type PolicySnapshot } from '@/lib/desktop/policy-schema'; +import type { BulwarkDesktopBridge, DesktopRecentFile } from '@/lib/desktop/types'; + +// Recent files in the Attach menu, in the desktop app: the shell's side is +// repos/desktop's test/recent-files.spec.ts; here the bridge is a stub. + +const hoisted = vi.hoisted(() => ({ + picked: [] as unknown[], + beforeAttachmentUpload: vi.fn(async (_info: { name: string }) => true), + toastError: vi.fn(), +})); + +vi.mock('@/components/files/file-picker-dialog', () => ({ + FilePickerDialog: ({ onPick }: { onPick: (nodes: unknown[]) => void }) => + React.createElement( + 'button', + { type: 'button', 'data-testid': 'fake-file-picker', onClick: () => onPick(hoisted.picked) }, + 'pick', + ), +})); + +// ─── Heavy component mocks (mirrors composer-plugin-attachment-source.test.tsx) ─ + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); + +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ + useFocusTrap: () => ({ current: null }), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +// ─── Store mocks ────────────────────────────────────────────────────────────── + +vi.mock('@/stores/auth-store', () => { + const state = { + client: null, + identities: [], + primaryIdentity: null, + isAuthenticated: false, + isDemoMode: false, + activeAccountId: null, + connectionLost: false, + getClientForAccount: () => undefined, + getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, + refreshIdentities: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useIdentityStore: hook }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { accounts: [], getAccountById: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAccountStore: hook }; +}); + +vi.mock('@/stores/email-store', () => { + const state = { + draftSaveEnabled: false, + sendRawEmail: async () => ({ sent: true }), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + emptySubjectWarningEnabled: true, + sendDelaySeconds: 0, + signaturePosition: 'above_quote', + signatureSeparatorEnabled: false, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + updateSetting: () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useContactStore: hook }; +}); + +vi.mock('@/stores/template-store', () => { + const state = { templates: [], addTemplate: async () => {} }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +// ─── Misc dependency mocks ──────────────────────────────────────────────────── + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: hoisted.toastError, success: () => {} }, +})); + +vi.mock('@/lib/plugin-hooks', () => ({ + identityHooks: { + onSignatureRender: { size: 0, transform: async (value: unknown) => value }, + onIdentitySelect: { size: 0, emit: async () => {} }, + }, + templateHooks: { + onTemplateRender: { size: 0, transform: async (value: unknown) => value }, + onTemplateApply: { size: 0, emit: async () => {} }, + }, + emailHooks: { + onComposerOpen: { call: async () => [] }, + onRecipientChange: { call: async () => [] }, + getRecipientSuggestions: { call: async () => [] }, + onRecipientChipsChange: { transform: async (chips: unknown) => chips }, + onDraftChange: { emit: () => {} }, + onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, + onBeforeAttachmentUpload: { intercept: hoisted.beforeAttachmentUpload }, + onComposeSend: { takeOver: async () => 'default' }, + onTransformOutgoingEmail: { transform: async (email: unknown) => email }, + }, + contactHooks: { + search: { call: async () => [] }, + onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial }, + }, +})); + +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/signature-utils', () => ({ + appendPlainTextSignature: (body: string) => body, + getPlainTextSignature: () => '', + plainTextBodyHasSignature: () => false, + plainTextBodyWithoutSignature: (body: string) => body, +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: () => '', + serializeEditorContent: () => '', +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +const NOW = Date.now(); +const RECENT: DesktopRecentFile[] = [ + { id: 'id-report', name: 'Quarterly report.docx', folder: '\\\\files\\team\\Reports', path: '\\\\files\\team\\Reports\\Quarterly report.docx', usedAt: NOW - 5 * 60_000, icon: 'data:image/png;base64,iVBORw0KGgo=' }, + { id: 'id-budget', name: 'Budget 2027.xlsx', folder: 'C:\\Users\\me\\Documents', path: 'C:\\Users\\me\\Documents\\Budget 2027.xlsx', usedAt: NOW - 3 * 60 * 60_000 }, +]; + +interface DesktopOptions { + files?: DesktopRecentFile[]; + policyOff?: boolean; + /** The shell's native menus: the Attach menu must stay HTML all the same. */ + popup?: ReturnType; +} + +function desktop({ files = RECENT, policyOff = false, popup }: DesktopOptions = {}) { + const snapshot: PolicySnapshot = { + ...DEFAULT_POLICY_SNAPSHOT, + ...(policyOff ? { RecentFilesInAttach: { value: false, source: 'forced-machine', locked: true } } : {}), + } as PolicySnapshot; + const list = vi.fn(async () => ({ ok: true as const, files })); + const attach = vi.fn(async (id: string) => { + const file = files.find((f) => f.id === id); + return file + ? { ok: true as const, file: { name: file.name, data: new TextEncoder().encode(`bytes of ${file.name}`) } } + : { ok: false as const, reason: 'unknown' as const }; + }); + window.bulwarkDesktop = { + version: 1, + platform: 'win32', + policy: { get: () => snapshot, onChange: () => () => {} }, + recentFiles: { list, attach }, + ...(popup ? { menu: { popup, setState: () => {}, onCommand: () => () => {} } } : {}), + } as unknown as BulwarkDesktopBridge; + return { list, attach }; +} + +function client(overrides: Record = {}) { + return { + createDraft: vi.fn().mockResolvedValue('draft-1'), + getEmail: vi.fn(), + hasDelayedSend: () => false, + getMaxDelayedSend: () => 0, + // No Files, no plugins: in a browser, Attach is a plain button. + supportsFiles: () => false, + getMaxSizeAttachmentsPerEmail: () => 0, + getMaxSizeUpload: () => 0, + uploadBlob: vi.fn(() => new Promise(() => {})), + ...overrides, + }; +} + +const rows = () => screen.queryAllByTestId('composer-recent-file'); + +async function openMenu() { + fireEvent.click(screen.getByTestId('composer-attach-menu')); + await act(async () => {}); +} + +describe('recent files in the Attach menu', () => { + afterEach(() => { + window.bulwarkDesktop = undefined; + useAuthStore.setState({ client: null }); + vi.clearAllMocks(); + hoisted.beforeAttachmentUpload.mockImplementation(async () => true); + }); + + it('in a browser the paperclip is the plain button it was', () => { + useAuthStore.setState({ client: client() as never }); + render(); + expect(screen.queryByTestId('composer-attach-menu')).toBeNull(); + expect(screen.getByRole('button', { name: 'attach' })).toBeInTheDocument(); + }); + + it('in the desktop app the paperclip opens a menu: recent files first, then the file dialog', async () => { + const { list } = desktop(); + useAuthStore.setState({ client: client() as never }); + render(); + expect(list).not.toHaveBeenCalled(); + await openMenu(); + expect(list).toHaveBeenCalledTimes(1); + + const menu = screen.getByRole('menu'); + const items = within(menu).getAllByRole('menuitem'); + expect(items.map((item) => item.getAttribute('data-testid'))).toEqual(['composer-recent-file', 'composer-recent-file', 'composer-attach-from-device']); + expect(within(menu).getByText('heading')).toBeInTheDocument(); + expect(items[2]).toHaveTextContent('browse'); + + const [report, budget] = rows(); + expect(report).toHaveTextContent('Quarterly report.docx'); + expect(report).toHaveTextContent('\\\\files\\team\\Reports'); + expect(report).toHaveTextContent('5 minutes ago'); + expect(report).toHaveAttribute('title', '\\\\files\\team\\Reports\\Quarterly report.docx'); + expect(report.querySelector('img')).toHaveAttribute('src', RECENT[0].icon); + // No icon from the shell: the app's own for the type. + expect(budget.querySelector('img')).toBeNull(); + expect(budget.querySelector('svg')).not.toBeNull(); + expect(budget).toHaveTextContent('3 hours ago'); + }); + + it('one click attaches that file through the composer\'s own checks and closes the menu', async () => { + const { attach } = desktop(); + useAuthStore.setState({ client: client() as never }); + render(); + await openMenu(); + fireEvent.click(rows()[1]); + expect(screen.queryByRole('menu')).toBeNull(); + expect(attach).toHaveBeenCalledWith('id-budget'); + expect(await screen.findByText('Budget 2027.xlsx')).toBeInTheDocument(); + expect(hoisted.beforeAttachmentUpload).toHaveBeenCalledWith(expect.objectContaining({ name: 'Budget 2027.xlsx', size: 'bytes of Budget 2027.xlsx'.length })); + }); + + it('a plugin can refuse it, as it can a file from the dialog', async () => { + hoisted.beforeAttachmentUpload.mockImplementation(async (info) => info.name !== 'Budget 2027.xlsx'); + desktop(); + useAuthStore.setState({ client: client() as never }); + render(); + await openMenu(); + fireEvent.click(rows()[1]); + await act(async () => {}); + expect(hoisted.beforeAttachmentUpload).toHaveBeenCalled(); + expect(screen.queryByText('Budget 2027.xlsx')).toBeNull(); + }); + + it('the server\'s size limit applies too', async () => { + desktop(); + useAuthStore.setState({ client: client({ getMaxSizeUpload: () => 5 }) as never }); + render(); + await openMenu(); + fireEvent.click(rows()[0]); + await act(async () => {}); + expect(hoisted.toastError).toHaveBeenCalledWith('attachment_too_large'); + expect(screen.queryByText('Quarterly report.docx')).toBeNull(); + }); + + it('says so when the file is gone or too large for the app', async () => { + const { attach } = desktop(); + useAuthStore.setState({ client: client() as never }); + render(); + attach.mockResolvedValueOnce({ ok: false, reason: 'missing' } as never); + await openMenu(); + fireEvent.click(rows()[0]); + await act(async () => {}); + expect(hoisted.toastError).toHaveBeenLastCalledWith('missing'); + + attach.mockResolvedValueOnce({ ok: false, reason: 'too-large' } as never); + await openMenu(); + fireEvent.click(rows()[0]); + await act(async () => {}); + expect(hoisted.toastError).toHaveBeenLastCalledWith('too_large'); + }); + + it('with nothing recent the menu has only its other entries', async () => { + desktop({ files: [] }); + useAuthStore.setState({ client: client() as never }); + render(); + await openMenu(); + const menu = screen.getByRole('menu'); + expect(within(menu).queryByText('heading')).toBeNull(); + expect(within(menu).queryByRole('separator')).toBeNull(); + expect(within(menu).getAllByRole('menuitem').map((item) => item.getAttribute('data-testid'))).toEqual(['composer-attach-from-device']); + }); + + it('with the policy off there\'s no list, and Attach is what it was', async () => { + const { list } = desktop({ policyOff: true }); + useAuthStore.setState({ client: client() as never }); + render(); + expect(screen.queryByTestId('composer-attach-menu')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'attach' })); + await act(async () => {}); + expect(list).not.toHaveBeenCalled(); + }); + + it('keys: the first file has the focus, arrows move, Escape closes', async () => { + desktop(); + useAuthStore.setState({ client: client() as never }); + render(); + await openMenu(); + await waitFor(() => expect(document.activeElement).toBe(rows()[0]), { timeout: 5000 }); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }); + expect(document.activeElement).toBe(rows()[1]); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }); + expect(document.activeElement).toHaveAttribute('data-testid', 'composer-attach-from-device'); + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); + expect(screen.queryByRole('menu')).toBeNull(); + }); + + it('stays an HTML menu where the shell would draw it natively', async () => { + const popup = vi.fn(async () => null); + desktop({ popup }); + useAuthStore.setState({ client: client() as never }); + render(); + await openMenu(); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 300)); }); + expect(popup).not.toHaveBeenCalled(); + expect(rows()).toHaveLength(2); + }); +}); diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx index d6f877d86..a1c099423 100644 --- a/components/email/__tests__/composer-close-guard.test.tsx +++ b/components/email/__tests__/composer-close-guard.test.tsx @@ -334,6 +334,42 @@ describe('composer close guard with a follow-up', () => { await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); expect(confirm).not.toHaveBeenCalled(); }); + + describe('with a shell that has windows of its own', () => { + const windowBridge = (answer: unknown) => { + const confirm = vi.fn(async () => 2); + const show = vi.fn(async () => answer); + window.bulwarkDesktop = { version: 1, platform: 'win32', dialogs: { confirm, show, cancel: vi.fn() } } as never; + return { confirm, show }; + }; + + it('asks in the app\'s own window, not the OS box, with Try again as the default', async () => { + const { confirm, show } = windowBridge(null); + const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); + act(() => requestCloseRef.current!(afterClose)); + await waitFor(() => expect(show).toHaveBeenCalledTimes(1)); + expect(confirm).not.toHaveBeenCalled(); + expect(show.mock.calls[0]).toMatchObject([{ + title: 'close_draft_title', + message: [{ text: 'save_failed_close' }], + buttons: [{ label: 'cancel', variant: 'secondary' }, { label: 'discard', variant: 'destructive' }, { label: 'try_again' }], + defaultIndex: 2, + }]); + expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument(); + // Escape: nothing happens. + await act(async () => {}); + expect(onClose).not.toHaveBeenCalled(); + expect(afterClose).not.toHaveBeenCalled(); + }); + + it('Discard in the window discards, and the follow-up runs', async () => { + windowBridge({ button: 1 }); + const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); + act(() => requestCloseRef.current!(afterClose)); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); + }); + }); }); it('runs the follow-up after discarding a draft that could not be saved', async () => { diff --git a/components/email/__tests__/edit-attachment-gates.test.tsx b/components/email/__tests__/edit-attachment-gates.test.tsx index de7c3b84d..f66e39add 100644 --- a/components/email/__tests__/edit-attachment-gates.test.tsx +++ b/components/email/__tests__/edit-attachment-gates.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Email, Mailbox } from '@/lib/jmap/types'; import { PHISHING_KEYWORD } from '@/lib/phishing-report'; @@ -50,14 +50,11 @@ function makeEmail(attachment: Record, keywords: Record (name === 'then' ? undefined : () => undefined) }); -let popup: ReturnType; - beforeEach(() => { - popup = vi.fn(async () => null); window.bulwarkDesktop = { version: 1, platform: 'win32', - menu: { setState: () => {}, popup }, + menu: { setState: () => {} }, // The shell's list, for the ones used here. edit: { canEdit: (name: string) => /\.(docx|txt)$/.test(name), list: async () => [], onChange: () => () => {} }, } as never; @@ -74,8 +71,8 @@ async function viewerMenu(email: Email): Promise<(string | undefined)[]> { render(); const tile = await screen.findByTestId('attachment'); fireEvent.contextMenu(tile.parentElement ?? tile, { clientX: 5, clientY: 5 }); - await waitFor(() => expect(popup).toHaveBeenCalled()); - return (popup.mock.calls[0][0] as { label?: string }[]).map((item) => item.label); + const menu = await screen.findByRole('menu'); + return within(menu).getAllByRole('menuitem').map((item) => item.querySelector('[data-menu-label]')?.textContent ?? undefined); } describe('the reader', () => { diff --git a/components/email/__tests__/email-context-menu-remove-from-folder.test.tsx b/components/email/__tests__/email-context-menu-remove-from-folder.test.tsx index 1b84b48c9..18ca47bf9 100644 --- a/components/email/__tests__/email-context-menu-remove-from-folder.test.tsx +++ b/components/email/__tests__/email-context-menu-remove-from-folder.test.tsx @@ -106,11 +106,13 @@ describe('Remove from this folder in the message menu', () => { const onBatchRemoveFromFolder = vi.fn(); const batch = { isMultiSelect: true, selectedCount: 2, onBatchRemoveFromFolder }; const { unmount } = renderMenu({ ...batch, batchEmails: [inBoth, inboxOnly] }); + fireEvent.click(screen.getByTestId('ctx-more')); expect(screen.queryByTestId('ctx-remove-from-folder')).toBeNull(); unmount(); const both = { ...inBoth, id: 'e3' } as Email; renderMenu({ ...batch, batchEmails: [inBoth, both] }); + fireEvent.click(screen.getByTestId('ctx-more')); fireEvent.click(screen.getByTestId('ctx-remove-from-folder')); expect(onBatchRemoveFromFolder).toHaveBeenCalledTimes(1); }); diff --git a/components/email/__tests__/email-context-menu.test.tsx b/components/email/__tests__/email-context-menu.test.tsx index 44ec665d2..6af616533 100644 --- a/components/email/__tests__/email-context-menu.test.tsx +++ b/components/email/__tests__/email-context-menu.test.tsx @@ -53,6 +53,7 @@ function renderMenu(props: Partial = {}) { describe('EmailContextMenu batch forward as attachment', () => { it('shows the batch header and batch forward item when the right-clicked email is in a multi-selection', () => { renderMenu({ isMultiSelect: true, selectedCount: 3, onBatchForwardAsAttachment: vi.fn() }); + fireEvent.click(screen.getByTestId('ctx-more')); expect(screen.getByTestId('ctx-batch-forward-as-attachment')).toBeInTheDocument(); expect(screen.getByText('items_selected')).toBeInTheDocument(); }); @@ -60,6 +61,7 @@ describe('EmailContextMenu batch forward as attachment', () => { it('clicking the batch item invokes onBatchForwardAsAttachment and closes the menu', () => { const onBatchForwardAsAttachment = vi.fn(); const { onClose } = renderMenu({ isMultiSelect: true, selectedCount: 3, onBatchForwardAsAttachment }); + fireEvent.click(screen.getByTestId('ctx-more')); fireEvent.click(screen.getByTestId('ctx-batch-forward-as-attachment')); expect(onBatchForwardAsAttachment).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1); @@ -67,6 +69,7 @@ describe('EmailContextMenu batch forward as attachment', () => { it('hides the single-message forward-as-attachment item in batch mode', () => { renderMenu({ isMultiSelect: true, selectedCount: 3, onBatchForwardAsAttachment: vi.fn() }); + fireEvent.click(screen.getByTestId('ctx-more')); // vitest's next-intl mock renders labels as raw keys; the singular key // must be absent while the plural batch key is present. expect(screen.queryByText('forward_as_attachment')).toBeNull(); @@ -96,6 +99,7 @@ describe('EmailContextMenu batch forward as attachment', () => { it('disables the batch item when no handler is passed', () => { renderMenu({ isMultiSelect: true, selectedCount: 3 }); + fireEvent.click(screen.getByTestId('ctx-more')); expect((screen.getByTestId('ctx-batch-forward-as-attachment') as HTMLButtonElement).disabled).toBe(true); }); }); diff --git a/components/email/__tests__/email-list-batch-bar.test.tsx b/components/email/__tests__/email-list-batch-bar.test.tsx index 67bddb70b..3a9cb6a19 100644 --- a/components/email/__tests__/email-list-batch-bar.test.tsx +++ b/components/email/__tests__/email-list-batch-bar.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { useEmailStore } from '@/stores/email-store'; import { useAuthStore } from '@/stores/auth-store'; import { useSettingsStore } from '@/stores/settings-store'; @@ -61,6 +61,7 @@ describe('selection bar', () => { fireEvent.click(screen.getByTestId('batch-more')); expect(screen.getByText('items_selected')).toBeTruthy(); expect(screen.getByTestId('ctx-move-to')).toBeTruthy(); + fireEvent.click(screen.getByTestId('ctx-more')); expect(screen.getByTestId('ctx-batch-forward-as-attachment')).toBeTruthy(); }); }); @@ -99,7 +100,8 @@ describe('selection bar: Move, read state and the reading pane', () => { const pane = host.querySelector('[data-testid="selection-pane"]') as HTMLElement; expect(pane).not.toBeNull(); expect(pane.textContent).toContain('selected_conversations'); - for (const id of ['archive', 'delete', 'move', 'mark-read']) { + // All read: the pane offers Mark as unread, as the selection's menu does. + for (const id of ['archive', 'delete', 'move', 'mark-unread', 'star', 'tag', 'spam', 'more']) { expect(pane.querySelector(`[data-testid="pane-batch-${id}"]`)).not.toBeNull(); } fireEvent.click(pane.querySelector('[data-testid="pane-batch-archive"]')!); @@ -136,15 +138,71 @@ describe('the reading pane for a selection (mail-p2-15)', () => { document.body.appendChild(host); render(); const pane = host.querySelector('[data-testid="selection-pane"]') as HTMLElement; - for (const id of ['archive', 'delete', 'move', 'mark-read', 'tag']) { - const button = pane.querySelector(`[data-testid="pane-batch-${id}"]`) as HTMLElement; - expect(button.textContent?.trim()).toBe(button.getAttribute('aria-label')); + for (const button of Array.from(pane.querySelectorAll('button'))) { + expect(button.textContent?.trim()).not.toBe(''); } expect(screen.getByTestId('batch-archive').textContent).toBe(''); host.remove(); }); }); +// The selection view shows the selection menu's top level as buttons, in its order, +// with More actions as a labelled button, not a bare icon. +describe('the selection view', () => { + const paneLabels = (pane: HTMLElement) => + Array.from(pane.querySelectorAll('[data-testid="selection-actions"] button')) + .map((button) => button.querySelector('[data-selection-label]')?.textContent ?? ''); + + it('has the same buttons as the selection menu has entries, in the same order', () => { + const emails = useEmailStore.getState().emails.map((email, i) => (i === 0 ? { ...email, keywords: {} } : email)); + useEmailStore.setState({ emails }); + const host = document.createElement('div'); + document.body.appendChild(host); + render(); + const pane = host.querySelector('[data-testid="selection-pane"]') as HTMLElement; + fireEvent.click(screen.getByTestId('batch-more')); + const menu = screen.getAllByRole('menu').find((m) => m.textContent?.includes('items_selected'))!; + const menuTop = Array.from(menu.querySelectorAll('[role="menuitem"]')) + .filter((row) => row.parentElement?.closest('[role="menu"]') === menu) + .map((row) => row.querySelector('[data-menu-label]')?.textContent ?? ''); + expect(paneLabels(pane)).toEqual(menuTop); + expect(paneLabels(pane)).toEqual(['archive', 'delete', 'move_to', 'mark_read', 'mark_unread', 'star', 'tag', 'mark_as_spam', 'more_actions']); + host.remove(); + }); + + it('opens what the menu has under More actions from its More actions button', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + render(); + fireEvent.click(host.querySelector('[data-testid="pane-batch-more"]')!); + const menu = screen.getByRole('menu', { name: 'more_actions' }); + expect(within(menu).getByTestId('ctx-batch-forward-as-attachment')).toBeTruthy(); + expect(within(menu).queryByTestId('ctx-archive')).toBeNull(); + host.remove(); + }); + + const lineLengths = (width: number) => { + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(width); + const emails = useEmailStore.getState().emails.map((email, i) => (i === 0 ? { ...email, keywords: {} } : email)); + useEmailStore.setState({ emails }); + const host = document.createElement('div'); + document.body.appendChild(host); + const { unmount } = render(); + const lengths = Array.from(host.querySelectorAll('[data-selection-line]')).map((line) => line.querySelectorAll('button').length); + unmount(); + host.remove(); + vi.restoreAllMocks(); + return lengths; + }; + + it('leaves no button alone on a line in a narrow pane', () => { + // Nine buttons, room for three a line: three even lines, not 3 + 3 + 2 + 1. + expect(lineLengths(560)).toEqual([3, 3, 3]); + // Room for two: pairs, and More actions across the width at the end. + expect(lineLengths(360)).toEqual([2, 2, 2, 2, 1]); + }); +}); + describe('selection bar and rows from the keyboard', () => { it('keeps the collapsed bar out of the Tab order', () => { useEmailStore.setState({ selectedEmailKeys: new Set() }); diff --git a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx index 043f7dbb9..98c067e31 100644 --- a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx +++ b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx @@ -277,6 +277,16 @@ describe('message-row-actions', () => { expect(onClose).toHaveBeenCalled(); }); + it('puts them under More actions in the right-click menu of a row, in the browser and the desktop app alike', async () => { + act(() => setPluginActions('p', 'Snooze+', 'message-row-actions', [{ id: 'later', label: 'Later' }])); + menu({ rowMenu: true }); + expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull(); + fireEvent.click(screen.getByTestId('ctx-more')); + const more = screen.getByRole('menu', { name: 'more_actions' }); + await act(async () => { fireEvent.click(within(more).getByRole('menuitem', { name: 'Later' })); }); + expect(dispatch).toHaveBeenCalledWith('p', 'message-row-actions', 'later', { emailId: 'a' }); + }); + it('keeps them out of the menu for a multi-selection, and out of an empty menu', () => { const { unmount } = menu(); expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull(); diff --git a/components/email/__tests__/move-menu-native.test.tsx b/components/email/__tests__/move-menu-native.test.tsx deleted file mode 100644 index 145de43d0..000000000 --- a/components/email/__tests__/move-menu-native.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { render, waitFor } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ContextMenu, ContextMenuSubMenu } from '@/components/ui/context-menu'; -import type { BulwarkDesktopBridge, DesktopNativeMenuItem } from '@/lib/desktop/types'; -import type { Mailbox } from '@/lib/jmap/types'; -import { buildMoveMenu } from '@/lib/move-menu'; -import { MoveFolderList } from '../move-target-list'; - -// The desktop app shows the Move menu natively, read back from the HTML one. -// A native menu can't indent and the account headers used to be plain divs, -// so it showed every account's Inbox, Sent, Spam and Trash in one flat list -// with nothing to tell them apart. - -const writable = { mayAddItems: true } as Mailbox['myRights']; -const box = (id: string, name: string, extra: Partial = {}) => - ({ - id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, - myRights: writable, isSubscribed: true, accountId: 'A', accountName: 'me@example.org', isShared: false, ...extra, - }) as Mailbox; -const shared = (id: string, name: string, extra: Partial = {}) => - box(`S:${id}`, name, { accountId: 'S', accountName: 'team@example.org', isShared: true, ...extra }); - -const mailboxes = [ - box('inbox', 'Inbox', { role: 'inbox' }), - box('trash', 'Trash', { role: 'trash' }), - box('work', 'Work'), - box('clients', 'Clients', { parentId: 'work' }), - box('acme', 'Acme', { parentId: 'clients' }), - box('rd', 'R&D'), - shared('inbox', 'Inbox', { role: 'inbox' }), - shared('trash', 'Trash', { role: 'trash' }), -]; - -let popup: ReturnType; - -beforeEach(() => { - popup = vi.fn(() => new Promise(() => {})); - window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } } as unknown as BulwarkDesktopBridge; -}); - -afterEach(() => { - window.bulwarkDesktop = undefined; -}); - -async function nativeMoveMenu(): Promise { - const model = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', recentIds: () => ['acme'] }); - render( - {}}> - - {}} /> - - , - ); - await waitFor(() => expect(popup).toHaveBeenCalled()); - const items = popup.mock.calls[0][0] as DesktopNativeMenuItem[]; - return items[0].submenu!; -} - -const labels = (items: DesktopNativeMenuItem[] | undefined) => (items ?? []).map((item) => item.label ?? item.type); - -describe('the Move menu shown natively', () => { - it('has the recent folders under a header, then the account\'s own folders', async () => { - const move = await nativeMoveMenu(); - expect(labels(move)).toEqual([ - 'recent', 'Work / Clients / Acme', 'separator', - 'mailboxes.inbox', 'mailboxes.trash', 'R&D', 'Work', - 'separator', 'team@example.org', - ]); - expect(move[0].enabled).toBe(false); - // The open folder is there, greyed. - expect(move[3].enabled).toBe(false); - }); - - it('nests subfolders as submenus, the folder itself first', async () => { - const move = await nativeMoveMenu(); - const work = move.find((item) => item.label === 'Work')!; - expect(labels(work.submenu)).toEqual(['Work', 'separator', 'Clients']); - expect(work.submenu![0].id).toBeTruthy(); - const clients = work.submenu![2]; - expect(labels(clients.submenu)).toEqual(['Clients', 'separator', 'Acme']); - }); - - it('puts another account\'s folders in a submenu under its name', async () => { - const move = await nativeMoveMenu(); - const team = move.find((item) => item.label === 'team@example.org')!; - expect(labels(team.submenu)).toEqual(['mailboxes.inbox', 'mailboxes.trash']); - }); -}); diff --git a/components/email/__tests__/phishing-report-menus.test.tsx b/components/email/__tests__/phishing-report-menus.test.tsx index 9a25f5627..7d9644e1d 100644 --- a/components/email/__tests__/phishing-report-menus.test.tsx +++ b/components/email/__tests__/phishing-report-menus.test.tsx @@ -167,6 +167,7 @@ describe('the row menu', () => { it('reports a whole selection, one message each', () => { setAddress('soc@example.org'); rowMenu('inbox', { isMultiSelect: true, selectedCount: 2, batchEmails: [email, other], onBatchMarkAsSpam: vi.fn() }); + fireEvent.click(screen.getByTestId('ctx-more')); fireEvent.click(screen.getByTestId('ctx-batch-report-phishing')); expect(usePhishingReportStore.getState().pending?.emails.map((e) => e.id)).toEqual(['e1', 'e2']); }); diff --git a/components/email/__tests__/reader-attachments.test.tsx b/components/email/__tests__/reader-attachments.test.tsx index 9bb922889..f742726cf 100644 --- a/components/email/__tests__/reader-attachments.test.tsx +++ b/components/email/__tests__/reader-attachments.test.tsx @@ -30,47 +30,50 @@ describe('the right-click menu in the desktop app', () => { cleanup(); }); - const openMenu = async (popupAnswer: (items: { id?: string; label?: string }[]) => string | null, overrides: Partial[0]> = {}) => { - const popup = vi.fn(async (items: { id?: string; label?: string }[]) => popupAnswer(items)); - window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup } } as never; + // The same HTML menu as everywhere else in the app (it was a native menu). + const openMenu = async (overrides: Partial[0]> = {}) => { + window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {} } } as never; const props = renderBar([file('1', 'report.pdf')], { onCopyFile: vi.fn(), ...overrides }); fireEvent.contextMenu(await screen.findByTestId('attachment'), { clientX: 5, clientY: 5 }); - await waitFor(() => expect(popup).toHaveBeenCalled()); - return { props, popup }; + const menu = await screen.findByRole('menu'); + const labels = within(menu).queryAllByRole('menuitem').map((item) => item.querySelector('[data-menu-label]')?.textContent ?? ''); + const pick = (label: string) => fireEvent.click(within(menu).getAllByRole('menuitem').find((item) => item.querySelector('[data-menu-label]')?.textContent === label)!); + return { props, labels, pick }; }; - it('has Open, Save as and Copy, natively', async () => { - const { popup } = await openMenu(() => null); - expect(popup.mock.calls[0][0].map((item) => item.label)).toEqual(['open', 'save_as', 'copy']); + it('has Open, Save as and Copy', async () => { + const { labels } = await openMenu(); + expect(labels).toEqual(['open', 'save_as', 'copy']); }); it.each([['open', 'onOpen'], ['save_as', 'onDownload'], ['copy', 'onCopyFile']] as const)('%s runs the action', async (label, handler) => { - const { props } = await openMenu((items) => items.find((item) => item.label === label)!.id!); + const { props, pick } = await openMenu(); + pick(label); await waitFor(() => expect((props as unknown as Record>)[handler]).toHaveBeenCalledTimes(1)); expect((props as unknown as Record>)[handler].mock.calls[0][0]).toMatchObject({ id: '1' }); }); - it('with downloads off there\'s nothing to save or copy', async () => { - const { popup } = await openMenu(() => null, { downloadsOff: true, opensPreview: () => true }); - expect(popup.mock.calls[0][0].map((item) => item.label)).toEqual(['open']); + it('with downloads off there is nothing to save or copy', async () => { + const { labels } = await openMenu({ downloadsOff: true, opensPreview: () => true }); + expect(labels).toEqual(['open']); }); it('Edit shows when the file can be edited and runs the action', async () => { const onEdit = vi.fn(); - const { props, popup } = await openMenu((items) => items.find((i) => i.label === 'edit')?.id ?? null, { onEdit, canEdit: () => true }); - const labels = popup.mock.calls[0][0].flatMap((i: { label?: string }) => (i.label ? [i.label] : [])); + const { props, labels, pick } = await openMenu({ onEdit, canEdit: () => true }); expect(labels).toEqual(['open', 'save_as', 'edit', 'copy']); + pick('edit'); await waitFor(() => expect(onEdit).toHaveBeenCalledWith(props.attachments[0])); }); it('Edit stays out for a file it cannot take', async () => { - const { popup } = await openMenu(() => null, { onEdit: vi.fn(), canEdit: () => false }); - expect(popup.mock.calls[0][0].map((i: { label?: string }) => i.label)).not.toContain('edit'); + const { labels } = await openMenu({ onEdit: vi.fn(), canEdit: () => false }); + expect(labels).not.toContain('edit'); }); it('Edit stays out with downloads off, whatever else says yes', async () => { - const { popup } = await openMenu(() => null, { onEdit: vi.fn(), canEdit: () => true, downloadsOff: true }); - expect(popup.mock.calls[0][0].map((i: { label?: string }) => i.label)).not.toContain('edit'); + const { labels } = await openMenu({ onEdit: vi.fn(), canEdit: () => true, downloadsOff: true }); + expect(labels).not.toContain('edit'); }); it('a browser keeps its own menu', async () => { diff --git a/components/email/__tests__/row-menu-everywhere.test.tsx b/components/email/__tests__/row-menu-everywhere.test.tsx new file mode 100644 index 000000000..b99df236d --- /dev/null +++ b/components/email/__tests__/row-menu-everywhere.test.tsx @@ -0,0 +1,138 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-copy-link', () => ({ useCopyLink: () => vi.fn() })); +// Rules needs a Sieve account; its entry stands in here. +vi.mock('../rules-menu', () => ({ + RulesContextSubMenu: () =>
rules.title
, +})); + +import { EmailContextMenu } from '../email-context-menu'; + +// The message's right-click menu is short everywhere: the browser and the desktop app +// show the same HTML menu (the desktop app had a native copy, without icons and with +// Chromium's key names), and the rarely used entries are under More actions. A +// selection gets the same structure without what needs one message. + +const email = { + id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', + hasAttachment: false, size: 100, +} as unknown as Email; +const other = { ...email, id: 'e2', keywords: {} } as unknown as Email; + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, + { id: 'work', name: 'Work', role: null, parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +type MenuProps = React.ComponentProps; + +function renderMenu(props: Partial = {}) { + return render( + , + ); +} + +/** The labels of a menu's own rows, not those of its submenus. */ +function labels(menu: HTMLElement): string[] { + return Array.from(menu.querySelectorAll('[role="menuitem"]')) + .filter((row) => row.parentElement?.closest('[role="menu"]') === menu) + .map((row) => row.querySelector('[data-menu-label]')?.textContent ?? ''); +} + +const ROW_TOP = ['reply', 'reply_all', 'forward', 'archive', 'delete', 'move_to', 'mark_unread', 'star', 'tag', 'mark_as_spam', 'open_in_window', 'more_actions']; + +afterEach(() => { + window.bulwarkDesktop = undefined; + localStorage.clear(); +}); + +describe("a message row's right-click menu", () => { + it('has the short list at the top in the browser', () => { + renderMenu(); + expect(labels(screen.getAllByRole('menu')[0])).toEqual(ROW_TOP); + }); + + it('is the same HTML menu in the desktop app, on screen, with no native copy', async () => { + const popup = vi.fn(async () => null); + window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup } } as never; + renderMenu(); + const menu = screen.getAllByRole('menu')[0]; + await waitFor(() => expect(menu.style.visibility).toBe('visible')); + expect(labels(menu)).toEqual(ROW_TOP); + // Icons on the rows, as in the browser. + expect(within(menu).getByTestId('ctx-delete').querySelector('svg')).not.toBeNull(); + expect(popup).not.toHaveBeenCalled(); + }); + + it('keeps the rest under More actions, in the order asked for', () => { + renderMenu(); + fireEvent.click(screen.getByTestId('ctx-more')); + expect(labels(screen.getByRole('menu', { name: 'more_actions' }))).toEqual([ + 'forward_as_attachment', 'copy_message', 'copy_conversation', 'rules.title', 'pin', 'export_email', 'print', 'view_source', + ]); + }); +}); + +describe("a selection's right-click menu", () => { + const selection = { isMultiSelect: true, selectedCount: 2, batchEmails: [email, other] }; + + it('has the same structure, without what needs one message', () => { + renderMenu(selection); + const top = labels(screen.getAllByRole('menu')[0]); + expect(top).toEqual(['archive', 'delete', 'move_to', 'mark_read', 'mark_unread', 'star', 'tag', 'mark_as_spam', 'more_actions']); + for (const single of ['reply', 'reply_all', 'forward', 'open_in_window']) expect(top).not.toContain(single); + fireEvent.click(screen.getByTestId('ctx-more')); + expect(labels(screen.getByRole('menu', { name: 'more_actions' }))).toEqual(['forward_as_attachments', 'rules.title']); + }); + + it('stars the whole selection', () => { + const onBatchSetStarred = vi.fn(); + renderMenu({ ...selection, onBatchSetStarred }); + fireEvent.click(screen.getByTestId('ctx-star')); + expect(onBatchSetStarred).toHaveBeenCalledWith(true); + }); + + it('only More actions, for the selection view\'s button', () => { + renderMenu({ ...selection, section: 'more' }); + expect(labels(screen.getAllByRole('menu')[0])).toEqual(['forward_as_attachments', 'rules.title']); + }); +}); diff --git a/components/email/__tests__/selection-view-focus.test.tsx b/components/email/__tests__/selection-view-focus.test.tsx new file mode 100644 index 000000000..944dfa80b --- /dev/null +++ b/components/email/__tests__/selection-view-focus.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { Star, StarOff, Archive } from '@/components/icons'; +import { SelectionView } from '../selection-view'; +import type { MessageActionEntry } from '../message-actions'; + +// After an action the focus stays where it was in the selection view: Star turning into +// Unstar is the same button. + +const entries = (starred: boolean): MessageActionEntry[] => [ + { kind: 'item', key: 'archive', icon: Archive, label: 'archive', run: vi.fn(), testId: 'ctx-archive' }, + starred + ? { kind: 'item', key: 'star', icon: StarOff, label: 'unstar', run: vi.fn(), testId: 'ctx-unstar' } + : { kind: 'item', key: 'star', icon: Star, label: 'star', run: vi.fn(), testId: 'ctx-star' }, +]; + +const props = { + title: '2 selected', + unread: 0, + shortcutOf: () => undefined, + onMove: vi.fn(), + onMore: vi.fn(), + onClear: vi.fn(), + tags: { selectedIds: [], partialIds: [], scope: null, onToggle: vi.fn() }, +}; + +describe('the selection view after an action', () => { + it('keeps the focus on Star when it becomes Unstar', () => { + const { rerender } = render(); + const star = screen.getByTestId('pane-batch-star'); + star.focus(); + rerender(); + expect(document.activeElement).toBe(screen.getByTestId('pane-batch-unstar')); + expect(screen.getByTestId('pane-batch-unstar')).toBe(star); + }); +}); diff --git a/components/email/__tests__/selection-view.test.ts b/components/email/__tests__/selection-view.test.ts new file mode 100644 index 000000000..35137449e --- /dev/null +++ b/components/email/__tests__/selection-view.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { selectionRows } from '../selection-view'; + +// The selection view's buttons go in even lines: none left alone on the last one. + +describe('selectionRows', () => { + it('puts everything on one line when it fits', () => { + expect(selectionRows(4, 4)).toEqual([4]); + }); + + it('evens out the lines instead of leaving one button alone', () => { + expect(selectionRows(7, 3)).toEqual([3, 2, 2]); + expect(selectionRows(9, 4)).toEqual([3, 3, 3]); + expect(selectionRows(5, 4)).toEqual([3, 2]); + }); + + it('with room for two, an odd count ends in one line across the width, not a column', () => { + expect(selectionRows(9, 2)).toEqual([2, 2, 2, 2, 1]); + }); + + it('never leaves one button alone at the end of a ragged wrap, for any count and width', () => { + for (let count = 2; count <= 12; count++) { + for (let perLine = 1; perLine <= 6; perLine++) { + const lines = selectionRows(count, perLine); + expect(lines.reduce((a, b) => a + b, 0)).toBe(count); + expect(Math.max(...lines)).toBeLessThanOrEqual(Math.max(1, perLine)); + // A line of one only in a column, or last under lines of two. + const ones = lines.map((n, i) => (n === 1 ? i : -1)).filter((i) => i >= 0); + if (ones.length && lines.some((n) => n > 1)) { + expect(ones).toEqual([lines.length - 1]); + expect(lines.slice(0, -1).every((n) => n === 2)).toBe(true); + } + } + } + }); +}); diff --git a/components/email/batch-tag-button.tsx b/components/email/batch-tag-button.tsx index f569007e7..23efaff19 100644 --- a/components/email/batch-tag-button.tsx +++ b/components/email/batch-tag-button.tsx @@ -1,21 +1,15 @@ "use client"; -import { useCallback, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; -import { createPortal } from "react-dom"; import { IconButton } from "@/components/ui/icon-button"; -import { useMenuNavigation } from "@/hooks/use-menu-navigation"; +import { ContextMenu } from "@/components/ui/context-menu"; +import { useContextMenu } from "@/hooks/use-context-menu"; import { Tag } from "@/components/icons"; import { TagPicker } from "./tag-picker"; import type { TagScope } from "@/hooks/use-account-tags"; -const TAG_PICKER_WIDTH = 224; - /** * The tag entry of the selection toolbar: opens the shared tag picker for - * every selected message at once. (#1077) - * - * The picker is portalled to the body because the toolbar clips its overflow - * to animate open and shut. + * every selected message at once (#1077), in the app's menu under the button. */ export function BatchTagButton({ title, @@ -44,63 +38,15 @@ export function BatchTagButton({ /** Name the button in words beside the icon, as the reading pane does (mail-p2-15). */ showLabel?: boolean; }) { - const [pos, setPos] = useState<{ top: number; left: number } | null>(null); - const buttonRef = useRef(null); - const close = useCallback(() => setPos(null), []); - const isOpen = pos !== null && active; - // The picker sits at the end of : the focus moves into it on open, - // the arrow keys walk the tags, and the focus goes back to the button on - // close (Escape, Tab, a pick that closes it). - const { menuRef: pickerRef, onKeyDown: onMenuKeyDown } = useMenuNavigation({ - open: isOpen, - onClose: close, - triggerRef: buttonRef, - }); - const onPickerKeyDown = (event: ReactKeyboardEvent) => { - // Home and End belong to the text fields (filter, new tag). - if ((event.key === "Home" || event.key === "End") && event.target instanceof HTMLInputElement) return; - onMenuKeyDown(event); - }; - - useEffect(() => { - if (!pos) return; - const onPointerDown = (event: PointerEvent) => { - const target = event.target as Node; - // The anchor is left to its own click handler, which toggles the picker - // shut - closing here first would let that click reopen it. - if (buttonRef.current?.contains(target)) return; - if (!pickerRef.current?.contains(target)) close(); - }; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") close(); - }; - document.addEventListener("pointerdown", onPointerDown, true); - document.addEventListener("keydown", onKeyDown); - window.addEventListener("resize", close); - return () => { - document.removeEventListener("pointerdown", onPointerDown, true); - document.removeEventListener("keydown", onKeyDown); - window.removeEventListener("resize", close); - }; - }, [pos, close, pickerRef]); + const menu = useContextMenu(); + const isOpen = menu.contextMenu.isOpen && active; return ( <> { - const rect = event.currentTarget.getBoundingClientRect(); - setPos((open) => - open - ? null - : { - top: rect.bottom + 4, - left: Math.max(8, Math.min(rect.left, window.innerWidth - TAG_PICKER_WIDTH - 8)), - }, - ); - }} + onClick={(event) => (isOpen ? menu.closeContextMenu() : menu.openContextMenuFrom(event.currentTarget, null, "start"))} aria-haspopup="menu" aria-expanded={isOpen} disabled={disabled} @@ -109,21 +55,11 @@ export function BatchTagButton({ {showLabel && {title}} - {isOpen && - createPortal( -
- -
, - document.body, - )} + +
+ +
+
); } diff --git a/components/email/composer-toolbar-menu.tsx b/components/email/composer-toolbar-menu.tsx index 07abf0e97..ad601258d 100644 --- a/components/email/composer-toolbar-menu.tsx +++ b/components/email/composer-toolbar-menu.tsx @@ -1,5 +1,6 @@ "use client"; +import { formatShortcutCombo } from "@/lib/shortcut-keys"; import { Button } from "@/components/ui/button"; import { ContextMenu, CONTEXT_MENU_ITEM_CLASS } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; @@ -112,17 +113,11 @@ export function ComposerToolbarMenuCheckItem({ checked, onToggle, description, t data-testid={testId} className={cn(CONTEXT_MENU_ITEM_CLASS, "items-start")} > -
{!contextMenu.data.recipient.group && ( - + )} - + {/* Moving between fields without dragging (WCAG 2.5.7). */} {(['to', 'cc', 'bcc'] as const).filter((target) => target !== field).map((target) => ( { @@ -7143,7 +7195,7 @@ export function RecipientChipInput({ /> ))} - { + { if (contextMenu.data) { handleChipRemove(contextMenu.data.index, { stopPropagation: () => {} } as React.MouseEvent); } diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 0d0d2eb0b..112d41c47 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -2,9 +2,7 @@ import { useMemo } from "react"; import { useLocale, useTranslations } from "next-intl"; -import { spamApplicableIn } from "@/lib/spam-applicable"; -import { useSettingsStore } from "@/stores/settings-store"; -import { useEmailStore, removalFolder, canRemoveFromFolder, bulkDeleteIsPermanent } from "@/stores/email-store"; +import { useEmailStore, removalFolder, canRemoveFromFolder } from "@/stores/email-store"; import { useEmailTagScope, type TagScope } from "@/hooks/use-account-tags"; import { Email, Mailbox } from "@/lib/jmap/types"; import { @@ -17,22 +15,9 @@ import { import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginActions, useMessageActionTarget, usePluginActions } from "@/components/plugins/plugin-actions"; import { - Mail, - MailOpen, - Trash2, - Archive, - FolderInput, - FolderX, - Tag, - ShieldAlert, - ShieldCheck, - Paperclip, - Copy, - Fish, MoreHorizontal, - Undo, } from "@/components/icons"; -import { tidySeparators, useMessageActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions"; +import { rowMenuSections, useMessageActionList, useSelectionActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions"; import { restoreTarget, restoreTargetForAll } from "@/lib/trash-origin"; import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; import { buildMoveMenu, type MoveMenuModel } from "@/lib/move-menu"; @@ -43,8 +28,6 @@ import { useConnectionState } from "@/hooks/use-connection-state"; import { getEmailTagIds } from "@/lib/thread-utils"; import { TagPicker } from "./tag-picker"; import { RulesContextSubMenu } from "./rules-menu"; -import { useArchiveActionEnabled } from "@/lib/archive-gate"; -import { usePhishingReport } from "@/hooks/use-phishing-report"; interface Position { x: number; @@ -104,6 +87,8 @@ interface EmailContextMenuProps { onRescheduleScheduled?: () => void; // Batch actions onBatchMarkAsRead?: (read: boolean) => void; + /** Stars the whole selection, or takes the star off every message. */ + onBatchSetStarred?: (starred: boolean) => void; /** Tags every selected message carries, and those only some of them do. */ batchTagIds?: string[]; batchPartialTagIds?: string[]; @@ -152,21 +137,13 @@ interface EmailContextMenuProps { * "More actions". The reader's ⋮ is that "more" already, so it shows them all. */ rowMenu?: boolean; + /** + * "more": only what a selection's menu has under "More actions", for the + * selection view's More actions button. + */ + section?: "all" | "more"; } -/** - * What a row's menu shows at its top level, as Outlook and Gmail have it. The rest - * goes under "More actions". - */ -export const ROW_MENU_KEYS = new Set([ - "reschedule", "cancel_and_edit", "cancel_send", "edit_draft", - "reply", "reply_all", "forward", - "restore", "archive", "delete", "move", - "read", "star", "tag", - "spam", - "open_window", -]); - /** Entries that change the message on the server, or queue the change offline. */ const CHANGES_MAIL = new Set([ "archive", "delete", "read", "remove_from_folder", "star", "pin", "spam", @@ -201,6 +178,7 @@ export function EmailContextMenu({ onMarkAsSpam, onUndoSpam, onBatchMarkAsRead, + onBatchSetStarred, batchTagIds, batchPartialTagIds, batchTagScope = null, @@ -230,33 +208,22 @@ export function EmailContextMenu({ extraEntries, label, rowMenu = false, + section = "all", }: EmailContextMenuProps) { const t = useTranslations("context_menu"); const tViewer = useTranslations("email_viewer"); - const tRecover = useTranslations("recover_deleted"); const shortcutOf = useMessageActionShortcut(); // Offline, a change to a message waits in the outbox only where the account keeps // its mail on this device; otherwise it would fail, so it's greyed out. Rules and // copies to another account always need the server. const { offline, canChangeMail } = useConnectionState(); const tMenu = useTranslations("menuAudit"); - const archiveActionEnabled = useArchiveActionEnabled(); - const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view"); const isUnread = !email.keywords?.$seen; const currentTagIds = getEmailTagIds(email.keywords); // The row's account names its tags; a selection uses batchTagScope. const tagScope = useEmailTagScope(email); const showBatchActions = isMultiSelect && selectedCount > 1; - const isInJunkFolder = currentMailboxRole === 'junk'; - // Marking your own outgoing mail as spam makes no sense - hide the action - // in Sent, Drafts and Scheduled, and in Trash, where it is already thrown out. - const spamApplicable = spamApplicableIn(currentMailboxRole); - const deleteForever = bulkDeleteIsPermanent({ role: currentMailboxRole }); - const tActions = useTranslations("reader_actions"); const isScheduled = email.isScheduled === true; - // One report per selected message, behind one confirm (enterprise pack). - const batchPhishing = usePhishingReport(showBatchActions ? (batchEmails ?? []) : [], currentMailboxRole); - const tPhishing = useTranslations("phishing_report"); // "Remove from this folder" needs one open folder and messages that are // also in another one; for a selection, every selected message must be. const folderToLeave = useEmailStore(removalFolder); @@ -383,91 +350,7 @@ export function EmailContextMenu({ ) : null; - const archiveItem = archiveActionEnabled && ( - <> - {/* Archive */} - handleAction(onBatchArchive!)} - disabled={!onBatchArchive || !canChangeMail} - /> - - ); - const deleteItem = ( - <> - {/* Delete */} - handleAction(onBatchDelete!)} - disabled={!onBatchDelete || !canChangeMail} - destructive={!mountainView} - /> - - ); - // A selection of read and unread mail gets both; otherwise the one that changes something. - const selection = batchEmails?.length ? batchEmails : [email]; - const someUnread = selection.some((e) => !e.keywords?.$seen); - const someRead = selection.some((e) => !!e.keywords?.$seen); - const readItem = ( - <> - {someUnread && ( - handleAction(() => onBatchMarkAsRead?.(true))} - /> - )} - {someRead && ( - handleAction(() => onBatchMarkAsRead?.(false))} - /> - )} - - ); - const batchRestoreItem = batchRestoreTo ? ( - <> - handleAction(() => onBatchMoveToMailbox?.(batchRestoreTo))} - /> - - - ) : null; - const moveItem = ( - <> - {/* Move to submenu */} - {canMoveAnywhere && ( - - {renderMoveList(onBatchMoveToMailbox)} - - )} - {canRemove && ( - handleAction(onBatchRemoveFromFolder!)} - /> - )} - - ); + // Copy to another connected account, keeping the originals. const copyTargetsContent = onCopyToAccount ? copyTrees.map((target) => (
@@ -476,73 +359,33 @@ export function EmailContextMenu({
)) : null; - const copyItem = ( - <> - {/* Copy to another connected account, keeping the originals */} - {onCopyToAccount && copyTrees.length > 0 && ( - - {copyTargetsContent} - - )} - - ); - const rulesItem = ( - <> - {/* Rules: filter rules made from the sender(s), saved as Sieve */} - - - ); - const tagItem = ( - <> - {/* Set tag submenu - the whole selection's tags (#1077) */} - {onBatchToggleTag && ( - -
- onBatchToggleTag?.(tagId)} - scope={batchTagScope} - /> -
-
- )} - - ); - const spamBlock = ( - <> - {/* Spam - contextual based on folder; pointless on own outgoing mail */} - {spamApplicable && ( - <> - - handleAction(isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)} - disabled={(isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) || !canChangeMail} - destructive={!isInJunkFolder && !mountainView} - /> - {batchPhishing.available && ( - handleAction(batchPhishing.report)} - /> - )} - - )} - - ); - const renderSubmenu = (entry: Extract) => { + // A selection: the same structure as a row's menu, without what needs one + // message (useSelectionActionList), shared with the reading pane's selection view. + const selectionLists = useSelectionActionList({ + emails: showBatchActions ? (batchEmails?.length ? batchEmails : [email]) : [], + currentMailboxRole, + canMove: canMoveAnywhere, + canRemoveFromFolder: canRemove, + canCopyToAccount: !!onCopyToAccount && copyTrees.length > 0, + canTag: !!onBatchToggleTag, + onRestore: batchRestoreTo ? () => onBatchMoveToMailbox?.(batchRestoreTo) : undefined, + onArchive: onBatchArchive, + onDelete: onBatchDelete, + onMarkAsRead: onBatchMarkAsRead, + onSetStarred: onBatchSetStarred, + onMarkAsSpam: onBatchMarkAsSpam, + onUndoSpam: onBatchUndoSpam, + onForwardAsAttachment: onBatchForwardAsAttachment, + onRemoveFromFolder: onBatchRemoveFromFolder, + }); + + const renderSubmenu = (entry: Extract, batch: boolean) => { switch (entry.key) { case "move": return ( - {renderMoveList(onMoveToMailbox)} + {renderMoveList(batch ? onBatchMoveToMailbox : onMoveToMailbox)} ); case "copy_to_account": @@ -555,7 +398,16 @@ export function EmailContextMenu({ return (
- onSetTag?.(tagId)} scope={tagScope} /> + {batch ? ( + onBatchToggleTag?.(tagId)} + scope={batchTagScope} + /> + ) : ( + onSetTag?.(tagId)} scope={tagScope} /> + )}
); @@ -564,43 +416,78 @@ export function EmailContextMenu({ } }; - const renderEntries = (entries: MessageActionEntry[], keyPrefix = "") => entries.map((entry, index) => { + const renderEntries = (entries: MessageActionEntry[], keyPrefix = "", batch = false) => entries.map((entry, index) => { if (entry.kind === "separator") return ; - if (entry.kind === "submenu") return renderSubmenu(entry); + if (entry.kind === "submenu") return renderSubmenu(entry, batch); + // Mark as read shows Shift+I, Mark as unread U. + const unread = entry.key === "read" ? entry.testId === "ctx-mark-read" : isUnread; return ( handleAction(entry.run)} /> ); }); - // A row's menu: the usual few, the rest one level down. Not Spam in Spam - // and Restore in Trash stay at the top. - const rowTop = rowMenu - ? tidySeparators(singleEntries.filter((entry) => entry.kind === "separator" || ROW_MENU_KEYS.has(entry.key))) - : singleEntries; - const rowMore = rowMenu - ? tidySeparators(singleEntries.filter((entry) => entry.kind === "separator" || !ROW_MENU_KEYS.has(entry.key))) - : []; - const singleMenu = ( + + // Plugins' entries: under "More actions" in a row's or a selection's menu, + // at the end of the reader's ⋮ (which is that "more" already). + const pluginEntries = ( <> - {renderEntries(rowTop)} - {rowMore.length > 0 && ( - <> - - - {renderEntries(rowMore, "more-")} - - - )} + {!showBatchActions && rowPlugins && rowPluginBlock} + ); + const moreMenu = (entries: MessageActionEntry[], batch: boolean) => ( + <> + + + {renderEntries(entries, "more-", batch)} + {pluginEntries} + + + ); + + let body: React.ReactNode; + if (showBatchActions) { + body = isScheduled ? null : section === "more" ? ( + <> + {renderEntries(selectionLists.more, "more-", true)} + {pluginEntries} + + ) : ( + <> + {t("items_selected", { count: selectedCount })} + {renderEntries(selectionLists.top, "", true)} + {moreMenu(selectionLists.more, true)} + + ); + } else if (rowMenu) { + // A row's menu: the usual few, the rest one level down. + const { top, more } = rowMenuSections(singleEntries); + body = ( + <> + {renderEntries(top)} + {moreMenu(more, false)} + {extraEntries} + + ); + } else { + body = ( + <> + {renderEntries(singleEntries)} + {rowPlugins && rowPluginBlock} + {extraEntries} + + + ); + } return ( {offline && {tMenu("offline_header")}} - {showBatchActions ? ( - <> - - {t("items_selected", { count: selectedCount })} - - - {!isScheduled && (<> - {batchRestoreItem} - {/* Compose-style actions first, above the archive/delete group, - mirroring the single menu's ordering. */} - handleAction(onBatchForwardAsAttachment!)} - disabled={!onBatchForwardAsAttachment} - /> - - - {mountainView ? ( - <> - {archiveItem} - {deleteItem} - {readItem} - - {moveItem} - {tagItem} - {rulesItem} - {copyItem} - {spamBlock} - - ) : ( - <> - {archiveItem} - {deleteItem} - - {moveItem} - {copyItem} - {rulesItem} - {tagItem} - {spamBlock} - - {readItem} - - )} - )} - - ) : ( - <> - {singleMenu} - {rowPlugins && rowPluginBlock} - {extraEntries} - - )} - - + {body} ); } diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx index de9d02acf..a81cf5ab3 100644 --- a/components/email/email-hover-actions.tsx +++ b/components/email/email-hover-actions.tsx @@ -1,7 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { createPortal } from "react-dom"; +import { useMemo } from "react"; import { Email } from "@/lib/jmap/types"; import { useSettingsStore } from "@/stores/settings-store"; import type { HoverAction } from "@/stores/settings-store"; @@ -11,6 +10,8 @@ import { useArchiveActionEnabled, withoutArchiveIfOff } from "@/lib/archive-gate import { useTranslations } from "next-intl"; import { useIsMobile } from "@/hooks/use-media-query"; import { TagPicker } from "@/components/email/tag-picker"; +import { ContextMenu } from "@/components/ui/context-menu"; +import { useContextMenu } from "@/hooks/use-context-menu"; import { useEmailTagScope } from "@/hooks/use-account-tags"; import { getEmailTagIds } from "@/lib/thread-utils"; import { PluginActions, useMessageActionTarget, usePluginActions } from "@/components/plugins/plugin-actions"; @@ -100,8 +101,6 @@ const CORNER_CLASSES = { } as const; /** Roughly the picker's height; used to flip it above the row near the viewport bottom. */ -const TAG_PICKER_MAX_HEIGHT = 320; -const TAG_PICKER_WIDTH = 224; export function EmailHoverActions({ email, @@ -142,40 +141,9 @@ export function EmailHoverActions({ // pane use. It is portalled to the body because every row sits inside a // `transform`ed virtual item, which would make a `fixed` child scroll with the // list and clip it at the scroller's edge. - const [tagPickerPos, setTagPickerPos] = useState<{ top: number; left: number } | null>(null); - const pickerRef = useRef(null); - const tagButtonRef = useRef(null); - const closeTagPicker = useCallback(() => setTagPickerPos(null), []); - - useEffect(() => { - if (!tagPickerPos) return; - const onPointerDown = (event: PointerEvent) => { - const target = event.target as Node; - // The anchor is left to its own click handler, which toggles the picker - // shut - closing here first would let that click reopen it. - if (tagButtonRef.current?.contains(target)) return; - if (!pickerRef.current?.contains(target)) closeTagPicker(); - }; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") closeTagPicker(); - }; - // The anchor moves with the list, so scrolling dismisses rather than chases - // it - but the picker's own tag list scrolls too, and that must not close it. - const onScroll = (event: Event) => { - if (pickerRef.current?.contains(event.target as Node)) return; - closeTagPicker(); - }; - document.addEventListener("pointerdown", onPointerDown, true); - document.addEventListener("keydown", onKeyDown); - window.addEventListener("scroll", onScroll, true); - window.addEventListener("resize", closeTagPicker); - return () => { - document.removeEventListener("pointerdown", onPointerDown, true); - document.removeEventListener("keydown", onKeyDown); - window.removeEventListener("scroll", onScroll, true); - window.removeEventListener("resize", closeTagPicker); - }; - }, [tagPickerPos, closeTagPicker]); + // The app's menu, under the button, with the picker in it. + const tagMenu = useContextMenu(); + const tagPickerOpen = tagMenu.contextMenu.isOpen; const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; @@ -205,22 +173,8 @@ export function EmailHoverActions({ // Previously this cleared every tag on the message, which is a no-op on // an untagged one - hence "clicking it does nothing". Open the picker. if (!onSetTag) break; - tagButtonRef.current = e.currentTarget as HTMLElement; - const rect = e.currentTarget.getBoundingClientRect(); - setTagPickerPos((open) => - open - ? null - : { - top: - rect.bottom + TAG_PICKER_MAX_HEIGHT > window.innerHeight - ? Math.max(8, rect.top - TAG_PICKER_MAX_HEIGHT - 4) - : rect.bottom + 4, - left: Math.max( - 8, - Math.min(rect.left, window.innerWidth - TAG_PICKER_WIDTH - 8), - ), - }, - ); + if (tagPickerOpen) tagMenu.closeContextMenu(); + else tagMenu.openContextMenuFrom(e.currentTarget as HTMLElement, null, "start"); break; } case "spam": @@ -264,7 +218,7 @@ export function EmailHoverActions({ title={title} aria-label={title} aria-haspopup={actionId === "tag" ? "menu" : undefined} - aria-expanded={actionId === "tag" ? tagPickerPos !== null : undefined} + aria-expanded={actionId === "tag" ? tagPickerOpen : undefined} className={cn( "transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10", // Gmail's are round 40px targets that darken rather than change hue. @@ -297,34 +251,32 @@ export function EmailHoverActions({ ); } - const tagPicker = - tagPickerPos && onSetTag - ? createPortal( -
e.stopPropagation()} - onContextMenu={(e) => e.stopPropagation()} - > - onSetTag(tagId)} - /> -
, - document.body, - ) - : null; + const tagPicker = onSetTag ? ( + + {/* The menu is rendered in a portal, but React still bubbles its clicks to the row. */} +
e.stopPropagation()} + onContextMenu={(e) => e.stopPropagation()} + > + onSetTag(tagId)} /> +
+
+ ) : null; // While the picker is open the row is no longer hovered, so the toolbar has to // stay mounted and visible or the picker would close under its own anchor. // Shown on keyboard focus too (R7), not only on hover. Only a control inside // the row counts, and only from the keyboard: a click focuses the row itself // (or the button clicked), and focus-within kept the toolbar on the open row. - const visibilityClass = tagPickerPos ? "flex" : "hidden group-hover:flex group-has-[:focus-visible]:flex"; + const visibilityClass = tagPickerOpen ? "flex" : "hidden group-hover:flex group-has-[:focus-visible]:flex"; if (hoverActionsMode === 'floating') { return ( diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index aa32bf841..a69ec7a66 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -11,11 +11,15 @@ import type { LoadListAttachments } from "@/lib/list-attachments"; import { listRowShowsChips } from "./attachment-chips"; import { listVerificationCode } from "@/lib/verification-code"; import { EmailContextMenu } from "./email-context-menu"; +import { SelectionView } from "./selection-view"; +import { restoreTargetForAll } from "@/lib/trash-origin"; +import { useSelectionActionList } from "./message-actions"; +import { useMessageActionShortcut } from "./message-action-shortcuts"; import { ViewSourceDialog } from "./view-source-dialog"; import { useCopyToAccount, useEmailExport, requestPrint } from "@/hooks/use-message-file-actions"; import { BatchTagButton } from "./batch-tag-button"; import { cn, cleanPreview } from "@/lib/utils"; -import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck, ShieldAlert, Archive, MoreVertical, X, FolderX, Fish, FolderInput, CheckSquare } from "@/components/icons"; +import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck, ShieldAlert, Archive, MoreVertical, X, FolderX, Fish, FolderInput } from "@/components/icons"; import { createPortal } from "react-dom"; import { ContextMenu } from "@/components/ui/context-menu"; import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list"; @@ -51,7 +55,7 @@ import { useTranslations } from "next-intl"; import { localizeMailboxName } from "@/lib/mailbox-label"; import { toast } from "@/stores/toast-store"; import { confirmMailRemoval } from "@/lib/mail-removal-confirm"; -import { markSelectionReadWithUndo, runBatchEmailAction, setSelectionTagWithUndo, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast"; +import { markSelectionReadWithUndo, runBatchEmailAction, setSelectionStarredWithUndo, setSelectionTagWithUndo, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast"; import { useVirtualizer } from "@tanstack/react-virtual"; import { measureVisibleRow } from "@/lib/virtual-row-measure"; import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display"; @@ -208,6 +212,7 @@ export function EmailList({ const openInWindow = useOpenEmailInWindow(); const tSpam = useTranslations('email_viewer.spam'); const tNotifications = useTranslations('notifications'); + const tSelection = useTranslations('selectionView'); const tViewer = useTranslations('email_viewer'); const tSidebar = useTranslations('sidebar'); const tListExtra = useTranslations('mail_list_extra'); @@ -407,6 +412,11 @@ export function EmailList({ const [sourceEmail, setSourceEmail] = useState(null); const [isProcessing, setIsProcessing] = useState(false); + // The selection view's More actions opens only that part of the selection's menu. + const [menuSection, setMenuSection] = useState<'all' | 'more'>('all'); + useEffect(() => { + if (!contextMenu.isOpen) setMenuSection('all'); + }, [contextMenu.isOpen]); const later = useTimeoutWhileMounted(); const parentRef = useRef(null); const batchToolbarRef = useRef(null); @@ -654,6 +664,20 @@ export function EmailList({ } }; + const handleBatchSetStarred = async (starred: boolean) => { + if (!client || isProcessing) return; + setIsProcessing(true); + try { + await setSelectionStarredWithUndo(client, starred, { + success: tSelection(starred ? 'starred' : 'unstarred'), + error: tNotifications('error_updating'), + labels: undo, + }); + } finally { + later(() => setIsProcessing(false), 500); + } + }; + const handleBatchUndoSpam = async () => { if (!client || isProcessing) return; setIsProcessing(true); @@ -777,33 +801,45 @@ export function EmailList({ // One read/unread button: "Mark as read" while any selected message is unread. const selectionHasUnread = selectedEmailsForRemoval.some((email) => !email.keywords?.$seen); - // Archive, Delete, Move, Mark read/unread and Tag, on the bar and in the - // reading pane. The pane's copies get their own test ids. - const selectionButtons = (where: 'bar' | 'pane') => { - const testId = (id: string) => (where === 'bar' ? id : `pane-${id}`); - // The pane has room: its buttons say what they do (mail-p2-15). The - // bar beside the list stays icons with tooltips. - const labelled = where === 'pane'; - const shape = labelled ? { size: 'sm' as const, title: '', className: 'gap-1.5' } : {}; - const name = (label: string) => (labelled ? {label} : null); - return ( + // The selection view's buttons: the selection menu's top level (useSelectionActionList). + const shortcutOf = useMessageActionShortcut(); + const selectionUnread = selectedEmailsForRemoval.filter((email) => !email.keywords?.$seen).length; + const selectionRestoreTo = effectiveMailboxRole === 'trash' && hasSelection + ? restoreTargetForAll(selectedEmailsForRemoval, mailboxes, selectedMailbox) + : undefined; + const selectionActions = useSelectionActionList({ + emails: selectedEmailsForRemoval, + currentMailboxRole: effectiveMailboxRole, + canMove: !moveMenuIsEmpty(selectionMoveModel), + canRemoveFromFolder: selectionCanLeaveFolder, + canTag: !isScheduledView, + onRestore: selectionRestoreTo ? () => void handleBatchMoveToMailbox(selectionRestoreTo) : undefined, + onArchive: () => void handleBatchArchive(), + onDelete: () => void handleBatchDelete(), + onMarkAsRead: (read) => void handleBatchMarkAsRead(read), + onSetStarred: (starred) => void handleBatchSetStarred(starred), + onMarkAsSpam: () => void handleBatchMarkAsSpam(), + onUndoSpam: () => void handleBatchUndoSpam(), + onRemoveFromFolder: () => void handleBatchRemoveFromFolder(), + }); + + // Archive, Delete, Move, Mark read/unread and Tag, as icons on the bar beside the + // list. The reading pane has the selection view. + const selectionButtons = () => ( <> {archiveActionEnabled && effectiveMailboxRole !== 'archive' && ( - void handleBatchArchive()} disabled={isProcessing} data-testid={testId('batch-archive')} {...shape}> + void handleBatchArchive()} disabled={isProcessing} data-testid="batch-archive"> - {name(tContextMenu('archive'))} )} - {name(t('batch_actions.delete'))} {!moveMenuIsEmpty(selectionMoveModel) && ( moveMenu.openContextMenuFrom(e.currentTarget, null, 'end')} disabled={isProcessing} - data-testid={testId('batch-move')} - {...shape} + data-testid="batch-move" > - {name(tContextMenu('move_to'))} )} handleBatchMarkAsRead(selectionHasUnread)} disabled={isProcessing} - data-testid={testId('batch-mark-read')} - {...shape} + data-testid="batch-mark-read" > {selectionHasUnread ? : } - {name(selectionHasUnread ? t('batch_actions.mark_read') : t('batch_actions.mark_unread'))} - ); - }; + ); + // What the bar and the pane call the selection: conversations, as the list // shows them, or emails when conversations are off. const selectionTitle = disableThreading @@ -1195,7 +1226,7 @@ export function EmailList({ {/* Archive, Delete, Move, Mark read/unread, Tag, then what fits a wide bar. The rest is in its ⋮, the selection's context menu (R6). */} - {selectionButtons('bar')} + {selectionButtons()} {selectionCanLeaveFolder && !batchNarrow && ( )} - {/* The reading pane for a selection: its count and the bar's buttons (mail-10). */} + {/* The reading pane for a selection: the selection menu's actions as buttons. */} {selectionPaneHost && hasSelection && !isScheduledView && createPortal( -
-
- -
-

{selectionTitle}

-
- {selectionButtons('pane')} - { - const anchor = selectedEmailsForRemoval[0]; - if (anchor) openContextMenuFrom(e.currentTarget, anchor, 'end'); - }} - > - - -
- -
, + (entry.key === 'star' ? undefined : shortcutOf(entry.key, entry.testId === 'ctx-mark-read'))} + onMove={(button) => moveMenu.openContextMenuFrom(button, null, 'start')} + onMore={(button) => { + const anchor = selectedEmailsForRemoval[0]; + if (!anchor) return; + setMenuSection('more'); + openContextMenuFrom(button, anchor, 'start'); + }} + onClear={clearSelection} + tags={{ selectedIds: selectionTags.all, partialIds: selectionTags.some, scope: selectionTags.scope, onToggle: handleBatchToggleTag }} + disabled={isProcessing || selectedEmailsForRemoval.length === 0} + />, selectionPaneHost, )} @@ -1600,6 +1623,8 @@ export function EmailList({ } : undefined} onViewSource={contextMenuEmail && !canExport(contextMenuEmail) ? undefined : () => setSourceEmail(contextMenuEmail)} onBatchMarkAsRead={(read) => markSelectionRead(read)} + onBatchSetStarred={(starred) => void handleBatchSetStarred(starred)} + section={menuSection} batchTagIds={selectionTags.all} batchPartialTagIds={selectionTags.some} batchTagScope={selectionTags.scope} diff --git a/components/email/mail-list-menus.tsx b/components/email/mail-list-menus.tsx index c69264028..037d69fcc 100644 --- a/components/email/mail-list-menus.tsx +++ b/components/email/mail-list-menus.tsx @@ -2,9 +2,9 @@ import { memo } from "react"; import { useTranslations } from "next-intl"; -import { ArrowUpDown, Check, CheckCheck, FolderX, MailOpen, MoreVertical, RefreshCw } from "@/components/icons"; +import { ArrowUpDown, CheckCheck, FolderX, MailOpen, MoreVertical, RefreshCw } from "@/components/icons"; import { ToolbarButton } from "@/components/ui/toolbar"; -import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu"; +import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu, ContextMenuCheckItem } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; import { useSettingsStore, getMessageListOrderFor } from "@/stores/settings-store"; import { usePolicyStore } from "@/stores/policy-store"; @@ -106,21 +106,7 @@ export function MailSortSubMenu({ folderRole, onPicked }: { folderRole: string | /** A checkable entry of the Sort menu, as in Files'. */ function SortItem({ label, checked, onClick, testId }: { label: string; checked: boolean; onClick: () => void; testId?: string }) { - return ( - - ); + return ; } /** diff --git a/components/email/message-action-shortcuts.ts b/components/email/message-action-shortcuts.ts index 944488657..2500a3204 100644 --- a/components/email/message-action-shortcuts.ts +++ b/components/email/message-action-shortcuts.ts @@ -3,6 +3,7 @@ import { useCallback } from "react"; import { useSettingsStore } from "@/stores/settings-store"; import type { MessageActionKey } from "./message-actions"; +import { formatShortcutCombo } from "@/lib/shortcut-keys"; /** * The keys the mail list runs a message's actions with (hooks/use-keyboard-shortcuts.ts), @@ -32,8 +33,14 @@ export function messageActionShortcut( return entry.key; } -/** `messageActionShortcut` with the user's single-key setting. */ +/** + * `messageActionShortcut` with the user's single-key setting, in the app's language as + * Windows writes the keys ("Umschalt + I" in German), for menus and tooltips. + */ export function useMessageActionShortcut(): (key: MessageActionKey, unread?: boolean) => string | undefined { const singleKeys = useSettingsStore((state) => state.singleKeyShortcuts !== false); - return useCallback((key, unread) => messageActionShortcut(key, { unread, singleKeys }), [singleKeys]); + return useCallback((key, unread) => { + const hint = messageActionShortcut(key, { unread, singleKeys }); + return hint && formatShortcutCombo(hint); + }, [singleKeys]); } diff --git a/components/email/message-actions.ts b/components/email/message-actions.ts index 8e707087e..07976af39 100644 --- a/components/email/message-actions.ts +++ b/components/email/message-actions.ts @@ -17,6 +17,7 @@ import { Mail, MailOpen, Star, + StarOff, Pin, PinOff, Trash2, @@ -320,3 +321,155 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr if (o.extras && o.extras.length > 0) list = [...list, SEPARATOR, ...o.extras]; return tidySeparators(list); } + +/** + * What a message's right-click and long-press menu shows at its top level, in the + * browser and in the desktop app alike: answer, file away, mark, spam, open. The rest + * goes under "More actions" (rowMenuSections). Restore in Trash, Not spam in Spam and a + * draft's or a scheduled message's own entries stay at the top. + */ +export const ROW_MENU_KEYS: ReadonlySet = new Set([ + "reschedule", "cancel_and_edit", "cancel_send", "edit_draft", + "reply", "reply_all", "forward", + "restore", "archive", "delete", "move", + "read", "star", "tag", + "spam", + "open_window", +]); + +/** + * "More actions", in groups with a line between: forwarding, links, filing and marks, + * then save, print and source. Keys not named here (a host's own entries) go before + * the last group. + */ +const MORE_GROUPS: MessageActionKey[][] = [ + ["forward_as_attachment"], + ["copy_message_link", "copy_conversation_link"], + ["copy_to_account", "rules", "remove_from_folder", "pin", "report_phishing", "find_from_sender"], + ["export", "print", "view_source"], +]; + +function moreGroupOf(key: MessageActionKey): number { + const at = MORE_GROUPS.findIndex((group) => group.includes(key)); + return at >= 0 ? at : MORE_GROUPS.length - 1.5; +} + +/** Splits one message's actions into the row menu's top level and its "More actions". */ +export function rowMenuSections(list: MessageActionEntry[]): { top: MessageActionEntry[]; more: MessageActionEntry[] } { + const top = tidySeparators(list.filter((entry) => entry.kind === "separator" || ROW_MENU_KEYS.has(entry.key))); + const rest = list.filter( + (entry): entry is Exclude => entry.kind !== "separator" && !ROW_MENU_KEYS.has(entry.key), + ); + // Stable: within a group the list's own order stays. + const sorted = rest + .map((entry, index) => ({ entry, index, group: moreGroupOf(entry.key) })) + .sort((a, b) => a.group - b.group || a.index - b.index); + const more: MessageActionEntry[] = []; + sorted.forEach(({ entry, group }, i) => { + if (i > 0 && sorted[i - 1].group !== group) more.push(SEPARATOR); + more.push(entry); + }); + return { top, more }; +} + +export interface SelectionActionOptions { + /** The selected messages. */ + emails: Email[]; + currentMailboxRole?: string; + canMove?: boolean; + canRemoveFromFolder?: boolean; + canCopyToAccount?: boolean; + /** Offer the Tag submenu. */ + canTag?: boolean; + /** In Trash: puts every selected message back. */ + onRestore?: () => void; + onArchive?: () => void; + onDelete?: () => void; + onMarkAsRead?: (read: boolean) => void; + onSetStarred?: (starred: boolean) => void; + onMarkAsSpam?: () => void; + onUndoSpam?: () => void; + onForwardAsAttachment?: () => void; + onRemoveFromFolder?: () => void; +} + +/** + * The actions for a selection of several messages. The selection's right-click menu + * and the reading pane's selection view both show these, in this order: the same + * structure as one message's row menu, without what needs a single message (answering, + * its own window, links, saving, printing, its source). + */ +export function useSelectionActionList(o: SelectionActionOptions): { top: MessageActionEntry[]; more: MessageActionEntry[] } { + const t = useTranslations("context_menu"); + const tPhishing = useTranslations("phishing_report"); + const tRecover = useTranslations("recover_deleted"); + const tActions = useTranslations("reader_actions"); + const archiveEnabled = useArchiveActionEnabled(); + const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view"); + const phishing = usePhishingReport(o.emails, o.currentMailboxRole); + const role = o.currentMailboxRole; + const inJunk = role === "junk"; + const deleteForever = bulkDeleteIsPermanent({ role }); + const someUnread = o.emails.some((e) => !e.keywords?.$seen); + const someRead = o.emails.some((e) => !!e.keywords?.$seen); + const allStarred = o.emails.length > 0 && o.emails.every((e) => !!e.keywords?.$flagged); + + const item = ( + key: MessageActionKey, + icon: Icon, + label: string, + run: (() => void) | undefined, + extra: { destructive?: boolean; testId?: string } = {}, + ): MessageActionEntry => ({ kind: "item", key, icon, label, run: () => run?.(), disabled: !run, ...extra }); + + const restore = role === "trash" && o.onRestore + ? [item("restore", Undo, tRecover("restore"), o.onRestore, { testId: "ctx-restore" }), SEPARATOR] + : []; + // In Archive there's nowhere further to archive it to. + const archive = archiveEnabled && role !== "archive" ? [item("archive", Archive, t("archive"), o.onArchive, { testId: "ctx-archive" })] : []; + const del = item("delete", Trash2, deleteForever ? tActions("delete_forever") : t("delete"), o.onDelete, { + destructive: !mountainView, + testId: "ctx-delete", + }); + const move: MessageActionEntry[] = o.canMove ? [{ kind: "submenu", key: "move", icon: FolderInput, label: t("move_to"), testId: "ctx-move-to" }] : []; + // A selection of read and unread mail gets both. + const read = [ + ...(someUnread ? [item("read", MailOpen, t("mark_read"), () => o.onMarkAsRead?.(true), { testId: "ctx-mark-read" })] : []), + ...(someRead ? [item("read", Mail, t("mark_unread"), () => o.onMarkAsRead?.(false), { testId: "ctx-mark-unread" })] : []), + ]; + // Takes the star off when every one has it, else stars them all. + const star = o.onSetStarred + ? [item("star", allStarred ? StarOff : Star, allStarred ? t("unstar") : t("star"), () => o.onSetStarred?.(!allStarred), { + testId: allStarred ? "ctx-unstar" : "ctx-star", + })] + : []; + const tag: MessageActionEntry[] = o.canTag ? [{ kind: "submenu", key: "tag", icon: Tag, label: t("tag"), testId: "ctx-tag" }] : []; + const spam = spamApplicableIn(role) + ? [item("spam", inJunk ? ShieldCheck : ShieldAlert, inJunk ? t("not_spam") : t("mark_as_spam"), inJunk ? o.onUndoSpam : o.onMarkAsSpam, { + destructive: !inJunk && !mountainView, + testId: inJunk ? "ctx-not-spam" : "ctx-spam", + })] + : []; + + // The row menu's order in each layout (useMessageActionList). + const top = tidySeparators(mountainView + ? [...restore, ...archive, del, ...read, SEPARATOR, ...move, ...tag, SEPARATOR, ...star, ...spam] + : [...restore, ...archive, del, SEPARATOR, ...move, SEPARATOR, ...read, ...star, ...tag, SEPARATOR, ...spam]); + + const copy: MessageActionEntry[] = o.canCopyToAccount + ? [{ kind: "submenu", key: "copy_to_account", icon: Copy, label: t("copy_to_account"), testId: "ctx-copy-to" }] + : []; + const more = tidySeparators([ + item("forward_as_attachment", Paperclip, t("forward_as_attachments"), o.onForwardAsAttachment, { testId: "ctx-batch-forward-as-attachment" }), + SEPARATOR, + ...copy, + { kind: "submenu", key: "rules", icon: Filter, label: t("rules.title"), testId: "ctx-rules" }, + ...(o.canRemoveFromFolder && o.onRemoveFromFolder + ? [item("remove_from_folder", FolderX, t("remove_from_folder"), o.onRemoveFromFolder, { testId: "ctx-remove-from-folder" })] + : []), + ...(phishing.available + ? [SEPARATOR, item("report_phishing", Fish, tPhishing("action"), phishing.report, { testId: "ctx-batch-report-phishing" })] + : []), + ]); + return { top, more }; +} diff --git a/components/email/move-target-list.tsx b/components/email/move-target-list.tsx index 34632ca49..88787d785 100644 --- a/components/email/move-target-list.tsx +++ b/components/email/move-target-list.tsx @@ -64,7 +64,7 @@ interface MoveFolderListProps { * `panel`: the phone's full-width panel (44px rows). */ variant?: "menu" | "dialog" | "panel"; - /** Opens the Move dialog. Offered at the end of a long list, since a native menu has no filter field. */ + /** Opens the Move dialog, which has the filter. Offered at the end of a long list. */ onChooseFolder?: () => void; /** Always show the filter field (the dialog). */ alwaysFilter?: boolean; @@ -80,9 +80,8 @@ interface MoveFolderListProps { * greyed, other reachable accounts under their names, and a filter field * once the list is long. * - * Each folder with subfolders is wrapped in `data-native-submenu`: the - * desktop app's native copy of the menu makes it a submenu there, the folder - * itself first (lib/desktop/native-menu.ts). + * Each folder with subfolders is wrapped in `data-native-submenu`, which keeps + * the folder and its subfolders together as one group. */ export function MoveFolderList({ model, diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx index 6bb5d9516..fc3f55276 100644 --- a/components/email/mv-list-toolbar.tsx +++ b/components/email/mv-list-toolbar.tsx @@ -17,6 +17,7 @@ import { Trash2, RefreshCw, Fish, + Plus, } from "@/components/icons"; import { spamApplicableIn } from "@/lib/spam-applicable"; import { usePhishingReport } from "@/hooks/use-phishing-report"; @@ -546,6 +547,7 @@ export function MvListToolbar({ <> { closeMove(); onCreateFolder(); }} /> diff --git a/components/email/reader-attachments.tsx b/components/email/reader-attachments.tsx index 52b386b42..7fbb874f3 100644 --- a/components/email/reader-attachments.tsx +++ b/components/email/reader-attachments.tsx @@ -2,11 +2,11 @@ import { useEffect, useState, type DragEvent, type ReactNode } from "react"; import { useTranslations } from "next-intl"; -import { ChevronDown, ChevronUp, Copy, Download, FolderInput, Loader2, Paperclip } from "@/components/icons"; +import { ChevronDown, ChevronUp, Copy, Download, FolderInput, Loader2, Paperclip, Eye, Pencil } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; -import { nativeMenusAvailable } from "@/lib/desktop/native-menu"; +import { isDesktopApp } from "@/lib/desktop/bridge"; import { IconButton } from "@/components/ui/icon-button"; import { getAttachmentDisplayName } from "@/lib/attachment-name"; import { cn, formatFileSize } from "@/lib/utils"; @@ -129,7 +129,7 @@ export function ReaderAttachments({ const tEdit = useTranslations("desktopEdit"); const menu = useContextMenu(); const [desktopMenu, setDesktopMenu] = useState(false); - useEffect(() => setDesktopMenu(nativeMenusAvailable()), []); + useEffect(() => setDesktopMenu(isDesktopApp()), []); const menuTarget = menu.contextMenu.data; // Opens nothing: no preview, and downloads are off. const locked = (attachment: T) => downloadsOff && !opensPreview(attachment); @@ -355,16 +355,16 @@ export function ReaderAttachments({ {desktopMenu && menuTarget && ( {!locked(menuTarget) && ( - { menu.closeContextMenu(); onOpen(menuTarget); }} /> + { menu.closeContextMenu(); onOpen(menuTarget); }} /> )} {!downloadsOff && ( - { menu.closeContextMenu(); onDownload(menuTarget); }} /> + { menu.closeContextMenu(); onDownload(menuTarget); }} /> )} {!downloadsOff && onEdit && canEdit?.(menuTarget) && ( - { menu.closeContextMenu(); onEdit(menuTarget); }} /> + { menu.closeContextMenu(); onEdit(menuTarget); }} /> )} {!downloadsOff && onCopyFile && ( - { menu.closeContextMenu(); onCopyFile(menuTarget); }} /> + { menu.closeContextMenu(); onCopyFile(menuTarget); }} /> )} )} diff --git a/components/email/recent-files-menu-section.tsx b/components/email/recent-files-menu-section.tsx new file mode 100644 index 000000000..524478d26 --- /dev/null +++ b/components/email/recent-files-menu-section.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { CONTEXT_MENU_ITEM_CLASS, menuLevelItems } from "@/components/ui/context-menu"; +import { attachmentIcon } from "@/components/email/attachment-chips"; +import { listRecentFiles, recentWhen, shortFolder } from "@/lib/desktop/recent-files"; +import type { DesktopRecentFile } from "@/lib/desktop/types"; +import { cn } from "@/lib/utils"; + +/** Only an icon the shell made: a PNG as a data: URL. */ +const ICON_URL = /^data:image\/png;base64,[A-Za-z0-9+/=]+$/; + +/** + * The top of the composer's Attach menu in the desktop app: the files the OS says were + * used last, as Outlook lists them. Mounted when the menu opens, from that click, which is + * what the shell asks for. Until the list is there, and when it's empty, it takes no room. + * It stays an HTML menu (data-native-menu): a native one can't show the icons and folders. + */ +export function RecentFilesMenuSection({ onPick }: { onPick: (file: DesktopRecentFile) => void }) { + const t = useTranslations("desktopAttachRecent"); + const locale = useLocale(); + const [files, setFiles] = useState(null); + // One request per opening, also when a strict-mode remount runs the effect twice. + const request = useRef | null>(null); + const rootRef = useRef(null); + + useEffect(() => { + let cancelled = false; + request.current ??= listRecentFiles(); + void request.current.then((list) => { + if (!cancelled) setFiles(list); + }); + return () => { + cancelled = true; + }; + }, []); + + // The menu put the focus on its first entry before the list came. If it's still + // there, the first file takes it: the list is what comes first. + useLayoutEffect(() => { + const root = rootRef.current; + const menu = root?.closest('[role="menu"]'); + if (!root || !menu || !files?.length) return; + const active = document.activeElement; + const others = menuLevelItems(menu).filter((item) => !root.contains(item)); + if (active && active === others[0]) root.querySelector('[role="menuitem"]')?.focus({ preventScroll: true }); + }, [files]); + + const now = Date.now(); + return ( + + )} + footer={( + <> + + + + )} + /> + ); +} + +/** + * App-wide: asks about a mass delete wherever the user is, one root at a time. + * "Decide later" hides it for this question; the folder's row on the settings page + * still asks, and the pair stays paused until then. + */ +export function FilesSyncPrompts() { + const available = useFilesSyncStore((s) => s.available); + const roots = useFilesSyncStore((s) => s.roots); + const status = useFilesSyncStore((s) => s.status); + const controller = useFilesSyncStore((s) => s.controller); + const pageAsks = useSyncExternalStore(subscribeInline, inlineShown, () => false); + const [later, setLater] = useState>(() => new Set()); + + if (!available || !controller || pageAsks) return null; + const keyOf = (s: FilesSyncRootStatus) => `${s.rootId}:${s.massDelete?.side}:${s.massDelete?.count}`; + const root = roots.find((r) => { + const s = status[r.id]; + return s?.state === "confirm-delete" && !!s.massDelete && !later.has(keyOf(s)); + }); + if (!root) return null; + const rootStatus = status[root.id]; + return ( + setLater((prev) => new Set(prev).add(keyOf(rootStatus)))} + /> + ); +} diff --git a/components/files-sync/files-sync-status-line.tsx b/components/files-sync/files-sync-status-line.tsx new file mode 100644 index 000000000..a94d09139 --- /dev/null +++ b/components/files-sync/files-sync-status-line.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useFormatter, useTranslations } from "next-intl"; +import { AlertCircle, AlertTriangle, CheckCircle2, CloudOff, Loader2, Lock, Pause, RefreshCw, type AppIcon } from "@/components/icons"; +import { cn } from "@/lib/utils"; +import type { FilesSyncRootState, FilesSyncRootStatus } from "@/lib/files-sync/types"; +import { filesSyncErrorKey, syncTimeOptions } from "./files-sync-text"; + +const ICONS: Record = { + starting: { icon: Loader2, className: "text-muted-foreground animate-spin motion-reduce:animate-none" }, + syncing: { icon: RefreshCw, className: "text-primary animate-spin motion-reduce:animate-none" }, + idle: { icon: CheckCircle2, className: "text-green-600 dark:text-green-400" }, + paused: { icon: Pause, className: "text-muted-foreground" }, + offline: { icon: CloudOff, className: "text-muted-foreground" }, + error: { icon: AlertCircle, className: "text-destructive" }, + "confirm-delete": { icon: AlertTriangle, className: "text-amber-600 dark:text-amber-400" }, + blocked: { icon: Lock, className: "text-muted-foreground" }, +}; + +/** One line saying how a root is doing: up to date, syncing, paused, offline, stopped. */ +export function FilesSyncStatusLine({ status, paused, className }: { + status: FilesSyncRootStatus | undefined; + /** The root says it's paused; the engine may not have said so yet. */ + paused?: boolean; + className?: string; +}) { + const t = useTranslations("files_sync"); + const format = useFormatter(); + const state: FilesSyncRootState = status?.state ?? (paused ? "paused" : "starting"); + + let text: string; + switch (state) { + case "idle": + text = status?.lastSyncedAt + ? t("status.idle", { time: format.dateTime(new Date(status.lastSyncedAt), syncTimeOptions(status.lastSyncedAt)) }) + : t("status.idle_never"); + break; + case "syncing": + text = t("status.syncing", { done: status?.done ?? 0, pending: status?.pending ?? 0 }); + break; + case "offline": + text = t("status.offline", { count: status?.pending ?? 0 }); + break; + case "error": + text = t("status.error", { reason: t(filesSyncErrorKey(status?.errorKey)) }); + break; + case "confirm-delete": + text = t("status.confirm_delete"); + break; + default: + text = t(`status.${state}`); + } + const { icon: Icon, className: iconClass } = ICONS[state]; + return ( +

+

+ ); +} diff --git a/components/files-sync/files-sync-text.ts b/components/files-sync/files-sync-text.ts new file mode 100644 index 000000000..50c395ba0 --- /dev/null +++ b/components/files-sync/files-sync-text.ts @@ -0,0 +1,46 @@ +import type { FilesRoot } from "@/lib/desktop/types"; +import type { FilesSyncActivityKind, FilesSyncItemStatus } from "@/lib/files-sync/types"; + +/** The engine's message keys, under `files_sync.errors.`. */ +export const FILES_SYNC_ERROR_KEYS = [ + "offline", "server_refused", "too_large", "name_invalid", "disk_full", "denied", "busy", "quota", "not_found", "policy", "failed", +] as const; + +/** + * The key inside the `files_sync` namespace for an engine message key. The engine may + * write `offline`, `errors.offline` or `files_sync.errors.offline`; anything unknown + * reads as `failed`, so a new key never shows raw. + */ +export function filesSyncErrorKey(key: string | undefined | null): string { + const last = (key ?? "").split(".").pop() ?? ""; + return `errors.${(FILES_SYNC_ERROR_KEYS as readonly string[]).includes(last) ? last : "failed"}`; +} + +export const FILES_SYNC_ACTIVITY_KINDS: readonly FilesSyncActivityKind[] = [ + "uploaded", "downloaded", "folder-created", "renamed", "moved", "deleted", "trashed", + "conflict", "renamed-for-disk", "too-large", "waiting", "mass-delete", "error", +]; + +export const FILES_SYNC_ITEM_STATES: readonly FilesSyncItemStatus[] = ["synced", "syncing", "pending", "error", "online-only"]; + +/** "Show in Explorer", "Show in Finder", "Show in file manager". */ +export function showInKey(platform: string | undefined | null): string { + if (platform === "win32") return "show_in.explorer"; + if (platform === "darwin") return "show_in.finder"; + return "show_in.file_manager"; +} + +/** The name of a root to show: the drive's, or the last part of the folder's path. */ +export function rootName(root: FilesRoot, driveName: string): string { + if (root.kind === "drive") return driveName; + const parts = root.path.split(/[\\/]+/).filter(Boolean); + return parts[parts.length - 1] ?? root.path; +} + +/** A time as "14:05" today and "3 Nov, 14:05" before. */ +export function syncTimeOptions(at: number, now = Date.now()): { hour: "numeric"; minute: "2-digit"; day?: "numeric"; month?: "short" } { + const sameDay = new Date(at).toDateString() === new Date(now).toDateString(); + return sameDay + ? { hour: "numeric", minute: "2-digit" } + : { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" }; +} diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index bd1c0d351..24e13cb88 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -39,6 +39,7 @@ import { getFilePreviewKind } from "@/lib/file-preview"; import type { FileAppAction } from "@/lib/files-app-actions"; import { useAppSidePanel } from "@/components/plugins/app-side-panel"; import { MAX_SIDE_PANEL_FILES } from "@/lib/plugin-sandbox/assistant/f.types"; +import { ShowInFolderMenuItem } from "@/components/files-sync/files-sync-item"; import { resourceToView, useFileStore, isSharedFilesPage, sharedFilePages, type FileResource } from "@/stores/file-store"; import { TRASH_LOCATION, TRASH_PATH, isTrashLocation } from "@/lib/files-trash"; import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions"; @@ -1057,6 +1058,7 @@ export function FileBrowser({ { closeMenu(); void onDownload(resource.name); }} /> )} { closeMenu(); toggleDetails(true); }} /> + { closeMenu(); deleteNames([resource.name]); }} /> )} +
} +
{pluginProps && }
, diff --git a/components/files/file-views.tsx b/components/files/file-views.tsx index 7087c3d62..f03d33c64 100644 --- a/components/files/file-views.tsx +++ b/components/files/file-views.tsx @@ -10,6 +10,7 @@ import { FileSize, MiddleCutName, ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate, type IconOptions, } from "@/components/files/file-item-parts"; +import { FilesSyncBadge } from "@/components/files-sync/files-sync-item"; import type { SortDir, SortKey } from "@/components/files/files-header"; import { TOUCH_HIT_AREA } from "@/components/ui/button"; import { FILE_LIST_COLUMNS } from "./file-list-layout"; @@ -274,6 +275,7 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget, {isStarred && } +
{touch && (
@@ -487,6 +489,7 @@ function FileTile({ resource, isSelected, isFocused, isTabStop, isCut, selecting {!resource.isSymlink && <> · }{formatShortDate(resource.lastModified)} +
void; icon?: React.ComponentType<{ className?: string }>; }) { - return ( - - ); + return ; } /** diff --git a/components/icons/index.ts b/components/icons/index.ts index 2dd968f2f..238d49b47 100644 --- a/components/icons/index.ts +++ b/components/icons/index.ts @@ -44,6 +44,7 @@ export { IconBookmark as Bookmark, IconBookmarkPlus as BookmarkPlus, IconBox as Box, + IconBraces as Braces, IconBrain as Brain, IconBriefcase as Briefcase, IconBug as Bug, diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index e36d84345..2e12621f7 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -22,7 +22,8 @@ import { accountAvatarName, getMaxAccounts } from "@/lib/account-utils"; import { isDocumentRTL } from "@/i18n/direction"; import { cn, formatFileSize } from "@/lib/utils"; import { quotaLevel, type StorageQuota } from "@/lib/quota"; -import { useMenuNavigation } from "@/hooks/use-menu-navigation"; +import { useContextMenu } from "@/hooks/use-context-menu"; +import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { useStalwartAdmin } from "@/hooks/use-stalwart-admin"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; @@ -304,61 +305,11 @@ export function NavigationRail({ const switchAccount = useAuthStore((s) => s.switchAccount); const logout = useAuthStore((s) => s.logout); const logoutAll = useAuthStore((s) => s.logoutAll); - const [logoutMenuOpen, setLogoutMenuOpen] = useState(false); - const logoutBtnRef = useRef(null); - // Portalled to , so without explicit focus handling the menu is - // unreachable for keyboard and screen reader users (#719). - const closeLogoutMenu = useCallback(() => setLogoutMenuOpen(false), []); - const { menuRef: logoutPopoverRef, onKeyDown: onLogoutMenuKeyDown } = useMenuNavigation({ - open: logoutMenuOpen, - onClose: closeLogoutMenu, - triggerRef: logoutBtnRef, - }); - const [logoutPopoverStyle, setLogoutPopoverStyle] = useState({}); + // Sign out: the app's own menu, as every ⋮ and right-click has it. + const logoutMenu = useContextMenu(); + const logoutMenuOpen = logoutMenu.contextMenu.isOpen; const [showShortcutsModal, setShowShortcutsModal] = useState(false); - const updateLogoutPosition = useCallback(() => { - if (!logoutBtnRef.current) return; - const rect = logoutBtnRef.current.getBoundingClientRect(); - setLogoutPopoverStyle( - isDocumentRTL() - ? { - position: "fixed", - right: window.innerWidth - rect.left + 8, - bottom: Math.max(8, window.innerHeight - rect.bottom), - } - : { - position: "fixed", - left: rect.right + 8, - bottom: Math.max(8, window.innerHeight - rect.bottom), - } - ); - }, []); - - // Position before the first paint (same reasoning as the quota popover above). - useLayoutEffect(() => { - if (logoutMenuOpen) updateLogoutPosition(); - }, [logoutMenuOpen, updateLogoutPosition]); - - useEffect(() => { - if (!logoutMenuOpen) return; - const handleClickOutside = (e: MouseEvent) => { - if ( - logoutBtnRef.current?.contains(e.target as Node) || - logoutPopoverRef.current?.contains(e.target as Node) - ) return; - setLogoutMenuOpen(false); - }; - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape") setLogoutMenuOpen(false); - }; - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleEscape); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleEscape); - }; - }, [logoutMenuOpen, logoutPopoverRef]); const navItems: NavItem[] = [ @@ -952,10 +903,9 @@ export function NavigationRail({ )}
- {/* Logout button with popover */} + {/* Sign out, with a menu */} - - {logoutMenuOpen && createPortal( -
- - {accounts.length > 1 && ( - - )} -
, - document.body - )} + + { logoutMenu.closeContextMenu(); logout(); }} /> + {accounts.length > 1 && ( + { logoutMenu.closeContextMenu(); logoutAll(); }} /> + )} + )} diff --git a/components/layout/phone-header-actions.tsx b/components/layout/phone-header-actions.tsx index 7e74c7074..7d5ac90c9 100644 --- a/components/layout/phone-header-actions.tsx +++ b/components/layout/phone-header-actions.tsx @@ -1,14 +1,14 @@ "use client"; -import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { useTranslations } from "next-intl"; import { MoreVertical, Terminal, Bell } from "@/components/icons"; import { IconButton } from "@/components/ui/icon-button"; -import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; +import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; import { AccountSwitcher } from "@/components/layout/account-switcher"; import { PluginActions } from "@/components/plugins/plugin-actions"; import { AppSidePanelPhoneButton } from "@/components/plugins/app-side-panel"; -import { useMenuNavigation } from "@/hooks/use-menu-navigation"; +import { useContextMenu } from "@/hooks/use-context-menu"; import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query"; import { openCommandPalette } from "@/lib/app-commands"; import { cn } from "@/lib/utils"; @@ -41,29 +41,17 @@ export function PhoneHeaderMenu({ menuItems }: PhoneHeaderMenuProps) { const tNotifications = useTranslations("notification_center"); const unread = useUnreadNotificationCount(); const [showNotifications, setShowNotifications] = useState(false); - const [open, setOpen] = useState(false); - const triggerRef = useRef(null); - const close = useCallback(() => setOpen(false), []); - const { menuRef, onKeyDown } = useMenuNavigation({ open, onClose: close, triggerRef }); - - useEffect(() => { - if (!open) return; - const onPointerDown = (e: MouseEvent) => { - if (triggerRef.current?.contains(e.target as Node) || menuRef.current?.contains(e.target as Node)) return; - setOpen(false); - }; - document.addEventListener("mousedown", onPointerDown); - return () => document.removeEventListener("mousedown", onPointerDown); - }, [open, menuRef]); + const menu = useContextMenu(); + const open = menu.contextMenu.isOpen; + const close = menu.closeContextMenu; const appItems = menuItems?.(close); return (
setOpen((v) => !v)} + onClick={(e) => (open ? close() : menu.openContextMenuFrom(e.currentTarget, null, "end"))} className={cn(open && "bg-accent")} label={unread > 0 ? `${t("more")} (${tNotifications("unread_count", { count: unread })})` : t("more")} title="" @@ -75,14 +63,7 @@ export function PhoneHeaderMenu({ menuItems }: PhoneHeaderMenuProps) { - {open && ( -
+ {appItems && ( <> {appItems} @@ -101,8 +82,7 @@ export function PhoneHeaderMenu({ menuItems }: PhoneHeaderMenuProps) { onClick={() => { close(); openCommandPalette(); }} /> -
- )} + {showNotifications && setShowNotifications(false)} />}
); diff --git a/components/layout/window-controls-host.tsx b/components/layout/window-controls-host.tsx index 1681da6f0..a80fbd422 100644 --- a/components/layout/window-controls-host.tsx +++ b/components/layout/window-controls-host.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { applyControlsArea, watchTitlebarArea, watchWindowRows, type TitlebarArea } from "@/lib/window-controls-overlay"; import { claimBrowserSignIn } from "@/lib/desktop/browser-sign-in"; +import { claimFilesControls } from "@/lib/files-sync/controls"; /** * The window's controls over the app, with no title bar row: see @@ -13,7 +14,10 @@ import { claimBrowserSignIn } from "@/lib/desktop/browser-sign-in"; export function WindowControlsHost() { // In the first render of every page, before any frame exists: a frame can reach the // bridge, and whoever comes after this gets nothing. - if (typeof window !== "undefined") claimBrowserSignIn(); + if (typeof window !== "undefined") { + claimBrowserSignIn(); + claimFilesControls(); + } const [area, setArea] = useState(null); useEffect(() => watchTitlebarArea((next) => { diff --git a/components/layout/window-menu-bar.tsx b/components/layout/window-menu-bar.tsx index 33e8f517e..c94eb190d 100644 --- a/components/layout/window-menu-bar.tsx +++ b/components/layout/window-menu-bar.tsx @@ -2,7 +2,14 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; -import { Check, ChevronRight } from "@/components/icons"; +import { + Archive, ArrowLeft, ArrowRight, BookOpen, Calendar, CalendarDays, CalendarPlus, Check, CheckSquare, ChevronDown, + ChevronRight, ChevronUp, Clipboard, Copy, Download, ExternalLink, FileText, FolderInput, Forward, Inbox, Info, Keyboard, + Lock, LogOut, Mail, MailOpen, Maximize2, Minus, Paperclip, PanelRight, Printer, RefreshCw, Reply, ReplyAll, Save, Scissors, + Search, Send, Settings, ShieldAlert, SquarePen, Star, Trash2, Undo, Redo, Upload, UserPlus, Users, Folder, ZoomIn, ZoomOut, + Server, RotateCcw, LayoutGrid, LayoutList, LayoutTemplate, Zap, Power, +} from "@/components/icons"; +import { CONTEXT_MENU_PANEL_CLASS, MenuShortcut } from "@/components/ui/context-menu"; import { getDesktopBridge } from "@/lib/desktop/bridge"; import { MENU_BAR_ATTRIBUTE, cachedMenuBarPinned, menuKeyText, rememberMenuBarPinned } from "@/lib/desktop/menu-bar"; import type { DesktopMenuBarEntry, DesktopMenuBarItem, DesktopMenuBarMenu } from "@/lib/desktop/types"; @@ -23,6 +30,39 @@ import { cn } from "@/lib/utils"; */ const LINE = "[data-menu-line]"; + +type Glyph = React.ComponentType<{ className?: string }>; + +/** + * The icon of each line of the shell's menus, by its id (src/main/menu.ts in the desktop + * repo; a role's line comes as `role.`), so the bar's menus look like every other + * menu in the app. A shell that sends no ids gets the lines without icons. + */ +const MENU_BAR_ICONS: Record = { + "file.newMessage": SquarePen, "file.newEvent": CalendarPlus, "file.newTask": CheckSquare, "file.newContact": UserPlus, + "file.quickCompose": Zap, "file.newWindow": ExternalLink, "file.print": Printer, "file.savePdf": Download, + "file.import": Upload, "file.importMenu": Upload, "file.importCalendar": Calendar, "file.importContacts": Users, + "file.settings": Settings, "app.settings": Settings, "file.switchMode": Server, "file.lock": Lock, "file.close": Minus, + "file.quit": Power, "role.quit": Power, "role.close": Minus, + "role.undo": Undo, "role.redo": Redo, "role.cut": Scissors, "role.copy": Copy, "role.paste": Clipboard, + "role.selectAll": CheckSquare, "role.selectall": CheckSquare, "edit.search": Search, + "view.refresh": RefreshCw, "view.calendar.day": CalendarDays, "view.calendar.week": CalendarDays, + "view.calendar.month": CalendarDays, "view.calendar.agenda": CalendarDays, "view.readingPane": PanelRight, + "view.layout.split": PanelRight, "view.layout.horizontal": LayoutList, "view.layout.focus": LayoutTemplate, + "view.zoomIn": ZoomIn, "view.zoomOut": ZoomOut, "view.zoomReset": RotateCcw, + "role.togglefullscreen": Maximize2, "role.toggleFullScreen": Maximize2, + "compose.send": Send, "compose.saveDraft": Save, "compose.attach": Paperclip, + "message.reply": Reply, "message.replyAll": ReplyAll, "message.forward": Forward, "message.markRead": MailOpen, + "message.markUnread": Mail, "message.star": Star, "message.move": FolderInput, "message.archive": Archive, + "message.spam": ShieldAlert, "message.delete": Trash2, "message.openWindow": ExternalLink, + "go.inbox": Inbox, "go.sent": Send, "go.drafts": FileText, "go.nextMessage": ChevronDown, "go.previousMessage": ChevronUp, + "go.nextUnread": MailOpen, "go.mail": Mail, "go.calendar": Calendar, "go.contacts": Users, "go.files": Folder, + "go.back": ArrowLeft, "go.forward": ArrowRight, "go.today": CalendarDays, "go.nextPeriod": ChevronRight, + "go.previousPeriod": ArrowLeft, + "window.minimize": Minus, "role.minimize": Minus, "window.cycle": LayoutGrid, + "help.shortcuts": Keyboard, "help.docs": BookOpen, "help.updates": Download, "help.restart": RefreshCw, "help.about": Info, + "role.about": Info, "app.lock": Lock, "file.unlock": Lock, "app.logout": LogOut, +}; const ENABLED_LINE = `${LINE}:not([aria-disabled="true"])`; type Focus = { kind: "name"; index: number } | { kind: "line" | "panel"; sub?: boolean }; @@ -363,6 +403,7 @@ export function WindowMenuBar() { const isSub = item.type === "submenu"; const checkable = item.type === "checkbox" || item.type === "radio"; const opened = level === "panel" && isSub && sub === index; + const LineIcon = item.id ? MENU_BAR_ICONS[item.id] : undefined; return (
); @@ -456,7 +501,7 @@ export function WindowMenuBar() { aria-label={menu.label} data-menu-panel="" tabIndex={-1} - className="absolute start-0 top-full mt-0.5 min-w-56 max-w-[min(24rem,90vw)] max-h-[calc(100dvh-2.75rem)] overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none" + className={cn("absolute start-0 top-full mt-0.5 min-w-56 max-w-[min(22rem,90vw)] max-h-[calc(100dvh-2.75rem)] overflow-y-auto py-1 text-foreground outline-none", CONTEXT_MENU_PANEL_CLASS)} > {renderLines(openItems, "panel")}
@@ -466,7 +511,7 @@ export function WindowMenuBar() { role="menu" aria-label={openItems[sub]?.label} data-menu-subpanel="" - className="fixed z-10 min-w-44 max-w-[min(24rem,90vw)] max-h-[calc(100dvh-0.5rem)] overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg" + className={cn("fixed z-10 min-w-44 max-w-[min(22rem,90vw)] max-h-[calc(100dvh-0.5rem)] overflow-y-auto py-1 text-foreground", CONTEXT_MENU_PANEL_CLASS)} style={{ top: -9999, left: -9999 }} > {renderLines(subItems, "subpanel")} diff --git a/components/mail/__tests__/mail-app-composer-replace.test.tsx b/components/mail/__tests__/mail-app-composer-replace.test.tsx index d3ade0db1..3e03e6830 100644 --- a/components/mail/__tests__/mail-app-composer-replace.test.tsx +++ b/components/mail/__tests__/mail-app-composer-replace.test.tsx @@ -22,6 +22,9 @@ const stub = vi.hoisted(() => ({ closes: [] as string[], mounts: [] as string[], restore: null as null | ((data: Record) => void), + // A slow runner: the mount is counted a task after the composer is on + // screen, as when React runs its effects late (CI, compose-p2-1). + lateMount: false, })); vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null })); @@ -42,7 +45,8 @@ vi.mock('@/components/email/email-composer', async () => { }) => { const closed = useRef(false); useEffect(() => { - stub.mounts.push(mode); + if (stub.lateMount) setTimeout(() => stub.mounts.push(mode), 0); + else stub.mounts.push(mode); stub.restore = onRestoreDiscarded ?? null; // The old host moved the composer between dock and pane for ever // (React error 185 in the browser); stop it here instead of hanging. @@ -122,6 +126,7 @@ beforeEach(() => { stub.closes = []; stub.mounts = []; stub.restore = null; + stub.lateMount = false; vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); @@ -157,24 +162,39 @@ afterEach(() => { const composerMode = () => screen.getByTestId('stub-composer').getAttribute('data-mode'); -describe('Compose while a reply is docked (compose-p2-1)', () => { - for (const action of ['stub reply', 'stub forward']) { - it(`opens a new message in the pane after ${action}`, async () => { - render(); - fireEvent.click(await screen.findByText(action)); - const reply = await screen.findByTestId('stub-composer'); - expect(reply.closest('[data-reply-dock]')).not.toBeNull(); +// Compose opens after an await, outside the click's act(): the composer is on +// screen before React runs its mount effect. Count mounts once they ran, and +// tell a remount by the element on screen, which a remount replaces. +async function composeOverDockedReply(action: string, first: string) { + render(); + fireEvent.click(await screen.findByText(action)); + const reply = await screen.findByTestId('stub-composer'); + expect(reply.closest('[data-reply-dock]')).not.toBeNull(); + await waitFor(() => expect(stub.mounts).toEqual([first])); - fireEvent.click(screen.getByText('stub compose')); - await waitFor(() => expect(composerMode()).toBe('compose')); - // The new message takes the pane, not the dock, and stays put. - expect(screen.getByTestId('stub-composer').closest('[data-reply-dock]')).toBeNull(); - const mounted = stub.mounts.length; - await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); - expect(stub.mounts.length).toBe(mounted); - expect(composerMode()).toBe('compose'); + fireEvent.click(screen.getByText('stub compose')); + await waitFor(() => expect(composerMode()).toBe('compose')); + // The new message takes the pane, not the dock, and stays put. + const composer = screen.getByTestId('stub-composer'); + expect(composer.closest('[data-reply-dock]')).toBeNull(); + await waitFor(() => expect(stub.mounts).toEqual([first, 'compose'])); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + expect(stub.mounts).toEqual([first, 'compose']); + expect(screen.getByTestId('stub-composer')).toBe(composer); + expect(composerMode()).toBe('compose'); +} + +describe('Compose while a reply is docked (compose-p2-1)', () => { + for (const [action, first] of [['stub reply', 'reply'], ['stub forward', 'forward']] as const) { + it(`opens a new message in the pane after ${action}`, async () => { + await composeOverDockedReply(action, first); }); } + + it('counts one mount when the composer mounts late, as on a slow runner', async () => { + stub.lateMount = true; + await composeOverDockedReply('stub reply', 'reply'); + }); }); describe('another composer while one is open (compose-p2-2)', () => { @@ -182,7 +202,7 @@ describe('another composer while one is open (compose-p2-2)', () => { stub.close = 'saves'; render(); fireEvent.click(await screen.findByText('stub reply')); - await screen.findByTestId('stub-composer'); + await waitFor(() => expect(stub.mounts).toEqual(['reply'])); fireEvent.click(screen.getByText('stub compose')); await waitFor(() => expect(composerMode()).toBe('compose')); @@ -194,7 +214,7 @@ describe('another composer while one is open (compose-p2-2)', () => { stub.close = 'saves'; render(); fireEvent.click(await screen.findByText('stub reply')); - await screen.findByTestId('stub-composer'); + await waitFor(() => expect(stub.mounts).toEqual(['reply'])); fireEvent.click(screen.getByText(action)); await waitFor(() => expect(composerMode()).toBe(mode)); @@ -206,11 +226,10 @@ describe('another composer while one is open (compose-p2-2)', () => { stub.close = 'saves'; render(); fireEvent.click(screen.getByText('stub compose')); - await screen.findByTestId('stub-composer'); - const mounted = stub.mounts.length; + await waitFor(() => expect(stub.mounts).toEqual(['compose'])); fireEvent.click(screen.getByText('stub compose')); - await waitFor(() => expect(stub.mounts.length).toBe(mounted + 1)); + await waitFor(() => expect(stub.mounts).toEqual(['compose', 'compose'])); expect(stub.closes).toEqual(['compose']); }); @@ -218,13 +237,14 @@ describe('another composer while one is open (compose-p2-2)', () => { stub.close = 'fails'; render(); fireEvent.click(await screen.findByText('stub reply')); - await screen.findByTestId('stub-composer'); - const mounted = stub.mounts.length; + await waitFor(() => expect(stub.mounts).toEqual(['reply'])); + const composer = screen.getByTestId('stub-composer'); fireEvent.click(screen.getByText('stub compose')); await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); expect(stub.closes).toEqual(['reply']); - expect(stub.mounts.length).toBe(mounted); + expect(stub.mounts).toEqual(['reply']); + expect(screen.getByTestId('stub-composer')).toBe(composer); expect(composerMode()).toBe('reply'); }); }); @@ -235,7 +255,7 @@ describe('Undo after the bin', () => { stub.close = 'saves'; render(); fireEvent.click(screen.getByText('stub compose')); - await screen.findByTestId('stub-composer'); + await waitFor(() => expect(stub.mounts).toEqual(['compose'])); const restore = stub.restore!; act(() => restore({ to: 'bob@example.org', cc: '', bcc: '', subject: 'Back', body: 'kept', mode: 'forward', draftId: 'd1' })); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 1e98883bd..1186c45c9 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -6680,7 +6680,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { void handleCompose(); }} emptyPaneContent={emptyPaneBesideList && selectedEmailKeys.size > 0 && !isScheduledView && !mvShell - ?
+ ?
: openFailure && !isLoadingEmail ? void handleEmailSelect(openFailure.target)} /> : undefined} diff --git a/components/plugins/__tests__/plugin-dialog-window.test.tsx b/components/plugins/__tests__/plugin-dialog-window.test.tsx new file mode 100644 index 000000000..dccd7b59e --- /dev/null +++ b/components/plugins/__tests__/plugin-dialog-window.test.tsx @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; + +// In the desktop app a plugin's confirm or alert is a window of its own. The host +// names the plugin there, the plugin's text stays text, and the ⋮ menu still +// turns the plugin off. Prompts stay in the page. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: { name?: string }) => (values?.name ? `${key}:${values.name}` : key), +})); +vi.mock('../plugin-iframe-slot', () => ({ PluginIframeSlot: () => null })); + +import { PluginDialogHost } from '../plugin-dialog-host'; +import { awaitDialog, awaitPrompt, head, resetDialogLimits, resolveHead } from '@/lib/plugin-sandbox/host-dialog'; +import { usePluginStore } from '@/stores/plugin-store'; +import type { BulwarkDesktopBridge, DesktopDialogWindowAnswer, DesktopDialogWindowRequest } from '@/lib/desktop/types'; + +type Show = (request: DesktopDialogWindowRequest) => Promise; +let answer: (value: DesktopDialogWindowAnswer) => void; +let show: ReturnType>; +let disablePlugin: ReturnType; + +beforeEach(() => { + while (head()) resolveHead(null); + resetDialogLimits(); + show = vi.fn(() => new Promise((resolve) => (answer = resolve))); + window.bulwarkDesktop = { version: 1, platform: 'win32', dialogs: { confirm: vi.fn(), show, cancel: vi.fn() } } as unknown as BulwarkDesktopBridge; + disablePlugin = vi.fn(); + usePluginStore.setState({ plugins: [{ id: 'elw', name: 'External Link Warning' }] as never, disablePlugin } as never); +}); + +afterEach(() => { + window.bulwarkDesktop = undefined; +}); + +const LINK = { + pluginId: 'elw', + kind: 'confirm' as const, + title: 'Open external link?', + message: 'You\'re about to visit **github.com**.\n', + confirmLabel: 'Continue', +}; + +describe('plugin dialogs in the desktop app', () => { + it('asks in a window with the plugin named in the host\'s line and its text as text', async () => { + render(); + let result: Promise; + act(() => { result = awaitDialog(LINK); }); + + expect(screen.queryByRole('dialog')).toBeNull(); + expect(show).toHaveBeenCalledTimes(1); + expect(show.mock.calls[0][0]).toMatchObject({ + title: 'Open external link?', + source: 'from_plugin:External Link Warning', + message: [{ text: 'You\'re about to visit ' }, { text: 'github.com', bold: true }, { text: '.\n' }], + buttons: [{ label: 'cancel', variant: 'secondary' }, { label: 'Continue', variant: 'primary' }], + defaultIndex: 1, + menu: { label: 'plugin_menu:External Link Warning', items: [{ id: 'turn-off', label: 'turn_off:External Link Warning', destructive: true }] }, + }); + + await act(async () => answer({ button: 1 })); + await expect(result!).resolves.toBe(true); + expect(head()).toBeNull(); + }); + + it('Cancel or Escape is no; "Turn off" in the menu turns the plugin off', async () => { + render(); + let result: Promise; + act(() => { result = awaitDialog(LINK); }); + await act(async () => answer(null)); + await expect(result!).resolves.toBe(false); + + act(() => { result = awaitDialog(LINK); }); + await act(async () => answer({ menu: 'turn-off' })); + await expect(result!).resolves.toBe(false); + expect(disablePlugin).toHaveBeenCalledWith('elw'); + }); + + it('an alert has one button, and a destructive confirm starts on Cancel', async () => { + render(); + act(() => { void awaitDialog({ pluginId: 'elw', kind: 'alert', title: 'Done', message: '' }); }); + expect(show.mock.calls[0][0]).toMatchObject({ buttons: [{ label: 'ok', variant: 'primary' }], defaultIndex: 0 }); + await act(async () => answer({ button: 0 })); + + act(() => { void awaitDialog({ ...LINK, danger: true }); }); + expect(show.mock.calls[1][0]).toMatchObject({ buttons: [{}, { variant: 'destructive' }], defaultIndex: 0 }); + }); + + it('a prompt stays in the page', () => { + render(); + act(() => { void awaitPrompt({ pluginId: 'elw', kind: 'prompt', title: 'Name?', message: '', fields: [{ name: 'n', label: 'Name' }] }); }); + expect(show).not.toHaveBeenCalled(); + expect(screen.getByRole('dialog', { name: 'Name?' })).toBeTruthy(); + }); + + it('a plugin that goes takes its window with it', () => { + render(); + act(() => { void awaitDialog(LINK); }); + const cancel = window.bulwarkDesktop!.dialogs!.cancel as ReturnType; + act(() => resolveHead(false)); + expect(cancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/plugins/app-side-panel.tsx b/components/plugins/app-side-panel.tsx index b6a04ff03..13461a519 100644 --- a/components/plugins/app-side-panel.tsx +++ b/components/plugins/app-side-panel.tsx @@ -20,6 +20,7 @@ // { view, context }), and so does api.ui.openPanel there. import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'; +import { formatShortcutCombo } from '@/lib/shortcut-keys'; import { useTranslations } from 'next-intl'; import { PanelRightClose, PanelRightOpen } from '@/components/icons'; import { Button } from '@/components/ui/button'; @@ -72,7 +73,7 @@ export function PluginPanelToggle({ open, onClick, className, buttonRef, shortcu size="icon" onClick={onClick} className={cn('h-8 w-8 shrink-0', className)} - title={shortcut ? `${label} (${shortcut})` : label} + title={shortcut ? `${label} (${formatShortcutCombo(shortcut)})` : label} aria-label={label} aria-pressed={open} aria-keyshortcuts={shortcut ?? undefined} diff --git a/components/plugins/plugin-dialog-host.tsx b/components/plugins/plugin-dialog-host.tsx index 24ed5d314..ef3ef1160 100644 --- a/components/plugins/plugin-dialog-host.tsx +++ b/components/plugins/plugin-dialog-host.tsx @@ -14,6 +14,11 @@ // (dialog-anchor.ts), with a one-line header naming the plugin. `fill: true` // gives either form a fixed height the plugin's frame fills (a chat), so an // anchored, filled dialog is a tall popover beside the control. +// +// In the desktop app a confirm or alert is a window of its own instead +// (lib/desktop/dialog-window.ts), with the same parts: the title, the +// "From the plugin" line, the ⋮ menu and the buttons. Prompts and custom +// dialogs stay here: they hold fields and the plugin's own frame. import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { useTranslations } from 'next-intl'; @@ -25,8 +30,11 @@ import { useFocusTrap } from '@/hooks/use-focus-trap'; import { toast } from '@/stores/toast-store'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { ContextMenu, ContextMenuItem } from '@/components/ui/context-menu'; +import { useContextMenu } from '@/hooks/use-context-menu'; import { iconForName, Loader2, MoreVertical, Power, Puzzle, X } from '@/components/icons'; import { cn } from '@/lib/utils'; +import { askInWindow, dialogWindowsAvailable } from '@/lib/desktop/dialog-window'; import { PluginIframeSlot } from './plugin-iframe-slot'; import type { SlotName } from '@/lib/plugin-types'; @@ -65,12 +73,77 @@ export function PluginDialogHost(): React.JSX.Element | null { return ( <> - {base && } - {top && } + {base && (inWindow(base) + ? + : )} + {top && (inWindow(top) + ? + : )} ); } +/** A confirm or alert, in the desktop app: a window of its own. */ +function inWindow(request: DialogRequest): boolean { + return (request.kind === 'confirm' || request.kind === 'alert') && dialogWindowsAvailable(); +} + +const TURN_OFF = 'turn-off'; + +/** + * A plugin's confirm or alert in a window of the app's own. The host names the plugin + * in the window's own line, which the plugin's text can't change, and the ⋮ menu + * still turns it off. If the shell refuses, the dialog is drawn in the page. + */ +function PluginDialogWindow({ current, layer }: { current: DialogRequest; layer: 'only' | 'below' | 'above' }): React.JSX.Element | null { + const t = useTranslations('plugins.dialog'); + const pluginName = usePluginStore((s) => s.plugins.find((p) => p.id === current.pluginId)?.name); + const disablePlugin = usePluginStore((s) => s.disablePlugin); + const [refused, setRefused] = useState(false); + const name = pluginName ?? current.pluginId; + + useEffect(() => { + if (refused) return; + const confirm = current.kind === 'confirm'; + const okLabel = current.confirmLabel ?? (confirm ? t('confirm') : t('ok')); + const buttons = confirm + ? [{ label: current.cancelLabel ?? t('cancel'), variant: 'secondary' as const }, { label: okLabel, variant: current.danger ? 'destructive' as const : 'primary' as const }] + : [{ label: okLabel, variant: 'primary' as const }]; + const yes = buttons.length - 1; + const asked = askInWindow({ + title: current.title || name, + message: current.message, + boldMarks: true, + source: t('from_plugin', { name }), + buttons, + // As in the page: a destructive confirm only says yes from its own button. + defaultIndex: confirm && current.danger ? 0 : yes, + menu: { label: t('plugin_menu', { name }), items: [{ id: TURN_OFF, label: t('turn_off', { name }), destructive: true }] }, + }); + if (!asked) return; + let live = true; + void asked.answer.then((answer) => { + if (!live) return; + live = false; + if (answer === 'failed') { + setRefused(true); + } else if (answer && 'menu' in answer && answer.menu === TURN_OFF) { + cancelForPlugin(current.pluginId); + disablePlugin(current.pluginId); + } else { + resolveDialog(current.id, Boolean(answer && 'button' in answer && answer.button === yes)); + } + }); + return () => { + if (!live) return; + live = false; + asked.cancel(); + }; + }, [current, name, refused, t, disablePlugin]); + + return refused ? : null; +} + const DIALOG_BUTTON_VARIANT = { primary: 'default', secondary: 'outline', ghost: 'ghost', destructive: 'destructive' } as const; const DIALOG_BUTTON_ID_RE = /^[A-Za-z0-9._-]{1,40}$/; export const MAX_DIALOG_BUTTONS = 4; @@ -467,21 +540,13 @@ function FooterIcon({ name }: { name?: string }) { /** The "⋯" menu in a plugin dialog's header: the way out of a plugin that keeps asking. */ function PluginDialogMenu({ name, onTurnOff, compact }: { name: string; onTurnOff: () => void; compact?: boolean }) { const t = useTranslations('plugins.dialog'); - const [open, setOpen] = useState(false); - const ref = useRef(null); - useEffect(() => { - if (!open) return; - const onDown = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - document.addEventListener('mousedown', onDown, true); - return () => document.removeEventListener('mousedown', onDown, true); - }, [open]); + const menu = useContextMenu(); + const open = menu.contextMenu.isOpen; return ( -
+
- {open && ( -
- -
- )} + + { menu.closeContextMenu(); onTurnOff(); }} + /> +
); } diff --git a/components/providers/files-sync-host.tsx b/components/providers/files-sync-host.tsx new file mode 100644 index 000000000..0f941679b --- /dev/null +++ b/components/providers/files-sync-host.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { getDesktopPolicy, onDesktopPolicyChange } from "@/lib/desktop/policy"; +import { canDownload } from "@/lib/device-policy"; +import { claimFilesControls } from "@/lib/files-sync/controls"; +import { FilesSyncHost as Host, type HostAccount } from "@/lib/files-sync/host"; +import { registerFilesSyncLifecycle, setRunningFilesSyncHost } from "@/lib/files-sync/lifecycle"; +import { scopeDecided } from "@/lib/offline/offline-mode"; +import { subscribePushStateChanges } from "@/lib/push-events"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useFilesSyncStore } from "@/stores/files-sync-store"; + +registerFilesSyncLifecycle(); + +/** `FilesFolderSyncEnabled` forced off. Main removes the folders itself; this keeps the engine off them meanwhile. */ +function folderSyncForcedOff(): boolean { + const entry = getDesktopPolicy().FilesFolderSyncEnabled; + return entry.locked && entry.value === false; +} + +/** A definite no from the organisation. An unknown policy isn't one. */ +function downloadsForbidden(account: string): boolean { + return scopeDecided(account) && !canDownload(account); +} + +/** + * Files on this computer, in the desktop app's main window (it's under MainWindowOnly): starts + * the sync engine for the signed-in accounts and keeps it fed with push, the network and the + * policies. Renders nothing. In a browser, or a shell without the files bridge, it does nothing. + */ +export function FilesSyncHost() { + const hostRef = useRef(null); + const accounts = useAccountStore((s) => s.accounts); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const revision = useAuthStore((s) => s.connectedAccountsRevision); + const client = useAuthStore((s) => s.client); + + useEffect(() => { + const bridge = getDesktopBridge(); + const controls = bridge?.files ? claimFilesControls() : null; + if (!bridge || !controls) return; + const store = useFilesSyncStore.getState(); + const host = new Host({ + controls, + platform: bridge.platform, + publish: (patch) => useFilesSyncStore.getState().publish(patch), + setController: (controller) => useFilesSyncStore.getState().setController(controller), + folderSyncForcedOff, + downloadsForbidden, + }); + hostRef.current = host; + setRunningFilesSyncHost(host); + host.start(); + const stopPush = subscribePushStateChanges((change, changedClient) => { + for (const [account, entry] of useAuthStore.getState().getAllConnectedClients()) { + if (entry !== changedClient) continue; + if (Object.values(change.changed).some((types) => !!types && "FileNode" in types)) host.remoteChanged(account); + } + }); + const stopPolicy = onDesktopPolicyChange(() => host.policyChanged()); + const online = () => host.online(); + window.addEventListener("online", online); + return () => { + stopPush(); + stopPolicy(); + window.removeEventListener("online", online); + setRunningFilesSyncHost(null); + host.stop(); + hostRef.current = null; + store.reset(); + }; + }, []); + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + const clients = isAuthenticated ? useAuthStore.getState().getAllConnectedClients() : new Map(); + const list: HostAccount[] = []; + for (const [id, entry] of clients) { + const account = accounts.find((a) => a.id === id); + if (account && !account.isConnected) continue; + list.push({ id, label: account?.email || account?.username || account?.label || id, client: entry }); + } + host.setAccounts(list, isAuthenticated ? activeAccountId : null); + }, [accounts, activeAccountId, isAuthenticated, revision, client]); + + return null; +} diff --git a/components/settings/__tests__/desktop-files-settings.test.tsx b/components/settings/__tests__/desktop-files-settings.test.tsx new file mode 100644 index 000000000..4ef21b3e3 --- /dev/null +++ b/components/settings/__tests__/desktop-files-settings.test.tsx @@ -0,0 +1,314 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextIntlClientProvider } from 'next-intl'; +import en from '@/locales/en/common.json'; +import type { FilesDriveState, FilesRoot } from '@/lib/desktop/types'; +import type { FilesSyncActivity, FilesSyncController, FilesSyncRootStatus } from '@/lib/files-sync/types'; + +// Files on this computer, the settings page: the drive, synced folders, the +// activity list and the mass-delete question, against the store and a fake +// controller (the engine is built elsewhere). + +vi.unmock('next-intl'); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); + +const { useFilesSyncStore } = await import('@/stores/files-sync-store'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { DesktopFilesSettings } = await import('../desktop-files-settings'); + +type Fake = { [K in keyof FilesSyncController]: ReturnType }; + +function fakeController(): Fake { + return { + addFolder: vi.fn(async () => ({ ok: true as const, value: folderRoot() })), + removeRoot: vi.fn(async () => {}), + setPaused: vi.fn(async () => {}), + confirmMassDelete: vi.fn(async () => {}), + setDriveEnabled: vi.fn(async () => ({ ok: true as const, value: null })), + setDriveLetter: vi.fn(async () => ({ ok: true as const, value: { letter: 'Z', chosen: 'Z', locked: false, available: [] } })), + coverage: vi.fn(() => null), + showInFolder: vi.fn(async () => true), + clearActivity: vi.fn(), + } as never; +} + +function folderRoot(patch: Partial = {}): FilesRoot { + return { + id: 'r1', kind: 'folder', account: 'acc', path: 'C:\\Users\\ann\\Documents\\Projects', remoteId: 'n-proj', + remoteLabel: 'Projects', paused: false, createdAt: 1, ...patch, + }; +} + +function drive(patch: Partial = {}): FilesDriveState { + return { + supported: true, forcedOff: false, wanted: [], + letter: { letter: null, chosen: null, locked: false, available: ['X', 'Z'] }, + ...patch, + }; +} + +let controller: ReturnType; + +function setup(patch: Record = {}) { + controller = fakeController(); + useFilesSyncStore.setState({ available: true, account: 'acc', controller: controller as unknown as FilesSyncController, ...patch }); +} + +function renderPage() { + return render( + + + , + ); +} + +beforeEach(() => { + useFilesSyncStore.getState().reset(); +}); +afterEach(() => { + useFilesSyncStore.getState().reset(); +}); + +describe('Files on this computer settings page', () => { + it('draws nothing outside the desktop app or on an older shell', () => { + useFilesSyncStore.setState({ available: false, roots: [folderRoot()], drive: drive() }); + const { container } = renderPage(); + expect(container).toBeEmptyDOMElement(); + }); + + it('has no drive section off Windows', () => { + setup({ drive: null }); + renderPage(); + expect(screen.queryByRole('switch', { name: 'Show Files as a drive' })).toBeNull(); + expect(screen.getByRole('heading', { name: 'Synced folders' })).toBeInTheDocument(); + }); + + it('turns the drive on for the account from the click itself', () => { + setup({ drive: drive() }); + renderPage(); + const toggle = screen.getByRole('switch', { name: 'Show Files as a drive' }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + // No drive, no letter. + expect(screen.queryByText('Drive letter')).toBeNull(); + fireEvent.click(toggle); + // Synchronously: the shell needs the click it came from. + expect(controller.setDriveEnabled).toHaveBeenCalledWith(true); + }); + + it('shows the drive on, and turns it off', () => { + setup({ drive: drive({ wanted: ['acc'] }) }); + renderPage(); + const toggle = screen.getByRole('switch', { name: 'Show Files as a drive' }); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + fireEvent.click(toggle); + expect(controller.setDriveEnabled).toHaveBeenCalledWith(false); + }); + + it('says the organisation turned the drive off and keeps the switch off', () => { + setup({ drive: drive({ forcedOff: true, wanted: ['acc'] }) }); + renderPage(); + const toggle = screen.getByRole('switch', { name: 'Show Files as a drive' }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(toggle).toBeDisabled(); + expect(screen.getByText(en.settings.org_locked)).toBeInTheDocument(); + }); + + it('offers no letter by default and the free letters, and sets the chosen one', () => { + setup({ drive: drive({ wanted: ['acc'] }) }); + renderPage(); + const select = screen.getByRole('combobox', { name: 'Drive letter' }) as HTMLSelectElement; + expect(select.value).toBe('none'); + expect([...select.options].map((o) => o.textContent)).toEqual(['No letter', 'X:', 'Z:']); + fireEvent.change(select, { target: { value: 'Z' } }); + expect(controller.setDriveLetter).toHaveBeenCalledWith('Z'); + fireEvent.change(select, { target: { value: 'none' } }); + expect(controller.setDriveLetter).toHaveBeenLastCalledWith(null); + }); + + it('shows a letter forced by policy, locked', () => { + setup({ drive: drive({ wanted: ['acc'], letter: { letter: 'Q', chosen: 'X', locked: true, available: ['X'] } }) }); + renderPage(); + const select = screen.getByRole('combobox', { name: 'Drive letter' }) as HTMLSelectElement; + expect(select.value).toBe('Q'); + expect(select).toBeDisabled(); + const row = select.closest('[data-search-label]') as HTMLElement; + expect(within(row).getByText(en.settings.org_locked)).toBeInTheDocument(); + }); + + it('lists a synced folder with its Files folder and pauses and resumes it', () => { + setup({ roots: [folderRoot()], status: { r1: { rootId: 'r1', state: 'idle', pending: 0, done: 0, lastSyncedAt: null } } }); + const { rerender } = renderPage(); + expect(screen.getByText('C:\\Users\\ann\\Documents\\Projects')).toBeInTheDocument(); + expect(screen.getByText('With Projects in Files')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Pause' })); + expect(controller.setPaused).toHaveBeenCalledWith('r1', true); + + act(() => useFilesSyncStore.setState({ roots: [folderRoot({ paused: true })], status: { r1: { rootId: 'r1', state: 'paused', pending: 0, done: 0, lastSyncedAt: null } } })); + rerender( + + + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Resume' })); + expect(controller.setPaused).toHaveBeenLastCalledWith('r1', false); + }); + + it('asks before removing a folder, saying its files stay', () => { + setup({ roots: [folderRoot()] }); + renderPage(); + fireEvent.click(screen.getByRole('button', { name: 'Remove…' })); + const dialog = screen.getByRole('alertdialog'); + expect(dialog).toHaveTextContent('The files on this computer stay where they are'); + expect(controller.removeRoot).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Stop syncing' })); + expect(controller.removeRoot).toHaveBeenCalledWith('r1'); + }); + + it('says how each folder is doing', () => { + const at = Date.UTC(2026, 10, 4, 14, 5); + vi.useFakeTimers({ now: at + 60_000, toFake: ['Date'] }); + const states: [string, Partial][] = [ + ['idle', { lastSyncedAt: at }], + ['syncing', { done: 3, pending: 7 }], + ['paused', {}], + ['offline', { pending: 2 }], + ['error', { errorKey: 'files_sync.errors.quota' }], + ['blocked', {}], + ['starting', {}], + ]; + const roots = states.map(([state]) => folderRoot({ id: state, path: `/home/ann/${state}` })); + const status = Object.fromEntries(states.map(([state, extra]) => [state, { rootId: state, state, pending: 0, done: 0, lastSyncedAt: null, ...extra }])); + setup({ roots, status }); + renderPage(); + const line = (id: string) => document.querySelector(`[data-files-sync-root="${id}"] [data-files-sync-state]`)!.textContent; + expect(line('idle')).toMatch(/^Up to date\. Last synced .*2:05/); + expect(line('syncing')).toBe('Syncing: 3 done, 7 to go'); + expect(line('paused')).toBe('Paused'); + expect(line('offline')).toBe('Offline. 2 changes wait for the server'); + expect(line('error')).toBe('Stopped: Your storage in Files is full'); + expect(line('blocked')).toBe('Turned off by your organisation'); + expect(line('starting')).toBe('Starting…'); + // A blocked folder can't be resumed, only removed. + expect(within(document.querySelector('[data-files-sync-root="blocked"]') as HTMLElement).queryByRole('button', { name: 'Resume' })).toBeNull(); + vi.useRealTimers(); + }); + + it('shows an unknown error key as a plain failure, never the key', () => { + setup({ roots: [folderRoot()], status: { r1: { rootId: 'r1', state: 'error', pending: 0, done: 0, lastSyncedAt: null, errorKey: 'something_new' } } }); + renderPage(); + expect(screen.getByText('Stopped: Something went wrong')).toBeInTheDocument(); + }); + + it('adds a folder: picks the Files folder, then calls the controller from that click', async () => { + const listAllFileNodes = vi.fn(async () => [ + { id: 'n-proj', name: 'Projects', parentId: null, blobId: null }, + { id: 'n-2026', name: '2026', parentId: 'n-proj', blobId: null }, + { id: 'n-file', name: 'a.txt', parentId: 'n-proj', blobId: 'b1' }, + ]); + useAuthStore.setState({ client: { listAllFileNodes } as never }); + setup({ roots: [] }); + renderPage(); + expect(screen.getByText('No folders are synced yet.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Add folder…' })); + const dialog = await screen.findByRole('dialog', { name: 'Which folder in Files?' }); + // Only folders are offered. + expect(within(dialog).queryByText('a.txt')).toBeNull(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Expand Projects' })); + await waitFor(() => expect(within(dialog).getByText('2026')).toBeInTheDocument()); + fireEvent.click(within(dialog).getByText('2026')); + expect(controller.addFolder).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Choose folder on this computer…' })); + // In the same task as the click: nothing slow is awaited first. + expect(controller.addFolder).toHaveBeenCalledWith('n-2026', 'Projects / 2026'); + useAuthStore.setState({ client: null }); + }); + + it('says why a folder could not be added, and nothing when the picker was closed', async () => { + useAuthStore.setState({ client: { listAllFileNodes: vi.fn(async () => [{ id: 'n-proj', name: 'Projects', parentId: null, blobId: null }]) } as never }); + setup({ roots: [] }); + controller.addFolder.mockResolvedValueOnce({ ok: false, error: 'overlap' }); + renderPage(); + fireEvent.click(screen.getByRole('button', { name: 'Add folder…' })); + let dialog = await screen.findByRole('dialog'); + fireEvent.click(within(dialog).getByText('Projects')); + fireEvent.click(within(dialog).getByRole('button', { name: 'Choose folder on this computer…' })); + expect(await screen.findByRole('alert')).toHaveTextContent(en.files_sync.folders.add_failed.overlap); + + controller.addFolder.mockResolvedValueOnce({ ok: false, error: 'cancelled' }); + fireEvent.click(screen.getByRole('button', { name: 'Add folder…' })); + dialog = await screen.findByRole('dialog'); + fireEvent.click(within(dialog).getByText('Projects')); + fireEvent.click(within(dialog).getByRole('button', { name: 'Choose folder on this computer…' })); + await waitFor(() => expect(controller.addFolder).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); + useAuthStore.setState({ client: null }); + }); + + it('hides adding folders when the organisation turned folder sync off', () => { + setup({ folderSyncForcedOff: true, roots: [folderRoot()], status: { r1: { rootId: 'r1', state: 'blocked', pending: 0, done: 0, lastSyncedAt: null } } }); + renderPage(); + expect(screen.queryByRole('button', { name: 'Add folder…' })).toBeNull(); + expect(screen.getByTestId('files-sync-folders-locked')).toHaveTextContent(en.settings.org_locked); + // The folder can still be removed; its files stay. + expect(screen.getByRole('button', { name: 'Remove…' })).toBeInTheDocument(); + }); + + it('lists recent activity newest first, marks conflicts and errors, and clears it', () => { + const activity: FilesSyncActivity[] = [ + { id: 'a1', at: 1000, rootId: 'r1', kind: 'uploaded', direction: 'up', path: 'notes.txt' }, + { id: 'a3', at: 3000, rootId: 'r1', kind: 'error', direction: 'up', path: 'big.iso', messageKey: 'files_sync.errors.quota' }, + { id: 'a2', at: 2000, rootId: 'r1', kind: 'conflict', direction: null, path: 'plan.docx', detail: 'plan (conflict, PC, 2026-11-04).docx' }, + { id: 'a4', at: 500, rootId: 'r1', kind: 'too-large', direction: null, path: 'video.mp4', detail: '1048576' }, + ]; + setup({ roots: [folderRoot()], activity }); + renderPage(); + const rows = within(screen.getByTestId('files-sync-activity')).getAllByRole('listitem'); + expect(rows.map((r) => r.getAttribute('data-activity-kind'))).toEqual(['error', 'conflict', 'uploaded', 'too-large']); + expect(rows[0]).toHaveTextContent('big.iso couldn\'t be synced: Your storage in Files is full'); + expect(within(rows[0]).getByRole('img', { name: 'Error' })).toBeInTheDocument(); + expect(rows[1]).toHaveTextContent('plan.docx was changed in both places. The other version is kept as plan (conflict, PC, 2026-11-04).docx'); + expect(within(rows[1]).getByRole('img', { name: 'Conflict' })).toBeInTheDocument(); + expect(rows[2]).toHaveTextContent('Projects'); + expect(rows[2]).toHaveTextContent('Uploaded notes.txt'); + expect(within(rows[2]).getByRole('img', { name: 'To Files' })).toBeInTheDocument(); + expect(rows[3]).toHaveTextContent('video.mp4 is bigger than Files allows (1 MB)'); + expect(within(rows[3]).getByRole('img', { name: 'Error' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Clear' })); + expect(controller.clearActivity).toHaveBeenCalled(); + }); + + it('has a line for every kind of activity', () => { + const kinds = ['uploaded', 'downloaded', 'folder-created', 'renamed', 'moved', 'deleted', 'trashed', 'conflict', 'renamed-for-disk', 'too-large', 'waiting', 'mass-delete', 'error'] as const; + setup({ roots: [folderRoot()], activity: kinds.map((kind, i) => ({ id: kind, at: 10_000 - i, rootId: 'r1', kind, direction: null, path: 'x.txt', detail: 'y.txt' })) }); + renderPage(); + const rows = within(screen.getByTestId('files-sync-activity')).getAllByRole('listitem'); + expect(rows).toHaveLength(kinds.length); + for (const row of rows) expect(row.textContent).not.toMatch(/files_sync\.|\{/); + }); + + it('says when nothing happened yet', () => { + setup({ roots: [folderRoot()] }); + renderPage(); + expect(screen.getByText('Nothing has happened yet.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Clear' })).toBeDisabled(); + }); + + it('asks about a mass delete in the folder row, with both answers', () => { + setup({ + roots: [folderRoot(), folderRoot({ id: 'r2', path: '/home/ann/Photos', remoteLabel: 'Photos' })], + status: { + r1: { rootId: 'r1', state: 'confirm-delete', pending: 0, done: 0, lastSyncedAt: null, massDelete: { count: 120, side: 'local' } }, + r2: { rootId: 'r2', state: 'confirm-delete', pending: 0, done: 0, lastSyncedAt: null, massDelete: { count: 64, side: 'remote' } }, + }, + }); + renderPage(); + const first = document.querySelector('[data-files-sync-root="r1"]') as HTMLElement; + expect(first).toHaveTextContent('120 items were deleted from Projects on this computer. Delete them in Files too?'); + const second = document.querySelector('[data-files-sync-root="r2"]') as HTMLElement; + expect(second).toHaveTextContent('64 items were deleted from Photos in Files. Delete them on this computer too?'); + fireEvent.click(within(first).getByRole('button', { name: 'Delete them' })); + expect(controller.confirmMassDelete).toHaveBeenCalledWith('r1', true); + fireEvent.click(within(second).getByRole('button', { name: 'Bring them back' })); + expect(controller.confirmMassDelete).toHaveBeenLastCalledWith('r2', false); + }); +}); diff --git a/components/settings/__tests__/settings-boot-skeleton.test.tsx b/components/settings/__tests__/settings-boot-skeleton.test.tsx index 5417f9871..d974d4cd0 100644 --- a/components/settings/__tests__/settings-boot-skeleton.test.tsx +++ b/components/settings/__tests__/settings-boot-skeleton.test.tsx @@ -35,6 +35,7 @@ const { useSettingsStore } = await import('@/stores/settings-store'); const { SETTINGS_NAV_GROUPS } = await import('../settings-tab-icons'); const { BOOT_HINTS_KEY } = await import('@/lib/boot-skeleton/keys'); const { resetRailHintsMemo } = await import('@/lib/boot-skeleton/hints'); +const { useFilesSyncStore } = await import('@/stores/files-sync-store'); /** A client that supports everything it is asked about. */ const fullClient = new Proxy({}, { @@ -91,11 +92,25 @@ describe('Settings while the session restores', () => { expect(skeleton()).toBeNull(); const shown = [...document.querySelectorAll('[data-testid^="settings-tab-"]')] .map((el) => el.getAttribute('data-testid')!.slice('settings-tab-'.length)); - // Recovery waits for a server probe this test doesn't answer. - const drawn = SETTINGS_NAV_GROUPS.flatMap((g) => g.tabs).filter((tab) => tab !== 'recovery'); + // Recovery waits for a server probe this test doesn't answer; Files on + // this computer for the desktop app's sync engine. + const drawn = SETTINGS_NAV_GROUPS.flatMap((g) => g.tabs).filter((tab) => tab !== 'recovery' && tab !== 'files_sync'); expect(shown).toEqual(drawn); }); + it('lists Files on this computer after Files only while the desktop sync engine runs', () => { + useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: fullClient as never }); + useFilesSyncStore.setState({ available: true }); + try { + render(); + const shown = [...document.querySelectorAll('[data-testid^="settings-tab-"]')] + .map((el) => el.getAttribute('data-testid')!.slice('settings-tab-'.length)); + expect(shown.slice(shown.indexOf('files'), shown.indexOf('files') + 2)).toEqual(['files', 'files_sync']); + } finally { + useFilesSyncStore.getState().reset(); + } + }); + it('leaves the next load the pages this account has', () => { useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: bareClient as never }); render(); diff --git a/components/settings/__tests__/settings-search-anchors.test.tsx b/components/settings/__tests__/settings-search-anchors.test.tsx index 14bb7ff1c..585aef541 100644 --- a/components/settings/__tests__/settings-search-anchors.test.tsx +++ b/components/settings/__tests__/settings-search-anchors.test.tsx @@ -36,6 +36,7 @@ const { useCalendarStore } = await import('@/stores/calendar-store'); const { useContactStore } = await import('@/stores/contact-store'); const { useEmailStore } = await import('@/stores/email-store'); const { useAccountStore } = await import('@/stores/account-store'); +const { useFilesSyncStore } = await import('@/stores/files-sync-store'); const { TourProvider } = await import('@/components/tour/tour-provider'); const { AdvancedSettingsProvider } = await import('../settings-section'); const { tabSearchEntries, getByPath, withAppName, settingsTabLabelKey } = await import('@/lib/settings-search'); @@ -60,6 +61,7 @@ const pages = { contacts: (await import('../contacts-settings')).ContactsSettings, contacts2: (await import('../address-book-management-settings')).AddressBookManagementSettings, files: (await import('../files-settings')).FilesSettingsComponent, + files_sync: (await import('../desktop-files-settings')).DesktopFilesSettings, addons: (await import('../addons-settings')).AddonsSettings, backup: (await import('../about-data-settings')).AboutDataSettings, offline: (await import('@/components/offline/offline-settings')).OfflineSettings, @@ -76,7 +78,7 @@ const OVERRIDES: Record unknown> = { getPrincipals: async () => [], listArchivedEmails: async () => ({ items: [], nextPosition: null }), }; /** Pages with rows of the desktop app's own, or browser rows it hides. */ -const DESKTOP_PAGES = new Set(['general', 'notifications', 'privacy', 'backup']); +const DESKTOP_PAGES = new Set(['general', 'notifications', 'privacy', 'backup', 'files_sync']); /** The desktop app's bridge, answering as Windows does, with every switch off. */ const DESKTOP_BRIDGE = { @@ -121,7 +123,7 @@ const TAB_PAGES: Record = { accessibility: ['accessibility'], message_list: ['message_list'], reading: ['reading'], composing: ['composing'], vacation: ['vacation'], filters: ['filters'], organize: ['organize'], security: ['security'], sharing: ['sharing'], recovery: ['recovery'], calendar: ['calendar', 'calendar2'], - contacts: ['contacts', 'contacts2'], files: ['files'], addons: ['addons'], backup: ['backup'], + contacts: ['contacts', 'contacts2'], files: ['files'], files_sync: ['files_sync'], addons: ['addons'], backup: ['backup'], offline: ['offline'], privacy: ['privacy'], about: ['about'], }; @@ -141,7 +143,15 @@ describe('settings search index', () => { const realMatchMedia = window.matchMedia; const variants: Record void> = { base: () => {}, - desktop: () => { window.bulwarkDesktop = DESKTOP_BRIDGE as never; }, + desktop: () => { + window.bulwarkDesktop = DESKTOP_BRIDGE as never; + // Files on this computer: the engine runs, on Windows, with the drive on offer. + useFilesSyncStore.setState({ + available: true, + account: 'acc', + drive: { supported: true, forcedOff: false, wanted: [], letter: { letter: null, chosen: null, locked: false, available: ['Z'] } }, + }); + }, touch: () => { window.matchMedia = ((q: string) => ({ ...realMatchMedia(q), matches: q.includes('coarse') })) as typeof window.matchMedia; }, @@ -160,6 +170,7 @@ describe('settings search index', () => { useAccountStore.setState(accountsInitial, true); window.matchMedia = realMatchMedia; window.bulwarkDesktop = undefined; + useFilesSyncStore.getState().reset(); apply(); out[variant] = {}; for (const [tab, Page] of Object.entries(pages)) { diff --git a/components/settings/desktop-files-settings.tsx b/components/settings/desktop-files-settings.tsx new file mode 100644 index 000000000..fbeb9357a --- /dev/null +++ b/components/settings/desktop-files-settings.tsx @@ -0,0 +1,346 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useFormatter, useTranslations } from "next-intl"; +import { + AlertCircle, AlertTriangle, ArrowDown, ArrowUp, FolderSync, HardDrive, HelpCircle, Loader2, Trash2, type AppIcon, +} from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { FolderPickerDialog, type PickerFolder } from "@/components/files/folder-picker-dialog"; +import { FilesSyncStatusLine } from "@/components/files-sync/files-sync-status-line"; +import { MassDeleteQuestion, useInlineMassDeleteQuestions } from "@/components/files-sync/files-sync-prompts"; +import { filesSyncErrorKey, rootName, syncTimeOptions } from "@/components/files-sync/files-sync-text"; +import { fileNodeKind } from "@/lib/jmap/filenode-kind"; +import { findTrashFolder, trashSubtreeIds } from "@/lib/files-trash"; +import { cn, formatFileSize } from "@/lib/utils"; +import type { FilesDriveState, FilesError, FilesRoot } from "@/lib/desktop/types"; +import type { FilesSyncActivity, FilesSyncActivityKind } from "@/lib/files-sync/types"; +import { useAuthStore } from "@/stores/auth-store"; +import { useFilesSyncStore } from "@/stores/files-sync-store"; +import { OrgLockedNote } from "./org-locked-note"; +import { SettingItem, SettingsSection, Select, ToggleSwitch } from "./settings-section"; + +/** + * Settings > Files on this computer, in the desktop app only: the Files drive + * (Windows), folders kept the same as a Files folder, and what happened lately. + * Everything comes from stores/files-sync-store.ts and goes to the engine's + * controller; the page never talks to the bridge. + */ +export function DesktopFilesSettings() { + const available = useFilesSyncStore((s) => s.available); + const drive = useFilesSyncStore((s) => s.drive); + // The page asks about a mass delete in the folder's row; the app-wide dialog waits. + useInlineMassDeleteQuestions(); + if (!available) return null; + return ( +
+ {drive && } + + +
+ ); +} + +/** What went wrong turning something on, in words. */ +function addErrorKey(error: FilesError): string | null { + if (error === "cancelled") return null; + if (error === "overlap" || error === "denied" || error === "policy") return `folders.add_failed.${error}`; + return "folders.add_failed.failed"; +} + +function DriveSection({ drive }: { drive: FilesDriveState }) { + const t = useTranslations("files_sync"); + const account = useFilesSyncStore((s) => s.account); + const roots = useFilesSyncStore((s) => s.roots); + const status = useFilesSyncStore((s) => s.status); + const controller = useFilesSyncStore((s) => s.controller); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const on = !drive.forcedOff && !!account && drive.wanted.includes(account); + const driveRoot = roots.find((r) => r.kind === "drive" && r.account === account); + const { letter } = drive; + const letterValue = (letter.locked ? letter.letter : letter.chosen) ?? "none"; + const letters = [...new Set([...letter.available, letter.chosen, letter.letter].filter((l): l is string => !!l))].sort(); + + const toggle = (next: boolean) => { + if (!controller) return; + setError(null); + setBusy(true); + // Straight from the click: turning it on needs the click it came from. + void controller.setDriveEnabled(next).then( + (result) => { if (!result.ok) setError(addErrorKey(result.error)); }, + () => setError("folders.add_failed.failed"), + ).finally(() => setBusy(false)); + }; + const chooseLetter = (value: string) => { + if (!controller) return; + setError(null); + void controller.setDriveLetter(value === "none" ? null : value).then( + (result) => { if (!result.ok) setError(addErrorKey(result.error)); }, + () => setError("folders.add_failed.failed"), + ); + }; + + return ( + + + + + {on && driveRoot && } + {on && driveRoot && status[driveRoot.id]?.state === "confirm-delete" && status[driveRoot.id]?.massDelete && ( + + )} + {(on || letter.locked) && drive.supported && ( + + )} - , - ); - await waitFor(() => expect(document.body.querySelector('[role="menu"]')?.style.visibility).toBe('visible')); - expect(popup).not.toHaveBeenCalled(); - expect(screen.getByRole('menuitem', { name: /Reply/ })).toBeTruthy(); - }); - - it('a plugin\'s frame keeps the rest native, with a line to the HTML menu', async () => { - const frame = ( -
'; - expect(readMenu(root).unsupported).toBe(true); - }); - - it('a row that went while the menu was up says so, instead of doing nothing', async () => { - const warning = vi.spyOn(toast, 'warning').mockImplementation(() => '' as never); - const reply = vi.fn(); - const { items, onClose } = await openMenu(testMenu(undefined, { reply })); - const row = screen.getAllByRole('menuitem', { hidden: true }).find((el) => el.textContent?.includes('Reply'))!; - row.parentElement!.remove(); - await act(async () => { - answer(items[1].id!); - }); - await waitFor(() => expect(onClose).toHaveBeenCalled()); - expect(reply).not.toHaveBeenCalled(); - expect(warning).toHaveBeenCalledWith('pick_gone'); - warning.mockRestore(); - }); - - it('a row with no words stays HTML too: a colour swatch says nothing in a native menu', () => { - const root = document.createElement('div'); - root.innerHTML = ''; - expect(readMenu(root).unsupported).toBe(true); - }); - - it('a search field over a list is left out, and what it searches stays', () => { - const root = document.createElement('div'); - root.innerHTML = ''; - const read = readMenu(root); - expect(read.unsupported).toBe(false); - expect(read.items.map((item) => item.label)).toEqual(['Inbox']); - }); - - it('reads check and radio rows', () => { - const root = document.createElement('div'); - root.innerHTML = ''; - expect(readMenu(root).items).toMatchObject([ - { type: 'checkbox', checked: true, label: 'Show' }, - { type: 'radio', checked: false, label: 'Other' }, - ]); - }); -}); - describe('the same menu in a browser', () => { it('is the HTML menu, on screen, and no bridge is asked', async () => { render( {}}>{testMenu()}); @@ -194,6 +49,28 @@ describe('the same menu in a browser', () => { }); }); +describe('the same menu in the desktop app', () => { + it('is the HTML menu too, with its icons and keys, and no native menu is asked for', async () => { + const popup = vi.fn(async () => null); + setBridge({ version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } }); + document.documentElement.lang = 'de'; + const Icon = () => ; + render( + {}}> + {}} shortcut="Shift + R" /> + , + ); + await waitFor(() => expect(document.body.querySelector('[role="menu"]')?.style.visibility).toBe('visible')); + const row = screen.getByRole('menuitem', { name: /Allen antworten/ }); + expect(row.querySelector('[data-testid="row-icon"]')).not.toBeNull(); + // Windows' own words for the keys, not Chromium's "Umschalttaste". + expect(row.querySelector('[data-menu-shortcut]')?.textContent).toBe('Umschalt + R'); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(popup).not.toHaveBeenCalled(); + document.documentElement.lang = ''; + }); +}); + describe('menu commands', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/lib/desktop/__tests__/policy-schema.test.ts b/lib/desktop/__tests__/policy-schema.test.ts index 07b11863c..b952bec50 100644 --- a/lib/desktop/__tests__/policy-schema.test.ts +++ b/lib/desktop/__tests__/policy-schema.test.ts @@ -15,6 +15,7 @@ import { jmapServerAllowed, parseCertificateRule, parseInstanceUrl, + pagePolicySnapshot, parsePolicyValue, parseServerPattern, pluginAllowedByPolicy, @@ -32,7 +33,7 @@ describe('the schema', () => { 'PluginsMode', 'PluginAllowlist', 'OfflineEnabled', 'AppLockRequired', 'AppLockTimeoutMinutes', 'TelemetryAllowed', 'CrashReportsAllowed', 'QuickComposeShortcut', 'AuthServerAllowlist', 'AuthNegotiateDelegateAllowlist', 'AutoSelectCertificateForUrls', 'KeystoreEnabled', 'KeystoreAllowedOrigins', 'BrowserSignIn', 'WipeOfflineData', - 'OpenWithEnabled', 'DeveloperToolsAvailability', + 'OpenWithEnabled', 'DeveloperToolsAvailability', 'RecentFilesInAttach', 'FilesDriveEnabled', 'FilesFolderSyncEnabled', 'FilesDriveLetter', ]); for (const key of POLICY_KEYS) { const { title, description } = POLICY_SCHEMA[key]; @@ -56,6 +57,44 @@ describe('developer tools and Open with', () => { expect(resolvePolicies(layers({ forcedMachine: { OpenWithEnabled: 0 } })).OpenWithEnabled).toMatchObject({ value: false, locked: true }); expect(resolvePolicies(layers({ forcedMachine: { OpenWithEnabled: 'maybe' } })).OpenWithEnabled).toMatchObject({ value: false, locked: true }); }); + + it('hide recent files in the Attach menu only by force, and a value nobody can read hides them', () => { + expect(resolvePolicies(layers({})).RecentFilesInAttach).toMatchObject({ value: true, locked: false }); + expect(resolvePolicies(layers({ recommendedMachine: { RecentFilesInAttach: 0 } })).RecentFilesInAttach).toMatchObject({ value: true, locked: false }); + expect(resolvePolicies(layers({ forcedUser: { RecentFilesInAttach: 0 } })).RecentFilesInAttach).toMatchObject({ value: false, locked: true }); + expect(resolvePolicies(layers({ forcedMachine: { RecentFilesInAttach: 'yes please' } })).RecentFilesInAttach).toMatchObject({ value: false, locked: true }); + }); +}); + +describe('Files on this computer', () => { + it('tells the page the drive and folder sync are forced off, so it can show the lock', () => { + const forced = resolvePolicies(layers({ forcedMachine: { FilesDriveEnabled: 0, FilesFolderSyncEnabled: 0, FilesDriveLetter: 'R' } })); + const page = pagePolicySnapshot(forced); + expect(page.FilesDriveEnabled).toMatchObject({ value: false, locked: true }); + expect(page.FilesFolderSyncEnabled).toMatchObject({ value: false, locked: true }); + // The letter comes from main with the drive's state. + expect(page.FilesDriveLetter).toMatchObject({ value: null, locked: false }); + }); + + it('turn the drive and folder sync off only by force, and a value nobody can read leaves them off', () => { + for (const key of ['FilesDriveEnabled', 'FilesFolderSyncEnabled'] as const) { + expect(resolvePolicies(layers({}))[key]).toMatchObject({ value: true, locked: false }); + expect(resolvePolicies(layers({ recommendedUser: { [key]: 0 } }))[key]).toMatchObject({ value: true, locked: false }); + expect(resolvePolicies(layers({ forcedUser: { [key]: 0 } }))[key]).toMatchObject({ value: false, locked: true }); + expect(resolvePolicies(layers({ forcedMachine: { [key]: 'perhaps' } }))[key]).toMatchObject({ value: false, locked: true }); + } + }); + + it('takes a drive letter from D to Z or none, forced only, and a value nobody can read means no letter', () => { + expect(parsePolicyValue('FilesDriveLetter', 'x')).toEqual({ ok: true, value: 'X' }); + expect(parsePolicyValue('FilesDriveLetter', ' Q: ')).toEqual({ ok: true, value: 'Q' }); + expect(parsePolicyValue('FilesDriveLetter', 'NONE')).toEqual({ ok: true, value: 'none' }); + for (const bad of ['C', 'A:', 'B', 'QQ', 'Q:\\', '', 4, null]) expect(parsePolicyValue('FilesDriveLetter', bad).ok, String(bad)).toBe(false); + expect(resolvePolicies(layers({})).FilesDriveLetter).toMatchObject({ value: null, locked: false }); + expect(resolvePolicies(layers({ recommendedMachine: { FilesDriveLetter: 'R' } })).FilesDriveLetter).toMatchObject({ value: null, locked: false }); + expect(resolvePolicies(layers({ forcedMachine: { FilesDriveLetter: 'r' } })).FilesDriveLetter).toMatchObject({ value: 'R', locked: true }); + expect(resolvePolicies(layers({ forcedUser: { FilesDriveLetter: 'C' } })).FilesDriveLetter).toMatchObject({ value: 'none', locked: true }); + }); }); describe('precedence', () => { diff --git a/lib/desktop/__tests__/recent-files.test.ts b/lib/desktop/__tests__/recent-files.test.ts new file mode 100644 index 000000000..7c5fa8c6e --- /dev/null +++ b/lib/desktop/__tests__/recent-files.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_POLICY_SNAPSHOT, type PolicySnapshot } from '../policy-schema'; +import type { BulwarkDesktopBridge, DesktopRecentFilesBridge } from '../types'; +import { createElement } from 'react'; +import { renderToString } from 'react-dom/server'; +import { listRecentFiles, readRecentFile, recentFilesOffered, recentWhen, shortFolder, useRecentFilesOffered } from '../recent-files'; + +describe('shortFolder', () => { + it('keeps the drive or share and the folders nearest the file, and drops the middle', () => { + expect(shortFolder('C:\\Users\\me\\Documents')).toBe('C:\\Users\\me\\Documents'); + expect(shortFolder('C:\\Users\\me\\AppData\\Local\\Temp\\bw-shots-gnIFSO\\Documents\\Reports')) + .toBe('C:\\…\\Temp\\bw-shots-gnIFSO\\Documents\\Reports'); + expect(shortFolder('\\\\fileserver\\departments\\Finance\\Controlling\\2026\\Quarterly\\Board')) + .toBe('\\\\fileserver\\departments\\…\\Quarterly\\Board'); + expect(shortFolder('/home/me/projects/customer-a/2026/contracts/signed/final')).toBe('/…/customer-a/2026/contracts/signed/final'); + // One long name: nothing to drop, the line's ellipsis does the rest. + const long = `C:\\${'x'.repeat(60)}`; + expect(shortFolder(long)).toBe(long); + }); +}); + +function setBridge(recentFiles?: Partial, policy: Partial = {}) { + const snapshot = { ...DEFAULT_POLICY_SNAPSHOT, ...policy }; + window.bulwarkDesktop = { + version: 1, + platform: 'win32', + policy: { get: () => snapshot, onChange: () => () => {} }, + ...(recentFiles ? { recentFiles: recentFiles as DesktopRecentFilesBridge } : {}), + } as BulwarkDesktopBridge; +} + +const off = { RecentFilesInAttach: { value: false, source: 'forced-machine', locked: true } } as Partial; + +afterEach(() => { + window.bulwarkDesktop = undefined; +}); + +describe('whether the Attach menu lists recent files', () => { + it('not in a browser, not under a shell without the list, not when the policy says no', () => { + expect(recentFilesOffered()).toBe(false); + setBridge(); + expect(recentFilesOffered()).toBe(false); + setBridge({ list: vi.fn(), attach: vi.fn() }); + expect(recentFilesOffered()).toBe(true); + setBridge({ list: vi.fn(), attach: vi.fn() }, off); + expect(recentFilesOffered()).toBe(false); + }); + + it("says no in the server's render, so the page's first render matches it", () => { + setBridge({ list: vi.fn(), attach: vi.fn() }); + const Probe = () => createElement('span', null, useRecentFilesOffered() ? 'menu' : 'button'); + expect(renderToString(createElement(Probe))).toBe('button'); + }); +}); + +describe('listRecentFiles', () => { + it('gives the files, and nothing when main says no or fails', async () => { + const file = { id: 'a', name: 'a.pdf', folder: 'C:\\Docs', path: 'C:\\Docs\\a.pdf', usedAt: 1 }; + setBridge({ list: vi.fn(async () => ({ ok: true as const, files: [file] })) }); + expect(await listRecentFiles()).toEqual([file]); + setBridge({ list: vi.fn(async () => ({ ok: false as const, reason: 'gesture' as const })) }); + expect(await listRecentFiles()).toEqual([]); + setBridge({ list: vi.fn(async () => { throw new Error('gone'); }) }); + expect(await listRecentFiles()).toEqual([]); + window.bulwarkDesktop = undefined; + expect(await listRecentFiles()).toEqual([]); + }); +}); + +describe('readRecentFile', () => { + it('turns the bytes into a File with the name main gave', async () => { + const attach = vi.fn(async () => ({ ok: true as const, file: { name: 'Report.docx', data: new TextEncoder().encode('hello') } })); + setBridge({ attach }); + const result = await readRecentFile('id-1'); + expect(attach).toHaveBeenCalledWith('id-1'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.file.name).toBe('Report.docx'); + expect(await result.file.text()).toBe('hello'); + }); + + it('says what the user can act on, and keeps quiet about a lock or a policy', async () => { + const answer = (reason: string) => { + setBridge({ attach: vi.fn(async () => ({ ok: false, reason }) as never) }); + return readRecentFile('x'); + }; + expect(await answer('missing')).toEqual({ ok: false, reason: 'missing' }); + expect(await answer('too-large')).toEqual({ ok: false, reason: 'too-large' }); + expect(await answer('unknown')).toEqual({ ok: false, reason: 'failed' }); + expect(await answer('gesture')).toEqual({ ok: false, reason: 'failed' }); + expect(await answer('locked')).toEqual({ ok: false, reason: 'quiet' }); + expect(await answer('policy')).toEqual({ ok: false, reason: 'quiet' }); + }); +}); + +describe('recentWhen', () => { + const now = new Date(2026, 10, 4, 15, 0).getTime(); + it('counts minutes and hours today, calendar days this week, then weeks and months', () => { + expect(recentWhen(now - 20_000, now, 'en')).toBe('now'); + expect(recentWhen(now - 5 * 60_000, now, 'en')).toBe('5 minutes ago'); + expect(recentWhen(now - 3 * 60 * 60_000, now, 'en')).toBe('3 hours ago'); + // Last night at 23:00, 16 hours ago: yesterday. + expect(recentWhen(new Date(2026, 10, 3, 23, 0).getTime(), now, 'en')).toBe('yesterday'); + expect(recentWhen(new Date(2026, 10, 1, 9, 0).getTime(), now, 'en')).toBe('3 days ago'); + expect(recentWhen(now - 15 * 24 * 60 * 60_000, now, 'en')).toBe('2 weeks ago'); + expect(recentWhen(now - 70 * 24 * 60 * 60_000, now, 'en')).toBe('2 months ago'); + expect(recentWhen(now - 3 * 60 * 60_000, now, 'de')).toBe('vor 3 Stunden'); + }); +}); diff --git a/lib/desktop/dialog-window.ts b/lib/desktop/dialog-window.ts new file mode 100644 index 000000000..88acabacc --- /dev/null +++ b/lib/desktop/dialog-window.ts @@ -0,0 +1,172 @@ +/** + * Short questions in a window of their own, in the desktop app: the shell draws a card + * like the in-page dialog in a frameless window, modal to this one (dialogs.show, + * src/main/dialog-window.ts in the desktop repo). The page sends its look along, so the + * window has the app's theme, accent, font and corners. + * + * Only for confirm/alert-sized questions: a title, a few lines of plain text and up to + * four buttons. Anything with fields, pickers or rich content stays in the page. Without + * the bridge (browser, Lite, an older shell) `askInWindow` returns null and the caller + * shows its in-page dialog. + */ + +import { getDesktopBridge } from './bridge'; +import type { DesktopDialogWindowAnswer, DesktopDialogWindowColour, DesktopDialogWindowRequest, DesktopDialogWindowTheme } from './types'; + +export type WindowQuestion = Omit & { + /** Plain text. With `boldMarks`, text between ** pairs is bold, as plugin dialogs have it. */ + message?: string; + boldMarks?: boolean; +}; + +/** `failed`: the shell refused the request; show the dialog in the page instead. */ +export type WindowAnswer = DesktopDialogWindowAnswer | 'failed'; + +export interface AskedInWindow { + answer: Promise; + /** Takes the question back: the state it belonged to went. Does nothing once answered. */ + cancel: () => void; +} + +type ShowBridge = Required>['dialogs']>, 'show' | 'cancel'>>; + +function showBridge(): ShowBridge | null { + const dialogs = getDesktopBridge()?.dialogs; + return dialogs && typeof dialogs.show === 'function' && typeof dialogs.cancel === 'function' ? (dialogs as ShowBridge) : null; +} + +export function dialogWindowsAvailable(): boolean { + return showBridge() !== null; +} + +/** Text in runs, with ** pairs bold (plugin-dialog-host.tsx's renderMessage, as runs). */ +export function messageRuns(text: string | undefined, boldMarks = false): { text: string; bold?: boolean }[] { + if (!text) return []; + if (!boldMarks) return [{ text }]; + return text.split('**').flatMap((part, i) => (part ? [i % 2 === 1 ? { text: part, bold: true } : { text: part }] : [])); +} + +const COLOUR_VARS: Record = { + background: '--color-background', + foreground: '--color-foreground', + muted: '--color-muted', + mutedForeground: '--color-muted-foreground', + border: '--color-border', + input: '--color-input', + primary: '--color-primary', + primaryForeground: '--color-primary-foreground', + destructive: '--color-destructive', + destructiveForeground: '--color-destructive-foreground', + accent: '--color-accent', + accentForeground: '--color-accent-foreground', + ring: '--color-ring', +}; + +/** What Chromium computes an sRGB colour to. */ +const RGB = /^rgba?\((\d{1,3}), (\d{1,3}), (\d{1,3})(?:, ([\d.]+))?\)$/; + +/** One colour as rgb()/rgba(), whatever the theme wrote it as (hex, hsl, oklch, color-mix). */ +function resolveColour(probe: HTMLElement, value: string): string | undefined { + probe.style.color = ''; + probe.style.color = value; + if (!probe.style.color) return undefined; + const computed = getComputedStyle(probe).color.trim(); + const rgb = RGB.exec(computed); + if (rgb) { + const [r, g, b, a] = rgb.slice(1).map((n) => (n === undefined ? undefined : Number(n))); + return a === undefined ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${Math.round(a * 1000) / 1000})`; + } + // A wide-gamut form (oklch, color(srgb …)): a canvas gives its sRGB bytes. + try { + const context = document.createElement('canvas').getContext('2d', { willReadFrequently: true }); + if (!context) return undefined; + context.fillStyle = computed; + context.fillRect(0, 0, 1, 1); + const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data; + return `rgba(${r}, ${g}, ${b}, ${Math.round((a / 255) * 1000) / 1000})`; + } catch { + return undefined; + } +} + +function pixels(value: string): number | undefined { + const n = parseFloat(value); + return Number.isFinite(n) ? n : undefined; +} + +/** The page's look as it is now: the current theme, dark or light, accent, font and corners. */ +export function readDialogTheme(): DesktopDialogWindowTheme { + if (typeof document === 'undefined') return {}; + const root = document.documentElement; + const styles = getComputedStyle(root); + // Inside the page, so the theme's own rules for dialogs and buttons apply to it too. + const probe = document.createElement('div'); + probe.setAttribute('aria-hidden', 'true'); + probe.style.cssText = 'position:fixed;left:-9999px;top:0;width:1px;height:1px;visibility:hidden;pointer-events:none'; + const card = document.createElement('div'); + card.className = 'rounded-lg'; + const button = document.createElement('div'); + button.className = 'rounded-md'; + probe.append(card, button); + document.body.append(probe); + try { + const colours: Partial> = {}; + for (const [key, name] of Object.entries(COLOUR_VARS) as [DesktopDialogWindowColour, string][]) { + const value = styles.getPropertyValue(name).trim(); + const colour = value ? resolveColour(probe, value) : undefined; + if (colour) colours[key] = colour; + } + return { + dark: root.classList.contains('dark'), + colours, + font: getComputedStyle(document.body).fontFamily || undefined, + fontSize: pixels(styles.fontSize), + radius: pixels(getComputedStyle(card).borderTopLeftRadius), + radiusSmall: pixels(getComputedStyle(button).borderTopLeftRadius), + }; + } finally { + probe.remove(); + } +} + +let next = 0; + +/** + * Asks in a window of the app's own. Null when this shell can't (the browser, an older + * shell): show the in-page dialog then. + */ +export function askInWindow(question: WindowQuestion): AskedInWindow | null { + const bridge = showBridge(); + if (!bridge) return null; + const { message, boldMarks, ...rest } = question; + next += 1; + const id = `dialog-${next}-${Math.random().toString(36).slice(2, 8)}`; + const request: DesktopDialogWindowRequest = { + ...rest, + id, + message: messageRuns(message, boldMarks), + theme: readDialogTheme(), + lang: document.documentElement.lang || undefined, + dir: document.documentElement.dir === 'rtl' ? 'rtl' : 'ltr', + }; + let settled = false; + let answer: Promise; + try { + answer = bridge.show(request).then( + (value) => value ?? null, + () => 'failed' as const, + ); + } catch { + answer = Promise.resolve('failed'); + } + answer = answer.then((value) => { + settled = true; + return value; + }); + return { + answer, + cancel: () => { + if (!settled) bridge.cancel(id); + }, + }; +} diff --git a/lib/desktop/native-menu.ts b/lib/desktop/native-menu.ts deleted file mode 100644 index c920c2fe6..000000000 --- a/lib/desktop/native-menu.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * Turns an HTML menu into a native one. The ContextMenu component renders as it does - * in a browser, hidden, with its submenus open. This reads the result and shows that - * natively. A pick clicks the row the HTML menu has for it, so a menu has one list of - * entries with one set of handlers and the two can't drift. - * - * A menu with something a native one can't show (a colour picker, a row with no text) - * stays HTML. So does anything marked data-native-menu="html". A plugin's frame in the - * menu doesn't: the rest shows natively, with one line that opens the HTML menu. - */ - -import { getDesktopBridge } from './bridge'; -import type { DesktopNativeMenuItem } from './types'; - -export function nativeMenusAvailable(): boolean { - return !!getDesktopBridge()?.menu?.popup; -} - -export interface ReadMenu { - items: DesktopNativeMenuItem[]; - /** The HTML row behind each native item's id. */ - elements: Map; - /** There's something here a native menu can't show. */ - unsupported: boolean; - /** A plugin drew something of its own in the menu. */ - hasFrames: boolean; -} - -const ROW = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], button, a[href]'; - -/** The native line that opens the HTML menu instead. */ -export const HTML_MENU_ITEM = "html-menu"; - -function isDisabled(el: HTMLElement): boolean { - return (el as HTMLButtonElement).disabled === true || el.getAttribute('aria-disabled') === 'true'; -} - -/** What a row shows apart from its label: the key, a count. Never part of the label. */ -const ASIDE = 'kbd, [data-menu-shortcut], [data-menu-badge]'; - -/** - * The words of an element, each element's apart: "Day" and "D" make "Day D", not - * "DayD", and a name over an address doesn't run into it. Keys and counts are left out. - */ -function wordsOf(node: Node): string { - if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? ''; - if (!(node instanceof Element) || node.matches(ASIDE)) return ''; - return Array.from(node.childNodes).map(wordsOf).join(' '); -} - -function labelOf(el: HTMLElement): string { - const named = el.getAttribute('aria-label') ?? el.querySelector('[data-menu-label]')?.textContent ?? wordsOf(el); - const badge = el.querySelector('[data-menu-badge]')?.textContent?.trim(); - const label = named.replace(/\s+/g, ' ').trim(); - return badge && label ? `${label} (${badge})` : label; -} - -const KEY_NAMES: Record = { - del: 'Delete', delete: 'Delete', esc: 'Escape', escape: 'Escape', enter: 'Enter', return: 'Enter', - space: 'Space', tab: 'Tab', backspace: 'Backspace', insert: 'Insert', home: 'Home', end: 'End', - pageup: 'PageUp', pagedown: 'PageDown', up: 'Up', down: 'Down', left: 'Left', right: 'Right', - '↑': 'Up', '↓': 'Down', '←': 'Left', '→': 'Right', '+': 'Plus', '⌫': 'Backspace', '⏎': 'Enter', '↵': 'Enter', -}; -const MODIFIERS: Record = { - ctrl: 'Ctrl', control: 'Ctrl', mod: 'CmdOrCtrl', cmd: 'Cmd', command: 'Cmd', '⌘': 'Cmd', alt: 'Alt', option: 'Alt', - '⌥': 'Alt', shift: 'Shift', '⇧': 'Shift', '⌃': 'Ctrl', meta: 'Super', win: 'Super', -}; -const KEY = /^([A-Z0-9]|F([1-9]|1[0-9]|2[0-4])|[)!@#$%^&*(:;=<,_\-.>?/~`{\][|\\}"']|Plus|Space|Tab|Backspace|Delete|Insert|Enter|Up|Down|Left|Right|Home|End|PageUp|PageDown|Escape)$/; - -/** - * A key hint as the app shows it ("Shift + I", "⌘X", "Del") in Electron's accelerator - * syntax, or undefined when it isn't one key (two alternatives, a word). - */ -export function toAccelerator(hint: string): string | undefined { - let text = hint.trim(); - if (!text || text.includes(' / ')) return undefined; - const parts: string[] = []; - // macOS writes its modifiers as symbols with nothing between: ⌘⇧K. - while (text.length > 1 && /^[⌘⌥⇧⌃]/.test(text)) { - parts.push(MODIFIERS[text[0]]); - text = text.slice(1).replace(/^\s*\+?\s*/, ''); - } - const pieces = text === '+' ? ['+'] : text.split(/\s*\+\s*/).filter(Boolean); - if (text.endsWith('++')) pieces.push('+'); - for (let i = 0; i < pieces.length - 1; i++) { - const modifier = MODIFIERS[pieces[i].toLowerCase()]; - if (!modifier) return undefined; - parts.push(modifier); - } - const last = pieces[pieces.length - 1]; - if (!last) return undefined; - const key = KEY_NAMES[last.toLowerCase()] ?? (last.length === 1 ? last.toUpperCase() : last); - if (!KEY.test(key)) return undefined; - return [...new Set(parts), key].join('+'); -} - -function acceleratorOf(el: HTMLElement): string | undefined { - const hint = el.querySelector('[data-menu-shortcut], kbd')?.textContent; - return hint ? toAccelerator(hint) : undefined; -} - -function isHidden(el: Element): boolean { - return typeof window.getComputedStyle === 'function' && window.getComputedStyle(el).display === 'none'; -} - -export function readMenu(root: HTMLElement): ReadMenu { - const elements = new Map(); - let unsupported = root.querySelector('[data-native-menu="html"], select, textarea, input[type="color"]') !== null; - let hasFrames = false; - let next = 1; - - const walk = (container: Element, into: DesktopNativeMenuItem[]): void => { - for (const child of Array.from(container.children)) { - if (!(child instanceof HTMLElement) || isHidden(child)) continue; - if (child.getAttribute('role') === 'separator') { - into.push({ type: 'separator' }); - continue; - } - if (child.tagName === 'IFRAME') { - // Only a plugin's own slot has this coming. Anything else stays HTML whole. - if (child.closest('[data-plugin-slot]')) hasFrames = true; - else unsupported = true; - continue; - } - if (child.dataset.submenuPanel !== undefined) continue; - if (child.dataset.nativeSubmenu !== undefined) { - // A folder with subfolders (a Move list): a native menu can't indent, - // so it's a submenu with the folder itself first, as Thunderbird has it. - const group: DesktopNativeMenuItem[] = []; - walk(child, group); - const [head, ...rest] = group; - if (head && rest.length && head.type !== 'separator' && head.label) { - into.push({ label: head.label, submenu: [head, { type: 'separator' }, ...rest] }); - } else { - into.push(...group); - } - continue; - } - if (child.dataset.contextMenuHeader !== undefined) { - const label = labelOf(child); - if (label) into.push({ id: `h${next++}`, label, enabled: false }); - continue; - } - if (child.dataset.submenuTrigger !== undefined) { - const label = labelOf(child); - if (isDisabled(child)) { - into.push({ id: `s${next++}`, label, enabled: false }); - continue; - } - const panel = child.parentElement?.querySelector(':scope > [data-submenu-panel]'); - if (!panel) { - unsupported = true; - continue; - } - const submenu: DesktopNativeMenuItem[] = []; - walk(panel, submenu); - into.push({ label, submenu }); - continue; - } - if (child.matches(ROW)) { - // A row that opens its own popup: there's nothing to click through to. - if (child.hasAttribute('aria-haspopup')) { - unsupported = true; - continue; - } - const label = labelOf(child); - if (!label) { - // A swatch or an icon says nothing in a native menu. - unsupported = true; - continue; - } - const id = `i${next++}`; - elements.set(id, child); - const role = child.getAttribute('role'); - const accelerator = acceleratorOf(child); - into.push({ - id, - label, - enabled: !isDisabled(child), - ...(accelerator ? { accelerator } : {}), - ...(role === 'menuitemcheckbox' || role === 'menuitemradio' - ? { type: role === 'menuitemcheckbox' ? 'checkbox' as const : 'radio' as const, checked: child.getAttribute('aria-checked') === 'true' } - : {}), - }); - continue; - } - // A search field over a list is no use where you can't type. - if (child instanceof HTMLInputElement) continue; - walk(child, into); - } - }; - - const items: DesktopNativeMenuItem[] = []; - walk(root, items); - return { items, elements, unsupported, hasFrames }; -} - -const SETTLE_MS = 30; -const SETTLE_LIMIT_MS = 400; - -/** - * The menu once it stopped changing. Entries can render a moment after the menu - * (the mail menu waits for its data), and a native menu can't grow once it's up. - */ -export async function readSettledMenu(root: HTMLElement, cancelled: () => boolean): Promise { - const started = Date.now(); - let previous = readMenu(root); - while (!cancelled() && Date.now() - started < SETTLE_LIMIT_MS) { - await new Promise((resolve) => setTimeout(resolve, SETTLE_MS)); - const current = readMenu(root); - if (current.items.length > 0 && JSON.stringify(current.items) === JSON.stringify(previous.items)) return current; - previous = current; - } - return previous; -} - -function labelOfId(items: DesktopNativeMenuItem[], id: string): string | undefined { - for (const item of items) { - if (item.id === id) return item.label; - const below = item.submenu && labelOfId(item.submenu, id); - if (below) return below; - } - return undefined; -} - -export type NativeMenuOutcome = 'done' | 'html' | 'gone'; - -/** - * Shows it and clicks the row picked. Says when the pick found nothing: the page can - * re-render under a menu that stays up (the list refreshing), and a row that's gone - * would otherwise do nothing with no word about it. If the same line is back at the - * same place, that's the one. - */ -export async function showNativeMenu(read: ReadMenu, root: HTMLElement, at?: { x: number; y: number }, fromPlugins?: string): Promise { - const popup = getDesktopBridge()?.menu?.popup; - if (!popup) return 'done'; - const items = read.hasFrames && fromPlugins - ? [...read.items, { type: 'separator' as const }, { id: HTML_MENU_ITEM, label: fromPlugins }] - : read.items; - document.documentElement.dataset.nativeMenuOpen = ''; - let picked: string | null; - try { - picked = await popup(items, at); - } finally { - delete document.documentElement.dataset.nativeMenuOpen; - } - if (!picked) return 'done'; - if (picked === HTML_MENU_ITEM) return 'html'; - let row = read.elements.get(picked); - if (!row?.isConnected) { - const again = root.isConnected ? readMenu(root) : null; - row = again && labelOfId(again.items, picked) === labelOfId(read.items, picked) ? again.elements.get(picked) : undefined; - } - if (!row?.isConnected) return 'gone'; - row.click(); - return 'done'; -} - -/** - * The system's menu font and the room a native menu adds around its words, in screen - * pixels: Chromium's menus on Windows and Linux, AppKit's on macOS. Measured off the - * real menus at 100 %; an estimate within a few pixels is all a placement needs. - */ -const NATIVE_METRICS = { - win32: { font: '12px "Segoe UI", system-ui, sans-serif', padding: 74, keyGap: 28, charWidth: 6.2 }, - darwin: { font: '14px -apple-system, system-ui, sans-serif', padding: 48, keyGap: 24, charWidth: 7.2 }, - linux: { font: '13px system-ui, sans-serif', padding: 70, keyGap: 28, charWidth: 6.8 }, -} as const; - -let measureContext: CanvasRenderingContext2D | null | undefined; - -function textWidth(text: string, font: string, charWidth: number): number { - if (measureContext === undefined) { - try { - // jsdom has no canvas, and says so on the console. - measureContext = typeof document === 'undefined' || /jsdom/i.test(navigator.userAgent) - ? null - : document.createElement('canvas').getContext('2d'); - } catch { - measureContext = null; - } - } - if (measureContext) { - measureContext.font = font; - const width = measureContext.measureText(text).width; - if (Number.isFinite(width) && width > 0) return width; - } - return text.length * charWidth; -} - -/** - * How wide the native menu for these items will be, in the page's CSS pixels. A native - * menu is drawn at the screen's scale, not the page's zoom: at 150 % zoom it takes up - * less of the page. outerWidth is in screen pixels and innerWidth in CSS pixels, which - * gives the zoom in a window with no frame. - */ -export function estimateNativeMenuWidth(items: DesktopNativeMenuItem[], platform = getDesktopBridge()?.platform ?? 'win32'): number { - const metrics = NATIVE_METRICS[platform] ?? NATIVE_METRICS.win32; - let widest = 0; - for (const item of items) { - if (item.type === 'separator' || !item.label) continue; - const key = item.accelerator ? metrics.keyGap + textWidth(item.accelerator, metrics.font, metrics.charWidth) : 0; - widest = Math.max(widest, textWidth(item.label, metrics.font, metrics.charWidth) + key); - } - const screenWidth = widest + metrics.padding; - const zoom = typeof window !== 'undefined' && window.innerWidth > 0 && window.outerWidth > 0 - ? Math.min(5, Math.max(0.25, window.outerWidth / window.innerWidth)) - : 1; - return Math.round(screenWidth / zoom); -} - -/** - * Where a native menu from a button opens, in page coordinates: its top left corner, - * under the button and lined up with the edge the HTML menu would use. Only that: the - * OS keeps a native menu on the screen itself, and it may reach past the window. - */ -export function nativeMenuPoint( - anchor: { left: number; right: number; bottom: number; align?: 'start' | 'end' }, - menuWidth: number, - rtl: boolean, - gap: number, -): { x: number; y: number } { - const alignEnd = (anchor.align === 'end') !== rtl; - return { x: Math.max(0, Math.round(alignEnd ? anchor.right - menuWidth : anchor.left)), y: Math.round(anchor.bottom + gap) }; -} - -/** A native menu is up: the window's blur or a stray event says nothing about it. */ -export function nativeMenuIsOpen(): boolean { - return typeof document !== 'undefined' && document.documentElement.dataset.nativeMenuOpen !== undefined; -} diff --git a/lib/desktop/policy-schema.ts b/lib/desktop/policy-schema.ts index e3229f2dd..fe2dbb390 100644 --- a/lib/desktop/policy-schema.ts +++ b/lib/desktop/policy-schema.ts @@ -82,6 +82,10 @@ export interface PolicyValues { WipeOfflineData: string | null; OpenWithEnabled: boolean; DeveloperToolsAvailability: number | null; + RecentFilesInAttach: boolean; + FilesDriveEnabled: boolean; + FilesFolderSyncEnabled: boolean; + FilesDriveLetter: string | null; } export type PolicyKey = keyof PolicyValues; @@ -128,7 +132,7 @@ export type PolicyDefinition = PolicyBase & } | { type: 'string'; - format: 'instanceUrl' | 'shortcut' | 'hostList' | 'nonce'; + format: 'instanceUrl' | 'shortcut' | 'hostList' | 'nonce' | 'driveLetter'; default: null; maxLength: number; label: PolicyText; @@ -466,6 +470,60 @@ export const POLICY_SCHEMA: Record = { de: 'Dieselben Werte wie die gleichnamige Richtlinie von Chrome. 2: Die Entwicklertools sind aus und die Einstellung ist gesperrt. 0 oder 1 oder nicht konfiguriert: Benutzer können sie nach einer Warnung in den Einstellungen einschalten. Mit den Entwicklertools kann jeder an der Tastatur alles lesen, was die App enthält, auch E-Mails und Passwörter im Speicher.', }, }, + RecentFilesInAttach: { + category: 'privacy', + type: 'boolean', + default: true, + onInvalid: false, + forcedOnly: true, + sample: true, + title: { en: 'Show recent files in the Attach menu', de: 'Zuletzt verwendete Dateien im Menü „Anfügen“ anzeigen' }, + description: { + en: 'When enabled or not configured, the Attach button in a new message lists the files the user opened recently, as the operating system records them, so one click attaches one. The list is read when the menu opens and stays on the device. When disabled, the list is gone and the app refuses to read it. Attaching through the file dialog is not affected.', + de: 'Wenn aktiviert oder nicht konfiguriert, listet die Schaltfläche „Anfügen“ in einer neuen Nachricht die Dateien auf, die der Benutzer zuletzt geöffnet hat, so wie das Betriebssystem sie erfasst. Ein Klick fügt eine davon an. Die Liste wird beim Öffnen des Menüs gelesen und bleibt auf dem Gerät. Wenn deaktiviert, fehlt die Liste und die App lehnt es ab, sie zu lesen. Das Anfügen über den Dateidialog ist nicht betroffen.', + }, + }, + FilesDriveEnabled: { + category: 'system', + type: 'boolean', + default: true, + onInvalid: false, + forcedOnly: true, + sample: true, + title: { en: 'Allow Files as a drive', de: 'Dateien als Laufwerk zulassen' }, + description: { + en: 'When enabled or not configured, people can show their Files in the file manager as a drive, with files downloaded when opened. Windows only for now. When disabled, the drive is not offered, and a drive that is already there is removed with the copies of files in it. Files on the server are not affected.', + de: 'Wenn aktiviert oder nicht konfiguriert, können Benutzer ihre Dateien im Dateimanager als Laufwerk anzeigen. Dateien werden beim Öffnen heruntergeladen. Vorerst nur unter Windows. Wenn deaktiviert, wird das Laufwerk nicht angeboten, und ein vorhandenes Laufwerk wird mit den Kopien der Dateien darin entfernt. Die Dateien auf dem Server sind davon nicht betroffen.', + }, + }, + FilesFolderSyncEnabled: { + category: 'system', + type: 'boolean', + default: true, + onInvalid: false, + forcedOnly: true, + sample: true, + title: { en: 'Allow syncing folders with Files', de: 'Synchronisieren von Ordnern mit Dateien zulassen' }, + description: { + en: 'When enabled or not configured, people can keep a folder on the computer and a folder in Files the same, in both directions. When disabled, this is not offered and folders that sync now stop. The files in those folders stay where they are.', + de: 'Wenn aktiviert oder nicht konfiguriert, können Benutzer einen Ordner auf dem Computer und einen Ordner in Dateien in beide Richtungen gleich halten. Wenn deaktiviert, wird dies nicht angeboten, und Ordner, die gerade synchronisiert werden, hören damit auf. Die Dateien in diesen Ordnern bleiben, wo sie sind.', + }, + }, + FilesDriveLetter: { + category: 'system', + forcedOnly: true, + type: 'string', + format: 'driveLetter', + default: null, + maxLength: 8, + sample: 'none', + label: { en: 'Drive letter', de: 'Laufwerksbuchstabe' }, + title: { en: 'Drive letter for Files', de: 'Laufwerksbuchstabe für Dateien' }, + description: { + en: 'Windows only. A letter from D to Z gives the Files drive that letter for every user, and they can\'t change it. Enter none to never give it a letter. When not configured, people choose under Settings, and there is no letter until they do. A value the app can\'t read counts as none.', + de: 'Nur unter Windows. Ein Buchstabe von D bis Z gibt dem Laufwerk Dateien für jeden Benutzer diesen Buchstaben, und Benutzer können ihn nicht ändern. Geben Sie none ein, damit es nie einen Buchstaben bekommt. Wenn nicht konfiguriert, wählen Benutzer in den Einstellungen, und bis dahin gibt es keinen Buchstaben. Ein Wert, den die App nicht lesen kann, gilt als none.', + }, + }, }; export const POLICY_KEYS = Object.keys(POLICY_SCHEMA) as PolicyKey[]; @@ -617,6 +675,16 @@ export function pluginAllowedByPolicy(mode: PluginsMode, allowlist: readonly str const SHORTCUT = /^(?:(?:Command|Cmd|Control|Ctrl|CommandOrControl|CmdOrCtrl|Alt|Option|AltGr|Shift|Super|Meta)\+){1,4}(?:[A-Z0-9]|F[1-9]|F1[0-9]|F2[0-4]|Space|Tab|Enter|Backspace|Delete|Insert|Home|End|PageUp|PageDown|Up|Down|Left|Right|Plus|[-=,./;'`[\]\\])$/i; +/** A drive letter from D to Z, upper case, or `none`. */ +function parseDriveLetter(raw: unknown): Parsed { + if (typeof raw !== 'string') return fail('not_text'); + const text = raw.trim(); + if (text.toLowerCase() === 'none') return { ok: true, value: 'none' }; + const match = /^([D-Za-z]):?$/.exec(text); + if (!match || !/^[D-Z]$/i.test(match[1])) return fail('invalid_drive_letter'); + return { ok: true, value: match[1].toUpperCase() }; +} + function parseShortcut(raw: unknown): Parsed { if (typeof raw !== 'string') return fail('not_text'); const text = raw.trim(); @@ -796,6 +864,8 @@ export function parsePolicyValue(key: PolicyKey, raw: unknown, allowInsecure = f return parseHostList(raw); case 'nonce': return parseNonce(raw); + case 'driveLetter': + return parseDriveLetter(raw); } break; case 'list': @@ -821,6 +891,8 @@ function failClosedValue(key: PolicyKey): PolicyValues[PolicyKey] { if (definition.type === 'list') return [] as PolicyValues[PolicyKey]; // A shortcut nobody can read doesn't get the built-in one instead. if (definition.type === 'string' && definition.format === 'shortcut') return 'off' as PolicyValues[PolicyKey]; + // A drive letter nobody can read means no letter, not the user's own. + if (definition.type === 'string' && definition.format === 'driveLetter') return 'none' as PolicyValues[PolicyKey]; // A limit nobody can read is the tightest one, not the user's own. if (definition.type === 'integer') return (definition.onInvalid !== undefined ? definition.onInvalid : definition.min) as PolicyValues[PolicyKey]; return definition.default as PolicyValues[PolicyKey]; @@ -916,6 +988,9 @@ const PAGE_POLICY_KEYS: readonly PolicyKey[] = [ 'AppLockRequired', 'AppLockTimeoutMinutes', 'CrashReportsAllowed', + 'RecentFilesInAttach', + 'FilesDriveEnabled', + 'FilesFolderSyncEnabled', ]; /** @@ -965,6 +1040,10 @@ export const POLICY_CONSUMERS: Record = { BrowserSignIn: { repo: 'shell', file: 'src/main/features/browser-sign-in.ts' }, OpenWithEnabled: { repo: 'shell', file: 'src/main/features/os-default-app.ts' }, DeveloperToolsAvailability: { repo: 'shell', file: 'src/main/features/dev-tools.ts' }, + RecentFilesInAttach: { repo: 'shell', file: 'src/main/features/os-recent.ts' }, + FilesDriveEnabled: { repo: 'shell', file: 'src/main/features/files-drive.ts' }, + FilesFolderSyncEnabled: { repo: 'shell', file: 'src/main/features/files-folders.ts' }, + FilesDriveLetter: { repo: 'shell', file: 'src/main/features/files-drive.ts' }, }; /** The part the embedded server enforces. Sent to it by message. */ diff --git a/lib/desktop/recent-files.ts b/lib/desktop/recent-files.ts new file mode 100644 index 000000000..b18a02211 --- /dev/null +++ b/lib/desktop/recent-files.ts @@ -0,0 +1,103 @@ +/** + * Recent files in the composer's Attach menu, in the desktop app only. The shell reads the + * OS's list when the menu opens and hands over what the menu shows; attaching sends back an + * id, and the bytes come back as a File for the composer's own checks, as a file from the + * file dialog does. In a browser there's no bridge, and nothing here offers anything. + */ + +import { useSyncExternalStore } from 'react'; +import { getDesktopBridge } from './bridge'; +import { getDesktopPolicy, onDesktopPolicyChange } from './policy'; +import type { PolicySnapshot } from './policy-schema'; +import type { DesktopRecentFile } from './types'; + +/** The shell's limit for one file, the same as for a file from "Open with" (HANDOFF_MAX_BYTES). */ +export const RECENT_FILE_MAX_BYTES = 100 * 1024 * 1024; + +/** The shell has the list and the RecentFilesInAttach policy doesn't hide it. */ +export function recentFilesOffered(policy: PolicySnapshot = getDesktopPolicy()): boolean { + return !!getDesktopBridge()?.recentFiles && policy.RecentFilesInAttach?.value !== false; +} + +/** recentFilesOffered, live. False in the server's render, so the first render in the page matches it. */ +export function useRecentFilesOffered(): boolean { + return useSyncExternalStore(onDesktopPolicyChange, () => recentFilesOffered(), () => false); +} + +/** Straight from the click that opened the menu: main wants one. Empty when there's nothing to show. */ +export async function listRecentFiles(): Promise { + const bridge = getDesktopBridge()?.recentFiles; + if (!bridge) return []; + try { + const result = await bridge.list(); + return result.ok ? result.files : []; + } catch { + return []; + } +} + +export type RecentFileAttach = + | { ok: true; file: File } + | { ok: false; reason: 'missing' | 'too-large' | 'failed' | 'quiet' }; + +/** Straight from the click on the row. `quiet`: nothing worth telling the user (locked, or switched off). */ +export async function readRecentFile(id: string): Promise { + const bridge = getDesktopBridge()?.recentFiles; + if (!bridge) return { ok: false, reason: 'quiet' }; + try { + const result = await bridge.attach(id); + if (result.ok) return { ok: true, file: new File([new Uint8Array(result.file.data)], result.file.name) }; + if (result.reason === 'missing' || result.reason === 'too-large') return { ok: false, reason: result.reason }; + // Locked or switched off since the menu opened: the page shows that itself. + if (result.reason === 'locked' || result.reason === 'policy') return { ok: false, reason: 'quiet' }; + // An id from a list a newer one replaced, or a click main didn't see: worth another try. + return { ok: false, reason: 'failed' }; + } catch { + return { ok: false, reason: 'failed' }; + } +} + +/** + * A long folder as its start and its end, which tell it apart: the drive or the share, then + * the folders nearest the file. The middle goes, where a path's end would under an ellipsis. + */ +export function shortFolder(folder: string, max = 44): string { + if (folder.length <= max) return folder; + const sep = folder.includes('\\') ? '\\' : '/'; + const parts = folder.split(sep); + // \\server\share, or C:, or the empty start of /home. + const rootCount = folder.startsWith('\\\\') ? 4 : 1; + if (parts.length <= rootCount + 1) return folder; + const root = parts.slice(0, rootCount).join(sep); + let tail = parts.slice(rootCount); + while (tail.length > 1 && `${root}${sep}…${sep}${tail.join(sep)}`.length > max) tail = tail.slice(1); + return `${root}${sep}…${sep}${tail.join(sep)}`; +} + +const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [ + ['year', 365 * 24 * 60 * 60_000], + ['month', 30 * 24 * 60 * 60_000], + ['week', 7 * 24 * 60 * 60_000], + ['day', 24 * 60 * 60_000], + ['hour', 60 * 60_000], + ['minute', 60_000], +]; + +/** "5 minutes ago", "yesterday", "3 weeks ago", in the UI's language. Under a minute: "now". */ +export function recentWhen(usedAt: number, now: number, locale: string): string { + const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + const ago = Math.max(0, now - usedAt); + // Whole days by the calendar, so something from last night is "yesterday", not "10 hours ago". + if (ago >= 12 * 60 * 60_000 && ago < 7 * 24 * 60 * 60_000) { + const day = (time: number) => { + const date = new Date(time); + return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / (24 * 60 * 60_000); + }; + const days = day(now) - day(usedAt); + if (days >= 1) return format.format(-days, 'day'); + } + for (const [unit, size] of UNITS) { + if (ago >= size) return format.format(-Math.floor(ago / size), unit); + } + return format.format(0, 'second'); +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 467f918d1..356c40a00 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -80,7 +80,10 @@ export interface DesktopSystemTheme { export type DesktopMenuState = Record; -/** A native menu built from an HTML one (lib/desktop/native-menu.ts). */ +/** + * A native menu the shell can show. The page shows its own HTML menus in the desktop app + * too, so they have their icons and the app's key names; this stays for older pages. + */ export interface DesktopNativeMenuItem { /** Handed back when this item is chosen. Left out of separators and submenus. */ id?: string; @@ -116,6 +119,8 @@ export interface DesktopMenuBarEvent { export interface DesktopMenuBarItem { /** Its place in the app menu: handed back with its text when it's picked. */ key: string; + /** The line's command id (`message.reply`), or `role.`: the page draws its icon by it. Newer shells only. */ + id?: string; label: string; type: 'normal' | 'checkbox' | 'radio' | 'separator' | 'submenu'; enabled: boolean; @@ -190,8 +195,66 @@ export interface DesktopDialogsBridge { defaultId?: number; type?: 'none' | 'info' | 'warning' | 'question'; }) => Promise; + /** + * A short question in a window of the app's own, modal to this one and drawn like the + * in-page dialog (lib/desktop/dialog-window.ts). One at a time per page; the next waits. + * Absent on older shells. + */ + show?: (request: DesktopDialogWindowRequest) => Promise; + /** Takes back a question asked with `show`, showing or waiting. */ + cancel?: (id: string) => void; } +/** Plain text throughout: the shell sets textContent only. */ +export interface DesktopDialogWindowRequest { + /** The page's own name for it, for `cancel`. */ + id: string; + title: string; + /** The text under the title, in runs; `bold` runs are drawn bold. */ + message?: { text: string; bold?: boolean }[]; + /** "From the plugin …": who is asking, when it isn't the app. Drawn under the title. */ + source?: string; + /** The warning sign of a destructive confirm. */ + icon?: 'warning'; + /** In the order they stand, start to end. At most four. */ + buttons: { label: string; variant?: 'primary' | 'secondary' | 'destructive' }[]; + /** The button Enter takes and the focus starts on. */ + defaultIndex?: number; + /** The ⋮ menu at the top end: a plugin's options. */ + menu?: { label: string; items: { id: string; label: string; destructive?: boolean }[] }; + theme?: DesktopDialogWindowTheme; + lang?: string; + dir?: 'ltr' | 'rtl'; +} + +export interface DesktopDialogWindowTheme { + dark?: boolean; + /** #rrggbb[aa] or rgb(a)(); anything else is dropped by the shell. */ + colours?: Partial>; + font?: string; + fontSize?: number; + radius?: number; + radiusSmall?: number; +} + +export type DesktopDialogWindowColour = + | 'background' + | 'foreground' + | 'muted' + | 'mutedForeground' + | 'border' + | 'input' + | 'primary' + | 'primaryForeground' + | 'destructive' + | 'destructiveForeground' + | 'accent' + | 'accentForeground' + | 'ring'; + +/** A button by its index, a menu item by its id. Null: closed without an answer (Escape, Alt+F4, the page went). */ +export type DesktopDialogWindowAnswer = { button: number } | { menu: string } | null; + export interface DesktopClipboardBridge { /** Puts a file on the clipboard, for pasting into a folder. False when that didn't work. */ copyFile: (name: string, data: ArrayBuffer) => Promise; @@ -752,6 +815,39 @@ export interface DesktopDevToolsBridge { onChange: (listener: (state: DesktopDevToolsState) => void) => () => void; } +/** One of the user's recently used files, as the Attach menu shows it. Nothing more of it reaches the page. */ +export interface DesktopRecentFile { + /** Opaque. `attach` takes it, from the window that listed it, until that window lists again. */ + id: string; + name: string; + /** The folder it's in, for display. */ + folder: string; + /** The whole path, for a tooltip. */ + path: string; + /** When it was last used, in milliseconds since 1970. */ + usedAt: number; + /** A small icon as a data: URL, when the OS gave one in time. */ + icon?: string; +} + +/** `gesture`: no click or key press just now. `policy`: RecentFilesInAttach is off. */ +export type DesktopRecentFilesResult = + | { ok: true; files: DesktopRecentFile[] } + | { ok: false; reason: 'policy' | 'locked' | 'gesture' }; + +export type DesktopRecentFileAttachResult = + | { ok: true; file: DesktopOsFile } + | { ok: false; reason: 'policy' | 'locked' | 'gesture' | 'unknown' | 'missing' | 'too-large' | 'failed' }; + +/** + * The files the OS says were used last, for the composer's Attach menu. Main wants a click + * or key press of its own for each call, reads only a file it just listed, and never logs. + */ +export interface DesktopRecentFilesBridge { + list: () => Promise; + attach: (id: string) => Promise; +} + /** An attachment copied to a private folder and open in its default program. */ export interface DesktopEditSession { id: string; @@ -845,6 +941,223 @@ export interface DesktopEditBridge { onChange: (listener: (sessions: DesktopEditSession[]) => void) => () => void; } +/* + * Files on this computer: folders kept the same as a Files folder, and on Windows the + * Files drive (Cloud Files API placeholders). The sync engine is the page's + * (lib/files-sync in the webmail): it holds the JMAP session and decides. Main only does + * what the page can't: the disk, the folder picker, the OS's sync root, and the + * callbacks Windows makes when someone opens a file that isn't on the disk yet. + * + * Paths here are always relative to a root, with `/` between parts, never `..`, never + * absolute. Main resolves them inside the root and refuses anything that leaves it, + * through a link or otherwise. + */ + +/** A place on this computer kept the same as Files: a folder the user picked, or the drive. */ +export interface FilesRoot { + /** Main's id for it. Random, never a path. */ + id: string; + kind: 'folder' | 'drive'; + /** The page's local account id it belongs to. */ + account: string; + /** Where it is on disk, to show and nothing else. */ + path: string; + /** A folder: the Files folder it pairs with (null: the top of Files). The drive: null. */ + remoteId: string | null; + /** What the page called the Files side when it was added, to show. */ + remoteLabel: string; + paused: boolean; + createdAt: number; +} + +/** One file or folder under a root, as it is on disk now. */ +export interface FilesLocalEntry { + /** Relative to the root, `/` between parts. */ + path: string; + kind: 'file' | 'directory'; + size: number; + mtimeMs: number; + /** The same across a rename on the same volume (file id, inode), to tell a move from a delete and a create. */ + fileId: string; + /** The drive only: what the placeholder says. */ + placeholder?: FilesPlaceholderState; +} + +export interface FilesPlaceholderState { + /** The FileNode id written into it. Null for something made in Explorer that isn't a placeholder yet. */ + identity: string | null; + /** Main or the page marked it in sync and nobody changed it since. */ + inSync: boolean; + /** The content is on this disk. */ + hydrated: boolean; + pin: 'pinned' | 'unpinned' | 'unspecified'; +} + +/** What the page expects a path to be, so main refuses to act on something that changed meanwhile. */ +export interface FilesExpect { + fileId: string; + size: number; + mtimeMs: number; +} + +export type FilesError = + /** Not there. */ + | 'missing' + /** Something is already at the target. */ + | 'exists' + /** It changed since the page looked (`expect` didn't match). */ + | 'changed' + /** Another program has it open. */ + | 'busy' + | 'denied' + /** The path leaves the root, or goes through a link. */ + | 'outside' + /** A name this file system can't hold. */ + | 'name' + /** The disk is full. */ + | 'full' + /** The organisation's policy says no. */ + | 'policy' + /** No real click came with a call that needs one. */ + | 'gesture' + /** The user closed the picker. */ + | 'cancelled' + /** The folder is inside another root, holds one, or is a system folder. */ + | 'overlap' + /** The root is paused, gone, or the app is locked. */ + | 'inactive' + | 'unsupported' + | 'failed'; + +export type FilesResult = { ok: true; value: T } | { ok: false; error: FilesError }; + +/** Something on disk under a root changed. `paths: null`: too much at once, scan it all. */ +export interface FilesChangeEvent { + rootId: string; + paths: string[] | null; +} + +/** Windows wants bytes of a file that isn't on the disk yet: someone opened it. */ +export interface FilesFetchRequest { + fetchId: string; + rootId: string; + /** The FileNode id in the placeholder. */ + identity: string; + path: string; + /** What Windows asked for. Supplying from 0 to the end is always fine. */ + offset: number; + length: number; + /** The size in the placeholder. */ + total: number; +} + +/** A remote file or folder the drive should show, before any content is fetched. */ +export interface FilesPlaceholder { + path: string; + kind: 'file' | 'directory'; + size: number; + mtimeMs: number; + identity: string; +} + +export interface FilesDriveLetterState { + /** In use now. */ + letter: string | null; + /** The user's own choice, kept even while a policy overrides it. */ + chosen: string | null; + /** `FilesDriveLetter` forces it (a letter, or `none`). */ + locked: boolean; + /** Letters free now, D to Z. */ + available: string[]; +} + +export interface FilesDriveState { + /** Windows 10 1809 or later with the helper loaded. */ + supported: boolean; + /** `FilesDriveEnabled` is off. */ + forcedOff: boolean; + /** Accounts the user turned the drive on for, whether or not it's registered right now. */ + wanted: string[]; + letter: FilesDriveLetterState; +} + +/** + * What the main page takes once, in its first render (`files.claim()`), like `auth.claim`: + * a same-origin frame reaches the bridge through `parent`, and main can't tell its calls + * apart. Main also takes these calls only from the main window. + */ +export interface FilesControls { + roots: () => Promise; + /** Shows the OS folder picker. Needs a real click. */ + addFolder: (request: { account: string; remoteId: string | null; remoteLabel: string }) => Promise>; + /** A folder: forgotten, its files stay. The drive: as `drive.disable(account, 'user')`. */ + removeRoot: (rootId: string) => Promise; + setPaused: (rootId: string, paused: boolean) => Promise; + /** Everything under `under` (default: the whole root). Skips links and main's own temporary files. */ + scan: (rootId: string, under?: string) => Promise>; + stat: (rootId: string, path: string) => Promise>; + /** A URL the page fetches once for the file's bytes (Range works). Refused for a drive file that isn't on the disk. */ + openRead: (rootId: string, path: string) => Promise>; + /** + * Writing a file: chunks go to a temporary file next to it, `commitWrite` puts it in place + * if what's there is still `expect` (null: nothing may be there), and sets `mtimeMs`. + */ + beginWrite: (rootId: string, path: string, options: { expect: FilesExpect | null; mtimeMs: number; size: number }) => Promise>; + writeChunk: (writeId: string, data: Uint8Array) => Promise>; + commitWrite: (writeId: string) => Promise>; + abortWrite: (writeId: string) => Promise; + mkdir: (rootId: string, path: string) => Promise>; + /** Never overwrites. A case-only rename works on a case-insensitive disk. */ + move: (rootId: string, from: string, to: string, expect: FilesExpect | null) => Promise>; + /** A folder: to the OS's trash where it has one. Refused when it changed since `expect`. */ + remove: (rootId: string, path: string, expect: FilesExpect | null) => Promise>; + onChange: (listener: (event: FilesChangeEvent) => void) => () => void; + /** The engine's own record of a root (what both sides looked like at the last sync). */ + loadState: (rootId: string) => Promise; + saveState: (rootId: string, state: string) => Promise; + /** Opens Explorer, Finder or the file manager on it. */ + showInFolder: (rootId: string, path: string) => Promise; + /** Windows only. Absent elsewhere. */ + drive?: FilesDriveControls; +} + +export interface FilesDriveControls { + state: () => Promise; + onStateChange: (listener: (state: FilesDriveState) => void) => () => void; + /** + * Registers and connects the account's sync root. Needs a real click unless the user + * turned it on before (a sign-in brings it back). `label` is the address, shown when + * there are several accounts. + */ + enable: (account: string, label: string) => Promise>; + /** + * `user`: turned off. `signed-out`: the account signed out, and comes back at the next + * sign-in. `wipe` and `refused`: a wipe or a refused login. Each unregisters the sync root + * and deletes it with everything in it; only `signed-out` keeps the wish. + */ + disable: (account: string, reason: 'user' | 'signed-out' | 'wipe' | 'refused') => Promise; + /** New placeholders, parents first. One answer per entry. */ + place: (rootId: string, entries: FilesPlaceholder[]) => Promise>; + /** + * New metadata from the server for a placeholder that is in sync. `dehydrate`: the + * content changed, drop the copy on disk (a pinned one is fetched again). Refused + * with `changed` when someone edited it here meanwhile. + */ + update: (rootId: string, path: string, meta: { size: number; mtimeMs: number; identity: string; dehydrate: boolean }, expect: FilesExpect | null) => Promise>; + /** After an upload: makes it a placeholder with this identity if it isn't one, and marks it in sync. */ + markInSync: (rootId: string, path: string, identity: string, expect: FilesExpect) => Promise>; + onFetch: (listener: (request: FilesFetchRequest) => void) => () => void; + onFetchCancel: (listener: (fetchId: string) => void) => () => void; + /** Bytes for a fetch, in order from `offset`. False when Windows no longer wants them. */ + supply: (fetchId: string, offset: number, data: Uint8Array) => Promise; + fail: (fetchId: string, error: 'offline' | 'missing' | 'failed') => void; + setLetter: (letter: string | null) => Promise>; +} + +export interface FilesBridge { + claim: () => FilesControls | null; +} + export interface BulwarkDesktopBridge { version: number; platform: 'win32' | 'darwin' | 'linux'; @@ -874,6 +1187,10 @@ export interface BulwarkDesktopBridge { edit?: DesktopEditBridge; /** Absent on older shells. */ devTools?: DesktopDevToolsBridge; + /** Files on this computer. Absent on older shells. */ + files?: FilesBridge; + /** Absent on older shells. */ + recentFiles?: DesktopRecentFilesBridge; } declare global { diff --git a/lib/email-action-toast.ts b/lib/email-action-toast.ts index 02d987771..a841fb5e4 100644 --- a/lib/email-action-toast.ts +++ b/lib/email-action-toast.ts @@ -159,6 +159,23 @@ export async function setSelectionTagWithUndo( return done; } +/** + * Stars the selection or takes the star off (batchSetStarred; rejects when a write + * fails), with a toast that offers Undo. The messages already as asked are not put back. + */ +export async function setSelectionStarredWithUndo( + client: IJMAPClient, + starred: boolean, + words: { success: string; error: string; labels: UndoLabels }, +): Promise { + const points = keywordRestorePoints(resolveSelectedEmails(useEmailStore.getState()), ["$flagged"], { $flagged: starred }); + await runBatchEmailAction(() => useEmailStore.getState().batchSetStarred(client, starred), { + success: words.success, + error: words.error, + undo: undoKeywordAction(client, points, words.labels), + }); +} + /** * The toast's Undo for a reversible move (delete to Trash, archive, move, * report spam, remove from folder): puts the messages back where `batches` diff --git a/lib/files-sync/__tests__/engine.test.ts b/lib/files-sync/__tests__/engine.test.ts new file mode 100644 index 000000000..381f435a2 --- /dev/null +++ b/lib/files-sync/__tests__/engine.test.ts @@ -0,0 +1,627 @@ +// @vitest-environment node +/** + * The sync engine against an in-memory disk (main's answers) and a fake FileNode server + * (Stalwart's). Each test changes one side or both and checks both sides afterwards. + */ +import { describe, expect, it } from 'vitest'; +import type { FilesRoot } from '@/lib/desktop/types'; +import { FilesSyncEngine, type EngineTiming } from '../engine'; +import { namingFor } from '../names'; +import { parseRecord } from '../state'; +import { FakeDisk, str, text } from './helpers/fake-disk'; +import { FakeServer, type FakeServerOptions } from './helpers/fake-server'; + +const FAST: Partial = { localDelayMs: 0, remoteDelayMs: 0, pollMs: 1e9, rescanMs: 1e9, retryMs: [1e9], soonMs: 5, fetchWaitMs: 2000 }; + +interface Setup { + server: FakeServer; + disk: FakeDisk; + engine: FilesSyncEngine; + root: FilesRoot; + sync: () => Promise; + kinds: () => string[]; +} + +async function setup(options: { server?: FakeServerOptions; drive?: boolean; remoteFolder?: string | null; platform?: string; seed?: (server: FakeServer, disk: FakeDisk, rootId: string) => void } = {}): Promise { + const server = new FakeServer(options.server); + const platform = options.platform ?? 'win32'; + const disk = new FakeDisk({ caseInsensitive: platform !== 'linux', drive: options.drive }); + let remoteId: string | null = null; + if (options.remoteFolder !== null && !options.drive) remoteId = server.folder(options.remoteFolder ?? 'Sync').id; + const root = disk.addRoot({ id: 'r1', account: 'a1', kind: options.drive ? 'drive' : 'folder', remoteId }); + options.seed?.(server, disk, root.id); + const engine = new FilesSyncEngine({ controls: disk, naming: namingFor(platform), device: 'Laptop', readUrl: disk.readUrl, timing: FAST, now: () => disk.clock }); + engine.start(); + engine.setAccount('a1', server.remote()); + const sync = async () => { + await engine.refreshRoots(); + engine.syncNow(); + await engine.settled(); + }; + await sync(); + return { server, disk, engine, root, sync, kinds: () => engine.activity.map((entry) => `${entry.kind} ${entry.path}${entry.detail ? ` (${entry.detail})` : ''}`).reverse() }; +} + +/** What's under the root's Files folder, as paths relative to it. */ +function remoteUnder(server: FakeServer, folder = 'Sync'): string[] { + return server.listing().filter((path) => path.startsWith(`${folder}/`)).map((path) => path.slice(folder.length + 1)).filter(Boolean); +} + +describe('first sync', () => { + it('puts both sides together without deleting anything', async () => { + const { server, disk } = await setup({ + seed: (s, d, rootId) => { + const sync = s.find('Sync')!; + const docs = s.folder('Docs', sync.id); + s.file('a.txt', docs.id, 'remote a'); + s.file('top.txt', sync.id, 'top'); + d.write(rootId, 'notes.txt', 'local notes'); + d.write(rootId, 'Pics/p.png', 'png'); + }, + }); + expect(disk.listing('r1')).toEqual(['Docs/', 'Docs/a.txt', 'Pics/', 'Pics/p.png', 'notes.txt', 'top.txt']); + expect(remoteUnder(server)).toEqual(['Docs/', 'Docs/a.txt', 'Pics/', 'Pics/p.png', 'notes.txt', 'top.txt']); + expect(disk.read('r1', 'Docs/a.txt')).toBe('remote a'); + expect(server.content(server.find('Sync/notes.txt')!.id)).toBe('local notes'); + expect(disk.trashed).toEqual([]); + const record = parseRecord(disk.states.get('r1') ?? null, server.find('Sync')!.id); + expect(record.items.map((item) => item.path).sort()).toEqual(['Docs', 'Docs/a.txt', 'Pics', 'Pics/p.png', 'notes.txt', 'top.txt']); + }); + + it('takes the same file on both sides as one, and keeps both when they differ', async () => { + const { server, disk, kinds } = await setup({ + seed: (s, d, rootId) => { + const sync = s.find('Sync')!; + s.file('same.txt', sync.id, 'identical'); + s.file('differ.txt', sync.id, 'from files'); + d.write(rootId, 'same.txt', 'identical'); + d.write(rootId, 'differ.txt', 'from here'); + }, + }); + expect(server.uploads).toEqual([9]); + expect(disk.read('r1', 'differ.txt')).toBe('from files'); + const copy = disk.listing('r1').find((path) => path.startsWith('differ (conflict, Laptop, ')); + expect(copy).toBeDefined(); + expect(disk.read('r1', copy!)).toBe('from here'); + expect(server.content(server.find(`Sync/${copy}`)!.id)).toBe('from here'); + expect(kinds()).toContain(`conflict differ.txt (${copy})`); + }); + + it('syncs the top of Files, without the trash, when the pair names no folder', async () => { + const s = await setup({ + remoteFolder: null, + seed: (server) => { + server.file('root.txt', null, 'at the top'); + server.file('old.txt', server.folder('.Trash').id, 'deleted'); + }, + }); + expect(s.disk.listing('r1')).toEqual(['root.txt']); + s.disk.write('r1', 'up.txt', 'new'); + await s.sync(); + expect(s.server.listing()).toEqual(['root.txt', 'up.txt']); + }); +}); + +describe('files the computer makes for itself', () => { + it('never uploads them', async () => { + const s = await setup({ seed: (server, disk, rootId) => { disk.write(rootId, 'desktop.ini', 'x'); disk.write(rootId, 'Docs/~$report.docx', 'lock'); disk.write(rootId, 'Docs/report.docx', 'r'); } }); + expect(remoteUnder(s.server)).toEqual(['Docs/', 'Docs/report.docx']); + }); +}); + +describe('renames and moves', () => { + it('sends a rename and a move made here, without uploading again', async () => { + const s = await setup({ seed: (server, disk, rootId) => { disk.write(rootId, 'a.txt', 'aaa'); disk.mkdirUser(rootId, 'Folder'); } }); + const id = s.server.find('Sync/a.txt')!.id; + const uploads = s.server.uploads.length; + s.disk.moveUser('r1', 'a.txt', 'Folder/b.txt'); + await s.sync(); + expect(remoteUnder(s.server)).toEqual(['Folder/', 'Folder/b.txt']); + expect(s.server.find('Sync/Folder/b.txt')!.id).toBe(id); + expect(s.server.uploads.length).toBe(uploads); + expect(s.kinds()).toContain('moved Folder/b.txt (a.txt)'); + }); + + it('moves a file here when it moved in Files, keeping the same file', async () => { + const s = await setup({ seed: (server) => { const sync = server.find('Sync')!; server.folder('Other', sync.id); server.file('x.txt', sync.id, 'xx'); } }); + const fileId = s.disk.node('r1', 'x.txt').fileId; + const downloads = s.server.downloads.length; + s.server.rename(s.server.find('Sync/x.txt')!.id, 'y.txt', s.server.find('Sync/Other')!.id); + await s.sync(); + expect(s.disk.listing('r1')).toEqual(['Other/', 'Other/y.txt']); + expect(s.disk.node('r1', 'Other/y.txt').fileId).toBe(fileId); + expect(s.server.downloads.length).toBe(downloads); + }); + + it('moves a whole folder either way with what is in it', async () => { + const s = await setup({ seed: (server, disk, rootId) => { disk.write(rootId, 'A/one.txt', '1'); disk.write(rootId, 'A/B/two.txt', '2'); } }); + s.disk.moveUser('r1', 'A', 'Renamed'); + await s.sync(); + expect(remoteUnder(s.server)).toEqual(['Renamed/', 'Renamed/B/', 'Renamed/B/two.txt', 'Renamed/one.txt']); + s.server.rename(s.server.find('Sync/Renamed')!.id, 'Back'); + await s.sync(); + expect(s.disk.listing('r1')).toEqual(['Back/', 'Back/B/', 'Back/B/two.txt', 'Back/one.txt']); + expect(s.disk.trashed).toEqual([]); + }); + + it('lets the place in Files win when both sides renamed the same file', async () => { + const s = await setup({ seed: (server, disk, rootId) => disk.write(rootId, 'doc.txt', 'd') }); + s.disk.moveUser('r1', 'doc.txt', 'mine.txt'); + s.server.rename(s.server.find('Sync/doc.txt')!.id, 'theirs.txt'); + await s.sync(); + expect(s.disk.listing('r1')).toEqual(['theirs.txt']); + expect(remoteUnder(s.server)).toEqual(['theirs.txt']); + }); + + it('does case-only renames both ways', async () => { + const s = await setup({ seed: (server, disk, rootId) => disk.write(rootId, 'readme.txt', 'r') }); + s.disk.moveUser('r1', 'readme.txt', 'README.txt'); + await s.sync(); + expect(remoteUnder(s.server)).toEqual(['README.txt']); + s.server.rename(s.server.find('Sync/README.txt')!.id, 'ReadMe.txt'); + await s.sync(); + expect(s.disk.listing('r1')).toEqual(['ReadMe.txt']); + expect(s.server.uploads.length).toBe(1); + }); +}); + +describe('conflicts', () => { + it('keeps both versions when both sides changed a file', async () => { + const s = await setup({ seed: (server, disk, rootId) => disk.write(rootId, 'report.txt', 'v1') }); + const id = s.server.find('Sync/report.txt')!.id; + s.disk.write('r1', 'report.txt', 'mine, longer'); + s.server.edit(id, 'theirs'); + await s.sync(); + expect(s.disk.read('r1', 'report.txt')).toBe('theirs'); + expect(s.server.content(id)).toBe('theirs'); + const copy = s.disk.listing('r1').find((path) => path.startsWith('report (conflict, Laptop, '))!; + expect(copy).toMatch(/^report \(conflict, Laptop, \d{4}-\d\d-\d\d \d\d\.\d\d\)\.txt$/); + expect(s.disk.read('r1', copy)).toBe('mine, longer'); + expect(s.server.content(s.server.find(`Sync/${copy}`)!.id)).toBe('mine, longer'); + expect(s.engine.activity.some((entry) => entry.kind === 'conflict' && entry.detail === copy)).toBe(true); + }); + + it('brings back a file deleted here that was changed in Files', async () => { + const s = await setup({ seed: (server, disk, rootId) => disk.write(rootId, 'keep.txt', 'v1') }); + const id = s.server.find('Sync/keep.txt')!.id; + s.disk.deleteUser('r1', 'keep.txt'); + s.server.edit(id, 'edited elsewhere'); + await s.sync(); + expect(s.disk.read('r1', 'keep.txt')).toBe('edited elsewhere'); + expect(s.server.find('Sync/keep.txt')!.id).toBe(id); + }); + + it('uploads again a file changed here that was deleted in Files', async () => { + const s = await setup({ seed: (server, disk, rootId) => disk.write(rootId, 'mine.txt', 'v1') }); + s.server.destroyTree(s.server.find('Sync/mine.txt')!.id); + s.disk.write('r1', 'mine.txt', 'v2 here'); + await s.sync(); + expect(s.server.content(s.server.find('Sync/mine.txt')!.id)).toBe('v2 here'); + expect(s.disk.read('r1', 'mine.txt')).toBe('v2 here'); + }); + + it('keeps a folder deleted in Files when something new was put in it here', async () => { + const s = await setup({ seed: (server, disk, rootId) => disk.write(rootId, 'F/old.txt', 'old') }); + s.server.destroyTree(s.server.find('Sync/F')!.id); + s.disk.write('r1', 'F/new.txt', 'new'); + await s.sync(); + expect(remoteUnder(s.server)).toEqual(['F/', 'F/new.txt', 'F/old.txt']); + expect(s.disk.trashed).toEqual([]); + }); +}); + +describe('deletes', () => { + it('puts a file deleted here into the Files trash', async () => { + const s = await setup({ seed: (server, disk, rootId) => { disk.write(rootId, 'gone.txt', 'g'); disk.write(rootId, 'stay.txt', 's'); } }); + const id = s.server.find('Sync/gone.txt')!.id; + s.disk.deleteUser('r1', 'gone.txt'); + await s.sync(); + expect(remoteUnder(s.server)).toEqual(['stay.txt']); + const trashed = s.server.listing(true).find((path) => path.endsWith('/gone.txt'))!; + expect(trashed).toMatch(new RegExp(`^\\.Trash/\\d+~${s.server.find('Sync')!.id}/gone\\.txt$`)); + expect(s.server.nodes.has(id)).toBe(true); + expect(s.kinds()).toContain('trashed gone.txt'); + }); + + it('puts a file deleted in Files into the computer trash', async () => { + const s = await setup({ seed: (server, disk, rootId) => { disk.write(rootId, 'x.txt', 'x'); disk.write(rootId, 'y.txt', 'y'); } }); + s.server.destroyTree(s.server.find('Sync/x.txt')!.id); + await s.sync(); + expect(s.disk.listing('r1')).toEqual(['y.txt']); + expect(s.disk.trashed).toEqual(['x.txt']); + }); + + function many(server: FakeServer, disk: FakeDisk, rootId: string): void { + void server; + for (let i = 0; i < 60; i += 1) disk.write(rootId, `bulk/f${i}.txt`, `file ${i}`); + disk.write(rootId, 'other.txt', 'o'); + } + + it('asks before many deletes made here, and brings them back on no', async () => { + const s = await setup({ seed: many }); + s.disk.deleteUser('r1', 'bulk'); + await s.sync(); + expect(s.engine.status().r1).toMatchObject({ state: 'confirm-delete', massDelete: { count: 61, side: 'remote' } }); + expect(remoteUnder(s.server)).toContain('bulk/f0.txt'); + expect(s.kinds()).toContain('mass-delete (61)'); + s.engine.confirmMassDelete('r1', false); + await s.engine.settled(); + expect(s.disk.listing('r1').filter((path) => path.startsWith('bulk/')).length).toBe(61); + expect(s.engine.status().r1.state).toBe('idle'); + }); + + it('deletes them on yes', async () => { + const s = await setup({ seed: many }); + s.disk.deleteUser('r1', 'bulk'); + await s.sync(); + s.engine.confirmMassDelete('r1', true); + await s.engine.settled(); + expect(remoteUnder(s.server)).toEqual(['other.txt']); + expect(s.server.listing(true).filter((path) => path.startsWith('.Trash/')).length).toBe(63); + }); + + it('asks before many deletes from Files, and uploads them again on no', async () => { + const s = await setup({ seed: many }); + s.server.destroyTree(s.server.find('Sync/bulk')!.id); + await s.sync(); + expect(s.engine.status().r1).toMatchObject({ state: 'confirm-delete', massDelete: { count: 61, side: 'local' } }); + expect(s.disk.trashed).toEqual([]); + s.engine.confirmMassDelete('r1', false); + await s.engine.settled(); + expect(remoteUnder(s.server).filter((path) => path.startsWith('bulk/')).length).toBe(61); + }); + + it('removes them here on yes', async () => { + const s = await setup({ seed: many }); + s.server.destroyTree(s.server.find('Sync/bulk')!.id); + await s.sync(); + s.engine.confirmMassDelete('r1', true); + await s.engine.settled(); + expect(s.disk.listing('r1')).toEqual(['other.txt']); + expect(s.disk.trashed).toEqual(['bulk']); + }); + + it('asks when more than half of a root goes at once', async () => { + const s = await setup({ seed: (server, disk, rootId) => { for (let i = 0; i < 12; i += 1) disk.write(rootId, `f${i}.txt`, `${i}`); } }); + for (let i = 0; i < 7; i += 1) s.disk.deleteUser('r1', `f${i}.txt`); + await s.sync(); + expect(s.engine.status().r1.state).toBe('confirm-delete'); + }); +}); + +describe('names the disk cannot hold', () => { + it('shows them under another name here and never renames them in Files', async () => { + const s = await setup({ + seed: (server) => { + const sync = server.find('Sync')!; + server.file('a:b?.txt', sync.id, 'colon'); + server.file('CON.txt', sync.id, 'device'); + server.file('trail.', sync.id, 'dot'); + server.file('Report.txt', sync.id, 'upper'); + server.file('report.txt', sync.id, 'lower'); + }, + }); + expect(s.disk.listing('r1')).toEqual(['CON_.txt', 'Report.txt', 'a:b?.txt', 'report (2).txt', 'trail._']); + expect(s.kinds()).toEqual(expect.arrayContaining(['renamed-for-disk a:b?.txt (a:b?.txt)', 'renamed-for-disk report (2).txt (report (2).txt)'])); + const id = s.server.find('Sync/a:b?.txt')!.id; + // An edit here goes to the same node, under the name it has in Files. + s.disk.write('r1', 'a:b?.txt', 'edited'); + s.disk.write('r1', 'report (2).txt', 'lower edited'); + await s.sync(); + expect(s.server.content(id)).toBe('edited'); + expect(s.server.content(s.server.find('Sync/report.txt')!.id)).toBe('lower edited'); + expect(remoteUnder(s.server)).toEqual(['CON.txt', 'Report.txt', 'a:b?.txt', 'report.txt', 'trail.']); + // Another pass changes nothing on either side. + const sets = s.server.sets.length; + await s.sync(); + expect(s.server.sets.length).toBe(sets); + }); + + it('keeps the name in Files when the disk name is renamed back and forth', async () => { + const s = await setup({ seed: (server) => server.file('q?.txt', server.find('Sync')!.id, 'q') }); + s.server.rename(s.server.find('Sync/q?.txt')!.id, 'r?.txt'); + await s.sync(); + expect(s.disk.listing('r1')).toEqual(['r?.txt']); + expect(remoteUnder(s.server)).toEqual(['r?.txt']); + }); + + it('leaves names alone on a disk that takes them', async () => { + const s = await setup({ platform: 'linux', seed: (server) => { server.file('a:b.txt', server.find('Sync')!.id, '1'); server.file('A:b.txt', server.find('Sync')!.id, '2'); } }); + expect(s.disk.listing('r1')).toEqual(['A:b.txt', 'a:b.txt']); + }); +}); + +describe('reading and uploading', () => { + it('carries on when a file is renamed while it uploads', async () => { + const s = await setup(); + s.disk.write('r1', 'big.txt', 'content'); + s.server.beforeRequest = () => { + if (s.server.uploads.length === 1 && s.disk.has('r1', 'big.txt')) s.disk.moveUser('r1', 'big.txt', 'renamed.txt'); + }; + await s.sync(); + s.server.beforeRequest = null; + await s.sync(); + expect(remoteUnder(s.server)).toEqual(['renamed.txt']); + expect(s.server.uploads.length).toBe(1); + expect(s.disk.listing('r1')).toEqual(['renamed.txt']); + }); + + it('does not upload a file that changed while it was read, and uploads it once it holds still', async () => { + const s = await setup(); + s.disk.write('r1', 'live.log', 'first'); + let writes = 0; + s.disk.onRead = (rootId, path) => { + if (writes < 1) { + writes += 1; + s.disk.write(rootId, path, 'first and more'); + } + }; + await s.sync(); + expect(s.server.uploads).toEqual([14]); + expect(s.server.content(s.server.find('Sync/live.log')!.id)).toBe('first and more'); + }); + + it('keeps a file over the upload limit here, says so once, and uploads it once it fits', async () => { + const s = await setup({ server: { maxSizeUpload: 10 } }); + s.disk.write('r1', 'huge.bin', 'x'.repeat(20)); + await s.sync(); + await s.sync(); + expect(s.server.uploads).toEqual([]); + expect(s.engine.activity.filter((entry) => entry.kind === 'too-large')).toEqual([expect.objectContaining({ path: 'huge.bin', detail: '10', messageKey: 'errors.too_large' })]); + s.disk.write('r1', 'huge.bin', 'small'); + await s.sync(); + expect(s.server.content(s.server.find('Sync/huge.bin')!.id)).toBe('small'); + }); + + it('writes a downloaded file in pieces and leaves nothing half written', async () => { + const big = new Uint8Array(2_600_000).map((_, i) => i % 251); + const s = await setup({ seed: (server) => server.file('big.bin', server.find('Sync')!.id, big) }); + const node = s.disk.node('r1', 'big.bin'); + expect(node.data?.length).toBe(big.length); + expect(node.data?.[2_599_999]).toBe(2_599_999 % 251); + expect(s.disk.openWrites).toBe(0); + }); +}); + +describe('offline', () => { + it('keeps the changes while the server is away and sends them when it is back', async () => { + const s = await setup({ seed: (server, disk, rootId) => disk.write(rootId, 'a.txt', 'v1') }); + s.server.offline = true; + s.disk.write('r1', 'a.txt', 'v2'); + s.disk.write('r1', 'b.txt', 'new'); + await s.sync(); + expect(s.engine.status().r1.state).toBe('offline'); + expect(s.engine.activity[0]).toMatchObject({ kind: 'waiting', messageKey: 'errors.offline' }); + await s.sync(); + expect(s.engine.activity.filter((entry) => entry.kind === 'waiting')).toHaveLength(1); + s.server.offline = false; + s.engine.retryNow(); + await s.engine.settled(); + expect(s.engine.status().r1.state).toBe('idle'); + expect(s.server.content(s.server.find('Sync/a.txt')!.id)).toBe('v2'); + expect(s.server.content(s.server.find('Sync/b.txt')!.id)).toBe('new'); + }); + + it('keeps the record when an upload breaks off, and finishes it later', async () => { + const s = await setup(); + s.disk.write('r1', 'u.txt', 'up'); + s.server.failUploads = 1; + await s.sync(); + expect(s.engine.status().r1.state).toBe('offline'); + s.engine.retryNow(); + await s.engine.settled(); + expect(remoteUnder(s.server)).toEqual(['u.txt']); + }); +}); + +describe('changes in Files', () => { + it('reads FileNode/changes, and lists everything again only when the server cannot say', async () => { + const s = await setup({ seed: (server, disk, rootId) => disk.write(rootId, 'a.txt', 'a') }); + const fullListings = () => s.server.calls.filter(([name, args]) => name === 'FileNode/get' && (args as { ids: unknown }).ids === null).length; + const before = fullListings(); + s.server.file('b.txt', s.server.find('Sync')!.id, 'b'); + s.engine.remoteChanged('a1'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await s.engine.settled(); + expect(s.disk.read('r1', 'b.txt')).toBe('b'); + expect(fullListings()).toBe(before); + s.server.file('c.txt', s.server.find('Sync')!.id, 'c'); + s.server.account().coll.truncatedBelow = s.server.account().coll.log.length; + s.engine.remoteChanged('a1'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await s.engine.settled(); + expect(s.disk.read('r1', 'c.txt')).toBe('c'); + expect(fullListings()).toBe(before + 1); + }); + + it('ignores changes outside the pair', async () => { + const s = await setup(); + const passes = s.server.calls.length; + s.server.file('elsewhere.txt', null, 'x'); + s.engine.remoteChanged('a1'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await s.engine.settled(); + // One /changes and one get of the new node, and no pass of the root. + expect(s.server.calls.length - passes).toBe(2); + }); + + it('says so when the paired folder is gone from Files, and deletes nothing', async () => { + const s = await setup({ seed: (server, disk, rootId) => disk.write(rootId, 'a.txt', 'a') }); + s.server.destroyTree(s.server.find('Sync')!.id); + await s.sync(); + expect(s.engine.status().r1).toMatchObject({ state: 'error', errorKey: 'errors.not_found' }); + expect(s.disk.listing('r1')).toEqual(['a.txt']); + }); +}); + +describe('pause', () => { + it('does nothing while paused and catches up on resume', async () => { + const s = await setup(); + await s.engine.setPaused('r1', true); + s.disk.write('r1', 'while-paused.txt', 'p'); + await s.sync(); + expect(s.engine.status().r1.state).toBe('paused'); + expect(remoteUnder(s.server)).toEqual([]); + await s.engine.setPaused('r1', false); + await s.engine.settled(); + expect(remoteUnder(s.server)).toEqual(['while-paused.txt']); + }); + + it('runs nothing while the app is locked, and loses no record', async () => { + const s = await setup({ seed: (server, disk, rootId) => disk.write(rootId, 'a.txt', 'a') }); + const record = s.disk.states.get('r1'); + s.disk.locked = true; + await s.sync(); + expect(s.disk.states.get('r1')).toBe(record); + s.disk.locked = false; + s.disk.emit('r1', null); + await s.engine.settled(); + s.disk.write('r1', 'b.txt', 'b'); + await s.sync(); + expect(remoteUnder(s.server)).toEqual(['a.txt', 'b.txt']); + }); +}); + +describe('the drive', () => { + async function drive(seed?: (server: FakeServer) => void) { + return setup({ drive: true, seed: (server) => seed?.(server) }); + } + + it('places every node of the account but the trash, and downloads nothing', async () => { + const s = await drive((server) => { + const docs = server.folder('Docs'); + server.file('a.txt', docs.id, 'aaa'); + server.file('top.txt', null, 'top'); + const trash = server.folder('.Trash'); + server.file('old.txt', trash.id, 'old'); + }); + expect(s.disk.listing('r1')).toEqual(['Docs/', 'Docs/a.txt', 'top.txt']); + expect(s.disk.node('r1', 'Docs/a.txt').placeholder).toMatchObject({ identity: s.server.find('Docs/a.txt')!.id, hydrated: false, inSync: true }); + expect(s.server.downloads).toEqual([]); + expect(s.engine.items('a1')[s.server.find('Docs/a.txt')!.id]).toBe('online-only'); + }); + + it('streams a file opened in Explorer from where Windows asked, to the end', async () => { + const content = new Uint8Array(3 * 1024 * 1024).map((_, i) => i % 199); + const s = await drive((server) => server.file('big.bin', null, content)); + const request = s.disk.requestFetch('r1', 'big.bin', 10_000); + expect(request.offset).toBe(8192); + await new Promise((resolve) => setTimeout(resolve, 50)); + const got = s.disk.suppliedBytes(request.fetchId); + expect(got.length).toBe(content.length - 8192); + expect(got[0]).toBe(content[8192]); + expect(got.at(-1)).toBe(content.at(-1)); + expect(s.disk.fetchFailed.size).toBe(0); + }); + + it('stops streaming when the fetch is cancelled, and fails one it cannot answer', async () => { + const content = new Uint8Array(4 * 1024 * 1024); + const s = await drive((server) => { server.file('big.bin', null, content); server.file('gone.txt', null, 'g'); }); + s.server.partBytes = 64 * 1024; + s.disk.refuseSupplyAfter = 1024 * 1024; + const request = s.disk.requestFetch('r1', 'big.bin'); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(s.disk.suppliedBytes(request.fetchId).length).toBe(1024 * 1024); + // Cancelled while the download runs: the rest isn't fetched, and nothing is failed. + s.disk.refuseSupplyAfter = null; + let second = ''; + let parts = 0; + s.server.onPart = () => { + parts += 1; + if (parts === 2) s.disk.cancelFetch(second); + }; + second = s.disk.requestFetch('r1', 'big.bin').fetchId; + await new Promise((resolve) => setTimeout(resolve, 50)); + s.server.onPart = null; + expect(parts).toBeLessThan(5); + expect(s.disk.suppliedBytes(second).length).toBeLessThan(content.length); + expect(s.disk.fetchFailed.has(second)).toBe(false); + // Deleted in Files meanwhile. + s.server.destroyTree(s.server.find('gone.txt')!.id); + const third = s.disk.requestFetch('r1', 'gone.txt'); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(s.disk.fetchFailed.get(third.fetchId)).toBe('missing'); + s.server.offline = true; + s.disk.supplied.clear(); + }); + + it('updates a placeholder when the file changed in Files', async () => { + const s = await drive((server) => server.file('a.txt', null, 'v1')); + s.server.edit(s.server.find('a.txt')!.id, 'version two'); + await s.sync(); + expect(s.disk.driveCalls.filter((call) => call.call === 'update')).toEqual([{ call: 'update', args: ['r1', 'a.txt', true] }]); + expect(s.disk.node('r1', 'a.txt')).toMatchObject({ size: 11, placeholder: { hydrated: false, inSync: true } }); + }); + + it('uploads a file made in Explorer and marks it in sync', async () => { + const s = await drive(); + s.disk.write('r1', 'new.txt', 'from explorer'); + await s.sync(); + const node = s.server.find('new.txt')!; + expect(s.server.content(node.id)).toBe('from explorer'); + expect(s.disk.node('r1', 'new.txt').placeholder).toMatchObject({ identity: node.id, inSync: true }); + }); + + it('uploads a file made in Explorer inside a placed folder, next to a file opened there', async () => { + const s = await drive((server) => { const docs = server.folder('Docs'); server.file('seen.txt', docs.id, 'seen'); }); + const request = s.disk.requestFetch('r1', 'Docs/seen.txt'); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(s.disk.fetchFailed.has(request.fetchId)).toBe(false); + s.disk.write('r1', 'Docs/new.txt', 'from explorer'); + await s.sync(); + const node = s.server.find('Docs/new.txt'); + expect(node && s.server.content(node.id)).toBe('from explorer'); + }); + + it('takes a save over a placeholder as an edit here', async () => { + const s = await drive((server) => server.file('doc.txt', null, 'v1')); + const id = s.server.find('doc.txt')!.id; + s.disk.write('r1', 'doc.txt', 'saved over'); + await s.sync(); + expect(s.server.content(id)).toBe('saved over'); + expect(s.disk.node('r1', 'doc.txt').placeholder).toMatchObject({ identity: id, inSync: true }); + }); + + it('keeps both when a file edited here also changed in Files', async () => { + const s = await drive((server) => server.file('doc.txt', null, 'v1')); + const id = s.server.find('doc.txt')!.id; + s.disk.write('r1', 'doc.txt', 'here'); + s.server.edit(id, 'there'); + await s.sync(); + const copy = s.disk.listing('r1').find((path) => path.includes('(conflict, Laptop,'))!; + expect(s.server.content(id)).toBe('there'); + expect(s.disk.node('r1', 'doc.txt').placeholder).toMatchObject({ identity: id, hydrated: false }); + expect(s.server.content(s.server.find(copy)!.id)).toBe('here'); + }); + + it('finds a placeholder moved in Explorer by its identity', async () => { + const s = await drive((server) => { server.folder('A'); server.file('f.txt', null, 'f'); }); + const id = s.server.find('f.txt')!.id; + s.disk.moveUser('r1', 'f.txt', 'A/g.txt'); + await s.sync(); + expect(s.server.find('A/g.txt')!.id).toBe(id); + expect(s.server.uploads).toEqual([]); + }); + + it('starts afresh when its folder came back empty', async () => { + const s = await drive((server) => server.file('a.txt', null, 'a')); + s.disk.trees.set('r1', new Map()); + await s.sync(); + expect(s.disk.listing('r1')).toEqual(['a.txt']); + expect(s.server.listing()).toEqual(['a.txt']); + }); +}); + +describe('coverage', () => { + it('maps a node to its root and path', async () => { + const s = await setup({ seed: (server, disk, rootId) => disk.write(rootId, 'Docs/a.txt', 'a') }); + const id = s.server.find('Sync/Docs/a.txt')!.id; + expect(s.engine.coverage('a1', id)).toEqual({ rootId: 'r1', kind: 'folder', path: 'Docs/a.txt' }); + expect(s.engine.coverage('a1', s.server.find('Sync')!.id)).toEqual({ rootId: 'r1', kind: 'folder', path: '' }); + expect(s.engine.coverage('a2', id)).toBeNull(); + expect(s.engine.items('a1')[id]).toBe('synced'); + }); +}); + +void str; +void text; diff --git a/lib/files-sync/__tests__/helpers/fake-disk.ts b/lib/files-sync/__tests__/helpers/fake-disk.ts new file mode 100644 index 000000000..23422e8ee --- /dev/null +++ b/lib/files-sync/__tests__/helpers/fake-disk.ts @@ -0,0 +1,532 @@ +/** + * The desktop app's disk side of Files on this computer, in memory, answering as main does + * (desktop src/main/files/disk.ts, drive-controller.ts, features/files-folders.ts): + * - `expect` is checked on write, move and remove; null on a write means nothing may be there; + * - a move never overwrites, and a case-only rename works on a case-insensitive disk; + * - a folder root's remove goes to the trash, the drive's deletes; + * - `openRead` refuses folders and drive files whose content isn't here; + * - while locked, `roots()` is empty, everything else `inactive`, and the state calls reject; + * - the drive: placeholders with an identity, `update` refused with `changed` once someone wrote + * over one, `markInSync` making a file a placeholder again, fetches answered by `supply`. + */ + +import type { + FilesChangeEvent, + FilesControls, + FilesDriveControls, + FilesDriveState, + FilesExpect, + FilesFetchRequest, + FilesLocalEntry, + FilesPlaceholder, + FilesPlaceholderState, + FilesResult, + FilesRoot, +} from '@/lib/desktop/types'; + +interface DiskNode { + kind: 'file' | 'directory'; + data: Uint8Array | null; + /** For a dehydrated placeholder, its size. */ + size: number; + fileId: string; + mtimeMs: number; + placeholder?: FilesPlaceholderState; +} + +const ok = (value: T): FilesResult => ({ ok: true, value }); +const fail = (error: Parameters[0]): FilesResult => failOf(error) as FilesResult; +function failOf(error: Exclude, { ok: true }>['error']) { + return { ok: false as const, error }; +} + +export const text = (value: string) => new TextEncoder().encode(value); +export const str = (value: Uint8Array | null | undefined) => (value ? new TextDecoder().decode(value) : ''); + +let fileIds = 1; + +export interface FakeDiskOptions { + caseInsensitive?: boolean; + drive?: boolean; +} + +export class FakeDisk implements FilesControls { + readonly rootsList: FilesRoot[] = []; + readonly trees = new Map>(); + readonly states = new Map(); + readonly trashed: string[] = []; + readonly shown: { rootId: string; path: string }[] = []; + readonly calls: string[] = []; + locked = false; + clock = Date.UTC(2026, 10, 1); + private readonly listeners = new Set<(event: FilesChangeEvent) => void>(); + private readonly fetchListeners = new Set<(request: FilesFetchRequest) => void>(); + private readonly cancelListeners = new Set<(fetchId: string) => void>(); + private readonly reads = new Map(); + private readonly writes = new Map(); + /** Called when a read URL is fetched, before the bytes go: to change the file meanwhile. */ + onRead: ((rootId: string, path: string) => void) | null = null; + /** Bytes supplied per fetch. */ + readonly supplied = new Map(); + readonly fetchFailed = new Map(); + /** Supply answers false from this many bytes on (Windows no longer wants them). */ + refuseSupplyAfter: number | null = null; + readonly driveCalls: { call: string; args: unknown[] }[] = []; + driveWanted: string[] = []; + readonly drive?: FilesDriveControls; + private nextWrite = 1; + + constructor(private readonly options: FakeDiskOptions = {}) { + if (options.drive) this.drive = this.makeDrive(); + } + + // ─── Test setup and what a user does ──────────────────────── + + addRoot(root: Partial & { id: string; account: string }): FilesRoot { + const full: FilesRoot = { kind: 'folder', path: `C:\\sync\\${root.id}`, remoteId: null, remoteLabel: '', paused: false, createdAt: 1, ...root }; + this.rootsList.push(full); + this.trees.set(full.id, new Map()); + return full; + } + + private tree(rootId: string): Map { + const tree = this.trees.get(rootId); + if (!tree) throw new Error(`no root ${rootId}`); + return tree; + } + + private key(path: string): string { + return this.options.caseInsensitive ? path.toLowerCase() : path; + } + + /** The stored path for `path`, as cased on disk. */ + private find(rootId: string, path: string): string | null { + const tree = this.tree(rootId); + if (tree.has(path)) return path; + if (!this.options.caseInsensitive) return null; + const wanted = this.key(path); + for (const stored of tree.keys()) if (this.key(stored) === wanted) return stored; + return null; + } + + private tick(): number { + this.clock += 1000; + return this.clock; + } + + private parentOk(rootId: string, path: string): boolean { + const slash = path.lastIndexOf('/'); + if (slash < 0) return true; + const parent = this.find(rootId, path.slice(0, slash)); + return !!parent && this.tree(rootId).get(parent)?.kind === 'directory'; + } + + /** A user writes a file: a new one, or over the old one in place (same file id). */ + write(rootId: string, path: string, content: string | Uint8Array): void { + const data = typeof content === 'string' ? text(content) : content; + this.ensureParents(rootId, path); + const tree = this.tree(rootId); + const stored = this.find(rootId, path); + const old = stored ? tree.get(stored) : undefined; + const node: DiskNode = { kind: 'file', data, size: data.length, fileId: old?.fileId ?? `f${fileIds++}`, mtimeMs: this.tick() }; + if (old?.placeholder) { + // Saved over with a plain write: no longer a placeholder. + node.placeholder = { identity: null, inSync: false, hydrated: true, pin: 'unspecified' }; + } else if (this.isDrive(rootId)) { + node.placeholder = { identity: null, inSync: false, hydrated: true, pin: 'unspecified' }; + } + tree.set(stored ?? path, node); + } + + /** An app that keeps the placeholder edits a hydrated drive file: no longer in sync. */ + editInPlace(rootId: string, path: string, content: string): void { + const node = this.node(rootId, path); + node.data = text(content); + node.size = node.data.length; + node.mtimeMs = this.tick(); + if (node.placeholder) node.placeholder = { ...node.placeholder, inSync: false, hydrated: true }; + } + + mkdirUser(rootId: string, path: string): void { + this.ensureParents(rootId, `${path}/x`); + } + + private ensureParents(rootId: string, path: string): void { + const parts = path.split('/'); + const tree = this.tree(rootId); + for (let i = 1; i < parts.length; i += 1) { + const dir = parts.slice(0, i).join('/'); + if (!this.find(rootId, dir)) { + tree.set(dir, { + kind: 'directory', data: null, size: 0, fileId: `f${fileIds++}`, mtimeMs: this.tick(), + ...(this.isDrive(rootId) ? { placeholder: { identity: null, inSync: false, hydrated: true, pin: 'unspecified' as const } } : {}), + }); + } + } + } + + moveUser(rootId: string, from: string, to: string): void { + const result = this.moveTree(rootId, from, to); + if (!result) throw new Error(`move ${from} -> ${to} failed`); + } + + deleteUser(rootId: string, path: string): void { + const tree = this.tree(rootId); + const stored = this.find(rootId, path) ?? path; + for (const key of [...tree.keys()]) if (key === stored || key.startsWith(`${stored}/`)) tree.delete(key); + } + + node(rootId: string, path: string): DiskNode { + const stored = this.find(rootId, path); + const node = stored ? this.tree(rootId).get(stored) : undefined; + if (!node) throw new Error(`nothing at ${path}`); + return node; + } + + has(rootId: string, path: string): boolean { + return this.find(rootId, path) === path; + } + + read(rootId: string, path: string): string { + return str(this.node(rootId, path).data); + } + + /** Every path in a root, sorted, folders with a trailing slash. */ + listing(rootId: string): string[] { + return [...this.tree(rootId)].map(([path, node]) => (node.kind === 'directory' ? `${path}/` : path)).sort(); + } + + emit(rootId: string, paths: string[] | null = null): void { + for (const listener of [...this.listeners]) listener({ rootId, paths }); + } + + private isDrive(rootId: string): boolean { + return this.rootsList.find((root) => root.id === rootId)?.kind === 'drive'; + } + + private active(rootId: string): boolean { + const root = this.rootsList.find((entry) => entry.id === rootId); + return !this.locked && !!root && !root.paused; + } + + private entry(path: string, node: DiskNode): FilesLocalEntry { + return { + path, + kind: node.kind, + size: node.kind === 'directory' ? 0 : node.size, + mtimeMs: node.mtimeMs, + fileId: node.fileId, + ...(node.placeholder ? { placeholder: { ...node.placeholder } } : {}), + }; + } + + private matches(node: DiskNode | undefined, expect: FilesExpect | null): boolean { + if (expect === null) return !node; + if (!node) return false; + return node.fileId === expect.fileId && (node.kind === 'directory' ? 0 : node.size) === expect.size && node.mtimeMs === expect.mtimeMs; + } + + private moveTree(rootId: string, from: string, to: string): DiskNode | null { + const tree = this.tree(rootId); + const source = this.find(rootId, from); + if (!source) return null; + const target = this.find(rootId, to); + if (target && this.key(target) !== this.key(source)) return null; + if (!this.parentOk(rootId, to)) return null; + const moving = [...tree].filter(([key]) => key === source || key.startsWith(`${source}/`)); + for (const [key] of moving) tree.delete(key); + for (const [key, node] of moving) tree.set(to + key.slice(source.length), node); + return tree.get(to) ?? null; + } + + // ─── FilesControls ────────────────────────────────────────── + + async roots(): Promise { + return this.locked ? [] : this.rootsList.map((root) => ({ ...root })); + } + + async addFolder(request: { account: string; remoteId: string | null; remoteLabel: string }): Promise> { + this.calls.push(`addFolder ${request.account} ${request.remoteId}`); + const root = this.addRoot({ id: `r${this.rootsList.length + 1}`, account: request.account, remoteId: request.remoteId, remoteLabel: request.remoteLabel }); + return ok(root); + } + + async removeRoot(rootId: string): Promise { + this.calls.push(`removeRoot ${rootId}`); + const at = this.rootsList.findIndex((root) => root.id === rootId); + if (at >= 0) this.rootsList.splice(at, 1); + } + + async setPaused(rootId: string, paused: boolean): Promise { + const root = this.rootsList.find((entry) => entry.id === rootId); + if (root) root.paused = paused; + if (!paused) this.emit(rootId, null); + } + + async scan(rootId: string): Promise> { + if (!this.active(rootId)) return fail('inactive'); + return ok([...this.tree(rootId)].map(([path, node]) => this.entry(path, node))); + } + + async stat(rootId: string, path: string): Promise> { + if (!this.active(rootId)) return fail('inactive'); + const stored = this.find(rootId, path); + return ok(stored ? this.entry(stored, this.tree(rootId).get(stored) as DiskNode) : null); + } + + async openRead(rootId: string, path: string): Promise> { + if (!this.active(rootId)) return fail('inactive'); + const stored = this.find(rootId, path); + const node = stored ? this.tree(rootId).get(stored) : undefined; + if (!node) return fail('missing'); + if (node.kind !== 'file' || !node.data || (node.placeholder && !node.placeholder.hydrated)) return fail('unsupported'); + const token = `mem://${this.reads.size + 1}-${Math.random().toString(36).slice(2)}`; + this.reads.set(token, { rootId, path: stored as string }); + return ok({ url: token, entry: this.entry(stored as string, node) }); + } + + /** What the page's fetch of a read URL gets. */ + readUrl = async (url: string): Promise => { + const grant = this.reads.get(url); + this.reads.delete(url); + if (!grant) throw new Error('404'); + const node = this.node(grant.rootId, grant.path); + const data = node.data as Uint8Array; + this.onRead?.(grant.rootId, grant.path); + return data; + }; + + async beginWrite(rootId: string, path: string, options: { expect: FilesExpect | null; mtimeMs: number; size: number }): Promise> { + if (!this.active(rootId)) return fail('inactive'); + if (!this.parentOk(rootId, path)) return fail('missing'); + const stored = this.find(rootId, path); + const node = stored ? this.tree(rootId).get(stored) : undefined; + if (node?.kind === 'directory') return fail('exists'); + if (!this.matches(node, options.expect)) return fail(options.expect ? 'changed' : 'exists'); + const id = `w${this.nextWrite++}`; + this.writes.set(id, { rootId, path, expect: options.expect, mtimeMs: options.mtimeMs, size: options.size, parts: [] }); + return ok(id); + } + + async writeChunk(writeId: string, data: Uint8Array): Promise> { + const write = this.writes.get(writeId); + if (!write) return fail('missing'); + write.parts.push(new Uint8Array(data)); + return ok(null); + } + + async commitWrite(writeId: string): Promise> { + const write = this.writes.get(writeId); + this.writes.delete(writeId); + if (!write) return fail('missing'); + if (!this.active(write.rootId)) return fail('inactive'); + const size = write.parts.reduce((sum, part) => sum + part.length, 0); + if (size !== write.size) return fail('failed'); + const stored = this.find(write.rootId, write.path); + const tree = this.tree(write.rootId); + const old = stored ? tree.get(stored) : undefined; + if (!this.matches(old, write.expect)) return fail(write.expect ? 'changed' : 'exists'); + const data = new Uint8Array(size); + let at = 0; + for (const part of write.parts) { + data.set(part, at); + at += part.length; + } + const node: DiskNode = { kind: 'file', data, size, fileId: `f${fileIds++}`, mtimeMs: write.mtimeMs }; + tree.set(stored ?? write.path, node); + return ok(this.entry(stored ?? write.path, node)); + } + + async abortWrite(writeId: string): Promise { + this.writes.delete(writeId); + } + + get openWrites(): number { + return this.writes.size; + } + + async mkdir(rootId: string, path: string): Promise> { + if (!this.active(rootId)) return fail('inactive'); + if (!this.parentOk(rootId, path)) return fail('missing'); + const stored = this.find(rootId, path); + const tree = this.tree(rootId); + if (stored) { + const node = tree.get(stored) as DiskNode; + return node.kind === 'directory' ? ok(this.entry(stored, node)) : fail('exists'); + } + const node: DiskNode = { kind: 'directory', data: null, size: 0, fileId: `f${fileIds++}`, mtimeMs: this.tick() }; + tree.set(path, node); + return ok(this.entry(path, node)); + } + + async move(rootId: string, from: string, to: string, expect: FilesExpect | null): Promise> { + if (!this.active(rootId)) return fail('inactive'); + const source = this.find(rootId, from); + if (!source) return fail('missing'); + const node = this.tree(rootId).get(source) as DiskNode; + if (expect && !this.matches(node, expect)) return fail('changed'); + const target = this.find(rootId, to); + if (target && this.key(target) !== this.key(source)) return fail('exists'); + if (!this.parentOk(rootId, to)) return fail('missing'); + const moved = this.moveTree(rootId, source, to); + return moved ? ok(this.entry(to, moved)) : fail('failed'); + } + + async remove(rootId: string, path: string, expect: FilesExpect | null): Promise> { + if (!this.active(rootId)) return fail('inactive'); + const stored = this.find(rootId, path); + if (!stored) return fail('missing'); + const node = this.tree(rootId).get(stored) as DiskNode; + if (expect && !this.matches(node, expect)) return fail('changed'); + this.deleteUser(rootId, stored); + this.trashed.push(stored); + return ok(this.isDrive(rootId) ? 'deleted' : 'trash'); + } + + onChange(listener: (event: FilesChangeEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async loadState(rootId: string): Promise { + if (this.locked) throw new Error('Locked'); + return this.states.get(rootId) ?? null; + } + + async saveState(rootId: string, state: string): Promise { + if (this.locked) throw new Error('Locked'); + if (!this.rootsList.some((root) => root.id === rootId)) throw new Error('No such root'); + this.states.set(rootId, state); + } + + async showInFolder(rootId: string, path: string): Promise { + this.shown.push({ rootId, path }); + return true; + } + + // ─── The drive ────────────────────────────────────────────── + + /** Windows asks for a file someone opened. */ + requestFetch(rootId: string, path: string, offset = 0): FilesFetchRequest { + const node = this.node(rootId, path); + const fetchId = `fetch${this.supplied.size + this.fetchFailed.size + 1}-${Math.random().toString(36).slice(2, 6)}`; + const start = Math.floor(offset / 4096) * 4096; + const request: FilesFetchRequest = { fetchId, rootId, identity: node.placeholder?.identity ?? '', path, offset: start, length: node.size - start, total: node.size }; + this.supplied.set(fetchId, { offset: start, length: 0, parts: [] }); + for (const listener of [...this.fetchListeners]) listener(request); + return request; + } + + /** Everything supplied for a fetch, in one piece. */ + suppliedBytes(fetchId: string): Uint8Array { + const got = this.supplied.get(fetchId); + const out = new Uint8Array(got?.length ?? 0); + let at = 0; + for (const part of got?.parts ?? []) { + out.set(part, at); + at += part.length; + } + return out; + } + + cancelFetch(fetchId: string): void { + for (const listener of [...this.cancelListeners]) listener(fetchId); + } + + private makeDrive(): FilesDriveControls { + // The bridge hands these out as plain functions, as the preload does. + // eslint-disable-next-line @typescript-eslint/no-this-alias + const disk = this; + const record = (call: string, ...args: unknown[]) => disk.driveCalls.push({ call, args }); + const state = (): FilesDriveState => ({ supported: true, forcedOff: false, wanted: [...disk.driveWanted], letter: { letter: null, chosen: null, locked: false, available: ['E'] } }); + return { + state: async () => state(), + onStateChange: () => () => {}, + enable: async (account, label) => { + record('enable', account, label); + if (!disk.driveWanted.includes(account)) disk.driveWanted.push(account); + const existing = disk.rootsList.find((root) => root.kind === 'drive' && root.account === account); + return ok(existing ?? disk.addRoot({ id: `drive-${account}`, account, kind: 'drive', remoteId: null, remoteLabel: label })); + }, + disable: async (account, reason) => { + record('disable', account, reason); + if (reason !== 'signed-out') disk.driveWanted = disk.driveWanted.filter((entry) => entry !== account); + const at = disk.rootsList.findIndex((root) => root.kind === 'drive' && root.account === account); + if (at >= 0) { + const [root] = disk.rootsList.splice(at, 1); + disk.trees.set(root.id, new Map()); + } + }, + place: async (rootId, entries: FilesPlaceholder[]) => { + record('place', rootId, entries.length); + if (!disk.active(rootId)) return fail('inactive'); + const tree = disk.tree(rootId); + const answers = entries.map((entry) => { + if (!disk.parentOk(rootId, entry.path)) return { path: entry.path, error: 'missing' as const }; + if (disk.find(rootId, entry.path)) return { path: entry.path, error: 'exists' as const }; + tree.set(entry.path, { + kind: entry.kind, + data: null, + size: entry.kind === 'file' ? entry.size : 0, + fileId: `f${fileIds++}`, + mtimeMs: entry.mtimeMs, + placeholder: { identity: entry.identity, inSync: true, hydrated: entry.kind === 'directory', pin: 'unspecified' }, + }); + return { path: entry.path }; + }); + return ok(answers); + }, + update: async (rootId, path, meta, expect) => { + record('update', rootId, path, meta.dehydrate); + if (!disk.active(rootId)) return fail('inactive'); + const stored = disk.find(rootId, path); + const node = stored ? disk.tree(rootId).get(stored) : undefined; + if (!node) return fail('missing'); + if (expect && !disk.matches(node, expect)) return fail('changed'); + if (!node.placeholder?.identity || !node.placeholder.inSync) return fail('changed'); + node.size = meta.size; + node.mtimeMs = meta.mtimeMs; + node.placeholder = { ...node.placeholder, identity: meta.identity, inSync: true }; + if (meta.dehydrate) { + node.data = null; + node.placeholder.hydrated = false; + } + return ok(disk.entry(stored as string, node)); + }, + markInSync: async (rootId, path, identity, expect) => { + record('markInSync', rootId, path, identity); + if (!disk.active(rootId)) return fail('inactive'); + const stored = disk.find(rootId, path); + const node = stored ? disk.tree(rootId).get(stored) : undefined; + if (!node) return fail('missing'); + if (!disk.matches(node, expect)) return fail('changed'); + node.placeholder = { identity, inSync: true, hydrated: true, pin: node.placeholder?.pin ?? 'unspecified' }; + return ok(disk.entry(stored as string, node)); + }, + onFetch: (listener) => { + disk.fetchListeners.add(listener); + return () => disk.fetchListeners.delete(listener); + }, + onFetchCancel: (listener) => { + disk.cancelListeners.add(listener); + return () => disk.cancelListeners.delete(listener); + }, + supply: async (fetchId, offset, data) => { + const got = disk.supplied.get(fetchId); + if (!got) return false; + if (offset !== got.offset + got.length) return false; + if (disk.refuseSupplyAfter !== null && got.length >= disk.refuseSupplyAfter) return false; + got.parts.push(new Uint8Array(data)); + got.length += data.length; + return true; + }, + fail: (fetchId, error) => { + disk.fetchFailed.set(fetchId, error); + }, + setLetter: async (letter) => { + record('setLetter', letter); + return ok({ letter, chosen: letter, locked: false, available: ['E'] }); + }, + }; + } +} diff --git a/lib/files-sync/__tests__/helpers/fake-server.ts b/lib/files-sync/__tests__/helpers/fake-server.ts new file mode 100644 index 000000000..e03340285 --- /dev/null +++ b/lib/files-sync/__tests__/helpers/fake-server.ts @@ -0,0 +1,247 @@ +/** + * Files on a fake server for the engine: lib/store's FakeFiles (FileNode/get, query and changes + * as both Stalwarts answer them) with FileNode/set and uploads added, as Stalwart does them: + * - a folder is a node without blobId; a node can only go under a folder; + * - two siblings with the same name are refused (`alreadyExists`); + * - a destroy with onDestroyRemoveChildren takes the subtree; + * - an upload over maxSizeUpload is an HTTP 413; + * - the session's filenode capability names forbidden characters (0.16.6+ style). + */ + +import { TransportError, type StoreSession } from '@/lib/store/engine/transport'; +import { FakeFiles, FILES, FILES_SESSION, type FakeFilesOptions, type FakeNode } from '@/lib/store/__tests__/helpers/fake-files'; +import { JMAP_ACCOUNT } from '@/lib/store/__tests__/helpers/fake-jmap'; +import { FilesRemote } from '../../remote'; + +export { JMAP_ACCOUNT }; + +export interface FakeServerOptions extends FakeFilesOptions { + maxSizeUpload?: number; + forbiddenNameChars?: string; +} + +export class FakeServer extends FakeFiles { + readonly session: StoreSession; + readonly sets: Record[] = []; + readonly uploads: number[] = []; + /** Fails the next N uploads like a network drop. */ + failUploads = 0; + private serverClock = Date.UTC(2026, 9, 1); + private nextNodeId = 1000; + private nextBlob = 1; + + constructor(options: FakeServerOptions = {}) { + super(options); + this.session = { + ...FILES_SESSION, + capabilities: { + ...FILES_SESSION.capabilities, + 'urn:ietf:params:jmap:core': { maxSizeUpload: options.maxSizeUpload ?? 50_000_000, maxObjectsInGet: 500, maxObjectsInSet: 500, maxCallsInRequest: 16 }, + }, + accounts: { + ...FILES_SESSION.accounts, + [JMAP_ACCOUNT]: { + ...FILES_SESSION.accounts[JMAP_ACCOUNT], + accountCapabilities: { [FILES]: { forbiddenNameChars: options.forbiddenNameChars ?? '/', forbiddenNodeNames: ['.', '..'], maxSizeFileNodeName: 255 } }, + }, + }, + }; + } + + remote(): FilesRemote { + return new FilesRemote(this, () => this.session); + } + + private stamp(): string { + this.serverClock += 1000; + return new Date(this.serverClock).toISOString(); + } + + get nodes(): Map { + return this.account().nodes; + } + + /** What another device does in Files. */ + folder(name: string, parentId: string | null = null): FakeNode { + const node = this.addFolder(name, parentId, `n${this.nextNodeId++}`); + node.modified = this.stamp(); + return node; + } + + file(name: string, parentId: string | null, content: string | Uint8Array): FakeNode { + const data = typeof content === 'string' ? new TextEncoder().encode(content) : content; + const node = this.addFile(name, parentId, data, { id: `n${this.nextNodeId++}`, blobId: `b${this.nextBlob++}`, modified: this.stamp() }); + return node; + } + + edit(id: string, content: string): void { + const data = new TextEncoder().encode(content); + const blobId = `b${this.nextBlob++}`; + this.blobs.set(blobId, data); + const node = this.nodes.get(id) as FakeNode; + node.blobId = blobId; + node.size = data.length; + node.modified = this.stamp(); + this.account().coll.push('updated', id); + } + + rename(id: string, name: string, parentId?: string | null): void { + const node = this.nodes.get(id) as FakeNode; + node.name = name; + if (parentId !== undefined) node.parentId = parentId; + this.account().coll.push('updated', id); + } + + destroyTree(id: string): void { + for (const child of [...this.nodes.values()].filter((node) => node.parentId === id)) this.destroyTree(child.id); + this.removeNode(id); + } + + /** The content of a node, as text. */ + content(id: string): string { + const node = this.nodes.get(id); + return node?.blobId ? new TextDecoder().decode(this.blobs.get(node.blobId)) : ''; + } + + /** Path from the top to each node, sorted, folders with a slash. Without the trash unless asked. */ + listing(withTrash = false): string[] { + const out: string[] = []; + const pathOf = (node: FakeNode): string | null => { + const parts = [node.name]; + let parent = node.parentId; + while (parent) { + const up = this.nodes.get(parent); + if (!up) return null; + parts.unshift(up.name); + parent = up.parentId; + } + return parts.join('/'); + }; + for (const node of this.nodes.values()) { + const path = pathOf(node); + if (path === null) continue; + if (!withTrash && (path === '.Trash' || path.startsWith('.Trash/'))) continue; + out.push(node.blobId === null ? `${path}/` : path); + } + return out.sort(); + } + + find(path: string): FakeNode | undefined { + const parts = path.split('/'); + let parent: string | null = null; + let found: FakeNode | undefined; + for (const part of parts) { + found = [...this.nodes.values()].find((node) => node.parentId === parent && node.name === part); + if (!found) return undefined; + parent = found.id; + } + return found; + } + + override async upload(_jmap?: string, data?: Uint8Array, type?: string): Promise<{ blobId: string; type: string; size: number }> { + if (this.offline) throw new TransportError('network', 'Failed to fetch'); + if (this.failUploads > 0) { + this.failUploads -= 1; + throw new TransportError('network', 'Connection reset'); + } + const bytes = data ?? new Uint8Array(); + const max = (this.session.capabilities['urn:ietf:params:jmap:core'] as { maxSizeUpload: number }).maxSizeUpload; + if (bytes.length > max) throw new TransportError('http', 'Upload failed: 413', 413); + const blobId = `up${this.nextBlob++}`; + this.blobs.set(blobId, new Uint8Array(bytes)); + this.uploads.push(bytes.length); + return { blobId, type: type ?? 'application/octet-stream', size: bytes.length }; + } + + protected override handle(name: string, args: Record): Record { + if (name !== 'FileNode/set') return super.handle(name, args); + this.sets.push(args); + const account = this.account(String(args.accountId)); + const nodes = account.nodes; + const forbidden = String(((this.session.accounts[JMAP_ACCOUNT].accountCapabilities as Record>)[FILES]).forbiddenNameChars ?? ''); + const clash = (parentId: string | null, nodeName: string, except?: string) => + [...nodes.values()].some((node) => node.parentId === parentId && node.name === nodeName && node.id !== except); + const isFolder = (id: string | null) => id === null || nodes.get(id)?.blobId === null; + const created: Record = {}; + const notCreated: Record = {}; + for (const [key, raw] of Object.entries((args.create ?? {}) as Record>)) { + const parentId = (raw.parentId as string | undefined) ?? null; + const nodeName = String(raw.name ?? ''); + if (!nodeName || [...nodeName].some((ch) => forbidden.includes(ch))) { + notCreated[key] = { type: 'invalidProperties', description: 'Name contains a forbidden character.' }; + continue; + } + if (parentId !== null && !nodes.has(parentId)) { + notCreated[key] = { type: 'invalidProperties', description: 'Parent ID does not exist or is not a folder.' }; + continue; + } + if (!isFolder(parentId)) { + notCreated[key] = { type: 'invalidProperties', description: 'Parent ID does not exist or is not a folder.' }; + continue; + } + if (clash(parentId, nodeName)) { + notCreated[key] = { type: 'alreadyExists', description: 'A node with the same name already exists.' }; + continue; + } + const id = `n${this.nextNodeId++}`; + const blobId = (raw.blobId as string | undefined) ?? null; + const type = (raw.type as string | undefined) ?? (raw.mediaType as string | undefined) ?? null; + const node: FakeNode = { id, parentId, name: nodeName, blobId, type: blobId ? type : null, size: blobId ? (this.blobs.get(blobId)?.length ?? 0) : null, modified: this.stamp() }; + nodes.set(id, node); + account.coll.push('created', id); + created[key] = { id }; + } + const updated: Record = {}; + const notUpdated: Record = {}; + for (const [id, patch] of Object.entries((args.update ?? {}) as Record>)) { + const node = nodes.get(id); + if (!node) { + notUpdated[id] = { type: 'notFound' }; + continue; + } + const parentId = 'parentId' in patch ? ((patch.parentId as string | null) ?? null) : node.parentId; + const nodeName = 'name' in patch ? String(patch.name) : node.name; + if ('parentId' in patch && !isFolder(parentId)) { + notUpdated[id] = { type: 'invalidProperties', description: 'Parent ID does not exist or is not a folder.' }; + continue; + } + if (clash(parentId, nodeName, id)) { + notUpdated[id] = { type: 'alreadyExists' }; + continue; + } + node.parentId = parentId; + node.name = nodeName; + if ('blobId' in patch) { + node.blobId = patch.blobId as string; + node.size = this.blobs.get(node.blobId)?.length ?? 0; + } + node.modified = this.stamp(); + account.coll.push('updated', id); + updated[id] = null; + } + const destroyed: string[] = []; + const notDestroyed: Record = {}; + for (const id of (args.destroy ?? []) as string[]) { + if (!nodes.has(id)) { + notDestroyed[id] = { type: 'notFound' }; + continue; + } + if (!args.onDestroyRemoveChildren && [...nodes.values()].some((node) => node.parentId === id)) { + notDestroyed[id] = { type: 'nodeHasChildren' }; + continue; + } + this.destroyTree(id); + destroyed.push(id); + } + return { + accountId: args.accountId, + newState: account.coll.state(), + created, + notCreated, + updated, + notUpdated, + destroyed, + notDestroyed, + }; + } +} diff --git a/lib/files-sync/__tests__/host.test.ts b/lib/files-sync/__tests__/host.test.ts new file mode 100644 index 000000000..d37b77657 --- /dev/null +++ b/lib/files-sync/__tests__/host.test.ts @@ -0,0 +1,225 @@ +/** + * The host of Files on this computer: claiming the bridge once, the accounts it runs for, what + * it publishes, the controller, and what sign-out, a wipe and a refused login do. + */ +import { render } from '@testing-library/react'; +import { createElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FilesControls } from '@/lib/desktop/types'; +import { WindowControlsHost } from '@/components/layout/window-controls-host'; +import { noteCredentialsRejected } from '@/lib/offline/credentials-rejected'; +import { runWipeCleaners } from '@/lib/offline/wipe-registry'; +import { runBeforeSignOut } from '@/lib/sign-out-cleanup'; +import type { FilesSyncController, FilesSyncSnapshot } from '../types'; +import { claimFilesControls, resetFilesControlsForTests } from '../controls'; +import { FilesSyncHost, type HostAccount } from '../host'; +import { registerFilesSyncLifecycle, resetFilesSyncLifecycleForTests, setRunningFilesSyncHost } from '../lifecycle'; +import { FakeDisk } from './helpers/fake-disk'; +import { FakeServer } from './helpers/fake-server'; + +const FAST = { localDelayMs: 0, remoteDelayMs: 0, pollMs: 1e9, rescanMs: 1e9, retryMs: [1e9], soonMs: 5, fetchWaitMs: 1000 }; + +function fakeBridge(controls: FilesControls) { + const claim = vi.fn<() => FilesControls | null>(); + let taken = false; + claim.mockImplementation(() => { + if (taken) return null; + taken = true; + return controls; + }); + return { version: 1, platform: 'win32' as const, files: { claim } }; +} + +describe('claiming the bridge', () => { + afterEach(() => { + delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; + resetFilesControlsForTests(); + }); + + it('takes the controls in the first render, once, so a frame coming later gets nothing', () => { + const controls = new FakeDisk(); + const bridge = fakeBridge(controls); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = bridge; + const { rerender } = render(createElement(WindowControlsHost)); + rerender(createElement(WindowControlsHost)); + expect(bridge.files.claim).toHaveBeenCalledTimes(1); + expect(claimFilesControls()).toBe(controls); + // A same-origin frame reaching the bridge through parent. + expect(bridge.files.claim()).toBeNull(); + }); + + it('takes nothing in a browser', () => { + render(createElement(WindowControlsHost)); + expect(claimFilesControls()).toBeNull(); + }); +}); + +interface Rig { + host: FilesSyncHost; + disk: FakeDisk; + server: FakeServer; + snapshot: () => Partial; + controller: () => FilesSyncController | null; + folderOff: { value: boolean }; + account: HostAccount; +} + +function rig(options: { drive?: boolean; wanted?: string[] } = {}): Rig { + const disk = new FakeDisk({ caseInsensitive: true, drive: options.drive }); + disk.driveWanted = options.wanted ?? []; + const server = new FakeServer(); + let published: Partial = {}; + let controller: FilesSyncController | null = null; + const folderOff = { value: false }; + const host = new FilesSyncHost({ + controls: disk, + platform: 'win32', + publish: (patch) => { + published = { ...published, ...patch }; + }, + setController: (next) => { + controller = next; + }, + folderSyncForcedOff: () => folderOff.value, + downloadsForbidden: () => false, + remoteFor: () => server.remote(), + readUrl: disk.readUrl, + timing: FAST, + }); + const account: HostAccount = { id: 'a1', label: 'a@example.org', client: { getSessionSnapshot: () => server.session, getAuthHeader: () => 'Basic x' } }; + return { host, disk, server, snapshot: () => published, controller: () => controller, folderOff, account }; +} + +async function settle(host: FilesSyncHost): Promise { + await new Promise((resolve) => setTimeout(resolve, 10)); + await host.engine.settled(); + await new Promise((resolve) => setTimeout(resolve, 60)); +} + +describe('the host', () => { + it('runs the roots of signed-in accounts, publishes the current one, and registers the controller', async () => { + const r = rig(); + r.disk.addRoot({ id: 'r1', account: 'a1', remoteId: null }); + r.disk.addRoot({ id: 'r2', account: 'other', remoteId: null }); + r.disk.write('r1', 'a.txt', 'a'); + r.host.start(); + r.host.setAccounts([r.account], 'a1'); + await settle(r.host); + expect(r.server.listing()).toEqual(['a.txt']); + expect(r.snapshot()).toMatchObject({ available: true, account: 'a1', folderSyncForcedOff: false }); + expect(r.snapshot().roots?.map((root) => root.id)).toEqual(['r1']); + expect(r.snapshot().status?.r1).toMatchObject({ state: 'idle' }); + expect(r.snapshot().activity?.[0]).toMatchObject({ kind: 'uploaded', path: 'a.txt' }); + expect(Object.values(r.snapshot().items ?? {})).toEqual(['synced']); + expect(r.controller()).not.toBeNull(); + r.host.stop(); + expect(r.controller()).toBeNull(); + }); + + it('keeps folders out of sync while the policy forbids them', async () => { + const r = rig(); + r.folderOff.value = true; + r.disk.addRoot({ id: 'r1', account: 'a1', remoteId: null }); + r.disk.write('r1', 'a.txt', 'a'); + r.host.start(); + r.host.setAccounts([r.account], 'a1'); + await settle(r.host); + expect(r.server.listing()).toEqual([]); + expect(r.snapshot().status?.r1).toMatchObject({ state: 'blocked', errorKey: 'errors.policy' }); + expect(r.snapshot().folderSyncForcedOff).toBe(true); + r.host.stop(); + }); + + it('turns the drive back on at sign-in without a click, and off at sign-out keeping the wish', async () => { + const r = rig({ drive: true, wanted: ['a1'] }); + r.server.file('a.txt', null, 'a'); + r.host.start(); + r.host.setAccounts([r.account], 'a1'); + await settle(r.host); + await settle(r.host); + expect(r.disk.driveCalls[0]).toEqual({ call: 'enable', args: ['a1', 'a@example.org'] }); + expect(r.disk.listing('drive-a1')).toEqual(['a.txt']); + expect(r.disk.states.get('drive-a1')).toContain('a.txt'); + await r.host.signedOut('a1'); + expect(r.disk.driveCalls.at(-1)).toEqual({ call: 'disable', args: ['a1', 'signed-out'] }); + expect(r.disk.driveWanted).toEqual(['a1']); + // The record went before the folder did: the next sign-in starts from an empty one. + expect(r.disk.states.get('drive-a1')).not.toContain('a.txt'); + r.host.stop(); + }); + + it('removes the drive and forgets the folders of a wiped account, and keeps their files', async () => { + const r = rig({ drive: true, wanted: ['a1', 'a2'] }); + r.disk.addRoot({ id: 'r1', account: 'a1', remoteId: null }); + r.disk.addRoot({ id: 'r2', account: 'a2', remoteId: null }); + r.disk.write('r1', 'mine.txt', 'mine'); + r.host.start(); + await r.host.removeAccountData('a1', 'wipe'); + expect(r.disk.driveCalls).toContainEqual({ call: 'disable', args: ['a1', 'wipe'] }); + expect(r.disk.driveCalls.some((call) => call.args[0] === 'a2')).toBe(false); + expect(r.disk.calls).toEqual(['removeRoot r1']); + expect(r.disk.read('r1', 'mine.txt')).toBe('mine'); + r.host.stop(); + }); + + it('answers the controller: shared folders refused, the drive switch, show in folder', async () => { + const r = rig({ drive: true }); + r.disk.addRoot({ id: 'r1', account: 'a1', remoteId: null }); + r.disk.write('r1', 'Docs/a.txt', 'a'); + r.host.start(); + r.host.setAccounts([r.account], 'a1'); + await settle(r.host); + const controller = r.controller()!; + expect(await controller.addFolder('shared1:n5', 'Team')).toEqual({ ok: false, error: 'unsupported' }); + expect(r.disk.calls).toEqual([]); + const id = r.server.find('Docs/a.txt')!.id; + expect(controller.coverage(id)).toEqual({ rootId: 'r1', kind: 'folder', path: 'Docs/a.txt' }); + expect(await controller.showInFolder(id)).toBe(true); + expect(r.disk.shown).toEqual([{ rootId: 'r1', path: 'Docs/a.txt' }]); + expect(await controller.showInFolder('unknown')).toBe(false); + expect((await controller.setDriveEnabled(true)).ok).toBe(true); + expect(r.disk.driveCalls.at(-1)).toEqual({ call: 'enable', args: ['a1', 'a@example.org'] }); + expect(await controller.setDriveEnabled(false)).toEqual({ ok: true, value: null }); + expect(r.disk.driveCalls.at(-1)).toEqual({ call: 'disable', args: ['a1', 'user'] }); + r.host.stop(); + }); +}); + +describe('sign-out, wipe and refused logins', () => { + let r: Rig; + beforeEach(async () => { + resetFilesSyncLifecycleForTests(); + registerFilesSyncLifecycle(); + r = rig({ drive: true, wanted: ['a1'] }); + r.disk.addRoot({ id: 'r1', account: 'a1', remoteId: null }); + r.host.start(); + setRunningFilesSyncHost(r.host); + }); + afterEach(() => { + r.host.stop(); + resetFilesSyncLifecycleForTests(); + }); + + it('a wipe removes the drive and forgets the folders', async () => { + await runWipeCleaners('a1'); + expect(r.disk.driveCalls).toContainEqual({ call: 'disable', args: ['a1', 'wipe'] }); + expect(r.disk.calls).toContain('removeRoot r1'); + }); + + it('a refused login the server is sure of does the same, an unsure one nothing', async () => { + noteCredentialsRejected('a1', false, 'refused'); + noteCredentialsRejected('a1', true, 'session'); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(r.disk.driveCalls.filter((call) => call.call === 'disable')).toEqual([]); + noteCredentialsRejected('a1', true, 'refused'); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(r.disk.driveCalls).toContainEqual({ call: 'disable', args: ['a1', 'refused'] }); + expect(r.disk.calls).toContain('removeRoot r1'); + }); + + it('a sign-out takes the drive down and keeps the folders', async () => { + await runBeforeSignOut('a1'); + expect(r.disk.driveCalls).toContainEqual({ call: 'disable', args: ['a1', 'signed-out'] }); + expect(r.disk.calls).toEqual([]); + }); +}); diff --git a/lib/files-sync/__tests__/real-server.test.ts b/lib/files-sync/__tests__/real-server.test.ts new file mode 100644 index 000000000..245301733 --- /dev/null +++ b/lib/files-sync/__tests__/real-server.test.ts @@ -0,0 +1,225 @@ +// @vitest-environment node +/** + * The engine against a real Stalwart, with the in-memory disk. Skipped unless asked for: + * + * FILES_SYNC_REAL_URL=https://stalwart.internal.bulwarkmail.org FILES_SYNC_REAL_LABEL=0.16 \ + * vw exec --env FILES_SYNC_REAL_PASSWORD="vault:///password" -- npx vitest run lib/files-sync/__tests__/real-server.test.ts + * + * Uses the throwaway accounts dso-fs and dso-fs-b (VM 106 ~/desktop-offline/dso_fs_provision.py, + * which also seeds dso-fs's "Seed" folder; dso_fs_dav_seed.py adds names Windows can't hold over + * WebDAV). It signs in once per account and stops there if that's refused: nothing polls as a + * missing user. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { FetchTransport, type StoreSession } from '@/lib/store/engine/transport'; +import { FilesSyncEngine, type EngineTiming } from '../engine'; +import { namingFor } from '../names'; +import { FilesRemote, type RemoteNode } from '../remote'; +import { FakeDisk, str, text } from './helpers/fake-disk'; + +const URL_BASE = process.env.FILES_SYNC_REAL_URL ?? ''; +const LABEL = process.env.FILES_SYNC_REAL_LABEL ?? ''; +const PASSWORD = process.env.FILES_SYNC_REAL_PASSWORD ?? ''; +const run = !!URL_BASE && !!PASSWORD; +const FAST: Partial = { localDelayMs: 0, remoteDelayMs: 0, pollMs: 1e9, rescanMs: 1e9, retryMs: [1e9], soonMs: 50, fetchWaitMs: 30_000 }; + +function basic(user: string): string { + return `Basic ${Buffer.from(`${user}@example.org:${PASSWORD}`).toString('base64')}`; +} + +async function signIn(user: string): Promise { + const response = await fetch(`${URL_BASE}/jmap/session`, { headers: { Authorization: basic(user) } }); + if (response.status !== 200) throw new Error(`${user} on ${LABEL}: session ${response.status}; run dso_fs_provision.py first`); + const session = (await response.json()) as StoreSession; + const transport = new FetchTransport({ session: () => session, auth: () => basic(user), refreshAuth: async () => basic(user) }); + return new FilesRemote(transport, () => session); +} + +async function tree(remote: FilesRemote): Promise> { + return new Map((await remote.listAll()).nodes.map((node) => [node.id, node])); +} + +/** Paths below a folder, as Files has them, folders with a slash. */ +function pathsUnder(nodes: Map, folderId: string): string[] { + const out: string[] = []; + const walk = (parent: string, prefix: string) => { + for (const node of nodes.values()) { + if (node.parentId !== parent) continue; + const path = prefix ? `${prefix}/${node.name}` : node.name; + out.push(node.kind === 'directory' ? `${path}/` : path); + if (node.kind === 'directory') walk(node.id, path); + } + }; + walk(folderId, ''); + return out.sort(); +} + +function find(nodes: Map, parentId: string | null, name: string): RemoteNode | undefined { + return [...nodes.values()].find((node) => node.parentId === parentId && node.name === name); +} + +async function download(remote: FilesRemote, node: RemoteNode): Promise { + const parts: Uint8Array[] = []; + await remote.download(node, (part) => parts.push(new Uint8Array(part))); + return new Uint8Array(Buffer.concat(parts)); +} + +describe.runIf(run)(`files sync against Stalwart ${LABEL}`, () => { + let a: FilesRemote; + let b: FilesRemote; + let other: FilesRemote; + let testFolder: RemoteNode; + let disk: FakeDisk; + let engine: FilesSyncEngine; + const created: string[] = []; + + const sync = async () => { + await engine.refreshRoots(); + engine.syncNow(); + await engine.settled(); + }; + + beforeAll(async () => { + a = await signIn('dso-fs'); + b = await signIn('dso-fs-b'); + // "Another device" of dso-fs: its own session and transport. + other = await signIn('dso-fs'); + const nodes = await tree(a); + // What an earlier run left. + const old = [...nodes.values()].filter((node) => node.parentId === null && node.name.startsWith('Sync test ')); + if (old.length > 0) await a.destroy(old.map((node) => node.id)); + testFolder = await a.createFolder(`Sync test ${Date.now()}`, null); + created.push(testFolder.id); + disk = new FakeDisk({ caseInsensitive: true, drive: true }); + engine = new FilesSyncEngine({ controls: disk, naming: namingFor('win32'), device: 'Test', readUrl: disk.readUrl, timing: FAST }); + engine.start(); + engine.setAccount('fs', a); + engine.setAccount('fs-b', b); + }, 120_000); + + afterAll(async () => { + engine?.stop(); + if (a && created.length > 0) await a.destroy(created).catch(() => {}); + if (a) { + const nodes = await tree(a).catch(() => new Map()); + const trash = find(nodes, null, '.Trash'); + if (trash) await a.destroy([trash.id]).catch(() => {}); + } + }, 120_000); + + it('brings the seeded folder down whole, with names the disk can hold, and changes nothing in Files', async () => { + const before = await tree(a); + const seed = find(before, null, 'Seed'); + expect(seed, 'run dso_fs_provision.py create').toBeDefined(); + disk.addRoot({ id: 'seed', account: 'fs', remoteId: seed!.id }); + await sync(); + const listing = disk.listing('seed'); + expect(listing).toEqual(expect.arrayContaining([ + 'Documents/', 'Documents/Level 1/Level 2/deep note.md', 'Documents/Quarterly report 2026.txt', 'Empty folder/', + 'Pictures/photo 1.png', 'Ärger und Grüße/Übersicht März.txt', 'Large/large 1.bin', 'Large/large 2.bin', + 'Report.txt', 'report (2).txt', 'trailing dot._', + 'a:b.txt', 'what?.txt', 'quote".txt', 'pipe|star*.txt', 'less<more>.txt', 'back\slash.txt', + ])); + if (LABEL === '0.16') expect(listing).toContain('CON_.txt'); + expect(disk.read('seed', 'Ärger und Grüße/Übersicht März.txt')).toBe('Grüße aus Köln\n'); + const large = find(before, find(before, seed!.id, 'Large')!.id, 'large 2.bin')!; + expect(disk.node('seed', 'Large/large 2.bin').size).toBe(large.size); + expect(Buffer.from(disk.node('seed', 'Large/large 2.bin').data!).equals(Buffer.from(await download(a, large)))).toBe(true); + const after = await tree(a); + expect(pathsUnder(after, seed!.id)).toEqual(pathsUnder(before, seed!.id)); + expect(engine.status().seed.state).toBe('idle'); + await engine.removeRoot('seed'); + }, 180_000); + + it('sends what is made here, with a large file, and keeps one over the limit here', async () => { + disk.addRoot({ id: 'pair', account: 'fs', remoteId: testFolder.id }); + const big = new Uint8Array(3 * 1024 * 1024 + 11).map((_, i) => (i * 7) % 256); + disk.write('pair', 'Docs/Notes/plan.txt', 'the plan'); + disk.write('pair', 'Grüße/übung.txt', 'Umlaute'); + disk.write('pair', 'with space.txt', 'spaces'); + disk.write('pair', 'big.bin', big); + const limit = a.maxUpload(); + expect(limit).toBeGreaterThan(0); + disk.write('pair', 'too big.bin', new Uint8Array(limit + 1)); + await sync(); + const nodes = await tree(a); + expect(pathsUnder(nodes, testFolder.id)).toEqual(['Docs/', 'Docs/Notes/', 'Docs/Notes/plan.txt', 'Grüße/', 'Grüße/übung.txt', 'big.bin', 'with space.txt']); + expect(Buffer.from(await download(a, find(nodes, testFolder.id, 'big.bin')!)).equals(Buffer.from(big))).toBe(true); + expect(engine.activity.filter((entry) => entry.kind === 'too-large').map((entry) => entry.path)).toEqual(['too big.bin']); + }, 180_000); + + it('follows renames, moves, edits, conflicts and deletes both ways', async () => { + let nodes = await tree(a); + const plan = find(nodes, find(nodes, find(nodes, testFolder.id, 'Docs')!.id, 'Notes')!.id, 'plan.txt')!; + // Here: rename and move. + disk.moveUser('pair', 'Docs/Notes/plan.txt', 'Grüße/Plan.txt'); + await sync(); + nodes = await tree(a); + expect(find(nodes, find(nodes, testFolder.id, 'Grüße')!.id, 'Plan.txt')?.id).toBe(plan.id); + // Another device: rename a folder and edit a file. + const spaced = find(nodes, testFolder.id, 'with space.txt')!; + await other.move(find(nodes, testFolder.id, 'Docs')!.id, { name: 'Documents' }); + const blob = await other.upload(text('edited elsewhere'), 'text/plain'); + await other.replaceBlob(spaced.id, blob.blobId); + engine.remoteChanged('fs'); + await new Promise((resolve) => setTimeout(resolve, 200)); + await sync(); + expect(disk.listing('pair')).toEqual(expect.arrayContaining(['Documents/', 'Documents/Notes/'])); + expect(disk.read('pair', 'with space.txt')).toBe('edited elsewhere'); + // Both sides edit one file. + disk.write('pair', 'with space.txt', 'edited here, longer'); + const blob2 = await other.upload(text('edited there'), 'text/plain'); + await other.replaceBlob(spaced.id, blob2.blobId); + await sync(); + const copy = disk.listing('pair').find((path) => path.startsWith('with space (conflict, Test, '))!; + expect(copy).toBeDefined(); + expect(disk.read('pair', 'with space.txt')).toBe('edited there'); + nodes = await tree(a); + expect(str(await download(a, find(nodes, testFolder.id, copy)!))).toBe('edited here, longer'); + // Deleted here: into the Files trash. + disk.deleteUser('pair', 'big.bin'); + await sync(); + nodes = await tree(a); + expect(find(nodes, testFolder.id, 'big.bin')).toBeUndefined(); + const trash = find(nodes, null, '.Trash')!; + expect(pathsUnder(nodes, trash.id).some((path) => path.endsWith('/big.bin'))).toBe(true); + // Deleted in Files: into the computer's trash. + await other.destroy([find(nodes, testFolder.id, 'Grüße')!.id]); + engine.remoteChanged('fs'); + await new Promise((resolve) => setTimeout(resolve, 200)); + await sync(); + expect(disk.trashed).toContain('Grüße'); + expect(disk.listing('pair').some((path) => path.startsWith('Grüße'))).toBe(false); + }, 240_000); + + it('places the drive for the whole account and streams a file from where Windows asks', async () => { + disk.addRoot({ id: 'drive', account: 'fs', kind: 'drive', remoteId: null }); + await sync(); + const listing = disk.listing('drive'); + expect(listing).toEqual(expect.arrayContaining(['Seed/', 'Seed/Large/large 2.bin', 'Seed/Pictures/photo 2.png'])); + expect(listing.some((path) => path.startsWith('.Trash'))).toBe(false); + expect(disk.node('drive', 'Seed/Large/large 2.bin').placeholder).toMatchObject({ hydrated: false, inSync: true }); + const request = disk.requestFetch('drive', 'Seed/Large/large 2.bin', 1024 * 1024 + 5); + const nodes = await tree(a); + const node = find(nodes, find(nodes, find(nodes, null, 'Seed')!.id, 'Large')!.id, 'large 2.bin')!; + const whole = await download(a, node); + for (let i = 0; i < 300 && disk.suppliedBytes(request.fetchId).length < node.size - request.offset; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + const got = disk.suppliedBytes(request.fetchId); + expect(request.offset).toBe(1024 * 1024); + expect(got.length).toBe(node.size - request.offset); + expect(Buffer.from(got).equals(Buffer.from(whole.subarray(request.offset)))).toBe(true); + await engine.removeRoot('drive'); + }, 240_000); + + it('keeps each account to its own Files', async () => { + const own = await b.createFolder(`Sync test ${Date.now()}`, null); + disk.addRoot({ id: 'pair-b', account: 'fs-b', remoteId: own.id }); + disk.write('pair-b', 'only b.txt', 'b'); + await sync(); + expect(pathsUnder(await tree(b), own.id)).toEqual(['only b.txt']); + expect([...(await tree(a)).values()].some((node) => node.name === 'only b.txt')).toBe(false); + await b.destroy([own.id]); + }, 120_000); +}); diff --git a/lib/files-sync/__tests__/remote.test.ts b/lib/files-sync/__tests__/remote.test.ts new file mode 100644 index 000000000..29793f91d --- /dev/null +++ b/lib/files-sync/__tests__/remote.test.ts @@ -0,0 +1,111 @@ +// @vitest-environment node +/** The Files side over JMAP, against the fake server's 0.16 and 1.0 behaviours, and names on disk. */ +import { describe, expect, it } from 'vitest'; +import { conflictName, diskName, freeLocalName, namingFor, nameKey } from '../names'; +import { RemoteTree } from '../remote'; +import { FakeServer } from './helpers/fake-server'; + +describe('listing the whole tree', () => { + function big(options: ConstructorParameters[0]): FakeServer { + const server = new FakeServer(options); + for (let f = 0; f < 6; f += 1) { + const folder = server.folder(`folder ${f}`); + for (let i = 0; i < 120; i += 1) server.file(`f${i}.txt`, folder.id, `${f}/${i}`); + } + return server; + } + + it('finds every folder past the /get ceiling where the query leaves folders out (0.16 before 0.16.6)', async () => { + const server = big({ queryOmitsFolders: true, style: 'blob' }); + const { nodes } = await server.remote().listAll(); + expect(nodes.length).toBe(6 + 720); + expect(nodes.filter((node) => node.kind === 'directory').length).toBe(6); + }); + + it('lists everything where the return-all /get is refused (1.0)', async () => { + const server = big({ getAll: 'refuse', style: 'draft' }); + const { nodes, state } = await server.remote().listAll(); + expect(nodes.length).toBe(726); + expect(nodes.filter((node) => node.kind === 'directory').length).toBe(6); + expect(state).toBe(server.account().coll.state()); + }); + + it('fails rather than give part of the tree', async () => { + const server = big({}); + let calls = 0; + server.beforeRequest = () => { + calls += 1; + if (calls === 3) server.offline = true; + }; + await expect(server.remote().listAll()).rejects.toMatchObject({ name: 'TransportError' }); + }); +}); + +describe('writes', () => { + it('creates in the draft Stalwart 1.0 wants, and folders without content', async () => { + const server = new FakeServer(); + const accountCaps = server.session.accounts.acc1.accountCapabilities as Record>; + accountCaps['urn:ietf:params:jmap:filenode'].supportedNodeTypes = ['File', 'Directory']; + const remote = server.remote(); + const folder = await remote.createFolder('Docs', null); + const blob = await remote.upload(new TextEncoder().encode('hi'), 'text/plain; charset=utf-8'); + await remote.createFile('a.txt', folder.id, blob); + const creates = server.sets.map((set) => Object.values(set.create as Record)[0]); + expect(creates[0]).toEqual({ '@type': 'Directory', name: 'Docs' }); + expect(creates[1]).toEqual({ '@type': 'File', name: 'a.txt', parentId: folder.id, blobId: blob.blobId, mediaType: 'text/plain' }); + }); + + it('moves into the Files trash the way the Files screen does', async () => { + const server = new FakeServer(); + const remote = server.remote(); + const a = server.file('a.txt', null, 'a'); + const { trashId, batchId } = await remote.trash([a.id], null, null, 1_790_000_000_000); + expect(server.nodes.get(trashId)?.name).toBe('.Trash'); + expect(server.nodes.get(batchId)?.name).toBe('1790000000000~'); + expect(server.nodes.get(a.id)?.parentId).toBe(batchId); + }); + + it('keeps the tree in order as changes come', () => { + const tree = new RemoteTree(); + tree.reset([ + { id: 'd', parentId: null, name: 'D', kind: 'directory', blobId: null, size: 0, modifiedMs: 0, type: '' }, + { id: 'f', parentId: 'd', name: 'f', kind: 'file', blobId: 'b', size: 1, modifiedMs: 0, type: '' }, + { id: 't', parentId: null, name: '.Trash', kind: 'directory', blobId: null, size: 0, modifiedMs: 0, type: '' }, + { id: 'x', parentId: 't', name: 'x', kind: 'file', blobId: 'b', size: 1, modifiedMs: 0, type: '' }, + ], 's1'); + expect(tree.subtree(null)?.map((node) => node.id)).toEqual(['d', 'f']); + expect(tree.inTrash('x')).toBe(true); + tree.apply({ changed: [], destroyed: ['d'] }); + expect(tree.get('f')).toBeUndefined(); + expect(tree.subtree('d')).toBeNull(); + }); +}); + +describe('names on disk', () => { + const win = namingFor('win32'); + + it('maps what Windows refuses and leaves the rest', () => { + expect(diskName('ac:d"e|f?g*h\\i.txt', win)).toBe('a<b>c:d"e|f?g*h\i.txt'); + expect(diskName('CON', win)).toBe('CON_'); + expect(diskName('nul.tar.gz', win)).toBe('nul_.tar.gz'); + expect(diskName('com1.txt', win)).toBe('com1_.txt'); + expect(diskName('console.txt', win)).toBe('console.txt'); + expect(diskName('ends with dot.', win)).toBe('ends with dot._'); + expect(diskName('ends with space ', win)).toBe('ends with space _'); + expect(diskName('tab\there', win)).toBe('tab␉here'); + expect(diskName('Grüße, Ärger.txt', win)).toBe('Grüße, Ärger.txt'); + expect(diskName('a:b', namingFor('linux'))).toBe('a:b'); + }); + + it('numbers a second name that differs only in case', () => { + const taken = new Set([nameKey('Report.txt', win)]); + expect(freeLocalName('report.txt', taken, win)).toBe('report (2).txt'); + const linux = namingFor('linux'); + expect(freeLocalName('report.txt', new Set([nameKey('Report.txt', linux)]), linux)).toBe('report.txt'); + }); + + it('names a conflict copy with the device and the time', () => { + expect(conflictName('report.final.pdf', 'Windows', new Date(2026, 10, 4, 9, 5))).toBe('report.final (conflict, Windows, 2026-11-04 09.05).pdf'); + expect(conflictName('.env', 'Mac', new Date(2026, 0, 1, 0, 0))).toBe('.env (conflict, Mac, 2026-01-01 00.00)'); + }); +}); diff --git a/lib/files-sync/controls.ts b/lib/files-sync/controls.ts new file mode 100644 index 000000000..5f2289c5e --- /dev/null +++ b/lib/files-sync/controls.ts @@ -0,0 +1,28 @@ +/** + * The desktop app's Files on this computer calls, taken once per document in its first render + * (WindowControlsHost), as auth.claim and appLock.claim are: a same-origin plugin frame + * reaches `bulwarkDesktop` through `parent`, and main can't tell its calls from the page's. + * Whoever comes after the page gets nothing from the preload. Main also answers only the main + * window, so a pop-out's own claim is just its share of nothing. + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import type { FilesBridge, FilesControls } from '@/lib/desktop/types'; + +let claimedFrom: FilesBridge | null | undefined; +let claimed: FilesControls | null = null; + +export function claimFilesControls(): FilesControls | null { + const member = getDesktopBridge()?.files ?? null; + if (member !== claimedFrom) { + claimedFrom = member; + claimed = member?.claim?.() ?? null; + } + return claimed; +} + +/** Tests only. */ +export function resetFilesControlsForTests(): void { + claimedFrom = undefined; + claimed = null; +} diff --git a/lib/files-sync/engine.ts b/lib/files-sync/engine.ts new file mode 100644 index 000000000..9a4e6f707 --- /dev/null +++ b/lib/files-sync/engine.ts @@ -0,0 +1,688 @@ +/** + * Files on this computer: the engine for every root of the signed-in accounts. It keeps each + * account's FileNode tree current (a listing once, then FileNode/changes on push and on a slow + * poll), runs a RootSync per root when either side changed, and answers the drive's fetches. + * + * Main only does the disk (lib/desktop/types.ts FilesControls); everything that decides is here, + * in the page, with the app's own JMAP session. The host (host.ts) starts it in the main window + * and tells it which accounts are signed in. + * + * When a pass runs: + * - main's `onChange` for a root: soon after, so a burst of writes is one pass. `paths: null` is + * what a new, resumed or re-armed watch says first. A root id nobody knows: ask for the roots. + * - FileNode changes that touch a root: at once. + * - offline: again after 5 s, then longer, up to 5 minutes, and at once when the host hears the + * network is back. + * - every 10 minutes regardless, in case a change went unheard. + */ + +import type { FilesControls, FilesFetchRequest, FilesRoot } from '@/lib/desktop/types'; +import { isOffline, type FilesSyncErrorName } from './errors'; +import type { DiskNaming } from './names'; +import { RemoteTree, type FilesRemote } from './remote'; +import { RootSync, type AccountLink, type ActivityInput } from './root-sync'; +import type { FilesSyncActivity, FilesSyncCoverage, FilesSyncItemStatus, FilesSyncRootStatus } from './types'; + +export const ACTIVITY_LIMIT = 200; +const PIECE = 1024 * 1024; +const MOST_SOON = 5; + +export interface EngineTiming { + /** After main says something changed on disk. */ + localDelayMs: number; + /** After a push says Files changed. */ + remoteDelayMs: number; + /** /changes without a push, per account. */ + pollMs: number; + /** A full pass of every root regardless. */ + rescanMs: number; + /** Waits while offline, one after another; the last repeats. */ + retryMs: number[]; + /** Again after a pass where something changed while it was read. */ + soonMs: number; + /** How long a drive fetch waits for the first listing. */ + fetchWaitMs: number; +} + +export const DEFAULT_TIMING: EngineTiming = { + localDelayMs: 1500, + remoteDelayMs: 300, + pollMs: 60_000, + rescanMs: 10 * 60_000, + retryMs: [5_000, 15_000, 30_000, 60_000, 120_000, 300_000], + soonMs: 3_000, + fetchWaitMs: 20_000, +}; + +export interface EngineOptions { + controls: FilesControls; + naming: DiskNaming; + device: string; + readUrl?: (url: string) => Promise; + now?: () => number; + timing?: Partial; + /** The organisation's word on a root: null when it may sync. */ + blocked?: (root: FilesRoot) => FilesSyncErrorName | null; + /** May this account's files be downloaded to this computer? */ + downloadsAllowed?: (account: string) => boolean; + /** Something the UI shows changed. */ + onUpdate?: () => void; +} + +async function fetchBytes(url: string): Promise { + const response = await fetch(url, { cache: 'no-store' }); + if (!response.ok) throw new Error(`read failed: ${response.status}`); + return new Uint8Array(await response.arrayBuffer()); +} + +class Account implements AccountLink { + readonly tree = new RemoteTree(); + private loaded = false; + private running: Promise | null = null; + private again = false; + private poll: ReturnType | null = null; + private readyWaiters: (() => void)[] = []; + + constructor( + readonly account: string, + public remote: FilesRemote, + private readonly changed: (account: Account, ids: Set, origin: string | null) => void, + ) {} + + get isLoaded(): boolean { + return this.loaded; + } + + startPolling(ms: number, run: () => void): void { + this.stopPolling(); + this.poll = setInterval(run, ms); + (this.poll as { unref?: () => void }).unref?.(); + } + + stopPolling(): void { + if (this.poll) clearInterval(this.poll); + this.poll = null; + } + + /** Waits for the first listing, up to `ms`. */ + ready(ms: number): Promise { + if (this.loaded) return Promise.resolve(true); + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), ms); + this.readyWaiters.push(() => { + clearTimeout(timer); + resolve(true); + }); + }); + } + + refresh(origin: string | null = null): Promise { + if (this.running) { + this.again = true; + return this.running; + } + this.running = (async () => { + try { + do { + this.again = false; + await this.catchUp(origin); + } while (this.again); + } finally { + this.running = null; + } + })(); + return this.running; + } + + private async catchUp(origin: string | null): Promise { + const ids = new Set(); + if (!this.loaded || !this.tree.state) { + await this.listAll(ids); + } else { + const written = [...this.tree.written]; + const heard = new Set(); + const outcome = await this.remote.changes(this.tree.state, (batch, state) => { + for (const node of batch.changed) { + ids.add(node.id); + heard.add(node.id); + } + for (const id of batch.destroyed) { + ids.add(id); + heard.add(id); + } + this.tree.apply(batch); + this.tree.state = state; + }); + // The server can't say what changed since then: read it all again. Only here. + if (outcome.outcome === 'resync') { + await this.listAll(ids); + } else { + for (const id of written) this.tree.written.delete(id); + // Made here and deleted elsewhere since: /changes says neither. + const unheard = written.filter((id) => !heard.has(id) && this.tree.get(id)); + if (unheard.length > 0) { + const still = new Set((await this.remote.get(unheard)).map((node) => node.id)); + for (const id of unheard) { + if (still.has(id)) continue; + this.tree.remove(id); + ids.add(id); + } + } + } + } + if (ids.size > 0) this.changed(this, ids, origin); + } + + private async listAll(ids: Set): Promise { + const before = new Map(this.tree.nodes); + const { nodes, state } = await this.remote.listAll(); + this.tree.reset(nodes, state); + // Whatever changed while it was read. + await this.remote.changes(state, (batch, next) => { + this.tree.apply(batch); + this.tree.state = next; + }).then((outcome) => { + if (outcome.outcome === 'resync') throw new Error('The FileNode state was refused right after a listing'); + }); + for (const [id, node] of this.tree.nodes) { + const old = before.get(id); + if (!old || old.parentId !== node.parentId || old.name !== node.name || old.blobId !== node.blobId) ids.add(id); + } + for (const id of before.keys()) if (!this.tree.nodes.has(id)) ids.add(id); + const first = !this.loaded; + this.loaded = true; + if (first) for (const waiter of this.readyWaiters.splice(0)) waiter(); + } +} + +class Runner { + private timer: ReturnType | null = null; + private due = 0; + private running: Promise | null = null; + private again = false; + private retryAt = 0; + private soon = 0; + private stopped = false; + + constructor(readonly sync: RootSync, private readonly timing: EngineTiming) {} + + get busy(): boolean { + return this.running !== null || (this.timer !== null && this.due - Date.now() <= Math.max(this.timing.localDelayMs, this.timing.soonMs)); + } + + get offline(): boolean { + return this.sync.status.state === 'offline'; + } + + request(delay: number): void { + if (this.stopped) return; + if (this.running) { + this.again = true; + return; + } + const due = Date.now() + delay; + if (this.timer) { + if (this.due <= due) return; + clearTimeout(this.timer); + } + this.due = due; + this.timer = setTimeout(() => { + this.timer = null; + void this.run(); + }, delay); + } + + private async run(): Promise { + if (this.stopped || this.running) return; + const pass = this.sync.pass().catch((error: unknown) => { + console.warn('[files-sync] A pass stopped:', error); + return 'retry' as const; + }); + this.running = pass.then(() => {}); + const outcome = await pass; + this.running = null; + if (this.stopped) return; + if (this.again) { + this.again = false; + this.request(0); + return; + } + if (outcome === 'retry') { + const waits = this.timing.retryMs; + this.request(waits[Math.min(this.retryAt, waits.length - 1)]); + this.retryAt += 1; + return; + } + this.retryAt = 0; + if (outcome === 'soon' && this.soon < MOST_SOON) { + this.soon += 1; + this.request(this.timing.soonMs); + return; + } + this.soon = 0; + } + + /** Waits for what's running and whatever is due within the short delays. For tests and shutdown. */ + async settle(): Promise { + for (let i = 0; i < 1000; i += 1) { + if (this.running) { + await this.running; + continue; + } + if (this.timer && this.due - Date.now() <= Math.max(this.timing.localDelayMs, this.timing.soonMs, this.timing.remoteDelayMs)) { + await new Promise((resolve) => setTimeout(resolve, Math.max(0, this.due - Date.now()) + 1)); + continue; + } + return; + } + } + + stop(): void { + this.stopped = true; + this.sync.stopped = true; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + } +} + +export class FilesSyncEngine { + roots: FilesRoot[] = []; + activity: FilesSyncActivity[] = []; + private readonly accounts = new Map(); + private readonly runners = new Map(); + private readonly timing: EngineTiming; + private readonly fetches = new Map(); + private unsubscribe: (() => void)[] = []; + private rescan: ReturnType | null = null; + private rootsRun: Promise | null = null; + private rootsAgain = false; + private activitySeq = 0; + private stopped = false; + private readonly readUrl: (url: string) => Promise; + private readonly now: () => number; + + constructor(private readonly options: EngineOptions) { + this.timing = { ...DEFAULT_TIMING, ...options.timing }; + this.readUrl = options.readUrl ?? fetchBytes; + this.now = options.now ?? Date.now; + } + + get controls(): FilesControls { + return this.options.controls; + } + + start(): void { + const { controls } = this.options; + this.unsubscribe.push(controls.onChange((event) => { + const runner = this.runners.get(event.rootId); + if (!runner) { + void this.refreshRoots(); + return; + } + runner.request(event.paths === null ? 0 : this.timing.localDelayMs); + })); + const drive = controls.drive; + if (drive) { + this.unsubscribe.push(drive.onFetch((request) => void this.fetch(request))); + this.unsubscribe.push(drive.onFetchCancel((fetchId) => { + const fetch = this.fetches.get(fetchId); + if (fetch) fetch.cancelled = true; + })); + } + this.rescan = setInterval(() => { + for (const runner of this.runners.values()) runner.request(0); + }, this.timing.rescanMs); + (this.rescan as { unref?: () => void }).unref?.(); + void this.refreshRoots(); + } + + stop(): void { + this.stopped = true; + for (const stop of this.unsubscribe.splice(0)) stop(); + if (this.rescan) clearInterval(this.rescan); + for (const runner of this.runners.values()) runner.stop(); + this.runners.clear(); + for (const account of this.accounts.values()) account.stopPolling(); + this.accounts.clear(); + for (const fetch of this.fetches.values()) fetch.cancelled = true; + } + + // ─── Accounts ─────────────────────────────────────────────── + + /** A signed-in account and its way to Files. Its roots start. */ + setAccount(account: string, remote: FilesRemote): void { + if (this.stopped) return; + const known = this.accounts.get(account); + if (known) { + known.remote = remote; + return; + } + const entry = new Account(account, remote, (from, ids, origin) => this.remoteTouched(from, ids, origin)); + this.accounts.set(account, entry); + entry.startPolling(this.timing.pollMs, () => void this.pollAccount(account)); + this.applyRoots(); + } + + /** Signed out, or gone: its roots stop. Nothing on disk changes. */ + dropAccount(account: string): void { + const entry = this.accounts.get(account); + if (!entry) return; + entry.stopPolling(); + this.accounts.delete(account); + for (const [id, runner] of this.runners) { + if (runner.sync.root.account !== account) continue; + runner.stop(); + this.runners.delete(id); + } + this.update(); + } + + hasAccount(account: string): boolean { + return this.accounts.has(account); + } + + /** A push said FileNode changed in this account. */ + remoteChanged(account: string): void { + void this.pollAccount(account); + } + + private async pollAccount(account: string): Promise { + const entry = this.accounts.get(account); + if (!entry?.isLoaded) { + // Nothing listed yet: a root's pass lists it. + for (const runner of this.runnersOf(account)) runner.request(this.timing.remoteDelayMs); + return; + } + try { + await entry.refresh(); + } catch (error) { + if (!isOffline(error)) console.warn('[files-sync] Reading changes failed:', error); + } + } + + /** The network is back: whatever waits goes now. */ + retryNow(): void { + for (const runner of this.runners.values()) if (runner.offline) runner.request(0); + } + + private runnersOf(account: string): Runner[] { + return [...this.runners.values()].filter((runner) => runner.sync.root.account === account); + } + + private remoteTouched(account: Account, ids: Set, origin: string | null): void { + for (const runner of this.runnersOf(account.account)) { + if (runner.sync.root.id === origin) continue; + const remoteId = runner.sync.root.remoteId; + const touched = [...ids].some((id) => !!runner.sync.item(id) || account.tree.isUnder(id, remoteId) || id === remoteId); + if (touched) runner.request(this.timing.remoteDelayMs); + } + } + + // ─── Roots ────────────────────────────────────────────────── + + /** Asks main for the roots again and starts, changes or stops runners to match. */ + refreshRoots(): Promise { + if (this.rootsRun) { + this.rootsAgain = true; + return this.rootsRun; + } + this.rootsRun = (async () => { + try { + do { + this.rootsAgain = false; + try { + this.roots = await this.options.controls.roots(); + } catch { + this.roots = []; + } + this.applyRoots(); + } while (this.rootsAgain && !this.stopped); + } finally { + this.rootsRun = null; + } + })(); + return this.rootsRun; + } + + private applyRoots(): void { + if (this.stopped) return; + const live = new Map(this.roots.map((root) => [root.id, root])); + for (const [id, runner] of this.runners) { + const root = live.get(id); + if (!root || !this.accounts.has(root.account)) { + runner.stop(); + this.runners.delete(id); + continue; + } + const wasPaused = runner.sync.root.paused; + runner.sync.setRoot(root); + if (wasPaused && !root.paused) runner.request(0); + } + for (const root of this.roots) { + if (this.runners.has(root.id)) continue; + const account = this.accounts.get(root.account); + if (!account) continue; + const sync = new RootSync(root, { + account: account.account, + tree: account.tree, + get remote() { + return account.remote; + }, + refresh: () => account.refresh(root.id), + }, { + controls: this.options.controls, + naming: this.options.naming, + device: this.options.device, + now: this.now, + readUrl: this.readUrl, + activity: (entry) => this.addActivity(entry), + changed: () => this.update(), + blocked: (current) => this.options.blocked?.(current) ?? null, + }); + const runner = new Runner(sync, this.timing); + this.runners.set(root.id, runner); + runner.request(0); + } + this.update(); + } + + root(rootId: string): RootSync | null { + return this.runners.get(rootId)?.sync ?? null; + } + + /** Runs a pass of a root (all of them without an id) now. */ + syncNow(rootId?: string): void { + for (const [id, runner] of this.runners) if (!rootId || id === rootId) runner.request(0); + } + + /** Waits until nothing runs or is due shortly. For tests and the sign-out. */ + async settled(): Promise { + for (let i = 0; i < 50; i += 1) { + if (this.rootsRun) await this.rootsRun; + await Promise.all([...this.runners.values()].map((runner) => runner.settle())); + if (!this.rootsRun && [...this.runners.values()].every((runner) => !runner.busy)) return; + } + } + + async setPaused(rootId: string, paused: boolean): Promise { + await this.options.controls.setPaused(rootId, paused); + await this.refreshRoots(); + } + + async removeRoot(rootId: string): Promise { + await this.options.controls.removeRoot(rootId); + await this.refreshRoots(); + } + + confirmMassDelete(rootId: string, allow: boolean): void { + const runner = this.runners.get(rootId); + if (!runner) return; + runner.sync.answerMassDelete(allow); + runner.request(0); + } + + /** The drive's record goes before its folder does (sign-out): the next sign-in starts from an empty folder. */ + async forgetRoot(rootId: string): Promise { + const runner = this.runners.get(rootId); + if (!runner) return; + runner.stop(); + await runner.sync.forget(); + this.runners.delete(rootId); + } + + // ─── What the UI reads ────────────────────────────────────── + + status(): Record { + const out: Record = {}; + for (const [id, runner] of this.runners) out[id] = runner.sync.status; + return out; + } + + items(account: string): Record { + const out: Record = {}; + for (const runner of this.runnersOf(account)) Object.assign(out, runner.sync.items); + return out; + } + + coverage(account: string, nodeId: string): FilesSyncCoverage | null { + for (const runner of this.runnersOf(account)) { + const { root } = runner.sync; + if (root.remoteId === nodeId) return { rootId: root.id, kind: root.kind, path: '' }; + const item = runner.sync.item(nodeId); + if (item) return { rootId: root.id, kind: root.kind, path: item.path }; + } + return null; + } + + clearActivity(): void { + this.activity = []; + this.update(); + } + + private addActivity(entry: ActivityInput): void { + this.activitySeq += 1; + this.activity = [{ id: `${this.now()}-${this.activitySeq}`, at: this.now(), ...entry }, ...this.activity].slice(0, ACTIVITY_LIMIT); + this.update(); + } + + private update(): void { + this.options.onUpdate?.(); + } + + // ─── The drive's fetches ──────────────────────────────────── + + /** + * Someone opened a file on the drive that isn't on the disk: stream its content to main, from + * where Windows asked (rounded down to 4 KB) to the end, in order. Main fails a fetch it hears + * nothing of for 30 s, and Windows cancels after 60 s. + */ + async fetch(request: FilesFetchRequest): Promise { + const drive = this.options.controls.drive; + if (!drive) return; + const handle = { cancelled: false }; + this.fetches.set(request.fetchId, handle); + try { + let runner = this.runners.get(request.rootId); + if (!runner) { + await this.refreshRoots(); + runner = this.runners.get(request.rootId); + } + if (!runner || runner.sync.root.kind !== 'drive') { + drive.fail(request.fetchId, this.accountsKnown(request.rootId) ? 'failed' : 'offline'); + return; + } + const { root } = runner.sync; + if (this.options.downloadsAllowed && !this.options.downloadsAllowed(root.account)) { + drive.fail(request.fetchId, 'failed'); + return; + } + const account = this.accounts.get(root.account); + if (!account || !(await account.ready(this.timing.fetchWaitMs))) { + drive.fail(request.fetchId, 'offline'); + return; + } + // The newest version, not one deleted or replaced since the last push. Offline: what's known. + await account.refresh().catch(() => {}); + const node = account.tree.get(request.identity); + if (!node || node.kind !== 'file' || account.tree.inTrash(node.id)) { + drive.fail(request.fetchId, 'missing'); + runner.request(this.timing.remoteDelayMs); + return; + } + if (node.size !== request.total) { + // The placeholder is older than the file: the pass puts the new one there. + drive.fail(request.fetchId, 'failed'); + runner.request(0); + return; + } + await this.stream(request, node, account, handle); + } catch (error) { + if (!handle.cancelled) drive.fail(request.fetchId, isOffline(error) ? 'offline' : 'failed'); + } finally { + this.fetches.delete(request.fetchId); + } + } + + private accountsKnown(rootId: string): boolean { + const root = this.roots.find((entry) => entry.id === rootId); + return !!root && this.accounts.has(root.account); + } + + private async stream(request: FilesFetchRequest, node: { blobId: string | null; type: string; name: string; size: number }, account: Account, handle: { cancelled: boolean }): Promise { + const drive = this.options.controls.drive; + if (!drive) return; + const start = Math.max(0, request.offset); + let at = 0; + let sent = start; + let pending: Uint8Array[] = []; + let pendingBytes = 0; + let chain: Promise = Promise.resolve(true); + let refused = false; + const STOP = 'files-sync:fetch-stopped'; + const flush = (all: boolean) => { + if (pendingBytes === 0 || (!all && pendingBytes < PIECE)) return; + const piece = joinParts(pending, pendingBytes); + pending = []; + pendingBytes = 0; + const offset = sent; + sent += piece.length; + chain = chain.then(async (good) => { + if (!good || handle.cancelled) return false; + const taken = await drive.supply(request.fetchId, offset, piece); + if (!taken) refused = true; + return taken; + }); + }; + try { + await account.remote.download(node, (part) => { + if (handle.cancelled || refused) throw new Error(STOP); + let slice = part; + if (at + part.length <= start) { + at += part.length; + return; + } + if (at < start) slice = part.subarray(start - at); + at += part.length; + pending.push(slice); + pendingBytes += slice.length; + flush(false); + }); + } catch (error) { + if ((error as Error)?.message === STOP) return; + throw error; + } + flush(true); + await chain; + } +} + +function joinParts(parts: Uint8Array[], size: number): Uint8Array { + if (parts.length === 1) return parts[0]; + const out = new Uint8Array(size); + let at = 0; + for (const part of parts) { + out.set(part, at); + at += part.length; + } + return out; +} diff --git a/lib/files-sync/errors.ts b/lib/files-sync/errors.ts new file mode 100644 index 000000000..b62063042 --- /dev/null +++ b/lib/files-sync/errors.ts @@ -0,0 +1,92 @@ +/** + * What went wrong, in the words the settings page shows: keys under `files_sync.errors.` + * (the ui writes the strings). Errors are told apart by name, not instanceof: Next has more + * than one copy of a module. + */ + +import type { FilesError } from '@/lib/desktop/types'; + +export type FilesSyncErrorName = + | 'offline' + | 'server_refused' + | 'too_large' + | 'name_invalid' + | 'disk_full' + | 'denied' + | 'busy' + | 'quota' + | 'not_found' + | 'policy' + | 'failed'; + +/** The key in the `files_sync` namespace. */ +export function errorKey(name: FilesSyncErrorName): string { + return `errors.${name}`; +} + +/** A disk answer the engine can't go on from for this item. */ +export class LocalError extends Error { + constructor(readonly error: FilesError) { + super(`files:${error}`); + this.name = 'LocalError'; + } +} + +export function localErrorName(error: FilesError): FilesSyncErrorName { + switch (error) { + case 'full': + return 'disk_full'; + case 'denied': + case 'outside': + case 'gesture': + return 'denied'; + case 'busy': + return 'busy'; + case 'name': + return 'name_invalid'; + case 'policy': + return 'policy'; + case 'missing': + return 'not_found'; + default: + return 'failed'; + } +} + +/** The server can't be reached, or is too busy now: the work waits, nothing failed. */ +export function isOffline(error: unknown): boolean { + const e = error as { name?: string; kind?: string; status?: number } | null; + if (e?.name !== 'TransportError') return false; + if (e.kind === 'network' || e.kind === 'rate') return true; + return e.kind === 'http' && typeof e.status === 'number' && e.status >= 500; +} + +/** The server no longer takes this login. */ +export function isAuthRefused(error: unknown): boolean { + const e = error as { name?: string; kind?: string } | null; + return e?.name === 'TransportError' && e.kind === 'auth'; +} + +/** What a server error on one item means. */ +export function remoteErrorName(error: unknown): FilesSyncErrorName { + const e = error as { name?: string; type?: string; status?: number } | null; + if (isOffline(error)) return 'offline'; + if (isAuthRefused(error)) return 'server_refused'; + if (e?.name === 'TransportError' && e.status === 413) return 'too_large'; + switch (e?.type) { + case 'overQuota': + return 'quota'; + case 'forbidden': + case 'accountReadOnly': + return 'denied'; + case 'notFound': + return 'not_found'; + case 'tooLarge': + return 'too_large'; + case 'invalidProperties': + case 'alreadyExists': + return 'name_invalid'; + default: + return 'failed'; + } +} diff --git a/lib/files-sync/host.ts b/lib/files-sync/host.ts new file mode 100644 index 000000000..eb707d1fd --- /dev/null +++ b/lib/files-sync/host.ts @@ -0,0 +1,329 @@ +/** + * Files on this computer in the page: starts the engine in the desktop app's main window, for + * the signed-in accounts, publishes what it does to stores/files-sync-store.ts and registers the + * controller the settings page and the Files screen use. The React side + * (components/providers/files-sync-host.tsx) feeds it the accounts, push and the policies. + * + * Each account reaches Files through the app's own JMAP session: the session resource and + * Authorization header the app's client holds, read when a request goes and renewed through + * it (as offline mode does). Nothing here writes a token or a password anywhere. + * + * What happens to an account's roots when it leaves (FILES-SYNC-BRIEF.md): + * - sign-out: the drive goes (`signed-out` keeps the wish, so the next sign-in brings it back + * without a click), and the folders stop syncing until then; + * - a wipe (lib/offline/wipe-registry.ts): the drive goes with everything in it, and the + * folders are forgotten; their files stay, they were the user's; + * - a refused login the server was sure of: the same as a wipe for these. + */ + +import type { FilesControls, FilesDriveState, FilesResult, FilesRoot } from '@/lib/desktop/types'; +import { FetchTransport, type StoreSession } from '@/lib/store/engine/transport'; +import { FilesSyncEngine, type EngineTiming } from './engine'; +import type { FilesSyncErrorName } from './errors'; +import { namingFor } from './names'; +import { FilesRemote } from './remote'; +import type { FilesSyncController, FilesSyncSnapshot } from './types'; + +/** What the host needs of the app's JMAP client. */ +export interface SessionClient { + getSessionSnapshot(): unknown; + getAuthHeader(): string; + isOfflineOnly?(): boolean; + requestNetworkOnly?(calls: [string, Record, string][]): Promise; +} + +export interface HostAccount { + id: string; + /** Shown with the drive when there are several accounts. */ + label: string; + client: SessionClient; +} + +export interface HostDeps { + controls: FilesControls; + platform: string; + publish: (patch: Partial) => void; + setController: (controller: FilesSyncController | null) => void; + /** `FilesFolderSyncEnabled` is off. */ + folderSyncForcedOff: () => boolean; + /** The organisation said no to downloads for this account. */ + downloadsForbidden: (account: string) => boolean; + /** Tests give their own. */ + remoteFor?: (account: HostAccount) => FilesRemote | null; + timing?: Partial; + readUrl?: (url: string) => Promise; +} + +const DEVICE: Record = { win32: 'Windows', darwin: 'Mac', linux: 'Linux' }; + +/** The app's client as a transport: its session and header, read each time, renewed through it. */ +export function remoteForClient(client: SessionClient): FilesRemote | null { + if (client.isOfflineOnly?.()) return null; + const session = client.getSessionSnapshot() as StoreSession | undefined; + if (!session?.apiUrl || !session.uploadUrl || !session.downloadUrl) return null; + const transport = new FetchTransport({ + session: () => (client.getSessionSnapshot() as StoreSession | undefined) ?? session, + auth: () => client.getAuthHeader() || null, + refreshAuth: async () => { + // Through the client: a 401 there runs its own renewal or re-login. + await client.requestNetworkOnly?.([['Core/echo', {}, '0']]).catch(() => null); + return client.getAuthHeader() || null; + }, + }); + const remote = new FilesRemote(transport, () => (client.getSessionSnapshot() as StoreSession | undefined) ?? session); + return remote.supported() ? remote : null; +} + +const fail = (error: Exclude, { ok: true }>['error']): FilesResult => ({ ok: false, error }); + +export class FilesSyncHost { + readonly engine: FilesSyncEngine; + private readonly accounts = new Map(); + private current: string | null = null; + private drive: FilesDriveState | null = null; + private publishTimer: ReturnType | null = null; + private readonly unsubscribe: (() => void)[] = []; + /** Accounts the drive was turned on for at this sign-in already. */ + private readonly driveStarted = new Set(); + private stopped = false; + + constructor(private readonly deps: HostDeps) { + this.engine = new FilesSyncEngine({ + controls: deps.controls, + naming: namingFor(deps.platform), + device: DEVICE[deps.platform] ?? 'this computer', + readUrl: deps.readUrl, + timing: deps.timing, + blocked: (root) => this.blocked(root), + downloadsAllowed: (account) => !deps.downloadsForbidden(account), + onUpdate: () => this.schedulePublish(), + }); + } + + get controls(): FilesControls { + return this.deps.controls; + } + + start(): void { + this.engine.start(); + const drive = this.deps.controls.drive; + if (drive) { + void drive.state().then((state) => { + this.drive = state; + this.schedulePublish(); + this.startDrives(); + }).catch(() => {}); + this.unsubscribe.push(drive.onStateChange((state) => { + this.drive = state; + this.schedulePublish(); + void this.engine.refreshRoots(); + })); + } + this.deps.setController(this.controller()); + this.publishNow(); + } + + stop(): void { + this.stopped = true; + for (const stop of this.unsubscribe.splice(0)) stop(); + if (this.publishTimer) clearTimeout(this.publishTimer); + this.engine.stop(); + this.deps.setController(null); + } + + private blocked(root: FilesRoot): FilesSyncErrorName | null { + if (root.kind === 'folder' && this.deps.folderSyncForcedOff()) return 'policy'; + if (this.deps.downloadsForbidden(root.account)) return 'policy'; + return null; + } + + // ─── Accounts ─────────────────────────────────────────────── + + /** The signed-in accounts with a connected client, and the one on screen. */ + setAccounts(accounts: HostAccount[], current: string | null): void { + if (this.stopped) return; + this.current = current; + const now = new Map(accounts.map((account) => [account.id, account])); + for (const id of [...this.accounts.keys()]) { + if (now.has(id)) continue; + // Not connected now (signed out goes through signedOut): its roots wait. + this.accounts.delete(id); + this.engine.dropAccount(id); + } + for (const account of accounts) { + const known = this.accounts.get(account.id); + if (known && known.client === account.client) continue; + const remote = (this.deps.remoteFor ?? ((entry: HostAccount) => remoteForClient(entry.client)))(account); + if (!remote) continue; + this.accounts.set(account.id, account); + this.engine.setAccount(account.id, remote); + } + this.startDrives(); + void this.engine.refreshRoots(); + this.schedulePublish(); + } + + /** A sign-in brings back the drive of an account that had it, without a click. */ + private startDrives(): void { + const drive = this.deps.controls.drive; + const state = this.drive; + if (!drive || !state || !state.supported || state.forcedOff) return; + for (const account of this.accounts.values()) { + if (!state.wanted.includes(account.id) || this.driveStarted.has(account.id)) continue; + this.driveStarted.add(account.id); + void drive.enable(account.id, account.label).then((result) => { + if (!result.ok) this.driveStarted.delete(account.id); + void this.engine.refreshRoots(); + }, () => this.driveStarted.delete(account.id)); + } + } + + /** A push for this account said FileNode changed. */ + remoteChanged(account: string): void { + this.engine.remoteChanged(account); + } + + online(): void { + this.engine.retryNow(); + } + + policyChanged(): void { + this.engine.syncNow(); + this.schedulePublish(); + } + + private async driveRootOf(account: string): Promise { + const roots = await this.deps.controls.roots().catch(() => [] as FilesRoot[]); + return roots.find((root) => root.kind === 'drive' && root.account === account) ?? null; + } + + /** + * Signed out ('all': everyone). The drive goes, and comes back at the next sign-in; its record + * goes first, since its folder goes with it. The folders stop until then. + */ + async signedOut(account: string): Promise { + const ids = account === 'all' ? [...new Set([...this.accounts.keys(), ...this.engine.roots.map((root) => root.account)])] : [account]; + for (const id of ids) { + const drive = await this.driveRootOf(id); + if (drive) await this.engine.forgetRoot(drive.id); + this.accounts.delete(id); + this.driveStarted.delete(id); + this.engine.dropAccount(id); + await this.deps.controls.drive?.disable(id, 'signed-out').catch(() => {}); + } + await this.engine.refreshRoots(); + } + + /** + * A wipe or a refused login the server was sure of: the drive goes with everything in it, + * the folders are forgotten and their files stay. `null`: every account. + */ + async removeAccountData(account: string | null, reason: 'wipe' | 'refused'): Promise { + await removeAccountData(this.deps.controls, account, reason, [...this.accounts.keys(), ...this.engine.roots.map((root) => root.account)]); + for (const id of account === null ? [...this.accounts.keys()] : [account]) { + this.accounts.delete(id); + this.driveStarted.delete(id); + this.engine.dropAccount(id); + } + await this.engine.refreshRoots(); + } + + // ─── The store ────────────────────────────────────────────── + + private schedulePublish(): void { + if (this.publishTimer || this.stopped) return; + this.publishTimer = setTimeout(() => { + this.publishTimer = null; + this.publishNow(); + }, 50); + } + + /** What the UI shows: the account on screen's roots, their status and activity. */ + publishNow(): void { + if (this.stopped) return; + const current = this.current; + const roots = current ? this.engine.roots.filter((root) => root.account === current) : []; + const ids = new Set(roots.map((root) => root.id)); + const status = Object.fromEntries(Object.entries(this.engine.status()).filter(([id]) => ids.has(id))); + this.deps.publish({ + available: true, + account: current, + roots, + status, + activity: this.engine.activity.filter((entry) => ids.has(entry.rootId)), + drive: this.drive, + folderSyncForcedOff: this.deps.folderSyncForcedOff(), + items: current ? this.engine.items(current) : {}, + }); + } + + controller(): FilesSyncController { + const controls = this.deps.controls; + return { + // Main wants the click this came from: the bridge call goes first, before any await. + addFolder: async (remoteId, remoteLabel) => { + const account = this.current; + if (!account) return fail('inactive'); + // A folder of someone else's shared drive: the engine syncs the account's own Files. + if (remoteId !== null && remoteId.includes(':')) return fail('unsupported'); + const result = await controls.addFolder({ account, remoteId, remoteLabel }); + await this.engine.refreshRoots(); + return result; + }, + removeRoot: (rootId) => this.engine.removeRoot(rootId), + setPaused: (rootId, paused) => this.engine.setPaused(rootId, paused), + confirmMassDelete: async (rootId, allow) => { + this.engine.confirmMassDelete(rootId, allow); + }, + setDriveEnabled: async (enabled) => { + const account = this.current; + const drive = controls.drive; + if (!drive) return fail('unsupported'); + if (!account) return fail('inactive'); + if (enabled) { + const label = this.accounts.get(account)?.label ?? account; + const result = await drive.enable(account, label); + if (result.ok) this.driveStarted.add(account); + await this.engine.refreshRoots(); + return result; + } + await drive.disable(account, 'user'); + this.driveStarted.delete(account); + await this.engine.refreshRoots(); + return { ok: true, value: null }; + }, + setDriveLetter: async (letter) => { + const drive = controls.drive; + if (!drive) return fail('unsupported'); + return drive.setLetter(letter); + }, + coverage: (nodeId) => (this.current ? this.engine.coverage(this.current, nodeId) : null), + showInFolder: async (nodeId) => { + const coverage = this.current ? this.engine.coverage(this.current, nodeId) : null; + if (!coverage) return false; + return controls.showInFolder(coverage.rootId, coverage.path); + }, + clearActivity: () => this.engine.clearActivity(), + }; + } +} + +/** + * For a wipe or a refused login, with or without a running host: the drive goes with + * everything in it, the folders are forgotten (their files stay). Bridge calls only: a wipe + * often runs because the server said no. + */ +export async function removeAccountData(controls: FilesControls, account: string | null, reason: 'wipe' | 'refused', known: string[] = []): Promise { + const roots = await controls.roots().catch(() => [] as FilesRoot[]); + const drive = controls.drive; + const wanted = drive ? await drive.state().then((state) => state.wanted, () => [] as string[]) : []; + const accounts = account === null ? [...new Set([...known, ...wanted, ...roots.map((root) => root.account)])] : [account]; + for (const id of accounts) { + if (drive && (wanted.includes(id) || roots.some((root) => root.kind === 'drive' && root.account === id))) { + await drive.disable(id, reason).catch(() => {}); + } + for (const root of roots) { + if (root.kind === 'folder' && root.account === id) await controls.removeRoot(root.id).catch(() => {}); + } + } +} diff --git a/lib/files-sync/lifecycle.ts b/lib/files-sync/lifecycle.ts new file mode 100644 index 000000000..c2d0550cc --- /dev/null +++ b/lib/files-sync/lifecycle.ts @@ -0,0 +1,72 @@ +/** + * How Files on this computer follows an account out: sign-out, a wipe, a refused login. Hooked + * in where edit-in-place's copies are (lib/desktop/edit-attachments.ts): the wipe registry, the + * sign-out tasks, the auth store's word on a refused login, and the account list. + * + * - Sign-out: the drive goes (`signed-out`: the next sign-in brings it back) and the account's + * folders stop syncing until then; nothing is deleted from them. + * - A wipe (remote, from settings, the organisation's): `wipe`. The drive goes with everything + * in it; the folders are forgotten, their files stay. + * - A refused login the server was sure of (`invalid_grant`, a lasting 401): `refused`, as a + * wipe for these. An unsure one, or the organisation's idle limit, is a sign-out: a single + * bad answer at a start can be a server in trouble, and deleting the drive's unsynced edits + * for that would lose work. + */ + +import { onCredentialsRejected } from '@/lib/offline/credentials-rejected'; +import { onDeviceWipe } from '@/lib/offline/wipe-registry'; +import { isPopoutWindow } from '@/lib/popout/window-role'; +import { onBeforeSignOut } from '@/lib/sign-out-cleanup'; +import { onAccountRemoved } from '@/stores/account-store'; +import { claimFilesControls } from './controls'; +import { removeAccountData, type FilesSyncHost } from './host'; + +let running: FilesSyncHost | null = null; + +export function setRunningFilesSyncHost(host: FilesSyncHost | null): void { + running = host; +} + +/** Signed out without a host running: the drive still goes. */ +async function signedOutWithoutHost(account: string): Promise { + const drive = claimFilesControls()?.drive; + if (!drive) return; + const wanted = account === 'all' ? await drive.state().then((state) => state.wanted, () => [] as string[]) : [account]; + for (const id of wanted) await drive.disable(id, 'signed-out').catch(() => {}); +} + +async function remove(account: string | null, reason: 'wipe' | 'refused'): Promise { + if (running) { + await running.removeAccountData(account, reason); + return; + } + const controls = claimFilesControls(); + if (controls) await removeAccountData(controls, account, reason); +} + +let registered = false; + +export function registerFilesSyncLifecycle(): void { + if (registered || typeof window === 'undefined' || isPopoutWindow()) return; + registered = true; + onDeviceWipe((account) => remove(account, 'wipe').catch(() => {})); + onCredentialsRejected((account, certain, kind) => { + if (certain && kind === 'refused') void remove(account, 'refused').catch(() => {}); + }); + onBeforeSignOut(async (account) => { + if (running) await running.signedOut(account).catch(() => {}); + else await signedOutWithoutHost(account); + }); + onAccountRemoved((account, reason) => { + // A wipe has its own way out, above. + if (reason === 'wiped') return; + if (running) void running.signedOut(account).catch(() => {}); + else void signedOutWithoutHost(account); + }); +} + +/** Tests only. */ +export function resetFilesSyncLifecycleForTests(): void { + registered = false; + running = null; +} diff --git a/lib/files-sync/names.ts b/lib/files-sync/names.ts new file mode 100644 index 000000000..ec70b3df7 --- /dev/null +++ b/lib/files-sync/names.ts @@ -0,0 +1,148 @@ +/** + * Names and paths on the computer's side of Files on this computer. + * + * A name in Files can be one the disk can't hold. Windows refuses `< > : " / \ | ? *`, control + * characters, a trailing dot or space, and the device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9, + * with any extension). Such a name gets a local one: the forbidden characters become their + * fullwidth forms, and a trailing dot or space or a device name gets `_`. Two names in one folder + * that differ only in case can't both be on a case-insensitive disk (Windows, macOS by default): + * the second gets ` (2)`. The engine keeps both names in its record, so the local name never + * reads as a rename, and the name in Files never changes for the disk's sake. + * + * Paths are relative to a root, `/` between parts, as main takes them. + */ + +export interface DiskNaming { + /** The disk takes only what Windows takes (Windows itself; any disk the drive is on). */ + windowsNames: boolean; + /** Two names differing only in case are one name on this disk. */ + caseInsensitive: boolean; +} + +export function namingFor(platform: 'win32' | 'darwin' | 'linux' | string): DiskNaming { + if (platform === 'win32') return { windowsNames: true, caseInsensitive: true }; + if (platform === 'darwin') return { windowsNames: false, caseInsensitive: true }; + return { windowsNames: false, caseInsensitive: false }; +} + +const FULLWIDTH: Record = { + '<': '<', + '>': '>', + ':': ':', + '"': '"', + '/': '/', + '\\': '\', + '|': '|', + '?': '?', + '*': '*', +}; + +const RESERVED = /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])$/i; + +function stemOf(name: string): string { + const dot = name.indexOf('.'); + return dot > 0 ? name.slice(0, dot) : name; +} + +/** The name as the disk can hold it. The same name back when it already can. */ +export function diskName(name: string, naming: DiskNaming): string { + let out = name; + if (naming.windowsNames) { + out = [...out].map((ch) => { + if (FULLWIDTH[ch]) return FULLWIDTH[ch]; + const code = ch.codePointAt(0) ?? 0; + // Control characters: the Control Pictures block keeps them visible and distinct. + if (code < 0x20) return String.fromCodePoint(0x2400 + code); + return ch; + }).join(''); + if (/[. ]$/.test(out)) out = `${out}_`; + const stem = stemOf(out); + if (RESERVED.test(stem.trimEnd())) out = `${stem}_${out.slice(stem.length)}`; + } else { + out = out.replace(/\//g, FULLWIDTH['/']).replace(/\0/g, '␀'); + } + return out; +} + +/** Whether the disk would refuse this name as it is. */ +export function needsDiskName(name: string, naming: DiskNaming): boolean { + return diskName(name, naming) !== name; +} + +/** The key two names on this disk share when they are the same name. */ +export function nameKey(name: string, naming: DiskNaming): string { + return naming.caseInsensitive ? name.normalize('NFC').toLocaleLowerCase('en-US') : name.normalize('NFC'); +} + +export function pathKey(path: string, naming: DiskNaming): string { + return path.split('/').map((part) => nameKey(part, naming)).join('/'); +} + +/** "report.pdf" with n = 2: "report (2).pdf". A dot at the start is a hidden name, not an extension. */ +export function numberedName(name: string, n: number): string { + const dot = name.lastIndexOf('.'); + if (dot > 0) return `${name.slice(0, dot)} (${n})${name.slice(dot)}`; + return `${name} (${n})`; +} + +/** + * A free local name for `wanted` in a folder whose other names are `taken` (as nameKey gives + * them): the disk's form of it, numbered when that's already there. + */ +export function freeLocalName(wanted: string, taken: ReadonlySet, naming: DiskNaming): string { + const base = diskName(wanted, naming); + if (!taken.has(nameKey(base, naming))) return base; + for (let n = 2; ; n += 1) { + const candidate = numberedName(base, n); + if (!taken.has(nameKey(candidate, naming))) return candidate; + } +} + +/** + * The name the copy that lost a conflict is kept under, next to the other: + * "report (conflict, Laptop, 2026-11-04 15.30).pdf". + */ +export function conflictName(name: string, device: string, at: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + const stamp = `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}.${pad(at.getMinutes())}`; + const label = `conflict, ${device.replace(/[<>:"/\\|?*]/g, '').trim() || 'this computer'}, ${stamp}`; + const dot = name.lastIndexOf('.'); + if (dot > 0) return `${name.slice(0, dot)} (${label})${name.slice(dot)}`; + return `${name} (${label})`; +} + +/** + * Files the computer makes for itself in any folder: Explorer's and Finder's folder settings and + * thumbnails, Office's owner files while a document is open, macOS's resource forks on other + * disks. They never go up; the same names coming down from Files are written like any other. + */ +export function isSystemFileName(name: string): boolean { + const lower = name.toLowerCase(); + return lower === 'desktop.ini' || lower === 'thumbs.db' || lower === '.ds_store' || name.startsWith('~$') || name.startsWith('._'); +} + +export function parentPath(path: string): string { + const slash = path.lastIndexOf('/'); + return slash < 0 ? '' : path.slice(0, slash); +} + +export function baseName(path: string): string { + const slash = path.lastIndexOf('/'); + return slash < 0 ? path : path.slice(slash + 1); +} + +export function joinPath(parent: string, name: string): string { + return parent ? `${parent}/${name}` : name; +} + +export function depthOf(path: string): number { + return path ? path.split('/').length : 0; +} + +/** Whether `path` is `folder` or inside it. */ +export function isUnderPath(path: string, folder: string, naming?: DiskNaming): boolean { + if (folder === '') return true; + const a = naming ? pathKey(path, naming) : path; + const b = naming ? pathKey(folder, naming) : folder; + return a === b || a.startsWith(`${b}/`); +} diff --git a/lib/files-sync/remote.ts b/lib/files-sync/remote.ts new file mode 100644 index 000000000..f63fb34d4 --- /dev/null +++ b/lib/files-sync/remote.ts @@ -0,0 +1,450 @@ +/** + * The Files side of Files on this computer: one account's FileNode tree, read once and then + * kept current with FileNode/changes, and the writes the engine makes. + * + * It goes through a JmapTransport (lib/store/engine/transport.ts): the host gives it one that + * borrows the app's own JMAP session and Authorization header, so nothing new holds a token or a + * password. Tests give it a fake server, and the real-server tests a FetchTransport. + * + * Reading the whole tree is done as lib/store/files/sync.ts does it, because neither server + * gives it in one call: `FileNode/get` with ids null stops at maxObjectsInGet on 0.16 and is + * refused on 1.0 (`requestTooLarge`); past that, ids come from paging FileNode/query from the + * last id read (`anchor`), and before 0.16.6 that query leaves out folders, so parents still + * unknown afterwards are read by id. Unlike the Files screen, a listing that breaks off fails: + * a partial tree would read as files deleted. + * + * Writes follow lib/jmap/client.ts: a folder is a node without blobId, type or size (0.16 makes + * a 0-byte file of anything else); Stalwart 1.0 wants `@type` and `mediaType` in a create; size + * is never sent; `onExists: "rename"` is not used here, since a name the engine didn't choose + * would be a second copy the disk doesn't know about. + */ + +import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from '@/lib/file-name-rules'; +import { fileNodeKind, fromDraftFileNode, toDraftFileNodeCreate } from '@/lib/jmap/filenode-kind'; +import { decodeFileNodeName } from '@/lib/jmap/filenode-name'; +import type { FileNode } from '@/lib/jmap/types'; +import { mediaTypeEssence, OCTET_STREAM } from '@/lib/media-type'; +import { TRASH_FOLDER_NAME, trashBatchName } from '@/lib/files-trash'; +import { runChangesLoop } from '@/lib/store/engine/changes'; +import { requestLimits } from '@/lib/store/engine/limits'; +import { JmapMethodError, responseFor, type JmapTransport, type MethodCall, type StoreSession } from '@/lib/store/engine/transport'; + +export const FILES_CAPABILITY = 'urn:ietf:params:jmap:filenode'; +export const FILES_USING = ['urn:ietf:params:jmap:core', FILES_CAPABILITY]; +const PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'modified', 'nodeType', '@type', 'mediaType']; +const QUERY_PAGE = 5000; +const MOST_GETS_PER_REQUEST = 8; +const MOST_IDS = 2_000_000; + +export interface RemoteNode { + id: string; + parentId: string | null; + name: string; + kind: 'file' | 'directory'; + blobId: string | null; + size: number; + /** Milliseconds; 0 when the server gave none. */ + modifiedMs: number; + type: string; +} + +/** A set call the server refused for one object. */ +export class RemoteSetError extends Error { + constructor(readonly type: string, description?: string) { + super(description ? `${type}: ${description}` : type); + this.name = 'RemoteSetError'; + } +} + +type Wire = Record & { id: string }; + +/** A node as the engine uses it, from either draft. Symlinks (1.0) are left out: nothing to sync. */ +export function nodeFromWire(raw: Wire): RemoteNode | null { + const node = fromDraftFileNode(raw as unknown as FileNode & { '@type'?: unknown; mediaType?: unknown }); + const kind = fileNodeKind(node); + if (kind === 'symlink') return null; + const modified = typeof node.modified === 'string' ? Date.parse(node.modified) : NaN; + return { + id: String(node.id), + parentId: node.parentId == null ? null : String(node.parentId), + name: decodeFileNodeName(String(node.name ?? '')), + kind, + blobId: kind === 'file' ? (node.blobId ?? null) : null, + size: kind === 'file' && typeof node.size === 'number' ? node.size : 0, + modifiedMs: Number.isFinite(modified) ? modified : 0, + type: typeof node.type === 'string' && node.type ? node.type : OCTET_STREAM, + }; +} + +export interface ChangeBatch { + changed: RemoteNode[]; + destroyed: string[]; +} + +export class FilesRemote { + constructor( + readonly transport: JmapTransport, + private readonly session: () => StoreSession, + ) {} + + /** The account Files lives in. */ + jmap(): string { + const session = this.session(); + return session.primaryAccounts[FILES_CAPABILITY] ?? session.primaryAccounts['urn:ietf:params:jmap:core'] ?? Object.keys(session.accounts)[0] ?? ''; + } + + supported(): boolean { + const session = this.session(); + const account = session.accounts[this.jmap()]; + return !!session.capabilities[FILES_CAPABILITY] && !!account?.accountCapabilities?.[FILES_CAPABILITY]; + } + + /** The largest upload the server takes, in bytes. 0: it doesn't say. */ + maxUpload(): number { + const core = this.session().capabilities['urn:ietf:params:jmap:core'] as { maxSizeUpload?: unknown } | undefined; + return typeof core?.maxSizeUpload === 'number' && core.maxSizeUpload > 0 ? core.maxSizeUpload : 0; + } + + private limits(): Record | undefined { + const session = this.session(); + return (session.accounts[this.jmap()]?.accountCapabilities?.[FILES_CAPABILITY] ?? session.capabilities[FILES_CAPABILITY]) as Record | undefined; + } + + nameRules(): FileNameRules | null { + return fileNameRulesFrom(this.limits()); + } + + /** Stalwart 1.0 (File Storage draft 18): `@type` and `mediaType` in a create. */ + private usesDraftCreate(): boolean { + const limits = this.limits(); + return !!limits && 'supportedNodeTypes' in limits; + } + + /** Before 0.16.6: 30 characters of media type at most. */ + private legacy(): boolean { + const limits = this.limits(); + return !!limits && !('forbiddenNameChars' in limits); + } + + private mediaType(type: string): string { + const essence = mediaTypeEssence(type); + return this.legacy() && essence.length > 30 ? OCTET_STREAM : essence; + } + + /** The name a new node gets on this server: what the disk had, made acceptable. */ + acceptedName(name: string): string { + return acceptedFileName(name, this.nameRules()); + } + + private async fetchByIds(ids: string[]): Promise { + const jmap = this.jmap(); + const { maxObjectsInGet, maxCallsInRequest } = requestLimits(this.session()); + const perRequest = Math.min(MOST_GETS_PER_REQUEST, maxCallsInRequest); + const out: Wire[] = []; + for (let at = 0; at < ids.length;) { + const calls: MethodCall[] = []; + for (let i = 0; i < perRequest && at < ids.length; i += 1, at += maxObjectsInGet) { + calls.push(['FileNode/get', { accountId: jmap, ids: ids.slice(at, at + maxObjectsInGet), properties: PROPERTIES }, `g${i}`]); + } + const response = await this.transport.request(calls, FILES_USING); + for (const [, , callId] of calls) out.push(...(responseFor(response, callId)[1].list as Wire[])); + } + return out; + } + + async get(ids: string[]): Promise { + if (ids.length === 0) return []; + return (await this.fetchByIds(ids)).map(nodeFromWire).filter((node): node is RemoteNode => !!node); + } + + /** The state to go on from: read before the listing, so nothing that changes meanwhile is missed. */ + async currentState(): Promise { + const response = await this.transport.request([['FileNode/get', { accountId: this.jmap(), ids: [], properties: ['id'] }, 's']], FILES_USING); + return String(responseFor(response, 's')[1].state); + } + + /** Every node of the account, and the state it was read at. Throws rather than give part of it. */ + async listAll(): Promise<{ nodes: RemoteNode[]; state: string }> { + const jmap = this.jmap(); + const state = await this.currentState(); + const known = new Map(); + const { maxObjectsInGet } = requestLimits(this.session()); + let complete = false; + try { + const response = await this.transport.request([['FileNode/get', { accountId: jmap, ids: null, properties: PROPERTIES }, 'a']], FILES_USING); + const list = responseFor(response, 'a')[1].list as Wire[]; + for (const node of list) known.set(String(node.id), node); + complete = list.length < maxObjectsInGet; + } catch (error) { + if (!((error as Error)?.name === 'JmapMethodError' && (error as JmapMethodError).type === 'requestTooLarge')) throw error; + } + if (!complete) { + let anchor: string | null = null; + let position = 0; + for (let pages = 0; position < MOST_IDS; pages += 1) { + const from: Record = anchor === null ? { position: 0 } : { anchor, anchorOffset: 1 }; + const response = await this.transport.request([['FileNode/query', { accountId: jmap, filter: {}, ...from, limit: QUERY_PAGE, calculateTotal: true }, 'q']], FILES_USING); + let query: { ids: string[]; position?: number; total?: number }; + try { + [, query] = responseFor(response, 'q'); + } catch (error) { + if ((error as JmapMethodError)?.type === 'anchorNotFound' && pages < 50) { + anchor = null; + position = 0; + continue; + } + throw error; + } + const ids = query.ids ?? []; + const missing = ids.filter((id) => !known.has(id)); + for (const node of await this.fetchByIds(missing)) known.set(String(node.id), node); + position = Number(query.position ?? 0) + ids.length; + if (ids.length > 0) anchor = ids[ids.length - 1]; + if (ids.length === 0 || (typeof query.total === 'number' && position >= query.total)) break; + } + const asked = new Set(); + for (;;) { + const parents = new Set(); + for (const node of known.values()) { + const parent = node.parentId == null ? null : String(node.parentId); + if (parent && !known.has(parent) && !asked.has(parent)) parents.add(parent); + } + if (parents.size === 0) break; + for (const id of parents) asked.add(id); + for (const node of await this.fetchByIds([...parents])) known.set(String(node.id), node); + } + } + const nodes = [...known.values()].map(nodeFromWire).filter((node): node is RemoteNode => !!node); + return { nodes, state }; + } + + /** + * What changed since `since`, page by page. `resync`: the server can't say (the state is too + * old or unknown), and the tree has to be read again. + */ + async changes(since: string, apply: (batch: ChangeBatch, newState: string) => void): Promise<{ outcome: 'done' | 'resync'; state: string }> { + const outcome = await runChangesLoop({ + transport: this.transport, + type: 'FileNode', + jmap: this.jmap(), + using: FILES_USING, + since, + apply: async (page) => { + const ids = [...new Set([...page.created, ...page.updated])]; + const wires = await this.fetchByIds(ids); + const seen = new Set(wires.map((node) => String(node.id))); + const changed = wires.map(nodeFromWire).filter((node): node is RemoteNode => !!node); + // Asked for and gone already: as good as destroyed. A symlink is nothing to this side either. + const gone = ids.filter((id) => !seen.has(id) || !changed.some((node) => node.id === id)); + apply({ changed, destroyed: [...page.destroyed, ...gone] }, page.newState); + }, + }); + return { outcome: outcome.outcome === 'resync' ? 'resync' : 'done', state: outcome.state }; + } + + private async set(args: Record): Promise> { + const response = await this.transport.request([['FileNode/set', { accountId: this.jmap(), ...args }, 's']], FILES_USING); + return responseFor(response, 's')[1] as Record; + } + + private async createNode(props: Record): Promise { + const wire = this.usesDraftCreate() ? toDraftFileNodeCreate(props) : props; + const result = await this.set({ create: { c: wire } }); + const created = (result.created as Record | undefined)?.c; + if (!created) { + const error = (result.notCreated as Record | undefined)?.c; + throw new RemoteSetError(error?.type ?? 'serverFail', error?.description); + } + // A create answers with what the server set; the rest is what was sent. + const [node] = await this.get([String(created.id)]); + if (node) return node; + const merged = nodeFromWire({ ...wire, ...created, id: String(created.id) } as Wire); + if (!merged) throw new RemoteSetError('serverFail', 'The new node is gone'); + return merged; + } + + createFolder(name: string, parentId: string | null): Promise { + return this.createNode({ name, ...(parentId !== null ? { parentId } : {}) }); + } + + createFile(name: string, parentId: string | null, blob: { blobId: string; type: string }): Promise { + return this.createNode({ name, type: this.mediaType(blob.type), blobId: blob.blobId, ...(parentId !== null ? { parentId } : {}) }); + } + + private async updateOne(id: string, patch: Record): Promise { + const result = await this.set({ update: { [id]: patch } }); + const refused = (result.notUpdated as Record | undefined)?.[id]; + if (refused) throw new RemoteSetError(refused.type ?? 'serverFail', refused.description); + } + + async move(id: string, change: { name?: string; parentId?: string | null }): Promise { + await this.updateOne(id, change); + const [node] = await this.get([id]); + if (!node) throw new RemoteSetError('notFound'); + return node; + } + + /** New content for a file. `modified: null` has the server stamp it (1.0 keeps a set one otherwise). */ + async replaceBlob(id: string, blobId: string): Promise { + await this.updateOne(id, { blobId, modified: null }); + const [node] = await this.get([id]); + if (!node) throw new RemoteSetError('notFound'); + return node; + } + + /** + * Into Files' trash, the way the Files screen does it (lib/files-trash.ts): a folder per delete + * inside `/.Trash`, named after when and where from, so Restore puts the items back. + */ + async trash(ids: string[], fromParentId: string | null, trashId: string | null, now = Date.now()): Promise<{ trashId: string; batchId: string }> { + let trash = trashId; + if (!trash) trash = (await this.createFolder(TRASH_FOLDER_NAME, null)).id; + const batch = await this.createFolder(trashBatchName(fromParentId, now), trash); + const result = await this.set({ update: Object.fromEntries(ids.map((id) => [id, { parentId: batch.id }])) }); + const refused = Object.entries((result.notUpdated as Record | undefined) ?? {}); + if (refused.length > 0) { + const gone = refused.every(([, error]) => error?.type === 'notFound'); + if (refused.length === ids.length) await this.destroy([batch.id]).catch(() => {}); + if (!gone) throw new RemoteSetError(refused[0][1]?.type ?? 'serverFail', refused[0][1]?.description); + } + return { trashId: trash, batchId: batch.id }; + } + + async destroy(ids: string[]): Promise { + const result = await this.set({ destroy: ids, onDestroyRemoveChildren: true }); + const refused = Object.values((result.notDestroyed as Record | undefined) ?? {}) + .filter((error) => error?.type !== 'notFound'); + if (refused.length > 0) throw new RemoteSetError(refused[0]?.type ?? 'serverFail', refused[0]?.description); + } + + upload(data: Uint8Array, type: string): Promise<{ blobId: string; type: string; size: number }> { + return this.transport.upload(this.jmap(), data, type || OCTET_STREAM); + } + + /** A file's bytes, piece by piece. An error thrown by `sink` stops it and comes out as it is. */ + async download(node: Pick, sink: (part: Uint8Array) => void): Promise { + if (!node.blobId) throw new RemoteSetError('notFound', 'No content'); + const jmap = this.jmap(); + if (this.transport.downloadStream) { + await this.transport.downloadStream(jmap, node.blobId, node.type || OCTET_STREAM, node.name || 'file', sink); + return; + } + sink(await this.transport.download(jmap, node.blobId, node.type || OCTET_STREAM, node.name || 'file')); + } +} + +/** One account's tree in memory, with where each node is. */ +export class RemoteTree { + readonly nodes = new Map(); + private childIndex: Map> | null = null; + state: string | null = null; + /** + * Nodes the engine wrote since `state`. FileNode/changes leaves out a node created and + * destroyed after the state it starts from, so one of these gone meanwhile is never said + * to be gone: they are looked at once the changes are read. + */ + readonly written = new Set(); + + reset(nodes: RemoteNode[], state: string): void { + this.nodes.clear(); + this.written.clear(); + for (const node of nodes) this.nodes.set(node.id, node); + this.state = state; + this.childIndex = null; + } + + apply(batch: ChangeBatch): void { + for (const id of batch.destroyed) this.nodes.delete(id); + for (const node of batch.changed) this.nodes.set(node.id, node); + // A folder destroyed takes what was in it; the server says so too, but not always in the same page. + if (batch.destroyed.length > 0) { + let removed = true; + while (removed) { + removed = false; + for (const node of this.nodes.values()) { + if (node.parentId && !this.nodes.has(node.parentId)) { + this.nodes.delete(node.id); + removed = true; + } + } + } + } + this.childIndex = null; + } + + /** What the engine itself wrote. */ + put(node: RemoteNode): void { + this.nodes.set(node.id, node); + this.written.add(node.id); + this.childIndex = null; + } + + remove(id: string): void { + for (const child of this.children(id)) this.remove(child.id); + this.nodes.delete(id); + this.childIndex = null; + } + + get(id: string): RemoteNode | undefined { + return this.nodes.get(id); + } + + children(parentId: string | null): RemoteNode[] { + if (!this.childIndex) { + this.childIndex = new Map(); + for (const node of this.nodes.values()) { + const key = node.parentId ?? ''; + let set = this.childIndex.get(key); + if (!set) this.childIndex.set(key, (set = new Set())); + set.add(node.id); + } + } + return [...(this.childIndex.get(parentId ?? '') ?? [])].map((id) => this.nodes.get(id)).filter((node): node is RemoteNode => !!node); + } + + /** The Files trash folder (`/.Trash`), if there is one. */ + trashId(): string | null { + for (const node of this.children(null)) { + if (node.name === TRASH_FOLDER_NAME && node.kind === 'directory') return node.id; + } + return null; + } + + /** Whether `id` is `ancestor` or below it. `ancestor` null: the top of Files. */ + isUnder(id: string, ancestor: string | null): boolean { + let current: string | null = id; + for (let hops = 0; current !== null && hops < 10_000; hops += 1) { + if (current === ancestor) return true; + const node = this.nodes.get(current); + if (!node) return false; + current = node.parentId; + } + return ancestor === null && current === null; + } + + /** Whether the node is in the trash. */ + inTrash(id: string): boolean { + const trash = this.trashId(); + return !!trash && this.isUnder(id, trash); + } + + /** + * Everything below `rootId` (null: the whole account, minus the trash), parents before + * children. Null when the root folder itself is gone or in the trash. + */ + subtree(rootId: string | null): RemoteNode[] | null { + if (rootId !== null && (!this.nodes.has(rootId) || this.inTrash(rootId))) return null; + const trash = this.trashId(); + const out: RemoteNode[] = []; + const queue: (string | null)[] = [rootId]; + while (queue.length > 0) { + const parent = queue.shift() as string | null; + for (const child of this.children(parent)) { + if (child.id === trash) continue; + out.push(child); + if (child.kind === 'directory') queue.push(child.id); + } + } + return out; + } +} diff --git a/lib/files-sync/root-sync.ts b/lib/files-sync/root-sync.ts new file mode 100644 index 000000000..22e865093 --- /dev/null +++ b/lib/files-sync/root-sync.ts @@ -0,0 +1,1327 @@ +/** + * One root of Files on this computer: a folder the user paired with a Files folder, or the + * Windows drive (the whole account). Each pass is a three-way comparison of what both sides + * were at the last sync (state.ts) with the disk now (main's scan) and Files now (the account's + * tree, remote.ts), and carries out what that calls for. Nothing is decided from one side alone: + * an item missing on one side was deleted there only if the record says it was synced before. + * + * A pass goes in rounds. Each round scans the disk again and does what it can: moves, changed + * content, new items, then deletes. What has to wait for something else of the same pass (a + * file moved into a folder that's new on the other side) is done in the next round. A pass + * ends when a round has nothing left to do. + * + * Rules (FILES-SYNC-BRIEF.md): + * - Renames and moves are found by identity, never by name: the FileNode id in Files, the + * file id (or the placeholder's identity on the drive) on disk. A path that's still there + * counts first, so an editor that saves by writing a new file and renaming it over the old + * one is an edit, not a delete and a create. + * - Both sides changed a file: neither is lost. The copy on this computer is kept as + * "name (conflict, , ).ext" next to the other and uploaded, and Files' version + * takes the name. Both changed where it is: Files' place wins. + * - Deleted on one side, changed on the other: the change wins and comes back. A folder is + * only deleted when nothing in it is new or changed on the other side. + * - Deleted on this computer goes to Files' trash (lib/files-trash.ts); deleted in Files goes + * to the computer's trash where main has one. + * - Many deletes at once (more than 50 items, or more than half of a root of 10 or more) + * stop the root and ask. No: what was deleted comes back from the other side. + * - A file over the server's upload limit stays on this computer, said once, and isn't tried + * again until it changes. + * - The server out of reach: nothing is lost, the record isn't moved on, and the pass runs + * again later. That is the offline queue. + */ + +import type { + FilesControls, + FilesError, + FilesExpect, + FilesLocalEntry, + FilesPlaceholder, + FilesResult, + FilesRoot, +} from '@/lib/desktop/types'; +import { errorKey, isAuthRefused, isOffline, LocalError, localErrorName, remoteErrorName, type FilesSyncErrorName } from './errors'; +import { + baseName, + conflictName, + depthOf, + diskName, + freeLocalName, + isSystemFileName, + isUnderPath, + joinPath, + nameKey, + parentPath, + pathKey, + type DiskNaming, +} from './names'; +import type { FilesRemote, RemoteNode, RemoteTree } from './remote'; +import { emptyRecord, parseRecord, serializeRecord, type BaseItem, type RootRecord } from './state'; +import type { FilesSyncActivityKind, FilesSyncItemStatus, FilesSyncRootState, FilesSyncRootStatus } from './types'; + +const MAX_ROUNDS = 12; +/** Both sides have a file of this size or less under one name: compare the bytes before calling it a conflict. */ +const COMPARE_LIMIT = 64 * 1024 * 1024; +const PIECE = 1024 * 1024; +const PLACE_BATCH = 1000; +export const MASS_DELETE_COUNT = 50; +export const MASS_DELETE_MIN_ROOT = 10; + +export interface ActivityInput { + rootId: string; + kind: FilesSyncActivityKind; + direction: 'up' | 'down' | null; + path: string; + detail?: string; + messageKey?: string; +} + +/** The account a root belongs to, as the engine keeps it. */ +export interface AccountLink { + account: string; + remote: FilesRemote; + tree: RemoteTree; + /** Brings the tree up to date with the server (FileNode/changes, or a full listing). */ + refresh(): Promise; +} + +export interface RootHost { + controls: FilesControls; + naming: DiskNaming; + /** This computer's name, for conflict copies. */ + device: string; + now(): number; + readUrl(url: string): Promise; + activity(entry: ActivityInput): void; + /** Status or items changed. */ + changed(rootId: string): void; + /** The organisation's word: null when it says nothing against syncing this root. */ + blocked(root: FilesRoot): FilesSyncErrorName | null; +} + +interface PendingDelete { + count: number; + side: 'local' | 'remote'; + /** Base ids whose deletion waits for the answer. */ + ids: string[]; +} + +class Stop extends Error { + constructor(readonly why: 'inactive' | 'offline' | 'auth' | 'not_found' | 'scan', readonly cause?: unknown) { + super(why); + this.name = 'RootStop'; + } +} + +function expectOf(entry: FilesLocalEntry): FilesExpect { + return { fileId: entry.fileId, size: entry.size, mtimeMs: entry.mtimeMs }; +} + +function unwrap(result: FilesResult): T { + if (!result.ok) throw new LocalError(result.error); + return result.value; +} + +function errorOfLocal(error: unknown): FilesError | null { + return (error as { name?: string })?.name === 'LocalError' ? (error as LocalError).error : null; +} + +function sameBytes(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false; + return true; +} + +/** What one round knows of the disk, kept up to date with what the round itself does there. */ +class LocalIndex { + readonly byKey = new Map(); + /** Taken names per folder, as nameKey gives them. */ + private readonly names = new Map>(); + + constructor(entries: FilesLocalEntry[], private readonly naming: DiskNaming) { + for (const entry of entries) this.add(entry); + } + + private folder(path: string): Set { + const key = pathKey(parentPath(path), this.naming); + let set = this.names.get(key); + if (!set) this.names.set(key, (set = new Set())); + return set; + } + + add(entry: FilesLocalEntry): void { + this.byKey.set(pathKey(entry.path, this.naming), entry); + this.folder(entry.path).add(nameKey(baseName(entry.path), this.naming)); + } + + get(path: string): FilesLocalEntry | undefined { + return this.byKey.get(pathKey(path, this.naming)); + } + + delete(path: string): void { + const key = pathKey(path, this.naming); + for (const [other, entry] of [...this.byKey]) { + if (other === key || other.startsWith(`${key}/`)) { + this.byKey.delete(other); + this.folder(entry.path).delete(nameKey(baseName(entry.path), this.naming)); + } + } + } + + /** Names taken in `folder`, without `except`. */ + taken(folder: string, except?: string): Set { + const set = new Set(this.names.get(pathKey(folder, this.naming)) ?? []); + if (except !== undefined && parentPath(except) === folder) set.delete(nameKey(baseName(except), this.naming)); + return set; + } + + /** A folder moved: everything in it moves with it. Answers the moved entries, old path to new. */ + move(from: string, to: string, moved: FilesLocalEntry): Map { + const changed = new Map(); + const fromKey = pathKey(from, this.naming); + const below = [...this.byKey].filter(([key]) => key.startsWith(`${fromKey}/`)); + this.delete(from); + this.add(moved); + changed.set(from, moved); + for (const [, entry] of below) { + const next = { ...entry, path: to + entry.path.slice(from.length) }; + this.add(next); + changed.set(entry.path, next); + } + return changed; + } +} + +export class RootSync { + root: FilesRoot; + status: FilesSyncRootStatus; + items: Record = {}; + private record: RootRecord | null = null; + private readonly base = new Map(); + private massDelete: PendingDelete | null = null; + private allowDeletes = false; + private readonly failed = new Set(); + /** Local paths skipped for the rest of a pass: changed while read, or failed. */ + private readonly skipPaths = new Set(); + /** Something changed while it was looked at: another pass, soon. */ + private retrySoon = false; + private lastSeen = new Map(); + private dirty = false; + private saveChain: Promise = Promise.resolve(); + private waitingSaid = false; + /** Nodes being placed by this pass, for items: `syncing`. */ + private busy = new Set(); + /** Says the root was stopped for good: no pass runs again. */ + stopped = false; + + constructor(root: FilesRoot, private readonly account: AccountLink, private readonly host: RootHost) { + this.root = root; + this.status = { rootId: root.id, state: root.paused ? 'paused' : 'starting', pending: 0, done: 0, lastSyncedAt: null }; + } + + get isDrive(): boolean { + return this.root.kind === 'drive'; + } + + get massDeletePending(): boolean { + return this.massDelete !== null; + } + + /** The record's items, for coverage. */ + item(id: string): BaseItem | undefined { + return this.base.get(id); + } + + setRoot(root: FilesRoot): void { + this.root = root; + if (root.paused) this.setStatus({ state: 'paused' }); + } + + private setStatus(patch: Partial): void { + this.status = { ...this.status, ...patch, rootId: this.root.id }; + // `blocked` says why too. + if (this.status.state !== 'error' && this.status.state !== 'blocked') delete this.status.errorKey; + if (this.status.state !== 'confirm-delete') delete this.status.massDelete; + this.host.changed(this.root.id); + } + + private log(kind: FilesSyncActivityKind, direction: 'up' | 'down' | null, path: string, extra: { detail?: string; messageKey?: string } = {}): void { + this.host.activity({ rootId: this.root.id, kind, direction, path, ...extra }); + } + + private logError(path: string, name: FilesSyncErrorName, direction: 'up' | 'down' | null = null): void { + this.log('error', direction, path, { messageKey: errorKey(name) }); + } + + // ─── The record ───────────────────────────────────────────── + + private async load(): Promise { + if (this.record) return; + let raw: string | null; + try { + raw = await this.host.controls.loadState(this.root.id); + } catch { + // Locked, or the root went: not "never synced", which would put both sides together anew. + throw new Stop('inactive'); + } + this.record = parseRecord(raw, this.root.remoteId); + this.base.clear(); + for (const item of this.record.items) this.base.set(item.id, item); + } + + private snapshot(): string { + const record = this.record ?? emptyRecord(this.root.remoteId); + return serializeRecord({ ...record, items: [...this.base.values()] }); + } + + /** Writes the record; a failed write is tried again with the next. */ + private save(): Promise { + if (!this.record) return Promise.resolve(); + const text = this.snapshot(); + this.saveChain = this.saveChain.then(() => this.host.controls.saveState(this.root.id, text)).catch(() => { + this.dirty = true; + }); + return this.saveChain; + } + + /** Forgets the record: the drive's folder went with a sign-out, and the next sign-in starts afresh. */ + async forget(): Promise { + this.base.clear(); + this.record = emptyRecord(this.root.remoteId); + await this.host.controls.saveState(this.root.id, serializeRecord(this.record)).catch(() => {}); + } + + // ─── Mass delete ──────────────────────────────────────────── + + /** The user's answer. No: what was deleted on one side comes back from the other. */ + answerMassDelete(allow: boolean): void { + const pending = this.massDelete; + if (!pending) return; + this.massDelete = null; + if (allow) { + this.allowDeletes = true; + } else { + for (const id of pending.ids) this.dropWithBelow(id); + void this.save(); + } + this.setStatus({ state: 'syncing' }); + } + + private dropWithBelow(id: string): void { + const item = this.base.get(id); + if (!item) return; + for (const other of [...this.base.values()]) { + if (other.id === id || isUnderPath(other.path, item.path)) this.base.delete(other.id); + } + } + + // ─── A pass ───────────────────────────────────────────────── + + /** + * One pass. Answers what the runner should do next: `idle`, `retry` (offline: later, with a + * longer wait each time), `soon` (something changed while it ran, or is being written now). + */ + async pass(): Promise<'idle' | 'retry' | 'soon' | 'stopped'> { + if (this.stopped) return 'stopped'; + if (this.root.paused) { + this.setStatus({ state: 'paused' }); + return 'idle'; + } + const blocked = this.host.blocked(this.root); + if (blocked) { + this.setStatus({ state: 'blocked', errorKey: errorKey(blocked) }); + return 'idle'; + } + if (this.massDelete) { + this.setStatus({ state: 'confirm-delete', massDelete: { count: this.massDelete.count, side: this.massDelete.side } }); + return 'idle'; + } + this.failed.clear(); + this.skipPaths.clear(); + this.retrySoon = false; + this.setStatus({ state: this.record ? 'syncing' : 'starting', done: 0 }); + let soon = false; + try { + await this.load(); + await this.account.refresh(); + for (let round = 0; round < MAX_ROUNDS; round += 1) { + const outcome = await this.round(); + if (outcome === 'confirm') break; + if (outcome === 0) break; + if (round === MAX_ROUNDS - 1) soon = true; + } + if (this.retrySoon) soon = true; + await this.save(); + } catch (error) { + await this.save(); + return this.stopWith(error); + } + this.allowDeletes = false; + this.waitingSaid = false; + // round() may have set it: TypeScript can't see that. + const asking = this.massDelete as PendingDelete | null; + if (asking) { + this.setStatus({ state: 'confirm-delete', massDelete: { count: asking.count, side: asking.side }, pending: asking.count }); + return 'idle'; + } + this.updateItems(); + const pending = this.failed.size; + this.setStatus({ state: 'idle', pending, lastSyncedAt: this.host.now() }); + return soon ? 'soon' : 'idle'; + } + + private stopWith(error: unknown): 'idle' | 'retry' | 'stopped' { + const why = (error as Stop)?.name === 'RootStop' ? (error as Stop).why : null; + if (why === 'inactive') { + // Paused, locked or gone meanwhile: the engine hears of it and asks for the roots again. + this.setStatus({ state: this.root.paused ? 'paused' : 'idle' }); + return 'stopped'; + } + if (why === 'not_found') { + this.setStatus({ state: 'error', errorKey: errorKey('not_found') }); + return 'idle'; + } + if (why === 'scan') { + const local = errorOfLocal((error as Stop).cause); + this.setStatus({ state: 'error', errorKey: errorKey(local ? localErrorName(local) : 'failed') }); + return 'retry'; + } + if (isOffline(error) || why === 'offline') { + if (!this.waitingSaid) { + this.waitingSaid = true; + this.log('waiting', null, '', { messageKey: errorKey('offline') }); + } + this.updateItems(); + this.setStatus({ state: 'offline', pending: Math.max(this.status.pending, this.failed.size) }); + return 'retry'; + } + if (isAuthRefused(error) || why === 'auth') { + this.setStatus({ state: 'error', errorKey: errorKey('server_refused') }); + return 'retry'; + } + console.warn('[files-sync] A pass failed:', error); + this.setStatus({ state: 'error', errorKey: errorKey(remoteErrorName(error)) }); + return 'retry'; + } + + private async scan(): Promise { + const result = await this.host.controls.scan(this.root.id); + if (!result.ok) { + if (result.error === 'inactive') throw new Stop('inactive'); + throw new Stop('scan', new LocalError(result.error)); + } + return result.value; + } + + /** Answers how many things it did, or `confirm` when a mass delete stopped it. */ + private async round(): Promise { + const entries = await this.scan(); + const tree = this.account.tree; + const subtree = tree.subtree(this.root.remoteId); + if (!subtree) throw new Stop('not_found'); + const view = new Map(subtree.map((node) => [node.id, node])); + + // The drive's folder goes with a sign-out and comes back empty: the record of the old one + // would read as everything deleted here. + if (this.isDrive && entries.length === 0 && this.base.size > 0) { + this.base.clear(); + this.record = emptyRecord(this.root.remoteId); + } + + const round = new Round(this, entries, view); + round.match(); + let did = 0; + did += await round.moves(); + did += await round.content(); + did += await round.remoteNew(); + did += await round.localNew(); + const deletes = round.planDeletes(); + if (deletes.local.count + deletes.remote.count > 0) { + const total = this.base.size; + const over = (count: number) => count > MASS_DELETE_COUNT || (total >= MASS_DELETE_MIN_ROOT && count * 2 > total); + const side = deletes.remote.count >= deletes.local.count ? 'remote' : 'local'; + if (!this.allowDeletes && (over(deletes.local.count) || over(deletes.remote.count))) { + const count = deletes.local.count + deletes.remote.count; + this.massDelete = { count, side, ids: [...deletes.local.ids, ...deletes.remote.ids] }; + this.log('mass-delete', side === 'remote' ? 'up' : 'down', '', { detail: String(count) }); + await this.save(); + return 'confirm'; + } + did += await round.deletes(deletes); + } + did += round.dropped; + this.lastSeen = round.matched; + if (did > 0) await this.save(); + return did; + } + + private updateItems(): void { + const items: Record = {}; + for (const item of this.base.values()) { + const seen = this.lastSeen.get(item.id); + if (this.failed.has(item.id)) items[item.id] = 'error'; + else if (this.isDrive && item.kind === 'file' && seen?.placeholder && !seen.placeholder.hydrated) items[item.id] = 'online-only'; + else items[item.id] = 'synced'; + } + const subtree = this.account.tree.subtree(this.root.remoteId) ?? []; + for (const node of subtree) { + if (!items[node.id]) items[node.id] = this.failed.has(node.id) ? 'error' : 'pending'; + } + for (const id of this.busy) items[id] = 'syncing'; + this.items = items; + } + + // ─── What a round uses ────────────────────────────────────── + + /** @internal */ get baseItems(): Map { + return this.base; + } + /** @internal */ get hostRef(): RootHost { + return this.host; + } + /** @internal */ get link(): AccountLink { + return this.account; + } + /** @internal */ get record_(): RootRecord { + if (!this.record) this.record = emptyRecord(this.root.remoteId); + return this.record; + } + /** @internal */ markFailed(id: string | null, path: string): void { + if (id) this.failed.add(id); + this.skipPaths.add(path); + } + /** @internal */ markRetry(path: string): void { + this.skipPaths.add(path); + this.retrySoon = true; + } + /** @internal */ noteError(id: string): void { + this.failed.add(id); + } + /** @internal */ isSkipped(path: string): boolean { + return this.skipPaths.has(path); + } + /** @internal */ noteDone(): void { + this.setStatus({ done: this.status.done + 1 }); + } + /** @internal */ logActivity(kind: FilesSyncActivityKind, direction: 'up' | 'down' | null, path: string, extra: { detail?: string; messageKey?: string } = {}): void { + this.log(kind, direction, path, extra); + } + /** @internal */ logFailure(path: string, name: FilesSyncErrorName, direction: 'up' | 'down' | null = null): void { + this.logError(path, name, direction); + } + /** @internal */ setBusy(id: string, on: boolean): void { + if (on) this.busy.add(id); + else this.busy.delete(id); + } + /** @internal */ get stateLabel(): FilesSyncRootState { + return this.status.state; + } +} + +/** One round of a pass: the disk as scanned now, Files as the tree has it now, and the record. */ +class Round { + readonly local: LocalIndex; + /** Base id to the entry it is on disk now. */ + readonly matched = new Map(); + /** Entries taken by an item: matched, paired, or made by this round. */ + private readonly claimed = new Set(); + /** Items the record dropped this round (both sides gone, or given back to one side). */ + dropped = 0; + private readonly root: FilesRoot; + private readonly base: Map; + private readonly host: RootHost; + private readonly naming: DiskNaming; + private readonly remote: FilesRemote; + private readonly tree: RemoteTree; + private readonly drive: boolean; + + constructor(private readonly sync: RootSync, entries: FilesLocalEntry[], private readonly view: Map) { + this.root = sync.root; + this.base = sync.baseItems; + this.host = sync.hostRef; + this.naming = this.host.naming; + this.remote = sync.link.remote; + this.tree = sync.link.tree; + this.drive = sync.isDrive; + this.local = new LocalIndex(entries, this.naming); + } + + private key(path: string): string { + return pathKey(path, this.naming); + } + + private claim(entry: FilesLocalEntry): void { + this.claimed.add(this.key(entry.path)); + } + + private isClaimed(entry: FilesLocalEntry): boolean { + return this.claimed.has(this.key(entry.path)); + } + + private sortedBase(): BaseItem[] { + return [...this.base.values()].sort((a, b) => depthOf(a.path) - depthOf(b.path)); + } + + // ─── Matching the record to the disk ──────────────────────── + + private fits(entry: FilesLocalEntry, item: BaseItem): boolean { + if (entry.kind !== item.kind) return false; + if (!this.drive) return true; + const identity = entry.placeholder?.identity; + return identity === undefined || identity === null || identity === item.id; + } + + match(): void { + const items = this.sortedBase(); + for (const item of items) { + const entry = this.local.get(item.path); + if (entry && !this.isClaimed(entry) && this.fits(entry, item)) { + this.matched.set(item.id, entry); + this.claim(entry); + } + } + const byFileId = new Map(); + const byIdentity = new Map(); + for (const entry of this.local.byKey.values()) { + if (this.isClaimed(entry)) continue; + if (entry.fileId) byFileId.set(entry.fileId, entry); + const identity = entry.placeholder?.identity; + if (identity) byIdentity.set(identity, entry); + } + for (const item of items) { + if (this.matched.has(item.id)) continue; + let entry = this.drive ? byIdentity.get(item.id) : undefined; + if (!entry && item.fileId) entry = byFileId.get(item.fileId); + if (entry && !this.isClaimed(entry) && entry.kind === item.kind) { + this.matched.set(item.id, entry); + this.claim(entry); + } + } + } + + /** The id of the item that is the folder at `folder` on disk now; the root's for ''. Undefined: not synced yet. */ + private idAtLocal(folder: string): string | null | undefined { + if (folder === '') return this.root.remoteId; + const key = this.key(folder); + for (const [id, entry] of this.matched) { + if (entry.kind === 'directory' && this.key(entry.path) === key) return this.view.has(id) ? id : undefined; + } + return undefined; + } + + /** Where a node's folder is on disk now: '' for the root's. Undefined: not there yet. */ + private localFolderOf(parentId: string | null): string | undefined { + if (parentId === this.root.remoteId) return ''; + if (parentId === null) return undefined; + const entry = this.matched.get(parentId); + return entry && entry.kind === 'directory' ? entry.path : undefined; + } + + private localChanged(item: BaseItem, entry: FilesLocalEntry): boolean { + if (item.kind !== 'file') return false; + if (this.drive) { + if (entry.placeholder?.inSync) return false; + // Content that isn't here can't have been changed here. + if (entry.placeholder && !entry.placeholder.hydrated) return false; + // Not in sync, and never seen in sync since it was placed: written over. + if (!item.fileId) return true; + } else if (!item.fileId) { + return false; + } + return entry.size !== item.localSize || entry.mtimeMs !== item.mtimeMs; + } + + private remoteChanged(item: BaseItem, node: RemoteNode): boolean { + return item.kind === 'file' && node.blobId !== item.blobId; + } + + private remember(item: BaseItem): void { + this.base.set(item.id, item); + } + + private adoptLocal(item: BaseItem, entry: FilesLocalEntry): BaseItem { + return { ...item, path: entry.path, fileId: entry.fileId, localSize: entry.size, mtimeMs: entry.mtimeMs }; + } + + private adoptRemote(item: BaseItem, node: RemoteNode): BaseItem { + return { ...item, name: node.name, parentId: node.parentId, blobId: node.blobId, size: node.size, modifiedMs: node.modifiedMs }; + } + + private itemOf(node: RemoteNode, entry: FilesLocalEntry | null, path: string): BaseItem { + return { + id: node.id, + kind: node.kind, + path, + name: node.name, + parentId: node.parentId, + blobId: node.blobId, + size: node.size, + modifiedMs: node.modifiedMs, + fileId: entry?.fileId ?? '', + localSize: entry?.size ?? node.size, + mtimeMs: entry?.mtimeMs ?? node.modifiedMs, + }; + } + + /** Runs one step. An error on one item is said and the item waits; offline and the like stop the pass. */ + private async step(id: string | null, path: string, direction: 'up' | 'down' | null, work: () => Promise): Promise { + if (this.sync.isSkipped(path)) return 0; + try { + if (id) this.sync.setBusy(id, true); + const did = await work(); + if (did) this.sync.noteDone(); + return did ? 1 : 0; + } catch (error) { + if (isOffline(error) || isAuthRefused(error)) throw error; + const local = errorOfLocal(error); + if (local === 'inactive') throw new Stop('inactive'); + // Changed meanwhile: the next round looks again. Not an error anyone has to see. + if (local === 'changed' || local === 'exists' || local === 'missing') { + this.sync.markRetry(path); + return 0; + } + const name = local ? localErrorName(local) : remoteErrorName(error); + if (!local && name === 'not_found') { + // Files changed under this pass: the next one reads it again. + this.sync.markRetry(path); + return 0; + } + if (!local) console.warn('[files-sync] An item failed:', error); + this.sync.markFailed(id, path); + this.sync.logFailure(path, name, direction); + return 0; + } finally { + if (id) this.sync.setBusy(id, false); + } + } + + // ─── Moves and renames ────────────────────────────────────── + + async moves(): Promise { + let did = 0; + for (const item of this.sortedBase()) { + const entry = this.matched.get(item.id); + const node = this.view.get(item.id); + if (!entry || !node) continue; + const localParent = this.idAtLocal(parentPath(entry.path)); + const localNameChanged = baseName(entry.path) !== baseName(item.path); + const localMoved = localNameChanged || localParent !== item.parentId; + const remoteMoved = node.name !== item.name || node.parentId !== item.parentId; + if (!localMoved && !remoteMoved) { + // A folder above moved: only the path is new. + if (entry.path !== item.path) this.remember({ ...item, path: entry.path }); + continue; + } + if (localMoved && !remoteMoved) { + if (localParent === undefined) continue; + did += await this.step(item.id, entry.path, 'up', async () => { + const name = localNameChanged ? this.remote.acceptedName(baseName(entry.path)) : item.name; + const change: { name?: string; parentId?: string | null } = {}; + if (name !== node.name) change.name = name; + if (localParent !== node.parentId) change.parentId = localParent; + const moved = await this.remote.move(item.id, change); + this.tree.put(moved); + this.view.set(moved.id, moved); + this.remember({ ...this.adoptRemote(item, moved), path: entry.path }); + this.sync.logActivity(change.parentId !== undefined ? 'moved' : 'renamed', 'up', entry.path, { detail: item.path }); + return true; + }); + continue; + } + // Files moved it, or both did: Files' place wins. + const folder = this.localFolderOf(node.parentId); + if (folder === undefined) continue; + const wanted = freeLocalName(node.name, this.local.taken(folder, entry.path), this.naming); + const target = joinPath(folder, wanted); + if (target === entry.path) { + this.remember({ ...this.adoptRemote(item, node), path: entry.path }); + continue; + } + did += await this.step(item.id, entry.path, 'down', async () => { + const moved = unwrap(await this.host.controls.move(this.root.id, entry.path, target, expectOf(entry))); + const changed = this.local.move(entry.path, target, moved); + for (const [id, seen] of [...this.matched]) { + const next = changed.get(seen.path); + if (next) this.matched.set(id, next); + } + this.claim(moved); + this.remember({ ...this.adoptLocal(this.adoptRemote(item, node), moved) }); + this.sync.logActivity(folder !== parentPath(entry.path) ? 'moved' : 'renamed', 'down', target, { detail: entry.path }); + if (wanted !== node.name) this.sync.logActivity('renamed-for-disk', 'down', target, { detail: wanted }); + return true; + }); + } + return did; + } + + // ─── Changed content ──────────────────────────────────────── + + async content(): Promise { + let did = 0; + for (const item of this.sortedBase()) { + if (item.kind !== 'file') continue; + const entry = this.matched.get(item.id); + const node = this.view.get(item.id); + if (!entry || !node) continue; + const localChanged = this.localChanged(item, entry); + const remoteChanged = this.remoteChanged(item, node); + if (!localChanged && !remoteChanged) { + if (this.drive && item.fileId && entry.placeholder && !entry.placeholder.inSync && entry.placeholder.hydrated) { + // The same size and time as synced: Windows dropped "in sync" for an attribute. + const marked = await this.host.controls.drive?.markInSync(this.root.id, entry.path, item.id, expectOf(entry)); + if (marked?.ok) this.remember(this.adoptLocal(item, marked.value)); + } else if (!item.fileId) { + // Just placed: what it is on disk, now that it's in sync. + this.remember(this.adoptLocal(item, entry)); + } + continue; + } + if (localChanged && !remoteChanged) { + did += await this.step(item.id, entry.path, 'up', () => this.uploadOver(item, entry, node)); + } else if (!localChanged && remoteChanged) { + did += await this.step(item.id, entry.path, 'down', () => this.downloadOver(item, entry, node)); + } else { + did += await this.step(item.id, entry.path, null, () => this.conflict(item, entry, node)); + } + } + return did; + } + + /** Reads a local file whole, and makes sure it didn't change while it was read. Null: it did. */ + private async read(entry: FilesLocalEntry): Promise<{ data: Uint8Array; entry: FilesLocalEntry } | null> { + if (this.drive && entry.placeholder && !entry.placeholder.hydrated) return null; + const opened = unwrap(await this.host.controls.openRead(this.root.id, entry.path)); + let data: Uint8Array; + try { + data = await this.host.readUrl(opened.url); + } catch { + // Main answers 409 when it changed between openRead and the read. + this.sync.markRetry(entry.path); + return null; + } + const after = unwrap(await this.host.controls.stat(this.root.id, entry.path)); + const before = opened.entry; + if (!after || after.fileId !== before.fileId || after.size !== before.size || after.mtimeMs !== before.mtimeMs || data.length !== before.size) { + this.sync.markRetry(entry.path); + return null; + } + return { data, entry: before }; + } + + /** Over the upload limit: said once, and not tried again until the file changes. */ + private tooLarge(entry: FilesLocalEntry): boolean { + const limit = this.remote.maxUpload(); + const record = this.sync.record_; + if (!limit || entry.size <= limit) { + if (record.tooLarge[entry.path]) delete record.tooLarge[entry.path]; + return false; + } + const known = record.tooLarge[entry.path]; + if (!known || known.size !== entry.size || known.mtimeMs !== entry.mtimeMs) { + record.tooLarge[entry.path] = { size: entry.size, mtimeMs: entry.mtimeMs }; + this.sync.logActivity('too-large', 'up', entry.path, { detail: String(limit), messageKey: errorKey('too_large') }); + } + return true; + } + + private async uploadBytes(entry: FilesLocalEntry, node: { type?: string } | null): Promise<{ blobId: string; type: string; read: FilesLocalEntry } | null> { + if (this.tooLarge(entry)) return null; + const read = await this.read(entry); + if (!read) return null; + const blob = await this.remote.upload(read.data, node?.type ?? typeFor(entry.path)); + return { blobId: blob.blobId, type: blob.type || node?.type || typeFor(entry.path), read: read.entry }; + } + + private async markDrive(path: string, id: string, read: FilesLocalEntry): Promise { + const drive = this.host.controls.drive; + if (!this.drive || !drive) return read; + const marked = await drive.markInSync(this.root.id, path, id, expectOf(read)); + // Renamed or changed since it was read: the next round finds it by its file id. + return marked.ok ? marked.value : read; + } + + private async uploadOver(item: BaseItem, entry: FilesLocalEntry, node: RemoteNode): Promise { + const bytes = await this.uploadBytes(entry, node); + if (!bytes) { + if (this.sync.record_.tooLarge[entry.path]) this.sync.noteError(item.id); + return false; + } + const updated = await this.remote.replaceBlob(item.id, bytes.blobId); + this.tree.put(updated); + this.view.set(updated.id, updated); + const local = await this.markDrive(entry.path, item.id, bytes.read); + this.remember(this.adoptLocal(this.adoptRemote(item, updated), local)); + this.sync.logActivity('uploaded', 'up', entry.path); + return true; + } + + /** Streams a node's content into a new file at `path`. `expect`: what's there now, null for nothing. */ + private async write(path: string, node: RemoteNode, expect: FilesExpect | null): Promise { + const controls = this.host.controls; + const writeId = unwrap(await controls.beginWrite(this.root.id, path, { expect, mtimeMs: node.modifiedMs || this.host.now(), size: node.size })); + let pending: Uint8Array[] = []; + let pendingBytes = 0; + let written = 0; + let chain: Promise = Promise.resolve(); + let failure: unknown = null; + const flush = (all: boolean) => { + if (pendingBytes === 0 || (!all && pendingBytes < PIECE)) return; + const piece = concat(pending, pendingBytes); + pending = []; + pendingBytes = 0; + written += piece.length; + chain = chain.then(async () => { + if (failure) return; + const result = await controls.writeChunk(writeId, piece); + if (!result.ok) failure = new LocalError(result.error); + }); + }; + try { + await this.remote.download(node, (part) => { + if (failure) throw failure; + pending.push(part); + pendingBytes += part.length; + flush(false); + }); + flush(true); + await chain; + if (failure) throw failure; + if (written !== node.size) throw new LocalError('failed'); + return unwrap(await controls.commitWrite(writeId)); + } catch (error) { + await chain.catch(() => {}); + await controls.abortWrite(writeId); + throw error; + } + } + + private async downloadOver(item: BaseItem, entry: FilesLocalEntry, node: RemoteNode): Promise { + if (this.drive) { + const drive = this.host.controls.drive; + if (!drive) return false; + const result = await drive.update(this.root.id, entry.path, { size: node.size, mtimeMs: node.modifiedMs || this.host.now(), identity: node.id, dehydrate: true }, expectOf(entry)); + if (!result.ok && result.error === 'changed') { + // Saved over meanwhile with a plain write: a change here, which the next round sees. + this.sync.markRetry(entry.path); + return false; + } + const updated = unwrap(result); + this.remember(this.adoptLocal(this.adoptRemote(item, node), updated)); + return true; + } + const written = await this.write(entry.path, node, expectOf(entry)); + this.local.add(written); + this.matched.set(item.id, written); + this.remember(this.adoptLocal(this.adoptRemote(item, node), written)); + this.sync.logActivity('downloaded', 'down', entry.path); + return true; + } + + /** Whether the file on disk and the node hold the same bytes. Only for files small enough to look. */ + private async sameContent(entry: FilesLocalEntry, node: RemoteNode): Promise { + if (entry.kind !== 'file' || node.kind !== 'file' || entry.size !== node.size || node.size > COMPARE_LIMIT) return false; + if (this.drive) return false; + const local = await this.read(entry); + if (!local) return false; + const parts: Uint8Array[] = []; + let size = 0; + await this.remote.download(node, (part) => { + parts.push(part); + size += part.length; + }); + return sameBytes(local.data, concat(parts, size)); + } + + /** A free name for the copy that lost, next to `path`. */ + private conflictPath(path: string): string { + const folder = parentPath(path); + const name = conflictName(baseName(path), this.host.device, new Date(this.host.now())); + return joinPath(folder, freeLocalName(name, this.local.taken(folder), this.naming)); + } + + /** + * Both sides changed the file. The disk's copy moves aside under a conflict name and goes up + * as a new file (the next round), and Files' version takes the name here. + */ + private async conflict(item: BaseItem, entry: FilesLocalEntry, node: RemoteNode): Promise { + if (await this.sameContent(entry, node)) { + this.remember(this.adoptLocal(this.adoptRemote(item, node), entry)); + return true; + } + if (this.drive && entry.placeholder && !entry.placeholder.hydrated) return false; + const aside = this.conflictPath(entry.path); + const moved = unwrap(await this.host.controls.move(this.root.id, entry.path, aside, expectOf(entry))); + this.local.delete(entry.path); + this.local.add(moved); + this.matched.delete(item.id); + this.sync.logActivity('conflict', null, entry.path, { detail: baseName(aside) }); + await this.bringDown(item.id, node, entry.path); + return true; + } + + /** Files' version of a node at `path`, where nothing is now: a file written, or a placeholder. */ + private async bringDown(id: string, node: RemoteNode, path: string): Promise { + if (this.drive) { + const drive = this.host.controls.drive; + if (!drive) return; + const answers = unwrap(await drive.place(this.root.id, [placeholderOf(node, path)])); + const problem = answers[0]?.error; + if (problem) throw new LocalError(problem); + const placed = reservedEntry(node, path); + this.local.add(placed); + this.claim(placed); + this.matched.set(id, placed); + this.remember(this.itemOf(node, null, path)); + return; + } + const written = node.kind === 'directory' + ? unwrap(await this.host.controls.mkdir(this.root.id, path)) + : await this.write(path, node, null); + this.local.add(written); + this.claim(written); + this.matched.set(id, written); + this.remember(this.itemOf(node, written, path)); + } + + // ─── New in Files ─────────────────────────────────────────── + + async remoteNew(): Promise { + let did = 0; + const places: { node: RemoteNode; path: string }[] = []; + for (const node of this.view.values()) { + if (this.base.has(node.id)) continue; + const folder = this.drive ? this.placedFolder(node.parentId) : this.localFolderOf(node.parentId); + if (folder === undefined) continue; + const wantedPath = joinPath(folder, diskName(node.name, this.naming)); + const there = this.local.get(wantedPath); + if (there && !this.isClaimed(there)) { + // Both sides have something new under this name: the first sync, mostly. + did += await this.step(node.id, there.path, 'down', () => this.pair(node, there)); + continue; + } + const name = freeLocalName(node.name, this.local.taken(folder), this.naming); + const path = joinPath(folder, name); + if (this.sync.isSkipped(path)) continue; + if (this.drive) { + // Reserved here so siblings placed in the same call don't take the name. + const reserved = reservedEntry(node, path); + this.local.add(reserved); + this.claim(reserved); + this.matched.set(node.id, reserved); + this.remember(this.itemOf(node, null, path)); + places.push({ node, path }); + if (name !== node.name) this.sync.logActivity('renamed-for-disk', 'down', path, { detail: name }); + continue; + } + did += await this.step(node.id, path, 'down', async () => { + await this.bringDown(node.id, node, path); + if (node.kind === 'directory') this.sync.logActivity('folder-created', 'down', path); + else this.sync.logActivity('downloaded', 'down', path); + if (name !== node.name) this.sync.logActivity('renamed-for-disk', 'down', path, { detail: name }); + return true; + }); + } + if (places.length > 0) did += await this.placeAll(places); + return did; + } + + /** The drive: where a node's folder is, counting folders placed by this round. */ + private placedFolder(parentId: string | null): string | undefined { + if (parentId === this.root.remoteId) return ''; + if (parentId === null) return undefined; + const matched = this.matched.get(parentId); + if (matched) return matched.kind === 'directory' ? matched.path : undefined; + const item = this.base.get(parentId); + return item && item.kind === 'directory' && this.local.get(item.path) ? item.path : undefined; + } + + private async placeAll(places: { node: RemoteNode; path: string }[]): Promise { + const drive = this.host.controls.drive; + if (!drive) return 0; + let did = 0; + for (let at = 0; at < places.length; at += PLACE_BATCH) { + const batch = places.slice(at, at + PLACE_BATCH); + const result = await drive.place(this.root.id, batch.map(({ node, path }) => placeholderOf(node, path))); + if (!result.ok) { + if (result.error === 'inactive') throw new Stop('inactive'); + for (const { node, path } of batch) { + this.base.delete(node.id); + this.matched.delete(node.id); + this.sync.markFailed(node.id, path); + } + this.sync.logFailure('', localErrorName(result.error), 'down'); + continue; + } + batch.forEach(({ node, path }, index) => { + const error = result.value[index]?.error; + if (!error) { + did += 1; + return; + } + this.base.delete(node.id); + this.matched.delete(node.id); + this.local.delete(path); + if (error === 'exists' || error === 'missing') { + this.sync.markRetry(path); + return; + } + this.sync.markFailed(node.id, path); + this.sync.logFailure(path, localErrorName(error), 'down'); + }); + } + return did; + } + + /** + * Something new on both sides under one name. Two folders are one. Two files with the same + * bytes are one. Anything else: the disk's moves aside as a conflict copy and goes up later, + * and Files' comes down under the name. + */ + private async pair(node: RemoteNode, entry: FilesLocalEntry): Promise { + if (node.kind === 'directory' && entry.kind === 'directory') { + this.claim(entry); + this.matched.set(node.id, entry); + this.remember(this.itemOf(node, entry, entry.path)); + if (this.drive) await this.host.controls.drive?.markInSync(this.root.id, entry.path, node.id, expectOf(entry)).catch(() => null); + return true; + } + if (this.drive && entry.placeholder?.identity === node.id) { + this.claim(entry); + this.matched.set(node.id, entry); + this.remember(this.itemOf(node, entry, entry.path)); + return true; + } + if (await this.sameContent(entry, node)) { + this.claim(entry); + this.matched.set(node.id, entry); + this.remember(this.itemOf(node, entry, entry.path)); + return true; + } + if (this.drive && entry.placeholder && !entry.placeholder.hydrated) return false; + const aside = this.conflictPath(entry.path); + const moved = unwrap(await this.host.controls.move(this.root.id, entry.path, aside, expectOf(entry))); + this.local.delete(entry.path); + this.local.add(moved); + this.sync.logActivity('conflict', null, entry.path, { detail: baseName(aside) }); + await this.bringDown(node.id, node, entry.path); + return true; + } + + // ─── New on this computer ─────────────────────────────────── + + async localNew(): Promise { + let did = 0; + const fresh = [...this.local.byKey.values()] + .filter((entry) => !this.isClaimed(entry) && !isSystemFileName(baseName(entry.path))) + .sort((a, b) => depthOf(a.path) - depthOf(b.path)); + for (const entry of fresh) { + if (this.isClaimed(entry) || this.sync.isSkipped(entry.path)) continue; + const parentId = this.idAtLocal(parentPath(entry.path)); + if (parentId === undefined) continue; + did += await this.step(null, entry.path, 'up', async () => { + const name = this.remote.acceptedName(baseName(entry.path)); + if (entry.kind === 'directory') { + const node = await this.remote.createFolder(name, parentId); + this.tree.put(node); + this.view.set(node.id, node); + const local = this.drive ? await this.markDrive(entry.path, node.id, entry).catch(() => entry) : entry; + this.claim(entry); + this.matched.set(node.id, local); + this.remember(this.itemOf(node, local, entry.path)); + this.sync.logActivity('folder-created', 'up', entry.path); + return true; + } + const bytes = await this.uploadBytes(entry, null); + if (!bytes) return false; + const node = await this.remote.createFile(name, parentId, bytes); + this.tree.put(node); + this.view.set(node.id, node); + const local = await this.markDrive(entry.path, node.id, bytes.read); + this.claim(entry); + this.matched.set(node.id, local); + this.remember(this.itemOf(node, local, entry.path)); + this.sync.logActivity('uploaded', 'up', entry.path); + return true; + }); + } + return did; + } + + // ─── Deletes ──────────────────────────────────────────────── + + /** Base items below `item` (by path), itself not counted. */ + private below(item: BaseItem): BaseItem[] { + return [...this.base.values()].filter((other) => other.id !== item.id && isUnderPath(other.path, item.path)); + } + + planDeletes(): { local: { count: number; ids: string[] }; remote: { count: number; ids: string[] } } { + const local = { count: 0, ids: [] as string[] }; + const remote = { count: 0, ids: [] as string[] }; + const covered = new Set(); + for (const item of this.sortedBase()) { + if (covered.has(item.id) || !this.base.has(item.id)) continue; + const entry = this.matched.get(item.id); + const node = this.view.get(item.id); + if (!entry && !node) { + this.base.delete(item.id); + this.dropped += 1; + continue; + } + if (entry && node) continue; + if (node) { + // Gone from this computer. Changed or moved in Files meanwhile: it comes back. + if (this.remoteChanged(item, node) || node.name !== item.name || node.parentId !== item.parentId) { + this.giveBack(item); + continue; + } + if (item.kind === 'directory') { + const inside = this.tree.subtree(item.id) ?? []; + const touched = inside.some((child) => { + const known = this.base.get(child.id); + return !known || known.parentId !== child.parentId || known.name !== child.name || this.remoteChanged(known, child); + }); + if (touched) { + this.giveBack(item); + continue; + } + // Something in it was moved out here, and Files hasn't heard yet: the next round. + if (inside.some((child) => this.matched.has(child.id))) continue; + } + const under = this.below(item); + for (const other of under) covered.add(other.id); + remote.ids.push(item.id); + remote.count += 1 + under.length; + continue; + } + // Gone from Files. Changed here meanwhile: it goes up again. + const here = entry as FilesLocalEntry; + if (this.localChanged(item, here)) { + this.giveBack(item); + continue; + } + if (item.kind === 'directory') { + const owners = new Map(); + for (const [id, seen] of this.matched) owners.set(seen, id); + let keep = false; + let wait = false; + for (const other of this.local.byKey.values()) { + if (other.path === here.path || !isUnderPath(other.path, here.path, this.naming)) continue; + if (isSystemFileName(baseName(other.path)) && !owners.has(other)) continue; + const id = owners.get(other); + const known = id ? this.base.get(id) : undefined; + if (!known || this.localChanged(known, other)) keep = true; + // Moved out in Files, and not yet here: the next round. + else if (this.view.has(known.id)) wait = true; + } + if (keep) { + this.giveBack(item); + continue; + } + if (wait) continue; + } + const under = this.below(item); + for (const other of under) covered.add(other.id); + local.ids.push(item.id); + local.count += 1 + under.length; + } + return { local, remote }; + } + + /** The record forgets an item and what's below it. */ + private forgetTree(item: BaseItem): void { + for (const other of this.below(item)) this.base.delete(other.id); + this.base.delete(item.id); + } + + /** The record forgets an item and what's below it, so the side that still has them gives them back. */ + private giveBack(item: BaseItem): void { + this.forgetTree(item); + this.dropped += 1; + } + + async deletes(plan: ReturnType): Promise { + let did = 0; + // In Files: one trash folder per parent, as the Files screen makes them. + const byParent = new Map(); + for (const id of plan.remote.ids) { + const item = this.base.get(id); + if (!item) continue; + const key = item.parentId ?? ''; + byParent.set(key, [...(byParent.get(key) ?? []), item]); + } + for (const [parent, items] of byParent) { + did += await this.step(null, items[0].path, 'up', async () => { + const ids = items.map((item) => item.id); + const trashBefore = this.tree.trashId(); + const { trashId, batchId } = await this.remote.trash(ids, parent || null, trashBefore, this.host.now()); + const now = this.host.now(); + if (!trashBefore) this.tree.put({ id: trashId, parentId: null, name: '.Trash', kind: 'directory', blobId: null, size: 0, modifiedMs: now, type: '' }); + this.tree.put({ id: batchId, parentId: trashId, name: `${now}~${parent}`, kind: 'directory', blobId: null, size: 0, modifiedMs: now, type: '' }); + for (const item of items) { + const node = this.tree.get(item.id); + if (node) this.tree.put({ ...node, parentId: batchId }); + this.view.delete(item.id); + this.forgetTree(item); + this.sync.logActivity('trashed', 'up', item.path); + } + return true; + }); + } + for (const id of plan.local.ids) { + const item = this.base.get(id); + const entry = this.matched.get(id); + if (!item || !entry) continue; + did += await this.step(null, entry.path, 'down', async () => { + const how = unwrap(await this.host.controls.remove(this.root.id, entry.path, expectOf(entry))); + this.local.delete(entry.path); + this.forgetTree(item); + this.sync.logActivity(how === 'trash' ? 'trashed' : 'deleted', 'down', entry.path); + return true; + }); + } + return did; + } +} + +/** A placeholder this round placed, as a scan will show it. */ +function reservedEntry(node: RemoteNode, path: string): FilesLocalEntry { + return { + path, + kind: node.kind, + size: node.kind === 'file' ? node.size : 0, + mtimeMs: node.modifiedMs, + fileId: '', + placeholder: { identity: node.id, inSync: true, hydrated: node.kind === 'directory', pin: 'unspecified' }, + }; +} + +function placeholderOf(node: RemoteNode, path: string): FilesPlaceholder { + return { path, kind: node.kind, size: node.kind === 'file' ? node.size : 0, mtimeMs: node.modifiedMs || Date.now(), identity: node.id }; +} + +function concat(parts: Uint8Array[], size: number): Uint8Array { + if (parts.length === 1) return parts[0]; + const out = new Uint8Array(size); + let at = 0; + for (const part of parts) { + out.set(part, at); + at += part.length; + } + return out; +} + +const TYPES: Record = { + txt: 'text/plain', md: 'text/markdown', csv: 'text/csv', html: 'text/html', htm: 'text/html', json: 'application/json', + pdf: 'application/pdf', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', + heic: 'image/heic', mp3: 'audio/mpeg', mp4: 'video/mp4', mov: 'video/quicktime', zip: 'application/zip', + doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + odt: 'application/vnd.oasis.opendocument.text', ods: 'application/vnd.oasis.opendocument.spreadsheet', odp: 'application/vnd.oasis.opendocument.presentation', +}; + +/** A media type from the name, as the server shows it. Anything else is plain bytes. */ +export function typeFor(path: string): string { + const name = baseName(path); + const dot = name.lastIndexOf('.'); + return (dot > 0 && TYPES[name.slice(dot + 1).toLowerCase()]) || 'application/octet-stream'; +} diff --git a/lib/files-sync/state.ts b/lib/files-sync/state.ts new file mode 100644 index 000000000..ce4683a7d --- /dev/null +++ b/lib/files-sync/state.ts @@ -0,0 +1,83 @@ +/** + * The engine's record of one root: what both sides were at the last sync, per item. Main keeps + * it (`loadState`/`saveState`), next to the root, as an opaque string. + * + * An item is a FileNode the root holds, with its place on both sides. `path` is the local path, + * `name` the name in Files: the two differ where the disk needed another name (names.ts), and + * keeping both is what keeps that from reading as a rename either way. + */ + +export interface BaseItem { + /** The FileNode id. */ + id: string; + kind: 'file' | 'directory'; + /** Local, relative to the root. */ + path: string; + /** In Files. */ + name: string; + /** The node's parent in Files. The root's folder (or null, the top of Files) for an item at the top. */ + parentId: string | null; + // Files, at the last sync. + blobId: string | null; + size: number; + modifiedMs: number; + // This computer, at the last sync. An empty fileId: not known yet (a placeholder just placed). + fileId: string; + localSize: number; + mtimeMs: number; +} + +export interface TooLarge { + size: number; + mtimeMs: number; +} + +export interface RootRecord { + v: 1; + /** The Files folder it was made for: a record of another one says nothing about this root. */ + remoteId: string | null; + items: BaseItem[]; + /** Local files over the server's upload limit, as they were when that was found. */ + tooLarge: Record; +} + +export function emptyRecord(remoteId: string | null): RootRecord { + return { v: 1, remoteId, items: [], tooLarge: {} }; +} + +function validItem(value: unknown): value is BaseItem { + const item = value as BaseItem; + return !!item + && typeof item.id === 'string' && item.id.length > 0 + && (item.kind === 'file' || item.kind === 'directory') + && typeof item.path === 'string' && item.path.length > 0 + && typeof item.name === 'string' + && (item.parentId === null || typeof item.parentId === 'string') + && (item.blobId === null || typeof item.blobId === 'string') + && Number.isFinite(item.size) && Number.isFinite(item.modifiedMs) + && typeof item.fileId === 'string' && Number.isFinite(item.localSize) && Number.isFinite(item.mtimeMs); +} + +/** + * The record, or an empty one when there's none, it can't be read, or it was made for another + * Files folder. Empty means "never synced": both sides are then put together without deleting + * anything. + */ +export function parseRecord(raw: string | null, remoteId: string | null): RootRecord { + if (!raw) return emptyRecord(remoteId); + try { + const parsed = JSON.parse(raw) as Partial; + if (parsed?.v !== 1 || parsed.remoteId !== remoteId || !Array.isArray(parsed.items)) return emptyRecord(remoteId); + const tooLarge: Record = {}; + for (const [path, entry] of Object.entries(parsed.tooLarge ?? {})) { + if (entry && Number.isFinite(entry.size) && Number.isFinite(entry.mtimeMs)) tooLarge[path] = { size: entry.size, mtimeMs: entry.mtimeMs }; + } + return { v: 1, remoteId, items: parsed.items.filter(validItem), tooLarge }; + } catch { + return emptyRecord(remoteId); + } +} + +export function serializeRecord(record: RootRecord): string { + return JSON.stringify(record); +} diff --git a/lib/files-sync/types.ts b/lib/files-sync/types.ts new file mode 100644 index 000000000..5e7cdb744 --- /dev/null +++ b/lib/files-sync/types.ts @@ -0,0 +1,131 @@ +/** + * Files on this computer, the page's side: what the sync engine reports and what the + * settings page and the Files screen can ask of it. The engine (lib/files-sync) keeps + * folders on disk and the Windows drive the same as Files; the desktop shell does the + * disk work (FilesControls in lib/desktop/types.ts). + * + * The store (stores/files-sync-store.ts) holds this state for the UI. The engine sets + * it and registers a controller there; the UI never talks to the bridge itself. + */ + +import type { FilesDriveLetterState, FilesDriveState, FilesResult, FilesRoot } from '@/lib/desktop/types'; + +export type FilesSyncRootState = + /** Reading both sides for the first time since the app started. */ + | 'starting' + | 'syncing' + /** Both sides are the same. */ + | 'idle' + | 'paused' + /** The server can't be reached; changes wait. */ + | 'offline' + | 'error' + /** A mass delete stopped it: the user has to say yes or no (`massDelete`). */ + | 'confirm-delete' + /** The organisation's policy stopped it. */ + | 'blocked'; + +export interface FilesSyncRootStatus { + rootId: string; + state: FilesSyncRootState; + /** Changes waiting to go either way. */ + pending: number; + /** Done in the current run. */ + done: number; + lastSyncedAt: number | null; + /** For `error`: a key in the `files_sync` namespace saying what went wrong. */ + errorKey?: string; + /** For `confirm-delete`: how many items would go, and where. */ + massDelete?: { count: number; side: 'local' | 'remote' }; +} + +export type FilesSyncActivityKind = + | 'uploaded' + | 'downloaded' + | 'folder-created' + | 'renamed' + | 'moved' + /** Deleted for good: the server has no trash, or it was deleted on this computer by the other side. */ + | 'deleted' + /** Into Files' trash, or the computer's. */ + | 'trashed' + /** Both sides changed. Neither is lost: `detail` is the name of the copy kept next to it. */ + | 'conflict' + /** A name this computer can't hold, shown under another name (`detail`). */ + | 'renamed-for-disk' + /** Bigger than the server takes. Stays on this computer only. `detail`: the limit in bytes. */ + | 'too-large' + /** Waiting for the server. */ + | 'waiting' + /** Stopped before deleting many items, see FilesSyncRootStatus.massDelete. */ + | 'mass-delete' + | 'error'; + +export interface FilesSyncActivity { + id: string; + at: number; + rootId: string; + kind: FilesSyncActivityKind; + /** `up`: to Files. `down`: to this computer. */ + direction: 'up' | 'down' | null; + /** Relative to the root, `/` between parts. */ + path: string; + detail?: string; + /** For `error` and `waiting`: a key in the `files_sync` namespace. */ + messageKey?: string; +} + +/** Where a FileNode is on this computer. */ +export interface FilesSyncCoverage { + rootId: string; + kind: 'folder' | 'drive'; + /** Relative to the root. */ + path: string; +} + +export type FilesSyncItemStatus = + /** The same on both sides. */ + | 'synced' + /** Going one way right now. */ + | 'syncing' + /** Waiting to go. */ + | 'pending' + | 'error' + /** The drive: only in Files, fetched when opened. */ + | 'online-only'; + +/** What the UI can ask of the engine. Registered by the engine in the store. */ +export interface FilesSyncController { + /** Shows the folder picker; needs the click it came from. */ + addFolder: (remoteId: string | null, remoteLabel: string) => Promise>; + /** A folder stops syncing; its files stay. The drive: turned off. */ + removeRoot: (rootId: string) => Promise; + setPaused: (rootId: string, paused: boolean) => Promise; + /** Answer to `confirm-delete`. No: the deleted items come back from the other side. */ + confirmMassDelete: (rootId: string, allow: boolean) => Promise; + /** The current account's drive. */ + setDriveEnabled: (enabled: boolean) => Promise>; + setDriveLetter: (letter: string | null) => Promise>; + /** Where a FileNode of the current account is on this computer, if a root covers it. */ + coverage: (nodeId: string) => FilesSyncCoverage | null; + /** Explorer, Finder or the file manager, on that file or folder. */ + showInFolder: (nodeId: string) => Promise; + clearActivity: () => void; +} + +export interface FilesSyncSnapshot { + /** In the desktop app, with a shell that has the files bridge, in the main window. */ + available: boolean; + /** The local account the engine runs for, or null when nobody is signed in. */ + account: string | null; + roots: FilesRoot[]; + status: Record; + /** Newest first, capped. */ + activity: FilesSyncActivity[]; + /** Null off Windows or on an older shell. */ + drive: FilesDriveState | null; + /** `FilesFolderSyncEnabled` is off. */ + folderSyncForcedOff: boolean; + /** Per FileNode id of the current account, for rows in the Files screen. */ + items: Record; +} diff --git a/lib/settings-search.ts b/lib/settings-search.ts index 4fdc3162e..a61e55922 100644 --- a/lib/settings-search.ts +++ b/lib/settings-search.ts @@ -20,6 +20,8 @@ export type SettingsSearchTab = | 'calendar' | 'contacts' | 'files' + /** Files on this computer, in the desktop app. */ + | 'files_sync' | 'addons' | 'offline' | 'privacy' @@ -369,6 +371,13 @@ export const tabSearchEntries: Record = { { label: 'settings.files.behavior.title', advanced: true }, { label: 'settings.files.show_hidden.label', advanced: true }, ], + // Desktop app only; the page is in the nav only while the sync engine runs. + files_sync: [ + { label: 'files_sync.drive.title', when: 'desktop' }, + { label: 'files_sync.drive.label', when: 'desktop' }, + { label: 'files_sync.folders.title', when: 'desktop' }, + { label: 'files_sync.activity.title', when: 'desktop' }, + ], addons: [ { label: 'settings.sidebar_apps.title' }, { label: 'settings.sidebar_apps.keep_loaded', advanced: true }, @@ -417,6 +426,7 @@ export const tabSearchEntries: Record = { /** Pages added after `settings.tabs` keep their names in their own namespace. */ const NEW_TAB_LABELS: Partial> = { offline: 'uxs_structure.tabs.offline', + files_sync: 'files_sync.title', privacy: 'uxs_structure.tabs.privacy', about: 'uxs_structure.tabs.about', }; @@ -449,6 +459,7 @@ export const tabKeywords: Record = { calendar: 'event schedule appointment meeting timezone invitation ics ical webcal subscribe subscription tasks birthday working hours', contacts: 'address book contact', files: 'attachments cloud drive storage upload', + files_sync: 'sync synchronize folder drive explorer finder computer local disk letter', addons: 'apps webview iframe plugins extensions addons sidebar', offline: 'offline connection storage device sync outbox local kept', privacy: 'privacy tracking remote images content read receipts favicons pictures telemetry crash reports', diff --git a/lib/settings-tabs.ts b/lib/settings-tabs.ts index a77f8dc13..7111d78fe 100644 --- a/lib/settings-tabs.ts +++ b/lib/settings-tabs.ts @@ -8,7 +8,7 @@ export const SETTINGS_TAB_IDS: readonly SettingsSearchTab[] = [ 'account', 'general', 'notifications', 'appearance', 'accessibility', 'message_list', 'reading', 'composing', 'vacation', 'filters', 'organize', 'security', 'sharing', 'recovery', 'calendar', 'contacts', 'files', - 'addons', 'offline', 'privacy', 'backup', 'about', + 'files_sync', 'addons', 'offline', 'privacy', 'backup', 'about', ]; // Map legacy tab IDs to current ones; runs once on read of localStorage and diff --git a/locales/ar/common.json b/locales/ar/common.json index 0a2286844..22d65ab47 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5279,6 +5279,11 @@ "cancelled": "ألغى {name} \"{title}\"", "untitled": "حدث بدون عنوان" }, + "calendar_month_select": { + "selected_range": "تم تحديد {start} إلى {end}.", + "selected_day": "تم تحديد {day}.", + "enter_hint": "اضغط Enter لإنشاء حدث." + }, "company_calendars": { "section": "المؤسسة" }, @@ -7650,6 +7655,112 @@ "signout_changed": "لم تُرفق أو تُحفظ بعد، وتُحذف الآن: {names}.", "signout_held": "ما زالت مفتوحة في برنامج آخر، لذا لن تُحذف إلا بعد إغلاقها هناك: {names}." }, + "files_sync": { + "title": "الملفات على هذا الحاسوب", + "drive": { + "title": "محرك أقراص الملفات", + "label": "إظهار الملفات كمحرك أقراص", + "description": "تظهر ملفاتك في مستكشف الملفات كمحرك أقراص. يُنزَّل الملف عندما تفتحه.", + "unsupported": "يتطلب Windows 10 الإصدار 1809 أو أحدث.", + "root_name": "محرك أقراص الملفات", + "letter_label": "حرف محرك الأقراص", + "letter_description": "يمنح محرك الأقراص حرفًا خاصًا به في مستكشف الملفات.", + "letter_none": "بلا حرف" + }, + "folders": { + "title": "المجلدات المتزامنة", + "description": "يبقى مجلد على هذا الحاسوب ومجلد في الملفات متطابقين، وتنتقل التغييرات في الاتجاهين.", + "empty": "لا توجد مجلدات متزامنة بعد.", + "forced_off": "أوقفت مؤسستك مزامنة المجلدات. تبقى الملفات الموجودة فيها على هذا الحاسوب.", + "add": "إضافة مجلد…", + "loading": "جارٍ تحميل مجلداتك…", + "load_failed": "تعذّر تحميل مجلداتك في الملفات. حاول مرة أخرى.", + "pick_title": "أي مجلد في الملفات؟", + "pick_confirm": "اختيار مجلد على هذا الحاسوب…", + "paired_with": "مع {folder} في الملفات", + "pause": "إيقاف مؤقت", + "resume": "استئناف", + "remove": "إزالة…", + "remove_title": "إيقاف مزامنة هذا المجلد؟", + "remove_message": "تبقى الملفات على هذا الحاسوب في مكانها، وكذلك الملفات في الملفات. لن تنتقل التغييرات بينهما بعد الآن.", + "remove_confirm": "إيقاف المزامنة", + "add_failed": { + "overlap": "هذا المجلد داخل مجلد متزامن، أو يحتوي على مجلد متزامن، أو هو مجلد نظام. اختر مجلدًا آخر.", + "denied": "لا يستطيع التطبيق استخدام هذا المجلد. اختر مجلدًا آخر.", + "policy": "لا تسمح مؤسستك بذلك.", + "failed": "تعذّرت إضافة المجلد. حاول مرة أخرى." + } + }, + "status": { + "starting": "جارٍ البدء…", + "idle": "محدَّث. آخر مزامنة {time}", + "idle_never": "محدَّث", + "syncing": "جارٍ المزامنة: اكتمل {done}، ويتبقى {pending}", + "paused": "متوقف مؤقتًا", + "offline": "غير متصل. {count, plural, =0 {التغييرات بانتظار الخادم} zero {# تغيير بانتظار الخادم} one {تغيير واحد بانتظار الخادم} two {تغييران بانتظار الخادم} few {# تغييرات بانتظار الخادم} many {# تغييرًا بانتظار الخادم} other {# تغيير بانتظار الخادم}}", + "error": "متوقف: {reason}", + "confirm_delete": "متوقف مؤقتًا حتى تجيب عن السؤال أدناه", + "blocked": "أوقفته مؤسستك" + }, + "mass_delete": { + "title": "حذف {count, plural, zero {# عنصر} one {عنصر واحد} two {عنصرين} few {# عناصر} many {# عنصرًا} other {# عنصر}}؟", + "local": "{count, plural, zero {حُذف # عنصر} one {حُذف عنصر واحد} two {حُذف عنصران} few {حُذفت # عناصر} many {حُذف # عنصرًا} other {حُذف # عنصر}} من {folder} على هذا الحاسوب. هل تريد {count, plural, zero {حذفها} one {حذفه} two {حذفهما} few {حذفها} many {حذفها} other {حذفها}} في الملفات أيضًا؟", + "remote": "{count, plural, zero {حُذف # عنصر} one {حُذف عنصر واحد} two {حُذف عنصران} few {حُذفت # عناصر} many {حُذف # عنصرًا} other {حُذف # عنصر}} من {folder} في الملفات. هل تريد {count, plural, zero {حذفها} one {حذفه} two {حذفهما} few {حذفها} many {حذفها} other {حذفها}} على هذا الحاسوب أيضًا؟", + "allow": "{count, plural, zero {حذفها} one {حذفه} two {حذفهما} few {حذفها} many {حذفها} other {حذفها}}", + "restore": "{count, plural, zero {استعادتها} one {استعادته} two {استعادتهما} few {استعادتها} many {استعادتها} other {استعادتها}}", + "later": "القرار لاحقًا" + }, + "activity": { + "title": "النشاط الأخير", + "empty": "لم يحدث شيء بعد.", + "clear": "مسح", + "to_files": "إلى الملفات", + "to_computer": "إلى هذا الحاسوب", + "mark_conflict": "تعارض", + "mark_error": "خطأ", + "mark_question": "بانتظار إجابتك", + "uploaded": "رُفع {path}", + "downloaded": "نُزّل {path}", + "folder-created": "أُنشئ المجلد {path}", + "renamed": "أُعيدت تسمية {detail} إلى {path}", + "moved": "نُقل {detail} إلى {path}", + "deleted": "حُذف {path}", + "trashed": "نُقل {path} إلى سلة المهملات", + "conflict": "تغيّر {path} في المكانين. حُفظت النسخة الأخرى باسم {detail}", + "renamed-for-disk": "اسم {path} على هذا الحاسوب هو {detail}، لأن اسمه غير مسموح به هنا", + "too-large": "{path} أكبر مما تسمح به الملفات ({detail}). يبقى على هذا الحاسوب فقط", + "waiting": "{path} بانتظار الخادم", + "mass-delete": "توقف مؤقتًا قبل حذف عناصر كثيرة", + "error": "تعذّرت مزامنة {path}" + }, + "errors": { + "offline": "تعذّر الوصول إلى الخادم", + "server_refused": "رفض الخادم التغيير", + "too_large": "الملف كبير جدًا بالنسبة للملفات", + "name_invalid": "هذا الاسم غير مسموح به", + "disk_full": "القرص ممتلئ", + "denied": "غير مسموح", + "busy": "يستخدمه برنامج آخر", + "quota": "مساحة التخزين الخاصة بك في الملفات ممتلئة", + "not_found": "لم يعد موجودًا", + "policy": "لا تسمح مؤسستك بذلك", + "failed": "حدث خطأ ما" + }, + "item": { + "label": "على هذا الحاسوب", + "synced": "متزامن", + "syncing": "جارٍ المزامنة", + "pending": "بانتظار المزامنة", + "error": "تعذّرت المزامنة", + "online-only": "في الملفات فقط، ويُنزَّل عند فتحه" + }, + "show_in": { + "explorer": "إظهار في مستكشف الملفات", + "finder": "إظهار في Finder", + "file_manager": "إظهار في مدير الملفات", + "failed": "تعذّر فتح المجلد." + } + }, "desktopQuickCompose": { "label": "كتابة سريعة", "menu": "كتابة سريعة", @@ -7665,6 +7776,14 @@ "types_character": "تكتب هذه المفاتيح حرفًا على لوحة مفاتيحك، وسيتوقف عن العمل في كل التطبيقات. جرّب مفاتيح أخرى.", "reserved": "يستخدم النظام أو معظم التطبيقات هذه المفاتيح. جرّب مفاتيح أخرى." }, + "desktopAttachRecent": { + "heading": "الملفات الأخيرة", + "browse": "استعراض هذا الكمبيوتر…", + "from_files": "من الملفات…", + "missing": "\"{name}\" لم يعد موجودًا.", + "too_large": "\"{name}\" أكبر من {max} ولا يمكن إرفاقه.", + "failed": "تعذّر إرفاق \"{name}\". حاول مرة أخرى." + }, "compose_windows": { "more_formatting": "مزيد من التنسيق", "offline": "دون اتصال", @@ -7770,5 +7889,9 @@ "unread_badge_description": "لترى البريد الجديد أثناء عملك في تطبيق آخر.", "tip_compose_keys": "اضغط {keys} لكتابة رسالة جديدة", "tip_palette_keys": "اضغط {keys} للعثور على أي أمر" + }, + "selectionView": { + "starred": "تمت إضافة النجمة", + "unstarred": "تمت إزالة النجمة" } } diff --git a/locales/ca/common.json b/locales/ca/common.json index 705591747..62752371b 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5279,6 +5279,11 @@ "cancelled": "{name} ha cancel·lat \"{title}\"", "untitled": "Esdeveniment sense títol" }, + "calendar_month_select": { + "selected_range": "Seleccionat de {start} a {end}.", + "selected_day": "Seleccionat {day}.", + "enter_hint": "Premeu Retorn per crear un esdeveniment." + }, "company_calendars": { "section": "Organització" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Encara sense adjuntar ni desar, i eliminats ara: {names}.", "signout_held": "Encara oberts en un altre programa, així que no s’eliminen fins que els tanqueu allà: {names}." }, + "files_sync": { + "title": "Fitxers en aquest ordinador", + "drive": { + "title": "Unitat Fitxers", + "label": "Mostra Fitxers com una unitat", + "description": "Els vostres Fitxers apareixen a l'Explorador de fitxers com una unitat. Un fitxer es baixa quan l'obriu.", + "unsupported": "Cal el Windows 10 versió 1809 o posterior.", + "root_name": "Unitat Fitxers", + "letter_label": "Lletra d'unitat", + "letter_description": "Dona a la unitat una lletra pròpia a l'Explorador de fitxers.", + "letter_none": "Sense lletra" + }, + "folders": { + "title": "Carpetes sincronitzades", + "description": "Una carpeta d'aquest ordinador i una carpeta de Fitxers es mantenen iguals, en tots dos sentits.", + "empty": "Encara no se sincronitza cap carpeta.", + "forced_off": "La vostra organització ha desactivat la sincronització de carpetes. Els fitxers que contenen es queden en aquest ordinador.", + "add": "Afegeix una carpeta…", + "loading": "S'estan carregant les vostres carpetes…", + "load_failed": "No s'han pogut carregar les vostres carpetes de Fitxers. Torneu-ho a provar.", + "pick_title": "Quina carpeta de Fitxers?", + "pick_confirm": "Trieu una carpeta d'aquest ordinador…", + "paired_with": "Amb {folder} a Fitxers", + "pause": "Posa en pausa", + "resume": "Reprèn", + "remove": "Elimina…", + "remove_title": "Voleu deixar de sincronitzar aquesta carpeta?", + "remove_message": "Els fitxers d'aquest ordinador es queden on són, i els de Fitxers també. Els canvis ja no passen de l'un a l'altre.", + "remove_confirm": "Deixa de sincronitzar", + "add_failed": { + "overlap": "Aquesta carpeta és dins d'una carpeta sincronitzada, en conté una o és una carpeta del sistema. Trieu-ne una altra.", + "denied": "L'aplicació no pot fer servir aquesta carpeta. Trieu-ne una altra.", + "policy": "La vostra organització no ho permet.", + "failed": "No s'ha pogut afegir la carpeta. Torneu-ho a provar." + } + }, + "status": { + "starting": "S'està iniciant…", + "idle": "Al dia. Darrera sincronització {time}", + "idle_never": "Al dia", + "syncing": "S'està sincronitzant: {done} fets, {pending} pendents", + "paused": "En pausa", + "offline": "Sense connexió. {count, plural, =0 {Els canvis esperen el servidor} one {# canvi espera el servidor} other {# canvis esperen el servidor}}", + "error": "Aturat: {reason}", + "confirm_delete": "En pausa fins que respongueu la pregunta de sota", + "blocked": "Desactivat per la vostra organització" + }, + "mass_delete": { + "title": "Voleu suprimir {count, plural, one {# element} other {# elements}}?", + "local": "{count, plural, one {S'ha suprimit # element} other {S'han suprimit # elements}} de {folder} en aquest ordinador. Voleu {count, plural, one {suprimir-lo} other {suprimir-los}} també a Fitxers?", + "remote": "{count, plural, one {S'ha suprimit # element} other {S'han suprimit # elements}} de {folder} a Fitxers. Voleu {count, plural, one {suprimir-lo} other {suprimir-los}} també en aquest ordinador?", + "allow": "{count, plural, one {Suprimeix-lo} other {Suprimeix-los}}", + "restore": "{count, plural, one {Recupera'l} other {Recupera'ls}}", + "later": "Decideix més tard" + }, + "activity": { + "title": "Activitat recent", + "empty": "Encara no ha passat res.", + "clear": "Esborra", + "to_files": "Cap a Fitxers", + "to_computer": "Cap a aquest ordinador", + "mark_conflict": "Conflicte", + "mark_error": "Ha fallat", + "mark_question": "Cal la vostra resposta", + "uploaded": "S'ha pujat {path}", + "downloaded": "S'ha baixat {path}", + "folder-created": "S'ha creat la carpeta {path}", + "renamed": "S'ha canviat el nom de {detail} a {path}", + "moved": "S'ha mogut {detail} a {path}", + "deleted": "S'ha suprimit {path}", + "trashed": "S'ha mogut {path} a la paperera", + "conflict": "{path} s'ha canviat als dos llocs. L'altra versió es desa com a {detail}", + "renamed-for-disk": "{path} es diu {detail} en aquest ordinador, perquè el seu nom no s'hi permet", + "too-large": "{path} supera la mida que permet Fitxers ({detail}). Es queda només en aquest ordinador", + "waiting": "{path} espera el servidor", + "mass-delete": "En pausa abans de suprimir molts elements", + "error": "No s'ha pogut sincronitzar {path}" + }, + "errors": { + "offline": "No es pot connectar amb el servidor", + "server_refused": "El servidor ha rebutjat el canvi", + "too_large": "El fitxer és massa gran per a Fitxers", + "name_invalid": "El nom no està permès", + "disk_full": "El disc és ple", + "denied": "Accés denegat", + "busy": "Un altre programa l'està fent servir", + "quota": "L'emmagatzematge de Fitxers és ple", + "not_found": "Ja no hi és", + "policy": "La vostra organització no ho permet", + "failed": "Alguna cosa ha anat malament" + }, + "item": { + "label": "En aquest ordinador", + "synced": "Sincronitzat", + "syncing": "S'està sincronitzant", + "pending": "Pendent de sincronitzar", + "error": "No s'ha pogut sincronitzar", + "online-only": "Només a Fitxers, es baixa en obrir-lo" + }, + "show_in": { + "explorer": "Mostra a l'Explorador de fitxers", + "finder": "Mostra al Finder", + "file_manager": "Mostra al gestor de fitxers", + "failed": "No s'ha pogut obrir la carpeta." + } + }, "desktopQuickCompose": { "label": "Redacció ràpida", "menu": "Redacció ràpida", @@ -7665,6 +7776,14 @@ "types_character": "Aquestes tecles escriuen un caràcter al teu teclat, i deixaria de funcionar a totes les aplicacions. Prova altres tecles.", "reserved": "El sistema o la majoria d'aplicacions fan servir aquestes tecles. Prova altres tecles." }, + "desktopAttachRecent": { + "heading": "Fitxers recents", + "browse": "Explora aquest ordinador…", + "from_files": "Des de Fitxers…", + "missing": "«{name}» ja no hi és.", + "too_large": "«{name}» supera {max} i no es pot adjuntar.", + "failed": "No s'ha pogut adjuntar «{name}». Torneu-ho a provar." + }, "compose_windows": { "more_formatting": "Més format", "offline": "Sense connexió", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Per veure el correu nou mentre treballeu en una altra aplicació.", "tip_compose_keys": "Premeu {keys} per escriure un missatge nou", "tip_palette_keys": "Premeu {keys} per trobar qualsevol ordre" + }, + "selectionView": { + "starred": "Marcat amb estrella", + "unstarred": "Estrella eliminada" } } diff --git a/locales/cs/common.json b/locales/cs/common.json index bb545640a..cd042741f 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5905,6 +5905,11 @@ "cancelled": "{name} zrušil(a) „{title}“", "untitled": "Událost bez názvu" }, + "calendar_month_select": { + "selected_range": "Vybráno {start} až {end}.", + "selected_day": "Vybráno {day}.", + "enter_hint": "Stisknutím klávesy Enter vytvoříte událost." + }, "company_calendars": { "section": "Organizace" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Zatím nepřipojené ani neuložené, a teď smazané: {names}.", "signout_held": "Stále otevřené v jiném programu, takže se smažou až po zavření: {names}." }, + "files_sync": { + "title": "Soubory v tomto počítači", + "drive": { + "title": "Jednotka Soubory", + "label": "Zobrazit Soubory jako jednotku", + "description": "Vaše Soubory se v Průzkumníkovi souborů zobrazí jako jednotka. Soubor se stáhne, až ho otevřete.", + "unsupported": "Vyžaduje Windows 10 verze 1809 nebo novější.", + "root_name": "Jednotka Soubory", + "letter_label": "Písmeno jednotky", + "letter_description": "Přidělí jednotce vlastní písmeno v Průzkumníkovi souborů.", + "letter_none": "Bez písmene" + }, + "folders": { + "title": "Synchronizované složky", + "description": "Složka v tomto počítači a složka v Souborech zůstávají stejné, změny jdou oběma směry.", + "empty": "Zatím se nesynchronizují žádné složky.", + "forced_off": "Vaše organizace vypnula synchronizaci složek. Soubory v nich zůstanou v tomto počítači.", + "add": "Přidat složku…", + "loading": "Načítání vašich složek…", + "load_failed": "Vaše složky v Souborech se nepodařilo načíst. Zkuste to znovu.", + "pick_title": "Kterou složku v Souborech?", + "pick_confirm": "Vybrat složku v tomto počítači…", + "paired_with": "Se složkou {folder} v Souborech", + "pause": "Pozastavit", + "resume": "Pokračovat", + "remove": "Odebrat…", + "remove_title": "Přestat synchronizovat tuto složku?", + "remove_message": "Soubory v tomto počítači zůstanou, kde jsou, a stejně tak soubory v Souborech. Změny se mezi nimi už nebudou přenášet.", + "remove_confirm": "Přestat synchronizovat", + "add_failed": { + "overlap": "Tato složka leží v synchronizované složce, takovou složku obsahuje nebo je to systémová složka. Vyberte jinou.", + "denied": "Aplikace tuto složku nemůže používat. Vyberte jinou.", + "policy": "Vaše organizace to nepovoluje.", + "failed": "Složku se nepodařilo přidat. Zkuste to znovu." + } + }, + "status": { + "starting": "Spouštění…", + "idle": "Aktuální. Naposledy synchronizováno {time}", + "idle_never": "Aktuální", + "syncing": "Synchronizace: hotovo {done}, zbývá {pending}", + "paused": "Pozastaveno", + "offline": "Offline. {count, plural, =0 {Změny čekají na server} one {# změna čeká na server} few {# změny čekají na server} many {# změny čeká na server} other {# změn čeká na server}}", + "error": "Zastaveno: {reason}", + "confirm_delete": "Pozastaveno, dokud neodpovíte na otázku níže", + "blocked": "Vypnuto vaší organizací" + }, + "mass_delete": { + "title": "Smazat {count, plural, one {# položku} few {# položky} many {# položky} other {# položek}}?", + "local": "{count, plural, one {# položka byla smazána} few {# položky byly smazány} many {# položky bylo smazáno} other {# položek bylo smazáno}} ze složky {folder} v tomto počítači. Smazat {count, plural, one {ji} few {je} many {je} other {je}} i v Souborech?", + "remote": "{count, plural, one {# položka byla smazána} few {# položky byly smazány} many {# položky bylo smazáno} other {# položek bylo smazáno}} ze složky {folder} v Souborech. Smazat {count, plural, one {ji} few {je} many {je} other {je}} i v tomto počítači?", + "allow": "{count, plural, one {Smazat ji} few {Smazat je} many {Smazat je} other {Smazat je}}", + "restore": "{count, plural, one {Vrátit ji zpět} few {Vrátit je zpět} many {Vrátit je zpět} other {Vrátit je zpět}}", + "later": "Rozhodnout později" + }, + "activity": { + "title": "Nedávná aktivita", + "empty": "Zatím se nic nestalo.", + "clear": "Vymazat", + "to_files": "Do Souborů", + "to_computer": "Do tohoto počítače", + "mark_conflict": "Konflikt", + "mark_error": "Chyba", + "mark_question": "Čeká na vaši odpověď", + "uploaded": "Nahráno: {path}", + "downloaded": "Staženo: {path}", + "folder-created": "Vytvořena složka {path}", + "renamed": "Přejmenováno: {detail} na {path}", + "moved": "Přesunuto: {detail} do {path}", + "deleted": "Smazáno: {path}", + "trashed": "Přesunuto do koše: {path}", + "conflict": "{path} se změnilo na obou místech. Druhá verze je uložena jako {detail}", + "renamed-for-disk": "{path} se v tomto počítači jmenuje {detail}, protože tento název tu není povolený", + "too-large": "{path} je větší, než Soubory povolují ({detail}). Zůstává jen v tomto počítači", + "waiting": "{path} čeká na server", + "mass-delete": "Pozastaveno před smazáním mnoha položek", + "error": "{path} se nepodařilo synchronizovat" + }, + "errors": { + "offline": "Server není dostupný", + "server_refused": "Server změnu odmítl", + "too_large": "Soubor je pro Soubory příliš velký", + "name_invalid": "Tento název není povolený", + "disk_full": "Disk je plný", + "denied": "Nepovoleno", + "busy": "Používá ho jiný program", + "quota": "Vaše úložiště v Souborech je plné", + "not_found": "Už neexistuje", + "policy": "Vaše organizace to nepovoluje", + "failed": "Něco se pokazilo" + }, + "item": { + "label": "V tomto počítači", + "synced": "Synchronizováno", + "syncing": "Synchronizuje se", + "pending": "Čeká na synchronizaci", + "error": "Nepodařilo se synchronizovat", + "online-only": "Jen v Souborech, stáhne se při otevření" + }, + "show_in": { + "explorer": "Zobrazit v Průzkumníkovi souborů", + "finder": "Zobrazit ve Finderu", + "file_manager": "Zobrazit ve správci souborů", + "failed": "Složku se nepodařilo otevřít." + } + }, "desktopQuickCompose": { "label": "Rychlá zpráva", "menu": "Rychlá zpráva", @@ -7665,6 +7776,14 @@ "types_character": "Tyto klávesy na vaší klávesnici píší znak, který by pak nefungoval v žádné aplikaci. Zkuste jiné klávesy.", "reserved": "Tyto klávesy používá systém nebo většina aplikací. Zkuste jiné klávesy." }, + "desktopAttachRecent": { + "heading": "Poslední soubory", + "browse": "Procházet tento počítač…", + "from_files": "Ze Souborů…", + "missing": "„{name}“ už neexistuje.", + "too_large": "„{name}“ je větší než {max} a nelze ho připojit.", + "failed": "„{name}“ se nepodařilo připojit. Zkuste to znovu." + }, "compose_windows": { "more_formatting": "Další formátování", "offline": "Offline", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Abyste viděli novou poštu, i když pracujete v jiné aplikaci.", "tip_compose_keys": "Stiskněte {keys} a napište novou zprávu", "tip_palette_keys": "Stiskněte {keys} a najděte jakýkoli příkaz" + }, + "selectionView": { + "starred": "Označeno hvězdičkou", + "unstarred": "Hvězdička odebrána" } } diff --git a/locales/da/common.json b/locales/da/common.json index 3b6e82d6f..d0f4eadc5 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5279,6 +5279,11 @@ "cancelled": "{name} har aflyst \"{title}\"", "untitled": "Unavngivet begivenhed" }, + "calendar_month_select": { + "selected_range": "{start} til {end} valgt.", + "selected_day": "{day} valgt.", + "enter_hint": "Tryk på Enter for at oprette en begivenhed." + }, "company_calendars": { "section": "Organisation" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Endnu ikke vedhæftet eller gemt, og slettet nu: {names}.", "signout_held": "Stadig åbne i et andet program, så de slettes først, når du lukker dem der: {names}." }, + "files_sync": { + "title": "Filer på denne computer", + "drive": { + "title": "Filer-drev", + "label": "Vis Filer som et drev", + "description": "Dine Filer vises i Stifinder som et drev. En fil hentes, når du åbner den.", + "unsupported": "Kræver Windows 10 version 1809 eller nyere.", + "root_name": "Filer-drev", + "letter_label": "Drevbogstav", + "letter_description": "Giver drevet sit eget bogstav i Stifinder.", + "letter_none": "Intet bogstav" + }, + "folders": { + "title": "Synkroniserede mapper", + "description": "En mappe på denne computer og en mappe i Filer holdes ens, i begge retninger.", + "empty": "Ingen mapper synkroniseres endnu.", + "forced_off": "Din organisation har slået synkronisering af mapper fra. Filerne i dem bliver på denne computer.", + "add": "Tilføj mappe…", + "loading": "Indlæser dine mapper…", + "load_failed": "Dine mapper i Filer kunne ikke indlæses. Prøv igen.", + "pick_title": "Hvilken mappe i Filer?", + "pick_confirm": "Vælg mappe på denne computer…", + "paired_with": "Med {folder} i Filer", + "pause": "Sæt på pause", + "resume": "Genoptag", + "remove": "Fjern…", + "remove_title": "Stop synkronisering af denne mappe?", + "remove_message": "Filerne på denne computer bliver, hvor de er, og det samme gør filerne i Filer. Ændringer går ikke længere fra den ene til den anden.", + "remove_confirm": "Stop synkronisering", + "add_failed": { + "overlap": "Denne mappe ligger i en synkroniseret mappe, indeholder en eller er en systemmappe. Vælg en anden.", + "denied": "Appen kan ikke bruge denne mappe. Vælg en anden.", + "policy": "Din organisation tillader ikke dette.", + "failed": "Mappen kunne ikke tilføjes. Prøv igen." + } + }, + "status": { + "starting": "Starter…", + "idle": "Opdateret. Sidst synkroniseret {time}", + "idle_never": "Opdateret", + "syncing": "Synkroniserer: {done} færdige, {pending} tilbage", + "paused": "På pause", + "offline": "Offline. {count, plural, =0 {Ændringer venter på serveren} one {# ændring venter på serveren} other {# ændringer venter på serveren}}", + "error": "Stoppet: {reason}", + "confirm_delete": "På pause, indtil du svarer på spørgsmålet nedenfor", + "blocked": "Slået fra af din organisation" + }, + "mass_delete": { + "title": "Slet {count, plural, one {# element} other {# elementer}}?", + "local": "{count, plural, one {# element blev} other {# elementer blev}} slettet fra {folder} på denne computer. Slet {count, plural, one {det} other {dem}} i Filer også?", + "remote": "{count, plural, one {# element blev} other {# elementer blev}} slettet fra {folder} i Filer. Slet {count, plural, one {det} other {dem}} på denne computer også?", + "allow": "{count, plural, one {Slet det} other {Slet dem}}", + "restore": "{count, plural, one {Gendan det} other {Gendan dem}}", + "later": "Beslut senere" + }, + "activity": { + "title": "Seneste aktivitet", + "empty": "Der er ikke sket noget endnu.", + "clear": "Ryd", + "to_files": "Til Filer", + "to_computer": "Til denne computer", + "mark_conflict": "Konflikt", + "mark_error": "Fejl", + "mark_question": "Venter på dit svar", + "uploaded": "Uploadede {path}", + "downloaded": "Downloadede {path}", + "folder-created": "Oprettede mappen {path}", + "renamed": "Omdøbte {detail} til {path}", + "moved": "Flyttede {detail} til {path}", + "deleted": "Slettede {path}", + "trashed": "Flyttede {path} til papirkurven", + "conflict": "{path} blev ændret begge steder. Den anden version er gemt som {detail}", + "renamed-for-disk": "{path} hedder {detail} på denne computer, fordi navnet ikke er tilladt her", + "too-large": "{path} er større, end Filer tillader ({detail}). Den bliver kun på denne computer", + "waiting": "{path} venter på serveren", + "mass-delete": "Sat på pause før sletning af mange elementer", + "error": "{path} kunne ikke synkroniseres" + }, + "errors": { + "offline": "Serveren kan ikke nås", + "server_refused": "Serveren afviste ændringen", + "too_large": "Filen er for stor til Filer", + "name_invalid": "Navnet er ikke tilladt", + "disk_full": "Disken er fuld", + "denied": "Ingen adgang", + "busy": "Et andet program bruger den", + "quota": "Din lagerplads i Filer er fuld", + "not_found": "Den findes ikke længere", + "policy": "Din organisation tillader ikke dette", + "failed": "Noget gik galt" + }, + "item": { + "label": "På denne computer", + "synced": "Synkroniseret", + "syncing": "Synkroniserer", + "pending": "Venter på synkronisering", + "error": "Kunne ikke synkronisere", + "online-only": "Kun i Filer, hentes ved åbning" + }, + "show_in": { + "explorer": "Vis i Stifinder", + "finder": "Vis i Finder", + "file_manager": "Vis i filhåndtering", + "failed": "Mappen kunne ikke åbnes." + } + }, "desktopQuickCompose": { "label": "Hurtig skrivning", "menu": "Hurtig skrivning", @@ -7665,6 +7776,14 @@ "types_character": "Disse taster skriver et tegn på dit tastatur, og det ville holde op med at virke i alle apps. Prøv andre taster.", "reserved": "Dit system eller de fleste apps bruger disse taster. Prøv andre taster." }, + "desktopAttachRecent": { + "heading": "Seneste filer", + "browse": "Gennemse denne computer…", + "from_files": "Fra Filer…", + "missing": "\"{name}\" findes ikke længere.", + "too_large": "\"{name}\" er større end {max} og kan ikke vedhæftes.", + "failed": "\"{name}\" kunne ikke vedhæftes. Prøv igen." + }, "compose_windows": { "more_formatting": "Mere formatering", "offline": "Offline", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Så du ser ny mail, mens du arbejder i en anden app.", "tip_compose_keys": "Tryk på {keys} for at skrive en ny besked", "tip_palette_keys": "Tryk på {keys} for at finde en kommando" + }, + "selectionView": { + "starred": "Stjernemarkeret", + "unstarred": "Stjerne fjernet" } } diff --git a/locales/de/common.json b/locales/de/common.json index 7c58391bb..684d899fc 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5905,6 +5905,11 @@ "cancelled": "{name} hat „{title}“ abgesagt", "untitled": "Unbenannter Termin" }, + "calendar_month_select": { + "selected_range": "{start} bis {end} ausgewählt.", + "selected_day": "{day} ausgewählt.", + "enter_hint": "Drücken Sie die Eingabetaste, um einen Termin zu erstellen." + }, "company_calendars": { "section": "Organisation" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Noch nicht angehängt oder gespeichert, und jetzt gelöscht: {names}.", "signout_held": "Noch in einem anderen Programm geöffnet, daher erst gelöscht, wenn Sie sie dort schließen: {names}." }, + "files_sync": { + "title": "Dateien auf diesem Computer", + "drive": { + "title": "Dateien-Laufwerk", + "label": "Dateien als Laufwerk anzeigen", + "description": "Ihre Dateien erscheinen im Explorer wie ein Laufwerk. Eine Datei wird heruntergeladen, wenn Sie sie öffnen.", + "unsupported": "Erfordert Windows 10 Version 1809 oder neuer.", + "root_name": "Dateien-Laufwerk", + "letter_label": "Laufwerksbuchstabe", + "letter_description": "Gibt dem Laufwerk einen eigenen Buchstaben im Explorer.", + "letter_none": "Kein Buchstabe" + }, + "folders": { + "title": "Synchronisierte Ordner", + "description": "Ein Ordner auf diesem Computer und ein Ordner in Dateien werden gleich gehalten, in beide Richtungen.", + "empty": "Es werden noch keine Ordner synchronisiert.", + "forced_off": "Ihre Organisation hat das Synchronisieren von Ordnern ausgeschaltet. Die Dateien darin bleiben auf diesem Computer.", + "add": "Ordner hinzufügen…", + "loading": "Ihre Ordner werden geladen…", + "load_failed": "Ihre Ordner in Dateien konnten nicht geladen werden. Versuchen Sie es erneut.", + "pick_title": "Welcher Ordner in Dateien?", + "pick_confirm": "Ordner auf diesem Computer wählen…", + "paired_with": "Mit {folder} in Dateien", + "pause": "Pausieren", + "resume": "Fortsetzen", + "remove": "Entfernen…", + "remove_title": "Synchronisierung dieses Ordners beenden?", + "remove_message": "Die Dateien auf diesem Computer bleiben, wo sie sind, und die in Dateien auch. Änderungen werden nicht mehr übertragen.", + "remove_confirm": "Synchronisierung beenden", + "add_failed": { + "overlap": "Dieser Ordner liegt in einem synchronisierten Ordner, enthält einen oder ist ein Systemordner. Wählen Sie einen anderen.", + "denied": "Die App kann diesen Ordner nicht verwenden. Wählen Sie einen anderen.", + "policy": "Ihre Organisation erlaubt das nicht.", + "failed": "Der Ordner konnte nicht hinzugefügt werden. Versuchen Sie es erneut." + } + }, + "status": { + "starting": "Wird gestartet…", + "idle": "Aktuell. Zuletzt synchronisiert {time}", + "idle_never": "Aktuell", + "syncing": "Wird synchronisiert: {done} erledigt, {pending} ausstehend", + "paused": "Pausiert", + "offline": "Offline. {count, plural, =0 {Änderungen warten auf den Server} one {# Änderung wartet auf den Server} other {# Änderungen warten auf den Server}}", + "error": "Gestoppt: {reason}", + "confirm_delete": "Pausiert, bis Sie die Frage unten beantworten", + "blocked": "Von Ihrer Organisation ausgeschaltet" + }, + "mass_delete": { + "title": "{count, plural, one {# Element} other {# Elemente}} löschen?", + "local": "{count, plural, one {# Element wurde} other {# Elemente wurden}} auf diesem Computer aus {folder} gelöscht. {count, plural, one {Soll es} other {Sollen sie}} auch in Dateien gelöscht werden?", + "remote": "{count, plural, one {# Element wurde} other {# Elemente wurden}} in Dateien aus {folder} gelöscht. {count, plural, one {Soll es} other {Sollen sie}} auch auf diesem Computer gelöscht werden?", + "allow": "{count, plural, one {Löschen} other {Alle löschen}}", + "restore": "{count, plural, one {Wiederherstellen} other {Alle wiederherstellen}}", + "later": "Später entscheiden" + }, + "activity": { + "title": "Letzte Aktivitäten", + "empty": "Bisher ist nichts passiert.", + "clear": "Leeren", + "to_files": "Nach Dateien", + "to_computer": "Auf diesen Computer", + "mark_conflict": "Konflikt", + "mark_error": "Fehler", + "mark_question": "Wartet auf Ihre Antwort", + "uploaded": "{path} hochgeladen", + "downloaded": "{path} heruntergeladen", + "folder-created": "Ordner {path} erstellt", + "renamed": "{detail} in {path} umbenannt", + "moved": "{detail} nach {path} verschoben", + "deleted": "{path} gelöscht", + "trashed": "{path} in den Papierkorb verschoben", + "conflict": "{path} wurde an beiden Stellen geändert. Die andere Version bleibt als {detail} erhalten", + "renamed-for-disk": "{path} heißt auf diesem Computer {detail}, weil der Name hier nicht erlaubt ist", + "too-large": "{path} ist größer, als Dateien erlaubt ({detail}). Die Datei bleibt nur auf diesem Computer", + "waiting": "{path} wartet auf den Server", + "mass-delete": "Vor dem Löschen vieler Elemente angehalten", + "error": "{path} konnte nicht synchronisiert werden" + }, + "errors": { + "offline": "Der Server ist nicht erreichbar", + "server_refused": "Der Server hat die Änderung abgelehnt", + "too_large": "Die Datei ist zu groß für Dateien", + "name_invalid": "Der Name ist nicht erlaubt", + "disk_full": "Der Datenträger ist voll", + "denied": "Kein Zugriff", + "busy": "Wird von einem anderen Programm verwendet", + "quota": "Ihr Speicher in Dateien ist voll", + "not_found": "Nicht mehr vorhanden", + "policy": "Ihre Organisation erlaubt das nicht", + "failed": "Etwas ist schiefgelaufen" + }, + "item": { + "label": "Auf diesem Computer", + "synced": "Synchronisiert", + "syncing": "Wird synchronisiert", + "pending": "Wartet auf Synchronisierung", + "error": "Synchronisierung fehlgeschlagen", + "online-only": "Nur in Dateien, wird beim Öffnen heruntergeladen" + }, + "show_in": { + "explorer": "Im Explorer anzeigen", + "finder": "Im Finder anzeigen", + "file_manager": "Im Dateimanager anzeigen", + "failed": "Der Ordner konnte nicht geöffnet werden." + } + }, "desktopQuickCompose": { "label": "Schnell schreiben", "menu": "Schnell schreiben", @@ -7665,6 +7776,14 @@ "types_character": "Diese Tasten erzeugen auf Ihrer Tastatur ein Zeichen, das dann in keiner App mehr funktionieren würde. Versuchen Sie andere Tasten.", "reserved": "Ihr System oder die meisten Apps verwenden diese Tasten. Versuchen Sie andere Tasten." }, + "desktopAttachRecent": { + "heading": "Zuletzt verwendete Dateien", + "browse": "Diesen Computer durchsuchen…", + "from_files": "Aus Dateien…", + "missing": "\"{name}\" ist nicht mehr vorhanden.", + "too_large": "\"{name}\" ist größer als {max} und kann nicht angehängt werden.", + "failed": "\"{name}\" konnte nicht angehängt werden. Versuchen Sie es erneut." + }, "compose_windows": { "more_formatting": "Weitere Formatierung", "offline": "Offline", @@ -7770,5 +7889,9 @@ "unread_badge_description": "So sehen Sie neue Mail, während Sie in einer anderen App arbeiten.", "tip_compose_keys": "Drücken Sie {keys}, um eine neue Nachricht zu schreiben", "tip_palette_keys": "Drücken Sie {keys}, um jeden Befehl zu finden" + }, + "selectionView": { + "starred": "Mit Stern markiert", + "unstarred": "Stern entfernt" } } diff --git a/locales/en/common.json b/locales/en/common.json index a7e3e248c..24163d613 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5279,6 +5279,11 @@ "cancelled": "{name} cancelled \"{title}\"", "untitled": "Untitled event" }, + "calendar_month_select": { + "selected_range": "{start} to {end} selected.", + "selected_day": "{day} selected.", + "enter_hint": "Press Enter to create an event." + }, "company_calendars": { "section": "Organisation" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Not attached or saved yet, and deleted now: {names}.", "signout_held": "Still open in another program, so not deleted until you close it there: {names}." }, + "files_sync": { + "title": "Files on this computer", + "drive": { + "title": "Files drive", + "label": "Show Files as a drive", + "description": "Your Files show in File Explorer like a drive. A file is downloaded when you open it.", + "unsupported": "Needs Windows 10 version 1809 or later.", + "root_name": "Files drive", + "letter_label": "Drive letter", + "letter_description": "Gives the drive its own letter in File Explorer.", + "letter_none": "No letter" + }, + "folders": { + "title": "Synced folders", + "description": "A folder on this computer and a folder in Files are kept the same, in both directions.", + "empty": "No folders are synced yet.", + "forced_off": "Your organisation turned off syncing folders. The files in them stay on this computer.", + "add": "Add folder…", + "loading": "Loading your folders…", + "load_failed": "Your folders in Files couldn't be loaded. Try again.", + "pick_title": "Which folder in Files?", + "pick_confirm": "Choose folder on this computer…", + "paired_with": "With {folder} in Files", + "pause": "Pause", + "resume": "Resume", + "remove": "Remove…", + "remove_title": "Stop syncing this folder?", + "remove_message": "The files on this computer stay where they are, and so do the files in Files. Changes no longer go from one to the other.", + "remove_confirm": "Stop syncing", + "add_failed": { + "overlap": "This folder is inside a synced folder, holds one, or is a system folder. Choose another one.", + "denied": "The app can't use this folder. Choose another one.", + "policy": "Your organisation doesn't allow this.", + "failed": "The folder couldn't be added. Try again." + } + }, + "status": { + "starting": "Starting…", + "idle": "Up to date. Last synced {time}", + "idle_never": "Up to date", + "syncing": "Syncing: {done} done, {pending} to go", + "paused": "Paused", + "offline": "Offline. {count, plural, =0 {Changes wait for the server} one {# change waits for the server} other {# changes wait for the server}}", + "error": "Stopped: {reason}", + "confirm_delete": "Paused until you answer the question below", + "blocked": "Turned off by your organisation" + }, + "mass_delete": { + "title": "Delete {count, plural, one {# item} other {# items}}?", + "local": "{count, plural, one {# item was} other {# items were}} deleted from {folder} on this computer. Delete {count, plural, one {it} other {them}} in Files too?", + "remote": "{count, plural, one {# item was} other {# items were}} deleted from {folder} in Files. Delete {count, plural, one {it} other {them}} on this computer too?", + "allow": "{count, plural, one {Delete it} other {Delete them}}", + "restore": "{count, plural, one {Bring it back} other {Bring them back}}", + "later": "Decide later" + }, + "activity": { + "title": "Recent activity", + "empty": "Nothing has happened yet.", + "clear": "Clear", + "to_files": "To Files", + "to_computer": "To this computer", + "mark_conflict": "Conflict", + "mark_error": "Error", + "mark_question": "Needs your answer", + "uploaded": "Uploaded {path}", + "downloaded": "Downloaded {path}", + "folder-created": "Created the folder {path}", + "renamed": "Renamed {detail} to {path}", + "moved": "Moved {detail} to {path}", + "deleted": "Deleted {path}", + "trashed": "Moved {path} to the trash", + "conflict": "{path} was changed in both places. The other version is kept as {detail}", + "renamed-for-disk": "{path} is called {detail} on this computer, because its name isn't allowed here", + "too-large": "{path} is bigger than Files allows ({detail}). It stays on this computer only", + "waiting": "{path} is waiting for the server", + "mass-delete": "Paused before deleting many items", + "error": "{path} couldn't be synced" + }, + "errors": { + "offline": "The server can't be reached", + "server_refused": "The server refused the change", + "too_large": "The file is too big for Files", + "name_invalid": "The name isn't allowed", + "disk_full": "The disk is full", + "denied": "Not allowed", + "busy": "Another program is using it", + "quota": "Your storage in Files is full", + "not_found": "It's no longer there", + "policy": "Your organisation doesn't allow this", + "failed": "Something went wrong" + }, + "item": { + "label": "On this computer", + "synced": "Synced", + "syncing": "Syncing", + "pending": "Waiting to sync", + "error": "Couldn't sync", + "online-only": "Only in Files, downloaded when opened" + }, + "show_in": { + "explorer": "Show in Explorer", + "finder": "Show in Finder", + "file_manager": "Show in file manager", + "failed": "The folder couldn't be opened." + } + }, "desktopQuickCompose": { "label": "Quick compose", "menu": "Quick compose", @@ -7665,6 +7776,14 @@ "types_character": "These keys type a character on your keyboard, and it would stop working in every app. Try other keys.", "reserved": "Your system or most apps use these keys. Try other keys." }, + "desktopAttachRecent": { + "heading": "Recent files", + "browse": "Browse this computer…", + "from_files": "From Files…", + "missing": "\"{name}\" isn't there any more.", + "too_large": "\"{name}\" is larger than {max} and can't be attached.", + "failed": "\"{name}\" couldn't be attached. Try again." + }, "compose_windows": { "more_formatting": "More formatting", "offline": "Offline", @@ -7770,5 +7889,9 @@ "unread_badge_description": "So you see new mail while you work in another app.", "tip_compose_keys": "Press {keys} to write a new message", "tip_palette_keys": "Press {keys} to find any command" + }, + "selectionView": { + "starred": "Starred", + "unstarred": "Star removed" } } diff --git a/locales/es/common.json b/locales/es/common.json index db6652b53..47392a745 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5905,6 +5905,11 @@ "cancelled": "{name} ha cancelado \"{title}\"", "untitled": "Evento sin título" }, + "calendar_month_select": { + "selected_range": "Seleccionado del {start} al {end}.", + "selected_day": "Seleccionado {day}.", + "enter_hint": "Pulsa Intro para crear un evento." + }, "company_calendars": { "section": "Organización" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Aún sin adjuntar ni guardar, y eliminados ahora: {names}.", "signout_held": "Siguen abiertos en otro programa, así que no se eliminan hasta que los cierre allí: {names}." }, + "files_sync": { + "title": "Archivos en este ordenador", + "drive": { + "title": "Unidad de Archivos", + "label": "Mostrar Archivos como una unidad", + "description": "Tus Archivos aparecen en el Explorador de archivos como una unidad. Un archivo se descarga cuando lo abres.", + "unsupported": "Requiere Windows 10 versión 1809 o posterior.", + "root_name": "Unidad de Archivos", + "letter_label": "Letra de unidad", + "letter_description": "Asigna a la unidad su propia letra en el Explorador de archivos.", + "letter_none": "Sin letra" + }, + "folders": { + "title": "Carpetas sincronizadas", + "description": "Una carpeta de este ordenador y una carpeta de Archivos se mantienen iguales, en los dos sentidos.", + "empty": "Todavía no se sincroniza ninguna carpeta.", + "forced_off": "Tu organización desactivó la sincronización de carpetas. Los archivos que contienen se quedan en este ordenador.", + "add": "Añadir carpeta…", + "loading": "Cargando tus carpetas…", + "load_failed": "No se pudieron cargar tus carpetas de Archivos. Inténtalo de nuevo.", + "pick_title": "¿Qué carpeta de Archivos?", + "pick_confirm": "Elegir carpeta en este ordenador…", + "paired_with": "Con {folder} en Archivos", + "pause": "Pausar", + "resume": "Reanudar", + "remove": "Quitar…", + "remove_title": "¿Dejar de sincronizar esta carpeta?", + "remove_message": "Los archivos de este ordenador se quedan donde están, y también los de Archivos. Los cambios ya no pasan de uno a otro.", + "remove_confirm": "Dejar de sincronizar", + "add_failed": { + "overlap": "Esta carpeta está dentro de una carpeta sincronizada, contiene una o es una carpeta del sistema. Elige otra.", + "denied": "La aplicación no puede usar esta carpeta. Elige otra.", + "policy": "Tu organización no lo permite.", + "failed": "No se pudo añadir la carpeta. Inténtalo de nuevo." + } + }, + "status": { + "starting": "Iniciando…", + "idle": "Al día. Última sincronización {time}", + "idle_never": "Al día", + "syncing": "Sincronizando: {done} hechos, {pending} pendientes", + "paused": "En pausa", + "offline": "Sin conexión. {count, plural, =0 {Los cambios esperan al servidor} one {# cambio espera al servidor} other {# cambios esperan al servidor}}", + "error": "Detenido: {reason}", + "confirm_delete": "En pausa hasta que respondas la pregunta de abajo", + "blocked": "Desactivado por tu organización" + }, + "mass_delete": { + "title": "¿Eliminar {count, plural, one {# elemento} other {# elementos}}?", + "local": "{count, plural, one {Se eliminó # elemento} other {Se eliminaron # elementos}} de {folder} en este ordenador. ¿{count, plural, one {Eliminarlo} other {Eliminarlos}} también en Archivos?", + "remote": "{count, plural, one {Se eliminó # elemento} other {Se eliminaron # elementos}} de {folder} en Archivos. ¿{count, plural, one {Eliminarlo} other {Eliminarlos}} también en este ordenador?", + "allow": "{count, plural, one {Eliminarlo} other {Eliminarlos}}", + "restore": "{count, plural, one {Recuperarlo} other {Recuperarlos}}", + "later": "Decidir más tarde" + }, + "activity": { + "title": "Actividad reciente", + "empty": "Todavía no ha pasado nada.", + "clear": "Borrar", + "to_files": "A Archivos", + "to_computer": "A este ordenador", + "mark_conflict": "Conflicto", + "mark_error": "Fallo", + "mark_question": "Necesita tu respuesta", + "uploaded": "Se subió {path}", + "downloaded": "Se descargó {path}", + "folder-created": "Se creó la carpeta {path}", + "renamed": "Se cambió el nombre de {detail} a {path}", + "moved": "Se movió {detail} a {path}", + "deleted": "Se eliminó {path}", + "trashed": "Se movió {path} a la papelera", + "conflict": "{path} se cambió en los dos sitios. La otra versión se guarda como {detail}", + "renamed-for-disk": "{path} se llama {detail} en este ordenador, porque su nombre no está permitido aquí", + "too-large": "{path} supera el tamaño que permite Archivos ({detail}). Se queda solo en este ordenador", + "waiting": "{path} está esperando al servidor", + "mass-delete": "En pausa antes de eliminar muchos elementos", + "error": "No se pudo sincronizar {path}" + }, + "errors": { + "offline": "No se puede conectar con el servidor", + "server_refused": "El servidor rechazó el cambio", + "too_large": "El archivo es demasiado grande para Archivos", + "name_invalid": "El nombre no está permitido", + "disk_full": "El disco está lleno", + "denied": "Sin permiso", + "busy": "Otro programa lo está usando", + "quota": "Tu almacenamiento en Archivos está lleno", + "not_found": "Ya no existe", + "policy": "Tu organización no lo permite", + "failed": "Algo salió mal" + }, + "item": { + "label": "En este ordenador", + "synced": "Sincronizado", + "syncing": "Sincronizando", + "pending": "Esperando para sincronizar", + "error": "No se pudo sincronizar", + "online-only": "Solo en Archivos, se descarga al abrirlo" + }, + "show_in": { + "explorer": "Mostrar en el Explorador de archivos", + "finder": "Mostrar en Finder", + "file_manager": "Mostrar en el gestor de archivos", + "failed": "No se pudo abrir la carpeta." + } + }, "desktopQuickCompose": { "label": "Redacción rápida", "menu": "Redacción rápida", @@ -7665,6 +7776,14 @@ "types_character": "Estas teclas escriben un carácter en tu teclado, y dejaría de funcionar en todas las aplicaciones. Prueba otras teclas.", "reserved": "Tu sistema o la mayoría de las aplicaciones usan estas teclas. Prueba otras teclas." }, + "desktopAttachRecent": { + "heading": "Archivos recientes", + "browse": "Examinar este equipo…", + "from_files": "Desde Archivos…", + "missing": "\"{name}\" ya no existe.", + "too_large": "\"{name}\" supera {max} y no se puede adjuntar.", + "failed": "No se pudo adjuntar \"{name}\". Inténtalo de nuevo." + }, "compose_windows": { "more_formatting": "Más formato", "offline": "Sin conexión", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Para ver el correo nuevo mientras trabaja en otra aplicación.", "tip_compose_keys": "Pulse {keys} para escribir un mensaje nuevo", "tip_palette_keys": "Pulse {keys} para buscar cualquier comando" + }, + "selectionView": { + "starred": "Destacado", + "unstarred": "Estrella quitada" } } diff --git a/locales/et/common.json b/locales/et/common.json index 336b04ed0..06ddf08f8 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5279,6 +5279,11 @@ "cancelled": "{name} tühistas sündmuse \"{title}\"", "untitled": "Nimetu sündmus" }, + "calendar_month_select": { + "selected_range": "Valitud {start} kuni {end}.", + "selected_day": "Valitud {day}.", + "enter_hint": "Sündmuse loomiseks vajutage Enter." + }, "company_calendars": { "section": "Organisatsioon" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Veel lisamata ja salvestamata ning nüüd kustutatud: {names}.", "signout_held": "Endiselt teises programmis avatud, seega kustutatakse alles pärast sulgemist: {names}." }, + "files_sync": { + "title": "Failid selles arvutis", + "drive": { + "title": "Failide draiv", + "label": "Näita Faile draivina", + "description": "Sinu Failid on Failihalduris näha draivina. Fail laaditakse alla, kui selle avad.", + "unsupported": "Vajab Windows 10 versiooni 1809 või uuemat.", + "root_name": "Failide draiv", + "letter_label": "Draivitäht", + "letter_description": "Annab draivile Failihalduris oma tähe.", + "letter_none": "Täheta" + }, + "folders": { + "title": "Sünkroonitud kaustad", + "description": "Kaust selles arvutis ja kaust Failides hoitakse ühesugused, muudatused liiguvad mõlemas suunas.", + "empty": "Ühtegi kausta pole veel sünkroonitud.", + "forced_off": "Sinu organisatsioon lülitas kaustade sünkroonimise välja. Nendes olevad failid jäävad sellesse arvutisse.", + "add": "Lisa kaust…", + "loading": "Sinu kaustade laadimine…", + "load_failed": "Sinu kaustu Failides ei õnnestunud laadida. Proovi uuesti.", + "pick_title": "Milline kaust Failides?", + "pick_confirm": "Vali kaust selles arvutis…", + "paired_with": "Koos kaustaga {folder} Failides", + "pause": "Peata", + "resume": "Jätka", + "remove": "Eemalda…", + "remove_title": "Kas lõpetada selle kausta sünkroonimine?", + "remove_message": "Selle arvuti failid jäävad oma kohale, samuti failid Failides. Muudatused ei liigu enam ühest teise.", + "remove_confirm": "Lõpeta sünkroonimine", + "add_failed": { + "overlap": "See kaust asub sünkroonitud kaustas, sisaldab sellist kausta või on süsteemikaust. Vali mõni teine.", + "denied": "Rakendus ei saa seda kausta kasutada. Vali mõni teine.", + "policy": "Sinu organisatsioon ei luba seda.", + "failed": "Kausta ei õnnestunud lisada. Proovi uuesti." + } + }, + "status": { + "starting": "Käivitamine…", + "idle": "Ajakohane. Viimati sünkroonitud {time}", + "idle_never": "Ajakohane", + "syncing": "Sünkroonimine: {done} tehtud, {pending} jäänud", + "paused": "Peatatud", + "offline": "Võrguühenduseta. {count, plural, =0 {Muudatused ootavad serverit} one {# muudatus ootab serverit} other {# muudatust ootab serverit}}", + "error": "Peatunud: {reason}", + "confirm_delete": "Peatatud, kuni vastad allolevale küsimusele", + "blocked": "Sinu organisatsioon lülitas selle välja" + }, + "mass_delete": { + "title": "Kas kustutada {count, plural, one {# üksus} other {# üksust}}?", + "local": "{count, plural, one {# üksus kustutati} other {# üksust kustutati}} selle arvuti kaustast {folder}. Kas kustutada {count, plural, one {see} other {need}} ka Failides?", + "remote": "{count, plural, one {# üksus kustutati} other {# üksust kustutati}} Failide kaustast {folder}. Kas kustutada {count, plural, one {see} other {need}} ka selles arvutis?", + "allow": "{count, plural, one {Kustuta see} other {Kustuta need}}", + "restore": "{count, plural, one {Too see tagasi} other {Too need tagasi}}", + "later": "Otsusta hiljem" + }, + "activity": { + "title": "Viimased tegevused", + "empty": "Midagi pole veel juhtunud.", + "clear": "Tühjenda", + "to_files": "Failidesse", + "to_computer": "Sellesse arvutisse", + "mark_conflict": "Konflikt", + "mark_error": "Viga", + "mark_question": "Ootab sinu vastust", + "uploaded": "Üles laaditud: {path}", + "downloaded": "Alla laaditud: {path}", + "folder-created": "Loodud kaust {path}", + "renamed": "Ümber nimetatud: {detail} → {path}", + "moved": "Teisaldatud: {detail} → {path}", + "deleted": "Kustutatud: {path}", + "trashed": "Prügikasti teisaldatud: {path}", + "conflict": "{path} muutus mõlemas kohas. Teine versioon on alles nimega {detail}", + "renamed-for-disk": "{path} on selles arvutis nimega {detail}, sest selle nimi pole siin lubatud", + "too-large": "{path} on suurem, kui Failid lubavad ({detail}). See jääb ainult sellesse arvutisse", + "waiting": "{path} ootab serverit", + "mass-delete": "Peatatud enne paljude üksuste kustutamist", + "error": "{path} sünkroonimine ebaõnnestus" + }, + "errors": { + "offline": "Server pole kättesaadav", + "server_refused": "Server lükkas muudatuse tagasi", + "too_large": "Fail on Failide jaoks liiga suur", + "name_invalid": "See nimi pole lubatud", + "disk_full": "Ketas on täis", + "denied": "Pole lubatud", + "busy": "Mõni teine programm kasutab seda", + "quota": "Sinu salvestusruum Failides on täis", + "not_found": "Seda pole enam olemas", + "policy": "Sinu organisatsioon ei luba seda", + "failed": "Midagi läks valesti" + }, + "item": { + "label": "Selles arvutis", + "synced": "Sünkroonitud", + "syncing": "Sünkroonimine", + "pending": "Ootab sünkroonimist", + "error": "Sünkroonimine ebaõnnestus", + "online-only": "Ainult Failides, laaditakse avamisel alla" + }, + "show_in": { + "explorer": "Kuva Failihalduris", + "finder": "Kuva Finderis", + "file_manager": "Kuva failihalduris", + "failed": "Kausta ei õnnestunud avada." + } + }, "desktopQuickCompose": { "label": "Kiirkiri", "menu": "Kiirkiri", @@ -7665,6 +7776,14 @@ "types_character": "Need klahvid kirjutavad teie klaviatuuril märgi, mis lakkaks kõigis rakendustes töötamast. Proovige teisi klahve.", "reserved": "Neid klahve kasutab süsteem või enamik rakendusi. Proovige teisi klahve." }, + "desktopAttachRecent": { + "heading": "Viimati kasutatud failid", + "browse": "Sirvi seda arvutit…", + "from_files": "Failidest…", + "missing": "„{name}” pole enam olemas.", + "too_large": "„{name}” on suurem kui {max} ja seda ei saa lisada.", + "failed": "Faili „{name}” ei saanud lisada. Proovi uuesti." + }, "compose_windows": { "more_formatting": "Rohkem vormindust", "offline": "Võrguta", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Nii näete uut kirja ka siis, kui töötate teises rakenduses.", "tip_compose_keys": "Uue kirja kirjutamiseks vajutage {keys}", "tip_palette_keys": "Mis tahes käsu leidmiseks vajutage {keys}" + }, + "selectionView": { + "starred": "Tärniga märgitud", + "unstarred": "Tärn eemaldatud" } } diff --git a/locales/fa/common.json b/locales/fa/common.json index 9253da444..4f8dbe374 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5279,6 +5279,11 @@ "cancelled": "{name} «{title}» را لغو کرد", "untitled": "رویداد بدون عنوان" }, + "calendar_month_select": { + "selected_range": "{start} تا {end} انتخاب شد.", + "selected_day": "{day} انتخاب شد.", + "enter_hint": "برای ساختن رویداد، Enter را بزنید." + }, "company_calendars": { "section": "سازمان" }, @@ -7650,6 +7655,112 @@ "signout_changed": "هنوز پیوست یا ذخیره نشده و اکنون حذف می‌شود: {names}.", "signout_held": "هنوز در برنامه دیگری باز است، پس فقط پس از بستن در همان‌جا حذف می‌شود: {names}." }, + "files_sync": { + "title": "فایل‌ها روی این رایانه", + "drive": { + "title": "درایو فایل‌ها", + "label": "نمایش فایل‌ها به‌صورت درایو", + "description": "فایل‌های شما مانند یک درایو در کاوشگر فایل نمایش داده می‌شوند. هر فایل هنگام باز کردن بارگیری می‌شود.", + "unsupported": "به Windows 10 نسخهٔ 1809 یا بالاتر نیاز دارد.", + "root_name": "درایو فایل‌ها", + "letter_label": "حرف درایو", + "letter_description": "به درایو در کاوشگر فایل یک حرف اختصاصی می‌دهد.", + "letter_none": "بدون حرف" + }, + "folders": { + "title": "پوشه‌های همگام‌شده", + "description": "یک پوشه روی این رایانه و یک پوشه در فایل‌ها، در هر دو جهت یکسان نگه داشته می‌شوند.", + "empty": "هنوز هیچ پوشه‌ای همگام نشده است.", + "forced_off": "سازمان شما همگام‌سازی پوشه‌ها را خاموش کرده است. فایل‌های داخل آن‌ها روی این رایانه می‌مانند.", + "add": "افزودن پوشه…", + "loading": "در حال بارگذاری پوشه‌های شما…", + "load_failed": "پوشه‌های شما در فایل‌ها بارگذاری نشد. دوباره تلاش کنید.", + "pick_title": "کدام پوشه در فایل‌ها؟", + "pick_confirm": "انتخاب پوشه روی این رایانه…", + "paired_with": "با {folder} در فایل‌ها", + "pause": "توقف موقت", + "resume": "ادامه", + "remove": "حذف…", + "remove_title": "همگام‌سازی این پوشه متوقف شود؟", + "remove_message": "فایل‌های روی این رایانه سر جای خود می‌مانند و فایل‌های داخل فایل‌ها هم همین‌طور. تغییرات دیگر از یکی به دیگری منتقل نمی‌شوند.", + "remove_confirm": "توقف همگام‌سازی", + "add_failed": { + "overlap": "این پوشه داخل یک پوشهٔ همگام‌شده است، یک پوشهٔ همگام‌شده را در خود دارد، یا پوشهٔ سیستمی است. پوشهٔ دیگری انتخاب کنید.", + "denied": "برنامه نمی‌تواند از این پوشه استفاده کند. پوشهٔ دیگری انتخاب کنید.", + "policy": "سازمان شما اجازهٔ این کار را نمی‌دهد.", + "failed": "پوشه اضافه نشد. دوباره تلاش کنید." + } + }, + "status": { + "starting": "در حال شروع…", + "idle": "به‌روز است. آخرین همگام‌سازی {time}", + "idle_never": "به‌روز است", + "syncing": "در حال همگام‌سازی: {done} انجام شد، {pending} مانده", + "paused": "متوقف‌شده", + "offline": "آفلاین. {count, plural, =0 {تغییرات منتظر سرور هستند} other {# تغییر منتظر سرور است}}", + "error": "متوقف شد: {reason}", + "confirm_delete": "تا وقتی به پرسش زیر پاسخ دهید متوقف است", + "blocked": "سازمان شما آن را خاموش کرده است" + }, + "mass_delete": { + "title": "{count, plural, other {# مورد}} حذف شود؟", + "local": "{count, plural, other {# مورد}} از {folder} روی این رایانه حذف شد. در فایل‌ها هم حذف شود؟", + "remote": "{count, plural, other {# مورد}} از {folder} در فایل‌ها حذف شد. روی این رایانه هم حذف شود؟", + "allow": "{count, plural, other {حذف}}", + "restore": "{count, plural, other {بازگرداندن}}", + "later": "بعداً تصمیم می‌گیرم" + }, + "activity": { + "title": "فعالیت‌های اخیر", + "empty": "هنوز اتفاقی نیفتاده است.", + "clear": "پاک کردن", + "to_files": "به فایل‌ها", + "to_computer": "به این رایانه", + "mark_conflict": "تعارض", + "mark_error": "خطا", + "mark_question": "نیاز به پاسخ شما", + "uploaded": "{path} بارگذاری شد", + "downloaded": "{path} بارگیری شد", + "folder-created": "پوشهٔ {path} ساخته شد", + "renamed": "نام {detail} به {path} تغییر کرد", + "moved": "{detail} به {path} منتقل شد", + "deleted": "{path} حذف شد", + "trashed": "{path} به سطل زباله منتقل شد", + "conflict": "{path} در هر دو جا تغییر کرد. نسخهٔ دیگر با نام {detail} نگه داشته شد", + "renamed-for-disk": "نام {path} روی این رایانه {detail} است، چون نام اصلی آن اینجا مجاز نیست", + "too-large": "{path} از حدی که فایل‌ها اجازه می‌دهد ({detail}) بزرگ‌تر است. فقط روی این رایانه می‌ماند", + "waiting": "{path} منتظر سرور است", + "mass-delete": "پیش از حذف تعداد زیادی مورد متوقف شد", + "error": "{path} همگام‌سازی نشد" + }, + "errors": { + "offline": "دسترسی به سرور ممکن نیست", + "server_refused": "سرور تغییر را نپذیرفت", + "too_large": "این فایل از حدی که فایل‌ها اجازه می‌دهد بزرگ‌تر است", + "name_invalid": "این نام مجاز نیست", + "disk_full": "دیسک پر است", + "denied": "مجاز نیست", + "busy": "برنامهٔ دیگری از آن استفاده می‌کند", + "quota": "فضای ذخیره‌سازی شما در فایل‌ها پر است", + "not_found": "دیگر وجود ندارد", + "policy": "سازمان شما اجازهٔ این کار را نمی‌دهد", + "failed": "مشکلی پیش آمد" + }, + "item": { + "label": "روی این رایانه", + "synced": "همگام‌شده", + "syncing": "در حال همگام‌سازی", + "pending": "در انتظار همگام‌سازی", + "error": "همگام‌سازی نشد", + "online-only": "فقط در فایل‌ها، هنگام باز کردن بارگیری می‌شود" + }, + "show_in": { + "explorer": "نمایش در کاوشگر فایل", + "finder": "نمایش در Finder", + "file_manager": "نمایش در مدیر فایل", + "failed": "پوشه باز نشد." + } + }, "desktopQuickCompose": { "label": "نوشتن سریع", "menu": "نوشتن سریع", @@ -7665,6 +7776,14 @@ "types_character": "این کلیدها روی صفحه‌کلید شما نویسه‌ای می‌نویسند و آن نویسه دیگر در هیچ برنامه‌ای کار نمی‌کند. کلیدهای دیگری را امتحان کنید.", "reserved": "سیستم یا بیشتر برنامه‌ها از این کلیدها استفاده می‌کنند. کلیدهای دیگری را امتحان کنید." }, + "desktopAttachRecent": { + "heading": "فایل‌های اخیر", + "browse": "مرور این رایانه…", + "from_files": "از فایل‌ها…", + "missing": "«{name}» دیگر وجود ندارد.", + "too_large": "«{name}» بزرگ‌تر از {max} است و نمی‌توان آن را پیوست کرد.", + "failed": "پیوست «{name}» ممکن نشد. دوباره امتحان کنید." + }, "compose_windows": { "more_formatting": "قالب‌بندی بیشتر", "offline": "آفلاین", @@ -7770,5 +7889,9 @@ "unread_badge_description": "تا ایمیل جدید را هنگام کار در برنامه‌ای دیگر ببینید.", "tip_compose_keys": "برای نوشتن پیام جدید {keys} را فشار دهید", "tip_palette_keys": "برای یافتن هر فرمانی {keys} را فشار دهید" + }, + "selectionView": { + "starred": "ستاره‌دار شد", + "unstarred": "ستاره برداشته شد" } } diff --git a/locales/fr/common.json b/locales/fr/common.json index 9d5ac7416..82420a184 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5905,6 +5905,11 @@ "cancelled": "{name} a annulé « {title} »", "untitled": "Événement sans titre" }, + "calendar_month_select": { + "selected_range": "Du {start} au {end} sélectionné.", + "selected_day": "{day} sélectionné.", + "enter_hint": "Appuyez sur Entrée pour créer un événement." + }, "company_calendars": { "section": "Organisation" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Pas encore jointes ni enregistrées, et supprimées maintenant : {names}.", "signout_held": "Encore ouvertes dans un autre programme, donc supprimées seulement une fois fermées : {names}." }, + "files_sync": { + "title": "Fichiers sur cet ordinateur", + "drive": { + "title": "Lecteur Fichiers", + "label": "Afficher Fichiers comme un lecteur", + "description": "Vos Fichiers apparaissent dans l'Explorateur de fichiers comme un lecteur. Un fichier est téléchargé quand vous l'ouvrez.", + "unsupported": "Nécessite Windows 10 version 1809 ou ultérieure.", + "root_name": "Lecteur Fichiers", + "letter_label": "Lettre de lecteur", + "letter_description": "Donne au lecteur sa propre lettre dans l'Explorateur de fichiers.", + "letter_none": "Aucune lettre" + }, + "folders": { + "title": "Dossiers synchronisés", + "description": "Un dossier sur cet ordinateur et un dossier dans Fichiers restent identiques, dans les deux sens.", + "empty": "Aucun dossier n'est encore synchronisé.", + "forced_off": "Votre organisation a désactivé la synchronisation des dossiers. Les fichiers qu'ils contiennent restent sur cet ordinateur.", + "add": "Ajouter un dossier…", + "loading": "Chargement de vos dossiers…", + "load_failed": "Vos dossiers dans Fichiers n'ont pas pu être chargés. Réessayez.", + "pick_title": "Quel dossier dans Fichiers ?", + "pick_confirm": "Choisir un dossier sur cet ordinateur…", + "paired_with": "Avec {folder} dans Fichiers", + "pause": "Suspendre", + "resume": "Reprendre", + "remove": "Retirer…", + "remove_title": "Arrêter de synchroniser ce dossier ?", + "remove_message": "Les fichiers sur cet ordinateur restent où ils sont, tout comme ceux dans Fichiers. Les modifications ne passent plus de l'un à l'autre.", + "remove_confirm": "Arrêter la synchronisation", + "add_failed": { + "overlap": "Ce dossier se trouve dans un dossier synchronisé, en contient un ou est un dossier système. Choisissez-en un autre.", + "denied": "L'application ne peut pas utiliser ce dossier. Choisissez-en un autre.", + "policy": "Votre organisation ne l'autorise pas.", + "failed": "Le dossier n'a pas pu être ajouté. Réessayez." + } + }, + "status": { + "starting": "Démarrage…", + "idle": "À jour. Dernière synchronisation {time}", + "idle_never": "À jour", + "syncing": "Synchronisation : {done} terminés, {pending} restants", + "paused": "Suspendu", + "offline": "Hors ligne. {count, plural, =0 {Les modifications attendent le serveur} one {# modification attend le serveur} other {# modifications attendent le serveur}}", + "error": "Arrêté : {reason}", + "confirm_delete": "Suspendu jusqu'à ce que vous répondiez à la question ci-dessous", + "blocked": "Désactivé par votre organisation" + }, + "mass_delete": { + "title": "Supprimer {count, plural, one {# élément} other {# éléments}} ?", + "local": "{count, plural, one {# élément a été supprimé} other {# éléments ont été supprimés}} de {folder} sur cet ordinateur. {count, plural, one {Le supprimer} other {Les supprimer}} aussi dans Fichiers ?", + "remote": "{count, plural, one {# élément a été supprimé} other {# éléments ont été supprimés}} de {folder} dans Fichiers. {count, plural, one {Le supprimer} other {Les supprimer}} aussi sur cet ordinateur ?", + "allow": "{count, plural, one {Le supprimer} other {Les supprimer}}", + "restore": "{count, plural, one {Le restaurer} other {Les restaurer}}", + "later": "Décider plus tard" + }, + "activity": { + "title": "Activité récente", + "empty": "Rien ne s'est encore passé.", + "clear": "Effacer", + "to_files": "Vers Fichiers", + "to_computer": "Vers cet ordinateur", + "mark_conflict": "Conflit", + "mark_error": "Erreur", + "mark_question": "Attend votre réponse", + "uploaded": "{path} téléversé", + "downloaded": "{path} téléchargé", + "folder-created": "Dossier {path} créé", + "renamed": "{detail} renommé en {path}", + "moved": "{detail} déplacé vers {path}", + "deleted": "{path} supprimé", + "trashed": "{path} placé dans la corbeille", + "conflict": "{path} a été modifié aux deux endroits. L'autre version est conservée sous le nom {detail}", + "renamed-for-disk": "{path} s'appelle {detail} sur cet ordinateur, car son nom n'y est pas autorisé", + "too-large": "{path} dépasse la taille autorisée par Fichiers ({detail}). Il reste uniquement sur cet ordinateur", + "waiting": "{path} attend le serveur", + "mass-delete": "Suspendu avant de supprimer de nombreux éléments", + "error": "{path} n'a pas pu être synchronisé" + }, + "errors": { + "offline": "Le serveur est injoignable", + "server_refused": "Le serveur a refusé la modification", + "too_large": "Le fichier est trop volumineux pour Fichiers", + "name_invalid": "Ce nom n'est pas autorisé", + "disk_full": "Le disque est plein", + "denied": "Accès refusé", + "busy": "Un autre programme l'utilise", + "quota": "Votre espace de stockage dans Fichiers est plein", + "not_found": "Il n'existe plus", + "policy": "Votre organisation ne l'autorise pas", + "failed": "Une erreur s'est produite" + }, + "item": { + "label": "Sur cet ordinateur", + "synced": "Synchronisé", + "syncing": "Synchronisation en cours", + "pending": "En attente de synchronisation", + "error": "Échec de la synchronisation", + "online-only": "Uniquement dans Fichiers, téléchargé à l'ouverture" + }, + "show_in": { + "explorer": "Afficher dans l'Explorateur de fichiers", + "finder": "Afficher dans le Finder", + "file_manager": "Afficher dans le gestionnaire de fichiers", + "failed": "Le dossier n'a pas pu être ouvert." + } + }, "desktopQuickCompose": { "label": "Rédaction rapide", "menu": "Rédaction rapide", @@ -7665,6 +7776,14 @@ "types_character": "Ces touches produisent un caractère sur votre clavier, et il ne fonctionnerait plus dans aucune application. Essayez d'autres touches.", "reserved": "Votre système ou la plupart des applications utilisent ces touches. Essayez d'autres touches." }, + "desktopAttachRecent": { + "heading": "Fichiers récents", + "browse": "Parcourir cet ordinateur…", + "from_files": "Depuis Fichiers…", + "missing": "« {name} » n'existe plus.", + "too_large": "« {name} » dépasse {max} et ne peut pas être joint.", + "failed": "Impossible de joindre « {name} ». Réessayez." + }, "compose_windows": { "more_formatting": "Plus de mise en forme", "offline": "Hors ligne", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Pour voir les nouveaux messages pendant que vous travaillez dans une autre application.", "tip_compose_keys": "Appuyez sur {keys} pour écrire un nouveau message", "tip_palette_keys": "Appuyez sur {keys} pour trouver n'importe quelle commande" + }, + "selectionView": { + "starred": "Suivi", + "unstarred": "Suivi retiré" } } diff --git a/locales/he/common.json b/locales/he/common.json index c8144b369..2285029d8 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5724,6 +5724,11 @@ "cancelled": "{name} ביטל/ה את \"{title}\"", "untitled": "אירוע ללא כותרת" }, + "calendar_month_select": { + "selected_range": "נבחרו {start} עד {end}.", + "selected_day": "נבחר {day}.", + "enter_hint": "הקישו Enter כדי ליצור אירוע." + }, "company_calendars": { "section": "ארגון" }, @@ -7651,6 +7656,112 @@ "signout_changed": "עדיין לא צורפו או נשמרו, ונמחקים עכשיו: {names}.", "signout_held": "עדיין פתוחים בתוכנה אחרת, ולכן יימחקו רק אחרי שתסגרו אותם שם: {names}." }, + "files_sync": { + "title": "קבצים במחשב הזה", + "drive": { + "title": "כונן קבצים", + "label": "הצגת קבצים ככונן", + "description": "הקבצים שלך מופיעים בסייר הקבצים כמו כונן. כל קובץ יורד כשפותחים אותו.", + "unsupported": "נדרש Windows 10 גרסה 1809 ואילך.", + "root_name": "כונן קבצים", + "letter_label": "אות כונן", + "letter_description": "נותן לכונן אות משלו בסייר הקבצים.", + "letter_none": "ללא אות" + }, + "folders": { + "title": "תיקיות מסונכרנות", + "description": "תיקיה במחשב הזה ותיקיה בקבצים נשמרות זהות, בשני הכיוונים.", + "empty": "עדיין אין תיקיות מסונכרנות.", + "forced_off": "הארגון שלך כיבה את סנכרון התיקיות. הקבצים שבהן נשארים במחשב הזה.", + "add": "הוספת תיקיה…", + "loading": "התיקיות שלך נטענות…", + "load_failed": "לא ניתן לטעון את התיקיות שלך בקבצים. כדאי לנסות שוב.", + "pick_title": "איזו תיקיה בקבצים?", + "pick_confirm": "בחירת תיקיה במחשב הזה…", + "paired_with": "עם {folder} בקבצים", + "pause": "השהיה", + "resume": "המשך", + "remove": "הסרה…", + "remove_title": "להפסיק לסנכרן את התיקיה הזו?", + "remove_message": "הקבצים במחשב הזה נשארים במקומם, וכך גם הקבצים בקבצים. שינויים כבר לא יעברו מצד אחד לשני.", + "remove_confirm": "הפסקת הסנכרון", + "add_failed": { + "overlap": "התיקיה הזו נמצאת בתוך תיקיה מסונכרנת, מכילה תיקיה מסונכרנת, או שהיא תיקיית מערכת. יש לבחור תיקיה אחרת.", + "denied": "היישום לא יכול להשתמש בתיקיה הזו. יש לבחור תיקיה אחרת.", + "policy": "הארגון שלך לא מאפשר זאת.", + "failed": "לא ניתן להוסיף את התיקיה. כדאי לנסות שוב." + } + }, + "status": { + "starting": "מתחיל…", + "idle": "מעודכן. סונכרן לאחרונה {time}", + "idle_never": "מעודכן", + "syncing": "מסנכרן: {done} הושלמו, {pending} נותרו", + "paused": "מושהה", + "offline": "לא מקוון. {count, plural, =0 {שינויים ממתינים לשרת} one {שינוי אחד ממתין לשרת} two {שני שינויים ממתינים לשרת} other {# שינויים ממתינים לשרת}}", + "error": "נעצר: {reason}", + "confirm_delete": "מושהה עד לתשובה על השאלה למטה", + "blocked": "כובה על ידי הארגון שלך" + }, + "mass_delete": { + "title": "למחוק {count, plural, one {פריט אחד} two {שני פריטים} other {# פריטים}}?", + "local": "{count, plural, one {פריט אחד נמחק} two {שני פריטים נמחקו} other {# פריטים נמחקו}} מ-{folder} במחשב הזה. למחוק {count, plural, one {אותו} two {אותם} other {אותם}} גם בקבצים?", + "remote": "{count, plural, one {פריט אחד נמחק} two {שני פריטים נמחקו} other {# פריטים נמחקו}} מ-{folder} בקבצים. למחוק {count, plural, one {אותו} two {אותם} other {אותם}} גם במחשב הזה?", + "allow": "{count, plural, one {למחוק אותו} two {למחוק אותם} other {למחוק אותם}}", + "restore": "{count, plural, one {להחזיר אותו} two {להחזיר אותם} other {להחזיר אותם}}", + "later": "להחליט אחר כך" + }, + "activity": { + "title": "פעילות אחרונה", + "empty": "עדיין לא קרה כלום.", + "clear": "ניקוי", + "to_files": "לקבצים", + "to_computer": "למחשב הזה", + "mark_conflict": "התנגשות", + "mark_error": "שגיאה", + "mark_question": "נדרשת תשובה", + "uploaded": "{path} הועלה", + "downloaded": "{path} הורד", + "folder-created": "התיקיה {path} נוצרה", + "renamed": "השם של {detail} שונה ל-{path}", + "moved": "{detail} הועבר ל-{path}", + "deleted": "{path} נמחק", + "trashed": "{path} הועבר לאשפה", + "conflict": "{path} שונה בשני המקומות. הגרסה השנייה נשמרה בשם {detail}", + "renamed-for-disk": "{path} נקרא {detail} במחשב הזה, כי השם שלו לא מותר כאן", + "too-large": "{path} גדול יותר ממה שקבצים מאפשר ({detail}). הוא נשאר רק במחשב הזה", + "waiting": "{path} ממתין לשרת", + "mass-delete": "הושהה לפני מחיקה של פריטים רבים", + "error": "לא ניתן לסנכרן את {path}" + }, + "errors": { + "offline": "אין גישה לשרת", + "server_refused": "השרת דחה את השינוי", + "too_large": "הקובץ גדול מדי עבור קבצים", + "name_invalid": "השם לא מותר", + "disk_full": "הדיסק מלא", + "denied": "אין הרשאה", + "busy": "תוכנה אחרת משתמשת בו", + "quota": "האחסון שלך בקבצים מלא", + "not_found": "הוא כבר לא קיים", + "policy": "הארגון שלך לא מאפשר זאת", + "failed": "משהו השתבש" + }, + "item": { + "label": "במחשב הזה", + "synced": "מסונכרן", + "syncing": "מסנכרן", + "pending": "ממתין לסנכרון", + "error": "הסנכרון נכשל", + "online-only": "רק בקבצים, יורד כשפותחים אותו" + }, + "show_in": { + "explorer": "הצגה בסייר הקבצים", + "finder": "הצגה ב-Finder", + "file_manager": "הצגה במנהל הקבצים", + "failed": "לא ניתן לפתוח את התיקיה." + } + }, "desktopQuickCompose": { "label": "כתיבה מהירה", "menu": "כתיבה מהירה", @@ -7666,6 +7777,14 @@ "types_character": "המקשים האלה מקלידים תו במקלדת שלכם, והוא יפסיק לעבוד בכל האפליקציות. נסו מקשים אחרים.", "reserved": "המערכת או רוב האפליקציות משתמשות במקשים האלה. נסו מקשים אחרים." }, + "desktopAttachRecent": { + "heading": "קבצים אחרונים", + "browse": "עיון במחשב הזה…", + "from_files": "מקבצים…", + "missing": "\"{name}\" כבר לא קיים.", + "too_large": "\"{name}\" גדול מ־{max} ולא ניתן לצרף אותו.", + "failed": "לא ניתן היה לצרף את \"{name}\". נסו שוב." + }, "compose_windows": { "more_formatting": "עוד עיצוב", "offline": "אין חיבור", @@ -7771,5 +7890,9 @@ "unread_badge_description": "כדי לראות דואר חדש בזמן שאתם עובדים באפליקציה אחרת.", "tip_compose_keys": "הקישו {keys} כדי לכתוב הודעה חדשה", "tip_palette_keys": "הקישו {keys} כדי למצוא כל פקודה" + }, + "selectionView": { + "starred": "סומן בכוכב", + "unstarred": "הכוכב הוסר" } } diff --git a/locales/hu/common.json b/locales/hu/common.json index d02f4cb08..04acaa536 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5279,6 +5279,11 @@ "cancelled": "{name} lemondta a(z) „{title}” eseményt", "untitled": "Névtelen esemény" }, + "calendar_month_select": { + "selected_range": "Kijelölve: {start} – {end}.", + "selected_day": "Kijelölve: {day}.", + "enter_hint": "Esemény létrehozásához nyomja meg az Entert." + }, "company_calendars": { "section": "Szervezet" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Még nincs csatolva vagy mentve, és most törlődik: {names}.", "signout_held": "Még nyitva van egy másik programban, ezért csak a bezárása után törlődik: {names}." }, + "files_sync": { + "title": "Fájlok ezen a számítógépen", + "drive": { + "title": "Fájlok meghajtó", + "label": "Fájlok megjelenítése meghajtóként", + "description": "A Fájlok meghajtóként jelenik meg a Fájlkezelőben. Egy fájl akkor töltődik le, amikor megnyitod.", + "unsupported": "Windows 10 1809-es vagy újabb verzió szükséges.", + "root_name": "Fájlok meghajtó", + "letter_label": "Meghajtóbetűjel", + "letter_description": "Saját betűjelet ad a meghajtónak a Fájlkezelőben.", + "letter_none": "Nincs betűjel" + }, + "folders": { + "title": "Szinkronizált mappák", + "description": "Egy mappa ezen a számítógépen és egy mappa a Fájlokban mindig egyforma marad, a változások mindkét irányba mennek.", + "empty": "Még nincs szinkronizált mappa.", + "forced_off": "A szervezeted kikapcsolta a mappák szinkronizálását. A bennük lévő fájlok ezen a számítógépen maradnak.", + "add": "Mappa hozzáadása…", + "loading": "Mappáid betöltése…", + "load_failed": "Nem sikerült betölteni a mappáidat a Fájlokból. Próbáld újra.", + "pick_title": "Melyik mappa a Fájlokban?", + "pick_confirm": "Mappa kiválasztása ezen a számítógépen…", + "paired_with": "A Fájlokban lévő {folder} mappával", + "pause": "Szüneteltetés", + "resume": "Folytatás", + "remove": "Eltávolítás…", + "remove_title": "Leállítod a mappa szinkronizálását?", + "remove_message": "A fájlok ezen a számítógépen a helyükön maradnak, és a Fájlokban lévők is. A változások többé nem kerülnek át egyikből a másikba.", + "remove_confirm": "Szinkronizálás leállítása", + "add_failed": { + "overlap": "Ez a mappa egy szinkronizált mappán belül van, tartalmaz egyet, vagy rendszermappa. Válassz másikat.", + "denied": "Az alkalmazás nem használhatja ezt a mappát. Válassz másikat.", + "policy": "A szervezeted ezt nem engedélyezi.", + "failed": "Nem sikerült hozzáadni a mappát. Próbáld újra." + } + }, + "status": { + "starting": "Indítás…", + "idle": "Naprakész. Utolsó szinkronizálás: {time}", + "idle_never": "Naprakész", + "syncing": "Szinkronizálás: {done} kész, {pending} van hátra", + "paused": "Szüneteltetve", + "offline": "Offline. {count, plural, =0 {A változások a kiszolgálóra várnak} one {# változás a kiszolgálóra vár} other {# változás a kiszolgálóra vár}}", + "error": "Leállt: {reason}", + "confirm_delete": "Szüneteltetve, amíg nem válaszolsz a lenti kérdésre", + "blocked": "A szervezeted kikapcsolta" + }, + "mass_delete": { + "title": "{count, plural, one {# elem} other {# elem}} törlése?", + "local": "{count, plural, one {# elem törlődött} other {# elem törlődött}} a(z) {folder} mappából ezen a számítógépen. A Fájlokban is {count, plural, one {törlöd} other {törlöd őket}}?", + "remote": "{count, plural, one {# elem törlődött} other {# elem törlődött}} a(z) {folder} mappából a Fájlokban. Ezen a számítógépen is {count, plural, one {törlöd} other {törlöd őket}}?", + "allow": "{count, plural, one {Törlöm} other {Mindet törlöm}}", + "restore": "{count, plural, one {Visszaállítom} other {Mindet visszaállítom}}", + "later": "Később döntök" + }, + "activity": { + "title": "Legutóbbi tevékenység", + "empty": "Még nem történt semmi.", + "clear": "Törlés", + "to_files": "A Fájlokba", + "to_computer": "Erre a számítógépre", + "mark_conflict": "Ütközés", + "mark_error": "Hiba", + "mark_question": "Válaszodra vár", + "uploaded": "Feltöltve: {path}", + "downloaded": "Letöltve: {path}", + "folder-created": "Mappa létrehozva: {path}", + "renamed": "Átnevezve: {detail} → {path}", + "moved": "Áthelyezve: {detail} → {path}", + "deleted": "Törölve: {path}", + "trashed": "Kukába helyezve: {path}", + "conflict": "{path} mindkét helyen megváltozott. A másik verzió {detail} néven megmaradt", + "renamed-for-disk": "{path} neve ezen a számítógépen {detail}, mert az eredeti név itt nem engedélyezett", + "too-large": "{path} nagyobb, mint amit a Fájlok enged ({detail}). Csak ezen a számítógépen marad", + "waiting": "{path} a kiszolgálóra vár", + "mass-delete": "Szüneteltetve sok elem törlése előtt", + "error": "{path} szinkronizálása nem sikerült" + }, + "errors": { + "offline": "A kiszolgáló nem érhető el", + "server_refused": "A kiszolgáló elutasította a változást", + "too_large": "A fájl túl nagy a Fájlokhoz", + "name_invalid": "A név nem engedélyezett", + "disk_full": "A lemez megtelt", + "denied": "Nem engedélyezett", + "busy": "Egy másik program használja", + "quota": "Megtelt a tárhelyed a Fájlokban", + "not_found": "Már nem létezik", + "policy": "A szervezeted ezt nem engedélyezi", + "failed": "Hiba történt" + }, + "item": { + "label": "Ezen a számítógépen", + "synced": "Szinkronizálva", + "syncing": "Szinkronizálás", + "pending": "Szinkronizálásra vár", + "error": "Nem sikerült szinkronizálni", + "online-only": "Csak a Fájlokban, megnyitáskor letöltődik" + }, + "show_in": { + "explorer": "Megjelenítés a Fájlkezelőben", + "finder": "Megjelenítés a Finderben", + "file_manager": "Megjelenítés a fájlkezelőben", + "failed": "Nem sikerült megnyitni a mappát." + } + }, "desktopQuickCompose": { "label": "Gyors levélírás", "menu": "Gyors levélírás", @@ -7665,6 +7776,14 @@ "types_character": "Ezek a billentyűk karaktert írnak az Ön billentyűzetén, amely többé egyik alkalmazásban sem működne. Próbáljon más billentyűket.", "reserved": "Ezeket a billentyűket a rendszer vagy a legtöbb alkalmazás használja. Próbáljon más billentyűket." }, + "desktopAttachRecent": { + "heading": "Legutóbbi fájlok", + "browse": "A számítógép tallózása…", + "from_files": "A Fájlokból…", + "missing": "A(z) „{name}” már nem létezik.", + "too_large": "A(z) „{name}” nagyobb, mint {max}, ezért nem csatolható.", + "failed": "A(z) „{name}” csatolása nem sikerült. Próbálja újra." + }, "compose_windows": { "more_formatting": "További formázás", "offline": "Offline", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Így akkor is látja az új leveleket, ha egy másik alkalmazásban dolgozik.", "tip_compose_keys": "Új üzenet írásához nyomja meg: {keys}", "tip_palette_keys": "Bármely parancs kereséséhez nyomja meg: {keys}" + }, + "selectionView": { + "starred": "Csillagozva", + "unstarred": "Csillag eltávolítva" } } diff --git a/locales/it/common.json b/locales/it/common.json index 3860e544d..e94eb6760 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5905,6 +5905,11 @@ "cancelled": "{name} ha annullato \"{title}\"", "untitled": "Evento senza titolo" }, + "calendar_month_select": { + "selected_range": "Selezionato dal {start} al {end}.", + "selected_day": "Selezionato {day}.", + "enter_hint": "Premi Invio per creare un evento." + }, "company_calendars": { "section": "Organizzazione" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Non ancora allegati o salvati, ed eliminati ora: {names}.", "signout_held": "Ancora aperti in un altro programma, quindi eliminati solo quando li chiudi lì: {names}." }, + "files_sync": { + "title": "File su questo computer", + "drive": { + "title": "Unità File", + "label": "Mostra File come unità", + "description": "I tuoi File appaiono in Esplora file come un'unità. Un file viene scaricato quando lo apri.", + "unsupported": "Richiede Windows 10 versione 1809 o successiva.", + "root_name": "Unità File", + "letter_label": "Lettera di unità", + "letter_description": "Assegna all'unità una lettera propria in Esplora file.", + "letter_none": "Nessuna lettera" + }, + "folders": { + "title": "Cartelle sincronizzate", + "description": "Una cartella su questo computer e una cartella in File restano uguali, in entrambe le direzioni.", + "empty": "Nessuna cartella è ancora sincronizzata.", + "forced_off": "La tua organizzazione ha disattivato la sincronizzazione delle cartelle. I file al loro interno restano su questo computer.", + "add": "Aggiungi cartella…", + "loading": "Caricamento delle tue cartelle…", + "load_failed": "Impossibile caricare le tue cartelle in File. Riprova.", + "pick_title": "Quale cartella in File?", + "pick_confirm": "Scegli una cartella su questo computer…", + "paired_with": "Con {folder} in File", + "pause": "Metti in pausa", + "resume": "Riprendi", + "remove": "Rimuovi…", + "remove_title": "Interrompere la sincronizzazione di questa cartella?", + "remove_message": "I file su questo computer restano dove sono, e così quelli in File. Le modifiche non passano più dall'uno all'altro.", + "remove_confirm": "Interrompi sincronizzazione", + "add_failed": { + "overlap": "Questa cartella si trova in una cartella sincronizzata, ne contiene una o è una cartella di sistema. Scegline un'altra.", + "denied": "L'app non può usare questa cartella. Scegline un'altra.", + "policy": "La tua organizzazione non lo consente.", + "failed": "Impossibile aggiungere la cartella. Riprova." + } + }, + "status": { + "starting": "Avvio…", + "idle": "Aggiornato. Ultima sincronizzazione {time}", + "idle_never": "Aggiornato", + "syncing": "Sincronizzazione: {done} completati, {pending} rimanenti", + "paused": "In pausa", + "offline": "Offline. {count, plural, =0 {Le modifiche attendono il server} one {# modifica attende il server} other {# modifiche attendono il server}}", + "error": "Interrotto: {reason}", + "confirm_delete": "In pausa finché non rispondi alla domanda qui sotto", + "blocked": "Disattivato dalla tua organizzazione" + }, + "mass_delete": { + "title": "Eliminare {count, plural, one {# elemento} other {# elementi}}?", + "local": "{count, plural, one {# elemento è stato eliminato} other {# elementi sono stati eliminati}} da {folder} su questo computer. {count, plural, one {Eliminarlo} other {Eliminarli}} anche in File?", + "remote": "{count, plural, one {# elemento è stato eliminato} other {# elementi sono stati eliminati}} da {folder} in File. {count, plural, one {Eliminarlo} other {Eliminarli}} anche su questo computer?", + "allow": "{count, plural, one {Eliminalo} other {Eliminali}}", + "restore": "{count, plural, one {Ripristinalo} other {Ripristinali}}", + "later": "Decidi più tardi" + }, + "activity": { + "title": "Attività recente", + "empty": "Non è ancora successo nulla.", + "clear": "Cancella", + "to_files": "Verso File", + "to_computer": "Verso questo computer", + "mark_conflict": "Conflitto", + "mark_error": "Errore", + "mark_question": "Serve una tua risposta", + "uploaded": "Caricato {path}", + "downloaded": "Scaricato {path}", + "folder-created": "Creata la cartella {path}", + "renamed": "Rinominato {detail} in {path}", + "moved": "Spostato {detail} in {path}", + "deleted": "Eliminato {path}", + "trashed": "Spostato {path} nel cestino", + "conflict": "{path} è stato modificato in entrambi i posti. L'altra versione è conservata come {detail}", + "renamed-for-disk": "{path} si chiama {detail} su questo computer, perché il suo nome qui non è consentito", + "too-large": "{path} è più grande di quanto consente File ({detail}). Resta solo su questo computer", + "waiting": "{path} è in attesa del server", + "mass-delete": "In pausa prima di eliminare molti elementi", + "error": "Impossibile sincronizzare {path}" + }, + "errors": { + "offline": "Il server non è raggiungibile", + "server_refused": "Il server ha rifiutato la modifica", + "too_large": "Il file è troppo grande per File", + "name_invalid": "Il nome non è consentito", + "disk_full": "Il disco è pieno", + "denied": "Accesso negato", + "busy": "Un altro programma lo sta usando", + "quota": "Lo spazio di archiviazione in File è pieno", + "not_found": "Non esiste più", + "policy": "La tua organizzazione non lo consente", + "failed": "Qualcosa è andato storto" + }, + "item": { + "label": "Su questo computer", + "synced": "Sincronizzato", + "syncing": "Sincronizzazione in corso", + "pending": "In attesa di sincronizzazione", + "error": "Sincronizzazione non riuscita", + "online-only": "Solo in File, scaricato all'apertura" + }, + "show_in": { + "explorer": "Mostra in Esplora file", + "finder": "Mostra nel Finder", + "file_manager": "Mostra nel file manager", + "failed": "Impossibile aprire la cartella." + } + }, "desktopQuickCompose": { "label": "Scrittura rapida", "menu": "Scrittura rapida", @@ -7665,6 +7776,14 @@ "types_character": "Questi tasti scrivono un carattere sulla tua tastiera, che smetterebbe di funzionare in tutte le app. Prova altri tasti.", "reserved": "Il sistema o la maggior parte delle app usano questi tasti. Prova altri tasti." }, + "desktopAttachRecent": { + "heading": "File recenti", + "browse": "Sfoglia questo computer…", + "from_files": "Da File…", + "missing": "\"{name}\" non esiste più.", + "too_large": "\"{name}\" supera {max} e non può essere allegato.", + "failed": "Impossibile allegare \"{name}\". Riprova." + }, "compose_windows": { "more_formatting": "Altre opzioni di formattazione", "offline": "Offline", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Per vedere la nuova posta mentre lavori in un'altra app.", "tip_compose_keys": "Premi {keys} per scrivere un nuovo messaggio", "tip_palette_keys": "Premi {keys} per trovare qualsiasi comando" + }, + "selectionView": { + "starred": "Aggiunto a Speciali", + "unstarred": "Stella rimossa" } } diff --git a/locales/ja/common.json b/locales/ja/common.json index 909f0511c..f8043dd1b 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5905,6 +5905,11 @@ "cancelled": "{name} さんが「{title}」をキャンセルしました", "untitled": "無題の予定" }, + "calendar_month_select": { + "selected_range": "{start}から{end}を選択しました。", + "selected_day": "{day}を選択しました。", + "enter_hint": "Enter キーで予定を作成します。" + }, "company_calendars": { "section": "組織" }, @@ -7650,6 +7655,112 @@ "signout_changed": "まだ添付も保存もされておらず、今削除されます: {names}。", "signout_held": "まだ別のプログラムで開かれているため、そちらで閉じるまで削除されません: {names}。" }, + "files_sync": { + "title": "このパソコンのファイル", + "drive": { + "title": "ファイル ドライブ", + "label": "ファイルをドライブとして表示", + "description": "ファイルがエクスプローラーにドライブのように表示されます。ファイルは開いたときにダウンロードされます。", + "unsupported": "Windows 10 バージョン 1809 以降が必要です。", + "root_name": "ファイル ドライブ", + "letter_label": "ドライブ文字", + "letter_description": "エクスプローラーでドライブに専用の文字を割り当てます。", + "letter_none": "文字なし" + }, + "folders": { + "title": "同期しているフォルダー", + "description": "このパソコンのフォルダーとファイルのフォルダーを、双方向で同じ内容に保ちます。", + "empty": "同期しているフォルダーはまだありません。", + "forced_off": "組織によってフォルダーの同期が無効にされました。フォルダー内のファイルはこのパソコンに残ります。", + "add": "フォルダーを追加…", + "loading": "フォルダーを読み込んでいます…", + "load_failed": "ファイルのフォルダーを読み込めませんでした。もう一度お試しください。", + "pick_title": "ファイルのどのフォルダーと同期しますか?", + "pick_confirm": "このパソコンのフォルダーを選択…", + "paired_with": "ファイルの {folder} と同期", + "pause": "一時停止", + "resume": "再開", + "remove": "削除…", + "remove_title": "このフォルダーの同期を停止しますか?", + "remove_message": "このパソコン上のファイルも、ファイル側のファイルもそのまま残ります。今後、変更は一方からもう一方に反映されなくなります。", + "remove_confirm": "同期を停止", + "add_failed": { + "overlap": "このフォルダーは同期フォルダーの中にあるか、同期フォルダーを含んでいるか、システム フォルダーです。別のフォルダーを選んでください。", + "denied": "アプリはこのフォルダーを使用できません。別のフォルダーを選んでください。", + "policy": "組織によって許可されていません。", + "failed": "フォルダーを追加できませんでした。もう一度お試しください。" + } + }, + "status": { + "starting": "起動しています…", + "idle": "最新の状態です。最終同期: {time}", + "idle_never": "最新の状態です", + "syncing": "同期中: {done} 件完了、残り {pending} 件", + "paused": "一時停止中", + "offline": "オフライン。{count, plural, =0 {変更はサーバーへの接続を待っています} other {# 件の変更がサーバーへの接続を待っています}}", + "error": "停止しました: {reason}", + "confirm_delete": "下の質問に答えるまで一時停止しています", + "blocked": "組織によって無効にされています" + }, + "mass_delete": { + "title": "{count, plural, other {# 個の項目}}を削除しますか?", + "local": "このパソコンの {folder} から {count, plural, other {# 個の項目}}が削除されました。ファイルでも削除しますか?", + "remote": "ファイルの {folder} から {count, plural, other {# 個の項目}}が削除されました。このパソコンでも削除しますか?", + "allow": "{count, plural, other {削除する}}", + "restore": "{count, plural, other {元に戻す}}", + "later": "後で決める" + }, + "activity": { + "title": "最近のアクティビティ", + "empty": "まだ何も起きていません。", + "clear": "クリア", + "to_files": "ファイルへ", + "to_computer": "このパソコンへ", + "mark_conflict": "競合", + "mark_error": "エラー", + "mark_question": "回答が必要", + "uploaded": "{path} をアップロードしました", + "downloaded": "{path} をダウンロードしました", + "folder-created": "フォルダー {path} を作成しました", + "renamed": "{detail} の名前を {path} に変更しました", + "moved": "{detail} を {path} に移動しました", + "deleted": "{path} を削除しました", + "trashed": "{path} をゴミ箱に移動しました", + "conflict": "{path} は両方の場所で変更されました。もう一方のバージョンは {detail} として保存されています", + "renamed-for-disk": "{path} の名前はここでは使えないため、このパソコンでは {detail} という名前になっています", + "too-large": "{path} はファイルの上限 ({detail}) を超えています。このパソコンにのみ保存されます", + "waiting": "{path} はサーバーを待っています", + "mass-delete": "多数の項目を削除する前に一時停止しました", + "error": "{path} を同期できませんでした" + }, + "errors": { + "offline": "サーバーに接続できません", + "server_refused": "サーバーが変更を拒否しました", + "too_large": "ファイルのサイズがファイルの上限を超えています", + "name_invalid": "この名前は使用できません", + "disk_full": "ディスクがいっぱいです", + "denied": "許可されていません", + "busy": "別のプログラムが使用中です", + "quota": "ファイルの保存容量がいっぱいです", + "not_found": "もう存在しません", + "policy": "組織によって許可されていません", + "failed": "問題が発生しました" + }, + "item": { + "label": "このパソコン上", + "synced": "同期済み", + "syncing": "同期中", + "pending": "同期待ち", + "error": "同期できませんでした", + "online-only": "ファイルにのみ保存。開くとダウンロードされます" + }, + "show_in": { + "explorer": "エクスプローラーで表示", + "finder": "Finder で表示", + "file_manager": "ファイル マネージャーで表示", + "failed": "フォルダーを開けませんでした。" + } + }, "desktopQuickCompose": { "label": "クイック作成", "menu": "クイック作成", @@ -7665,6 +7776,14 @@ "types_character": "このキーはお使いのキーボードで文字を入力します。割り当てると、すべてのアプリでその文字を入力できなくなります。別のキーをお試しください。", "reserved": "このキーはシステムまたは多くのアプリが使用しています。別のキーをお試しください。" }, + "desktopAttachRecent": { + "heading": "最近使ったファイル", + "browse": "このコンピューターを参照…", + "from_files": "ファイルから…", + "missing": "「{name}」はもうありません。", + "too_large": "「{name}」は {max} を超えているため添付できません。", + "failed": "「{name}」を添付できませんでした。もう一度お試しください。" + }, "compose_windows": { "more_formatting": "その他の書式", "offline": "オフライン", @@ -7770,5 +7889,9 @@ "unread_badge_description": "別のアプリで作業中でも新着メールがわかります。", "tip_compose_keys": "{keys} を押して新しいメッセージを作成", "tip_palette_keys": "{keys} を押してコマンドを検索" + }, + "selectionView": { + "starred": "スターを付けました", + "unstarred": "スターを外しました" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index 68a313526..a09fa4494 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5905,6 +5905,11 @@ "cancelled": "{name}님이 \"{title}\"을(를) 취소했습니다", "untitled": "제목 없는 일정" }, + "calendar_month_select": { + "selected_range": "{start}부터 {end}까지 선택됨.", + "selected_day": "{day} 선택됨.", + "enter_hint": "Enter 키를 눌러 일정을 만드세요." + }, "company_calendars": { "section": "조직" }, @@ -7650,6 +7655,112 @@ "signout_changed": "아직 첨부하거나 저장하지 않았으며 지금 삭제됩니다: {names}.", "signout_held": "아직 다른 프로그램에서 열려 있어 그곳에서 닫아야 삭제됩니다: {names}." }, + "files_sync": { + "title": "이 컴퓨터의 파일", + "drive": { + "title": "파일 드라이브", + "label": "파일을 드라이브로 표시", + "description": "파일이 파일 탐색기에 드라이브처럼 표시됩니다. 파일은 열 때 다운로드됩니다.", + "unsupported": "Windows 10 버전 1809 이상이 필요합니다.", + "root_name": "파일 드라이브", + "letter_label": "드라이브 문자", + "letter_description": "파일 탐색기에서 드라이브에 고유한 문자를 지정합니다.", + "letter_none": "문자 없음" + }, + "folders": { + "title": "동기화된 폴더", + "description": "이 컴퓨터의 폴더와 파일의 폴더를 양방향으로 똑같이 유지합니다.", + "empty": "아직 동기화된 폴더가 없습니다.", + "forced_off": "조직에서 폴더 동기화를 사용하지 않도록 설정했습니다. 폴더 안의 파일은 이 컴퓨터에 그대로 남습니다.", + "add": "폴더 추가…", + "loading": "폴더를 불러오는 중…", + "load_failed": "파일의 폴더를 불러올 수 없습니다. 다시 시도하세요.", + "pick_title": "파일의 어느 폴더와 동기화할까요?", + "pick_confirm": "이 컴퓨터의 폴더 선택…", + "paired_with": "파일의 {folder}와(과) 동기화", + "pause": "일시 중지", + "resume": "다시 시작", + "remove": "제거…", + "remove_title": "이 폴더의 동기화를 중지할까요?", + "remove_message": "이 컴퓨터의 파일과 파일에 있는 파일은 모두 그대로 남습니다. 앞으로 한쪽의 변경 사항이 다른 쪽에 반영되지 않습니다.", + "remove_confirm": "동기화 중지", + "add_failed": { + "overlap": "이 폴더는 동기화된 폴더 안에 있거나, 동기화된 폴더를 포함하거나, 시스템 폴더입니다. 다른 폴더를 선택하세요.", + "denied": "앱에서 이 폴더를 사용할 수 없습니다. 다른 폴더를 선택하세요.", + "policy": "조직에서 허용하지 않습니다.", + "failed": "폴더를 추가할 수 없습니다. 다시 시도하세요." + } + }, + "status": { + "starting": "시작하는 중…", + "idle": "최신 상태입니다. 마지막 동기화: {time}", + "idle_never": "최신 상태입니다", + "syncing": "동기화 중: {done}개 완료, {pending}개 남음", + "paused": "일시 중지됨", + "offline": "오프라인. {count, plural, =0 {변경 사항이 서버를 기다리고 있습니다} other {변경 사항 #개가 서버를 기다리고 있습니다}}", + "error": "중지됨: {reason}", + "confirm_delete": "아래 질문에 답할 때까지 일시 중지됨", + "blocked": "조직에서 사용하지 않도록 설정함" + }, + "mass_delete": { + "title": "{count, plural, other {항목 #개}}를 삭제할까요?", + "local": "이 컴퓨터의 {folder}에서 {count, plural, other {항목 #개가}} 삭제되었습니다. 파일에서도 삭제할까요?", + "remote": "파일의 {folder}에서 {count, plural, other {항목 #개가}} 삭제되었습니다. 이 컴퓨터에서도 삭제할까요?", + "allow": "{count, plural, other {삭제}}", + "restore": "{count, plural, other {되돌리기}}", + "later": "나중에 결정" + }, + "activity": { + "title": "최근 활동", + "empty": "아직 활동이 없습니다.", + "clear": "지우기", + "to_files": "파일로", + "to_computer": "이 컴퓨터로", + "mark_conflict": "충돌", + "mark_error": "오류", + "mark_question": "답변 필요", + "uploaded": "{path} 업로드함", + "downloaded": "{path} 다운로드함", + "folder-created": "{path} 폴더를 만듦", + "renamed": "{detail}의 이름을 {path}(으)로 바꿈", + "moved": "{detail}을(를) {path}(으)로 이동함", + "deleted": "{path} 삭제함", + "trashed": "{path}을(를) 휴지통으로 이동함", + "conflict": "{path}이(가) 양쪽에서 변경되었습니다. 다른 버전은 {detail}(으)로 보관됩니다", + "renamed-for-disk": "{path}의 이름은 여기서 허용되지 않아 이 컴퓨터에서는 {detail}(이)라는 이름을 사용합니다", + "too-large": "{path}이(가) 파일에서 허용하는 크기({detail})보다 큽니다. 이 컴퓨터에만 보관됩니다", + "waiting": "{path}이(가) 서버를 기다리는 중입니다", + "mass-delete": "많은 항목을 삭제하기 전에 일시 중지함", + "error": "{path}을(를) 동기화할 수 없습니다" + }, + "errors": { + "offline": "서버에 연결할 수 없습니다", + "server_refused": "서버에서 변경을 거부했습니다", + "too_large": "파일이 너무 커서 파일에 올릴 수 없습니다", + "name_invalid": "허용되지 않는 이름입니다", + "disk_full": "디스크가 가득 찼습니다", + "denied": "허용되지 않음", + "busy": "다른 프로그램에서 사용 중입니다", + "quota": "파일 저장 공간이 가득 찼습니다", + "not_found": "더 이상 존재하지 않습니다", + "policy": "조직에서 허용하지 않습니다", + "failed": "문제가 발생했습니다" + }, + "item": { + "label": "이 컴퓨터에 있음", + "synced": "동기화됨", + "syncing": "동기화 중", + "pending": "동기화 대기 중", + "error": "동기화할 수 없음", + "online-only": "파일에만 있음, 열 때 다운로드됨" + }, + "show_in": { + "explorer": "파일 탐색기에 표시", + "finder": "Finder에서 보기", + "file_manager": "파일 관리자에서 보기", + "failed": "폴더를 열 수 없습니다." + } + }, "desktopQuickCompose": { "label": "빠른 작성", "menu": "빠른 작성", @@ -7665,6 +7776,14 @@ "types_character": "이 키는 사용 중인 키보드에서 문자를 입력하며, 지정하면 모든 앱에서 그 문자를 입력할 수 없게 됩니다. 다른 키를 사용해 보세요.", "reserved": "시스템 또는 대부분의 앱이 이 키를 사용합니다. 다른 키를 사용해 보세요." }, + "desktopAttachRecent": { + "heading": "최근 파일", + "browse": "이 컴퓨터 찾아보기…", + "from_files": "파일에서…", + "missing": "\"{name}\"이(가) 더 이상 없어요.", + "too_large": "\"{name}\"이(가) {max}보다 커서 첨부할 수 없어요.", + "failed": "\"{name}\"을(를) 첨부하지 못했어요. 다시 시도해 주세요." + }, "compose_windows": { "more_formatting": "추가 서식", "offline": "오프라인", @@ -7770,5 +7889,9 @@ "unread_badge_description": "다른 앱에서 작업하는 동안에도 새 메일을 확인할 수 있습니다.", "tip_compose_keys": "{keys}를 눌러 새 메시지 쓰기", "tip_palette_keys": "{keys}를 눌러 명령 찾기" + }, + "selectionView": { + "starred": "별표를 추가했습니다", + "unstarred": "별표를 삭제했습니다" } } diff --git a/locales/lv/common.json b/locales/lv/common.json index 36c6b4df4..d99e02c8b 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5905,6 +5905,11 @@ "cancelled": "{name} atcēla “{title}”", "untitled": "Notikums bez nosaukuma" }, + "calendar_month_select": { + "selected_range": "Atlasīts no {start} līdz {end}.", + "selected_day": "Atlasīts {day}.", + "enter_hint": "Nospiediet Enter, lai izveidotu notikumu." + }, "company_calendars": { "section": "Organizācija" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Vēl nav pievienoti vai saglabāti, un tagad izdzēsti: {names}.", "signout_held": "Joprojām atvērti citā programmā, tāpēc tiks izdzēsti tikai pēc aizvēršanas: {names}." }, + "files_sync": { + "title": "Faili šajā datorā", + "drive": { + "title": "Failu disks", + "label": "Rādīt Failus kā disku", + "description": "Jūsu Faili failu pārlūkā ir redzami kā disks. Fails tiek lejupielādēts, kad to atverat.", + "unsupported": "Nepieciešama Windows 10 versija 1809 vai jaunāka.", + "root_name": "Failu disks", + "letter_label": "Diska burts", + "letter_description": "Piešķir diskam savu burtu failu pārlūkā.", + "letter_none": "Bez burta" + }, + "folders": { + "title": "Sinhronizētās mapes", + "description": "Mape šajā datorā un mape Failos tiek uzturētas vienādas, izmaiņas notiek abos virzienos.", + "empty": "Vēl netiek sinhronizēta neviena mape.", + "forced_off": "Jūsu organizācija izslēdza mapju sinhronizēšanu. Faili tajās paliek šajā datorā.", + "add": "Pievienot mapi…", + "loading": "Ielādē jūsu mapes…", + "load_failed": "Neizdevās ielādēt jūsu mapes Failos. Mēģiniet vēlreiz.", + "pick_title": "Kura mape Failos?", + "pick_confirm": "Izvēlēties mapi šajā datorā…", + "paired_with": "Ar mapi {folder} Failos", + "pause": "Pauzēt", + "resume": "Turpināt", + "remove": "Noņemt…", + "remove_title": "Pārtraukt šīs mapes sinhronizēšanu?", + "remove_message": "Faili šajā datorā paliek savā vietā, tāpat arī faili Failos. Izmaiņas vairs netiek pārnestas no vienas vietas uz otru.", + "remove_confirm": "Pārtraukt sinhronizēšanu", + "add_failed": { + "overlap": "Šī mape atrodas sinhronizētā mapē, satur šādu mapi vai ir sistēmas mape. Izvēlieties citu.", + "denied": "Lietotne nevar izmantot šo mapi. Izvēlieties citu.", + "policy": "Jūsu organizācija to neatļauj.", + "failed": "Neizdevās pievienot mapi. Mēģiniet vēlreiz." + } + }, + "status": { + "starting": "Sāk darbu…", + "idle": "Viss aktuāls. Pēdējo reizi sinhronizēts {time}", + "idle_never": "Viss aktuāls", + "syncing": "Sinhronizē: pabeigti {done}, atlikuši {pending}", + "paused": "Pauzēts", + "offline": "Bezsaistē. {count, plural, =0 {Izmaiņas gaida serveri} zero {# izmaiņas gaida serveri} one {# izmaiņa gaida serveri} other {# izmaiņas gaida serveri}}", + "error": "Apturēts: {reason}", + "confirm_delete": "Pauzēts, līdz atbildēsiet uz jautājumu zemāk", + "blocked": "Izslēgusi jūsu organizācija" + }, + "mass_delete": { + "title": "Dzēst {count, plural, zero {# vienumus} one {# vienumu} other {# vienumus}}?", + "local": "{count, plural, zero {# vienumi izdzēsti} one {# vienums izdzēsts} other {# vienumi izdzēsti}} no mapes {folder} šajā datorā. Vai dzēst {count, plural, zero {tos} one {to} other {tos}} arī Failos?", + "remote": "{count, plural, zero {# vienumi izdzēsti} one {# vienums izdzēsts} other {# vienumi izdzēsti}} no mapes {folder} Failos. Vai dzēst {count, plural, zero {tos} one {to} other {tos}} arī šajā datorā?", + "allow": "{count, plural, zero {Dzēst tos} one {Dzēst to} other {Dzēst tos}}", + "restore": "{count, plural, zero {Atjaunot tos} one {Atjaunot to} other {Atjaunot tos}}", + "later": "Izlemt vēlāk" + }, + "activity": { + "title": "Nesenās darbības", + "empty": "Vēl nekas nav noticis.", + "clear": "Notīrīt", + "to_files": "Uz Failiem", + "to_computer": "Uz šo datoru", + "mark_conflict": "Konflikts", + "mark_error": "Kļūda", + "mark_question": "Gaida jūsu atbildi", + "uploaded": "Augšupielādēts: {path}", + "downloaded": "Lejupielādēts: {path}", + "folder-created": "Izveidota mape {path}", + "renamed": "Pārdēvēts: {detail} → {path}", + "moved": "Pārvietots: {detail} → {path}", + "deleted": "Izdzēsts: {path}", + "trashed": "Pārvietots uz miskasti: {path}", + "conflict": "{path} tika mainīts abās vietās. Otra versija saglabāta kā {detail}", + "renamed-for-disk": "{path} šajā datorā saucas {detail}, jo tā nosaukums šeit nav atļauts", + "too-large": "{path} ir lielāks, nekā Faili atļauj ({detail}). Tas paliek tikai šajā datorā", + "waiting": "{path} gaida serveri", + "mass-delete": "Pauzēts pirms daudzu vienumu dzēšanas", + "error": "Neizdevās sinhronizēt {path}" + }, + "errors": { + "offline": "Serveris nav sasniedzams", + "server_refused": "Serveris noraidīja izmaiņu", + "too_large": "Fails ir pārāk liels Failiem", + "name_invalid": "Šis nosaukums nav atļauts", + "disk_full": "Disks ir pilns", + "denied": "Nav atļauts", + "busy": "To izmanto cita programma", + "quota": "Jūsu krātuve Failos ir pilna", + "not_found": "Tas vairs nepastāv", + "policy": "Jūsu organizācija to neatļauj", + "failed": "Radās kļūda" + }, + "item": { + "label": "Šajā datorā", + "synced": "Sinhronizēts", + "syncing": "Sinhronizē", + "pending": "Gaida sinhronizēšanu", + "error": "Neizdevās sinhronizēt", + "online-only": "Tikai Failos, tiek lejupielādēts atverot" + }, + "show_in": { + "explorer": "Rādīt failu pārlūkā", + "finder": "Rādīt programmā Finder", + "file_manager": "Rādīt failu pārvaldniekā", + "failed": "Neizdevās atvērt mapi." + } + }, "desktopQuickCompose": { "label": "Ātrā rakstīšana", "menu": "Ātrā rakstīšana", @@ -7665,6 +7776,14 @@ "types_character": "Šie taustiņi jūsu tastatūrā raksta rakstzīmi, kas pārstātu darboties visās lietotnēs. Izmēģiniet citus taustiņus.", "reserved": "Šos taustiņus izmanto sistēma vai lielākā daļa lietotņu. Izmēģiniet citus taustiņus." }, + "desktopAttachRecent": { + "heading": "Nesen izmantotie faili", + "browse": "Pārlūkot šo datoru…", + "from_files": "No Failiem…", + "missing": "“{name}” vairs nav pieejams.", + "too_large": "“{name}” ir lielāks par {max}, un to nevar pievienot.", + "failed": "Neizdevās pievienot “{name}”. Mēģiniet vēlreiz." + }, "compose_windows": { "more_formatting": "Vairāk formatēšanas", "offline": "Bezsaistē", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Lai redzētu jaunu pastu, kamēr strādājat citā lietotnē.", "tip_compose_keys": "Nospiediet {keys}, lai rakstītu jaunu ziņojumu", "tip_palette_keys": "Nospiediet {keys}, lai atrastu jebkuru komandu" + }, + "selectionView": { + "starred": "Atzīmēts ar zvaigznīti", + "unstarred": "Zvaigznīte noņemta" } } diff --git a/locales/mn/common.json b/locales/mn/common.json index 81566b789..704792138 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5279,6 +5279,11 @@ "cancelled": "{name} \"{title}\"-г цуцаллаа", "untitled": "Нэргүй арга хэмжээ" }, + "calendar_month_select": { + "selected_range": "{start}-с {end} хүртэл сонгосон.", + "selected_day": "{day} сонгосон.", + "enter_hint": "Үйл явдал үүсгэхийн тулд Enter дарна уу." + }, "company_calendars": { "section": "Байгууллага" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Хавсаргаагүй, хадгалаагүй бөгөөд одоо устгагдана: {names}.", "signout_held": "Өөр программд нээлттэй хэвээр тул тэнд хаасны дараа л устана: {names}." }, + "files_sync": { + "title": "Энэ компьютер дээрх Файл", + "drive": { + "title": "Файл диск", + "label": "Файлыг диск болгож харуулах", + "description": "Таны Файл Файл судлаачид диск шиг харагдана. Файлыг нээхэд татаж авна.", + "unsupported": "Windows 10-ийн 1809 буюу түүнээс хойшхи хувилбар шаардлагатай.", + "root_name": "Файл диск", + "letter_label": "Дискийн үсэг", + "letter_description": "Файл судлаачид дискэд өөрийн үсэг өгнө.", + "letter_none": "Үсэггүй" + }, + "folders": { + "title": "Синхрончлогдсон хавтаснууд", + "description": "Энэ компьютер дээрх хавтас болон Файл дахь хавтсыг хоёр чиглэлд адилхан байлгана.", + "empty": "Синхрончлогдсон хавтас одоогоор алга.", + "forced_off": "Таны байгууллага хавтас синхрончлохыг унтраасан. Доторх файлууд энэ компьютер дээр үлдэнэ.", + "add": "Хавтас нэмэх…", + "loading": "Хавтаснуудыг ачаалж байна…", + "load_failed": "Файл дахь таны хавтаснуудыг ачаалж чадсангүй. Дахин оролдоно уу.", + "pick_title": "Файл дахь аль хавтастай синхрончлох вэ?", + "pick_confirm": "Энэ компьютер дээрх хавтсыг сонгох…", + "paired_with": "Файл дахь {folder}-тай", + "pause": "Түр зогсоох", + "resume": "Үргэлжлүүлэх", + "remove": "Хасах…", + "remove_title": "Энэ хавтсыг синхрончлохоо зогсоох уу?", + "remove_message": "Энэ компьютер дээрх файлууд байрандаа үлдэнэ, Файл дахь файлууд ч мөн адил. Өөрчлөлт нэг талаас нөгөө рүү дамжихаа болино.", + "remove_confirm": "Синхрончлолыг зогсоох", + "add_failed": { + "overlap": "Энэ хавтас синхрончлогдсон хавтсан дотор байна, синхрончлогдсон хавтас агуулж байна, эсвэл системийн хавтас байна. Өөр хавтас сонгоно уу.", + "denied": "Апп энэ хавтсыг ашиглаж чадахгүй. Өөр хавтас сонгоно уу.", + "policy": "Таны байгууллага үүнийг зөвшөөрөхгүй.", + "failed": "Хавтсыг нэмж чадсангүй. Дахин оролдоно уу." + } + }, + "status": { + "starting": "Эхэлж байна…", + "idle": "Шинэчлэгдсэн. Сүүлд синхрончилсон: {time}", + "idle_never": "Шинэчлэгдсэн", + "syncing": "Синхрончилж байна: {done} дууссан, {pending} үлдсэн", + "paused": "Түр зогссон", + "offline": "Офлайн. {count, plural, =0 {Өөрчлөлтүүд серверийг хүлээж байна} other {# өөрчлөлт серверийг хүлээж байна}}", + "error": "Зогссон: {reason}", + "confirm_delete": "Доорх асуултад хариулах хүртэл түр зогссон", + "blocked": "Таны байгууллага унтраасан" + }, + "mass_delete": { + "title": "{count, plural, other {# зүйлийг}} устгах уу?", + "local": "Энэ компьютер дээрх {folder}-оос {count, plural, other {# зүйл}} устгагдсан. Файл дотроос ч устгах уу?", + "remote": "Файл дахь {folder}-оос {count, plural, other {# зүйл}} устгагдсан. Энэ компьютер дээрээс ч устгах уу?", + "allow": "{count, plural, other {Устгах}}", + "restore": "{count, plural, other {Сэргээх}}", + "later": "Дараа шийдэх" + }, + "activity": { + "title": "Сүүлийн үйл ажиллагаа", + "empty": "Одоогоор юу ч болоогүй байна.", + "clear": "Цэвэрлэх", + "to_files": "Файл руу", + "to_computer": "Энэ компьютер руу", + "mark_conflict": "Зөрчил", + "mark_error": "Алдаа", + "mark_question": "Таны хариулт хэрэгтэй", + "uploaded": "{path}-г байршуулсан", + "downloaded": "{path}-г татаж авсан", + "folder-created": "{path} хавтсыг үүсгэсэн", + "renamed": "{detail}-н нэрийг {path} болгож өөрчилсөн", + "moved": "{detail}-г {path} руу зөөсөн", + "deleted": "{path}-г устгасан", + "trashed": "{path}-г хогийн сав руу зөөсөн", + "conflict": "{path} хоёр газарт өөрчлөгдсөн. Нөгөө хувилбарыг {detail} нэрээр хадгалсан", + "renamed-for-disk": "{path}-н нэр энд зөвшөөрөгдөхгүй тул энэ компьютер дээр {detail} гэж нэрлэгдэнэ", + "too-large": "{path} нь Файлын зөвшөөрөх хэмжээнээс ({detail}) том. Зөвхөн энэ компьютер дээр үлдэнэ", + "waiting": "{path} серверийг хүлээж байна", + "mass-delete": "Олон зүйл устгахаас өмнө түр зогссон", + "error": "{path}-г синхрончилж чадсангүй" + }, + "errors": { + "offline": "Сервертэй холбогдож чадахгүй байна", + "server_refused": "Сервер өөрчлөлтийг татгалзсан", + "too_large": "Файл нь Файлын зөвшөөрөх хэмжээнээс том байна", + "name_invalid": "Энэ нэр зөвшөөрөгдөхгүй", + "disk_full": "Диск дүүрсэн", + "denied": "Зөвшөөрөгдөөгүй", + "busy": "Өөр програм ашиглаж байна", + "quota": "Файл дахь таны хадгалах сан дүүрсэн", + "not_found": "Энэ нь байхгүй болсон", + "policy": "Таны байгууллага үүнийг зөвшөөрөхгүй", + "failed": "Алдаа гарлаа" + }, + "item": { + "label": "Энэ компьютер дээр", + "synced": "Синхрончлогдсон", + "syncing": "Синхрончилж байна", + "pending": "Синхрончлолыг хүлээж байна", + "error": "Синхрончилж чадсангүй", + "online-only": "Зөвхөн Файлд, нээхэд татаж авна" + }, + "show_in": { + "explorer": "Файл судлаачид харуулах", + "finder": "Finder-т харуулах", + "file_manager": "Файл менежерт харуулах", + "failed": "Хавтсыг нээж чадсангүй." + } + }, "desktopQuickCompose": { "label": "Хурдан бичих", "menu": "Хурдан бичих", @@ -7665,6 +7776,14 @@ "types_character": "Эдгээр товч таны гар дээр тэмдэгт бичдэг тул тэр тэмдэгт бүх аппад ажиллахаа болино. Өөр товч туршина уу.", "reserved": "Систем эсвэл ихэнх апп эдгээр товчийг ашигладаг. Өөр товч туршина уу." }, + "desktopAttachRecent": { + "heading": "Сүүлийн файлууд", + "browse": "Энэ компьютерээс хайх…", + "from_files": "Файлаас…", + "missing": "\"{name}\" байхгүй болсон байна.", + "too_large": "\"{name}\" {max}-аас том тул хавсаргах боломжгүй.", + "failed": "\"{name}\"-г хавсаргаж чадсангүй. Дахин оролдоно уу." + }, "compose_windows": { "more_formatting": "Бусад хэлбэржүүлэлт", "offline": "Офлайн", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Өөр апп дээр ажиллаж байхдаа шинэ шуудангаа харахын тулд.", "tip_compose_keys": "Шинэ зурвас бичихийн тулд {keys} дарна уу", "tip_palette_keys": "Ямар ч тушаал олохын тулд {keys} дарна уу" + }, + "selectionView": { + "starred": "Одоор тэмдэглэсэн", + "unstarred": "Од хассан" } } diff --git a/locales/nb/common.json b/locales/nb/common.json index bc7296b99..83981f1e6 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5279,6 +5279,11 @@ "cancelled": "{name} har avlyst «{title}»", "untitled": "Hendelse uten tittel" }, + "calendar_month_select": { + "selected_range": "{start} til {end} er valgt.", + "selected_day": "{day} er valgt.", + "enter_hint": "Trykk Enter for å opprette en hendelse." + }, "company_calendars": { "section": "Organisasjon" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Ikke lagt ved eller lagret ennå, og slettet nå: {names}.", "signout_held": "Fortsatt åpne i et annet program, så de slettes først når du lukker dem der: {names}." }, + "files_sync": { + "title": "Filer på denne datamaskinen", + "drive": { + "title": "Filer-stasjon", + "label": "Vis Filer som en stasjon", + "description": "Filer vises i Filutforsker som en stasjon. En fil lastes ned når du åpner den.", + "unsupported": "Krever Windows 10 versjon 1809 eller nyere.", + "root_name": "Filer-stasjon", + "letter_label": "Stasjonsbokstav", + "letter_description": "Gir stasjonen sin egen bokstav i Filutforsker.", + "letter_none": "Ingen bokstav" + }, + "folders": { + "title": "Synkroniserte mapper", + "description": "En mappe på denne datamaskinen og en mappe i Filer holdes like, i begge retninger.", + "empty": "Ingen mapper synkroniseres ennå.", + "forced_off": "Organisasjonen din har slått av synkronisering av mapper. Filene i dem blir værende på denne datamaskinen.", + "add": "Legg til mappe…", + "loading": "Laster inn mappene dine…", + "load_failed": "Mappene dine i Filer kunne ikke lastes inn. Prøv igjen.", + "pick_title": "Hvilken mappe i Filer?", + "pick_confirm": "Velg mappe på denne datamaskinen…", + "paired_with": "Med {folder} i Filer", + "pause": "Sett på pause", + "resume": "Fortsett", + "remove": "Fjern…", + "remove_title": "Slutte å synkronisere denne mappen?", + "remove_message": "Filene på denne datamaskinen blir der de er, og det samme gjør filene i Filer. Endringer går ikke lenger fra den ene til den andre.", + "remove_confirm": "Slutt å synkronisere", + "add_failed": { + "overlap": "Denne mappen ligger i en synkronisert mappe, inneholder en, eller er en systemmappe. Velg en annen.", + "denied": "Appen kan ikke bruke denne mappen. Velg en annen.", + "policy": "Organisasjonen din tillater ikke dette.", + "failed": "Mappen kunne ikke legges til. Prøv igjen." + } + }, + "status": { + "starting": "Starter…", + "idle": "Oppdatert. Sist synkronisert {time}", + "idle_never": "Oppdatert", + "syncing": "Synkroniserer: {done} ferdig, {pending} gjenstår", + "paused": "På pause", + "offline": "Frakoblet. {count, plural, =0 {Endringer venter på serveren} one {# endring venter på serveren} other {# endringer venter på serveren}}", + "error": "Stoppet: {reason}", + "confirm_delete": "På pause til du svarer på spørsmålet nedenfor", + "blocked": "Slått av av organisasjonen din" + }, + "mass_delete": { + "title": "Slette {count, plural, one {# element} other {# elementer}}?", + "local": "{count, plural, one {# element ble} other {# elementer ble}} slettet fra {folder} på denne datamaskinen. Slette {count, plural, one {det} other {dem}} i Filer også?", + "remote": "{count, plural, one {# element ble} other {# elementer ble}} slettet fra {folder} i Filer. Slette {count, plural, one {det} other {dem}} på denne datamaskinen også?", + "allow": "{count, plural, one {Slett det} other {Slett dem}}", + "restore": "{count, plural, one {Gjenopprett det} other {Gjenopprett dem}}", + "later": "Bestem senere" + }, + "activity": { + "title": "Nylig aktivitet", + "empty": "Ingenting har skjedd ennå.", + "clear": "Tøm", + "to_files": "Til Filer", + "to_computer": "Til denne datamaskinen", + "mark_conflict": "Konflikt", + "mark_error": "Feil", + "mark_question": "Venter på svaret ditt", + "uploaded": "Lastet opp {path}", + "downloaded": "Lastet ned {path}", + "folder-created": "Opprettet mappen {path}", + "renamed": "Endret navnet på {detail} til {path}", + "moved": "Flyttet {detail} til {path}", + "deleted": "Slettet {path}", + "trashed": "Flyttet {path} til papirkurven", + "conflict": "{path} ble endret begge steder. Den andre versjonen er beholdt som {detail}", + "renamed-for-disk": "{path} heter {detail} på denne datamaskinen, fordi navnet ikke er tillatt her", + "too-large": "{path} er større enn Filer tillater ({detail}). Den blir bare på denne datamaskinen", + "waiting": "{path} venter på serveren", + "mass-delete": "Satt på pause før sletting av mange elementer", + "error": "{path} kunne ikke synkroniseres" + }, + "errors": { + "offline": "Serveren kan ikke nås", + "server_refused": "Serveren avviste endringen", + "too_large": "Filen er for stor for Filer", + "name_invalid": "Navnet er ikke tillatt", + "disk_full": "Disken er full", + "denied": "Ingen tilgang", + "busy": "Et annet program bruker den", + "quota": "Lagringsplassen din i Filer er full", + "not_found": "Den finnes ikke lenger", + "policy": "Organisasjonen din tillater ikke dette", + "failed": "Noe gikk galt" + }, + "item": { + "label": "På denne datamaskinen", + "synced": "Synkronisert", + "syncing": "Synkroniserer", + "pending": "Venter på synkronisering", + "error": "Kunne ikke synkronisere", + "online-only": "Bare i Filer, lastes ned når den åpnes" + }, + "show_in": { + "explorer": "Vis i Filutforsker", + "finder": "Vis i Finder", + "file_manager": "Vis i filbehandler", + "failed": "Mappen kunne ikke åpnes." + } + }, "desktopQuickCompose": { "label": "Rask skriving", "menu": "Rask skriving", @@ -7665,6 +7776,14 @@ "types_character": "Disse tastene skriver et tegn på tastaturet ditt, og det ville slutte å virke i alle apper. Prøv andre taster.", "reserved": "Systemet ditt eller de fleste apper bruker disse tastene. Prøv andre taster." }, + "desktopAttachRecent": { + "heading": "Nylige filer", + "browse": "Bla gjennom denne datamaskinen…", + "from_files": "Fra Filer…", + "missing": "«{name}» finnes ikke lenger.", + "too_large": "«{name}» er større enn {max} og kan ikke legges ved.", + "failed": "Kunne ikke legge ved «{name}». Prøv igjen." + }, "compose_windows": { "more_formatting": "Mer formatering", "offline": "Frakoblet", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Slik ser du ny e-post mens du jobber i en annen app.", "tip_compose_keys": "Trykk på {keys} for å skrive en ny melding", "tip_palette_keys": "Trykk på {keys} for å finne en kommando" + }, + "selectionView": { + "starred": "Stjernemerket", + "unstarred": "Stjerne fjernet" } } diff --git a/locales/nl/common.json b/locales/nl/common.json index 4380cef06..3686ba69a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5905,6 +5905,11 @@ "cancelled": "{name} heeft \"{title}\" geannuleerd", "untitled": "Afspraak zonder titel" }, + "calendar_month_select": { + "selected_range": "{start} tot {end} geselecteerd.", + "selected_day": "{day} geselecteerd.", + "enter_hint": "Druk op Enter om een afspraak te maken." + }, "company_calendars": { "section": "Organisatie" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Nog niet bijgevoegd of opgeslagen, en nu verwijderd: {names}.", "signout_held": "Nog geopend in een ander programma, dus pas verwijderd als u ze daar sluit: {names}." }, + "files_sync": { + "title": "Bestanden op deze computer", + "drive": { + "title": "Station Bestanden", + "label": "Bestanden als station tonen", + "description": "Je Bestanden verschijnen in Verkenner als een station. Een bestand wordt gedownload als je het opent.", + "unsupported": "Vereist Windows 10 versie 1809 of nieuwer.", + "root_name": "Station Bestanden", + "letter_label": "Stationsletter", + "letter_description": "Geeft het station een eigen letter in Verkenner.", + "letter_none": "Geen letter" + }, + "folders": { + "title": "Gesynchroniseerde mappen", + "description": "Een map op deze computer en een map in Bestanden blijven gelijk, in beide richtingen.", + "empty": "Er worden nog geen mappen gesynchroniseerd.", + "forced_off": "Je organisatie heeft het synchroniseren van mappen uitgezet. De bestanden erin blijven op deze computer.", + "add": "Map toevoegen…", + "loading": "Je mappen worden geladen…", + "load_failed": "Je mappen in Bestanden konden niet worden geladen. Probeer het opnieuw.", + "pick_title": "Welke map in Bestanden?", + "pick_confirm": "Map op deze computer kiezen…", + "paired_with": "Met {folder} in Bestanden", + "pause": "Pauzeren", + "resume": "Hervatten", + "remove": "Verwijderen…", + "remove_title": "Deze map niet meer synchroniseren?", + "remove_message": "De bestanden op deze computer blijven waar ze zijn, en die in Bestanden ook. Wijzigingen gaan niet meer van de een naar de ander.", + "remove_confirm": "Synchronisatie stoppen", + "add_failed": { + "overlap": "Deze map staat in een gesynchroniseerde map, bevat er een of is een systeemmap. Kies een andere.", + "denied": "De app kan deze map niet gebruiken. Kies een andere.", + "policy": "Je organisatie staat dit niet toe.", + "failed": "De map kon niet worden toegevoegd. Probeer het opnieuw." + } + }, + "status": { + "starting": "Bezig met starten…", + "idle": "Bijgewerkt. Laatst gesynchroniseerd {time}", + "idle_never": "Bijgewerkt", + "syncing": "Synchroniseren: {done} klaar, {pending} te gaan", + "paused": "Gepauzeerd", + "offline": "Offline. {count, plural, =0 {Wijzigingen wachten op de server} one {# wijziging wacht op de server} other {# wijzigingen wachten op de server}}", + "error": "Gestopt: {reason}", + "confirm_delete": "Gepauzeerd tot je de vraag hieronder beantwoordt", + "blocked": "Uitgezet door je organisatie" + }, + "mass_delete": { + "title": "{count, plural, one {# item} other {# items}} verwijderen?", + "local": "{count, plural, one {# item is} other {# items zijn}} verwijderd uit {folder} op deze computer. {count, plural, one {Het} other {Ze}} ook in Bestanden verwijderen?", + "remote": "{count, plural, one {# item is} other {# items zijn}} verwijderd uit {folder} in Bestanden. {count, plural, one {Het} other {Ze}} ook op deze computer verwijderen?", + "allow": "{count, plural, one {Verwijderen} other {Alle verwijderen}}", + "restore": "{count, plural, one {Terugzetten} other {Alle terugzetten}}", + "later": "Later beslissen" + }, + "activity": { + "title": "Recente activiteit", + "empty": "Er is nog niets gebeurd.", + "clear": "Wissen", + "to_files": "Naar Bestanden", + "to_computer": "Naar deze computer", + "mark_conflict": "Versieconflict", + "mark_error": "Fout", + "mark_question": "Wacht op je antwoord", + "uploaded": "{path} geüpload", + "downloaded": "{path} gedownload", + "folder-created": "Map {path} gemaakt", + "renamed": "{detail} hernoemd naar {path}", + "moved": "{detail} verplaatst naar {path}", + "deleted": "{path} verwijderd", + "trashed": "{path} naar de prullenbak verplaatst", + "conflict": "{path} is op beide plekken gewijzigd. De andere versie is bewaard als {detail}", + "renamed-for-disk": "{path} heet {detail} op deze computer, omdat de naam hier niet is toegestaan", + "too-large": "{path} is groter dan Bestanden toestaat ({detail}). Het blijft alleen op deze computer", + "waiting": "{path} wacht op de server", + "mass-delete": "Gepauzeerd voor het verwijderen van veel items", + "error": "{path} kon niet worden gesynchroniseerd" + }, + "errors": { + "offline": "De server is niet bereikbaar", + "server_refused": "De server heeft de wijziging geweigerd", + "too_large": "Het bestand is te groot voor Bestanden", + "name_invalid": "De naam is niet toegestaan", + "disk_full": "De schijf is vol", + "denied": "Geen toegang", + "busy": "Een ander programma gebruikt het", + "quota": "Je opslag in Bestanden is vol", + "not_found": "Het bestaat niet meer", + "policy": "Je organisatie staat dit niet toe", + "failed": "Er ging iets mis" + }, + "item": { + "label": "Op deze computer", + "synced": "Gesynchroniseerd", + "syncing": "Bezig met synchroniseren", + "pending": "Wacht op synchronisatie", + "error": "Niet gesynchroniseerd", + "online-only": "Alleen in Bestanden, wordt gedownload bij openen" + }, + "show_in": { + "explorer": "Weergeven in Verkenner", + "finder": "Weergeven in Finder", + "file_manager": "Weergeven in bestandsbeheer", + "failed": "De map kon niet worden geopend." + } + }, "desktopQuickCompose": { "label": "Snel opstellen", "menu": "Snel opstellen", @@ -7665,6 +7776,14 @@ "types_character": "Deze toetsen typen een teken op je toetsenbord, en dat zou in geen enkele app meer werken. Probeer andere toetsen.", "reserved": "Je systeem of de meeste apps gebruiken deze toetsen. Probeer andere toetsen." }, + "desktopAttachRecent": { + "heading": "Recente bestanden", + "browse": "Deze computer doorzoeken…", + "from_files": "Uit Bestanden…", + "missing": "\"{name}\" bestaat niet meer.", + "too_large": "\"{name}\" is groter dan {max} en kan niet worden bijgevoegd.", + "failed": "\"{name}\" kon niet worden bijgevoegd. Probeer het opnieuw." + }, "compose_windows": { "more_formatting": "Meer opmaak", "offline": "Offline", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Zo ziet u nieuwe mail terwijl u in een andere app werkt.", "tip_compose_keys": "Druk op {keys} om een nieuw bericht te schrijven", "tip_palette_keys": "Druk op {keys} om elke opdracht te vinden" + }, + "selectionView": { + "starred": "Met ster gemarkeerd", + "unstarred": "Ster verwijderd" } } diff --git a/locales/pl/common.json b/locales/pl/common.json index e973b3634..339d0aa67 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5905,6 +5905,11 @@ "cancelled": "{name} odwołał(a) „{title}”", "untitled": "Wydarzenie bez tytułu" }, + "calendar_month_select": { + "selected_range": "Wybrano od {start} do {end}.", + "selected_day": "Wybrano {day}.", + "enter_hint": "Naciśnij Enter, aby utworzyć wydarzenie." + }, "company_calendars": { "section": "Organizacja" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Jeszcze niedołączone ani niezapisane, a teraz usunięte: {names}.", "signout_held": "Nadal otwarte w innym programie, więc zostaną usunięte dopiero po zamknięciu: {names}." }, + "files_sync": { + "title": "Pliki na tym komputerze", + "drive": { + "title": "Dysk Pliki", + "label": "Pokazuj Pliki jako dysk", + "description": "Twoje Pliki widać w Eksploratorze plików jako dysk. Plik jest pobierany, gdy go otworzysz.", + "unsupported": "Wymaga systemu Windows 10 w wersji 1809 lub nowszej.", + "root_name": "Dysk Pliki", + "letter_label": "Litera dysku", + "letter_description": "Nadaje dyskowi własną literę w Eksploratorze plików.", + "letter_none": "Bez litery" + }, + "folders": { + "title": "Synchronizowane foldery", + "description": "Folder na tym komputerze i folder w Plikach pozostają takie same, zmiany idą w obie strony.", + "empty": "Nie synchronizujesz jeszcze żadnych folderów.", + "forced_off": "Twoja organizacja wyłączyła synchronizację folderów. Pliki w nich zostają na tym komputerze.", + "add": "Dodaj folder…", + "loading": "Wczytywanie Twoich folderów…", + "load_failed": "Nie udało się wczytać Twoich folderów w Plikach. Spróbuj ponownie.", + "pick_title": "Który folder w Plikach?", + "pick_confirm": "Wybierz folder na tym komputerze…", + "paired_with": "Z folderem {folder} w Plikach", + "pause": "Wstrzymaj", + "resume": "Wznów", + "remove": "Usuń…", + "remove_title": "Przestać synchronizować ten folder?", + "remove_message": "Pliki na tym komputerze zostają na miejscu, tak samo jak pliki w Plikach. Zmiany nie będą już przenoszone między nimi.", + "remove_confirm": "Przestań synchronizować", + "add_failed": { + "overlap": "Ten folder leży w synchronizowanym folderze, zawiera taki folder albo jest folderem systemowym. Wybierz inny.", + "denied": "Aplikacja nie może używać tego folderu. Wybierz inny.", + "policy": "Twoja organizacja na to nie pozwala.", + "failed": "Nie udało się dodać folderu. Spróbuj ponownie." + } + }, + "status": { + "starting": "Uruchamianie…", + "idle": "Aktualne. Ostatnia synchronizacja: {time}", + "idle_never": "Aktualne", + "syncing": "Synchronizacja: gotowe {done}, pozostało {pending}", + "paused": "Wstrzymano", + "offline": "Brak połączenia. {count, plural, =0 {Zmiany czekają na serwer} one {# zmiana czeka na serwer} few {# zmiany czekają na serwer} many {# zmian czeka na serwer} other {# zmiany czeka na serwer}}", + "error": "Zatrzymano: {reason}", + "confirm_delete": "Wstrzymano, dopóki nie odpowiesz na pytanie poniżej", + "blocked": "Wyłączone przez Twoją organizację" + }, + "mass_delete": { + "title": "Usunąć {count, plural, one {# element} few {# elementy} many {# elementów} other {# elementu}}?", + "local": "{count, plural, one {# element został usunięty} few {# elementy zostały usunięte} many {# elementów zostało usuniętych} other {# elementu zostało usunięte}} z folderu {folder} na tym komputerze. Usunąć {count, plural, one {go} few {je} many {je} other {je}} także w Plikach?", + "remote": "{count, plural, one {# element został usunięty} few {# elementy zostały usunięte} many {# elementów zostało usuniętych} other {# elementu zostało usunięte}} z folderu {folder} w Plikach. Usunąć {count, plural, one {go} few {je} many {je} other {je}} także na tym komputerze?", + "allow": "{count, plural, one {Usuń go} few {Usuń je} many {Usuń je} other {Usuń je}}", + "restore": "{count, plural, one {Przywróć go} few {Przywróć je} many {Przywróć je} other {Przywróć je}}", + "later": "Zdecyduj później" + }, + "activity": { + "title": "Ostatnia aktywność", + "empty": "Jeszcze nic się nie wydarzyło.", + "clear": "Wyczyść", + "to_files": "Do Plików", + "to_computer": "Na ten komputer", + "mark_conflict": "Konflikt", + "mark_error": "Błąd", + "mark_question": "Czeka na Twoją odpowiedź", + "uploaded": "Przesłano {path}", + "downloaded": "Pobrano {path}", + "folder-created": "Utworzono folder {path}", + "renamed": "Zmieniono nazwę {detail} na {path}", + "moved": "Przeniesiono {detail} do {path}", + "deleted": "Usunięto {path}", + "trashed": "Przeniesiono {path} do kosza", + "conflict": "{path} zmieniono w obu miejscach. Druga wersja została zachowana jako {detail}", + "renamed-for-disk": "{path} nazywa się na tym komputerze {detail}, bo jego nazwa jest tu niedozwolona", + "too-large": "{path} jest większy, niż pozwalają Pliki ({detail}). Zostaje tylko na tym komputerze", + "waiting": "{path} czeka na serwer", + "mass-delete": "Wstrzymano przed usunięciem wielu elementów", + "error": "Nie udało się zsynchronizować {path}" + }, + "errors": { + "offline": "Nie można połączyć się z serwerem", + "server_refused": "Serwer odrzucił zmianę", + "too_large": "Plik jest za duży dla Plików", + "name_invalid": "Ta nazwa jest niedozwolona", + "disk_full": "Dysk jest pełny", + "denied": "Brak uprawnień", + "busy": "Używany przez inny program", + "quota": "Twoje miejsce w Plikach jest pełne", + "not_found": "Już nie istnieje", + "policy": "Twoja organizacja na to nie pozwala", + "failed": "Coś poszło nie tak" + }, + "item": { + "label": "Na tym komputerze", + "synced": "Zsynchronizowano", + "syncing": "Synchronizacja", + "pending": "Czeka na synchronizację", + "error": "Nie udało się zsynchronizować", + "online-only": "Tylko w Plikach, pobierany po otwarciu" + }, + "show_in": { + "explorer": "Pokaż w Eksploratorze plików", + "finder": "Pokaż w Finderze", + "file_manager": "Pokaż w menedżerze plików", + "failed": "Nie udało się otworzyć folderu." + } + }, "desktopQuickCompose": { "label": "Szybka wiadomość", "menu": "Szybka wiadomość", @@ -7665,6 +7776,14 @@ "types_character": "Te klawisze wpisują znak na Twojej klawiaturze i przestałby on działać we wszystkich aplikacjach. Wypróbuj inne klawisze.", "reserved": "Te klawisze są używane przez system lub większość aplikacji. Wypróbuj inne klawisze." }, + "desktopAttachRecent": { + "heading": "Ostatnie pliki", + "browse": "Przeglądaj ten komputer…", + "from_files": "Z Plików…", + "missing": "„{name}” już nie istnieje.", + "too_large": "„{name}” jest większy niż {max} i nie można go dołączyć.", + "failed": "Nie udało się dołączyć „{name}”. Spróbuj ponownie." + }, "compose_windows": { "more_formatting": "Więcej formatowania", "offline": "Offline", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Aby widzieć nową pocztę, gdy pracujesz w innej aplikacji.", "tip_compose_keys": "Naciśnij {keys}, aby napisać nową wiadomość", "tip_palette_keys": "Naciśnij {keys}, aby znaleźć dowolne polecenie" + }, + "selectionView": { + "starred": "Oznaczono gwiazdką", + "unstarred": "Usunięto gwiazdkę" } } diff --git a/locales/pt/common.json b/locales/pt/common.json index ace709678..1bc6b73dc 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5905,6 +5905,11 @@ "cancelled": "{name} cancelou \"{title}\"", "untitled": "Evento sem título" }, + "calendar_month_select": { + "selected_range": "Selecionado de {start} a {end}.", + "selected_day": "Selecionado {day}.", + "enter_hint": "Prima Enter para criar um evento." + }, "company_calendars": { "section": "Organização" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Ainda não anexados nem guardados, e eliminados agora: {names}.", "signout_held": "Ainda abertos noutro programa, por isso só são eliminados depois de os fechar lá: {names}." }, + "files_sync": { + "title": "Arquivos neste computador", + "drive": { + "title": "Unidade Arquivos", + "label": "Mostrar Arquivos como uma unidade", + "description": "Seus Arquivos aparecem no Explorador de Arquivos como uma unidade. Um arquivo é baixado quando você o abre.", + "unsupported": "Requer o Windows 10 versão 1809 ou posterior.", + "root_name": "Unidade Arquivos", + "letter_label": "Letra da unidade", + "letter_description": "Dá à unidade uma letra própria no Explorador de Arquivos.", + "letter_none": "Sem letra" + }, + "folders": { + "title": "Pastas sincronizadas", + "description": "Uma pasta neste computador e uma pasta em Arquivos ficam iguais, nos dois sentidos.", + "empty": "Nenhuma pasta está sincronizada ainda.", + "forced_off": "Sua organização desativou a sincronização de pastas. Os arquivos nelas continuam neste computador.", + "add": "Adicionar pasta…", + "loading": "Carregando suas pastas…", + "load_failed": "Não foi possível carregar suas pastas em Arquivos. Tente novamente.", + "pick_title": "Qual pasta em Arquivos?", + "pick_confirm": "Escolher pasta neste computador…", + "paired_with": "Com {folder} em Arquivos", + "pause": "Pausar", + "resume": "Retomar", + "remove": "Remover…", + "remove_title": "Parar de sincronizar esta pasta?", + "remove_message": "Os arquivos neste computador ficam onde estão, assim como os de Arquivos. As alterações deixam de passar de um para o outro.", + "remove_confirm": "Parar de sincronizar", + "add_failed": { + "overlap": "Esta pasta está dentro de uma pasta sincronizada, contém uma ou é uma pasta do sistema. Escolha outra.", + "denied": "O aplicativo não pode usar esta pasta. Escolha outra.", + "policy": "Sua organização não permite isso.", + "failed": "Não foi possível adicionar a pasta. Tente novamente." + } + }, + "status": { + "starting": "Iniciando…", + "idle": "Atualizado. Última sincronização {time}", + "idle_never": "Atualizado", + "syncing": "Sincronizando: {done} concluídos, {pending} restantes", + "paused": "Pausado", + "offline": "Offline. {count, plural, =0 {As alterações aguardam o servidor} one {# alteração aguarda o servidor} other {# alterações aguardam o servidor}}", + "error": "Parado: {reason}", + "confirm_delete": "Pausado até você responder à pergunta abaixo", + "blocked": "Desativado pela sua organização" + }, + "mass_delete": { + "title": "Excluir {count, plural, one {# item} other {# itens}}?", + "local": "{count, plural, one {# item foi excluído} other {# itens foram excluídos}} de {folder} neste computador. {count, plural, one {Excluí-lo} other {Excluí-los}} também em Arquivos?", + "remote": "{count, plural, one {# item foi excluído} other {# itens foram excluídos}} de {folder} em Arquivos. {count, plural, one {Excluí-lo} other {Excluí-los}} também neste computador?", + "allow": "{count, plural, one {Excluir} other {Excluir todos}}", + "restore": "{count, plural, one {Restaurar} other {Restaurar todos}}", + "later": "Decidir depois" + }, + "activity": { + "title": "Atividade recente", + "empty": "Nada aconteceu ainda.", + "clear": "Limpar", + "to_files": "Para Arquivos", + "to_computer": "Para este computador", + "mark_conflict": "Conflito", + "mark_error": "Erro", + "mark_question": "Precisa da sua resposta", + "uploaded": "{path} enviado", + "downloaded": "{path} baixado", + "folder-created": "Pasta {path} criada", + "renamed": "{detail} renomeado para {path}", + "moved": "{detail} movido para {path}", + "deleted": "{path} excluído", + "trashed": "{path} movido para a lixeira", + "conflict": "{path} foi alterado nos dois lugares. A outra versão foi mantida como {detail}", + "renamed-for-disk": "{path} se chama {detail} neste computador, porque o nome não é permitido aqui", + "too-large": "{path} é maior do que Arquivos permite ({detail}). Fica só neste computador", + "waiting": "{path} está aguardando o servidor", + "mass-delete": "Pausado antes de excluir muitos itens", + "error": "Não foi possível sincronizar {path}" + }, + "errors": { + "offline": "Não é possível acessar o servidor", + "server_refused": "O servidor recusou a alteração", + "too_large": "O arquivo é grande demais para Arquivos", + "name_invalid": "O nome não é permitido", + "disk_full": "O disco está cheio", + "denied": "Acesso negado", + "busy": "Outro programa está usando", + "quota": "Seu armazenamento em Arquivos está cheio", + "not_found": "Não existe mais", + "policy": "Sua organização não permite isso", + "failed": "Algo deu errado" + }, + "item": { + "label": "Neste computador", + "synced": "Sincronizado", + "syncing": "Sincronizando", + "pending": "Aguardando sincronização", + "error": "Falha na sincronização", + "online-only": "Só em Arquivos, baixado ao abrir" + }, + "show_in": { + "explorer": "Mostrar no Explorador de Arquivos", + "finder": "Mostrar no Finder", + "file_manager": "Mostrar no gerenciador de arquivos", + "failed": "Não foi possível abrir a pasta." + } + }, "desktopQuickCompose": { "label": "Escrita rápida", "menu": "Escrita rápida", @@ -7665,6 +7776,14 @@ "types_character": "Estas teclas escrevem um carácter no seu teclado, e ele deixaria de funcionar em todas as aplicações. Experimente outras teclas.", "reserved": "O seu sistema ou a maioria das aplicações usam estas teclas. Experimente outras teclas." }, + "desktopAttachRecent": { + "heading": "Arquivos recentes", + "browse": "Procurar neste computador…", + "from_files": "De Arquivos…", + "missing": "\"{name}\" não existe mais.", + "too_large": "\"{name}\" é maior que {max} e não pode ser anexado.", + "failed": "Não foi possível anexar \"{name}\". Tente novamente." + }, "compose_windows": { "more_formatting": "Mais formatação", "offline": "Offline", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Para ver novos e-mails enquanto trabalha noutra aplicação.", "tip_compose_keys": "Prima {keys} para escrever uma nova mensagem", "tip_palette_keys": "Prima {keys} para encontrar qualquer comando" + }, + "selectionView": { + "starred": "Com estrela", + "unstarred": "Estrela removida" } } diff --git a/locales/ro/common.json b/locales/ro/common.json index 4c66b85fe..60c484ae2 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5279,6 +5279,11 @@ "cancelled": "{name} a anulat „{title}”", "untitled": "Eveniment fără titlu" }, + "calendar_month_select": { + "selected_range": "Selectat de la {start} la {end}.", + "selected_day": "Selectat {day}.", + "enter_hint": "Apăsați Enter pentru a crea un eveniment." + }, "company_calendars": { "section": "Organizație" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Încă neatașate sau nesalvate, și șterse acum: {names}.", "signout_held": "Încă deschise în alt program, așa că se șterg doar după ce le închideți acolo: {names}." }, + "files_sync": { + "title": "Fișiere pe acest computer", + "drive": { + "title": "Unitatea Fișiere", + "label": "Afișează Fișiere ca unitate", + "description": "Fișierele dvs. apar în Explorer ca o unitate. Un fișier se descarcă atunci când îl deschideți.", + "unsupported": "Necesită Windows 10 versiunea 1809 sau mai nouă.", + "root_name": "Unitatea Fișiere", + "letter_label": "Literă de unitate", + "letter_description": "Atribuie unității o literă proprie în Explorer.", + "letter_none": "Fără literă" + }, + "folders": { + "title": "Dosare sincronizate", + "description": "Un dosar de pe acest computer și un dosar din Fișiere sunt păstrate identice, în ambele direcții.", + "empty": "Încă nu se sincronizează niciun dosar.", + "forced_off": "Organizația dvs. a dezactivat sincronizarea dosarelor. Fișierele din ele rămân pe acest computer.", + "add": "Adăugați un dosar…", + "loading": "Se încarcă dosarele…", + "load_failed": "Dosarele dvs. din Fișiere nu au putut fi încărcate. Încercați din nou.", + "pick_title": "Ce dosar din Fișiere?", + "pick_confirm": "Alegeți un dosar de pe acest computer…", + "paired_with": "Cu {folder} din Fișiere", + "pause": "Întrerupe", + "resume": "Reia", + "remove": "Elimină…", + "remove_title": "Opriți sincronizarea acestui dosar?", + "remove_message": "Fișierele de pe acest computer rămân unde sunt, la fel și cele din Fișiere. Modificările nu mai trec de la unul la altul.", + "remove_confirm": "Oprește sincronizarea", + "add_failed": { + "overlap": "Acest dosar se află într-un dosar sincronizat, conține unul sau este un dosar de sistem. Alegeți altul.", + "denied": "Aplicația nu poate folosi acest dosar. Alegeți altul.", + "policy": "Organizația dvs. nu permite acest lucru.", + "failed": "Dosarul nu a putut fi adăugat. Încercați din nou." + } + }, + "status": { + "starting": "Se pornește…", + "idle": "Actualizat. Ultima sincronizare {time}", + "idle_never": "Actualizat", + "syncing": "Se sincronizează: {done} gata, {pending} rămase", + "paused": "Întrerupt", + "offline": "Offline. {count, plural, =0 {Modificările așteaptă serverul} one {# modificare așteaptă serverul} few {# modificări așteaptă serverul} other {# de modificări așteaptă serverul}}", + "error": "Oprit: {reason}", + "confirm_delete": "Întrerupt până când răspundeți la întrebarea de mai jos", + "blocked": "Dezactivat de organizația dvs." + }, + "mass_delete": { + "title": "Ștergeți {count, plural, one {# element} few {# elemente} other {# de elemente}}?", + "local": "{count, plural, one {# element a fost șters} few {# elemente au fost șterse} other {# de elemente au fost șterse}} din {folder} de pe acest computer. {count, plural, one {Îl ștergeți} few {Le ștergeți} other {Le ștergeți}} și din Fișiere?", + "remote": "{count, plural, one {# element a fost șters} few {# elemente au fost șterse} other {# de elemente au fost șterse}} din {folder} din Fișiere. {count, plural, one {Îl ștergeți} few {Le ștergeți} other {Le ștergeți}} și de pe acest computer?", + "allow": "{count, plural, one {Șterge-l} few {Șterge-le} other {Șterge-le}}", + "restore": "{count, plural, one {Restaurează-l} few {Restaurează-le} other {Restaurează-le}}", + "later": "Decideți mai târziu" + }, + "activity": { + "title": "Activitate recentă", + "empty": "Nu s-a întâmplat nimic încă.", + "clear": "Golește", + "to_files": "Către Fișiere", + "to_computer": "Către acest computer", + "mark_conflict": "Conflict de versiuni", + "mark_error": "Eroare", + "mark_question": "Așteaptă răspunsul dvs.", + "uploaded": "S-a încărcat {path}", + "downloaded": "S-a descărcat {path}", + "folder-created": "S-a creat dosarul {path}", + "renamed": "{detail} a fost redenumit în {path}", + "moved": "{detail} a fost mutat în {path}", + "deleted": "S-a șters {path}", + "trashed": "{path} a fost mutat în coșul de gunoi", + "conflict": "{path} a fost modificat în ambele locuri. Cealaltă versiune este păstrată ca {detail}", + "renamed-for-disk": "{path} se numește {detail} pe acest computer, deoarece numele său nu este permis aici", + "too-large": "{path} este mai mare decât permite Fișiere ({detail}). Rămâne doar pe acest computer", + "waiting": "{path} așteaptă serverul", + "mass-delete": "Întrerupt înainte de ștergerea multor elemente", + "error": "{path} nu a putut fi sincronizat" + }, + "errors": { + "offline": "Serverul nu poate fi accesat", + "server_refused": "Serverul a refuzat modificarea", + "too_large": "Fișierul este prea mare pentru Fișiere", + "name_invalid": "Numele nu este permis", + "disk_full": "Discul este plin", + "denied": "Acces refuzat", + "busy": "Un alt program îl folosește", + "quota": "Spațiul dvs. de stocare din Fișiere este plin", + "not_found": "Nu mai există", + "policy": "Organizația dvs. nu permite acest lucru", + "failed": "Ceva nu a mers bine" + }, + "item": { + "label": "Pe acest computer", + "synced": "Sincronizat", + "syncing": "Se sincronizează", + "pending": "Așteaptă sincronizarea", + "error": "Nu s-a putut sincroniza", + "online-only": "Doar în Fișiere, se descarcă la deschidere" + }, + "show_in": { + "explorer": "Afișează în Explorer", + "finder": "Afișează în Finder", + "file_manager": "Afișează în managerul de fișiere", + "failed": "Dosarul nu a putut fi deschis." + } + }, "desktopQuickCompose": { "label": "Scriere rapidă", "menu": "Scriere rapidă", @@ -7665,6 +7776,14 @@ "types_character": "Aceste taste scriu un caracter pe tastatura ta, iar el n-ar mai funcționa în nicio aplicație. Încearcă alte taste.", "reserved": "Sistemul sau majoritatea aplicațiilor folosesc aceste taste. Încearcă alte taste." }, + "desktopAttachRecent": { + "heading": "Fișiere recente", + "browse": "Răsfoiește acest computer…", + "from_files": "Din Fișiere…", + "missing": "„{name}” nu mai există.", + "too_large": "„{name}” depășește {max} și nu poate fi atașat.", + "failed": "„{name}” nu a putut fi atașat. Încearcă din nou." + }, "compose_windows": { "more_formatting": "Mai multe opțiuni de formatare", "offline": "Offline", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Ca să vedeți e-mailurile noi în timp ce lucrați în altă aplicație.", "tip_compose_keys": "Apăsați {keys} pentru a scrie un mesaj nou", "tip_palette_keys": "Apăsați {keys} pentru a găsi orice comandă" + }, + "selectionView": { + "starred": "Marcat cu stea", + "unstarred": "Stea eliminată" } } diff --git a/locales/ru/common.json b/locales/ru/common.json index a15eec34e..bfd8c241e 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5905,6 +5905,11 @@ "cancelled": "{name} отменил(а) «{title}»", "untitled": "Событие без названия" }, + "calendar_month_select": { + "selected_range": "Выбрано с {start} по {end}.", + "selected_day": "Выбрано: {day}.", + "enter_hint": "Нажмите Enter, чтобы создать событие." + }, "company_calendars": { "section": "Организация" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Ещё не вложены и не сохранены, и сейчас удаляются: {names}.", "signout_held": "Ещё открыты в другой программе, поэтому удалятся только после закрытия там: {names}." }, + "files_sync": { + "title": "Файлы на этом компьютере", + "drive": { + "title": "Диск «Файлы»", + "label": "Показывать Файлы как диск", + "description": "Ваши Файлы видны в Проводнике как диск. Файл скачивается, когда вы его открываете.", + "unsupported": "Нужна Windows 10 версии 1809 или новее.", + "root_name": "Диск «Файлы»", + "letter_label": "Буква диска", + "letter_description": "Даёт диску собственную букву в Проводнике.", + "letter_none": "Без буквы" + }, + "folders": { + "title": "Синхронизируемые папки", + "description": "Папка на этом компьютере и папка в Файлах остаются одинаковыми, изменения идут в обе стороны.", + "empty": "Пока нет синхронизируемых папок.", + "forced_off": "Ваша организация отключила синхронизацию папок. Файлы в них остаются на этом компьютере.", + "add": "Добавить папку…", + "loading": "Загрузка ваших папок…", + "load_failed": "Не удалось загрузить ваши папки в Файлах. Попробуйте ещё раз.", + "pick_title": "Какую папку в Файлах выбрать?", + "pick_confirm": "Выбрать папку на этом компьютере…", + "paired_with": "С папкой {folder} в Файлах", + "pause": "Приостановить", + "resume": "Возобновить", + "remove": "Убрать…", + "remove_title": "Прекратить синхронизацию этой папки?", + "remove_message": "Файлы на этом компьютере останутся на месте, как и файлы в Файлах. Изменения больше не будут переноситься между ними.", + "remove_confirm": "Прекратить синхронизацию", + "add_failed": { + "overlap": "Эта папка находится внутри синхронизируемой папки, содержит такую папку или является системной. Выберите другую.", + "denied": "Приложение не может использовать эту папку. Выберите другую.", + "policy": "Ваша организация это не разрешает.", + "failed": "Не удалось добавить папку. Попробуйте ещё раз." + } + }, + "status": { + "starting": "Запуск…", + "idle": "Всё актуально. Последняя синхронизация: {time}", + "idle_never": "Всё актуально", + "syncing": "Синхронизация: готово {done}, осталось {pending}", + "paused": "Приостановлено", + "offline": "Нет связи. {count, plural, =0 {Изменения ждут сервер} one {# изменение ждёт сервер} few {# изменения ждут сервер} many {# изменений ждут сервер} other {# изменения ждут сервер}}", + "error": "Остановлено: {reason}", + "confirm_delete": "Приостановлено, пока вы не ответите на вопрос ниже", + "blocked": "Отключено вашей организацией" + }, + "mass_delete": { + "title": "Удалить {count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элемента}}?", + "local": "{count, plural, one {# элемент удалён} few {# элемента удалены} many {# элементов удалены} other {# элемента удалены}} из папки {folder} на этом компьютере. Удалить {count, plural, one {его} few {их} many {их} other {их}} и в Файлах?", + "remote": "{count, plural, one {# элемент удалён} few {# элемента удалены} many {# элементов удалены} other {# элемента удалены}} из папки {folder} в Файлах. Удалить {count, plural, one {его} few {их} many {их} other {их}} и на этом компьютере?", + "allow": "{count, plural, one {Удалить его} few {Удалить их} many {Удалить их} other {Удалить их}}", + "restore": "{count, plural, one {Вернуть его} few {Вернуть их} many {Вернуть их} other {Вернуть их}}", + "later": "Решить позже" + }, + "activity": { + "title": "Последние действия", + "empty": "Пока ничего не произошло.", + "clear": "Очистить", + "to_files": "В Файлы", + "to_computer": "На этот компьютер", + "mark_conflict": "Конфликт", + "mark_error": "Ошибка", + "mark_question": "Нужен ваш ответ", + "uploaded": "Загружено: {path}", + "downloaded": "Скачано: {path}", + "folder-created": "Создана папка {path}", + "renamed": "Переименовано: {detail} → {path}", + "moved": "Перемещено: {detail} → {path}", + "deleted": "Удалено: {path}", + "trashed": "Перемещено в корзину: {path}", + "conflict": "{path}: изменено в обоих местах. Другая версия сохранена как {detail}", + "renamed-for-disk": "{path} на этом компьютере называется {detail}, потому что такое имя здесь не разрешено", + "too-large": "{path} больше, чем допускают Файлы ({detail}). Остаётся только на этом компьютере", + "waiting": "{path} ждёт сервер", + "mass-delete": "Приостановлено перед удалением многих элементов", + "error": "Не удалось синхронизировать {path}" + }, + "errors": { + "offline": "Сервер недоступен", + "server_refused": "Сервер отклонил изменение", + "too_large": "Файл слишком большой для Файлов", + "name_invalid": "Такое имя не разрешено", + "disk_full": "Диск заполнен", + "denied": "Нет доступа", + "busy": "Используется другой программой", + "quota": "Ваше хранилище в Файлах заполнено", + "not_found": "Больше не существует", + "policy": "Ваша организация это не разрешает", + "failed": "Что-то пошло не так" + }, + "item": { + "label": "На этом компьютере", + "synced": "Синхронизировано", + "syncing": "Синхронизация", + "pending": "Ожидает синхронизации", + "error": "Не удалось синхронизировать", + "online-only": "Только в Файлах, скачивается при открытии" + }, + "show_in": { + "explorer": "Показать в Проводнике", + "finder": "Показать в Finder", + "file_manager": "Показать в файловом менеджере", + "failed": "Не удалось открыть папку." + } + }, "desktopQuickCompose": { "label": "Быстрое письмо", "menu": "Быстрое письмо", @@ -7665,6 +7776,14 @@ "types_character": "На вашей клавиатуре эти клавиши вводят символ, и он перестал бы работать во всех приложениях. Попробуйте другие клавиши.", "reserved": "Эти клавиши использует система или большинство приложений. Попробуйте другие клавиши." }, + "desktopAttachRecent": { + "heading": "Недавние файлы", + "browse": "Обзор этого компьютера…", + "from_files": "Из Файлов…", + "missing": "«{name}» больше не существует.", + "too_large": "«{name}» больше {max}, его нельзя прикрепить.", + "failed": "Не удалось прикрепить «{name}». Попробуйте ещё раз." + }, "compose_windows": { "more_formatting": "Дополнительное форматирование", "offline": "Не в сети", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Чтобы видеть новую почту, пока вы работаете в другом приложении.", "tip_compose_keys": "Нажмите {keys}, чтобы написать новое письмо", "tip_palette_keys": "Нажмите {keys}, чтобы найти любую команду" + }, + "selectionView": { + "starred": "Помечено звёздочкой", + "unstarred": "Звёздочка снята" } } diff --git a/locales/sk/common.json b/locales/sk/common.json index 6cd0b797b..a7fdd66cc 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5279,6 +5279,11 @@ "cancelled": "{name} zrušil(a) „{title}“", "untitled": "Udalosť bez názvu" }, + "calendar_month_select": { + "selected_range": "Vybraté {start} až {end}.", + "selected_day": "Vybraté {day}.", + "enter_hint": "Stlačením klávesu Enter vytvoríte udalosť." + }, "company_calendars": { "section": "Organizácia" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Zatiaľ nepripojené ani neuložené, a teraz odstránené: {names}.", "signout_held": "Stále otvorené v inom programe, takže sa odstránia až po zatvorení: {names}." }, + "files_sync": { + "title": "Súbory v tomto počítači", + "drive": { + "title": "Jednotka Súbory", + "label": "Zobraziť Súbory ako jednotku", + "description": "Vaše Súbory sa v Prieskumníkovi zobrazia ako jednotka. Súbor sa stiahne, keď ho otvoríte.", + "unsupported": "Vyžaduje Windows 10 verzie 1809 alebo novší.", + "root_name": "Jednotka Súbory", + "letter_label": "Písmeno jednotky", + "letter_description": "Pridelí jednotke vlastné písmeno v Prieskumníkovi.", + "letter_none": "Bez písmena" + }, + "folders": { + "title": "Synchronizované priečinky", + "description": "Priečinok v tomto počítači a priečinok v Súboroch zostávajú rovnaké, zmeny idú oboma smermi.", + "empty": "Zatiaľ sa nesynchronizujú žiadne priečinky.", + "forced_off": "Vaša organizácia vypla synchronizáciu priečinkov. Súbory v nich zostanú v tomto počítači.", + "add": "Pridať priečinok…", + "loading": "Načítavajú sa vaše priečinky…", + "load_failed": "Vaše priečinky v Súboroch sa nepodarilo načítať. Skúste to znova.", + "pick_title": "Ktorý priečinok v Súboroch?", + "pick_confirm": "Vybrať priečinok v tomto počítači…", + "paired_with": "S priečinkom {folder} v Súboroch", + "pause": "Pozastaviť", + "resume": "Pokračovať", + "remove": "Odstrániť…", + "remove_title": "Prestať synchronizovať tento priečinok?", + "remove_message": "Súbory v tomto počítači zostanú, kde sú, a rovnako aj súbory v Súboroch. Zmeny sa medzi nimi už nebudú prenášať.", + "remove_confirm": "Prestať synchronizovať", + "add_failed": { + "overlap": "Tento priečinok je v synchronizovanom priečinku, taký priečinok obsahuje alebo je to systémový priečinok. Vyberte iný.", + "denied": "Aplikácia tento priečinok nemôže používať. Vyberte iný.", + "policy": "Vaša organizácia to nepovoľuje.", + "failed": "Priečinok sa nepodarilo pridať. Skúste to znova." + } + }, + "status": { + "starting": "Spúšťa sa…", + "idle": "Aktuálne. Naposledy synchronizované {time}", + "idle_never": "Aktuálne", + "syncing": "Synchronizácia: hotovo {done}, zostáva {pending}", + "paused": "Pozastavené", + "offline": "Offline. {count, plural, =0 {Zmeny čakajú na server} one {# zmena čaká na server} few {# zmeny čakajú na server} many {# zmeny čaká na server} other {# zmien čaká na server}}", + "error": "Zastavené: {reason}", + "confirm_delete": "Pozastavené, kým neodpoviete na otázku nižšie", + "blocked": "Vypnuté vašou organizáciou" + }, + "mass_delete": { + "title": "Odstrániť {count, plural, one {# položku} few {# položky} many {# položky} other {# položiek}}?", + "local": "{count, plural, one {# položka bola odstránená} few {# položky boli odstránené} many {# položky bolo odstránených} other {# položiek bolo odstránených}} z priečinka {folder} v tomto počítači. Odstrániť {count, plural, one {ju} few {ich} many {ich} other {ich}} aj v Súboroch?", + "remote": "{count, plural, one {# položka bola odstránená} few {# položky boli odstránené} many {# položky bolo odstránených} other {# položiek bolo odstránených}} z priečinka {folder} v Súboroch. Odstrániť {count, plural, one {ju} few {ich} many {ich} other {ich}} aj v tomto počítači?", + "allow": "{count, plural, one {Odstrániť ju} few {Odstrániť ich} many {Odstrániť ich} other {Odstrániť ich}}", + "restore": "{count, plural, one {Vrátiť ju späť} few {Vrátiť ich späť} many {Vrátiť ich späť} other {Vrátiť ich späť}}", + "later": "Rozhodnúť neskôr" + }, + "activity": { + "title": "Nedávna aktivita", + "empty": "Zatiaľ sa nič nestalo.", + "clear": "Vymazať", + "to_files": "Do Súborov", + "to_computer": "Do tohto počítača", + "mark_conflict": "Konflikt", + "mark_error": "Chyba", + "mark_question": "Čaká na vašu odpoveď", + "uploaded": "Nahrané: {path}", + "downloaded": "Stiahnuté: {path}", + "folder-created": "Vytvorený priečinok {path}", + "renamed": "Premenované: {detail} na {path}", + "moved": "Presunuté: {detail} do {path}", + "deleted": "Odstránené: {path}", + "trashed": "Presunuté do koša: {path}", + "conflict": "{path} sa zmenilo na oboch miestach. Druhá verzia je uložená ako {detail}", + "renamed-for-disk": "{path} sa v tomto počítači volá {detail}, pretože tento názov tu nie je povolený", + "too-large": "{path} je väčšie, než Súbory povoľujú ({detail}). Zostáva len v tomto počítači", + "waiting": "{path} čaká na server", + "mass-delete": "Pozastavené pred odstránením mnohých položiek", + "error": "{path} sa nepodarilo synchronizovať" + }, + "errors": { + "offline": "Server nie je dostupný", + "server_refused": "Server zmenu odmietol", + "too_large": "Súbor je pre Súbory príliš veľký", + "name_invalid": "Tento názov nie je povolený", + "disk_full": "Disk je plný", + "denied": "Nepovolené", + "busy": "Používa ho iný program", + "quota": "Vaše úložisko v Súboroch je plné", + "not_found": "Už neexistuje", + "policy": "Vaša organizácia to nepovoľuje", + "failed": "Niečo sa pokazilo" + }, + "item": { + "label": "V tomto počítači", + "synced": "Synchronizované", + "syncing": "Synchronizuje sa", + "pending": "Čaká na synchronizáciu", + "error": "Nepodarilo sa synchronizovať", + "online-only": "Len v Súboroch, stiahne sa pri otvorení" + }, + "show_in": { + "explorer": "Zobraziť v Prieskumníkovi", + "finder": "Zobraziť vo Finderi", + "file_manager": "Zobraziť v správcovi súborov", + "failed": "Priečinok sa nepodarilo otvoriť." + } + }, "desktopQuickCompose": { "label": "Rýchla správa", "menu": "Rýchla správa", @@ -7665,6 +7776,14 @@ "types_character": "Tieto klávesy na vašej klávesnici píšu znak, ktorý by potom nefungoval v žiadnej aplikácii. Skúste iné klávesy.", "reserved": "Tieto klávesy používa systém alebo väčšina aplikácií. Skúste iné klávesy." }, + "desktopAttachRecent": { + "heading": "Nedávne súbory", + "browse": "Prehľadávať tento počítač…", + "from_files": "Zo Súborov…", + "missing": "„{name}“ už neexistuje.", + "too_large": "„{name}“ je väčší ako {max} a nedá sa priložiť.", + "failed": "„{name}“ sa nepodarilo priložiť. Skúste to znova." + }, "compose_windows": { "more_formatting": "Ďalšie formátovanie", "offline": "Offline", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Aby ste videli novú poštu, aj keď pracujete v inej aplikácii.", "tip_compose_keys": "Stlačte {keys} a napíšte novú správu", "tip_palette_keys": "Stlačte {keys} a nájdite akýkoľvek príkaz" + }, + "selectionView": { + "starred": "Označené hviezdičkou", + "unstarred": "Hviezdička odstránená" } } diff --git a/locales/th/common.json b/locales/th/common.json index 297c92761..5dfd0271f 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2839,6 +2839,11 @@ "cancelled": "{name} ยกเลิกกิจกรรม “{title}”", "untitled": "กิจกรรมที่ไม่มีชื่อ" }, + "calendar_month_select": { + "selected_range": "เลือก {start} ถึง {end} แล้ว", + "selected_day": "เลือก {day} แล้ว", + "enter_hint": "กด Enter เพื่อสร้างกิจกรรม" + }, "company_calendars": { "section": "องค์กร" }, @@ -7658,6 +7663,112 @@ "signout_changed": "ยังไม่ได้แนบหรือบันทึก และจะถูกลบตอนนี้: {names}", "signout_held": "ยังเปิดอยู่ในโปรแกรมอื่น จึงจะถูกลบหลังจากคุณปิดที่นั่นเท่านั้น: {names}" }, + "files_sync": { + "title": "ไฟล์บนคอมพิวเตอร์เครื่องนี้", + "drive": { + "title": "ไดรฟ์ไฟล์", + "label": "แสดงไฟล์เป็นไดรฟ์", + "description": "ไฟล์ของคุณจะแสดงในตัวสำรวจไฟล์เหมือนไดรฟ์ แต่ละไฟล์จะดาวน์โหลดเมื่อคุณเปิด", + "unsupported": "ต้องใช้ Windows 10 เวอร์ชัน 1809 ขึ้นไป", + "root_name": "ไดรฟ์ไฟล์", + "letter_label": "อักษรไดรฟ์", + "letter_description": "กำหนดอักษรเฉพาะให้ไดรฟ์ในตัวสำรวจไฟล์", + "letter_none": "ไม่มีอักษร" + }, + "folders": { + "title": "โฟลเดอร์ที่ซิงค์", + "description": "โฟลเดอร์บนคอมพิวเตอร์เครื่องนี้และโฟลเดอร์ในไฟล์จะเหมือนกันเสมอ ทั้งสองทิศทาง", + "empty": "ยังไม่มีโฟลเดอร์ที่ซิงค์", + "forced_off": "องค์กรของคุณปิดการซิงค์โฟลเดอร์ ไฟล์ในโฟลเดอร์เหล่านั้นยังอยู่บนคอมพิวเตอร์เครื่องนี้", + "add": "เพิ่มโฟลเดอร์…", + "loading": "กำลังโหลดโฟลเดอร์ของคุณ…", + "load_failed": "โหลดโฟลเดอร์ของคุณในไฟล์ไม่ได้ โปรดลองอีกครั้ง", + "pick_title": "ซิงค์กับโฟลเดอร์ใดในไฟล์", + "pick_confirm": "เลือกโฟลเดอร์บนคอมพิวเตอร์เครื่องนี้…", + "paired_with": "กับ {folder} ในไฟล์", + "pause": "หยุดชั่วคราว", + "resume": "ทำต่อ", + "remove": "นำออก…", + "remove_title": "หยุดซิงค์โฟลเดอร์นี้หรือไม่", + "remove_message": "ไฟล์บนคอมพิวเตอร์เครื่องนี้และไฟล์ในไฟล์จะยังอยู่ที่เดิม การเปลี่ยนแปลงจะไม่ส่งจากฝั่งหนึ่งไปอีกฝั่งอีกต่อไป", + "remove_confirm": "หยุดซิงค์", + "add_failed": { + "overlap": "โฟลเดอร์นี้อยู่ในโฟลเดอร์ที่ซิงค์ มีโฟลเดอร์ที่ซิงค์อยู่ข้างใน หรือเป็นโฟลเดอร์ระบบ โปรดเลือกโฟลเดอร์อื่น", + "denied": "แอปใช้โฟลเดอร์นี้ไม่ได้ โปรดเลือกโฟลเดอร์อื่น", + "policy": "องค์กรของคุณไม่อนุญาต", + "failed": "เพิ่มโฟลเดอร์ไม่ได้ โปรดลองอีกครั้ง" + } + }, + "status": { + "starting": "กำลังเริ่ม…", + "idle": "เป็นปัจจุบันแล้ว ซิงค์ล่าสุด {time}", + "idle_never": "เป็นปัจจุบันแล้ว", + "syncing": "กำลังซิงค์: เสร็จแล้ว {done} เหลืออีก {pending}", + "paused": "หยุดชั่วคราว", + "offline": "ออฟไลน์ {count, plural, =0 {การเปลี่ยนแปลงกำลังรอเซิร์ฟเวอร์} other {การเปลี่ยนแปลง # รายการกำลังรอเซิร์ฟเวอร์}}", + "error": "หยุดแล้ว: {reason}", + "confirm_delete": "หยุดชั่วคราวจนกว่าคุณจะตอบคำถามด้านล่าง", + "blocked": "ปิดโดยองค์กรของคุณ" + }, + "mass_delete": { + "title": "ลบ {count, plural, other {# รายการ}}หรือไม่", + "local": "{count, plural, other {# รายการ}}ถูกลบออกจาก {folder} บนคอมพิวเตอร์เครื่องนี้ ลบในไฟล์ด้วยหรือไม่", + "remote": "{count, plural, other {# รายการ}}ถูกลบออกจาก {folder} ในไฟล์ ลบบนคอมพิวเตอร์เครื่องนี้ด้วยหรือไม่", + "allow": "{count, plural, other {ลบ}}", + "restore": "{count, plural, other {นำกลับมา}}", + "later": "ตัดสินใจภายหลัง" + }, + "activity": { + "title": "กิจกรรมล่าสุด", + "empty": "ยังไม่มีอะไรเกิดขึ้น", + "clear": "ล้าง", + "to_files": "ไปยังไฟล์", + "to_computer": "ไปยังคอมพิวเตอร์เครื่องนี้", + "mark_conflict": "ขัดแย้ง", + "mark_error": "ข้อผิดพลาด", + "mark_question": "รอคำตอบจากคุณ", + "uploaded": "อัปโหลด {path} แล้ว", + "downloaded": "ดาวน์โหลด {path} แล้ว", + "folder-created": "สร้างโฟลเดอร์ {path} แล้ว", + "renamed": "เปลี่ยนชื่อ {detail} เป็น {path} แล้ว", + "moved": "ย้าย {detail} ไปยัง {path} แล้ว", + "deleted": "ลบ {path} แล้ว", + "trashed": "ย้าย {path} ไปที่ถังขยะแล้ว", + "conflict": "{path} ถูกเปลี่ยนทั้งสองที่ อีกเวอร์ชันหนึ่งเก็บไว้เป็น {detail}", + "renamed-for-disk": "{path} ใช้ชื่อ {detail} บนคอมพิวเตอร์เครื่องนี้ เพราะชื่อเดิมใช้ที่นี่ไม่ได้", + "too-large": "{path} ใหญ่เกินกว่าที่ไฟล์อนุญาต ({detail}) จึงอยู่บนคอมพิวเตอร์เครื่องนี้เท่านั้น", + "waiting": "{path} กำลังรอเซิร์ฟเวอร์", + "mass-delete": "หยุดชั่วคราวก่อนลบหลายรายการ", + "error": "ซิงค์ {path} ไม่ได้" + }, + "errors": { + "offline": "เชื่อมต่อเซิร์ฟเวอร์ไม่ได้", + "server_refused": "เซิร์ฟเวอร์ปฏิเสธการเปลี่ยนแปลง", + "too_large": "ไฟล์นี้ใหญ่เกินกว่าที่ไฟล์อนุญาต", + "name_invalid": "ใช้ชื่อนี้ไม่ได้", + "disk_full": "ดิสก์เต็ม", + "denied": "ไม่ได้รับอนุญาต", + "busy": "โปรแกรมอื่นกำลังใช้งานอยู่", + "quota": "พื้นที่เก็บข้อมูลของคุณในไฟล์เต็มแล้ว", + "not_found": "ไม่มีอยู่แล้ว", + "policy": "องค์กรของคุณไม่อนุญาต", + "failed": "เกิดข้อผิดพลาด" + }, + "item": { + "label": "บนคอมพิวเตอร์เครื่องนี้", + "synced": "ซิงค์แล้ว", + "syncing": "กำลังซิงค์", + "pending": "รอซิงค์", + "error": "ซิงค์ไม่ได้", + "online-only": "อยู่ในไฟล์เท่านั้น ดาวน์โหลดเมื่อเปิด" + }, + "show_in": { + "explorer": "แสดงในตัวสำรวจไฟล์", + "finder": "แสดงใน Finder", + "file_manager": "แสดงในตัวจัดการไฟล์", + "failed": "เปิดโฟลเดอร์ไม่ได้" + } + }, "desktopQuickCompose": { "label": "เขียนด่วน", "menu": "เขียนด่วน", @@ -7673,6 +7784,14 @@ "types_character": "ปุ่มเหล่านี้พิมพ์อักขระบนแป้นพิมพ์ของคุณ และอักขระนั้นจะใช้ไม่ได้ในทุกแอป ลองใช้ปุ่มอื่น", "reserved": "ระบบหรือแอปส่วนใหญ่ใช้ปุ่มเหล่านี้ ลองใช้ปุ่มอื่น" }, + "desktopAttachRecent": { + "heading": "ไฟล์ล่าสุด", + "browse": "เรียกดูคอมพิวเตอร์เครื่องนี้…", + "from_files": "จากไฟล์…", + "missing": "“{name}” ไม่มีอยู่แล้ว", + "too_large": "“{name}” มีขนาดใหญ่กว่า {max} จึงแนบไม่ได้", + "failed": "แนบ “{name}” ไม่สำเร็จ ลองอีกครั้ง" + }, "compose_windows": { "more_formatting": "การจัดรูปแบบเพิ่มเติม", "offline": "ออฟไลน์", @@ -7778,5 +7897,9 @@ "unread_badge_description": "เพื่อให้เห็นอีเมลใหม่ขณะทำงานในแอปอื่น", "tip_compose_keys": "กด {keys} เพื่อเขียนข้อความใหม่", "tip_palette_keys": "กด {keys} เพื่อค้นหาคำสั่งใดก็ได้" + }, + "selectionView": { + "starred": "ติดดาวแล้ว", + "unstarred": "นำดาวออกแล้ว" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index d2e8b9057..e6a83cc36 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5279,6 +5279,11 @@ "cancelled": "{name} \"{title}\" etkinliğini iptal etti", "untitled": "Başlıksız etkinlik" }, + "calendar_month_select": { + "selected_range": "{start} ile {end} arası seçildi.", + "selected_day": "{day} seçildi.", + "enter_hint": "Etkinlik oluşturmak için Enter tuşuna basın." + }, "company_calendars": { "section": "Kuruluş" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Henüz eklenmedi veya kaydedilmedi, şimdi siliniyor: {names}.", "signout_held": "Hâlâ başka bir programda açık, bu yüzden ancak orada kapatınca silinir: {names}." }, + "files_sync": { + "title": "Bu bilgisayardaki dosyalar", + "drive": { + "title": "Dosyalar sürücüsü", + "label": "Dosyalar'ı sürücü olarak göster", + "description": "Dosyalar'ınız Dosya Gezgini'nde bir sürücü gibi görünür. Bir dosya, onu açtığınızda indirilir.", + "unsupported": "Windows 10 sürüm 1809 veya üstü gerekir.", + "root_name": "Dosyalar sürücüsü", + "letter_label": "Sürücü harfi", + "letter_description": "Sürücüye Dosya Gezgini'nde kendi harfini verir.", + "letter_none": "Harf yok" + }, + "folders": { + "title": "Eşitlenen klasörler", + "description": "Bu bilgisayardaki bir klasör ile Dosyalar'daki bir klasör her iki yönde de aynı tutulur.", + "empty": "Henüz eşitlenen klasör yok.", + "forced_off": "Kuruluşunuz klasör eşitlemeyi kapattı. İçlerindeki dosyalar bu bilgisayarda kalır.", + "add": "Klasör ekle…", + "loading": "Klasörleriniz yükleniyor…", + "load_failed": "Dosyalar'daki klasörleriniz yüklenemedi. Yeniden deneyin.", + "pick_title": "Dosyalar'daki hangi klasör?", + "pick_confirm": "Bu bilgisayarda klasör seç…", + "paired_with": "Dosyalar'daki {folder} ile", + "pause": "Duraklat", + "resume": "Sürdür", + "remove": "Kaldır…", + "remove_title": "Bu klasörün eşitlenmesi durdurulsun mu?", + "remove_message": "Bu bilgisayardaki dosyalar yerinde kalır, Dosyalar'daki dosyalar da öyle. Değişiklikler artık birinden diğerine aktarılmaz.", + "remove_confirm": "Eşitlemeyi durdur", + "add_failed": { + "overlap": "Bu klasör eşitlenen bir klasörün içinde, böyle bir klasör içeriyor ya da bir sistem klasörü. Başka bir klasör seçin.", + "denied": "Uygulama bu klasörü kullanamıyor. Başka bir klasör seçin.", + "policy": "Kuruluşunuz buna izin vermiyor.", + "failed": "Klasör eklenemedi. Yeniden deneyin." + } + }, + "status": { + "starting": "Başlatılıyor…", + "idle": "Güncel. Son eşitleme: {time}", + "idle_never": "Güncel", + "syncing": "Eşitleniyor: {done} tamamlandı, {pending} kaldı", + "paused": "Duraklatıldı", + "offline": "Çevrimdışı. {count, plural, =0 {Değişiklikler sunucuyu bekliyor} one {# değişiklik sunucuyu bekliyor} other {# değişiklik sunucuyu bekliyor}}", + "error": "Durdu: {reason}", + "confirm_delete": "Aşağıdaki soruyu yanıtlayana kadar duraklatıldı", + "blocked": "Kuruluşunuz tarafından kapatıldı" + }, + "mass_delete": { + "title": "{count, plural, one {# öğe} other {# öğe}} silinsin mi?", + "local": "Bu bilgisayarda {folder} klasöründen {count, plural, one {# öğe} other {# öğe}} silindi. Dosyalar'da da {count, plural, one {silinsin} other {silinsinler}} mi?", + "remote": "Dosyalar'da {folder} klasöründen {count, plural, one {# öğe} other {# öğe}} silindi. Bu bilgisayarda da {count, plural, one {silinsin} other {silinsinler}} mi?", + "allow": "{count, plural, one {Sil} other {Hepsini sil}}", + "restore": "{count, plural, one {Geri getir} other {Hepsini geri getir}}", + "later": "Sonra karar ver" + }, + "activity": { + "title": "Son etkinlik", + "empty": "Henüz bir şey olmadı.", + "clear": "Temizle", + "to_files": "Dosyalar'a", + "to_computer": "Bu bilgisayara", + "mark_conflict": "Çakışma", + "mark_error": "Hata", + "mark_question": "Yanıtınızı bekliyor", + "uploaded": "Yüklendi: {path}", + "downloaded": "İndirildi: {path}", + "folder-created": "Klasör oluşturuldu: {path}", + "renamed": "Yeniden adlandırıldı: {detail} → {path}", + "moved": "Taşındı: {detail} → {path}", + "deleted": "Silindi: {path}", + "trashed": "Çöp kutusuna taşındı: {path}", + "conflict": "{path} iki yerde de değiştirildi. Diğer sürüm {detail} olarak saklandı", + "renamed-for-disk": "{path}, adına burada izin verilmediği için bu bilgisayarda {detail} olarak adlandırıldı", + "too-large": "{path}, Dosyalar'ın izin verdiğinden büyük ({detail}). Yalnızca bu bilgisayarda kalır", + "waiting": "{path} sunucuyu bekliyor", + "mass-delete": "Çok sayıda öğe silinmeden önce duraklatıldı", + "error": "{path} eşitlenemedi" + }, + "errors": { + "offline": "Sunucuya ulaşılamıyor", + "server_refused": "Sunucu değişikliği reddetti", + "too_large": "Dosya, Dosyalar için çok büyük", + "name_invalid": "Bu ada izin verilmiyor", + "disk_full": "Disk dolu", + "denied": "İzin verilmiyor", + "busy": "Başka bir program kullanıyor", + "quota": "Dosyalar'daki depolama alanınız dolu", + "not_found": "Artık mevcut değil", + "policy": "Kuruluşunuz buna izin vermiyor", + "failed": "Bir sorun oluştu" + }, + "item": { + "label": "Bu bilgisayarda", + "synced": "Eşitlendi", + "syncing": "Eşitleniyor", + "pending": "Eşitlenmeyi bekliyor", + "error": "Eşitlenemedi", + "online-only": "Yalnızca Dosyalar'da, açıldığında indirilir" + }, + "show_in": { + "explorer": "Dosya Gezgini'nde göster", + "finder": "Finder'da göster", + "file_manager": "Dosya yöneticisinde göster", + "failed": "Klasör açılamadı." + } + }, "desktopQuickCompose": { "label": "Hızlı ileti", "menu": "Hızlı ileti", @@ -7665,6 +7776,14 @@ "types_character": "Bu tuşlar klavyenizde bir karakter yazar ve bu karakter hiçbir uygulamada çalışmaz olur. Başka tuşlar deneyin.", "reserved": "Bu tuşları sisteminiz veya çoğu uygulama kullanıyor. Başka tuşlar deneyin." }, + "desktopAttachRecent": { + "heading": "Son dosyalar", + "browse": "Bu bilgisayara göz at…", + "from_files": "Dosyalar'dan…", + "missing": "\"{name}\" artık yok.", + "too_large": "\"{name}\" {max} boyutundan büyük ve eklenemez.", + "failed": "\"{name}\" eklenemedi. Yeniden deneyin." + }, "compose_windows": { "more_formatting": "Daha fazla biçimlendirme", "offline": "Çevrimdışı", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Başka bir uygulamada çalışırken yeni postayı görmeniz için.", "tip_compose_keys": "Yeni bir ileti yazmak için {keys} tuşlarına basın", "tip_palette_keys": "Herhangi bir komutu bulmak için {keys} tuşlarına basın" + }, + "selectionView": { + "starred": "Yıldız eklendi", + "unstarred": "Yıldız kaldırıldı" } } diff --git a/locales/uk/common.json b/locales/uk/common.json index f37d89731..8cda00c9f 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5905,6 +5905,11 @@ "cancelled": "{name} скасував(ла) «{title}»", "untitled": "Подія без назви" }, + "calendar_month_select": { + "selected_range": "Вибрано з {start} по {end}.", + "selected_day": "Вибрано: {day}.", + "enter_hint": "Натисніть Enter, щоб створити подію." + }, "company_calendars": { "section": "Організація" }, @@ -7650,6 +7655,112 @@ "signout_changed": "Ще не вкладено й не збережено, і зараз видаляється: {names}.", "signout_held": "Ще відкрито в іншій програмі, тому видалиться лише після закриття там: {names}." }, + "files_sync": { + "title": "Файли на цьому комп’ютері", + "drive": { + "title": "Диск «Файли»", + "label": "Показувати Файли як диск", + "description": "Ваші Файли видно в Провіднику як диск. Файл завантажується, коли ви його відкриваєте.", + "unsupported": "Потрібна Windows 10 версії 1809 або новіша.", + "root_name": "Диск «Файли»", + "letter_label": "Літера диска", + "letter_description": "Призначає диску власну літеру в Провіднику.", + "letter_none": "Без літери" + }, + "folders": { + "title": "Синхронізовані папки", + "description": "Папка на цьому комп’ютері й папка у Файлах залишаються однаковими, зміни йдуть в обидва боки.", + "empty": "Ще немає синхронізованих папок.", + "forced_off": "Ваша організація вимкнула синхронізацію папок. Файли в них залишаються на цьому комп’ютері.", + "add": "Додати папку…", + "loading": "Завантаження ваших папок…", + "load_failed": "Не вдалося завантажити ваші папки у Файлах. Спробуйте ще раз.", + "pick_title": "Яку папку у Файлах вибрати?", + "pick_confirm": "Вибрати папку на цьому комп’ютері…", + "paired_with": "З папкою {folder} у Файлах", + "pause": "Призупинити", + "resume": "Відновити", + "remove": "Прибрати…", + "remove_title": "Припинити синхронізацію цієї папки?", + "remove_message": "Файли на цьому комп’ютері залишаться на місці, як і файли у Файлах. Зміни більше не переноситимуться між ними.", + "remove_confirm": "Припинити синхронізацію", + "add_failed": { + "overlap": "Ця папка лежить усередині синхронізованої папки, містить таку папку або є системною. Виберіть іншу.", + "denied": "Застосунок не може використовувати цю папку. Виберіть іншу.", + "policy": "Ваша організація цього не дозволяє.", + "failed": "Не вдалося додати папку. Спробуйте ще раз." + } + }, + "status": { + "starting": "Запуск…", + "idle": "Усе актуально. Остання синхронізація: {time}", + "idle_never": "Усе актуально", + "syncing": "Синхронізація: готово {done}, залишилося {pending}", + "paused": "Призупинено", + "offline": "Немає зв’язку. {count, plural, =0 {Зміни чекають на сервер} one {# зміна чекає на сервер} few {# зміни чекають на сервер} many {# змін чекають на сервер} other {# зміни чекають на сервер}}", + "error": "Зупинено: {reason}", + "confirm_delete": "Призупинено, доки ви не відповісте на запитання нижче", + "blocked": "Вимкнено вашою організацією" + }, + "mass_delete": { + "title": "Видалити {count, plural, one {# елемент} few {# елементи} many {# елементів} other {# елемента}}?", + "local": "{count, plural, one {# елемент видалено} few {# елементи видалено} many {# елементів видалено} other {# елемента видалено}} з папки {folder} на цьому комп’ютері. Видалити {count, plural, one {його} few {їх} many {їх} other {їх}} і у Файлах?", + "remote": "{count, plural, one {# елемент видалено} few {# елементи видалено} many {# елементів видалено} other {# елемента видалено}} з папки {folder} у Файлах. Видалити {count, plural, one {його} few {їх} many {їх} other {їх}} і на цьому комп’ютері?", + "allow": "{count, plural, one {Видалити його} few {Видалити їх} many {Видалити їх} other {Видалити їх}}", + "restore": "{count, plural, one {Повернути його} few {Повернути їх} many {Повернути їх} other {Повернути їх}}", + "later": "Вирішити пізніше" + }, + "activity": { + "title": "Остання активність", + "empty": "Поки нічого не сталося.", + "clear": "Очистити", + "to_files": "У Файли", + "to_computer": "На цей комп’ютер", + "mark_conflict": "Конфлікт", + "mark_error": "Помилка", + "mark_question": "Потрібна ваша відповідь", + "uploaded": "Вивантажено: {path}", + "downloaded": "Завантажено: {path}", + "folder-created": "Створено папку {path}", + "renamed": "Перейменовано: {detail} → {path}", + "moved": "Переміщено: {detail} → {path}", + "deleted": "Видалено: {path}", + "trashed": "Переміщено в кошик: {path}", + "conflict": "{path}: змінено в обох місцях. Іншу версію збережено як {detail}", + "renamed-for-disk": "{path} на цьому комп’ютері називається {detail}, бо таке ім’я тут не дозволене", + "too-large": "{path} більший, ніж дозволяють Файли ({detail}). Залишається лише на цьому комп’ютері", + "waiting": "{path} чекає на сервер", + "mass-delete": "Призупинено перед видаленням багатьох елементів", + "error": "Не вдалося синхронізувати {path}" + }, + "errors": { + "offline": "Сервер недоступний", + "server_refused": "Сервер відхилив зміну", + "too_large": "Файл завеликий для Файлів", + "name_invalid": "Таке ім’я не дозволене", + "disk_full": "Диск заповнено", + "denied": "Немає доступу", + "busy": "Використовується іншою програмою", + "quota": "Ваше сховище у Файлах заповнене", + "not_found": "Більше не існує", + "policy": "Ваша організація цього не дозволяє", + "failed": "Щось пішло не так" + }, + "item": { + "label": "На цьому комп’ютері", + "synced": "Синхронізовано", + "syncing": "Синхронізація", + "pending": "Очікує синхронізації", + "error": "Не вдалося синхронізувати", + "online-only": "Лише у Файлах, завантажується під час відкриття" + }, + "show_in": { + "explorer": "Показати в Провіднику", + "finder": "Показати у Finder", + "file_manager": "Показати у файловому менеджері", + "failed": "Не вдалося відкрити папку." + } + }, "desktopQuickCompose": { "label": "Швидкий лист", "menu": "Швидкий лист", @@ -7665,6 +7776,14 @@ "types_character": "На вашій клавіатурі ці клавіші вводять символ, і він перестав би працювати в усіх застосунках. Спробуйте інші клавіші.", "reserved": "Ці клавіші використовує система або більшість застосунків. Спробуйте інші клавіші." }, + "desktopAttachRecent": { + "heading": "Нещодавні файли", + "browse": "Огляд цього комп'ютера…", + "from_files": "З Файлів…", + "missing": "«{name}» більше не існує.", + "too_large": "«{name}» більший за {max}, його не можна прикріпити.", + "failed": "Не вдалося прикріпити «{name}». Спробуйте ще раз." + }, "compose_windows": { "more_formatting": "Додаткове форматування", "offline": "Не в мережі", @@ -7770,5 +7889,9 @@ "unread_badge_description": "Щоб бачити нову пошту, поки ви працюєте в іншому застосунку.", "tip_compose_keys": "Натисніть {keys}, щоб написати новий лист", "tip_palette_keys": "Натисніть {keys}, щоб знайти будь-яку команду" + }, + "selectionView": { + "starred": "Позначено зірочкою", + "unstarred": "Зірочку знято" } } diff --git a/locales/vi/common.json b/locales/vi/common.json index b7bf55de9..aca277f32 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5291,6 +5291,11 @@ "cancelled": "{name} đã hủy \"{title}\"", "untitled": "Sự kiện không có tiêu đề" }, + "calendar_month_select": { + "selected_range": "Đã chọn từ {start} đến {end}.", + "selected_day": "Đã chọn {day}.", + "enter_hint": "Nhấn Enter để tạo sự kiện." + }, "company_calendars": { "section": "Tổ chức" }, @@ -7662,6 +7667,112 @@ "signout_changed": "Chưa đính kèm hoặc lưu, và bị xóa ngay: {names}.", "signout_held": "Vẫn đang mở trong chương trình khác nên chỉ bị xóa sau khi bạn đóng ở đó: {names}." }, + "files_sync": { + "title": "Tệp trên máy tính này", + "drive": { + "title": "Ổ đĩa Tệp", + "label": "Hiển thị Tệp dưới dạng ổ đĩa", + "description": "Tệp của bạn hiển thị trong Trình khám phá Tệp như một ổ đĩa. Mỗi tệp chỉ được tải xuống khi bạn mở nó.", + "unsupported": "Cần Windows 10 phiên bản 1809 trở lên.", + "root_name": "Ổ đĩa Tệp", + "letter_label": "Ký tự ổ đĩa", + "letter_description": "Gán cho ổ đĩa một ký tự riêng trong Trình khám phá Tệp.", + "letter_none": "Không có ký tự" + }, + "folders": { + "title": "Thư mục đồng bộ", + "description": "Một thư mục trên máy tính này và một thư mục trong Tệp được giữ giống nhau, theo cả hai chiều.", + "empty": "Chưa có thư mục nào được đồng bộ.", + "forced_off": "Tổ chức của bạn đã tắt đồng bộ thư mục. Các tệp trong đó vẫn ở trên máy tính này.", + "add": "Thêm thư mục…", + "loading": "Đang tải thư mục của bạn…", + "load_failed": "Không tải được thư mục của bạn trong Tệp. Hãy thử lại.", + "pick_title": "Thư mục nào trong Tệp?", + "pick_confirm": "Chọn thư mục trên máy tính này…", + "paired_with": "Với {folder} trong Tệp", + "pause": "Tạm dừng", + "resume": "Tiếp tục", + "remove": "Gỡ bỏ…", + "remove_title": "Ngừng đồng bộ thư mục này?", + "remove_message": "Các tệp trên máy tính này vẫn ở nguyên chỗ cũ, các tệp trong Tệp cũng vậy. Thay đổi sẽ không còn được chuyển từ bên này sang bên kia.", + "remove_confirm": "Ngừng đồng bộ", + "add_failed": { + "overlap": "Thư mục này nằm trong một thư mục đồng bộ, chứa một thư mục đồng bộ, hoặc là thư mục hệ thống. Hãy chọn thư mục khác.", + "denied": "Ứng dụng không thể dùng thư mục này. Hãy chọn thư mục khác.", + "policy": "Tổ chức của bạn không cho phép việc này.", + "failed": "Không thêm được thư mục. Hãy thử lại." + } + }, + "status": { + "starting": "Đang khởi động…", + "idle": "Đã cập nhật. Đồng bộ lần cuối {time}", + "idle_never": "Đã cập nhật", + "syncing": "Đang đồng bộ: xong {done}, còn {pending}", + "paused": "Đã tạm dừng", + "offline": "Ngoại tuyến. {count, plural, =0 {Các thay đổi đang chờ máy chủ} other {# thay đổi đang chờ máy chủ}}", + "error": "Đã dừng: {reason}", + "confirm_delete": "Tạm dừng cho đến khi bạn trả lời câu hỏi bên dưới", + "blocked": "Đã bị tổ chức của bạn tắt" + }, + "mass_delete": { + "title": "Xóa {count, plural, other {# mục}}?", + "local": "{count, plural, other {# mục}} đã bị xóa khỏi {folder} trên máy tính này. Cũng xóa chúng trong Tệp?", + "remote": "{count, plural, other {# mục}} đã bị xóa khỏi {folder} trong Tệp. Cũng xóa chúng trên máy tính này?", + "allow": "{count, plural, other {Xóa}}", + "restore": "{count, plural, other {Khôi phục}}", + "later": "Quyết định sau" + }, + "activity": { + "title": "Hoạt động gần đây", + "empty": "Chưa có gì xảy ra.", + "clear": "Xóa danh sách", + "to_files": "Đến Tệp", + "to_computer": "Đến máy tính này", + "mark_conflict": "Xung đột", + "mark_error": "Lỗi", + "mark_question": "Cần bạn trả lời", + "uploaded": "Đã tải lên {path}", + "downloaded": "Đã tải xuống {path}", + "folder-created": "Đã tạo thư mục {path}", + "renamed": "Đã đổi tên {detail} thành {path}", + "moved": "Đã di chuyển {detail} đến {path}", + "deleted": "Đã xóa {path}", + "trashed": "Đã chuyển {path} vào thùng rác", + "conflict": "{path} đã bị thay đổi ở cả hai nơi. Phiên bản kia được giữ với tên {detail}", + "renamed-for-disk": "{path} có tên {detail} trên máy tính này, vì tên gốc không được phép ở đây", + "too-large": "{path} lớn hơn mức Tệp cho phép ({detail}). Tệp này chỉ ở trên máy tính này", + "waiting": "{path} đang chờ máy chủ", + "mass-delete": "Đã tạm dừng trước khi xóa nhiều mục", + "error": "Không đồng bộ được {path}" + }, + "errors": { + "offline": "Không kết nối được máy chủ", + "server_refused": "Máy chủ đã từ chối thay đổi", + "too_large": "Tệp này lớn hơn mức Tệp cho phép", + "name_invalid": "Tên này không được phép", + "disk_full": "Ổ đĩa đã đầy", + "denied": "Không được phép", + "busy": "Một chương trình khác đang dùng nó", + "quota": "Dung lượng lưu trữ của bạn trong Tệp đã đầy", + "not_found": "Không còn ở đó nữa", + "policy": "Tổ chức của bạn không cho phép việc này", + "failed": "Đã xảy ra lỗi" + }, + "item": { + "label": "Trên máy tính này", + "synced": "Đã đồng bộ", + "syncing": "Đang đồng bộ", + "pending": "Đang chờ đồng bộ", + "error": "Không đồng bộ được", + "online-only": "Chỉ có trong Tệp, tải xuống khi mở" + }, + "show_in": { + "explorer": "Hiển thị trong Trình khám phá Tệp", + "finder": "Hiển thị trong Finder", + "file_manager": "Hiển thị trong trình quản lý tệp", + "failed": "Không mở được thư mục." + } + }, "desktopQuickCompose": { "label": "Soạn nhanh", "menu": "Soạn nhanh", @@ -7677,6 +7788,14 @@ "types_character": "Các phím này gõ một ký tự trên bàn phím của bạn, và ký tự đó sẽ không dùng được trong mọi ứng dụng. Hãy thử phím khác.", "reserved": "Hệ thống hoặc hầu hết ứng dụng dùng các phím này. Hãy thử phím khác." }, + "desktopAttachRecent": { + "heading": "Tệp gần đây", + "browse": "Duyệt máy tính này…", + "from_files": "Từ Tệp…", + "missing": "\"{name}\" không còn nữa.", + "too_large": "\"{name}\" lớn hơn {max} nên không thể đính kèm.", + "failed": "Không thể đính kèm \"{name}\". Hãy thử lại." + }, "compose_windows": { "more_formatting": "Thêm định dạng", "offline": "Ngoại tuyến", @@ -7782,5 +7901,9 @@ "unread_badge_description": "Để bạn thấy thư mới khi đang làm việc trong ứng dụng khác.", "tip_compose_keys": "Nhấn {keys} để viết thư mới", "tip_palette_keys": "Nhấn {keys} để tìm bất kỳ lệnh nào" + }, + "selectionView": { + "starred": "Đã gắn dấu sao", + "unstarred": "Đã bỏ dấu sao" } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 3ddca4003..54aeb9021 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5279,6 +5279,11 @@ "cancelled": "{name} 取消了「{title}」", "untitled": "未命名活動" }, + "calendar_month_select": { + "selected_range": "已選取 {start} 至 {end}。", + "selected_day": "已選取 {day}。", + "enter_hint": "按 Enter 鍵建立活動。" + }, "company_calendars": { "section": "組織" }, @@ -7650,6 +7655,112 @@ "signout_changed": "尚未附加或儲存,現在將刪除:{names}。", "signout_held": "仍在其他程式中開啟,因此要在那裡關閉後才會刪除:{names}。" }, + "files_sync": { + "title": "這部電腦上的檔案", + "drive": { + "title": "檔案磁碟機", + "label": "將檔案顯示為磁碟機", + "description": "您的檔案會像磁碟機一樣顯示在檔案總管中。檔案在開啟時才會下載。", + "unsupported": "需要 Windows 10 版本 1809 或更新版本。", + "root_name": "檔案磁碟機", + "letter_label": "磁碟機代號", + "letter_description": "在檔案總管中為磁碟機指定專屬的代號。", + "letter_none": "無代號" + }, + "folders": { + "title": "已同步的資料夾", + "description": "這部電腦上的一個資料夾和檔案中的一個資料夾會雙向保持一致。", + "empty": "尚未同步任何資料夾。", + "forced_off": "您的組織已關閉資料夾同步。其中的檔案會保留在這部電腦上。", + "add": "新增資料夾…", + "loading": "正在載入您的資料夾…", + "load_failed": "無法載入您在檔案中的資料夾。請再試一次。", + "pick_title": "要與檔案中的哪個資料夾同步?", + "pick_confirm": "選擇這部電腦上的資料夾…", + "paired_with": "與檔案中的 {folder} 同步", + "pause": "暫停", + "resume": "繼續", + "remove": "移除…", + "remove_title": "要停止同步此資料夾嗎?", + "remove_message": "這部電腦上的檔案和檔案中的檔案都會保持原樣。變更將不再於兩者之間同步。", + "remove_confirm": "停止同步", + "add_failed": { + "overlap": "此資料夾位於已同步的資料夾中、包含已同步的資料夾,或者是系統資料夾。請選擇其他資料夾。", + "denied": "應用程式無法使用此資料夾。請選擇其他資料夾。", + "policy": "您的組織不允許此操作。", + "failed": "無法新增資料夾。請再試一次。" + } + }, + "status": { + "starting": "正在啟動…", + "idle": "已是最新狀態。上次同步於 {time}", + "idle_never": "已是最新狀態", + "syncing": "正在同步:已完成 {done} 個,還剩 {pending} 個", + "paused": "已暫停", + "offline": "離線。{count, plural, =0 {變更正在等待伺服器} other {# 項變更正在等待伺服器}}", + "error": "已停止:{reason}", + "confirm_delete": "已暫停,請先回答下方的問題", + "blocked": "已被您的組織關閉" + }, + "mass_delete": { + "title": "要刪除 {count, plural, other {# 個項目}}嗎?", + "local": "已從這部電腦上的 {folder} 中刪除 {count, plural, other {# 個項目}}。也要在檔案中刪除嗎?", + "remote": "已從檔案中的 {folder} 刪除 {count, plural, other {# 個項目}}。也要在這部電腦上刪除嗎?", + "allow": "{count, plural, other {刪除}}", + "restore": "{count, plural, other {復原}}", + "later": "稍後決定" + }, + "activity": { + "title": "最近活動", + "empty": "目前還沒有任何活動。", + "clear": "清除", + "to_files": "到檔案", + "to_computer": "到這部電腦", + "mark_conflict": "衝突", + "mark_error": "錯誤", + "mark_question": "需要您回答", + "uploaded": "已上傳 {path}", + "downloaded": "已下載 {path}", + "folder-created": "已建立資料夾 {path}", + "renamed": "已將 {detail} 重新命名為 {path}", + "moved": "已將 {detail} 移至 {path}", + "deleted": "已刪除 {path}", + "trashed": "已將 {path} 移至垃圾桶", + "conflict": "{path} 在兩處都被變更。另一個版本已保留為 {detail}", + "renamed-for-disk": "{path} 的名稱在此處不允許使用,因此在這部電腦上名為 {detail}", + "too-large": "{path} 超出了檔案允許的大小({detail})。它只會保留在這部電腦上", + "waiting": "{path} 正在等待伺服器", + "mass-delete": "在刪除大量項目前已暫停", + "error": "無法同步 {path}" + }, + "errors": { + "offline": "無法連線到伺服器", + "server_refused": "伺服器拒絕了此變更", + "too_large": "此檔案超出了檔案允許的大小", + "name_invalid": "不允許使用此名稱", + "disk_full": "磁碟已滿", + "denied": "不允許", + "busy": "另一個程式正在使用它", + "quota": "您在檔案中的儲存空間已滿", + "not_found": "它已不存在", + "policy": "您的組織不允許此操作", + "failed": "發生錯誤" + }, + "item": { + "label": "在這部電腦上", + "synced": "已同步", + "syncing": "正在同步", + "pending": "等待同步", + "error": "無法同步", + "online-only": "僅在檔案中,開啟時下載" + }, + "show_in": { + "explorer": "在檔案總管中顯示", + "finder": "在 Finder 中顯示", + "file_manager": "在檔案管理員中顯示", + "failed": "無法開啟資料夾。" + } + }, "desktopQuickCompose": { "label": "快速撰寫", "menu": "快速撰寫", @@ -7665,6 +7776,14 @@ "types_character": "這些鍵在您的鍵盤上會輸入一個字元,占用後該字元在所有應用程式中都無法輸入。請試試其他按鍵。", "reserved": "系統或大多數應用程式正在使用這些鍵。請試試其他按鍵。" }, + "desktopAttachRecent": { + "heading": "最近的檔案", + "browse": "瀏覽這部電腦…", + "from_files": "從檔案…", + "missing": "「{name}」已不存在。", + "too_large": "「{name}」超過 {max},無法附加。", + "failed": "無法附加「{name}」。請再試一次。" + }, "compose_windows": { "more_formatting": "更多格式", "offline": "離線", @@ -7770,5 +7889,9 @@ "unread_badge_description": "這樣您在其他應用程式中工作時也能看到新郵件。", "tip_compose_keys": "按 {keys} 撰寫新郵件", "tip_palette_keys": "按 {keys} 尋找任何指令" + }, + "selectionView": { + "starred": "已加星號", + "unstarred": "已移除星號" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index f386804ab..bf5673390 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5905,6 +5905,11 @@ "cancelled": "{name} 取消了“{title}”", "untitled": "无标题活动" }, + "calendar_month_select": { + "selected_range": "已选择 {start} 至 {end}。", + "selected_day": "已选择 {day}。", + "enter_hint": "按 Enter 键创建活动。" + }, "company_calendars": { "section": "组织" }, @@ -7650,6 +7655,112 @@ "signout_changed": "尚未附加或保存,现在将删除:{names}。", "signout_held": "仍在其他程序中打开,因此要在那里关闭后才会删除:{names}。" }, + "files_sync": { + "title": "此电脑上的文件", + "drive": { + "title": "文件驱动器", + "label": "将文件显示为驱动器", + "description": "你的文件会像驱动器一样显示在文件资源管理器中。文件在打开时才会下载。", + "unsupported": "需要 Windows 10 版本 1809 或更高版本。", + "root_name": "文件驱动器", + "letter_label": "驱动器号", + "letter_description": "在文件资源管理器中为驱动器分配专用的驱动器号。", + "letter_none": "无驱动器号" + }, + "folders": { + "title": "同步的文件夹", + "description": "此电脑上的一个文件夹和文件中的一个文件夹会双向保持一致。", + "empty": "还没有同步任何文件夹。", + "forced_off": "你的组织已关闭文件夹同步。其中的文件会保留在此电脑上。", + "add": "添加文件夹…", + "loading": "正在加载你的文件夹…", + "load_failed": "无法加载你在文件中的文件夹。请重试。", + "pick_title": "与文件中的哪个文件夹同步?", + "pick_confirm": "选择此电脑上的文件夹…", + "paired_with": "与文件中的 {folder} 同步", + "pause": "暂停", + "resume": "继续", + "remove": "移除…", + "remove_title": "停止同步此文件夹?", + "remove_message": "此电脑上的文件和文件中的文件都会保持原样。更改将不再在两者之间同步。", + "remove_confirm": "停止同步", + "add_failed": { + "overlap": "此文件夹位于已同步的文件夹中、包含已同步的文件夹,或者是系统文件夹。请选择其他文件夹。", + "denied": "应用无法使用此文件夹。请选择其他文件夹。", + "policy": "你的组织不允许此操作。", + "failed": "无法添加文件夹。请重试。" + } + }, + "status": { + "starting": "正在启动…", + "idle": "已是最新。上次同步于 {time}", + "idle_never": "已是最新", + "syncing": "正在同步:已完成 {done} 个,还剩 {pending} 个", + "paused": "已暂停", + "offline": "离线。{count, plural, =0 {更改正在等待服务器} other {# 项更改正在等待服务器}}", + "error": "已停止:{reason}", + "confirm_delete": "已暂停,请先回答下面的问题", + "blocked": "已被你的组织关闭" + }, + "mass_delete": { + "title": "删除 {count, plural, other {# 个项目}}?", + "local": "已从此电脑上的 {folder} 中删除 {count, plural, other {# 个项目}}。也要在文件中删除吗?", + "remote": "已从文件中的 {folder} 删除 {count, plural, other {# 个项目}}。也要在此电脑上删除吗?", + "allow": "{count, plural, other {删除}}", + "restore": "{count, plural, other {恢复}}", + "later": "稍后决定" + }, + "activity": { + "title": "最近活动", + "empty": "还没有任何活动。", + "clear": "清除", + "to_files": "到文件", + "to_computer": "到此电脑", + "mark_conflict": "冲突", + "mark_error": "错误", + "mark_question": "需要你回答", + "uploaded": "已上传 {path}", + "downloaded": "已下载 {path}", + "folder-created": "已创建文件夹 {path}", + "renamed": "已将 {detail} 重命名为 {path}", + "moved": "已将 {detail} 移动到 {path}", + "deleted": "已删除 {path}", + "trashed": "已将 {path} 移到回收站", + "conflict": "{path} 在两处都被更改。另一个版本已保留为 {detail}", + "renamed-for-disk": "{path} 的名称在此处不允许使用,因此在此电脑上名为 {detail}", + "too-large": "{path} 超出了文件允许的大小({detail})。它只保留在此电脑上", + "waiting": "{path} 正在等待服务器", + "mass-delete": "在删除大量项目前已暂停", + "error": "无法同步 {path}" + }, + "errors": { + "offline": "无法连接到服务器", + "server_refused": "服务器拒绝了此更改", + "too_large": "该文件超出了文件允许的大小", + "name_invalid": "不允许使用此名称", + "disk_full": "磁盘已满", + "denied": "不允许", + "busy": "另一个程序正在使用它", + "quota": "你在文件中的存储空间已满", + "not_found": "它已不存在", + "policy": "你的组织不允许此操作", + "failed": "出了点问题" + }, + "item": { + "label": "在此电脑上", + "synced": "已同步", + "syncing": "正在同步", + "pending": "等待同步", + "error": "无法同步", + "online-only": "仅在文件中,打开时下载" + }, + "show_in": { + "explorer": "在文件资源管理器中显示", + "finder": "在 Finder 中显示", + "file_manager": "在文件管理器中显示", + "failed": "无法打开文件夹。" + } + }, "desktopQuickCompose": { "label": "快速撰写", "menu": "快速撰写", @@ -7665,6 +7776,14 @@ "types_character": "这些键在您的键盘上会输入一个字符,占用后该字符在所有应用中都无法输入。请试试其他按键。", "reserved": "系统或大多数应用在使用这些键。请试试其他按键。" }, + "desktopAttachRecent": { + "heading": "最近的文件", + "browse": "浏览此电脑…", + "from_files": "从文件…", + "missing": "“{name}”已不存在。", + "too_large": "“{name}”超过 {max},无法添加为附件。", + "failed": "无法将“{name}”添加为附件。请重试。" + }, "compose_windows": { "more_formatting": "更多格式", "offline": "离线", @@ -7770,5 +7889,9 @@ "unread_badge_description": "这样您在其他应用中工作时也能看到新邮件。", "tip_compose_keys": "按 {keys} 写新邮件", "tip_palette_keys": "按 {keys} 查找任何命令" + }, + "selectionView": { + "starred": "已加星标", + "unstarred": "已取消星标" } } diff --git a/stores/__tests__/email-store-plugin-organize-hooks.test.ts b/stores/__tests__/email-store-plugin-organize-hooks.test.ts index 2cdf1fdb4..5b8176328 100644 --- a/stores/__tests__/email-store-plugin-organize-hooks.test.ts +++ b/stores/__tests__/email-store-plugin-organize-hooks.test.ts @@ -373,6 +373,37 @@ describe('read, star and keyword hooks', () => { expect(after).toHaveBeenCalledWith({ emailIds: ['e1'], starred: true }); }); + it('stars a selection in one write, asks once and reports once', async () => { + openFolder('inbox', [makeEmail('a'), makeEmail('b', { keywords: { $seen: true, $flagged: true } }), makeEmail('c')]); + useEmailStore.setState({ selectedEmailKeys: new Set(['a', 'b', 'c']) }); + const before = vi.fn(() => true); + const after = vi.fn(); + emailHooks.onBeforeEmailStarToggle.register('t', before); + emailHooks.onEmailStarToggle.register('t', after); + + expect(await useEmailStore.getState().batchSetStarred(client, true)).toBe(true); + + // b has its star already: left alone. + expect(client.batchUpdateKeywords).toHaveBeenCalledTimes(1); + expect(client.batchUpdateKeywords).toHaveBeenCalledWith(['a', 'c'], { 'keywords/$flagged': true }, undefined); + expect(before).toHaveBeenCalledWith({ emailIds: ['a', 'c'], starred: true }); + expect(after).toHaveBeenCalledWith({ emailIds: ['a', 'c'], starred: true }); + expect(useEmailStore.getState().emails.every((e) => e.keywords.$flagged)).toBe(true); + + await useEmailStore.getState().batchSetStarred(client, false); + expect(client.batchUpdateKeywords).toHaveBeenLastCalledWith(['a', 'b', 'c'], { 'keywords/$flagged': null }, undefined); + expect(useEmailStore.getState().emails.some((e) => e.keywords.$flagged)).toBe(false); + }); + + it('writes nothing for a selection a plugin stops', async () => { + openFolder('inbox', [makeEmail('a'), makeEmail('b')]); + useEmailStore.setState({ selectedEmailKeys: new Set(['a', 'b']) }); + emailHooks.onBeforeEmailStarToggle.register('t', () => false); + await useEmailStore.getState().batchSetStarred(client, true); + expect(client.batchUpdateKeywords).not.toHaveBeenCalled(); + expect(useEmailStore.getState().emails.some((e) => e.keywords.$flagged)).toBe(false); + }); + it('names a group mailbox\'s account on a star toggle', async () => { const groupInbox = makeMailbox({ id: 'vertrieb:inbox', originalId: 'inbox', role: 'inbox', isShared: true, accountId: 'vertrieb' }); openFolder('vertrieb:inbox', [makeEmail('e1', { mailboxIds: { 'vertrieb:inbox': true } })]); diff --git a/stores/email-store.ts b/stores/email-store.ts index 69720d6a4..4ff8ab42c 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -600,6 +600,12 @@ interface EmailStore { */ /** `target`: these messages (target.emails) instead of the selection. */ batchSetTag: (client: IJMAPClient, tagId: string, add: boolean, target?: BatchActionTarget) => Promise; + /** + * Star every selected message, or take the star off every one. Written per account, + * as toggleStar writes one; the star hooks hear each account's messages. Rejects when + * a write fails. + */ + batchSetStarred: (client: IJMAPClient, starred: boolean) => Promise; batchDelete:(client: IJMAPClient, permanent?: boolean) => Promise; /** * Acts on the selection, or on `target`'s messages when given; with a @@ -6039,6 +6045,60 @@ export const useEmailStore = create((set, get) => ({ return true; }, + batchSetStarred: async (client, starred): Promise => { + if (get().selectedEmailKeys.size === 0) return true; + // By the account each message lives in, as toggleStar routes one. + const groups = new Map>(); + for (const email of resolveSelectedEmails(get())) { + if (!!email.keywords?.$flagged === starred) continue; + const { client: actionClient, accountId } = resolveEmailActionContext(email, client); + const byAccount = groups.get(actionClient) ?? new Map(); + groups.set(actionClient, byAccount); + byAccount.set(accountId, [...(byAccount.get(accountId) ?? []), email]); + } + if (groups.size === 0) return true; + + const writes = [...groups].flatMap(([actionClient, byAccount]) => + [...byAccount].map(async ([accountId, group]) => { + const hookAccountId = accountId && accountId !== actionClient.getAccountId?.() ? accountId : undefined; + const payload = { emailIds: group.map(e => e.id), starred, ...(hookAccountId ? { accountId: hookAccountId } : {}) }; + if (emailHooks.onBeforeEmailStarToggle.size > 0 && !(await emailHooks.onBeforeEmailStarToggle.intercept(payload))) return []; + await actionClient.batchUpdateKeywords(payload.emailIds, { [keywordPointer('$flagged')]: starred ? true : null }, accountId); + void emailHooks.onEmailStarToggle.emit(payload); + return group; + }), + ); + const settled = await Promise.allSettled(writes); + const written = new Set(settled.flatMap(result => result.status === 'fulfilled' ? result.value.map(emailKeyFor) : [])); + const restar = (keywords: Record | undefined) => { + const next = { ...keywords }; + if (starred) next.$flagged = true; + else delete next.$flagged; + return next; + }; + set((state) => { + let retained = state.retainedInViewIds; + // In the starred cross view an un-starred row stays until the view is opened again. + if (retainingCrossView(state) === 'starred') { + for (const e of state.emails) { + if (written.has(emailKeyFor(e))) retained = updateRetainedForView(retained, starred ? 'remove' : 'add', e.id); + } + } + return { + emails: state.emails.map(e => written.has(emailKeyFor(e)) ? { ...e, keywords: restar(e.keywords) } : e), + selectedEmail: state.selectedEmail && written.has(emailKeyFor(state.selectedEmail)) + ? { ...state.selectedEmail, keywords: restar(state.selectedEmail.keywords) } + : state.selectedEmail, + retainedInViewIds: retained, + }; + }); + if (written.size > 0) refillAfterKeywordChange(get, client, ['$flagged']); + + const failed = settled.find((result): result is PromiseRejectedResult => result.status === 'rejected'); + if (failed) throw failed.reason; + return true; + }, + batchDelete: async (client, permanent = false): Promise => { const { selectedEmailKeys, emails, selectedMailbox } = get(); const mailboxes = resolveActionMailboxes(); diff --git a/stores/files-sync-store.ts b/stores/files-sync-store.ts new file mode 100644 index 000000000..a6f1af6af --- /dev/null +++ b/stores/files-sync-store.ts @@ -0,0 +1,35 @@ +import { create } from 'zustand'; +import type { FilesSyncController, FilesSyncSnapshot } from '@/lib/files-sync/types'; + +/** + * Files on this computer, for the UI: the engine (lib/files-sync) writes the snapshot + * and registers its controller; the settings page and the Files screen read it and + * call the controller. Nothing here touches the bridge. + */ +interface FilesSyncStore extends FilesSyncSnapshot { + controller: FilesSyncController | null; + /** The engine's: replaces what it says changed. */ + publish: (patch: Partial) => void; + setController: (controller: FilesSyncController | null) => void; + /** Back to nothing: signed out, or the engine stopped. */ + reset: () => void; +} + +const EMPTY: FilesSyncSnapshot = { + available: false, + account: null, + roots: [], + status: {}, + activity: [], + drive: null, + folderSyncForcedOff: false, + items: {}, +}; + +export const useFilesSyncStore = create((set) => ({ + ...EMPTY, + controller: null, + publish: (patch) => set(patch), + setController: (controller) => set({ controller }), + reset: () => set({ ...EMPTY, controller: null }), +}));