Ask before deleting, and connect a host by double-clicking it

DELETE on a host, an SSH key, a stored password or a file on the host now puts
a question where the button was, and only answering it deletes anything. It is
a state rather than a dialog, which is the arrangement signing out already had
and for the same reason: this is the moment that has to be able to say what is
about to go before it goes.

What the question says is counted rather than generic, because a confirmation
that only asks whether you are sure is a click to train people out of. A key
names the hosts that authenticate with it and says they will refuse to connect
afterwards rather than falling back to a typed password, which is what the
connect path actually does. A host discloses a terminal open on it, because
deleting the host does not close the session. Every vault deletion says how far
it travels and whether this machine can push the tombstone yet or is queuing
it. Deleting on the host carries the strongest warning of the four on purpose:
everything else here is a tombstone against a copy the server still holds, and
a file on somebody's machine is bytes with nothing behind them — so that one
names the full path, since a bare name identifies nothing.

The armed request carries the item's entity id, so nothing that moves the
selection between the question and the answer can redirect it, and answering
about something that has since gone says so instead of doing nothing quietly.
Disarming compares ids rather than rows, which is the subtle half: a reload
replaces every row object, so the naive rule would have let the pass that runs
every minute take the card away from somebody halfway through reading it.

Forgetting a pinned host key is deliberately still unguarded. It costs one
fingerprint check on the next connection and it is the safe direction to be
wrong in — the dangerous button there is the one that adds trust, and that one
is already a prompt at connect time. Discarding a stopped transfer is likewise
unguarded: it removes a resumable part file and leaves the source alone.

Double-clicking a host in the sidebar connects to it, wired as a gesture in the
control exactly as the transfers screen opens a directory. CONNECT stays, since
it is the button with the password box beside it.

Ten existing delete call sites now go through arm-and-confirm helpers, and
eight new flow tests cover asking first, cancelling, the counted warning,
disarming on a selection change and on an editor opening, surviving a sync, and
the stale-item guard. Three layout tests measure the new shapes — the sidebar
card is the one card in the application a user cannot scroll — and one of them
also asserts the card renders its text, because a card whose compiled bindings
did not resolve would lay out perfectly as empty rows. The double-click test
performs the real gesture and proves it reached the connect command through a
refusal that never touches a network.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
853 tests, including the end-to-end suite against real containers.
This commit is contained in:
2026-07-31 11:52:13 +02:00
parent 240aadb746
commit 91438fb382
11 changed files with 979 additions and 48 deletions
+14 -2
View File
@@ -132,8 +132,9 @@ dotnet run --project src/DodoSSH.Client.App
In the app, enter `http://localhost:5233` as the server. Your browser opens for sign-in — the realm ships
`alice` / `alice` — then choose a vault passphrase and **write down the recovery code**, which cannot be
skipped and cannot be recovered from the server. You can then add a host and open a shell on it. Keycloak's
admin console is at `http://localhost:18080` (`admin` / `admin`).
skipped and cannot be recovered from the server. You can then add a host and open a shell on it — double-click
it in the sidebar, or select it and press **CONNECT**, which is the same command with the password box beside
it. Keycloak's admin console is at `http://localhost:18080` (`admin` / `admin`).
You can also add an SSH key, which is stored in the vault like a host and synced the same way: paste the
private key, then edit a host and pick that key from its **key** dropdown. From then on that host
@@ -146,6 +147,14 @@ the next sync. If a server is legitimately rebuilt and offers a new key, the con
with no way to continue from the warning — edit the host and choose **Forget host key**, which is deliberately
somewhere you have to go on purpose.
**Deleting asks first, and the question is worth reading.** DELETE on a host, an SSH key or a stored password
puts a question where the buttons were, and what it says is counted rather than generic: how many hosts
authenticate with the key about to go — they refuse to connect afterwards rather than falling back to a typed
password — whether a terminal is open on the host about to go, and whether this machine can push the deletion
yet or is queuing it. There is no undo, which is the other thing it says. Withdrawing host key trust is the
deliberate exception: it costs one fingerprint check on the next connection, and the dangerous button there is
the one that *adds* trust.
**Signing in once is enough.** The refresh token is kept in the local cache, sealed under the vault's own
key, so a later launch resumes the session itself and no browser opens — and because it is sealed under that
key, resuming can only happen *after* the vault is unlocked. A machine that unlocks with no network keeps
@@ -186,6 +195,9 @@ beside its destination and is renamed into place at the end, so an interrupted t
mistaken for a finished one — which matters most for what people actually use this for, which is copying a
build artefact onto a server and then running it. A destination that already exists is refused outright
rather than overwritten; the remote pane has **DELETE** and **MKDIR** so that refusal is not a dead end.
DELETE asks first and names the full path, and it carries the strongest warning in the application on
purpose: everything else DodoSSH deletes is a tombstone against a copy the server still holds, and a file on
somebody's host is bytes with nothing behind them.
**RESUME** on a stopped transfer carries on from what the part file already holds.
Resume works within a run of the application and not across a restart, and that limit is deliberate: nothing