fix: keep file sizes as number then unit in right-to-left languages and write Files dates in the app's language

This commit is contained in:
Linus Rath
2026-10-28 10:24:55 +01:00
parent 55fb5acef5
commit d1e0fddfdc
5 changed files with 61 additions and 15 deletions
@@ -0,0 +1,27 @@
import { render } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
let appLocale = 'ar';
vi.mock('@/i18n/detect-locale', () => ({ getEffectiveLocale: () => appLocale }));
import { FileSize, formatFullDate, formatShortDate } from '../file-item-parts';
// The browser here is en-US; the app's language must win.
describe('Files list dates and sizes', () => {
afterEach(() => { appLocale = 'ar'; });
it('writes dates in the app language, not the browser one', () => {
const now = new Date('2026-10-27T12:00:00Z');
expect(formatShortDate('2026-03-05T10:00:00Z', now)).toMatch(/مارس/);
expect(formatFullDate('2026-03-05T10:00:00Z')).toMatch(/مارس/);
appLocale = 'de';
expect(formatShortDate('2026-03-05T10:00:00Z', now)).toMatch(/März/);
});
it('keeps the number before the unit in a right-to-left layout', () => {
const { container } = render(<div dir="rtl"><FileSize bytes={6861} /></div>);
const size = container.querySelector('bdi')!;
expect(size).toHaveAttribute('dir', 'ltr');
expect(size.textContent).toBe('6.7 KB');
});
});
+5 -5
View File
@@ -4,12 +4,12 @@ import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { X, Download, Share2, Star, StarOff, FolderOpen, Eye, SquarePen, Files } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { cn, formatFileSize } from "@/lib/utils";
import { cn } from "@/lib/utils";
import type { FileResource } from "@/stores/file-store";
import type { FileResourceView } from "@/lib/plugin-types";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import {
ResourceIcon, Thumbnail, canShowThumbnail, formatFullDate, type IconOptions,
FileSize, ResourceIcon, Thumbnail, canShowThumbnail, formatFullDate, type IconOptions,
} from "@/components/files/file-item-parts";
interface FileDetailsPanelProps {
@@ -91,7 +91,7 @@ export function FileDetailsPanel({
<p className="text-sm text-muted-foreground">{t("folder_summary", { folders, files })}</p>
</div>
<dl className="space-y-3 text-sm">
<Row label={t("size")} value={formatFileSize(totalSize(items))} />
<Row label={t("size")} value={<FileSize bytes={totalSize(items)} />} />
{selection.length <= 1 && <Row label={t("path")} value={path} mono />}
</dl>
{selection.length <= 1 && (
@@ -160,7 +160,7 @@ export function FileDetailsPanel({
label={t("type")}
value={resource.isDirectory || resource.isSymlink ? kindOf(resource, t) : (resource.contentType || kindOf(resource, t))}
/>
{!resource.isDirectory && !resource.isSymlink && <Row label={t("size")} value={formatFileSize(resource.contentLength)} />}
{!resource.isDirectory && !resource.isSymlink && <Row label={t("size")} value={<FileSize bytes={resource.contentLength} />} />}
{resource.lastModified && <Row label={t("modified")} value={formatFullDate(resource.lastModified)} />}
<Row label={t("sharing")} value={sharing} />
<Row label={t("path")} value={path} mono />
@@ -170,7 +170,7 @@ export function FileDetailsPanel({
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
function Row({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
return (
<div>
<dt className="text-xs text-muted-foreground">{label}</dt>
+23 -4
View File
@@ -3,8 +3,10 @@
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Share2, Users } from "@/components/icons";
import { cn } from "@/lib/utils";
import { cn, formatFileSize } from "@/lib/utils";
import { getEffectiveTimeZone } from "@/lib/timezone";
import { getEffectiveLocale } from "@/i18n/detect-locale";
import { useSettingsStore } from "@/stores/settings-store";
import type { FileResource } from "@/stores/file-store";
import { getFileIconByName, getFolderIcon, getSymlinkIcon, isImageFile, type FileIconSize } from "@/components/files/file-icons";
@@ -124,17 +126,33 @@ export function ShareBadge({ resource, t, className }: {
return null;
}
/**
* A size that keeps its number before its unit ("6.7 KB") in a right-to-left
* layout too, where the bidi algorithm would otherwise show "KB 6.7".
*/
export function FileSize({ bytes }: { bytes: number }) {
return <bdi dir="ltr">{formatFileSize(bytes)}</bdi>;
}
/** Dates follow the app's language and 12/24-hour setting, as Mail's list does, not the browser's. */
function dateLocale(): { locale: string; hour12: boolean } {
const locale = getEffectiveLocale();
return { locale: locale === "en" ? "en-US" : locale, hour12: useSettingsStore.getState().timeFormat === "12h" };
}
/** Full date and time, for tooltips and the details panel. */
export function formatFullDate(value: string): string {
if (!value) return "";
try {
return new Date(value).toLocaleString(undefined, {
const { locale, hour12 } = dateLocale();
return new Date(value).toLocaleString(locale, {
timeZone: getEffectiveTimeZone(),
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12,
});
} catch {
return value;
@@ -149,12 +167,13 @@ export function formatShortDate(value: string, now = new Date()): string {
const timeZone = getEffectiveTimeZone();
const dayKey = (d: Date) => d.toLocaleDateString("en-CA", { timeZone });
try {
const { locale, hour12 } = dateLocale();
if (dayKey(date) === dayKey(now)) {
return date.toLocaleTimeString(undefined, { timeZone, hour: "2-digit", minute: "2-digit" });
return date.toLocaleTimeString(locale, { timeZone, hour: "2-digit", minute: "2-digit", hour12 });
}
const sameYear = date.toLocaleDateString("en-CA", { timeZone, year: "numeric" })
=== now.toLocaleDateString("en-CA", { timeZone, year: "numeric" });
return date.toLocaleDateString(undefined, {
return date.toLocaleDateString(locale, {
timeZone,
day: "numeric",
month: "short",
+1 -1
View File
@@ -175,7 +175,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
<span className="block truncate">{node.name}</span>
{detail && <span className="block truncate text-xs text-muted-foreground">{detail}</span>}
</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{formatFileSize(node.size)}</span>
<bdi dir="ltr" className="shrink-0 text-xs text-muted-foreground tabular-nums">{formatFileSize(node.size)}</bdi>
</button>
);
};
+5 -5
View File
@@ -3,11 +3,11 @@
import { useRef } from "react";
import { useTranslations } from "next-intl";
import { ArrowUp, ArrowDown, MoreVertical, Download, Share2, Star, Check } from "@/components/icons";
import { cn, formatFileSize } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { IconButton } from "@/components/ui/icon-button";
import type { FileResource } from "@/stores/file-store";
import {
ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate,
FileSize, ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate,
type IconOptions,
} from "@/components/files/file-item-parts";
import type { SortDir, SortKey } from "@/components/files/files-header";
@@ -271,7 +271,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
<div className="text-xs text-muted-foreground truncate">
{note && <>{note} · </>}
{formatShortDate(resource.lastModified)}
{!resource.isDirectory && !resource.isSymlink && <> · {formatFileSize(resource.contentLength)}</>}
{!resource.isDirectory && !resource.isSymlink && <> · <FileSize bytes={resource.contentLength} /></>}
</div>
)}
</div>
@@ -280,7 +280,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
{!touch && formatShortDate(resource.lastModified)}
</div>
<div role="gridcell" className="hidden sm:block text-muted-foreground tabular-nums text-end">
{!touch && (resource.isDirectory || resource.isSymlink ? "—" : formatFileSize(resource.contentLength))}
{!touch && (resource.isDirectory || resource.isSymlink ? "—" : <FileSize bytes={resource.contentLength} />)}
</div>
<div role="gridcell" className="flex items-center justify-end gap-0.5 w-[4.75rem]">
{!touch && !resource.isDirectory && !resource.isSymlink && (
@@ -482,7 +482,7 @@ function FileTile({ resource, isSelected, isFocused, isCut, selecting, touch, ic
</button>
<div className="flex items-center gap-1 text-xs text-muted-foreground min-w-0">
<span className="truncate">
{resource.isSymlink ? "" : `${formatFileSize(resource.contentLength)} · `}{formatShortDate(resource.lastModified)}
{!resource.isSymlink && <><FileSize bytes={resource.contentLength} /> · </>}{formatShortDate(resource.lastModified)}
</span>
<ShareBadge resource={resource} t={t} className="w-3 h-3" />
</div>