panel names take a late-bound thunk (494 -> 469)
AppRegistryEntry.name is now `string | (() => string)` and the framework calls it if it is a function. Owner-approved, with both of their conditions met. Why a thunk rather than a key: `workspaces` has no dependencies at all, deliberately not even i18n, so resolving a catalogue key there would invert the graph -- officerdev depends on that package, not the reverse. The caller binds the words; the framework only asks for them. `string` stays valid, so the 27 names migrate one at a time and a name that never needs translating keeps a plain string. Minesweeper, SendOff, Tmux, Neovim, Claude Code and bTop are exactly that case: product names, and a key whose two sides can never legitimately differ is a maintenance cost bought for nothing. Resolved at every render via resolveEntryName, never cached onto the entry, with the reason stated at both call sites -- caching would pin the name to whatever locale the registry was seeded with, and it is seeded once before any locale has loaded. There is a test that re-renders under a different provider locale and asserts the name changes; it is the test that fails if anybody memoises this later. The thunk reads i18n/standalone, which mirrors the provider for callers with no hook in scope. Deliberately not on the package barrel and documented as not-a-general-escape- hatch, because it is not reactive: it is only correct where the caller re-renders for another reason.
This commit is contained in:
@@ -2,6 +2,7 @@ import { createContext, useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { I18nContextValue, InterpolationParams, TranslationMap } from './types';
|
||||
import { interpolate, resolveKey } from './resolve';
|
||||
import { setActiveI18n } from './standalone';
|
||||
|
||||
export const I18nContext = createContext<I18nContextValue>({
|
||||
t: (key: string) => key,
|
||||
@@ -17,6 +18,10 @@ type I18nProviderProps = {
|
||||
};
|
||||
|
||||
export const I18nProvider = ({ translations, locale, fallbackLocale = 'en', children }: I18nProviderProps) => {
|
||||
// Mirrored for the one caller that cannot use a hook — see standalone.ts. Set during render rather
|
||||
// than in an effect so it is never a render behind what this provider is handing out.
|
||||
setActiveI18n({ translations, locale, fallbackLocale });
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => {
|
||||
const t = (key: string, params?: InterpolationParams) => {
|
||||
const count = typeof params?.count === 'number' ? params.count : undefined;
|
||||
|
||||
@@ -3,5 +3,7 @@ export { useTranslation } from './useTranslation';
|
||||
export { useFormat } from './useFormat';
|
||||
export { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
||||
export { findMissingKeys } from './parity';
|
||||
// Deliberately NOT re-exporting translateStandalone — see standalone.ts on why it is awkward to reach.
|
||||
export { activeLocale } from './standalone';
|
||||
export type { MissingKey } from './parity';
|
||||
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys, PluralForms } from './types';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { InterpolationParams, TranslationMap } from './types';
|
||||
import { interpolate, resolveKey } from './resolve';
|
||||
|
||||
/**
|
||||
* The active locale, readable OUTSIDE a React render.
|
||||
*
|
||||
* There is exactly one caller and it is not a shortcut: a panel's name in the app registry is
|
||||
* module-level data, and the framework invokes its thunk from a render where no hook of ours is
|
||||
* reachable — `workspaces` has no dependencies at all, deliberately, not even this package.
|
||||
*
|
||||
* NOT a general escape hatch. Anything inside a component uses `useTranslation`, which is reactive.
|
||||
* This is not reactive: writing here re-renders nothing. It is only correct where the caller is
|
||||
* already re-rendering for another reason, which is why `translateStandalone` is deliberately awkward
|
||||
* to reach and documented rather than exported from the package barrel.
|
||||
*/
|
||||
type ActiveI18n = { translations: TranslationMap; locale: string; fallbackLocale: string };
|
||||
|
||||
let active: ActiveI18n = { translations: {}, locale: 'en', fallbackLocale: 'en' };
|
||||
|
||||
/** Called by `I18nProvider` on every render, so this never lags the provider it mirrors. */
|
||||
export const setActiveI18n = (next: ActiveI18n): void => {
|
||||
active = next;
|
||||
};
|
||||
|
||||
export const translateStandalone = (key: string, params?: InterpolationParams): string => {
|
||||
const count = typeof params?.count === 'number' ? params.count : undefined;
|
||||
return interpolate(resolveKey(active.translations, active.locale, key, active.fallbackLocale, count), params);
|
||||
};
|
||||
|
||||
/** The locale the provider currently holds. Exposed so a caller can key a render on it. */
|
||||
export const activeLocale = (): string => active.locale;
|
||||
@@ -2,6 +2,7 @@ import { Store } from 'lucide-react';
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { AppStoreList } from './AppStoreList';
|
||||
import { AppStoreDetail } from './AppStoreDetail';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
export { AppStoreList } from './AppStoreList';
|
||||
export { AppStoreDetail } from './AppStoreDetail';
|
||||
@@ -12,6 +13,18 @@ export type { StoreItem, InstallMode, ConfigField } from './useAppStore';
|
||||
// side by side. `availableOnPanel: false` keeps them off the generic panel picker — they only make
|
||||
// sense together, on /app-store.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'app-store-list', name: 'App store', icon: Store, component: AppStoreList, availableOnPanel: false },
|
||||
{ key: 'app-store-detail', name: 'App detail', icon: Store, component: AppStoreDetail, availableOnPanel: false },
|
||||
{
|
||||
key: 'app-store-list',
|
||||
name: panelName('panelName.appStore'),
|
||||
icon: Store,
|
||||
component: AppStoreList,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'app-store-detail',
|
||||
name: panelName('panelName.appDetail'),
|
||||
icon: Store,
|
||||
component: AppStoreDetail,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { ChatPanelWrapper } from './ChatPanelWrapper';
|
||||
import { ChatHeader } from './ChatHeader';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
export { MessageList } from './components/MessageList';
|
||||
export { MessageBubble, StreamingBubble } from './components/MessageBubble';
|
||||
@@ -26,7 +27,7 @@ export * from './types';
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'officerdev/chat',
|
||||
name: 'Chat',
|
||||
name: panelName('panelName.chat'),
|
||||
icon: MessageSquare,
|
||||
component: ChatPanelWrapper,
|
||||
header: ChatHeader,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { MessageSquare, List } from 'lucide-react';
|
||||
import { SessionList } from './SessionList';
|
||||
import { ChatDetailPanel } from './ChatDetailPanel';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
export { SessionList };
|
||||
export { ChatDetailPanel };
|
||||
@@ -11,14 +12,14 @@ export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'chat-session-list',
|
||||
name: 'Sessions',
|
||||
name: panelName('panelName.chatSessions'),
|
||||
icon: List,
|
||||
component: SessionList,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'chat-detail',
|
||||
name: 'Chat',
|
||||
name: panelName('panelName.chat'),
|
||||
icon: MessageSquare,
|
||||
component: ChatDetailPanel,
|
||||
availableOnPanel: false,
|
||||
|
||||
@@ -2,13 +2,14 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { Code } from 'lucide-react';
|
||||
import { CodeEditorPanelWrapper } from './CodeEditorPanelWrapper';
|
||||
import { CodeEditorHeader } from './CodeEditorHeader';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
export { CodeEditorView } from './CodeEditor';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'officerdev/code-editor',
|
||||
name: 'Code Editor',
|
||||
name: panelName('panelName.codeEditor'),
|
||||
icon: Code,
|
||||
component: CodeEditorPanelWrapper,
|
||||
header: CodeEditorHeader,
|
||||
|
||||
@@ -15,25 +15,26 @@ export type {
|
||||
CommandHqTurn,
|
||||
CommandHqFire,
|
||||
} from './types';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'command-hq-categories',
|
||||
name: 'Categories',
|
||||
name: panelName('panelName.categories'),
|
||||
icon: Radar,
|
||||
component: CommandHqCategories,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'command-hq-grid',
|
||||
name: 'Commanders',
|
||||
name: panelName('panelName.commanders'),
|
||||
icon: LayoutGrid,
|
||||
component: CommandHqGrid,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'command-hq-detail',
|
||||
name: 'Commander',
|
||||
name: panelName('panelName.commander'),
|
||||
icon: MessageSquare,
|
||||
component: CommandHqDetail,
|
||||
availableOnPanel: false,
|
||||
|
||||
@@ -2,23 +2,30 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import { DashboardListApp } from './DashboardListApp';
|
||||
import { DashboardPreview } from './DashboardPreview';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
export { DashboardListApp };
|
||||
export { DashboardPreview };
|
||||
export { CREATING_DASHBOARD_KEY, EDITING_DASHBOARD_KEY, NEW_DASH_NAME_KEY, NEW_DASH_DESC_KEY, NEW_DASH_TEMPLATE_KEY } from './constants';
|
||||
export {
|
||||
CREATING_DASHBOARD_KEY,
|
||||
EDITING_DASHBOARD_KEY,
|
||||
NEW_DASH_NAME_KEY,
|
||||
NEW_DASH_DESC_KEY,
|
||||
NEW_DASH_TEMPLATE_KEY,
|
||||
} from './constants';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'dashboard-list',
|
||||
name: 'Dashboards',
|
||||
name: panelName('panelName.dashboards'),
|
||||
icon: LayoutGrid,
|
||||
component: DashboardListApp
|
||||
component: DashboardListApp,
|
||||
},
|
||||
{
|
||||
key: 'dashboard-preview',
|
||||
name: 'Dashboard Preview',
|
||||
name: panelName('panelName.dashboardPreview'),
|
||||
icon: LayoutGrid,
|
||||
component: DashboardPreview,
|
||||
availableOnPanel: false
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CalendarDays, Contact as ContactIcon, PanelLeft } from 'lucide-react';
|
||||
import { CalendarNav, ContactsNav } from './CollectionNav';
|
||||
import { CalendarView } from './CalendarView';
|
||||
import { ContactsView } from './ContactsView';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
// One app folder for both /calendar and /contacts: they are the same DAV sidecar, the same collection
|
||||
// list and the same selection rule, differing only in which kind of collection they show.
|
||||
@@ -10,8 +11,32 @@ import { ContactsView } from './ContactsView';
|
||||
export { CalendarNav, ContactsNav, CalendarView, ContactsView };
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'calendar-nav', name: 'Calendar', icon: PanelLeft, component: CalendarNav, availableOnPanel: false },
|
||||
{ key: 'calendar-view', name: 'Calendar', icon: CalendarDays, component: CalendarView, availableOnPanel: false },
|
||||
{ key: 'contacts-nav', name: 'Contacts', icon: PanelLeft, component: ContactsNav, availableOnPanel: false },
|
||||
{ key: 'contacts-view', name: 'Contacts', icon: ContactIcon, component: ContactsView, availableOnPanel: false },
|
||||
{
|
||||
key: 'calendar-nav',
|
||||
name: panelName('panelName.calendar'),
|
||||
icon: PanelLeft,
|
||||
component: CalendarNav,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'calendar-view',
|
||||
name: panelName('panelName.calendar'),
|
||||
icon: CalendarDays,
|
||||
component: CalendarView,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'contacts-nav',
|
||||
name: panelName('panelName.contacts'),
|
||||
icon: PanelLeft,
|
||||
component: ContactsNav,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'contacts-view',
|
||||
name: panelName('panelName.contacts'),
|
||||
icon: ContactIcon,
|
||||
component: ContactsView,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { FileBrowserPanelWrapper } from './FileBrowserApp';
|
||||
import { FileBrowserWidget } from './FileBrowserWidget';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
export { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
|
||||
export { useTasks, type TaskSummary } from './useTasks';
|
||||
@@ -14,13 +15,13 @@ export { TaskRunnerModal } from './FileBrowserApp/components/TaskRunnerModal';
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'officerdev/file-browser',
|
||||
name: 'File Browser',
|
||||
name: panelName('panelName.fileBrowser'),
|
||||
icon: FolderOpen,
|
||||
component: FileBrowserPanelWrapper,
|
||||
},
|
||||
{
|
||||
key: 'officerdev/file-browser-widget',
|
||||
name: 'File Browser',
|
||||
name: panelName('panelName.fileBrowser'),
|
||||
icon: FolderOpen,
|
||||
component: FileBrowserWidget,
|
||||
availableOnPanel: false,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Puzzle } from 'lucide-react';
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { PluginsList } from './PluginsList';
|
||||
import { PluginDetail } from './PluginDetail';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
export { PluginsList } from './PluginsList';
|
||||
export { PluginDetail } from './PluginDetail';
|
||||
@@ -12,6 +13,18 @@ export type { PluginItem, PluginPermission } from './usePlugins';
|
||||
// both read it. `availableOnPanel: false` keeps them off the generic picker: they only make sense on
|
||||
// /plugins, together.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'plugins-list', name: 'Plugins', icon: Puzzle, component: PluginsList, availableOnPanel: false },
|
||||
{ key: 'plugin-detail', name: 'Plugin detail', icon: Puzzle, component: PluginDetail, availableOnPanel: false },
|
||||
{
|
||||
key: 'plugins-list',
|
||||
name: panelName('panelName.plugins'),
|
||||
icon: Puzzle,
|
||||
component: PluginsList,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'plugin-detail',
|
||||
name: panelName('panelName.pluginDetail'),
|
||||
icon: Puzzle,
|
||||
component: PluginDetail,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { PreviewApp } from './PreviewApp';
|
||||
import { PreviewHeader } from './PreviewHeader';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
export { PreviewApp } from './PreviewApp';
|
||||
export { PreviewHeader } from './PreviewHeader';
|
||||
@@ -10,7 +11,7 @@ export { usePreview, normalizeUrl, type PreviewPanelState } from './use-preview'
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'officerdev/preview',
|
||||
name: 'Preview',
|
||||
name: panelName('panelName.preview'),
|
||||
icon: Globe,
|
||||
component: PreviewApp,
|
||||
header: PreviewHeader,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { QrCode, ScanLine } from 'lucide-react';
|
||||
import { QrSender } from './Sender';
|
||||
import { QrReceiver } from './Receiver';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
export { QrSender } from './Sender';
|
||||
export { QrReceiver } from './Receiver';
|
||||
@@ -12,6 +13,6 @@ export * from './protocol';
|
||||
// enough to reimplement on iOS and Android.
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'qr-send', name: 'QR Send', icon: QrCode, component: QrSender },
|
||||
{ key: 'qr-receive', name: 'QR Receive', icon: ScanLine, component: QrReceiver },
|
||||
{ key: 'qr-send', name: panelName('panelName.qrSend'), icon: QrCode, component: QrSender },
|
||||
{ key: 'qr-receive', name: panelName('panelName.qrReceive'), icon: ScanLine, component: QrReceiver },
|
||||
];
|
||||
|
||||
@@ -2,10 +2,23 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { Activity, PanelLeft } from 'lucide-react';
|
||||
import { ScopeList } from './ScopeList';
|
||||
import { MonitorMain } from './MonitorMain';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
export { ScopeList, MonitorMain };
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'monitor-side', name: 'Scope', icon: PanelLeft, component: ScopeList, availableOnPanel: false },
|
||||
{ key: 'monitor-main', name: 'System Monitor', icon: Activity, component: MonitorMain, availableOnPanel: false },
|
||||
{
|
||||
key: 'monitor-side',
|
||||
name: panelName('panelName.scope'),
|
||||
icon: PanelLeft,
|
||||
component: ScopeList,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'monitor-main',
|
||||
name: panelName('panelName.systemMonitor'),
|
||||
icon: Activity,
|
||||
component: MonitorMain,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import { WidgetPanel } from './WidgetPanel';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'officerdev/widgets',
|
||||
name: 'Widgets',
|
||||
name: panelName('panelName.widgets'),
|
||||
icon: LayoutGrid,
|
||||
component: WidgetPanel,
|
||||
transparent: true,
|
||||
|
||||
@@ -109,5 +109,32 @@
|
||||
"removeFile": "Remove {{name}}",
|
||||
"sendTo": "Send to {{name}}",
|
||||
"turningOff": "Turning someone off stops future sends and leaves what they already sent alone."
|
||||
},
|
||||
"panelName": {
|
||||
"appStore": "App store",
|
||||
"appDetail": "App detail",
|
||||
"calendar": "Calendar",
|
||||
"contacts": "Contacts",
|
||||
"categories": "Categories",
|
||||
"chat": "Chat",
|
||||
"chatSessions": "Sessions",
|
||||
"codeEditor": "Code Editor",
|
||||
"commander": "Commander",
|
||||
"commanders": "Commanders",
|
||||
"dashboardPreview": "Dashboard Preview",
|
||||
"dashboards": "Dashboards",
|
||||
"fileBrowser": "File Browser",
|
||||
"pluginDetail": "Plugin detail",
|
||||
"plugins": "Plugins",
|
||||
"preview": "Preview",
|
||||
"qrReceive": "QR Receive",
|
||||
"qrSend": "QR Send",
|
||||
"scope": "Scope",
|
||||
"systemMonitor": "System Monitor",
|
||||
"widgets": "Widgets",
|
||||
"terminal": "Terminal",
|
||||
"hostTerminal": "Host Terminal",
|
||||
"runningShells": "Running Shells",
|
||||
"pm2Processes": "pm2 processes"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,3 +7,4 @@ export const panelCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT };
|
||||
|
||||
export { useTranslation } from './useTranslation';
|
||||
export type { PanelTranslationKey } from './useTranslation';
|
||||
export { panelName } from './panelName';
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { I18nProvider, type TranslationMap } from 'i18n';
|
||||
import { resolveEntryName } from 'workspaces/react';
|
||||
import { panelName } from './panelName';
|
||||
|
||||
const translations: TranslationMap = {
|
||||
'en-GB': { panel: { panelName: { fileBrowser: 'File Browser' } } },
|
||||
'pt-PT': { panel: { panelName: { fileBrowser: 'Explorador de ficheiros' } } },
|
||||
};
|
||||
|
||||
/** Stands in for AppPicker/PanelSlot: reads the name during ITS OWN render, exactly as they do. */
|
||||
const NameProbe = ({ entry }: { entry: { name: string | (() => string) } }) => <span>{resolveEntryName(entry)}</span>;
|
||||
|
||||
describe('panelName', () => {
|
||||
test('a plain string still works, so the 27 can migrate one at a time', () => {
|
||||
render(<NameProbe entry={{ name: 'Minesweeper' }} />);
|
||||
expect(screen.getByText('Minesweeper')).toBeDefined();
|
||||
});
|
||||
|
||||
/**
|
||||
* The condition the owner set: the thunk must be READ at render, so a locale change reaches it.
|
||||
* Re-rendering under a different provider locale has to produce the other language — if the name
|
||||
* were cached anywhere on the entry or in the module, this is the test that would fail.
|
||||
*/
|
||||
test('the name follows the provider locale across a re-render', () => {
|
||||
const entry = { name: panelName('panelName.fileBrowser') };
|
||||
|
||||
const { rerender } = render(
|
||||
<I18nProvider translations={translations} locale="en-GB" fallbackLocale="en-GB">
|
||||
<NameProbe entry={entry} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(screen.getByText('File Browser')).toBeDefined();
|
||||
|
||||
rerender(
|
||||
<I18nProvider translations={translations} locale="pt-PT" fallbackLocale="en-GB">
|
||||
<NameProbe entry={entry} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(screen.getByText('Explorador de ficheiros')).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { translateStandalone } from 'i18n/standalone';
|
||||
import type { PanelTranslationKey } from './useTranslation';
|
||||
|
||||
/**
|
||||
* A panel's display name, as a thunk the framework calls during its own render.
|
||||
*
|
||||
* `AppRegistryEntry.name` accepts `string | (() => string)` precisely so this can be late-bound: the
|
||||
* metas are module-level data, so there is no hook in scope, and `workspaces` has no dependencies to
|
||||
* resolve a key with. See that type's comment for the layering argument.
|
||||
*
|
||||
* A panel whose name needs no translating — a product name like Minesweeper or SendOff — stays a plain
|
||||
* string in its meta rather than becoming a key whose two sides can never legitimately differ.
|
||||
*/
|
||||
export const panelName =
|
||||
(key: PanelTranslationKey): (() => string) =>
|
||||
() =>
|
||||
translateStandalone(`panel.${key}`);
|
||||
@@ -109,5 +109,32 @@
|
||||
"removeFile": "Remover {{name}}",
|
||||
"sendTo": "Enviar para {{name}}",
|
||||
"turningOff": "Desativar alguém impede envios futuros e deixa intacto o que já enviou."
|
||||
},
|
||||
"panelName": {
|
||||
"appStore": "Loja de aplicações",
|
||||
"appDetail": "Detalhe da aplicação",
|
||||
"calendar": "Calendário",
|
||||
"contacts": "Contactos",
|
||||
"categories": "Categorias",
|
||||
"chat": "Chat",
|
||||
"chatSessions": "Sessões",
|
||||
"codeEditor": "Editor de código",
|
||||
"commander": "Comandante",
|
||||
"commanders": "Comandantes",
|
||||
"dashboardPreview": "Pré-visualização do painel",
|
||||
"dashboards": "Painéis",
|
||||
"fileBrowser": "Explorador de ficheiros",
|
||||
"pluginDetail": "Detalhe do plugin",
|
||||
"plugins": "Plugins",
|
||||
"preview": "Pré-visualização",
|
||||
"qrReceive": "Receber por QR",
|
||||
"qrSend": "Enviar por QR",
|
||||
"scope": "Âmbito",
|
||||
"systemMonitor": "Monitor do sistema",
|
||||
"widgets": "Widgets",
|
||||
"terminal": "Terminal",
|
||||
"hostTerminal": "Terminal do anfitrião",
|
||||
"runningShells": "Shells em execução",
|
||||
"pm2Processes": "processos pm2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AppRegistryMap } from './types';
|
||||
import { resolveEntryName } from './types';
|
||||
|
||||
type AppPickerProps = {
|
||||
registry: AppRegistryMap;
|
||||
@@ -18,7 +19,9 @@ export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
|
||||
onClick={() => onSelect(key)}
|
||||
>
|
||||
<entry.icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium whitespace-nowrap">{entry.name}</span>
|
||||
{/* Resolved here, at render. Caching it would pin the name to the locale the registry was
|
||||
seeded with — which is before any locale has loaded. See resolveEntryName. */}
|
||||
<span className="text-xs font-medium whitespace-nowrap">{resolveEntryName(entry)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ComponentType } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { ArrowLeftRight, ChevronLeft, X, Maximize2, Minimize2, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types';
|
||||
import { resolveEntryName } from './types';
|
||||
import { useWorkspace } from './WorkspaceContext';
|
||||
import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from '../core/layout';
|
||||
import { Card } from './internal/Card';
|
||||
@@ -419,7 +420,9 @@ export const PanelSlot = ({
|
||||
) : entry ? (
|
||||
<>
|
||||
<entry.icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium truncate flex-1">{entry.name}</span>
|
||||
{/* Resolved here, at render. Caching it would pin the name to the locale the registry was
|
||||
seeded with — which is before any locale has loaded. See resolveEntryName. */}
|
||||
<span className="text-xs font-medium truncate flex-1">{resolveEntryName(entry)}</span>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export type { WorkspaceViewProps } from './WorkspaceView';
|
||||
export { WorkspaceLayout } from './WorkspaceLayout';
|
||||
export { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||
export { PanelSlot } from './PanelSlot';
|
||||
export { resolveEntryName } from './types';
|
||||
export { WorkspaceProvider, useWorkspace, inertInteraction } from './WorkspaceContext';
|
||||
export type { WorkspaceContextValue } from './WorkspaceContext';
|
||||
export { usePanelConfig } from './usePanelConfig';
|
||||
|
||||
@@ -24,7 +24,17 @@ export type DashboardState = {
|
||||
* an icon library on anyone — Officer's lucide icons satisfy it without changing.
|
||||
*/
|
||||
export type AppRegistryEntry = {
|
||||
name: string;
|
||||
/**
|
||||
* The panel's display name, as a string or as a thunk that returns one.
|
||||
*
|
||||
* A thunk because this package has NO dependencies — not even i18n — and resolving a catalogue key
|
||||
* here would invert the dependency graph: officerdev depends on this, not the other way round. So the
|
||||
* caller binds the words and this only asks for them. A name that never needs translating stays a
|
||||
* plain string, which is why the union keeps `string` rather than requiring a thunk everywhere.
|
||||
*
|
||||
* Call it during render, NEVER cache the result onto the entry — see `resolveEntryName`.
|
||||
*/
|
||||
name: string | (() => string);
|
||||
icon: ComponentType<{ className?: string; size?: number | string }>;
|
||||
component: ComponentType<{ panelId: string }>;
|
||||
header?: ComponentType<{ panelId: string }>;
|
||||
@@ -37,6 +47,17 @@ export type AppRegistryEntry = {
|
||||
|
||||
export type AppRegistryMap = Record<string, AppRegistryEntry>;
|
||||
|
||||
/**
|
||||
* Resolve a panel name for display. Called at every render on purpose.
|
||||
*
|
||||
* Memoising this onto the entry, or hoisting it out of the render, would freeze the name at whatever the
|
||||
* locale was when the registry was seeded — and the registry is seeded once, before the locale has even
|
||||
* loaded. A locale change would then update every string in the app except the panel titles. That is the
|
||||
* `useClient()` shape documented in platform/CLAUDE.md, which cost days on the Jellyfin player.
|
||||
*/
|
||||
export const resolveEntryName = (entry: Pick<AppRegistryEntry, 'name'>): string =>
|
||||
typeof entry.name === 'function' ? entry.name() : entry.name;
|
||||
|
||||
/** A panel supplied by a screen rather than by the registry. Rendered with `panelId`, exactly as those are. */
|
||||
export type PanelComponentEntry = {
|
||||
component: ComponentType<{ panelId: string }>;
|
||||
|
||||
Reference in New Issue
Block a user