feat(ui): Ticket-Projekt pro Liste und Import ueber das Kontextmenue

This commit is contained in:
mika kuns
2026-08-27 13:36:44 +02:00
parent 020f93d55e
commit e91ade9cad
8 changed files with 102 additions and 12 deletions
+6 -3
View File
@@ -248,11 +248,13 @@
"contextOpenExplorer": "Im Explorer öffnen", "contextOpenExplorer": "Im Explorer öffnen",
"contextOpenTerminal": "Im Terminal öffnen", "contextOpenTerminal": "Im Terminal öffnen",
"contextOpenFindings": "Findings-Ordner öffnen", "contextOpenFindings": "Findings-Ordner öffnen",
"contextImportTickets": "Aus Ticketsystem aktualisieren",
"contextSettings": "Einstellungen…", "contextSettings": "Einstellungen…",
"newList": "Neue Liste", "newList": "Neue Liste",
"addReposTip": "Repos als Listen hinzufügen", "addReposTip": "Repos als Listen hinzufügen",
"noRepoBannerText": "Noch kein Repo verknüpft. ClaudeDo führt Tasks in einem Git-Repo aus.", "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": { "details": {
"deleteTaskTip": "Aufgabe löschen", "deleteTaskTip": "Aufgabe löschen",
@@ -396,7 +398,8 @@
"sectionVerify": "VERIFIKATION", "sectionVerify": "VERIFIKATION",
"verifyCommand": "Verifikationskommando", "verifyCommand": "Verifikationskommando",
"verifyCommandPlaceholder": "(keines — Merge verhält sich wie bisher)", "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": { "merge": {
"title": "WORKTREE MERGEN", "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}" }, "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}" }, "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}" }, "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}" }, "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}" }, "mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" }, "about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
+6 -3
View File
@@ -248,11 +248,13 @@
"contextOpenExplorer": "Open in Explorer", "contextOpenExplorer": "Open in Explorer",
"contextOpenTerminal": "Open in Terminal", "contextOpenTerminal": "Open in Terminal",
"contextOpenFindings": "Open findings folder", "contextOpenFindings": "Open findings folder",
"contextImportTickets": "Update from ticket system",
"contextSettings": "Settings…", "contextSettings": "Settings…",
"newList": "New list", "newList": "New list",
"addReposTip": "Add repos as lists", "addReposTip": "Add repos as lists",
"noRepoBannerText": "No repo linked yet. ClaudeDo runs tasks in a git repo.", "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": { "details": {
"deleteTaskTip": "Delete task", "deleteTaskTip": "Delete task",
@@ -396,7 +398,8 @@
"sectionVerify": "VERIFICATION", "sectionVerify": "VERIFICATION",
"verifyCommand": "Verify command", "verifyCommand": "Verify command",
"verifyCommandPlaceholder": "(none — merge behaves as today)", "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": { "merge": {
"title": "MERGE WORKTREE", "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}" }, "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}" }, "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}" }, "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}" }, "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}" }, "mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
"about": { "openPathFailed": "Couldn't open path: {0}" }, "about": { "openPathFailed": "Couldn't open path: {0}" },
@@ -208,12 +208,12 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
catch { } 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 // 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 // passes them through so the single UpdateListConfig call carries the full desired row instead
// of clobbering it. // of clobbering it.
public async System.Threading.Tasks.Task SaveAsync( 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; if (TargetId is null) return;
var model = string.IsNullOrWhiteSpace(Model) ? null : Model; var model = string.IsNullOrWhiteSpace(Model) ? null : Model;
@@ -226,7 +226,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
if (_scope == AgentConfigScope.Task) if (_scope == AgentConfigScope.Task)
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills, permission)); await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills, permission));
else 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<string>? SelectedSessionSkillNames() private List<string>? SelectedSessionSkillNames()
@@ -16,6 +16,9 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
[ObservableProperty] private bool _isManual; [ObservableProperty] private bool _isManual;
// Whether this list's .claudedo/ findings folder is committed with the repo (ListEntity.FindingsTracked). // Whether this list's .claudedo/ findings folder is committed with the repo (ListEntity.FindingsTracked).
[ObservableProperty] private bool _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 _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow; [ObservableProperty] private bool _dropHintBelow;
// Set while a dragged task hovers over this row as a move target — distinct from // Set while a dragged task hovers over this row as a move target — distinct from
@@ -285,6 +285,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
var ok = false; var ok = false;
try try
{ {
var ticketsConfigured = await IsTicketsConfiguredAsync();
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx); var lists = new ListRepository(ctx);
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" }); var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
@@ -293,6 +294,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
foreach (var l in await lists.GetAllAsync(ct)) foreach (var l in await lists.GetAllAsync(ct))
{ {
if (seedNames.Contains(l.Name)) continue; if (seedNames.Contains(l.Name)) continue;
var cfg = await lists.GetConfigAsync(l.Id, ct);
var item = new ListNavItemViewModel var item = new ListNavItemViewModel
{ {
Id = $"user:{l.Id}", Id = $"user:{l.Id}",
@@ -304,6 +306,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
DefaultCommitType = l.DefaultCommitType, DefaultCommitType = l.DefaultCommitType,
IsManual = l.IsManual, IsManual = l.IsManual,
FindingsTracked = l.FindingsTracked, FindingsTracked = l.FindingsTracked,
HasTicketProject = ticketsConfigured && cfg?.TicketProjectId > 0,
}; };
Items.Add(item); Items.Add(item);
UserLists.Add(item); UserLists.Add(item);
@@ -486,10 +489,12 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
var lists = new ListRepository(ctx); var lists = new ListRepository(ctx);
var entity = await lists.GetByIdAsync(rawId); var entity = await lists.GetByIdAsync(rawId);
if (entity is null) return; if (entity is null) return;
var cfg = await lists.GetConfigAsync(rawId);
var hasTicketProject = await IsTicketsConfiguredAsync() && cfg?.TicketProjectId > 0;
if (row is null) if (row is null)
{ {
await AddRowAsync(lists, entity); await AddRowAsync(lists, entity, hasTicketProject);
return; return;
} }
@@ -498,16 +503,47 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
row.DefaultCommitType = entity.DefaultCommitType; row.DefaultCommitType = entity.DefaultCommitType;
row.IsManual = entity.IsManual; row.IsManual = entity.IsManual;
row.FindingsTracked = entity.FindingsTracked; row.FindingsTracked = entity.FindingsTracked;
row.HasTicketProject = hasTicketProject;
RecomputeHasNoLinkedRepo(); RecomputeHasNoLinkedRepo();
} }
catch { /* best-effort refresh */ } catch { /* best-effort refresh */ }
} }
private async Task<bool> 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);
}
}
/// <summary> /// <summary>
/// A `create_list` from a running MCP session has no local row yet — build one from the DB /// 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. /// entity and insert it at its DB sort position, instead of waiting for the next full reload.
/// </summary> /// </summary>
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 dotColors = new[] { "Moss", "Peat", "Sage" };
var item = new ListNavItemViewModel var item = new ListNavItemViewModel
@@ -521,6 +557,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
DefaultCommitType = entity.DefaultCommitType, DefaultCommitType = entity.DefaultCommitType,
IsManual = entity.IsManual, IsManual = entity.IsManual,
FindingsTracked = entity.FindingsTracked, FindingsTracked = entity.FindingsTracked,
HasTicketProject = hasTicketProject,
}; };
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" }); var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
@@ -11,6 +11,10 @@ using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels.Modals; 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 public sealed partial class ListSettingsModalViewModel : ViewModelBase
{ {
private readonly IWorkerClient _worker; 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 // 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). // running/awaiting-merge sibling in this list (ListConfigEntity.SerializeOnFileOverlap).
[ObservableProperty] private bool _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<TicketProjectOption> TicketProjects { get; } = new();
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types); public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
@@ -75,6 +83,24 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
var cfg = await _worker.GetListConfigAsync(listId); var cfg = await _worker.GetListConfigAsync(listId);
VerifyCommand = cfg?.VerifyCommand ?? ""; VerifyCommand = cfg?.VerifyCommand ?? "";
SerializeOnFileOverlap = cfg?.SerializeOnFileOverlap ?? false; 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] [RelayCommand]
@@ -88,9 +114,15 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
IsManual, IsManual,
FindingsTracked)); 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( await Agent.SaveAsync(
string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand, string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand,
SerializeOnFileOverlap); SerializeOnFileOverlap,
ticketProjectId);
CloseAction?.Invoke(); CloseAction?.Invoke();
} }
@@ -155,6 +155,10 @@
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}" Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}"
CommandParameter="{Binding}"/> CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextImportTickets}"
IsVisible="{Binding HasTicketProject}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).ImportTicketsCommand}"
CommandParameter="{Binding}"/>
<Separator/> <Separator/>
<MenuItem Header="{loc:Tr lists.contextSettings}" <MenuItem Header="{loc:Tr lists.contextSettings}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}" Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
@@ -81,6 +81,14 @@
<TextBlock Text="{loc:Tr modals.listSettings.serializeOnFileOverlapHint}" <TextBlock Text="{loc:Tr modals.listSettings.serializeOnFileOverlapHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/> Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding TicketsAvailable}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.ticketProject}"/>
<ComboBox ItemsSource="{Binding TicketProjects}"
SelectedItem="{Binding SelectedTicketProject, Mode=TwoWay}"
DisplayMemberBinding="{Binding Display}"
HorizontalAlignment="Left" MinWidth="240"/>
</StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
</StackPanel> </StackPanel>