feat(mission-control): rename sessions inline in the Recent sessions flyout
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
d9355383a4
commit
5bf08873bd
@@ -19,6 +19,11 @@ public sealed class MissionControlSessionRepository
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Sets the session's title; a no-op for an unknown id. <see cref="RecordAsync"/> never overwrites it.</summary>
|
||||
public async Task RenameAsync(string sessionId, string title, CancellationToken ct = default) =>
|
||||
await _context.MissionControlSessions.Where(s => s.SessionId == sessionId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Title, title), ct);
|
||||
|
||||
/// <summary>The recorded session, or null.</summary>
|
||||
public async Task<MissionControlSessionEntity?> GetAsync(string sessionId, CancellationToken ct = default) =>
|
||||
await _context.MissionControlSessions.AsNoTracking().FirstOrDefaultAsync(s => s.SessionId == sessionId, ct);
|
||||
|
||||
@@ -403,6 +403,8 @@
|
||||
"recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen",
|
||||
"resumeSession": "Fortsetzen",
|
||||
"showBrief": "Brief",
|
||||
"renameSession": "Doppelklick zum Umbenennen",
|
||||
"renameFailed": "Sitzung konnte nicht umbenannt werden: {0}",
|
||||
"handoffSession": "Übergabe",
|
||||
"briefOpenFailed": "Brief konnte nicht geöffnet werden: {0}",
|
||||
"focusMode": "Fokus",
|
||||
|
||||
@@ -403,6 +403,8 @@
|
||||
"recentSessionsEmpty": "No sessions in the last 14 days",
|
||||
"resumeSession": "Resume",
|
||||
"showBrief": "Brief",
|
||||
"renameSession": "Double-click to rename",
|
||||
"renameFailed": "Couldn't rename the session: {0}",
|
||||
"handoffSession": "Handoff",
|
||||
"briefOpenFailed": "Couldn't open the brief: {0}",
|
||||
"focusMode": "Focus",
|
||||
|
||||
@@ -326,14 +326,18 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
var time = DateTime.SpecifyKind(s.StartedAt, DateTimeKind.Utc).ToLocalTime().ToString("g");
|
||||
var briefPath = s.BriefPath;
|
||||
RecentSessions.Add(new RecentSessionViewModel
|
||||
RecentSessionViewModel row = null!;
|
||||
row = new RecentSessionViewModel
|
||||
{
|
||||
SessionId = s.SessionId,
|
||||
Title = s.Title,
|
||||
Meta = isHandoff ? $"{Loc.T("missionControl.handoffSession")} · {time}" : $"{time} · {s.Cwd}",
|
||||
IsHandoff = isHandoff,
|
||||
ResumeCommand = new RelayCommand(() => ResumeSession(s, taskCurrent.Contains(s.SessionId))),
|
||||
ResumeCommand = new RelayCommand(() => ResumeSession(s, row.Title, taskCurrent.Contains(s.SessionId))),
|
||||
ShowBriefCommand = briefPath is null ? null : new RelayCommand(() => ShowBrief(briefPath)),
|
||||
});
|
||||
CommitRenameCommand = new RelayCommand(() => _ = RenameSessionAsync(row)),
|
||||
};
|
||||
RecentSessions.Add(row);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
|
||||
@@ -365,7 +369,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
|
||||
// A task's current session reopens its task tile (deduped, same worktree guards); any other
|
||||
// session gets a fresh ad-hoc tile resuming it in its recorded cwd.
|
||||
private void ResumeSession(MissionControlSessionEntity session, bool isTaskCurrent)
|
||||
private void ResumeSession(MissionControlSessionEntity session, string title, bool isTaskCurrent)
|
||||
{
|
||||
if (session.TaskId is { } taskId && isTaskCurrent)
|
||||
{
|
||||
@@ -374,10 +378,27 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
var paneId = Guid.NewGuid().ToString("n");
|
||||
var sessionId = session.SessionId;
|
||||
AddConPtyPane(new ConPtyPaneViewModel(null, session.Title,
|
||||
AddConPtyPane(new ConPtyPaneViewModel(null, title,
|
||||
() => DescribeAsync(() => _worker.GetResumeSessionLaunchSpecAsync(sessionId, paneId))) { IdentityKey = paneId });
|
||||
}
|
||||
|
||||
/// <summary>Commits an inline rename: trims, ignores empty/unchanged input, persists the title.</summary>
|
||||
internal async System.Threading.Tasks.Task RenameSessionAsync(RecentSessionViewModel row)
|
||||
{
|
||||
if (!row.IsEditing) return;
|
||||
row.IsEditing = false;
|
||||
|
||||
var title = row.EditText.Trim();
|
||||
if (title.Length == 0 || title == row.Title) return;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
await new MissionControlSessionRepository(ctx).RenameAsync(row.SessionId, title);
|
||||
row.Title = title;
|
||||
}
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.renameFailed", ex.Message)); }
|
||||
}
|
||||
|
||||
private void ShowBrief(string briefPath)
|
||||
{
|
||||
var (ok, error) = ShellOpen.Path(briefPath);
|
||||
@@ -941,9 +962,28 @@ public sealed partial class RunningOperationViewModel : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>One row in the "Recent sessions" flyout.</summary>
|
||||
public sealed class RecentSessionViewModel
|
||||
public sealed partial class RecentSessionViewModel : ObservableObject
|
||||
{
|
||||
public required string Title { get; init; }
|
||||
public required string SessionId { get; init; }
|
||||
/// <summary>Display title; updated in place once a rename is persisted.</summary>
|
||||
[ObservableProperty] private string _title = "";
|
||||
/// <summary>True while the inline title TextBox is shown.</summary>
|
||||
[ObservableProperty] private bool _isEditing;
|
||||
/// <summary>The TextBox's working value, kept apart from <see cref="Title"/> so Escape needs no restore.</summary>
|
||||
[ObservableProperty] private string _editText = "";
|
||||
/// <summary>Commits <see cref="EditText"/> as the new title.</summary>
|
||||
public required IRelayCommand CommitRenameCommand { get; init; }
|
||||
|
||||
/// <summary>Switches the row into inline edit mode, seeded with the current title.</summary>
|
||||
public void BeginEdit()
|
||||
{
|
||||
EditText = Title;
|
||||
IsEditing = true;
|
||||
}
|
||||
|
||||
/// <summary>Leaves edit mode without saving.</summary>
|
||||
public void CancelEdit() => IsEditing = false;
|
||||
|
||||
/// <summary>Start time plus cwd (origin) or the handoff label (handoff).</summary>
|
||||
public required string Meta { get; init; }
|
||||
/// <summary>Indents the row under its chain's origin session.</summary>
|
||||
|
||||
@@ -83,10 +83,21 @@
|
||||
<DataTemplate x:DataType="vm:RecentSessionViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto" Classes.handoff="{Binding IsHandoff}">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,4" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding Title}"
|
||||
Foreground="{DynamicResource TextDimBrush}" />
|
||||
<Panel>
|
||||
<TextBlock Text="{Binding Title}"
|
||||
IsVisible="{Binding !IsEditing}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{loc:Tr missionControl.renameSession}"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
Cursor="Ibeam"
|
||||
DoubleTapped="OnSessionTitleDoubleTapped" />
|
||||
<TextBox Text="{Binding EditText, Mode=TwoWay}"
|
||||
IsVisible="{Binding IsEditing}"
|
||||
AcceptsReturn="False"
|
||||
MinHeight="0" Padding="4,2"
|
||||
KeyDown="OnSessionTitleEditKeyDown"
|
||||
LostFocus="OnSessionTitleEditLostFocus" />
|
||||
</Panel>
|
||||
<TextBlock Classes="meta"
|
||||
Text="{Binding Meta}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
@@ -40,6 +41,31 @@ public partial class MissionControlView : UserControl
|
||||
OverviewGrid.AddHandler(PointerReleasedEvent, OnTilePointerReleased);
|
||||
}
|
||||
|
||||
// Inline rename in the Recent sessions flyout. Deliberately not Buttons: the Click handler above would hide the flyout.
|
||||
private void OnSessionTitleDoubleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (sender is not TextBlock { DataContext: RecentSessionViewModel row, Parent: Panel panel }) return;
|
||||
row.BeginEdit();
|
||||
var box = panel.Children.OfType<TextBox>().FirstOrDefault();
|
||||
// Deferred: the TextBox only becomes visible (focusable) after the binding update.
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() => { box?.Focus(); box?.SelectAll(); });
|
||||
}
|
||||
|
||||
private void OnSessionTitleEditKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (sender is not TextBox { DataContext: RecentSessionViewModel row }) return;
|
||||
if (e.Key == Key.Enter) row.CommitRenameCommand.Execute(null);
|
||||
else if (e.Key == Key.Escape) row.CancelEdit();
|
||||
else return;
|
||||
e.Handled = true; // Escape would otherwise close the flyout too
|
||||
}
|
||||
|
||||
private void OnSessionTitleEditLostFocus(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is TextBox { DataContext: RecentSessionViewModel row })
|
||||
row.CommitRenameCommand.Execute(null);
|
||||
}
|
||||
|
||||
// Ticks the "Running now" elapsed times while the view is on screen.
|
||||
private Avalonia.Threading.DispatcherTimer? _elapsedTimer;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user