refactor(i18n): bundle catalogs under assets/i18n/<locale>/ (T-462)

Move the catalogs out of lib/kernel/src/i18n/catalog/ into assets/i18n/, and
switch the layout from a `{namespace}_{locale}.json` filename to a per-locale
DIRECTORY: assets/i18n/en_us/{namespace}.json. Adding a language is now just a
new folder (assets/i18n/nl_nl/, nl_be, en_eu, …) of the same namespace files —
no renames — which is cleaner to maintain and mirrors how an external
extension ships its own catalog. AssetCatalogLoader/FileCatalogLoader resolve
`<root>/<locale>/<namespace>.json`; pubspec bundles the locale folder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 15:54:14 +02:00
co-authored by Claude Opus 4.8
parent 37fa255a63
commit 1bdfd1be1c
31 changed files with 40 additions and 17 deletions
@@ -5945,3 +5945,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 ('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;
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 ('06FDA0TPYSWEM10RP0Q76XAP58', 'status', 'in_progress', 'done', NULL, '2026-06-19 08:20:44', '2026-06-19 08:20:44.218', '2026-06-19 08:20:44.218', NULL, 'f5289951d9bfdf8695a1cb77c212c3fb', 2) ON CONFLICT(hash) DO NOTHING;
+13
View File
@@ -7366,3 +7366,16 @@ Open question to settle in this task: do framework widgets get a ''core''/''kern
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);
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 ('06FDA0TPYSWEM10RP0Q76XAP58', 'epic', NULL, 'i18n coverage: route hardcoded UI labels through the catalog', 'D-21 makes i18n a Tier-0 contract: "All user-facing strings resolve through a namespaced i18n catalogue loader." In practice coverage is ~7%. A June 2026 audit found:
- Only 2 of 30 builtin extensions actually call .string()/.interpolated(): builtin.settings-ui and builtin.theme-picker. Total live i18n call sites: 17.
- ~144 hardcoded user-facing string literals (Text/ClideText, tooltip:, hintText:, dialog/toast text) bypass i18n across the other 28 extensions + lib/widgets + lib/kernel.
- 12 of the 14 existing catalogs are effectively orphaned: the strings are pre-translated but the code hardcodes the label anyway (e.g. welcome ships a 10-key catalog AND 15 hardcoded strings; claude ships 10 keys + 35 hardcoded).
Because every call site and TabContribution supplies an inline English placeholder, nothing renders blank at runtime so this is invisible in the running app. The cost is that ~93% of labels can never be localized: they never reach the catalog lookup. That is a Tier-0 (D-21) gap, not polish.
Prior work: T-155 (done) fixed the catalog plumbing + boot warnings and created tab.title catalogs; T-371 (backlog, high, parent T-359) fixes the a11y/i18n gate tests that under-enumerate namespaces. Neither migrates the hardcoded labels that is this epic.
This epic = migrate the ~144 hardcoded labels to i18n, per extension, extending the existing catalogs where present. T-371 should land alongside so the gate fails when a new namespace ships uncovered and the work cannot re-drift.
Audit per-extension hardcoded counts (approx): claude 35, welcome 15, git 9, diff 7, tickets 6, pql 6, menubar 6, decisions 6, output 4, search 3, problems 3, deeplink 3, editor 1, files 1, terminal 1, markdown 1, vim 1; lib/widgets 9; lib/kernel 1.', 'done', 'high', NULL, NULL, 'D-21', '2026-06-17 09:57:06.422', '2026-06-19 08:20:44.217', NULL, '8ee6f221b3d13163bbe9e149111aca64', 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);
+14 -9
View File
@@ -8,13 +8,18 @@ import 'package:flutter/services.dart';
/// Loads catalog JSON for a given `(namespace, locale)` pair.
///
/// The file format (mirrors fframe verbatim):
/// `{namespace}_{lang}_{country}.json` or `{namespace}_{lang}.json`
/// Layout: the locale is a **directory**, the namespace is the file:
/// `<root>/{lang}_{country}/{namespace}.json` (or `<root>/{lang}/...`)
/// Content: `{ "key": { "translation": "...", ...extras }, ... }`.
/// So a new language is a new folder named `{lang}_{country}` like `en_us`
/// (e.g. `assets/i18n/nl_nl/`, and a sibling `assets/i18n/nl_be/` later) — drop
/// the same namespace files in, no renames. The fallback chain resolves
/// narrow→broad, e.g. `nl_be → nl → en_us`.
///
/// Two reader shapes:
/// * Asset bundle (built-in catalogs shipped under `lib/kernel/src/i18n/catalog/`).
/// * Filesystem (third-party extensions under `~/.clide/extensions/<id>/`).
/// * Asset bundle (built-in catalogs shipped under `assets/i18n/`).
/// * Filesystem (third-party extensions under `~/.clide/extensions/<id>/`,
/// same `<locale>/<namespace>.json` layout).
///
/// Missing files return an empty map — not an error. The fallback chain
/// walker handles "nothing for this locale" by trying the next one.
@@ -28,12 +33,12 @@ class AssetCatalogLoader implements CatalogLoader {
final AssetBundle bundle;
final String rootDir;
static const String _defaultRoot = 'lib/kernel/src/i18n/catalog';
static const String _defaultRoot = 'assets/i18n';
@override
Future<Map<String, Object?>> load(String namespace, Locale locale) async {
final suffix = FallbackChain.filenameSuffix(locale);
final path = '$rootDir/${namespace}_$suffix.json';
final locDir = FallbackChain.filenameSuffix(locale);
final path = '$rootDir/$locDir/$namespace.json';
try {
final text = await bundle.loadString(path);
if (text.trim().isEmpty) return const {};
@@ -56,8 +61,8 @@ class FileCatalogLoader implements CatalogLoader {
@override
Future<Map<String, Object?>> load(String namespace, Locale locale) async {
final suffix = FallbackChain.filenameSuffix(locale);
final f = File('${rootDir.path}/${namespace}_$suffix.json');
final locDir = FallbackChain.filenameSuffix(locale);
final f = File('${rootDir.path}/$locDir/$namespace.json');
if (!await f.exists()) return const {};
try {
final text = await f.readAsString();
+3 -1
View File
@@ -79,7 +79,9 @@ flutter:
assets:
- lib/kernel/src/theme/themes/
- lib/kernel/src/i18n/catalog/
# i18n catalogs, one folder per locale (en_us, future nl_nl, …); each holds
# <namespace>.json. Add a line per new locale folder.
- assets/i18n/en_us/
- assets/keymaps/
- assets/licenses.yaml
- assets/LICENSE
@@ -29,7 +29,7 @@ class _MapAssetBundle extends CachingAssetBundle {
void main() {
group('AssetCatalogLoader', () {
test('returns parsed JSON for a present asset', () async {
final bundle = _MapAssetBundle({'lib/kernel/src/i18n/catalog/welcome_en_us.json': '{"title":{"translation":"Hi"}}'});
final bundle = _MapAssetBundle({'assets/i18n/en_us/welcome.json': '{"title":{"translation":"Hi"}}'});
final loader = AssetCatalogLoader(bundle: bundle);
final r = await loader.load('welcome', const Locale('en', 'US'));
expect(r['title'], isA<Map>());
@@ -42,19 +42,19 @@ void main() {
});
test('returns an empty map on malformed JSON (FormatException catch)', () async {
final bundle = _MapAssetBundle({'lib/kernel/src/i18n/catalog/welcome_en_us.json': 'not json at all'});
final bundle = _MapAssetBundle({'assets/i18n/en_us/welcome.json': 'not json at all'});
final loader = AssetCatalogLoader(bundle: bundle);
expect(await loader.load('welcome', const Locale('en', 'US')), isEmpty);
});
test('returns an empty map when the asset is blank', () async {
final bundle = _MapAssetBundle({'lib/kernel/src/i18n/catalog/welcome_en_us.json': ' \n'});
final bundle = _MapAssetBundle({'assets/i18n/en_us/welcome.json': ' \n'});
final loader = AssetCatalogLoader(bundle: bundle);
expect(await loader.load('welcome', const Locale('en', 'US')), isEmpty);
});
test('returns an empty map when JSON parses to a non-object', () async {
final bundle = _MapAssetBundle({'lib/kernel/src/i18n/catalog/welcome_en_us.json': '[1, 2, 3]'});
final bundle = _MapAssetBundle({'assets/i18n/en_us/welcome.json': '[1, 2, 3]'});
final loader = AssetCatalogLoader(bundle: bundle);
expect(await loader.load('welcome', const Locale('en', 'US')), isEmpty);
});
@@ -65,6 +65,8 @@ void main() {
setUp(() async {
tmp = await Directory.systemTemp.createTemp('clide-catalog-');
// Catalogs live under a per-locale subdir (<root>/<locale>/<ns>.json).
await Directory('${tmp.path}/en_us').create();
});
tearDown(() async {
@@ -72,7 +74,7 @@ void main() {
});
test('reads and parses a present file', () async {
await File('${tmp.path}/welcome_en_us.json').writeAsString('{"k":{"translation":"v"}}');
await File('${tmp.path}/en_us/welcome.json').writeAsString('{"k":{"translation":"v"}}');
final loader = FileCatalogLoader(rootDir: tmp);
final r = await loader.load('welcome', const Locale('en', 'US'));
expect(r['k'], isA<Map>());
@@ -84,13 +86,13 @@ void main() {
});
test('returns an empty map on malformed JSON (FormatException catch)', () async {
await File('${tmp.path}/welcome_en_us.json').writeAsString('garbage');
await File('${tmp.path}/en_us/welcome.json').writeAsString('garbage');
final loader = FileCatalogLoader(rootDir: tmp);
expect(await loader.load('welcome', const Locale('en', 'US')), isEmpty);
});
test('returns an empty map when the file is blank', () async {
await File('${tmp.path}/welcome_en_us.json').writeAsString(' ');
await File('${tmp.path}/en_us/welcome.json').writeAsString(' ');
final loader = FileCatalogLoader(rootDir: tmp);
expect(await loader.load('welcome', const Locale('en', 'US')), isEmpty);
});