From 198c8b18b5caaa0cae126383b067c790b41d410d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 19 Jun 2026 09:44:35 +0200 Subject: [PATCH] feat(i18n): i18n key fields on command + settings contributions (T-462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets manifest labels (command-palette/menu titles, settings labels) localize, not just displayed widget strings. Adds optional titleKey/i18nNamespace to CommandContribution and labelKey/helpKey/titleKey + a category i18nNamespace to the settings schema. The command palette and menu bar now resolve titles via a shared localizedCommandTitle helper — and the palette's fuzzy search matches the localized title too (PaletteController.titleResolver). No behaviour change until the per-extension keys + catalog entries land (placeholder == English). Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 1 + .pql/changelog/ticket_idmap/2026-06.sql | 2 ++ .pql/changelog/tickets/2026-06.sql | 2 ++ lib/builtin/menubar/src/menu_bar.dart | 8 +++++- lib/builtin/menubar/src/menu_model.dart | 10 +++++-- lib/extension/src/contribution.dart | 20 +++++++++++++- lib/kernel/src/commands/palette.dart | 10 +++++-- lib/kernel/src/settings_schema.dart | 32 ++++++++++++++++++++--- lib/widgets/src/clide_palette.dart | 17 +++++++++++- 9 files changed, 91 insertions(+), 11 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 567fdb79..f90afe01 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -5944,3 +5944,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 ('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; 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 ('06FDA10DRQE3SED10JAW2CZDCR', 'status', 'backlog', 'done', NULL, '2026-06-18 21:21:27', '2026-06-18 21:21:27.304', '2026-06-18 21:21:27.304', NULL, '0ab235b21c54d46de3e3e4d1529de041', 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 ('06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'description', NULL, 'Today clide uses Claude Code''s system-wide login (the CLI''s global auth), so every repo/workspace shares one Claude account. Allow selecting/logging into a separate Claude account per repo, so different projects can run under different accounts (e.g. personal vs work). Needs: per-workspace auth storage + a way for the spawned Claude session to use that repo''s account instead of the global one; UI to sign in/switch per repo; fall back to the system login when none is set.', NULL, '2026-06-19 07:42:08', '2026-06-19 07:42:08.761', '2026-06-19 07:42:08.761', NULL, '04b83ed9bd668d2cb22e75d18d1c2c94', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/ticket_idmap/2026-06.sql b/.pql/changelog/ticket_idmap/2026-06.sql index 907b64b6..235cd920 100644 --- a/.pql/changelog/ticket_idmap/2026-06.sql +++ b/.pql/changelog/ticket_idmap/2026-06.sql @@ -300,3 +300,5 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDDTCGG5Z0KNQKF89W1VZSQ8', 'T-472', '2026-06-17 18:48:11.649', '2026-06-17 18:48:11.649', NULL, '097120cff851cb2dac9f54937f4c7b17', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDDX5GVH3FTCVDEC1QAFACY4', 'T-473', '2026-06-17 19:00:20.828', '2026-06-17 19:00:20.828', NULL, '647782edc3fbb4e7285e4f82a1fb84be', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDDX5KEAJ8KRWVV7ZSG6H7A0', 'T-474', '2026-06-17 19:00:21.490', '2026-06-17 19:00:21.490', NULL, '79576f18553ed885d3a2b745becdf2f5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDEMY3DQWDNY535PMMSB0CSW', 'T-475', '2026-06-17 20:44:11.501', '2026-06-17 20:44:11.501', NULL, 'bb2b56ea9a76d967123238f588c70aba', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'T-476', '2026-06-19 07:42:08.735', '2026-06-19 07:42:08.735', NULL, 'f75a0a8c00b0ec40c110c1169e42fe2c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 268c9fe5..bf883840 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -7364,3 +7364,5 @@ Hotspots from the audit: Open question to settle in this task: do framework widgets get a ''core''/''kernel'' namespace, or does each caller pass its owning extension''s namespace down? Pick one and note it (small D- or Q- record if it sets precedent). Single-char icon glyphs (''+'', '':'') are exempt — note any left. Acceptance: real user-facing sentences in lib/widgets + lib/kernel resolve through i18n under a documented namespace; glyph exemptions noted.', 'done', 'low', NULL, NULL, 'D-21', '2026-06-17 09:57:53.221', '2026-06-18 21:21:27.304', NULL, '9817d717fa77916d07547d9674d14fcb', 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 ('06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'story', NULL, 'Per-repo Claude account login (not system-wide)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-19 07:42:08.735', '2026-06-19 07:42:08.735', NULL, '35baee58ff25f10d9ba81902bd4072b8', 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 ('06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'story', NULL, 'Per-repo Claude account login (not system-wide)', 'Today clide uses Claude Code''s system-wide login (the CLI''s global auth), so every repo/workspace shares one Claude account. Allow selecting/logging into a separate Claude account per repo, so different projects can run under different accounts (e.g. personal vs work). Needs: per-workspace auth storage + a way for the spawned Claude session to use that repo''s account instead of the global one; UI to sign in/switch per repo; fall back to the system login when none is set.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-19 07:42:08.735', '2026-06-19 07:42:08.761', NULL, '65bb4a1083e1ecfba38bfd1c3837e133', 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); diff --git a/lib/builtin/menubar/src/menu_bar.dart b/lib/builtin/menubar/src/menu_bar.dart index 11f65fe3..adbac509 100644 --- a/lib/builtin/menubar/src/menu_bar.dart +++ b/lib/builtin/menubar/src/menu_bar.dart @@ -72,7 +72,13 @@ class MenuBar extends StatelessWidget { return ListenableBuilder( listenable: Listenable.merge([controller, kernel.commands, kernel.project]), builder: (ctx, _) { - final menus = resolveMenus(buildClideMenuTree(), kernel.commands, kernel, bindingLabel: (id) => keymapBindingLabel(kernel.keymap, id)); + final menus = resolveMenus( + buildClideMenuTree(), + kernel.commands, + kernel, + bindingLabel: (id) => keymapBindingLabel(kernel.keymap, id), + localizeTitle: (c) => localizedCommandTitle(context, c), + ); controller.setMnemonics([for (final m in menus) m.title[m.mnemonic].toLowerCase()]); return Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/builtin/menubar/src/menu_model.dart b/lib/builtin/menubar/src/menu_model.dart index e472c253..65fa992d 100644 --- a/lib/builtin/menubar/src/menu_model.dart +++ b/lib/builtin/menubar/src/menu_model.dart @@ -108,7 +108,13 @@ String? keymapBindingLabel(KeymapService keymap, String commandId) { /// menus. [bindingLabel] supplies the keybinding string for a command id /// (typically [keymapBindingLabel] bound to the keymap); when it returns null /// the command's own `defaultBinding` is used as a fallback. -List resolveMenus(List tree, CommandRegistry registry, KernelServices services, {String? Function(String commandId)? bindingLabel}) { +List resolveMenus( + List tree, + CommandRegistry registry, + KernelServices services, { + String? Function(String commandId)? bindingLabel, + String Function(CommandContribution cmd)? localizeTitle, +}) { final placed = { for (final m in tree) for (final n in m.nodes) @@ -130,7 +136,7 @@ List resolveMenus(List tree, CommandRegistry registry, Ke ResolvedItem resolveItem(MenuCommandItem item) { final cmd = registry.get(item.commandId); final enabled = cmd != null && (item.enabledWhen?.call(services) ?? true); - final raw = cmd?.title ?? item.fallbackTitle ?? item.commandId; + final raw = (cmd != null ? (localizeTitle?.call(cmd) ?? cmd.title) : null) ?? item.fallbackTitle ?? item.commandId; return ResolvedItem(commandId: item.commandId, title: _stripCategory(raw), enabled: enabled, keybinding: label(item.commandId)); } diff --git a/lib/extension/src/contribution.dart b/lib/extension/src/contribution.dart index 57b6b2f7..a26ea59b 100644 --- a/lib/extension/src/contribution.dart +++ b/lib/extension/src/contribution.dart @@ -93,10 +93,28 @@ class ToolbarButtonContribution extends ContributionPoint { /// A command extensions register with [CommandRegistry]. Surfaced by the /// command palette, the keybinding resolver, and `clide` CLI subcommands. class CommandContribution extends ContributionPoint { - const CommandContribution({required super.id, required this.command, required this.run, this.title, this.defaultBinding, this.bindingWhen}); + const CommandContribution({ + required super.id, + required this.command, + required this.run, + this.title, + this.titleKey, + this.i18nNamespace, + this.defaultBinding, + this.bindingWhen, + }); final String command; // e.g. "git.commit" final String? title; // "Git: Commit staged" + + /// When set, the command palette / menu resolve the display title via + /// `i18n.string(titleKey, namespace: i18nNamespace, placeholder: title)` + /// (T-462). [title] stays the English fallback (also used in logs/CLI). + final String? titleKey; + + /// The i18n namespace to look up [titleKey] in — usually the contributing + /// extension's `id`. Required when [titleKey] is set. + final String? i18nNamespace; final String? defaultBinding; // e.g. "ctrl+shift+g" /// Optional when-clause guarding [defaultBinding] (same grammar as keymap diff --git a/lib/kernel/src/commands/palette.dart b/lib/kernel/src/commands/palette.dart index cafa6ffd..9a82e32f 100644 --- a/lib/kernel/src/commands/palette.dart +++ b/lib/kernel/src/commands/palette.dart @@ -8,6 +8,12 @@ class PaletteController extends ChangeNotifier { final CommandRegistry _registry; + /// Resolves a command's display title (the widget sets this to an i18n-aware + /// resolver so both the rendered title AND fuzzy search use the localized + /// string, T-462). Defaults to the English title/id. + String Function(CommandContribution cmd)? titleResolver; + String _titleOf(CommandContribution c) => titleResolver?.call(c) ?? c.title ?? c.command; + bool _open = false; String _filter = ''; int _selectedIndex = 0; @@ -103,7 +109,7 @@ class PaletteController extends ChangeNotifier { final scored = <({CommandContribution cmd, int score})>[]; for (final c in all) { - final s = fuzzyScore((c.title ?? c.command).toLowerCase(), q); + final s = fuzzyScore(_titleOf(c).toLowerCase(), q); if (s != null) scored.add((cmd: c, score: s)); } scored.sort((a, b) { @@ -111,7 +117,7 @@ class PaletteController extends ChangeNotifier { if (byScore != 0) return byScore; final byRecent = rank(a.cmd.command).compareTo(rank(b.cmd.command)); if (byRecent != 0) return byRecent; - return (a.cmd.title ?? a.cmd.command).toLowerCase().compareTo((b.cmd.title ?? b.cmd.command).toLowerCase()); + return _titleOf(a.cmd).toLowerCase().compareTo(_titleOf(b.cmd).toLowerCase()); }); return [for (final s in scored) s.cmd]; } diff --git a/lib/kernel/src/settings_schema.dart b/lib/kernel/src/settings_schema.dart index 270c2c36..6e5f8790 100644 --- a/lib/kernel/src/settings_schema.dart +++ b/lib/kernel/src/settings_schema.dart @@ -34,13 +34,17 @@ enum SettingsFieldKind { /// One choice in a [SettingsFieldKind.select] field. class SettingsOption { - const SettingsOption({required this.value, required this.label}); + const SettingsOption({required this.value, required this.label, this.labelKey}); /// Stored value. final String value; - /// Human label shown in the picker. + /// Human label shown in the picker (English fallback). final String label; + + /// Optional i18n key for [label], resolved in the owning category's namespace + /// (T-462). + final String? labelKey; } /// One editable setting. [key] is a `SettingsStore` key — its `app.`/ @@ -52,7 +56,9 @@ class SettingsField { required this.key, required this.kind, required this.label, + this.labelKey, this.help, + this.helpKey, this.defaultValue, this.options = const [], this.min, @@ -66,9 +72,15 @@ class SettingsField { final SettingsFieldKind kind; final String label; + /// Optional i18n key for [label], resolved in the category's namespace (T-462). + final String? labelKey; + /// Optional one-line help shown under the label. final String? help; + /// Optional i18n key for [help], resolved in the category's namespace (T-462). + final String? helpKey; + /// Value shown / restored when the key is unset (reset-to-default target). final Object? defaultValue; @@ -97,9 +109,12 @@ class SettingsField { /// A carded group of fields (surface.md "sectioned cards"). [label] is the /// small-caps header rendered just above the card. class SettingsSection { - const SettingsSection({required this.label, required this.fields}); + const SettingsSection({required this.label, required this.fields, this.labelKey}); final String label; + + /// Optional i18n key for [label], resolved in the category's namespace (T-462). + final String? labelKey; final List fields; } @@ -107,11 +122,20 @@ class SettingsSection { /// shows. Subsystems register these via `SettingsCategoryContribution`; the /// renderer draws them. class SettingsCategory { - const SettingsCategory({required this.id, required this.title, required this.sections, this.iconName, this.priority = 0}); + const SettingsCategory({required this.id, required this.title, required this.sections, this.iconName, this.priority = 0, this.titleKey, this.i18nNamespace}); final String id; final String title; + /// Optional i18n key for [title] (the rail label), looked up in + /// [i18nNamespace] (T-462). + final String? titleKey; + + /// The i18n namespace for this category's [titleKey] and every section/field/ + /// option key beneath it — usually the contributing extension's `id`. The + /// renderer threads it down so the whole category localizes from one catalog. + final String? i18nNamespace; + /// Phosphor glyph name, resolved via `PhosphorIcons.byName` at render (T-314). final String? iconName; diff --git a/lib/widgets/src/clide_palette.dart b/lib/widgets/src/clide_palette.dart index 68c595d1..225680d1 100644 --- a/lib/widgets/src/clide_palette.dart +++ b/lib/widgets/src/clide_palette.dart @@ -1,9 +1,22 @@ +import 'package:clide/extension/src/contribution.dart' show CommandContribution; import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/src/clide_settings.dart'; import 'package:clide/widgets/src/clide_text.dart'; import 'package:clide/widgets/src/typography.dart'; import 'package:flutter/widgets.dart'; +/// The localized display title for a command (T-462): resolves [titleKey] in +/// [i18nNamespace] when both are set, else the English title (then id). Shared +/// by the command palette and the menu bar so both localize identically. +String localizedCommandTitle(BuildContext context, CommandContribution cmd) { + final k = cmd.titleKey; + final ns = cmd.i18nNamespace; + if (k != null && ns != null) { + return ClideSettings.i18n.string(context, k, namespace: ns, placeholder: cmd.title ?? cmd.command); + } + return cmd.title ?? cmd.command; +} + class ClidePalette extends StatefulWidget { const ClidePalette({super.key}); @@ -109,6 +122,8 @@ class _ClidePaletteState extends State { listenable: kernel.palette, builder: (ctx, _) { if (!kernel.palette.isOpen) return const SizedBox.shrink(); + // Localize titles for both the fuzzy filter and the rendered rows (T-462). + kernel.palette.titleResolver = (cmd) => localizedCommandTitle(ctx, cmd); final filtered = kernel.palette.filtered(); final selected = kernel.palette.selectedIndex; return Positioned( @@ -164,7 +179,7 @@ class _ClidePaletteState extends State { final key = _itemKeys.putIfAbsent(i, () => GlobalKey()); return _PaletteItem( key: key, - title: cmd.title ?? cmd.command, + title: localizedCommandTitle(ctx, cmd), command: cmd.command, binding: cmd.defaultBinding, highlighted: i == selected,