Merge origin/main into claude/spelunk-tunnel

Port forwarding took host schema version 8 first, so the Spelunk route moves to 9 and is written after the forwards. A host may carry both.
This commit is contained in:
jaap-jan committed 2026-09-28 21:38:09 +02:00
commit 689ba6c1bf
44 files changed
+3187 -75

No files matched your search

@@ -1241,6 +1241,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
internal event EventHandler? TerminalSessionOpened;
/// <summary>
/// Raised when any tab's forward starts, stops or fails, so a head can re-count what is listening.
/// </summary>
/// <remarks>
/// The phone's keep-alive notification is the one listener: it says how many forwards are listening
/// alongside how many shells are connected. On the UI thread, since every state change is.
/// </remarks>
internal event EventHandler? ForwardsChanged;
/// <summary>How many forwards are listening, across every tab.</summary>
internal int ListeningForwardCount => Tabs.Sum(tab => tab.ListeningForwardCount);
/// <summary>
/// Raised when something this shell did belongs in the terminal the user is already looking at, so the
/// view can put the keyboard back there.
@@ -1487,6 +1499,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
internal bool IsTerminalShowing =>
IsTerminalSurface
&& !IsConnectSheetOpen
&& !IsForwardsSheetOpen
&& !IsHostKeyDecisionShowing
&& SelectedTab is { HasSession: true };
@@ -2232,6 +2245,45 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private void CloseConnectSheet() => IsConnectSheetOpen = false;
// ---- The phone's forwards sheet ----
/// <summary>
/// Whether the phone's list of the selected tab's forwards is open over the terminal.
/// </summary>
/// <remarks>
/// <para>
/// The phone's answer to the desktop sidebar's FORWARDS section, over the same rows —
/// <see cref="TerminalTabViewModel.Forwards"/> — with the same state word, reason and switch. A sheet
/// rather than something always drawn because a phone gives a shell the whole screen, so it is raised
/// from a button in the terminal's bar, which draws only for a tab that has forwards and turns the danger
/// colour when one of them failed.
/// </para>
/// <para>
/// Shell state for the reason <see cref="IsConnectSheetOpen"/> is: it collapses the renderer, which is a
/// native view nothing can be drawn over. Lowered with the connect sheet whenever the terminal surface is
/// left — see <see cref="OnSurfaceChanged"/>.
/// </para>
/// </remarks>
[ObservableProperty]
private bool isForwardsSheetOpen;
/// <summary>Raises the forwards sheet over the terminal.</summary>
/// <remarks>Gated as <see cref="OpenConnectSheet"/> is, and on there being a tab to list.</remarks>
[RelayCommand]
private void OpenForwardsSheet()
{
if (!IsTerminalSurface || SelectedTab is null)
{
return;
}
IsForwardsSheetOpen = true;
}
/// <summary>Lowers the forwards sheet, leaving the terminal where it was.</summary>
[RelayCommand]
private void CloseForwardsSheet() => IsForwardsSheetOpen = false;
// ---- Open terminals ----
/// <summary>
@@ -3868,11 +3920,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
IdentityLabel = e.IdentityLabel,
};
AdoptTab(adopted);
StartForwards(adopted, e);
RefreshConnectedHosts();
return;
}
tab.Opened(e.SessionId);
StartForwards(tab, e);
// From this moment, not from when the tab first appeared — connecting is not open, and the session
// shell's elapsed timer is about a shell that is actually running.
@@ -3894,6 +3948,54 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Puts the host's saved forwards on a tab whose session has just opened, and starts every one.
/// </summary>
/// <remarks>
/// <para>
/// All at once and none awaited: each row reports its own outcome, and the terminal is already usable
/// while a remote forward waits for the server to answer. See <see cref="PortForwardViewModel"/> for why
/// one failing stops nothing else.
/// </para>
/// <para>
/// The session id is captured into the start delegate rather than read off the tab when a row is
/// toggled later. A reconnect puts a new id on the same tab, and a row from the old session that reached
/// for the tab's current id would start its listener on a connection it was never part of.
/// </para>
/// <para>
/// ◆ <b>On the phone too.</b> This used to return early on Android, because that head had nowhere to show
/// what was listening. Its terminal bar now carries a FORWARDS button over the same rows the desktop's
/// sidebar lists — see <see cref="IsForwardsSheetOpen"/> — so both heads start the same forwards.
/// </para>
/// </remarks>
private void StartForwards(TerminalTabViewModel tab, TerminalSessionEventArgs e)
{
var sessionId = e.SessionId;
tab.ReplaceForwards(e.Forwards.Select(rule => new PortForwardViewModel(
rule,
(spec, cancellationToken) => workspace.StartForwardAsync(sessionId, spec, cancellationToken),
post)));
foreach (var forward in tab.Forwards)
{
// Never unsubscribed, and it need not be: the row holds the handler, not the other way round, so a
// row replaced by a reconnect is collected with it — after its last change, to Stopped, is heard.
forward.PropertyChanged += OnForwardChanged;
_ = forward.StartAsync();
}
ForwardsChanged?.Invoke(this, EventArgs.Empty);
}
private void OnForwardChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(PortForwardViewModel.State))
{
ForwardsChanged?.Invoke(this, EventArgs.Empty);
}
}
/// <summary>
/// Answers a connection that did not become a session.
/// </summary>
@@ -4694,6 +4796,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
vault.EditSelectedHostCommand.Execute(null);
}
/// <summary>
/// The sidebar's FORWARDS affordance: opens the active tab's host for editing, where forwards are added.
/// </summary>
/// <remarks>
/// The same route <see cref="PinFolderFromSidebar"/> takes, because a forward is saved on the host for
/// the same reason a pin is. A forward added there starts with the next session to the host, not this
/// one — the editor says so — since this session's forwards were started from the host as it was.
/// </remarks>
[RelayCommand]
private void AddForwardFromSidebar() => PinFolderFromSidebar();
/// <summary>
/// The SFTP tab row's click: makes one of the terminal's tabs the SFTP surface's browsed host.
/// </summary>
@@ -5169,6 +5282,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
if (value is not ShellSurface.Terminal)
{
IsConnectSheetOpen = false;
IsForwardsSheetOpen = false;
}
RaiseSurfaceState();
@@ -5353,6 +5467,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <inheritdoc cref="OnIsSearchingChanged" />
partial void OnIsConnectSheetOpenChanged(bool value) => RaiseTerminalState();
/// <inheritdoc cref="OnIsSearchingChanged" />
partial void OnIsForwardsSheetOpenChanged(bool value) => RaiseTerminalState();
/// <remarks>
/// The unlock card and the confirmation swap, so arming one has to hide the other — see
/// <see cref="IsAskingForThePassphrase"/>.