Public Access
The commit before this one taught a terminal to notice its machine going away.
The file browser had the same defect and three of the same symptoms, and it is
also the screen people leave soonest — so the disconnect work and four things it
was missing arrive together, because they are the same complaint: this screen
knows less about the host than the host does.
◆ THE DISCONNECT HALF, WHICH IS THE OTHER SURFACE OF THE LAST COMMIT.
IsConnected was set at connect and cleared only by the DISCONNECT button. A host
that went away therefore left this screen saying CONNECTED over a session that
was gone, and every action after it failing with the same server message and
nothing saying why they all did.
IRemoteFileStore now carries a latched TransportFailure and a ConnectionLost
event. The latch is the shell's own — read ISshShellSession.TransportFailure for
the argument, which is unchanged. The event is not, and it is the part this
surface needs: a file browser is idle between operations, so without something
raised at the moment the transport fails, nobody finds out until they click. A
bucket implements both and raises neither, which is honest rather than a gap:
S3 is request-per-operation with no connection to lose.
- THE LISTING STAYS ON SCREEN, and that is the half that makes the rest worth
having. SftpEntry is a record of what the server said rather than a handle,
so the pane can go on showing where the user was — which is what RECONNECT
is offering to put them back into. That needed a new ShowsRemoteContents on
both heads: every "is there anything here" binding read IsConnected, which
was right while the only way to stop being connected was to press a button
that empties the pane, and wrong the moment a session could be lost.
- RECONNECT re-dials through the ordinary connect path — same host key gate,
same refusals, same log entry — and then walks back to the directory the
pane was showing. The path is read off RemotePath rather than captured,
because the pane already keeps it up to date and a copy is a second thing to
get wrong. A host wanting a typed password is asked for one rather than
dialled with an empty box.
- SessionEndedBar is now one control on both surfaces, with the shell
resolving which it is drawn over: a terminal has two endings to tell apart
and a keychain entry to check, a file session has one ending and already
captured what it would dial. The phone's Files screen has its own copy,
bound to TransfersViewModel directly, because its DataContext is that rather
than the shell.
◆ PERMISSIONS: THE PERMS COLUMN STOPS BEING READ-ONLY.
It has been a true fact about a file with nothing to be done about it since it
arrived, and the commonest next act after using this screen was opening a
terminal — a whole second authenticated connection — to chmod what had just been
uploaded.
Nine checkboxes rather than a box to type 755 into, because the mode is already
drawn as -rwxr-xr-x in the column the form was opened from: a who-can-do-what
grid is the same representation the reader is holding, where an octal field asks
them to translate something they can see into something they cannot check. The
octal is printed underneath as it changes, so the two readings check each other.
The EXEC column is headed "enter" over a directory, which is what that bit means
there and the commonest misreading of a POSIX mode. The listing is re-read after
APPLY, because a umask, an ACL or a filesystem mounted without permissions can
all land somewhere other than what was asked for.
Beside it, mode and mtime carried across a transfer — which is what makes an
uploaded deploy.sh arrive executable instead of with the server's umask on it.
THE MODE IS ONLY SENT FROM A PLATFORM THAT HAS ONE. A file on Windows has no
POSIX mode; this screen already says so by leaving the local pane's PERMS column
empty rather than filling it with a plausible -rw-r--r--, and inventing 0644, or
0755 for anything called .sh, would be this client deciding a fact about
somebody's file that nothing on the machine it came from ever said. The
timestamp goes either way, because every platform has one. A store with neither
refuses in its own words and is never asked.
◆ MULTI-SELECT: A TICK IS NOT THE LIST'S SELECTION.
The two answer different questions and the split is the whole design. The
selection is "the row I am looking at", which rename, move and permissions act
on — each takes a form naming one path and is one-at-a-time by construction. The
tick is "the rows I have gathered up", which download, upload and delete take,
and it survives the highlight moving.
With nothing ticked the batch falls back to the selection, which is what keeps
the commonest use of this screen from growing a step — the usual way a
multi-select makes a tool worse. Ticks are released once a set is queued, or a
second press sends everything again.
Deleting several names every path rather than a count, because agreeing to "3
items" is agreeing to a number, and this is the one act on this screen that
nothing walks back. Each goes on its own and a refusal does not stop the rest:
the host turns down a directory that still has something in it, and in a set of
five that must not silently cancel the four it would have removed — nor hide
which ones it did not.
A drawn checkbox rather than ctrl-click, and not for taste: the phone shares
this view model and has neither ctrl nor shift, so a multi-selection has to be a
tap target there whatever the desktop does. One shape on both heads, and a state
somebody can see.
◆ RECURSIVE FOLDERS: THE TREE BECOMES FILES BEFORE THE QUEUE SEES IT.
A directory used to be counted and skipped. It is now walked, and the queue
still knows nothing about trees — which is what keeps every property it has:
each file resumable on its own, each written to a part file and renamed into
place, none able to overwrite anything already there.
The plan is collected whole and then queued, because directories have to exist
before the files that go in them and shortest-first is the only order that
guarantees a parent before its child. It also means a walk refused halfway has
queued nothing yet, so the sentence it produces describes one outcome rather
than an outcome and a queue that half happened.
Links are counted, not followed, in both directions. One pointing at an ancestor
is a walk with no end, and on the remote side there is a second reason: an lstat
listing says a name is a link and not what it leads to, so descending would be a
round trip per entry to find out. Locally that is AttributesToSkip on the one
enumeration that decides what to descend into. A 5000-file ceiling is a backstop
for the accidental drag of a home directory, and it says so rather than
truncating quietly.
The status line grew four more ways to be partly what was asked for, and states
each: gone, refused, left alone, not a file.
◆ EDITING IN PLACE: FETCH, OPEN, WATCH, PUSH BACK.
The round trip this replaces is download, find it, open it, edit, come back,
delete the old one because nothing here overwrites, upload, check the mode
survived.
DELIBERATELY NOT THROUGH THE TRANSFER QUEUE, in either direction. The queue
refuses to overwrite, which is the right rule for a file somebody dragged and
exactly the wrong one for a file they are editing, where replacing the original
is the entire intent. The write-back deletes first: OpenWriteAsync at offset
zero writes over the beginning and leaves any tail that is longer — its own
contract says so — which for a config that got shorter means a file ending in
the back half of its previous version. Deleting is the only truncation this
interface offers, and the mode goes back on afterwards because deleting took it.
A stat before every save, compared against what the server said when the copy
was taken, catches somebody else writing while the file is open — which is how a
config gets silently reverted. There is no merge and no button pretending
otherwise; the three answers offered are the three that exist. Length and
timestamp rather than a digest, because a digest is a second full read of a file
there is otherwise no reason to fetch.
Both platform halves are injected: what opens a .conf is a registry
association, a desktop entry or a Launch Services binding depending on the
desktop, and watching a file is a platform object with its own thread. The
watcher watches the directory and filters the name, because a great many editors
write a temporary file and rename it over the top — which to a watcher pointed
at an inode is the file disappearing. The phone gets neither delegate, so the
button is not live there rather than there and disappointing.
◆ TWO THINGS FOUND ON THE WAY, BOTH WORTH STATING.
The shell suite's inline `post` was not serialising the way a dispatcher does.
Routing TransfersViewModel through it exposed a check-then-add in
OnTransferChanged that two threads turn into two rows for one transfer. Harmless
in production, where the UI thread serialises — the fixture was under-specifying
what it stood in for, and now posts under a lock.
And the edit session replaced its own record with `with` on each save, which
left the watcher holding an instance that was no longer the current one: every
save after the first quietly did nothing. The stamp moves in place now, and the
test saves twice.
◆ WHAT IS VERIFIED.
901 across the five suites this touches, 19 of them new. The layout suite earned
its keep three times over: the batch deletion card's path list overflowed the
204-pixel pane at 120, at 72 and at 56 before 32 held, and the edit strip
overflowed by 131 until the whole strip — rather than the log inside it — was
bounded. Each number in those files is measured and says which failure it came
from, because the next person to add a line to either card will move them again.
What no suite covers is the platform halves of editing: Process.Start with
UseShellExecute and a live FileSystemWatcher are the operating system, and a test
waiting on one would be measuring it. The shell's own half — when a save is
written back, when it is refused, what stopping releases — is covered against a
watcher that fires on demand.
533 lines
33 KiB
XML
533 lines
33 KiB
XML
<Window xmlns="https://github.com/avaloniaui"
|
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
xmlns:sys="using:System.Collections.Generic"
|
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
|
xmlns:views="using:DodoSSH.Client.App.Views"
|
|
x:Class="DodoSSH.Client.App.Views.MainWindow"
|
|
x:DataType="vm:MainWindowViewModel"
|
|
Title="DodoSSH"
|
|
Icon="/Assets/dodossh.ico"
|
|
Width="1180"
|
|
Height="760"
|
|
MinWidth="1081"
|
|
MinHeight="583"
|
|
Background="{StaticResource Canvas}"
|
|
SystemDecorations="BorderOnly"
|
|
Focusable="True">
|
|
|
|
<!--
|
|
The shell window: a titlebar it draws itself, a nav rail, one surface at a time, and a status bar.
|
|
|
|
THE MINIMUM GREW, and it grew by exactly what v2 added rather than by a round number somebody liked.
|
|
The sidebar went from 54 pixels to 190 and the chrome from 72 tall to 86, so 880x560 became 1016x574 —
|
|
which leaves every screen the same 826x464 it was designed against. That is the whole point of moving
|
|
it: the layout suite's premise is that everything fits at the minimum, and four of the tables stop
|
|
fitting at 690 wide. Widening the sidebar without widening the window would have quietly broken them
|
|
somewhere nobody was looking.
|
|
|
|
v5b moves it again, by exactly the same reasoning: the titlebar's fidelity pass takes it from 44 to 53
|
|
and the rail's from 190 to 255, so 1016x574 becomes 1081x583 — nine pixels and sixty-five pixels, added
|
|
straight onto the minimum rather than absorbed by shrinking a screen. <c>LayoutHarness.ScreenWidth</c>
|
|
stays unchanged at 826, because the rail is the only thing beside a page that grew.
|
|
|
|
<c>ScreenHeight</c> did move, and in the other direction: v5b's own fidelity pass also retires the
|
|
window-wide tab strip this comment used to describe — see the paragraph below — and every full-bleed
|
|
page gets that 42 pixels back rather than the window losing height to compensate. A screen this suite
|
|
measures is 826 pixels wide and taller than it was, by exactly the strip's own height; see
|
|
<c>LayoutHarness</c>'s own remark on the budget for the arithmetic.
|
|
|
|
Windows is asked for a resize border and nothing else, so TitleBar does the dragging, the maximising and
|
|
the closing. That is a real cost, and the reason it is paid is that a stock grey system bar above a
|
|
near-black application is the one part of the window that would look borrowed.
|
|
|
|
SystemDecorations="BorderOnly" rather than extending the client area under the system chrome, which is
|
|
what this tried first and is measurably wrong on Avalonia 12: ExtendClientAreaChromeHints is gone from
|
|
that version, so extending leaves the caption buttons and the window title drawn on top of this
|
|
window's own — two sets of minimise/maximise/close, and "DodoSSH" printed twice, overlapping. BorderOnly
|
|
removes the caption and keeps the resize border and the drop shadow, which is the half of the system
|
|
chrome worth having.
|
|
|
|
TWO SURFACES, ONE RECTANGLE — AND EACH ONE OWNS ITS OWN TAB ROW NOW.
|
|
|
|
v5b retires the tab strip this file used to draw above the whole window — Vaults, SFTP and S3 left it
|
|
for the rail's own switcher in an earlier pass, and this one moves the remaining pills, one per open
|
|
terminal, off the window's own chrome entirely. Each of the two screens that carries a tab row —
|
|
<c>SessionTabRow.axaml</c> — draws its own, 38 pixels, inside its own 26-pixel padded column, per the
|
|
design; see the terminal surface's own Grid below and the SFTP one inside the pages Panel. A tab still
|
|
survives navigating away from either screen — that is what makes the terminal reachable from anywhere —
|
|
it simply is not drawn as chrome above every screen while it does.
|
|
|
|
The terminal and the pages still share the one rectangle beside the rail, and exactly one of them may
|
|
occupy it at a time: that is the whole of ShellSurface, an enum rather than two flags, so there is no way
|
|
to write the state where both are showing.
|
|
|
|
THE OCCLUSION RULE, which every arrangement in this file obeys.
|
|
|
|
NativeWebView hosts a real Win32 child window through NativeControlHost, and a child window composites
|
|
above everything the parent paints — so no sibling in this visual tree can cover it, whatever the
|
|
z-order says. Layering a screen over it leaves that screen sliced at the WebView's left edge with its
|
|
buttons unreachable, which this window has shipped once already.
|
|
|
|
So anything that would occupy the terminal's rectangle collapses the terminal instead, and
|
|
IsTerminalShowing is the one place that decision is made: a locked vault, the page area, or the
|
|
quick-connect palette. Collapsing is safe, and cheaply so — NativeControlHost creates the native
|
|
control when the control is attached to the visual tree, not when it is laid out or shown, so WebView2
|
|
still starts, still loads the page and still lets the renderer attach its socket while it is false. It
|
|
only swaps ShowInBounds for HideWithSize, and flipping it back re-pushes the bounds.
|
|
|
|
Note where IsShowingPages is bound: on the one Panel that holds every screen, not on each screen. That
|
|
is what makes the rule hard to break rather than merely documented — a sixth screen added inside that
|
|
Panel cannot forget to collapse, because it is not the thing doing the collapsing. Its own IsVisible
|
|
only chooses between the pages.
|
|
|
|
Two nearby alternatives are wrong. Removing the control from the tree instead — conditional content, a
|
|
template swap — detaches it, and detaching destroys the native control and the whole WebView2 process
|
|
tree, so every unlock would pay a cold start. Hoisting the binding to an ancestor looks tidier and is
|
|
unverified: NativeControlHost does watch ancestors, but NativeWebView's own bounds-and-scaling re-push
|
|
fires only for its own IsVisible. v5b nests the WebView three levels deeper than it used to sit, inside
|
|
the terminal surface's own session shell — see that Grid's own remark below for why the rule still holds
|
|
with the control that much further from the Panel that used to be its only parent.
|
|
-->
|
|
|
|
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
|
|
|
<views:TitleBar Grid.Row="0" IsVisible="{Binding !IsSettingsMode}" />
|
|
|
|
<Panel Grid.Row="1">
|
|
|
|
<!-- The unlocked application. -->
|
|
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}"
|
|
IsEnabled="{Binding !IsSettingsMode}">
|
|
|
|
<!--
|
|
The window's own furniture, at 255 pixels — see NavRail.axaml's own v5b remark. It no longer sits
|
|
under a strip: v5b retired the window-wide tab strip entirely, and with it the row this Grid used
|
|
to give up its own first row to. See the remark below on where a session's tabs live now.
|
|
-->
|
|
<views:NavRail Grid.Column="0" />
|
|
|
|
<Panel Grid.Column="1">
|
|
|
|
<!-- ============ THE PAGES ============ -->
|
|
<Panel IsVisible="{Binding IsShowingPages}">
|
|
|
|
<!--
|
|
Wrapped, like every other screen here. It used to be bound directly — its data context was
|
|
the shell's, so IsHostsScreen resolved on the same element — and that was only so it could
|
|
hand the vault to the host sidebar from inside its own markup. The sidebar is gone and the
|
|
drawer that replaced it is a plain child, so the screen takes the vault like the rest and
|
|
its visibility goes on the wrapper.
|
|
-->
|
|
<Panel IsVisible="{Binding IsHostsScreen}">
|
|
<views:HostsScreen x:Name="HostsPane" DataContext="{Binding Vault}" />
|
|
</Panel>
|
|
|
|
<!-- ============ SFTP ============ -->
|
|
<!--
|
|
◆ v5b's session shell, wrapping this screen's existing content rather than replacing it — the
|
|
two-pane grid and the queue inside TransfersScreen.axaml are untouched; wave C restyles their
|
|
internals. What is new here is everything design-notes/v5b-fidelity-notes.md calls the session
|
|
shell: a 26px padded column, an in-screen tab row, a bordered rounded-bottom container holding
|
|
a host header and a status bar around the screen's own content, and a 300px sidebar.
|
|
|
|
Gated on IsTransfersScreen exactly as before — IsShowingPages means "the Avalonia page area,
|
|
not the WebView", which is the occlusion question, and which mode the switcher is on is this
|
|
wrapper's own separate question.
|
|
|
|
The tab row's own click does not select a terminal tab — there is no per-tab SFTP session in
|
|
this application, and building one is out of this wave's scope; see the notes' own open
|
|
question and MainWindowViewModel.SelectFilesHostCommand for how this resolves it: a click
|
|
reuses the same "Browse files" plumbing a pin click already does, honestly opening (or
|
|
reusing) a second, SFTP-specific connection to that tab's host rather than pretending a session
|
|
exists that does not.
|
|
-->
|
|
<Panel IsVisible="{Binding IsTransfersScreen}">
|
|
<Grid RowDefinitions="Auto,*" Margin="26">
|
|
<views:SessionTabRow Grid.Row="0" Classes="sftp"
|
|
TabCommand="{Binding SelectFilesHostCommand}" />
|
|
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
|
CornerRadius="0,0,12,12" ClipToBounds="True">
|
|
<Grid ColumnDefinitions="*,Auto">
|
|
<!--
|
|
v5c-4: two rows rather than three. The 60-pixel host header that used to sit above this
|
|
screen is gone; the address and the "Open terminal" button it carried are in the
|
|
sidebar now — see SessionSidebar.axaml — and the pane keeps the height. Its third
|
|
binding, the Transfers.Status line it printed while no host was open, is not moved
|
|
either: TransfersScreen draws that same string itself, both in its own empty state and
|
|
beside the remote pane's DISCONNECT once something is open.
|
|
-->
|
|
<!--
|
|
Three rows since the disconnect strip arrived, and it is the same control the terminal
|
|
surface draws — see SessionEndedBar.axaml, which resolves which surface it is about
|
|
through the shell rather than through a binding of its own. Auto and empty except while
|
|
the file session has been lost, so an ordinary browse pays nothing for it.
|
|
|
|
Note the DataContext: this row is the shell's rather than the transfers screen's, which
|
|
is why the strip sits here and not inside TransfersScreen.axaml.
|
|
-->
|
|
<Grid Grid.Column="0" RowDefinitions="*,Auto,Auto">
|
|
<views:TransfersScreen Grid.Row="0" DataContext="{Binding Transfers}" />
|
|
<views:SessionEndedBar Grid.Row="1" />
|
|
<views:SessionStatusBar Grid.Row="2" />
|
|
</Grid>
|
|
<!--
|
|
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always
|
|
drawn: the SFTP surface reaches this Panel before a host is chosen, and a sidebar with
|
|
an empty QUICK ACCESS and no host name to print would be furniture with nothing to say.
|
|
Collapsing frees the "Auto" column it sits in, so the pane column takes the width back.
|
|
-->
|
|
<views:SessionSidebar Grid.Column="1"
|
|
IsVisible="{Binding ShowsQuickAccessSidebar, FallbackValue=False}" />
|
|
</Grid>
|
|
</Border>
|
|
</Grid>
|
|
</Panel>
|
|
|
|
<!-- ============ S3 ============ -->
|
|
<!--
|
|
The same screen as SFTP above, over the same view model, because an object store and an
|
|
SFTP host are both an IRemoteFileStore and everything below the picker was written once.
|
|
What differs is which picker is offered, and that is decided by the destination rather than
|
|
by a toggle inside the screen — see ShowFiles, and the rail's own switcher segment that
|
|
calls it now; see NavRail.axaml.
|
|
|
|
Deliberately not given the full session shell above: a bucket is not a host, has no terminal tab
|
|
to be the other end of a cross-surface button, and pins nothing the sidebar's QUICK ACCESS
|
|
could show — so no tab row, no host header, no status bar and no sidebar. Wave B's own scope
|
|
was the terminal and SFTP surfaces and left this plain; v5c-3 gives it the session shell's own
|
|
LOOK without its machinery — a 26px padded column and the same bordered, radius-12 container —
|
|
since design-notes/v5c-fidelity-notes.md calls for the fidelity pass here too, and there is
|
|
nothing this screen does that needs the parts still withheld.
|
|
-->
|
|
<Panel IsVisible="{Binding IsBucketsScreen}">
|
|
<Border Margin="26" BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
|
|
ClipToBounds="True">
|
|
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
|
</Border>
|
|
</Panel>
|
|
|
|
<!-- ============ KEYCHAIN ============ -->
|
|
<!--
|
|
Wrapped rather than bound directly, for the reason the vault column always was: this
|
|
element's visibility is the shell's business and its data context is the vault, and put both
|
|
on one element and IsVisible resolves against the vault as well, where IsKeychainScreen does
|
|
not exist.
|
|
-->
|
|
<Panel IsVisible="{Binding IsKeychainScreen}">
|
|
<views:KeychainScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
|
|
</Panel>
|
|
|
|
<!-- ============ HOST KEYS ============ -->
|
|
<!--
|
|
Wrapped, like the two above and for the same reason: its data context is the screen's own
|
|
view model, where IsKnownHostsScreen does not exist.
|
|
-->
|
|
<Panel IsVisible="{Binding IsKnownHostsScreen}">
|
|
<views:KnownHostsScreen x:Name="PinsPane" DataContext="{Binding KnownHostsScreen}" />
|
|
</Panel>
|
|
|
|
<!-- ============ SNIPPETS ============ -->
|
|
<!-- Wrapped, like the others whose data context is their own view model. -->
|
|
<Panel IsVisible="{Binding IsSnippetsScreen}">
|
|
<views:SnippetsScreen x:Name="SnippetsPane" DataContext="{Binding SnippetsScreen}" />
|
|
</Panel>
|
|
|
|
<!-- ============ LOGS ============ -->
|
|
<!-- Wrapped, like the others whose data context is their own view model. -->
|
|
<Panel IsVisible="{Binding IsLogsScreen}">
|
|
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
|
|
</Panel>
|
|
|
|
<!--
|
|
v5c-2: the VAULTS panel that used to live here is gone. ShellScreen.Vaults now only ever shows
|
|
through settings mode's own SettingsVaultsPage — see MainWindowViewModel.ShowScreen, which
|
|
redirects it into EnterSettings before Screen is ever set — so IsVaultsScreen being true also
|
|
means IsSettingsMode is true, and this whole page-area Panel (bound to !IsSettingsMode, above)
|
|
is already hidden behind SettingsView by then. A Panel here would never have been drawn.
|
|
|
|
v5c-3: IMPORT left this Panel the same way, and for the same reason. Import.dc.html draws the
|
|
importer inside the settings chrome — SettingsNav lit on Preferences, a "Back to preferences"
|
|
titlebar rather than the ordinary rail and titlebar this Panel belongs to — so ImportScreen now
|
|
lives in SettingsView.axaml behind MainWindowViewModel.IsImportOpen, beside the seven settings
|
|
pages rather than among the screens here. See ShowScreen's own translation of
|
|
ShellScreen.Import for what still reaches it: the Preferences page's own "OPEN IMPORTER" row,
|
|
and anything else that names the same destination.
|
|
-->
|
|
|
|
</Panel>
|
|
|
|
<!-- ============ TERMINAL ============ -->
|
|
<!--
|
|
◆ v5b's session shell for the terminal surface, built the same way SFTP's own wrapper above is —
|
|
around the real terminal control rather than around a page.
|
|
|
|
Gated on IsTerminalSurface rather than on IsShowingPages: the two are always exclusive, because
|
|
Surface is a single ShellSurface value, so this Grid and the pages Panel above it are never both
|
|
visible at once. That is what keeps the occlusion rule intact with the WebView now nested inside
|
|
a padded column, a bordered container and a header row rather than sitting directly beside the
|
|
page area the way it used to.
|
|
|
|
THE RULE ITSELF DID NOT MOVE. NativeWebView's own IsVisible binding, below, is unchanged and is
|
|
not hoisted to this Grid — hoisting it is the one alternative the pin-strip era of this file
|
|
called out as unverified, and nesting the control deeper without touching its own binding is not
|
|
that: IsTerminalShowing already depends on IsTerminalSurface, so this Grid's own visibility and
|
|
the WebView's own visibility flip together on every surface change, driven by the same property
|
|
change rather than one waiting on the other.
|
|
|
|
The tab row's own click selects a terminal tab exactly as the old window-wide strip's did; see
|
|
SelectTabCommand. "+" keeps its current meaning, quick connect, on both this row and SFTP's own.
|
|
-->
|
|
<Grid RowDefinitions="Auto,*" Margin="26" IsVisible="{Binding IsTerminalSurface}">
|
|
<views:SessionTabRow Grid.Row="0" TabCommand="{Binding SelectTabCommand}" />
|
|
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
|
CornerRadius="0,0,12,12" ClipToBounds="True">
|
|
<Grid ColumnDefinitions="*,Auto">
|
|
<!--
|
|
v5c-4: two rows rather than three, the same as the SFTP wrapper above and for the same
|
|
reason — the host header is gone and the terminal has its 60 pixels. The empty state it
|
|
used to print ("no terminals open · press + or Ctrl+K…") went with it rather than moving:
|
|
this Grid is only drawn on the terminal surface, and the surface with no tab open already
|
|
answers for itself in the tab row's own "+" and in the connecting card below.
|
|
-->
|
|
<!--
|
|
Three rows since the disconnect strip arrived, where v5c-4 left two. The extra one is Auto
|
|
and empty except while the selected tab's shell has stopped, so it costs the terminal
|
|
nothing in the ordinary case — and when it is up it takes its 34 pixels off the pane rather
|
|
than drawing over it, which is the only way Avalonia content can sit near the renderer at
|
|
all. See SessionEndedBar.axaml.
|
|
-->
|
|
<Grid Grid.Column="0" RowDefinitions="*,Auto,Auto">
|
|
|
|
<Panel Grid.Row="0" Background="{StaticResource Pane}">
|
|
|
|
<!--
|
|
The other thing that can be in the terminal's rectangle: a tab whose session does not
|
|
exist yet, or never will. Exclusive with the WebView below by construction — a
|
|
selected tab either has a session or it does not — which is what makes drawing it here
|
|
safe under the occlusion rule, the same way the page area is. See ConnectingCard.axaml.
|
|
-->
|
|
<views:ConnectingCard x:Name="ConnectingPane"
|
|
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
|
|
|
|
<!--
|
|
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate
|
|
browser process tree, so twenty tabs would cost twenty of them.
|
|
|
|
FallbackValue, because a compiled binding with no DataContext yields UnsetValue,
|
|
IsVisible then falls back to its default of true, and the occlusion comes back
|
|
silently. Not reachable at runtime — the DataContext is set before the window is shown
|
|
— but it is what the previewer does.
|
|
-->
|
|
<NativeWebView x:Name="Terminal"
|
|
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
|
|
|
</Panel>
|
|
|
|
<views:SessionEndedBar Grid.Row="1" />
|
|
<views:SessionStatusBar Grid.Row="2" ShowsEncoding="True" />
|
|
</Grid>
|
|
<!--
|
|
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always drawn:
|
|
the terminal surface is reachable with no tab open at all (the rail's own SSH segment), and
|
|
a sidebar naming no host would be furniture with nothing to say. Collapsing frees the
|
|
"Auto" column it sits in, so the pane column takes the width back.
|
|
-->
|
|
<views:SessionSidebar Grid.Column="1" ShowsSnips="True"
|
|
IsVisible="{Binding ShowsQuickAccessSidebar, FallbackValue=False}" />
|
|
</Grid>
|
|
</Border>
|
|
</Grid>
|
|
|
|
</Panel>
|
|
</Grid>
|
|
|
|
<!--
|
|
v5c: the settings mode, which replaces the titlebar above and everything in this Panel below it with
|
|
its own — see SettingsView.axaml and TitleBar's own IsVisible next to it in this file. Later in the
|
|
Panel than the unlocked Grid, on the same reasoning the setup Border below gives for its own
|
|
position: Avalonia z-order draws a later sibling over an earlier one, and SettingsView's own
|
|
Background is opaque, so it covers the rail and the page area completely rather than needing them
|
|
hidden out from under it. The unlocked Grid keeps IsEnabled bound to the negation above so a Tab
|
|
press cannot walk keyboard focus into a control this mode has covered.
|
|
|
|
Earlier than the palette and the host-key decision, both further down this same Panel, so either can
|
|
still interrupt settings mode exactly as it already interrupts every other screen — a host key that
|
|
changes while the sync loop is running does not stop being worth answering because the window
|
|
happens to be showing Settings.
|
|
-->
|
|
<views:SettingsView IsVisible="{Binding IsSettingsMode}" />
|
|
|
|
<!--
|
|
Setup and unlock. Later in the Panel, so it is above the application content in Avalonia's z-order —
|
|
which covers Avalonia-drawn content and nothing else. The terminal is collapsed rather than covered;
|
|
see the occlusion rule above.
|
|
-->
|
|
<Border Background="{StaticResource Canvas}" IsVisible="{Binding !IsUnlocked}">
|
|
<Panel>
|
|
|
|
<Border Classes="card" IsVisible="{Binding IsStarting}">
|
|
<StackPanel Spacing="10">
|
|
<TextBlock Classes="heading" Text="DodoSSH" />
|
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<Border Classes="card" IsVisible="{Binding IsNeedingServer}">
|
|
<StackPanel Spacing="12">
|
|
<TextBlock Classes="heading" Text="Connect to your server" />
|
|
<TextBlock Classes="hint"
|
|
Text="One address is all this needs. The identity provider, the client id and the scopes all come from the server itself." />
|
|
<!--
|
|
Named because signing out lands here with the keyboard needing somewhere to go: the vault's
|
|
controls have just been collapsed, and Focus() on a collapsed control is a no-op that is
|
|
not replayed. See MainWindow.axaml.cs.
|
|
-->
|
|
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
|
|
PlaceholderText="https://dodossh.example" />
|
|
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
|
|
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<Border Classes="card" IsVisible="{Binding IsNeedingEnrollment}">
|
|
<StackPanel Spacing="12">
|
|
<TextBlock Classes="heading" Text="Choose a keychain passphrase" />
|
|
<TextBlock Classes="hint"
|
|
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." />
|
|
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
|
|
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
|
|
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
|
|
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!--
|
|
Shown once and impossible to skip. This is the only moment the code exists, and losing it
|
|
together with the passphrase means the keychain is unrecoverable — there is no server-side reset
|
|
by design.
|
|
-->
|
|
<Border Classes="card" IsVisible="{Binding IsShowingRecoveryCode}">
|
|
<StackPanel Spacing="12">
|
|
<TextBlock Classes="heading" Text="Write this recovery code down" />
|
|
<TextBlock Classes="hint"
|
|
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the keychain: nobody — including whoever runs the server — can recover it for you." />
|
|
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
|
BorderThickness="1" CornerRadius="6" Padding="14">
|
|
<SelectableTextBlock Classes="mono" Text="{Binding RecoveryCode}"
|
|
FontSize="17" Foreground="{StaticResource Accent}"
|
|
TextWrapping="Wrap" />
|
|
</Border>
|
|
<!--
|
|
◆ COPY, beside the tick rather than inside the box. The code is selectable and has been
|
|
since this card existed, on the argument that refusing to let somebody take it only pushes
|
|
them to a screenshot — this is that argument finished, because "select 64 characters of
|
|
letter-spaced monospace without missing one" is a thing people get wrong silently.
|
|
|
|
It is the one secret this application offers to the clipboard on purpose; see
|
|
MainWindowViewModel.CopyRecoveryCode for why that is right here and refused for a private
|
|
key, and for what the status line below says afterwards.
|
|
-->
|
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
|
<Button Classes="ghost" Content="COPY CODE"
|
|
Command="{Binding CopyRecoveryCodeCommand}"
|
|
ToolTip.Tip="Puts the code on the clipboard. Paste it into your password manager now — it is shown once and the next thing you copy replaces it." />
|
|
<CheckBox IsChecked="{Binding RecoveryCodeWrittenDown}"
|
|
Content="I have written it down somewhere safe"
|
|
VerticalAlignment="Center" />
|
|
</StackPanel>
|
|
<Button Classes="accent" Content="CONTINUE" Command="{Binding ConfirmRecoveryCodeCommand}"
|
|
HorizontalAlignment="Left" />
|
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!--
|
|
The unlock card, and the sign-out confirmation that replaces it. Both live in their own files:
|
|
nothing inside this window can be laid out by a test — WebView2's adapter refuses the headless
|
|
session's thread — so markup that stays here is markup nobody can measure. See UnlockCard.axaml.
|
|
-->
|
|
<Border Classes="card" IsVisible="{Binding IsAskingForThePassphrase}">
|
|
<views:UnlockCard x:Name="UnlockPane" />
|
|
</Border>
|
|
|
|
<!--
|
|
The confirmation, in place of the unlock card rather than under it: the card is already close
|
|
to the height the window guarantees at its minimum, and a screen a user cannot read all of is
|
|
worse than one that shows one question at a time.
|
|
-->
|
|
<Border Classes="card" IsVisible="{Binding IsConfirmingSignOut}">
|
|
<views:SignOutCard />
|
|
</Border>
|
|
|
|
</Panel>
|
|
</Border>
|
|
|
|
<!--
|
|
Last, so it is over everything Avalonia draws. It is over the terminal too, because opening it
|
|
collapses the terminal — see IsTerminalShowing.
|
|
-->
|
|
<views:QuickConnect x:Name="Palette" IsVisible="{Binding IsSearching}" />
|
|
|
|
<!--
|
|
◆ Later still, and the order between these two is a ranking rather than an accident: the palette is
|
|
something the user opened and can close, and a host-key decision is a connection waiting on them. It
|
|
used to be two banners at the top of the hosts screen, which is why the shell navigated there before
|
|
the question could be asked — see HostKeyCard.axaml, and MainWindowViewModel.OnVaultConnectionFailed
|
|
for what that cost every other screen.
|
|
|
|
Wrapped, like every other child here whose data context is the vault: the visibility is the shell's
|
|
business — it is the flag that also collapses the WebView — and IsHostKeyDecisionShowing does not
|
|
exist on a VaultViewModel, which with compiled bindings is a build error rather than a card that
|
|
silently never appears.
|
|
-->
|
|
<Panel IsVisible="{Binding IsHostKeyDecisionShowing, FallbackValue=False}">
|
|
<views:HostKeyCard x:Name="HostKeyPane" DataContext="{Binding Vault}" />
|
|
</Panel>
|
|
|
|
</Panel>
|
|
|
|
<!--
|
|
A fourth row, and a row rather than an overlay for the reason the occlusion rule above gives: this
|
|
appears while a terminal may be open, and anything drawn in the WebView's rectangle is sliced. Taking
|
|
height from the row above moves the native control's bounds instead of covering it, which is the one
|
|
arrangement that works — the same one TitleBar and StatusBar already rely on.
|
|
|
|
It is a separate control because nothing in this file can be measured by a test, and a strip with two
|
|
buttons and a version string of unknown length is exactly the shape that arranges one of them off the
|
|
edge. See UpdateBanner.axaml.
|
|
|
|
◆ THE DATA CONTEXT IS SET HERE, and the banner did nothing at all until it was.
|
|
|
|
Unlike the titlebar and the status bar, which are typed to this window's own view model and inherit its
|
|
context, the banner is typed to UpdateViewModel — it is one screen's control and its layout suite hosts
|
|
it over that view model alone. Inheriting the shell's context instead left every compiled binding
|
|
inside it resolving against the wrong type and failing silently: no headline, and both Commands null,
|
|
so the strip appeared, hovered and pressed like a real banner and neither button did anything.
|
|
|
|
IsVisible is unqualified because the context is set on this same element, which resolves it against
|
|
UpdateViewModel too — the rule the page area's wrappers above are wrapped for. It needs no wrapper: the
|
|
flag it binds is the banner's own, unlike IsHostsScreen and its siblings, which belong to the shell.
|
|
|
|
FallbackValue, for the reason the WebView and the connecting card carry one: a compiled binding with
|
|
no DataContext yields UnsetValue, IsVisible falls back to true, and the previewer would show a banner
|
|
announcing an update that does not exist.
|
|
-->
|
|
<!--
|
|
v5c: wrapped rather than given a third condition of its own — UpdateBanner sets its own DataContext to
|
|
UpdateViewModel on this same element, so a binding against IsSettingsMode has to live on an ancestor
|
|
whose context is still the shell. The design's settings titlebar has no room for this strip; hiding it
|
|
here rather than teaching the banner about a mode it otherwise has no reason to know about.
|
|
-->
|
|
<Panel Grid.Row="2" IsVisible="{Binding !IsSettingsMode}">
|
|
<views:UpdateBanner DataContext="{Binding Updates}"
|
|
IsVisible="{Binding IsBannerShowing, FallbackValue=False}" />
|
|
</Panel>
|
|
|
|
<views:StatusBar Grid.Row="3" IsVisible="{Binding !IsSettingsMode}" />
|
|
|
|
</Grid>
|
|
|
|
</Window>
|