Notice when a file session stops, and give the file browser the four things it was short of
ci / build and test (push) Successful in 3m22s
ci / android head (push) Successful in 3m33s
ci / desktop nightly (push) Successful in 1m26s
ci / api image (push) Successful in 24s

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.
This commit is contained in:
jaap-jan committed 2026-08-24 11:48:23 +02:00
1 parent e2733e5cbc
commit cfc2e3ae0c
19 files changed
+3896 -240

No files matched your search

@@ -63,6 +63,24 @@ public sealed class ShellFlowTests : IAsyncLifetime
/// <summary>When set, resuming throws — how a revoked or rotated-away token is exercised.</summary>
private Exception? resumeFailure;
/// <summary>Local paths the fake editor was asked to open, in order.</summary>
private readonly List<string> opened = [];
/// <summary>
/// Fires the watcher on the file being edited, as a save would.
/// </summary>
/// <remarks>
/// Null until something is being watched, and null again once the watch is disposed — which is itself
/// what says STOP EDITING actually stopped. A fake rather than a real <c>FileSystemWatcher</c> because
/// what is being tested is what happens <em>when</em> a save arrives; waiting on a real one would be
/// measuring the operating system's notification latency.
/// </remarks>
private Action? save;
/// <summary>Serialises posted actions, which is the half of a dispatcher this suite has to supply.</summary>
/// <remarks>See the <c>post</c> argument below for what happens without it.</remarks>
private readonly Lock posting = new();
private string directory = null!;
private ClientPaths paths = null!;
private ClientCacheFactory caches = null!;
@@ -149,9 +167,34 @@ public sealed class ShellFlowTests : IAsyncLifetime
// Inline, because this suite has no window and therefore no dispatcher to drain — the same
// answer TransferQueueingTests reached, and for the reason its own remark gives: reaching
// Dispatcher.UIThread from a test means asserting on a queue owned by whichever class touched
// it first. Running the action where it was raised takes the thread out of the question, and
// every phase this suite reports is raised on the thread doing the asserting anyway.
post: action => action());
// it first. Running the action where it was raised takes the thread out of the question.
//
// ◆ UNDER A LOCK, AND THAT IS NOT BELT AND BRACES. What this delegate stands in for is a
// dispatcher, whose defining property is that the actions posted to it do not overlap — and the
// view models rely on it: TransfersViewModel.OnTransferChanged looks a transfer up by id and
// adds a row when it finds none, which two threads running it at once turn into two rows for
// one transfer. Every phase the shell reports is raised on the thread doing the asserting, so
// this cost nothing while nothing else went through here; the transfer queue announces from its
// own pump thread, and it does.
post: action =>
{
lock (posting)
{
action();
}
},
openInEditor: path =>
{
opened.Add(path);
return Task.CompletedTask;
},
watchFile: (_, onChanged) =>
{
save = onChanged;
return new StopWatching(() => save = null);
});
return ValueTask.CompletedTask;
}
@@ -1126,6 +1169,689 @@ public sealed class ShellFlowTests : IAsyncLifetime
// directly beside the pre-existing `RefreshConnectedHosts();` — is covered indirectly: it is the same
// VaultViewModel.RefreshLastConnectedAsync the activation test above already exercises.
// ---- Editing a remote file in place ----
/// <remarks>
/// <para>
/// ◆ The round trip this replaces is: download, find the file, open it, edit, come back, delete the old
/// one because nothing here overwrites, upload, check the mode survived. What it is instead is a copy,
/// an editor, and every save going back.
/// </para>
/// <para>
/// <b>The write-back deletes first.</b> <c>OpenWriteAsync</c> 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. Asserted here because nothing about
/// the bytes that arrive would show it in a test that only edited a file to the same length.
/// </para>
/// </remarks>
[Fact]
public async Task EditingARemoteFile_OpensACopyAndSendsEachSaveBack()
{
var sftpSession = await ConnectedTransfersAsync();
var row = shell.Transfers.RemoteEntries[0];
shell.Transfers.SelectedRemoteEntry = row;
// The session stats before each write-back to see whether the host's copy moved; this is the fake
// agreeing that the file is where the listing said it was.
sftpSession.Existing[row.FullPath] = row.Entry;
shell.Transfers.CanEditRemote.ShouldBeTrue();
await shell.Transfers.EditRemoteCommand.ExecuteAsync(null);
var editing = shell.Transfers.RemoteEdit.ShouldNotBeNull();
editing.RemotePath.ShouldBe(row.FullPath);
opened.ShouldHaveSingleItem().ShouldBe(editing.LocalCopy);
File.ReadAllText(editing.LocalCopy).ShouldBe("hello there\n", "the host's copy was fetched");
// Somebody edits and saves.
await File.WriteAllTextAsync(editing.LocalCopy, "goodbye\n", Token);
save.ShouldNotBeNull().Invoke();
await EventuallyAsync(
() => sftpSession.Written is not null,
$"the save should have gone back; log was <{string.Join(" | ", shell.Transfers.RemoteEditLog)}>");
System.Text.Encoding.UTF8.GetString(sftpSession.Written!).ShouldBe("goodbye\n");
// Truncated rather than written over, and the mode the listing had put back on afterwards.
sftpSession.Deleted.ShouldContain(row.FullPath);
sftpSession.PermissionsSet.ShouldHaveSingleItem().Mode.ShouldBe(0b110_100_100);
shell.Transfers.RemoteEditLog[0].ShouldContain("uploaded");
// ◆ AND AGAIN, which is the whole point of watching rather than fetching once. The first save moved
// the file on the host — the fake tracks that, as a server would — so this second one is a save onto
// a file that has changed since the copy was taken, and it must not be reported as somebody else's
// change. A version of this that replaced the session record instead of moving its stamp in place
// left the watcher holding an instance that was no longer the current one, and every save after the
// first quietly did nothing at all.
await File.WriteAllTextAsync(editing.LocalCopy, "again!\n", Token);
save.ShouldNotBeNull().Invoke();
await EventuallyAsync(
() => System.Text.Encoding.UTF8.GetString(sftpSession.Written!) == "again!\n",
$"the second save should have gone back too; log was "
+ $"<{string.Join(" | ", shell.Transfers.RemoteEditLog)}>");
shell.Transfers.HasRemoteEditConflict.ShouldBeFalse(
"the file changing because of this session's own write is not somebody else's change");
}
/// <remarks>
/// ◆ Two people editing one config is how a change gets silently reverted, and a client this thin
/// cannot merge. What it can do is notice — a stat before each save, compared against what the server
/// said when the copy was taken — and stop, which is the only honest answer available. The three
/// buttons on the card are the three things that actually exist: send it anyway, take the host's copy,
/// or stop.
/// </remarks>
[Fact]
public async Task ASaveOntoAFileThatMovedOnTheHost_IsRefusedUntilItIsAnswered()
{
var sftpSession = await ConnectedTransfersAsync();
var row = shell.Transfers.RemoteEntries[0];
shell.Transfers.SelectedRemoteEntry = row;
sftpSession.Existing[row.FullPath] = row.Entry;
await shell.Transfers.EditRemoteCommand.ExecuteAsync(null);
var editing = shell.Transfers.RemoteEdit.ShouldNotBeNull();
// Somebody else writes it while it is open.
sftpSession.Existing[row.FullPath] = row.Entry with { Length = row.Entry.Length + 40 };
await File.WriteAllTextAsync(editing.LocalCopy, "mine\n", Token);
save.ShouldNotBeNull().Invoke();
await EventuallyAsync(
() => shell.Transfers.HasRemoteEditConflict,
"the save should have been stopped rather than written over somebody else's");
sftpSession.Written.ShouldBeNull("nothing may be written until the question is answered");
shell.Transfers.RemoteEditConflict.ShouldContain("changed since you opened it");
// And UPLOAD ANYWAY is the answer that goes through.
await shell.Transfers.ForceSaveEditCommand.ExecuteAsync(null);
System.Text.Encoding.UTF8.GetString(sftpSession.Written.ShouldNotBeNull()).ShouldBe("mine\n");
shell.Transfers.HasRemoteEditConflict.ShouldBeFalse();
}
/// <remarks>
/// Stopping has to actually stop: the watch is disposed and the copy this screen made is removed. A
/// watcher left running would go on writing somebody's edits to a host after they had said not to,
/// which is the worst failure available to this feature.
/// </remarks>
[Fact]
public async Task StopEditing_ReleasesTheWatchAndRemovesTheCopy()
{
var sftpSession = await ConnectedTransfersAsync();
var row = shell.Transfers.RemoteEntries[0];
shell.Transfers.SelectedRemoteEntry = row;
sftpSession.Existing[row.FullPath] = row.Entry;
await shell.Transfers.EditRemoteCommand.ExecuteAsync(null);
var editing = shell.Transfers.RemoteEdit.ShouldNotBeNull();
File.Exists(editing.LocalCopy).ShouldBeTrue();
await shell.Transfers.StopEditingCommand.ExecuteAsync(null);
shell.Transfers.IsEditingRemote.ShouldBeFalse();
shell.Transfers.RemoteEditLog.ShouldBeEmpty();
save.ShouldBeNull("the watch was disposed");
File.Exists(editing.LocalCopy).ShouldBeFalse("the copy this screen made goes with the session");
}
// ---- Whole folders ----
/// <remarks>
/// <para>
/// ◆ The tree is turned into files before the queue sees it, and the directories it needs are made on
/// the host first. That is what keeps every property the queue already had — each file resumable on its
/// own, each written to a part file and renamed into place, none able to overwrite anything — while the
/// thing being dragged is a folder.
/// </para>
/// <para>
/// <b>Shortest-first is asserted rather than assumed.</b> A child directory asked for before its parent
/// exists is a <c>mkdir</c> that fails, and the transfers under it then fail one at a time with a
/// message about a path rather than about the folder that was dropped.
/// </para>
/// </remarks>
[Fact]
public async Task UploadingAFolder_MakesItsDirectoriesThenQueuesEveryFileUnderIt()
{
var sftpSession = await ConnectedTransfersAsync();
var root = Path.Combine(directory, "site");
var nested = Path.Combine(root, "assets", "img");
Directory.CreateDirectory(nested);
await File.WriteAllTextAsync(Path.Combine(root, "index.html"), "<!doctype html>", Token);
await File.WriteAllTextAsync(Path.Combine(root, "assets", "main.js"), "console.log(1)", Token);
await File.WriteAllTextAsync(Path.Combine(nested, "logo.svg"), "<svg/>", Token);
await shell.Transfers.QueueUploadsAsync([root], Token);
await EventuallyAsync(
() => shell.Transfers.Transfers.Count == 3,
$"every file under the folder should be queued; status was <{shell.Transfers.Status}>");
// Made before anything was queued, parents first.
sftpSession.Created.ShouldBe(
[
"/home/deploy/site",
"/home/deploy/site/assets",
"/home/deploy/site/assets/img",
],
ignoreOrder: false);
shell.Transfers.Status.ShouldContain("3 files");
// ◆ And the remote paths are POSIX, whatever this test is running on. A Windows relative path
// carried into SftpPath.Combine would have produced one file called `site\assets\main.js` rather
// than three directories deep, which is the failure this platform is most likely to ship.
var queued = shell.Transfers.Transfers.Select(row => row.Transfer.RemotePath).Order().ToArray();
queued.ShouldBe(
[
"/home/deploy/site/assets/img/logo.svg",
"/home/deploy/site/assets/main.js",
"/home/deploy/site/index.html",
],
ignoreOrder: false);
}
/// <remarks>
/// The other direction. The walk is a listing per directory, because SFTP has no recursive one — what
/// matters here is that the local tree it lands in is made before the transfers are queued into it, and
/// that the paths mirror the remote's.
/// </remarks>
[Fact]
public async Task DownloadingAFolder_MakesTheLocalTreeAndQueuesEveryFileUnderIt()
{
await ConnectedTransfersAsync();
var into = Path.Combine(directory, "landing");
Directory.CreateDirectory(into);
shell.Transfers.LocalPath = into;
// The shell's SFTP fake answers every listing with one file, so a directory row is a tree one deep
// — which is enough to say the walk descended and the local folder was made.
await shell.Transfers.QueueDownloadsAsync(
[new RemoteEntryRowViewModel(new SftpEntry(
"logs",
"/var/log/app",
SftpEntryKind.Directory,
0,
DateTimeOffset.UnixEpoch,
"drwxr-xr-x"))],
Token);
await EventuallyAsync(
() => shell.Transfers.Transfers.Count == 1,
$"the file under the folder should be queued; status was <{shell.Transfers.Status}>");
Directory.Exists(Path.Combine(into, "logs"))
.ShouldBeTrue("the folder is made here before anything is queued into it");
shell.Transfers.Transfers[0].Transfer.LocalPath
.ShouldBe(Path.Combine(into, "logs", "notes.txt"));
}
/// <remarks>
/// ◆ A link is counted and left alone, in both directions and for the same reason: one pointing at an
/// ancestor is a walk with no end, and the first anybody would know of it is a queue filling for ever.
/// On the remote side there is a second reason — an <c>lstat</c> 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.
/// </remarks>
[Fact]
public async Task AFolderWalk_CountsLinksRatherThanFollowingThem()
{
await ConnectedTransfersAsync();
await shell.Transfers.QueueDownloadsAsync(
[new RemoteEntryRowViewModel(new SftpEntry(
"current",
"/srv/releases/current",
SftpEntryKind.SymbolicLink,
0,
DateTimeOffset.UnixEpoch,
"lrwxrwxrwx"))],
Token);
shell.Transfers.Transfers.ShouldBeEmpty();
shell.Transfers.Status.ShouldContain("1 link was left alone");
}
// ---- Several rows at once ----
/// <remarks>
/// ◆ The fallback is the half worth guarding. Somebody who clicked a row and pressed DOWNLOAD has
/// ticked nothing and means that row — so a multi-select that made the commonest use of this screen
/// grow a step would be the usual way one of these makes a tool worse. Both readings are asserted here
/// because they are one property with two branches.
/// </remarks>
[Fact]
public async Task ABatchAction_TakesTheTickedRowsOrTheSelectionWhenNothingIsTicked()
{
await ConnectedTransfersAsync();
var row = shell.Transfers.RemoteEntries[0];
// Nothing ticked: the selection is the batch.
shell.Transfers.SelectedRemoteEntry = row;
shell.Transfers.HasCheckedRemote.ShouldBeFalse();
shell.Transfers.CheckedRemoteLabel.ShouldBeEmpty();
shell.Transfers.RemoteBatch.ShouldHaveSingleItem().ShouldBe(row);
shell.Transfers.CanDownload.ShouldBeTrue();
// Ticked: the ticks win, and the selection stops being what the buttons are about.
row.IsChecked = true;
shell.Transfers.RemoteChecksChanged();
shell.Transfers.HasCheckedRemote.ShouldBeTrue();
shell.Transfers.CheckedRemoteLabel.ShouldBe("1 selected");
shell.Transfers.SelectedRemoteEntry = null;
shell.Transfers.RemoteBatch.ShouldHaveSingleItem().ShouldBe(row, "a tick survives the highlight moving");
}
/// <remarks>
/// <para>
/// A ticked set is one act — these files, into that directory — so the boxes are released once it has
/// been queued. Leaving them ticked is how the next press sends everything a second time.
/// </para>
/// <para>
/// The queue is what counts and reports what it skipped, which is why a directory among the ticks does
/// not stop the files going: see QueueDownloads and the sentence it builds.
/// </para>
/// </remarks>
[Fact]
public async Task DownloadingATickedSet_QueuesEachFileAndReleasesTheTicks()
{
await ConnectedTransfersAsync();
// One listing row is all the shell fake offers, so a second is made by navigating: the rows are
// rebuilt per directory and the ticks go with them, which is itself the behaviour being relied on.
var first = shell.Transfers.RemoteEntries[0];
first.IsChecked = true;
shell.Transfers.RemoteChecksChanged();
shell.Transfers.CanDownload.ShouldBeTrue();
shell.Transfers.DownloadCommand.Execute(null);
// Waited for rather than read straight away: the queue announces from its own pump thread, so the
// row appears a moment after the command returns.
await EventuallyAsync(
() => shell.Transfers.Transfers.Count == 1,
"the ticked file should have become exactly one queue row");
shell.Transfers.Transfers[0].Name.ShouldBe(first.Name);
shell.Transfers.HasCheckedRemote.ShouldBeFalse("a queued set releases its ticks");
first.IsChecked.ShouldBeFalse();
}
/// <remarks>
/// ◆ Deleting is the one act on this screen that nothing walks back, so a batch of them has to name
/// every path rather than a count — agreeing to "3 items" is agreeing to a number. And each goes on its
/// own: the host refuses 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.
/// </remarks>
[Fact]
public async Task DeletingATickedSet_NamesEveryPathAndKeepsGoingPastARefusal()
{
var sftpSession = await ConnectedTransfersAsync();
var row = shell.Transfers.RemoteEntries[0];
row.IsChecked = true;
shell.Transfers.RemoteChecksChanged();
shell.Transfers.DeleteRemoteCommand.Execute(null);
var question = shell.Transfers.PendingRemoteDeletion.ShouldNotBeNull();
question.Count.ShouldBe(1);
question.IsSingle.ShouldBeTrue();
question.Listing.ShouldBe(row.FullPath, "the path is what makes the question answerable");
await shell.Transfers.ConfirmDeleteRemoteCommand.ExecuteAsync(null);
sftpSession.Deleted.ShouldHaveSingleItem().ShouldBe(row.FullPath);
shell.Transfers.Status.ShouldBe("Deleted 1 item.");
shell.Transfers.HasCheckedRemote.ShouldBeFalse();
}
/// <remarks>
/// One gesture for both directions, because from the user's side "select all" and "select none" are the
/// same act — they look at the boxes and want the other state.
/// </remarks>
[Fact]
public async Task SelectAll_TicksEverythingAndThenUnticksIt()
{
await ConnectedTransfersAsync();
shell.Transfers.ToggleAllRemoteCommand.Execute(null);
shell.Transfers.CheckedRemoteCount.ShouldBe(shell.Transfers.RemoteEntries.Count);
shell.Transfers.RemoteEntries.ShouldAllBe(row => row.IsChecked);
shell.Transfers.ToggleAllRemoteCommand.Execute(null);
shell.Transfers.CheckedRemoteCount.ShouldBe(0);
shell.Transfers.RemoteEntries.ShouldAllBe(row => !row.IsChecked);
}
// ---- Permissions: the PERMS column stops being read-only ----
/// <remarks>
/// <para>
/// ◆ The whole point of the form: what reaches the host is the nine bits that were ticked, not the
/// sentence the screen writes about itself afterwards. It is asserted as a literal — <c>-rw-r--r--</c>
/// with the owner's execute bit added is <c>0744</c>, which is close enough to the <c>0755</c> everybody
/// reaches for that a test agreeing with the code's own arithmetic would agree with it being wrong.
/// </para>
/// <para>
/// The listing is re-read afterwards, and that is not tidiness. A umask, an ACL or a filesystem mounted
/// without permissions can all land somewhere other than what was asked for, and the server is the only
/// thing that knows which — so the column goes back to being a fact rather than a hope.
/// </para>
/// </remarks>
[Fact]
public async Task ChangingPermissions_SendsTheBitsThatWereTickedAndRereadsTheListing()
{
var sftpSession = await ConnectedTransfersAsync();
shell.Transfers.SelectedRemoteEntry = shell.Transfers.RemoteEntries[0];
shell.Transfers.ChangeRemotePermissionsCommand.Execute(null);
var form = shell.Transfers.PendingRemotePermissions.ShouldNotBeNull();
// The form opens on the mode the listing already had, rather than on a fresh stat or on nothing.
form.OwnerRead.ShouldBeTrue();
form.OwnerExecute.ShouldBeFalse();
form.Preview.ShouldBe("-rw-r--r-- (0644)");
form.HasChanges.ShouldBeFalse("nothing has been touched yet");
form.OwnerExecute = true;
form.HasChanges.ShouldBeTrue();
form.Preview.ShouldBe("-rwxr--r-- (0744)");
var listedBefore = sftpSession.Listed.Count;
await shell.Transfers.ConfirmRemotePermissionsCommand.ExecuteAsync(null);
var sent = sftpSession.PermissionsSet.ShouldHaveSingleItem();
sent.Path.ShouldBe(shell.Transfers.RemoteEntries[0].FullPath);
sent.Mode.ShouldBe(0b111_100_100);
shell.Transfers.PendingRemotePermissions.ShouldBeNull("the form closes on success");
sftpSession.Listed.Count.ShouldBeGreaterThan(listedBefore, "the column it changed is re-read");
}
/// <remarks>
/// The three questions this pane can ask are one at a time by construction, and each one's own opening
/// is what clears the others. Worth a test because the guard is three assignments in three methods
/// rather than one piece of shared state, so a fourth question added later has three places to remember.
/// </remarks>
[Fact]
public async Task OpeningThePermissionsForm_TakesAwayWhicheverQuestionWasAlreadyUp()
{
await ConnectedTransfersAsync();
shell.Transfers.SelectedRemoteEntry = shell.Transfers.RemoteEntries[0];
shell.Transfers.DeleteRemoteCommand.Execute(null);
shell.Transfers.IsConfirmingRemoteDeletion.ShouldBeTrue();
shell.Transfers.ChangeRemotePermissionsCommand.Execute(null);
shell.Transfers.IsConfirmingRemoteDeletion.ShouldBeFalse();
shell.Transfers.IsChangingRemotePermissions.ShouldBeTrue();
shell.Transfers.IsAskingAboutRemoteEntry.ShouldBeTrue();
// And back the other way, so the clearing is not one-directional.
shell.Transfers.RenameRemoteCommand.Execute(null);
shell.Transfers.IsChangingRemotePermissions.ShouldBeFalse();
shell.Transfers.IsMovingRemote.ShouldBeTrue();
}
/// <remarks>
/// An object is governed by an access policy rather than nine bits, so the button is dark on that
/// destination rather than live and answered with a refusal from the store. The other three item actions
/// stay live, which is the half that says this is a considered difference rather than the whole row
/// being switched off.
/// </remarks>
[Fact]
public async Task ABucket_IsNotOfferedPermissions()
{
var vault = await ReadyToConnectAsync();
shell.Transfers.Attach(vault, knownHosts, buckets: new FakeObjectStoreFactory());
vault.NewObjectStoreCommand.Execute(null);
vault.BucketEditorLabel = "Backups";
vault.BucketEditorBucket = "backups";
vault.BucketEditorAccessKeyId = "AKIAEXAMPLE";
vault.BucketEditorSecretAccessKey = "a-secret-access-key";
vault.BucketEditorRegion = "eu-west-1";
await vault.SaveObjectStoreCommand.ExecuteAsync(null);
shell.Transfers.Remote = RemoteKind.Bucket;
shell.Transfers.SelectedBucket = shell.Transfers.Buckets[0];
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
shell.Transfers.CanChangeRemotePermissions.ShouldBeFalse();
shell.Transfers.CanDeleteRemote.ShouldBeTrue("the other three are unaffected");
}
/// <remarks>
/// ◆ The reason "keep mode and mtime" is worth having at all: an uploaded <c>deploy.sh</c> that lands
/// with the server's umask on it is the thing that makes people open a terminal straight after using
/// this screen. What can be carried depends on the platform the file came from — a mode is not a fact
/// about a file on Windows — so this asserts the timestamp, which every platform has, and that a mode is
/// sent exactly when the local filesystem had one to send.
/// </remarks>
[Fact]
public async Task AnUpload_CarriesTheSourcesTimestampAndItsModeWhereThereIsOne()
{
var sftpSession = await ConnectedTransfersAsync();
var source = Path.Combine(directory, "deploy.sh");
await File.WriteAllTextAsync(source, "#!/bin/sh\necho hello\n", Token);
var written = new DateTimeOffset(2026, 3, 14, 9, 26, 53, TimeSpan.Zero);
File.SetLastWriteTimeUtc(source, written.UtcDateTime);
shell.Transfers.QueueUploads([source]);
await EventuallyAsync(
() => sftpSession.TimesSet.Count > 0,
"the upload should have set the destination's timestamp");
var stamped = sftpSession.TimesSet.ShouldHaveSingleItem();
stamped.Path.ShouldEndWith("deploy.sh");
stamped.When.ShouldBe(written);
if (OperatingSystem.IsWindows())
{
sftpSession.PermissionsSet.ShouldBeEmpty(
"a file on Windows has no POSIX mode, and inventing one would be this client deciding it");
}
else
{
sftpSession.PermissionsSet.ShouldHaveSingleItem().Path.ShouldEndWith("deploy.sh");
}
}
// ---- A file session that stops, and the way back into the directory it was showing ----
/// <remarks>
/// <para>
/// ◆ The same defect the terminal had, on the other surface: nothing noticed. IsConnected was set at
/// connect and cleared only by the DISCONNECT button, so a host that went away left this screen saying
/// CONNECTED over a session that was gone, and every action after it failed with the same server message
/// and no explanation of why they all did.
/// </para>
/// <para>
/// <b>The listing deliberately stays.</b> SftpEntry is a record of what the server said rather than a
/// handle on anything, so the pane can go on showing where the user was — which is the whole of what
/// makes the reconnect below worth offering rather than merely correct.
/// </para>
/// </remarks>
[Fact]
public async Task AFileSessionWhoseConnectionBreaks_SaysSoAndKeepsWhereItWas()
{
var sftpSession = await ConnectedTransfersAsync();
shell.ShowFilesCommand.Execute(RemoteKind.Host);
shell.Transfers.IsConnected.ShouldBeTrue();
shell.IsSessionConnected.ShouldBeTrue();
var showing = shell.Transfers.RemotePath;
sftpSession.Drop("An established connection was aborted by the server.");
shell.Transfers.IsConnected.ShouldBeFalse();
shell.Transfers.IsConnectionLost.ShouldBeTrue();
shell.Transfers.LostConnection.ShouldBe("An established connection was aborted by the server.");
// Where they were, still on screen and still named.
shell.Transfers.RemotePath.ShouldBe(showing);
shell.Transfers.RemoteEntries.ShouldNotBeEmpty("the listing outlives the session it came from");
// And the status bar says which of the two states this is, rather than collapsing to nothing.
shell.SessionEndedText.ShouldBe("DISCONNECTED");
shell.SessionEndedDetail.ShouldBe("An established connection was aborted by the server.");
shell.IsSessionDropped.ShouldBeTrue();
shell.SessionAddress.ShouldNotBeNull("there is still a host worth naming");
shell.CanReconnectSession.ShouldBeTrue();
shell.SessionEndedDismissLabel.ShouldBe("DISCONNECT");
}
/// <remarks>
/// The half that makes the button worth having. A reconnect that landed in the home directory would
/// leave somebody five levels deep in a deployment tree to walk back down it, so the path is captured at
/// connect and restored afterwards — and the dial itself goes through the ordinary connect path, so the
/// host key gate and the log entry are the ones every other connection gets.
/// </remarks>
[Fact]
public async Task ReconnectingALostFileSession_DialsAgainAndGoesBackToTheDirectoryItWasShowing()
{
var vault = await ReadyToConnectAsync();
// Bound to a key, which is what lets this host be redialled with nobody present. One that wants a
// password typed is asked for it again instead, and has a test of its own below.
await AddKeyAsync(vault, "deploy");
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
shell.Transfers.Attach(vault, knownHosts);
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
var sftpSession = ssh.OpenedSftp.ShouldNotBeNull();
shell.ShowFilesCommand.Execute(RemoteKind.Host);
await shell.Transfers.GoRemoteCommand.ExecuteAsync("/home/dodo/deploy");
var showing = shell.Transfers.RemotePath;
showing.ShouldBe("/home/dodo/deploy");
sftpSession.Drop("An established connection was aborted by the server.");
await shell.ReconnectSessionCommand.ExecuteAsync(null);
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
shell.Transfers.IsConnectionLost.ShouldBeFalse();
shell.Transfers.RemotePath.ShouldBe(showing, "a reconnect puts them back where they were");
shell.SessionEndedText.ShouldBeNull();
ssh.SftpRequests.Count.ShouldBe(2, "the ordinary connect path was used, not a second one");
// A second, separate session — the first one is gone, not resumed.
ssh.OpenedSftp.ShouldNotBeNull().ShouldNotBe(sftpSession);
}
/// <remarks>
/// A host that wants a password typed cannot be redialled on its own: the box is cleared the moment a
/// connection succeeds and nothing keeps it, so dialling with an empty one would be an authentication
/// failure wearing the word RECONNECT. The picker comes back up with the host already chosen, which is
/// as far as this can honestly get by itself — and the pane keeps its listing, so the button is still
/// there once the password is in.
/// </remarks>
[Fact]
public async Task ReconnectingAFileSessionOnAPasswordHost_AsksForOneRatherThanDiallingWithout()
{
var sftpSession = await ConnectedTransfersAsync();
shell.ShowFilesCommand.Execute(RemoteKind.Host);
sftpSession.Drop("An established connection was aborted by the server.");
await shell.ReconnectSessionCommand.ExecuteAsync(null);
shell.Transfers.IsChoosingRemote.ShouldBeTrue();
shell.Transfers.Status.ShouldContain("asks for a password");
ssh.SftpRequests.ShouldHaveSingleItem("nothing should have been dialled without the password");
shell.Transfers.RemoteEntries.ShouldNotBeEmpty("the listing is still there to come back to");
}
/// <remarks>
/// A bucket has no connection to lose — it is HTTPS per request — so it never reaches the lost state at
/// all, and the strip that offers a reconnect stays away. Asserted because the store interface carries
/// the two members regardless, and an implementation that raised them would put a DISCONNECTED strip
/// over a screen whose next request would have worked perfectly well.
/// </remarks>
[Fact]
public async Task ABucket_NeverReportsALostConnection()
{
var vault = await ReadyToConnectAsync();
shell.Transfers.Attach(vault, knownHosts, buckets: new FakeObjectStoreFactory());
vault.NewObjectStoreCommand.Execute(null);
vault.BucketEditorLabel = "Backups";
vault.BucketEditorBucket = "backups";
vault.BucketEditorAccessKeyId = "AKIAEXAMPLE";
vault.BucketEditorSecretAccessKey = "a-secret-access-key";
vault.BucketEditorRegion = "eu-west-1";
await vault.SaveObjectStoreCommand.ExecuteAsync(null);
// Remote is what ConnectAsync branches on; Attach has already auto-selected the host the fixture
// left in the picker.
shell.Transfers.Remote = RemoteKind.Bucket;
shell.Transfers.SelectedBucket = shell.Transfers.Buckets[0];
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
shell.Transfers.ConnectedTo.ShouldBe("s3://backups");
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
shell.Transfers.IsConnectionLost.ShouldBeFalse();
shell.ShowFilesCommand.Execute(RemoteKind.Bucket);
shell.SessionEndedText.ShouldBeNull("a bucket has no connection to report the loss of");
}
// ---- A session that stops: the status it gets, and the way back ----
/// <remarks>
@@ -9125,6 +9851,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
{
public bool IsConnected => true;
/// <remarks>Null, and never raised: a bucket has no connection to lose. See <c>S3FileStore</c>.</remarks>
public string? TransportFailure => null;
#pragma warning disable CS0067 // The event is never used
public event EventHandler? ConnectionLost;
#pragma warning restore CS0067
public string HomeDirectory => "/";
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken) =>
@@ -9133,6 +9866,17 @@ public sealed class ShellFlowTests : IAsyncLifetime
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
Task.FromResult<SftpEntry?>(null);
/// <remarks>Refused, as S3FileStore refuses it: an object has an access policy rather than a mode.</remarks>
public Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken) =>
throw new SftpPathException(path, "An object in a bucket has no POSIX mode.");
/// <inheritdoc cref="SetPermissionsAsync" />
public Task SetLastWriteTimeAsync(
string path,
DateTimeOffset lastWriteTimeUtc,
CancellationToken cancellationToken) =>
throw new SftpPathException(path, "An object's timestamp is when the bucket stored it.");
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
@@ -9634,6 +10378,12 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.Issuer.ShouldBe("https://idp.example/realms/dodossh");
}
/// <summary>What the fake watcher hands back, so disposing it is observable.</summary>
private sealed class StopWatching(Action onDisposed) : IDisposable
{
public void Dispose() => onDisposed();
}
/// <summary>An unlocked vault with one selected host and a renderer attached.</summary>
private async Task<VaultViewModel> ReadyToConnectAsync()
{