feat(i18n): localize ViewModel-built strings via ambient Loc accessor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-06-03 12:43:30 +02:00
parent 086c6f6c45
commit 350a89f364
23 changed files with 250 additions and 84 deletions

View File

@@ -86,6 +86,7 @@ sealed class Program
fallback: "en");
var localizer = new Localizer(localeStore, initialLang);
TrExtension.Localizer = localizer;
ClaudeDo.Ui.Localization.Loc.Current = localizer;
sc.AddSingleton<ILocalizer>(localizer);
sc.AddDbContextFactory<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={dbPath}"));

View File

@@ -302,5 +302,26 @@
"updateNow": "Update now",
"dismiss": "Dismiss"
}
},
"vm": {
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
"shell": { "restartingWorker": "Restarting worker…" },
"agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}" },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show." },
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files)." },
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" },
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s)." },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed." },
"listSettings": { "untitled": "Untitled" },
"details": { "effectiveIfInherited": "Effective if inherited: {0}" },
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }
}
}

View File

@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using ClaudeDo.Localization;
namespace ClaudeDo.Ui.Localization;
/// Ambient access to the active localizer for code-built (ViewModel) strings.
/// Set once at startup. Defaults to a key-echo localizer so unit tests that
/// construct ViewModels without startup wiring do not crash.
public static class Loc
{
private static ILocalizer _current = new KeyEchoLocalizer();
public static ILocalizer Current
{
get => _current;
set
{
if (_current is not null) _current.LanguageChanged -= OnInnerChanged;
_current = value;
_current.LanguageChanged += OnInnerChanged;
OnInnerChanged(value, EventArgs.Empty);
}
}
public static event EventHandler? LanguageChanged;
private static void OnInnerChanged(object? sender, EventArgs e) =>
LanguageChanged?.Invoke(sender, e);
public static string T(string key) => Current[key];
public static string T(string key, params object[] args) => Current.Get(key, args);
private sealed class KeyEchoLocalizer : ILocalizer
{
public string this[string key] => key;
public string Get(string key, params object[] args) => key;
public string CurrentCode => "en";
public IReadOnlyList<LanguageOption> AvailableLanguages => Array.Empty<LanguageOption>();
public void SetLanguage(string code) { }
public event EventHandler? LanguageChanged { add { } remove { } }
}
}

View File

@@ -6,6 +6,7 @@ using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Helpers;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Services.Interfaces;
using ClaudeDo.Ui.ViewModels.Modals;
@@ -99,13 +100,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
[NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
[NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
private string _agentStatusLabel = "Idle";
public bool IsIdle => AgentStatusLabel == "Idle";
public bool IsQueued => AgentStatusLabel == "Queued";
public bool IsRunning => AgentStatusLabel == "Running";
public bool IsDone => AgentStatusLabel == "Done";
public bool IsFailed => AgentStatusLabel == "Failed";
public bool IsCancelled => AgentStatusLabel == "Cancelled";
private string _agentState = "idle";
public string AgentStatusLabel => Loc.T($"vm.agentStatus.{AgentState}");
public bool IsIdle => AgentState == "idle";
public bool IsQueued => AgentState == "queued";
public bool IsRunning => AgentState == "running";
public bool IsDone => AgentState == "done";
public bool IsFailed => AgentState == "failed";
public bool IsCancelled => AgentState == "cancelled";
// Recovery actions: Continue (resume session) for Failed/Cancelled.
public bool ShowContinue => IsFailed || IsCancelled;
@@ -116,8 +118,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
private string? _latestRunSessionId;
partial void OnAgentStatusLabelChanged(string value)
partial void OnAgentStateChanged(string value)
{
OnPropertyChanged(nameof(AgentStatusLabel));
OnPropertyChanged(nameof(IsIdle));
OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(IsRunning));
@@ -127,6 +130,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowContinue));
OnPropertyChanged(nameof(ShowResetAndRetry));
OnPropertyChanged(nameof(IsAgentSectionEnabled));
OnPropertyChanged(nameof(EffectiveModelLabel));
OnPropertyChanged(nameof(EffectiveAgentLabel));
}
[ObservableProperty] private string? _model;
@@ -139,6 +144,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
[ObservableProperty] private string _effectiveSystemPromptHint = "";
[ObservableProperty] private string _effectiveAgentHint = "";
public string EffectiveModelLabel => Loc.T("vm.details.effectiveIfInherited", EffectiveModelHint);
public string EffectiveAgentLabel => Loc.T("vm.details.effectiveIfInherited", EffectiveAgentHint);
partial void OnEffectiveModelHintChanged(string value) => OnPropertyChanged(nameof(EffectiveModelLabel));
partial void OnEffectiveAgentHintChanged(string value) => OnPropertyChanged(nameof(EffectiveAgentLabel));
public System.Collections.ObjectModel.ObservableCollection<string> TaskModelOptions { get; } = new(
new[] { ModelRegistry.TaskInheritSentinel }.Concat(ModelRegistry.Aliases));
@@ -223,6 +234,26 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
// Set by the view so DeleteTaskCommand can show an error message
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
private static string StatusToStateKey(ClaudeDo.Data.Models.TaskStatus status) => status switch
{
ClaudeDo.Data.Models.TaskStatus.Queued => "queued",
ClaudeDo.Data.Models.TaskStatus.Running => "running",
ClaudeDo.Data.Models.TaskStatus.WaitingForReview => "running",
ClaudeDo.Data.Models.TaskStatus.Done => "done",
ClaudeDo.Data.Models.TaskStatus.Failed => "failed",
ClaudeDo.Data.Models.TaskStatus.Cancelled => "cancelled",
_ => "idle",
};
private static string FinishedStatusToStateKey(string status) => status switch
{
"done" => "done",
"failed" => "failed",
"cancelled" => "cancelled",
"waiting_for_review" => "running",
_ => status.ToLowerInvariant(),
};
private async System.Threading.Tasks.Task RefreshStatusAsync(string taskId)
{
try
@@ -233,7 +264,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
.FirstOrDefaultAsync(t => t.Id == taskId);
if (entity is null || Task?.Id != taskId) return;
AgentStatusLabel = entity.Status.ToString();
AgentState = StatusToStateKey(entity.Status);
}
catch { }
}
@@ -245,6 +276,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
_services = services;
_notesApi = notesApi;
Notes = new NotesEditorViewModel(_notesApi);
Loc.LanguageChanged += (_, _) =>
{
OnPropertyChanged(nameof(AgentStatusLabel));
OnPropertyChanged(nameof(EffectiveModelLabel));
OnPropertyChanged(nameof(EffectiveAgentLabel));
};
// Subscribe once; filter by current task id inside the handler
_worker.TaskMessageEvent += OnTaskMessage;
@@ -264,7 +301,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
// If the task row's live status changes (e.g. TaskStarted/Finished), mirror it.
_worker.TaskStartedEvent += (slot, taskId, startedAt) =>
{
if (Task?.Id == taskId) AgentStatusLabel = "Running";
if (Task?.Id == taskId) AgentState = "running";
};
_worker.TaskFinishedEvent += (slot, taskId, status, finishedAt) =>
{
@@ -275,7 +312,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
Kind = LogKind.Done,
Text = $"── {status.ToUpperInvariant()} · {finishedAt.ToLocalTime():HH:mm:ss} ──",
});
AgentStatusLabel = status;
AgentState = FinishedStatusToStateKey(status);
// Re-query to pick up worktree created during the run.
_ = RefreshWorktreeAsync(taskId);
};
@@ -473,7 +510,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
WorktreePath = null;
WorktreeStateLabel = null;
BranchLine = null;
AgentStatusLabel = "Idle";
AgentState = "idle";
LatestRunSessionId = null;
_suppressAgentSave = true;
try
@@ -519,7 +556,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
WorktreeStateLabel = entity.Worktree?.State.ToString();
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
AgentStatusLabel = entity.Status.ToString();
AgentState = StatusToStateKey(entity.Status);
await LoadAgentSettingsAsync(entity, ct);
ct.ThrowIfCancellationRequested();
@@ -730,7 +767,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
WorktreeStateLabel = entity.Worktree?.State.ToString();
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
AgentStatusLabel = entity.Status.ToString();
AgentState = StatusToStateKey(entity.Status);
if (Task is { } row && entity.Worktree?.DiffStat is { } stat)
row.DiffStat = stat;
}
@@ -808,7 +845,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
? ClaudeDo.Data.Models.TaskStatus.Done
: ClaudeDo.Data.Models.TaskStatus.Idle;
Task.Status = entity.Status;
AgentStatusLabel = entity.Status.ToString();
AgentState = StatusToStateKey(entity.Status);
await repo.UpdateAsync(entity);
}
@@ -922,7 +959,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
try
{
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
AgentStatusLabel = "Queued";
AgentState = "queued";
}
catch { /* offline */ }
}
@@ -938,7 +975,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
try
{
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Idle);
AgentStatusLabel = "Idle";
AgentState = "idle";
}
catch { /* offline */ }
}
@@ -973,7 +1010,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
try
{
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
AgentStatusLabel = "Queued";
AgentState = "queued";
}
catch { /* offline */ }
}

View File

@@ -6,6 +6,7 @@ using ClaudeDo.Data.Filtering;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
@@ -137,6 +138,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
public string UserName { get; } = Environment.UserName;
public string MachineName { get; } = Environment.MachineName;
public string MachineNameLocal => Loc.T("vm.lists.localSuffix", MachineName);
public string UserInitials { get; }
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null, WorkerClient? worker = null)
@@ -170,12 +172,12 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
var smart = new[]
{
new ListNavItemViewModel { Id = "smart:my-day", Name = "My Day", Kind = ListKind.Smart, IconKey = "Sun" },
new ListNavItemViewModel { Id = "smart:important", Name = "Important", Kind = ListKind.Smart, IconKey = "Star" },
new ListNavItemViewModel { Id = "smart:planned", Name = "Planned", Kind = ListKind.Smart, IconKey = "Calendar" },
new ListNavItemViewModel { Id = "virtual:queued", Name = "Queue", Kind = ListKind.Virtual, IconKey = "Inbox" },
new ListNavItemViewModel { Id = "virtual:running", Name = "Running", Kind = ListKind.Virtual, IconKey = "Activity" },
new ListNavItemViewModel { Id = "virtual:review", Name = "Review", Kind = ListKind.Virtual, IconKey = "Eye" },
new ListNavItemViewModel { Id = "smart:my-day", Name = Loc.T("vm.lists.smartMyDay"), Kind = ListKind.Smart, IconKey = "Sun" },
new ListNavItemViewModel { Id = "smart:important", Name = Loc.T("vm.lists.smartImportant"), Kind = ListKind.Smart, IconKey = "Star" },
new ListNavItemViewModel { Id = "smart:planned", Name = Loc.T("vm.lists.smartPlanned"), Kind = ListKind.Smart, IconKey = "Calendar" },
new ListNavItemViewModel { Id = "virtual:queued", Name = Loc.T("vm.lists.virtualQueue"), Kind = ListKind.Virtual, IconKey = "Inbox" },
new ListNavItemViewModel { Id = "virtual:running", Name = Loc.T("vm.lists.virtualRunning"), Kind = ListKind.Virtual, IconKey = "Activity" },
new ListNavItemViewModel { Id = "virtual:review", Name = Loc.T("vm.lists.virtualReview"), Kind = ListKind.Virtual, IconKey = "Eye" },
};
foreach (var s in smart) { Items.Add(s); SmartLists.Add(s); }
@@ -242,7 +244,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
var entity = new ListEntity
{
Id = Guid.NewGuid().ToString("N"),
Name = "New list",
Name = Loc.T("vm.lists.newList"),
DefaultCommitType = CommitTypeRegistry.DefaultType,
CreatedAt = DateTime.UtcNow,
};

View File

@@ -1,5 +1,6 @@
using CommunityToolkit.Mvvm.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels.Islands;
@@ -31,7 +32,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[ObservableProperty] private bool _parentFinalized;
public DateTime CreatedAt { get; init; }
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
public string CreatedAtFormatted => CreatedAt == default ? "—" : Loc.T("vm.taskRow.createdPrefix", CreatedAt.ToString("MMM d"));
public int StepsCount { get; init; }
public int StepsCompleted { get; init; }
@@ -50,8 +51,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public string? PlanningBadge => PlanningPhase switch
{
PlanningPhase.Active => "PLANNING",
PlanningPhase.Finalized => "PLANNED",
PlanningPhase.Active => Loc.T("vm.planningBadge.active"),
PlanningPhase.Finalized => Loc.T("vm.planningBadge.finalized"),
_ => null,
};
@@ -77,9 +78,19 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public string DiffAdditionsText => $"+{DiffAdditions}";
public string DiffDeletionsText => $"{DiffDeletions}";
public string StepsText => $"{StepsCompleted}/{StepsCount} steps";
public string StepsText => Loc.T("vm.taskRow.stepsText", StepsCompleted, StepsCount);
public string StatusLabel => Status == TaskStatus.WaitingForReview ? "Waiting for Review" : Status.ToString();
public string StatusLabel => Status switch
{
TaskStatus.Idle => Loc.T("vm.taskStatus.idle"),
TaskStatus.Queued => Loc.T("vm.taskStatus.queued"),
TaskStatus.Running => Loc.T("vm.taskStatus.running"),
TaskStatus.WaitingForReview => Loc.T("vm.taskStatus.waitingForReview"),
TaskStatus.Done => Loc.T("vm.taskStatus.done"),
TaskStatus.Failed => Loc.T("vm.taskStatus.failed"),
TaskStatus.Cancelled => Loc.T("vm.taskStatus.cancelled"),
_ => Status.ToString(),
};
public string StatusChipClass => (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
{
@@ -164,6 +175,14 @@ public sealed partial class TaskRowViewModel : ViewModelBase
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); }
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
public void RefreshLocalized()
{
OnPropertyChanged(nameof(StatusLabel));
OnPropertyChanged(nameof(PlanningBadge));
OnPropertyChanged(nameof(CreatedAtFormatted));
OnPropertyChanged(nameof(StepsText));
}
public static TaskRowViewModel FromEntity(TaskEntity t)
{
var row = new TaskRowViewModel { Id = t.Id, CreatedAt = t.CreatedAt };

View File

@@ -6,6 +6,7 @@ using ClaudeDo.Data;
using ClaudeDo.Data.Filtering;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
@@ -52,7 +53,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
[ObservableProperty] private bool _hasOpen;
[ObservableProperty] private bool _hasCompleted;
[ObservableProperty] private bool _showOpenLabel;
[ObservableProperty] private string _completedHeader = "COMPLETED";
[ObservableProperty] private string _completedHeader = "";
[ObservableProperty] private bool _showNotesRow;
public Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; }
@@ -61,6 +62,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
{
_dbFactory = dbFactory;
_worker = worker;
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
if (_worker is not null)
{
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
@@ -68,6 +70,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
_worker.ListUpdatedEvent += OnWorkerListUpdated;
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
}
Loc.LanguageChanged += (_, _) => RefreshLocalizedText();
}
private void RefreshLocalizedText()
{
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
foreach (var row in Items) row.RefreshLocalized();
foreach (var row in CompletedItems) row.RefreshLocalized();
}
private async void OnWorkerListUpdated(string listId)
@@ -340,7 +350,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
HasOpen = OpenItems.Count > 0;
HasCompleted = CompletedItems.Count > 0;
ShowOpenLabel = HasOpen && HasOverdue;
CompletedHeader = $"COMPLETED · {CompletedItems.Count}";
CompletedHeader = Loc.T("vm.tasksIsland.completedHeaderCount", CompletedItems.Count);
}
private void UpdateSubtitle()

View File

@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
@@ -23,9 +24,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
public UpdateCheckService UpdateCheck => _updateCheck;
public string ConnectionText =>
Worker?.IsConnected == true ? "Online"
: Worker?.IsReconnecting == true ? "Connecting"
: "Offline";
Worker?.IsConnected == true ? Loc.T("vm.connection.online")
: Worker?.IsReconnecting == true ? Loc.T("vm.connection.connecting")
: Loc.T("vm.connection.offline");
public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true;
@@ -358,7 +359,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
[RelayCommand]
private async Task RestartWorkerAsync()
{
RestartWorkerStatus = "Restarting worker";
RestartWorkerStatus = Loc.T("vm.shell.restartingWorker");
try
{
await Task.Run(RestartWorkerService);

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data.Git;
using ClaudeDo.Ui.Localization;
namespace ClaudeDo.Ui.ViewModels.Modals;
@@ -91,13 +92,13 @@ public sealed partial class DiffModalViewModel : ViewModelBase
}
catch (Exception ex)
{
StatusMessage = $"Failed to load diff: {ex.Message}";
StatusMessage = Loc.T("vm.diff.loadFailed", ex.Message);
return;
}
if (string.IsNullOrWhiteSpace(raw))
{
StatusMessage = "No changes to show.";
StatusMessage = Loc.T("vm.diff.noChanges");
return;
}
@@ -169,7 +170,7 @@ public sealed partial class DiffModalViewModel : ViewModelBase
}
SelectedFile = Files.Count > 0 ? Files[0] : null;
if (Files.Count == 0) StatusMessage = "No changes to show.";
if (Files.Count == 0) StatusMessage = Loc.T("vm.diff.noChanges");
}
private static void ParseHunkHeader(string header, out int oldStart, out int newStart)

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -80,7 +81,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
await _worker.UpdateListAsync(new UpdateListDto(
ListId,
string.IsNullOrWhiteSpace(Name) ? "Untitled" : Name,
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
DefaultCommitType));
@@ -93,7 +94,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
[RelayCommand]
private async Task DeleteAsync()
{
var displayName = string.IsNullOrWhiteSpace(Name) ? "Untitled" : Name;
var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
if (ConfirmAsync is not null)
{
var ok = await ConfirmAsync($"Delete list \"{displayName}\" and all its tasks? This cannot be undone.");

View File

@@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -36,7 +37,7 @@ public sealed partial class MergeModalViewModel : ViewModelBase
{
TaskId = taskId;
TaskTitle = taskTitle;
CommitMessage = $"Merge task: {taskTitle}";
CommitMessage = Loc.T("vm.merge.commitMessage", taskTitle);
IsBusy = true;
try
@@ -45,7 +46,7 @@ public sealed partial class MergeModalViewModel : ViewModelBase
Branches.Clear();
if (targets is null)
{
ErrorMessage = "Worker offline — cannot list branches.";
ErrorMessage = Loc.T("vm.merge.workerOfflineBranches");
return;
}
foreach (var b in targets.LocalBranches) Branches.Add(b);
@@ -55,7 +56,7 @@ public sealed partial class MergeModalViewModel : ViewModelBase
}
catch (Exception ex)
{
ErrorMessage = $"Failed to load branches: {ex.Message}";
ErrorMessage = Loc.T("vm.merge.loadBranchesFailed", ex.Message);
}
finally { IsBusy = false; }
}
@@ -81,7 +82,7 @@ public sealed partial class MergeModalViewModel : ViewModelBase
case "merged":
SuccessMessage = result.ErrorMessage is not null
? $"Merged with warning: {result.ErrorMessage}"
: "Merged.";
: Loc.T("vm.merge.merged");
// Auto-close after a short delay.
_ = Task.Run(async () =>
{
@@ -92,19 +93,19 @@ public sealed partial class MergeModalViewModel : ViewModelBase
case "conflict":
HasConflict = true;
ConflictFiles = result.ConflictFiles;
ErrorMessage = "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.";
ErrorMessage = Loc.T("vm.merge.conflict");
break;
case "blocked":
ErrorMessage = $"Blocked: {result.ErrorMessage}";
ErrorMessage = Loc.T("vm.merge.blocked", result.ErrorMessage ?? "");
break;
default:
ErrorMessage = $"Unknown status: {result.Status}";
ErrorMessage = Loc.T("vm.merge.unknownStatus", result.Status);
break;
}
}
catch (Exception ex)
{
ErrorMessage = $"Merge failed: {ex.Message}";
ErrorMessage = Loc.T("vm.merge.mergeFailed", ex.Message);
}
finally
{

View File

@@ -1,5 +1,6 @@
using System.Diagnostics;
using ClaudeDo.Data;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -26,13 +27,13 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
try
{
var r = await _worker.RestoreDefaultAgentsAsync();
if (r is null) StatusMessage = "Worker offline.";
else if (r.Copied == 0 && r.Skipped == 0) StatusMessage = "No default agents bundled.";
else if (r.Copied == 0) StatusMessage = "All default agents already present.";
else StatusMessage = $"Restored {r.Copied} default agent(s).";
if (r is null) StatusMessage = Loc.T("vm.filesTab.workerOffline");
else if (r.Copied == 0 && r.Skipped == 0) StatusMessage = Loc.T("vm.filesTab.noneBundled");
else if (r.Copied == 0) StatusMessage = Loc.T("vm.filesTab.allPresent");
else StatusMessage = Loc.T("vm.filesTab.restored", r.Copied);
await _worker.RefreshAgentsAsync();
}
catch (Exception ex) { StatusMessage = $"Restore failed: {ex.Message}"; }
catch (Exception ex) { StatusMessage = Loc.T("vm.filesTab.restoreFailed", ex.Message); }
finally { IsBusy = false; }
}
@@ -46,6 +47,6 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
var path = PromptFiles.PathFor(kind);
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
catch (Exception ex) { StatusMessage = $"Open failed: {ex.Message}"; }
catch (Exception ex) { StatusMessage = Loc.T("vm.filesTab.openFailed", ex.Message); }
}
}

View File

@@ -1,4 +1,5 @@
using System.IO;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -43,7 +44,7 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
try
{
var r = await _worker.CleanupFinishedWorktreesAsync();
StatusMessage = r is null ? "Worker offline." : $"Removed {r.Removed} worktree(s).";
StatusMessage = r is null ? Loc.T("vm.worktreesTab.workerOffline") : Loc.T("vm.worktreesTab.removed", r.Removed);
}
finally { IsBusy = false; }
}
@@ -58,9 +59,9 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
try
{
var r = await _worker.ResetAllWorktreesAsync();
if (r is null) StatusMessage = "Worker offline.";
else if (r.Blocked) StatusMessage = $"Cannot force-remove: {r.RunningTasks} task(s) still running. Cancel them first.";
else StatusMessage = $"Removed {r.Removed} worktree(s) from {r.TasksAffected} task(s).";
if (r is null) StatusMessage = Loc.T("vm.worktreesTab.workerOffline");
else if (r.Blocked) StatusMessage = Loc.T("vm.worktreesTab.blocked", r.RunningTasks);
else StatusMessage = Loc.T("vm.worktreesTab.removedFrom", r.Removed, r.TasksAffected);
}
finally { IsBusy = false; }
}

View File

@@ -1,6 +1,7 @@
using System.Linq;
using ClaudeDo.Data;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -60,7 +61,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
System.Text.Json.JsonSerializer.Deserialize<List<string>>(dto.ReportExcludedPaths) ?? new());
General.StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday;
}
else StatusMessage = "Worker offline — settings read-only.";
else StatusMessage = Loc.T("vm.settingsModal.workerOffline");
await Prime.LoadAsync();
}
@@ -95,7 +96,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
await Prime.SaveAsync();
CloseAction?.Invoke();
}
catch (Exception ex) { StatusMessage = $"Save failed: {ex.Message}"; }
catch (Exception ex) { StatusMessage = Loc.T("vm.settingsModal.saveFailed", ex.Message); }
finally { IsBusy = false; }
}

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -72,16 +73,16 @@ public sealed partial class WeeklyReportModalViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanGenerate))]
private async Task Generate()
{
if (!RangeValid) { StatusMessage = "Invalid date range."; return; }
if (!RangeValid) { StatusMessage = Loc.T("vm.weeklyReport.invalidRange"); return; }
IsBusy = true;
StatusMessage = "Generating report…";
StatusMessage = Loc.T("vm.weeklyReport.generating");
try
{
ReportMarkdown = await _worker.GenerateWeekReportAsync(
DateOnly.FromDateTime(StartDate!.Value), DateOnly.FromDateTime(EndDate!.Value));
StatusMessage = "";
}
catch (Exception ex) { StatusMessage = $"Error: {ex.Message}"; }
catch (Exception ex) { StatusMessage = Loc.T("vm.weeklyReport.error", ex.Message); }
finally { IsBusy = false; }
}
}

View File

@@ -4,6 +4,7 @@ using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input.Platform;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -86,7 +87,9 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
{
ListIdFilter = listId;
IsGlobal = listId is null;
Title = listId is null ? "Worktrees" : $"Worktrees — {listName ?? "list"}";
Title = listId is null
? Loc.T("vm.worktreesOverview.titleAll")
: Loc.T("vm.worktreesOverview.titleList", listName ?? Loc.T("vm.worktreesOverview.listFallback"));
}
public async Task LoadAsync(CancellationToken ct = default)
@@ -138,7 +141,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
try
{
var result = await _worker.CleanupFinishedWorktreesAsync(ListIdFilter);
StatusMessage = result is null ? "Cleanup failed." : $"Removed {result.Removed} worktree(s).";
StatusMessage = result is null ? Loc.T("vm.worktreesOverview.cleanupFailed") : Loc.T("vm.worktreesOverview.removed", result.Removed);
await LoadAsync();
}
finally { IsBusy = false; }
@@ -190,7 +193,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
if (row is null || row.State != WorktreeState.Active) return;
var (ok, err) = await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Discarded);
if (ok) row.State = WorktreeState.Discarded;
else StatusMessage = err ?? "Failed to discard worktree.";
else StatusMessage = err ?? Loc.T("vm.worktreesOverview.discardFailed");
}
[RelayCommand]
@@ -199,20 +202,20 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
if (row is null || row.State != WorktreeState.Active) return;
var (ok, err) = await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Kept);
if (ok) row.State = WorktreeState.Kept;
else StatusMessage = err ?? "Failed to keep worktree.";
else StatusMessage = err ?? Loc.T("vm.worktreesOverview.keepFailed");
}
[RelayCommand]
private async Task ForceRemove(WorktreeOverviewRowViewModel? row)
{
if (row is null) return;
if (row.IsRunning) { StatusMessage = "Cannot force-remove a running task."; return; }
if (row.IsRunning) { StatusMessage = Loc.T("vm.worktreesOverview.cannotForceRunning"); return; }
if (ConfirmAction is not null && !await ConfirmAction($"Force remove worktree for '{row.TaskTitle}'? This deletes the directory and branch.")) return;
var result = await _worker.ForceRemoveWorktreeAsync(row.TaskId);
if (result is null || !result.Removed)
{
StatusMessage = result?.Reason ?? "Force remove failed.";
StatusMessage = result?.Reason ?? Loc.T("vm.worktreesOverview.forceRemoveFailed");
return;
}
if (IsGlobal)

View File

@@ -1,6 +1,7 @@
using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.Planning;
@@ -14,6 +15,8 @@ public sealed partial class ConflictResolutionViewModel : ObservableObject
public string SubtaskTitle { get; }
public string TargetBranch { get; }
public IReadOnlyList<string> ConflictedFiles { get; }
public string SubtaskLabel => Loc.T("vm.conflictResolution.subtaskPrefix", SubtaskTitle);
public string TargetLabel => Loc.T("vm.conflictResolution.targetPrefix", TargetBranch);
[ObservableProperty] private string? _vsCodeError;
[ObservableProperty] private string? _actionError;
@@ -53,7 +56,7 @@ public sealed partial class ConflictResolutionViewModel : ObservableObject
}
catch (Exception ex)
{
VsCodeError = $"Could not launch VS Code: {ex.Message}. Paths are listed above — copy them manually.";
VsCodeError = Loc.T("vm.conflictResolution.vsCodeError", ex.Message);
}
}

View File

@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.Planning;
@@ -59,7 +60,7 @@ public sealed partial class PlanningDiffViewModel : ObservableObject
if (result is null)
{
DisplayedDiff = "";
CombinedWarning = "Could not build combined preview (hub error).";
CombinedWarning = Loc.T("vm.planningDiff.hubError");
}
else if (result.Success)
{
@@ -69,7 +70,7 @@ public sealed partial class PlanningDiffViewModel : ObservableObject
else
{
var files = result.ConflictedFiles?.Count ?? 0;
CombinedWarning = $"Cannot build combined preview: subtask {result.FirstConflictSubtaskId} conflicts with an earlier subtask ({files} files).";
CombinedWarning = Loc.T("vm.planningDiff.conflict", result.FirstConflictSubtaskId ?? "", files);
DisplayedDiff = "";
}
}

View File

@@ -91,7 +91,7 @@
SelectedItem="{Binding TaskModelSelection, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
<TextBlock Classes="meta"
Text="{Binding EffectiveModelHint, StringFormat='Effective if inherited: {0}'}"
Text="{Binding EffectiveModelLabel}"
Opacity="0.6"/>
</StackPanel>
@@ -114,7 +114,7 @@
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="meta"
Text="{Binding EffectiveAgentHint, StringFormat='Effective if inherited: {0}'}"
Text="{Binding EffectiveAgentLabel}"
Opacity="0.6"/>
</StackPanel>
</StackPanel>

View File

@@ -47,13 +47,7 @@
<!-- Name + machine -->
<StackPanel Grid.Column="1" Margin="8,0" Spacing="1" VerticalAlignment="Center">
<TextBlock Classes="title" Text="{Binding UserName}"/>
<TextBlock Classes="meta">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0} / local">
<Binding Path="MachineName"/>
</MultiBinding>
</TextBlock.Text>
</TextBlock>
<TextBlock Classes="meta" Text="{Binding MachineNameLocal}"/>
</StackPanel>
<!-- More button -->
<Button Grid.Column="2" Classes="icon-btn" VerticalAlignment="Center"

View File

@@ -31,8 +31,8 @@
<!-- Content -->
<StackPanel Spacing="12" Margin="20,16" MinWidth="520">
<TextBlock Classes="heading"
Text="{Binding SubtaskTitle, StringFormat='Conflicts in subtask: {0}'}"/>
<TextBlock Classes="body" Text="{Binding TargetBranch, StringFormat='Merging into: {0}'}"/>
Text="{Binding SubtaskLabel}"/>
<TextBlock Classes="body" Text="{Binding TargetLabel}"/>
<ItemsControl ItemsSource="{Binding ConflictedFiles}">
<ItemsControl.ItemTemplate>
<DataTemplate>