Public Access
Move SFTP and S3 into the tab strip, and the host list into a card grid
Four asks in one pass over the desktop head, and two of them are furniture moving rather than anything new. THE STRIP IS THE WINDOW'S NOW, not the terminal's. Vaults, SFTP and S3 sit at its head and cannot be closed, and open terminals follow them. SFTP and S3 left the nav rail to get there, which is the one semantic change: they are the two destinations you stay in while something runs, and a rail entry is for somewhere you go and come back from. So the rail belongs to the Vaults tab and collapses with it, which also means SFTP, S3 and a terminal each get the full 1016 rather than the 826 a page gets. The tab is expressed as "a page, and not one of the two the strip took" rather than as a fourth ShellSurface. Both are still ShellScreen members and have to be — that is what they are on the phone, where they are two rows in a hub — so a surface each would have been a second way to say a thing Screen already says. IsTransfersShowing and IsBucketsShowing light the other two tabs unchanged. What is new is one field: the page Vaults comes back to, because it is the one tab with sub-navigation and therefore the one that can return to the wrong place. That is not the hidden field ShellSurface argues against — that one would be a second copy of "which page"; this is a tab remembering its own. THE HOSTS SCREEN IS A GRID, and the 268-pixel sidebar is gone. That column was choosing among forty machines and editing one of them at two-thirds width, and it was narrow so the editor beneath it could be a column at all. Cards took the first job at full width; a 304-pixel drawer took the second and collapses when nothing is selected. Pressing a group card narrows the grid; SHOW ALL is the way back. The group editor moved into the drawer as well, which finally makes IsEditingGroup mean the same thing on both heads — it was the phone's alone, because the desktop's editor was a bar that was always on screen. AreHostsExpanded and ToggleHosts went with the control that used them. They folded the whole list away under one heading, an affordance that existed because the column was narrow. Folding a single group is a different thing and is still here. THE TYPE SCALE IS A POINT LARGER and the text ramp is white. The base size was never stated anywhere — a bare TextBlock took TextElement's default of 12 — so raising the scale meant naming it, on Window and on UserControl. The second selector is not redundancy: the layout harness hosts a UserControl in a window it builds itself, and without it the suite would measure every screen a point smaller than it ships, silently. A selector on TextBlock would have been the obvious way and is wrong, because a style setter beats an inherited value and would collapse every deliberate step back to one number. #E3E7F4 is a blue-tinted white on blue-black surfaces, which costs contrast twice — once for being darker than white and once for sharing a hue with what it is drawn on. Pure white is 18.3:1 against the canvas where that was 15.5:1. Every step below moved with the top, so the intervals the design chose are kept and TextDim clears 9:1 against 6.4:1. The palette is shared, so the phone has both changes too. TWO DEFECTS THE HARNESS STRUCTURALLY CANNOT SEE, found by rendering the screen rather than by measuring it, and both now covered. The tile was 232 and was first written as 248, from arithmetic that left out the scrolling stack's own margins. Every layout test passed — the harness asks whether a control is inside the window, never how many fit on a line — so the grid quietly became one column wide at exactly the minimum this application guarantees, which is the shape cards exist to avoid. TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen counts columns instead, and fails at 248. And a card's text ran past its own border, because a horizontal StackPanel measures children with infinite width: a TextBlock inside one never learns it is short of room, so TextTrimming never fires. Both card rows are grids with a star column that gives way and an Auto column that does not — a hostname with its tail cut is still the machine you were looking for, where a badge or the word naming an auth method is not. The keychain header changed shape for the same class of reason. It was Auto,Auto,*,Auto with the buttons last, so the slack column was the only thing absorbing a change of width and five buttons fell off the right edge the moment the type grew. That is how GENERATE lost the word KEY once already. The summary sits in the star column and trims now, so the buttons always get their width. HostSidebarTests became HostGridTests and moved to the grid with the gestures it drives. docs/design-import-gaps.md gains a v3 section naming the five toolbar controls in the design with nothing behind them — a view-mode switch, a tag filter, a calendar, a share control and Serial — and manual-checks.md and the README follow the controls that moved.
This commit is contained in:
@@ -893,6 +893,58 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[RelayCommand]
|
||||
private void ShowTerminal() => Surface = ShellSurface.Terminal;
|
||||
|
||||
// ---- The desktop's fixed tabs ----
|
||||
|
||||
/// <summary>
|
||||
/// Whether the tab strip's <c>Vaults</c> tab is the one showing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The desktop strip holds three tabs that are always there — Vaults, SFTP, S3 — and then a tab per open
|
||||
/// terminal. This is the first of the three, and it is the only one with anything under it: the nav rail
|
||||
/// and whichever of its screens the rail points at. So the rail is drawn on this and nothing else, which
|
||||
/// is what the strip buys — a rail beside a file transfer would be offering nine destinations none of
|
||||
/// which is the screen you are looking at.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Expressed as "a page, and not one of the two the strip took" rather than as a fourth
|
||||
/// <see cref="ShellSurface"/>. SFTP and S3 were already <see cref="ShellScreen"/> members before they
|
||||
/// were tabs, and they still are on the phone, where they are two rows in the hub rather than two tabs —
|
||||
/// so a surface for each would have been a second way to say a thing <see cref="Screen"/> already says,
|
||||
/// and the two would have had to be kept in step. <see cref="IsTransfersShowing"/> and
|
||||
/// <see cref="IsBucketsShowing"/> are the other two tabs, unchanged and already used by both heads.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsVaultsTab => IsShowingPages && IsVaultsPage(Screen);
|
||||
|
||||
/// <summary>The pages that live under the Vaults tab, as opposed to under SFTP or S3.</summary>
|
||||
private static bool IsVaultsPage(ShellScreen screen) =>
|
||||
screen is not (ShellScreen.Transfers or ShellScreen.Buckets);
|
||||
|
||||
/// <summary>
|
||||
/// Which page the Vaults tab returns to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The Vaults tab has sub-navigation and the other tabs do not, so it is the one tab with somewhere to
|
||||
/// come back to: leaving the keychain for SFTP and pressing Vaults again should land on the keychain,
|
||||
/// not on the hosts screen. Without this it would land on whatever <see cref="Screen"/> happened to hold,
|
||||
/// which after a visit to SFTP is <see cref="ShellScreen.Transfers"/> — a Vaults tab showing the file
|
||||
/// screen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>This is not the hidden field <see cref="ShellSurface"/> argues against</b>, and the difference is
|
||||
/// worth stating because the two look alike. That one would have been a second copy of "which page",
|
||||
/// kept because the enum could not hold two facts at once. This is the Vaults tab's own state — a tab
|
||||
/// remembering its page, the way any tab does — and nothing else reads it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private ShellScreen vaultsScreen = ShellScreen.Hosts;
|
||||
|
||||
/// <summary>Selects the Vaults tab, on the page it was last left on.</summary>
|
||||
[RelayCommand]
|
||||
private void ShowVaults() => ShowScreen(vaultsScreen);
|
||||
|
||||
// ---- Open terminals ----
|
||||
|
||||
/// <summary>
|
||||
@@ -2475,6 +2527,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
RaiseSurfaceState();
|
||||
|
||||
// What the Vaults tab comes back to; see the field.
|
||||
if (IsVaultsPage(value))
|
||||
{
|
||||
vaultsScreen = value;
|
||||
}
|
||||
|
||||
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
|
||||
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
|
||||
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
|
||||
@@ -2559,6 +2617,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsBucketsScreen));
|
||||
|
||||
OnPropertyChanged(nameof(IsShowingPages));
|
||||
OnPropertyChanged(nameof(IsVaultsTab));
|
||||
OnPropertyChanged(nameof(IsHostsShowing));
|
||||
OnPropertyChanged(nameof(IsTransfersShowing));
|
||||
OnPropertyChanged(nameof(IsVaultShowing));
|
||||
|
||||
@@ -1022,6 +1022,34 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostRowViewModel> VisibleHosts { get; } = [];
|
||||
|
||||
/// <summary>Whether the grid has anything to draw.</summary>
|
||||
/// <remarks>
|
||||
/// A property rather than <c>{Binding !VisibleHosts.Count}</c> in the markup. Avalonia's <c>!</c> is a
|
||||
/// boolean operator: against an <c>int</c> it produces a binding error, <c>IsVisible</c> falls back to
|
||||
/// its default of true, and the empty-state sentence is shown permanently — under a grid of hosts.
|
||||
/// </remarks>
|
||||
internal bool HasVisibleHosts => VisibleHosts.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// What the hosts grid says when it has nothing in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Three answers rather than one, because "there are no hosts", "this group is empty" and "nothing
|
||||
/// matches what you typed" are three different situations and only the first is an invitation to add
|
||||
/// something. Telling somebody with thirty machines to add their first one is answering a question they
|
||||
/// did not ask.
|
||||
/// </remarks>
|
||||
internal string NoVisibleHostsMessage => (Hosts.Count, GroupFilter, HostFilter.Trim().Length) switch
|
||||
{
|
||||
(0, _, _) =>
|
||||
"No hosts yet. Press + NEW HOST to add one, or import the machines already in this computer's "
|
||||
+ "~/.ssh/config from Preferences.",
|
||||
(_, not null, 0) =>
|
||||
"Nothing is filed under this group yet. Drag a host onto its heading in the grid, or choose the "
|
||||
+ "group in a host's own editor.",
|
||||
_ => "No host matches that. The name, the address and the notes are all searched.",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// What the sidebar's list actually holds: the visible hosts, with group headings between them.
|
||||
/// </summary>
|
||||
@@ -1068,10 +1096,6 @@ internal sealed partial class VaultViewModel(
|
||||
internal string HostsHeading =>
|
||||
session.ReadableVaults.Take(2).Count() > 1 ? "ALL VAULTS" : VaultName.ToUpperInvariant();
|
||||
|
||||
/// <summary>Whether the host list under the heading is folded away.</summary>
|
||||
[ObservableProperty]
|
||||
private bool areHostsExpanded = true;
|
||||
|
||||
/// <summary>The SSH keys to show, unpushed local state included.</summary>
|
||||
internal ObservableCollection<SshKeyRowViewModel> Keys { get; } = [];
|
||||
|
||||
@@ -1153,6 +1177,40 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
private HostGroupRowViewModel? selectedGroup;
|
||||
|
||||
/// <summary>
|
||||
/// The group the hosts grid is narrowed to, or null for every host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The desktop draws its groups as cards above the hosts, and pressing one narrows what is under it.
|
||||
/// This is that choice. <see cref="ClearGroupFilterCommand"/> is the way back to all of them, and it is
|
||||
/// an explicit control rather than a second press on the chosen card: the cards are a
|
||||
/// <c>ListBox</c> so that the selected one is marked by the same style every other list in this
|
||||
/// application uses, and a <c>ListBox</c> does not unselect on a second click.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Separate from <see cref="SelectedGroup"/>, and it sets it.</b> The two answer different questions —
|
||||
/// "what is the grid showing" and "what would EDIT and DELETE act on" — and on the desktop pressing a
|
||||
/// card means both, which is why the change handler assigns one from the other. They are not one
|
||||
/// property because the phone sets <see cref="SelectedGroup"/> on its own account:
|
||||
/// <see cref="EditGroupFromHeading"/> selects a group in order to open its editor, and a single property
|
||||
/// would have made opening that editor silently filter the phone's host list to the group being renamed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private HostGroupRowViewModel? groupFilter;
|
||||
|
||||
/// <summary>Whether the grid is showing one group rather than every host.</summary>
|
||||
internal bool IsFilteredByGroup => GroupFilter is not null;
|
||||
|
||||
/// <summary>Shows every host again.</summary>
|
||||
[RelayCommand]
|
||||
private void ClearGroupFilter()
|
||||
{
|
||||
GroupFilter = null;
|
||||
SelectedGroup = null;
|
||||
}
|
||||
|
||||
/// <summary>What the group name box holds, for both creating and renaming.</summary>
|
||||
[ObservableProperty]
|
||||
private string groupEditorLabel = string.Empty;
|
||||
@@ -1424,6 +1482,8 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
|
||||
private bool isEditing;
|
||||
|
||||
/// <summary>
|
||||
@@ -1431,10 +1491,17 @@ internal sealed partial class VaultViewModel(
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The desktop does not need this: its group editor is a bar under the group list that is always there,
|
||||
/// and <see cref="EditingGroupId"/> is enough to tell adding from saving. The phone has no room for a
|
||||
/// permanent bar, so its group editor is a card that replaces the list — and "is the card showing" is a
|
||||
/// different question from "which group is being edited", because adding one has no id.
|
||||
/// It was the phone's alone. The phone has no room for a permanent bar, so its group editor is a card
|
||||
/// that replaces the list — and "is the card showing" is a different question from "which group is being
|
||||
/// edited", because adding one has no id.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The desktop sets it too now.</b> Its group editor used to be a bar under the group list that was
|
||||
/// always on screen, which is why <see cref="EditingGroupId"/> was enough there. The hosts screen has no
|
||||
/// such bar since it became a grid of cards: the group editor is a panel in the drawer, raised by
|
||||
/// <c>+ NEW GROUP</c> or by <c>EDIT</c>, and "is it raised" is exactly this. So
|
||||
/// <see cref="AGroupEditorIsInTheWay"/> now answers for both heads rather than being false on one of
|
||||
/// them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Held here rather than on the phone's own control so that the two heads cannot disagree about
|
||||
@@ -1444,8 +1511,36 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
|
||||
private bool isEditingGroup;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the hosts screen's right-hand drawer is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The drawer is where everything that is about <em>one</em> thing lives: what a host is, the host
|
||||
/// editor, and the group editor. The grid beside it is about all of them. Splitting the screen that way
|
||||
/// is what let the 268-pixel host list go — the list was carrying both jobs, and neither at full size.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It stays open while a host deletion is in question, because the question is asked in the drawer and a
|
||||
/// deletion does not clear the selection. There is no separate term for that here: a pending deletion
|
||||
/// always has a selected host behind it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It occupies a column of the hosts screen rather than floating over it, which is the occlusion rule
|
||||
/// rather than a preference — see <c>MainWindow.axaml</c>. Nothing on this screen may be laid over the
|
||||
/// terminal's rectangle, and a drawer that slid over the grid would be doing exactly that on the day
|
||||
/// somebody moved the grid.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsDrawerOpen => IsEditing || IsEditingGroup || SelectedHost is not null;
|
||||
|
||||
/// <summary>Whether the drawer is showing what a host is, rather than one of the two editors.</summary>
|
||||
internal bool IsShowingHostDetail => !IsEditing && !IsEditingGroup && SelectedHost is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the add sheet is showing over the host list.
|
||||
/// </summary>
|
||||
@@ -2555,6 +2650,7 @@ internal sealed partial class VaultViewModel(
|
||||
private void RebuildGroups()
|
||||
{
|
||||
var selectedId = SelectedGroup?.EntityId;
|
||||
var filteredId = GroupFilter?.EntityId;
|
||||
|
||||
Groups.Clear();
|
||||
|
||||
@@ -2570,6 +2666,19 @@ internal sealed partial class VaultViewModel(
|
||||
// chose.
|
||||
SelectedGroup = Groups.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
|
||||
// Re-resolved by id for the reason the selection above is: every row object here is replaced on
|
||||
// every reload, so a filter holding the old one would go on narrowing the grid to a group that is no
|
||||
// longer in the list — and the card the user could press to clear it would be a different object
|
||||
// that never matched. A group deleted by a sync clears the filter, which is the honest answer: the
|
||||
// grid comes back to every host rather than to none.
|
||||
//
|
||||
// This assignment is a new row object whenever there is a filter at all, so it always fires
|
||||
// OnGroupFilterChanged and therefore an extra RebuildVisibleHosts before the caller's own. That is
|
||||
// wasted work rather than a bug — Hosts is already filled by the time this runs, so both passes see
|
||||
// the same thing — and it is left rather than dodged by writing the backing field, because writing
|
||||
// the field would skip SelectedGroup and IsFilteredByGroup with it.
|
||||
GroupFilter = Groups.FirstOrDefault(row => row.EntityId == filteredId);
|
||||
|
||||
OnPropertyChanged(nameof(HasGroups));
|
||||
}
|
||||
|
||||
@@ -2605,6 +2714,11 @@ internal sealed partial class VaultViewModel(
|
||||
// After the host selection, not before: this mirrors it, and the ListBox's own answer to the Clear()
|
||||
// above is a null that has to be overwritten rather than read.
|
||||
SelectedSidebarRow = SelectedHost;
|
||||
|
||||
// The grid's empty state. Both of these are computed rather than stored, and neither has a change
|
||||
// notification of its own — VisibleHosts raises collection changes, which is not the same event.
|
||||
OnPropertyChanged(nameof(HasVisibleHosts));
|
||||
OnPropertyChanged(nameof(NoVisibleHostsMessage));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2804,6 +2918,14 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private bool Matches(HostRowViewModel row)
|
||||
{
|
||||
// The group cards, and they narrow before the box does — a host outside the chosen group is out
|
||||
// whatever was typed. The two are deliberately not one control: the box is what you type when you
|
||||
// know the name, and the cards are what you press when you do not.
|
||||
if (GroupFilter is { } group && row.Host.GroupId != group.EntityId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var filter = HostFilter.Trim();
|
||||
|
||||
if (filter.Length == 0)
|
||||
@@ -3391,9 +3513,10 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Folds the host list away, or brings it back.</summary>
|
||||
[RelayCommand]
|
||||
private void ToggleHosts() => AreHostsExpanded = !AreHostsExpanded;
|
||||
// AreHostsExpanded and ToggleHosts were here, and they went with the control that used them. They folded
|
||||
// the sidebar's whole host list away under its one heading — an affordance that existed because that
|
||||
// list was 268 pixels wide and the editor beneath it needed the room. The grid has neither the heading
|
||||
// nor the problem. Folding one *group* away is a different thing and is still here: see ToggleGroup.
|
||||
|
||||
/// <summary>Stores whatever the group name box holds, as a new group or as a rename.</summary>
|
||||
/// <remarks>
|
||||
@@ -3552,9 +3675,9 @@ internal sealed partial class VaultViewModel(
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <inheritdoc cref="AHostEditorIsInTheWay" path="/remarks" />
|
||||
/// It answers only for the phone, where the group editor is a raised card. On the desktop the bar is
|
||||
/// always present and <see cref="IsEditingGroup"/> is never set by anything the user can reach, so this
|
||||
/// is false there and nothing is refused.
|
||||
/// It answered only for the phone while the desktop's group editor was a bar that was always present.
|
||||
/// Both heads raise a card now — the desktop's is the panel in the hosts drawer — so this refuses on
|
||||
/// both, which is what it was always meant to do.
|
||||
/// </remarks>
|
||||
private bool AGroupEditorIsInTheWay()
|
||||
{
|
||||
@@ -5964,6 +6087,10 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
|
||||
OnPropertyChanged(nameof(ShowsConnectBar));
|
||||
|
||||
// The drawer opens on a selection and closes when there is none, so both of these move with it.
|
||||
OnPropertyChanged(nameof(IsDrawerOpen));
|
||||
OnPropertyChanged(nameof(IsShowingHostDetail));
|
||||
|
||||
// Kept in step so that selecting a host in code — a reload restoring one, the palette connecting to
|
||||
// one — lights the right row. Assigning the same value again is a no-op, so the two do not chase each
|
||||
// other.
|
||||
@@ -6009,6 +6136,20 @@ internal sealed partial class VaultViewModel(
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Group, value?.EntityId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Sets the selection as well as the filter, because on the desktop pressing a card means both — see the
|
||||
/// property. Assigning the same value again is a no-op, so this and
|
||||
/// <see cref="ClearGroupFilterCommand"/> cannot chase each other.
|
||||
/// </remarks>
|
||||
partial void OnGroupFilterChanged(HostGroupRowViewModel? value)
|
||||
{
|
||||
SelectedGroup = value;
|
||||
|
||||
OnPropertyChanged(nameof(IsFilteredByGroup));
|
||||
|
||||
RebuildVisibleHosts();
|
||||
}
|
||||
|
||||
partial void OnPendingDeletionChanged(DeletionRequest? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsConfirmingDeletion));
|
||||
|
||||
Reference in New Issue
Block a user