feat(i18n): i18n key fields on command + settings contributions (T-462)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ResolvedMenu> resolveMenus(List<TopMenu> tree, CommandRegistry registry, KernelServices services, {String? Function(String commandId)? bindingLabel}) {
|
||||
List<ResolvedMenu> resolveMenus(
|
||||
List<TopMenu> tree,
|
||||
CommandRegistry registry,
|
||||
KernelServices services, {
|
||||
String? Function(String commandId)? bindingLabel,
|
||||
String Function(CommandContribution cmd)? localizeTitle,
|
||||
}) {
|
||||
final placed = <String>{
|
||||
for (final m in tree)
|
||||
for (final n in m.nodes)
|
||||
@@ -130,7 +136,7 @@ List<ResolvedMenu> resolveMenus(List<TopMenu> 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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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<SettingsField> 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;
|
||||
|
||||
|
||||
@@ -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<ClidePalette> {
|
||||
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<ClidePalette> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user