refactor(i18n): route remaining extension labels through the catalog (T-468)

Migrate the user-facing strings in menubar, output, search, deeplink, editor,
files, terminal, markdown, and vim to ClideSettings.i18n.string/.interpolated;
extend the existing catalogs and create the missing ones (deeplink, output,
search, markdown, menubar, vim). vim mode labels now resolve through
builtin.vim (default still `-- NORMAL --`, now localizable). A dock-status test
seeds builtin.output so the widget's own i18n lookups resolve instead of
warning into the logRing it counts. No en_US behaviour change (D-21).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 22:16:20 +02:00
co-authored by Claude Opus 4.8
parent 17d22b9571
commit c5500dc235
25 changed files with 495 additions and 97 deletions
@@ -5942,3 +5942,4 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0WCPG458GF5FXTXM9VY98', 'status', 'in_progress', 'done', NULL, '2026-06-18 19:14:41', '2026-06-18 19:14:41.116', '2026-06-18 19:14:41.116', NULL, '2bf6c6fbef7a2e950fc1f31541f8dc22', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0XMA9BQV4RHJ55BFN4JR4', 'status', 'in_progress', 'done', NULL, '2026-06-18 19:19:25', '2026-06-18 19:19:25.269', '2026-06-18 19:19:25.269', NULL, 'f1660567ecfe0c7220c23244206bc4d2', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0YCY2WGSG8W46RHQ5Z9MW', 'status', 'in_progress', 'done', NULL, '2026-06-18 20:13:50', '2026-06-18 20:13:50.352', '2026-06-18 20:13:50.352', NULL, 'a885fb7b56c053669a8ef5f0e6032feb', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0YYCJ0NK8C4HMDYKJHRDG', 'status', 'in_progress', 'done', NULL, '2026-06-18 20:16:03', '2026-06-18 20:16:03.747', '2026-06-18 20:16:03.747', NULL, '5e67beaa758d779f1b9212aecb82e050', 2) ON CONFLICT(hash) DO NOTHING;
+7
View File
@@ -7348,3 +7348,10 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
For each: route hardcoded ClideText/Text/tooltip literals through ctx.i18n.string(..., namespace: ''builtin.<ext>'', placeholder: <current literal>) and add keys.
Acceptance: no hardcoded user-facing literals remain in lib/builtin/{tickets,pql,decisions,problems}/; keys present in each respective catalog.', 'done', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:36.624', '2026-06-18 20:13:50.352', NULL, '919efbfb38b655d5157f73572aa20fad', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0YYCJ0NK8C4HMDYKJHRDG', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate remaining extension labels — menubar, output, search, deeplink, editor, files, terminal, markdown, vim (~24 strings)', 'Tail of the audit: the remaining extensions with hardcoded labels. menubar ~6 (no catalog), output ~4 (no catalog), search ~3 (no catalog), deeplink ~3 (no catalog), editor ~1, files ~1, terminal ~1, markdown ~1 (no catalog), vim ~1 (no catalog).
Several have no catalog file yet create lib/kernel/src/i18n/catalog/builtin.<ext>_en_us.json for menubar, output, search, deeplink, markdown, vim. editor/files/terminal already have catalogs (with orphaned keys) wire their hardcoded literals up and consume the orphans where they match.
Can be split into smaller tasks if a single PR is too large; grouped here to keep the long tail in one tracked place.
Acceptance: no hardcoded user-facing literals remain in any of these extensions; each has a catalog file with the keys it uses; no orphaned keys left in editor/files/terminal.', 'done', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:41.092', '2026-06-18 20:16:03.746', NULL, '8212472a6e32ec3000b829a7abee3cb9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+29 -5
View File
@@ -78,20 +78,44 @@ class _DeepLinkConfirmDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText('Open an external link?', fontSize: clideFontBody, color: t.globalForeground),
ClideText(
ClideSettings.i18n.string(context, 'dialog.title', namespace: 'builtin.deeplink', placeholder: 'Open an external link?'),
fontSize: clideFontBody,
color: t.globalForeground,
),
const SizedBox(height: 6),
ClideText('A clide:// link from outside the app is asking to:', muted: true, fontSize: clideFontSmall),
ClideText(
ClideSettings.i18n.string(context, 'dialog.body', namespace: 'builtin.deeplink', placeholder: 'A clide:// link from outside the app is asking to:'),
muted: true,
fontSize: clideFontSmall,
),
const SizedBox(height: 8),
ClideText(action.describe, fontFamily: ClideSettings.fonts.monoOf(context), fontSize: clideFontSmall, color: t.globalForeground),
const SizedBox(height: 8),
ClideText('Only allow this if you trust where the link came from.', fontSize: clideFontMeta, color: t.statusWarning),
ClideText(
ClideSettings.i18n.string(
context,
'dialog.warning',
namespace: 'builtin.deeplink',
placeholder: 'Only allow this if you trust where the link came from.',
),
fontSize: clideFontMeta,
color: t.statusWarning,
),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(label: 'Cancel', onPressed: () => onResolve(false)),
ClideButton(
label: ClideSettings.i18n.string(context, 'button.cancel', namespace: 'builtin.deeplink', placeholder: 'Cancel'),
onPressed: () => onResolve(false),
),
const SizedBox(width: 8),
ClideButton(label: 'Open', variant: ClideButtonVariant.primary, onPressed: () => onResolve(true)),
ClideButton(
label: ClideSettings.i18n.string(context, 'button.open', namespace: 'builtin.deeplink', placeholder: 'Open'),
variant: ClideButtonVariant.primary,
onPressed: () => onResolve(true),
),
],
),
],
+14 -4
View File
@@ -320,9 +320,19 @@ class _EditorViewState extends State<EditorView> {
builder: (context, _) {
if (c.buffers.isEmpty) {
return ClidePaneChrome(
title: 'editor',
subtitle: 'no buffer · use `clide open <path>` or pick a file in the tree',
child: const Center(child: ClideText('Open a file to begin editing.', muted: true)),
title: ClideSettings.i18n.string(context, 'chrome.title', namespace: 'builtin.editor', placeholder: 'editor'),
subtitle: ClideSettings.i18n.string(
context,
'subtitle.no-buffer',
namespace: 'builtin.editor',
placeholder: 'no buffer · use `clide open <path>` or pick a file in the tree',
),
child: Center(
child: ClideText(
ClideSettings.i18n.string(context, 'empty', namespace: 'builtin.editor', placeholder: 'Open a file to begin editing.'),
muted: true,
),
),
);
}
return MultitabPane<String>(
@@ -392,7 +402,7 @@ class _TextBody extends StatelessWidget {
showCursor: true,
);
return Semantics(
label: 'editor text area',
label: ClideSettings.i18n.string(context, 'a11y.text-area', namespace: 'builtin.editor', placeholder: 'editor text area'),
textField: true,
multiline: true,
child: ColoredBox(
+38 -5
View File
@@ -105,7 +105,10 @@ class _FileTreeViewState extends State<FileTreeView> {
}
final root = c.rootPath;
if (root == null) {
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true));
return Padding(
padding: const EdgeInsets.all(12),
child: ClideText(ClideSettings.i18n.string(context, 'loading', namespace: 'builtin.files', placeholder: 'Loading…'), muted: true),
);
}
final rootName = root.split(Platform.pathSeparator).last;
final selected = c.selectedPath;
@@ -127,10 +130,20 @@ class _FileTreeViewState extends State<FileTreeView> {
);
return Column(
children: [
ClideFilterBox(address: 'files.tree', hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)),
ClideFilterBox(
address: 'files.tree',
hint: ClideSettings.i18n.string(context, 'filter.hint', namespace: 'builtin.files', placeholder: 'Filter files…'),
onChanged: (v) => setState(() => _filter = v),
),
Expanded(
child: Semantics(
label: 'file tree — $rootName',
label: ClideSettings.i18n.interpolated(
context,
'a11y.tree',
namespace: 'builtin.files',
placeholder: 'file tree — {name}',
replacers: [I18nReplacer(from: '{name}', replace: rootName)],
),
container: true,
explicitChildNodes: true,
// Vim nav (j/k/h/l/gg/G/o) drives a selection cursor while this
@@ -206,7 +219,21 @@ class _DirRow extends StatelessWidget {
final selected = path == selectedPath;
return Semantics(
button: true,
label: '${expanded ? 'Collapse' : 'Expand'} $name',
label: expanded
? ClideSettings.i18n.interpolated(
context,
'a11y.collapse',
namespace: 'builtin.files',
placeholder: 'Collapse {name}',
replacers: [I18nReplacer(from: '{name}', replace: name)],
)
: ClideSettings.i18n.interpolated(
context,
'a11y.expand',
namespace: 'builtin.files',
placeholder: 'Expand {name}',
replacers: [I18nReplacer(from: '{name}', replace: name)],
),
onTap: () => controller.toggle(path),
child: _Row(
key: selected ? selectedKey : null,
@@ -235,7 +262,13 @@ class _FileRow extends StatelessWidget {
final selected = path == selectedPath;
return Semantics(
button: true,
label: 'Open $name',
label: ClideSettings.i18n.interpolated(
context,
'a11y.open',
namespace: 'builtin.files',
placeholder: 'Open {name}',
replacers: [I18nReplacer(from: '{name}', replace: name)],
),
onTap: () => _openFile(context, path),
child: _Row(key: selected ? selectedKey : null, depth: depth, onTap: () => _openFile(context, path), label: name, selected: selected),
);
+15 -3
View File
@@ -95,11 +95,23 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
}
if (_content == null) {
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Select a .md file to preview it here.', muted: true));
return Padding(
padding: const EdgeInsets.all(12),
child: ClideText(
ClideSettings.i18n.string(context, 'empty', namespace: 'builtin.markdown', placeholder: 'Select a .md file to preview it here.'),
muted: true,
),
);
}
return ClidePaneChrome(
title: _path ?? 'viewer',
subtitle: '${_content!.split('\n').length} lines',
title: _path ?? ClideSettings.i18n.string(context, 'chrome.title', namespace: 'builtin.markdown', placeholder: 'viewer'),
subtitle: ClideSettings.i18n.interpolated(
context,
'subtitle.lines',
namespace: 'builtin.markdown',
placeholder: '{count} lines',
replacers: [I18nReplacer(from: '{count}', replace: '${_content!.split('\n').length}')],
),
leading: ReaderPinButton(pinned: _nav?.hasPinned ?? false, onTap: _path != null ? _onPin : null),
trailing: [
ReaderActionBar(
+38 -7
View File
@@ -32,18 +32,43 @@ class AboutDialog extends StatelessWidget {
const SizedBox(height: 2),
const ClideText(clideTagline, muted: true, fontSize: 13),
const SizedBox(height: 16),
_Kv(label: 'Version', value: clideVersion, tokens: tokens),
_Kv(label: 'Commit', value: clideCommit, tokens: tokens),
_Kv(label: 'Built', value: _formatDate(clideDate), tokens: tokens),
_Kv(label: 'Repository', value: clideRepository, tokens: tokens),
_Kv(
label: ClideSettings.i18n.string(context, 'about.version', namespace: 'builtin.menubar', placeholder: 'Version'),
value: clideVersion,
tokens: tokens,
),
_Kv(
label: ClideSettings.i18n.string(context, 'about.commit', namespace: 'builtin.menubar', placeholder: 'Commit'),
value: clideCommit,
tokens: tokens,
),
_Kv(
label: ClideSettings.i18n.string(context, 'about.built', namespace: 'builtin.menubar', placeholder: 'Built'),
value: _formatDate(clideDate),
tokens: tokens,
),
_Kv(
label: ClideSettings.i18n.string(context, 'about.repository', namespace: 'builtin.menubar', placeholder: 'Repository'),
value: clideRepository,
tokens: tokens,
),
const SizedBox(height: 16),
ClideText('Bundled dependencies', fontSize: clideFontCaption, color: tokens.globalTextMuted),
ClideText(
ClideSettings.i18n.string(context, 'licenses.heading', namespace: 'builtin.menubar', placeholder: 'Bundled dependencies'),
fontSize: clideFontCaption,
color: tokens.globalTextMuted,
),
const SizedBox(height: 6),
_Licenses(tokens: tokens),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [ClideButton(label: 'Close', onPressed: onDismiss)],
children: [
ClideButton(
label: ClideSettings.i18n.string(context, 'button.close', namespace: 'builtin.menubar', placeholder: 'Close'),
onPressed: onDismiss,
),
],
),
],
),
@@ -86,7 +111,13 @@ class _Licenses extends StatelessWidget {
future: loadLicenses(),
builder: (context, snap) {
if (!snap.hasData) {
return ClideText(snap.hasError ? 'Licenses unavailable.' : 'Loading…', fontSize: 12, color: tokens.globalTextMuted);
return ClideText(
snap.hasError
? ClideSettings.i18n.string(context, 'licenses.unavailable', namespace: 'builtin.menubar', placeholder: 'Licenses unavailable.')
: ClideSettings.i18n.string(context, 'licenses.loading', namespace: 'builtin.menubar', placeholder: 'Loading…'),
fontSize: 12,
color: tokens.globalTextMuted,
);
}
final deps = snap.data!.dependencies;
return Container(
+49 -8
View File
@@ -123,9 +123,17 @@ class _OpenFolderDialogState extends State<OpenFolderDialog> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ClideText('Open project', fontSize: 16, fontWeight: FontWeight.w600),
ClideText(
ClideSettings.i18n.string(context, 'dialog.openProject.title', namespace: 'builtin.menubar', placeholder: 'Open project'),
fontSize: 16,
fontWeight: FontWeight.w600,
),
const SizedBox(height: 4),
const ClideText('Enter the path to a git repository.', muted: true, fontSize: 13),
ClideText(
ClideSettings.i18n.string(context, 'dialog.openProject.body', namespace: 'builtin.menubar', placeholder: 'Enter the path to a git repository.'),
muted: true,
fontSize: 13,
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
@@ -148,14 +156,29 @@ class _OpenFolderDialogState extends State<OpenFolderDialog> {
onSubmitted: (_) => unawaited(_submit()),
),
),
if (_error != null) ...[const SizedBox(height: 8), ClideText(_error!, color: tokens.statusError, fontSize: 12)],
if (_error != null) ...[
const SizedBox(height: 8),
ClideText(
ClideSettings.i18n.string(context, 'dialog.openProject.error', namespace: 'builtin.menubar', placeholder: 'Not a git repository'),
color: tokens.statusError,
fontSize: 12,
),
],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(label: 'Cancel', onPressed: widget.onCancel),
ClideButton(
label: ClideSettings.i18n.string(context, 'button.cancel', namespace: 'builtin.menubar', placeholder: 'Cancel'),
onPressed: widget.onCancel,
),
const SizedBox(width: 8),
ClideButton(label: _loading ? 'Opening…' : 'Open', onPressed: _loading ? null : _submit),
ClideButton(
label: _loading
? ClideSettings.i18n.string(context, 'button.opening', namespace: 'builtin.menubar', placeholder: 'Opening…')
: ClideSettings.i18n.string(context, 'button.open', namespace: 'builtin.menubar', placeholder: 'Open'),
onPressed: _loading ? null : _submit,
),
],
),
],
@@ -186,15 +209,33 @@ class NotARepoDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ClideText('No git repo found', fontSize: 16, fontWeight: FontWeight.w600),
ClideText(
ClideSettings.i18n.string(context, 'dialog.notRepo.title', namespace: 'builtin.menubar', placeholder: 'No git repo found'),
fontSize: 16,
fontWeight: FontWeight.w600,
),
const SizedBox(height: 8),
ClideText(path, muted: true, fontSize: 13),
const SizedBox(height: 8),
const ClideText('A clide project root requires a git repository.', muted: true, fontSize: 13),
ClideText(
ClideSettings.i18n.string(
context,
'dialog.notRepo.body',
namespace: 'builtin.menubar',
placeholder: 'A clide project root requires a git repository.',
),
muted: true,
fontSize: 13,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [ClideButton(label: 'OK', onPressed: () => onDismiss())],
children: [
ClideButton(
label: ClideSettings.i18n.string(context, 'button.ok', namespace: 'builtin.menubar', placeholder: 'OK'),
onPressed: () => onDismiss(),
),
],
),
],
),
+15 -1
View File
@@ -187,10 +187,24 @@ class _TopMenuButtonState extends State<_TopMenuButton> {
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: clideInsetStandard),
color: open || hovered ? tokens.listItemHoverBackground : null,
child: ClideText(widget.menu.title, fontSize: 12, color: open || hovered ? tokens.globalForeground : tokens.chromeForeground),
child: ClideText(_menuTitle(ctx, widget.menu.title), fontSize: 12, color: open || hovered ? tokens.globalForeground : tokens.chromeForeground),
),
),
),
);
}
/// Localize a curated top-menu title. The curated tree authors English
/// titles ('File'/'View'/'Help'); resolve each to its catalog entry, falling
/// back to the authored English (which also drives the Alt mnemonic).
String _menuTitle(BuildContext context, String title) {
final key = switch (title) {
'File' => 'menu.file',
'View' => 'menu.view',
'Help' => 'menu.help',
_ => null,
};
if (key == null) return title;
return ClideSettings.i18n.string(context, key, namespace: 'builtin.menubar', placeholder: title);
}
}
+3 -2
View File
@@ -62,9 +62,10 @@ class _DockStatusItemState extends State<DockStatusItem> {
: warns > 0
? ('$warns', tokens.statusWarning)
: ('', tokens.statusSuccess);
final dockLabel = ClideSettings.i18n.string(ctx, 'dock.label', namespace: 'builtin.output', placeholder: 'Output');
return Semantics(
button: true,
label: 'toggle output dock',
label: ClideSettings.i18n.string(ctx, 'a11y.toggleDock', namespace: 'builtin.output', placeholder: 'toggle output dock'),
child: GestureDetector(
onTap: () => _toggle(kernel),
child: MouseRegion(
@@ -74,7 +75,7 @@ class _DockStatusItemState extends State<DockStatusItem> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideText('${open ? '' : ''} Output ', fontSize: clideFontCaption, color: tokens.globalForeground),
ClideText('${open ? '' : ''} $dockLabel ', fontSize: clideFontCaption, color: tokens.globalForeground),
ClideText(badge, fontSize: clideFontCaption, color: color),
],
),
+45 -10
View File
@@ -78,18 +78,23 @@ class _OutputViewState extends State<OutputView> {
final tokens = ClideSettings.theme.of(context).surface;
final rows = _c.filtered;
return Semantics(
label: 'output log',
label: ClideSettings.i18n.string(context, 'a11y.log', namespace: 'builtin.output', placeholder: 'output log'),
container: true,
explicitChildNodes: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(tokens),
_header(context, tokens),
Expanded(
child: rows.isEmpty
? Padding(
padding: const EdgeInsets.all(12),
child: ClideText(widget.ring.isEmpty ? 'No output yet.' : 'No output matches the filter.', muted: true),
child: ClideText(
widget.ring.isEmpty
? ClideSettings.i18n.string(context, 'empty', namespace: 'builtin.output', placeholder: 'No output yet.')
: ClideSettings.i18n.string(context, 'empty.filtered', namespace: 'builtin.output', placeholder: 'No output matches the filter.'),
muted: true,
),
)
: Stack(
children: [
@@ -113,7 +118,8 @@ class _OutputViewState extends State<OutputView> {
);
}
Widget _header(SurfaceTokens tokens) {
Widget _header(BuildContext context, SurfaceTokens tokens) {
final sourceValue = _c.source ?? ClideSettings.i18n.string(context, 'source.all', namespace: 'builtin.output', placeholder: 'all');
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
@@ -122,14 +128,39 @@ class _OutputViewState extends State<OutputView> {
child: Row(
children: [
Expanded(
child: ClideFilterBox(address: 'output.panel', hint: 'Filter…', onChanged: _c.setText),
child: ClideFilterBox(
address: 'output.panel',
hint: ClideSettings.i18n.string(context, 'filter.hint', namespace: 'builtin.output', placeholder: 'Filter…'),
onChanged: _c.setText,
),
),
const SizedBox(width: 8),
_Chip(label: 'Level: ${_c.minLevel.name}', onTap: () => _c.setMinLevel(LogLevel.values[(_c.minLevel.index + 1) % LogLevel.values.length])),
_Chip(
label: ClideSettings.i18n.interpolated(
context,
'chip.level',
namespace: 'builtin.output',
placeholder: 'Level: {level}',
replacers: [I18nReplacer(from: '{level}', replace: _c.minLevel.name)],
),
onTap: () => _c.setMinLevel(LogLevel.values[(_c.minLevel.index + 1) % LogLevel.values.length]),
),
const SizedBox(width: 6),
_Chip(label: 'Source: ${_c.source ?? 'all'}', onTap: _cycleSource),
_Chip(
label: ClideSettings.i18n.interpolated(
context,
'chip.source',
namespace: 'builtin.output',
placeholder: 'Source: {source}',
replacers: [I18nReplacer(from: '{source}', replace: sourceValue)],
),
onTap: _cycleSource,
),
const SizedBox(width: 6),
_Chip(label: 'Clear', onTap: _c.clear),
_Chip(
label: ClideSettings.i18n.string(context, 'chip.clear', namespace: 'builtin.output', placeholder: 'Clear'),
onTap: _c.clear,
),
],
),
);
@@ -180,7 +211,7 @@ class _JumpPill extends StatelessWidget {
final tokens = ClideSettings.theme.of(context).surface;
return Semantics(
button: true,
label: 'jump to latest',
label: ClideSettings.i18n.string(context, 'a11y.jumpToLatest', namespace: 'builtin.output', placeholder: 'jump to latest'),
child: GestureDetector(
onTap: onTap,
child: MouseRegion(
@@ -192,7 +223,11 @@ class _JumpPill extends StatelessWidget {
border: Border.all(color: tokens.globalFocus),
borderRadius: BorderRadius.circular(12),
),
child: ClideText('Jump to latest ↓', fontSize: clideFontCaption, color: tokens.globalFocus),
child: ClideText(
ClideSettings.i18n.string(context, 'jump.label', namespace: 'builtin.output', placeholder: 'Jump to latest ↓'),
fontSize: clideFontCaption,
color: tokens.globalFocus,
),
),
),
),
+100 -36
View File
@@ -51,14 +51,33 @@ class _SearchPanelViewState extends State<SearchPanelView> {
final dialog = ClideKernel.of(context).dialog;
if (!await c.isWorkingTreeClean()) {
await dialog.show<Object>(
(ctx, dismiss) =>
_MessageDialog(title: 'Working tree not clean', body: 'Commit or stash your changes before replacing — git is the only undo.', dismiss: dismiss),
(ctx, dismiss) => _MessageDialog(
title: ClideSettings.i18n.string(ctx, 'dialog.dirty.title', namespace: 'builtin.search', placeholder: 'Working tree not clean'),
body: ClideSettings.i18n.string(
ctx,
'dialog.dirty.body',
namespace: 'builtin.search',
placeholder: 'Commit or stash your changes before replacing — git is the only undo.',
),
dismiss: dismiss,
),
);
return;
}
final confirmed = await dialog.show<bool>(
(ctx, dismiss) =>
_ConfirmDialog(body: 'Replace ${c.matchCount} match(es) across ${c.fileCount} file(s)? This cannot be undone in clide.', dismiss: dismiss),
(ctx, dismiss) => _ConfirmDialog(
body: ClideSettings.i18n.interpolated(
ctx,
'dialog.confirm.body',
namespace: 'builtin.search',
placeholder: 'Replace {matches} match(es) across {files} file(s)? This cannot be undone in clide.',
replacers: [
I18nReplacer(from: '{matches}', replace: '${c.matchCount}'),
I18nReplacer(from: '{files}', replace: '${c.fileCount}'),
],
),
dismiss: dismiss,
),
);
if (confirmed != true) return;
await c.applyReplace();
@@ -104,13 +123,18 @@ class _SearchPanelViewState extends State<SearchPanelView> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClideFilterBox(address: 'search.findInFiles', hint: 'Search', onChanged: c.run, onSubmitted: c.run),
ClideFilterBox(
address: 'search.findInFiles',
hint: ClideSettings.i18n.string(context, 'find.hint', namespace: 'builtin.search', placeholder: 'Search'),
onChanged: c.run,
onSubmitted: c.run,
),
const SizedBox(height: 6),
Row(
children: [
_Toggle(
label: '.*',
tooltip: 'Regular expression',
tooltip: ClideSettings.i18n.string(context, 'toggle.regex', namespace: 'builtin.search', placeholder: 'Regular expression'),
active: c.regex,
tokens: tokens,
onTap: () {
@@ -121,7 +145,7 @@ class _SearchPanelViewState extends State<SearchPanelView> {
const SizedBox(width: 6),
_Toggle(
label: 'Aa',
tooltip: 'Case insensitive',
tooltip: ClideSettings.i18n.string(context, 'toggle.caseInsensitive', namespace: 'builtin.search', placeholder: 'Case insensitive'),
active: c.ignoreCase,
tokens: tokens,
onTap: () {
@@ -139,7 +163,7 @@ class _SearchPanelViewState extends State<SearchPanelView> {
Expanded(
child: ClideFilterBox(
address: 'search.findInFiles.replace',
hint: 'Replace',
hint: ClideSettings.i18n.string(context, 'replace.hint', namespace: 'builtin.search', placeholder: 'Replace'),
icon: null,
debounce: Duration.zero,
onChanged: c.setReplacement,
@@ -152,7 +176,7 @@ class _SearchPanelViewState extends State<SearchPanelView> {
const SizedBox(height: 6),
ClideFilterBox(
address: 'search.findInFiles.include',
hint: 'files to include (e.g. *.dart)',
hint: ClideSettings.i18n.string(context, 'include.hint', namespace: 'builtin.search', placeholder: 'files to include (e.g. *.dart)'),
icon: null,
debounce: Duration.zero,
onChanged: (v) => c.include = v,
@@ -160,7 +184,7 @@ class _SearchPanelViewState extends State<SearchPanelView> {
const SizedBox(height: 4),
ClideFilterBox(
address: 'search.findInFiles.exclude',
hint: 'files to exclude',
hint: ClideSettings.i18n.string(context, 'exclude.hint', namespace: 'builtin.search', placeholder: 'files to exclude'),
icon: null,
debounce: Duration.zero,
onChanged: (v) => c.exclude = v,
@@ -196,7 +220,14 @@ class _ModeSwitcher extends StatelessWidget {
final SurfaceTokens tokens;
final ValueChanged<SearchTabMode> onSelect;
static const _labels = {SearchTabMode.find: 'Find', SearchTabMode.vault: 'Vault', SearchTabMode.query: 'Query', SearchTabMode.markdown: 'Markdown'};
// (catalog key, English label) per mode. The key/English pair resolves
// through the i18n catalog at render (D-21).
static const _labels = {
SearchTabMode.find: ('mode.find', 'Find'),
SearchTabMode.vault: ('mode.vault', 'Vault'),
SearchTabMode.query: ('mode.query', 'Query'),
SearchTabMode.markdown: ('mode.markdown', 'Markdown'),
};
@override
Widget build(BuildContext context) {
@@ -207,26 +238,28 @@ class _ModeSwitcher extends StatelessWidget {
),
child: Row(
children: [
for (final m in SearchTabMode.values) ...[
Semantics(
button: true,
selected: m == mode,
label: _labels[m]!,
child: ClideTappable(
onTap: () => onSelect(m),
builder: (ctx, hovered, _) => ClideText(
_labels[m]!,
fontSize: clideFontCaption,
color: m == mode ? tokens.globalForeground : (hovered ? tokens.sidebarForeground : tokens.globalTextMuted),
),
),
),
const SizedBox(width: 10),
],
for (final m in SearchTabMode.values) ...[_modeButton(context, m), const SizedBox(width: 10)],
],
),
);
}
Widget _modeButton(BuildContext context, SearchTabMode m) {
final label = ClideSettings.i18n.string(context, _labels[m]!.$1, namespace: 'builtin.search', placeholder: _labels[m]!.$2);
return Semantics(
button: true,
selected: m == mode,
label: label,
child: ClideTappable(
onTap: () => onSelect(m),
builder: (ctx, hovered, _) => ClideText(
label,
fontSize: clideFontCaption,
color: m == mode ? tokens.globalForeground : (hovered ? tokens.sidebarForeground : tokens.globalTextMuted),
),
),
);
}
}
class _Toggle extends StatelessWidget {
@@ -274,11 +307,20 @@ class _StatusText extends StatelessWidget {
Widget build(BuildContext context) {
final String text;
if (c.running) {
text = 'Searching…';
text = ClideSettings.i18n.string(context, 'status.searching', namespace: 'builtin.search', placeholder: 'Searching…');
} else if (c.matchCount == 0 && c.done) {
text = 'No results';
text = ClideSettings.i18n.string(context, 'status.noResults', namespace: 'builtin.search', placeholder: 'No results');
} else if (c.matchCount > 0) {
text = '${c.matchCount} in ${c.fileCount}';
text = ClideSettings.i18n.interpolated(
context,
'status.counts',
namespace: 'builtin.search',
placeholder: '{matches} in {files}',
replacers: [
I18nReplacer(from: '{matches}', replace: '${c.matchCount}'),
I18nReplacer(from: '{files}', replace: '${c.fileCount}'),
],
);
} else {
text = '';
}
@@ -332,7 +374,16 @@ class _MatchRow extends StatelessWidget {
Widget build(BuildContext context) {
return Semantics(
button: true,
label: 'Open ${match.path} line ${match.line}',
label: ClideSettings.i18n.interpolated(
context,
'a11y.openMatch',
namespace: 'builtin.search',
placeholder: 'Open {path} line {line}',
replacers: [
I18nReplacer(from: '{path}', replace: match.path),
I18nReplacer(from: '{line}', replace: '${match.line}'),
],
),
onTap: onTap,
child: ClideTappable(
onTap: onTap,
@@ -414,10 +465,11 @@ class _ReplaceAllButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final label = ClideSettings.i18n.string(context, 'button.replaceAll', namespace: 'builtin.search', placeholder: 'Replace all');
return Semantics(
button: true,
enabled: enabled,
label: 'Replace all',
label: label,
child: ClideTappable(
onTap: enabled ? onTap : () {},
builder: (context, hovered, _) => Container(
@@ -427,7 +479,7 @@ class _ReplaceAllButton extends StatelessWidget {
border: Border.all(color: tokens.buttonBorder),
borderRadius: BorderRadius.circular(4),
),
child: ClideText('Replace all', fontSize: clideFontCaption, color: enabled ? tokens.sidebarForeground : tokens.globalTextMuted),
child: ClideText(label, fontSize: clideFontCaption, color: enabled ? tokens.sidebarForeground : tokens.globalTextMuted),
),
),
);
@@ -453,7 +505,11 @@ class _MessageDialog extends StatelessWidget {
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: _DialogButton(label: 'OK', tokens: tokens, onTap: () => dismiss()),
child: _DialogButton(
label: ClideSettings.i18n.string(context, 'button.ok', namespace: 'builtin.search', placeholder: 'OK'),
tokens: tokens,
onTap: () => dismiss(),
),
),
],
);
@@ -477,9 +533,17 @@ class _ConfirmDialog extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_DialogButton(label: 'Cancel', tokens: tokens, onTap: () => dismiss(false)),
_DialogButton(
label: ClideSettings.i18n.string(context, 'button.cancel', namespace: 'builtin.search', placeholder: 'Cancel'),
tokens: tokens,
onTap: () => dismiss(false),
),
const SizedBox(width: 8),
_DialogButton(label: 'Confirm', tokens: tokens, onTap: () => dismiss(true)),
_DialogButton(
label: ClideSettings.i18n.string(context, 'button.confirm', namespace: 'builtin.search', placeholder: 'Confirm'),
tokens: tokens,
onTap: () => dismiss(true),
),
],
),
],
+32 -4
View File
@@ -140,14 +140,38 @@ class _TerminalPaneState extends State<TerminalPane> {
@override
Widget build(BuildContext context) {
final subtitle = _error != null ? _error! : (_paneId == null ? 'spawning shell…' : 'pid $_pid · ${_paneId!}');
final spawning = ClideSettings.i18n.string(context, 'subtitle.spawning', namespace: 'builtin.terminal', placeholder: 'spawning shell…');
final errorText = _error == null ? null : _localizeError(context, _error!);
final subtitle = errorText ?? (_paneId == null ? spawning : 'pid $_pid · ${_paneId!}');
return ClidePaneChrome(
title: 'terminal',
title: ClideSettings.i18n.string(context, 'chrome.title', namespace: 'builtin.terminal', placeholder: 'terminal'),
subtitle: subtitle,
child: _error != null ? _ErrorBody(message: _error!) : ClidePtyView(terminal: _terminal, label: 'terminal — $subtitle'),
child: errorText != null
? _ErrorBody(message: errorText)
: ClidePtyView(
terminal: _terminal,
label: ClideSettings.i18n.interpolated(
context,
'a11y.label',
namespace: 'builtin.terminal',
placeholder: 'terminal — {subtitle}',
replacers: [I18nReplacer(from: '{subtitle}', replace: subtitle)],
),
),
);
}
/// Localize the two known error literals stored in [_error] (set in async
/// handlers where `context` can't be used); any other value — e.g. a daemon
/// `response.error?.message` — is dynamic data and passes through as-is.
String _localizeError(BuildContext context, String raw) {
return switch (raw) {
'Backend not connected.' => ClideSettings.i18n.string(context, 'error.daemon', namespace: 'builtin.terminal', placeholder: 'Backend not connected.'),
'Shell exited.' => ClideSettings.i18n.string(context, 'subtitle.exited', namespace: 'builtin.terminal', placeholder: 'Shell exited.'),
_ => raw,
};
}
}
class _ErrorBody extends StatelessWidget {
@@ -161,7 +185,11 @@ class _ErrorBody extends StatelessWidget {
alignment: Alignment.topLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [const ClideText('Terminal unavailable'), const SizedBox(height: 4), ClideText(message, muted: true)],
children: [
ClideText(ClideSettings.i18n.string(context, 'error.unavailable', namespace: 'builtin.terminal', placeholder: 'Terminal unavailable')),
const SizedBox(height: 4),
ClideText(message, muted: true),
],
),
),
);
+3 -6
View File
@@ -17,14 +17,11 @@ class VimModeIndicator extends StatelessWidget {
builder: (context, _) {
if (!service.enabled) return const SizedBox.shrink();
final tokens = ClideSettings.theme.of(context).surface;
final mode = service.mode;
final label = ClideSettings.i18n.string(context, 'mode.${mode.name}', namespace: 'builtin.vim', placeholder: mode.label);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ClideText(
'-- ${service.mode.label} --',
fontFamily: ClideSettings.fonts.monoOf(context),
fontSize: clideFontCaption,
color: tokens.statusBarForeground,
),
child: ClideText('-- $label --', fontFamily: ClideSettings.fonts.monoOf(context), fontSize: clideFontCaption, color: tokens.statusBarForeground),
);
},
);
+3 -2
View File
@@ -31,8 +31,9 @@ enum VimMode {
/// The keymap scope flag set true exactly when this mode is active.
final String scopeFlag;
/// Status-bar label, shown as `-- NORMAL --` (Vim's own vocabulary;
/// intentionally untranslated, like Vim itself).
/// English status-bar label, shown as `-- NORMAL --`. Used as the i18n
/// placeholder for `mode.<name>` in the builtin.vim catalog (T-462); the
/// default keeps Vim's own vocabulary, but a locale may override it.
final String label;
}
@@ -0,0 +1,7 @@
{
"dialog.title": { "translation": "Open an external link?" },
"dialog.body": { "translation": "A clide:// link from outside the app is asking to:" },
"dialog.warning": { "translation": "Only allow this if you trust where the link came from." },
"button.cancel": { "translation": "Cancel" },
"button.open": { "translation": "Open" }
}
@@ -1,5 +1,7 @@
{
"tab.title": { "translation": "Editor" },
"chrome.title": { "translation": "editor" },
"empty": { "translation": "Open a file to begin editing." },
"subtitle.no-buffer": { "translation": "no buffer · use `clide open <path>` or pick a file in the tree" }
"subtitle.no-buffer": { "translation": "no buffer · use `clide open <path>` or pick a file in the tree" },
"a11y.text-area": { "translation": "editor text area" }
}
@@ -1,5 +1,10 @@
{
"tab.title": { "translation": "Files" },
"loading": { "translation": "Loading…" },
"empty": { "translation": "No visible files" }
"empty": { "translation": "No visible files" },
"filter.hint": { "translation": "Filter files…" },
"a11y.tree": { "translation": "file tree — {name}" },
"a11y.collapse": { "translation": "Collapse {name}" },
"a11y.expand": { "translation": "Expand {name}" },
"a11y.open": { "translation": "Open {name}" }
}
@@ -0,0 +1,5 @@
{
"empty": { "translation": "Select a .md file to preview it here." },
"chrome.title": { "translation": "viewer" },
"subtitle.lines": { "translation": "{count} lines" }
}
@@ -0,0 +1,22 @@
{
"menu.file": { "translation": "File" },
"menu.view": { "translation": "View" },
"menu.help": { "translation": "Help" },
"about.version": { "translation": "Version" },
"about.commit": { "translation": "Commit" },
"about.built": { "translation": "Built" },
"about.repository": { "translation": "Repository" },
"licenses.heading": { "translation": "Bundled dependencies" },
"licenses.unavailable": { "translation": "Licenses unavailable." },
"licenses.loading": { "translation": "Loading…" },
"button.close": { "translation": "Close" },
"button.cancel": { "translation": "Cancel" },
"button.open": { "translation": "Open" },
"button.opening": { "translation": "Opening…" },
"button.ok": { "translation": "OK" },
"dialog.openProject.title": { "translation": "Open project" },
"dialog.openProject.body": { "translation": "Enter the path to a git repository." },
"dialog.openProject.error": { "translation": "Not a git repository" },
"dialog.notRepo.title": { "translation": "No git repo found" },
"dialog.notRepo.body": { "translation": "A clide project root requires a git repository." }
}
@@ -0,0 +1,14 @@
{
"a11y.log": { "translation": "output log" },
"empty": { "translation": "No output yet." },
"empty.filtered": { "translation": "No output matches the filter." },
"filter.hint": { "translation": "Filter…" },
"chip.level": { "translation": "Level: {level}" },
"chip.source": { "translation": "Source: {source}" },
"chip.clear": { "translation": "Clear" },
"source.all": { "translation": "all" },
"a11y.jumpToLatest": { "translation": "jump to latest" },
"jump.label": { "translation": "Jump to latest ↓" },
"a11y.toggleDock": { "translation": "toggle output dock" },
"dock.label": { "translation": "Output" }
}
@@ -0,0 +1,23 @@
{
"mode.find": { "translation": "Find" },
"mode.vault": { "translation": "Vault" },
"mode.query": { "translation": "Query" },
"mode.markdown": { "translation": "Markdown" },
"find.hint": { "translation": "Search" },
"replace.hint": { "translation": "Replace" },
"include.hint": { "translation": "files to include (e.g. *.dart)" },
"exclude.hint": { "translation": "files to exclude" },
"toggle.regex": { "translation": "Regular expression" },
"toggle.caseInsensitive": { "translation": "Case insensitive" },
"status.searching": { "translation": "Searching…" },
"status.noResults": { "translation": "No results" },
"status.counts": { "translation": "{matches} in {files}" },
"a11y.openMatch": { "translation": "Open {path} line {line}" },
"button.replaceAll": { "translation": "Replace all" },
"button.ok": { "translation": "OK" },
"button.cancel": { "translation": "Cancel" },
"button.confirm": { "translation": "Confirm" },
"dialog.dirty.title": { "translation": "Working tree not clean" },
"dialog.dirty.body": { "translation": "Commit or stash your changes before replacing — git is the only undo." },
"dialog.confirm.body": { "translation": "Replace {matches} match(es) across {files} file(s)? This cannot be undone in clide." }
}
@@ -1,7 +1,9 @@
{
"tab.title": { "translation": "Terminal" },
"chrome.title": { "translation": "terminal" },
"subtitle.spawning": { "translation": "spawning shell…" },
"subtitle.exited": { "translation": "Shell exited." },
"error.unavailable": { "translation": "Terminal unavailable" },
"error.daemon": { "translation": "Backend not connected." }
"error.daemon": { "translation": "Backend not connected." },
"a11y.label": { "translation": "terminal — {subtitle}" }
}
@@ -0,0 +1,5 @@
{
"mode.normal": { "translation": "NORMAL" },
"mode.insert": { "translation": "INSERT" },
"mode.visual": { "translation": "VISUAL" }
}
+15 -1
View File
@@ -3,6 +3,8 @@ library;
import 'package:clide/builtin/default_layout/default_layout.dart';
import 'package:clide/builtin/output/output.dart';
import 'dart:ui';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -16,7 +18,19 @@ void main() {
late KernelFixture f;
setUp(() async {
f = await KernelFixture.create();
// Seed builtin.output so the widget's own i18n lookups resolve — as they do
// at boot in the real app. Otherwise "namespace not registered" warnings
// land in the logRing this very widget counts, skewing the health badge.
f = await KernelFixture.create(
i18nCatalogs: {
'builtin.output': {
const Locale('en', 'US'): const {
'dock.label': {'translation': 'Output'},
'a11y.toggleDock': {'translation': 'toggle output dock'},
},
},
},
);
f.services.extensions.register(DefaultLayoutExtension());
await f.services.extensions.activate('builtin.default-layout');
});