refactor(i18n): route git + diff panel labels through the catalog (T-466)

Migrate the user-facing strings in builtin.git (panel, status groups, commit
bar, branch picker, discard dialog, row a11y) and builtin.diff (toolbar,
empty states, file meta) to ClideSettings.i18n.string/.interpolated; extend
both catalogs. _stateLabel threaded a BuildContext from its caller. 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:13:35 +02:00
co-authored by Claude Opus 4.8
parent 16fe71cb3f
commit f33cd603be
7 changed files with 247 additions and 50 deletions
+41 -11
View File
@@ -88,7 +88,7 @@ class _DiffViewState extends State<DiffView> {
builder: (context, _) {
final tokens = ClideSettings.theme.of(context).surface;
return Semantics(
label: 'diff view',
label: ClideSettings.i18n.string(context, 'view.semantics', namespace: 'builtin.diff', placeholder: 'diff view'),
container: true,
explicitChildNodes: true,
child: Column(
@@ -100,9 +100,21 @@ class _DiffViewState extends State<DiffView> {
padding: const EdgeInsets.all(12),
child: ClideText(c.error!, color: tokens.statusError),
),
if (c.loading && c.diffs.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
if (c.loading && c.diffs.isEmpty)
Padding(
padding: const EdgeInsets.all(12),
child: ClideText(ClideSettings.i18n.string(context, 'status.loading', namespace: 'builtin.diff', placeholder: 'Loading…'), muted: true),
),
if (!c.loading && c.diffs.isEmpty && c.error == null)
Padding(padding: const EdgeInsets.all(12), child: ClideText(c.showStaged ? 'No staged changes.' : 'No unstaged changes.', muted: true)),
Padding(
padding: const EdgeInsets.all(12),
child: ClideText(
c.showStaged
? ClideSettings.i18n.string(context, 'empty.staged', namespace: 'builtin.diff', placeholder: 'No staged changes.')
: ClideSettings.i18n.string(context, 'empty.unstaged', namespace: 'builtin.diff', placeholder: 'No unstaged changes.'),
muted: true,
),
),
Expanded(
child: SingleChildScrollView(
controller: _scroll,
@@ -147,20 +159,28 @@ class _DiffToolbar extends StatelessWidget {
Semantics(
button: true,
toggled: !controller.showStaged,
label: 'show unstaged changes',
label: ClideSettings.i18n.string(context, 'toolbar.unstaged.semantics', namespace: 'builtin.diff', placeholder: 'show unstaged changes'),
child: GestureDetector(
onTap: controller.showStaged ? controller.toggleStaged : null,
child: ClideText('Unstaged', fontSize: clideFontCaption, color: controller.showStaged ? tokens.globalTextMuted : tokens.globalForeground),
child: ClideText(
ClideSettings.i18n.string(context, 'toolbar.unstaged', namespace: 'builtin.diff', placeholder: 'Unstaged'),
fontSize: clideFontCaption,
color: controller.showStaged ? tokens.globalTextMuted : tokens.globalForeground,
),
),
),
const SizedBox(width: 12),
Semantics(
button: true,
toggled: controller.showStaged,
label: 'show staged changes',
label: ClideSettings.i18n.string(context, 'toolbar.staged.semantics', namespace: 'builtin.diff', placeholder: 'show staged changes'),
child: GestureDetector(
onTap: controller.showStaged ? null : controller.toggleStaged,
child: ClideText('Staged', fontSize: clideFontCaption, color: controller.showStaged ? tokens.globalForeground : tokens.globalTextMuted),
child: ClideText(
ClideSettings.i18n.string(context, 'toolbar.staged', namespace: 'builtin.diff', placeholder: 'Staged'),
fontSize: clideFontCaption,
color: controller.showStaged ? tokens.globalForeground : tokens.globalTextMuted,
),
),
),
],
@@ -191,13 +211,23 @@ class _FileDiff extends StatelessWidget {
final hunks = (diff['hunks'] as List?) ?? const [];
final meta = <String>[];
if (isNew) meta.add('new file');
if (isDeleted) meta.add('deleted');
if (isNew) meta.add(ClideSettings.i18n.string(context, 'meta.newFile', namespace: 'builtin.diff', placeholder: 'new file'));
if (isDeleted) meta.add(ClideSettings.i18n.string(context, 'meta.deleted', namespace: 'builtin.diff', placeholder: 'deleted'));
if (isRenamed) {
final oldPath = diff['oldPath'] as String?;
if (oldPath != null) meta.add('renamed from $oldPath');
if (oldPath != null) {
meta.add(
ClideSettings.i18n.interpolated(
context,
'meta.renamedFrom',
namespace: 'builtin.diff',
placeholder: 'renamed from {path}',
replacers: [I18nReplacer(from: '{path}', replace: oldPath)],
),
);
}
}
if (isBinary) meta.add('binary');
if (isBinary) meta.add(ClideSettings.i18n.string(context, 'meta.binary', namespace: 'builtin.diff', placeholder: 'binary'));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
+126 -32
View File
@@ -70,12 +70,16 @@ class _GitPanelViewState extends State<GitPanelView> {
builder: (context, _) {
final tokens = ClideSettings.theme.of(context).surface;
return Semantics(
label: 'git panel',
label: ClideSettings.i18n.string(context, 'panel.semantics', namespace: 'builtin.git', placeholder: 'git panel'),
container: true,
explicitChildNodes: true,
child: Column(
children: [
ClideFilterBox(address: 'git.panel', hint: 'Filter changes…', onChanged: (v) => setState(() => _filter = v)),
ClideFilterBox(
address: 'git.panel',
hint: ClideSettings.i18n.string(context, 'filter.hint', namespace: 'builtin.git', placeholder: 'Filter changes…'),
onChanged: (v) => setState(() => _filter = v),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 4),
@@ -89,34 +93,62 @@ class _GitPanelViewState extends State<GitPanelView> {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption, maxLines: 3),
),
if (c.loading && c.isClean) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
if (c.loading && c.isClean)
Padding(
padding: const EdgeInsets.all(12),
child: ClideText(
ClideSettings.i18n.string(context, 'status.loading', namespace: 'builtin.git', placeholder: 'Loading…'),
muted: true,
),
),
if (!c.loading && c.isClean && c.error == null)
const Padding(padding: EdgeInsets.all(12), child: ClideText('Nothing to commit, working tree clean.', muted: true)),
if (c.conflicted.isNotEmpty) _FileGroup(label: 'Merge conflicts', entries: _applyFilter(c.conflicted), actions: const []),
Padding(
padding: const EdgeInsets.all(12),
child: ClideText(
ClideSettings.i18n.string(context, 'status.clean', namespace: 'builtin.git', placeholder: 'Nothing to commit, working tree clean.'),
muted: true,
),
),
if (c.conflicted.isNotEmpty)
_FileGroup(
label: ClideSettings.i18n.string(context, 'group.conflicts', namespace: 'builtin.git', placeholder: 'Merge conflicts'),
entries: _applyFilter(c.conflicted),
actions: const [],
),
if (c.staged.isNotEmpty) ...[
_FileGroup(
label: 'Staged',
label: ClideSettings.i18n.string(context, 'group.staged', namespace: 'builtin.git', placeholder: 'Staged'),
entries: _applyFilter(c.staged),
actions: [_GroupAction(label: 'Unstage all', onTap: () => unawaited(c.unstage(const [])))],
actions: [
_GroupAction(
label: ClideSettings.i18n.string(context, 'action.unstageAll', namespace: 'builtin.git', placeholder: 'Unstage all'),
onTap: () => unawaited(c.unstage(const [])),
),
],
onUnstage: (path) => unawaited(c.unstage([path])),
),
_CommitInput(commitMsg: _commitMsg, commitFocus: _commitFocus, controller: c),
],
if (c.unstaged.isNotEmpty)
_FileGroup(
label: 'Changes',
label: ClideSettings.i18n.string(context, 'group.changes', namespace: 'builtin.git', placeholder: 'Changes'),
entries: _applyFilter(c.unstaged),
actions: [_GroupAction(label: 'Stage all', onTap: () => unawaited(c.stageAll()))],
actions: [
_GroupAction(
label: ClideSettings.i18n.string(context, 'action.stageAll', namespace: 'builtin.git', placeholder: 'Stage all'),
onTap: () => unawaited(c.stageAll()),
),
],
onStage: (path) => unawaited(c.stage([path])),
onDiscard: (path) => _confirmDiscard(context, c, path),
),
if (c.untracked.isNotEmpty)
_FileGroup(
label: 'Untracked',
label: ClideSettings.i18n.string(context, 'group.untracked', namespace: 'builtin.git', placeholder: 'Untracked'),
entries: _applyFilter(c.untracked),
actions: [
_GroupAction(
label: 'Stage all',
label: ClideSettings.i18n.string(context, 'action.stageAll', namespace: 'builtin.git', placeholder: 'Stage all'),
onTap: () {
final paths = [for (final e in c.untracked) e['path'] as String];
unawaited(c.stage(paths));
@@ -144,7 +176,7 @@ class _BranchHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
final branch = controller.branch ?? '(detached)';
final branch = controller.branch ?? ClideSettings.i18n.string(context, 'branch.detached', namespace: 'builtin.git', placeholder: '(detached)');
final parts = <String>[branch];
if (controller.ahead > 0) parts.add('${controller.ahead}');
if (controller.behind > 0) parts.add('${controller.behind}');
@@ -155,9 +187,17 @@ class _BranchHeader extends StatelessWidget {
Expanded(
child: ClideText(parts.join(' '), fontSize: clideFontCaption, color: tokens.sidebarForeground),
),
_SmallAction(label: 'Pull', semanticsLabel: 'git pull', onTap: () => unawaited(controller.pull())),
_SmallAction(
label: ClideSettings.i18n.string(context, 'action.pull', namespace: 'builtin.git', placeholder: 'Pull'),
semanticsLabel: ClideSettings.i18n.string(context, 'action.pull.semantics', namespace: 'builtin.git', placeholder: 'git pull'),
onTap: () => unawaited(controller.pull()),
),
const SizedBox(width: 4),
_SmallAction(label: 'Push', semanticsLabel: 'git push', onTap: () => unawaited(controller.push())),
_SmallAction(
label: ClideSettings.i18n.string(context, 'action.push', namespace: 'builtin.git', placeholder: 'Push'),
semanticsLabel: ClideSettings.i18n.string(context, 'action.push.semantics', namespace: 'builtin.git', placeholder: 'git push'),
onTap: () => unawaited(controller.push()),
),
],
),
);
@@ -181,7 +221,7 @@ class _CommitInput extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Semantics(
label: 'commit message',
label: ClideSettings.i18n.string(context, 'commit.message.semantics', namespace: 'builtin.git', placeholder: 'commit message'),
textField: true,
child: Container(
decoration: BoxDecoration(border: Border.all(color: tokens.globalBorder)),
@@ -200,9 +240,9 @@ class _CommitInput extends StatelessWidget {
),
const SizedBox(height: 4),
ClideButton(
label: 'Commit',
label: ClideSettings.i18n.string(context, 'commit.button', namespace: 'builtin.git', placeholder: 'Commit'),
onPressed: _doCommit,
semanticLabel: 'commit staged changes',
semanticLabel: ClideSettings.i18n.string(context, 'commit.button.semantics', namespace: 'builtin.git', placeholder: 'commit staged changes'),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
),
],
@@ -277,7 +317,7 @@ class _GitFileRow extends StatelessWidget {
final indexState = entry['indexState'] as String?;
final workTreeState = entry['workTreeState'] as String?;
final state = indexState ?? workTreeState ?? '';
final stateLabel = _stateLabel(state);
final stateLabel = _stateLabel(context, state);
return Semantics(
button: true,
@@ -298,9 +338,42 @@ class _GitFileRow extends StatelessWidget {
child: ClideText(name, maxLines: 1, overflow: TextOverflow.ellipsis, color: tokens.sidebarForeground),
),
if (hovered) ...[
if (onStage != null) _SmallAction(label: '+', semanticsLabel: 'stage $name', onTap: () => onStage!(path)),
if (onUnstage != null) _SmallAction(label: '-', semanticsLabel: 'unstage $name', onTap: () => onUnstage!(path)),
if (onDiscard != null) _SmallAction(label: 'x', semanticsLabel: 'discard changes to $name', onTap: () => onDiscard!(path)),
if (onStage != null)
_SmallAction(
label: '+',
semanticsLabel: ClideSettings.i18n.interpolated(
context,
'row.stage.semantics',
namespace: 'builtin.git',
placeholder: 'stage {name}',
replacers: [I18nReplacer(from: '{name}', replace: name)],
),
onTap: () => onStage!(path),
),
if (onUnstage != null)
_SmallAction(
label: '-',
semanticsLabel: ClideSettings.i18n.interpolated(
context,
'row.unstage.semantics',
namespace: 'builtin.git',
placeholder: 'unstage {name}',
replacers: [I18nReplacer(from: '{name}', replace: name)],
),
onTap: () => onUnstage!(path),
),
if (onDiscard != null)
_SmallAction(
label: 'x',
semanticsLabel: ClideSettings.i18n.interpolated(
context,
'row.discard.semantics',
namespace: 'builtin.git',
placeholder: 'discard changes to {name}',
replacers: [I18nReplacer(from: '{name}', replace: name)],
),
onTap: () => onDiscard!(path),
),
],
],
),
@@ -321,14 +394,15 @@ class _GitFileRow extends StatelessWidget {
};
}
static String _stateLabel(String state) {
static String _stateLabel(BuildContext context, String state) {
String t(String key, String english) => ClideSettings.i18n.string(context, key, namespace: 'builtin.git', placeholder: english);
return switch (state) {
'added' => 'added',
'modified' => 'modified',
'deleted' => 'deleted',
'renamed' => 'renamed',
'copied' => 'copied',
'untracked' => 'untracked',
'added' => t('state.added', 'added'),
'modified' => t('state.modified', 'modified'),
'deleted' => t('state.deleted', 'deleted'),
'renamed' => t('state.renamed', 'renamed'),
'copied' => t('state.copied', 'copied'),
'untracked' => t('state.untracked', 'untracked'),
_ => '',
};
}
@@ -390,16 +464,36 @@ class _DiscardConfirmDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText('Discard changes?', color: tokens.globalForeground),
ClideText(
ClideSettings.i18n.string(context, 'discard.title', namespace: 'builtin.git', placeholder: 'Discard changes?'),
color: tokens.globalForeground,
),
const SizedBox(height: 8),
ClideText('Unstaged changes to $name will be permanently lost.', fontSize: clideFontCaption, color: tokens.statusError),
ClideText(
ClideSettings.i18n.interpolated(
context,
'discard.body',
namespace: 'builtin.git',
placeholder: 'Unstaged changes to {name} will be permanently lost.',
replacers: [I18nReplacer(from: '{name}', replace: name)],
),
fontSize: clideFontCaption,
color: tokens.statusError,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(label: 'Cancel', variant: ClideButtonVariant.subtle, onPressed: onCancel),
ClideButton(
label: ClideSettings.i18n.string(context, 'button.cancel', namespace: 'builtin.git', placeholder: 'Cancel'),
variant: ClideButtonVariant.subtle,
onPressed: onCancel,
),
const SizedBox(width: 8),
ClideButton(label: 'Discard', onPressed: onConfirm),
ClideButton(
label: ClideSettings.i18n.string(context, 'button.discard', namespace: 'builtin.git', placeholder: 'Discard'),
onPressed: onConfirm,
),
],
),
],
+22 -5
View File
@@ -63,7 +63,13 @@ class _GitStatusItemState extends State<GitStatusItem> {
if (_behind > 0) parts.add('$_behind');
return Semantics(
button: true,
label: 'switch branch — $_branch',
label: ClideSettings.i18n.interpolated(
context,
'branch.switch.semantics',
namespace: 'builtin.git',
placeholder: 'switch branch — {branch}',
replacers: [I18nReplacer(from: '{branch}', replace: _branch!)],
),
child: GestureDetector(
onTap: _openBranchPicker,
child: MouseRegion(
@@ -123,7 +129,8 @@ class _BranchPickerState extends State<_BranchPicker> {
if (r.ok) {
_branches = [for (final b in (r.data['branches'] as List? ?? const [])) (b as Map).cast<String, Object?>()];
} else {
_error = r.error?.message ?? 'failed to load branches';
_error =
r.error?.message ?? ClideSettings.i18n.string(context, 'branchPicker.loadFailed', namespace: 'builtin.git', placeholder: 'failed to load branches');
}
});
}
@@ -165,7 +172,7 @@ class _BranchPickerState extends State<_BranchPicker> {
children: [
Expanded(
child: ClideText(
'Switch branch',
ClideSettings.i18n.string(context, 'branchPicker.title', namespace: 'builtin.git', placeholder: 'Switch branch'),
fontSize: clideFontCaption,
color: tokens.globalTextMuted,
fontFamily: ClideSettings.fonts.monoOf(context),
@@ -181,10 +188,20 @@ class _BranchPickerState extends State<_BranchPicker> {
],
),
),
if (_loading) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
if (_loading)
Padding(
padding: const EdgeInsets.all(12),
child: ClideText(ClideSettings.i18n.string(context, 'status.loading', namespace: 'builtin.git', placeholder: 'Loading…'), muted: true),
),
if (_error != null) Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true)),
if (!_loading && _error == null && _branches.isEmpty)
const Padding(padding: EdgeInsets.all(12), child: ClideText('No branches found.', muted: true)),
Padding(
padding: const EdgeInsets.all(12),
child: ClideText(
ClideSettings.i18n.string(context, 'branchPicker.empty', namespace: 'builtin.git', placeholder: 'No branches found.'),
muted: true,
),
),
if (_branches.isNotEmpty)
Flexible(
child: ListView.builder(
@@ -1,3 +1,15 @@
{
"tab.title": { "translation": "Diff" }
"tab.title": { "translation": "Diff" },
"view.semantics": { "translation": "diff view" },
"status.loading": { "translation": "Loading…" },
"empty.staged": { "translation": "No staged changes." },
"empty.unstaged": { "translation": "No unstaged changes." },
"toolbar.unstaged": { "translation": "Unstaged" },
"toolbar.unstaged.semantics": { "translation": "show unstaged changes" },
"toolbar.staged": { "translation": "Staged" },
"toolbar.staged.semantics": { "translation": "show staged changes" },
"meta.newFile": { "translation": "new file" },
"meta.deleted": { "translation": "deleted" },
"meta.renamedFrom": { "translation": "renamed from {path}" },
"meta.binary": { "translation": "binary" }
}
@@ -1,3 +1,38 @@
{
"tab.title": { "translation": "Git" }
"tab.title": { "translation": "Git" },
"panel.semantics": { "translation": "git panel" },
"filter.hint": { "translation": "Filter changes…" },
"status.loading": { "translation": "Loading…" },
"status.clean": { "translation": "Nothing to commit, working tree clean." },
"group.conflicts": { "translation": "Merge conflicts" },
"group.staged": { "translation": "Staged" },
"group.changes": { "translation": "Changes" },
"group.untracked": { "translation": "Untracked" },
"action.unstageAll": { "translation": "Unstage all" },
"action.stageAll": { "translation": "Stage all" },
"commit.message.semantics": { "translation": "commit message" },
"commit.button": { "translation": "Commit" },
"commit.button.semantics": { "translation": "commit staged changes" },
"branch.detached": { "translation": "(detached)" },
"action.pull": { "translation": "Pull" },
"action.pull.semantics": { "translation": "git pull" },
"action.push": { "translation": "Push" },
"action.push.semantics": { "translation": "git push" },
"state.added": { "translation": "added" },
"state.modified": { "translation": "modified" },
"state.deleted": { "translation": "deleted" },
"state.renamed": { "translation": "renamed" },
"state.copied": { "translation": "copied" },
"state.untracked": { "translation": "untracked" },
"row.stage.semantics": { "translation": "stage {name}" },
"row.unstage.semantics": { "translation": "unstage {name}" },
"row.discard.semantics": { "translation": "discard changes to {name}" },
"discard.title": { "translation": "Discard changes?" },
"discard.body": { "translation": "Unstaged changes to {name} will be permanently lost." },
"button.cancel": { "translation": "Cancel" },
"button.discard": { "translation": "Discard" },
"branch.switch.semantics": { "translation": "switch branch — {branch}" },
"branchPicker.title": { "translation": "Switch branch" },
"branchPicker.empty": { "translation": "No branches found." },
"branchPicker.loadFailed": { "translation": "failed to load branches" }
}