Compare commits

...
2 Commits
Author SHA1 Message Date
pastilhas ac890fd996 code editor panel keyed (528 -> 516)
The 'editor buffer changed while unfocused' Error stays English — it is the trap
marker for docs/editor-buffer-corruption.md, read from a log rather than a screen.
2026-08-23 14:18:26 +00:00
pastilhas 24d46a815a system monitor panel keyed (544 -> 528)
Pm2View's column metadata is module-level, so it carries labelKey and the header
resolves it. '#', '↺' and 'PID' keep plain labels — a glyph and an acronym read the
same in every locale, and a key whose two sides can never differ is a cost bought
for nothing.

PanelTranslationKey is now exported from the locales barrel, which panel-level data
tables need in the same way officer-web needed AppTranslationKey.
2026-08-23 14:18:26 +00:00
11 changed files with 112 additions and 31 deletions
@@ -13,6 +13,7 @@ import { useFilesRefresh } from '../../channels';
import { useWorkspace } from '../../components/Workspace'; import { useWorkspace } from '../../components/Workspace';
import { useEditorTabs } from './use-editor-tabs'; import { useEditorTabs } from './use-editor-tabs';
import { configureMonacoTypescript } from './monaco-setup'; import { configureMonacoTypescript } from './monaco-setup';
import { useTranslation } from '../../locales';
type CodeEditorViewProps = { type CodeEditorViewProps = {
className?: string; className?: string;
@@ -103,6 +104,7 @@ export const CodeEditorView = ({
urlState, urlState,
panelId, panelId,
}: CodeEditorViewProps) => { }: CodeEditorViewProps) => {
const { t } = useTranslation();
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const { restored, remember } = useEditorTabs(panelId, workspace); const { restored, remember } = useEditorTabs(panelId, workspace);
const { const {
@@ -138,7 +140,7 @@ export const CodeEditorView = ({
const res = await readFile(path); const res = await readFile(path);
openFile(path, name, res.content); openFile(path, name, res.content);
} catch { } catch {
toast.error('Failed to read file'); toast.error(t('codeEditor.readFailed'));
} }
}; };
@@ -159,7 +161,7 @@ export const CodeEditorView = ({
.then((res) => alive && openFile(path, path.split('/').pop() ?? path, res.content)) .then((res) => alive && openFile(path, path.split('/').pop() ?? path, res.content))
.catch(() => { .catch(() => {
unreadable.current.add(path); unreadable.current.add(path);
if (alive) toast.error('Failed to read file'); if (alive) toast.error(t('codeEditor.readFailed'));
}); });
return () => { return () => {
alive = false; alive = false;
@@ -273,9 +275,9 @@ export const CodeEditorView = ({
try { try {
await writeFile(activeFile.path, activeFile.content); await writeFile(activeFile.path, activeFile.content);
markSaved(activeFile.path, activeFile.content); markSaved(activeFile.path, activeFile.content);
toast.success('File saved'); toast.success(t('codeEditor.saved'));
} catch { } catch {
toast.error('Failed to save file'); toast.error(t('codeEditor.saveFailed'));
} }
}; };
@@ -322,10 +324,10 @@ export const CodeEditorView = ({
className="px-3 py-2 text-xs font-semibold uppercase tracking-wider flex items-center justify-between gap-2" className="px-3 py-2 text-xs font-semibold uppercase tracking-wider flex items-center justify-between gap-2"
style={{ color: '#888', borderBottom: `1px solid ${borderColor}` }} style={{ color: '#888', borderBottom: `1px solid ${borderColor}` }}
> >
<span>Explorer</span> <span>{t('codeEditor.explorer')}</span>
<button <button
onClick={() => setManualRefresh((n) => n + 1)} onClick={() => setManualRefresh((n) => n + 1)}
title="Refresh the tree and reload unmodified open files" title={t('codeEditor.refresh')}
className="p-0.5 rounded hover:bg-white/10 transition-colors cursor-pointer" className="p-0.5 rounded hover:bg-white/10 transition-colors cursor-pointer"
> >
<RefreshCw className="h-3.5 w-3.5" /> <RefreshCw className="h-3.5 w-3.5" />
@@ -383,7 +385,7 @@ export const CodeEditorView = ({
/> />
) : ( ) : (
<div className="flex-1 flex items-center justify-center text-sm" style={{ color: '#888' }}> <div className="flex-1 flex items-center justify-center text-sm" style={{ color: '#888' }}>
Open a file from the explorer to start editing {t('codeEditor.openAFile')}
</div> </div>
)} )}
</div> </div>
@@ -1,12 +1,14 @@
import { Code } from 'lucide-react'; import { Code } from 'lucide-react';
import { useWorkspace } from '../../components/Workspace'; import { useWorkspace } from '../../components/Workspace';
import { useTranslation } from '../../locales';
export const CodeEditorHeader = () => { export const CodeEditorHeader = () => {
const { t } = useTranslation();
const { cwd } = useWorkspace(); const { cwd } = useWorkspace();
return ( return (
<> <>
<Code className="h-3.5 w-3.5 shrink-0" /> <Code className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Code Editor</span> <span className="text-xs font-medium shrink-0">{t('codeEditor.title')}</span>
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span> <span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</> </>
); );
@@ -2,6 +2,7 @@ import { Link } from 'react-router';
import { X, FileX } from 'lucide-react'; import { X, FileX } from 'lucide-react';
import { getIcon } from 'material-file-icons'; import { getIcon } from 'material-file-icons';
import type { OpenFile } from './useEditorState'; import type { OpenFile } from './useEditorState';
import { useTranslation } from '../../locales';
type EditorTabsProps = { type EditorTabsProps = {
files: OpenFile[]; files: OpenFile[];
@@ -19,6 +20,7 @@ const FileIcon = ({ name }: { name: string }) => {
}; };
export const EditorTabs = ({ files, activePath, onSelect, onClose, theme, searchForFile }: EditorTabsProps) => { export const EditorTabs = ({ files, activePath, onSelect, onClose, theme, searchForFile }: EditorTabsProps) => {
const { t } = useTranslation();
if (files.length === 0) return null; if (files.length === 0) return null;
const isDark = theme !== 'vs'; const isDark = theme !== 'vs';
@@ -75,7 +77,7 @@ export const EditorTabs = ({ files, activePath, onSelect, onClose, theme, search
)} )}
<button <button
onClick={() => onClose(file.path)} onClick={() => onClose(file.path)}
aria-label={`Close ${file.name}`} aria-label={t('codeEditor.closeFile', { name: file.name })}
className="p-0.5 rounded cursor-pointer transition-colors hover:bg-white/10" className="p-0.5 rounded cursor-pointer transition-colors hover:bg-white/10"
style={{ color: inactiveColor }} style={{ color: inactiveColor }}
> >
@@ -3,6 +3,7 @@ import { Link } from 'react-router';
import { ChevronRight, ChevronDown, Folder } from 'lucide-react'; import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
import { getIcon } from 'material-file-icons'; import { getIcon } from 'material-file-icons';
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI'; import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
import { useTranslation } from '../../locales';
/** /**
* `searchForFile` turns file rows into real links when the editor owns the address bar. Folder rows stay * `searchForFile` turns file rows into real links when the editor owns the address bar. Folder rows stay
@@ -148,6 +149,7 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth, r
}; };
export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshToken }: FileTreeProps) => { export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshToken }: FileTreeProps) => {
const { t } = useTranslation();
const { listDir } = useFilesAPI(root); const { listDir } = useFilesAPI(root);
const [entries, setEntries] = useState<DirEntry[] | null>(null); const [entries, setEntries] = useState<DirEntry[] | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -187,11 +189,11 @@ export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshTok
} }
if (loading && entries === null) { if (loading && entries === null) {
return <div className="p-2 text-sm text-[#888]">Loading...</div>; return <div className="p-2 text-sm text-[#888]">{t('codeEditor.loading')}</div>;
} }
if (!entries || entries.length === 0) { if (!entries || entries.length === 0) {
return <div className="p-2 text-sm text-[#888]">No files</div>; return <div className="p-2 text-sm text-[#888]">{t('codeEditor.noFiles')}</div>;
} }
return ( return (
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { Cpu, MemoryStick, HardDrive, Server, Thermometer, CircuitBoard, Network, Zap } from 'lucide-react'; import { Cpu, MemoryStick, HardDrive, Server, Thermometer, CircuitBoard, Network, Zap } from 'lucide-react';
import { fmtBytes, fmtDuration } from './shared'; import { fmtBytes, fmtDuration } from './shared';
import { useTranslation } from '../../locales';
type Sensor = { name: string; label: string; celsius: number }; type Sensor = { name: string; label: string; celsius: number };
type Stats = { type Stats = {
@@ -67,6 +68,7 @@ const Card = ({
); );
export const BtopView = () => { export const BtopView = () => {
const { t } = useTranslation();
const { get } = useClient(); const { get } = useClient();
const [stats, setStats] = useState<Stats | null>(null); const [stats, setStats] = useState<Stats | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -133,7 +135,7 @@ export const BtopView = () => {
</div> </div>
</Card> </Card>
<Card title="Memory" icon={MemoryStick}> <Card title={t('systemMonitor.memory')} icon={MemoryStick}>
{stats.mem ? ( {stats.mem ? (
<> <>
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
@@ -154,7 +156,7 @@ export const BtopView = () => {
)} )}
</Card> </Card>
<Card title="Disks" icon={HardDrive}> <Card title={t('systemMonitor.disks')} icon={HardDrive}>
{stats.disks.length ? ( {stats.disks.length ? (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{stats.disks.map((d) => ( {stats.disks.map((d) => (
@@ -195,7 +197,7 @@ export const BtopView = () => {
)} )}
</Card> </Card>
<Card title="Temperature" icon={Thermometer}> <Card title={t('systemMonitor.temperature')} icon={Thermometer}>
{stats.temp?.cpuC != null ? ( {stats.temp?.cpuC != null ? (
<> <>
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
@@ -223,7 +225,7 @@ export const BtopView = () => {
)} )}
</Card> </Card>
<Card title="Network" icon={Network}> <Card title={t('systemMonitor.network')} icon={Network}>
{stats.net ? ( {stats.net ? (
<> <>
<div className="flex items-center justify-between text-lg font-semibold text-foreground"> <div className="flex items-center justify-between text-lg font-semibold text-foreground">
@@ -247,11 +249,11 @@ export const BtopView = () => {
)} )}
</Card> </Card>
<Card title="Power" icon={Zap}> <Card title={t('systemMonitor.power')} icon={Zap}>
{stats.power ? ( {stats.power ? (
<div className="flex flex-col gap-2 pt-1"> <div className="flex flex-col gap-2 pt-1">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">CPU package</span> <span className="text-muted-foreground">{t('systemMonitor.cpuPackage')}</span>
<span className="font-mono text-foreground"> <span className="font-mono text-foreground">
{stats.power.cpuWatts != null ? `${stats.power.cpuWatts} W` : '—'} {stats.power.cpuWatts != null ? `${stats.power.cpuWatts} W` : '—'}
</span> </span>
@@ -269,16 +271,16 @@ export const BtopView = () => {
</Card> </Card>
</div> </div>
<Card title="Top processes" icon={Server}> <Card title={t('systemMonitor.topProcesses')} icon={Server}>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead> <thead>
<tr className="text-xs uppercase tracking-wide text-muted-foreground"> <tr className="text-xs uppercase tracking-wide text-muted-foreground">
<th className="pb-2 pr-4 font-medium">PID</th> <th className="pb-2 pr-4 font-medium">PID</th>
<th className="pb-2 pr-4 font-medium">User</th> <th className="pb-2 pr-4 font-medium">{t('systemMonitor.user')}</th>
<th className="pb-2 pr-4 text-right font-medium">CPU%</th> <th className="pb-2 pr-4 text-right font-medium">{t('systemMonitor.cpuPct')}</th>
<th className="pb-2 pr-4 text-right font-medium">MEM%</th> <th className="pb-2 pr-4 text-right font-medium">{t('systemMonitor.memPct')}</th>
<th className="pb-2 font-medium">Command</th> <th className="pb-2 font-medium">{t('systemMonitor.command')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { LogStream } from './LogStream'; import { LogStream } from './LogStream';
import { useTranslation } from '../../locales';
type Container = { id: string; name: string; image: string; state: string; status: string; ports: string }; type Container = { id: string; name: string; image: string; state: string; status: string; ports: string };
@@ -8,6 +9,7 @@ const POLL_MS = 3000;
const stateColor = (s: string) => (s === 'running' ? 'bg-success' : s === 'exited' ? 'bg-destructive' : 'bg-busy'); const stateColor = (s: string) => (s === 'running' ? 'bg-success' : s === 'exited' ? 'bg-destructive' : 'bg-busy');
export const DockerView = () => { export const DockerView = () => {
const { t } = useTranslation();
const { get } = useClient(); const { get } = useClient();
const [containers, setContainers] = useState<Container[] | null>(null); const [containers, setContainers] = useState<Container[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -59,7 +61,7 @@ export const DockerView = () => {
<input <input
value={filter} value={filter}
onChange={(e) => setFilter(e.target.value)} onChange={(e) => setFilter(e.target.value)}
placeholder="filter name or image…" placeholder={t('systemMonitor.filterPlaceholder')}
className="w-56 rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary" className="w-56 rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
/> />
</div> </div>
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { fmtBytes, fmtDuration } from './shared'; import { fmtBytes, fmtDuration } from './shared';
import { LogStream } from './LogStream'; import { LogStream } from './LogStream';
import type { PanelTranslationKey } from '../../locales';
import { useTranslation } from '../../locales';
type Proc = { type Proc = {
id: number; id: number;
@@ -25,18 +27,24 @@ const statusColor = (s: string) =>
? 'bg-destructive' ? 'bg-destructive'
: 'bg-busy'; : 'bg-busy';
const COLS: { key: SortKey; label: string; align: 'left' | 'right' }[] = [ /*
Column metadata lives outside the component, so it carries catalogue KEYS and the header resolves
them. `#`, `↺` and `PID` have no key: a glyph and an acronym are the same in every locale, and a key
whose two sides can never differ is a maintenance cost bought for nothing.
*/
const COLS: { key: SortKey; labelKey?: PanelTranslationKey; label?: string; align: 'left' | 'right' }[] = [
{ key: 'id', label: '#', align: 'right' }, { key: 'id', label: '#', align: 'right' },
{ key: 'name', label: 'Name', align: 'left' }, { key: 'name', labelKey: 'systemMonitor.name', align: 'left' },
{ key: 'status', label: 'Status', align: 'left' }, { key: 'status', labelKey: 'systemMonitor.status', align: 'left' },
{ key: 'cpuPct', label: 'CPU%', align: 'right' }, { key: 'cpuPct', labelKey: 'systemMonitor.cpuPct', align: 'right' },
{ key: 'memBytes', label: 'Mem', align: 'right' }, { key: 'memBytes', labelKey: 'systemMonitor.mem', align: 'right' },
{ key: 'restarts', label: '↺', align: 'right' }, { key: 'restarts', label: '↺', align: 'right' },
{ key: 'uptimeMs', label: 'Uptime', align: 'left' }, { key: 'uptimeMs', labelKey: 'systemMonitor.uptime', align: 'left' },
{ key: 'pid', label: 'PID', align: 'right' }, { key: 'pid', label: 'PID', align: 'right' },
]; ];
export const Pm2View = () => { export const Pm2View = () => {
const { t } = useTranslation();
const { get } = useClient(); const { get } = useClient();
const [procs, setProcs] = useState<Proc[] | null>(null); const [procs, setProcs] = useState<Proc[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -116,7 +124,7 @@ export const Pm2View = () => {
onClick={() => toggleSort(c.key)} onClick={() => toggleSort(c.key)}
className={`inline-flex items-center gap-1 hover:text-foreground ${c.align === 'right' ? 'flex-row-reverse' : ''} ${sortKey === c.key ? 'text-foreground' : ''}`} className={`inline-flex items-center gap-1 hover:text-foreground ${c.align === 'right' ? 'flex-row-reverse' : ''} ${sortKey === c.key ? 'text-foreground' : ''}`}
> >
{c.label} {c.labelKey ? t(c.labelKey) : c.label}
<span className="w-2 text-[10px]"> <span className="w-2 text-[10px]">
{sortKey === c.key ? (sortDir === 'asc' ? '▲' : '▼') : ''} {sortKey === c.key ? (sortDir === 'asc' ? '▲' : '▼') : ''}
</span> </span>
@@ -51,5 +51,35 @@
"installing": "installing", "installing": "installing",
"failed": "failed", "failed": "failed",
"installedIcon": "installed" "installedIcon": "installed"
},
"systemMonitor": {
"cpuPackage": "CPU package",
"user": "User",
"cpuPct": "CPU%",
"memPct": "MEM%",
"command": "Command",
"memory": "Memory",
"disks": "Disks",
"temperature": "Temperature",
"network": "Network",
"power": "Power",
"topProcesses": "Top processes",
"filterPlaceholder": "filter name or image…",
"name": "Name",
"status": "Status",
"mem": "Mem",
"uptime": "Uptime"
},
"codeEditor": {
"closeFile": "Close {{name}}",
"loading": "Loading…",
"noFiles": "No files",
"explorer": "Explorer",
"openAFile": "Open a file from the explorer to start editing",
"refresh": "Refresh the tree and reload unmodified open files",
"readFailed": "Failed to read file",
"saved": "File saved",
"saveFailed": "Failed to save file",
"title": "Code Editor"
} }
} }
@@ -6,3 +6,4 @@ import ptPT from './pt-PT.json';
export const panelCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT }; export const panelCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT };
export { useTranslation } from './useTranslation'; export { useTranslation } from './useTranslation';
export type { PanelTranslationKey } from './useTranslation';
@@ -51,5 +51,35 @@
"installing": "a instalar", "installing": "a instalar",
"failed": "falhou", "failed": "falhou",
"installedIcon": "instalada" "installedIcon": "instalada"
},
"systemMonitor": {
"cpuPackage": "Pacote do CPU",
"user": "Utilizador",
"cpuPct": "CPU%",
"memPct": "MEM%",
"command": "Comando",
"memory": "Memória",
"disks": "Discos",
"temperature": "Temperatura",
"network": "Rede",
"power": "Energia",
"topProcesses": "Processos principais",
"filterPlaceholder": "filtrar por nome ou imagem…",
"name": "Nome",
"status": "Estado",
"mem": "Mem",
"uptime": "Tempo ativo"
},
"codeEditor": {
"closeFile": "Fechar {{name}}",
"loading": "A carregar…",
"noFiles": "Sem ficheiros",
"explorer": "Explorador",
"openAFile": "Abra um ficheiro no explorador para começar a editar",
"refresh": "Atualizar a árvore e recarregar os ficheiros abertos não modificados",
"readFailed": "Falha ao ler o ficheiro",
"saved": "Ficheiro guardado",
"saveFailed": "Falha ao guardar o ficheiro",
"title": "Editor de código"
} }
} }
@@ -3,7 +3,7 @@ import { useCallback } from 'react';
import { useTranslation as useTranslationBase } from 'i18n'; import { useTranslation as useTranslationBase } from 'i18n';
import type en from './en-GB.json'; import type en from './en-GB.json';
type PanelTranslationKey = TranslationKeys<typeof en>; export type PanelTranslationKey = TranslationKeys<typeof en>;
// The `panel.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they // The `panel.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
// pass the same bare key the JSON file uses. // pass the same bare key the JSON file uses.