Merge branch 'claudedo/12bae32376504fd28bedb3c7202feba5'
This commit is contained in:
@@ -26,6 +26,13 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
// review/merge/status machinery.
|
||||
public ObservableCollection<ConPtyPaneViewModel> ConPtySessions { get; } = new();
|
||||
|
||||
// Claims a TaskId/listId for the duration of an Open*ConPtySessionAsync call so a second,
|
||||
// overlapping invocation for the same key (e.g. a double-click) bails out instead of racing
|
||||
// past the ConPtySessions dedupe check before the first call has added its pane. Checked and
|
||||
// claimed synchronously at method entry — before any await — and released in a finally.
|
||||
private readonly HashSet<string> _pendingTaskOpens = new();
|
||||
private readonly HashSet<string> _pendingMergeHelperLists = new();
|
||||
|
||||
// Mirror of ConPtySessions typed as the pane abstraction so the layout toggle (grid/tabs)
|
||||
// binds one contract rather than a ConPTY-specific type.
|
||||
public ObservableCollection<IMissionControlPane> Panes { get; } = new();
|
||||
@@ -145,18 +152,26 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
FocusedPane = existing;
|
||||
return;
|
||||
}
|
||||
if (!_pendingTaskOpens.Add(taskId)) return;
|
||||
|
||||
var title = taskId;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity?.Title is { Length: > 0 } t) title = t;
|
||||
}
|
||||
catch { /* best-effort title lookup */ }
|
||||
var title = taskId;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity?.Title is { Length: > 0 } t) title = t;
|
||||
}
|
||||
catch { /* best-effort title lookup */ }
|
||||
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||
() => DescribeAsync(() => _worker.GetInteractiveLaunchSpecAsync(taskId))));
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||
() => DescribeAsync(() => _worker.GetInteractiveLaunchSpecAsync(taskId))));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingTaskOpens.Remove(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
// Starts (or resumes) a planning session and hosts it as an embedded ConPTY Command Center
|
||||
@@ -170,20 +185,28 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
FocusedPane = existing;
|
||||
return;
|
||||
}
|
||||
if (!_pendingTaskOpens.Add(taskId)) return;
|
||||
|
||||
var title = taskId;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity?.Title is { Length: > 0 } t) title = t;
|
||||
}
|
||||
catch { /* best-effort title lookup */ }
|
||||
title += Loc.T("missionControl.planningTitleSuffix");
|
||||
var title = taskId;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity?.Title is { Length: > 0 } t) title = t;
|
||||
}
|
||||
catch { /* best-effort title lookup */ }
|
||||
title += Loc.T("missionControl.planningTitleSuffix");
|
||||
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title, () => DescribeAsync(() => resume
|
||||
? _worker.GetPlanningResumeLaunchSpecAsync(taskId)
|
||||
: _worker.GetPlanningStartLaunchSpecAsync(taskId))));
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title, () => DescribeAsync(() => resume
|
||||
? _worker.GetPlanningResumeLaunchSpecAsync(taskId)
|
||||
: _worker.GetPlanningStartLaunchSpecAsync(taskId))));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingTaskOpens.Remove(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
// Ad-hoc (task-less) ConPTY session in a user-chosen directory. Never deduped — every call
|
||||
@@ -207,38 +230,49 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
public async System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
|
||||
{
|
||||
if (taskIds is not { Count: > 0 }) return;
|
||||
// The TaskId dedupe below can't help here — CreateMergeHelperTaskAsync mints a brand-new
|
||||
// task id every call, so a double-trigger for the same list would always mint two host
|
||||
// tasks before either pane exists to dedupe against. Guard the whole method per listId.
|
||||
if (!_pendingMergeHelperLists.Add(listId)) return;
|
||||
|
||||
var title = Loc.T("missionControl.mergeHelperTitle");
|
||||
var listName = listId;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
|
||||
if (list?.Name is { Length: > 0 } name) { listName = name; title = $"{title} — {name}"; }
|
||||
}
|
||||
catch { /* best-effort title lookup */ }
|
||||
var title = Loc.T("missionControl.mergeHelperTitle");
|
||||
var listName = listId;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
|
||||
if (list?.Name is { Length: > 0 } name) { listName = name; title = $"{title} — {name}"; }
|
||||
}
|
||||
catch { /* best-effort title lookup */ }
|
||||
|
||||
string taskId;
|
||||
try
|
||||
{
|
||||
taskId = await _worker.CreateMergeHelperTaskAsync(taskIds, listId,
|
||||
Loc.T("missionControl.mergeHelperTaskTitle", listName),
|
||||
Loc.T("missionControl.mergeHelperTaskDescriptionHeader"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
|
||||
return;
|
||||
}
|
||||
string taskId;
|
||||
try
|
||||
{
|
||||
taskId = await _worker.CreateMergeHelperTaskAsync(taskIds, listId,
|
||||
Loc.T("missionControl.mergeHelperTaskTitle", listName),
|
||||
Loc.T("missionControl.mergeHelperTaskDescriptionHeader"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
|
||||
return;
|
||||
}
|
||||
|
||||
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
|
||||
{
|
||||
FocusedPane = existing;
|
||||
return;
|
||||
}
|
||||
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
|
||||
{
|
||||
FocusedPane = existing;
|
||||
return;
|
||||
}
|
||||
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||
() => DescribeAsync(() => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId))));
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||
() => DescribeAsync(() => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId))));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingMergeHelperLists.Remove(listId);
|
||||
}
|
||||
}
|
||||
|
||||
// List-handler handoff: the running session called handoff_list_handler at the end of Phase 2.
|
||||
|
||||
Reference in New Issue
Block a user