using System.Collections.ObjectModel; using System.Collections.Specialized; using System.Globalization; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DodoSSH.Client.Domain; using DodoSSH.Client.ObjectStore; using DodoSSH.Client.Session; using DodoSSH.Client.Ssh; using DodoSSH.Client.Transfer; namespace DodoSSH.Client.Shell.ViewModels; /// What sort of remote the file browser's right-hand pane is showing. internal enum RemoteKind { /// A host, over SFTP. Host, /// An S3-compatible bucket. Bucket, } /// One segment of a path, as a button in a breadcrumb trail. /// What the segment is called. /// The absolute path that reaches it. internal sealed record CrumbViewModel(string Name, string Path); /// One remote file or directory, as a row. internal sealed partial class RemoteEntryRowViewModel(SftpEntry entry) : ObservableObject { /// /// Whether this row is in the set the batch actions act on. /// /// /// /// ◆ A tick of its own rather than the list control's selection, and the two are different things /// here. The selection is "the row I am looking at", which is what rename, move and permissions act /// on — all three are one-at-a-time by construction and take a form that names one path. This is "the /// rows I have gathered up", which is what downloading and deleting act on, and it survives the /// selection moving. /// /// /// It is also the only shape that works on both heads. A phone has no shift-click and no ctrl-click, so /// a multi-selection there has to be a tap target either way — and one that is drawn is one somebody can /// see the state of, which for a batch delete is worth more than the two keystrokes it costs a desk. /// /// [ObservableProperty] private bool isChecked; internal SftpEntry Entry => entry; internal string Name => entry.Name; internal string FullPath => entry.FullPath; internal bool IsNavigable => entry.IsNavigable; internal bool IsFile => entry.Kind is SftpEntryKind.File; /// The row's own Material Icons glyph — folder for a directory, a document for a file. /// /// The design's own sample data asks for "draft", which is a Material Symbols glyph this application's /// embedded classic Material Icons font does not contain — see TransfersScreen.axaml's own remark on the /// substitution. insert_drive_file is the closest classic equivalent: a plain document rather than one /// with a folded corner, but a file rather than a folder is the fact this glyph exists to carry. /// internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D"; /// /// A directory shows nothing rather than a zero. Its inode's size is a number no user has ever wanted, /// and a column of zeroes beside real sizes reads as a listing that failed to measure them. /// internal string Size => entry.Kind is SftpEntryKind.File ? ByteSize.Format(entry.Length) : string.Empty; /// /// Blank when there is no timestamp, for the reason the size above is blank for a directory. A bucket /// has no folders — a prefix is inferred from the keys under it — so the object store has nothing to /// report for one and leaves the timestamp at its default. Formatting that prints /// 0001-01-01 00:00, which is not a quiet "unknown": it is a date, and a listing that states one /// for every folder in a bucket is stating something untrue. /// internal string Modified => entry.LastWriteTimeUtc == default ? string.Empty : Timestamps.Format(entry.LastWriteTimeUtc); /// The mode as drwxr-xr-x, which is the design's PERMS column. internal string Permissions => entry.Permissions; /// Whether the row is a file with an execute bit, which the NAME column colours for. internal bool IsExecutable => entry.IsExecutable; /// Whether the row is a file anyone may write to, which the PERMS column colours for. internal bool IsWorldWritable => entry.IsWorldWritable; } /// One local file or directory, as a row. /// /// The same shape as the remote row minus the permissions, which have no honest value here: this client is /// developed on Windows, where a POSIX mode is not a fact about a file. The column is empty on this side /// rather than filled with a plausible-looking -rw-r--r--. /// internal sealed partial class LocalEntryRowViewModel(LocalEntry entry) : ObservableObject { /// [ObservableProperty] private bool isChecked; internal LocalEntry Entry => entry; internal string Name => entry.Name; internal string FullPath => entry.FullPath; internal bool IsNavigable => entry.IsDirectory; internal bool IsFile => !entry.IsDirectory; /// internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D"; internal string Size => entry.IsDirectory ? string.Empty : ByteSize.Format(entry.Length); internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc); } /// How this screen writes a modification time. /// /// /// UTC and ISO-ordered, in one place, because both panes show this column side by side: a local pane in this /// machine's conventions beside a remote pane in the server's would invite comparing two timestamps that are /// not written the same way, which is the only thing anybody does with this column. /// /// /// The one place in this application that deliberately ignores the user's locale — see the App project's /// InvariantGlobalization, which is false precisely so dates elsewhere follow it. Sortable order and /// an unambiguous zone beat familiarity when the two columns have to be read against each other. /// /// internal static class Timestamps { internal static string Format(DateTimeOffset moment) => moment.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); } /// One transfer, as a row in the queue. /// /// Observable and long-lived, unlike the two listing rows, because a transfer's progress changes several /// times a second while its identity does not. It is refreshed from rather /// than holding the queue's own object: the queue mutates its entries from a thread-pool thread, and this is /// only ever read from the UI thread. /// internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) : ObservableObject { [ObservableProperty] private TransferSnapshot transfer = snapshot; internal Guid Id => Transfer.Id; internal string Name => Transfer.Name; /// Which way, as an arrow the eye can scan a column of. internal string Arrow => Transfer.Direction is TransferDirection.Download ? "↓" : "↑"; /// The end that is not this machine, which is the one worth showing. internal string Path => Transfer.Direction is TransferDirection.Download ? Transfer.RemotePath : Transfer.LocalPath; /// /// "source → destination directory", the v5b TRANSFERS strip's own single-line label. /// /// /// Built from real paths this snapshot already carries rather than a shortened form invented for the /// strip: the source is the file name at whichever end the transfer reads from, and the destination is /// the whole directory it is written into — for an upload, and /// for a download, because the two ends are a /// remote path and a local one and this codebase does not run the BCL's path helpers on the former; see /// 's own remark on why. Neither end is trimmed to a last segment the way the /// design's own sample data is — a directory two levels down would read as the same word as its parent — /// so the strip's own 320-pixel column and TextTrimming="CharacterEllipsis" carry the length /// instead. /// internal string Label { get { var (sourcePath, destinationDirectory) = Transfer.Direction is TransferDirection.Upload ? (Transfer.LocalPath, SftpPath.Parent(Transfer.RemotePath)) : (Transfer.RemotePath, System.IO.Path.GetDirectoryName(Transfer.LocalPath) ?? string.Empty); // Path.GetFileName reads either separator on Windows, so a remote path's forward slashes need no // translation to name the file at the end of it. var source = System.IO.Path.GetFileName(sourcePath); return $"{source} → {destinationDirectory}"; } } internal double Percent => Transfer.Fraction * 100; /// /// The v5b TRANSFERS strip's own right-aligned status word: a state, or a live percentage while running. /// /// /// Not , which stays for the row's own tooltip: that string carries the bytes and /// the rate together, which is the sentence somebody reads on purpose, and a status column has room for a /// word rather than a sentence. A running transfer's word is its own percentage rather than "running", /// because the strip's slim progress bar already says "in motion" and the number beside it is the fact /// the bar alone cannot state exactly. /// internal string StatusWord => Transfer.State switch { TransferState.Queued => "queued", TransferState.Running => string.Create( CultureInfo.InvariantCulture, $"{(int)Percent}%"), TransferState.Completed => "done", TransferState.Cancelled => "stopped", _ => "failed", }; /// Whether the status word should read as the strip's own "in motion" colour. internal bool IsInProgress => Transfer.State is TransferState.Running; /// Whether the status word should read as the strip's own "finished, kept" colour. internal bool IsDone => Transfer.State is TransferState.Completed; /// /// What the row says about where it has got to. /// /// /// The bytes and the rate together while it runs, because either alone leaves the obvious question /// unanswered — a rate with no total cannot say how long is left, and a total with no rate cannot say /// whether it is still moving. /// internal string Progress => Transfer.State switch { TransferState.Queued => "queued", TransferState.Running => $"{ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}" + $" · {ByteSize.Format((long)Transfer.BytesPerSecond)}/s", TransferState.Completed => ByteSize.Format(Transfer.Length), TransferState.Cancelled when Transfer.Transferred > 0 => $"stopped at {ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}", TransferState.Cancelled => "stopped", _ => Transfer.Failure ?? "failed", }; internal string StateLabel => Transfer.State switch { TransferState.Queued => "QUEUED", TransferState.Running => "RUNNING", TransferState.Completed => "DONE", TransferState.Cancelled => "STOPPED", _ => "FAILED", }; internal bool IsRunning => Transfer.State is TransferState.Running or TransferState.Queued; internal bool IsFinished => Transfer.IsFinished; internal bool CanResume => Transfer.CanResume; internal bool HasFailed => Transfer.State is TransferState.Failed; /// Whether a stopped transfer is worth offering to run again at all. /// /// Wider than : a transfer that failed before it moved a byte — the host was /// unreachable, the destination was occupied — is worth retrying from the start, and only the button's /// wording differs. See . /// internal bool CanRetry => Transfer.IsFinished && Transfer.State is not TransferState.Completed; internal string RetryLabel => CanResume ? "RESUME" : "RETRY"; /// /// Every derived member at once. They are one fact — the snapshot — read from eight directions, and /// raising only the ones that happened to change is how a progress bar moves under a label that still /// says "queued". /// partial void OnTransferChanged(TransferSnapshot value) { OnPropertyChanged(nameof(Progress)); OnPropertyChanged(nameof(Percent)); OnPropertyChanged(nameof(StateLabel)); OnPropertyChanged(nameof(StatusWord)); OnPropertyChanged(nameof(IsInProgress)); OnPropertyChanged(nameof(IsDone)); OnPropertyChanged(nameof(IsRunning)); OnPropertyChanged(nameof(IsFinished)); OnPropertyChanged(nameof(CanResume)); OnPropertyChanged(nameof(CanRetry)); OnPropertyChanged(nameof(HasFailed)); OnPropertyChanged(nameof(RetryLabel)); } } /// /// Something on the host that has been asked about and not yet agreed to. /// /// /// /// The one deletion in this application that nothing can walk back. A vault item is a tombstone against a /// copy the server still holds until the pass lands; a file on somebody's host is bytes, and this screen /// has no wastebasket to put them in. /// /// /// It carries the full path rather than only the name, because the name is the half that does not identify /// anything: config in the directory that was showing a moment ago and config in the one /// showing now look identical in a confirmation, and only one of them is the file somebody meant. /// /// /// It covers however many were ticked, and it names them all. A batch delete that said "3 items" and /// nothing else would be asking somebody to agree to a number — the paths are what makes the question /// answerable, and they are the whole of what this form has to show for the risk it is taking. /// /// /// /// What is being deleted, each with the name and kind it was listed under. Never empty: the command that /// builds one refuses before it gets here. /// internal sealed record RemoteDeletionRequest(IReadOnlyList Paths) { /// The one path, when there is one — which is every question this screen asked before batches. internal RemoteDeletionTarget First => Paths[0]; /// How many. internal int Count => Paths.Count; /// Whether this is the ordinary one-row question. internal bool IsSingle => Count == 1; /// Whether every one of them is a directory, which changes what the host will refuse. private bool AllDirectories => Paths.All(target => target.IsDirectory); /// The question, naming the kind because the two behave differently. internal string Question => IsSingle ? First.IsDirectory ? $"Delete the directory '{First.Name}' on the host?" : $"Delete '{First.Name}' on the host?" : $"Delete {Count} items on the host?"; /// /// What is about to go, one path per line. /// /// /// The full paths rather than the names, for the reason this record carries them: config in the /// directory that was showing a moment ago and config in the one showing now look identical in a /// confirmation, and only one of them is the file somebody meant. /// internal string Listing => string.Join('\n', Paths.Select(target => target.FullPath)); /// What it costs, which is everything: there is no copy here and no undo there. internal string Consequence => AllDirectories ? "Removed on the host itself. The host refuses a directory that still has anything in it, so this " + "either removes empty ones or fails — and what goes is gone: nothing here keeps a copy and " + "there is no undo." : "Removed on the host itself. Nothing here keeps a copy, the folder on this machine is not " + "touched, and there is no undo."; } /// One thing a deletion question is about. /// What the row was called. /// Where it is, which is what the question actually promises to delete. /// Whether it is a directory, which the host treats differently. internal sealed record RemoteDeletionTarget(string Name, string FullPath, bool IsDirectory); /// Which of the two things a rename actually is, from where the user is standing. /// /// One SFTP operation underneath — ISftpSession.RenameAsync is the whole of both — and two separate /// questions on the screen, because the answer a person types is a different kind of thing each time. A /// rename takes a name and must not take a path; a move takes a directory and must not take a name. Folding /// them into one box asking for a destination path would be fewer controls and a worse question: the two /// mistakes it invites — typing a directory where a name goes, or a name where a directory goes — are /// exactly the ones this split makes impossible to make. /// internal enum RemoteMoveKind { /// Same directory, different name. Rename, /// Same name, different directory. Move, } /// /// Something on the host that is being given a new name or a new home, and has not been committed yet. /// /// /// /// The shape already established, and here for the same reason: it /// carries the full path rather than the name, so nothing that happens between opening the form and /// pressing its button can point the operation at a different file. The selection can move underneath it — /// a refresh nulls it — and the request stays about what was asked about. /// /// /// It asks rather than warns. Renaming and moving on the host are not deletions: what they change is /// where the bytes are reached from, and both are undone by doing them again the other way. So this form /// carries a field and a verb instead of 's "there is no /// undo" — the one warning on this screen that is earned, and worth not spending twice. /// /// /// What the row is called now. /// Where it is now, which is the end of the operation nothing may re-point. /// Whether it is a directory, which changes what moving it can be asked to do. /// Which question is being asked. internal sealed record RemoteMoveRequest(string Name, string FullPath, bool IsDirectory, RemoteMoveKind Kind) { /// Whether this is the rename question rather than the move one. internal bool IsRename => Kind is RemoteMoveKind.Rename; /// The heading, which names the thing rather than only the verb. internal string Question => IsRename ? $"Rename '{Name}' on the host" : $"Move '{Name}' to another directory"; /// /// What an empty box shows, which is the shape of the answer rather than an example of one. /// /// /// The placeholder is the whole of what says which of the two kinds of answer the box wants — there is /// no label above it. A field label would be a second line saying what this line already says, and the /// pane this form opens in is 204 pixels wide inside the session shell: every line it does not need is /// a line of the listing underneath that survives. /// internal string FieldHint => IsRename ? "new name" : "/path/to/directory"; /// What the button that commits it says. internal string ConfirmLabel => IsRename ? "RENAME" : "MOVE"; /// /// What it does, in the one sentence the form has room for. /// /// /// Both halves state the refusal rather than the action, because the action is already in the heading /// and the refusal is the part nobody expects: SFTP's rename does not replace, so a name already taken /// is a failure rather than a silent overwrite — which is the same promise the transfer queue makes and /// rests on the same server behaviour. /// internal string Note => IsRename ? "A name already taken is refused, not overwritten." : "Relative to the directory showing. A name already taken is refused, not overwritten."; } /// /// A path on the host whose mode is being changed, and has not been committed yet. /// /// /// /// The shape and established — the full /// path is carried so that nothing which happens between opening the form and pressing its button can /// re-point the operation — and unlike those two it is mutable, because the answer is nine checkboxes /// rather than a typed string. /// /// /// ◆ Nine boxes rather than a box to type 755 into, and that is the whole design of this form. /// The mode is already drawn as -rwxr-xr-x in the column beside it, so a grid of who-can-do-what is /// the same representation the reader is already looking at; an octal field would ask them to translate a /// thing they can see into a thing they cannot check. The octal is printed underneath as it changes, which /// is what makes the two readings verifiable against each other rather than a choice between them. /// /// /// The three bits above the nine are not here. PosixMode neither renders nor writes setuid, /// setgid or the sticky bit — see its own remark — so a form that offered them would be writing something /// the listing cannot show, and one that silently cleared them would be worse. What this sends is the nine /// bits and a documented zero above them. /// /// internal sealed partial class RemotePermissionsRequest : ObservableObject { internal RemotePermissionsRequest(string name, string fullPath, bool isDirectory, string mode) { Name = name; FullPath = fullPath; IsDirectory = isDirectory; Original = mode; var bits = PosixMode.ToOctal(mode); ownerRead = (bits & 0b100_000_000) != 0; ownerWrite = (bits & 0b010_000_000) != 0; ownerExecute = (bits & 0b001_000_000) != 0; groupRead = (bits & 0b000_100_000) != 0; groupWrite = (bits & 0b000_010_000) != 0; groupExecute = (bits & 0b000_001_000) != 0; othersRead = (bits & 0b000_000_100) != 0; othersWrite = (bits & 0b000_000_010) != 0; othersExecute = (bits & 0b000_000_001) != 0; } /// What the row is called, which is what the heading names. internal string Name { get; } /// Where it is, which is the end of the operation nothing may re-point. internal string FullPath { get; } /// Whether it is a directory, which changes what the execute bit is called. internal bool IsDirectory { get; } /// The mode as the listing found it, so the form can say what it is about to change. internal string Original { get; } [ObservableProperty] private bool ownerRead; [ObservableProperty] private bool ownerWrite; [ObservableProperty] private bool ownerExecute; [ObservableProperty] private bool groupRead; [ObservableProperty] private bool groupWrite; [ObservableProperty] private bool groupExecute; [ObservableProperty] private bool othersRead; [ObservableProperty] private bool othersWrite; [ObservableProperty] private bool othersExecute; /// The heading, which names the thing rather than only the verb. internal string Question => $"Permissions for '{Name}'"; /// The nine bits as they now stand, ready for the wire. internal int Mode => (OwnerRead ? 0b100_000_000 : 0) | (OwnerWrite ? 0b010_000_000 : 0) | (OwnerExecute ? 0b001_000_000 : 0) | (GroupRead ? 0b000_100_000 : 0) | (GroupWrite ? 0b000_010_000 : 0) | (GroupExecute ? 0b000_001_000 : 0) | (OthersRead ? 0b000_000_100 : 0) | (OthersWrite ? 0b000_000_010 : 0) | (OthersExecute ? 0b000_000_001 : 0); /// /// What the form is about to send, in both spellings at once. /// /// /// The ls rendering and the chmod number side by side, because the two audiences for this /// form are the person reading the column and the person who would otherwise have typed the number — /// and printing both is what lets either check the boxes they just ticked. /// internal string Preview => $"{Rendered} ({PosixMode.FormatOctal(Mode)})"; /// Whether anything has actually been changed, which is what the button is enabled on. /// /// A round trip that sets a mode to the mode it already has is not wrong, only pointless — and a button /// that stays live over a form nobody has touched invites exactly that. /// internal bool HasChanges => !string.Equals(Rendered, Original, StringComparison.Ordinal); /// What the execute column means here, which is not the same thing for a directory. /// /// On a directory the execute bit is permission to enter it — to resolve a name inside — and a /// column headed "exec" over a folder is the commonest misreading of a POSIX mode there is. It costs one /// word to say which is meant. /// internal string ExecuteLabel => IsDirectory ? "enter" : "exec"; private string Rendered => PosixMode.Format( IsDirectory ? SftpEntryKind.Directory : SftpEntryKind.File, OwnerRead, OwnerWrite, OwnerExecute, GroupRead, GroupWrite, GroupExecute, OthersRead, OthersWrite, OthersExecute); /// /// One hook for all nine, because the two things a view reads off this form — the preview line and /// whether the button is live — are functions of the whole mode rather than of any one bit. /// partial void OnOwnerReadChanged(bool value) => Restring(); /// partial void OnOwnerWriteChanged(bool value) => Restring(); /// partial void OnOwnerExecuteChanged(bool value) => Restring(); /// partial void OnGroupReadChanged(bool value) => Restring(); /// partial void OnGroupWriteChanged(bool value) => Restring(); /// partial void OnGroupExecuteChanged(bool value) => Restring(); /// partial void OnOthersReadChanged(bool value) => Restring(); /// partial void OnOthersWriteChanged(bool value) => Restring(); /// partial void OnOthersExecuteChanged(bool value) => Restring(); private void Restring() { OnPropertyChanged(nameof(Preview)); OnPropertyChanged(nameof(HasChanges)); } } /// /// A remote file that has been fetched, opened in a local editor, and is being watched for saves. /// /// /// /// ◆ The one thing this screen does that is neither a listing nor a transfer. Editing a config on a /// host is the commonest reason somebody opens a file browser and then gives up and opens a terminal, and /// the round trip it replaces is: download, find the file, open it, edit, save, come back, delete the old /// one because nothing here overwrites, upload, check the mode survived. /// /// /// It remembers what the server said when the copy was taken. Not to be clever about merging — /// nothing here merges — but so that a write-back can refuse when the file has moved underneath it. Two /// people editing one nginx.conf is the ordinary way a config gets silently reverted, and the only /// honest thing a client this thin can do is notice and ask. /// /// /// What the file is called, for the strip's heading. /// Where it came from, which is where a save goes back to. /// The temporary file the editor has open. This screen made it and will remove it. /// The mode it had, so a write-back can put it back. Zero when there was none to read. internal sealed record RemoteEditSession( string Name, string RemotePath, string LocalCopy, int Mode) { /// What the server says the file is, as of the last time this session looked. /// /// /// Length and timestamp rather than a digest, because a digest is a second full read of a file this /// client has no other reason to re-fetch. The pair catches every case anybody has actually run into — /// somebody else saving, a deploy replacing it, a process rewriting it — and misses only a change that /// preserved both, which SFTP gives no cheap way to see at all. /// /// /// ◆ Settable, and the record is mutated rather than replaced. It moves after every write-back, /// so the next save compares against what this one produced rather than against the original and /// reports its own change as somebody else's. Replacing the record with with would have been the /// tidier expression and is wrong: the watcher's callback closes over the instance it was started for /// and checks it is still the current one, so a new instance silently turns every save after the first /// into a no-op. /// /// internal (long Length, DateTimeOffset Written) Stamp { get; set; } /// The heading, which names the file rather than only the verb. internal string Question => $"Editing '{Name}'"; } /// /// The transfers screen: a host, two directory panes, and the queue between them. /// /// /// /// Its connection is its own. SSH.NET cannot open an SFTP subsystem on a transport that is already /// carrying a shell — see ISftpSession — so this screen authenticates separately, and connecting here /// is a deliberate act with its own button rather than something that happens because a terminal is open. /// The consequence a user sees is that the host records a second login, and that a host whose password is /// typed each time asks for it again here. /// /// /// It outlives a lock, as terminals do. This object is created once and the vault is attached to it /// on unlock and detached on lock, the same arrangement VaultKnownHostStore has and for the same /// reason: MainWindowViewModel.LockAsync argues that locking must not destroy work in flight, and a /// half-finished transfer is the clearest case of work in flight there is. What locking takes away is the /// host list — those are decrypted vault items — and not the connection or the queue. /// /// internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDisposable { private readonly ISftpSessionFactory sftp; private readonly FileTransferQueue queue; private readonly Action post; /// /// Opens a local path in whatever this machine opens that kind of file with, or null on a head that has /// no such notion. /// /// /// A delegate for the reason copyToClipboard is one: "open this with the default application" is /// a platform call, and the view model that decides when to make it has no business knowing /// how. Null is an honest answer rather than a gap — see . /// private readonly Func? openInEditor; /// /// Watches one local file and calls back when it changes, until the returned handle is disposed — or /// null on a head that cannot. /// /// /// ◆ Injected rather than a FileSystemWatcher built here, and that is what makes editing /// testable at all. A watcher is a platform object with its own thread, its own buffer and its own /// notion of when a save has finished; a test that waited on one would be measuring the operating /// system. What this view model is responsible for is what happens when a save arrives, and a /// fake that fires on demand is what lets that be asserted. /// private readonly Func? watchFile; /// The watcher for the file being edited, or null when nothing is. private IDisposable? editWatch; /// /// Local files that exist only so this queue could move them — see and /// . /// /// /// Compared case-insensitively because the paths come back through the queue's snapshots rather than /// straight from the caller, and a comparison that a casing round trip could break would leak a file /// per transfer on any head that ever normalises one. /// private readonly HashSet staged = new(StringComparer.OrdinalIgnoreCase); /// /// What to do with a completed download whose real destination this layer cannot write to. /// /// /// Keyed by transfer rather than by path so a retry keeps its delivery: the queue reuses the id, and a /// download that failed once and succeeded on the second attempt must still end up where the person /// pointed. See . /// private readonly Dictionary> deliveries = []; private VaultViewModel? vault; private VaultKnownHostStore? knownHosts; private IRemoteFileStore? session; private ConnectionRecorder? connectionLog; /// How a bucket is opened, or null in a build that was not given one. private IObjectStoreFactory? objectStores; /// The open SFTP connection, as the log will record it, or null when there is none. /// /// Held rather than rebuilt at close time, because by then the session is being disposed and the host /// row it came from may have been replaced by a background sync. The address is the one that was /// actually dialled, which is the whole point of capturing it at connect. /// private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected; /// /// When the open connection was made, for the v5b session shell's status bar — or null while nothing is /// connected. /// /// /// A read of rather than a field of its own: that tuple is already the one place /// this screen keeps "what is open and when it opened", written at the same two call sites — a host and a /// bucket — that set . A second field would be a second fact to keep in step with /// the first, for no reader that could not already reach it here. /// internal DateTimeOffset? ConnectedStartedAt => connected?.StartedAt; private bool disposed; /// Whether the remote listing on screen is known to be out of date. /// /// Set by and cleared by any listing of the directory it is about. /// Read back by , which exists so that the deferral this drives can be /// asserted on in the state where it defers — the moment a transfer lands over an open question is not /// one a test can otherwise see the inside of. /// private bool remoteListingIsStale; /// Opens SFTP sessions. /// Time source, for transfer rates and timestamps. /// /// Runs an action on the thread this view model's collections are read from. Defaults to the UI thread's /// dispatcher, which is the answer in every real head. /// /// A delegate rather than Dispatcher.UIThread reached directly, for the reason /// VaultViewModel's clipboard is one. Dispatcher.UIThread is process-wide and belongs to /// whichever thread touched it first, so a test that posts through it is asserting on a queue owned by /// some other test's thread — which passes or throws "the calling thread cannot access this object" /// depending on the order a runner happened to schedule its classes in. Running the action inline /// removes the thread from the question rather than making the test guess it right. /// /// /// /// Takes the user to where a bucket is made, or null in a head that has nowhere to take them. /// /// A delegate rather than this screen making one itself, and it is the same seam the vaults screen uses /// for the shell: a bucket is a keychain item, the editor that writes one belongs to the keychain screen, /// and duplicating it here would be a second form writing the same secret. What this screen owns is the /// knowledge that somebody standing on it with no buckets needs to be sent somewhere. /// /// /// /// internal TransfersViewModel( ISftpSessionFactory sftp, TimeProvider clock, Action? post = null, Action? addBucket = null, Func? openInEditor = null, Func? watchFile = null) { this.sftp = sftp; this.post = post ?? (action => Dispatcher.UIThread.Post(action)); this.addBucket = addBucket; this.openInEditor = openInEditor; this.watchFile = watchFile; // The supplier answers with whatever session is current at the moment a transfer starts, which is // what lets a queue survive a disconnect and reconnect without every queued row failing. queue = new FileTransferQueue(_ => Task.FromResult(RequireSession()), clock); queue.Changed += OnTransferChanged; // The three "is there anything in it" flags follow their collections rather than being raised by // hand at each of the eight places that add or clear a row. Subscribed for the life of this object, // which is the life of the process. RemoteEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRemoteEntries)); LocalEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasLocalEntries)); Transfers.CollectionChanged += (_, _) => { OnPropertyChanged(nameof(HasTransfers)); OnPropertyChanged(nameof(ActiveTransfersLabel)); }; // The fourth, and it follows a list this screen does not own: the buckets are refilled from the // vault on every synchronisation pass, so the invitation these flags decide between has to change // with them rather than at the moment somebody pressed something. That is the path that matters — // adding the first bucket happens on another screen, and this one has to notice when it comes back. Buckets.CollectionChanged += (_, _) => RaiseBucketState(); } /// Where a bucket is made, or null in a head with nowhere to send anybody. private readonly Action? addBucket; /// Goes to the keychain with the bucket editor open. /// /// The dead end this replaces was a real one: SELECT BUCKET opened a picker with nothing in it, and /// nothing anywhere on the screen said that a bucket is made on the keychain screen. Somebody who had /// come to S3 to add one had arrived at the place it is used and been shown no route to the place it is /// created. /// [RelayCommand] private void AddBucket() { // Closed first, so returning here from the keychain does not find a picker still open over a list // that has since gained the bucket it was empty of. IsChoosingRemote = false; addBucket?.Invoke(); } /// The hosts that can be connected to, which is the vault's list. /// /// A collection of its own rather than the vault's, because it is empty while locked and the vault's is /// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would /// be a second decrypted copy of a secret for no gain. /// internal ReplaceableCollection Hosts { get; } = []; [ObservableProperty] private HostRowViewModel? selectedHost; /// The buckets that can be browsed, which is the vault's list. /// internal ReplaceableCollection Buckets { get; } = []; [ObservableProperty] private ObjectStoreRowViewModel? selectedBucket; /// The host and bucket somebody chose, as opposed to whatever the pickers show this instant. /// /// re-finds the selection by id, and it runs on every change to the vault's /// lists — including the Clear() a reload starts with, at which point there is nothing to re-find /// and the picker's own selection becomes null. Re-finding from that null on the next Add moved the /// picker to the first host on every sync pass, a minute after somebody chose another one, and CONNECT then /// dialled the first host with the password typed for theirs. So the choice is remembered here, written /// only when a person changes the picker, and is what every refresh looks for. /// private Guid? chosenHost; /// private Guid? chosenBucket; /// Whether a selection change is putting things back, not a choice. private bool refreshingPickers; /// /// Whether there is a bucket to open at all. /// /// /// The question this screen used to answer by showing an empty combo box. A bucket is made on the /// keychain screen and nowhere else, and S3 is where somebody goes looking for it — so a picker with /// nothing in it and no sentence beside it was, from where the user was standing, an application with no /// way to add a bucket. See . /// internal bool HasBuckets => Buckets.Count > 0; /// Whether this screen is on S3 with a bucket to offer. /// /// The two flags are resolved here rather than combined in markup, because a binding cannot say "and" — /// and because the pair is one question with two answers, which is easier to keep straight in one place /// than in four IsVisible expressions that have to stay each other's opposites. /// internal bool ShowsBucketChoice => ShowsBucketPicker && HasBuckets; /// Whether this screen is on S3 with nothing to open yet. internal bool ShowsNoBuckets => ShowsBucketPicker && !HasBuckets; /// /// Which sort of remote the right-hand pane is about to open. /// /// /// /// Two buttons and a command rather than one picker holding both kinds, which is the opposite of what /// the host editor's authentication picker does — and the reason is that these two are not /// interchangeable the way a key and a password are. A host brings a password box, a host key prompt and /// a mismatch refusal with it; a bucket brings none of those and has no equivalent. One picker would /// mean a form whose surrounding half appears and disappears with the selection, which is a worse thing /// to look at than two clearly separate choices. /// /// /// Settable, and the markup binds buttons rather than a selector's selection, for the reason the /// keychain's categories do: a selection binding moves before a command could refuse it. /// /// [ObservableProperty] private RemoteKind remote; /// Whether the picker is showing hosts. internal bool ShowsHostPicker => Remote is RemoteKind.Host; /// Whether the picker is showing buckets. internal bool ShowsBucketPicker => Remote is RemoteKind.Bucket; /// /// Whether the picker is open, as opposed to the invitation that offers it. /// /// /// /// The desktop's connect bar is gone — a strip of controls across the top of a screen that is not /// connected to anything, asking a question the empty right-hand pane was already asking silently. What /// replaced it is the pane itself: an invitation where the listing would be, and this flag is the step /// between "connect to a host" and the picker that does it. /// /// /// Two steps rather than a picker sitting open, because the pane is the whole answer to "why is this /// half of the screen empty" and a combo box does not say that. The phone does not use this: its screen /// is one pane at a time, so the picker is what it shows before a connection exists. /// /// /// It is cleared by everything that changes what the picker would be picking — connecting, disconnecting, /// switching between SFTP and S3, and losing the vault — so an open form is never left over a pane that /// has moved on. See the property-changed hooks at the foot of this file. /// /// [ObservableProperty] private bool isChoosingRemote; /// /// What the button that opens the remote says. /// /// /// "Connect" is wrong for a bucket and worth not saying: S3 is request-per-operation, so nothing is /// connected and nothing stays open. A word that implied otherwise would make the absence of a /// DISCONNECT step look like a bug rather than the shape of the protocol. /// internal string ConnectLabel => Remote is RemoteKind.Bucket ? "OPEN" : "CONNECT"; /// /// The transfers screen's own box, and deliberately not the one on the hosts screen. This connection is a /// separate authentication, so a password typed to open a terminal has not been offered here — and a /// screen that quietly reused it would make a one-time password appear to work twice. /// [ObservableProperty] private string typedPassword = string.Empty; /// The host showing when was typed, which is the only one it is for. /// /// The picker can move without anybody touching it — a host deleted elsewhere falls back to the first — /// and the box does not empty when it does, because it cannot tell that from a reload refilling the list. /// So the password is checked against this at the moment it would be sent instead, and never goes to a /// host it was not typed for. /// private Guid? typedPasswordFor; partial void OnTypedPasswordChanged(string value) => typedPasswordFor = SelectedHost?.EntityId; /// /// It opens saying what the state is rather than what to do about it, and that is a v3 change the /// desktop forced. "Choose a host and connect to browse its files" was this line for two versions, and /// it was the only thing on the screen saying so — the connect bar it sat in had a picker and a button /// and no prose at all. The invitation that replaced the bar says it in a heading, a sentence and a /// button, so a status line repeating it underneath was the same instruction three times. /// [ObservableProperty] private string status = "Nothing is open yet."; [ObservableProperty] private bool isBusy; [ObservableProperty] private bool isConnected; /// /// Why the connection went away on its own, or null when it did not. /// /// /// /// ◆ Not the same state as "not connected", which is what this screen used to collapse it into. /// Nothing connected is where the screen starts and where DISCONNECT leaves it: an invitation, two empty /// panes, nothing to explain. This is a session that was working and stopped, which is worth three /// things the invitation is not — the sentence saying what happened, the listing left on screen so the /// user can see where they were, and a button that puts them back there. /// /// /// Set only by , which is the store's own event. An operation that /// merely failed does not set it: a permission denied on one path is not a connection, and a screen that /// said DISCONNECTED over a directory it simply may not read would be worse than one that said nothing. /// /// [ObservableProperty] private string? lostConnection; /// Whether the connection went away on its own and has not been re-established. internal bool IsConnectionLost => LostConnection is { Length: > 0 }; /// /// Whether the remote pane has a listing worth drawing, connected or not. /// /// /// ◆ The half of a lost connection the panes have to know about. Every "is there anything here" /// binding on both heads used to read , which was right while the only way to /// stop being connected was to press DISCONNECT — that empties the pane, so an invitation drawn over it /// covered nothing. A connection lost underneath somebody does not empty it, and an invitation over /// that would hide the one thing worth keeping: where they were, which is what the reconnect /// beside it is offering to put them back into. /// internal bool ShowsRemoteContents => IsConnected || IsConnectionLost; /// Whether the remote pane should offer to open something instead of listing one. /// The other side of , so the two can never be drawn at once. internal bool ShowsRemoteInvitation => !ShowsRemoteContents; /// /// What a reconnect would dial, captured at the moment the last connection was made. /// /// /// /// Captured rather than read off the pickers when the button is pressed, and for the reason /// is captured: the selection is a control somebody can move, and a reconnect /// that dialled whatever happened to be lit would send them to a different machine than the one whose /// listing is on the screen in front of them. /// /// /// The path is deliberately not in here. It would have to be kept up to date with every /// navigation, and there is already something that is: the pane itself. A lost connection leaves its /// listing on screen — see — so where to go back to is whatever /// still says, which is by construction the last directory that was actually /// reached rather than a copy of it that could disagree. /// /// private (RemoteKind Kind, Guid EntityId)? reconnectTo; /// /// Whether something is being dragged over the local pane, and whether it would be accepted. /// /// /// Two flags rather than one tri-state, because the markup binds visibility and Avalonia has no /// three-way binding — and because the refusing state is worth showing rather than merely not showing /// the accepting one. A pane that lights up nowhere while something is dragged over it reads as a /// window that has stopped responding. /// [ObservableProperty] private bool isLocalDropTarget; /// [ObservableProperty] private bool isRemoteDropTarget; /// [ObservableProperty] private bool isRemoteDropRefused; /// The account and endpoint actually dialled, once connected. [ObservableProperty] private string? connectedTo; /// /// The negotiated server-to-client cipher for the open SFTP session, for the v5b status bar — or null /// while nothing is connected, or while what is connected is a bucket rather than a host. /// /// /// A bucket is IRemoteFileStore with no SSH underneath it at all, so it has no cipher, no host key /// and no identity to name — leaves all three null rather than each reading /// as "not yet known", which is the meaning null already carries for a host that has not connected yet. /// Read off the concrete ISftpSession at connect time, the same moment is /// set, because itself is typed as IRemoteFileStore and does not carry it. /// [ObservableProperty] private string? connectedCipher; /// /// Whether there is a live SFTP connection this session would lose by dying — the phone's foreground- /// service question, not the desktop's. /// /// /// is already the fact that tells a host apart from a bucket, because only /// a host set it — a bucket is HTTP, per-request, and closes nothing a dying process would have kept /// open, so it answers false here even while is true. Android reads this to /// decide whether an idle Files screen with no transfer moving still needs the process kept alive; the /// desktop has no such question because nothing stops its process for having gone quiet. /// internal bool HasLiveFileSession => IsConnected && ConnectedCipher is not null; /// The accepted host key's algorithm, e.g. ssh-ed25519. See . [ObservableProperty] private string? connectedHostKeyAlgorithm; /// /// The display name of the key or credential that authenticated, or null when a typed password did, or /// null while nothing is connected. /// /// /// Threaded from 's own out parameter rather than /// re-resolved here: the label names a keychain item this screen has no authentication ladder of its own /// to climb, and a second lookup would be a second place for a stale binding to answer differently than /// the one that actually authenticated. /// [ObservableProperty] private string? connectedIdentityLabel; /// /// The paths pinned on the connected host, for the phone's Files-screen chip row — the desktop draws /// the same list in its QUICK ACCESS sidebar, over the terminal surface rather than this one. See /// for where a pin is actually added or removed; this is /// a read of what was already saved there. /// /// /// Captured at connect, the same moment is, rather than followed live off the /// host row's own PinnedPaths. A pin edited while this session stays open shows up on the next /// connect rather than mid-session — the same lag itself already carries for /// a relabel — because this screen reads the vault once, at the moment it dials, rather than staying /// wired to a collection it otherwise never has a reason to watch. A bucket has no pins at all: /// leaves this empty rather than reading as "not yet known", which is what /// empty already means for a host that connected with none pinned. /// internal ObservableCollection ConnectedPinnedPaths { get; } = []; /// Whether the connected host or bucket has any pins to draw as chips. /// /// A read of rather than an [ObservableProperty] of its own, /// so it is raised by hand at each of the three places that collection is repopulated or cleared — /// , and . /// internal bool HasConnectedPins => ConnectedPinnedPaths.Count > 0; [ObservableProperty] private HostKeyPresentation? pendingHostKey; [ObservableProperty] private string? hostKeyMismatch; internal bool HasPendingHostKey => PendingHostKey is not null; internal bool HasHostKeyMismatch => HostKeyMismatch is not null; /// Whether the chosen host will want something typed into the password box. /// /// The resolved binding, for the reason its counterpart on VaultViewModel gives: a host naming /// neither a key nor a credential used to mean "type one" and now means "take the group's". This screen /// has its own password box and so needs its own copy of the question — but it must give the same /// answer, or one screen would ask for a password the other knew was not wanted. /// internal bool SelectedHostAsksForAPassword => ShowsHostPicker && (SelectedHost is null || (!SelectedHost.Host.SignsInThroughSpelunk && SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword)); // ---- The remote pane ---- [ObservableProperty] private string remotePath = string.Empty; internal ObservableCollection RemoteEntries { get; } = []; internal ObservableCollection RemoteTrail { get; } = []; /// /// What is in the pane's path box, which is not where the pane is. /// /// /// /// A draft beside rather than the path itself made editable, and the two facts /// really are different: is the directory that was listed, and this is what /// somebody is halfway through typing. Binding the box straight to the path would navigate on every /// keystroke — /, then /v, then /va — which is a round trip and a failed listing /// per character. /// /// /// It is written back from , so every other way of moving — a breadcrumb, /// UP, a pin, connecting — puts the real path back in the box. What a failed listing leaves is what was /// typed, on purpose: the path that did not resolve is the one worth having in front of you to correct. /// /// [ObservableProperty] private string remotePathDraft = string.Empty; [ObservableProperty] private RemoteEntryRowViewModel? selectedRemoteEntry; /// What a new directory would be called, when the user is making one. [ObservableProperty] private string newRemoteFolder = string.Empty; internal bool HasRemoteEntries => RemoteEntries.Count > 0; /// The deletion on the host that has been asked about, or null when none has. [ObservableProperty] private RemoteDeletionRequest? pendingRemoteDeletion; internal bool IsConfirmingRemoteDeletion => PendingRemoteDeletion is not null; /// The rename or move being filled in, or null when none is. [ObservableProperty] private RemoteMoveRequest? pendingRemoteMove; /// /// What has been typed into whichever of the two forms is open — a name, or a directory. /// /// /// One box for both, because only one of the two forms is ever open: they replace each other, and a /// second field would be a second thing to clear at every place this one is. Seeded from the row when /// the form opens — the current name for a rename, the current directory for a move — so that both /// questions start from the answer that changes nothing, and what somebody edits is the part they came /// to change. /// [ObservableProperty] private string remoteMoveTarget = string.Empty; internal bool IsMovingRemote => PendingRemoteMove is not null; /// The mode being changed, or null when none is. [ObservableProperty] private RemotePermissionsRequest? pendingRemotePermissions; internal bool IsChangingRemotePermissions => PendingRemotePermissions is not null; /// The file being edited locally, or null when none is. [ObservableProperty] private RemoteEditSession? remoteEdit; /// Whether a file is open in a local editor and being watched. internal bool IsEditingRemote => RemoteEdit is not null; /// /// What the edit strip has to say — each save, most recent first, or why one did not go. /// /// /// A log rather than a single line, because the whole point of watching is that saving happens more than /// once and each one is a round trip somebody may want to see happened. Bounded: an editor that /// autosaves would otherwise grow this without limit for as long as the file is open. /// internal ObservableCollection RemoteEditLog { get; } = []; /// /// Set when the server's copy moved underneath the one being edited, and cleared by answering. /// /// /// The one state in which a save is not written back automatically. Two people editing one /// config is how a change gets silently reverted, and a client this thin cannot merge — what it can do /// is stop, say so, and offer the three answers that are actually available. /// [ObservableProperty] private string? remoteEditConflict; /// Whether the file being edited has moved on the host since the copy was taken. internal bool HasRemoteEditConflict => RemoteEditConflict is { Length: > 0 }; /// /// Whether EDIT is offered on the chosen row. /// /// /// Four conditions, and the last is the one worth naming: the head has to have given this screen a way /// to open a file and a way to watch it. The phone has neither — "the default application" is a chooser /// there and a file it hands to another app is a copy it does not get told about — so the button is /// simply not live on that head rather than there and disappointing. /// internal bool CanEditRemote => IsConnected && !IsAskingAboutRemoteEntry && SelectedRemoteEntry is { IsFile: true } && openInEditor is not null && watchFile is not null; /// /// Whether the pane is waiting on an answer about the selected row. /// /// /// The three item buttons and the automatic refresh all read this rather than the two flags separately. /// Deleting, renaming and moving are one at a time by construction — each form's own opening clears the /// other — and what this names is the state where none of them may be started and nothing may pull the /// listing out from under the question. See . /// internal bool IsAskingAboutRemoteEntry => IsConfirmingRemoteDeletion || IsMovingRemote || IsChangingRemotePermissions; /// How many saves the strip keeps before it starts dropping the oldest. /// /// Enough to see a session's worth of saves and short enough that an editor autosaving every thirty /// seconds cannot turn this into a memory leak with a scrollbar. /// private const int EditLogDepth = 20; /// Whether the pane's DELETE is live. /// /// Off while any of the three questions is up, so a second press cannot arm a second one behind the /// form — and disabled rather than hidden, because these buttons sit in a row and a gap where one was /// would move the other two out from under the pointer. /// internal bool CanDeleteRemote => IsConnected && !IsAskingAboutRemoteEntry; /// internal bool CanRenameRemote => CanDeleteRemote; /// internal bool CanMoveRemote => CanDeleteRemote; /// /// Whether the pane's PERMS is live. /// /// /// The three conditions the others have, and one more: a bucket has no mode to change. An object is /// governed by an access policy rather than nine bits — S3FileStore refuses this outright — so the /// button is dark on that destination rather than live and answered with a refusal. /// internal bool CanChangeRemotePermissions => CanDeleteRemote && Remote is RemoteKind.Host; /// /// Whether the pane shows the row of things that can be done to the chosen row. /// /// /// Gone while one of the three questions is up, rather than merely disabled, and the phone head does /// the same with its own buttons for the same reason: the form is the answer to the question those /// buttons ask, and a pane 204 pixels wide inside the session shell has to spend the height on one or /// the other. What is not on screen is not a control somebody can press by mistake either. /// internal bool ShowsRemoteItemActions => IsConnected && !IsAskingAboutRemoteEntry; /// Whether a transfer has landed in the directory showing and it has not been re-read yet. /// See for when that is allowed to be true for a while. internal bool RemoteListingIsStale => remoteListingIsStale; /// /// What the item row says it is about — the chosen row's name, or the sentence that says to choose one. /// /// /// A name rather than a full path, unlike the questions the buttons open: this line sits directly above /// the listing with the row it names highlighted in it, so the disambiguation a confirmation needs is /// already on screen. The forms carry the path, because by then the answer is being committed rather /// than merely looked at. /// internal string SelectedRemoteName => SelectedRemoteEntry?.Name ?? "Choose a row to act on it"; /// /// The rows a batch action would act on: whatever is ticked, or the selection when nothing is. /// /// /// /// ◆ The fallback is what keeps the one-file case exactly as it was. Ticking is for gathering /// several up; somebody who has clicked a row and pressed DOWNLOAD has not ticked anything and means /// that row. Without this the commonest use of the screen would have grown a step, which is the usual /// way a multi-select makes a tool worse. /// /// /// Read fresh each time rather than kept as a collection. The ticks live on the rows, the rows are /// replaced wholesale by every listing, and a second list to keep in step with that is a second thing to /// get wrong — see , which simply builds new rows. /// /// internal IReadOnlyList RemoteBatch { get { var ticked = RemoteEntries.Where(row => row.IsChecked).ToArray(); return ticked.Length > 0 ? ticked : SelectedRemoteEntry is { } row ? [row] : []; } } /// internal IReadOnlyList LocalBatch { get { var ticked = LocalEntries.Where(row => row.IsChecked).ToArray(); return ticked.Length > 0 ? ticked : SelectedLocalEntry is { } row ? [row] : []; } } /// How many rows are ticked on the host's side, or zero when none are. /// /// The tick count rather than 's, deliberately: this is what the strip prints /// beside SELECT ALL, and a screen that said "1 selected" over a listing with nothing ticked — because /// a row happened to be highlighted — would be describing a state the checkboxes visibly contradict. /// internal int CheckedRemoteCount => RemoteEntries.Count(row => row.IsChecked); /// internal int CheckedLocalCount => LocalEntries.Count(row => row.IsChecked); /// Whether anything is ticked on the host's side. internal bool HasCheckedRemote => CheckedRemoteCount > 0; /// internal bool HasCheckedLocal => CheckedLocalCount > 0; /// What the host pane's selection strip says, or empty when there is nothing to say. internal string CheckedRemoteLabel => CheckedRemoteCount switch { 0 => string.Empty, 1 => "1 selected", var many => $"{many} selected", }; /// internal string CheckedLocalLabel => CheckedLocalCount switch { 0 => string.Empty, 1 => "1 selected", var many => $"{many} selected", }; /// /// Ticks every row in the host's listing, or unticks them when they already all are. /// /// /// One command for both directions rather than two buttons, because "select all" and "select none" are /// the same gesture from the user's side — they look at the boxes and want the other state. Directories /// are ticked along with files; what happens to one is the batch action's business, and a select-all /// that quietly skipped rows would leave boxes unticked with no way to tick them. /// [RelayCommand] private void ToggleAllRemote() { var wanted = CheckedRemoteCount < RemoteEntries.Count; foreach (var row in RemoteEntries) { row.IsChecked = wanted; } RaiseBatchState(); } /// [RelayCommand] private void ToggleAllLocal() { var wanted = CheckedLocalCount < LocalEntries.Count; foreach (var row in LocalEntries) { row.IsChecked = wanted; } RaiseBatchState(); } /// /// Notices a row being ticked, so the counts and the buttons that read them keep up. /// /// /// Called by the views from the checkbox itself rather than subscribed per row. Each listing replaces /// every row it has, so a per-row subscription would be one to attach and detach on every navigation — /// and the thing being counted is a property of the collection rather than of any row, which is why the /// collection's owner is what raises it. /// internal void RemoteChecksChanged() => RaiseBatchState(); /// internal void LocalChecksChanged() => RaiseBatchState(); /// Raises everything that reads the ticks, on both panes. /// /// Both panes together, because the six properties are cheap to re-read and a caller that raised one /// pane's would eventually be the caller that forgot the other's. /// private void RaiseBatchState() { OnPropertyChanged(nameof(CheckedRemoteCount)); OnPropertyChanged(nameof(CheckedLocalCount)); OnPropertyChanged(nameof(HasCheckedRemote)); OnPropertyChanged(nameof(HasCheckedLocal)); OnPropertyChanged(nameof(CheckedRemoteLabel)); OnPropertyChanged(nameof(CheckedLocalLabel)); OnPropertyChanged(nameof(CanDownload)); OnPropertyChanged(nameof(CanUpload)); RaiseItemActionState(); } // ---- The local pane ---- [ObservableProperty] private string localPath = LocalDirectory.Home; internal ObservableCollection LocalEntries { get; } = []; internal ObservableCollection LocalTrail { get; } = []; /// /// The drives this machine has, as somewhere the local pane can jump to. /// /// /// The remote pane's breadcrumb reaches everywhere, because a POSIX filesystem has one root. This one /// does not: above C:\ is a list of drives rather than a directory, so without this the pane /// could be walked to the top of the drive it opened on and no further — and a file on D: would /// be unreachable from an application whose whole purpose on this screen is to move one. /// internal ObservableCollection LocalRoots { get; } = []; [ObservableProperty] private LocalEntryRowViewModel? selectedLocalEntry; internal bool HasLocalEntries => LocalEntries.Count > 0; // ---- The queue ---- internal ObservableCollection Transfers { get; } = []; /// /// Raised on the UI thread whenever a transfer appears or changes state, or a host or bucket connects or /// disconnects. /// /// /// For a head that has to tell the operating system what this process is doing — Android's foreground /// service, which must be up for as long as bytes are moving, or a host session sits open, and down /// afterwards. An event rather than letting that head watch itself: the /// collection announces rows arriving and leaving, and the transition that matters most is neither of /// those but a row going from RUNNING to DONE without moving. Connecting and disconnecting are the other /// two transitions the service cares about — see — and neither touches /// at all, so they need this same announcement made by hand. /// internal event EventHandler? ActivityChanged; /// How many transfers are moving or waiting to move. /// /// Queued counts as active. A queue with three files in it and one of them running is a process that /// must not be stopped, and the two that have not started yet are exactly the ones a stop would lose. /// internal int ActiveTransfers => Transfers.Count(row => row.IsRunning); /// /// The v5b TRANSFERS strip's own count chip: "N active", off . /// /// /// A string of its own rather than a converter in the markup, for the reason every other label in this /// class is one: the word belongs beside the number it is stated for, in one place, rather than composed /// out of a format string a screen's own XAML would otherwise have to carry. It reads "0 active" rather /// than hiding at zero — the chip only exists at all while is true, which a /// queue holding nothing but finished or failed rows still is, and "0 active" is the honest word for /// that shape rather than a chip claiming activity that has already finished. /// internal string ActiveTransfersLabel => string.Create( CultureInfo.InvariantCulture, $"{ActiveTransfers} active"); internal bool HasTransfers => Transfers.Count > 0; /// Whether a download of the chosen remote file would have somewhere to go. /// /// Any file in the batch is enough, rather than every one of them: a gathered-up set with a directory /// among it is the ordinary case, and the queue counts and reports what it skipped — see /// . A button dead until the set was perfectly clean would be refusing to /// start work it can plainly do most of. /// internal bool CanDownload => IsConnected && RemoteBatch.Any(row => row.IsFile); /// Whether an upload of the chosen local file would have somewhere to go. /// internal bool CanUpload => IsConnected && LocalBatch.Any(row => row.IsFile); /// Takes an unlocked vault, so the host list has something in it. /// The open keychain. /// The pins this screen's own trust decisions are written to. /// /// Where an SFTP session is recorded, or null to record none. Arrives here rather than being read off /// the vault, for the reason the recorder itself exists: it outlives the vault, and a session still open /// when the keychain locks still ends somewhere. /// internal void Attach( VaultViewModel openVault, VaultKnownHostStore hostKeys, ConnectionRecorder? log = null, IObjectStoreFactory? buckets = null) { vault = openVault; knownHosts = hostKeys; connectionLog = log; objectStores = buckets; // Followed rather than copied once, which is the difference between a picker that is right at unlock // and one that is right afterwards. A host or a bucket added on this machine — or pulled in by a // synchronisation pass from another — rebuilds the vault's collections, and a screen that had taken a // snapshot at unlock went on offering the list as it was when the vault opened. Detached again in // Detach: these collections belong to a vault that is about to be disposed. openVault.Hosts.CollectionChanged += OnVaultListChanged; openVault.ObjectStores.CollectionChanged += OnVaultListChanged; RefreshHosts(); // Read once per unlock rather than per navigation: a drive appearing while the application is open // is possible and rare, and probing every removable drive on every click into a folder is not. LocalRoots.Clear(); foreach (var root in LocalDirectory.Roots()) { LocalRoots.Add(new CrumbViewModel(RootChipName(root), root)); } RefreshLocalCommand.Execute(null); } /// What a root's chip in the pane header says: C:, /, ~, or a mount's name. /// /// /// A name, never a path — the full path is the chip's and its command /// parameter, and it stays there. This used to be root.TrimEnd(separator), which is a name only /// for a Windows drive: on Unix it made the / chip an empty pill and the home chip the entire /// home path, drawn at full width in a header column nothing bounds. A machine whose home directory sat /// deep enough — CI's per-job HOME is forty-six characters — had that one chip push the header's own /// buttons past the window's edge at the session shell's 472-pixel budget. /// /// /// ~ for home is the one substitution rather than a shortening: every shell a user of this /// application has ever typed into already means "my home directory" by it, which is exactly what the /// chip does when pressed. /// /// internal static string RootChipName(string root) { if (string.Equals(root, LocalDirectory.Home, StringComparison.Ordinal)) { return "~"; } var trimmed = root.TrimEnd(Path.DirectorySeparatorChar); if (trimmed.Length == 0) { // Unix's "/": trimming eats the whole string, and the root's name is the root itself. return "/"; } // A mount under /media or /run/media names itself by its last segment; a Windows drive ("C:") has // no file-name segment at all, and the trimmed root is already the two-character name it always had. var name = Path.GetFileName(trimmed); return name.Length == 0 ? trimmed : name; } /// /// Gives up the vault, keeping the connection and anything in flight. /// /// /// The host list goes because those rows carry decrypted secrets and the vault they came from is being /// disposed. The session and the queue stay, which is the whole point: see the remark on this type. /// internal void Detach() { if (vault is { } open) { open.Hosts.CollectionChanged -= OnVaultListChanged; open.ObjectStores.CollectionChanged -= OnVaultListChanged; } vault = null; knownHosts = null; Hosts.Clear(); Buckets.Clear(); SelectedHost = null; SelectedBucket = null; TypedPassword = string.Empty; // The picker with it. Its two lists have just been emptied, so leaving it open would show a form // offering a choice between nothing. IsChoosingRemote = false; } // ShowRemoteCommand was here, and it went with the toggle that invoked it. Which kind of remote this // screen offers is a destination now rather than a control on the screen — both heads reach it through // MainWindowViewModel.ShowFiles, which sets Remote directly because it has a refusal to make first. // Keeping the command would have left one nothing could invoke. /// Opens the picker, from the invitation in the empty remote pane. [RelayCommand] private void BeginChoosingRemote() => IsChoosingRemote = true; /// /// Puts the picker away without connecting. /// /// /// The typed password goes with it. It is a secret nobody asked to keep, and leaving it in the box would /// mean the next person to open the picker — for a different host, possibly — starts with somebody /// else's password already typed in. /// [RelayCommand] private void CancelChoosingRemote() { IsChoosingRemote = false; TypedPassword = string.Empty; } /// Opens the chosen remote, whichever kind it is. [RelayCommand] private Task ConnectAsync(CancellationToken cancellationToken) => Remote is RemoteKind.Bucket ? OpenBucketAsync(cancellationToken) : ConnectToHostAsync(cancellationToken); /// /// Opens the chosen bucket. /// /// /// /// No host key prompt, no password box, and no connect step: S3 is request-per-operation, so the factory /// only builds a client and the first listing is what actually tests the keys and the endpoint. That is /// why the failure this reports is a listing failure rather than a connection one — there is no /// connection to fail. /// /// /// It goes through the same session field, the same queue and the same panes as a host, because by this /// point it is an IRemoteFileStore like any other. Everything below this method was written for /// SFTP and needed no change. /// /// private async Task OpenBucketAsync(CancellationToken cancellationToken) { if (objectStores is not { } factory) { Status = "This build cannot open buckets."; return; } if (SelectedBucket is not { } row) { Status = "Choose a bucket first."; return; } PendingHostKey = null; HostKeyMismatch = null; await RunAsync( $"Opening {row.Label}…", async () => { await CloseSessionAsync().ConfigureAwait(true); session = factory.Open(row.Store); IsConnected = true; ConnectedTo = string.Create( CultureInfo.InvariantCulture, $"s3://{row.Store.Bucket}"); // No SSH underneath a bucket, so none of the three has an honest value — see // ConnectedCipher's own remark. ConnectedCipher = null; ConnectedHostKeyAlgorithm = null; ConnectedIdentityLabel = null; // And no pins either — see ConnectedPinnedPaths's own remark. ConnectedPinnedPaths.Clear(); OnPropertyChanged(nameof(HasConnectedPins)); connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow()); reconnectTo = (RemoteKind.Bucket, row.EntityId); Watch(session); await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true); Status = $"Opened {row.Label}."; }).ConfigureAwait(true); } /// Opens a file-transfer session on the chosen host. private async Task ConnectToHostAsync(CancellationToken cancellationToken) { if (vault is not { } open || SelectedHost is not { } row) { Status = "Choose a host first."; return; } var password = typedPasswordFor == row.EntityId ? TypedPassword : string.Empty; if (!open.TryBuildConnectionRequest( row.Host, password, out var request, out var identityLabel, out var refusal)) { Status = refusal; return; } PendingHostKey = null; HostKeyMismatch = null; await RunAsync( $"Connecting to {row.Label}…", async () => { await CloseSessionAsync().ConfigureAwait(true); if (await OpenHostSessionAsync(open, row, request, cancellationToken).ConfigureAwait(true) is not ({ } opened, { } dialled)) { return; } session = opened; TypedPassword = string.Empty; MarkHostConnected(opened, dialled, row, identityLabel); reconnectTo = (RemoteKind.Host, row.EntityId); Watch(opened); await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true); Status = $"Connected to {row.Label}."; }).ConfigureAwait(true); } /// /// Opens the session on a host, through the relay or Spelunk when the host uses one, or says why not. /// /// /// The session and the request it was opened with — which, for a Spelunk sign-in, carries the account /// Spelunk named rather than the one the host resolved to — or null with saying why /// there is none. /// private async Task<(ISftpSession Session, SshConnectionRequest Request)?> OpenHostSessionAsync( VaultViewModel open, HostRowViewModel row, SshConnectionRequest request, CancellationToken cancellationToken) { IConnectionRoute? route = null; try { // Through the relay or Spelunk for a host set to use one, exactly as a shell to it would be. // The session owns the route from here, on success and on failure alike. route = await open.OpenRouteAsync(row, cancellationToken).ConfigureAwait(true); var through = VaultViewModel.Through(request, route); return (await sftp.OpenSftpAsync(through, cancellationToken).ConfigureAwait(true), through); } catch (Exception exception) when (route?.Failure is { } failure && exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException)) { // See VaultViewModel.OpenThroughAsync: the route's reason is the true one. Status = failure; return null; } catch (SshHostKeyUnknownException exception) { // First contact, decided here rather than inherited from a terminal. File transfer is // its own connection, so it makes its own trust decision — and the pin it writes is the // same pin a shell would then find. PendingHostKey = exception.Presentation; Status = "This host has not been seen before."; return null; } catch (SshHostKeyMismatchException exception) { HostKeyMismatch = exception.Message; Status = "The host key has changed. Nothing was connected."; return null; } } /// /// Records that a host connection just succeeded: the address, the negotiated facts, and the ticket the /// disconnect log closes out later. /// /// /// Split out of for length rather than for reuse — /// sets the same four properties its own way, with no SSH underneath to read the last three off. Reads /// the cipher and host key off rather than , which is typed /// as IRemoteFileStore and does not carry either. /// private void MarkHostConnected( ISftpSession opened, SshConnectionRequest request, HostRowViewModel row, string? identityLabel) { IsConnected = true; ConnectedTo = string.Create( CultureInfo.InvariantCulture, $"{request.Username}@{request.Host}:{request.Port}"); ConnectedCipher = opened.Cipher; ConnectedHostKeyAlgorithm = opened.HostKey.Algorithm; ConnectedIdentityLabel = identityLabel; // See ConnectedPinnedPaths's own remark for why this is a snapshot rather than a live follow. ConnectedPinnedPaths.Clear(); foreach (var path in row.Host.PinnedPaths) { ConnectedPinnedPaths.Add(path); } OnPropertyChanged(nameof(HasConnectedPins)); // Recorded, and not hidden because it is "only" the file browser. Opening this is a second login as // far as the remote's own auth.log is concerned, so a log of ours that omitted it would disagree with // the host's — and anybody comparing the two would be right to believe the host. connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow()); // Raised here rather than from OnIsConnectedChanged, on purpose: IsConnected is set first, above, // and ConnectedCipher second — a partial method firing off the first assignment would read // HasLiveFileSession against a ConnectedCipher still holding whatever the previous session left // there. Only at the end of this method are both facts actually true together. OnPropertyChanged(nameof(HasLiveFileSession)); ActivityChanged?.Invoke(this, EventArgs.Empty); } /// Listens for the store losing its connection on its own. /// /// Nothing unsubscribes: the handler checks that the store raising it is still the one this screen holds, /// and a store this screen has replaced is disposed — which stops it raising anything anyway. That check /// is what a detach would buy, and having it means a session torn down mid-event cannot leave a /// subscription pointing at a screen that has moved on. /// private void Watch(IRemoteFileStore store) => store.ConnectionLost += (sender, _) => post(() => OnSessionConnectionLost(sender)); /// /// Says the connection went away, and keeps everything a reconnect will need. /// /// /// /// ◆ The listing stays on screen, and that is deliberate. is a record of /// what the server said rather than a handle on anything — its own remark says so — so the pane can go /// on showing where the user was after the session behind it has gone. Clearing it would take away the /// one thing that makes the reconnect below meaningful: they can see what they were doing, and the /// button puts them back in it. /// /// /// What does go is the session and the two open questions. A delete or a move left half-asked would /// commit against a store that is not there, and both are about a path this screen can no longer promise /// anything about. /// /// /// Nothing is done about transfers in flight. The queue's own row carries the failure with the server's /// words on it and keeps its part file, so a reconnect leaves RESUME meaning exactly what it meant /// before — see FileTransferQueue.Retry. Reaching in to restart them here would be this screen /// making a decision about somebody's file that they can make better themselves. /// /// private void OnSessionConnectionLost(object? sender) { if (!ReferenceEquals(sender, session)) { // A store this screen has already replaced or disposed. Its news is about a connection nobody is // looking at any more. return; } var reason = (session as ISftpSession)?.TransportFailure; session = null; IsConnected = false; LostConnection = reason is { Length: > 0 } said ? said : "The connection was lost."; PendingRemoteDeletion = null; PendingRemoteMove = null; PendingRemotePermissions = null; RemoteMoveTarget = string.Empty; // The ticket is closed here rather than left for the reconnect, because the session really did end // at this moment and a log written later would say it lasted until somebody noticed. CloseConnectionRecord(ConnectionOutcome.Closed); RaiseRemotePaneState(); OnPropertyChanged(nameof(HasLiveFileSession)); Status = LostConnection; ActivityChanged?.Invoke(this, EventArgs.Empty); } /// /// Opens the connection again, on the same remote, and goes back to the directory it was showing. /// /// /// /// It goes through rather than round it, so the host key gate, the refusals /// and the log entry are the same ones any other connection gets — a reconnect is a second login as far /// as the host is concerned, and one that skipped the checks would be a second connect path to get /// subtly wrong. What it adds either side is the two things that make it a reconnect: the /// remote is the one that was lost rather than whatever is lit, and the path is restored afterwards. /// /// /// A host that wants a password typed is asked for one rather than dialled without. The password /// is cleared the moment a connection succeeds and is not kept, so redialling with an empty box would be /// an authentication failure wearing the word RECONNECT. The picker comes up with the host already /// chosen, which is as far as this can honestly get on its own. /// /// [RelayCommand] private async Task ReconnectAsync(CancellationToken cancellationToken) { if (reconnectTo is not { } target) { Status = "There is nothing here to connect to again."; return; } // Read before anything below can clear it: connecting closes the old session first, and that is what // empties the pane this path is being taken from. var wasShowing = RemotePath; Remote = target.Kind; if (target.Kind is RemoteKind.Bucket) { SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == target.EntityId); if (SelectedBucket is null) { Status = "That bucket is not in this keychain any more."; return; } } else { SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == target.EntityId); if (SelectedHost is null) { Status = "That host is not in this keychain any more, so there is nothing here to dial again."; return; } if (SelectedHostAsksForAPassword && TypedPassword.Length == 0) { IsChoosingRemote = true; Status = $"{SelectedHost.Label} asks for a password. Type it here, then CONNECT."; return; } } await ConnectAsync(cancellationToken).ConfigureAwait(true); if (!IsConnected) { // Whatever refused it has already said so through RunAsync's own status. The pane keeps the // listing it had, so the button is still there to press again. return; } if (wasShowing is { Length: > 0 } && !string.Equals(RemotePath, wasShowing, StringComparison.Ordinal)) { // Back where they were, rather than in the home directory a fresh connect lands in. A failure // here — the directory has gone, or is no longer readable — is reported by GoRemoteAsync and // leaves the pane on the home directory, which is a place that exists. await GoRemoteAsync(wasShowing, cancellationToken).ConfigureAwait(true); } } /// /// Closes the file-transfer session. /// /// /// Refuses while the queue has work, rather than cancelling it. Disconnecting is a tidy-up and stopping /// a transfer is a decision about somebody's file; a button that did both would make the second one by /// accident. /// [RelayCommand] private async Task DisconnectAsync() { if (queue.IsBusy) { Status = "There are transfers still running. Stop them first, or let them finish."; return; } await CloseSessionAsync().ConfigureAwait(true); Status = "Disconnected. Anything already transferred is where it landed."; } /// Pins the offered host key and connects. [RelayCommand] private async Task TrustHostKeyAsync(CancellationToken cancellationToken) { if (PendingHostKey is not { } presentation || knownHosts is null) { return; } try { await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true); } catch (Exception exception) when (exception is not OperationCanceledException) { Status = $"The host key could not be stored, so nothing was connected: {exception.Message}"; return; } PendingHostKey = null; await ConnectAsync(cancellationToken).ConfigureAwait(true); } /// /// Dismisses whichever host key card is showing, without pinning or forgetting anything. /// /// /// One command for both cards, because both are dismissals and the two states are mutually exclusive. /// The mismatch card has nothing else it may offer: withdrawing a pin is a deliberate act performed in /// the host's editor, away from the moment of connecting, and a button here would be "continue anyway" /// with two clicks instead of one. /// [RelayCommand] private void RejectHostKey() { var wasOffered = PendingHostKey is not null; PendingHostKey = null; HostKeyMismatch = null; Status = wasOffered ? "The host key was not trusted, so nothing was connected." : "Nothing was connected."; } // ---- Navigation ---- /// Goes to a remote directory. [RelayCommand] private async Task GoRemoteAsync(string path, CancellationToken cancellationToken) { await NavigateRemoteAsync(path, cancellationToken).ConfigureAwait(true); } /// Goes up one remote directory. [RelayCommand] private async Task RemoteUpAsync(CancellationToken cancellationToken) { if (RemotePath.Length > 0) { await NavigateRemoteAsync(SftpPath.Parent(RemotePath), cancellationToken).ConfigureAwait(true); } } /// Re-reads the remote directory. [RelayCommand] private async Task RefreshRemoteAsync(CancellationToken cancellationToken) { if (RemotePath.Length > 0) { await NavigateRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true); } } /// /// Goes wherever the path box says. /// /// /// /// The way out of the breadcrumb's own limit: it can only offer what is under the directory already /// listed, so crossing the filesystem meant walking down to the root and back up again, a round trip a /// level. What is typed may be absolute, relative to the directory showing, or start at ~ — see /// , which is where all three become one absolute path. /// /// /// A blank box is not a request to go to the root. It puts the current path back and stays where it is: /// somebody who cleared the box and pressed Enter has not asked for anything, and / is a long way /// from where they were. /// /// /// Nothing is validated here beyond that. Whether the path exists, is a directory and may be read are /// three questions only the server can answer, and it answers all three at once by refusing the listing /// — which lands in the status line with the server's own words in it. /// /// [RelayCommand] private async Task GoToTypedRemotePathAsync(CancellationToken cancellationToken) { if (session is not { } open) { Status = "Connect to a host first."; return; } if (RemotePathDraft.Trim().Length == 0) { RemotePathDraft = RemotePath; return; } var from = RemotePath.Length > 0 ? RemotePath : open.HomeDirectory; await NavigateRemoteAsync( SftpPath.Resolve(from, RemotePathDraft, open.HomeDirectory), cancellationToken) .ConfigureAwait(true); } /// Puts the real path back in the box, for the Escape key. /// /// Half-typed text left in a box that says where you are is a box that is lying about where you are, and /// the pane underneath it goes on showing the directory it always was. Escape is the way out of that /// without having to remember what was in the box before. /// [RelayCommand] private void RevertTypedRemotePath() => RemotePathDraft = RemotePath; /// /// Opens whatever is selected in the remote pane, if it is somewhere to go. /// /// /// A symbolic link is tried as a directory: a listing carries lstat attributes, so a link to a /// directory reports as a link and resolving every one of them would be a round trip per row. The /// failure, when it is a link to a file, is the server's own and says so. /// [RelayCommand] private async Task OpenRemoteAsync(CancellationToken cancellationToken) { if (SelectedRemoteEntry is { IsNavigable: true } row) { await NavigateRemoteAsync(row.FullPath, cancellationToken).ConfigureAwait(true); } } /// Goes to a local directory. [RelayCommand] private void GoLocal(string path) => NavigateLocal(path); /// Goes up one local directory, as far as the top of the drive. /// /// Above a drive root there is no directory to list, so this stops there and says so. Getting to another /// drive is , which is a list of places rather than a step upwards. /// [RelayCommand] private void LocalUp() { if (LocalDirectory.Parent(LocalPath) is { } parent) { NavigateLocal(parent); return; } Status = "That is the top of this drive. Use the drive list to go to another one."; } /// Re-reads the local directory. [RelayCommand] private void RefreshLocal() => NavigateLocal(LocalPath); /// Opens whatever is selected in the local pane, if it is a directory. [RelayCommand] private void OpenLocal() { if (SelectedLocalEntry is { IsNavigable: true } row) { NavigateLocal(row.FullPath); } } // ---- Moving files ---- /// What a transfer asked for while another command is still running is told. private const string StillBusy = "Still working on the last request. Nothing was queued — try again when it has finished."; /// Queues the chosen remote file for download into the local directory showing. [RelayCommand] private void Download() { if (RemoteBatch is not { Count: > 0 } rows) { Status = "Choose a file on the host to download."; return; } _ = RunAsync( "Working out what to download…", async () => { await QueueDownloadsAsync(rows, CancellationToken.None).ConfigureAwait(true); ClearRemoteChecks(); }, StillBusy); } /// Queues the chosen local file for upload into the remote directory showing. [RelayCommand] private void Upload() { if (LocalBatch is not { Count: > 0 } rows) { Status = "Choose a file on this machine to upload."; return; } var paths = rows.Select(row => row.FullPath).ToArray(); _ = RunAsync( "Working out what to upload…", async () => { await QueueUploadsAsync(paths, CancellationToken.None).ConfigureAwait(true); ClearLocalChecks(); }, StillBusy); } /// Unticks everything on the host's side. /// /// Run after a batch has been queued, and after a listing replaces the rows. A gathered-up set is about /// one act — these files, into that directory — and leaving the boxes ticked afterwards is how the next /// press sends them all a second time. /// private void ClearRemoteChecks() { foreach (var row in RemoteEntries) { row.IsChecked = false; } RaiseBatchState(); } /// private void ClearLocalChecks() { foreach (var row in LocalEntries) { row.IsChecked = false; } RaiseBatchState(); } /// /// Queues every one of these local paths for upload into the remote directory showing. /// /// /// /// The one path both the button and a drop go through, so there is one set of rules about what can be /// queued rather than two that have to agree. The button hands it one path; a drop hands it however many /// were dragged, from this window's own pane or from the file manager. /// /// /// ◆ A directory is walked rather than skipped, which it used to be. The queue still moves one /// file at a time and knows nothing about trees — what happens here is that the tree is turned into /// files before it gets there, and the directories it needs are made on the host first. That keeps /// every property the queue has: each file is resumable on its own, each is written to a part file and /// renamed into place, and none of them can overwrite something already there. /// /// /// Reported per item, not per drop. The queue refuses to overwrite, so a drop of five files where /// two names already exist is three transfers and two refusals — and "the drop failed" would be wrong /// about all five. /// /// /// /// Starts an upload walk from something a drop handed over, and reports it through the busy funnel. /// /// /// ◆ A separate entry point because a drop cannot await. The walk became asynchronous when /// folders stopped being skipped — it lists directories over the wire and makes them on the host — and /// an Avalonia drop handler that awaited it would be an async void whose failures nothing /// catches. is where every other command on this screen puts exactly that /// problem: it owns the busy flag and turns a failure into a status line. /// internal void QueueUploads(IReadOnlyList paths) => _ = RunAsync( "Working out what to upload…", () => QueueUploadsAsync(paths, CancellationToken.None), StillBusy); /// internal void QueueDownloads(IReadOnlyList rows) => _ = RunAsync( "Working out what to download…", () => QueueDownloadsAsync(rows, CancellationToken.None), StillBusy); internal async Task QueueUploadsAsync(IReadOnlyList paths, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(paths); if (!IsConnected) { Status = "Connect to a host first."; return; } var plan = new TransferPlan(); foreach (var path in paths) { if (Directory.Exists(path)) { WalkLocal(path, SftpPath.Combine(RemotePath, Path.GetFileName(path)), plan); continue; } // Between the drag starting and the drop landing, a file can be moved or deleted — and the // paths in an OS drop come from another process, which is not obliged to be right about them. if (!File.Exists(path)) { plan.Missing++; continue; } plan.Files.Add((path, SftpPath.Combine(RemotePath, Path.GetFileName(path)))); } // ◆ Before anything is queued, and in shortest-first order so a parent exists before its child is // asked for. A transfer whose directory is not there fails at the part file and leaves a row saying // something about a path rather than about the folder that was dragged. foreach (var directory in plan.Directories.OrderBy(each => each.Length)) { if (!await EnsureRemoteDirectoryAsync(directory, cancellationToken).ConfigureAwait(true)) { plan.Refused++; } } foreach (var (local, remote) in plan.Files) { var file = new FileInfo(local); queue.Enqueue(TransferDirection.Upload, local, remote, file.Length, Preserved(file)); plan.Queued++; } Status = Describe(plan, "upload into", RemotePath); } /// /// Adds every file under a local directory to the plan, with the remote path each will land at. /// /// /// /// ◆ Reparse points are not followed, and that is a correctness rule rather than a nicety. A /// junction or a symlink pointing at an ancestor is a walk that never ends, and the first anybody would /// know of it is a queue filling for ever. is what /// rules it out, at the one place a decision is made about what to descend into. /// /// /// Empty directories are carried too. They are in the plan's directory set even when no file /// under them reaches the file list, because a build output tree with an empty logs/ in it is a /// tree that arrives wrong if the folder is silently dropped. /// /// private static void WalkLocal(string root, string destination, TransferPlan plan) { plan.Directories.Add(destination); var options = new EnumerationOptions { RecurseSubdirectories = true, AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = true, }; foreach (var directory in Directory.EnumerateDirectories(root, "*", options)) { plan.Directories.Add(RemoteFor(root, destination, directory)); } foreach (var file in Directory.EnumerateFiles(root, "*", options)) { if (plan.IsFull) { plan.Truncated = true; return; } plan.Files.Add((file, RemoteFor(root, destination, file))); } } /// Where a path under lands under . /// /// Split on both separators rather than on , and rejoined with /// / through : a remote path is POSIX whatever this client is running on, /// and a Windows relative path carrying backslashes into SftpPath.Combine would produce /// /srv/app\assets\main.js — one file with a backslash in its name rather than three /// directories deep. /// private static string RemoteFor(string root, string destination, string path) { var relative = Path.GetRelativePath(root, path); var walked = destination; foreach (var segment in relative.Split( ['/', '\\'], StringSplitOptions.RemoveEmptyEntries)) { walked = SftpPath.Combine(walked, segment); } return walked; } /// Makes one remote directory if it is not already there. /// Whether it exists now. /// /// Asked about before it is made rather than made and the failure swallowed. The two are not the same: /// a directory that is already there is the ordinary case of uploading into a tree that partly exists, /// and a mkdir refused for any other reason — no permission, a file of that name in the way — is /// a thing the person who dragged the folder needs told about. /// private async Task EnsureRemoteDirectoryAsync(string path, CancellationToken cancellationToken) { var open = RequireSession(); try { if (await open.StatAsync(path, cancellationToken).ConfigureAwait(true) is not null) { return true; } await open.CreateDirectoryAsync(path, cancellationToken).ConfigureAwait(true); return true; } catch (Exception exception) when (exception is not OutOfMemoryException and not OperationCanceledException) { return false; } } /// /// Queues copies that were made for this upload and belong to nothing else, so they are deleted once /// the transfer no longer needs them. /// /// /// /// This exists for the phone, and the copy is not an implementation detail that could be avoided. /// Android hands a chosen document over as a content:// URI with no path behind it and no promise /// that the stream can be seeked — and this queue seeks, because an upload resumes from the byte the /// last attempt reached. So the head copies the document into the application's own cache first and /// hands over the copy, which is a real file that behaves like every other thing in this queue. /// /// /// Released on success and on discard, never on failure. A failed or stopped upload is offered a /// RESUME or a RETRY, and both read the local file again — deleting it at the moment it stopped would /// turn one visible failure into a second, stranger one. What is left after a failure is swept at the /// next launch by the head that made it, which is the only place that knows where it put it. /// /// internal void QueueStagedUploads(IReadOnlyList paths) { ArgumentNullException.ThrowIfNull(paths); foreach (var path in paths) { staged.Add(path); } _ = RunAsync( "Working out what to upload…", () => QueueUploadsAsync(paths, CancellationToken.None), StillBusy); } /// /// Queues one download into a local file this application made, and hands the finished bytes to /// something that knows where they were really meant to go. /// /// /// /// The mirror of , and it exists for the same reason. A phone has /// no directory a download could simply be written into: what the person chose is a document handed back /// by the system's save picker, which this layer cannot open and the queue could not resume against. So /// the transfer runs into the cache like any other, and — supplied by the head /// that raised the picker — copies the result out once there is a result to copy. /// /// /// The destination is chosen before the transfer starts, not after. A picker raised on completion /// would arrive minutes later over whatever the person had moved on to, and on a phone it would often /// arrive while the application is in the background, where Android will not show it at all. The cost is /// stated where a person will meet it: the save picker creates the document when it is dismissed, so a /// download that then fails leaves an empty file where it was pointed. /// /// /// Delivery failure does not delete the bytes. They were fetched over somebody's network and the /// staged copy is all that is left of them; it stays for the next launch's sweep rather than being /// thrown away at the one moment it is worth the most. /// /// /// The remote file to fetch. /// Where to stage it — a path the head owns and will sweep. /// Copies the staged file to wherever it was really meant to go. internal void QueueDeliveredDownload( RemoteEntryRowViewModel row, string localPath, Func deliver) { ArgumentNullException.ThrowIfNull(row); ArgumentNullException.ThrowIfNull(deliver); if (!IsConnected) { Status = "Connect to a host first."; return; } if (!row.IsFile) { Status = "Only files can be transferred."; return; } staged.Add(localPath); deliveries[queue.Enqueue(TransferDirection.Download, localPath, row.FullPath, row.Entry.Length)] = deliver; Status = $"Queued {row.Name} for download."; } /// /// Fire-and-forget from the queue's own event, which cannot await: the transfer is over as far as the /// queue is concerned, and what is left is a copy this class owns and a callback the head gave it. The /// status line is the only report either way, which is the same place every other outcome on this screen /// is reported. /// private async Task DeliverAsync(string localPath, Func deliver) { var name = Path.GetFileName(localPath); try { await deliver(localPath).ConfigureAwait(true); Status = $"Saved {name}."; ReleaseStaged(localPath); } catch (Exception exception) when (exception is not OutOfMemoryException) { Status = $"{name} was downloaded but could not be saved where you chose: {exception.Message}"; } } /// /// The directory goes only if it is empty, and that is the whole of the safety here: staging puts one /// file in a directory of its own, so an empty parent is this transfer's and a parent with anything else /// in it is not something this method is entitled to reason about. Failures are ignored rather than /// reported — a cached copy that outlives its transfer is swept at the next launch, and there is nothing /// a person could do with the news. /// private void ReleaseStaged(string localPath) { if (!staged.Remove(localPath)) { return; } try { File.Delete(localPath); if (Path.GetDirectoryName(localPath) is { Length: > 0 } folder) { Directory.Delete(folder); } } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { } } /// Queues every one of these remote entries for download into the local directory showing. /// internal async Task QueueDownloadsAsync( IReadOnlyList rows, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(rows); if (!IsConnected) { Status = "Connect to a host first."; return; } var plan = new TransferPlan(); foreach (var row in rows) { if (LocalDirectory.ChildPath(LocalPath, row.Name) is not { } local) { plan.Unsafe++; continue; } if (row.IsFile) { plan.Downloads.Add((local, row.Entry)); continue; } if (row.Entry.Kind is SftpEntryKind.SymbolicLink) { // Not followed, for the reason WalkLocal skips a reparse point — and here there is a second // one: an lstat listing says a name is a link and does not say what it leads to, so // descending would mean a round trip per entry to find out, and a link pointing at an // ancestor is a walk with no end. plan.Links++; continue; } await WalkRemoteAsync( row.FullPath, local, plan, cancellationToken).ConfigureAwait(true); } EnqueueDownloads(plan); Status = Describe(plan, "download into", LocalPath); } /// Makes the directories a download plan needs here, then hands its files to the queue. private void EnqueueDownloads(TransferPlan plan) { foreach (var directory in plan.LocalDirectories.OrderBy(each => each.Length)) { try { Directory.CreateDirectory(directory); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { plan.Refused++; } } foreach (var (local, entry) in plan.Downloads) { queue.Enqueue( TransferDirection.Download, local, entry.FullPath, entry.Length, Preserved(entry)); plan.Queued++; } } /// Adds every file under a remote directory to the plan, with where each will land here. /// /// /// Breadth-first over ListAsync, which is a round trip per directory and nothing cleverer: SFTP /// has no recursive listing, so the tree has to be walked whatever this does. What it must not do is walk /// for ever — see the link rule below and . /// /// /// A directory that cannot be listed is counted and the walk carries on. Half a tree with a sentence /// saying which part was refused is worth more than nothing with the same sentence, and a permission /// somewhere deep is the ordinary reason a whole-folder download of somebody else's tree stops short. /// /// private async Task WalkRemoteAsync( string remote, string local, TransferPlan plan, CancellationToken cancellationToken) { var pending = new Queue<(string Remote, string Local)>(); pending.Enqueue((remote, local)); while (pending.Count > 0) { var (fromDirectory, toDirectory) = pending.Dequeue(); plan.LocalDirectories.Add(toDirectory); IReadOnlyList entries; try { entries = await RequireSession() .ListAsync(fromDirectory, cancellationToken) .ConfigureAwait(true); } catch (Exception exception) when (exception is not OutOfMemoryException and not OperationCanceledException) { plan.Refused++; continue; } foreach (var entry in entries) { if (plan.IsFull) { plan.Truncated = true; return; } PlanRemoteEntry(plan, pending, entry, toDirectory); } } } /// Adds one entry of a remote listing to a download plan. private static void PlanRemoteEntry( TransferPlan plan, Queue<(string Remote, string Local)> pending, SftpEntry entry, string toDirectory) { var here = entry.Kind is SftpEntryKind.File or SftpEntryKind.Directory ? LocalDirectory.ChildPath(toDirectory, entry.Name) : null; switch (entry.Kind) { case SftpEntryKind.File or SftpEntryKind.Directory when here is null: plan.Unsafe++; break; case SftpEntryKind.File: plan.Downloads.Add((here!, entry)); break; case SftpEntryKind.Directory: pending.Enqueue((entry.FullPath, here!)); break; case SftpEntryKind.SymbolicLink: plan.Links++; break; default: // A socket, a device, a pipe. There is nothing to copy and no local equivalent to create, and // counting it as a refusal would overstate what went wrong. plan.Skipped++; break; } } /// What an upload of this local file should carry to the host with 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-- — so an upload from there carries the timestamp alone and lands /// with whatever the server's umask gives it. 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. /// /// /// A bucket carries nothing: it has neither to set, and asking would be a refusal per transfer. See /// S3FileStore. /// /// private TransferAttributes? Preserved(FileInfo file) { if (Remote is not RemoteKind.Host) { return null; } int? mode = null; if (!OperatingSystem.IsWindows()) { mode = PosixMode.Sanitise((int)file.UnixFileMode); } // Stated as UTC rather than left to an implicit conversion, which would read the local machine's // offset off a DateTime that already is UTC and move the timestamp by it. return new TransferAttributes( mode, new DateTimeOffset(file.LastWriteTimeUtc, TimeSpan.Zero)); } /// What a download of this remote entry should carry to this machine with it. /// /// The other direction of , and the mode is passed whatever this /// platform is: the queue is what knows whether the local filesystem can be told one, and it drops the /// mode rather than failing where it cannot. See FileTransferQueue.ApplyLocally. /// private TransferAttributes? Preserved(SftpEntry entry) => Remote is RemoteKind.Host ? new TransferAttributes(PosixMode.ToOctal(entry.Permissions), entry.LastWriteTimeUtc) : null; /// /// What a walk found, before any of it is handed to the queue. /// /// /// ◆ Collected whole and then queued, rather than queued as it is found. The 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 — which needs the whole set before the first one is made. It also means a /// walk that is refused halfway has queued nothing yet, so the sentence it produces describes one /// outcome rather than an outcome and a queue that half happened. /// private sealed class TransferPlan { /// /// The most files one walk will collect. /// /// /// A backstop rather than a policy, and it is deliberately far above any tree somebody means to /// drag: what it exists for is the accident — a home directory, a mounted volume, the root — where /// the honest answer is to stop and say so rather than to fill a queue for a quarter of an hour. /// private const int Ceiling = 5000; /// Local path and its destination on the host, for an upload. public List<(string Local, string Remote)> Files { get; } = []; /// Where it will land here, and what the listing said it is, for a download. public List<(string Local, SftpEntry Entry)> Downloads { get; } = []; /// Directories to make on the host before the uploads go. public HashSet Directories { get; } = new(StringComparer.Ordinal); /// Directories to make here before the downloads land. public HashSet LocalDirectories { get; } = new(StringComparer.OrdinalIgnoreCase); /// How many were actually handed to the queue. public int Queued { get; set; } /// Paths that were gone by the time the walk reached them. public int Missing { get; set; } /// Directories the host or this machine would not let the walk have. public int Refused { get; set; } /// Symbolic links, which are counted rather than followed. public int Links { get; set; } /// Sockets, devices and pipes, which have nothing to copy. public int Skipped { get; set; } /// Remote names that would land outside the directory they were meant for. /// See . public int Unsafe { get; set; } /// Whether the walk stopped at rather than at the end of the tree. public bool Truncated { get; set; } /// Whether the ceiling has been reached. public bool IsFull => Files.Count + Downloads.Count >= Ceiling; } /// /// One sentence for both directions and every shape of partial success. What it must never do is stay /// silent about the difference: a drop of six that queued four and reported "queued 4" leaves somebody /// looking for the other two in a queue they are not in — and with folders walked rather than skipped, /// there are four more ways for a set to be partly what was asked for. /// private static string Describe(TransferPlan plan, string verb, string destination) { var files = plan.Queued == 1 ? "1 file" : $"{plan.Queued} files"; var said = plan.Queued == 0 ? "Nothing was queued." : $"Queued {files} for {verb} {destination}."; if (plan.Missing > 0) { var gone = plan.Missing == 1 ? "1 item was" : $"{plan.Missing} items were"; said += $" {gone} no longer there."; } if (plan.Refused > 0) { var refused = plan.Refused == 1 ? "1 directory" : $"{plan.Refused} directories"; said += $" {refused} could not be read or made."; } if (plan.Links > 0) { var links = plan.Links == 1 ? "1 link was" : $"{plan.Links} links were"; said += $" {links} left alone — a link is not followed."; } if (plan.Skipped > 0) { var other = plan.Skipped == 1 ? "1 entry was" : $"{plan.Skipped} entries were"; said += $" {other} not a file."; } if (plan.Unsafe > 0) { var named = plan.Unsafe == 1 ? "1 name points outside the folder and was" : $"{plan.Unsafe} names point outside the folder and were"; said += $" {named} left on the host."; } if (plan.Truncated) { said += " The walk stopped at 5000 files; queue the rest a folder at a time."; } return said; } /// Stops one transfer. [RelayCommand] private void CancelTransfer(TransferRowViewModel row) => queue.Cancel(row.Id); /// Runs a stopped transfer again, resuming where there is something to resume from. [RelayCommand] private void RetryTransfer(TransferRowViewModel row) => queue.Retry(row.Id); /// Removes one stopped transfer and whatever it left behind. [RelayCommand] private async Task DiscardTransferAsync(TransferRowViewModel row, CancellationToken cancellationToken) { // Only when the queue agreed. It refuses to discard a transfer that has not finished, and a row // removed anyway would take the only view of a transfer that was still running. if (await queue.DiscardAsync(row.Id, cancellationToken).ConfigureAwait(true)) { Transfers.Remove(row); // Discarding is the deliberate end of a stopped transfer — the row is gone and with it the // RESUME the staged copy was being kept for, and any delivery that was waiting on it. deliveries.Remove(row.Id); ReleaseStaged(row.Transfer.LocalPath); } } /// Clears the finished transfers, which have nothing left on disk. [RelayCommand] private void ClearCompleted() { queue.ClearCompleted(); foreach (var row in Transfers.Where(row => row.Transfer.State is TransferState.Completed).ToArray()) { Transfers.Remove(row); } } // ---- Changing the remote directory ---- /// Creates a directory on the host. [RelayCommand] private async Task CreateRemoteFolderAsync(CancellationToken cancellationToken) { var name = NewRemoteFolder.Trim(); if (name.Length == 0) { Status = "Type a name for the new directory."; return; } if (name.Contains('/', StringComparison.Ordinal)) { // One directory, whose parent must exist. A name with a separator in it would be a request to // create a path, and this button creates a directory in the one on screen. Status = "A directory name cannot contain '/'. Make one level at a time."; return; } await RunAsync( $"Creating {name}…", async () => { await RequireSession() .CreateDirectoryAsync(SftpPath.Combine(RemotePath, name), cancellationToken) .ConfigureAwait(true); NewRemoteFolder = string.Empty; await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true); Status = $"Created {name}."; }).ConfigureAwait(true); } /// /// Asks whether the chosen remote file, or empty directory, should go. /// /// /// Deleting on the host is offered because the queue refuses to overwrite: without a way to remove what /// is in the way, "that file is already there" would be a dead end. It is asked about first because of /// what it is — the only thing this application destroys that neither the server nor this machine has a /// copy of. /// [RelayCommand] private void DeleteRemote() { if (RemoteBatch is not { Count: > 0 } rows) { Status = "Choose something on the host to delete."; return; } // The others go, so no two forms can be on screen at once over one row. PendingRemoteMove = null; PendingRemotePermissions = null; PendingRemoteDeletion = new RemoteDeletionRequest( [.. rows.Select(row => new RemoteDeletionTarget(row.Name, row.FullPath, !row.IsFile))]); } /// /// Opens the mode of the chosen row for editing. /// /// /// ◆ The PERMS column stops being read-only here. 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 /// is opening a terminal to make the thing just uploaded executable — which is a whole second /// authenticated connection to set nine bits this one is already holding a session for. /// /// It starts from the mode the listing has rather than from a fresh stat. That listing is what the user /// is looking at and what they are about to change; a re-read would be a round trip to fetch a number /// already on screen, and a form that opened showing something different from the column beside it /// would be the worse of the two ways to be out of date. /// /// [RelayCommand] private void ChangeRemotePermissions() { if (SelectedRemoteEntry is not { } row) { Status = "Choose something on the host to change the permissions of."; return; } if (Remote is not RemoteKind.Host) { Status = "An object in a bucket has an access policy rather than a POSIX mode."; return; } PendingRemoteDeletion = null; PendingRemoteMove = null; PendingRemotePermissions = new RemotePermissionsRequest( row.Name, row.FullPath, !row.IsFile, row.Permissions); } /// /// Sends the mode that was ticked. /// /// /// It acts on the path the form named rather than on the selection, which is the promise the other two /// questions make and rests on the same reasoning: nothing between opening the form and pressing its /// button may re-point it. /// [RelayCommand] private async Task ConfirmRemotePermissionsAsync(CancellationToken cancellationToken) { if (PendingRemotePermissions is not { } request) { return; } var mode = request.Mode; await RunAsync( $"Setting permissions on {request.Name}…", async () => { await RequireSession() .SetPermissionsAsync(request.FullPath, mode, cancellationToken) .ConfigureAwait(true); PendingRemotePermissions = null; // Re-read, because the column beside the row has just stopped being true — and the server is // the only thing that knows what it actually settled on. A umask, an ACL or a filesystem // mounted without permissions can all land somewhere other than what was asked for. await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true); Status = $"{request.Name} is now {PosixMode.FormatOctal(mode)}."; }).ConfigureAwait(true); } /// Puts the mode form away without sending anything. [RelayCommand] private void CancelRemotePermissions() => PendingRemotePermissions = null; /// /// Fetches the chosen file, opens it in this machine's editor, and writes each save back. /// /// /// /// ◆ Not through the transfer queue, in either direction, and that is deliberate. 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. So this reads and /// writes the bytes itself. It is a config file: small, and worth neither a part file nor a resume. /// /// /// The copy is this screen's, and it is deleted when the session ends. It goes beside the ones /// the phone's document staging already makes, under a name derived from the remote path so that two /// files called config from different directories do not land on each other. /// /// [RelayCommand] private async Task EditRemoteAsync(CancellationToken cancellationToken) { if (openInEditor is not { } open || watchFile is not { } watch) { Status = "This build cannot open a file in an editor."; return; } if (SelectedRemoteEntry is not { IsFile: true } row) { Status = "Choose a file on the host to edit."; return; } await StopEditingAsync().ConfigureAwait(true); await RunAsync( $"Fetching {row.Name}…", async () => { var folder = Path.Combine( Path.GetTempPath(), "dodossh-edits", Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture)); if (LocalDirectory.ChildPath(folder, row.Name) is not { } copy) { Status = $"{row.Name} is not a name this machine can open as a file."; return; } Directory.CreateDirectory(folder); await FetchAsync(row.FullPath, copy, cancellationToken).ConfigureAwait(true); var editing = new RemoteEditSession( row.Name, row.FullPath, copy, PosixMode.ToOctal(row.Permissions)) { Stamp = (row.Entry.Length, row.Entry.LastWriteTimeUtc), }; RemoteEdit = editing; RemoteEditConflict = null; RemoteEditLog.Clear(); Note("opened, watching for saves"); // Watched before it is opened, so a save made the instant the editor comes up is not the one // that gets missed. editWatch = watch(copy, () => post(() => _ = SaveEditAsync(editing, force: false))); await open(copy).ConfigureAwait(true); Status = $"{row.Name} is open. Saves go back to the host."; }).ConfigureAwait(true); } /// Writes the edited copy back, unless the host's own has moved since it was fetched. /// /// /// The check is a stat and a comparison of length and timestamp against what the listing said when the /// copy was taken — see for why those two and not a digest. It is /// made before every save rather than once, because the whole risk this guards against is somebody else /// writing while this file is open. /// /// /// is the answer to that question and nothing else: it is set only by the /// button that says UPLOAD ANYWAY, over a strip that has just said what changed. /// /// private async Task SaveEditAsync(RemoteEditSession session, bool force) { if (!ReferenceEquals(session, RemoteEdit) || !IsConnected) { // A save arriving for a session that has been stopped, or after the connection went. The copy // is still on disk either way; what is gone is the right to write it back. return; } try { var open = RequireSession(); if (!force) { var current = await open.StatAsync(session.RemotePath, CancellationToken.None) .ConfigureAwait(true); if (current is null) { RemoteEditConflict = "It is no longer on the host."; Note("not saved — the file is gone"); return; } if (current.Length != session.Stamp.Length || current.LastWriteTimeUtc != session.Stamp.Written) { RemoteEditConflict = "The server's copy has changed since you opened it."; Note("not saved — it changed on the host"); return; } } var bytes = await File.ReadAllBytesAsync(session.LocalCopy, CancellationToken.None) .ConfigureAwait(true); await WriteBackAsync(open, session, bytes).ConfigureAwait(true); // The stamp moves to what this write produced — in place, on the session the watcher is holding. // See RemoteEditSession.Stamp. if (await open.StatAsync(session.RemotePath, CancellationToken.None).ConfigureAwait(true) is { } written) { session.Stamp = (written.Length, written.LastWriteTimeUtc); } RemoteEditConflict = null; Note($"saved → uploaded ({ByteSize.Format(bytes.Length)})"); } catch (Exception exception) when (exception is not OutOfMemoryException and not OperationCanceledException) { Note($"not saved — {exception.Message}"); } } /// Replaces the file on the host with the bytes that were just saved here. /// /// /// ◆ Truncated first, and that is not optional. OpenWriteAsync at offset zero writes over /// the beginning and leaves whatever tail is longer than the new content — 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. /// /// /// The mode goes back on afterwards, because deleting took it with it. Best-effort: a server that will /// not set one has still received the bytes, and failing the save over the nine bits would be the wrong /// half to insist on. /// /// private static async Task WriteBackAsync( IRemoteFileStore store, RemoteEditSession session, byte[] bytes) { await store.DeleteAsync(session.RemotePath, CancellationToken.None).ConfigureAwait(true); var writing = await store.OpenWriteAsync(session.RemotePath, 0, CancellationToken.None) .ConfigureAwait(true); await using (writing.ConfigureAwait(false)) { await writing.WriteAsync(bytes, CancellationToken.None).ConfigureAwait(true); } if (session.Mode == 0) { return; } try { await store.SetPermissionsAsync(session.RemotePath, session.Mode, CancellationToken.None) .ConfigureAwait(true); } catch (Exception exception) when (exception is not OutOfMemoryException and not OperationCanceledException) { // The bytes are there. See the remark above. } } /// Writes the save the conflict card asked about, over whatever is on the host now. [RelayCommand] private async Task ForceSaveEditAsync() { if (RemoteEdit is { } session) { await SaveEditAsync(session, force: true).ConfigureAwait(true); } } /// Fetches the host's copy again, over the local one the editor has open. /// /// The other honest answer to a conflict: throw away what was typed and start from what is there. It /// keeps the same temporary file so the editor does not need reopening — every editor worth the name /// notices its file changing underneath it and offers to reload. /// [RelayCommand] private async Task ReloadEditAsync(CancellationToken cancellationToken) { if (RemoteEdit is not { } session) { return; } await RunAsync( $"Fetching {session.Name} again…", async () => { await FetchAsync(session.RemotePath, session.LocalCopy, cancellationToken) .ConfigureAwait(true); if (await RequireSession().StatAsync(session.RemotePath, cancellationToken) .ConfigureAwait(true) is { } current) { session.Stamp = (current.Length, current.LastWriteTimeUtc); } RemoteEditConflict = null; Note("reloaded from the host"); }).ConfigureAwait(true); } /// Stops watching, and removes the copy this screen made. [RelayCommand] private Task StopEditingAsync() { editWatch?.Dispose(); editWatch = null; if (RemoteEdit is { } session) { RemoveEditCopy(session); } RemoteEdit = null; RemoteEditConflict = null; RemoteEditLog.Clear(); return Task.CompletedTask; } /// Reads a remote file into a local path, replacing whatever is there. private async Task FetchAsync(string remotePath, string localPath, CancellationToken cancellationToken) { var reading = await RequireSession() .OpenReadAsync(remotePath, 0, cancellationToken) .ConfigureAwait(true); await using var readingScope = reading.ConfigureAwait(false); var writing = new FileStream( localPath, FileMode.Create, FileAccess.Write, FileShare.None, 64 * 1024, useAsync: true); await using var writingScope = writing.ConfigureAwait(false); await reading.CopyToAsync(writing, cancellationToken).ConfigureAwait(true); } /// Removes the temporary copy and the directory made to hold it. /// /// Best-effort, and quietly: the editor may still have the file open, which on Windows means the delete /// fails — and a screen that reported that would be reporting on a file the user never asked to exist. /// What is left behind is one small file in the temporary directory the operating system clears. /// private static void RemoveEditCopy(RemoteEditSession session) { try { File.Delete(session.LocalCopy); if (Path.GetDirectoryName(session.LocalCopy) is { Length: > 0 } folder) { Directory.Delete(folder); } } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { } } /// Adds a line to the edit strip's log, newest first and bounded. private void Note(string what) { RemoteEditLog.Insert(0, what); while (RemoteEditLog.Count > EditLogDepth) { RemoteEditLog.RemoveAt(RemoteEditLog.Count - 1); } } /// /// Deletes what was agreed to. /// /// /// Not recursive, and the refusal comes from the server rather than from a check here — see /// ISftpSession.DeleteAsync. It acts on the path the question named rather than on the selection, /// which is what makes the question a promise: nothing between asking and answering can point it /// somewhere else. /// [RelayCommand] private async Task ConfirmDeleteRemoteAsync(CancellationToken cancellationToken) { if (PendingRemoteDeletion is not { } request) { return; } PendingRemoteDeletion = null; await RunAsync( request.IsSingle ? $"Deleting {request.First.Name}…" : $"Deleting {request.Count} items…", async () => { var open = RequireSession(); var deleted = 0; var refusals = new List(); foreach (var target in request.Paths) { // ◆ Each on its own, and a refusal does not stop the rest. The host turns down a // directory that still has something in it — which is the refusal this screen depends on // rather than checking for — and in a set of five that must not silently cancel the four // it would have removed. What it must also not do is hide which ones were refused. try { await open.DeleteAsync(target.FullPath, cancellationToken).ConfigureAwait(true); deleted++; } catch (Exception exception) when (exception is not OutOfMemoryException and not OperationCanceledException) { refusals.Add($"{target.Name}: {exception.Message}"); } } ClearRemoteChecks(); await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true); Status = Deleted(deleted, refusals); }).ConfigureAwait(true); } /// What a batch delete has to say for itself, however mixed the outcome was. /// /// One sentence for every shape of it, and what it must never do is stay quiet about the difference: a /// set of five that removed three and reported "deleted 3" leaves somebody looking for two files that /// are still there, with nothing saying why. /// private static string Deleted(int deleted, List refusals) { var said = deleted switch { 0 => "Nothing was deleted.", 1 => "Deleted 1 item.", var many => $"Deleted {many} items.", }; return refusals.Count == 0 ? said : $"{said} {string.Join(" ", refusals)}"; } /// Thinks better of it. /// /// Settles the listing on the way out. A transfer that landed while the question was up left the pane /// out of date and was not allowed to refresh it — see — so /// dismissing the question is the moment that refresh becomes safe. /// [RelayCommand] private async Task CancelDeleteRemoteAsync() { PendingRemoteDeletion = null; await SettleRemoteListingAsync().ConfigureAwait(true); } /// Opens the rename form on the chosen row. [RelayCommand] private void RenameRemote() => AskAboutMoving(RemoteMoveKind.Rename); /// Opens the move form on the chosen row. [RelayCommand] private void MoveRemote() => AskAboutMoving(RemoteMoveKind.Move); /// /// Both forms, because they differ only in what the box starts out holding and what the labels say — /// see . The rename starts from the current name and the move from the /// current directory, so each opens showing the answer that changes nothing and what gets edited is the /// half somebody actually came to change. /// private void AskAboutMoving(RemoteMoveKind kind) { if (SelectedRemoteEntry is not { } row) { Status = kind is RemoteMoveKind.Rename ? "Choose something on the host to rename." : "Choose something on the host to move."; return; } // The others go, so no two forms can be on screen at once over one row. PendingRemoteDeletion = null; PendingRemotePermissions = null; PendingRemoteMove = new RemoteMoveRequest(row.Name, row.FullPath, !row.IsFile, kind); RemoteMoveTarget = kind is RemoteMoveKind.Rename ? row.Name : SftpPath.Parent(row.FullPath); } /// /// Carries out whichever of the two was filled in. /// /// /// /// One SFTP rename underneath both, which is what the protocol offers and the whole of what a move is /// on one host. It follows that neither works across hosts and neither works across a filesystem /// boundary on the same host: the server refuses with EXDEV, and that refusal arrives in the /// status line as the server's own sentence rather than being pre-empted by a guess here about which of /// its mounts a path is on. /// /// /// It acts on the path the form named rather than on the selection, for the reason /// does: nothing between opening the form and pressing its button /// may re-point it at a different file. /// /// [RelayCommand] private async Task ConfirmMoveRemoteAsync(CancellationToken cancellationToken) { if (PendingRemoteMove is not { } request) { return; } if (!TryResolveMoveDestination(request, out var destination, out var refusal)) { // The form stays open. What was typed is what has to be corrected, and taking the form away // would take the box holding it as well. Status = refusal; return; } PendingRemoteMove = null; await RunAsync( request.IsRename ? $"Renaming {request.Name}…" : $"Moving {request.Name}…", async () => { await RequireSession() .RenameAsync(request.FullPath, destination, cancellationToken) .ConfigureAwait(true); await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true); Status = request.IsRename ? $"Renamed {request.Name} to {SftpPath.Name(destination)}." : $"Moved {request.Name} to {SftpPath.Parent(destination)}."; }).ConfigureAwait(true); } /// Thinks better of it. /// [RelayCommand] private async Task CancelMoveRemoteAsync() { PendingRemoteMove = null; RemoteMoveTarget = string.Empty; await SettleRemoteListingAsync().ConfigureAwait(true); } /// /// Works out where the typed answer actually points, or why it points nowhere. /// /// /// The three refusals here are the ones a server's own error message would not explain. A separator in /// a name is a move wearing a rename's clothing and the server would simply say "no such file" about a /// directory nobody meant to name; a destination equal to the source is a request that succeeds while /// doing nothing; and a directory moved inside itself is EINVAL, which is true and says nothing. /// Everything else — a path that is not there, a name already taken, a mount that cannot be crossed — /// is left to the server, which knows and this does not. /// private bool TryResolveMoveDestination( RemoteMoveRequest request, out string destination, out string refusal) { destination = string.Empty; var typed = RemoteMoveTarget.Trim(); if (typed.Length == 0) { refusal = request.IsRename ? "Type a new name for it." : "Type the directory to move it into."; return false; } if (request.IsRename) { if (typed.Contains('/', StringComparison.Ordinal)) { refusal = "A name cannot contain '/'. Use MOVE to put it in another directory."; return false; } destination = SftpPath.Combine(SftpPath.Parent(request.FullPath), typed); } else { var into = SftpPath.Resolve(RemotePath, typed, session?.HomeDirectory); destination = SftpPath.Combine(into, request.Name); } if (string.Equals(destination, request.FullPath, StringComparison.Ordinal)) { refusal = $"{request.Name} is already there."; return false; } if (request.IsDirectory && destination.StartsWith(request.FullPath + "/", StringComparison.Ordinal)) { refusal = "A directory cannot be moved inside itself."; return false; } refusal = string.Empty; return true; } /// public async ValueTask DisposeAsync() { if (disposed) { return; } disposed = true; queue.Changed -= OnTransferChanged; // The queue first: it holds the session, and a transfer still writing into a part file has to finish // unwinding before the transport under it goes. await queue.DisposeAsync().ConfigureAwait(false); if (session is not null) { await session.DisposeAsync().ConfigureAwait(false); session = null; } } /// /// Goes to a remote directory, on its own. /// /// /// Split from so that the commands which navigate as part of /// something else — connecting, making a directory, deleting one — can list without going back through /// the busy guard. returns immediately when a command is already running, so a /// nested call did nothing at all: the pane simply stayed empty after connecting, with no failure /// anywhere to explain it. /// private Task NavigateRemoteAsync(string path, CancellationToken cancellationToken) { if (session is null) { Status = "Connect to a host first."; return Task.CompletedTask; } return RunAsync($"Reading {path}…", () => ListRemoteAsync(path, cancellationToken)); } /// /// /// The selection survives if the row it was on does. Every listing replaces every row object, so /// keeping the object would keep a selection pointing at something no longer in the list; it is re-found /// by path instead. This matters because a listing is no longer only something somebody asked for — a /// finished upload refreshes this pane on its own, and a refresh that cleared the selection would take /// the row out from under whoever was about to press RENAME on it. /// /// /// The path box is written back here rather than by each caller, so that every way of moving — a crumb, /// UP, a pin, connecting, a rename that lands — leaves the box saying where the pane actually is. /// /// private async Task ListRemoteAsync(string path, CancellationToken cancellationToken) { var entries = await RequireSession().ListAsync(path, cancellationToken).ConfigureAwait(true); var wasSelected = SelectedRemoteEntry?.FullPath; RemotePath = path; RemotePathDraft = path; SelectedRemoteEntry = null; // Whatever made it stale, this is a fresh read of the very directory that was stale. remoteListingIsStale = false; RemoteEntries.Clear(); foreach (var entry in entries) { RemoteEntries.Add(new RemoteEntryRowViewModel(entry)); } RemoteTrail.Clear(); foreach (var (name, crumb) in SftpPath.Trail(path)) { RemoteTrail.Add(new CrumbViewModel(name, crumb)); } SelectedRemoteEntry = wasSelected is null ? null : RemoteEntries.FirstOrDefault( row => string.Equals(row.FullPath, wasSelected, StringComparison.Ordinal)); Status = string.Empty; } /// /// Re-reads the remote directory because something landed in it, if now is a moment to. /// /// /// /// An upload that finished is a listing that is wrong. The pane went on showing the directory as /// it was before the file arrived, and the only way to see the file that had just been sent was to press /// REFRESH — which is the application asking somebody to tell it about something it did itself. /// /// /// It waits rather than interrupting. Three states are not the moment: another operation is /// running, which would have two listings racing for the same collection; one of the three questions is /// on screen, and pulling the listing out from under a question about a row is how the row somebody /// answered about stops being the row that was acted on; or nothing is connected. In all three the /// staleness is remembered and settled later — at the end of the operation, or when the question is /// dismissed. /// /// /// It says nothing. Nobody asked for this listing, so it neither announces itself nor reports a /// failure: the status line at that moment is carrying the word about the transfer that just finished, /// which is the news. A refresh that failed leaves the pane as it was and the staleness set, so the next /// settling tries again. /// /// internal async Task SettleRemoteListingAsync() { if (!remoteListingIsStale || !IsConnected || IsBusy || IsAskingAboutRemoteEntry || RemotePath.Length == 0) { return; } var said = Status; IsBusy = true; try { await ListRemoteAsync(RemotePath, CancellationToken.None).ConfigureAwait(true); Status = said; } catch (Exception exception) when (exception is not OutOfMemoryException) { remoteListingIsStale = true; } finally { IsBusy = false; } } /// /// Notices that a finished transfer changed a directory that is on screen. /// /// /// Both panes, because both go stale the same way and for the same reason — an upload lands on the host /// and a download lands on this machine, and whichever pane is showing that directory is now showing it /// as it was a moment ago. Only the directory actually on screen: a transfer into somewhere else changed /// nothing anybody is looking at, and re-listing for it would be a round trip per completed transfer in /// a queue that may be moving a hundred files elsewhere. /// internal void NoteTransferLanded(TransferSnapshot transfer) { if (transfer.Direction is TransferDirection.Upload) { if (string.Equals(SftpPath.Parent(transfer.RemotePath), RemotePath, StringComparison.Ordinal)) { remoteListingIsStale = true; _ = SettleRemoteListingAsync(); } return; } // The local pane is a filesystem call rather than a round trip, so there is nothing to defer: it is // re-read where it stands. Compared case-insensitively because this end is Windows as often as not. if (string.Equals( Path.GetDirectoryName(transfer.LocalPath), LocalPath, StringComparison.OrdinalIgnoreCase)) { NavigateLocal(LocalPath); } } /// /// Synchronous, unlike its remote counterpart. A local directory listing is a filesystem call rather than /// a network round trip, and wrapping it in a task would put a state machine and a thread hop behind /// something that returns before the click has finished being handled. /// private void NavigateLocal(string path) { try { var entries = LocalDirectory.List(path); var wasSelected = SelectedLocalEntry?.FullPath; LocalPath = Path.GetFullPath(path); SelectedLocalEntry = null; LocalEntries.Clear(); foreach (var entry in entries) { LocalEntries.Add(new LocalEntryRowViewModel(entry)); } RebuildLocalTrail(); // Re-found by path rather than kept, for the reason ListRemoteAsync gives: a finished download // re-reads this pane on its own, and a refresh that cleared the selection would take the row // out from under whoever was about to send it somewhere. SelectedLocalEntry = wasSelected is null ? null : LocalEntries.FirstOrDefault( row => string.Equals(row.FullPath, wasSelected, StringComparison.OrdinalIgnoreCase)); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { // The pane stays where it was. A listing that failed leaves nothing to show, and emptying the // pane would look like a directory that had become empty. Status = $"{path} could not be read: {exception.Message}"; } } /// /// Built by walking up rather than by splitting on the separator, because a Windows path's first segment /// is C:\ — a root with a separator inside it, which splitting turns into a crumb called /// C: that navigates to the process's current directory on that drive rather than to its root. /// private void RebuildLocalTrail() { var crumbs = new List(); for (var walk = LocalPath; walk is not null; walk = LocalDirectory.Parent(walk)) { crumbs.Insert(0, new CrumbViewModel(Path.GetFileName(walk) is { Length: > 0 } name ? name : walk, walk)); } LocalTrail.Clear(); foreach (var crumb in crumbs) { LocalTrail.Add(crumb); } } /// /// Only the picker the list feeds. Refilling both on either list's change made every reload refill the host /// picker once for the hosts and again for the buckets. /// private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (vault is { } open && ReferenceEquals(sender, open.ObjectStores)) { RefreshPickers(hosts: false, buckets: true); } else { RefreshPickers(hosts: true, buckets: false); } } /// /// Rebuilds both pickers from the vault's lists. /// /// /// /// Called whenever either of those lists changes, not only at unlock — so a host created on the hosts /// screen, or a bucket added to the keychain, can be picked here without locking and unlocking first. /// /// /// The selection is re-found by id rather than kept. The vault replaces every row on every reload, /// which a synchronisation pass does once a minute, so holding the object would leave the picker showing /// nothing at all: the row it points at is no longer one of the items in the list. Re-finding it also /// means a host deleted elsewhere falls back to the first entry rather than to a selection that cannot be /// connected to. /// /// private void RefreshHosts() => RefreshPickers(hosts: true, buckets: true); private void RefreshPickers(bool hosts, bool buckets) { refreshingPickers = true; try { if (vault is not { } open) { Hosts.ReplaceAll([]); return; } if (hosts) { Hosts.ReplaceAll(open.Hosts); SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == chosenHost) ?? Hosts.FirstOrDefault(); } if (buckets) { Buckets.ReplaceAll(open.ObjectStores); SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == chosenBucket) ?? Buckets.FirstOrDefault(); } } finally { refreshingPickers = false; } } /// The session, or a failure a queue row can carry. private IRemoteFileStore RequireSession() => session ?? throw new InvalidOperationException( "This screen is not connected to a host, so there is nowhere to move the file."); /// Closes the connection log's open ticket, if there is one. /// /// Written whole rather than through an open/close ticket, because this connection is not one the /// terminal workspace ever knew about — it has no session id, and borrowing one would collide with a /// real terminal's. /// /// Its own method since a connection can now end two ways: closed by this screen, and lost. Both write /// one entry and neither may write two, which is what the null-out is for. /// /// private void CloseConnectionRecord(ConnectionOutcome outcome) { if (connected is not { } record) { return; } connected = null; connectionLog?.Record( record.Address, record.HostLabel, record.HostId, ConnectionKind.Sftp, record.StartedAt, TimeProvider.System.GetUtcNow(), outcome); } private async Task CloseSessionAsync() { if (session is { } open) { session = null; await open.DisposeAsync().ConfigureAwait(true); } CloseConnectionRecord(ConnectionOutcome.Closed); IsConnected = false; LostConnection = null; reconnectTo = null; ConnectedTo = null; ConnectedCipher = null; ConnectedHostKeyAlgorithm = null; ConnectedIdentityLabel = null; ConnectedPinnedPaths.Clear(); OnPropertyChanged(nameof(HasConnectedPins)); RemotePath = string.Empty; RemotePathDraft = string.Empty; RemoteEntries.Clear(); RemoteTrail.Clear(); SelectedRemoteEntry = null; // Both questions go with the session they were about, and the box either of them was filled in // from with them. A form left open over a pane that is no longer connected to anything would // commit against RequireSession's own refusal. PendingRemoteDeletion = null; PendingRemoteMove = null; PendingRemotePermissions = null; RemoteMoveTarget = string.Empty; remoteListingIsStale = false; // Same ordering reason as the raise at the end of MarkHostConnected: both properties this reads are // already null above, so the raise belongs after them rather than in OnIsConnectedChanged. This also // covers OpenBucketAsync, which calls this method first and never itself turns HasLiveFileSession on. OnPropertyChanged(nameof(HasLiveFileSession)); ActivityChanged?.Invoke(this, EventArgs.Empty); } /// /// The queue raises this from whichever thread its pump is on, so everything here is marshalled. The row /// is created on first sight rather than at enqueue time, which keeps one path for "a transfer changed" /// instead of one for the first change and one for the rest. /// private void OnTransferChanged(object? sender, TransferChangedEventArgs e) => post(() => { // Completed only, and the reason is in QueueStagedUploads: a stopped upload still has a RESUME // button that will read this file again. if (e.Transfer.State is TransferState.Completed) { // A staged download is not finished when the queue says so — it is finished when the bytes // reach the document the person picked, and only the head can put them there. So the copy // is released by the delivery rather than here, or it would be deleted on the way. if (deliveries.Remove(e.Transfer.Id, out var deliver)) { _ = DeliverAsync(e.Transfer.LocalPath, deliver); } else { ReleaseStaged(e.Transfer.LocalPath); } // Whichever pane the bytes landed in is showing that directory as it was before they did. NoteTransferLanded(e.Transfer); } if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing) { existing.Transfer = e.Transfer; } else { Transfers.Add(new TransferRowViewModel(e.Transfer)); } // Not covered by the CollectionChanged subscription above: a transfer that finishes changes which // rows count as active without the collection itself gaining or losing a row, so the strip's own // count chip needs its own raise here. OnPropertyChanged(nameof(ActiveTransfersLabel)); ActivityChanged?.Invoke(this, EventArgs.Empty); }); /// /// The same funnel VaultViewModel uses, and here for the same reason: every command on this screen /// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the /// pane permanently disabled. /// /// is for the commands somebody can fire while another is running — /// a drop, or the download and upload buttons pressed twice — which used to vanish without a word: the /// files were not queued and nothing said so. A command given one says why it did nothing instead. /// /// private async Task RunAsync(string busyMessage, Func work, string? refusedWhileBusy = null) { if (IsBusy) { if (refusedWhileBusy is not null) { Status = refusedWhileBusy; } return; } IsBusy = true; Status = busyMessage; try { await work().ConfigureAwait(true); } catch (OperationCanceledException) { Status = "Cancelled."; } catch (Exception exception) when (exception is not OutOfMemoryException) { Status = exception.Message; } finally { IsBusy = false; } // The one place every command on this screen passes through on its way out, which makes it the one // place a listing that went stale while it was running can safely be settled. await SettleRemoteListingAsync().ConfigureAwait(true); } partial void OnSelectedHostChanged(HostRowViewModel? value) { if (!refreshingPickers) { chosenHost = value?.EntityId; // A person moving to another host gets an empty box rather than one still showing a password // that, by typedPasswordFor, would no longer be sent. if (value is not null && typedPasswordFor is not null && value.EntityId != typedPasswordFor) { TypedPassword = string.Empty; } } OnPropertyChanged(nameof(SelectedHostAsksForAPassword)); } partial void OnSelectedBucketChanged(ObjectStoreRowViewModel? value) { if (!refreshingPickers) { chosenBucket = value?.EntityId; } } /// /// The password box follows this as well as the host, because it is shown only for a host that asks for /// one — and a bucket never does. Without this, switching to BUCKET would leave a password box beside a /// picker that has nothing to do with passwords. /// partial void OnRemoteChanged(RemoteKind value) { // PERMS is the one item action that is not offered on both destinations; see // CanChangeRemotePermissions. OnPropertyChanged(nameof(CanChangeRemotePermissions)); OnPropertyChanged(nameof(ShowsHostPicker)); OnPropertyChanged(nameof(ConnectLabel)); OnPropertyChanged(nameof(SelectedHostAsksForAPassword)); RaiseBucketState(); // Arriving at the other destination shows its invitation rather than a picker somebody left open on // this one — and the picker it would have left open is the wrong one, since the two kinds have // different lists behind them. IsChoosingRemote = false; } /// Raises the three flags that answer "is there a bucket to open". /// /// Together, because they are one fact read three ways and a caller that raised two of them would leave /// an invitation and an empty state on screen at once. /// private void RaiseBucketState() { OnPropertyChanged(nameof(ShowsBucketPicker)); OnPropertyChanged(nameof(HasBuckets)); OnPropertyChanged(nameof(ShowsBucketChoice)); OnPropertyChanged(nameof(ShowsNoBuckets)); } /// /// A fresh connection clears it, which is what puts the strip away — and it is here rather than in each /// connect path so that every way of connecting takes the last failure down with it. /// partial void OnIsConnectedChanged(bool value) { if (value) { LostConnection = null; } RaiseRemotePaneState(); OnPropertyChanged(nameof(CanDownload)); OnPropertyChanged(nameof(CanUpload)); RaiseItemActionState(); // Both ways, and the disconnecting half is the one worth stating: closing a session puts the pane // back to its invitation rather than to the form, so what the pane shows after a disconnect is the // same thing it showed before anything was ever connected. IsChoosingRemote = false; } /// /// Any change to the selection takes the question away, which is stricter than the vault's rule and can /// afford to be: this list is refilled only by a navigation or a refresh somebody asked for, so there is /// no background pass to pull a card out from under a reader. Listing and disconnecting both null the /// selection, so this one hook covers all three ways the answer could stop being about what was asked. /// partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value) { OnPropertyChanged(nameof(CanDownload)); OnPropertyChanged(nameof(CanEditRemote)); OnPropertyChanged(nameof(SelectedRemoteName)); PendingRemoteDeletion = null; PendingRemoteMove = null; PendingRemotePermissions = null; } partial void OnPendingRemoteDeletionChanged(RemoteDeletionRequest? value) { OnPropertyChanged(nameof(IsConfirmingRemoteDeletion)); RaiseItemActionState(); } partial void OnPendingRemoteMoveChanged(RemoteMoveRequest? value) { OnPropertyChanged(nameof(IsMovingRemote)); RaiseItemActionState(); } partial void OnPendingRemotePermissionsChanged(RemotePermissionsRequest? value) { OnPropertyChanged(nameof(IsChangingRemotePermissions)); RaiseItemActionState(); } partial void OnRemoteEditChanged(RemoteEditSession? value) { OnPropertyChanged(nameof(IsEditingRemote)); OnPropertyChanged(nameof(CanEditRemote)); } partial void OnRemoteEditConflictChanged(string? value) => OnPropertyChanged(nameof(HasRemoteEditConflict)); /// Raises the three flags that answer "what is in the remote pane". /// /// Together, because they are one fact read three ways — see — and a /// caller that raised some of them would leave an invitation drawn over a listing, or neither. /// private void RaiseRemotePaneState() { OnPropertyChanged(nameof(IsConnectionLost)); OnPropertyChanged(nameof(ShowsRemoteContents)); OnPropertyChanged(nameof(ShowsRemoteInvitation)); } /// Raises the flags the three item buttons and their two forms are drawn from. /// /// Together, because they are one fact — is there a question on screen, and is anything connected — /// read five ways, and a caller that raised some of them would leave a button live beside a form that /// says it is not. /// private void RaiseItemActionState() { OnPropertyChanged(nameof(IsAskingAboutRemoteEntry)); OnPropertyChanged(nameof(ShowsRemoteItemActions)); OnPropertyChanged(nameof(CanDeleteRemote)); OnPropertyChanged(nameof(CanRenameRemote)); OnPropertyChanged(nameof(CanMoveRemote)); OnPropertyChanged(nameof(CanChangeRemotePermissions)); OnPropertyChanged(nameof(CanEditRemote)); } partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) => OnPropertyChanged(nameof(CanUpload)); partial void OnPendingHostKeyChanged(HostKeyPresentation? value) => OnPropertyChanged(nameof(HasPendingHostKey)); partial void OnHostKeyMismatchChanged(string? value) => OnPropertyChanged(nameof(HasHostKeyMismatch)); }