From cfc2e3ae0c50a5a946d0cc99b2515d90ad26c1c9 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Mon, 24 Aug 2026 11:48:23 +0200 Subject: [PATCH] Notice when a file session stops, and give the file browser the four things it was short of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Views/FilesScreen.axaml | 189 +- .../Views/FilesScreen.axaml.cs | 9 + src/DodoSSH.Client.App/App.axaml.cs | 101 +- src/DodoSSH.Client.App/Views/MainWindow.axaml | 14 +- .../Views/SessionEndedBar.axaml | 53 +- .../Views/SessionEndedBar.axaml.cs | 9 +- .../Views/TransfersScreen.axaml | 282 ++- .../Views/TransfersScreen.axaml.cs | 14 + src/DodoSSH.Client.ObjectStore/S3FileStore.cs | 44 + .../ViewModels/MainWindowViewModel.cs | 164 +- .../ViewModels/TransfersViewModel.cs | 1805 ++++++++++++++++- src/DodoSSH.Client.Ssh/SftpSession.cs | 121 ++ src/DodoSSH.Client.Ssh/SshNetSftpSession.cs | 108 +- .../FileTransferQueue.cs | 158 +- .../ScreenLayoutTests.cs | 58 +- tests/DodoSSH.Client.App.Tests/FakeSsh.cs | 126 +- .../ShellFlowTests.cs | 756 ++++++- .../TransferQueueingTests.cs | 68 +- .../FakeSftpSession.cs | 57 + 19 files changed, 3896 insertions(+), 240 deletions(-) diff --git a/src/DodoSSH.Client.Android/Views/FilesScreen.axaml b/src/DodoSSH.Client.Android/Views/FilesScreen.axaml index 3873086..a16f590 100644 --- a/src/DodoSSH.Client.Android/Views/FilesScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/FilesScreen.axaml @@ -48,7 +48,12 @@ the refusal has no continue button here either. --> - + + @@ -78,7 +83,13 @@ - + + - + @@ -197,9 +209,24 @@ Command="{Binding GoToTypedRemotePathCommand}" /> + + + + + + + IsVisible="{Binding ShowsRemoteContents}" Margin="0,0,0,4">