Add port forwarding: local, remote and dynamic, per terminal session
ci / build and test (push) Successful in 3m43s
ci / api image (push) Canceled after 0s
ci / android head (push) Canceled after 4m6s
ci / desktop nightly (push) Canceled after 21s

A host can now carry port forwards (-L, -R, -D), saved inside its
encrypted payload as RelayTicketRequest.PortForwardId already assumed,
so they sync and merge per forward like tags and pinned paths. The host
schema moves to version 8 only for hosts that carry forwards.

ISshConnection gains StartForwardAsync over SSH.NET's forwarded ports.
Every listener binds loopback and there is no bind-address field: a
forward is something any member of a shared keychain can edit. A remote
forward's target is resolved here, IPv4 first, rather than by SSH.NET's
constructor, which takes the first DNS answer and can send ::1.

Forwards start with every terminal opened to the host and stop with its
connection; a reconnect starts them again. The session sidebar lists
them with state, failure reason and an on/off switch, the status bar
counts the listening ones, and the host editor and detail pane gain a
PORT FORWARDS section. A forward that cannot start fails alone.

The phone does not start forwards yet: it has no surface showing what
is listening, and its More screen now says so. No byte counts, since
SSH.NET keeps none.
This commit is contained in:
jaap-jan committed 2026-09-28 20:47:02 +02:00
1 parent c8848ec1e3
commit f05deb4fbc
36 files changed
+2776 -42

No files matched your search

@@ -3832,11 +3832,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.
@@ -3858,6 +3860,47 @@ 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>Not on the phone.</b> This view model is shared, and the forwarding would work there unchanged —
/// but that head has no surface listing a tab's forwards, and listeners opened on a phone that never shows
/// them are exactly the invisible state this codebase refuses elsewhere. Its More screen says forwarding
/// is desktop-only for now. A runtime check for the reason <c>RendererNeverStarted</c> gives.
/// </para>
/// </remarks>
private void StartForwards(TerminalTabViewModel tab, TerminalSessionEventArgs e)
{
if (OperatingSystem.IsAndroid())
{
return;
}
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)
{
_ = forward.StartAsync();
}
}
/// <summary>
/// Answers a connection that did not become a session.
/// </summary>
@@ -4658,6 +4701,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>
@@ -0,0 +1,249 @@
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>Where one of a tab's forwards has got to.</summary>
internal enum PortForwardState
{
/// <summary>Not listening: switched off, or its session is over.</summary>
Stopped = 0,
/// <summary>Asked for and not answered yet. A remote forward waits on the server.</summary>
Starting = 1,
/// <summary>Listening.</summary>
Listening = 2,
/// <summary>Could not be started; <see cref="PortForwardViewModel.Detail"/> says why.</summary>
Failed = 3,
}
/// <summary>
/// One of a host's saved forwards, as it runs on one terminal tab.
/// </summary>
/// <remarks>
/// <para>
/// Built fresh for each session the tab holds, and started with it: the rule is the host's, the listener is
/// this session's. A reconnect is a new session, so it gets new rows — see
/// <see cref="TerminalTabViewModel.ReplaceForwards"/> — rather than trying to carry a listener across a
/// connection that no longer exists.
/// </para>
/// <para>
/// <b>One failing does not stop the others, or the terminal.</b> The commonest failure is a port already in
/// use — very often by a second tab to the same host, which is running the same forwards — and a shell that
/// would not open because of it would be the forward taking the session hostage. The row says why instead.
/// </para>
/// </remarks>
internal sealed partial class PortForwardViewModel : ObservableObject
{
private readonly Func<SshPortForwardSpec, CancellationToken, Task<ISshPortForward>> start;
private readonly Action<Action> post;
private ISshPortForward? forward;
private bool sessionOver;
/// <summary>The detail line: why it failed, or what last went wrong through it.</summary>
[ObservableProperty]
private string? detail;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsListening))]
[NotifyPropertyChangedFor(nameof(IsFailed))]
[NotifyPropertyChangedFor(nameof(CanToggle))]
[NotifyPropertyChangedFor(nameof(StateText))]
[NotifyCanExecuteChangedFor(nameof(ToggleCommand))]
private PortForwardState state;
/// <param name="rule">The host's saved forward.</param>
/// <param name="start">Starts a forward on this tab's session; see <c>TerminalWorkspace.StartForwardAsync</c>.</param>
/// <param name="post">Runs an action on the UI thread, for reports SSH.NET raises on its own.</param>
internal PortForwardViewModel(
PortForwardRule rule,
Func<SshPortForwardSpec, CancellationToken, Task<ISshPortForward>> start,
Action<Action> post)
{
Rule = rule;
this.start = start;
this.post = post;
}
/// <summary>The saved forward this row runs.</summary>
internal PortForwardRule Rule { get; }
/// <summary>The forward in OpenSSH's notation, e.g. <c>-L 5432:db.internal:5432</c>.</summary>
internal string Notation => Rule.Notation;
/// <summary>What the forward connects to what, in words rather than flags.</summary>
/// <remarks>
/// Which end each port is on is the thing people get backwards about <c>-L</c> and <c>-R</c>, so it is
/// written out: "this machine" and "the remote" rather than an arrow whose direction has to be learned.
/// </remarks>
internal string Route => Rule.Kind switch
{
PortForwardKind.Local =>
$"localhost:{Port(Rule.BindPort)} here → {Rule.TargetHost}:{Port(Rule.TargetPort)} from the remote",
PortForwardKind.Remote =>
$"localhost:{Port(Rule.BindPort)} on the remote → {Rule.TargetHost}:{Port(Rule.TargetPort)} from here",
_ => $"SOCKS proxy on localhost:{Port(Rule.BindPort)}, out through the remote",
};
/// <summary>Whether it is listening now.</summary>
internal bool IsListening => State is PortForwardState.Listening;
/// <summary>Whether it could not be started.</summary>
internal bool IsFailed => State is PortForwardState.Failed;
/// <summary>
/// Whether the switch does anything: not while a start is in flight, and never once the session is over.
/// </summary>
internal bool CanToggle => !sessionOver && State is not PortForwardState.Starting;
/// <summary>The state in the status bar's own lettering.</summary>
internal string StateText => State switch
{
PortForwardState.Starting => "STARTING",
PortForwardState.Listening => "LISTENING",
PortForwardState.Failed => "FAILED",
_ => "OFF",
};
/// <summary>Starts the forward, unless it is already running or its session has ended.</summary>
/// <remarks>
/// Never throws. It is started without anyone waiting on it — every forward on a tab at once, as the
/// tab opens — so a failure has nowhere to go but this row.
/// </remarks>
internal async Task StartAsync()
{
if (sessionOver || State is PortForwardState.Starting or PortForwardState.Listening)
{
return;
}
State = PortForwardState.Starting;
Detail = null;
ISshPortForward started;
try
{
started = await start(ToSpec(Rule), CancellationToken.None).ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Unless the session ended while this was in flight, in which case that is the true story and
// SessionEnded has already told it.
if (!sessionOver)
{
Detail = exception.Message;
State = PortForwardState.Failed;
}
return;
}
if (sessionOver)
{
// The session ended while the server was answering. Its connection has gone and taken the
// listener with it; this only lets go of the handle.
await started.DisposeAsync().ConfigureAwait(true);
return;
}
forward = started;
started.ErrorOccurred += OnForwardError;
State = PortForwardState.Listening;
}
/// <summary>Stops the forward and leaves the row switched off.</summary>
internal async Task StopAsync()
{
await ReleaseAsync().ConfigureAwait(true);
if (!sessionOver)
{
State = PortForwardState.Stopped;
Detail = null;
}
}
/// <summary>
/// Records that the session this forward ran over has ended, which stopped the forward with it.
/// </summary>
/// <remarks>
/// A failed row keeps its reason: that the session later ended does not make it any less true that the
/// port was taken. Anything else is off, and says it went with the session rather than being switched
/// off, which is the difference between "I did that" and "that happened".
/// </remarks>
internal void SessionEnded()
{
if (sessionOver)
{
return;
}
sessionOver = true;
_ = ReleaseAsync();
if (State is not PortForwardState.Failed)
{
State = PortForwardState.Stopped;
Detail = "Stopped with the session.";
}
OnPropertyChanged(nameof(CanToggle));
ToggleCommand.NotifyCanExecuteChanged();
}
/// <summary>The SSH layer's spec for a saved rule; the two enums share their numbering on purpose.</summary>
internal static SshPortForwardSpec ToSpec(PortForwardRule rule) => rule.Kind switch
{
PortForwardKind.Local => new SshPortForwardSpec(
SshPortForwardKind.Local, rule.BindPort, rule.TargetHost, rule.TargetPort ?? 0),
PortForwardKind.Remote => new SshPortForwardSpec(
SshPortForwardKind.Remote, rule.BindPort, rule.TargetHost, rule.TargetPort ?? 0),
_ => new SshPortForwardSpec(SshPortForwardKind.Dynamic, rule.BindPort),
};
/// <remarks>A failed row's switch is a retry: the port may have been freed since.</remarks>
[RelayCommand(CanExecute = nameof(CanToggle))]
private Task ToggleAsync() => IsListening ? StopAsync() : StartAsync();
private async Task ReleaseAsync()
{
if (forward is not { } running)
{
return;
}
forward = null;
running.ErrorOccurred -= OnForwardError;
try
{
await running.DisposeAsync().ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Stopping a listener has no failure anybody could act on; the connection closing would have
// stopped it regardless.
}
}
/// <remarks>
/// A connection through the forward failed — usually the target refusing it. The listener is still up,
/// so the row stays LISTENING and says what happened underneath.
/// </remarks>
private void OnForwardError(object? sender, SshPortForwardErrorEventArgs e) => post(() =>
{
if (State is PortForwardState.Listening)
{
Detail = e.Message;
}
});
private static string Port(int? port) => port?.ToString(CultureInfo.InvariantCulture) ?? "?";
}
@@ -1,3 +1,6 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using DodoSSH.Client.Ssh;
@@ -326,6 +329,73 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
/// <summary>How many steps there are, for the card's track.</summary>
internal int StepCount => Steps.Count;
/// <summary>
/// The host's saved forwards, as they run over this tab's current session.
/// </summary>
/// <remarks>
/// Replaced whole each time a session opens here — see <see cref="ReplaceForwards"/> — and emptied of
/// meaning, though not of rows, when it ends: a row that says it stopped with the session is more use
/// than one that silently disappeared.
/// </remarks>
internal ObservableCollection<PortForwardViewModel> Forwards { get; } = [];
/// <summary>How many of <see cref="Forwards"/> are listening now.</summary>
internal int ListeningForwardCount => Forwards.Count(forward => forward.IsListening);
/// <summary>
/// The status bar's forward count, or null when nothing is listening and there is nothing to say.
/// </summary>
internal string? ForwardsSummary => ListeningForwardCount switch
{
0 => null,
1 => "⇄ 1 FORWARD",
var count => string.Create(CultureInfo.InvariantCulture, $"⇄ {count} FORWARDS"),
};
/// <summary>Puts a new session's forwards on this tab, ending whatever the last session left.</summary>
internal void ReplaceForwards(IEnumerable<PortForwardViewModel> forwards)
{
EndForwards();
foreach (var forward in Forwards)
{
forward.PropertyChanged -= OnForwardChanged;
}
Forwards.Clear();
foreach (var forward in forwards)
{
forward.PropertyChanged += OnForwardChanged;
Forwards.Add(forward);
}
RaiseForwardCount();
}
/// <summary>Tells every forward its session is over; SSH.NET has already stopped the listeners.</summary>
private void EndForwards()
{
foreach (var forward in Forwards)
{
forward.SessionEnded();
}
}
private void OnForwardChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(PortForwardViewModel.State))
{
RaiseForwardCount();
}
}
private void RaiseForwardCount()
{
OnPropertyChanged(nameof(ListeningForwardCount));
OnPropertyChanged(nameof(ForwardsSummary));
}
/// <inheritdoc cref="TerminalTabState" />
[ObservableProperty]
private TerminalTabState state;
@@ -603,6 +673,8 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
EndDescription = description;
IsLive = false;
EndForwards();
OnPropertyChanged(nameof(EndedDetail));
OnPropertyChanged(nameof(EndedSummary));
OnPropertyChanged(nameof(EndDescription));
@@ -638,6 +710,9 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
EndDescription = null;
IsLive = false;
// The old session is being closed for its pane's sake, and its forwards go with its connection.
EndForwards();
// Cleared with the session they were facts about. Keeping them would put the previous connection's
// cipher in the status bar beside a handshake that has not negotiated one yet.
StartedAt = null;
@@ -594,6 +594,10 @@ internal sealed partial class HostRowViewModel(
/// <summary>Whether the card draws a pin-count badge at all.</summary>
internal bool HasPins => PinCount > 0;
/// <summary>Whether this host carries any port forwards, for the detail pane's FORWARDS card.</summary>
/// <remarks>Read off the secret for the reason <see cref="PinCount"/> is.</remarks>
internal bool HasForwards => host.Secret.PortForwards.Count > 0;
/// <summary>
/// The two letters a card's monogram avatar draws.
/// </summary>
@@ -956,8 +960,17 @@ internal sealed class TerminalSessionEventArgs(
string cipher,
string hostKeyAlgorithm,
string? identityLabel,
Guid? hostId) : EventArgs
Guid? hostId,
PortForwardList? forwards = null) : EventArgs
{
/// <summary>
/// The forwards to start on this session: the host's saved ones, or none for a typed-in address.
/// </summary>
/// <remarks>
/// Carried here because the shell has no vault to read them from, for the reason the label is.
/// </remarks>
internal PortForwardList Forwards { get; } = forwards ?? PortForwardList.Empty;
/// <summary>Which attempt this session came out of.</summary>
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
internal Guid AttemptId { get; } = attemptId;
@@ -3722,6 +3735,196 @@ internal sealed partial class VaultViewModel(
EditorNewPin = string.Empty;
}
/// <summary>
/// The port forwards on the host being edited, staged the way <see cref="EditorPinnedPaths"/> is.
/// </summary>
/// <remarks>
/// Populated when the editor opens, changed by <see cref="AddEditorForward"/> and
/// <see cref="RemoveEditorForward"/>, read back by <see cref="BuildHost"/> on Save. A rule keeps its id
/// through the editor, which is what lets the merge tell an edited forward from a new one.
/// </remarks>
internal ObservableCollection<PortForwardRule> EditorPortForwards { get; } = [];
/// <summary>The add row's kind, as the index of <see cref="PortForwardKind"/>: local, remote, dynamic.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(EditorNewForwardHasTarget))]
[NotifyPropertyChangedFor(nameof(EditorNewForwardBindHint))]
[NotifyPropertyChangedFor(nameof(EditorNewForwardKindHint))]
private int editorNewForwardKind;
/// <summary>The add row's listening port, as typed.</summary>
[ObservableProperty]
private string editorNewForwardBindPort = string.Empty;
/// <summary>The add row's target, as <c>host:port</c>.</summary>
[ObservableProperty]
private string editorNewForwardTarget = string.Empty;
/// <summary>Whether the add row's kind has a target at all; a dynamic forward does not.</summary>
internal bool EditorNewForwardHasTarget => (PortForwardKind)EditorNewForwardKind is not PortForwardKind.Dynamic;
/// <summary>The add row's listening-port placeholder: short, because the box is narrow.</summary>
internal string EditorNewForwardBindHint => (PortForwardKind)EditorNewForwardKind switch
{
PortForwardKind.Remote => "remote port",
PortForwardKind.Dynamic => "SOCKS port",
_ => "local port",
};
/// <summary>
/// What the add row's kind does, in a sentence naming which end each port is on — the thing people get
/// backwards about <c>-L</c> and <c>-R</c>.
/// </summary>
internal string EditorNewForwardKindHint => (PortForwardKind)EditorNewForwardKind switch
{
PortForwardKind.Remote =>
"A port on the remote, carried back to a host:port as this machine sees it.",
PortForwardKind.Dynamic =>
"A SOCKS proxy on this machine. Each connection through it leaves from the remote.",
_ => "A port on this machine, carried to a host:port as the remote sees it.",
};
/// <summary>
/// Adds a forward to the host being edited, from the editor's add row.
/// </summary>
/// <remarks>
/// <para>
/// Validated here rather than at Save, for the reason <see cref="AddEditorPin"/> is: a refusal at the
/// box that caused it costs nothing else on the form. The rule's own
/// <see cref="PortForwardRule.TryValidate"/> is the check, so this cannot drift from what the codec
/// accepts.
/// </para>
/// <para>
/// <b>A second listener on a port this host already listens on is refused here</b>, though the domain
/// allows it — it has to, because two machines each adding one is what a merge produces. Here it is one
/// person, and the second would only ever fail to start.
/// </para>
/// </remarks>
[RelayCommand]
private void AddEditorForward()
{
var kind = (PortForwardKind)EditorNewForwardKind;
if (!int.TryParse(EditorNewForwardBindPort.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var bindPort))
{
Status = "A forward needs a listening port, as a number.";
return;
}
string? targetHost = null;
int? targetPort = null;
if (kind is not PortForwardKind.Dynamic)
{
if (!TryParseForwardTarget(EditorNewForwardTarget, out targetHost, out targetPort))
{
Status = "A forward's target is host:port, for example db.internal:5432.";
return;
}
}
var rule = new PortForwardRule
{
Id = Guid.CreateVersion7(),
Kind = kind,
BindPort = bindPort,
TargetHost = targetHost,
TargetPort = targetPort,
};
if (!rule.TryValidate(out var reason))
{
Status = reason;
return;
}
if (EditorPortForwards.Count >= HostSecret.MaxPortForwards)
{
Status = $"A host cannot carry more than {HostSecret.MaxPortForwards} port forwards.";
return;
}
// Local and dynamic forwards both listen on this machine, so they share its ports; a remote forward
// listens on the remote, which is a different set.
var listensHere = kind is not PortForwardKind.Remote;
if (EditorPortForwards.Any(existing =>
existing.BindPort == bindPort && existing.Kind is not PortForwardKind.Remote == listensHere))
{
Status = listensHere
? $"This host already has a forward listening on port {bindPort} here."
: $"This host already has a forward listening on port {bindPort} on the remote.";
return;
}
EditorPortForwards.Add(rule);
EditorNewForwardBindPort = string.Empty;
EditorNewForwardTarget = string.Empty;
Status = string.Empty;
}
/// <summary>Removes a forward from the host being edited.</summary>
[RelayCommand]
private void RemoveEditorForward(PortForwardRule? rule)
{
if (rule is not null)
{
EditorPortForwards.Remove(rule);
}
}
/// <summary>Stages an existing host's forwards for editing. See <see cref="EditorPortForwards"/>.</summary>
private void LoadEditorPortForwards(PortForwardList forwards)
{
EditorPortForwards.Clear();
foreach (var forward in forwards)
{
EditorPortForwards.Add(forward);
}
EditorNewForwardKind = (int)PortForwardKind.Local;
EditorNewForwardBindPort = string.Empty;
EditorNewForwardTarget = string.Empty;
}
/// <summary>
/// Reads <c>host:port</c>, with an IPv6 literal in brackets as OpenSSH writes one: <c>[::1]:5432</c>.
/// </summary>
/// <remarks>
/// Split at the last colon, so an unbracketed IPv6 address fails to parse as a port rather than being
/// cut somewhere in its middle. The host is checked by the rule, not here.
/// </remarks>
internal static bool TryParseForwardTarget(string text, out string? host, out int? port)
{
host = null;
port = null;
var trimmed = text.Trim();
var colon = trimmed.LastIndexOf(':');
if (colon <= 0
|| !int.TryParse(trimmed.AsSpan(colon + 1), NumberStyles.None, CultureInfo.InvariantCulture, out var parsed))
{
return false;
}
var name = trimmed[..colon];
if (name.StartsWith('[') && name.EndsWith(']'))
{
name = name[1..^1];
}
else if (name.Contains(':', StringComparison.Ordinal))
{
return false;
}
host = name;
port = parsed;
return true;
}
/// <summary>The item being edited, or null when creating.</summary>
private Guid? editingEntityId;
@@ -7547,6 +7750,7 @@ internal sealed partial class VaultViewModel(
BuildTagChoices();
EditorPinnedPaths.Clear();
EditorNewPin = string.Empty;
LoadEditorPortForwards(PortForwardList.Empty);
// Closed rather than carried over, and it holds a password — see EditorNewCredentialPassword.
ClearEditorCredentialForm();
@@ -7640,6 +7844,7 @@ internal sealed partial class VaultViewModel(
ClearEditorCredentialForm();
LoadEditorPinnedPaths(row.Host.PinnedPaths);
LoadEditorPortForwards(row.Host.PortForwards);
BuildEditorVaultChoices(editingHostVaultId);
BuildGroupChoices(row.Host.GroupId);
@@ -11810,7 +12015,8 @@ internal sealed partial class VaultViewModel(
facts?.Cipher ?? string.Empty,
facts?.HostKeyAlgorithm ?? string.Empty,
authentication.IdentityLabel,
target.HostId));
target.HostId,
target.Row?.Host.PortForwards));
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
// for must not wait on a vault write to appear.
@@ -12367,6 +12573,9 @@ internal sealed partial class VaultViewModel(
// than twice.
PinnedPaths = PinnedPathList.Create(EditorPinnedPaths),
// The staged list, in the order it was built; each rule keeps the id it arrived with.
PortForwards = PortForwardList.Create(EditorPortForwards),
// Both read off the one picker, including the id of something that has gone missing. Reading them
// from the picker rather than carrying the originals through is what lets a binding be removed at
// all, and preserving a missing id is what stops an unrelated edit removing one by accident. One