Public Access
Merge branch 'main'
Two of main's changes land in files this branch rewrote, and both needed carrying across by hand rather than by the merge. The phone's nav staying up on Connections with nothing running is a fourth input to RefreshChrome, which this branch had already given two more — whether hosts are ticked and whether the host editor is filling the screen. They compose: the rail and the bottom bar now ask (pages || connectPage) && !editing, so a page-shaped terminal surface keeps its way off the screen and the editor still takes the whole display. The key question under the host's move panel is the harder one, because this branch deleted the panel it was added to. The connect card is gone and the phone's only route to a move is the action bar, so leaving the merge to take this side would have removed a capability main had just shipped — silently, since nothing would fail to build. It is asked in the action bar's own picker instead, in two shapes fewer than the desktop's: one host, because which key to carry is a fact about one machine and a selection of six has six answers, and a move rather than a copy, because taking the key out from under an original that is staying put would leave that original unable to connect. BindingOfTheMovingHost splits into MovableBindingOf so both heads answer it the same way from different panels. Main also fixed a real trap in the same commit — a host that only inherited its key from its group arrived in the destination naming nothing at all, because the group stays behind — and the batch move had the same bug for the same reason. It goes through Detached now, which is where that fix lives. The carried host is written as the carry left it rather than being detached again, which is the one thing worth measuring: the key takes a new id over there, so a run that rebuilt the payload from the row would send the machine across naming a tombstone. Both directions are pinned, along with the rule about which shapes the question is asked in at all.
This commit is contained in:
@@ -35,6 +35,15 @@ internal sealed record InsertTarget(uint? SessionId, string Label)
|
||||
/// there", which is what <see cref="InsertLabel"/> says, and the Enter is the user's unless the snippet was
|
||||
/// deliberately marked as one that runs — see <see cref="SnippetSecret.RunsOnInsert"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A snippet can be shared, which is why this screen has two vault controls rather than none.</b> The
|
||||
/// editor asks which vault a <em>new</em> snippet goes into, and the move panel re-seals an existing one into
|
||||
/// another — two controls because they are two different acts. A save writes a payload; a move re-encrypts it
|
||||
/// under a second key and tombstones the first, so putting that in the editor would let somebody fixing a
|
||||
/// typo hand a command to a team by leaving a picker where they found it. Both live here rather than on
|
||||
/// <see cref="VaultViewModel"/> because this screen owns its editor, unlike the host pane; the writing they
|
||||
/// ask for is still the vault's.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
{
|
||||
@@ -103,7 +112,100 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
internal bool HasSnippets => vault.Snippets.Count > 0;
|
||||
/// <summary>
|
||||
/// The vault the open editor will write to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Latched when the editor opens — the chosen vault for a new snippet, the row's own vault for an
|
||||
/// existing one — rather than read back off the selection at save time. The list this screen shows spans
|
||||
/// every readable vault now, so a save that reached for the active vault instead would fork a colleague's
|
||||
/// snippet into a private copy; and a selection that moved under a half-typed form would send the text to
|
||||
/// whichever row happened to be highlighted. The same reason <c>VaultViewModel.editingHostVaultId</c>
|
||||
/// exists.
|
||||
/// </remarks>
|
||||
private Guid editorVaultId;
|
||||
|
||||
/// <summary>Which vault the open move panel would send the snippet to.</summary>
|
||||
private SnippetRowViewModel? moving;
|
||||
|
||||
/// <summary>
|
||||
/// The vaults a new snippet may be filed into.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Filled from <c>VaultViewModel.TargetVaults</c>, which is already the readable-and-writable set: a
|
||||
/// vault this session cannot read has no key to encrypt with, and one it can read but not write is a
|
||||
/// team vault this account is a viewer of. The options are the shared objects rather than copies, so
|
||||
/// this picker and the keychain screen's show the same names without either being able to move the
|
||||
/// other — what they do not share is the selection.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<VaultChoiceViewModel> EditorVaultChoices { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private VaultChoiceViewModel? editorSelectedVault;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the editor should be asking which vault this snippet goes into.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only while creating, and only where there is more than one vault to choose between. An existing
|
||||
/// snippet's vault is not a field of this form — moving it is a re-seal and a tombstone rather than a
|
||||
/// save, offered by <see cref="Move"/> — and a control offering one option is a question nobody was
|
||||
/// asked.
|
||||
/// </remarks>
|
||||
internal bool ShowsEditorVaultChoice => IsCreating && EditorVaultChoices.Count > 1;
|
||||
|
||||
/// <summary>Whether the panel asking which vault to move the selected snippet to is up.</summary>
|
||||
/// <remarks>
|
||||
/// The armed-state idiom this application uses instead of a modal, carrying a choice rather than a yes:
|
||||
/// the question is not "are you sure" but "which vault".
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsSelectionActions))]
|
||||
private bool isMoving;
|
||||
|
||||
/// <summary>Where the selected snippet could be moved: every vault this session can write to but its own.</summary>
|
||||
internal ObservableCollection<VaultChoiceViewModel> MoveVaultChoices { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private VaultChoiceViewModel? selectedMoveVault;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the selected snippet has anywhere to move to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Asked so the phone can leave the button out rather than draw one that answers with a refusal, as the
|
||||
/// hosts screen does. It counts vaults rather than merely asking whether there are two, because the
|
||||
/// answer is per snippet: one already in the only other writable vault has nowhere to go.
|
||||
/// </remarks>
|
||||
internal bool CanMove =>
|
||||
Selected is { IsReadOnly: false } row && vault.MoveTargetsBesides(row.VaultId).Count > 0;
|
||||
|
||||
/// <summary>Whether the buttons under the selected snippet are showing.</summary>
|
||||
/// <remarks>
|
||||
/// Off while the move panel is up, which takes their place — the same rule the host pane's
|
||||
/// <c>ShowsHostPaneActions</c> carries, and for the same reason: the button that opened the panel would
|
||||
/// otherwise still be there offering to open it again.
|
||||
/// </remarks>
|
||||
internal bool ShowsSelectionActions => HasSelection && !IsMoving;
|
||||
|
||||
/// <summary>The vault the selected snippet lives in, named, or empty when there is only one.</summary>
|
||||
/// <remarks>
|
||||
/// For the detail pane, which is where somebody decides whether to insert a command into a production
|
||||
/// terminal. Who else can read it is part of that, and the badge on the row is gone by the time the pane
|
||||
/// is being read.
|
||||
/// </remarks>
|
||||
internal string SelectionVaultBadge => Selected?.VaultBadge ?? string.Empty;
|
||||
|
||||
/// <summary>Whether there is a vault to name beside the selected snippet.</summary>
|
||||
internal bool HasSelectionVaultBadge => SelectionVaultBadge.Length > 0;
|
||||
|
||||
/// <summary>Whether this keychain holds any snippet the screen would draw.</summary>
|
||||
/// <remarks>
|
||||
/// Counts what a hidden vault leaves behind rather than the whole list, so that switching a team's vault
|
||||
/// off and emptying the screen produces the "nothing saved yet" copy rather than a filter box over
|
||||
/// nothing.
|
||||
/// </remarks>
|
||||
internal bool HasSnippets => vault.Snippets.Any(row => vault.IsVaultShown(row.VaultId));
|
||||
|
||||
internal bool HasVisible => Visible.Count > 0;
|
||||
|
||||
@@ -136,19 +238,31 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
+ "typing it again.";
|
||||
|
||||
/// <summary>Starts a new snippet.</summary>
|
||||
/// <remarks>
|
||||
/// The vault picker lands on wherever the keychain screen is filing new items — the personal vault
|
||||
/// unless that has been changed — because a snippet put in a team's vault is a command everybody in
|
||||
/// that team can read, and that has to be chosen rather than defaulted into.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void New()
|
||||
{
|
||||
CloseMovePanel();
|
||||
|
||||
EditingId = null;
|
||||
EditorLabel = string.Empty;
|
||||
EditorCommand = string.Empty;
|
||||
EditorNotes = string.Empty;
|
||||
EditorRunsOnInsert = false;
|
||||
BuildEditorVaultChoices(vault.TargetVaultId);
|
||||
IsEditing = true;
|
||||
Status = "Adding a snippet.";
|
||||
}
|
||||
|
||||
/// <summary>Opens the selected snippet for editing.</summary>
|
||||
/// <remarks>
|
||||
/// The editor writes back to the vault this row came out of, which is what the latch is for. The picker
|
||||
/// is not drawn for an existing snippet: its vault is not a field of this form.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void Edit()
|
||||
{
|
||||
@@ -163,11 +277,14 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
return;
|
||||
}
|
||||
|
||||
CloseMovePanel();
|
||||
|
||||
EditingId = row.EntityId;
|
||||
EditorLabel = row.Snippet.Label;
|
||||
EditorCommand = row.Snippet.Command;
|
||||
EditorNotes = row.Snippet.Notes ?? string.Empty;
|
||||
EditorRunsOnInsert = row.Snippet.RunsOnInsert;
|
||||
BuildEditorVaultChoices(row.VaultId);
|
||||
IsEditing = true;
|
||||
Status = $"Editing {row.Label}.";
|
||||
}
|
||||
@@ -197,7 +314,8 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
RunsOnInsert = EditorRunsOnInsert,
|
||||
};
|
||||
|
||||
var saved = await vault.SaveSnippetAsync(EditingId, snippet, cancellationToken).ConfigureAwait(true);
|
||||
var saved = await vault.SaveSnippetAsync(editorVaultId, EditingId, snippet, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (!saved)
|
||||
{
|
||||
@@ -224,6 +342,105 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
Status = vault.Status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the panel that asks which vault the selected snippet should move to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// How a snippet gets shared: a command one person keeps becomes one the team holds a key to. A panel
|
||||
/// rather than a picker in the editor, because a move is a re-seal under the destination's key and a
|
||||
/// tombstone in the source — see <c>VaultItemRepository.MoveAsync</c> — and that must not happen as a
|
||||
/// side effect of saving a corrected typo.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Refused for a snippet a newer client wrote, as editing one is, and refused with the editor open: two
|
||||
/// forms about the same snippet, one of which moves it, is not something anybody should have to read
|
||||
/// carefully.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void Move()
|
||||
{
|
||||
if (Selected is not { } row || IsEditing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.IsReadOnly)
|
||||
{
|
||||
Status = "This snippet was written by a newer version of DodoSSH. Moving it would re-encode it "
|
||||
+ "here and lose what this build cannot read. Update first.";
|
||||
return;
|
||||
}
|
||||
|
||||
var choices = vault.MoveTargetsBesides(row.VaultId);
|
||||
|
||||
if (choices.Count == 0)
|
||||
{
|
||||
// The one-vault case, and the honest sentence rather than an empty picker. It is also what
|
||||
// somebody in a team whose only other vault is read-only sees.
|
||||
Status = $"There is nowhere to move '{row.Label}' to: this is the only vault you can write to.";
|
||||
return;
|
||||
}
|
||||
|
||||
MoveVaultChoices.Clear();
|
||||
|
||||
foreach (var choice in choices)
|
||||
{
|
||||
MoveVaultChoices.Add(choice);
|
||||
}
|
||||
|
||||
SelectedMoveVault = MoveVaultChoices[0];
|
||||
moving = row;
|
||||
IsMoving = true;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Abandons the move panel.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelMove()
|
||||
{
|
||||
CloseMovePanel();
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves the selected snippet into the chosen vault.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The snippet crosses whole.</b> Nothing on it points at an item of the vault it is leaving — a
|
||||
/// snippet is a label, a command and a note — so unlike a host there is no group and no tag to strip,
|
||||
/// and nothing to report as left behind.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The row is re-selected by its new id afterwards. A move carries the item into the destination under a
|
||||
/// fresh id, so a screen that went on looking for the old one would leave the pane empty and read as the
|
||||
/// snippet having been deleted.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ConfirmMoveAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (moving is not { } row || SelectedMoveVault is not { } target)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CloseMovePanel();
|
||||
|
||||
var moved = await vault.MoveSnippetAsync(row, target, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// The reload inside the move refilled the list, which rebuilt this one and dropped a selection
|
||||
// keyed on an id that no longer exists.
|
||||
if (moved is { } entityId)
|
||||
{
|
||||
Selected = Visible.FirstOrDefault(candidate => candidate.EntityId == entityId);
|
||||
}
|
||||
|
||||
Status = vault.Status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types the selected snippet into the selected terminal, without pressing Enter.
|
||||
/// </summary>
|
||||
@@ -282,14 +499,78 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
|
||||
partial void OnFilterChanged(string value) => Rebuild();
|
||||
|
||||
/// <remarks>
|
||||
/// The move panel folds away with the selection it was opened about. Without that, a filter that stopped
|
||||
/// matching the snippet would leave a picker on screen aimed at a row nobody can see.
|
||||
/// </remarks>
|
||||
partial void OnSelectedChanged(SnippetRowViewModel? value)
|
||||
{
|
||||
if (IsMoving && value?.EntityId != moving?.EntityId)
|
||||
{
|
||||
CloseMovePanel();
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasSelection));
|
||||
OnPropertyChanged(nameof(CanInsert));
|
||||
OnPropertyChanged(nameof(SelectionRuns));
|
||||
OnPropertyChanged(nameof(CanMove));
|
||||
OnPropertyChanged(nameof(ShowsSelectionActions));
|
||||
OnPropertyChanged(nameof(SelectionVaultBadge));
|
||||
OnPropertyChanged(nameof(HasSelectionVaultBadge));
|
||||
}
|
||||
|
||||
partial void OnEditingIdChanged(Guid? value) => OnPropertyChanged(nameof(IsCreating));
|
||||
partial void OnEditingIdChanged(Guid? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsCreating));
|
||||
OnPropertyChanged(nameof(ShowsEditorVaultChoice));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves a half-typed snippet into the vault just chosen for it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only while creating, and this guard is what makes that true rather than the view merely not drawing
|
||||
/// the control. An existing snippet <em>can</em> change vaults — see <see cref="Move"/> — but not this
|
||||
/// way and not as part of a save: reassigning it here on an edit would write the snippet into a second
|
||||
/// vault and leave the original behind, which is a fork rather than a move.
|
||||
/// </remarks>
|
||||
partial void OnEditorSelectedVaultChanged(VaultChoiceViewModel? value)
|
||||
{
|
||||
if (value is null || EditingId is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
editorVaultId = value.VaultId;
|
||||
}
|
||||
|
||||
/// <summary>Refills the editor's vault picker, landing on the vault the editor will write to.</summary>
|
||||
private void BuildEditorVaultChoices(Guid vaultId)
|
||||
{
|
||||
editorVaultId = vaultId;
|
||||
|
||||
EditorVaultChoices.Clear();
|
||||
|
||||
foreach (var choice in vault.TargetVaults)
|
||||
{
|
||||
EditorVaultChoices.Add(choice);
|
||||
}
|
||||
|
||||
// Null where the snippet's vault is one this session cannot write — a team vault this account is a
|
||||
// viewer of. The picker is hidden for an existing snippet anyway, and an empty box is a better
|
||||
// answer than an option that would move the snippet if it were touched.
|
||||
EditorSelectedVault = EditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId);
|
||||
|
||||
OnPropertyChanged(nameof(ShowsEditorVaultChoice));
|
||||
}
|
||||
|
||||
private void CloseMovePanel()
|
||||
{
|
||||
IsMoving = false;
|
||||
moving = null;
|
||||
MoveVaultChoices.Clear();
|
||||
SelectedMoveVault = null;
|
||||
}
|
||||
|
||||
/// <summary>Whether the editor would create a snippet rather than replace one.</summary>
|
||||
internal bool IsCreating => EditingId is null;
|
||||
@@ -315,14 +596,27 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(HasSnippets));
|
||||
OnPropertyChanged(nameof(HasVisible));
|
||||
OnPropertyChanged(nameof(EmptyMessage));
|
||||
OnPropertyChanged(nameof(CanMove));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The command is searched as well as the name and the notes, because half of what somebody remembers
|
||||
/// about a saved command is a word that was in it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A hidden vault's snippets come off here rather than out of <c>VaultViewModel.Snippets</c>, which is
|
||||
/// the rule that list follows for keys and passwords too: the projection is filtered and the list stays
|
||||
/// whole. Hiding a vault is a preference about what is drawn, not about what the keychain contains.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private bool Matches(SnippetRowViewModel row)
|
||||
{
|
||||
if (!vault.IsVaultShown(row.VaultId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var needle = Filter.Trim();
|
||||
|
||||
if (needle.Length == 0)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user