feat: add complete French localization (#1482)
* feat: add French locale runtime Add French to OpenChamber's shared i18n runtime, dictionaries, and parity tests so the existing language picker can load a complete fr locale across shared UI surfaces. * fix: localize shared UI formatting Remove remaining shared UI locale hardcodings so dates, numbers, and first-party helper copy follow the active app locale instead of leaking English on French surfaces. * feat: localize VS Code French surfaces Localize VS Code bootstrap, native runtime messages, panel titles, and manifest contribution strings so French users get consistent first-party copy across the extension experience. * fix: TASK-2026-05-30-008 correct French review findings Fix broken French relative-time and weekday strings reported on PR #1482 and restore proper import order in quota utils without broadening scope. * fix: TASK-2026-05-30-008 address final PR review comments Capture the localized More Info label once in the VS Code CLI-missing flow and replace the remaining inline French-only utility strings with dictionary-driven copy plus required locale keys. * fix: TASK-2026-05-30-008 normalize French glossary Correct glossary-level French terminology on the live PR branch, keeping canonical technical terms like PR, worktree, stash, HEAD, Mermaid, Markdown, remote, and session while replacing misleading literal translations. * fix: TASK-2026-05-30-008 refine French terminology pass Clean up remaining glossary mistakes on the French PR branch, especially around Mermaid, Markdown, PR, worktree, stash, branch, remote, and commit terminology, while keeping behavior unchanged. * fix: TASK-2026-05-30-008 clean remaining French false friends Correct the SOCKS5 mistranslation and a final small set of obvious false-friend technical nouns on the French branch without changing behavior. * fix: TASK-2026-05-30-008 correct French glossary terms Replace remaining false-friend translations in the French UI dictionaries and normalize technical labels for the French PR branch. * fix: TASK-2026-05-30-008 remove remaining French Mermaid false friend Replace the last confirmed Sirène translation with Mermaid and re-run the requested blacklist and build verification on the PR branch. * fix: TASK-2026-05-30-008 enforce French glossary policy Keep skill/PR/worktree/remote terminology developer-credible in French and remove remaining machine-translated Git and settings copy. * fix: TASK-2026-05-30-008 keep prompt terminology in French Replace remaining technical invite translations with prompt wording across scheduled tasks, multi-run, prompt templates, and Magic Prompts. * fix: TASK-2026-05-30-008 finalize French terminology cleanup Polish remaining worktree/remote wording, remove visible metadata leakage, and correct final Git and settings labels on the French PR branch. * fix: TASK-2026-05-30-008 polish final French strings Correct the last aria-like artifacts and awkward worktree/remote/GitHub URL phrasing in the French dictionaries. * fix: TASK-2026-05-30-008 normalize final French glossary framing Tighten the last worktree/remote/checkout wording and fix remaining French grammar around canonical technical terms. * fix: TASK-2026-05-30-008 align final developer glossary wording Normalize the last French framing around canonical developer terms like worktree, remote, prompt, and checkout. * fix: TASK-2026-05-30-008 harmonize final French sentence framing Replace the last raw franglais around checkout, remote, worktree, and prompt-facing labels with more natural French framing while keeping the chosen technical terms. * fix: TASK-2026-05-30-008 add compact relative date keys Replace French-specific prefix stripping in compact session date labels with dedicated i18n keys across locale dictionaries, preserving existing compact label output while making French wording robust. * docs: add French documentation * docs: mention French locale folder --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
85bf7c7563
commit
49a1424e5f
@@ -36,7 +36,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useModelLists } from '@/hooks/useModelLists';
|
||||
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
|
||||
import { formatEffortLabel, getCycledPrimaryAgentName, type MobileControlsPanel } from './mobileControlsUtils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness';
|
||||
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
|
||||
import { markStartupTrace } from '@/lib/startupTrace';
|
||||
@@ -164,27 +164,27 @@ const getModalityIcons = (metadata: ModelMetadata | undefined, direction: 'input
|
||||
return result;
|
||||
};
|
||||
|
||||
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
|
||||
const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: 0,
|
||||
});
|
||||
}).format(value);
|
||||
|
||||
const CURRENCY_FORMATTER = new Intl.NumberFormat('en-US', {
|
||||
const formatUsdCurrency = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 4,
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
}).format(value);
|
||||
|
||||
const KNOWLEDGE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric' });
|
||||
const formatKnowledgeDate = (value: Date) => new Intl.DateTimeFormat(getCurrentIntlLocale(), { month: 'short', year: 'numeric' }).format(value);
|
||||
|
||||
const DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
|
||||
const formatReleaseDate = (value: Date) => new Intl.DateTimeFormat(getCurrentIntlLocale(), {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}).format(value);
|
||||
|
||||
const ADD_PROVIDER_ID = '__add_provider__';
|
||||
|
||||
@@ -226,7 +226,7 @@ const formatTokens = (value?: number | null) => {
|
||||
return '0';
|
||||
}
|
||||
|
||||
const formatted = COMPACT_NUMBER_FORMATTER.format(value);
|
||||
const formatted = formatCompactNumber(value);
|
||||
return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted;
|
||||
};
|
||||
|
||||
@@ -235,7 +235,7 @@ const formatCost = (value?: number | null) => {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return CURRENCY_FORMATTER.format(value);
|
||||
return formatUsdCurrency(value);
|
||||
};
|
||||
|
||||
const getCapabilityIcons = (metadata?: ModelMetadata) => {
|
||||
@@ -259,7 +259,7 @@ const formatKnowledge = (knowledge?: string) => {
|
||||
const monthIndex = Number.parseInt(match[2], 10) - 1;
|
||||
const knowledgeDate = new Date(Date.UTC(year, monthIndex, 1));
|
||||
if (!Number.isNaN(knowledgeDate.getTime())) {
|
||||
return KNOWLEDGE_DATE_FORMATTER.format(knowledgeDate);
|
||||
return formatKnowledgeDate(knowledgeDate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ const formatDate = (value?: string) => {
|
||||
return value;
|
||||
}
|
||||
|
||||
return DATE_FORMATTER.format(parsedDate);
|
||||
return formatReleaseDate(parsedDate);
|
||||
};
|
||||
|
||||
interface ModelControlsProps {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useSessionMessageRecords } from '@/sync/sync-context';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -56,7 +56,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
if (diffMins < 60) return t('chat.timeline.relative.minutesAgo', { count: diffMins });
|
||||
if (diffHours < 24) return t('chat.timeline.relative.hoursAgo', { count: diffHours });
|
||||
if (diffDays < 7) return t('chat.timeline.relative.daysAgo', { count: diffDays });
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
return new Date(timestamp).toLocaleDateString(getCurrentIntlLocale());
|
||||
}, [t]);
|
||||
|
||||
// Timeline actions are only valid for user messages.
|
||||
|
||||
@@ -969,7 +969,7 @@ const AssistantMessageBody = React.memo(({
|
||||
errorVariant = 'error',
|
||||
reviewTransferDirection = null,
|
||||
}: Omit<MessageBodyProps, 'isUser'>) => {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const chatSurfaceMode = useChatSurfaceMode();
|
||||
const streamPhase = _streamPhase;
|
||||
void _allowAnimation;
|
||||
@@ -1879,6 +1879,7 @@ const AssistantMessageBody = React.memo(({
|
||||
}, [isLastAssistantInTurn, hasStopFinish, turnGroupingContext?.userMessageCreatedAt, messageCompletedAt]);
|
||||
|
||||
const footerTimestamp = React.useMemo(() => {
|
||||
void locale;
|
||||
const timestamp = typeof messageCompletedAt === 'number' && messageCompletedAt > 0
|
||||
? messageCompletedAt
|
||||
: (typeof messageCreatedAt === 'number' && messageCreatedAt > 0 ? messageCreatedAt : null);
|
||||
@@ -1886,7 +1887,7 @@ const AssistantMessageBody = React.memo(({
|
||||
|
||||
const formatted = formatTimestampForDisplay(timestamp, timeFormatPreference);
|
||||
return formatted.length > 0 ? formatted : null;
|
||||
}, [messageCompletedAt, messageCreatedAt, timeFormatPreference]);
|
||||
}, [messageCompletedAt, messageCreatedAt, timeFormatPreference, locale]);
|
||||
|
||||
const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1';
|
||||
const canOpenMessagePreview = !isMiniChatSurface && !isMobile && !isVSCode;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getCurrentIntlLocale } from '@/lib/i18n';
|
||||
import { formatMessage, useI18nStore } from '@/lib/i18n/store';
|
||||
import { formatTimeForPreference } from '@/lib/timeFormat';
|
||||
import type { TimeFormatPreference } from '@/stores/useUIStore';
|
||||
|
||||
@@ -26,18 +28,19 @@ export const formatTimestampForDisplay = (timestamp: number, timeFormatPreferenc
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
|
||||
const timePart = formatTimeForPreference(date, timeFormatPreference);
|
||||
const locale = getCurrentIntlLocale();
|
||||
const dictionary = useI18nStore.getState().dictionary;
|
||||
|
||||
if (isSameDay(date, now)) {
|
||||
return timePart;
|
||||
}
|
||||
|
||||
if (isYesterday(date, now)) {
|
||||
return `Yesterday ${timePart}`;
|
||||
return formatMessage(dictionary, 'common.date.yesterdayWithTime', { time: timePart });
|
||||
}
|
||||
|
||||
const monthPart = date.toLocaleString(undefined, { month: 'short' });
|
||||
const monthPart = date.toLocaleString(locale, { month: 'short' });
|
||||
const dayPart = date.getDate();
|
||||
const datePart = `${monthPart} ${dayPart}`;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessions, useSessionMessageRecords } from '@/sync/sync-context';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { formatDateTimeForPreference } from '@/lib/timeFormat';
|
||||
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
||||
|
||||
@@ -223,12 +223,16 @@ const computeContextBreakdown = (
|
||||
};
|
||||
};
|
||||
|
||||
const formatNumber = (value: number): string => value.toLocaleString();
|
||||
const formatNumber = (value: number): string => value.toLocaleString(getCurrentIntlLocale());
|
||||
|
||||
const formatMoney = (value: number): string => {
|
||||
if (!Number.isFinite(value) || value <= 0) return '$0.00';
|
||||
if (value < 0.01) return `$${value.toFixed(4)}`;
|
||||
return `$${value.toFixed(2)}`;
|
||||
if (!Number.isFinite(value) || value <= 0) return new Intl.NumberFormat(getCurrentIntlLocale(), { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(0);
|
||||
return new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: value < 0.01 ? 4 : 2,
|
||||
maximumFractionDigits: value < 0.01 ? 4 : 2,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatDateTime = (timestamp: number | null, timeFormatPreference: TimeFormatPreference): string => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { getCurrentIntlLocale } from '@/lib/i18n';
|
||||
import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata';
|
||||
import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDisplay';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -44,19 +45,19 @@ type IndexSelectionStore = {
|
||||
set: (value: number) => void;
|
||||
};
|
||||
|
||||
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
|
||||
const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: 0,
|
||||
});
|
||||
}).format(value);
|
||||
|
||||
const CURRENCY_FORMATTER = new Intl.NumberFormat('en-US', {
|
||||
const formatUsdCurrency = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 4,
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
}).format(value);
|
||||
|
||||
const getModelDisplayName = (model: Record<string, unknown>) => {
|
||||
return getSharedModelDisplayName(model, undefined, { maxLength: 40 });
|
||||
@@ -65,13 +66,13 @@ const getModelDisplayName = (model: Record<string, unknown>) => {
|
||||
const formatModelContextTokens = (value?: number | null) => {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return '';
|
||||
if (value === 0) return '0';
|
||||
const formatted = COMPACT_NUMBER_FORMATTER.format(value);
|
||||
const formatted = formatCompactNumber(value);
|
||||
return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted;
|
||||
};
|
||||
|
||||
const formatCost = (value?: number | null) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '—';
|
||||
return CURRENCY_FORMATTER.format(value);
|
||||
return formatUsdCurrency(value);
|
||||
};
|
||||
|
||||
const hasTooltipMetadata = (metadata?: ModelMetadata) => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type PasskeyStatus,
|
||||
type StoredPasskey,
|
||||
} from '@/lib/passkeys';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
||||
|
||||
const formatTimestamp = (timestamp: number | null, neverUsedText: string, timeFormatPreference: TimeFormatPreference) => {
|
||||
@@ -22,7 +22,7 @@ const formatTimestamp = (timestamp: number | null, neverUsedText: string, timeFo
|
||||
return neverUsedText;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
return new Intl.DateTimeFormat(getCurrentIntlLocale(), {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
hour12: timeFormatPreference === 'auto' ? undefined : timeFormatPreference === '12h',
|
||||
|
||||
@@ -20,16 +20,16 @@ import { cn } from '@/lib/utils';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import type { ModelMetadata } from '@/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
|
||||
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
|
||||
const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: 0,
|
||||
});
|
||||
}).format(value);
|
||||
|
||||
const formatTokens = (value?: number | null) => {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
@@ -38,7 +38,7 @@ const formatTokens = (value?: number | null) => {
|
||||
if (value === 0) {
|
||||
return '0';
|
||||
}
|
||||
const formatted = COMPACT_NUMBER_FORMATTER.format(value);
|
||||
const formatted = formatCompactNumber(value);
|
||||
return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted;
|
||||
};
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ import { useInputStore } from '@/sync/input-store';
|
||||
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { TodoSendDialog, type TodoSendExecution } from './TodoSendDialog';
|
||||
|
||||
@@ -972,7 +972,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
>
|
||||
<span className="min-w-0 truncate typography-ui-label text-foreground">{plan.title}</span>
|
||||
<span className="flex-shrink-0 typography-micro text-muted-foreground">
|
||||
{new Date(plan.createdAt).toLocaleDateString()}
|
||||
{new Date(plan.createdAt).toLocaleDateString(getCurrentIntlLocale())}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { getCurrentIntlLocale } from '@/lib/i18n';
|
||||
import { formatMessage, useI18nStore } from '@/lib/i18n/store';
|
||||
import type { SessionSummaryMeta } from './types';
|
||||
|
||||
const t = (key: Parameters<typeof formatMessage>[1], params?: Parameters<typeof formatMessage>[2]) =>
|
||||
formatMessage(useI18nStore.getState().dictionary, key, params);
|
||||
|
||||
const formatDateLabel = (value: string | number) => {
|
||||
const targetDate = new Date(value);
|
||||
const today = new Date();
|
||||
@@ -14,12 +19,12 @@ const formatDateLabel = (value: string | number) => {
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
if (isSameDay(targetDate, today)) {
|
||||
return 'Today';
|
||||
return t('common.date.today');
|
||||
}
|
||||
if (isSameDay(targetDate, yesterday)) {
|
||||
return 'Yesterday';
|
||||
return t('common.date.yesterday');
|
||||
}
|
||||
const formatted = targetDate.toLocaleDateString('en-US', {
|
||||
const formatted = targetDate.toLocaleDateString(getCurrentIntlLocale(), {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
@@ -37,9 +42,9 @@ export const formatSessionDateLabel = (updatedMs: number): string => {
|
||||
|
||||
if (isSameDay(updatedDate, today)) {
|
||||
const diff = Date.now() - updatedMs;
|
||||
if (diff < 60_000) return 'Just now';
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}min ago`;
|
||||
return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
if (diff < 60_000) return t('common.relative.justNow');
|
||||
if (diff < 3_600_000) return t('common.relative.minutesAgoShort', { count: Math.floor(diff / 60_000) });
|
||||
return t('common.relative.hoursAgoShort', { count: Math.floor(diff / 3_600_000) });
|
||||
}
|
||||
|
||||
return formatDateLabel(updatedMs);
|
||||
@@ -62,15 +67,15 @@ export const formatSessionCompactDateLabel = (updatedMs: number): string => {
|
||||
return `${Math.floor(diff / hour)}h`;
|
||||
}
|
||||
if (diff < week) {
|
||||
return `${Math.floor(diff / day)}d`;
|
||||
return t('common.relative.daysAgoCompact', { count: Math.floor(diff / day) });
|
||||
}
|
||||
if (diff < 5 * week) {
|
||||
return `${Math.floor(diff / week)}w`;
|
||||
return t('common.relative.weeksAgoCompact', { count: Math.floor(diff / week) });
|
||||
}
|
||||
if (diff < year) {
|
||||
return `${Math.floor(diff / month)}mo`;
|
||||
}
|
||||
return `${Math.floor(diff / year)}y`;
|
||||
return t('common.relative.yearsAgoCompact', { count: Math.floor(diff / year) });
|
||||
};
|
||||
|
||||
export const normalizePath = (value?: string | null) => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { cn } from '@/lib/utils';
|
||||
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
type WebUpdateState = 'idle' | 'updating' | 'restarting' | 'reconnecting' | 'error';
|
||||
@@ -57,7 +57,7 @@ function formatIsoDateForUI(isoDate: string): string {
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return isoDate;
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
return new Intl.DateTimeFormat(getCurrentIntlLocale(), {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { FilesAPI } from '@/lib/api/types';
|
||||
import { MAX_OPEN_FILE_LINES, countLinesWithLimit } from '@/lib/fileOpenLimits';
|
||||
import { getCurrentIntlLocale } from '@/lib/i18n';
|
||||
import { formatMessage, useI18nStore } from '@/lib/i18n/store';
|
||||
|
||||
const t = (key: Parameters<typeof formatMessage>[1], params?: Parameters<typeof formatMessage>[2]) =>
|
||||
formatMessage(useI18nStore.getState().dictionary, key, params);
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
export type ContextFileOpenFailureReason = 'too-large' | 'missing' | 'unreadable';
|
||||
@@ -60,12 +65,13 @@ export const validateContextFileOpen = async (files: FilesAPI, path: string): Pr
|
||||
|
||||
export const getContextFileOpenFailureMessage = (reason: ContextFileOpenFailureReason): string => {
|
||||
if (reason === 'too-large') {
|
||||
return `File is too large to open (>${MAX_OPEN_FILE_LINES.toLocaleString()} lines)`;
|
||||
const lines = MAX_OPEN_FILE_LINES.toLocaleString(getCurrentIntlLocale());
|
||||
return t('contextFileOpen.failure.tooLarge', { count: lines });
|
||||
}
|
||||
|
||||
if (reason === 'missing') {
|
||||
return 'File not found';
|
||||
return t('contextFileOpen.failure.missing');
|
||||
}
|
||||
|
||||
return 'Failed to open file';
|
||||
return t('contextFileOpen.failure.unreadable');
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { getCurrentIntlLocale } from '@/lib/i18n';
|
||||
import { isVSCodeRuntime, openDesktopPath, revealDesktopPath, saveDesktopMarkdownFile } from '@/lib/desktop';
|
||||
import { getRevealLabelKey } from '@/lib/utils';
|
||||
|
||||
@@ -22,7 +23,7 @@ function formatTimestamp(timestamp: number | undefined): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
const monthPart = date.toLocaleString(undefined, { month: 'short' });
|
||||
const monthPart = date.toLocaleString(getCurrentIntlLocale(), { month: 'short' });
|
||||
const dayPart = date.getDate();
|
||||
const yearPart = date.getFullYear();
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { LOCALE_STORAGE_KEY, normalizeLocale, type Locale } from './runtime';
|
||||
|
||||
type BootstrapMessages = {
|
||||
startingApi: string;
|
||||
initializing: string;
|
||||
connecting: string;
|
||||
connected: string;
|
||||
connectionError: string;
|
||||
disconnected: string;
|
||||
reconnecting: string;
|
||||
initialDataLoadFailed: string;
|
||||
cliNotFound: string;
|
||||
providersReady: string;
|
||||
providersLoading: string;
|
||||
agentsReady: string;
|
||||
agentsLoading: string;
|
||||
startingDevServer: (hostLabel: string) => string;
|
||||
waitingDevServer: (hostLabel: string, attempt: number) => string;
|
||||
loadingData: (providersText: string, agentsText: string) => string;
|
||||
};
|
||||
|
||||
const EN_MESSAGES: BootstrapMessages = {
|
||||
startingApi: 'Starting OpenCode API…',
|
||||
initializing: 'Initializing…',
|
||||
connecting: 'Connecting…',
|
||||
connected: 'Connected!',
|
||||
connectionError: 'Connection error',
|
||||
disconnected: 'Disconnected',
|
||||
reconnecting: 'Reconnecting…',
|
||||
initialDataLoadFailed: 'OpenCode connected, but initial data load failed.',
|
||||
cliNotFound: 'OpenCode CLI not found. Please install it first.',
|
||||
providersReady: '✓ Providers',
|
||||
providersLoading: '… Providers',
|
||||
agentsReady: '✓ Agents',
|
||||
agentsLoading: '… Agents',
|
||||
startingDevServer: (hostLabel) => `Starting webview dev server (${hostLabel})...`,
|
||||
waitingDevServer: (hostLabel, attempt) => `Waiting for webview dev server (${hostLabel})... attempt ${attempt}`,
|
||||
loadingData: (providersText, agentsText) => `Loading data (${providersText}, ${agentsText})…`,
|
||||
};
|
||||
|
||||
const FR_MESSAGES: BootstrapMessages = {
|
||||
startingApi: 'Démarrage de l’API OpenCode…',
|
||||
initializing: 'Initialisation…',
|
||||
connecting: 'Connexion…',
|
||||
connected: 'Connecté !',
|
||||
connectionError: 'Erreur de connexion',
|
||||
disconnected: 'Déconnecté',
|
||||
reconnecting: 'Reconnexion…',
|
||||
initialDataLoadFailed: 'OpenCode est connecté, mais le chargement initial des données a échoué.',
|
||||
cliNotFound: 'L’interface en ligne de commande OpenCode est introuvable. Veuillez l’installer d’abord.',
|
||||
providersReady: '✓ Fournisseurs',
|
||||
providersLoading: '… Fournisseurs',
|
||||
agentsReady: '✓ Agents',
|
||||
agentsLoading: '… Agents',
|
||||
startingDevServer: (hostLabel) => `Démarrage du serveur de développement de la webview (${hostLabel})...`,
|
||||
waitingDevServer: (hostLabel, attempt) => `En attente du serveur de développement de la webview (${hostLabel})... tentative ${attempt}`,
|
||||
loadingData: (providersText, agentsText) => `Chargement des données (${providersText}, ${agentsText})…`,
|
||||
};
|
||||
|
||||
export const getBootstrapMessages = (locale: Locale): BootstrapMessages => (locale === 'fr' ? FR_MESSAGES : EN_MESSAGES);
|
||||
|
||||
export const readStoredLocaleForBootstrap = (): Locale => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as { locale?: unknown };
|
||||
return typeof parsed.locale === 'string' ? normalizeLocale(parsed.locale) : 'en';
|
||||
} catch {
|
||||
return 'en';
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
export { I18nProvider } from './context';
|
||||
export { getBootstrapMessages, readStoredLocaleForBootstrap } from './bootstrap';
|
||||
export { getCurrentIntlLocale, getIntlLocale } from './intl';
|
||||
export { useI18n } from './useI18n';
|
||||
export { formatMessage, initializeLocale, useI18nStore } from './store';
|
||||
export type { I18nKey, I18nParams, Locale } from './store';
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useI18nStore } from './store';
|
||||
import type { Locale } from './runtime';
|
||||
|
||||
const INTL_LOCALE_BY_LOCALE: Record<Locale, string> = {
|
||||
en: 'en-US',
|
||||
fr: 'fr-FR',
|
||||
'zh-CN': 'zh-CN',
|
||||
'zh-TW': 'zh-TW',
|
||||
uk: 'uk-UA',
|
||||
es: 'es-ES',
|
||||
'pt-BR': 'pt-BR',
|
||||
ko: 'ko-KR',
|
||||
pl: 'pl-PL',
|
||||
};
|
||||
|
||||
export const getIntlLocale = (locale: Locale): string => INTL_LOCALE_BY_LOCALE[locale] ?? 'en-US';
|
||||
|
||||
export const getCurrentIntlLocale = (): string => getIntlLocale(useI18nStore.getState().locale);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { dict as enDict } from './messages/en';
|
||||
import { dict as esDict } from './messages/es';
|
||||
import { dict as frDict } from './messages/fr';
|
||||
import { dict as koDict } from './messages/ko';
|
||||
import { dict as plDict } from './messages/pl';
|
||||
import { dict as ptBrDict } from './messages/pt-BR';
|
||||
import { dict as ukDict } from './messages/uk';
|
||||
import { dict as zhCnDict } from './messages/zh-CN';
|
||||
import { dict as zhTwDict } from './messages/zh-TW';
|
||||
|
||||
const localeDictionaries = {
|
||||
en: enDict,
|
||||
fr: frDict,
|
||||
es: esDict,
|
||||
'pt-BR': ptBrDict,
|
||||
uk: ukDict,
|
||||
ko: koDict,
|
||||
pl: plDict,
|
||||
'zh-CN': zhCnDict,
|
||||
'zh-TW': zhTwDict,
|
||||
} as const;
|
||||
|
||||
describe('i18n dictionaries', () => {
|
||||
test('french stays in key parity with english', () => {
|
||||
const englishKeys = Object.keys(enDict).sort();
|
||||
|
||||
expect(Object.keys(frDict).sort()).toEqual(englishKeys);
|
||||
});
|
||||
|
||||
test('all locales expose the french language label key', () => {
|
||||
for (const [, dictionary] of Object.entries(localeDictionaries)) {
|
||||
expect(dictionary['common.language.french']).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ export const dict = {
|
||||
'common.loading': 'Loading...',
|
||||
'common.unavailable': 'Unavailable',
|
||||
'common.language.english': 'English',
|
||||
'common.language.french': 'French',
|
||||
'common.language.simplifiedChinese': 'Chinese (Simplified)',
|
||||
'common.language.traditionalChinese': 'Chinese (Traditional)',
|
||||
'common.language.ukrainian': 'Ukrainian',
|
||||
@@ -2546,6 +2547,35 @@ export const dict = {
|
||||
'markdownRenderer.mermaid.actions.copySourceTitle': 'Copy source',
|
||||
'markdownRenderer.mermaid.actions.downloadSvgTitle': 'Download SVG',
|
||||
'markdownRenderer.mermaid.toast.downloadFailed': 'Failed to download diagram',
|
||||
'common.date.today': 'Today',
|
||||
'common.date.yesterday': 'Yesterday',
|
||||
'common.date.yesterdayWithTime': 'Yesterday {time}',
|
||||
'common.relative.justNow': 'Just now',
|
||||
'common.relative.minutesAgoShort': '{count}min ago',
|
||||
'common.relative.hoursAgoShort': '{count}h ago',
|
||||
'common.relative.daysAgoShort': '{count}d ago',
|
||||
'common.relative.weeksAgoShort': '{count}w ago',
|
||||
'common.relative.yearsAgoShort': '{count}y ago',
|
||||
'common.relative.daysAgoCompact': '{count}d ago',
|
||||
'common.relative.weeksAgoCompact': '{count}w ago',
|
||||
'common.relative.yearsAgoCompact': '{count}y ago',
|
||||
'contextFileOpen.failure.tooLarge': 'File is too large to open (>{count} lines)',
|
||||
'contextFileOpen.failure.missing': 'File not found',
|
||||
'contextFileOpen.failure.unreadable': 'Failed to open file',
|
||||
'quota.window.5h': '5-Hour',
|
||||
'quota.window.7d': '7-Day Limit',
|
||||
'quota.window.7dSonnet': '7-Day Sonnet Limit',
|
||||
'quota.window.7dOpus': '7-Day Opus Limit',
|
||||
'quota.window.weekly': 'Weekly Limit',
|
||||
'quota.window.daily': 'Daily',
|
||||
'quota.window.monthly': 'Monthly Limit',
|
||||
'quota.window.credits': 'Credits',
|
||||
'quota.window.creditsBalance': 'Credits Balance',
|
||||
'quota.window.session': 'Session',
|
||||
'quota.window.premium': 'Premium Interactions',
|
||||
'quota.window.chat': 'Chat Requests',
|
||||
'quota.window.completions': 'Completions',
|
||||
'quota.window.premiumInteractions': 'Premium interactions',
|
||||
} as const;
|
||||
|
||||
export type I18nKey = keyof typeof dict;
|
||||
|
||||
@@ -6,6 +6,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"common.loading": "Cargando...",
|
||||
"common.unavailable": "No disponible",
|
||||
"common.language.english": "Inglés",
|
||||
"common.language.french": "Francés",
|
||||
"common.language.simplifiedChinese": "Chino (simplificado)",
|
||||
"common.language.traditionalChinese": "Chino (tradicional)",
|
||||
"common.language.ukrainian": "Ucraniano",
|
||||
@@ -2547,4 +2548,33 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextPanel.preview.console.filter.errors": "Errores",
|
||||
"contextPanel.preview.console.filter.warnings": "Advertencias",
|
||||
"contextPanel.preview.console.filter.logs": "Registros",
|
||||
"common.date.today": "Today",
|
||||
"common.date.yesterday": "Yesterday",
|
||||
"common.date.yesterdayWithTime": "Yesterday {time}",
|
||||
"common.relative.justNow": "Just now",
|
||||
"common.relative.minutesAgoShort": "{count}min ago",
|
||||
"common.relative.hoursAgoShort": "{count}h ago",
|
||||
"common.relative.daysAgoShort": "{count}d ago",
|
||||
"common.relative.weeksAgoShort": "{count}w ago",
|
||||
"common.relative.yearsAgoShort": "{count}y ago",
|
||||
"common.relative.daysAgoCompact": "{count}d ago",
|
||||
"common.relative.weeksAgoCompact": "{count}w ago",
|
||||
"common.relative.yearsAgoCompact": "{count}y ago",
|
||||
"contextFileOpen.failure.tooLarge": "File is too large to open (>{count} lines)",
|
||||
"contextFileOpen.failure.missing": "File not found",
|
||||
"contextFileOpen.failure.unreadable": "Failed to open file",
|
||||
"quota.window.5h": "5-Hour",
|
||||
"quota.window.7d": "7-Day Limit",
|
||||
"quota.window.7dSonnet": "7-Day Sonnet Limit",
|
||||
"quota.window.7dOpus": "7-Day Opus Limit",
|
||||
"quota.window.weekly": "Weekly Limit",
|
||||
"quota.window.daily": "Daily",
|
||||
"quota.window.monthly": "Monthly Limit",
|
||||
"quota.window.credits": "Credits",
|
||||
"quota.window.creditsBalance": "Credits Balance",
|
||||
"quota.window.session": "Session",
|
||||
"quota.window.premium": "Premium Interactions",
|
||||
"quota.window.chat": "Chat Requests",
|
||||
"quota.window.completions": "Completions",
|
||||
"quota.window.premiumInteractions": "Premium interactions",
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'common.loading': '로딩 중...',
|
||||
'common.unavailable': '사용할 수 없음',
|
||||
'common.language.english': '영어',
|
||||
'common.language.french': '프랑스어',
|
||||
'common.language.simplifiedChinese': '중국어(간체)',
|
||||
'common.language.traditionalChinese': '중국어(번체)',
|
||||
'common.language.ukrainian': '우크라이나어',
|
||||
@@ -2546,4 +2547,33 @@ export const dict: Record<I18nKey, string> = {
|
||||
'markdownRenderer.mermaid.actions.copySourceTitle': '소스 복사',
|
||||
'markdownRenderer.mermaid.actions.downloadSvgTitle': 'SVG 다운로드',
|
||||
'markdownRenderer.mermaid.toast.downloadFailed': '다이어그램 다운로드 실패',
|
||||
'common.date.today': 'Today',
|
||||
'common.date.yesterday': 'Yesterday',
|
||||
'common.date.yesterdayWithTime': 'Yesterday {time}',
|
||||
'common.relative.justNow': 'Just now',
|
||||
'common.relative.minutesAgoShort': '{count}min ago',
|
||||
'common.relative.hoursAgoShort': '{count}h ago',
|
||||
'common.relative.daysAgoShort': '{count}d ago',
|
||||
'common.relative.weeksAgoShort': '{count}w ago',
|
||||
'common.relative.yearsAgoShort': '{count}y ago',
|
||||
'common.relative.daysAgoCompact': '{count}d ago',
|
||||
'common.relative.weeksAgoCompact': '{count}w ago',
|
||||
'common.relative.yearsAgoCompact': '{count}y ago',
|
||||
'contextFileOpen.failure.tooLarge': 'File is too large to open (>{count} lines)',
|
||||
'contextFileOpen.failure.missing': 'File not found',
|
||||
'contextFileOpen.failure.unreadable': 'Failed to open file',
|
||||
'quota.window.5h': '5-Hour',
|
||||
'quota.window.7d': '7-Day Limit',
|
||||
'quota.window.7dSonnet': '7-Day Sonnet Limit',
|
||||
'quota.window.7dOpus': '7-Day Opus Limit',
|
||||
'quota.window.weekly': 'Weekly Limit',
|
||||
'quota.window.daily': 'Daily',
|
||||
'quota.window.monthly': 'Monthly Limit',
|
||||
'quota.window.credits': 'Credits',
|
||||
'quota.window.creditsBalance': 'Credits Balance',
|
||||
'quota.window.session': 'Session',
|
||||
'quota.window.premium': 'Premium Interactions',
|
||||
'quota.window.chat': 'Chat Requests',
|
||||
'quota.window.completions': 'Completions',
|
||||
'quota.window.premiumInteractions': 'Premium interactions',
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'common.loading': 'Ładowanie...',
|
||||
'common.unavailable': 'Niedostępne',
|
||||
'common.language.english': 'Angielski',
|
||||
'common.language.french': 'Francuski',
|
||||
'common.language.simplifiedChinese': 'Chiński (Uproszczony)',
|
||||
'common.language.traditionalChinese': 'Chiński (Tradycyjny)',
|
||||
'common.language.ukrainian': 'Ukraiński',
|
||||
@@ -2563,4 +2564,33 @@ export const dict: Record<I18nKey, string> = {
|
||||
'filesView.editor.manualSave': 'Zapis ręczny',
|
||||
'filesView.editor.saveNowManualTitle': 'Zapisz teraz (tryb ręczny)',
|
||||
'chat.commandAutocomplete.command.timelineDescription': 'Pokaż oś czasu sesji',
|
||||
'common.date.today': 'Today',
|
||||
'common.date.yesterday': 'Yesterday',
|
||||
'common.date.yesterdayWithTime': 'Yesterday {time}',
|
||||
'common.relative.justNow': 'Just now',
|
||||
'common.relative.minutesAgoShort': '{count}min ago',
|
||||
'common.relative.hoursAgoShort': '{count}h ago',
|
||||
'common.relative.daysAgoShort': '{count}d ago',
|
||||
'common.relative.weeksAgoShort': '{count}w ago',
|
||||
'common.relative.yearsAgoShort': '{count}y ago',
|
||||
'common.relative.daysAgoCompact': '{count}d ago',
|
||||
'common.relative.weeksAgoCompact': '{count}w ago',
|
||||
'common.relative.yearsAgoCompact': '{count}y ago',
|
||||
'contextFileOpen.failure.tooLarge': 'File is too large to open (>{count} lines)',
|
||||
'contextFileOpen.failure.missing': 'File not found',
|
||||
'contextFileOpen.failure.unreadable': 'Failed to open file',
|
||||
'quota.window.5h': '5-Hour',
|
||||
'quota.window.7d': '7-Day Limit',
|
||||
'quota.window.7dSonnet': '7-Day Sonnet Limit',
|
||||
'quota.window.7dOpus': '7-Day Opus Limit',
|
||||
'quota.window.weekly': 'Weekly Limit',
|
||||
'quota.window.daily': 'Daily',
|
||||
'quota.window.monthly': 'Monthly Limit',
|
||||
'quota.window.credits': 'Credits',
|
||||
'quota.window.creditsBalance': 'Credits Balance',
|
||||
'quota.window.session': 'Session',
|
||||
'quota.window.premium': 'Premium Interactions',
|
||||
'quota.window.chat': 'Chat Requests',
|
||||
'quota.window.completions': 'Completions',
|
||||
'quota.window.premiumInteractions': 'Premium interactions',
|
||||
} as const;
|
||||
|
||||
@@ -6,6 +6,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"common.loading": "Carregando...",
|
||||
"common.unavailable": "Indisponível",
|
||||
"common.language.english": "Inglês",
|
||||
"common.language.french": "Francês",
|
||||
"common.language.simplifiedChinese": "Chinês (simplificado)",
|
||||
"common.language.traditionalChinese": "Chinês (tradicional)",
|
||||
"common.language.ukrainian": "Ucraniano",
|
||||
@@ -2547,4 +2548,33 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextPanel.preview.console.filter.errors": "Erros",
|
||||
"contextPanel.preview.console.filter.warnings": "Avisos",
|
||||
"contextPanel.preview.console.filter.logs": "Logs",
|
||||
"common.date.today": "Today",
|
||||
"common.date.yesterday": "Yesterday",
|
||||
"common.date.yesterdayWithTime": "Yesterday {time}",
|
||||
"common.relative.justNow": "Just now",
|
||||
"common.relative.minutesAgoShort": "{count}min ago",
|
||||
"common.relative.hoursAgoShort": "{count}h ago",
|
||||
"common.relative.daysAgoShort": "{count}d ago",
|
||||
"common.relative.weeksAgoShort": "{count}w ago",
|
||||
"common.relative.yearsAgoShort": "{count}y ago",
|
||||
"common.relative.daysAgoCompact": "{count}d ago",
|
||||
"common.relative.weeksAgoCompact": "{count}w ago",
|
||||
"common.relative.yearsAgoCompact": "{count}y ago",
|
||||
"contextFileOpen.failure.tooLarge": "File is too large to open (>{count} lines)",
|
||||
"contextFileOpen.failure.missing": "File not found",
|
||||
"contextFileOpen.failure.unreadable": "Failed to open file",
|
||||
"quota.window.5h": "5-Hour",
|
||||
"quota.window.7d": "7-Day Limit",
|
||||
"quota.window.7dSonnet": "7-Day Sonnet Limit",
|
||||
"quota.window.7dOpus": "7-Day Opus Limit",
|
||||
"quota.window.weekly": "Weekly Limit",
|
||||
"quota.window.daily": "Daily",
|
||||
"quota.window.monthly": "Monthly Limit",
|
||||
"quota.window.credits": "Credits",
|
||||
"quota.window.creditsBalance": "Credits Balance",
|
||||
"quota.window.session": "Session",
|
||||
"quota.window.premium": "Premium Interactions",
|
||||
"quota.window.chat": "Chat Requests",
|
||||
"quota.window.completions": "Completions",
|
||||
"quota.window.premiumInteractions": "Premium interactions",
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"common.loading": "Завантаження...",
|
||||
"common.unavailable": "Недоступно",
|
||||
"common.language.english": "англійська",
|
||||
"common.language.french": "Французька",
|
||||
"common.language.simplifiedChinese": "Китайська (спрощена)",
|
||||
"common.language.traditionalChinese": "Китайська (традиційна)",
|
||||
"common.language.ukrainian": "Українська",
|
||||
@@ -2547,4 +2548,33 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextPanel.preview.console.filter.errors": "Помилки",
|
||||
"contextPanel.preview.console.filter.warnings": "Попередження",
|
||||
"contextPanel.preview.console.filter.logs": "Логи",
|
||||
"common.date.today": "Today",
|
||||
"common.date.yesterday": "Yesterday",
|
||||
"common.date.yesterdayWithTime": "Yesterday {time}",
|
||||
"common.relative.justNow": "Just now",
|
||||
"common.relative.minutesAgoShort": "{count}min ago",
|
||||
"common.relative.hoursAgoShort": "{count}h ago",
|
||||
"common.relative.daysAgoShort": "{count}d ago",
|
||||
"common.relative.weeksAgoShort": "{count}w ago",
|
||||
"common.relative.yearsAgoShort": "{count}y ago",
|
||||
"common.relative.daysAgoCompact": "{count}d ago",
|
||||
"common.relative.weeksAgoCompact": "{count}w ago",
|
||||
"common.relative.yearsAgoCompact": "{count}y ago",
|
||||
"contextFileOpen.failure.tooLarge": "File is too large to open (>{count} lines)",
|
||||
"contextFileOpen.failure.missing": "File not found",
|
||||
"contextFileOpen.failure.unreadable": "Failed to open file",
|
||||
"quota.window.5h": "5-Hour",
|
||||
"quota.window.7d": "7-Day Limit",
|
||||
"quota.window.7dSonnet": "7-Day Sonnet Limit",
|
||||
"quota.window.7dOpus": "7-Day Opus Limit",
|
||||
"quota.window.weekly": "Weekly Limit",
|
||||
"quota.window.daily": "Daily",
|
||||
"quota.window.monthly": "Monthly Limit",
|
||||
"quota.window.credits": "Credits",
|
||||
"quota.window.creditsBalance": "Credits Balance",
|
||||
"quota.window.session": "Session",
|
||||
"quota.window.premium": "Premium Interactions",
|
||||
"quota.window.chat": "Chat Requests",
|
||||
"quota.window.completions": "Completions",
|
||||
"quota.window.premiumInteractions": "Premium interactions",
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'common.loading': '加载中...',
|
||||
'common.unavailable': '不可用',
|
||||
'common.language.english': 'English',
|
||||
'common.language.french': '法语',
|
||||
'common.language.simplifiedChinese': '简体中文',
|
||||
'common.language.traditionalChinese': '繁体中文',
|
||||
'common.language.ukrainian': '乌克兰语',
|
||||
@@ -2547,4 +2548,33 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextPanel.preview.console.filter.errors': '错误',
|
||||
'contextPanel.preview.console.filter.warnings': '警告',
|
||||
'contextPanel.preview.console.filter.logs': '日志',
|
||||
'common.date.today': 'Today',
|
||||
'common.date.yesterday': 'Yesterday',
|
||||
'common.date.yesterdayWithTime': 'Yesterday {time}',
|
||||
'common.relative.justNow': 'Just now',
|
||||
'common.relative.minutesAgoShort': '{count}min ago',
|
||||
'common.relative.hoursAgoShort': '{count}h ago',
|
||||
'common.relative.daysAgoShort': '{count}d ago',
|
||||
'common.relative.weeksAgoShort': '{count}w ago',
|
||||
'common.relative.yearsAgoShort': '{count}y ago',
|
||||
'common.relative.daysAgoCompact': '{count}d ago',
|
||||
'common.relative.weeksAgoCompact': '{count}w ago',
|
||||
'common.relative.yearsAgoCompact': '{count}y ago',
|
||||
'contextFileOpen.failure.tooLarge': 'File is too large to open (>{count} lines)',
|
||||
'contextFileOpen.failure.missing': 'File not found',
|
||||
'contextFileOpen.failure.unreadable': 'Failed to open file',
|
||||
'quota.window.5h': '5-Hour',
|
||||
'quota.window.7d': '7-Day Limit',
|
||||
'quota.window.7dSonnet': '7-Day Sonnet Limit',
|
||||
'quota.window.7dOpus': '7-Day Opus Limit',
|
||||
'quota.window.weekly': 'Weekly Limit',
|
||||
'quota.window.daily': 'Daily',
|
||||
'quota.window.monthly': 'Monthly Limit',
|
||||
'quota.window.credits': 'Credits',
|
||||
'quota.window.creditsBalance': 'Credits Balance',
|
||||
'quota.window.session': 'Session',
|
||||
'quota.window.premium': 'Premium Interactions',
|
||||
'quota.window.chat': 'Chat Requests',
|
||||
'quota.window.completions': 'Completions',
|
||||
'quota.window.premiumInteractions': 'Premium interactions',
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'common.loading': '載入中...',
|
||||
'common.unavailable': '無法使用',
|
||||
'common.language.english': 'English',
|
||||
'common.language.french': '法語',
|
||||
'common.language.simplifiedChinese': '簡體中文',
|
||||
'common.language.traditionalChinese': '繁體中文',
|
||||
'common.language.ukrainian': '烏克蘭語',
|
||||
@@ -2546,4 +2547,33 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextPanel.preview.console.filter.logs': '日誌',
|
||||
'filesView.tree.actions.refreshTitle': '重新整理',
|
||||
'sidebarFilesTree.actions.fileMenuTitle': '檔案選單',
|
||||
'common.date.today': 'Today',
|
||||
'common.date.yesterday': 'Yesterday',
|
||||
'common.date.yesterdayWithTime': 'Yesterday {time}',
|
||||
'common.relative.justNow': 'Just now',
|
||||
'common.relative.minutesAgoShort': '{count}min ago',
|
||||
'common.relative.hoursAgoShort': '{count}h ago',
|
||||
'common.relative.daysAgoShort': '{count}d ago',
|
||||
'common.relative.weeksAgoShort': '{count}w ago',
|
||||
'common.relative.yearsAgoShort': '{count}y ago',
|
||||
'common.relative.daysAgoCompact': '{count}d ago',
|
||||
'common.relative.weeksAgoCompact': '{count}w ago',
|
||||
'common.relative.yearsAgoCompact': '{count}y ago',
|
||||
'contextFileOpen.failure.tooLarge': 'File is too large to open (>{count} lines)',
|
||||
'contextFileOpen.failure.missing': 'File not found',
|
||||
'contextFileOpen.failure.unreadable': 'Failed to open file',
|
||||
'quota.window.5h': '5-Hour',
|
||||
'quota.window.7d': '7-Day Limit',
|
||||
'quota.window.7dSonnet': '7-Day Sonnet Limit',
|
||||
'quota.window.7dOpus': '7-Day Opus Limit',
|
||||
'quota.window.weekly': 'Weekly Limit',
|
||||
'quota.window.daily': 'Daily',
|
||||
'quota.window.monthly': 'Monthly Limit',
|
||||
'quota.window.credits': 'Credits',
|
||||
'quota.window.creditsBalance': 'Credits Balance',
|
||||
'quota.window.session': 'Session',
|
||||
'quota.window.premium': 'Premium Interactions',
|
||||
'quota.window.chat': 'Chat Requests',
|
||||
'quota.window.completions': 'Completions',
|
||||
'quota.window.premiumInteractions': 'Premium interactions',
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
export type Locale = 'en' | 'zh-CN' | 'zh-TW' | 'uk' | 'es' | 'pt-BR' | 'ko' | 'pl';
|
||||
export type Locale = 'en' | 'fr' | 'zh-CN' | 'zh-TW' | 'uk' | 'es' | 'pt-BR' | 'ko' | 'pl';
|
||||
|
||||
export const LOCALES = ['en', 'zh-CN', 'zh-TW', 'uk', 'es', 'pt-BR', 'ko', 'pl'] as const satisfies readonly Locale[];
|
||||
export const LOCALES = ['en', 'fr', 'zh-CN', 'zh-TW', 'uk', 'es', 'pt-BR', 'ko', 'pl'] as const satisfies readonly Locale[];
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = 'en';
|
||||
|
||||
export const LOCALE_LABEL_KEYS: Record<Locale, 'common.language.english' | 'common.language.simplifiedChinese' | 'common.language.traditionalChinese' | 'common.language.ukrainian' | 'common.language.spanish' | 'common.language.brazilianPortuguese' | 'common.language.korean' | 'common.language.polish'> = {
|
||||
export const LOCALE_LABEL_KEYS: Record<Locale, 'common.language.english' | 'common.language.french' | 'common.language.simplifiedChinese' | 'common.language.traditionalChinese' | 'common.language.ukrainian' | 'common.language.spanish' | 'common.language.brazilianPortuguese' | 'common.language.korean' | 'common.language.polish'> = {
|
||||
en: 'common.language.english',
|
||||
fr: 'common.language.french',
|
||||
'zh-CN': 'common.language.simplifiedChinese',
|
||||
'zh-TW': 'common.language.traditionalChinese',
|
||||
uk: 'common.language.ukrainian',
|
||||
@@ -39,6 +40,9 @@ export function normalizeLocale(value: string | undefined | null): Locale {
|
||||
if (normalized.startsWith('en')) {
|
||||
return 'en';
|
||||
}
|
||||
if (normalized === 'fr' || normalized.startsWith('fr-')) {
|
||||
return 'fr';
|
||||
}
|
||||
if (normalized === 'uk' || normalized.startsWith('uk-') || normalized === 'ua' || normalized.startsWith('ua-')) {
|
||||
return 'uk';
|
||||
}
|
||||
|
||||
@@ -42,4 +42,16 @@ describe('i18n store', () => {
|
||||
resetStore();
|
||||
}
|
||||
});
|
||||
|
||||
test('loads the french dictionary', async () => {
|
||||
try {
|
||||
useI18nStore.getState().setLocale('fr');
|
||||
|
||||
expect(useI18nStore.getState().loadingLocale).toBe('fr');
|
||||
await waitForLocaleLoadToSettle('fr');
|
||||
expect(useI18nStore.getState().dictionary['common.language.french']).toBe('Français');
|
||||
} finally {
|
||||
resetStore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,8 @@ async function loadDictionary(locale: Locale): Promise<I18nDictionary> {
|
||||
|
||||
const mod = locale === 'zh-CN'
|
||||
? await import('./messages/zh-CN') as { dict: I18nDictionary }
|
||||
: locale === 'fr'
|
||||
? await import('./messages/fr') as { dict: I18nDictionary }
|
||||
: locale === 'zh-TW'
|
||||
? await import('./messages/zh-TW') as { dict: I18nDictionary }
|
||||
: locale === 'es'
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { formatMessage, useI18nStore } from '@/lib/i18n/store';
|
||||
import { formatDateTimeForPreference, formatTimeForPreference } from '@/lib/timeFormat';
|
||||
import type { TimeFormatPreference } from '@/stores/useUIStore';
|
||||
|
||||
const t = (key: Parameters<typeof formatMessage>[1]) => formatMessage(useI18nStore.getState().dictionary, key);
|
||||
|
||||
export const clampPercent = (value: number | null): number | null => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
@@ -70,20 +73,20 @@ export const resolveUsageTone = (percent: number | null): 'safe' | 'warn' | 'cri
|
||||
};
|
||||
|
||||
export const formatWindowLabel = (label: string): string => {
|
||||
if (label === '5h') return '5-Hour';
|
||||
if (label === '7d') return '7-Day Limit';
|
||||
if (label === '7d-sonnet') return '7-Day Sonnet Limit';
|
||||
if (label === '7d-opus') return '7-Day Opus Limit';
|
||||
if (label === 'weekly') return 'Weekly Limit';
|
||||
if (label === 'daily') return 'Daily';
|
||||
if (label === 'monthly') return 'Monthly Limit';
|
||||
if (label === 'credits') return 'Credits';
|
||||
if (label === 'credits_balance') return 'Credits Balance';
|
||||
if (label === 'session') return 'Session';
|
||||
if (label === 'premium') return 'Premium Interactions';
|
||||
if (label === 'chat') return 'Chat Requests';
|
||||
if (label === 'completions') return 'Completions';
|
||||
if (label === 'premium_interactions') return 'Premium interactions';
|
||||
if (label === '5h') return t('quota.window.5h');
|
||||
if (label === '7d') return t('quota.window.7d');
|
||||
if (label === '7d-sonnet') return t('quota.window.7dSonnet');
|
||||
if (label === '7d-opus') return t('quota.window.7dOpus');
|
||||
if (label === 'weekly') return t('quota.window.weekly');
|
||||
if (label === 'daily') return t('quota.window.daily');
|
||||
if (label === 'monthly') return t('quota.window.monthly');
|
||||
if (label === 'credits') return t('quota.window.credits');
|
||||
if (label === 'credits_balance') return t('quota.window.creditsBalance');
|
||||
if (label === 'session') return t('quota.window.session');
|
||||
if (label === 'premium') return t('quota.window.premium');
|
||||
if (label === 'chat') return t('quota.window.chat');
|
||||
if (label === 'completions') return t('quota.window.completions');
|
||||
if (label === 'premium_interactions') return t('quota.window.premiumInteractions');
|
||||
return label;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCurrentIntlLocale } from '@/lib/i18n';
|
||||
import type { TimeFormatPreference } from '@/stores/useUIStore';
|
||||
|
||||
type TimePrecision = 'minute' | 'second';
|
||||
@@ -33,7 +34,7 @@ export const formatTimeForPreference = (
|
||||
return options.fallback ?? '';
|
||||
}
|
||||
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
return date.toLocaleTimeString(getCurrentIntlLocale(), {
|
||||
hour: options.hour ?? 'numeric',
|
||||
minute: '2-digit',
|
||||
second: options.precision === 'second' ? '2-digit' : undefined,
|
||||
@@ -51,7 +52,7 @@ export const formatDateTimeForPreference = (
|
||||
return '';
|
||||
}
|
||||
|
||||
return date.toLocaleString(undefined, {
|
||||
return date.toLocaleString(getCurrentIntlLocale(), {
|
||||
...options,
|
||||
hour12: options.hour ? getHour12Option(preference) : options.hour12,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user