diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 824b95ff..875f8c17 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -248,11 +248,13 @@ "contextOpenExplorer": "Im Explorer öffnen", "contextOpenTerminal": "Im Terminal öffnen", "contextOpenFindings": "Findings-Ordner öffnen", + "contextImportTickets": "Aus Ticketsystem aktualisieren", "contextSettings": "Einstellungen…", "newList": "Neue Liste", "addReposTip": "Repos als Listen hinzufügen", "noRepoBannerText": "Noch kein Repo verknüpft. ClaudeDo führt Tasks in einem Git-Repo aus.", - "noRepoBannerButton": "Repo importieren" + "noRepoBannerButton": "Repo importieren", + "importTicketsResult": "{0} Tickets geprüft, {1} neue Tasks." }, "details": { "deleteTaskTip": "Aufgabe löschen", @@ -396,7 +398,8 @@ "sectionVerify": "VERIFIKATION", "verifyCommand": "Verifikationskommando", "verifyCommandPlaceholder": "(keines — Merge verhält sich wie bisher)", - "verifyCommandHint": "Läuft im Arbeitsverzeichnis nach einem Merge, bevor die Aufgabe auf 'Erledigt' gesetzt wird. Ein Exit-Code ungleich 0 (oder Timeout) lässt den Merge bestehen, verhindert aber 'Erledigt' und meldet den Fehler." + "verifyCommandHint": "Läuft im Arbeitsverzeichnis nach einem Merge, bevor die Aufgabe auf 'Erledigt' gesetzt wird. Ein Exit-Code ungleich 0 (oder Timeout) lässt den Merge bestehen, verhindert aber 'Erledigt' und meldet den Fehler.", + "ticketProject": "Ticket-Projekt" }, "merge": { "title": "WORKTREE MERGEN", @@ -751,7 +754,7 @@ "sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" }, - "listSettings": { "untitled": "Unbenannt" }, + "listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —" }, "detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" }, "mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" }, "about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 6ae389b7..c2b567a3 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -248,11 +248,13 @@ "contextOpenExplorer": "Open in Explorer", "contextOpenTerminal": "Open in Terminal", "contextOpenFindings": "Open findings folder", + "contextImportTickets": "Update from ticket system", "contextSettings": "Settings…", "newList": "New list", "addReposTip": "Add repos as lists", "noRepoBannerText": "No repo linked yet. ClaudeDo runs tasks in a git repo.", - "noRepoBannerButton": "Import repo" + "noRepoBannerButton": "Import repo", + "importTicketsResult": "{0} tickets checked, {1} new tasks." }, "details": { "deleteTaskTip": "Delete task", @@ -396,7 +398,8 @@ "sectionVerify": "VERIFICATION", "verifyCommand": "Verify command", "verifyCommandPlaceholder": "(none — merge behaves as today)", - "verifyCommandHint": "Runs in the working directory after a merge lands, before the task is marked Done. A non-zero exit (or timeout) leaves the merge in place but keeps the task out of Done and reports the failure." + "verifyCommandHint": "Runs in the working directory after a merge lands, before the task is marked Done. A non-zero exit (or timeout) leaves the merge in place but keeps the task out of Done and reports the failure.", + "ticketProject": "Ticket project" }, "merge": { "title": "MERGE WORKTREE", @@ -751,7 +754,7 @@ "sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove 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).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" }, - "listSettings": { "untitled": "Untitled" }, + "listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —" }, "detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" }, "mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" }, "about": { "openPathFailed": "Couldn't open path: {0}" }, diff --git a/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs index 5900a11f..f2333acc 100644 --- a/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs @@ -208,12 +208,12 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa catch { } } - // verifyCommand and serializeOnFileOverlap are List-only fields owned by + // verifyCommand, serializeOnFileOverlap and ticketProjectId are List-only fields owned by // ListSettingsModalViewModel (not this editor, which is also reused for Task scope); the caller // passes them through so the single UpdateListConfig call carries the full desired row instead // of clobbering it. public async System.Threading.Tasks.Task SaveAsync( - string? verifyCommand = null, bool? serializeOnFileOverlap = null) + string? verifyCommand = null, bool? serializeOnFileOverlap = null, int? ticketProjectId = null) { if (TargetId is null) return; var model = string.IsNullOrWhiteSpace(Model) ? null : Model; @@ -226,7 +226,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa if (_scope == AgentConfigScope.Task) await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills, permission)); else - await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, serializeOnFileOverlap, permission)); + await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, serializeOnFileOverlap, permission, ticketProjectId)); } private List? SelectedSessionSkillNames() diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs index 9e14bb9f..5ebc1918 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs @@ -16,6 +16,9 @@ public sealed partial class ListNavItemViewModel : ViewModelBase [ObservableProperty] private bool _isManual; // Whether this list's .claudedo/ findings folder is committed with the repo (ListEntity.FindingsTracked). [ObservableProperty] private bool _findingsTracked; + // True when a ticket-system base URL is configured AND this list has a linked ticket project + // (ListConfigEntity.TicketProjectId) — gates the "Update from ticket system" context menu entry. + [ObservableProperty] private bool _hasTicketProject; [ObservableProperty] private bool _dropHintAbove; [ObservableProperty] private bool _dropHintBelow; // Set while a dragged task hovers over this row as a move target — distinct from diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index 3d5cf076..095e80c1 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -285,6 +285,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable var ok = false; try { + var ticketsConfigured = await IsTicketsConfiguredAsync(); await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var lists = new ListRepository(ctx); var seedNames = new HashSet(new[] { "My Day", "Important", "Planned" }); @@ -293,6 +294,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable foreach (var l in await lists.GetAllAsync(ct)) { if (seedNames.Contains(l.Name)) continue; + var cfg = await lists.GetConfigAsync(l.Id, ct); var item = new ListNavItemViewModel { Id = $"user:{l.Id}", @@ -304,6 +306,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable DefaultCommitType = l.DefaultCommitType, IsManual = l.IsManual, FindingsTracked = l.FindingsTracked, + HasTicketProject = ticketsConfigured && cfg?.TicketProjectId > 0, }; Items.Add(item); UserLists.Add(item); @@ -486,10 +489,12 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable var lists = new ListRepository(ctx); var entity = await lists.GetByIdAsync(rawId); if (entity is null) return; + var cfg = await lists.GetConfigAsync(rawId); + var hasTicketProject = await IsTicketsConfiguredAsync() && cfg?.TicketProjectId > 0; if (row is null) { - await AddRowAsync(lists, entity); + await AddRowAsync(lists, entity, hasTicketProject); return; } @@ -498,16 +503,47 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable row.DefaultCommitType = entity.DefaultCommitType; row.IsManual = entity.IsManual; row.FindingsTracked = entity.FindingsTracked; + row.HasTicketProject = hasTicketProject; RecomputeHasNoLinkedRepo(); } catch { /* best-effort refresh */ } } + private async Task IsTicketsConfiguredAsync() + { + if (_worker is null) return false; + try + { + var settings = await _worker.GetTicketSettingsAsync(); + return !string.IsNullOrWhiteSpace(settings?.ApiBaseUrl); + } + catch { return false; } + } + + [RelayCommand] + private async Task ImportTicketsAsync(ListNavItemViewModel? row) + { + if (row is null || _worker is null) return; + var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id; + try + { + var result = await _worker.ImportTicketsAsync(rawId); + // No FlashFooterInfo variant exists in this project — the footer strip only has an + // error channel (ErrorReported -> IslandsShellViewModel.FlashFooterError). A silent + // success is the worse option, so the result reuses the same channel. + ErrorReported?.Invoke(Loc.T("lists.importTicketsResult", result.Examined, result.Created)); + } + catch (Exception ex) + { + ErrorReported?.Invoke(ex.Message); + } + } + /// /// A `create_list` from a running MCP session has no local row yet — build one from the DB /// entity and insert it at its DB sort position, instead of waiting for the next full reload. /// - private async Task AddRowAsync(ListRepository lists, ListEntity entity) + private async Task AddRowAsync(ListRepository lists, ListEntity entity, bool hasTicketProject = false) { var dotColors = new[] { "Moss", "Peat", "Sage" }; var item = new ListNavItemViewModel @@ -521,6 +557,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable DefaultCommitType = entity.DefaultCommitType, IsManual = entity.IsManual, FindingsTracked = entity.FindingsTracked, + HasTicketProject = hasTicketProject, }; var seedNames = new HashSet(new[] { "My Day", "Important", "Planned" }); diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs index 91c81e7b..88f0619f 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs @@ -11,6 +11,10 @@ using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Ui.ViewModels.Modals; +// UI-only projection of TicketProjectDto for the ComboBox display text, plus a synthetic +// Id=0 "none" entry (mirrors AgentConfigEditorViewModel's "(none)" AgentInfo placeholder). +public sealed record TicketProjectOption(int Id, string Display); + public sealed partial class ListSettingsModalViewModel : ViewModelBase { private readonly IWorkerClient _worker; @@ -39,6 +43,10 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase // When on, the queue picker holds back a queued task whose declared scope globs overlap a // running/awaiting-merge sibling in this list (ListConfigEntity.SerializeOnFileOverlap). [ObservableProperty] private bool _serializeOnFileOverlap; + // Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab). + [ObservableProperty] private bool _ticketsAvailable; + [ObservableProperty] private TicketProjectOption? _selectedTicketProject; + public ObservableCollection TicketProjects { get; } = new(); public ObservableCollection CommitTypeOptions { get; } = new(CommitTypeRegistry.Types); @@ -75,6 +83,24 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase var cfg = await _worker.GetListConfigAsync(listId); VerifyCommand = cfg?.VerifyCommand ?? ""; SerializeOnFileOverlap = cfg?.SerializeOnFileOverlap ?? false; + + var ticketSettings = await _worker.GetTicketSettingsAsync(); + TicketsAvailable = !string.IsNullOrWhiteSpace(ticketSettings?.ApiBaseUrl); + TicketProjects.Clear(); + if (TicketsAvailable) + { + TicketProjects.Add(new TicketProjectOption(0, Loc.T("vm.listSettings.ticketProjectNone"))); + foreach (var p in await _worker.GetTicketProjectsAsync()) + TicketProjects.Add(new TicketProjectOption(p.Id, $"{p.DepartmentName} / {p.Title}")); + + SelectedTicketProject = cfg?.TicketProjectId is int id + ? TicketProjects.FirstOrDefault(t => t.Id == id) ?? TicketProjects[0] + : TicketProjects[0]; + } + else + { + SelectedTicketProject = null; + } } [RelayCommand] @@ -88,9 +114,15 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase IsManual, FindingsTracked)); + // Tri-state: dropdown never shown (no base URL) -> null, leave the stored link alone. + // Dropdown shown -> always an explicit value, 0 for "— none —" to actively clear it. + // Never null here, or an explicit unlink would silently fail to reach the worker. + var ticketProjectId = TicketsAvailable ? (SelectedTicketProject?.Id ?? 0) : (int?)null; + await Agent.SaveAsync( string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand, - SerializeOnFileOverlap); + SerializeOnFileOverlap, + ticketProjectId); CloseAction?.Invoke(); } diff --git a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml index 8ddda175..e3e1d925 100644 --- a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml @@ -155,6 +155,10 @@ IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}" CommandParameter="{Binding}"/> + + + + + +