Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f43436218a | ||
|
|
5b2e3f3651 | ||
|
|
988248fa57 | ||
|
|
902761d37b | ||
|
|
509937c4b8 | ||
|
|
5b32c76c95 | ||
|
|
6806e542f9 | ||
|
|
ad1a129159 | ||
|
|
e240c0cf26 | ||
|
|
96dd176121 | ||
|
|
367cebae9b | ||
|
|
c21de4ade4 |
No files matched your search
@@ -10,7 +10,7 @@
|
||||
"rollForward": false
|
||||
},
|
||||
"vpk": {
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.161",
|
||||
"commands": [
|
||||
"vpk"
|
||||
],
|
||||
|
||||
@@ -1221,27 +1221,55 @@ jobs:
|
||||
fi
|
||||
curl -fsS -X DELETE -H "$auth" "$api/tags/$TAG" >/dev/null 2>&1 || true
|
||||
|
||||
# vpk rather than curl, unlike the android job above, and the difference is what is being
|
||||
# uploaded. An APK is one file the client is told about by a manifest this repository writes; a
|
||||
# Velopack release is a set of files plus an index whose format is Velopack's own. Writing that
|
||||
# index by hand would be reimplementing the tool that is already here.
|
||||
# curl, like the android job above, and no longer `vpk upload gitea`. vpk's Gitea client is a
|
||||
# generated SDK that cannot read the forge's answer to "create a release" since its upgrade to
|
||||
# 28.0.0: the release is created and the client gets null back, so both 1.2.0 and 1.2.161 threw a
|
||||
# NullReferenceException before uploading anything, leaving an empty draft behind on every push.
|
||||
# Nothing here writes Velopack's index by hand: `vpk pack` already wrote releases.win-nightly.json
|
||||
# beside the packages, and with no earlier release downloaded into that directory it is exactly
|
||||
# the index `vpk upload` would have generated from it.
|
||||
#
|
||||
# --pre is load-bearing twice over. It keeps this out of the release channel, which refuses
|
||||
# A draft first, published only once everything is on it, which is the order vpk used too: the
|
||||
# client reads the index and then fetches what it names, and a published release missing a
|
||||
# package is a few seconds in which every desktop is told to download something that is not
|
||||
# there. The index goes up last for the same reason.
|
||||
#
|
||||
# prerelease is load-bearing twice over. It keeps this out of the release channel, which refuses
|
||||
# prereleases — and it keeps it out of `releases/latest`, which is what the *phone's* release
|
||||
# channel reads: a desktop nightly published as a stable release would become the newest release
|
||||
# in this repository and every phone on the release channel would start failing its update check
|
||||
# against a release carrying no android manifest.
|
||||
dotnet vpk upload gitea \
|
||||
--skip-updates \
|
||||
--repoUrl "$FORGE/$REPO" \
|
||||
--token "$TOKEN" \
|
||||
--outputDir "$RUNNER_TEMP/nightly-releases" \
|
||||
--channel win-nightly \
|
||||
--tag "$TAG" \
|
||||
--releaseName "Nightly desktop $VERSION" \
|
||||
--targetCommitish "$GITHUB_SHA" \
|
||||
--pre \
|
||||
--publish
|
||||
releases="$RUNNER_TEMP/nightly-releases"
|
||||
|
||||
created="$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
|
||||
-d "$(printf '{"tag_name":"%s","target_commitish":"%s","name":"Nightly desktop %s","draft":true,"prerelease":true,"body":"Built from %s by CI. Installs beside the release build, never over it."}' \
|
||||
"$TAG" "$GITHUB_SHA" "$VERSION" "$GITHUB_SHA")" \
|
||||
"$api/releases")"
|
||||
|
||||
draft="$(printf '%s' "$created" | release_id)"
|
||||
if [ -z "$draft" ]; then
|
||||
echo "Gitea accepted the release call and returned no id:" >&2
|
||||
printf '%s\n' "$created" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for file in "$releases"/*-full.nupkg "$releases"/*-Portable.zip "$releases"/*-Setup.exe \
|
||||
"$releases"/releases.win-nightly.json; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "vpk pack did not leave $file behind:" >&2
|
||||
ls -la "$releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
name="$(basename "$file")"
|
||||
echo "Uploading $name ($(wc -c < "$file") bytes)"
|
||||
curl -fsS -X POST -H "$auth" \
|
||||
-F "attachment=@$file" \
|
||||
"$api/releases/$draft/assets?name=$name" >/dev/null
|
||||
done
|
||||
|
||||
curl -fsS -X PATCH -H "$auth" -H 'Content-Type: application/json' \
|
||||
-d '{"draft":false}' \
|
||||
"$api/releases/$draft" >/dev/null
|
||||
|
||||
# Asked for back rather than assumed, and the android job's history is why: it once created a
|
||||
# release, uploaded nothing, and reported success for every upload it never made. A nightly
|
||||
@@ -1260,20 +1288,16 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
# ◆ THE RPM GOES ONTO THE RELEASE vpk JUST MADE, RATHER THAN ONTO ONE OF ITS OWN.
|
||||
# ◆ THE RPM GOES ONTO THE RELEASE JUST MADE, RATHER THAN ONTO ONE OF ITS OWN.
|
||||
#
|
||||
# One rolling release carrying both heads, because they are one nightly: the same commit,
|
||||
# the same version, and one page a person looks at to find out what is on main. A second tag
|
||||
# would double the delete-and-recreate dance this step already does, and would let the two
|
||||
# drift apart on a run where one of them failed.
|
||||
#
|
||||
# It has to happen here, after `vpk upload`, and not in a step before it. vpk deletes and
|
||||
# recreates this release, so an RPM uploaded first would be attached to something that no
|
||||
# longer exists by the time the job ends — silently, because the delete succeeds.
|
||||
#
|
||||
# curl rather than vpk, unlike the assets above, for the reason the android job gives about
|
||||
# its APK: this is one file the release simply carries. There is no index to write and no
|
||||
# format that belongs to a tool.
|
||||
# It has to happen here, after the Windows upload, and not in a step before it. This step
|
||||
# deletes and recreates the release, so an RPM uploaded first would be attached to something
|
||||
# that no longer exists by the time the job ends — silently, because the delete succeeds.
|
||||
release="$(printf '%s' "$published" | release_id)"
|
||||
if [ -z "$release" ]; then
|
||||
echo "The release was published and its id could not be read back:" >&2
|
||||
@@ -1608,6 +1632,112 @@ jobs:
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
run: docker logout "$REGISTRY"
|
||||
|
||||
# Rolls ssh.dodotech.cloud onto the image the job above just pushed, by calling the deploy hook of the
|
||||
# Spelunk stack that runs it. Without this nothing told the server a new image existed, so a commit with
|
||||
# a migration in it reached the registry and stopped there, and the schema never moved.
|
||||
#
|
||||
# The migration itself is applied by the new container as it starts — Database:AutoMigrate, see
|
||||
# Persistence.MigrateDodoDatabaseAsync — under an advisory lock, before its port opens. So triggering
|
||||
# the deploy is what applies it, and the wait below is what proves it: /healthz/ready is unhealthy for
|
||||
# as long as any migration the running image knows about is pending, and an image whose migration
|
||||
# failed does not start at all.
|
||||
#
|
||||
# main only, like the :main tag the stack follows. A failure here fails the run, unlike Spelunk's own
|
||||
# trigger: a server left behind its schema is the outage this job exists to prevent, and a green build
|
||||
# over one is the quiet version of it.
|
||||
#
|
||||
# SPELUNK_DEPLOY_TOKEN is a repository secret (Settings -> Actions -> Secrets), sent as a header so it
|
||||
# stays out of every proxy and runner log a URL would be written to.
|
||||
deploy:
|
||||
name: deploy ssh.dodotech.cloud
|
||||
needs: [image]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: [linux]
|
||||
timeout-minutes: 15
|
||||
# A newer push to main cancels this run like any other (see concurrency at the top). That is safe:
|
||||
# once the hook has answered, the rollout and its migration happen on the server, and the newer
|
||||
# run's own deploy job does the waiting instead.
|
||||
env:
|
||||
DEPLOY_HOOK: https://api.spelunk.dodotech.cloud/api/hooks/deploy
|
||||
SERVER: https://ssh.dodotech.cloud
|
||||
steps:
|
||||
# See the desktop job's step of the same name.
|
||||
- name: ensure curl
|
||||
run: |
|
||||
set -eu
|
||||
SUDO=""
|
||||
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "Installing curl"
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
$SUDO apk add --no-cache curl
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y --no-install-recommends curl
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
$SUDO dnf install -y curl
|
||||
else
|
||||
echo "No apk, apt-get or dnf here, so curl cannot be installed from inside the job." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
curl --version | head -1
|
||||
|
||||
- name: trigger the deploy
|
||||
env:
|
||||
SPELUNK_DEPLOY_TOKEN: ${{ secrets.SPELUNK_DEPLOY_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
if [ -z "${SPELUNK_DEPLOY_TOKEN:-}" ]; then
|
||||
echo "SPELUNK_DEPLOY_TOKEN is empty or unset, so the server was not told about this image." >&2
|
||||
echo "Add it under Settings -> Actions -> Secrets (not Variables)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
code="$(curl -sS -o "$RUNNER_TEMP/deploy-response" -w '%{http_code}' -X POST \
|
||||
-H "Authorization: Bearer $SPELUNK_DEPLOY_TOKEN" \
|
||||
"$DEPLOY_HOOK" || echo 000)"
|
||||
|
||||
case "$code" in
|
||||
200|202|204) echo "Deploy hook accepted (HTTP $code)." ;;
|
||||
401|404)
|
||||
echo "Deploy hook rejected the token (HTTP $code): revoked or regenerated, or the stack is gone." >&2
|
||||
exit 1 ;;
|
||||
409)
|
||||
echo "Deploy hook refused the token (HTTP 409): it is limited to a service the stack no longer has." >&2
|
||||
exit 1 ;;
|
||||
*)
|
||||
echo "Deploy hook answered HTTP $code:" >&2
|
||||
cat "$RUNNER_TEMP/deploy-response" >&2 || true
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
# Ten minutes, polled every ten seconds. A rollout restarts the container, so a few failed probes
|
||||
# in the middle are expected; what fails the job is never becoming ready again — a migration that
|
||||
# is still pending, one that crashed the start, or a stack holding the deploy for its schedule.
|
||||
- name: wait for the server to be ready
|
||||
run: |
|
||||
set -eu
|
||||
deadline=$(( $(date +%s) + 600 ))
|
||||
last=""
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
sleep 10
|
||||
last="$(curl -sS -m 10 -w ' (HTTP %{http_code})' "$SERVER/healthz/ready" 2>&1 || true)"
|
||||
case "$last" in
|
||||
"Healthy (HTTP 200)")
|
||||
echo "$SERVER is ready, with no migration pending."
|
||||
curl -sS -m 10 "$SERVER/api/v1/meta" || true
|
||||
echo
|
||||
exit 0 ;;
|
||||
esac
|
||||
echo "not ready yet: $last"
|
||||
done
|
||||
echo "$SERVER did not become ready within ten minutes. Last answer: $last" >&2
|
||||
echo "A pending migration is named in the container's log (PendingMigrations)." >&2
|
||||
exit 1
|
||||
|
||||
# No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
|
||||
# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
|
||||
# install directory and profile — and the distance between those two sentences is the whole design. What
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
constant rather than a setting: an operator who could answer the update check could pin a
|
||||
chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md.
|
||||
-->
|
||||
<PackageVersion Include="Velopack" Version="1.2.0" />
|
||||
<PackageVersion Include="Velopack" Version="1.2.161" />
|
||||
|
||||
<!--
|
||||
Microsoft's own TPM 2.0 stack, TSS.MSR, and the only way LinuxDeviceKeyStore reaches the chip.
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# Android design audit — 2026-10-02
|
||||
|
||||
Run on the emulator (`dodossh_api36`, Pixel 5 profile, 1080×2340 @ 440 dpi, Android 16). It used a
|
||||
standalone **Debug** APK from `main` at `858e362`, against a local API, Keycloak and a throwaway `sshd`.
|
||||
The walk went from a cleared install through sign-in, enrolment, recovery code, unlock, adding a host,
|
||||
connecting, host-key trust, terminal, and every Settings sub-page.
|
||||
|
||||
Screenshots are in [`android/`](android/), downscaled. The recovery-code screen can't be captured,
|
||||
because `FLAG_SECURE` blocks it, so it was reviewed from its markup and the accessibility tree.
|
||||
|
||||
**How to read the evidence.** "a11y tree" means `uiautomator dump`, which reads what TalkBack and any
|
||||
other accessibility service see. Times and hangs come from a Debug build on an emulator (JIT, no AOT),
|
||||
so treat any performance note as "confirm on Release".
|
||||
|
||||
---
|
||||
|
||||
## What works well
|
||||
|
||||
- **Connection progress card** ([11](android/11-connecting.png)): five named steps, the current one lit,
|
||||
with *close* and *logs* available while it's in progress.
|
||||
- **Host-key sheet** ([13](android/13-host-key.png)): "UNKNOWN HOST KEY", first contact, the algorithm,
|
||||
the full SHA256 fingerprint, and an instruction to compare. **TRUST AND CONNECT** and **CANCEL** are
|
||||
both full width.
|
||||
- **Recovery-code screen**:
|
||||
- no back and no skip;
|
||||
- **CONTINUE** stays disabled until the tick;
|
||||
- a full-width **COPY CODE**;
|
||||
- capture is blocked.
|
||||
- **Terminal** ([14](android/14-terminal.png)):
|
||||
- resizes above the keyboard;
|
||||
- the two-row accessory bar (esc, tab, ctrl, `| - ~ /`, arrows, A−/A+) stays reachable;
|
||||
- the open-sessions strip and the tab strip are clear.
|
||||
- **Busy states**: enrolment disables its fields and button and explains the wait ("deliberately takes a
|
||||
moment").
|
||||
- **Touch targets** measure 44 dp or more almost everywhere: checkboxes, buttons, rows.
|
||||
- **Back** closes sheets. The soft keyboard's ✓ submits single-field forms (unlock, connect).
|
||||
- **Copy is honest and specific**, e.g. "works with no network — the keychain decrypts on this phone",
|
||||
and the Vaults explanation.
|
||||
|
||||
---
|
||||
|
||||
## Critical / high
|
||||
|
||||
### H1 · Password fields expose their plaintext to accessibility services
|
||||
|
||||
- **What:** the vault passphrase field ([05](android/05-unlock-prefilled.png)) and the SSH password field
|
||||
([10](android/10-connect-sheet.png)) both report `password="false"` in the a11y tree. Their `text`
|
||||
attribute is the secret itself, in clear.
|
||||
- **Evidence:** the a11y tree on both screens; the values matched exactly what was typed (redacted here).
|
||||
- **Why it matters:** any installed app holding an accessibility service can read every passphrase and
|
||||
host password typed into DodoSSH. That's a common Android malware technique. TalkBack would also read
|
||||
the passphrase aloud character by character.
|
||||
- **Where:** `TextBox.secret` sets `PasswordChar` and `ContentType=Password`
|
||||
(`Theme/Phone.axaml:583`). That's correct for the input method, but Avalonia's Android automation peer
|
||||
ignores it.
|
||||
- **Fix:** give secret boxes an automation peer that reports `IsPassword` and returns no value. At
|
||||
minimum, override the exposed text on `TextBox.secret`. File it upstream with Avalonia as well. Check
|
||||
the desktop's UIA peer for the same leak.
|
||||
|
||||
### H2 · The passphrase survives enrolment and pre-fills the unlock box
|
||||
|
||||
- **What:** right after the recovery code, the unlock screen already holds the passphrase (16 dots;
|
||||
[05](android/05-unlock-prefilled.png)) beside "Unlock with the passphrase you just chose."
|
||||
- **Why:** `ConfirmRecoveryCode` clears `RecoveryCode` from memory on purpose ("keeping it in a view
|
||||
model … would undo that"), but `Passphrase` is never cleared after enrolment. Combined with H1, it's
|
||||
readable from the a11y tree on that screen. It also skips the one moment that proves the user
|
||||
remembers the passphrase.
|
||||
- **Fix:** clear `Passphrase` and its confirmation when enrolment completes, as the recovery code is
|
||||
cleared.
|
||||
|
||||
### H3 · A hardware Enter that submits the connect sheet leaves the window "not responding"
|
||||
|
||||
- **What:** I typed the password and pressed Enter (an injected `KEYCODE_ENTER`, which is what a
|
||||
Bluetooth or DeX keyboard sends). The connection started, but Android reported the window
|
||||
unresponsive. The key-up was never acknowledged (`InputDispatcher: Waited 5009ms for
|
||||
KeyEvent … ENTER … action=UP`). The ANR dialog then kept coming back
|
||||
([12](android/12-anr.png)) until the process was killed. Meanwhile the main thread was idle in
|
||||
`Looper.pollOnce`: it wasn't busy, the event was simply lost.
|
||||
- **Not affected:** tapping **CONNECT**, and the soft keyboard's ✓ (which sends an editor action, not a
|
||||
key event). Both connected cleanly.
|
||||
- **Likely cause:** focus moves to the terminal's native renderer (`Platform/RendererFocus.cs`) while
|
||||
the Enter key-up is still in flight.
|
||||
- **Repro:** host row → type password → hardware Enter.
|
||||
- **Confidence:** reproduced once, and an accessibility dump was running at the time. Confirm on a real
|
||||
device with a keyboard.
|
||||
|
||||
### H4 · Screen-reader names are type names across the app
|
||||
|
||||
The a11y tree announces these:
|
||||
|
||||
| Control | Announced as |
|
||||
|---|---|
|
||||
| Bottom nav (Hosts / Connections / Settings) | `Avalonia.Controls.Panel` ×3 |
|
||||
| Add-sheet rows, Settings rows, session chip | `Avalonia.Controls.StackPanel` / `Avalonia.Controls.Grid` |
|
||||
| Keychain filter chips | `Avalonia.Controls.StackPanel` ×5 |
|
||||
| Host list rows | `DodoSSH.Client.Shell.ViewModels.HostRowViewModel` |
|
||||
| Host editor dropdowns | `AuthenticationChoice { Kind = Typed, EntityId = , … }`, `GroupChoice { … }` |
|
||||
| Vault row | `VaultRowViewModel { VaultId = 01a0fe77-… }` (leaks an internal id) |
|
||||
| FAB / header add | `+` (no name) |
|
||||
| Decorative glyphs | `⌕ ▤ ⌗ ⚙ ⚿ ❯ ⇅ ◳ ≡ ◎ ›` exposed as text |
|
||||
|
||||
There are two further problems:
|
||||
- **Hidden content:** the hosts list stays in the tree under the full-screen host editor, so a
|
||||
screen-reader user can wander into a screen they can't see.
|
||||
- **Leftover desktop controls:** the desktop scrollbar's "Line up / Page down" buttons are exposed too.
|
||||
|
||||
**Fix:** set `AutomationProperties.Name` on every button whose content isn't a string. Give row
|
||||
templates a name, or override `ToString` on the row view models. Mark glyph `TextBlock`s
|
||||
`AutomationProperties.AccessibilityView="Raw"`. Hide covered content from the tree while an editor
|
||||
covers it.
|
||||
|
||||
### H5 · Copying the recovery code isn't marked sensitive
|
||||
|
||||
`CopyRecoveryCode` goes through Avalonia's plain `SetTextAsync` (`App.axaml.cs:187`). On Android 13+,
|
||||
the system shows a clipboard preview of the copied text. That overlay is system UI, so this screen's
|
||||
`FLAG_SECURE` doesn't cover it, and Gboard keeps the text in its clipboard history. Use a native
|
||||
`ClipData` with `ClipDescription.EXTRA_IS_SENSITIVE` for any secret the app copies. (Read from code;
|
||||
not observed, because the screen can't be captured.)
|
||||
|
||||
---
|
||||
|
||||
## Medium — visual system
|
||||
|
||||
### M1 · The accent glow is clipped to a square on every primary button and the FAB
|
||||
|
||||
Each gradient button sits on a slightly lighter rectangle, and its corners show past the rounded shape:
|
||||
- [z-glow-button](android/z-glow-button.png): SIGN IN, CREATE THE VAULT, UNLOCK, CONNECT, SAVE, ADD
|
||||
FILES;
|
||||
- [z-glow-fab](android/z-glow-fab.png): the round FAB sits inside a visible square.
|
||||
|
||||
`AccentGlow` (`0 0 22 0`) is set on the template's `ContentPresenter`
|
||||
(`Phone.axaml` `Button.primary` / `Button.fab /template/ ContentPresenter`) and gets clipped to the
|
||||
button's own bounds. The desktop's `Button.accent` uses the same pattern, so check it there too. Fix:
|
||||
draw the glow from an unclipped wrapper (a `Border` with margin), or drop it on the phone.
|
||||
|
||||
### M2 · Body copy is monospace, against the shared-typeface decision
|
||||
|
||||
`docs/design-import-gaps.md` ("The phone catches up") records that the phone shares the desktop's
|
||||
faces. On the desktop, prose (`hint`, `heading`, the settings titles and captions) is Montserrat, and
|
||||
mono is kept for identifiers (`.mono`) and labels (`.label`). On the phone, `TextBlock.body`, `.title`
|
||||
and `.detail` all still set `MonoFont` (`Phone.axaml:661–680`). So every paragraph, title and caption is
|
||||
JetBrains Mono, and only screen headers (Personal, Settings, New host) are Montserrat. The result is
|
||||
long, wide-set paragraphs that are tiring to read ([03](android/03-enrol.png),
|
||||
[16](android/16-settings.png), [19](android/19-sftp.png)).
|
||||
|
||||
Keep mono for labels, buttons (the recorded ALL-CAPS-mono convention), addresses and code. Move
|
||||
`.body`, `.title` and `.detail` to the default sans.
|
||||
|
||||
### M3 · Fluent's selection fill leaks into the host list
|
||||
|
||||
After a connect, the tapped row stays lit in `#3d486b` ([15](android/15-selected-row.png)). That
|
||||
slate-blue isn't in `Palette.axaml`, which says selection is `Active` `#1E1E2C`. The comment at
|
||||
`HostsScreen.axaml:360` says the selection fill was deliberately removed ("a lit row would be a mark
|
||||
with no action behind it"). But only the custom `Border.hostrow.chosen` style was changed. Fluent's
|
||||
`ListBoxItem:selected /template/ ContentPresenter` background was never overridden.
|
||||
|
||||
### M4 · Disabled secondary buttons are heavier than enabled ones
|
||||
|
||||
`Button.secondary` has no `:disabled` style, so Fluent's `#3c3c43` slab shows through. SFTP's disabled
|
||||
**SAVE FILE** is the most prominent button in its row ([z-sftp-actions](android/z-sftp-actions.png)).
|
||||
Mirror `Button.primary:disabled`: a `Raised` fill and `TextFaint` text.
|
||||
|
||||
### M5 · Unstyled Fluent controls among styled ones
|
||||
|
||||
- **Dropdowns:** `ComboBox` keeps Fluent's ~4 px radius, bright white border and black fill beside
|
||||
10 px, softly bordered fields (host editor, SFTP host picker; [08](android/08-host-editor.png)).
|
||||
- **Checkboxes:** the default `CheckBox`, with its label sitting below the box's centre (relay, remember
|
||||
password, press-Enter; [10](android/10-connect-sheet.png)).
|
||||
- **Focused fields:** the fill switches from `Field` to pure `#000`.
|
||||
- **Scrollbars:** desktop scrollbars are always visible. On Keychain, the horizontal bar is drawn over
|
||||
the bottom of the filter chips ([17](android/17-keychain.png)). Android expects auto-hiding overlay
|
||||
bars.
|
||||
- **Stuck hover:** after a tap, Vaults' ↻ keeps a grey hover square. Touch has no hover, so
|
||||
`:pointerover` styles need a touch-safe variant.
|
||||
|
||||
### M6 · The terminal's colour and font have drifted from the palette
|
||||
|
||||
`WebAssets/terminal.css` says its `#171a26` background "is `TerminalSurface` in Palette.axaml". The
|
||||
palette now says `#18161F`. The terminal therefore reads navy, against the app's purple-black
|
||||
([14](android/14-terminal.png)). Its font stack starts at `ui-monospace` and never names the embedded
|
||||
JetBrains Mono, so on Android the WebView falls back to the system mono. That makes two different
|
||||
monospace faces on one screen.
|
||||
|
||||
### M7 · Iconography
|
||||
|
||||
- **Glyphs, not icons:** the phone uses Unicode glyphs (`⚿ ❯ ⇅ ◳ ≡ ◎ ⚙ ▤ ⌗ ⌕`), even though Material
|
||||
Icons is embedded and the desktop uses it. Weights and optical sizes vary (the heavy purple `❯` beside
|
||||
hairline `◳`; [16](android/16-settings.png)).
|
||||
- **Reused meanings:** ⚙ is both the Settings tab and the Preferences row, and ⚿ is both Keychain and
|
||||
"Lock the keychain".
|
||||
- **Splash:** the splash shows a flat purple circle, while the app's own icon is a rounded square with
|
||||
the gradient.
|
||||
|
||||
---
|
||||
|
||||
## Medium — information architecture and flows
|
||||
|
||||
### M8 · "Settings" holds the product
|
||||
|
||||
Keychain, Snippets, SFTP, S3, Logs and Vaults all sit under the ⚙ **Settings** tab, beside
|
||||
Preferences and Lock ([16](android/16-settings.png)). File transfer and snippets are working tools, not
|
||||
settings. Options:
|
||||
- rename the tab ("More" or "Tools") and keep Preferences as one row in it;
|
||||
- or promote SFTP to the bottom nav. Its icon is already ⇅.
|
||||
|
||||
### M9 · Keychain can't add anything
|
||||
|
||||
The empty state says "Add an SSH key or a password to stop typing one" ([17](android/17-keychain.png)).
|
||||
The screen has no add control: no FAB, no header +, nothing in the a11y tree. Passwords can only be
|
||||
created from the host editor's "+ NEW CREDENTIAL", and no phone path adds an SSH key. If keys are
|
||||
desktop-only, the empty state should say so.
|
||||
|
||||
### M10 · Three ways to add, two ways to edit
|
||||
|
||||
- **Add:** Hosts has a FAB, Snippets a header "+", Keychain nothing, and the terminal's header "+"
|
||||
means "new session".
|
||||
- **Edit:** the host editor is a full-screen page with ← and ✓ ([08](android/08-host-editor.png)). The
|
||||
snippet editor is an inline card with **SAVE/CANCEL**, and the header "+" stays live while it's open
|
||||
([18](android/18-snippet-editor.png)).
|
||||
|
||||
Pick one pattern for each.
|
||||
|
||||
### M11 · SFTP
|
||||
|
||||
- **Action block:** it covers more than half the screen (seven buttons, a permanent help paragraph and a
|
||||
status line), so only about four file rows are visible ([19](android/19-sftp.png)).
|
||||
- **No selection:** RENAME, MOVE, PERMS and DELETE are enabled with nothing selected. Tapping one only
|
||||
writes "Choose something on the host to rename." into the status line at the bottom.
|
||||
- **DELETE:** neutral-styled here, while delete is danger-red on hosts and the keychain.
|
||||
- **Directory rows:** marked only by a tiny purple ▸, with no folder icon.
|
||||
- **Password again:** it's asked for again although a shell to the same host is already open.
|
||||
- **S3:** blocked while an SFTP session is open. Tapping S3 bounces to SFTP, with "An SFTP session is
|
||||
open. Close it before opening a bucket." at the bottom ([20](android/20-s3-bounced.png)).
|
||||
|
||||
Suggestion: a contextual action bar that appears with a selection, help moved behind an ⓘ, and the
|
||||
listing given the screen.
|
||||
|
||||
### M12 · Bottom sheets
|
||||
|
||||
- **Partial scrim:** the scrim covers only the content area. The header (with **LOCK**) and the bottom
|
||||
nav stay bright and tappable under every sheet ([07](android/07-add-sheet.png),
|
||||
[10](android/10-connect-sheet.png)).
|
||||
- **Decorative handle:** it has a drag handle, but swipe-down doesn't dismiss.
|
||||
- **Button order:** the primary action is on the left (CONNECT | CANCEL, SAVE | CANCEL). Android puts
|
||||
the confirming action on the right.
|
||||
- **No autofocus:** the connect sheet's password field isn't focused on open, so it takes an extra tap.
|
||||
- **Nav above keyboard:** the bottom nav rides up above the keyboard and eats the sheet's space
|
||||
([13 before trust](android/13-host-key.png)).
|
||||
|
||||
### M13 · Sign-in and enrolment
|
||||
|
||||
- **Full browser:** sign-in opens a full Chrome tab (`ChromeTabbedActivity`), not a Custom Tab, so the
|
||||
user leaves the app. Chrome's own first-run prompts then land on top.
|
||||
- **Unannounced second sign-in:** step 2 asks for a passphrase, and **CREATE THE VAULT** then sends the
|
||||
user to the identity provider's login form a second time. That's the deliberate `prompt=login` key
|
||||
binding (`OidcClient.AuthorizeKeyBindingAsync`), but no copy warns that it's coming. Say "you'll
|
||||
confirm your identity once more" on the passphrase screen.
|
||||
- **Blank resume:** after the browser returns, the app showed a blank `#05050a` canvas for 5–10 s with
|
||||
no progress indicator. Every shell state has a screen, so this looks like surface re-creation on
|
||||
resume (Debug + emulator). Confirm on Release, and keep a visible state on screen while it happens.
|
||||
- **Step labels:** start at "STEP 2 OF 3"; step 1 is never labelled.
|
||||
|
||||
### M14 · The global status line leaks across screens
|
||||
|
||||
A single `StatusMessage` follows the user around:
|
||||
- "Unlocked 'Personal'." in the middle of Preferences ([21](android/21-preferences.png));
|
||||
- "Synchronised: 2 in, 2 out." after adding one host. `Describe` counts `Pulled`/`Pushed`, which include
|
||||
the app's own log entries, while `PulledItems`/`PushedItems` already exist for this;
|
||||
- "audit-box asks for a password." repeated under the sheet that asks for it.
|
||||
|
||||
Scope messages to the screen that raised them, or use transient snackbars.
|
||||
|
||||
---
|
||||
|
||||
## Low — input, copy, polish
|
||||
|
||||
- **Keyboards:**
|
||||
- the server URL field gets a text keyboard with contact suggestions, not the URL keyboard
|
||||
([02](android/02-server-keyboard.png));
|
||||
- the port field gets the full letter keyboard ([z-port-keyboard](android/z-port-keyboard.png));
|
||||
- hostname and username get autocorrect suggestions.
|
||||
|
||||
Set `TextInputOptions.ContentType` (`Url`, `Digits`) and turn autocorrect off for those.
|
||||
- **Placeholder-as-label:** name, hostname, username and notes in the host editor, the unlock field,
|
||||
and the snippet fields have no labels, so each field's meaning disappears once typed in.
|
||||
- **Terminology:**
|
||||
- "CREATE THE VAULT", then "Creating your keychain";
|
||||
- "vault passphrase" (placeholder) next to "Enter your keychain passphrase" on the same screen;
|
||||
- "ENROLLMENT" (US spelling) in a British-English app ("Synchronised", "unopenable").
|
||||
- **Fingerprint wrapping:** a single "I" wraps onto its own line. Chunk it, or size it to fit
|
||||
([13](android/13-host-key.png)).
|
||||
- **Address format:** "10.0.2.2 · 2222" on the host-key sheet, "10.0.2.2:2222" everywhere else.
|
||||
- **Dates:** "10/2/2026 11:29 PM" in Logs ([23](android/23-logs.png)), "2026-10-02 21:04" in SFTP.
|
||||
- **Notification permission:** Android's prompt appears without explanation at the first connect.
|
||||
Explain the keep-alive notification first.
|
||||
- **Terminal width:** the default size gives about 37 columns, so `ls -la` wraps every line.
|
||||
- **Empty and loading states:**
|
||||
- Hosts' empty state is one grey line at the top left ([06](android/06-hosts-empty.png));
|
||||
- Vaults first rendered an empty list with no loading indicator;
|
||||
- the keychain shows both "0 items" and an empty-state sentence.
|
||||
- **Connecting card:** **CLOSE THIS TAB** and **SHOW LOGS** are indented past the card's left edge
|
||||
([11](android/11-connecting.png)).
|
||||
- **Host-key context:** the sheet opened over the Quick Connect form, not over the connection progress
|
||||
it belongs to.
|
||||
- **Disabled fields:** on the enrolment busy state they turn a flat mid-grey
|
||||
([04](android/04-enrol-busy.png)), and the keyboard stays up.
|
||||
- **Fingerprint setting:** "Unlock with a fingerprint" in Preferences is drawn as a bare circle; it reads
|
||||
as a radio button, not a switch.
|
||||
|
||||
## Not verified here
|
||||
|
||||
- **System font size:** whether the app honours it. No `FontScale` handling exists in the phone code,
|
||||
and Avalonia scales by density, so text likely ignores the setting. I didn't change device settings
|
||||
to confirm.
|
||||
- **Landscape and tablet:** an `IsWide` layout exists but wasn't exercised.
|
||||
- **TalkBack:** not run end to end. The H4 findings come from the tree TalkBack reads.
|
||||
|
||||
## Environment notes for a re-run
|
||||
|
||||
- **Keycloak port:** 18080 was taken by another project's container, so Keycloak ran on **18180** via a
|
||||
compose override, with `Oidc__Authority=http://127.0.0.1:18180/realms/dodossh` and
|
||||
`adb reverse tcp:18180`.
|
||||
- **`adb root`** restarts adbd and drops `adb reverse`. Re-add both forwards afterwards, or the app
|
||||
shows OFFLINE.
|
||||
- **The sshd** was a throwaway `linuxserver/openssh-server` container (`dodossh-audit-sshd`, port 2222,
|
||||
user `dodo`).
|
||||
- **Build:** `-p:RuntimeIdentifier=android-x64` adds an `android-x64` section to every
|
||||
`packages.lock.json`. Restore those files afterwards.
|
||||
@@ -0,0 +1,147 @@
|
||||
# DodoSSH full sweep — 2026-10-02
|
||||
|
||||
## Fix status (same day)
|
||||
|
||||
Every fix below has a regression test. Where marked ✔✔, the test was also checked to fail with the fix taken out.
|
||||
|
||||
| Item | Fix |
|
||||
|---|---|
|
||||
| C1 ✔✔ | `LocalDirectory.ChildPath` cleans remote names and requires the resolved parent to be the target folder. It is used by every download path and by EDIT. `LocalEditor` opens a file that would run (PATHEXT plus a known list, and macOS launchers) with the type's own `edit` verb or with Notepad / `open -t`. On Linux, opening a `.desktop` file is refused. |
|
||||
| C2 ✔✔ | Pinned key types are offered first. A key under an algorithm with no pin of its own is accepted only if its fingerprint matches one of the endpoint's pins (the same RSA key under `rsa-sha2-256` vs `-512`); otherwise it is a hard mismatch. Tested against the real OpenSSH container. |
|
||||
| C3 ✔✔ | Complete, Revise and Park take the `PendingOperation` and act only while the row still holds that operation id. A later edit is rebased onto the version that was just accepted, so it pushes without a conflict. |
|
||||
| C4 | Pull leaves out change entries the item has moved past, so a revision is never paired with newer ciphertext. This also stops re-sending an item's payload once per historical edit. |
|
||||
| C5 | Deleting a team's last vault archives the team only when the owner does it. `TeamService.ArchiveAsync` now checks ownership itself. |
|
||||
| C6 | `DeviceKeyNames`: the default profile keeps `DodoSSH.DeviceKey.v1`, and every other profile gets its own name. Sign-out deletes only its own key. A profile still sealed under the shared key keeps reading it. The same applies to the macOS keychain tag. |
|
||||
| H1–H3 | The data plane reassembles messages up to 16 MiB. A frame that throws stays with its own session. Input writes are serialized per pump, so a slow write is never overtaken. |
|
||||
| H4 | The host and bucket the user chose are remembered across reloads. A typed password is only sent to the host it was typed for. |
|
||||
| H5 | Selecting a vault reads its people even while the screen is busy. A generation counter discards answers for an older selection. |
|
||||
| H6 / U9 | Locking or signing out leaves Settings, and the Members panel closes with it. Escape closes the Members panel first. |
|
||||
| H7 ✔✔ | The tracked user is reloaded after the out-of-band last-seen update. |
|
||||
| H8 | `sync_state.pulled_types` (with a client migration) restarts the pull once when the synced type list changes. |
|
||||
| H9 | `ActiveAccountRequirement` on both policies. A suspended account gets 403 `account-not-active`. |
|
||||
| H10 | `DevicePinWindow` palette keys fixed, and it now uses `InitializeComponent`. `Load` never assigned the named fields, so the window could never have opened. Covered by a new layout test. |
|
||||
| H11 ✔✔ | `IRemoteFileStore.CanWriteFromAnOffset`: false for buckets, so a retried upload starts over. |
|
||||
| H12 | `ImportOutcome` carries refused hosts and the reason a batch stopped early, including a busy keychain. Both import screens say so. |
|
||||
| U1–U4 | The host card's sync badge moved to the address line, so names show in full. Tag chips wrap and trim. Keychain names trim inside their column, with a tooltip. The Security caption wraps. |
|
||||
|
||||
Second round, on branch `claude/audit-followups`:
|
||||
|
||||
| Item | Fix |
|
||||
|---|---|
|
||||
| U5 ✔✔ | The closed vault's card takes the keyboard on launch and on each new card (server address, enrolment, passphrase). Enter signs in and enrols. |
|
||||
| U6 ✔✔ | The host-key, delete and sign-out questions give the keyboard to their harmless answer (CANCEL / CLOSE, never TRUST or DELETE). Escape says no to each, and closes the hosts drawer. Tab stays inside the host-key card. |
|
||||
| U7 ✔✔ | Arrow-key selection on the Hosts board is written back to the vault, from the list that has the keyboard only. An arrow on the board itself enters the cards. Enter on a card connects. |
|
||||
| U8 | `DeviceUnlockWording`: Windows Hello, Touch ID, a TPM-checked device PIN or a fingerprint, according to the platform. |
|
||||
| U10 | Session tab labels are capped at 200 px and trimmed, with the full label in a tooltip. |
|
||||
| U11 | The keychain's VAULT column is an Auto column in a shared size group, so it takes no width when no row names a vault. The Tags page's always-empty column is gone. The snippet buttons appear only with a snippet chosen. |
|
||||
| U12 ✔✔ | `TerminalTabViewModel.EndedAt`: the status-bar timer stops when the shell ends. |
|
||||
| P1 (part) ✔✔ | A sync pass that moved nothing no longer reloads (and decrypts) the whole keychain. It restrings the ago-texts instead. Failed vaults still reload. The Clear-plus-Add rebuild itself is unchanged. |
|
||||
| P3 | `ProbedDeviceKeyStore`: the TPM probe runs once per process, on the thread pool. |
|
||||
| P5 | A pulled page is mirrored in one write, and the outbox is read once per page. Changes with a local edit waiting still go one at a time, in order. |
|
||||
| P6 ✔✔ | `OpenShellAsync` makes its three round trips on the thread pool, honours cancellation, and closes a shell that arrives after the caller gave up. |
|
||||
| Transfers ✔✔ | Lost wake-up fixed with a `pumping` flag cleared under the same lock that finds the queue empty (a stress test failed 2 of 2 runs before the fix). Cancel as a transfer finishes no longer throws. A drop or download refused while busy says so. |
|
||||
| EF retry ✔✔ | `FreshAttempts.ExecuteFreshAsync`: a retried write transaction clears the tracker and reloads what it carries in. Before this, a lost commit made a team hand-over answer 204 with nothing changed. Tested with an interceptor that fails one commit. |
|
||||
| Rejected delete ✔✔ | A delete of a create that never landed is complete when the server says it has no such item. A create that did land still behaves as before: the delete gives way, and the user is told. |
|
||||
| Team archive race | Archive and team-vault creation take a per-team advisory lock. Archive re-counts the team's vaults, and creation re-checks the team is live. No race test. |
|
||||
| Relay revocation ✔✔ | `RelayAccessWatch` re-checks a user's open relay sessions when their access changes and ends lost ones with 4403 / `AccessRevoked`. |
|
||||
| Shell | `ToggleVaultAsync` catches a failure and redraws. `CrashLog` writes uncaught exceptions to `crash.log` in the profile (the process still exits). A lock pressed during an unlock now wins (✔✔). |
|
||||
| Window chrome | `CaptionPresses` decides a double-click on the press, before `BeginMoveDrag`. Only a headless test covers it; it is not verified on a real Windows window. |
|
||||
|
||||
| P1 (rest) ✔✔ | `ReplaceableCollection.ReplaceAll`: the host, visible-host, section and bucket lists are swapped in whole and announced once, and each transfers picker refreshes only from its own list. |
|
||||
| P2 (most) | The transfer queue and import lists are virtualised (✔✔: 2,000 of 2,000 rows were built before). The find box hands its text to the vault after a 150 ms pause in typing, and the view model still filters synchronously. |
|
||||
|
||||
| P2 (board) ✔✔ | `VirtualizingWrapPanel` builds only the host cards near the view. Measured headless, after warm-up: first layout with 300 hosts went from about 3 s to about 0.5 s, and with 1,000 from 8.3 s to 0.2 s. Clearing a filter went from 1–8 s to about 0.1–0.2 s. Arrow keys, End, the band and selection still work. |
|
||||
|
||||
| P4 ✔✔ | **Server:** `TombstoneCollector` runs every 6 h. It deletes tombstones past the 90-day retention together with their change entries, and compacts each item's superseded entries down to its newest. It then raises the vault's `SyncHorizon`, and a cursor from before it is refused. A cursor now carries the horizon it was issued under. Without that, the read from the beginning that a refused client starts was refused on its own second page and looped for ever on any vault larger than a page (✔✔: found by a client test, then reproduced in an API test). **Client** ("resync and prune", as chosen): a read from the beginning marks every item it reads, keeping the mark in the sync state because the read can span passes. When it finishes, it removes unmarked items of the synced types. It keeps items with an unsent change, and items pushed during the read. Such an edit is then sent as a create, as before, but now records a `RecreatedAfterCollection` conflict and is named in the sync message. The removed items are named too. New client cache migration `AddSweepMarks`. |
|
||||
|
||||
Not done, and why:
|
||||
- **Live-run observations** (production as the default server URL, Keycloak on host port 18080): product decisions, not bugs.
|
||||
|
||||
Pre-existing flakiness: on untouched HEAD, 3 of 8 runs of `ShellFlowTests` each had one test fail: `TheCountsAPhoneShows_FollowEveryForwardsState`, `ASpelunkHost_IsDialledThroughTheTunnel_AndStillAsItself` and `ReconnectingAHostThatWantsAPassword_AsksForOneRatherThanDiallingWithout`. These races are in the tests and predate this work.
|
||||
|
||||
How it was done:
|
||||
- The desktop client ran on Windows with an isolated profile against a local stack: Postgres, Keycloak and the API on :5333.
|
||||
- The real MainWindow was rendered headless through every screen and settings page, 39 captures at 1081×583 and 1440×900.
|
||||
- Four parallel code audits covered: the Shell view models; SSH/terminal/transfer; the server and sync; the desktop views and platform code.
|
||||
- The findings marked **✔ verified** were re-read in code by me, or seen in a screenshot. The rest come from the audits; each one was traced, but I did not check them again.
|
||||
|
||||
The live session could not get past OIDC sign-in. The default browser is Edge, which I can't type into, so the terminal, SFTP and host-key flows were reviewed in code only.
|
||||
|
||||
---
|
||||
|
||||
## Critical — data loss, security
|
||||
|
||||
| # | Finding | Where |
|
||||
|---|---|---|
|
||||
| C1 | **Path traversal on download / open-to-edit.** Remote names go straight into `Path.Combine(LocalPath, row.Name)`. SFTP only drops `.` and `..`, so on Windows a name with backslashes (`..\..\AppData\…\Startup\x.bat`) or a rooted name escapes the folder. S3 keys `../..` do the same on every OS. EDIT then opens the file with `UseShellExecute`, so a `.bat`/`.hta` *runs*. The Android head sanitises names; the desktop doesn't. ✔ verified | `TransfersViewModel.cs:2682,2698,2780,2784,3165`; `SshNetSftpSession.cs:105`; `ObjectKeys.cs:50`; `App.axaml.cs:380` |
|
||||
| C2 | **Host-key pin bypass by changing the key algorithm.** Pins are looked up per algorithm. A MITM that offers ecdsa or rsa to a host pinned for ed25519 gets the "never seen before" first-use prompt instead of the mismatch hard block. ✔ verified | `SshNetConnectionFactory.cs:261-272` |
|
||||
| C3 | **The outbox loses an edit made while a push is in flight.** `CompleteAsync` deletes the row by `Sequence` only, but `QueueAsync` folds a newer edit into that same row with a new `OperationId`. When the older push is acknowledged, the newer edit is deleted silently. ✔ verified | `OutboxStore.cs:239-250` vs `:71-97`; `SyncEngine.cs:554` |
|
||||
| C4 | **Pull pairs historical revision numbers with the current ciphertext.** `Describe` sends `Version: change.Revision`, but the payload comes from the item's current row. The AAD binds the version, so the payload fails to decrypt. A concurrent offline edit gets parked as "Undecryptable". ✔ verified | `SyncService.cs:565,592` |
|
||||
| C5 | **A team admin can archive the team (meant to be owner-only).** If an admin deletes the last vault, `ArchiveAsync` runs with no role check and revokes every membership, the owner's included. | `VaultGrantEndpoints.cs:182,196`; `TeamService.cs:242-289` |
|
||||
| C6 | **The shared TPM key name breaks other profiles.** `WindowsDeviceKeyStore` uses one CNG key name (`DodoSSH.DeviceKey.v1`) for the release and nightly profiles. Sign-out in one (`ForgetAsync`) deletes the key, and the other profile's device unlock stops working. This contradicts `ClientPaths.ForChannel`, which says the two installs are kept apart. ✔ verified | `WindowsDeviceKeyStore.cs:127,233-246` |
|
||||
|
||||
## High — broken behaviour
|
||||
|
||||
| # | Finding | Where |
|
||||
|---|---|---|
|
||||
| H1 | **Terminal input >64 KiB (a large paste or copy) is lost.** On a frame that doesn't fit the buffer, the loop `continue`s, and the tail is then dispatched as if it were a frame. ✔ verified | `TerminalDataPlane.cs:465-472` |
|
||||
| H2 | **Keystrokes can race.** Input frames are written to `ShellStream` without awaiting and without a lock, and the base `FlushAsync` runs on the pool. A snippet RUN (text, then `\r`) over about 4 KiB can execute truncated. | `TerminalDataPlane.cs:505`; `SshNetConnectionFactory.cs:554-558` |
|
||||
| H3 | **One resize on a closing shell tears down the renderer socket for every tab** (`ObjectDisposedException` escapes the receive loop). | `TerminalDataPlane.cs:516-520` |
|
||||
| H4 | **The SFTP host picker resets to the first host on every sync pass**, about once a minute. Selection is lost on `Hosts.Clear()`, so CONNECT can dial host[0] with the password typed for host X. The same Clear-then-Add pattern resets the Known Hosts and Snippets selection and closes the snippet delete card. | `TransfersViewModel.cs:3905,3923-3950`; `KnownHostsViewModel.cs:264`; `SnippetsViewModel.cs:689` |
|
||||
| H5 | **The Vaults Members panel can show vault A's members under vault B**, and SHARE / REVOKE then act on that mix. | `VaultsViewModel.cs:1646,1698-1765` |
|
||||
| H6 | **Locking or signing out from Settings leaves a window with no title bar** (no close, minimize or drag). Unlock then returns to Settings. ✔ verified by screenshot | `MainWindowViewModel.cs:3254,3400`; see `locked-from-settings-no-titlebar.png` |
|
||||
| H7 | **Enrollment returns 500 if last-seen is more than an hour old.** `ExecuteUpdate` changes `xmin` under a tracked entity, and the `SaveChanges` that follows throws a concurrency exception. | `CurrentUserContext.cs:92,120`; `EnrollmentService.cs:177` |
|
||||
| H8 | **A new synced type never back-fills.** A filtered pull moves the cursor past types the client didn't request, so after an upgrade adds a type (SpelunkKey, ObjectStore), older changes of that type never arrive. | `SyncService.cs:50,69`; `SyncEngine.cs:145` |
|
||||
| H9 | **Suspended users keep full access.** `UserStatus` is checked nowhere on the request path. | `CurrentUserContext.cs:126` |
|
||||
| H10 | **`DevicePinWindow` references `Surface` and `MutedText`, which don't exist**, so Linux TPM-PIN register/unlock should throw on open. ✔ verified | `DevicePinWindow.axaml:7,35` |
|
||||
| H11 | **A failed S3 upload commits a truncated part, and every retry then fails.** | `S3UploadStream.cs:126-146` |
|
||||
| H12 | **Import screens report success when the vault skipped or failed the import** (busy, or a mid-batch exception). | `ImportViewModel.cs:374`; `HetznerImportViewModel.cs:561` |
|
||||
|
||||
## UI issues seen in the running app
|
||||
|
||||
| # | Finding | Where |
|
||||
|---|---|---|
|
||||
| U1 | **Host card labels are cut to 3-4 characters even at 1440px.** "bastion" shows as "bast…", and k8s-worker-a / -b both show as "k8s…", as do web-prod-01 / -02. The label is the primary identifier, and the "not synced" badge takes most of its width. ✔ `hosts-truncated-labels.png` | `HostsScreen.axaml` card template |
|
||||
| U2 | **Tag chips overflow the host card edge.** The WrapPanel sits inside a horizontal StackPanel, so it never wraps. ✔ | `HostsScreen.axaml:737-774` |
|
||||
| U3 | **Keychain table: long names overprint the TYPE / USED BY columns.** `TextTrimming` has no effect inside a horizontal StackPanel. The same pattern appears in Logs, SessionSidebar, Snippets, NavRail and Transfers. ✔ `keychain-overlap.png` | `KeychainScreen.axaml:294` et al. |
|
||||
| U4 | **Security page: the "End-to-end encrypted" caption runs past the card edge.** ✔ `security-text-clipped.png` | `SettingsSecurityPage.axaml:49-60` |
|
||||
| U5 | **Enter does nothing in the server URL box** (verified live). The same applies to the enrollment passphrase boxes. The passphrase box is not focused on launch. | `MainWindow.axaml:385` |
|
||||
| U6 | **Overlay cards (host key, delete, sign-out, host editor) have no Enter/Escape and don't take focus**, and Tab walks the page behind them. | `HostKeyCard.axaml` etc. |
|
||||
| U7 | **Hosts board: arrow-key selection isn't written back to the view model**, so the drawer's Connect / Delete act on the previously clicked host. There is no Enter-to-connect. | `HostsScreen.axaml:573` |
|
||||
| U8 | **Windows-only wording on macOS/Linux:** "USE WINDOWS HELLO", "Waiting for Windows…". | `UnlockCard.axaml:51`; `MainWindowViewModel.GestureWait` |
|
||||
| U9 | **Escape over the Members panel leaves Settings and leaves the panel open** for the next visit. | `MainWindow.axaml.cs:242` |
|
||||
| U10 | **Session tab labels are unbounded.** One long label pushes "+" off the strip. | `SessionTabRow.axaml:89` |
|
||||
| U11 | **A VAULT column is drawn and empty** with a single vault (Keychain, Tags). The snippets list footer shows Edit / Move / Delete with nothing selected. | screenshots |
|
||||
| U12 | **The status-bar session timer keeps counting after a shell has ended.** | `MainWindowViewModel.cs:4979` |
|
||||
|
||||
## Performance
|
||||
|
||||
| # | Finding | Where |
|
||||
|---|---|---|
|
||||
| P1 | **Every sync pass, about once a minute, decrypts the entire keychain even when nothing changed**, then rebuilds each list with Clear + Add. That is O(n²) notification fan-out: with 300 hosts, roughly 45k collection notifications per pass. | `VaultViewModel.cs:6375,4709`; `MainWindowViewModel.cs:4549` |
|
||||
| P2 | **The Hosts board is not virtualised and is rebuilt on every filter keystroke.** The transfer queue (one row per file in a folder download) and the import lists are not virtualised either. | `HostsScreen.axaml:448-576`; `TransfersScreen.axaml:1193` |
|
||||
| P3 | **The TPM probe (create and delete an RSA-2048 key) runs synchronously on the UI thread** at startup and after every unlock. | `WindowsDeviceKeyStore.cs:152`; `MainWindowViewModel.cs:2594,2970` |
|
||||
| P4 | **Pull re-sends the full current payload once per historical change.** There is no change-log compaction and no tombstone GC, even though ADR 0003 promises 90-day GC. | `SyncService.cs:45-56,565` |
|
||||
| P5 | **The client applies each pulled change in 2 or more separate SQLite transactions.** | `SyncEngine.cs:157-169,303` |
|
||||
| P6 | **`OpenShellAsync` does blocking network round trips wrapped in `Task.FromResult`** and ignores cancellation. | `SshNetConnectionFactory.cs:401-418` |
|
||||
|
||||
## Lower / plausible
|
||||
|
||||
- **Transfers:**
|
||||
- A wake-up can be lost, leaving a transfer queued forever (`FileTransferQueue.cs:452-505`).
|
||||
- Cancel at the moment a transfer finishes can throw `ObjectDisposedException`.
|
||||
- Drops and downloads are ignored silently while busy (`TransfersViewModel.cs:2272`).
|
||||
- **Sync and server:**
|
||||
- The EF retry strategy re-runs a push with stale tracked state (`Persistence.cs:38`).
|
||||
- Deleting a create that never landed leaves a permanent "Rejected" conflict.
|
||||
- A team can be archived while a vault is being created in it.
|
||||
- Relay sessions survive removal from the team, for up to 12 hours.
|
||||
- **Shell:**
|
||||
- Lock can race an unlock that is still loading.
|
||||
- `ToggleVaultAsync` has no try/catch, and there is no global `UnhandledException` handler.
|
||||
- **Window chrome:** double-click to maximise may be swallowed by `BeginMoveDrag`.
|
||||
|
||||
## Observations from the live run
|
||||
|
||||
- **The first-launch default is the production server, and sign-in completes without a credential prompt.** `DefaultServerUrl = "https://ssh.dodotech.cloud"` is pre-filled. With a live browser SSO session, one press of SIGN IN on a brand-new profile completed sign-in with no password typed, and wrote the account's wrapped key and vault list into the new profile. For a self-hosted product, an empty field with a placeholder is the safer default.
|
||||
- **The local dev compose hard-codes host port 18080 for Keycloak**, which collides with other projects on this machine.
|
||||
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 69 KiB |
@@ -640,6 +640,16 @@ go at 360dp:
|
||||
the membership test, the tab that lights and the back gesture's first case all read that one property.
|
||||
The desktop keeps its Keychain rail entry, so this is the second thing the two heads arrange
|
||||
deliberately differently, after the hub itself.
|
||||
|
||||
**And the 2026-10-04 redesign moved it back out.** The bar is VAULTS · CONNECTIONS · SETTINGS. The first
|
||||
entry is the open vault, with a HOSTS · KEYCHAIN · SNIPPETS switch under the vault's header, so the
|
||||
keychain and snippets sit beside the hosts they are used with rather than behind the hub (audit M8);
|
||||
`IsVaultSurface` lights it, and `IsMoreSurface` lost `Keychain` and `Snippets`. SETTINGS keeps what is
|
||||
about this phone and this account: an account card, security, vaults and the log, files, preferences.
|
||||
The strip of open shells above the bar went too — Connections carries the count in a badge instead. The
|
||||
keychain gained an add sheet and an editor page over the editors the shared view model already had, the
|
||||
files screen moved its seven buttons into a selection bar and an actions sheet, and a terminal's `+`
|
||||
sheet now opens SFTP on that terminal's own host.
|
||||
8. ~~**The terminal.**~~ **Done** — one `NativeWebView` for every session, as on the desktop and for the
|
||||
same reason, with the tab strip scrolling horizontally rather than wrapping so that opening a tab never
|
||||
reflows a terminal while output is arriving.
|
||||
|
||||
@@ -510,7 +510,7 @@ field cannot be removed and stays as a permanently refused member; `SyncEndpoint
|
||||
| Org chip `dodotech / platform` | contracts + server | An *organisation* above teams, which does not exist — `VaultSummary.TeamId` is no longer always null since M3, but a team is not an org and there is exactly one tenant per deployment. | The vault's own name, and the account this machine is enrolled as. Team names are on the VAULTS screen, where they are about something. |
|
||||
| `SYNCED` dot, always green | client-app | Nothing — the design's claim is simply unconditional. | Green **only** when a connection is held, the last sync pass actually reached the server, and the outbox is empty; otherwise `UNREACHABLE`, the count of changes still waiting, or `OFFLINE`. Holding an `IVaultServer` proves a sign-in once succeeded and nothing more, so a laptop whose lid has been shut all afternoon still has one — reachability comes from the outcome of the last pass. A permanently green light is the same as no light. |
|
||||
| `VAULT SYNCED 11:02` | client-session | `StoredSyncState.LastPulledAt`/`LastPushedAt` are persisted, but `VaultSession` exposes the store as `internal`. A property away. | Omitted. The one honest sync fact — the outbox depth — is in the titlebar and the status bar. |
|
||||
| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter. The box says "search hosts", not "search hosts · run command". |
|
||||
| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter, and reads a typed `user@host[:port]` as a machine to connect to with a password, without saving it. The box says "search hosts, or type user@host", not "search hosts · run command". |
|
||||
| Status bar `· via bastion-eu` | client-ssh | Jump-host execution. See below. | Omitted. |
|
||||
| Status bar port forwards | client-ssh | — | **Shipped** on the session status bar as a count of listening forwards; the ports themselves are in the session sidebar. |
|
||||
| Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. |
|
||||
|
||||
@@ -322,6 +322,30 @@ closing the only open window ends the process, so the real window is shown while
|
||||
Nothing appearing at all means composition is running in the same dispatcher turn that has to paint the
|
||||
panel, which is what posting it at `DispatcherPriority.Background` prevents.
|
||||
|
||||
### 1.13 An emoji takes two columns, and the line after it stays where the shell thinks it is
|
||||
|
||||
Open a shell on a Linux host and run:
|
||||
|
||||
```
|
||||
printf '😀|\n12|\n日|\n'
|
||||
```
|
||||
|
||||
**Pass:** the three bars line up in one column. The emoji, the two digits and the CJK character each take two
|
||||
columns, which is what the remote counted.
|
||||
|
||||
Then type `echo 😀😀 hello` at the prompt without pressing Enter, press **←** five times, and type `X`.
|
||||
|
||||
**Pass:** the `X` appears between `he` and `llo`, which is where bash put it, and the line does not redraw over
|
||||
itself.
|
||||
|
||||
**What the tests already own.** `RendererUnicodeTests` proves the page loads `@xterm/addon-unicode11` and
|
||||
selects its table before the pane opens. What is left here is xterm itself drawing to that table.
|
||||
|
||||
**Failure means:** the emoji's bar sits one column left of the others, or the `X` lands a column away from the
|
||||
cursor. The pane is on xterm's default Unicode 6 widths, where an emoji is one column; check that
|
||||
`terminal.html` still loads `vendor/addon-unicode11.js` and that `AvaloniaTerminalAssetProvider` serves it.
|
||||
A script the provider does not list is a 404 the page never reports.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Known Hosts as its own page
|
||||
@@ -1364,6 +1388,22 @@ Ctrl+K, then click a result with the mouse rather than pressing Enter.
|
||||
|
||||
**Pass:** the palette closes and the connection starts.
|
||||
|
||||
### 7.10a Connecting to a machine that is not saved
|
||||
|
||||
Ctrl+K, type `user@host` for a reachable machine that is **not** on HOSTS, press Enter, type its password and
|
||||
press Enter again. Do it once against a machine whose host key this vault has never seen.
|
||||
|
||||
**Pass:** the palette asks for the password in place, with the caret already in the box. On the second Enter
|
||||
it closes, a tab named `user@host` opens, and the fingerprint card asks about the key; approving it connects.
|
||||
Afterwards HOSTS has nothing new on it, and the connection log has an entry for `user@host:22`. Escape from
|
||||
the password box goes back to the address with what was typed still in it.
|
||||
|
||||
**Failure means:** a new row on HOSTS is the palette saving what it promised not to. A tab that fails with
|
||||
"authentication failed" for a password known to be right is worth comparing against the phone's CONNECT TO A
|
||||
MACHINE box, which takes the same path — `VaultViewModel.ConnectToEndpointAsync` — so a difference between
|
||||
the two is in how the palette hands the password over, not in the connection. The headless half of this is
|
||||
`QuickConnectTests`; what it cannot do is dial.
|
||||
|
||||
### 7.11 A host or a bucket made now can be picked on FILES now
|
||||
|
||||
With the files screen's picker open on an empty or short list, go to HOSTS and add a host — or to KEYS and
|
||||
@@ -1397,7 +1437,7 @@ time)" — is the worse failure of the two: it rebinds a host as a side effect o
|
||||
|
||||
## Phase 8 — Adding and removing on the phone's host list
|
||||
|
||||
Twenty-one checks, and the reason there are twenty-one rather than none is worth stating: **the layout suite
|
||||
Twenty-two checks, and the reason there are twenty-two rather than none is worth stating: **the layout suite
|
||||
cannot see any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and
|
||||
`DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's
|
||||
application, dispatcher and platform are one-shot process globals, so a second head cannot share the
|
||||
@@ -1770,6 +1810,23 @@ follow, and try disconnecting and reconnecting instead.
|
||||
|
||||
---
|
||||
|
||||
### 8.20 Connecting from the search box to a machine that is not saved
|
||||
|
||||
With a terminal already open on some other machine, go back to HOSTS and type `user@host` for a reachable
|
||||
machine that is **not** in the list into the search box at the top. Type its password into the card that
|
||||
appears, and press CONNECT.
|
||||
|
||||
**Pass:** the card appears only once there is an `@` in the box, names the address with `:22` on the end, and
|
||||
says NOT SAVED. CONNECT opens a second tab for `user@host` — the first is still there — and the fingerprint
|
||||
card asks about an unknown key over the terminal. Back on HOSTS the search box is empty, the list is the whole
|
||||
list again, and there is no new host in it. A port the parser refuses (`root@box:70000`) gives a sentence in
|
||||
amber under the box rather than a card.
|
||||
|
||||
**Failure means:** the card sitting *over* the list rather than pushing it down is the panels' arrangement
|
||||
having been lost — see the remark above `IsSendingChosenHostsToAVault` in `HostsScreen.axaml`. A password that
|
||||
shows in the keyboard's suggestion strip is `Classes="secret"` missing from the box. Everything behind the
|
||||
card is `ShellFlowTests`' hosts-search cases; this is the rectangles, and the software keyboard.
|
||||
|
||||
## Phase 9 — Tag chips and the picker
|
||||
|
||||
The desktop half is measured by the layout suite, which now seeds three tags and puts all of them on one
|
||||
@@ -2034,16 +2091,31 @@ not have. If the password came back, something is storing it — it never was. A
|
||||
at a list with nothing marked, `ConnectToRecent` is setting the selection rather than going through
|
||||
`AskAboutHost`: nothing on that list means "selected" any more, so that is a screen with nothing to press.
|
||||
|
||||
### 11.9 Settings holds the keychain, and back walks out of it
|
||||
### 11.9 The keychain and snippets are the vault's, and back walks out of them
|
||||
|
||||
From HOSTS: Settings, then Keychain.
|
||||
From HOSTS, tap KEYCHAIN in the switch under the vault's header, then SNIPPETS.
|
||||
|
||||
**Pass:** the bottom bar has three entries and the Settings one stays lit while the keychain is showing.
|
||||
The keychain draws its own header with a back arrow — the vault name and sync light are not above it. Back,
|
||||
by gesture or by the arrow, returns to Settings and not to HOSTS; a second back returns to HOSTS.
|
||||
**Pass:** the bottom bar reads Vaults · Connections · Settings, and Vaults stays lit on all three. The
|
||||
vault's name, the sync light, the lock and the switch stay above each one; neither screen draws a header of
|
||||
its own. Back from KEYCHAIN or SNIPPETS returns to HOSTS; a second back leaves the application.
|
||||
|
||||
**Failure means:** `ShellScreen.Vault` is missing from `IsMoreSurface` or from the back gesture's first
|
||||
case, and those two have to move together — the switch mirrors that property by construction.
|
||||
**Failure means:** `IsVaultSurface` and the back gesture's first case have drifted apart — they list the same
|
||||
three screens and have to move together.
|
||||
|
||||
### 11.9b Every add is the floating button, and every confirm is on the right
|
||||
|
||||
On HOSTS, KEYCHAIN and SNIPPETS, and on SFTP once connected, there is a round + (an upload arrow on SFTP)
|
||||
at the bottom right, glowing without a square behind it. On the keychain it raises a sheet of five things
|
||||
to add; choosing ADD A PASSWORD opens a full-screen page with labels above its boxes. Tap a host that asks
|
||||
for a password.
|
||||
|
||||
**Pass:** the password sheet opens with the keyboard already up, its box ringed in violet; CANCEL is on the
|
||||
left and CONNECT on the right; the header and the bottom bar behind the sheet are dimmed, and tapping either
|
||||
closes the sheet. On SFTP, tapping a file puts a bar across the top with its name, a save arrow and ⋮; the ⋮
|
||||
sheet has Rename, Move, Permissions and a red Delete.
|
||||
|
||||
**Failure means:** a square behind a glowing button is `ClipToBounds` back on (`Phone.axaml`); a bright
|
||||
header under a sheet is `PhoneShell.ShowsSheetScrim` missing that sheet.
|
||||
|
||||
### 11.10 The accessory keys do not cost the terminal its keyboard · **needs a hardware keyboard**
|
||||
|
||||
|
||||
@@ -139,7 +139,8 @@ EXPOSE 8080
|
||||
# without are DODOSSH_ConnectionStrings__Postgres, DODOSSH_Oidc__Authority and — on more
|
||||
# than one node — DODOSSH_Sync__CursorSigningKey.
|
||||
#
|
||||
# Nothing migrates the database from in here. That is the API's own design: it fails
|
||||
# /healthz/ready while a migration is pending and names it, so the schema is applied by
|
||||
# `dotnet ef database update` alongside the deployment rather than by a racing container.
|
||||
# The process migrates the database itself as it starts, before its port opens and under an
|
||||
# advisory lock so two replicas cannot race (Database:AutoMigrate, on by default). Leave it on
|
||||
# for ssh.dodotech.cloud: the CI deploy job relies on it. Turned off, /healthz/ready fails
|
||||
# while a migration is pending and names it, and something else has to apply the schema.
|
||||
ENTRYPOINT ["dotnet", "DodoSSH.Api.dll"]
|
||||
@@ -1,10 +1,15 @@
|
||||
using global::Android.OS;
|
||||
using global::Android.Views;
|
||||
|
||||
using Avalonia.Threading;
|
||||
|
||||
using DodoSSH.Client.Shell.Keyboard;
|
||||
|
||||
namespace DodoSSH.Client.Android.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Takes the keyboard away from the terminal's <c>WebView</c>, and reports the taps that should bring it
|
||||
/// back.
|
||||
/// Takes the keyboard away from the terminal's <c>WebView</c>, reports the taps that should bring it back,
|
||||
/// and turns a long press and drag into arrow keys.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -36,6 +41,23 @@ namespace DodoSSH.Client.Android.Platform;
|
||||
/// keyboard over the very output being scrolled to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A long press and a drag is the cursor gesture, and scrolling is still everything else.</b> There is no
|
||||
/// mode to switch: a finger that moves straight away is a scroll, exactly as before, and the page gets every
|
||||
/// event of it. A finger held still past <see cref="CursorGestureDelay"/> becomes a joystick for the arrow
|
||||
/// keys — see <see cref="ArrowGesture"/> for the speeds — and from that moment the rest of the touch is this
|
||||
/// class's: the <c>WebView</c> is sent a cancel, so it lets go of whatever it thought was starting, and is
|
||||
/// not shown another event until the finger lifts. A haptic tick says the gesture has begun, because nothing
|
||||
/// can be drawn over the terminal to say it instead.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The delay is a little short of the platform's long-press timeout, and that is the point of it. Chromium
|
||||
/// starts its own long press — selection, and a <c>contextmenu</c> event the page answers with a paste — at
|
||||
/// the platform timeout, so taking the touch away first is what keeps a held finger from pasting the
|
||||
/// clipboard into a shell. Both timers run on the main looper and this one is posted first and shorter, so
|
||||
/// it wins without a race. With the gesture switched off nothing is taken, and a long press does what it
|
||||
/// did before.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, for the reason
|
||||
/// <c>TerminalFocus</c> gave and which has not changed: the control does not expose its platform child, and
|
||||
/// this application only ever has the one <c>WebView</c>, so the walk's first match is necessarily the
|
||||
@@ -52,17 +74,26 @@ internal static class RendererFocus
|
||||
/// </remarks>
|
||||
private static WeakReference<global::Android.Webkit.WebView>? configured;
|
||||
|
||||
/// <summary>How long a finger has to stay still for the cursor gesture, as a share of the long-press timeout.</summary>
|
||||
private const double CursorGestureDelay = 0.8;
|
||||
|
||||
/// <summary>How often a held gesture is asked whether another arrow has come due — about one frame.</summary>
|
||||
private const long TickMs = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Makes the renderer unable to hold the keyboard, and calls <paramref name="onTapped"/> when somebody
|
||||
/// taps it.
|
||||
/// Makes the renderer unable to hold the keyboard, calls <paramref name="onTapped"/> when somebody taps
|
||||
/// it, and <paramref name="onArrows"/> while somebody long-presses and drags it.
|
||||
/// </summary>
|
||||
/// <param name="onTapped">A tap: bring the keyboard back.</param>
|
||||
/// <param name="gesturesEnabled">Asked at each press, so the setting applies from the next touch.</param>
|
||||
/// <param name="onArrows">How many arrows to send, and which.</param>
|
||||
/// <remarks>
|
||||
/// Idempotent, and called on every transition into the terminal rather than once at startup, because the
|
||||
/// <c>WebView</c> does not exist until the surface it lives on has been shown — and because Android is
|
||||
/// free to tear its renderer down and build a new one, which is a case this head already handles
|
||||
/// elsewhere (see <c>docs/android-port.md</c>).
|
||||
/// </remarks>
|
||||
public static void Suppress(Action onTapped)
|
||||
public static void Suppress(Action onTapped, Func<bool> gesturesEnabled, Action<ArrowDirection, int> onArrows)
|
||||
{
|
||||
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
|
||||
{
|
||||
@@ -83,57 +114,246 @@ internal static class RendererFocus
|
||||
webView.FocusableInTouchMode = false;
|
||||
webView.ClearFocus();
|
||||
|
||||
WatchForTaps(webView, onTapped);
|
||||
WatchTouches(webView, onTapped, gesturesEnabled, onArrows);
|
||||
|
||||
configured = new WeakReference<global::Android.Webkit.WebView>(webView);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls <paramref name="onTapped"/> for a tap, and stays out of the way of everything else.
|
||||
/// Calls <paramref name="onTapped"/> for a tap, runs the cursor gesture for a long press, and stays out of
|
||||
/// the way of everything else.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A tap is a press and a release that did not travel further than the platform's own
|
||||
/// <c>ScaledTouchSlop</c> — the same test every Android view uses, and necessary rather than fastidious:
|
||||
/// without it, dragging the scrollback would raise the keyboard over the output being scrolled to.
|
||||
/// without it, dragging the scrollback would raise the keyboard over the output being scrolled to. The
|
||||
/// same slop is what cancels a pending long press, so a scroll can never turn into the gesture halfway.
|
||||
/// </remarks>
|
||||
private static void WatchForTaps(global::Android.Webkit.WebView webView, Action onTapped)
|
||||
private static void WatchTouches(
|
||||
global::Android.Webkit.WebView webView,
|
||||
Action onTapped,
|
||||
Func<bool> gesturesEnabled,
|
||||
Action<ArrowDirection, int> onArrows)
|
||||
{
|
||||
// The fallback is the platform's own historical default, for a context that has somehow gone.
|
||||
var slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
|
||||
var downX = 0f;
|
||||
var downY = 0f;
|
||||
var moved = false;
|
||||
var touch = new TouchState(webView, gesturesEnabled, onArrows);
|
||||
|
||||
webView.Touch += (_, e) =>
|
||||
{
|
||||
// Observed, never consumed. The WebView's own OnTouchEvent still runs, so the page scrolls.
|
||||
e.Handled = false;
|
||||
|
||||
if (e.Event is not { } motion)
|
||||
{
|
||||
e.Handled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (motion.Action)
|
||||
// Observed and passed on, except while the cursor gesture holds the finger: then the page must not
|
||||
// see it scroll.
|
||||
e.Handled = touch.Handle(motion, onTapped);
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>One finger's worth of touch on the terminal, from press to release.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A class rather than locals captured by the handler, because two timers — the long press and the
|
||||
/// repeat tick — read and write the same state from outside the touch callback.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Avalonia's <see cref="DispatcherTimer"/> rather than <c>View.PostDelayed</c>. On this head the
|
||||
/// dispatcher is the main looper, so the timers land on the same thread and in the same queue as the
|
||||
/// touches and as Chromium's own long press; and a dispatcher timer is a plain managed object, where a
|
||||
/// posted <c>Runnable</c> is a Java peer that would want disposing for the life of a view that has none.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed class TouchState
|
||||
{
|
||||
private readonly global::Android.Webkit.WebView webView;
|
||||
private readonly Func<bool> gesturesEnabled;
|
||||
private readonly Action<ArrowDirection, int> onArrows;
|
||||
private readonly ArrowGesture gesture = new();
|
||||
private readonly int slop;
|
||||
private readonly float density;
|
||||
private readonly DispatcherTimer longPress;
|
||||
private readonly DispatcherTimer tick;
|
||||
|
||||
private float downX;
|
||||
private float downY;
|
||||
private float lastX;
|
||||
private float lastY;
|
||||
private long downTime;
|
||||
private bool moved;
|
||||
private bool steering;
|
||||
private int lastSpeed;
|
||||
|
||||
internal TouchState(
|
||||
global::Android.Webkit.WebView webView,
|
||||
Func<bool> gesturesEnabled,
|
||||
Action<ArrowDirection, int> onArrows)
|
||||
{
|
||||
this.webView = webView;
|
||||
this.gesturesEnabled = gesturesEnabled;
|
||||
this.onArrows = onArrows;
|
||||
|
||||
// The fallbacks are the platform's own historical defaults, for a context that has somehow gone.
|
||||
slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
|
||||
density = webView.Resources?.DisplayMetrics?.Density is > 0 and var d ? d : 1;
|
||||
|
||||
longPress = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(ViewConfiguration.LongPressTimeout * CursorGestureDelay),
|
||||
};
|
||||
longPress.Tick += (_, _) => BeginSteering();
|
||||
|
||||
tick = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(TickMs) };
|
||||
tick.Tick += (_, _) => Tick();
|
||||
}
|
||||
|
||||
/// <returns>Whether the event is consumed rather than passed on to the page.</returns>
|
||||
internal bool Handle(MotionEvent motion, Action onTapped)
|
||||
{
|
||||
switch (motion.ActionMasked)
|
||||
{
|
||||
case MotionEventActions.Down:
|
||||
downX = motion.GetX();
|
||||
downY = motion.GetY();
|
||||
moved = false;
|
||||
Press(motion);
|
||||
return false;
|
||||
|
||||
break;
|
||||
case MotionEventActions.PointerDown:
|
||||
// A second finger is a pinch or a two-finger scroll, never the cursor gesture.
|
||||
longPress.Stop();
|
||||
moved = true;
|
||||
return steering;
|
||||
|
||||
case MotionEventActions.Move when steering:
|
||||
Steer(motion.GetX(), motion.GetY());
|
||||
return true;
|
||||
|
||||
case MotionEventActions.Move:
|
||||
moved |= Math.Abs(motion.GetX() - downX) > slop || Math.Abs(motion.GetY() - downY) > slop;
|
||||
Travel(motion.GetX(), motion.GetY());
|
||||
return false;
|
||||
|
||||
break;
|
||||
case MotionEventActions.Up or MotionEventActions.Cancel:
|
||||
return Release(motion.ActionMasked is MotionEventActions.Up, onTapped);
|
||||
|
||||
case MotionEventActions.Up when !moved:
|
||||
onTapped();
|
||||
|
||||
break;
|
||||
default:
|
||||
return steering;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void Press(MotionEvent motion)
|
||||
{
|
||||
downX = lastX = motion.GetX();
|
||||
downY = lastY = motion.GetY();
|
||||
downTime = motion.DownTime;
|
||||
moved = false;
|
||||
steering = false;
|
||||
|
||||
longPress.Stop();
|
||||
|
||||
if (gesturesEnabled())
|
||||
{
|
||||
longPress.Start();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A finger moving before the long press: past the slop it is a scroll, and stays one.</summary>
|
||||
private void Travel(float x, float y)
|
||||
{
|
||||
lastX = x;
|
||||
lastY = y;
|
||||
|
||||
if (!moved && (Math.Abs(x - downX) > slop || Math.Abs(y - downY) > slop))
|
||||
{
|
||||
moved = true;
|
||||
longPress.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// <returns>Whether the release is consumed — it is, when it ends the cursor gesture.</returns>
|
||||
private bool Release(bool lifted, Action onTapped)
|
||||
{
|
||||
longPress.Stop();
|
||||
|
||||
if (steering)
|
||||
{
|
||||
EndSteering();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (lifted && !moved)
|
||||
{
|
||||
onTapped();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>The finger has been held still long enough: take the touch and start steering.</summary>
|
||||
private void BeginSteering()
|
||||
{
|
||||
longPress.Stop();
|
||||
|
||||
steering = true;
|
||||
lastSpeed = 0;
|
||||
gesture.Reset();
|
||||
|
||||
// The page has seen a press and nothing since; a cancel tells it that press is over, so neither
|
||||
// its scroller nor its own long press goes on to do anything with it. Parents are asked not to
|
||||
// intercept either — the gesture now owns the finger until it lifts.
|
||||
var now = SystemClock.UptimeMillis();
|
||||
|
||||
using (var cancel = MotionEvent.Obtain(downTime, now, MotionEventActions.Cancel, lastX, lastY, MetaKeyStates.None))
|
||||
{
|
||||
if (cancel is not null)
|
||||
{
|
||||
webView.OnTouchEvent(cancel);
|
||||
cancel.Recycle();
|
||||
}
|
||||
}
|
||||
|
||||
webView.Parent?.RequestDisallowInterceptTouchEvent(true);
|
||||
webView.PerformHapticFeedback(FeedbackConstants.LongPress);
|
||||
|
||||
tick.Start();
|
||||
}
|
||||
|
||||
private void Steer(float x, float y)
|
||||
{
|
||||
var count = gesture.Move((x - downX) / density, (y - downY) / density, SystemClock.UptimeMillis());
|
||||
|
||||
Emit(count);
|
||||
}
|
||||
|
||||
/// <summary>The repeat clock: whatever has come due since the last frame.</summary>
|
||||
private void Tick()
|
||||
{
|
||||
if (steering)
|
||||
{
|
||||
Emit(gesture.Advance(SystemClock.UptimeMillis()));
|
||||
}
|
||||
}
|
||||
|
||||
private void Emit(int count)
|
||||
{
|
||||
if (count > 0)
|
||||
{
|
||||
onArrows(gesture.Direction, count);
|
||||
}
|
||||
|
||||
// A tick under the thumb each time the speed shifts, which is the only way to feel the three
|
||||
// levels without looking: the terminal under the finger cannot draw anything to show them.
|
||||
if (gesture.Speed > lastSpeed && lastSpeed > 0)
|
||||
{
|
||||
webView.PerformHapticFeedback(FeedbackConstants.ClockTick);
|
||||
}
|
||||
|
||||
lastSpeed = gesture.Speed;
|
||||
}
|
||||
|
||||
private void EndSteering()
|
||||
{
|
||||
steering = false;
|
||||
tick.Stop();
|
||||
gesture.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
private static global::Android.Webkit.WebView? FindWebView(ViewGroup parent)
|
||||
|
||||
@@ -36,6 +36,156 @@
|
||||
this paragraph to learn it was deliberate.
|
||||
-->
|
||||
|
||||
<!--
|
||||
◆ FLUENT'S OWN BRUSHES, ANSWERED ONCE HERE RATHER THAN CONTROL BY CONTROL.
|
||||
|
||||
The 2026-10-02 audit (M5) found the controls this file never styled still drawing Fluent's defaults
|
||||
among the ones it did: a dropdown with a bright white border on a black fill beside 10-radius fields on
|
||||
#101019, a checkbox in Fluent's blue, a switch drawn as a bare circle, and a focused box whose fill went
|
||||
to pure black. Each of those reads its colours through a DynamicResource key, and a key defined here is
|
||||
found before the theme's own, because Avalonia searches an application's styles last-first and this file
|
||||
is included after FluentTheme. So one block of keys restyles every instance on the head — the ones on
|
||||
screens written later included — where a selector per control would restyle only the ones somebody
|
||||
remembered.
|
||||
|
||||
Every value is a palette brush, never a new colour. The focused border is the accent rather than a
|
||||
grey: a box with the keyboard up is the one control the user is currently writing into, and the
|
||||
redesign gives it the ring that says so.
|
||||
-->
|
||||
<Styles.Resources>
|
||||
<StaticResource x:Key="TextControlBackground" ResourceKey="Field" />
|
||||
<StaticResource x:Key="TextControlBackgroundPointerOver" ResourceKey="Field" />
|
||||
<StaticResource x:Key="TextControlBackgroundFocused" ResourceKey="Field" />
|
||||
<StaticResource x:Key="TextControlBorderBrush" ResourceKey="BorderMid" />
|
||||
<StaticResource x:Key="TextControlBorderBrushPointerOver" ResourceKey="BorderMid" />
|
||||
<StaticResource x:Key="TextControlBorderBrushFocused" ResourceKey="TerminalTabAccent" />
|
||||
<StaticResource x:Key="TextControlForegroundFocused" ResourceKey="Text" />
|
||||
<StaticResource x:Key="TextControlPlaceholderForeground" ResourceKey="TextGhost" />
|
||||
<StaticResource x:Key="TextControlPlaceholderForegroundFocused" ResourceKey="TextGhost" />
|
||||
<StaticResource x:Key="TextControlPlaceholderForegroundPointerOver" ResourceKey="TextGhost" />
|
||||
|
||||
<StaticResource x:Key="ComboBoxBackground" ResourceKey="Field" />
|
||||
<StaticResource x:Key="ComboBoxBackgroundPointerOver" ResourceKey="Field" />
|
||||
<StaticResource x:Key="ComboBoxBackgroundPressed" ResourceKey="Hover" />
|
||||
<StaticResource x:Key="ComboBoxBackgroundUnfocused" ResourceKey="Field" />
|
||||
<StaticResource x:Key="ComboBoxBackgroundDisabled" ResourceKey="Raised" />
|
||||
<StaticResource x:Key="ComboBoxBorderBrush" ResourceKey="BorderMid" />
|
||||
<StaticResource x:Key="ComboBoxBorderBrushPointerOver" ResourceKey="BorderMid" />
|
||||
<StaticResource x:Key="ComboBoxBorderBrushPressed" ResourceKey="TerminalTabAccent" />
|
||||
<StaticResource x:Key="ComboBoxBorderBrushDisabled" ResourceKey="Border" />
|
||||
<StaticResource x:Key="ComboBoxBackgroundBorderBrushFocused" ResourceKey="TerminalTabAccent" />
|
||||
<StaticResource x:Key="ComboBoxBackgroundBorderBrushUnfocused" ResourceKey="BorderMid" />
|
||||
<StaticResource x:Key="ComboBoxForeground" ResourceKey="Text" />
|
||||
<StaticResource x:Key="ComboBoxForegroundFocused" ResourceKey="Text" />
|
||||
<StaticResource x:Key="ComboBoxForegroundFocusedPressed" ResourceKey="Text" />
|
||||
<StaticResource x:Key="ComboBoxDropDownGlyphForeground" ResourceKey="TextFaint" />
|
||||
<StaticResource x:Key="ComboBoxDropDownGlyphForegroundFocused" ResourceKey="TextFaint" />
|
||||
<StaticResource x:Key="ComboBoxDropDownGlyphForegroundFocusedPressed" ResourceKey="TextFaint" />
|
||||
<StaticResource x:Key="ComboBoxDropDownBackground" ResourceKey="Chip" />
|
||||
<StaticResource x:Key="ComboBoxDropDownBorderBrush" ResourceKey="BorderHover" />
|
||||
<StaticResource x:Key="ComboBoxItemBackgroundPointerOver" ResourceKey="Hover" />
|
||||
<StaticResource x:Key="ComboBoxItemBackgroundPressed" ResourceKey="Hover" />
|
||||
<StaticResource x:Key="ComboBoxItemBackgroundSelected" ResourceKey="Active" />
|
||||
<StaticResource x:Key="ComboBoxItemBackgroundSelectedPointerOver" ResourceKey="Active" />
|
||||
<StaticResource x:Key="ComboBoxItemBackgroundSelectedPressed" ResourceKey="Active" />
|
||||
<StaticResource x:Key="ComboBoxItemForegroundSelected" ResourceKey="AccentText" />
|
||||
<StaticResource x:Key="ComboBoxItemForegroundSelectedPointerOver" ResourceKey="AccentText" />
|
||||
<StaticResource x:Key="ComboBoxItemForegroundSelectedPressed" ResourceKey="AccentText" />
|
||||
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundFillUnchecked" ResourceKey="Field" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundFillUncheckedPointerOver" ResourceKey="Field" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundFillUncheckedPressed" ResourceKey="Hover" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundStrokeUnchecked" ResourceKey="TextGhost" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundStrokeUncheckedPointerOver" ResourceKey="TextGhost" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundStrokeUncheckedPressed" ResourceKey="TextFaint" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundFillChecked" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundFillCheckedPointerOver" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="CheckBoxCheckBackgroundFillCheckedPressed" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="CheckBoxCheckGlyphForegroundChecked" ResourceKey="AccentInk" />
|
||||
<StaticResource x:Key="CheckBoxCheckGlyphForegroundCheckedPointerOver" ResourceKey="AccentInk" />
|
||||
<StaticResource x:Key="CheckBoxCheckGlyphForegroundCheckedPressed" ResourceKey="AccentInk" />
|
||||
<StaticResource x:Key="CheckBoxForegroundUnchecked" ResourceKey="Text" />
|
||||
<StaticResource x:Key="CheckBoxForegroundUncheckedPointerOver" ResourceKey="Text" />
|
||||
<StaticResource x:Key="CheckBoxForegroundUncheckedPressed" ResourceKey="Text" />
|
||||
<StaticResource x:Key="CheckBoxForegroundChecked" ResourceKey="Text" />
|
||||
<StaticResource x:Key="CheckBoxForegroundCheckedPointerOver" ResourceKey="Text" />
|
||||
<StaticResource x:Key="CheckBoxForegroundCheckedPressed" ResourceKey="Text" />
|
||||
|
||||
<StaticResource x:Key="ToggleSwitchFillOn" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ToggleSwitchFillOnPointerOver" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ToggleSwitchFillOnPressed" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ToggleSwitchStrokeOn" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ToggleSwitchStrokeOnPointerOver" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ToggleSwitchStrokeOnPressed" ResourceKey="Accent" />
|
||||
<StaticResource x:Key="ToggleSwitchKnobFillOn" ResourceKey="AccentInk" />
|
||||
<StaticResource x:Key="ToggleSwitchKnobFillOnPointerOver" ResourceKey="AccentInk" />
|
||||
<StaticResource x:Key="ToggleSwitchKnobFillOnPressed" ResourceKey="AccentInk" />
|
||||
<StaticResource x:Key="ToggleSwitchFillOff" ResourceKey="Hover" />
|
||||
<StaticResource x:Key="ToggleSwitchFillOffPointerOver" ResourceKey="Hover" />
|
||||
<StaticResource x:Key="ToggleSwitchFillOffPressed" ResourceKey="Hover" />
|
||||
<StaticResource x:Key="ToggleSwitchStrokeOff" ResourceKey="TextGhost" />
|
||||
<StaticResource x:Key="ToggleSwitchStrokeOffPointerOver" ResourceKey="TextGhost" />
|
||||
<StaticResource x:Key="ToggleSwitchStrokeOffPressed" ResourceKey="TextFaint" />
|
||||
<StaticResource x:Key="ToggleSwitchKnobFillOff" ResourceKey="TextFaint" />
|
||||
<StaticResource x:Key="ToggleSwitchKnobFillOffPointerOver" ResourceKey="TextFaint" />
|
||||
<StaticResource x:Key="ToggleSwitchKnobFillOffPressed" ResourceKey="TextFaint" />
|
||||
</Styles.Resources>
|
||||
|
||||
<!--
|
||||
Touch has no hover, so a pointer-over fill is one that sticks after a tap until the next touch lands
|
||||
somewhere else — the grey square the audit found left behind on Vaults' refresh button (M5). The
|
||||
transparent shapes keep their resting fill under the pointer; pressed is the feedback a finger gets.
|
||||
-->
|
||||
<Style Selector="Button.icon:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="Button.row:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="Button.secondary:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ A selected row in a list is not lit. The hosts list says why in HostsScreen.axaml — a lit row would be
|
||||
a mark with no action behind it — but only its own Border.hostrow.chosen was ever changed, and Fluent's
|
||||
ListBoxItem:selected went on painting #3D486B, a slate blue the palette has never held, under whichever
|
||||
row had been tapped last (audit M3). Pointer-over goes with it for the reason the button keys above do.
|
||||
-->
|
||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The dropdown, on TextBox.field's numbers so the two sit in one form as one kind of box: 44 tall, a
|
||||
10 radius, Field on BorderMid. The colours come from the keys above; these are the geometry Fluent sets
|
||||
as plain values.
|
||||
-->
|
||||
<Style Selector="ComboBox">
|
||||
<Setter Property="MinHeight" Value="44" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="14,0,10,0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
A checkbox's caption on the box's own line. Fluent tops the content of a CheckBox taller than its box,
|
||||
so in a 44-pixel row the label sat below the tick's centre (audit M5).
|
||||
-->
|
||||
<Style Selector="CheckBox">
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.primary">
|
||||
<Setter Property="Height" Value="48" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
@@ -46,6 +196,14 @@
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<!--
|
||||
◆ Off, because Fluent's Button theme turns it on, and the glow below is drawn outside the control by
|
||||
definition. With the clip on, the blur was cut at the button's square bounds and showed as a lighter
|
||||
rectangle behind the rounded fill on every primary button and the FAB (audit M1). Measured in the
|
||||
desktop's layout harness: ClipToBounds reads True under the theme, and cleared, the glow renders whole.
|
||||
The desktop's Button.accent carries the same setter for the same reason.
|
||||
-->
|
||||
<Setter Property="ClipToBounds" Value="False" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
@@ -86,6 +244,11 @@
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The alternative to a primary button, and in a row of the two it is on the LEFT: Android puts the action
|
||||
that confirms on the right, and every two-button row on this head had it the other way round until the
|
||||
2026-10-04 redesign (audit M12) — CANCEL | SAVE, KEEP | DELETE, CANCEL | CONNECT.
|
||||
-->
|
||||
<Style Selector="Button.secondary">
|
||||
<Setter Property="Height" Value="48" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
@@ -100,6 +263,22 @@
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<!--
|
||||
◆ Disabled is quieter than enabled, which Fluent had the wrong way round: with no rule here its #3C3C43
|
||||
disabled slab filled the outline, so SFTP's unavailable SAVE FILE was the heaviest button in its row
|
||||
(audit M4). The same flat-and-unlit treatment Button.primary:disabled gets, on an outline.
|
||||
-->
|
||||
<Style Selector="Button.secondary /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="Button.secondary:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||
</Style>
|
||||
<Style Selector="Button.secondary:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextGhost}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.danger">
|
||||
<Setter Property="Height" Value="48" />
|
||||
@@ -324,6 +503,8 @@
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="24" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<!-- Off for the glow; see the remark on Button.primary's own setter, which is the same fix. -->
|
||||
<Setter Property="ClipToBounds" Value="False" />
|
||||
</Style>
|
||||
<!-- See the remark above Button.primary's own /template/ ContentPresenter rule: BoxShadow has no home on
|
||||
a Button itself, so the fill moves down here alongside it rather than staying a plain Setter. -->
|
||||
@@ -468,9 +649,9 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<!-- Sans since the 2026-10-04 redesign: a category is a word, "SSH keys 3", rather than an identifier. -->
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border Name="Chip" Background="{TemplateBinding Background}"
|
||||
@@ -493,11 +674,88 @@
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
No segmented control either. The design draws one over the file listing for LOCAL / REMOTE, and there is
|
||||
no local side on a phone — see FilesScreen. The two-way choices that are real, the log's two logs and
|
||||
the keychain's categories, are chip rows, which is the same control at the same size without a well
|
||||
around it.
|
||||
◆ A segmented control, which this file used to argue against having.
|
||||
|
||||
The argument was that the one the v2 design drew — LOCAL / REMOTE over the file listing — had nothing to
|
||||
switch on a phone, and the choices that were real were chip rows. The 2026-10-04 redesign has a choice
|
||||
that is a segmented control in every sense: the open vault's HOSTS · KEYCHAIN · SNIPPETS, three equal,
|
||||
exclusive views of one thing, always all three, never scrolled. Chips would make it a filter; a well with
|
||||
one lit segment says it is where you are.
|
||||
|
||||
The well is Panel on BorderSubtle at 14, the lit segment Active at 10 — a step inside it on the ladder,
|
||||
as a button sits inside a card. Sans and 14, since these are words rather than identifiers.
|
||||
-->
|
||||
<Style Selector="Border.segmented">
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtle}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="Padding" Value="4" />
|
||||
</Style>
|
||||
<Style Selector="RadioButton.segment">
|
||||
<Setter Property="MinHeight" Value="40" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="Margin" Value="2,0" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border Background="{TemplateBinding Background}" CornerRadius="{TemplateBinding CornerRadius}"
|
||||
MinHeight="{TemplateBinding MinHeight}">
|
||||
<ContentPresenter Content="{TemplateBinding Content}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="RadioButton.segment:checked">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
<Style Selector="RadioButton.segment:pressed">
|
||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The eye beside a secret box: outlined like the box it sits beside, lit while it is revealing. Lit with
|
||||
the accent ring the focused box wears, so "you can read this" is said in the same colour as "you are
|
||||
typing here".
|
||||
-->
|
||||
<Style Selector="ToggleButton.reveal">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton.reveal /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton.reveal:checked /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource TerminalTabAccent}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
One Material icon: a codepoint of the embedded font the desktop draws its icons with, standing where a
|
||||
Unicode glyph stood (audit M7). Hidden from the accessibility tree — the codepoint reads as nothing, and the
|
||||
control around it carries the name — and it inherits its colour, so an icon in a button is the button's.
|
||||
-->
|
||||
<Style Selector="TextBlock.icon">
|
||||
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="AutomationProperties.AccessibilityView" Value="Raw" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBox.field">
|
||||
<Setter Property="Height" Value="44" />
|
||||
@@ -657,25 +915,45 @@
|
||||
row's own height around — now say TextWrapping="NoWrap" beside their TextTrimming, which is the pairing
|
||||
that was always meant and was previously being had by accident.
|
||||
-->
|
||||
<!--
|
||||
◆ PROSE IS IN THE SANS FACE NOW, AND THAT IS THE RECORDED DECISION CATCHING UP RATHER THAN A NEW ONE.
|
||||
|
||||
docs/design-import-gaps.md ("The phone catches up") says the phone shares the desktop's faces, and on
|
||||
the desktop a hint, a caption and a settings title are Montserrat while the mono is kept for what is
|
||||
machine-shaped. These three still set MonoFont, so every paragraph on this head was JetBrains Mono —
|
||||
long, wide-set lines that the 2026-10-02 audit (M2) found tiring to read on the enrolment, settings and
|
||||
SFTP screens. They now take the application's default family, which is Montserrat (see
|
||||
DodoSshAndroidApplication.CustomizeAppBuilder).
|
||||
|
||||
One size step up with the change: Montserrat at the old mono sizes set noticeably smaller on the line,
|
||||
and 10-pixel captions were already at the floor. A block that shows an address, a path or a command
|
||||
rather than a sentence says Classes="detail code" at its own site — see TextBlock.detail.code below — so
|
||||
the split is still made where the content is known.
|
||||
-->
|
||||
<Style Selector="TextBlock.detail">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.body">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="LineHeight" Value="19" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.title">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<!--
|
||||
The other half of that split: a detail line that is an address, a path, a fingerprint fragment or a
|
||||
command. Combined with .detail rather than replacing it, so wrapping and colour stay the caption's.
|
||||
-->
|
||||
<Style Selector="TextBlock.detail.code">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
A screen's own name, in the header. The one place this design is not monospaced: the design sets every
|
||||
|
||||
@@ -56,16 +56,19 @@
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*,Auto,Auto">
|
||||
|
||||
<!-- ============ header ============ -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto" Height="56" Margin="8,0"
|
||||
IsVisible="{Binding !HasRemoteBatch}">
|
||||
<!--
|
||||
◆ Gone on a wide surface, where the rail is how you leave and the hub it points at is not drawn at
|
||||
all. An arrow to a screen nothing else can reach would be the one control on this header that leads
|
||||
somewhere the layout has removed. See PhoneShell.IsWide.
|
||||
-->
|
||||
<Button Grid.Column="0" Classes="icon" Content="←"
|
||||
<Button Grid.Column="0" Classes="icon" AutomationProperties.Name="Back"
|
||||
IsVisible="{Binding !$parent[views:PhoneShell].IsWide}"
|
||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.More}" />
|
||||
CommandParameter="{x:Static vm:ShellScreen.More}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" />
|
||||
</Button>
|
||||
|
||||
<TextBlock Grid.Column="1" Classes="heading" Text="SFTP" Margin="4,0"
|
||||
IsVisible="{Binding ShowsHostPicker}" />
|
||||
@@ -78,10 +81,59 @@
|
||||
<TextBlock Text="{Binding ConnectedTo}" TextTrimming="CharacterEllipsis" />
|
||||
</Border>
|
||||
|
||||
<Button Grid.Column="3" Classes="icon" Content="↻" Command="{Binding RefreshRemoteCommand}"
|
||||
IsVisible="{Binding IsConnected}" />
|
||||
<!--
|
||||
◆ The other half of this host: a terminal on the machine whose files are showing. The redesign puts
|
||||
the two side by side as one session's two views; this application holds them as two connections,
|
||||
so what this does is the desktop's "Open terminal" — a new shell to the same host, through the same
|
||||
command the session sidebar uses. A bucket has no shell, so it is SFTP only.
|
||||
-->
|
||||
<Button Grid.Column="3" Classes="icon" AutomationProperties.Name="Open a terminal on this host"
|
||||
IsVisible="{Binding IsHostConnected}"
|
||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).OpenTerminalForFilesHostCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" />
|
||||
</Button>
|
||||
<Button Grid.Column="4" Classes="icon" AutomationProperties.Name="Refresh"
|
||||
Command="{Binding RefreshRemoteCommand}" IsVisible="{Binding IsConnected}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" />
|
||||
</Button>
|
||||
<!-- CLOSE was a button of its own in the block at the foot of the screen; it is the session's, so it
|
||||
is on the session's header. -->
|
||||
<Button Grid.Column="5" Classes="icon" AutomationProperties.Name="Close this session"
|
||||
Command="{Binding DisconnectCommand}" IsVisible="{Binding IsConnected}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
◆ THE SELECTION BAR, in the header's row and in its place, as the hosts screen's action bar is.
|
||||
|
||||
The 2026-10-04 redesign's answer to the audit's M11, where seven buttons sat under the listing whether
|
||||
or not anything was chosen, and four of them answered a tap on nothing with a line in the status. Now
|
||||
the actions appear when there is something to act on: a tap on a file, or a tick. SAVE is on the bar
|
||||
because it is the one somebody reaches for; the four that change a file are behind the ⋮, in a sheet
|
||||
with room to say what each does. The ✕ lets go of the choice.
|
||||
-->
|
||||
<Border Grid.Row="0" Height="56" Background="{StaticResource Active}" Padding="4,0"
|
||||
IsVisible="{Binding HasRemoteBatch}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
<Button Grid.Column="0" Classes="icon" AutomationProperties.Name="Clear the selection"
|
||||
Command="{Binding ClearRemoteBatchCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" Foreground="{StaticResource Text}" />
|
||||
</Button>
|
||||
<TextBlock Grid.Column="1" Margin="6,0" FontSize="16" FontWeight="Bold" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Text}" Text="{Binding RemoteBatchLabel}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Button Grid.Column="2" Classes="icon" AutomationProperties.Name="Save to this phone"
|
||||
IsEnabled="{Binding CanDownload}" Click="OnSaveFile">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" Foreground="{StaticResource Text}" />
|
||||
</Button>
|
||||
<Button Grid.Column="3" Classes="icon" AutomationProperties.Name="More actions"
|
||||
Command="{Binding OpenEntryMenuCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" Foreground="{StaticResource Text}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ opening one ============ -->
|
||||
<!--
|
||||
ShowsRemoteInvitation rather than !IsConnected, which stopped being the same question when a
|
||||
@@ -164,9 +216,9 @@
|
||||
<!-- ◆ In full, and wrapping rather than clipping. See the .fingerprint style. -->
|
||||
<SelectableTextBlock Classes="fingerprint" Text="{Binding PendingHostKey.Fingerprint}" />
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="TRUST AND OPEN"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="TRUST AND OPEN"
|
||||
Command="{Binding TrustHostKeyCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding RejectHostKeyCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -225,12 +277,12 @@
|
||||
</Grid>
|
||||
|
||||
<!-- ============ where we are ============ -->
|
||||
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding ShowsRemoteContents}" Margin="0,0,0,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
|
||||
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="10"
|
||||
Command="{Binding RemoteUpCommand}">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="↑" Foreground="{StaticResource AccentText}" />
|
||||
<Button Classes="row" MinHeight="40" Padding="9,0" CornerRadius="10"
|
||||
AutomationProperties.Name="Up one folder" Command="{Binding RemoteUpCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="18" Foreground="{StaticResource AccentText}" />
|
||||
</Button>
|
||||
<ItemsControl ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
@@ -241,7 +293,7 @@
|
||||
<Button Classes="row" MinHeight="36" Padding="7,0" CornerRadius="10"
|
||||
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
|
||||
CommandParameter="{Binding Path}">
|
||||
<TextBlock Classes="detail" FontSize="11" Text="{Binding Name}" />
|
||||
<TextBlock Classes="detail code" FontSize="11" Text="{Binding Name}" />
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
@@ -265,7 +317,7 @@
|
||||
answers: an empty scrolling strip under the breadcrumb would read as a loading row rather than as
|
||||
"this host has nothing pinned".
|
||||
-->
|
||||
<ScrollViewer Grid.Row="5" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
<ScrollViewer Grid.Row="5" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding HasConnectedPins}" Margin="0,0,0,4">
|
||||
<ItemsControl ItemsSource="{Binding ConnectedPinnedPaths}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
@@ -317,6 +369,8 @@
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Margin" Value="10,1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<!-- A name for TalkBack rather than the row view model's type name (audit H4). -->
|
||||
<Setter Property="AutomationProperties.Name" Value="{ReflectionBinding Name}" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
@@ -339,25 +393,33 @@
|
||||
<CheckBox Grid.Column="0" Width="40" MinWidth="0" VerticalAlignment="Center"
|
||||
IsChecked="{Binding IsChecked}" Click="OnRemoteRowChecked" />
|
||||
|
||||
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" FontSize="12" Text="▸"
|
||||
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsNavigable}" />
|
||||
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" FontSize="12" Text="·"
|
||||
Foreground="{StaticResource TextFaint}" IsVisible="{Binding IsFile}" />
|
||||
<!--
|
||||
◆ A folder looks like a folder now. It was a 12-pixel purple ▸, and a file a dot, which on a
|
||||
listing of names that are all set in the same face left a directory to be told apart by a
|
||||
glyph a thumb covers (audit M11). The tiles are the redesign's: the accent's wash for a place
|
||||
to go, a quieter one for a thing to take.
|
||||
-->
|
||||
<Border Grid.Column="1" Width="38" Height="38" CornerRadius="10"
|
||||
Background="{StaticResource AccentSoft}" IsVisible="{Binding IsNavigable}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="20" Foreground="{StaticResource AccentText}" />
|
||||
</Border>
|
||||
<Border Grid.Column="1" Width="38" Height="38" CornerRadius="10"
|
||||
Background="{StaticResource Hover}" IsVisible="{Binding IsFile}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="20" Foreground="{StaticResource TextFaint}" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="2" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Name}"
|
||||
<StackPanel Grid.Column="2" Margin="12,0,0,0" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" Text="{Binding Name}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<TextBlock Classes="detail" Text="{Binding Size}"
|
||||
<TextBlock Classes="detail code" Text="{Binding Size}"
|
||||
IsVisible="{Binding Size, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<!-- Real POSIX bits from the server, not a plausible-looking mode. -->
|
||||
<TextBlock Classes="detail" Text="{Binding Permissions}" />
|
||||
<TextBlock Classes="detail code" Text="{Binding Permissions}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="3" Classes="detail" Text="{Binding Modified}"
|
||||
<TextBlock Grid.Column="3" Classes="detail code" Text="{Binding Modified}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -410,7 +472,7 @@
|
||||
<!-- ============ what to do with the chosen entry ============ -->
|
||||
<!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. -->
|
||||
<Border Grid.Row="8" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="14,8">
|
||||
<StackPanel Spacing="9">
|
||||
|
||||
<!-- ◆ Counted, and it replaces the actions in place rather than stacking over the row it names. -->
|
||||
@@ -423,9 +485,9 @@
|
||||
<TextBlock Classes="body" Foreground="{StaticResource DangerText}"
|
||||
Text="{Binding PendingRemoteDeletion.Consequence}" />
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="danger" Height="44" Content="DELETE"
|
||||
<Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
|
||||
Command="{Binding ConfirmDeleteRemoteCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="KEEP"
|
||||
Command="{Binding CancelDeleteRemoteCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -443,7 +505,7 @@
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemoteMove.Question}" />
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap" Text="{Binding PendingRemoteMove.FullPath}" />
|
||||
<TextBlock Classes="detail code" TextWrapping="Wrap" Text="{Binding PendingRemoteMove.FullPath}" />
|
||||
<TextBox Classes="field" Text="{Binding RemoteMoveTarget}"
|
||||
PlaceholderText="{Binding PendingRemoteMove.FieldHint}">
|
||||
<TextBox.KeyBindings>
|
||||
@@ -452,10 +514,10 @@
|
||||
</TextBox>
|
||||
<TextBlock Classes="body" Text="{Binding PendingRemoteMove.Note}" />
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44"
|
||||
Content="{Binding PendingRemoteMove.ConfirmLabel}"
|
||||
Command="{Binding ConfirmMoveRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelMoveRemoteCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -521,10 +583,10 @@
|
||||
Text="{Binding PendingRemotePermissions.Preview}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="APPLY"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="APPLY"
|
||||
Command="{Binding ConfirmRemotePermissionsCommand}"
|
||||
IsEnabled="{Binding PendingRemotePermissions.HasChanges}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelRemotePermissionsCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -579,54 +641,13 @@
|
||||
IsVisible="{Binding HasTransfers}" Command="{Binding ClearCompletedCommand}" />
|
||||
|
||||
<!--
|
||||
◆ The two directions, on their own row above the pair below — because DELETE is the button on this
|
||||
screen that nothing can undo, and it must not sit at a thumb's width from the ones somebody presses
|
||||
often. ADD FILES is the primary of the two: it is the one that needs no selection, and the one this
|
||||
screen exists for on a phone.
|
||||
|
||||
SAVE FILE takes the selected row rather than several, and that asymmetry is the platform's: the
|
||||
save picker names one destination. CanDownload is the desktop's own flag for the same question —
|
||||
a file is selected and something is connected — and is reused rather than restated here.
|
||||
◆ The seven buttons that were here are gone, and every one of them went somewhere it is wanted:
|
||||
ADD FILES is the floating button over the listing, SAVE FILE is on the selection bar, RENAME, MOVE,
|
||||
PERMS and DELETE are in the sheet behind its ⋮, and CLOSE is on the header. The paragraph explaining
|
||||
the first two went with them, to the sheet's own SAVE row, where it is read at the moment it is true.
|
||||
What is left down here is what is genuinely about the session as a whole: the questions above, the
|
||||
queue, and the status.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="ADD FILES" Click="OnAddFiles" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="SAVE FILE" Click="OnSaveFile"
|
||||
IsEnabled="{Binding CanDownload}" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
◆ The two that put a file right rather than taking it away, above the row DELETE is on and away
|
||||
from it. Both open a form in place of these buttons — see the card above — because a phone has
|
||||
nowhere to put a text field except where something else already was.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="RENAME"
|
||||
Command="{Binding RenameRemoteCommand}" IsEnabled="{Binding CanRenameRemote}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
|
||||
Command="{Binding MoveRemoteCommand}" IsEnabled="{Binding CanMoveRemote}" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
◆ PERMS beside DELETE rather than on the row above, which is full: it is the fourth thing that can
|
||||
be done to a chosen row and the third that puts one right. Dark on a bucket, where an object has an
|
||||
access policy rather than nine bits — see CanChangeRemotePermissions.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="PERMS"
|
||||
Command="{Binding ChangeRemotePermissionsCommand}"
|
||||
IsEnabled="{Binding CanChangeRemotePermissions}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="DELETE"
|
||||
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CLOSE"
|
||||
Command="{Binding DisconnectCommand}" />
|
||||
<Panel Grid.Column="2" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="body" IsVisible="{Binding !IsAskingAboutRemoteEntry}"
|
||||
Text="ADD FILES picks documents to send. SAVE FILE asks where the selected file should be kept — the file is created there when you choose it, so a transfer that fails leaves it empty." />
|
||||
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
|
||||
Text="{Binding Status}" />
|
||||
@@ -634,6 +655,105 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ add ============ -->
|
||||
<!--
|
||||
◆ ADD FILES as the floating button, the one shape every add on this head takes. In the listing's row
|
||||
so it rides with the list rather than over the questions and the queue under it. Not while something
|
||||
is chosen: the bar across the top is then about that, and an add beside it would be a second subject.
|
||||
-->
|
||||
<Button Grid.Row="6" Classes="fab" Margin="0,0,18,18" HorizontalAlignment="Right" VerticalAlignment="Bottom"
|
||||
AutomationProperties.Name="Add files from this phone" Click="OnAddFiles">
|
||||
<Button.IsVisible>
|
||||
<MultiBinding Converter="{x:Static BoolConverters.And}">
|
||||
<Binding Path="IsConnected" />
|
||||
<Binding Path="!HasRemoteBatch" />
|
||||
</MultiBinding>
|
||||
</Button.IsVisible>
|
||||
<TextBlock Classes="icon" Text="" FontSize="24" />
|
||||
</Button>
|
||||
|
||||
<!-- ============ ◆ what can be done to the chosen rows ============ -->
|
||||
<!--
|
||||
The add sheet's shape on the hosts screen, doing the files screen's job: a scrim, a bottom panel with
|
||||
its top corners rounded, dismissible because "which of these" has no wrong answer. DELETE is under a rule
|
||||
and in red — it was the one neutral DELETE on this head (audit M11) — and each entry is dark rather than
|
||||
gone when it cannot be done, so the four keep their places under a thumb.
|
||||
|
||||
Each entry also lowers the sheet as it runs: three of the four open a question in the space under the
|
||||
listing, and the sheet left over it would be the one thing in the way of answering.
|
||||
-->
|
||||
<Panel Grid.Row="0" Grid.RowSpan="9" IsVisible="{Binding IsEntryMenuOpen}">
|
||||
<Button Classes="scrim" Command="{Binding CloseEntryMenuCommand}" />
|
||||
|
||||
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||
CornerRadius="22,22,0,0" Padding="12,10,12,16">
|
||||
<StackPanel>
|
||||
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,12" />
|
||||
|
||||
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold" Margin="12,0,12,8"
|
||||
Text="{Binding RemoteBatchLabel}" TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<Button Classes="row" IsEnabled="{Binding CanDownload}" Click="OnSaveFile"
|
||||
AutomationProperties.Name="Save to this phone">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22" />
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Save to this phone" />
|
||||
<TextBlock Classes="detail" Text="Asks where first. The file is made there when you choose, so a transfer that fails leaves it empty." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding RenameRemoteCommand}" IsEnabled="{Binding CanRenameRemote}"
|
||||
Click="OnEntryMenuChosen" AutomationProperties.Name="Rename">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22" />
|
||||
<TextBlock Grid.Column="2" FontSize="14.5" FontWeight="SemiBold" Text="Rename"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding MoveRemoteCommand}" IsEnabled="{Binding CanMoveRemote}"
|
||||
Click="OnEntryMenuChosen" AutomationProperties.Name="Move">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22" />
|
||||
<TextBlock Grid.Column="2" FontSize="14.5" FontWeight="SemiBold" Text="Move"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding ChangeRemotePermissionsCommand}"
|
||||
IsEnabled="{Binding CanChangeRemotePermissions}" Click="OnEntryMenuChosen"
|
||||
AutomationProperties.Name="Permissions">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22" />
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Permissions" />
|
||||
<TextBlock Classes="detail" Text="Read, write and execute for the owner, the group and everybody else." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Border Height="1" Background="{StaticResource Border}" Margin="12,6" />
|
||||
|
||||
<Button Classes="row" Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}"
|
||||
Click="OnEntryMenuChosen" AutomationProperties.Name="Delete">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22"
|
||||
Foreground="{StaticResource DangerText}" />
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource DangerText}"
|
||||
Text="Delete" />
|
||||
<TextBlock Classes="detail" Text="Asks first, and says how many. There is no undo." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -41,6 +41,19 @@ internal sealed partial class FilesScreen : UserControl
|
||||
/// rather than of any row, and each listing replaces every row it has. See
|
||||
/// <c>TransfersViewModel.RemoteChecksChanged</c>.
|
||||
/// </remarks>
|
||||
/// <summary>An entry in the actions sheet was chosen: the sheet goes down as its command runs.</summary>
|
||||
/// <remarks>
|
||||
/// Three of the four open a question under the listing, and the sheet left over it would be the one thing
|
||||
/// in the way of answering. The command is bound in the markup; this only lowers the sheet.
|
||||
/// </remarks>
|
||||
private void OnEntryMenuChosen(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TransfersViewModel transfers)
|
||||
{
|
||||
transfers.IsEntryMenuOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) =>
|
||||
(DataContext as TransfersViewModel)?.RemoteChecksChanged();
|
||||
|
||||
@@ -116,6 +129,11 @@ internal sealed partial class FilesScreen : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
// From the actions sheet as well as the bar, and the sheet goes down either way: the save picker is
|
||||
// about to cover the screen, and coming back to the sheet still up would be coming back to a question
|
||||
// already answered.
|
||||
transfers.IsEntryMenuOpen = false;
|
||||
|
||||
if (transfers.SelectedRemoteEntry is not { IsFile: true } row)
|
||||
{
|
||||
transfers.Status = "Choose a file on the host to save.";
|
||||
|
||||
@@ -67,21 +67,57 @@
|
||||
held as ids rather than as rows, see VaultViewModel.chosenHostIds — so searching for the second half
|
||||
of a selection and ticking it is a thing this screen supports rather than merely survives.
|
||||
-->
|
||||
<Border Grid.Row="0" Margin="14,10,14,4" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="10" Height="44">
|
||||
<Border Grid.Row="0" Margin="16,10,16,4" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="12" Height="46">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Text="⌕" Foreground="{StaticResource TextFaint}" FontSize="13"
|
||||
Margin="13,0,0,0" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts"
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" Foreground="{StaticResource TextFaint}"
|
||||
FontSize="20" Margin="12,0,0,0" />
|
||||
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts, or type user@host"
|
||||
Padding="9,0" VerticalContentAlignment="Center"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
Foreground="{StaticResource Text}" FontFamily="{StaticResource MonoFont}" FontSize="12" />
|
||||
Foreground="{StaticResource Text}" FontSize="14" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ sync + conflicts ============ -->
|
||||
<StackPanel Grid.Row="1" Margin="16,4,16,0" Spacing="7">
|
||||
|
||||
<!--
|
||||
◆ A MACHINE THAT IS NOT SAVED. When the search box holds user@host, this card offers to connect to it
|
||||
with a password, and nothing is written to the keychain — the desktop palette's typed-address row, in
|
||||
this screen's shape. The terminal screen's own box does the same but is drawn only while nothing is
|
||||
open; this one is here whatever is running. See VaultViewModel.FilterAddress.
|
||||
|
||||
Above the list rather than over it, like the panels below: the hosts that also match what was typed
|
||||
stay in view, and one of them may be the same machine with a key already bound to it.
|
||||
|
||||
Classes="field secret" on the password for the reason the terminal screen's box gives: the class is
|
||||
what keeps the password out of the software keyboard's dictionary, not only off the screen.
|
||||
-->
|
||||
<Border IsVisible="{Binding HasFilterAddress}" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="12" Padding="13,11">
|
||||
<StackPanel Spacing="9">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding FilterAddressText}" FontSize="13"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NOT SAVED" Margin="10,0,0,0"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
<TextBox Classes="field secret" Text="{Binding FilterAddressPassword}" PlaceholderText="password">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding ConnectToFilterAddressCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Classes="primary" Height="44" Content="CONNECT"
|
||||
Command="{Binding ConnectToFilterAddressCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Why what was typed is not an address yet, or why CONNECT refused — a sentence about the box. -->
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding FilterAddressRefusal}" IsVisible="{Binding HasFilterAddressRefusal}" />
|
||||
|
||||
<TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap" />
|
||||
|
||||
<!--
|
||||
@@ -181,11 +217,11 @@
|
||||
Text="{Binding ChosenBindingToBringNote}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44"
|
||||
Content="{Binding ChosenHostVaultPanelVerb}"
|
||||
Command="{Binding ConfirmSendChosenHostsToAVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelSendChosenHostsToAVaultCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -224,9 +260,9 @@
|
||||
Text="A group lends its port, its account and its key to every host filed under it that says nothing itself, so this can change what these machines dial. Nothing else about them moves." />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="FILE"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="FILE"
|
||||
Command="{Binding ConfirmRegroupChosenHostsCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelRegroupChosenHostsCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -250,9 +286,9 @@
|
||||
Text="{Binding PendingDeletion.Usage}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="danger" Height="44" Content="DELETE"
|
||||
<Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
|
||||
Command="{Binding ConfirmDeleteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
|
||||
Command="{Binding CancelDeleteCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -291,9 +327,9 @@
|
||||
</CheckBox>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="danger" Height="44" Content="DELETE"
|
||||
<Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
|
||||
Command="{Binding ConfirmDeleteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
|
||||
Command="{Binding CancelDeleteCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -338,9 +374,9 @@
|
||||
Text="The group goes with the groups inside it and every host filed under them, all re-encrypted with the other vault's key. Any group it is nested under stays behind, so it arrives at the top level, and the hosts' tags stay behind with it." />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="MOVE"
|
||||
Command="{Binding ConfirmMoveGroupCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelMoveGroupCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -374,11 +410,21 @@
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Margin" Value="10,1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Margin" Value="16,4" />
|
||||
<Setter Property="CornerRadius" Value="16" />
|
||||
<!--
|
||||
A name for TalkBack. Without it the item announced its view model's type name —
|
||||
"DodoSSH.Client.Shell.ViewModels.HostRowViewModel" (audit H4). Both row kinds in this list carry a
|
||||
Label, and a reflection binding is what lets one setter serve the two of them.
|
||||
-->
|
||||
<Setter Property="AutomationProperties.Name" Value="{ReflectionBinding Label}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ A host is a card since the 2026-10-04 redesign: Panel on a hairline at the 16 radius, where it was a
|
||||
transparent row on the canvas. A list of machines is a list of things, each one a target, and a card
|
||||
says where one ends and the next begins without a rule between them.
|
||||
|
||||
◆ The ticked row, filled. On a Border inside the item rather than on the item's own selected state,
|
||||
because "ticked" is a property of the host and the list has no idea about it — the fill and the
|
||||
tick at the right-hand end are the same fact drawn twice, which is deliberate: a fill alone is
|
||||
@@ -386,11 +432,14 @@
|
||||
360dp row.
|
||||
-->
|
||||
<Style Selector="Border.hostrow">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="16" />
|
||||
</Style>
|
||||
<Style Selector="Border.hostrow.chosen">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource TerminalTabAccent}" />
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
|
||||
@@ -402,7 +451,7 @@
|
||||
the filter, which is what a heading over a filtered list has to say.
|
||||
-->
|
||||
<DataTemplate DataType="vm:SidebarGroupHeader">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto" Margin="8,12,8,5">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto" Margin="4,14,0,2">
|
||||
<TextBlock Grid.Column="0" Classes="detail" Text="{Binding Chevron}" VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
@@ -419,8 +468,9 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Count}" FontSize="9"
|
||||
VerticalAlignment="Center" />
|
||||
<Border Grid.Column="2" Classes="tag outline" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Count}" />
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ The phone's only way to do anything to a group. The desktop reaches all three from a menu on
|
||||
@@ -435,25 +485,40 @@
|
||||
|
||||
Hidden on the ungrouped heading, which has no group behind it to act on.
|
||||
-->
|
||||
<Button Grid.Column="3" Classes="icon" FontSize="15" Content="⋯" Margin="4,0,0,0"
|
||||
<Button Grid.Column="3" Classes="icon" Margin="4,0,0,0"
|
||||
AutomationProperties.Name="{Binding Label, StringFormat='Group {0}: edit, move or delete'}"
|
||||
IsVisible="{Binding GroupId, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
Command="{Binding $parent[views:HostsScreen].((vm:VaultViewModel)DataContext).OpenGroupSheetCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="20" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- A host. -->
|
||||
<DataTemplate DataType="vm:HostRowViewModel">
|
||||
<Border Classes="hostrow" Classes.chosen="{Binding IsChosen}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto" MinHeight="56" Margin="14,12">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto" MinHeight="48" Margin="12,11">
|
||||
|
||||
<!-- Green means a terminal is open on this host right now. See the .dot style. -->
|
||||
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}" Width="8" Height="8"
|
||||
VerticalAlignment="Center" />
|
||||
<!--
|
||||
◆ The redesign's tile: a terminal mark on the accent's wash, with the live dot on its corner.
|
||||
Green still means one thing only — a terminal is open on this host right now, see the .dot
|
||||
style — and riding the tile it says which machine without spending a column on it.
|
||||
-->
|
||||
<Panel Grid.Column="0" Width="42" Height="42" VerticalAlignment="Center">
|
||||
<Border CornerRadius="12" Background="{StaticResource AccentSoft}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="21" Foreground="{StaticResource AccentText}" />
|
||||
</Border>
|
||||
<Border Width="13" Height="13" CornerRadius="7" Background="{StaticResource Panel}"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,-3,-3"
|
||||
IsVisible="{Binding IsConnected}">
|
||||
<Ellipse Classes="dot live" Width="9" Height="9" />
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<StackPanel Grid.Column="1" Margin="12,0,0,0" Spacing="3" VerticalAlignment="Center">
|
||||
<StackPanel Grid.Column="1" Margin="14,0,0,0" Spacing="4" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="14" FontWeight="SemiBold"
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="15" FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border Classes="tag outline" IsVisible="{Binding HasVaultBadge}">
|
||||
<TextBlock Text="{Binding VaultBadge}" />
|
||||
@@ -464,10 +529,10 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
|
||||
<TextBlock Classes="detail code" FontSize="11.5" Text="{Binding Address}"
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="·" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Authentication}" />
|
||||
<TextBlock Classes="detail" Text="·" />
|
||||
<TextBlock Classes="detail" Text="{Binding Authentication}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
@@ -499,8 +564,8 @@
|
||||
Background="{StaticResource LiveWash}">
|
||||
<TextBlock Text="live" Foreground="{StaticResource Live}" />
|
||||
</Border>
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" IsVisible="{Binding !IsConnected}" />
|
||||
<TextBlock Grid.Column="2" Classes="icon" Text="" Foreground="{StaticResource TextGhost}"
|
||||
FontSize="22" IsVisible="{Binding !IsConnected}" />
|
||||
|
||||
<!--
|
||||
◆ The tick, in a column of its own at the far end rather than in place of the dot or the
|
||||
@@ -509,9 +574,8 @@
|
||||
choosing a host hide a fact about it. An Auto column takes no width while the mark is
|
||||
collapsed, so a list with nothing ticked is laid out exactly as it was.
|
||||
-->
|
||||
<TextBlock Grid.Column="3" Text="✓" Margin="10,0,0,0" FontSize="15"
|
||||
Foreground="{StaticResource AccentText}" VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsChosen}" />
|
||||
<TextBlock Grid.Column="3" Classes="icon" Text="" Margin="10,0,0,0" FontSize="22"
|
||||
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsChosen}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
@@ -604,9 +668,9 @@
|
||||
Text="A host inside this group uses these wherever it says nothing itself. They are read at connect time rather than copied in, so changing one here changes every host that never overrode it." />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="SAVE"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="SAVE"
|
||||
Command="{Binding SaveGroupCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelGroupEditCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -628,10 +692,13 @@
|
||||
twelfth machine while eleven are ticked for deletion is worse, because it does something and the
|
||||
something is not what the screen is about.
|
||||
-->
|
||||
<Button Grid.Row="2" Classes="fab" Content="+" Margin="0,0,18,18"
|
||||
<Button Grid.Row="2" Classes="fab" Margin="0,0,18,18"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Bottom"
|
||||
AutomationProperties.Name="Add a host or a group"
|
||||
IsVisible="{Binding ShowsAddButton}"
|
||||
Command="{Binding OpenAddSheetCommand}" />
|
||||
Command="{Binding OpenAddSheetCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="26" />
|
||||
</Button>
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -646,9 +713,10 @@
|
||||
"Which kind of thing are you adding" has no wrong answer and no answer at all is one of them.
|
||||
|
||||
And it lives here rather than in PhoneShell. HostKeySheet sits up there because the files screen raises
|
||||
it too; nothing but this screen raises this. The cost is that the scrim covers the screen and not the
|
||||
64px nav bar underneath, so the four destinations stay lit and tappable — which for a menu is right:
|
||||
navigating away is a perfectly good way to decide not to add anything.
|
||||
it too; nothing but this screen raises this. The cost used to be that the scrim covered the screen and
|
||||
not the header or the nav bar, which stayed lit and tappable round every sheet (audit M12). PhoneShell
|
||||
now dims those two rows as well while any sheet here is up, and a tap on them lowers the sheet — see
|
||||
PhoneShell.ShowsSheetScrim.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsAddSheetOpen}">
|
||||
|
||||
@@ -670,7 +738,7 @@
|
||||
|
||||
<Button Classes="row" Margin="0,10,0,0" Command="{Binding NewHostCommand}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="New host" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="New host" />
|
||||
<TextBlock Classes="detail" FontSize="10.5"
|
||||
Text="A machine to connect to, filed under a group if you want one." />
|
||||
</StackPanel>
|
||||
@@ -678,7 +746,7 @@
|
||||
|
||||
<Button Classes="row" Command="{Binding NewGroupCommand}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="New group" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="New group" />
|
||||
<TextBlock Classes="detail" FontSize="10.5"
|
||||
Text="A heading for hosts, and the port, user and key they fall back to." />
|
||||
</StackPanel>
|
||||
@@ -742,19 +810,19 @@
|
||||
|
||||
<Button Classes="row" IsVisible="{Binding HasOneChosenHost}"
|
||||
Command="{Binding ConnectToChosenHostCommand}">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
Text="Connect" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" IsVisible="{Binding HasOneChosenHost}"
|
||||
Command="{Binding BrowseChosenHostCommand}">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
Text="Connect via SFTP" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding MoveChosenHostsToVaultCommand}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Move to vault" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Move to vault" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Re-encrypted with the other vault's key. Groups and tags stay behind." />
|
||||
</StackPanel>
|
||||
@@ -762,19 +830,19 @@
|
||||
|
||||
<Button Classes="row" Command="{Binding CopyChosenHostsToVaultCommand}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Copy to vault" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Copy to vault" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="A second copy over there, and these stay where they are." />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding RegroupChosenHostsCommand}">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
Text="Change group" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding DuplicateChosenHostsCommand}">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
Text="Duplicate" />
|
||||
</Button>
|
||||
|
||||
@@ -782,7 +850,7 @@
|
||||
|
||||
<Button Classes="row" Command="{Binding DeleteChosenHostsCommand}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Danger}" Text="Remove" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Asks first, and says how many. There is no undo." />
|
||||
@@ -816,34 +884,73 @@
|
||||
the box the next tap reads, and it would satisfy the length check that decides whether to raise this at
|
||||
all. See CancelConnectPassword.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsAskingForConnectPassword}">
|
||||
<Panel x:Name="PasswordSheet" IsVisible="{Binding IsAskingForConnectPassword}">
|
||||
|
||||
<Button Classes="scrim" Command="{Binding CancelConnectPasswordCommand}" />
|
||||
|
||||
<!--
|
||||
◆ The 2026-10-04 redesign's connect sheet. Three changes from the audit (M12, and the placeholder-as-
|
||||
label finding), and one from the redesign itself:
|
||||
|
||||
· CANCEL on the left and CONNECT on the right, which is the order Android puts a confirming action
|
||||
in — the sheet had them the other way round, and so did every two-button row on this head;
|
||||
· the box has a label over it, so what it is for is still on screen once something is typed;
|
||||
· it takes the focus as it opens — see HostsScreen.FocusThePasswordBox — so the keyboard is up
|
||||
without a second tap on the one box the sheet exists for;
|
||||
· the host is named at the top with its address under it, in the card shape the list draws it in,
|
||||
and a ✕ beside it closes the sheet as the scrim does.
|
||||
|
||||
The eye beside the box reveals what was typed, for the one case a phone keyboard makes common: a
|
||||
password typed blind on glass is a password typed wrong. It is TextBox.RevealPassword, so the secret
|
||||
is never copied anywhere to be shown.
|
||||
-->
|
||||
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||
CornerRadius="22,22,0,0" Padding="20,18,20,16">
|
||||
<StackPanel Spacing="10">
|
||||
CornerRadius="22,22,0,0" Padding="20,10,20,18">
|
||||
<StackPanel Spacing="12">
|
||||
|
||||
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,6" />
|
||||
HorizontalAlignment="Center" Margin="0,0,0,4" />
|
||||
|
||||
<!--
|
||||
It names the host, as the bar did and for the same reason: the row it was tapped on is behind a
|
||||
sheet and a scrim, and "which machine is this password for" is the one thing somebody about to
|
||||
type a secret has to be sure of.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="label" Text="CONNECT TO" />
|
||||
<TextBlock Classes="mono" FontSize="11" VerticalAlignment="Center"
|
||||
Text="{Binding SelectedHost.Label}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="Auto,12,*,Auto">
|
||||
<Border Grid.Column="0" Width="42" Height="42" CornerRadius="12"
|
||||
Background="{StaticResource AccentSoft}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="21" Foreground="{StaticResource AccentText}" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="15" FontWeight="SemiBold"
|
||||
Text="{Binding SelectedHost.Label}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail code" Text="{Binding SelectedHost.Address}"
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="3" Classes="icon" AutomationProperties.Name="Close"
|
||||
Command="{Binding CancelConnectPasswordCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<TextBox Classes="field secret" Text="{Binding ConnectPassword}" PlaceholderText="password">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding ConnectCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="label" Text="PASSWORD" />
|
||||
<Grid ColumnDefinitions="*,6,Auto">
|
||||
<TextBox x:Name="PasswordBox" Grid.Column="0" Classes="field secret" Height="48"
|
||||
Text="{Binding ConnectPassword}" PlaceholderText="the account's password"
|
||||
AutomationProperties.Name="Password">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding ConnectCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<ToggleButton Grid.Column="2" Classes="reveal" Width="48" Height="48"
|
||||
AutomationProperties.Name="Show the password"
|
||||
IsChecked="{Binding #PasswordBox.RevealPassword}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="21" />
|
||||
</ToggleButton>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
The tick is the phone's whole answer to storing a password, and on this head it is the only one:
|
||||
@@ -853,15 +960,15 @@
|
||||
quietly lives.
|
||||
-->
|
||||
<CheckBox IsChecked="{Binding RemembersConnectPassword}" MinHeight="44">
|
||||
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||
<TextBlock FontSize="13.5" TextWrapping="Wrap" Foreground="{StaticResource Text}"
|
||||
Text="Remember this password for this host" />
|
||||
</CheckBox>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="CONNECT"
|
||||
Command="{Binding ConnectCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Grid ColumnDefinitions="*,10,*">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="48" Content="CANCEL"
|
||||
Command="{Binding CancelConnectPasswordCommand}" />
|
||||
<Button Grid.Column="2" Classes="primary" Height="48" Content="CONNECT"
|
||||
Command="{Binding ConnectCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
</Grid>
|
||||
|
||||
</StackPanel>
|
||||
@@ -912,7 +1019,7 @@
|
||||
Command="{Binding EditGroupFromHeadingCommand}"
|
||||
CommandParameter="{Binding GroupSheet}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Edit" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Edit" />
|
||||
<TextBlock Classes="detail" FontSize="10.5"
|
||||
Text="Its name, where it sits, and the port, user and key its hosts fall back to." />
|
||||
</StackPanel>
|
||||
@@ -922,7 +1029,7 @@
|
||||
Command="{Binding MoveGroupFromHeadingCommand}"
|
||||
CommandParameter="{Binding GroupSheet}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}"
|
||||
Text="Move to another vault" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Takes the groups inside it and every host filed under them, re-encrypted with the other vault's key." />
|
||||
@@ -935,7 +1042,7 @@
|
||||
Command="{Binding DeleteGroupFromHeadingCommand}"
|
||||
CommandParameter="{Binding GroupSheet}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Danger}" Text="Delete" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Asks first, and asks separately whether the hosts filed under it go too." />
|
||||
@@ -1066,10 +1173,10 @@
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap"
|
||||
Text="Added to the keychain as soon as you press ADD, so it stays even if you leave this host without saving." />
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Content="ADD" MinHeight="44"
|
||||
<Button Grid.Column="2" Classes="primary" Content="ADD" MinHeight="44"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Command="{Binding AddEditorCredentialCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Content="CANCEL" MinHeight="44"
|
||||
<Button Grid.Column="0" Classes="secondary" Content="CANCEL" MinHeight="44"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Command="{Binding CancelEditorCredentialCommand}" />
|
||||
</Grid>
|
||||
@@ -1265,9 +1372,9 @@
|
||||
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="SAVE"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="SAVE"
|
||||
Command="{Binding SaveHostCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelEditCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
@@ -3,6 +3,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
@@ -62,8 +63,30 @@ internal sealed partial class HostsScreen : UserControl
|
||||
|
||||
InputElement.SetIsHoldingEnabled(rows, true);
|
||||
rows.Holding += OnRowHeld;
|
||||
|
||||
var sheet = this.FindControl<Panel>("PasswordSheet")!;
|
||||
var box = this.FindControl<TextBox>("PasswordBox")!;
|
||||
|
||||
sheet.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.Property == IsVisibleProperty && sheet.IsVisible)
|
||||
{
|
||||
FocusThePasswordBox(box);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts the focus, and so the keyboard, in the connect sheet's box as the sheet opens.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The sheet exists for that one box, and it used to open with nothing focused — a second tap before
|
||||
/// typing could start (audit M12). Posted at <c>Loaded</c> priority because the box is not yet laid out
|
||||
/// on the turn the sheet's visibility changes, and focusing a control with no bounds raises no keyboard.
|
||||
/// </remarks>
|
||||
private static void FocusThePasswordBox(TextBox box) =>
|
||||
Dispatcher.UIThread.Post(() => box.Focus(NavigationMethod.Tab), DispatcherPriority.Loaded);
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the row that was tapped, or ticks it where hosts are already ticked.
|
||||
/// </summary>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.Android.Views"
|
||||
xmlns:ssh="using:DodoSSH.Client.Ssh"
|
||||
x:Class="DodoSSH.Client.Android.Views.KeychainScreen"
|
||||
x:DataType="vm:VaultViewModel"
|
||||
Background="{StaticResource Canvas}">
|
||||
@@ -32,38 +33,42 @@
|
||||
This screen moved behind SETTINGS, so it draws its own header — the one every hub screen has. It is not
|
||||
a decoration: the shell's header stands down behind the hub, and without a row of its own there would be
|
||||
no back arrow and nothing on screen saying what this list is.
|
||||
|
||||
── 2026-10-04 redesign ───────────────────────────────────────────────────────────────────────────────
|
||||
And it moved back out, under VAULTS: the keychain is the open vault's contents as much as its hosts are,
|
||||
so it is the middle of the vault's HOSTS · KEYCHAIN · SNIPPETS switch, and the shell's header is over it
|
||||
again. The header below is now drawn only where that switch is not.
|
||||
-->
|
||||
|
||||
<Panel>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
|
||||
|
||||
<!-- ============ header ============ -->
|
||||
<!--
|
||||
The same shape as the one over PREFERENCES in PhoneShell, and pointed at the same place. Back goes to
|
||||
the hub rather than to HOSTS, because the hub is where this screen is reached from and back that
|
||||
skipped it would strand the four rows underneath it.
|
||||
◆ Wide surfaces only, since the 2026-10-04 redesign. On a narrow one this screen is the KEYCHAIN third of
|
||||
the open vault's switch, under the vault's own header — see PhoneShell.ShowsVaultTabs — and a second row
|
||||
saying "Keychain" under a lit KEYCHAIN segment would be the two rows of chrome this head avoids. Its back
|
||||
arrow went with it: back from here is the system gesture, which goes to the vault's hosts.
|
||||
|
||||
A wide surface has no switch — the rail lists the three — so the screen's own name stays there, without
|
||||
an arrow, for the reason it never had one there: the rail is how you leave.
|
||||
-->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,*" Height="56" Margin="8,0">
|
||||
<!--
|
||||
◆ Gone on a wide surface, where the rail is how you leave and the hub it points at is not drawn at
|
||||
all. An arrow to a screen nothing else can reach would be the one control on this header that leads
|
||||
somewhere the layout has removed. See PhoneShell.IsWide.
|
||||
-->
|
||||
<Button Grid.Column="0" Classes="icon" Content="←"
|
||||
IsVisible="{Binding !$parent[views:PhoneShell].IsWide}"
|
||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.More}" />
|
||||
<TextBlock Grid.Column="1" Classes="heading" Text="Keychain" Margin="4,0" />
|
||||
<Grid Grid.Row="0" Height="56" Margin="12,0" IsVisible="{Binding $parent[views:PhoneShell].IsWide}">
|
||||
<TextBlock Classes="heading" Text="Keychain" Margin="4,0" />
|
||||
</Grid>
|
||||
|
||||
<!-- ============ categories ============ -->
|
||||
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
<!-- Hidden rather than Auto: the row still scrolls, but Fluent's desktop scrollbar was drawn across the
|
||||
bottom of the chips themselves (audit M5). -->
|
||||
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Disabled"
|
||||
Margin="0,12,0,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="16,0">
|
||||
|
||||
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsAll, Mode=OneWay}"
|
||||
Command="{Binding ShowSectionCommand}" CommandParameter="{x:Static vm:VaultSection.All}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="ALL" />
|
||||
<TextBlock Text="All" />
|
||||
<TextBlock Text="{Binding TotalItemCount}" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
@@ -71,7 +76,7 @@
|
||||
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsKeys, Mode=OneWay}"
|
||||
Command="{Binding ShowSectionCommand}" CommandParameter="{x:Static vm:VaultSection.Keys}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="SSH KEYS" />
|
||||
<TextBlock Text="SSH keys" />
|
||||
<TextBlock Text="{Binding Keys.Count}" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
@@ -80,7 +85,7 @@
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.Credentials}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="PASSWORDS" />
|
||||
<TextBlock Text="Passwords" />
|
||||
<TextBlock Text="{Binding Credentials.Count}" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
@@ -95,7 +100,7 @@
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.Tags}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="TAGS" />
|
||||
<TextBlock Text="Tags" />
|
||||
<TextBlock Text="{Binding Tags.Count}" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
@@ -104,7 +109,7 @@
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.Buckets}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="BUCKETS" />
|
||||
<TextBlock Text="Buckets" />
|
||||
<TextBlock Text="{Binding ObjectStores.Count}" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
@@ -134,35 +139,45 @@
|
||||
style below, which had nothing to colour until now. DELETE is the last button that should be pressed on
|
||||
a guess about which row it means.
|
||||
-->
|
||||
<Border Grid.Row="2" IsVisible="{Binding ShowsItemActions}" Margin="12,4"
|
||||
<!--
|
||||
◆ EDIT is here now, beside MOVE and DELETE, because there is something for it to open: the editor page at
|
||||
the foot of this file, which is the desktop's five editors drawn for a phone. The three sit in a row
|
||||
under the item's name rather than beside it, which is what fits at 360dp; DELETE keeps the danger
|
||||
outline and the far end of the row.
|
||||
|
||||
COPY PUBLIC KEY under them for a key that has one, because a phone is where somebody is standing at a
|
||||
machine that needs it in authorized_keys.
|
||||
-->
|
||||
<Border Grid.Row="2" IsVisible="{Binding ShowsItemActions}" Margin="16,4"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="12" Padding="14,10">
|
||||
<Grid ColumnDefinitions="*,10,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="label" Text="SELECTED" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold"
|
||||
Text="{Binding SelectedVaultItem.Name}" TextTrimming="CharacterEllipsis" />
|
||||
<Border Classes="tag outline">
|
||||
<TextBlock Text="{Binding SelectedVaultItem.Type}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
BorderThickness="1" CornerRadius="14" Padding="14,12">
|
||||
<StackPanel Spacing="10">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="mono" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||
Text="{Binding SelectedVaultItem.Name}" TextTrimming="CharacterEllipsis" />
|
||||
<Border Classes="tag outline">
|
||||
<TextBlock Text="{Binding SelectedVaultItem.Type}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
MOVE beside it, and only where there is somewhere to move to — the rule the host's MOVE follows on
|
||||
this head, for the reason a phone has: there is no room to draw a button that answers with a
|
||||
refusal. It is the ghost of the pair rather than the danger one, because a move is undone by
|
||||
moving it back.
|
||||
-->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="secondary" Height="44" Width="86" Content="MOVE"
|
||||
<Grid ColumnDefinitions="*,8,*,8,*">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
|
||||
Command="{Binding EditSelectedItemCommand}" />
|
||||
<!--
|
||||
MOVE only where there is somewhere to move to — the rule the host's MOVE follows on this head, for
|
||||
the reason a phone has: there is no room to draw a button that answers with a refusal.
|
||||
-->
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
|
||||
IsVisible="{Binding CanMoveSelectedItem}"
|
||||
Command="{Binding MoveSelectedItemCommand}" />
|
||||
<Button Classes="danger" Height="44" Width="104" Content="DELETE"
|
||||
<Button Grid.Column="4" Classes="danger" Height="44" Content="DELETE"
|
||||
Command="{Binding DeleteSelectedItemCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Button Classes="secondary" Height="44" Content="COPY PUBLIC KEY"
|
||||
Command="{Binding CopyPublicKeyCommand}"
|
||||
IsVisible="{Binding SelectedKey.Key.PublicKey, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ ◆ the counted confirmation ============ -->
|
||||
@@ -183,9 +198,9 @@
|
||||
IsVisible="{Binding PendingDeletion.HasUsage}"
|
||||
Text="{Binding PendingDeletion.Usage}" />
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
|
||||
<Button Grid.Column="0" Classes="danger" Content="DELETE"
|
||||
<Button Grid.Column="2" Classes="danger" Content="DELETE"
|
||||
Command="{Binding ConfirmDeleteCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
|
||||
Command="{Binding CancelDeleteCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -224,9 +239,9 @@
|
||||
IsVisible="{Binding HasMovingItemUsage}"
|
||||
Text="{Binding MovingItemUsage}" />
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="MOVE"
|
||||
Command="{Binding ConfirmMoveItemCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelMoveItemCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -239,12 +254,15 @@
|
||||
<TextBlock Classes="body" IsVisible="{Binding !HasVaultItems}" Margin="24,20"
|
||||
VerticalAlignment="Top" Text="{Binding EmptySectionMessage}" />
|
||||
|
||||
<ListBox ItemsSource="{Binding VaultItems}" SelectedItem="{Binding SelectedVaultItem}"
|
||||
<!-- The foot padding is the floating button's: the last card scrolls clear of it. -->
|
||||
<ListBox ItemsSource="{Binding VaultItems}" SelectedItem="{Binding SelectedVaultItem}" Padding="0,0,0,88"
|
||||
Background="Transparent" BorderThickness="0" IsVisible="{Binding HasVaultItems}">
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<!-- A name for TalkBack rather than the row view model's type name (audit H4). -->
|
||||
<Setter Property="AutomationProperties.Name" Value="{ReflectionBinding Name}" />
|
||||
</Style>
|
||||
<!--
|
||||
The fill goes on the card and not on the ContentPresenter behind it, which is where HOSTS and
|
||||
@@ -255,28 +273,28 @@
|
||||
-->
|
||||
<Style Selector="ListBoxItem:selected Border.card">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource TerminalTabAccent}" />
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
|
||||
<Border Classes="card" Margin="12,4">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" MinHeight="64">
|
||||
<Border Classes="card" Margin="16,4" CornerRadius="16" Background="{StaticResource Panel}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" MinHeight="56">
|
||||
|
||||
<!--
|
||||
The glyph tile. One mark for all three kinds rather than three: a key, a password and a
|
||||
bucket are already named by the badge beside them, and three near-identical monochrome
|
||||
glyphs at 14px would be decoration that reads as a distinction.
|
||||
-->
|
||||
<Border Grid.Column="0" Width="36" Height="36" CornerRadius="10"
|
||||
Background="{StaticResource Active}" VerticalAlignment="Center">
|
||||
<TextBlock Text="⚿" Foreground="{StaticResource AccentText}" FontSize="14"
|
||||
FontWeight="SemiBold" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
<Border Grid.Column="0" Width="40" Height="40" CornerRadius="12"
|
||||
Background="{StaticResource AccentSoft}" VerticalAlignment="Center">
|
||||
<TextBlock Classes="icon" Text="{Binding IconGlyph}" FontSize="20"
|
||||
Foreground="{StaticResource AccentText}" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1" Margin="12,0" Spacing="3" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
|
||||
<TextBlock Classes="mono" FontSize="14.5" FontWeight="SemiBold" Text="{Binding Name}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border Classes="tag outline">
|
||||
<TextBlock Text="{Binding Type}" />
|
||||
@@ -287,18 +305,310 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!-- What is known *about* an item, never the secret. -->
|
||||
<TextBlock Classes="detail" Text="{Binding Detail}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail code" Text="{Binding Detail}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="icon" Text="" Foreground="{StaticResource TextGhost}"
|
||||
FontSize="22" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- ============ add ============ -->
|
||||
<!--
|
||||
◆ The floating +, which this screen never had: its empty state asked for an SSH key or a password over
|
||||
a screen with no way to add either (audit M9). It raises the add sheet below, because there are five
|
||||
kinds of thing to add here and one button.
|
||||
-->
|
||||
<Button Classes="fab" Margin="0,0,18,18" HorizontalAlignment="Right" VerticalAlignment="Bottom"
|
||||
AutomationProperties.Name="Add to the keychain" Command="{Binding OpenKeychainAddSheetCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="26" />
|
||||
</Button>
|
||||
</Panel>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ============ the add sheet ============ -->
|
||||
<!--
|
||||
The desktop's "+ New key" menu, as the hosts screen's add sheet: a scrim, a bottom panel, dismissible
|
||||
because "which kind" has no wrong answer. Each row opens its editor and lowers the sheet as it does —
|
||||
see KeychainScreen.OnAddChosen. Generating comes first, as it does on the desktop: on a phone it is
|
||||
the way to a key that never existed as a file anywhere.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsKeychainAddSheetOpen}">
|
||||
<Button Classes="scrim" Command="{Binding CloseKeychainAddSheetCommand}" />
|
||||
|
||||
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||
CornerRadius="22,22,0,0" Padding="12,10,12,16">
|
||||
<StackPanel>
|
||||
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,12" />
|
||||
<TextBlock Classes="label" Text="ADD TO THE KEYCHAIN" Margin="12,0,12,6" />
|
||||
|
||||
<Button Classes="row" Command="{Binding NewGeneratedKeyCommand}" Click="OnAddChosen"
|
||||
AutomationProperties.Name="Generate a key">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22"
|
||||
Foreground="{StaticResource AccentText}" />
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Generate a key" />
|
||||
<TextBlock Classes="detail" Text="A new key pair made here, so the private half never becomes a file." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
<Button Classes="row" Command="{Binding NewKeyCommand}" Click="OnAddChosen"
|
||||
AutomationProperties.Name="Add an SSH key">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22"
|
||||
Foreground="{StaticResource AccentText}" />
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Add an SSH key" />
|
||||
<TextBlock Classes="detail" Text="Paste in a key you already have." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
<Button Classes="row" Command="{Binding NewCredentialCommand}" Click="OnAddChosen"
|
||||
AutomationProperties.Name="Add a password">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22"
|
||||
Foreground="{StaticResource AccentText}" />
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Add a password" />
|
||||
<TextBlock Classes="detail" Text="One account, described once, for as many hosts as use it." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
<Button Classes="row" Command="{Binding NewObjectStoreCommand}" Click="OnAddChosen"
|
||||
AutomationProperties.Name="Add a bucket">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22"
|
||||
Foreground="{StaticResource AccentText}" />
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Add a bucket" />
|
||||
<TextBlock Classes="detail" Text="An S3-compatible bucket, to browse under Settings, Files." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
<Button Classes="row" Command="{Binding NewTagCommand}" Click="OnAddChosen"
|
||||
AutomationProperties.Name="Add a tag">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22"
|
||||
Foreground="{StaticResource AccentText}" />
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Add a tag" />
|
||||
<TextBlock Classes="detail" Text="A name to put on hosts, before there is anything to put it on." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ ◆ the editor page ============ -->
|
||||
<!--
|
||||
The desktop's five keychain editors, as one page over the whole screen — the host editor's shape on this
|
||||
head, and PhoneShell stands its chrome down for it the same way (see PhoneShell.RefreshChrome). Only one
|
||||
of the five is ever open; each draws its own section and its own CANCEL and SAVE, and the ← in the
|
||||
header cancels whichever it is, as the system back gesture does.
|
||||
|
||||
Every label is a label above its box rather than a placeholder in it, which the audit asked of the
|
||||
host editor too: on a phone the box is full of what was typed long before anybody is done reading it.
|
||||
-->
|
||||
<Grid IsVisible="{Binding IsEditingAVaultItem}" RowDefinitions="Auto,*" Background="{StaticResource Canvas}">
|
||||
<Border Grid.Row="0" Height="56" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*" Margin="8,0">
|
||||
<Button Grid.Column="0" Classes="icon" AutomationProperties.Name="Cancel" Click="OnCancelEditor">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" />
|
||||
</Button>
|
||||
<TextBlock Grid.Column="1" Classes="heading" Margin="4,0" Text="Generate a key"
|
||||
IsVisible="{Binding IsGeneratingKey}" />
|
||||
<TextBlock Grid.Column="1" Classes="heading" Margin="4,0" Text="SSH key"
|
||||
IsVisible="{Binding IsEditingKey}" />
|
||||
<TextBlock Grid.Column="1" Classes="heading" Margin="4,0" Text="Password"
|
||||
IsVisible="{Binding IsEditingCredential}" />
|
||||
<TextBlock Grid.Column="1" Classes="heading" Margin="4,0" Text="Bucket"
|
||||
IsVisible="{Binding IsEditingObjectStore}" />
|
||||
<TextBlock Grid.Column="1" Classes="heading" Margin="4,0" Text="Tag"
|
||||
IsVisible="{Binding IsEditingTag}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel Margin="16,14,16,24" Spacing="8">
|
||||
|
||||
<!--
|
||||
Which vault a new item is filed into, hidden at one vault, which is where most people stay. The
|
||||
desktop's own "NEW ITEMS FILE TO", and the sentence under it for the reason that one has it.
|
||||
-->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasVaultChoice}" Margin="0,0,0,8">
|
||||
<TextBlock Classes="label" Text="NEW ITEMS FILE TO" />
|
||||
<ComboBox ItemsSource="{Binding TargetVaults}" SelectedItem="{Binding SelectedTargetVault}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Display}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Classes="detail" Text="An item filed into a shared vault is readable by everyone holding that vault's key." />
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ generating ============ -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding IsGeneratingKey}">
|
||||
<TextBlock Classes="label" Text="KIND" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="chiptoggle" Classes.worn="{Binding GeneratesEd25519}" MinHeight="44"
|
||||
Command="{Binding ChooseKeyAlgorithmCommand}"
|
||||
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Ed25519}">
|
||||
<TextBlock Classes="mono" FontSize="12.5" Text="Ed25519" />
|
||||
</Button>
|
||||
<Button Classes="chiptoggle" Classes.worn="{Binding GeneratesRsa}" MinHeight="44"
|
||||
Command="{Binding ChooseKeyAlgorithmCommand}"
|
||||
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Rsa4096}">
|
||||
<TextBlock Classes="mono" FontSize="12.5" Text="RSA 4096" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<TextBlock Classes="detail" Text="Ed25519 is what every current OpenSSH prefers. RSA is for servers too old to accept it, and takes a few seconds to make." />
|
||||
|
||||
<TextBlock Classes="label" Text="NAME" Margin="0,8,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding GenerateComment}" />
|
||||
<TextBlock Classes="detail" Text="What the key is called here, and the comment written into it, so the line on a host says where it came from." />
|
||||
|
||||
<TextBlock Classes="body" Margin="0,8,0,0"
|
||||
Text="The key file itself has no passphrase. Your keychain passphrase is what protects it, and it never reaches the server in a form it can read. It lands in the key editor unsaved, to be named and kept." />
|
||||
|
||||
<Grid ColumnDefinitions="*,10,*" Margin="0,10,0,0">
|
||||
<Button Grid.Column="0" Classes="secondary" Content="CANCEL"
|
||||
Command="{Binding CancelGenerateKeyCommand}" />
|
||||
<Button Grid.Column="2" Classes="primary" Content="GENERATE"
|
||||
Command="{Binding GenerateKeyCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ a key ============ -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding IsEditingKey}">
|
||||
<TextBlock Classes="label" Text="NAME" />
|
||||
<TextBox Classes="field" Text="{Binding KeyEditorLabel}" />
|
||||
|
||||
<!--
|
||||
Not a password box, for the desktop's reason: the armour has to be visible to be pasted and
|
||||
checked, and the mistake this prevents is pasting the .pub file, which is refused by name.
|
||||
-->
|
||||
<TextBlock Classes="label" Text="PRIVATE KEY" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding KeyEditorPrivateKey}" Height="140"
|
||||
PlaceholderText="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
AcceptsReturn="True" TextWrapping="NoWrap" VerticalContentAlignment="Top"
|
||||
Padding="12,10" FontSize="11" />
|
||||
|
||||
<TextBlock Classes="label" Text="PASSPHRASE, IF THE KEY HAS ONE" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field secret" Text="{Binding KeyEditorPassphrase}" />
|
||||
|
||||
<TextBlock Classes="label" Text="PUBLIC HALF (OPTIONAL)" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding KeyEditorPublicKey}" FontSize="11" />
|
||||
|
||||
<TextBlock Classes="label" Text="NOTES" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding KeyEditorNotes}" Height="72" AcceptsReturn="True"
|
||||
TextWrapping="Wrap" VerticalContentAlignment="Top" Padding="12,10" />
|
||||
|
||||
<TextBlock Classes="body" Margin="0,6,0,0"
|
||||
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read." />
|
||||
|
||||
<Grid ColumnDefinitions="*,10,*" Margin="0,10,0,0">
|
||||
<Button Grid.Column="0" Classes="secondary" Content="CANCEL" Command="{Binding CancelKeyEditCommand}" />
|
||||
<Button Grid.Column="2" Classes="primary" Content="SAVE" Command="{Binding SaveKeyCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ a password ============ -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding IsEditingCredential}">
|
||||
<TextBlock Classes="label" Text="NAME" />
|
||||
<TextBox Classes="field" Text="{Binding CredentialEditorLabel}" />
|
||||
|
||||
<TextBlock Classes="label" Text="USERNAME" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding CredentialEditorUsername}"
|
||||
PlaceholderText="blank: each host's own" />
|
||||
|
||||
<TextBlock Classes="label" Text="PASSWORD" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field secret" Text="{Binding CredentialEditorPassword}" />
|
||||
|
||||
<TextBlock Classes="label" Text="NOTES" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding CredentialEditorNotes}" Height="72" AcceptsReturn="True"
|
||||
TextWrapping="Wrap" VerticalContentAlignment="Top" Padding="12,10" />
|
||||
|
||||
<TextBlock Classes="body" Margin="0,6,0,0"
|
||||
Text="Encrypted here and never sent to the server in a form it can read. Bind a host to it in the host's own editor, under how it signs in." />
|
||||
|
||||
<Grid ColumnDefinitions="*,10,*" Margin="0,10,0,0">
|
||||
<Button Grid.Column="0" Classes="secondary" Content="CANCEL"
|
||||
Command="{Binding CancelCredentialEditCommand}" />
|
||||
<Button Grid.Column="2" Classes="primary" Content="SAVE" Command="{Binding SaveCredentialCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ a bucket ============ -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding IsEditingObjectStore}">
|
||||
<TextBlock Classes="label" Text="NAME" />
|
||||
<TextBox Classes="field" Text="{Binding BucketEditorLabel}" />
|
||||
|
||||
<TextBlock Classes="label" Text="BUCKET" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding BucketEditorBucket}" />
|
||||
|
||||
<TextBlock Classes="label" Text="ACCESS KEY ID" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding BucketEditorAccessKeyId}" />
|
||||
|
||||
<TextBlock Classes="label" Text="SECRET ACCESS KEY" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field secret" Text="{Binding BucketEditorSecretAccessKey}" />
|
||||
|
||||
<TextBlock Classes="label" Text="REGION" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding BucketEditorRegion}" PlaceholderText="e.g. eu-west-1" />
|
||||
|
||||
<TextBlock Classes="label" Text="ENDPOINT" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding BucketEditorEndpoint}" PlaceholderText="blank: Amazon S3" />
|
||||
|
||||
<CheckBox IsChecked="{Binding BucketEditorUsePathStyle}" MinHeight="44">
|
||||
<TextBlock FontSize="13.5" Foreground="{StaticResource Text}" TextWrapping="Wrap"
|
||||
Text="Address the bucket as a path" />
|
||||
</CheckBox>
|
||||
<TextBlock Classes="detail"
|
||||
Text="Off for Amazon S3. On for most self-hosted services — MinIO and Ceph have no wildcard DNS, so the bucket cannot be a subdomain." />
|
||||
|
||||
<TextBlock Classes="label" Text="NOTES" Margin="0,6,0,0" />
|
||||
<TextBox Classes="field" Text="{Binding BucketEditorNotes}" Height="72" AcceptsReturn="True"
|
||||
TextWrapping="Wrap" VerticalContentAlignment="Top" Padding="12,10" />
|
||||
|
||||
<Grid ColumnDefinitions="*,10,*" Margin="0,10,0,0">
|
||||
<Button Grid.Column="0" Classes="secondary" Content="CANCEL"
|
||||
Command="{Binding CancelObjectStoreEditCommand}" />
|
||||
<Button Grid.Column="2" Classes="primary" Content="SAVE" Command="{Binding SaveObjectStoreCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ a tag ============ -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding IsEditingTag}">
|
||||
<TextBlock Classes="label" Text="NAME" />
|
||||
<TextBox Classes="field" Text="{Binding TagEditorLabel}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding SaveTagCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<TextBlock Classes="detail"
|
||||
Text="Renaming a tag changes it everywhere at once. No host is rewritten — each one names this tag rather than repeating its name." />
|
||||
|
||||
<Grid ColumnDefinitions="*,10,*" Margin="0,10,0,0">
|
||||
<Button Grid.Column="0" Classes="secondary" Content="CANCEL" Command="{Binding CancelTagEditCommand}" />
|
||||
<Button Grid.Column="2" Classes="primary" Content="SAVE" Command="{Binding SaveTagCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="detail" Margin="0,8,0,0" Text="{Binding Status}" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
</Panel>
|
||||
|
||||
</UserControl>
|
||||
@@ -1,10 +1,70 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>Design 04 — everything in the vault that is not a host.</summary>
|
||||
internal sealed partial class KeychainScreen : UserControl
|
||||
{
|
||||
public KeychainScreen() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels whichever of the keychain's five editors is open.
|
||||
/// </summary>
|
||||
/// <returns>Whether one was, and so whether there was anything to cancel.</returns>
|
||||
/// <remarks>
|
||||
/// One place for the question the editor page's ← and the system back gesture both ask. Only one editor is
|
||||
/// ever open — each one's command refuses while another is — so the order only decides which test runs
|
||||
/// first. Cancelling rather than hiding, as the host editor does, because cancelling is what empties the
|
||||
/// boxes, and two of them hold a private key and a password.
|
||||
/// </remarks>
|
||||
internal static bool TryCancelAnEditor(VaultViewModel vault)
|
||||
{
|
||||
if (vault.IsGeneratingKey)
|
||||
{
|
||||
vault.CancelGenerateKeyCommand.Execute(null);
|
||||
}
|
||||
else if (vault.IsEditingKey)
|
||||
{
|
||||
vault.CancelKeyEditCommand.Execute(null);
|
||||
}
|
||||
else if (vault.IsEditingCredential)
|
||||
{
|
||||
vault.CancelCredentialEditCommand.Execute(null);
|
||||
}
|
||||
else if (vault.IsEditingObjectStore)
|
||||
{
|
||||
vault.CancelObjectStoreEditCommand.Execute(null);
|
||||
}
|
||||
else if (vault.IsEditingTag)
|
||||
{
|
||||
vault.CancelTagEditCommand.Execute(null);
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnCancelEditor(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is VaultViewModel vault)
|
||||
{
|
||||
TryCancelAnEditor(vault);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A kind was chosen on the add sheet: the sheet goes down as its editor opens.</summary>
|
||||
private void OnAddChosen(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is VaultViewModel vault)
|
||||
{
|
||||
vault.IsKeychainAddSheetOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@
|
||||
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
||||
<StackPanel Spacing="1" Margin="0,5">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="detail" FontSize="10" Text="{Binding Started}"
|
||||
<TextBlock Classes="detail code" FontSize="10" Text="{Binding Started}"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
@@ -104,7 +104,7 @@
|
||||
Text="{Binding Kind}" Foreground="{StaticResource Live}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="detail" FontSize="10" Text="{Binding HostLabel}"
|
||||
<TextBlock Classes="detail code" FontSize="10" Text="{Binding HostLabel}"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
@@ -148,7 +148,7 @@
|
||||
<DataTemplate x:DataType="vm:ActivityLogRowViewModel">
|
||||
<StackPanel Spacing="1" Margin="0,5">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="detail" FontSize="10" Text="{Binding At}"
|
||||
<TextBlock Classes="detail code" FontSize="10" Text="{Binding At}"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="detail" FontSize="10" FontWeight="SemiBold"
|
||||
Text="{Binding Operation}" Foreground="{StaticResource Info}"
|
||||
|
||||
@@ -6,190 +6,267 @@
|
||||
Background="{StaticResource Canvas}">
|
||||
|
||||
<!--
|
||||
Design v2 — SETTINGS: the hub for everything the bottom bar has no room for.
|
||||
SETTINGS: the hub behind the bottom bar's third entry.
|
||||
|
||||
Three slots and ten destinations is the arithmetic the design solves by putting seven of them one tap
|
||||
deeper. This screen is that tap. It takes the shell as its data context rather than the vault, because
|
||||
every row on it is a navigation command and nothing here reads an item.
|
||||
It takes the shell as its data context rather than the vault, because nearly every row on it is a
|
||||
navigation command and nothing here reads an item.
|
||||
|
||||
Teams is the tenth and the design never drew it — see the row itself. That one is the design's count
|
||||
plus one rather than a rearrangement of it. The keychain below is a rearrangement, and the only one:
|
||||
the bottom bar lost a slot to gain this row.
|
||||
── the 2026-10-04 redesign ────────────────────────────────────────────────────────────────────────────
|
||||
The hub used to hold the product: the keychain, snippets, SFTP and S3 were rows here beside Preferences
|
||||
and LOCK, and the audit's M8 said so. The keychain and snippets now live under VAULTS, in the open vault's
|
||||
own switch, and what is left is arranged the way a phone's settings are — who you are, then grouped
|
||||
sections of rows on cards — rather than one flat list of places.
|
||||
|
||||
The rows are the design's list rows rather than cards: a card is one thing you act on, and a destination
|
||||
is not a thing — it is a place. Each carries a sentence saying what is behind it, because a hub whose
|
||||
entries are one word each is a menu you have to open to read.
|
||||
The account card says who is signed in and where, which nothing on this head said before: the server's
|
||||
address is the fact that decides whose keychain this is, and it was only ever typed once, on the first
|
||||
screen. It is not a button; there is no account page on the phone to open (signing out is on the lock
|
||||
screen — see PreferencesScreen's NOT HERE).
|
||||
|
||||
── from MORE to SETTINGS ──────────────────────────────────────────────────────────────────────────────
|
||||
The hub was called More because that was the honest name for a handful of things that did not fit. With
|
||||
the keychain on it as well it is the place everything that is not connecting to a machine lives, which
|
||||
has a name of its own and a glyph everyone already knows.
|
||||
Rows are cards of rows rather than cards each, because a destination is a place and not a thing, and a
|
||||
group of them reads as one section only when they share a surface. Each keeps a sentence saying what is
|
||||
behind it, because a hub whose entries are one word each is a menu you have to open to read.
|
||||
|
||||
The keychain is first, and above the rule, because it is the only row here that holds the user's own
|
||||
material rather than a screen about the application. Everything below it is somewhere to look at what
|
||||
has happened or how this phone behaves.
|
||||
Every icon is the embedded Material font's, as the bar's are — see NavButton, which says why the
|
||||
Unicode glyphs went (audit M7).
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
<!-- A section's rows. The card's own padding is the rows' business, so a row's press fill reaches its
|
||||
edges; the hairlines between rows are drawn by the rows' containers. -->
|
||||
<Style Selector="Border.settingsgroup">
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="16" />
|
||||
<Setter Property="ClipToBounds" Value="True" />
|
||||
</Style>
|
||||
<Style Selector="Button.settingsrow">
|
||||
<Setter Property="MinHeight" Value="60" />
|
||||
<Setter Property="Padding" Value="14,8" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="Border.rowrule">
|
||||
<Setter Property="Height" Value="1" />
|
||||
<Setter Property="Margin" Value="62,0,0,0" />
|
||||
<Setter Property="Background" Value="{StaticResource BorderSubtle}" />
|
||||
</Style>
|
||||
<!-- The tile an icon sits on: a square of the accent's wash, so a row's mark is a colour as well as a
|
||||
shape. Amber for the one row that ends something rather than going somewhere. -->
|
||||
<Style Selector="Border.rowtile">
|
||||
<Setter Property="Width" Value="36" />
|
||||
<Setter Property="Height" Value="36" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="{StaticResource AccentSoft}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Border.rowtile > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
<Setter Property="FontSize" Value="19" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.rowtitle">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="FontSize" Value="14.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.chevron">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Height="56" Padding="16,0">
|
||||
<TextBlock Classes="heading" Text="Settings" />
|
||||
<Border Grid.Row="0" Height="60" Padding="20,0" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<TextBlock Classes="heading" FontSize="22" FontWeight="Bold" Text="Settings" />
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel Margin="10,0,10,20" Spacing="2">
|
||||
<StackPanel Margin="16,14,16,24" Spacing="8">
|
||||
|
||||
<!-- ============ who, and where ============ -->
|
||||
<Border Classes="settingsgroup" Padding="14">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<Border Grid.Column="0" Width="48" Height="48" CornerRadius="24"
|
||||
Background="{StaticResource AvatarGradient}">
|
||||
<TextBlock Text="{Binding AvatarInitials}" FontSize="17" FontWeight="Bold"
|
||||
Foreground="{StaticResource AccentInk}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock FontSize="16" FontWeight="Bold" Foreground="{StaticResource Text}"
|
||||
Text="{Binding AccountName}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock FontSize="12.5" Foreground="{StaticResource TextDim}" Text="{Binding Email}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding ServerUrl}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ security ============ -->
|
||||
<TextBlock Classes="label" Text="SECURITY" Margin="6,14,0,2" />
|
||||
<Border Classes="settingsgroup">
|
||||
<StackPanel>
|
||||
<!--
|
||||
The fingerprint is a row to Preferences rather than a switch here. Turning it on registers a key
|
||||
in the phone's secure hardware and turning it off withdraws one — two operations with a prompt
|
||||
and a paragraph each, which live on that screen — so this row says which state the phone is in
|
||||
and goes there. ON and OFF are the two flags the shell already computes; a phone with no way to
|
||||
hold a key says neither and the screen behind explains.
|
||||
-->
|
||||
<Button Classes="row settingsrow" AutomationProperties.Name="Unlock with a fingerprint"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}">
|
||||
<Grid ColumnDefinitions="Auto,12,*,8,Auto,4,Auto">
|
||||
<Border Grid.Column="0" Classes="rowtile">
|
||||
<TextBlock Classes="icon" Text="" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="rowtitle" Text="Unlock with a fingerprint" />
|
||||
<TextBlock Classes="detail" Text="The key stays in this phone's secure hardware." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="4" Classes="label" FontSize="10.5" Text="ON"
|
||||
Foreground="{StaticResource Live}" VerticalAlignment="Center"
|
||||
IsVisible="{Binding CanForgetDevice}" />
|
||||
<TextBlock Grid.Column="4" Classes="label" FontSize="10.5" Text="OFF"
|
||||
VerticalAlignment="Center" IsVisible="{Binding CanRegisterDevice}" />
|
||||
<TextBlock Grid.Column="6" Classes="icon chevron" Text="" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<Border Classes="rowrule" />
|
||||
<!--
|
||||
Locking, here as well as in the vault's header. The header is on the VAULTS tab and stands down
|
||||
on this one, so without this row every screen under SETTINGS would be two taps from locking the
|
||||
keychain — the wrong direction for the one action whose whole value is being quick.
|
||||
-->
|
||||
<Button Classes="row settingsrow" AutomationProperties.Name="Lock the keychain now"
|
||||
Command="{Binding LockCommand}">
|
||||
<Grid ColumnDefinitions="Auto,12,*">
|
||||
<Border Grid.Column="0" Classes="rowtile">
|
||||
<TextBlock Classes="icon" Text="" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="rowtitle" Text="Lock now" />
|
||||
<TextBlock Classes="detail" Text="Shells you have open keep running." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ vaults and history ============ -->
|
||||
<TextBlock Classes="label" Text="VAULTS AND HISTORY" Margin="6,14,0,2" />
|
||||
<Border Classes="settingsgroup">
|
||||
<StackPanel>
|
||||
<!--
|
||||
Vaults, which the v2 design had no row for. It is on the phone because a vault arrives without
|
||||
being asked for, and somebody it arrives for is at least as likely to be holding a phone. The
|
||||
vault name in the VAULTS tab's header leads here too.
|
||||
-->
|
||||
<Button Classes="row settingsrow" AutomationProperties.Name="Vaults"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Vaults}">
|
||||
<Grid ColumnDefinitions="Auto,12,*,8,Auto">
|
||||
<Border Grid.Column="0" Classes="rowtile">
|
||||
<TextBlock Classes="icon" Text="" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="rowtitle" Text="Vaults and sharing" />
|
||||
<TextBlock Classes="detail" Text="Your vaults, who is in each one, and who holds its key." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="4" Classes="icon chevron" Text="" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<Border Classes="rowrule" />
|
||||
<Button Classes="row settingsrow" AutomationProperties.Name="Logs"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}">
|
||||
<Grid ColumnDefinitions="Auto,12,*,8,Auto">
|
||||
<Border Grid.Column="0" Classes="rowtile">
|
||||
<TextBlock Classes="icon" Text="" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="rowtitle" Text="Activity log" />
|
||||
<TextBlock Classes="detail" Text="What has been connected to, and what has changed." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="4" Classes="icon chevron" Text="" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ files ============ -->
|
||||
<!--
|
||||
"Keychain", although the design's tab says Vault and the enum member is Vault. The split is a
|
||||
standing rule rather than an inconsistency: every sentence a user reads calls the encrypted store a
|
||||
keychain, and everything under the surface calls it a vault, because renaming the wire route, the
|
||||
tables and the CLR types would put an AAD resource type inside the blast radius of a
|
||||
find-and-replace. See docs/design-import-gaps.md. A row reading Vault would be the one place the
|
||||
product surface leaked the implementation's word.
|
||||
SFTP and S3 stay reachable from here, and the terminal now reaches a host's files directly — the
|
||||
FILES entry in a session's menu opens them on that session's host. A bucket has no session, so this
|
||||
is still the way to one. Both go through ShowFiles rather than ShowScreen: they are one screen over
|
||||
one view model, and which kind of remote it offers is the thing being chosen.
|
||||
-->
|
||||
<Button Classes="row" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Keychain}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="⚿" Foreground="{StaticResource AccentText}" FontSize="14"
|
||||
Width="22" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Keychain" />
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
|
||||
Text="Keys, passwords, buckets and tags — everything in the vault that is not a host." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<TextBlock Classes="label" Text="FILES" Margin="6,14,0,2" />
|
||||
<Border Classes="settingsgroup">
|
||||
<StackPanel>
|
||||
<Button Classes="row settingsrow" AutomationProperties.Name="SFTP"
|
||||
Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Host}">
|
||||
<Grid ColumnDefinitions="Auto,12,*,8,Auto">
|
||||
<Border Grid.Column="0" Classes="rowtile">
|
||||
<TextBlock Classes="icon" Text="" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="rowtitle" Text="SFTP" />
|
||||
<TextBlock Classes="detail" Text="Browse a host's files." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="4" Classes="icon chevron" Text="" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<Border Classes="rowrule" />
|
||||
<Button Classes="row settingsrow" AutomationProperties.Name="S3"
|
||||
Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Bucket}">
|
||||
<Grid ColumnDefinitions="Auto,12,*,8,Auto">
|
||||
<Border Grid.Column="0" Classes="rowtile">
|
||||
<TextBlock Classes="icon" Text="" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="rowtitle" Text="S3" />
|
||||
<TextBlock Classes="detail" Text="Objects in an S3-compatible bucket from the keychain." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="4" Classes="icon chevron" Text="" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Height="1" Background="{StaticResource Border}" Margin="16,8" />
|
||||
|
||||
<Button Classes="row" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Snippets}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="❯" Foreground="{StaticResource AccentText}" FontSize="14"
|
||||
Width="22" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Snippets" />
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" Text="Saved commands, typed into an open shell." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
These two go through ShowFiles rather than ShowScreen: they are one screen over one view model,
|
||||
and which kind of remote it offers is the thing being chosen. See ShowFilesCommand for why that
|
||||
is set by the button rather than on arrival.
|
||||
-->
|
||||
<Button Classes="row" Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Host}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="⇅" Foreground="{StaticResource AccentText}" FontSize="14"
|
||||
Width="22" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="SFTP" />
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
|
||||
Text="Browse a host's files." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Bucket}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="◳" Foreground="{StaticResource AccentText}" FontSize="14"
|
||||
Width="22" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="S3" />
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
|
||||
Text="Objects in an S3-compatible bucket from the keychain." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="≡" Foreground="{StaticResource AccentText}" FontSize="14"
|
||||
Width="22" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Logs" />
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" Text="What has been connected to, and what has changed." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
Vaults, which the v2 design has no row for — it is a shipped screen the design had no slot for
|
||||
rather than a drawn one with nothing behind it. It is on the phone because a vault arrives
|
||||
without being asked for, and somebody it arrives for is at least as likely to be holding a phone.
|
||||
|
||||
◎ rather than a glyph of its own. The desktop rail already draws this destination with it, and
|
||||
two heads giving one destination two marks is how a user learns the wrong one.
|
||||
-->
|
||||
<Button Classes="row" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Vaults}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="◎" Foreground="{StaticResource AccentText}" FontSize="14"
|
||||
Width="22" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Vaults" />
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
|
||||
Text="Your vaults, who is in each one, and who holds its key." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="⚙" Foreground="{StaticResource AccentText}" FontSize="14"
|
||||
Width="22" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Preferences" />
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
|
||||
Text="Unlocking with a fingerprint, and what this phone cannot do." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
Locking, here as well as in the header.
|
||||
|
||||
The header carries it on HOSTS and stands down behind SETTINGS, so without this row every screen
|
||||
under here would be two taps from locking the keychain. That is the wrong direction for the one
|
||||
action whose whole value is being quick, and it is why this is a row on the hub rather than
|
||||
something you go back for.
|
||||
-->
|
||||
<Button Classes="row" Margin="0,10,0,0" Command="{Binding LockCommand}">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Text="⚿" Foreground="{StaticResource TextDim}" FontSize="14"
|
||||
Width="22" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Lock the keychain" />
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
|
||||
Text="Shells you have open keep running." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
<!-- ============ this app ============ -->
|
||||
<!-- Preferences with tune rather than a gear: the gear is the tab this row is under (audit M7). -->
|
||||
<TextBlock Classes="label" Text="THIS APP" Margin="6,14,0,2" />
|
||||
<Border Classes="settingsgroup">
|
||||
<Button Classes="row settingsrow" AutomationProperties.Name="Preferences"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}">
|
||||
<Grid ColumnDefinitions="Auto,12,*,8,Auto,4,Auto">
|
||||
<Border Grid.Column="0" Classes="rowtile">
|
||||
<TextBlock Classes="icon" Text="" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="rowtitle" Text="Preferences and updates" />
|
||||
<TextBlock Classes="detail" Text="Fingerprint unlock, updates, and what this phone cannot do." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="4" Classes="mono" FontSize="11" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding Updates.CurrentVersion}" VerticalAlignment="Center"
|
||||
MaxWidth="110" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="6" Classes="icon chevron" Text="" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ Port forwarding, stated rather than drawn.
|
||||
|
||||
The v2 design gives this hub a sixth entry and a full screen behind it: local, remote and dynamic
|
||||
rules with toggles and byte counters. Forwarding is built and runs here as on the desktop — a host's
|
||||
The v2 design gives this hub an entry and a full screen behind it: local, remote and dynamic rules
|
||||
with toggles and byte counters. Forwarding is built and runs here as on the desktop — a host's
|
||||
forwards start with every terminal opened to it — but it is not a destination, so it has no row. A
|
||||
forward belongs to a host and runs with a session, and on this head the session's forwards are a
|
||||
sheet off the terminal's bar: the ⇄ button, drawn for a tab that has some, lists them with a switch
|
||||
@@ -202,13 +279,12 @@
|
||||
It is a paragraph here rather than a missing row for the reason the desktop keeps TEAMS in its rail:
|
||||
a place somebody looks for a feature should say where it went. See docs/design-import-gaps.md.
|
||||
-->
|
||||
<Border Classes="card" Margin="6,16,6,0">
|
||||
<Border Classes="card" Margin="0,14,0,0">
|
||||
<StackPanel Spacing="7">
|
||||
<StackPanel Orientation="Horizontal" Spacing="9">
|
||||
<TextBlock Text="⇄" Foreground="{StaticResource TextFaint}" FontSize="14"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextDim}" Text="Port forwarding" />
|
||||
<TextBlock Classes="icon" Text="" Foreground="{StaticResource TextFaint}" FontSize="18" />
|
||||
<TextBlock FontSize="14" FontWeight="SemiBold" Foreground="{StaticResource TextDim}"
|
||||
Text="Port forwarding" VerticalAlignment="Center" />
|
||||
<Border Classes="tag outline" VerticalAlignment="Center">
|
||||
<TextBlock Text="IN THE TERMINAL" />
|
||||
</Border>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Globalization;
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Automation;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Layout;
|
||||
@@ -17,16 +20,31 @@ namespace DodoSSH.Client.Android.Views;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Written in C# rather than as a XAML control because it is a button with a glyph, a label and a rule about
|
||||
/// the accent bar, and a templated control for that would be more machinery than the thing it draws.
|
||||
/// Written in C# rather than as a XAML control because it is a button with an icon, a label and a rule about
|
||||
/// the indicator, and a templated control for that would be more machinery than the thing it draws.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The accent bar is on top, not underneath.</b> The v2 design distinguishes the current destination with
|
||||
/// colour and weight alone and draws no bar at all; the bar is kept, and kept at the top, for a reason the
|
||||
/// design does not have to think about. The bottom of a phone screen is under the system's own gesture bar,
|
||||
/// so an indicator drawn there is one the user cannot see — and colour alone at 9pt on a dark background is
|
||||
/// the single signal a user with a colour deficiency would be left with. Weight, colour and the bar are
|
||||
/// three, which is the right number for the control that says where you are.
|
||||
/// ◆ <b>The indicator is a pill behind the icon, where it was a bar along the top edge.</b> The bar was put at
|
||||
/// the top because the bottom of the screen is under the system's gesture bar, and an indicator drawn there is
|
||||
/// one nobody sees; the pill answers the same worry from the other side, by sitting in the middle of the
|
||||
/// control rather than at either edge. It is the shape Android's own navigation bar has drawn since Material 3
|
||||
/// and the one the 2026-10-04 redesign asks for. The rule the bar existed to keep is kept: the current entry is
|
||||
/// still told apart three ways — the filled pill, the accent colour and the heavier label — because colour
|
||||
/// alone at this size is the one signal a user with a colour deficiency would be left with.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>An icon from the embedded Material font rather than a Unicode glyph.</b> The glyphs (▤ ⌗ ⚙) came from
|
||||
/// whichever symbol font the phone had, at whatever weight it drew them, and ⚙ meant both this tab and the
|
||||
/// Preferences row under it (audit M7). The icon font is the one the desktop's rail uses.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A count, when there is one.</b> Connections carries the number of open sessions in a green badge, which
|
||||
/// was otherwise only said by the strip of shells above the bar. Zero draws nothing: a "0" is a number about
|
||||
/// nothing, and the badge being absent already says it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It has a name.</b> Its content is a panel, and Avalonia's automation peer reported that panel's type name
|
||||
/// to TalkBack — "Avalonia.Controls.Panel", three times across the bar (audit H4). The label is the name.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every colour here is resolved from <c>Theme/Palette.axaml</c> rather than written out. A nav bar that
|
||||
@@ -39,25 +57,28 @@ internal sealed class NavButton : Button
|
||||
public static readonly StyledProperty<string> LabelProperty =
|
||||
AvaloniaProperty.Register<NavButton, string>(nameof(Label), string.Empty);
|
||||
|
||||
/// <summary>The glyph drawn above the label.</summary>
|
||||
/// <remarks>
|
||||
/// A character rather than an icon asset. The four the design uses — ▤ ⌗ ⚿ ≣ — are all in the fonts
|
||||
/// Android already has, so shipping four vectors to draw them would buy nothing but four more files to
|
||||
/// keep in step with the palette.
|
||||
/// </remarks>
|
||||
/// <summary>The icon drawn above the label: one codepoint of the embedded Material Icons font.</summary>
|
||||
public static readonly StyledProperty<string> GlyphProperty =
|
||||
AvaloniaProperty.Register<NavButton, string>(nameof(Glyph), string.Empty);
|
||||
|
||||
public static readonly StyledProperty<bool> IsCurrentProperty =
|
||||
AvaloniaProperty.Register<NavButton, bool>(nameof(IsCurrent));
|
||||
|
||||
private readonly Border indicator;
|
||||
/// <summary>A count drawn over the icon, or zero for none.</summary>
|
||||
public static readonly StyledProperty<int> CountProperty =
|
||||
AvaloniaProperty.Register<NavButton, int>(nameof(Count));
|
||||
|
||||
private readonly Border pill;
|
||||
private readonly TextBlock glyph;
|
||||
private readonly TextBlock text;
|
||||
private readonly Border badge;
|
||||
private readonly TextBlock badgeText;
|
||||
|
||||
// Resolved from the palette once the control is in the tree; see Repaint.
|
||||
private IBrush? currentBrush;
|
||||
private IBrush? currentIconBrush;
|
||||
private IBrush? idleBrush;
|
||||
private IBrush? pillBrush;
|
||||
|
||||
public NavButton()
|
||||
{
|
||||
@@ -70,35 +91,71 @@ internal sealed class NavButton : Button
|
||||
HorizontalContentAlignment = HorizontalAlignment.Stretch;
|
||||
VerticalContentAlignment = VerticalAlignment.Stretch;
|
||||
|
||||
indicator = new Border
|
||||
{
|
||||
Height = 2,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Background = Brushes.Transparent,
|
||||
};
|
||||
|
||||
glyph = new TextBlock
|
||||
{
|
||||
FontSize = 15,
|
||||
FontSize = 21,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
AutomationProperties.SetAccessibilityView(glyph, AccessibilityView.Raw);
|
||||
|
||||
(badge, badgeText) = MakeBadge();
|
||||
|
||||
pill = new Border
|
||||
{
|
||||
Width = 64,
|
||||
Height = 32,
|
||||
CornerRadius = new CornerRadius(16),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
Background = Brushes.Transparent,
|
||||
Child = glyph,
|
||||
};
|
||||
|
||||
text = new TextBlock
|
||||
{
|
||||
FontSize = 9,
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeight.Medium,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
|
||||
var stack = new StackPanel
|
||||
Content = new StackPanel
|
||||
{
|
||||
Spacing = 3,
|
||||
Spacing = 4,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Children = { new Panel { Children = { pill, badge } }, text },
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The count's badge, over the pill's top-right corner.</summary>
|
||||
/// <remarks>
|
||||
/// Over the corner rather than beside the label, so it reads as belonging to the icon and costs the
|
||||
/// label no width.
|
||||
/// </remarks>
|
||||
private static (Border Badge, TextBlock Text) MakeBadge()
|
||||
{
|
||||
var count = new TextBlock
|
||||
{
|
||||
FontSize = 10.5,
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Children = { glyph, text },
|
||||
};
|
||||
|
||||
Content = new Panel { Children = { indicator, stack } };
|
||||
var holder = new Border
|
||||
{
|
||||
MinWidth = 18,
|
||||
Height = 18,
|
||||
CornerRadius = new CornerRadius(9),
|
||||
Padding = new Thickness(5, 0),
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Margin = new Thickness(0, -4, 8, 0),
|
||||
IsVisible = false,
|
||||
Child = count,
|
||||
};
|
||||
|
||||
return (holder, count);
|
||||
}
|
||||
|
||||
public string Label
|
||||
@@ -121,6 +178,13 @@ internal sealed class NavButton : Button
|
||||
set => SetValue(IsCurrentProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="CountProperty" />
|
||||
public int Count
|
||||
{
|
||||
get => GetValue(CountProperty);
|
||||
set => SetValue(CountProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
@@ -129,6 +193,7 @@ internal sealed class NavButton : Button
|
||||
if (change.Property == LabelProperty)
|
||||
{
|
||||
text.Text = Label;
|
||||
AutomationProperties.SetName(this, Label);
|
||||
}
|
||||
else if (change.Property == GlyphProperty)
|
||||
{
|
||||
@@ -138,9 +203,10 @@ internal sealed class NavButton : Button
|
||||
{
|
||||
Repaint();
|
||||
}
|
||||
else if (change.Property == FontFamilyProperty)
|
||||
else if (change.Property == CountProperty)
|
||||
{
|
||||
text.FontFamily = FontFamily;
|
||||
badge.IsVisible = Count > 0;
|
||||
badgeText.Text = Count.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,13 +217,17 @@ internal sealed class NavButton : Button
|
||||
|
||||
// Resolved here rather than in the constructor: application resources are not reachable while the
|
||||
// control is still being constructed, and a hard-coded stack would drift from Palette.axaml.
|
||||
if (Application.Current?.FindResource("MonoFont") is FontFamily mono)
|
||||
if (Application.Current?.FindResource("IconFont") is FontFamily icons)
|
||||
{
|
||||
text.FontFamily = mono;
|
||||
glyph.FontFamily = icons;
|
||||
}
|
||||
|
||||
currentBrush = Application.Current?.FindResource("AccentText") as IBrush;
|
||||
currentBrush = Application.Current?.FindResource("Text") as IBrush;
|
||||
currentIconBrush = Application.Current?.FindResource("AccentText") as IBrush;
|
||||
idleBrush = Application.Current?.FindResource("TextFaint") as IBrush;
|
||||
pillBrush = Application.Current?.FindResource("AccentSoft") as IBrush;
|
||||
badge.Background = Application.Current?.FindResource("Live") as IBrush;
|
||||
badgeText.Foreground = Application.Current?.FindResource("Canvas") as IBrush;
|
||||
|
||||
// IsCurrent is normally bound and so has usually already been set by the time the template applies,
|
||||
// which is why this runs again here rather than only on the property change.
|
||||
@@ -166,16 +236,21 @@ internal sealed class NavButton : Button
|
||||
|
||||
private void Repaint()
|
||||
{
|
||||
// Both the label and the bar, not just the bar — and the weight as well. See the remark on the type.
|
||||
// The pill, the colour and the weight together. See the remark on the type.
|
||||
var paint = IsCurrent ? currentBrush : idleBrush;
|
||||
var iconPaint = IsCurrent ? currentIconBrush : idleBrush;
|
||||
|
||||
if (paint is not null)
|
||||
{
|
||||
glyph.Foreground = paint;
|
||||
text.Foreground = paint;
|
||||
}
|
||||
|
||||
text.FontWeight = IsCurrent ? FontWeight.SemiBold : FontWeight.Medium;
|
||||
indicator.Background = IsCurrent && currentBrush is not null ? currentBrush : Brushes.Transparent;
|
||||
if (iconPaint is not null)
|
||||
{
|
||||
glyph.Foreground = iconPaint;
|
||||
}
|
||||
|
||||
text.FontWeight = IsCurrent ? FontWeight.Bold : FontWeight.Medium;
|
||||
pill.Background = IsCurrent && pillBrush is not null ? pillBrush : Brushes.Transparent;
|
||||
}
|
||||
}
|
||||
@@ -73,17 +73,28 @@
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ Material icons rather than Unicode glyphs, as the desktop's own rail draws them. The glyphs came from
|
||||
whatever symbol font the phone happened to have, at whatever weight and optical size it drew them — the
|
||||
heavy ❯ beside the hairline ◳ the 2026-10-02 audit pointed at (M7) — and two of them meant two things:
|
||||
⚙ was both the Settings tab and Preferences, ⚿ both the keychain and locking it. The icon font is the
|
||||
one the desktop embeds, so one destination now has one mark on both heads.
|
||||
-->
|
||||
<Style Selector="Button.railentry TextBlock.railglyph">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
|
||||
<Setter Property="FontSize" Value="19" />
|
||||
<!-- A ligature codepoint is noise to a screen reader; the entry's name is on the button. -->
|
||||
<Setter Property="AutomationProperties.AccessibilityView" Value="Raw" />
|
||||
<Setter Property="Width" Value="24" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!-- A destination's name is a word rather than an identifier, so it takes the sans face — see the remark
|
||||
on TextBlock.body in Theme/Phone.axaml. -->
|
||||
<Style Selector="Button.railentry TextBlock.raillabel">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="FontSize" Value="13.5" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
</Style>
|
||||
@@ -120,11 +131,11 @@
|
||||
<DockPanel LastChildFill="False">
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Margin="8,0,8,10">
|
||||
<Button Classes="railentry" Classes.active="{Binding IsPreferencesShowing}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="Preferences" Classes.active="{Binding IsPreferencesShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="⚙" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Preferences" />
|
||||
</Grid>
|
||||
</Button>
|
||||
@@ -134,11 +145,11 @@
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="8,10,8,0" Spacing="2">
|
||||
|
||||
<Button Classes="railentry" Classes.active="{Binding IsHostsShowing}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="Hosts" Classes.active="{Binding IsHostsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="▤" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Hosts" />
|
||||
<TextBlock Grid.Column="2" Classes="railcount" Text="{Binding Vault.Hosts.Count}" />
|
||||
</Grid>
|
||||
@@ -155,67 +166,67 @@
|
||||
surface and is that count, drawn as the sessions themselves — a number beside this word would be
|
||||
the same fact said twice, three inches apart.
|
||||
-->
|
||||
<Button Classes="railentry" Classes.active="{Binding IsTerminalSurface}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="Connections" Classes.active="{Binding IsTerminalSurface}"
|
||||
Command="{Binding ShowTerminalCommand}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="⌗" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Connections" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Border Height="1" Background="{StaticResource Border}" Margin="4,8" />
|
||||
|
||||
<Button Classes="railentry" Classes.active="{Binding IsKeychainShowing}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="Keychain" Classes.active="{Binding IsKeychainShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Keychain}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="⚿" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Keychain" />
|
||||
<TextBlock Grid.Column="2" Classes="railcount" Text="{Binding Vault.Keys.Count}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="railentry" Classes.active="{Binding IsSnippetsShowing}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="Snippets" Classes.active="{Binding IsSnippetsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Snippets}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="❯" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Snippets" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="railentry" Classes.active="{Binding IsTransfersShowing}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="SFTP" Classes.active="{Binding IsTransfersShowing}"
|
||||
Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Host}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="⇅" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="SFTP" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="railentry" Classes.active="{Binding IsBucketsShowing}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="S3" Classes.active="{Binding IsBucketsShowing}"
|
||||
Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Bucket}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="◳" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="S3" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="railentry" Classes.active="{Binding IsLogsShowing}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="Logs" Classes.active="{Binding IsLogsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="≡" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Logs" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="railentry" Classes.active="{Binding IsVaultsShowing}"
|
||||
<Button Classes="railentry" AutomationProperties.Name="Vaults" Classes.active="{Binding IsVaultsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Vaults}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="◎" />
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Vaults" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
@@ -24,10 +24,11 @@
|
||||
are the pair a session moves between; on the desktop the terminal is not a rail entry at all.
|
||||
|
||||
── a terminal gets the screen ─────────────────────────────────────────────────────────────────────────
|
||||
Three of the four rows below stand down while a shell is showing: the header, the shells strip and the
|
||||
bottom bar itself. The strip asks IsShowingPages directly; the other two go through flags the control
|
||||
computes, and the bar's is the one that differs — it stays up on Connections with nothing running, which
|
||||
is the terminal surface drawing a page rather than a shell. See PhoneShell.RefreshChrome.
|
||||
The rows around the screen stand down while a shell is showing: the header and the bottom bar. Both go
|
||||
through flags the control computes, and the bar's is the one that differs — it stays up on Connections
|
||||
with nothing running, which is the terminal surface drawing a page rather than a shell. See
|
||||
PhoneShell.RefreshChrome. (A third row, the strip of open shells, was removed by the 2026-10-04 redesign;
|
||||
the count on Connections took its place — see the remark where it was.)
|
||||
|
||||
The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at
|
||||
360dp the shell was framed by about a third of the display, all of it about somewhere the user was not.
|
||||
@@ -78,7 +79,7 @@
|
||||
<views:PhoneRail Grid.Column="0"
|
||||
IsVisible="{Binding $parent[views:PhoneShell].ShowsRail}" />
|
||||
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,*,Auto,Auto">
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!--
|
||||
The header: which vault, and whether it is synced.
|
||||
@@ -106,34 +107,53 @@
|
||||
header in HostsScreen, which the header stands down for, both move with it for the same reason;
|
||||
see the remark on each.
|
||||
-->
|
||||
<StackPanel>
|
||||
<Border Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,0,0,1" Padding="14,0" Height="56">
|
||||
BorderThickness="0,0,0,1" Padding="10,0,6,0" Height="60">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<!--
|
||||
Filled rather than outlined since v2. The mark is the one thing on this header that is not a
|
||||
fact about the vault, and the design gives it the accent as a solid tile — which is also what
|
||||
the launcher icon draws, so the two agree.
|
||||
-->
|
||||
<Border Grid.Column="0" Width="26" Height="26" CornerRadius="8"
|
||||
Background="{StaticResource Accent}" VerticalAlignment="Center">
|
||||
<TextBlock Text=">_" Foreground="{StaticResource AccentInk}"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="10" FontWeight="Bold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
◆ The vault's name is a button now, and it opens the vaults.
|
||||
|
||||
<TextBlock Grid.Column="1" Classes="heading" Margin="10,0,8,0" FontSize="16"
|
||||
Text="{Binding Vault.VaultName}" TextTrimming="CharacterEllipsis" />
|
||||
The 2026-10-04 redesign draws the name with a chevron, as the switch it is on every phone app
|
||||
that holds more than one of something. What it leads to is VAULTS, which is where the vaults
|
||||
this phone shows are chosen — so the chevron is a promise the screen behind it keeps, rather
|
||||
than a dropdown this head has not got.
|
||||
|
||||
The mark stays filled, and takes the gradient Button.primary has: it is the one thing on this
|
||||
header that is not a fact about the vault, and the launcher icon draws it the same way.
|
||||
-->
|
||||
<Button Grid.Column="0" Classes="row" MinHeight="48" Padding="4,0,8,0" CornerRadius="12"
|
||||
HorizontalAlignment="Left"
|
||||
AutomationProperties.Name="{Binding Vault.VaultName, StringFormat='Vault {0}. Choose vaults'}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Vaults}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Border Width="32" Height="32" CornerRadius="10" Background="{StaticResource AccentGradient}"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text=">_" Foreground="{StaticResource AccentInk}"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="12" FontWeight="Bold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw" />
|
||||
</Border>
|
||||
<TextBlock Classes="heading" FontSize="18" FontWeight="Bold" MaxWidth="170"
|
||||
Text="{Binding Vault.VaultName}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="icon" Text="" FontSize="20" Foreground="{StaticResource TextFaint}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
The sync light, and it is green only when it has earned it — see SyncLabel. The design draws
|
||||
a permanently green "Synced" here, which is the one claim on that mock-up this application
|
||||
will not make.
|
||||
-->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="6" Height="6"
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center"
|
||||
Margin="0,0,6,0">
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="8" Height="8"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="label" FontSize="9" Text="{Binding SyncLabel}" />
|
||||
<TextBlock FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource TextDim}"
|
||||
Text="{Binding SyncLabel}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
@@ -145,16 +165,49 @@
|
||||
the bar it raises takes this header's place — so the pencil is in that bar, beside the count of
|
||||
what it would edit. See HostActionBar.axaml, which is drawn in the Panel below this one.
|
||||
|
||||
What is left in this row is the vault's name, the sync light and LOCK: three facts about the
|
||||
keychain, and none of them about a row.
|
||||
What is left in this row is the vault's name, the sync light and the lock: three facts about
|
||||
the keychain, and none of them about a row. The lock is an icon since the redesign, with its
|
||||
name on it for a screen reader rather than the word LOCK in nine-pixel capitals.
|
||||
-->
|
||||
<Button Grid.Column="3" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
|
||||
ToolTip.Tip="Lock the keychain">
|
||||
<TextBlock Text="LOCK" Classes="label" FontSize="8.5"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
<Button Grid.Column="3" Classes="icon" Command="{Binding LockCommand}"
|
||||
AutomationProperties.Name="Lock the keychain" ToolTip.Tip="Lock the keychain">
|
||||
<TextBlock Classes="icon" Text="" FontSize="21" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ HOSTS · KEYCHAIN · SNIPPETS: the vault's three kinds of content, as one switch.
|
||||
|
||||
The redesign's answer to the audit's M8, that SETTINGS held the product. The keychain and the
|
||||
snippets were rows on the hub, a screen away from the hosts they are used with; they are the open
|
||||
vault's contents as much as the hosts are, so they now sit beside them under the VAULTS tab, and the
|
||||
hub keeps what is about this phone rather than about a vault.
|
||||
|
||||
Narrow surfaces only. A wide one has the rail, which lists all three already — see
|
||||
PhoneShell.ShowsVaultTabs. The chips are RadioButtons for the reason the keychain's own are: the
|
||||
three are exclusive and a screen reader should say so, and Mode=OneWay so the switch moves when the
|
||||
screen does and never ahead of it.
|
||||
-->
|
||||
<Border IsVisible="{Binding $parent[views:PhoneShell].ShowsVaultTabs}" Padding="16,12,16,4">
|
||||
<Border Classes="segmented">
|
||||
<Grid ColumnDefinitions="*,*,*">
|
||||
<RadioButton Grid.Column="0" Classes="segment" GroupName="vaulttab" Content="Hosts"
|
||||
IsChecked="{Binding IsHostsShowing, Mode=OneWay}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}" />
|
||||
<RadioButton Grid.Column="1" Classes="segment" GroupName="vaulttab" Content="Keychain"
|
||||
IsChecked="{Binding IsKeychainShowing, Mode=OneWay}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Keychain}" />
|
||||
<RadioButton Grid.Column="2" Classes="segment" GroupName="vaulttab" Content="Snippets"
|
||||
IsChecked="{Binding IsSnippetsShowing, Mode=OneWay}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Snippets}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ ◆ the contextual action bar ============ -->
|
||||
@@ -277,67 +330,14 @@
|
||||
</Panel>
|
||||
|
||||
<!--
|
||||
The shells strip. The design puts it above the nav on every screen, and that is the single most
|
||||
desktop-shaped idea in the product — it is what makes a terminal a surface you switch to rather than
|
||||
a screen you navigate away from. Kept, because losing it would make an open shell invisible from the
|
||||
host list, which is where you are when you open the next one.
|
||||
◆ THE SHELLS STRIP IS GONE, AND THE BADGE ON CONNECTIONS IS WHAT REPLACED IT.
|
||||
|
||||
v2 draws the sessions as pills rather than as a labelled row, and drops the word SHELLS: with a
|
||||
rounded chip carrying a live dot and a name, the label was spending nine characters of a 360dp row
|
||||
saying what the row already looks like.
|
||||
|
||||
On every screen except the one it names. The terminal draws these same sessions in its own bar, and
|
||||
two rows of the same pills — one of them 46 pixels of it — is the arrangement this surface exists to
|
||||
stop. Wrapped rather than given a second condition, because the strip's own visibility is about
|
||||
whether there are any tabs and this one is about which surface is up.
|
||||
|
||||
◆ And it stands down under the host editor, which is a page rather than a card since the pencil moved
|
||||
into the action bar. A strip of open shells above a form is the same two-rows-of-chrome problem the
|
||||
terminal has, on a screen where the form is the whole point of being there. See
|
||||
PhoneShell.ShowsShellStrip, which is where that "and" is made, Avalonia's bindings having none.
|
||||
It sat above the bar on every page — the single most desktop-shaped idea in the product, kept so that
|
||||
an open shell was never invisible from the host list. The 2026-10-04 redesign keeps that promise with
|
||||
less: Connections carries the number of open sessions in a green badge, and one tap on it is the
|
||||
sessions themselves, in the terminal's own bar. That is 46 pixels of every page given back to the list,
|
||||
and one row of the same pills where there were two on the way into a shell.
|
||||
-->
|
||||
<Panel Grid.Row="2" IsVisible="{Binding $parent[views:PhoneShell].ShowsShellStrip}">
|
||||
<!-- DeepChrome rather than Sidebar, since v5, joining the header and the bar above and below it:
|
||||
the strip is chrome the same way they are — a frame around the screen rather than a pane of
|
||||
it — and Sidebar is what a card sits on, which this row is not. -->
|
||||
<Border IsVisible="{Binding HasTabs}" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
||||
<Button Classes="row" MinHeight="34" Padding="13,0" CornerRadius="10"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1"
|
||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<!--
|
||||
Green only while there is a shell behind the tab. It used to be lit unconditionally,
|
||||
which was true when a tab could not exist without a session; one can now —
|
||||
connecting opens the tab first — and a dot that was green before anything had
|
||||
answered would be the one thing on this strip claiming something untrue.
|
||||
|
||||
Amber while it is being made, which is the other half of that correction. Not being
|
||||
green stopped the dot lying, but it left a tab still dialling drawn exactly like a
|
||||
tab whose shell has exited — the two states on this strip with the least in common,
|
||||
one worth waiting for and one over. See Phone.axaml.
|
||||
-->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
|
||||
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ navigation ============ -->
|
||||
<!--
|
||||
@@ -353,20 +353,26 @@
|
||||
◆ It is no longer a single question — a wide surface takes the rail instead — so it reads one flag
|
||||
the control computes rather than a condition here. See PhoneShell.ShowsBottomBar.
|
||||
|
||||
── three rather than four ─────────────────────────────────────────────────────────────────────────
|
||||
The keychain moved behind SETTINGS. A bottom bar is for the places a session moves between, and keys,
|
||||
credentials and tags are not that — they are managed occasionally and then left alone, which is the
|
||||
shape of everything else already behind the hub. What is left is the two halves of using this
|
||||
application, and the drawer holding the rest.
|
||||
── VAULTS · CONNECTIONS · SETTINGS ────────────────────────────────────────────────────────────────
|
||||
Three entries still, and the first is the open vault rather than its host list: hosts, the keychain
|
||||
and snippets are a switch at the top of it. The keychain had moved behind SETTINGS on the argument that
|
||||
a bottom bar is for the places a session moves between; the redesign keeps the bar at three and puts the
|
||||
vault's own contents back one tap from anywhere, which is what the audit's M8 asked for. 72 tall, for
|
||||
the pill and a 12-pixel label under it — see NavButton.
|
||||
-->
|
||||
<!-- DeepChrome rather than Chrome, since v5 — see the remark on the vault header, above, which this
|
||||
bar is the foot of the same frame the header is the top of. -->
|
||||
<Border Grid.Row="3" IsVisible="{Binding $parent[views:PhoneShell].ShowsBottomBar}"
|
||||
<Border Grid.Row="2" IsVisible="{Binding $parent[views:PhoneShell].ShowsBottomBar}"
|
||||
Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" Height="64">
|
||||
BorderThickness="0,1,0,0" Height="72">
|
||||
<Grid ColumnDefinitions="*,*,*">
|
||||
|
||||
<views:NavButton Grid.Column="0" Label="Hosts" Glyph="▤" IsCurrent="{Binding IsHostsShowing}"
|
||||
<!--
|
||||
IsVaultSurface rather than IsHostsShowing, for the reason SETTINGS reads IsMoreSurface: the tab
|
||||
stands for three screens, and it stays lit on whichever of them is up. A tap goes to the hosts,
|
||||
which is where the application opens and what the vault's switch starts on.
|
||||
-->
|
||||
<views:NavButton Grid.Column="0" Label="Vaults" Glyph="" IsCurrent="{Binding IsVaultSurface}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}" />
|
||||
|
||||
@@ -386,9 +392,11 @@
|
||||
shell up the bar is gone and nothing here is read at all. It was left unbound while the bar was
|
||||
collapsed for the whole of the terminal surface, which made a lit state unreachable; a screen the
|
||||
bar can now sit under and not point at is the entry looking broken instead.
|
||||
|
||||
◆ The count is the open tabs, and it is what the shells strip used to say. See the remark above.
|
||||
-->
|
||||
<views:NavButton Grid.Column="1" Label="Connections" Glyph="⌗"
|
||||
IsCurrent="{Binding IsTerminalSurface}"
|
||||
<views:NavButton Grid.Column="1" Label="Connections" Glyph=""
|
||||
IsCurrent="{Binding IsTerminalSurface}" Count="{Binding Tabs.Count}"
|
||||
Command="{Binding ShowTerminalCommand}" />
|
||||
|
||||
<!--
|
||||
@@ -396,18 +404,32 @@
|
||||
and a bar that went dark the moment you opened one of them would only ever light two of its
|
||||
three entries.
|
||||
|
||||
"Settings" and a gear, where it was "More" and a hamburger. The hub held a handful of things
|
||||
that did not fit and could not be called anything better than More; with the keychain, teams and
|
||||
preferences all on it, it is the place everything that is not connecting to something lives —
|
||||
which is what a gear has meant on a phone since before this application existed.
|
||||
"Settings" and a gear, where it was "More" and a hamburger. With the vault's contents moved under
|
||||
VAULTS, what is behind it is about this phone and this account — security, the terminal's
|
||||
behaviour, which vaults are shown, the logs — which is what a gear has meant on a phone since
|
||||
before this application existed.
|
||||
-->
|
||||
<views:NavButton Grid.Column="2" Label="Settings" Glyph="⚙" IsCurrent="{Binding IsMoreSurface}"
|
||||
<views:NavButton Grid.Column="2" Label="Settings" Glyph="" IsCurrent="{Binding IsMoreSurface}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.More}" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ THE REST OF THE SCRIM, over the two rows of chrome a sheet on the hosts screen cannot reach.
|
||||
|
||||
The hosts screen's sheets are drawn inside the screen's own row, so their scrim dimmed the list and
|
||||
left the header — with the lock on it — and the bottom bar bright and live above and below every
|
||||
sheet (audit M12). These two finish the job in the same colour, declared last so they draw over
|
||||
the header and the bar, and a tap on either does what a tap on the sheet's own scrim does: lowers
|
||||
the sheet. See PhoneShell.ShowsSheetScrim and OnChromeScrimTapped.
|
||||
-->
|
||||
<Button Grid.Row="0" Classes="scrim" IsVisible="{Binding $parent[views:PhoneShell].ShowsSheetScrim}"
|
||||
Click="OnChromeScrimTapped" AutomationProperties.Name="Close the sheet" />
|
||||
<Button Grid.Row="2" Classes="scrim" IsVisible="{Binding $parent[views:PhoneShell].ShowsSheetScrim}"
|
||||
Click="OnChromeScrimTapped" AutomationProperties.Name="Close the sheet" />
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -43,6 +43,13 @@ internal sealed partial class PhoneShell : UserControl
|
||||
/// <summary>The software keyboard, while this control is attached. Null on a platform without one.</summary>
|
||||
private IInputPane? keyboard;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the software keyboard is up, read from the pane's state rather than from the inset: before
|
||||
/// Android 15 the window itself shrinks for the keyboard and the inset reads zero. See
|
||||
/// <see cref="ApplyKeyboardInset(double)"/>.
|
||||
/// </summary>
|
||||
private bool keyboardOpen;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the lock screen currently showing is the one the application launched into.
|
||||
/// </summary>
|
||||
@@ -89,6 +96,7 @@ internal sealed partial class PhoneShell : UserControl
|
||||
if (shell is not null)
|
||||
{
|
||||
shell.PropertyChanged -= OnShellChanged;
|
||||
shell.Transfers.PropertyChanged -= OnVaultChanged;
|
||||
}
|
||||
|
||||
shell = DataContext as MainWindowViewModel;
|
||||
@@ -96,6 +104,11 @@ internal sealed partial class PhoneShell : UserControl
|
||||
if (shell is not null)
|
||||
{
|
||||
shell.PropertyChanged += OnShellChanged;
|
||||
|
||||
// The files screen's actions sheet is the one sheet outside the hosts screen that the chrome
|
||||
// scrim covers for, so its openness is heard here too. Transfers is built once with the shell
|
||||
// and never replaced, unlike the vault.
|
||||
shell.Transfers.PropertyChanged += OnVaultChanged;
|
||||
ApplyScreenshotPolicy(shell.State);
|
||||
|
||||
thisLockIsTheLaunch = true;
|
||||
@@ -170,9 +183,13 @@ internal sealed partial class PhoneShell : UserControl
|
||||
public static readonly StyledProperty<bool> ShowsHostSelectionBarProperty =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsHostSelectionBar));
|
||||
|
||||
/// <summary>Whether the strip of open shells above the bottom bar is drawn.</summary>
|
||||
public static readonly StyledProperty<bool> ShowsShellStripProperty =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsShellStrip));
|
||||
/// <summary>Whether the HOSTS · KEYCHAIN · SNIPPETS switch is drawn under the vault's header.</summary>
|
||||
/// <remarks>
|
||||
/// Narrow surfaces only: a wide one has the rail, which lists the three already, and a switch repeating
|
||||
/// three of the rail's entries an inch to the right of them would be the same choice offered twice.
|
||||
/// </remarks>
|
||||
public static readonly StyledProperty<bool> ShowsVaultTabsProperty =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsVaultTabs));
|
||||
|
||||
/// <inheritdoc cref="IsWideProperty" />
|
||||
public bool IsWide
|
||||
@@ -209,11 +226,22 @@ internal sealed partial class PhoneShell : UserControl
|
||||
private set => SetValue(ShowsHostSelectionBarProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ShowsShellStripProperty" />
|
||||
public bool ShowsShellStrip
|
||||
/// <summary>Whether the header and the bottom bar are dimmed under a sheet the hosts screen raised.</summary>
|
||||
public static readonly StyledProperty<bool> ShowsSheetScrimProperty =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsSheetScrim));
|
||||
|
||||
/// <inheritdoc cref="ShowsSheetScrimProperty" />
|
||||
public bool ShowsSheetScrim
|
||||
{
|
||||
get => GetValue(ShowsShellStripProperty);
|
||||
private set => SetValue(ShowsShellStripProperty, value);
|
||||
get => GetValue(ShowsSheetScrimProperty);
|
||||
private set => SetValue(ShowsSheetScrimProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ShowsVaultTabsProperty" />
|
||||
public bool ShowsVaultTabs
|
||||
{
|
||||
get => GetValue(ShowsVaultTabsProperty);
|
||||
private set => SetValue(ShowsVaultTabsProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -243,6 +271,13 @@ internal sealed partial class PhoneShell : UserControl
|
||||
/// Losing them on the keychain would be losing the only LOCK button on the surface.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>Narrow, it is the VAULTS tab's header now, on all three of its screens.</b> The keychain and
|
||||
/// snippets moved from behind SETTINGS to the vault's own switch (see
|
||||
/// <see cref="MainWindowViewModel.IsVaultSurface"/>), so the header and the switch under it are drawn
|
||||
/// over hosts, keychain and snippets alike, and those two screens' own headers stand down on a narrow
|
||||
/// surface to make room.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>The header is now a swap rather than a switch, and the editor takes the whole screen.</b> Two
|
||||
/// more flags and two more inputs, both of them the vault's rather than the shell's — see
|
||||
/// <see cref="vault"/>. While hosts are ticked the header stands down and
|
||||
@@ -272,9 +307,11 @@ internal sealed partial class PhoneShell : UserControl
|
||||
var connectPage = shell is { IsTerminalSurface: true, HasTabs: false };
|
||||
|
||||
// The editor is a page of its own now, so nothing else is drawn around it — not the vault header,
|
||||
// not the shells strip, and not the way off the screen. Its own header carries the back arrow, which
|
||||
// not the vault's switch, and not the way off the screen. Its own header carries the back arrow, which
|
||||
// is the one control it needs and the one the system gesture already maps to.
|
||||
var editing = vault?.IsEditing == true;
|
||||
// The keychain's editor page counts too: it is the host editor's shape and wants the same screen.
|
||||
var editing = vault?.IsEditing == true
|
||||
|| (vault?.IsEditingAVaultItem == true && shell?.IsKeychainShowing == true);
|
||||
|
||||
// Only on the hosts screen. The ticks survive a trip to the keychain — the set is not cleared by
|
||||
// navigating — and a bar counting hosts over the transfers screen would be chrome about a list that
|
||||
@@ -293,10 +330,17 @@ internal sealed partial class PhoneShell : UserControl
|
||||
|
||||
IsWide = wide;
|
||||
ShowsRail = wide && (pages || connectPage) && !editing;
|
||||
ShowsBottomBar = !wide && (pages || connectPage) && !editing;
|
||||
ShowsShellStrip = pages && !editing;
|
||||
// ◆ And not while the software keyboard is up. The body shrinks to stay clear of the keyboard, so the
|
||||
// bar rode up on top of it and took 72 pixels from whatever was being typed into — the connect sheet,
|
||||
// most often (audit M12). Every Android app with a bottom bar puts it away for the keyboard.
|
||||
ShowsBottomBar = !wide && (pages || connectPage) && !editing && !keyboardOpen;
|
||||
ShowsHostSelectionBar = pages && choosing && !editing;
|
||||
ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsMoreSurface != true);
|
||||
ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsVaultSurface == true);
|
||||
ShowsVaultTabs = ShowsVaultHeader && !wide;
|
||||
ShowsSheetScrim = pages
|
||||
&& ((vault is not null && HasASheetUp(vault))
|
||||
|| (shell is { Transfers.IsEntryMenuOpen: true }
|
||||
&& (shell.IsTransfersShowing || shell.IsBucketsShowing)));
|
||||
}
|
||||
|
||||
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
@@ -452,10 +496,18 @@ internal sealed partial class PhoneShell : UserControl
|
||||
DispatcherPriority.Background);
|
||||
|
||||
private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e)
|
||||
=> ApplyKeyboardInset(e.NewState is InputPaneState.Open ? e.EndRect.Height : 0);
|
||||
{
|
||||
keyboardOpen = e.NewState is InputPaneState.Open;
|
||||
ApplyKeyboardInset(keyboardOpen ? e.EndRect.Height : 0);
|
||||
RefreshChrome();
|
||||
}
|
||||
|
||||
private void ApplyKeyboardInset(IInputPane pane)
|
||||
=> ApplyKeyboardInset(pane.State is InputPaneState.Open ? pane.OccludedRect.Height : 0);
|
||||
{
|
||||
keyboardOpen = pane.State is InputPaneState.Open;
|
||||
ApplyKeyboardInset(keyboardOpen ? pane.OccludedRect.Height : 0);
|
||||
RefreshChrome();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds the phone's whole interface clear of the software keyboard.
|
||||
@@ -568,7 +620,7 @@ internal sealed partial class PhoneShell : UserControl
|
||||
/// application opens on, which is what every other Android app does.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>v3 adds one guard above the switch rather than another case inside it.</b> The switch's first case
|
||||
/// <b>v3 adds one guard above the switch rather than another case inside it.</b> The switch's second case
|
||||
/// is the membership test of <see cref="MainWindowViewModel.IsMoreSurface"/> minus <c>More</c> itself,
|
||||
/// and has to stay in step with it — a screen added to the hub and not to that case would trap the user
|
||||
/// on it. An editor is not a screen and has no entry there. It is also strictly nearer: the add sheet
|
||||
@@ -608,6 +660,14 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
// The files screen's actions sheet, raised from its selection bar.
|
||||
if (current.Transfers.IsEntryMenuOpen)
|
||||
{
|
||||
current.Transfers.IsEntryMenuOpen = false;
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryCloseAnOpenEditor(current))
|
||||
{
|
||||
e.Handled = true;
|
||||
@@ -621,41 +681,75 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryGoUp(current))
|
||||
{
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Moves one level up the screens, for the back gesture.</summary>
|
||||
/// <returns>Whether it moved, and so whether back has been spent.</returns>
|
||||
/// <remarks>See <see cref="OnBackRequested"/>, whose last step this is.</remarks>
|
||||
private bool TryGoUp(MainWindowViewModel current)
|
||||
{
|
||||
switch (current.Screen)
|
||||
{
|
||||
case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers
|
||||
or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vaults
|
||||
or ShellScreen.Keychain:
|
||||
// ◆ The vault's other two screens go back to its hosts rather than to the hub, since the redesign
|
||||
// moved them under VAULTS: the switch at the top of the vault is where they were reached from, and
|
||||
// HOSTS is where that switch starts.
|
||||
case ShellScreen.Keychain or ShellScreen.Snippets:
|
||||
current.ShowScreenCommand.Execute(ShellScreen.Hosts);
|
||||
return true;
|
||||
|
||||
case ShellScreen.Logs or ShellScreen.Transfers
|
||||
or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vaults:
|
||||
|
||||
// ◆ The hub, or Hosts where there is no hub. A wide surface draws the rail instead and
|
||||
// never draws SETTINGS, so backing out to it would land on a screen with no way off it but
|
||||
// a second back — and the arrows in these screens' own headers are hidden there for the
|
||||
// same reason. Home is where back goes when the thing you came from is not on the surface.
|
||||
current.ShowScreenCommand.Execute(IsWide ? ShellScreen.Hosts : ShellScreen.More);
|
||||
e.Handled = true;
|
||||
break;
|
||||
return true;
|
||||
|
||||
case ShellScreen.More:
|
||||
current.ShowScreenCommand.Execute(ShellScreen.Hosts);
|
||||
e.Handled = true;
|
||||
break;
|
||||
return true;
|
||||
|
||||
default:
|
||||
// Hosts, and anything the phone does not draw. Left unhandled, so back leaves the app.
|
||||
break;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lowers the connect menu or the forwards sheet, whichever is up over the terminal.
|
||||
/// Lowers whatever is up over the terminal: the customise sheet, the quick-access keyboard, the connect
|
||||
/// menu or the forwards sheet.
|
||||
/// </summary>
|
||||
/// <returns>Whether one was closed, and so whether back has been spent.</returns>
|
||||
/// <remarks>
|
||||
/// Both are raised from the terminal's bar and the scrim under each covers that bar, so at most one is
|
||||
/// ever open; the order below decides nothing.
|
||||
/// The three sheets are raised from the terminal's bar or its keys and the scrim under each covers both,
|
||||
/// so at most one is ever open. The quick-access keyboard comes second because it is the one that can be
|
||||
/// up with nothing over it — opening the customise sheet lowers it, so the two never compete either.
|
||||
/// </remarks>
|
||||
private static bool TryCloseATerminalSheet(MainWindowViewModel current)
|
||||
{
|
||||
// The customise sheet, raised from the quick-access keyboard. DONE is the only other way off it, and
|
||||
// every change on it has already been saved, so back loses nothing.
|
||||
if (current.Keyboard.IsCustomizing)
|
||||
{
|
||||
current.Keyboard.CloseCustomizeCommand.Execute(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The quick-access keyboard stands where the software keyboard stands, and back puts the software
|
||||
// keyboard away without leaving the terminal — so it does the same for this. Only on the terminal
|
||||
// surface: the flag outlives a trip to another screen, where there is no panel to lower.
|
||||
if (current.Keyboard.IsQuickAccessOpen && current.IsTerminalShowing)
|
||||
{
|
||||
current.Keyboard.CloseQuickAccess();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current.IsConnectSheetOpen)
|
||||
{
|
||||
current.CloseConnectSheetCommand.Execute(null);
|
||||
@@ -671,6 +765,25 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Whether one of the hosts screen's four sheets is up.</summary>
|
||||
/// <remarks>The same four <see cref="TryLowerASheet"/> lowers, in the same terms.</remarks>
|
||||
private static bool HasASheetUp(VaultViewModel vault) =>
|
||||
vault.IsHostActionSheetOpen || vault.IsAskingForConnectPassword || vault.IsAddSheetOpen
|
||||
|| vault.GroupSheet is not null || vault.IsKeychainAddSheetOpen;
|
||||
|
||||
/// <summary>A tap on the scrim over the header or the bar: lowers the sheet, as its own scrim does.</summary>
|
||||
private void OnChromeScrimTapped(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (shell is { Transfers.IsEntryMenuOpen: true })
|
||||
{
|
||||
shell.Transfers.IsEntryMenuOpen = false;
|
||||
}
|
||||
else if (shell?.Vault is { } open)
|
||||
{
|
||||
TryLowerASheet(open);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lowers whatever the hosts screen has raised over its list, topmost first.
|
||||
/// </summary>
|
||||
@@ -736,6 +849,12 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return true;
|
||||
}
|
||||
|
||||
if (vault.IsKeychainAddSheetOpen)
|
||||
{
|
||||
vault.CloseKeychainAddSheetCommand.Execute(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (vault.GroupSheet is not null)
|
||||
{
|
||||
vault.CloseGroupSheetCommand.Execute(null);
|
||||
@@ -773,6 +892,11 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return true;
|
||||
}
|
||||
|
||||
if (KeychainScreen.TryCancelAnEditor(vault))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (vault.IsEditingGroup)
|
||||
{
|
||||
vault.CancelGroupEditCommand.Execute(null);
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<!-- The lock screen draws the same ring beside its own fingerprint button. -->
|
||||
<Ellipse Width="16" Height="16" Stroke="{StaticResource Accent}" StrokeThickness="1.5"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Unlock with a fingerprint" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Unlock with a fingerprint" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="body"
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:kb="using:DodoSSH.Client.Shell.Keyboard"
|
||||
xmlns:views="using:DodoSSH.Client.Android.Views"
|
||||
x:Class="DodoSSH.Client.Android.Views.QuickAccessKeyboard"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Background="{StaticResource DeepChrome}">
|
||||
|
||||
<!--
|
||||
The quick-access keyboard: every key, snippet and command from history, and the terminal's appearance,
|
||||
one tap from the prompt.
|
||||
|
||||
One control in two places. On a phone it takes the software keyboard's place under the terminal, at the
|
||||
keyboard's own height so the bar above it does not move; on a wide screen it is a panel beside the
|
||||
terminal that stays up while the software keyboard comes and goes. Same tabs, same rows, same view model
|
||||
either way — see TerminalScreen for which one is drawn when.
|
||||
|
||||
It reopens to the tab last used, which is a setting rather than a field on this control: the tab is the
|
||||
one somebody reaches for on this device, and the next launch should know it too. See
|
||||
TerminalKeyboardViewModel.SelectedTab.
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
|
||||
<!--
|
||||
◆ NOTHING HERE TAKES FOCUS, FOR THE REASON THE BAR'S KEYS DO NOT.
|
||||
|
||||
This panel is an extension of the keyboard, not a place for the keyboard to go. On a wide screen the
|
||||
software keyboard can be up at the same time, and an ordinary button would take Avalonia's focus off
|
||||
TerminalInputSurface on tap — tearing down the input connection and dropping the keyboard every time a
|
||||
key in this panel was used. On a phone the keyboard is down while this is up, but the focus it would take
|
||||
is the focus that brings the keyboard straight back when the terminal is tapped. One rule for the whole
|
||||
control, so a button added to it later cannot forget.
|
||||
-->
|
||||
<Style Selector="Button">
|
||||
<Setter Property="Focusable" Value="False" />
|
||||
</Style>
|
||||
|
||||
<!-- The tab strip's buttons: an icon square, lit when it is the tab showing. -->
|
||||
<Style Selector="Button.qtab">
|
||||
<Setter Property="Width" Value="48" />
|
||||
<Setter Property="Height" Value="40" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.qtab:pressed /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||
</Style>
|
||||
<Style Selector="Button.qtab.selected">
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
</Style>
|
||||
<Style Selector="Button.qtab.selected /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource AccentSoft}" />
|
||||
</Style>
|
||||
|
||||
<!-- A group's caption over its keys. -->
|
||||
<Style Selector="TextBlock.groupcaption">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="9.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Margin" Value="4,10,0,4" />
|
||||
</Style>
|
||||
|
||||
<!-- Keys in a panel are a little shorter than the bar's: there are many more of them, all reachable. -->
|
||||
<Style Selector="Button.hotkey.panelkey">
|
||||
<Setter Property="Height" Value="38" />
|
||||
<Setter Property="MinHeight" Value="38" />
|
||||
<Setter Property="Margin" Value="3" />
|
||||
</Style>
|
||||
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<!-- ============ the tabs ============ -->
|
||||
<!--
|
||||
Icons rather than words, as the keys tab's own grid is: four words at 360dp leave no room for the
|
||||
keyboard button at the end, and each glyph is the one every phone uses for the thing — a grid of keys,
|
||||
braces for snippets, a clock for history, a palette for appearance. Each carries its name for
|
||||
accessibility, which is where a word belongs when there is no room to print one.
|
||||
-->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="8,6,8,2">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4">
|
||||
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsKeysTab}" Content=""
|
||||
AutomationProperties.Name="Keys"
|
||||
Command="{Binding Keyboard.SelectTabCommand}"
|
||||
CommandParameter="{x:Static vm:QuickAccessTab.Keys}" />
|
||||
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsSnippetsTab}" Content=""
|
||||
AutomationProperties.Name="Snippets"
|
||||
Command="{Binding Keyboard.SelectTabCommand}"
|
||||
CommandParameter="{x:Static vm:QuickAccessTab.Snippets}" />
|
||||
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsHistoryTab}" Content=""
|
||||
AutomationProperties.Name="History"
|
||||
Command="{Binding Keyboard.SelectTabCommand}"
|
||||
CommandParameter="{x:Static vm:QuickAccessTab.History}" />
|
||||
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsAppearanceTab}" Content=""
|
||||
AutomationProperties.Name="Appearance"
|
||||
Command="{Binding Keyboard.SelectTabCommand}"
|
||||
CommandParameter="{x:Static vm:QuickAccessTab.Appearance}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Back to the phone's own keyboard. The one control here that is not a tab, so it sits apart behind a
|
||||
hairline: it does not change what this panel shows, it puts the panel away.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4">
|
||||
<Border Width="1" Height="22" Background="{StaticResource Border}" VerticalAlignment="Center" />
|
||||
<Button Classes="qtab" Content="" AutomationProperties.Name="Show the keyboard"
|
||||
Click="OnKeyboardRequested" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Panel Grid.Row="1">
|
||||
|
||||
<!-- ============ KEYS ============ -->
|
||||
<ScrollViewer IsVisible="{Binding Keyboard.IsKeysTab}" HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="8,4,8,12">
|
||||
|
||||
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center"
|
||||
Command="{Binding Keyboard.OpenCustomizeCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text=""
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="label" FontSize="10" Text="CUSTOMIZE" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
The bar's own keys, first, when the bar is not drawn — which is a wide screen with this panel
|
||||
open beside the terminal. A key somebody put on their bar is one they want within reach, and
|
||||
hiding the bar must not hide it.
|
||||
-->
|
||||
<StackPanel IsVisible="{Binding $parent[views:QuickAccessKeyboard].ShowsBarGroup}">
|
||||
<TextBlock Classes="groupcaption" Text="YOUR BAR" />
|
||||
<ItemsControl ItemsSource="{Binding Keyboard.BarRows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KeyRowViewModel">
|
||||
<ItemsControl ItemsSource="{Binding Keys}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Columns="4" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KeyButtonViewModel">
|
||||
<Button Classes="hotkey panelkey" Classes.latched="{Binding IsLatched}"
|
||||
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
|
||||
Command="{Binding PressCommand}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Keyboard.Groups}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KeyGroupViewModel">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="groupcaption" Text="{Binding Title}" />
|
||||
<!--
|
||||
Four across, which at 360dp is an 84dp key — wide enough for "shift+tab" and "ctrl+B %"
|
||||
in the mono face without shrinking it, and still four keys a thumb can tell apart.
|
||||
-->
|
||||
<ItemsControl ItemsSource="{Binding Keys}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Columns="4" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KeyButtonViewModel">
|
||||
<Button Classes="hotkey panelkey" Classes.latched="{Binding IsLatched}"
|
||||
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
|
||||
Command="{Binding PressCommand}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ SNIPPETS ============ -->
|
||||
<!--
|
||||
The keychain's snippets, through the shell's own InsertSnippet — the command the desktop sidebar's
|
||||
rows run, which carries the snippets screen's whole safety story: typed rather than pasted for one
|
||||
line, and no Enter unless the snippet was saved as one that runs. A second insert path here would be
|
||||
a second place that story could go stale. The ▶ says which ones run.
|
||||
-->
|
||||
<ScrollViewer IsVisible="{Binding Keyboard.IsSnippetsTab}" HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="8,4,8,12" Spacing="2">
|
||||
<ItemsControl ItemsSource="{Binding SnippetsScreen.Visible, FallbackValue={x:Null}}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SnippetRowViewModel">
|
||||
<Button Classes="row" MinHeight="48" Padding="12,6"
|
||||
Command="{Binding $parent[views:QuickAccessKeyboard].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="{Binding Label}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail code" FontSize="10.5" Text="{Binding Preview}"
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="label" FontSize="9" Text="▶ RUNS" Margin="10,0,0,0"
|
||||
Foreground="{StaticResource WarnText}" VerticalAlignment="Center"
|
||||
IsVisible="{Binding RunsOnInsert}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
|
||||
Text="Tap a snippet to type it at the prompt. Snippets live in the keychain, where they are added and edited." />
|
||||
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center" Margin="0,6,0,0"
|
||||
Command="{Binding ShowScreenCommand}" CommandParameter="{x:Static vm:ShellScreen.Snippets}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="MANAGE SNIPPETS" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ HISTORY ============ -->
|
||||
<!--
|
||||
What was typed on this phone and echoed back by the remote — never a line typed at a password prompt,
|
||||
which echoes nothing. Kept in memory only and emptied by LOCK; the sentence under the list says so,
|
||||
because a list of commands is exactly the kind of thing somebody should know the lifetime of. See
|
||||
TypedCommandHistory for the whole of what is and is not kept.
|
||||
-->
|
||||
<ScrollViewer IsVisible="{Binding Keyboard.IsHistoryTab}" HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="8,4,8,12" Spacing="2">
|
||||
<ItemsControl ItemsSource="{Binding Keyboard.History}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="kb:HistoryEntry">
|
||||
<Button Classes="row" MinHeight="44" Padding="12,6"
|
||||
Command="{Binding $parent[views:QuickAccessKeyboard].((vm:MainWindowViewModel)DataContext).Keyboard.InsertHistoryCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="12.5" Text="{Binding Command}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="detail code" FontSize="10" Text="{Binding Host}"
|
||||
Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !Keyboard.HasHistory}"
|
||||
Text="Commands you type here appear in this list. Only what the remote echoes back is kept, so nothing typed at a password prompt ever is." />
|
||||
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
|
||||
Text="Tap a command to type it at the prompt without running it. The list is kept in memory only, and locking the keychain clears it." />
|
||||
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center" Margin="0,6,0,0"
|
||||
IsVisible="{Binding Keyboard.HasHistory}"
|
||||
Command="{Binding Keyboard.ClearHistoryCommand}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="CLEAR HISTORY" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ APPEARANCE ============ -->
|
||||
<!--
|
||||
The text size, which used to ride at the end of the arrow row as A− and A+. A phone cannot press
|
||||
Ctrl+plus, so these are still buttons; they moved here because the bar is now somebody's own and two
|
||||
fixed cells at its end were cells they could not have. Disabled at the ends rather than clamping
|
||||
silently, for the reason they always were: a button that accepts taps and does nothing reads as the
|
||||
terminal having stopped responding.
|
||||
|
||||
And the cursor gesture's switch, for somebody whose long press means something else to them.
|
||||
-->
|
||||
<ScrollViewer IsVisible="{Binding Keyboard.IsAppearanceTab}" HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="12,6,12,12" Spacing="8">
|
||||
|
||||
<TextBlock Classes="groupcaption" Text="TEXT SIZE" Margin="0,4,0,0" />
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
<Button Grid.Column="0" Classes="hotkey" Width="56" Command="{Binding ShrinkTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||
</Button>
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="14" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Text="{Binding TerminalFontSize, StringFormat='{}{0} px'}" />
|
||||
<Button Grid.Column="2" Classes="hotkey" Width="56" Command="{Binding EnlargeTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||
</Button>
|
||||
<Button Grid.Column="3" Classes="row" MinHeight="40" Padding="12,0" Margin="8,0,0,0"
|
||||
Command="{Binding ResetTerminalFontCommand}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="RESET" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="groupcaption" Text="CURSOR GESTURE" Margin="0,10,0,0" />
|
||||
<Button Classes="row" MinHeight="52" Padding="12,8" Command="{Binding Keyboard.ToggleCursorGesturesCommand}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="Long-press and drag" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Moves the cursor or walks through history. Drag further to go faster." />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="label" FontSize="9.5" Text="ON" Margin="10,0,0,0"
|
||||
Foreground="{StaticResource Live}" VerticalAlignment="Center"
|
||||
IsVisible="{Binding Keyboard.CursorGestures}" />
|
||||
<TextBlock Grid.Column="1" Classes="label" FontSize="9.5" Text="OFF" Margin="10,0,0,0"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !Keyboard.CursorGestures}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</Panel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,40 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>The quick-access keyboard: keys, snippets, history and appearance, in four tabs.</summary>
|
||||
internal sealed partial class QuickAccessKeyboard : UserControl
|
||||
{
|
||||
/// <summary>Whether the KEYS tab repeats the bar's own keys at the top.</summary>
|
||||
/// <remarks>
|
||||
/// On for the wide screen's side panel, where the bar is not drawn; off under a phone's terminal, where
|
||||
/// the bar is directly above this and a second copy would be two rows of the same keys.
|
||||
/// </remarks>
|
||||
public static readonly StyledProperty<bool> ShowsBarGroupProperty =
|
||||
AvaloniaProperty.Register<QuickAccessKeyboard, bool>(nameof(ShowsBarGroup));
|
||||
|
||||
public QuickAccessKeyboard() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
/// <summary>
|
||||
/// The keyboard button in the tab strip was pressed: put this panel away and bring the phone's back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An event rather than a command, because both halves are platform calls the shared view model has no
|
||||
/// business making — showing the software keyboard means focusing the terminal's input surface, which
|
||||
/// only <see cref="TerminalScreen"/> holds.
|
||||
/// </remarks>
|
||||
public event EventHandler? KeyboardRequested;
|
||||
|
||||
/// <inheritdoc cref="ShowsBarGroupProperty" />
|
||||
public bool ShowsBarGroup
|
||||
{
|
||||
get => GetValue(ShowsBarGroupProperty);
|
||||
set => SetValue(ShowsBarGroupProperty, value);
|
||||
}
|
||||
|
||||
private void OnKeyboardRequested(object? sender, RoutedEventArgs e) =>
|
||||
KeyboardRequested?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
@@ -27,18 +27,14 @@
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
|
||||
|
||||
<!-- ============ header ============ -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Height="56" Margin="8,0">
|
||||
<!--
|
||||
◆ Gone on a wide surface, where the rail is how you leave and the hub it points at is not drawn at
|
||||
all. An arrow to a screen nothing else can reach would be the one control on this header that leads
|
||||
somewhere the layout has removed. See PhoneShell.IsWide.
|
||||
-->
|
||||
<Button Grid.Column="0" Classes="icon" Content="←"
|
||||
IsVisible="{Binding !$parent[views:PhoneShell].IsWide}"
|
||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.More}" />
|
||||
<TextBlock Grid.Column="1" Classes="heading" Text="Snippets" Margin="4,0" />
|
||||
<Button Grid.Column="2" Classes="icon accent" Content="+" Command="{Binding NewCommand}" />
|
||||
<!--
|
||||
◆ Wide surfaces only, since the 2026-10-04 redesign, for the reason the keychain's header is: on a narrow
|
||||
one this screen is the SNIPPETS third of the open vault's switch, under the vault's own header. The back
|
||||
arrow went with it, and the + moved to the floating button below — the one shape every add on this head
|
||||
now takes (audit M10), where a header + here meant "new snippet" and the terminal's meant "new session".
|
||||
-->
|
||||
<Grid Grid.Row="0" Height="56" Margin="12,0" IsVisible="{Binding $parent[views:PhoneShell].IsWide}">
|
||||
<TextBlock Classes="heading" Text="Snippets" Margin="4,0" />
|
||||
</Grid>
|
||||
|
||||
<!-- ============ filter ============ -->
|
||||
@@ -103,9 +99,9 @@
|
||||
Text="Left off, inserting types the command at the prompt and stops, so it can be read and edited before it runs. Nothing here can tell whether the terminal is even at a prompt, which is why that is the default." />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="SAVE"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="SAVE"
|
||||
Command="{Binding SaveCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -119,27 +115,41 @@
|
||||
<TextBlock Classes="body" IsVisible="{Binding !HasVisible}" Margin="24,20" VerticalAlignment="Top"
|
||||
Text="{Binding EmptyMessage}" />
|
||||
|
||||
<ListBox ItemsSource="{Binding Visible}" SelectedItem="{Binding Selected}"
|
||||
<!-- The foot padding is the floating button's: the last card scrolls clear of it. -->
|
||||
<ListBox ItemsSource="{Binding Visible}" SelectedItem="{Binding Selected}" Padding="0,4,0,88"
|
||||
Background="Transparent" BorderThickness="0" IsVisible="{Binding HasVisible}">
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<!-- A name for TalkBack rather than the row view model's type name (audit H4). -->
|
||||
<Setter Property="AutomationProperties.Name" Value="{ReflectionBinding Label}" />
|
||||
</Style>
|
||||
<!--
|
||||
On the card, as the keychain does it and for the keychain's reason: Border.card paints an opaque
|
||||
surface over the item, so a fill behind it would never show. This list used to be lit by Fluent's
|
||||
own selection blue showing round the card's edges; the theme no longer draws that (audit M3), and
|
||||
the chosen snippet is the one the bar under the list acts on, so it has to look chosen.
|
||||
-->
|
||||
<Style Selector="ListBoxItem:selected Border.card">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource TerminalTabAccent}" />
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SnippetRowViewModel">
|
||||
<Border Classes="card" Margin="12,5">
|
||||
<StackPanel Spacing="9">
|
||||
<!--
|
||||
◆ The redesign's snippet card: the name in the sans face, the command under it in the mono, and
|
||||
what a tap on INSERT will do in a badge on the right — RUNS for one that presses Enter, INSERTS
|
||||
for one that waits at the prompt to be read. Both are said, rather than only the first: a card
|
||||
with no badge was the safe one, and the safe one is the one worth naming too.
|
||||
-->
|
||||
<Border Classes="card" Margin="16,4" Padding="14,12">
|
||||
<Grid ColumnDefinitions="*,10,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="5">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Label}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
|
||||
<!-- ◆ The chip that says this one does not wait to be read. -->
|
||||
<Border Classes="tag" IsVisible="{Binding RunsOnInsert}"
|
||||
Background="{StaticResource WarnWash}">
|
||||
<TextBlock Text="RUNS" Foreground="{StaticResource Warn}" />
|
||||
</Border>
|
||||
<TextBlock FontSize="15" FontWeight="SemiBold" Foreground="{StaticResource Text}"
|
||||
Text="{Binding Label}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
|
||||
<Border Classes="tag outline"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
@@ -156,22 +166,39 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- The command, on the surface every block of monospace in this design is drawn on.
|
||||
No radius override any more: it diverged from Border.output's own 14 under v2 for a
|
||||
smaller card-nested block, and now that the style's own value has moved to 12 there is
|
||||
no gap left worth a second number for — it takes the class's, like every other output
|
||||
block on this head. -->
|
||||
<Border Classes="output" Padding="11,9">
|
||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Preview}"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
</Border>
|
||||
<!-- The command, one line of mono under the name. It was a block of its own on the terminal's
|
||||
surface; the redesign keeps the card to two lines so four of them fit where two did. -->
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Preview}"
|
||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- ◆ The badge that says whether this one waits to be read. Amber for the one that does not. -->
|
||||
<Border Grid.Column="2" Classes="tag" IsVisible="{Binding RunsOnInsert}"
|
||||
Background="{StaticResource WarnWash}">
|
||||
<TextBlock Text="RUNS" Foreground="{StaticResource Warn}" />
|
||||
</Border>
|
||||
<Border Grid.Column="2" Classes="tag outline" IsVisible="{Binding !RunsOnInsert}">
|
||||
<TextBlock Text="INSERTS" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ add ============ -->
|
||||
<!--
|
||||
◆ The floating button, where the header's + was: every add on this head is this one shape now — hosts,
|
||||
keychain, snippets — and it is where a thumb already is. Not while the editor is open, which is the
|
||||
add it would raise.
|
||||
-->
|
||||
<Button Grid.Row="3" Classes="fab" Margin="0,0,18,18" HorizontalAlignment="Right" VerticalAlignment="Bottom"
|
||||
AutomationProperties.Name="New snippet" IsVisible="{Binding !IsEditing}"
|
||||
Command="{Binding NewCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="26" />
|
||||
</Button>
|
||||
|
||||
<!-- ============ what to do with the chosen one ============ -->
|
||||
<!--
|
||||
Raised over the list, as the hosts screen raises its connect bar, and for the same reason: there is no
|
||||
@@ -242,9 +269,9 @@
|
||||
<TextBlock Classes="body"
|
||||
Text="The snippet is re-encrypted with the other vault's key, so everybody who holds that key can read this command and insert it — and nobody else can. Nothing else about it changes." />
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="MOVE"
|
||||
Command="{Binding ConfirmMoveCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelMoveCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
job — see the remark on the header in PhoneShell.axaml. -->
|
||||
<Border Grid.Row="0" Height="35" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto">
|
||||
|
||||
<!--
|
||||
Back, and it goes to the page this terminal was opened over rather than to Hosts by name. The
|
||||
@@ -178,8 +178,10 @@
|
||||
the 44 that matters is the horizontal one, since nothing in a row of this shape is hard to hit
|
||||
above or below.
|
||||
-->
|
||||
<Button Grid.Column="0" Classes="icon" Content="←" Height="34" Margin="4,0,0,0"
|
||||
Command="{Binding ShowScreenCommand}" CommandParameter="{Binding Screen}" />
|
||||
<Button Grid.Column="0" Classes="icon" Height="34" Margin="4,0,0,0" AutomationProperties.Name="Back"
|
||||
Command="{Binding ShowScreenCommand}" CommandParameter="{Binding Screen}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="20" />
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
v2 draws these as pills rather than as a segmented strip, so the row is transparent and each
|
||||
@@ -206,7 +208,7 @@
|
||||
BorderThickness="1" CornerRadius="10" Height="30">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Classes="row" MinHeight="28" Padding="11,0" CornerRadius="10"
|
||||
VerticalContentAlignment="Center"
|
||||
VerticalContentAlignment="Center" AutomationProperties.Name="{Binding Label}"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
@@ -239,13 +241,13 @@
|
||||
to look like.
|
||||
-->
|
||||
<Button Classes="row" MinHeight="28" Width="44" Padding="0" CornerRadius="0,10,10,0"
|
||||
AutomationProperties.Name="{Binding Label, StringFormat='Close {0}'}"
|
||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1,0,0,0"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock Text="×" Foreground="{StaticResource TextFaint}" FontSize="14"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" />
|
||||
<TextBlock Classes="icon" Text="" Foreground="{StaticResource TextFaint}"
|
||||
FontSize="16" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
@@ -301,14 +303,40 @@
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="3" Classes="icon" Content="+" Height="34" Margin="0,0,4,0"
|
||||
<Button Grid.Column="3" Classes="icon" Height="34" Margin="0,0,4,0"
|
||||
AutomationProperties.Name="Connect, or open this host's files"
|
||||
Click="OnSheetRequested"
|
||||
Command="{Binding OpenConnectSheetCommand}" />
|
||||
Command="{Binding OpenConnectSheetCommand}">
|
||||
<TextBlock Classes="icon" Text="" FontSize="22" />
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
The wide screen's side panel, shown and hidden — the quick-access keyboard beside the terminal
|
||||
rather than under it. Here rather than at the start of the key bar, which is where a phone keeps
|
||||
its toggle: on a tablet the bar is not drawn while the panel is open, and the control that brings
|
||||
the panel back cannot live inside the thing the panel replaced. Lit while open, like a latched key.
|
||||
-->
|
||||
<Button Grid.Column="4" Classes="icon" Height="34" Margin="0,0,4,0"
|
||||
Classes.accent="{Binding Keyboard.IsSidePanelOpen}"
|
||||
AutomationProperties.Name="Keys, snippets and history"
|
||||
IsVisible="{Binding $parent[views:TerminalScreen].IsWide}"
|
||||
Command="{Binding Keyboard.ToggleSidePanelCommand}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text=""
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ the renderer ============ -->
|
||||
<Panel Grid.Row="1">
|
||||
<!-- ============ the renderer, and the wide screen's side panel ============ -->
|
||||
<!--
|
||||
Two columns, and the second is collapsed except on a wide screen with the panel open. Beside the
|
||||
renderer rather than over it, for the reason nothing on this head is drawn over it: the WebView is a
|
||||
native child composited above everything Avalonia paints, so a panel laid on top would be invisible.
|
||||
Taking its width off the terminal refits the grid and tells the remote, exactly as the software
|
||||
keyboard taking its height does.
|
||||
-->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto">
|
||||
<Panel Grid.Column="0">
|
||||
|
||||
<!--
|
||||
============ nothing open ============
|
||||
@@ -395,7 +423,7 @@
|
||||
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold"
|
||||
Text="{Binding HostLabel}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
|
||||
<TextBlock Classes="detail code" FontSize="10.5" Text="{Binding Address}"
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="detail" FontSize="10"
|
||||
@@ -551,60 +579,21 @@
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ accessory keys ============ -->
|
||||
<!--
|
||||
The row every Android SSH client ships, and the reason is not preference: a software keyboard has no
|
||||
Ctrl, no Esc, no Tab and no arrows, and without them a phone cannot interrupt a process, complete a
|
||||
path, or reach the previous command. Ctrl and Alt latch — pressed once they apply to the next key and
|
||||
then release, because holding a modifier while typing is not possible one-thumbed.
|
||||
The quick-access keyboard as a side panel: additional keys, snippets, history and appearance in view
|
||||
while the terminal is worked in, which is what a tablet's width is for. 320 wide, which at the 600dp
|
||||
where this head turns wide leaves the terminal 280 — a narrow terminal, and a panel somebody can close
|
||||
from the bar above; at a tablet's 900 it is a third.
|
||||
|
||||
ShowsBarGroup, because the bar under the terminal stands down while this is open and the keys someone
|
||||
put on it have to go somewhere they can still be reached.
|
||||
-->
|
||||
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
|
||||
<!--
|
||||
◆ TWO FIXED ROWS THAT FILL THE WIDTH, AND NOTHING HERE SCROLLS. THAT IS THE WHOLE CHANGE.
|
||||
<Border Grid.Column="1" Width="320" BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
|
||||
IsVisible="{Binding $parent[views:TerminalScreen].ShowsSidePanel}">
|
||||
<views:QuickAccessKeyboard x:Name="SidePanel" ShowsBarGroup="True" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
What this replaced was one 33-pixel row holding eleven 30-by-44 keys inside a horizontal ScrollViewer,
|
||||
and it was wrong in two ways that compounded each other.
|
||||
|
||||
── the scroller was eating the presses ──────────────────────────────────────────────────────────────
|
||||
A horizontally scrollable strip and a row of buttons want opposite things from the same gesture. Every
|
||||
tap starts as a press and a few pixels of travel, and a ScrollViewer that can pan claims exactly that:
|
||||
a thumb coming down at an angle — which is how a thumb comes down — is read as the start of a pan, the
|
||||
button never sees a click, and the row slides slightly instead. Pressing a key and having the keys
|
||||
move is the single most confidence-destroying thing a keyboard can do, and it happened often enough
|
||||
to be the first thing anybody noticed about this row.
|
||||
|
||||
── and the keys were too small to hit anyway ─────────────────────────────────────────────────────────
|
||||
30 tall. Android's own minimum touch target is 48 and every accessory row in every terminal on the
|
||||
platform is somewhere near it. The height had been taken down from 38 to save a strip of terminal,
|
||||
which is a trade worth making right up until the control stops working, and 30 is past that.
|
||||
|
||||
── what the other clients do, because all of them agree ─────────────────────────────────────────────
|
||||
Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it.
|
||||
They divide the width evenly between a fixed set of keys and, where the set does not fit, they use a
|
||||
second row — Termux ships two by default. That is not convention for its own sake: a key whose
|
||||
position is fixed can be hit without looking, which is the entire point of a modifier row you use
|
||||
while reading the output above it. A key that might have scrolled has to be found first.
|
||||
|
||||
So: two rows, every key stretched to an equal share of the width, none of it scrollable, and 40 tall
|
||||
instead of 30. Nothing was dropped — all eleven keys are here, and the four that mattered most are the
|
||||
ones that benefit, because the arrows were the keys living past the right edge.
|
||||
|
||||
── what it costs, stated rather than buried ─────────────────────────────────────────────────────────
|
||||
About 51 pixels of terminal, which is four or five lines of output. That is a real price and it is the
|
||||
right one: the row that was cheaper did not work, and a terminal with five more lines and no reachable
|
||||
Ctrl is not the better surface.
|
||||
|
||||
── the split ────────────────────────────────────────────────────────────────────────────────────────
|
||||
Modifiers and punctuation on top, cursor keys underneath. The arrows go on the lower row because they
|
||||
are the keys pressed repeatedly and in sequence — history, then along the line — so they want the row
|
||||
the thumb rests nearest, and there are only four of them, which leaves them the widest targets on the
|
||||
whole keypad. The text-size buttons ride at that row's right-hand end, outside the flex.
|
||||
|
||||
The five pixels above the whole thing are a gap and not a border. The renderer is a native child view,
|
||||
so nothing Avalonia draws can sit on top of it — a hairline between the two would have to be a row of
|
||||
its own — and a terminal whose last line of output is flush against a row of grey keys reads as one
|
||||
surface that has gone wrong rather than as two that are different things.
|
||||
-->
|
||||
<!-- ============ what a terminal says once its shell has stopped ============ -->
|
||||
<!--
|
||||
The phone's half of SessionEndedBar, and it needs it more than the desk does: a mobile link drops on
|
||||
@@ -654,71 +643,90 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Margin="0,5,0,0">
|
||||
<StackPanel Spacing="4">
|
||||
<!-- ============ the keys ============ -->
|
||||
<!--
|
||||
The keys a software keyboard does not have, and the way to all the others.
|
||||
|
||||
── the bar ──────────────────────────────────────────────────────────────────────────────────────────
|
||||
The row every Android SSH client ships, and the reason is not preference: a software keyboard has no
|
||||
Ctrl, no Esc, no Tab and no arrows, and without them a phone cannot interrupt a process, complete a
|
||||
path, or reach the previous command. Ctrl and Alt latch — pressed once they apply to the next key, from
|
||||
the bar or from the phone's own keyboard, and then release — because holding a modifier while typing is
|
||||
not possible one-thumbed.
|
||||
|
||||
It is somebody's own now: which keys, in what order, set on the customise screen and kept on this
|
||||
device. What did not change is the shape the old fixed keypad argued its way to, and the argument still
|
||||
holds. Nothing here scrolls — a horizontally scrollable strip reads a thumb coming down at an angle as
|
||||
the start of a pan, and the key never sees the press. Every key gets an equal share of its row's width,
|
||||
so "ctrl" and "|" are the same target. At most eight to a row and two rows, which keeps a key at about
|
||||
41dp on a 360dp phone; anything past sixteen belongs on the quick-access keyboard. The first eight are
|
||||
the top row, so a person arranging their bar decides the rows by the order alone.
|
||||
|
||||
As shipped: modifiers and punctuation on top, cursor keys underneath, nearest the thumb, because they
|
||||
are the keys pressed repeatedly and in sequence — history, then along the line.
|
||||
|
||||
── the grid button ──────────────────────────────────────────────────────────────────────────────────
|
||||
At the bar's left-hand end, outside the rows, and the one key nobody can remove: it is the way to the
|
||||
quick-access keyboard and from there to the customise screen, so a bar arranged to nothing is still a
|
||||
bar with a way back. Lit while the panel is up, as a latched modifier is, because it is a state.
|
||||
|
||||
── the panel ────────────────────────────────────────────────────────────────────────────────────────
|
||||
The quick-access keyboard, in the software keyboard's place: the code-behind puts the phone's keyboard
|
||||
away as this opens and gives this the height that keyboard last had, so the bar does not move between
|
||||
the two. Tapping the terminal brings the phone's keyboard back and puts this away again.
|
||||
|
||||
The five pixels above the whole thing are a gap and not a border. The renderer is a native child view,
|
||||
so nothing Avalonia draws can sit on top of it — a hairline between the two would have to be a row of its
|
||||
own — and a terminal whose last line is flush against a row of grey keys reads as one surface gone wrong.
|
||||
-->
|
||||
<StackPanel Grid.Row="3" IsVisible="{Binding $parent[views:TerminalScreen].ShowsKeys}">
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*" Margin="8,5,8,4">
|
||||
|
||||
<!--
|
||||
Modifiers and punctuation. Populated from the table in the code-behind, which is also where the
|
||||
bytes each one sends live; a UniformGrid rather than a StackPanel so the seven divide the width
|
||||
between them instead of taking what their labels happen to need — "ctrl" is four characters and
|
||||
"|" is one, and on a row of keys those two want the same target, not proportional ones.
|
||||
Focusable="False", for the reason every key carries it — see the remark on Button.hotkey's use in
|
||||
QuickAccessKeyboard. A button here that took focus would take it off the terminal's input surface,
|
||||
and the keyboard would go with it.
|
||||
-->
|
||||
<UniformGrid x:Name="AccessoryKeys" Columns="7" Margin="8,0" />
|
||||
<Button Grid.Column="0" Classes="hotkey" Width="44" Focusable="False" VerticalAlignment="Bottom"
|
||||
Margin="0,0,4,0"
|
||||
Classes.latched="{Binding Keyboard.IsQuickAccessOpen}"
|
||||
AutomationProperties.Name="Keys, snippets and history"
|
||||
IsVisible="{Binding !$parent[views:TerminalScreen].IsWide}"
|
||||
Click="OnQuickAccessToggled">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text=""
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
The cursor keys, and the two text-size buttons riding at the end.
|
||||
<ItemsControl Grid.Column="1" ItemsSource="{Binding Keyboard.BarRows}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Spacing="4" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KeyRowViewModel">
|
||||
<ItemsControl ItemsSource="{Binding Keys}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Rows="1" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KeyButtonViewModel">
|
||||
<Button Classes="hotkey" Focusable="False" Classes.latched="{Binding IsLatched}"
|
||||
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
|
||||
Command="{Binding PressCommand}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
|
||||
Grid rather than a second UniformGrid across the whole width: the arrows share what is left after
|
||||
A− and A+ have taken their fixed 44 each, so the four of them flex and the two of them do not.
|
||||
Putting all six in one uniform row would make the font buttons as wide as an arrow key and move
|
||||
them every time the row's contents changed.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
|
||||
|
||||
<UniformGrid x:Name="ArrowKeys" Grid.Column="0" Columns="4" />
|
||||
|
||||
<!--
|
||||
The text-size buttons.
|
||||
|
||||
They used to have a row of their own above the terminal, on the argument that the accessory row
|
||||
scrolls and these two must never be off-screen — a terminal too small to read is exactly the
|
||||
state in which hunting for the control that fixes it is worst. Nothing on this keypad scrolls
|
||||
any more, so that argument is now satisfied by construction rather than by placement.
|
||||
|
||||
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
|
||||
has no keyboard to press it with, which is why the two heads differ here and nowhere else in
|
||||
this screen.
|
||||
|
||||
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
|
||||
nothing reads as the terminal having stopped responding, which is the one thing this screen must
|
||||
never look like.
|
||||
|
||||
◆ Focusable="False", for the reason the accessory keys carry in code — see
|
||||
TerminalScreen.axaml.cs. Every button on this keypad sits over a terminal somebody is typing
|
||||
into, and an ordinary one takes Avalonia's focus off the input surface on tap. Making the text
|
||||
smaller must not cost the keyboard.
|
||||
-->
|
||||
<!-- No Spacing: Button.hotkey carries a 2-pixel side margin, so the gaps here match the
|
||||
UniformGrid cells' without being counted twice. -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="8,0,0,0">
|
||||
<Border Width="1" Height="20" Background="{StaticResource Border}" Margin="0,0,4,0"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<Button Classes="hotkey" Width="44" Focusable="False"
|
||||
Command="{Binding ShrinkTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="hotkey" Width="44" Focusable="False"
|
||||
Command="{Binding EnlargeTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
Height="{Binding $parent[views:TerminalScreen].QuickAccessHeight}"
|
||||
IsVisible="{Binding $parent[views:TerminalScreen].ShowsQuickAccessPanel}">
|
||||
<views:QuickAccessKeyboard x:Name="BottomPanel" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -757,10 +765,32 @@
|
||||
|
||||
<TextBlock Classes="title" Text="CONNECT" FontSize="13" />
|
||||
|
||||
<Button Classes="row" Margin="0,10,0,0" Command="{Binding ShowScreenCommand}"
|
||||
<!--
|
||||
◆ This shell's own host's files, first: the redesign draws a session's terminal and its files as
|
||||
two views of one thing, and this is the nearest this application comes. It opens SFTP to the host
|
||||
the selected tab is on — the desktop sidebar's "Open SFTP", through the same command — so the
|
||||
listing lands on the right machine without choosing it again from a picker. It is a second
|
||||
connection, which is why it is offered rather than switched to; see
|
||||
MainWindowViewModel.SelectFilesHostAsync.
|
||||
-->
|
||||
<Button Classes="row" Margin="0,10,0,0" Command="{Binding OpenOtherSurfaceCommand}"
|
||||
IsVisible="{Binding SelectedTab, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
AutomationProperties.Name="Files on this host">
|
||||
<Grid ColumnDefinitions="Auto,14,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" FontSize="22"
|
||||
Foreground="{StaticResource AccentText}" />
|
||||
<StackPanel Grid.Column="2" Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Files on this host" />
|
||||
<TextBlock Classes="detail code" Text="{Binding SelectedTab.Address, FallbackValue=''}"
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Connect" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Connect" />
|
||||
<TextBlock Classes="detail" FontSize="10.5"
|
||||
Text="Another shell, on this host or any other in the keychain." />
|
||||
</StackPanel>
|
||||
@@ -776,7 +806,7 @@
|
||||
<Button Classes="row" Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Host}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Connect via SFTP" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Connect via SFTP" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="Browse a host's files." />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -784,7 +814,7 @@
|
||||
<Button Classes="row" Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Bucket}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Connect via S3" />
|
||||
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Connect via S3" />
|
||||
<TextBlock Classes="detail" FontSize="10.5"
|
||||
Text="Objects in an S3-compatible bucket from the keychain." />
|
||||
</StackPanel>
|
||||
@@ -866,6 +896,185 @@
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ the customise sheet ============ -->
|
||||
<!--
|
||||
Reached from CUSTOMIZE at the top of the quick-access keyboard's KEYS tab: which keys are on the bar
|
||||
and in what order, which groups the KEYS tab shows and in what order, and keys of one's own.
|
||||
|
||||
Taller than the other two sheets, nearly the whole display, because it is a page of settings rather
|
||||
than a menu — and it scrolls for the same reason. It takes the screen the way they do too: opening it
|
||||
collapses the renderer (see MainWindowViewModel.IsTerminalShowing), so nothing is drawn over the
|
||||
WebView and the boxes on it can have the keyboard.
|
||||
|
||||
Every change applies the moment it is made, to the bar and to the settings file, which is why there is
|
||||
DONE and no SAVE. See TerminalKeyboardViewModel.
|
||||
|
||||
↑ and ↓ rather than drag handles. A drag inside a scrolling sheet is the same gesture as a scroll and
|
||||
has to be told apart by a long press nobody can see; two buttons are slower and never wrong.
|
||||
-->
|
||||
<Panel IsVisible="{Binding Keyboard.IsCustomizing}">
|
||||
|
||||
<Button Classes="scrim" Command="{Binding Keyboard.CloseCustomizeCommand}" />
|
||||
|
||||
<Border Margin="0,40,0,0" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||
CornerRadius="22,22,0,0" Padding="20,18,20,0">
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,12" />
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="0,0,0,8">
|
||||
<TextBlock Grid.Column="0" Classes="title" Text="CUSTOMIZE KEYS" FontSize="13"
|
||||
VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1" Classes="row" MinHeight="40" Padding="14,0"
|
||||
Command="{Binding Keyboard.CloseCustomizeCommand}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="DONE" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Grid.Row="2" HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Spacing="6" Margin="0,0,0,24">
|
||||
|
||||
<!-- ── the bar ── -->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="THE BAR" />
|
||||
<TextBlock Grid.Column="1" Classes="detail" FontSize="10" Text="{Binding Keyboard.BarCountText}" />
|
||||
</Grid>
|
||||
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||
Text="The first eight keys are the top row and the rest the second. Tap a key in a group below to put it on the bar or take it off." />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Keyboard.BarEditor}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:BarEditorRowViewModel">
|
||||
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" Padding="0,4">
|
||||
<Grid ColumnDefinitions="64,*,Auto,Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" FontWeight="SemiBold"
|
||||
Text="{Binding Label}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<StackPanel Grid.Column="1" Spacing="1" VerticalAlignment="Center">
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Hint}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" FontSize="9" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding Row}" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Classes="icon" Height="40" Width="40"
|
||||
AutomationProperties.Name="Move earlier" Command="{Binding MoveUpCommand}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="" />
|
||||
</Button>
|
||||
<Button Grid.Column="3" Classes="icon" Height="40" Width="40"
|
||||
AutomationProperties.Name="Move later" Command="{Binding MoveDownCommand}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="" />
|
||||
</Button>
|
||||
<Button Grid.Column="4" Classes="icon" Height="40" Width="40"
|
||||
AutomationProperties.Name="Take off the bar" Command="{Binding RemoveCommand}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text=""
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<TextBlock Classes="body" TextWrapping="Wrap" IsVisible="{Binding !Keyboard.HasBar}"
|
||||
Text="The bar is empty, so only the grid button is drawn above the keyboard." />
|
||||
|
||||
<!-- ── the groups ── -->
|
||||
<TextBlock Classes="label" Text="GROUPS" Margin="0,16,0,0" />
|
||||
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||
Text="The quick-access keyboard shows the ticked groups, in this order. A lit key is on the bar." />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Keyboard.GroupEditor}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GroupEditorRowViewModel">
|
||||
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" Padding="0,6,0,10">
|
||||
<StackPanel Spacing="6">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding IsShown}" MinHeight="40">
|
||||
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="{Binding Title}" />
|
||||
</CheckBox>
|
||||
<Button Grid.Column="1" Classes="icon" Height="40" Width="40"
|
||||
AutomationProperties.Name="Move up" Command="{Binding MoveUpCommand}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="" />
|
||||
</Button>
|
||||
<Button Grid.Column="2" Classes="icon" Height="40" Width="40"
|
||||
AutomationProperties.Name="Move down" Command="{Binding MoveDownCommand}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text="" />
|
||||
</Button>
|
||||
</Grid>
|
||||
<ItemsControl ItemsSource="{Binding Keys}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KeyChoiceViewModel">
|
||||
<Button Classes="chiptoggle" Classes.worn="{Binding IsOnBar}" Margin="0,0,6,6"
|
||||
Command="{Binding ToggleCommand}">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- ── keys of one's own ── -->
|
||||
<TextBlock Classes="label" Text="ADD A KEY" Margin="0,16,0,0" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Keyboard.CustomKeyEditor}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CustomKeyRowViewModel">
|
||||
<Border BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" Padding="0,4">
|
||||
<Grid ColumnDefinitions="64,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" FontWeight="SemiBold"
|
||||
Text="{Binding Label}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="1" Classes="detail code" FontSize="10.5" Text="{Binding Sequence}"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
<Button Grid.Column="2" Classes="icon" Height="40" Width="40"
|
||||
AutomationProperties.Name="Delete this key" Command="{Binding DeleteCommand}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="18" Text=""
|
||||
Foreground="{StaticResource DangerText}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
ShowSuggestions off on both boxes, for the reason the terminal's own input surface turns it off:
|
||||
an autocorrecting keyboard capitalises the e in \e and puts a full stop after ^C, and the key
|
||||
then sends something nobody typed.
|
||||
-->
|
||||
<TextBox Classes="field" Text="{Binding Keyboard.NewKeyLabel}" MaxLength="10"
|
||||
TextInputOptions.ShowSuggestions="False"
|
||||
PlaceholderText="Label, as it appears on the key" />
|
||||
<TextBox Classes="field" Text="{Binding Keyboard.NewKeySequence}"
|
||||
TextInputOptions.ShowSuggestions="False"
|
||||
PlaceholderText="What it sends, e.g. \e:wq\r or ^C" />
|
||||
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||
Text="\e is Escape, \r is Enter, \t is Tab, and ^ before a letter is Ctrl; \^ is a plain caret. Keys are kept on this phone in plain text, so this is no place for a password." />
|
||||
<Button Classes="primary" Content="ADD KEY" Command="{Binding Keyboard.AddCustomKeyCommand}" />
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding Keyboard.CustomizeStatus}"
|
||||
IsVisible="{Binding Keyboard.CustomizeStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<Button Classes="secondary" Content="RESET TO DEFAULTS" Margin="0,16,0,0"
|
||||
Command="{Binding Keyboard.ResetLayoutCommand}" />
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap"
|
||||
Text="Puts the bar and the groups back as they shipped. Keys you added are kept." />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
</Panel>
|
||||
|
||||
</UserControl>
|
||||
@@ -1,17 +1,16 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Controls.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
|
||||
using DodoSSH.Client.Android.Platform;
|
||||
using DodoSSH.Client.Shell.Keyboard;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
// Avalonia's Button, not Android.Widget's. .NET for Android puts Android.Widget in this project's
|
||||
@@ -22,85 +21,45 @@ using Button = Avalonia.Controls.Button;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>Design 03 — the terminal, and the accessory key row under it.</summary>
|
||||
/// <summary>Design 03 — the terminal, and the keys under or beside it.</summary>
|
||||
internal sealed partial class TerminalScreen : UserControl
|
||||
{
|
||||
/// <summary>
|
||||
/// The keys a software keyboard does not have.
|
||||
/// The height the quick-access keyboard takes on a phone until the software keyboard has been measured.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Chosen by what a shell session actually needs rather than by what a keyboard has: Esc leaves vi's
|
||||
/// insert mode, Tab completes a path, Ctrl makes <c>^C</c> reachable, the arrows reach history, and the
|
||||
/// pipe and hyphen are two characters that are three taps deep on every Android keyboard and appear in
|
||||
/// almost every command worth typing on a phone.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The order matches the design's row. It is scrollable rather than compressed, because shrinking ten
|
||||
/// keys to fit 360dp produces ten targets too small to hit.
|
||||
/// </para>
|
||||
/// About what Gboard and Samsung's keyboard take in portrait. It is only a starting point: the first time
|
||||
/// the software keyboard is up, its own height replaces this, so the bar above the panel does not move
|
||||
/// when one is swapped for the other.
|
||||
/// </remarks>
|
||||
/// <summary>The upper row: the modifiers, and the punctuation a symbols layer is two taps away from.</summary>
|
||||
/// <remarks>
|
||||
/// Seven, which is what the row divides between — see the keypad's remark in TerminalScreen.axaml for
|
||||
/// why the set is fixed and flexed rather than scrollable. Adding an eighth is a matter of adding it
|
||||
/// here and changing the <c>Columns</c> on the UniformGrid; the two numbers have to agree, and the row
|
||||
/// simply gets narrower keys.
|
||||
/// </remarks>
|
||||
private static readonly (string Label, byte[] Bytes, bool Latches)[] ModifierKeys =
|
||||
[
|
||||
("esc", [0x1B], false),
|
||||
("tab", [0x09], false),
|
||||
("ctrl", [], true),
|
||||
("|", [(byte)'|'], false),
|
||||
("-", [(byte)'-'], false),
|
||||
("~", [(byte)'~'], false),
|
||||
("/", [(byte)'/'], false),
|
||||
];
|
||||
private const double DefaultQuickAccessHeight = 280;
|
||||
|
||||
/// <summary>The lower row: the cursor keys, which is where they became reachable.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The ANSI sequences a PTY expects, written out rather than composed, because the difference between
|
||||
/// CSI A and SS3 A is the difference between working in bash and not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>Left, down, up, right</b> — the order every other client on the platform puts them in, and not
|
||||
/// the order they were in here. On one flat row the pairs have to read as pairs: horizontal at the ends
|
||||
/// and vertical in the middle means the two keys that undo each other are always neighbours, and a
|
||||
/// thumb learns the row's shape rather than its labels. Up-down-left-right is the order they are named
|
||||
/// in, which is a different thing from the order they are used in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// These four were the keys that used to live past the right-hand edge of the scroller — the most
|
||||
/// pressed keys on the keypad, and the ones you had to go looking for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly (string Label, byte[] Bytes, bool Latches)[] CursorKeys =
|
||||
[
|
||||
("←", [0x1B, (byte)'[', (byte)'D'], false),
|
||||
("↓", [0x1B, (byte)'[', (byte)'B'], false),
|
||||
("↑", [0x1B, (byte)'[', (byte)'A'], false),
|
||||
("→", [0x1B, (byte)'[', (byte)'C'], false),
|
||||
];
|
||||
public static readonly StyledProperty<bool> IsWideProperty =
|
||||
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(IsWide));
|
||||
|
||||
/// <summary>
|
||||
/// Whether the next ordinary key should be sent as a control character.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Latching rather than held. Holding a modifier while typing needs two hands and a keyboard that
|
||||
/// reports chords, and this row has neither — so Ctrl is pressed, then C, and releases itself. Every
|
||||
/// Android SSH client does this and users expect it.
|
||||
/// </remarks>
|
||||
private bool controlLatched;
|
||||
public static readonly StyledProperty<bool> ShowsKeysProperty =
|
||||
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(ShowsKeys));
|
||||
|
||||
private Button? controlKey;
|
||||
public static readonly StyledProperty<bool> ShowsQuickAccessPanelProperty =
|
||||
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(ShowsQuickAccessPanel));
|
||||
|
||||
public static readonly StyledProperty<bool> ShowsSidePanelProperty =
|
||||
AvaloniaProperty.Register<TerminalScreen, bool>(nameof(ShowsSidePanel));
|
||||
|
||||
public static readonly StyledProperty<double> QuickAccessHeightProperty =
|
||||
AvaloniaProperty.Register<TerminalScreen, double>(nameof(QuickAccessHeight), DefaultQuickAccessHeight);
|
||||
|
||||
private readonly TerminalInputSurface input;
|
||||
|
||||
/// <summary>The shell whose <c>PropertyChanged</c> this screen is subscribed to, so it can unsubscribe.</summary>
|
||||
private MainWindowViewModel? subscribed;
|
||||
|
||||
/// <summary>The phone shell this screen sits in, for its width class.</summary>
|
||||
private PhoneShell? phoneShell;
|
||||
|
||||
/// <summary>The software keyboard, while this control is attached. Null on a platform without one.</summary>
|
||||
private IInputPane? softKeyboard;
|
||||
|
||||
/// <summary>
|
||||
/// What <see cref="MainWindowViewModel.IsTerminalShowing"/> was last seen to be.
|
||||
/// </summary>
|
||||
@@ -115,10 +74,14 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
BuildAccessoryRow();
|
||||
|
||||
input = this.FindControl<TerminalInputSurface>("Input")!;
|
||||
input.Input += (_, e) => SendAsync(e.Bytes);
|
||||
|
||||
// Everything typed on the phone's own keyboard goes through the same path as the bar's keys, which is
|
||||
// what lets Ctrl latched on the bar apply to a letter typed on Gboard. See TerminalKeyboardViewModel.
|
||||
input.Input += (_, e) => Keyboard?.Send(e.Bytes);
|
||||
|
||||
this.FindControl<QuickAccessKeyboard>("BottomPanel")!.KeyboardRequested += OnKeyboardRequested;
|
||||
this.FindControl<QuickAccessKeyboard>("SidePanel")!.KeyboardRequested += OnKeyboardRequested;
|
||||
|
||||
var renderer = this.FindControl<NativeWebView>("Renderer")!;
|
||||
|
||||
@@ -140,6 +103,7 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
{
|
||||
previous.PropertyChanged -= OnShellPropertyChanged;
|
||||
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
||||
previous.Keyboard.PropertyChanged -= OnKeyboardPropertyChanged;
|
||||
subscribed = null;
|
||||
}
|
||||
|
||||
@@ -153,13 +117,250 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
// a prompt the user is about to want to keep typing at, so the keyboard should be up when
|
||||
// they look. See MainWindowViewModel.TerminalFocusRequested.
|
||||
shell.TerminalFocusRequested += OnTerminalFocusRequested;
|
||||
shell.Keyboard.PropertyChanged += OnKeyboardPropertyChanged;
|
||||
subscribed = shell;
|
||||
|
||||
terminalWasShowing = shell.IsTerminalShowing;
|
||||
}
|
||||
|
||||
RefreshKeys();
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Whether the screen is wide enough for the side panel. <see cref="PhoneShell.IsWide"/>, mirrored.</summary>
|
||||
public bool IsWide
|
||||
{
|
||||
get => GetValue(IsWideProperty);
|
||||
private set => SetValue(IsWideProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>Whether the bar under the terminal is drawn.</summary>
|
||||
public bool ShowsKeys
|
||||
{
|
||||
get => GetValue(ShowsKeysProperty);
|
||||
private set => SetValue(ShowsKeysProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>Whether the quick-access keyboard is drawn under the bar, in the software keyboard's place.</summary>
|
||||
public bool ShowsQuickAccessPanel
|
||||
{
|
||||
get => GetValue(ShowsQuickAccessPanelProperty);
|
||||
private set => SetValue(ShowsQuickAccessPanelProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>Whether the quick-access keyboard is drawn beside the terminal.</summary>
|
||||
public bool ShowsSidePanel
|
||||
{
|
||||
get => GetValue(ShowsSidePanelProperty);
|
||||
private set => SetValue(ShowsSidePanelProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>How tall the quick-access keyboard is on a phone: the software keyboard's last height.</summary>
|
||||
public double QuickAccessHeight
|
||||
{
|
||||
get => GetValue(QuickAccessHeightProperty);
|
||||
private set => SetValue(QuickAccessHeightProperty, value);
|
||||
}
|
||||
|
||||
private TerminalKeyboardViewModel? Keyboard => (DataContext as MainWindowViewModel)?.Keyboard;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnAttachedToVisualTree(e);
|
||||
|
||||
phoneShell = this.FindAncestorOfType<PhoneShell>();
|
||||
|
||||
if (phoneShell is not null)
|
||||
{
|
||||
phoneShell.PropertyChanged += OnPhoneShellPropertyChanged;
|
||||
}
|
||||
|
||||
softKeyboard = TopLevel.GetTopLevel(this)?.InputPane;
|
||||
|
||||
if (softKeyboard is not null)
|
||||
{
|
||||
softKeyboard.StateChanged += OnSoftKeyboardChanged;
|
||||
}
|
||||
|
||||
RefreshKeys();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
if (phoneShell is not null)
|
||||
{
|
||||
phoneShell.PropertyChanged -= OnPhoneShellPropertyChanged;
|
||||
phoneShell = null;
|
||||
}
|
||||
|
||||
if (softKeyboard is not null)
|
||||
{
|
||||
softKeyboard.StateChanged -= OnSoftKeyboardChanged;
|
||||
softKeyboard = null;
|
||||
}
|
||||
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Works out which of the three key surfaces is drawn: the bar, the panel under it, the panel beside.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Computed here rather than written as conditions in the markup, for PhoneShell's reason: each is an
|
||||
/// "and" of facts from three objects — the shell, the keyboard and the width — and a binding has no
|
||||
/// "and". Recomputed on any notification from any of the three, which is cheaper than being wrong about
|
||||
/// which of their properties are computed ones.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Wide, the side panel replaces the bar while it is open.</b> Its KEYS tab repeats the bar's keys at
|
||||
/// the top, so nothing somebody put on their bar goes out of reach, and a tablet keeps the height. Closed,
|
||||
/// the bar comes back exactly as on a phone.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Wide, the bottom panel never shows.</b> Rotating a phone into the wide class with the panel up would
|
||||
/// otherwise leave it raised under a terminal that also has the side panel; it is lowered instead, and the
|
||||
/// side panel is where the same tabs are.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void RefreshKeys()
|
||||
{
|
||||
var wide = phoneShell?.IsWide == true;
|
||||
var showing = subscribed?.IsTerminalShowing == true;
|
||||
var keyboard = subscribed?.Keyboard;
|
||||
var sidePanel = wide && keyboard?.IsSidePanelOpen == true;
|
||||
|
||||
if (wide && keyboard is { IsQuickAccessOpen: true })
|
||||
{
|
||||
keyboard.CloseQuickAccess();
|
||||
}
|
||||
|
||||
IsWide = wide;
|
||||
ShowsSidePanel = showing && sidePanel;
|
||||
ShowsKeys = showing && !sidePanel;
|
||||
ShowsQuickAccessPanel = showing && !wide && keyboard?.IsQuickAccessOpen == true;
|
||||
}
|
||||
|
||||
private void OnPhoneShellPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.Property == PhoneShell.IsWideProperty)
|
||||
{
|
||||
RefreshKeys();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnKeyboardPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
switch (e.PropertyName)
|
||||
{
|
||||
case nameof(TerminalKeyboardViewModel.IsQuickAccessOpen)
|
||||
or nameof(TerminalKeyboardViewModel.IsSidePanelOpen):
|
||||
RefreshKeys();
|
||||
break;
|
||||
|
||||
// The customise sheet has boxes of its own, and on a wide screen it can be opened while the
|
||||
// terminal's keyboard is up. Put away here so the sheet is laid out into the whole display
|
||||
// rather than the strip above a keyboard configured for a shell.
|
||||
case nameof(TerminalKeyboardViewModel.IsCustomizing)
|
||||
when sender is TerminalKeyboardViewModel { IsCustomizing: true }:
|
||||
SoftKeyboard.Hide();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notes the software keyboard's height, and lowers the quick-access keyboard when it comes back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The two cannot both be up on a phone — they are the same place on the screen — and the software
|
||||
/// keyboard arriving is the one signal every way of bringing it back has in common: a tap on the
|
||||
/// terminal, the panel's own keyboard button, a box elsewhere taking focus.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The height is kept only when it is plausibly a keyboard. Before Android 15 the window is resized for
|
||||
/// the keyboard instead and the inset reported here is zero (see PhoneShell.ApplyKeyboardInset); a panel
|
||||
/// sized from that would be no panel at all, so the default stands.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnSoftKeyboardChanged(object? sender, InputPaneStateEventArgs e)
|
||||
{
|
||||
if (e.NewState is not InputPaneState.Open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RememberKeyboardHeight(e.EndRect.Height);
|
||||
Keyboard?.CloseQuickAccess();
|
||||
}
|
||||
|
||||
private void RememberKeyboardHeight(double height)
|
||||
{
|
||||
if (double.IsFinite(height) && height >= 160)
|
||||
{
|
||||
QuickAccessHeight = Math.Min(height, 480);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The grid button: swaps the software keyboard for the quick-access keyboard, or back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A click handler rather than the view model's toggle alone, because both halves of the swap are
|
||||
/// platform calls: the software keyboard is measured and put away on the way in, and brought back by
|
||||
/// focusing the input surface on the way out. The view model only records which of the two is up.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The panel is raised before the keyboard is hidden. The two arrive as separate layout passes either
|
||||
/// way; this order means the frame between them is the panel over a closing keyboard rather than a
|
||||
/// terminal briefly the full height of the display.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnQuickAccessToggled(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Keyboard is not { } keyboard)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyboard.IsQuickAccessOpen)
|
||||
{
|
||||
keyboard.CloseQuickAccess();
|
||||
input.Acquire();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (softKeyboard is { State: InputPaneState.Open } pane)
|
||||
{
|
||||
RememberKeyboardHeight(pane.OccludedRect.Height);
|
||||
}
|
||||
|
||||
keyboard.ToggleQuickAccessCommand.Execute(null);
|
||||
SoftKeyboard.Hide();
|
||||
}
|
||||
|
||||
/// <summary>The panel's keyboard button: put the panel away and bring the phone's keyboard back.</summary>
|
||||
private void OnKeyboardRequested(object? sender, EventArgs e)
|
||||
{
|
||||
Keyboard?.CloseQuickAccess();
|
||||
input.Acquire();
|
||||
}
|
||||
|
||||
/// <summary>A tap on the terminal: the phone's keyboard, in place of the panel if that was up.</summary>
|
||||
private void OnRendererTapped()
|
||||
{
|
||||
Keyboard?.CloseQuickAccess();
|
||||
input.Acquire();
|
||||
}
|
||||
|
||||
/// <summary>The cursor gesture's arrows, from <see cref="RendererFocus"/>.</summary>
|
||||
private void OnCursorGesture(ArrowDirection direction, int count) =>
|
||||
Keyboard?.SendArrow(direction, count);
|
||||
|
||||
/// <summary>
|
||||
/// Raises the keyboard when a shell arrives, and never while one is merely being redrawn.
|
||||
/// </summary>
|
||||
@@ -171,6 +372,8 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
RefreshKeys();
|
||||
|
||||
var showing = shell.IsTerminalShowing;
|
||||
|
||||
if (showing == terminalWasShowing)
|
||||
@@ -207,7 +410,8 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
/// See <see cref="RendererFocus"/> for why a <c>WebView</c> that cannot hold focus still scrolls, and why
|
||||
/// the tap has to be reported rather than left to the page.
|
||||
/// </remarks>
|
||||
private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire);
|
||||
private void AdoptRenderer() =>
|
||||
RendererFocus.Suppress(OnRendererTapped, () => Keyboard?.CursorGestures == true, OnCursorGesture);
|
||||
|
||||
/// <summary>
|
||||
/// Puts the keyboard away on the way to the connect menu or the forwards sheet.
|
||||
@@ -260,129 +464,4 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
android.BuiltInZoomControls = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildAccessoryRow()
|
||||
{
|
||||
// ── nothing to hand back any more ──────────────────────────────────────────────────────────────
|
||||
// This row used to counter both halves of every press with a posted TerminalFocus.Return(), because
|
||||
// the platform requests Android's own focus for Avalonia's view after dispatching each handled
|
||||
// touch, and the keyboard belonged to the WebView's input connection. Under ADR 0015 it belongs to
|
||||
// TerminalInputSurface, which is an Avalonia control — so the focus the platform takes is the focus
|
||||
// the keyboard already wants, and the counter-move is not merely unnecessary but wrong. The keys
|
||||
// stay Focusable=false, which is now what keeps Avalonia's own focus on the input surface.
|
||||
|
||||
Fill(this.FindControl<UniformGrid>("AccessoryKeys")!, ModifierKeys);
|
||||
Fill(this.FindControl<UniformGrid>("ArrowKeys")!, CursorKeys);
|
||||
}
|
||||
|
||||
/// <summary>Puts one table's worth of keys into one row of the keypad.</summary>
|
||||
/// <remarks>
|
||||
/// The rows differ in what they hold and in nothing else — same control, same class, same wiring — so
|
||||
/// they are filled by one method rather than by two that would drift.
|
||||
/// </remarks>
|
||||
private void Fill(UniformGrid row, (string Label, byte[] Bytes, bool Latches)[] keys)
|
||||
{
|
||||
foreach (var (label, bytes, latches) in keys)
|
||||
{
|
||||
var key = CreateKey(label);
|
||||
|
||||
if (latches)
|
||||
{
|
||||
controlKey = key;
|
||||
key.Click += (_, _) => ToggleControl();
|
||||
}
|
||||
else
|
||||
{
|
||||
key.Click += (_, _) => SendAsync(bytes);
|
||||
}
|
||||
|
||||
row.Children.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One key of the accessory row, before its click is wired.</summary>
|
||||
/// <remarks>Split from <see cref="BuildAccessoryRow"/> for length rather than for reuse.</remarks>
|
||||
private static Button CreateKey(string label) =>
|
||||
new()
|
||||
{
|
||||
Content = label,
|
||||
|
||||
// ◆ Every dimension and brush this used to set by hand is now Button.hotkey in Phone.axaml,
|
||||
// and the move is what lets the row have a pressed state at all: a style can say :pressed and
|
||||
// an assignment cannot. It also takes this file out of the business of naming the palette's
|
||||
// brushes, which is how a keypad ends up with a grey nothing else uses.
|
||||
//
|
||||
// Content is the string rather than a TextBlock built here for the same reason — the class
|
||||
// carries the font and the alignment, so a wrapper element would only be somewhere for them to
|
||||
// disagree.
|
||||
Classes = { "hotkey" },
|
||||
|
||||
// ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL.
|
||||
//
|
||||
// These keys are an extension of the keyboard, not a place the keyboard should go. As
|
||||
// ordinary buttons they take Avalonia's focus on tap, which takes it off whatever the
|
||||
// terminal's input target is — and a modifier row that dismisses the keyboard every time it
|
||||
// is used is not a modifier row. Focusable=false is what a toolbar button is, and it means
|
||||
// the focused element never changes: TerminalInputSurface is still it, so the input
|
||||
// connection is never torn down and nothing has to be handed back.
|
||||
//
|
||||
// This used to be only half a fix, because the target was the NativeWebView and Android keeps
|
||||
// a focus of its own that the platform hands to Avalonia's view after every touch it
|
||||
// dispatches. That half is gone rather than countered: under ADR 0015 the input target is an
|
||||
// Avalonia control, so the focus Android moves is already where the keyboard should be.
|
||||
Focusable = false,
|
||||
};
|
||||
|
||||
private void ToggleControl()
|
||||
{
|
||||
controlLatched = !controlLatched;
|
||||
|
||||
if (controlKey is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Latched state has to be visible. A modifier that is on and does not look on is how somebody sends
|
||||
// ^L to a database prompt believing they typed an l.
|
||||
//
|
||||
// A class rather than two brush assignments, so what "latched" looks like is stated once in
|
||||
// Phone.axaml beside what "pressed" looks like — which is where somebody changing either will be.
|
||||
controlKey.Classes.Set("latched", controlLatched);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to
|
||||
/// 0x1A, which is letter & 0x1F. Applied only to letters, because Ctrl with an arrow key is a
|
||||
/// different sequence entirely and silently mangling one into a control byte would be worse than
|
||||
/// ignoring the latch.
|
||||
/// </remarks>
|
||||
private void SendAsync(byte[] bytes)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel { SelectedTab: { } tab } shell)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = bytes;
|
||||
|
||||
if (controlLatched && bytes.Length == 1)
|
||||
{
|
||||
var c = bytes[0];
|
||||
|
||||
if (c is >= (byte)'a' and <= (byte)'z' or >= (byte)'A' and <= (byte)'Z')
|
||||
{
|
||||
payload = [(byte)(c & 0x1F)];
|
||||
}
|
||||
|
||||
ToggleControl();
|
||||
}
|
||||
|
||||
// Discarded rather than awaited: this is a keystroke, the workspace ignores a session that has
|
||||
// gone, and a button handler that awaited would serialise the row behind a slow link.
|
||||
_ = shell.SendTerminalInputAsync(tab.SessionId, payload).AsTask();
|
||||
}
|
||||
|
||||
/// <summary>Sends a literal string, for the keys that carry text rather than a control code.</summary>
|
||||
/// <remarks>Kept because the snippet feature will type into a terminal exactly this way.</remarks>
|
||||
internal void Send(string text) => SendAsync(Encoding.UTF8.GetBytes(text));
|
||||
}
|
||||
@@ -113,9 +113,9 @@
|
||||
Text="Its key is made on this phone and nobody else has it. Add people to it once it exists, then share the key with them." />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="CREATE"
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="CREATE"
|
||||
Command="{Binding CreateVaultCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelNewVaultCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
@@ -214,7 +214,17 @@
|
||||
ContentPresenter for Background for the Fluent-override reason explained above, and ContentPresenter
|
||||
carries its own BoxShadow property in Avalonia 11, so the glow and the fill live on the same element
|
||||
and move together.
|
||||
|
||||
◆ ClipToBounds on the Button, because Fluent's own Button theme sets it to True — and a glow is
|
||||
drawn outside the control by definition. With the clip on, the 22-pixel blur was cut at the button's
|
||||
square bounds and showed as a faint rectangle behind the rounded fill: the "slightly lighter square"
|
||||
the 2026-10-02 Android audit measured (M1), which this head shares because the pattern is the same.
|
||||
Measured in the layout harness rather than assumed: Button.ClipToBounds reads True under the theme,
|
||||
and the same button with it cleared renders the full glow.
|
||||
-->
|
||||
<Style Selector="Button.accent">
|
||||
<Setter Property="ClipToBounds" Value="False" />
|
||||
</Style>
|
||||
<Style Selector="Button.accent /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||
<Setter Property="BoxShadow" Value="{StaticResource AccentGlow}" />
|
||||
@@ -1332,6 +1342,19 @@
|
||||
<Style Selector="TextBlock.qcrow-subtext">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<!--
|
||||
The palette's typed-address row, which sits above the list rather than in it and so has no ListBoxItem
|
||||
to take :pointerover and :selected from. The same two states, on the row itself.
|
||||
-->
|
||||
<Style Selector="Border.qcrow.adhoc:pointerover">
|
||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||
</Style>
|
||||
<Style Selector="Border.qcrow.adhoc.selected, Border.qcrow.adhoc.selected:pointerover">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
<Style Selector="Border.qcrow.adhoc.selected TextBlock.qcrow-text, Border.qcrow.adhoc.selected TextBlock.qcrow-subtext">
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected TextBlock.qcrow-text, ListBoxItem:selected TextBlock.qcrow-subtext">
|
||||
<!--
|
||||
AccentInk rather than a literal white: it is v5's name for "the colour text sits in on top of the
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
Ctrl+K: type a few characters of a host's name and press Enter to connect to it.
|
||||
Ctrl+K: type a few characters of a host's name and press Enter to connect to it — or type user@host, press
|
||||
Enter, and give a password, to connect to a machine that is not saved at all.
|
||||
|
||||
The design's box promises "search hosts · run command". Only the first half is here, because there is no
|
||||
snippet or saved-command item type in the vault for the second half to run — see
|
||||
@@ -35,93 +36,171 @@
|
||||
BorderThickness="1" CornerRadius="14">
|
||||
<StackPanel>
|
||||
|
||||
<!--
|
||||
">_" rather than the bare ">" v4 drew: the mock's own prompt glyph, and the underscore is what
|
||||
reads as a cursor waiting for the first keystroke rather than a stray angle bracket. There is
|
||||
still no drawn caret in the field beside it, for the reason TextBox.address in this file gives —
|
||||
it already has a real one.
|
||||
-->
|
||||
<Border Padding="16,14" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text=">_" FontSize="14" FontWeight="Bold"
|
||||
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
|
||||
PlaceholderText="search hosts" Margin="10,0,0,0"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="14"
|
||||
Background="Transparent" BorderThickness="0" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<StackPanel x:Name="QueryStep" IsVisible="{Binding IsAskingForSearchQuery}">
|
||||
|
||||
<!--
|
||||
">_" rather than the bare ">" v4 drew: the mock's own prompt glyph, and the underscore is what
|
||||
reads as a cursor waiting for the first keystroke rather than a stray angle bracket. There is
|
||||
still no drawn caret in the field beside it, for the reason TextBox.address in this file gives —
|
||||
it already has a real one.
|
||||
-->
|
||||
<Border Padding="16,14" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text=">_" FontSize="14" FontWeight="Bold"
|
||||
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
|
||||
PlaceholderText="search hosts, or type user@host" Margin="10,0,0,0"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="14"
|
||||
Background="Transparent" BorderThickness="0" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The typed address, above the saved hosts: what was typed was an address, so the row that dials it
|
||||
is the one the query is most plainly about. It is lit by IsAdHocSelected rather than by the list's
|
||||
own selection, because it is not a host and the list below holds hosts only — see the remark on
|
||||
MainWindowViewModel.IsAdHocSelected. Classes="qcrow adhoc" shares the list rows' look; the
|
||||
selected state is a class here because there is no ListBoxItem around it to carry :selected.
|
||||
|
||||
"not saved" in the corner, where a saved row says how it authenticates: that is this row's answer
|
||||
to the same question, and the one thing worth knowing before choosing it.
|
||||
-->
|
||||
<Border x:Name="AdHocRow" Classes="qcrow adhoc" Classes.selected="{Binding IsAdHocSelected}"
|
||||
Height="44" CornerRadius="9" Padding="10,0" Margin="8,6,8,2"
|
||||
IsVisible="{Binding HasAdHocTarget}" Cursor="Hand">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="mono qcrow-text" Text=">_" FontSize="12"
|
||||
FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono qcrow-text" Text="{Binding AdHocAddress}" FontSize="13"
|
||||
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="connect with a password" FontSize="11.5" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Classes="mono qcrow-subtext" Text="not saved" FontSize="11"
|
||||
Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Why a query that looks like an address has no row to dial it — a missing port number, say. -->
|
||||
<TextBlock Classes="hint" FontSize="12" Margin="18,12,18,4"
|
||||
Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding AdHocRefusal}"
|
||||
IsVisible="{Binding HasAdHocRefusal}" />
|
||||
|
||||
<!--
|
||||
Arrow keys move the selection and Enter takes it; the code-behind owns both, because a ListBox that
|
||||
took focus would take the arrow keys away from the box being typed into.
|
||||
|
||||
A click connects rather than merely selecting, which is what a list of hosts under a search box
|
||||
looks like it does — the keyboard route already treats choosing a row and connecting to it as one
|
||||
act, and a pointer that only highlighted would leave the palette open over a choice already made.
|
||||
The gesture is wired in the code-behind, as the sidebar's double-click is.
|
||||
|
||||
Classes="tiles" clears the theme's full-bleed hover and selection wash the way the hosts grid's own
|
||||
cards do — see the remark on Border.qcrow in App.axaml, which is what paints every state here
|
||||
instead. Rows rather than cards, but the same reason applies: a square wash behind a rounded shape
|
||||
shows its corners.
|
||||
-->
|
||||
<ListBox x:Name="Results" Classes="tiles" MaxHeight="320" Focusable="False"
|
||||
ItemsSource="{Binding SearchResults}"
|
||||
SelectedItem="{Binding SelectedSearchResult}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<Border Classes="qcrow" Height="44" CornerRadius="9" Padding="10,0" Margin="8,2">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<!--
|
||||
Green when this host has a terminal open right now, grey otherwise — the same two-state
|
||||
dot the hosts grid draws, and the same reason: nothing here pings a machine, so a third
|
||||
colour would be a claim this application never checks. See Ellipse.dot in App.axaml.
|
||||
-->
|
||||
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}"
|
||||
Width="8" Height="8" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono qcrow-text" Text="{Binding Label}" FontSize="13"
|
||||
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Address}" FontSize="11.5"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Two readings stacked against the two on the left, and in the same order: what this row
|
||||
IS above, how it is reached below.
|
||||
|
||||
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
|
||||
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
|
||||
The palette searches every readable vault at once, so without it two hosts a team and a
|
||||
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
|
||||
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
|
||||
vault name would otherwise take its width out of the host name beside it.
|
||||
|
||||
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
|
||||
connect here is SSH, so printing that word would be a constant dressed up as a reading;
|
||||
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
|
||||
-->
|
||||
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
|
||||
HorizontalAlignment="Right" MaxWidth="150"
|
||||
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
|
||||
FontSize="11" HorizontalAlignment="Right" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!--
|
||||
Names the other thing the box takes, at the moment somebody is most likely to want it: a name that
|
||||
matches nothing is often a machine that was never saved.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="12" Margin="18,16"
|
||||
Text="No host matches that. Type user@host to connect without saving it."
|
||||
IsVisible="{Binding HasNothingToOffer}" />
|
||||
|
||||
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
||||
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
|
||||
Text="↑ ↓ to choose · ENTER or click to connect · ESC to close" />
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Arrow keys move the selection and Enter takes it; the code-behind owns both, because a ListBox that
|
||||
took focus would take the arrow keys away from the box being typed into.
|
||||
============ the typed address's password ============
|
||||
|
||||
A click connects rather than merely selecting, which is what a list of hosts under a search box
|
||||
looks like it does — the keyboard route already treats choosing a row and connecting to it as one
|
||||
act, and a pointer that only highlighted would leave the palette open over a choice already made.
|
||||
The gesture is wired in the code-behind, as the sidebar's double-click is.
|
||||
|
||||
Classes="tiles" clears the theme's full-bleed hover and selection wash the way the hosts grid's own
|
||||
cards do — see the remark on Border.qcrow in App.axaml, which is what paints every state here
|
||||
instead. Rows rather than cards, but the same reason applies: a square wash behind a rounded shape
|
||||
shows its corners.
|
||||
The second step, in the same card, so the keyboard never leaves the palette: Enter on the address
|
||||
comes here, Enter here dials, Escape goes back to the address rather than closing. The box carries
|
||||
PasswordChar and nothing is written anywhere — see MainWindowViewModel.IsAskingForAdHocPassword,
|
||||
and VaultViewModel.ConnectManuallyAsync for why a typed password is the only way in.
|
||||
-->
|
||||
<ListBox x:Name="Results" Classes="tiles" MaxHeight="320" Focusable="False"
|
||||
ItemsSource="{Binding SearchResults}"
|
||||
SelectedItem="{Binding SelectedSearchResult}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<Border Classes="qcrow" Height="44" CornerRadius="9" Padding="10,0" Margin="8,2">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<!--
|
||||
Green when this host has a terminal open right now, grey otherwise — the same two-state
|
||||
dot the hosts grid draws, and the same reason: nothing here pings a machine, so a third
|
||||
colour would be a claim this application never checks. See Ellipse.dot in App.axaml.
|
||||
-->
|
||||
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}"
|
||||
Width="8" Height="8" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono qcrow-text" Text="{Binding Label}" FontSize="13"
|
||||
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Address}" FontSize="11.5"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Two readings stacked against the two on the left, and in the same order: what this row
|
||||
IS above, how it is reached below.
|
||||
<StackPanel x:Name="PasswordStep" IsVisible="{Binding IsAskingForAdHocPassword}">
|
||||
|
||||
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
|
||||
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
|
||||
The palette searches every readable vault at once, so without it two hosts a team and a
|
||||
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
|
||||
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
|
||||
vault name would otherwise take its width out of the host name beside it.
|
||||
<Border Padding="16,14" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text=">_" FontSize="14" FontWeight="Bold"
|
||||
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding AdHocAddress}" Margin="10,0,0,0"
|
||||
FontSize="14" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
|
||||
connect here is SSH, so printing that word would be a constant dressed up as a reading;
|
||||
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
|
||||
-->
|
||||
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
|
||||
HorizontalAlignment="Right" MaxWidth="150"
|
||||
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
|
||||
FontSize="11" HorizontalAlignment="Right" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<StackPanel Margin="16,14" Spacing="8">
|
||||
<TextBox x:Name="Password" Text="{Binding AdHocPassword}" PasswordChar="•"
|
||||
PlaceholderText="password" FontSize="14" Height="38" />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="12" Margin="18,16"
|
||||
Text="No host matches that."
|
||||
IsVisible="{Binding !HasSearchResults}" />
|
||||
<TextBlock Classes="hint" FontSize="12" Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding AdHocRefusal}" IsVisible="{Binding HasAdHocRefusal}" />
|
||||
|
||||
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
||||
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
|
||||
Text="↑ ↓ to choose · ENTER or click to connect · ESC to close" />
|
||||
</Border>
|
||||
<TextBlock Classes="hint" FontSize="12"
|
||||
Text="Nothing here is saved. A machine worth keeping belongs on HOSTS, where it can carry a key." />
|
||||
</StackPanel>
|
||||
|
||||
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
||||
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
|
||||
Text="ENTER to connect · ESC to go back" />
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -39,11 +39,25 @@ internal sealed partial class QuickConnect : UserControl
|
||||
// drag across the list — and the ListBox has moved its own selection by the time a tap completes,
|
||||
// which is what makes the handler below a matter of reading the selection rather than hit-testing.
|
||||
Results.Tapped += OnResultTapped;
|
||||
AdHocRow.Tapped += OnAdHocRowTapped;
|
||||
|
||||
// The two steps swap in place, and whichever one has just appeared takes the caret: typing an address
|
||||
// and pressing Enter should leave somebody typing a password, not looking for the box to click into.
|
||||
// Watched on the steps themselves rather than on the view model, for the reason TakeKeyboard gives —
|
||||
// the view model's change arrives ahead of the binding that reveals the step.
|
||||
QueryStep.PropertyChanged += OnStepChanged;
|
||||
PasswordStep.PropertyChanged += OnStepChanged;
|
||||
}
|
||||
|
||||
/// <summary>The box, so the caret can be put in it the moment the palette opens.</summary>
|
||||
internal TextBox QueryBox => Query;
|
||||
|
||||
/// <summary>The typed address's password box, which takes the caret when the palette asks for it.</summary>
|
||||
internal TextBox PasswordBox => Password;
|
||||
|
||||
/// <summary>The row offering to dial what was typed, when it reads as an address.</summary>
|
||||
internal Control AdHocTargetRow => AdHocRow;
|
||||
|
||||
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
|
||||
|
||||
/// <summary>
|
||||
@@ -63,6 +77,29 @@ internal sealed partial class QuickConnect : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
// The password step answers only Enter and Escape, and Escape there goes back to the address: a
|
||||
// mistyped address is the likeliest reason to want out of it, and closing would throw that away too.
|
||||
if (shell.IsAskingForAdHocPassword)
|
||||
{
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Escape:
|
||||
shell.BackToSearchQueryCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Enter:
|
||||
_ = shell.ConnectToAdHocTargetCommand.ExecuteAsync(null);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Escape:
|
||||
@@ -76,12 +113,12 @@ internal sealed partial class QuickConnect : UserControl
|
||||
break;
|
||||
|
||||
case Key.Down:
|
||||
Move(shell, 1);
|
||||
shell.MoveSearchSelection(1);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Up:
|
||||
Move(shell, -1);
|
||||
shell.MoveSearchSelection(-1);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
@@ -129,23 +166,38 @@ internal sealed partial class QuickConnect : UserControl
|
||||
NativeKeyboardFocus.ReturnTo(window);
|
||||
}
|
||||
|
||||
Query.Focus();
|
||||
if (PasswordStep.IsVisible)
|
||||
{
|
||||
Password.Focus();
|
||||
}
|
||||
else
|
||||
{
|
||||
Query.Focus();
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>Clamped rather than wrapped: a list that jumps from the last row to the first loses people.</remarks>
|
||||
private static void Move(MainWindowViewModel shell, int delta)
|
||||
/// <summary>Hands the caret to whichever step has just been revealed.</summary>
|
||||
/// <remarks>Posted, for the same reason the palette's own opening is: see <see cref="TakeKeyboard"/>.</remarks>
|
||||
private void OnStepChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||
{
|
||||
if (shell.SearchResults.Count == 0)
|
||||
if (e.Property == IsVisibleProperty && e.GetNewValue<bool>() && IsVisible)
|
||||
{
|
||||
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the typed address, for a click on its row: the pointer's version of Enter on it.
|
||||
/// </summary>
|
||||
private void OnAdHocRowTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (Shell is not { IsSearching: true, HasAdHocTarget: true } shell)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var current = shell.SelectedSearchResult is { } selected
|
||||
? shell.SearchResults.IndexOf(selected)
|
||||
: -1;
|
||||
|
||||
shell.SelectedSearchResult =
|
||||
shell.SearchResults[Math.Clamp(current + delta, 0, shell.SearchResults.Count - 1)];
|
||||
e.Handled = true;
|
||||
shell.ChooseAdHocTargetCommand.Execute(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -87,6 +87,11 @@
|
||||
<TextBlock Classes="settingsrowtitle" Text="Synchronise" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open." />
|
||||
<!-- What SYNC NOW said. The status bar that carries it elsewhere is hidden in settings
|
||||
mode, so without this the button ran, failed or found the machine offline in silence. -->
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
IsVisible="{Binding Vault.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}"
|
||||
Text="{Binding Vault.Status}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="ghost" Height="38" Content="SIGN IN" Command="{Binding SignInCommand}"
|
||||
|
||||
@@ -70,6 +70,13 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- What SYNC NOW said. The status bar that carries it elsewhere is hidden in settings mode, so
|
||||
without this the button ran, failed or found the machine offline in perfect silence. -->
|
||||
<TextBlock Margin="0,10,0,0" FontSize="11.5" TextWrapping="Wrap" HorizontalAlignment="Right"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
IsVisible="{Binding Vault.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}"
|
||||
Text="{Binding Vault.Status}" />
|
||||
|
||||
<!-- The name-a-vault and rename-a-vault forms, in place — this window has no modal idiom for a bare
|
||||
text field, and the members panel below is a different kind of overlay: over one vault's people,
|
||||
not over a form. -->
|
||||
|
||||
@@ -86,9 +86,9 @@
|
||||
},
|
||||
"Velopack": {
|
||||
"type": "Direct",
|
||||
"requested": "[1.2.0, )",
|
||||
"resolved": "1.2.0",
|
||||
"contentHash": "Rz67gJL619fSBS6omaSINUxyDuwhIxkm5mmubf7uLd5Qgi6LLKaKCha+QFP6n+Bw/UjA0vutnH4JQfYzn6ANtw=="
|
||||
"requested": "[1.2.161, )",
|
||||
"resolved": "1.2.161",
|
||||
"contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
|
||||
},
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
|
||||
@@ -26,10 +26,15 @@ namespace DodoSSH.Client.Session;
|
||||
/// <param name="SessionSidebarOpen">
|
||||
/// Whether the session shell's QUICK ACCESS sidebar is drawn. See the remarks on the property.
|
||||
/// </param>
|
||||
/// <param name="TerminalKeyboard">
|
||||
/// How the phone's keys above the keyboard are arranged, or null for the arrangement it ships with. See
|
||||
/// <see cref="TerminalKeyboardSettings"/>.
|
||||
/// </param>
|
||||
public sealed record ClientSettings(
|
||||
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
|
||||
bool AutomaticUpdateChecks = true,
|
||||
bool SessionSidebarOpen = true)
|
||||
bool SessionSidebarOpen = true,
|
||||
TerminalKeyboardSettings? TerminalKeyboard = null)
|
||||
{
|
||||
/*
|
||||
A positional record, and the defaults live on the parameters rather than on property initializers.
|
||||
@@ -213,4 +218,5 @@ public sealed class ClientSettingsStore(ClientPaths paths)
|
||||
WriteIndented = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(ClientSettings))]
|
||||
[JsonSerializable(typeof(TerminalKeyboardSettings))]
|
||||
internal sealed partial class ClientSettingsJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// How the phone's terminal keys are arranged on this device.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Ids, not keys.</b> Every list here names keys and groups by the stable ids in the shell's key catalog
|
||||
/// (<c>TerminalKeyCatalog</c>) rather than storing what a key sends, so a fix to a predefined key's bytes
|
||||
/// reaches people who customised their bar before the fix. An id this build does not know — one written by
|
||||
/// a newer build, or one for a custom key since deleted — is skipped on the way in rather than refused.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>In <see cref="ClientSettings"/>, so on this device and in plain text.</b> The arrangement suits one
|
||||
/// screen and one pair of thumbs, which is the same argument the font size makes for not following
|
||||
/// somebody onto a desktop. Custom keys are keystrokes such as <c>:wq</c>, and the customise screen says
|
||||
/// outright that this file is not a place for a password.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Null lists mean "as shipped" rather than "empty". An empty <see cref="Bar"/> is a choice somebody made,
|
||||
/// and has to survive a launch as one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Bar">The keys on the bar above the software keyboard, in order.</param>
|
||||
/// <param name="GroupOrder">The order of the groups on the quick-access keyboard's KEYS tab.</param>
|
||||
/// <param name="HiddenGroups">Groups turned off on the KEYS tab.</param>
|
||||
/// <param name="CustomKeys">Keys somebody added for themselves.</param>
|
||||
/// <param name="QuickAccessTab">The quick-access keyboard's tab last used, which it reopens to.</param>
|
||||
/// <param name="SidePanelOpen">Whether a wide screen shows the quick-access keyboard beside the terminal.</param>
|
||||
/// <param name="CursorGestures">Whether long-press-and-drag on the terminal moves the cursor.</param>
|
||||
public sealed record TerminalKeyboardSettings(
|
||||
string[]? Bar = null,
|
||||
string[]? GroupOrder = null,
|
||||
string[]? HiddenGroups = null,
|
||||
CustomTerminalKey[]? CustomKeys = null,
|
||||
string? QuickAccessTab = null,
|
||||
bool SidePanelOpen = true,
|
||||
bool CursorGestures = true);
|
||||
|
||||
/// <summary>A key somebody added: what it says on it, and what it types.</summary>
|
||||
/// <param name="Id">Stable, so the bar can refer to it. Always starts <c>custom:</c>.</param>
|
||||
/// <param name="Label">What the key shows.</param>
|
||||
/// <param name="Sequence">
|
||||
/// What it sends, in the notation the customise screen accepts — <c>\e</c>, <c>\r</c>, <c>^C</c> and so on —
|
||||
/// rather than as bytes, so the file stays readable and the parser stays the one place escapes are decided.
|
||||
/// </param>
|
||||
public sealed record CustomTerminalKey(string Id, string Label, string Sequence);
|
||||
@@ -0,0 +1,171 @@
|
||||
namespace DodoSSH.Client.Shell.Keyboard;
|
||||
|
||||
/// <summary>Which arrow key a cursor gesture is pressing, if any.</summary>
|
||||
internal enum ArrowDirection
|
||||
{
|
||||
None,
|
||||
Left,
|
||||
Down,
|
||||
Up,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Long-press-and-drag on the terminal as a joystick for the arrow keys.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A joystick rather than a trackpad, and the difference is what the gesture is for.</b> A trackpad would
|
||||
/// send one arrow per so many pixels of travel, which runs out of screen long before a long command line
|
||||
/// runs out of characters, and does nothing at all for the other half of the job — holding Up to walk back
|
||||
/// through history. Here the finger's distance from where the press landed is a speed, and the key repeats
|
||||
/// for as long as it is held there: a short way out steps slowly enough to stop on the character wanted, and
|
||||
/// further out in the same direction shifts up through the second and third speeds.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The first step is immediate and the second waits.</b> Crossing the dead zone sends one key at once, so
|
||||
/// a quick nudge is exactly one character, and the slowest speed then holds off for
|
||||
/// <see cref="FirstRepeatDelayMs"/> before repeating — the delay every hardware keyboard puts between a
|
||||
/// keypress and auto-repeat, for the same reason.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The axis is sticky.</b> A thumb dragging left does not travel in a straight line, and without some
|
||||
/// hysteresis a drift upwards of a few pixels would flip the gesture onto Up and walk into history mid-edit.
|
||||
/// The other axis has to win by <see cref="AxisSwitchRatio"/> to take over.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Pure arithmetic over displacements in density-independent pixels and times in milliseconds, which is
|
||||
/// what lets it be tested without a touchscreen. The platform half — noticing the long press, taking the
|
||||
/// touch stream away from the page, running the clock — is in the Android head's <c>RendererFocus</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ArrowGesture
|
||||
{
|
||||
/// <summary>How far from the press the finger can wander before anything is sent, in dp.</summary>
|
||||
internal const double DeadZone = 16;
|
||||
|
||||
/// <summary>Where the second speed starts, in dp from the press.</summary>
|
||||
internal const double SecondSpeedAt = 64;
|
||||
|
||||
/// <summary>Where the third speed starts, in dp from the press.</summary>
|
||||
internal const double ThirdSpeedAt = 128;
|
||||
|
||||
/// <summary>How long the slowest speed waits after its first key before repeating.</summary>
|
||||
internal const long FirstRepeatDelayMs = 400;
|
||||
|
||||
/// <summary>How much the other axis has to dominate before the direction changes axis.</summary>
|
||||
internal const double AxisSwitchRatio = 1.4;
|
||||
|
||||
/// <summary>The most keys one call reports, so a stalled clock cannot come back as a burst.</summary>
|
||||
private const int MaximumBurst = 3;
|
||||
|
||||
private long nextDue;
|
||||
|
||||
/// <summary>The key being held, or <see cref="ArrowDirection.None"/> inside the dead zone.</summary>
|
||||
internal ArrowDirection Direction { get; private set; }
|
||||
|
||||
/// <summary>The speed, from 1 to 3, or 0 inside the dead zone.</summary>
|
||||
internal int Speed { get; private set; }
|
||||
|
||||
/// <summary>The interval between repeats at a speed.</summary>
|
||||
internal static long IntervalMs(int speed) => speed switch
|
||||
{
|
||||
1 => 220,
|
||||
2 => 90,
|
||||
_ => 35,
|
||||
};
|
||||
|
||||
/// <summary>Takes the finger's new displacement from where the press landed.</summary>
|
||||
/// <returns>How many keys to send now, in <see cref="Direction"/>.</returns>
|
||||
internal int Move(double dx, double dy, long nowMs)
|
||||
{
|
||||
var direction = DirectionFor(dx, dy);
|
||||
|
||||
if (direction is ArrowDirection.None)
|
||||
{
|
||||
Direction = ArrowDirection.None;
|
||||
Speed = 0;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
var along = direction is ArrowDirection.Left or ArrowDirection.Right ? Math.Abs(dx) : Math.Abs(dy);
|
||||
var speed = along >= ThirdSpeedAt ? 3 : along >= SecondSpeedAt ? 2 : 1;
|
||||
|
||||
if (direction != Direction)
|
||||
{
|
||||
Direction = direction;
|
||||
Speed = speed;
|
||||
nextDue = nowMs + (speed == 1 ? FirstRepeatDelayMs : IntervalMs(speed));
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (speed > Speed)
|
||||
{
|
||||
// Faster takes effect at once rather than after the slower interval already running out.
|
||||
nextDue = Math.Min(nextDue, nowMs + IntervalMs(speed));
|
||||
}
|
||||
|
||||
Speed = speed;
|
||||
|
||||
return Advance(nowMs);
|
||||
}
|
||||
|
||||
/// <summary>Lets time pass with the finger held still.</summary>
|
||||
/// <returns>How many keys have come due, in <see cref="Direction"/>.</returns>
|
||||
internal int Advance(long nowMs)
|
||||
{
|
||||
if (Direction is ArrowDirection.None)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var interval = IntervalMs(Speed);
|
||||
var count = 0;
|
||||
|
||||
while (nowMs >= nextDue && count < MaximumBurst)
|
||||
{
|
||||
count++;
|
||||
nextDue += interval;
|
||||
}
|
||||
|
||||
if (nowMs >= nextDue)
|
||||
{
|
||||
// Behind by more than a burst: drop the backlog rather than owe it.
|
||||
nextDue = nowMs + interval;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>Lets go.</summary>
|
||||
internal void Reset()
|
||||
{
|
||||
Direction = ArrowDirection.None;
|
||||
Speed = 0;
|
||||
}
|
||||
|
||||
private ArrowDirection DirectionFor(double dx, double dy)
|
||||
{
|
||||
var ax = Math.Abs(dx);
|
||||
var ay = Math.Abs(dy);
|
||||
|
||||
if (Math.Max(ax, ay) < DeadZone)
|
||||
{
|
||||
return ArrowDirection.None;
|
||||
}
|
||||
|
||||
var horizontal = Direction switch
|
||||
{
|
||||
ArrowDirection.Left or ArrowDirection.Right => ay <= ax * AxisSwitchRatio,
|
||||
ArrowDirection.Up or ArrowDirection.Down => ax > ay * AxisSwitchRatio,
|
||||
_ => ax >= ay,
|
||||
};
|
||||
|
||||
// Screen coordinates: y grows downwards, so a drag towards the top of the screen is Up.
|
||||
return horizontal
|
||||
? dx < 0 ? ArrowDirection.Left : ArrowDirection.Right
|
||||
: dy < 0 ? ArrowDirection.Up : ArrowDirection.Down;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace DodoSSH.Client.Shell.Keyboard;
|
||||
|
||||
/// <summary>
|
||||
/// The line the cursor is on, as a terminal would draw it from the last of a session's output.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Just enough of a terminal to read back a line being edited</b>, and deliberately no more. Line editors
|
||||
/// redraw with a handful of moves — backspace, carriage return, cursor left and right, erase to end of line,
|
||||
/// delete and insert characters — and those are modelled. Everything else that starts with an escape is
|
||||
/// skipped whole: colours, titles, mode switches. A redraw this does not follow produces a line that does
|
||||
/// not match, and the history then keeps nothing, which is the direction it is meant to fail in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The tail can start in the middle of a UTF-8 sequence or an escape. The first becomes a replacement
|
||||
/// character on a line long since scrolled past; the second is a few stray characters at the start of the
|
||||
/// same line. Neither reaches the last line unless it is the only line, and a prompt is never that short.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class EchoedLine
|
||||
{
|
||||
/// <summary>Reads the cursor's line out of a session's recent output.</summary>
|
||||
internal static string LastLine(ReadOnlySpan<byte> tail)
|
||||
{
|
||||
var text = Encoding.UTF8.GetString(tail);
|
||||
var line = new StringBuilder();
|
||||
var cursor = 0;
|
||||
|
||||
for (var i = 0; i < text.Length; i++)
|
||||
{
|
||||
var c = text[i];
|
||||
|
||||
switch (c)
|
||||
{
|
||||
case '\x1b':
|
||||
i = Escape(text, i, line, ref cursor);
|
||||
break;
|
||||
|
||||
case '\r':
|
||||
cursor = 0;
|
||||
break;
|
||||
|
||||
case '\n':
|
||||
line.Clear();
|
||||
cursor = 0;
|
||||
break;
|
||||
|
||||
case '\b':
|
||||
cursor = Math.Max(0, cursor - 1);
|
||||
break;
|
||||
|
||||
case < ' ' or '\x7f':
|
||||
break;
|
||||
|
||||
default:
|
||||
Put(line, ref cursor, c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return line.ToString();
|
||||
}
|
||||
|
||||
private static void Put(StringBuilder line, ref int cursor, char c)
|
||||
{
|
||||
while (line.Length < cursor)
|
||||
{
|
||||
line.Append(' ');
|
||||
}
|
||||
|
||||
if (cursor < line.Length)
|
||||
{
|
||||
line[cursor] = c;
|
||||
}
|
||||
else
|
||||
{
|
||||
line.Append(c);
|
||||
}
|
||||
|
||||
cursor++;
|
||||
}
|
||||
|
||||
/// <summary>Follows one escape sequence starting at <paramref name="at"/>.</summary>
|
||||
/// <returns>The index of its last character.</returns>
|
||||
private static int Escape(string text, int at, StringBuilder line, ref int cursor)
|
||||
{
|
||||
if (at + 1 >= text.Length)
|
||||
{
|
||||
return at;
|
||||
}
|
||||
|
||||
switch (text[at + 1])
|
||||
{
|
||||
case '[':
|
||||
return ControlSequence(text, at + 2, line, ref cursor);
|
||||
|
||||
case ']':
|
||||
// An operating system command — a window title, a hyperlink — ends at BEL or ST.
|
||||
for (var i = at + 2; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\a')
|
||||
{
|
||||
return i;
|
||||
}
|
||||
|
||||
if (text[i] == '\x1b' && i + 1 < text.Length && text[i + 1] == '\\')
|
||||
{
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return text.Length - 1;
|
||||
|
||||
default:
|
||||
// Two-character escapes, and the charset selectors that take one more.
|
||||
return text[at + 1] is '(' or ')' or '*' or '+' ? Math.Min(at + 2, text.Length - 1) : at + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static int ControlSequence(string text, int from, StringBuilder line, ref int cursor)
|
||||
{
|
||||
var end = from;
|
||||
|
||||
while (end < text.Length && text[end] is < '@' or > '~')
|
||||
{
|
||||
end++;
|
||||
}
|
||||
|
||||
if (end >= text.Length)
|
||||
{
|
||||
return text.Length - 1;
|
||||
}
|
||||
|
||||
var parameters = text[from..end];
|
||||
var n = int.TryParse(parameters, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) && parsed > 0
|
||||
? parsed
|
||||
: 1;
|
||||
|
||||
switch (text[end])
|
||||
{
|
||||
case 'C':
|
||||
cursor += n;
|
||||
break;
|
||||
|
||||
case 'D':
|
||||
cursor = Math.Max(0, cursor - n);
|
||||
break;
|
||||
|
||||
case 'G':
|
||||
cursor = n - 1;
|
||||
break;
|
||||
|
||||
case 'K':
|
||||
EraseInLine(line, cursor, parameters);
|
||||
break;
|
||||
|
||||
case 'P':
|
||||
if (cursor < line.Length)
|
||||
{
|
||||
line.Remove(cursor, Math.Min(n, line.Length - cursor));
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case '@':
|
||||
if (cursor < line.Length)
|
||||
{
|
||||
line.Insert(cursor, new string(' ', n));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return end;
|
||||
}
|
||||
|
||||
private static void EraseInLine(StringBuilder line, int cursor, string parameters)
|
||||
{
|
||||
switch (parameters)
|
||||
{
|
||||
case "" or "0":
|
||||
if (cursor < line.Length)
|
||||
{
|
||||
line.Length = cursor;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "1":
|
||||
for (var i = 0; i < Math.Min(cursor + 1, line.Length); i++)
|
||||
{
|
||||
line[i] = ' ';
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "2":
|
||||
line.Clear();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace DodoSSH.Client.Shell.Keyboard;
|
||||
|
||||
/// <summary>
|
||||
/// Reads what a custom key should send, in the notation people already write it in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two notations, both the ones a terminal user has seen before: backslash escapes as <c>printf</c> and
|
||||
/// <c>bind</c> write them (<c>\e</c>, <c>\r</c>, <c>\t</c>, <c>\n</c>, <c>\\</c>, <c>\xHH</c>), and caret
|
||||
/// notation as <c>stty</c> and every man page write control characters (<c>^C</c>, <c>^[</c>, <c>^?</c>).
|
||||
/// Anything else is typed as it stands, in UTF-8.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A caret only means Ctrl in front of a character that has a control form</b> — a letter, or one of
|
||||
/// <c>@[\]^_?</c>. Elsewhere, as in <c>^1</c> or a trailing <c>^</c>, it is a caret. The cost is that
|
||||
/// <c>grep ^root</c> written here sends Ctrl-R; <c>\^</c> is the way to say a literal caret before a
|
||||
/// letter, and the customise screen says so.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Refusing is reserved for what cannot mean anything — a backslash at the very end, or <c>\x</c> without
|
||||
/// two hex digits — so a sequence that parses is one that sends exactly what it says.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class KeySequence
|
||||
{
|
||||
/// <summary>Turns notation into bytes.</summary>
|
||||
/// <returns>False when the notation is malformed; <paramref name="bytes"/> is then empty.</returns>
|
||||
internal static bool TryParse(string notation, out byte[] bytes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notation);
|
||||
|
||||
var output = new List<byte>(notation.Length);
|
||||
var literal = new StringBuilder();
|
||||
|
||||
for (var i = 0; i < notation.Length; i++)
|
||||
{
|
||||
var c = notation[i];
|
||||
|
||||
if (c == '\\')
|
||||
{
|
||||
if (!TryEscape(notation, ref i, output, literal))
|
||||
{
|
||||
bytes = [];
|
||||
return false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '^' && i + 1 < notation.Length && TryControl(notation[i + 1], out var control))
|
||||
{
|
||||
FlushLiteral(output, literal);
|
||||
output.Add(control);
|
||||
i++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
literal.Append(c);
|
||||
}
|
||||
|
||||
FlushLiteral(output, literal);
|
||||
|
||||
bytes = [.. output];
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Reads the escape whose backslash is at <paramref name="i"/>, leaving it on its last character.</summary>
|
||||
private static bool TryEscape(string notation, ref int i, List<byte> output, StringBuilder literal)
|
||||
{
|
||||
if (i + 1 >= notation.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var next = notation[++i];
|
||||
|
||||
if (next is 'x' or 'X')
|
||||
{
|
||||
// Exactly two digits, always. One digit followed by text would be a guess at where the number
|
||||
// stops.
|
||||
if (notation.Length - i - 1 < 2
|
||||
|| !byte.TryParse(
|
||||
notation.AsSpan(i + 1, 2),
|
||||
NumberStyles.AllowHexSpecifier,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var hex))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
FlushLiteral(output, literal);
|
||||
output.Add(hex);
|
||||
i += 2;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
byte? escaped = next switch
|
||||
{
|
||||
'e' or 'E' => 0x1B,
|
||||
'r' => 0x0D,
|
||||
'n' => 0x0A,
|
||||
't' => 0x09,
|
||||
'b' => 0x08,
|
||||
'a' => 0x07,
|
||||
'0' => 0x00,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (escaped is { } value)
|
||||
{
|
||||
FlushLiteral(output, literal);
|
||||
output.Add(value);
|
||||
}
|
||||
else
|
||||
{
|
||||
// \\, \^ and any other escaped character stand for themselves.
|
||||
literal.Append(next);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Moves the plain text gathered so far into the output, as UTF-8.</summary>
|
||||
private static void FlushLiteral(List<byte> output, StringBuilder literal)
|
||||
{
|
||||
if (literal.Length > 0)
|
||||
{
|
||||
output.AddRange(Encoding.UTF8.GetBytes(literal.ToString()));
|
||||
literal.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The control character caret notation names, where there is one.</summary>
|
||||
/// <remarks>
|
||||
/// <c>^?</c> is DEL rather than a masked byte — the one exception every table of caret notation makes,
|
||||
/// and the one people reach for as "backspace".
|
||||
/// </remarks>
|
||||
internal static bool TryControl(char c, out byte control)
|
||||
{
|
||||
if (c == '?')
|
||||
{
|
||||
control = 0x7F;
|
||||
return true;
|
||||
}
|
||||
|
||||
var upper = char.ToUpperInvariant(c);
|
||||
|
||||
if (upper is >= '@' and <= '_')
|
||||
{
|
||||
control = (byte)(upper & 0x1F);
|
||||
return true;
|
||||
}
|
||||
|
||||
control = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace DodoSSH.Client.Shell.Keyboard;
|
||||
|
||||
/// <summary>What pressing a terminal key does.</summary>
|
||||
internal enum TerminalKeyKind
|
||||
{
|
||||
/// <summary>Sends its bytes, after any latched modifier has had its say.</summary>
|
||||
Send,
|
||||
|
||||
/// <summary>Latches Ctrl for the next key, and releases itself after it.</summary>
|
||||
Control,
|
||||
|
||||
/// <summary>Latches Alt for the next key, and releases itself after it.</summary>
|
||||
Alt,
|
||||
|
||||
/// <summary>Pastes the clipboard, through the same path a right-click on the desktop takes.</summary>
|
||||
Paste,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One key the phone's own keyboard does not have.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A record for the convenience of construction and nothing else: two keys are the same key when their
|
||||
/// <see cref="Id"/> is, which is what the bar stores. The byte array would make record equality reference
|
||||
/// equality anyway, so nothing here leans on it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="IsWide"/> exists for the labels that are words — <c>ctrl+B %</c> — which want two cells on
|
||||
/// the quick-access keyboard where an arrow wants one. It is a fact about the label rather than a layout
|
||||
/// choice made per screen, so it lives here.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Id">Stable across releases; the bar and the group order are stored by it.</param>
|
||||
/// <param name="Label">What the key says.</param>
|
||||
/// <param name="Kind">What pressing it does.</param>
|
||||
/// <param name="Bytes">What it sends, for <see cref="TerminalKeyKind.Send"/>. Empty otherwise.</param>
|
||||
/// <param name="Hint">What it is for, for the customise screen and for accessibility.</param>
|
||||
internal sealed record TerminalKey(string Id, string Label, TerminalKeyKind Kind, byte[] Bytes, string Hint)
|
||||
{
|
||||
/// <summary>Whether the label is long enough to want a double-width cell.</summary>
|
||||
internal bool IsWide => Label.Length > 4;
|
||||
|
||||
/// <summary>Whether this is one of the two latching modifiers.</summary>
|
||||
internal bool IsModifier => Kind is TerminalKeyKind.Control or TerminalKeyKind.Alt;
|
||||
|
||||
/// <summary>Whether this key was added by the person using it rather than shipped.</summary>
|
||||
internal bool IsCustom => Id.StartsWith(TerminalKeyCatalog.CustomPrefix, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>A named set of keys on the quick-access keyboard.</summary>
|
||||
/// <param name="Id">Stable across releases; the order and the hidden set are stored by it.</param>
|
||||
/// <param name="Title">The caption above the keys.</param>
|
||||
/// <param name="Keys">The keys, in the order they are drawn.</param>
|
||||
internal sealed record TerminalKeyGroup(string Id, string Title, IReadOnlyList<TerminalKey> Keys);
|
||||
@@ -0,0 +1,199 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Text;
|
||||
|
||||
namespace DodoSSH.Client.Shell.Keyboard;
|
||||
|
||||
/// <summary>
|
||||
/// Every key the phone ships with, in the groups the quick-access keyboard draws them in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The ids are a stored format.</b> The bar and the group order are written to the settings file by id,
|
||||
/// so renaming one silently drops it from every bar it was on. Labels, hints and bytes can change freely —
|
||||
/// that is the point of storing ids — but an id, once shipped, stays.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Cursor and function keys are written as the sequences xterm sends in its default mode</b> — CSI for
|
||||
/// the arrows, SS3 for F1 to F4 — for the reason <c>TerminalInputSurface</c> gives: nothing on this side
|
||||
/// tracks the remote's application-cursor mode, and CSI is what bash, zsh and every readline accept in
|
||||
/// both modes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class TerminalKeyCatalog
|
||||
{
|
||||
/// <summary>What every custom key's id starts with, so one can never collide with a shipped key.</summary>
|
||||
internal const string CustomPrefix = "custom:";
|
||||
|
||||
/// <summary>The id of the group custom keys are drawn in.</summary>
|
||||
internal const string CustomGroupId = "custom";
|
||||
|
||||
/// <summary>The most keys the bar holds.</summary>
|
||||
/// <remarks>
|
||||
/// Two rows of <see cref="BarRowWidth"/>. A third row would cost the terminal another five lines on a
|
||||
/// phone, and anything used rarely enough not to make the first sixteen belongs on the quick-access
|
||||
/// keyboard, one tap further away.
|
||||
/// </remarks>
|
||||
internal const int MaximumBarKeys = 16;
|
||||
|
||||
/// <summary>The most keys one row of the bar divides its width between.</summary>
|
||||
/// <remarks>
|
||||
/// Eight across 360dp is a 41dp target after margins — just under Android's 48 and roughly what every
|
||||
/// terminal on the platform ships. Nine would be 36, which is where the old scrolling row's misses began.
|
||||
/// </remarks>
|
||||
internal const int BarRowWidth = 8;
|
||||
|
||||
private static TerminalKey Send(string id, string label, string hint, params byte[] bytes) =>
|
||||
new(id, label, TerminalKeyKind.Send, bytes, hint);
|
||||
|
||||
private static TerminalKey Text(string id, string label, string hint, string text) =>
|
||||
new(id, label, TerminalKeyKind.Send, Encoding.UTF8.GetBytes(text), hint);
|
||||
|
||||
private static byte[] Csi(string tail) => [0x1B, (byte)'[', .. Encoding.ASCII.GetBytes(tail)];
|
||||
|
||||
private static byte[] Ss3(char final) => [0x1B, (byte)'O', (byte)final];
|
||||
|
||||
/// <summary>Ctrl-B and then a key: one press of a tmux binding under the default prefix.</summary>
|
||||
private static TerminalKey Tmux(string id, string key, string hint) =>
|
||||
new(id, $"ctrl+B {key}", TerminalKeyKind.Send, [0x02, .. Encoding.ASCII.GetBytes(key)], hint);
|
||||
|
||||
/// <summary>
|
||||
/// Escape first, then the command: whatever mode vi was in, this is the one it is meant for.
|
||||
/// </summary>
|
||||
private static TerminalKey Vim(string id, string label, string command, string hint) =>
|
||||
new(id, label, TerminalKeyKind.Send, [0x1B, .. Encoding.ASCII.GetBytes(command)], hint);
|
||||
|
||||
private static TerminalKey Control(char letter) =>
|
||||
Send(
|
||||
$"ctrl-{char.ToLowerInvariant(letter)}",
|
||||
$"^{letter}",
|
||||
ControlHints.GetValueOrDefault(letter, $"Ctrl+{letter}"),
|
||||
(byte)(letter & 0x1F));
|
||||
|
||||
private static readonly Dictionary<char, string> ControlHints = new()
|
||||
{
|
||||
['C'] = "Interrupt the running command",
|
||||
['D'] = "End of input; logs out at an empty prompt",
|
||||
['Z'] = "Suspend the running command",
|
||||
['L'] = "Clear the screen",
|
||||
['R'] = "Search command history",
|
||||
['A'] = "Start of the line",
|
||||
['E'] = "End of the line",
|
||||
['U'] = "Delete to the start of the line",
|
||||
['W'] = "Delete the word before the cursor",
|
||||
['K'] = "Delete to the end of the line",
|
||||
};
|
||||
|
||||
/// <summary>The groups as shipped, in their shipped order.</summary>
|
||||
internal static IReadOnlyList<TerminalKeyGroup> Groups { get; } =
|
||||
[
|
||||
new("essentials", "ESSENTIALS",
|
||||
[
|
||||
Send("esc", "esc", "Escape", 0x1B),
|
||||
Send("tab", "tab", "Complete a command or path", 0x09),
|
||||
Send("shift-tab", "shift+tab", "Backwards through completions and forms", Csi("Z")),
|
||||
new("ctrl", "ctrl", TerminalKeyKind.Control, [], "Hold Ctrl for the next key"),
|
||||
new("alt", "alt", TerminalKeyKind.Alt, [], "Hold Alt for the next key"),
|
||||
Send("enter", "⏎", "Enter", 0x0D),
|
||||
new("paste", "paste", TerminalKeyKind.Paste, [], "Paste the clipboard"),
|
||||
Send("ins", "ins", "Insert", Csi("2~")),
|
||||
Send("del", "del", "Delete forwards", Csi("3~")),
|
||||
]),
|
||||
new("cursor", "CURSOR",
|
||||
[
|
||||
Send("left", "←", "Left", Csi("D")),
|
||||
Send("down", "↓", "Down; the next command in history", Csi("B")),
|
||||
Send("up", "↑", "Up; the previous command in history", Csi("A")),
|
||||
Send("right", "→", "Right", Csi("C")),
|
||||
Send("home", "home", "Start of the line", Csi("H")),
|
||||
Send("end", "end", "End of the line", Csi("F")),
|
||||
Send("pgup", "pgup", "Page up", Csi("5~")),
|
||||
Send("pgdn", "pgdn", "Page down", Csi("6~")),
|
||||
]),
|
||||
new("control", "CONTROL",
|
||||
[
|
||||
Control('C'), Control('D'), Control('Z'), Control('L'), Control('R'),
|
||||
Control('A'), Control('E'), Control('U'), Control('W'), Control('K'),
|
||||
]),
|
||||
new("symbols", "SYMBOLS",
|
||||
[
|
||||
Text("pipe", "|", "Pipe", "|"),
|
||||
Text("dash", "-", "Hyphen", "-"),
|
||||
Text("underscore", "_", "Underscore", "_"),
|
||||
Text("tilde", "~", "Home directory", "~"),
|
||||
Text("slash", "/", "Slash", "/"),
|
||||
Text("backslash", "\\", "Backslash", "\\"),
|
||||
Text("backtick", "`", "Backtick", "`"),
|
||||
Text("star", "*", "Asterisk", "*"),
|
||||
Text("amp", "&", "Ampersand", "&"),
|
||||
Text("dollar", "$", "Dollar", "$"),
|
||||
Text("lt", "<", "Less than", "<"),
|
||||
Text("gt", ">", "Greater than", ">"),
|
||||
Text("lbrace", "{", "Left brace", "{"),
|
||||
Text("rbrace", "}", "Right brace", "}"),
|
||||
Text("lbracket", "[", "Left bracket", "["),
|
||||
Text("rbracket", "]", "Right bracket", "]"),
|
||||
]),
|
||||
new("tmux", "TMUX",
|
||||
[
|
||||
Send("tmux-prefix", "^B", "The tmux prefix", 0x02),
|
||||
Tmux("tmux-new", "c", "New window"),
|
||||
Tmux("tmux-next", "n", "Next window"),
|
||||
Tmux("tmux-prev", "p", "Previous window"),
|
||||
Tmux("tmux-split-v", "%", "Split side by side"),
|
||||
Tmux("tmux-split-h", "\"", "Split top and bottom"),
|
||||
Tmux("tmux-other", "o", "Next pane"),
|
||||
Tmux("tmux-kill", "x", "Close the pane"),
|
||||
Tmux("tmux-detach", "d", "Detach"),
|
||||
Tmux("tmux-copy", "[", "Scroll and copy mode"),
|
||||
]),
|
||||
new("vim", "VIM",
|
||||
[
|
||||
Vim("vim-write", ":w", ":w\r", "Save"),
|
||||
Vim("vim-quit", ":q", ":q\r", "Quit"),
|
||||
Vim("vim-write-quit", ":wq", ":wq\r", "Save and quit"),
|
||||
Vim("vim-force-quit", ":q!", ":q!\r", "Quit without saving"),
|
||||
Vim("vim-top", "gg", "gg", "First line"),
|
||||
Vim("vim-bottom", "G", "G", "Last line"),
|
||||
]),
|
||||
new("function", "FUNCTION",
|
||||
[
|
||||
Send("f1", "F1", "F1", Ss3('P')),
|
||||
Send("f2", "F2", "F2", Ss3('Q')),
|
||||
Send("f3", "F3", "F3", Ss3('R')),
|
||||
Send("f4", "F4", "F4", Ss3('S')),
|
||||
Send("f5", "F5", "F5", Csi("15~")),
|
||||
Send("f6", "F6", "F6", Csi("17~")),
|
||||
Send("f7", "F7", "F7", Csi("18~")),
|
||||
Send("f8", "F8", "F8", Csi("19~")),
|
||||
Send("f9", "F9", "F9", Csi("20~")),
|
||||
Send("f10", "F10", "F10", Csi("21~")),
|
||||
Send("f11", "F11", "F11", Csi("23~")),
|
||||
Send("f12", "F12", "F12", Csi("24~")),
|
||||
]),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// The bar as shipped: the keys this head's old two fixed rows carried, plus Alt and paste.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Modifiers and punctuation on the first row, cursor keys on the second — the split the old keypad's
|
||||
/// remarks argue for, which is unchanged: the arrows are the keys pressed repeatedly and in sequence, so
|
||||
/// they get the row nearest the thumb and, being fewer, the widest targets. Paste joins them because a
|
||||
/// long press on the terminal is now the cursor gesture, and was the only way to paste before.
|
||||
/// </remarks>
|
||||
internal static IReadOnlyList<string> DefaultBar { get; } =
|
||||
[
|
||||
"esc", "tab", "ctrl", "alt", "pipe", "dash", "slash", "tilde",
|
||||
"left", "down", "up", "right", "paste",
|
||||
];
|
||||
|
||||
/// <summary>Every shipped key by id.</summary>
|
||||
internal static FrozenDictionary<string, TerminalKey> ById { get; } =
|
||||
Groups.SelectMany(g => g.Keys).ToFrozenDictionary(k => k.Id, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Builds the key a stored custom entry describes, or null when its sequence does not parse.</summary>
|
||||
internal static TerminalKey? FromCustom(string id, string label, string sequence) =>
|
||||
KeySequence.TryParse(sequence, out var bytes) && bytes.Length > 0
|
||||
? new TerminalKey(id, label, TerminalKeyKind.Send, bytes, $"Sends {sequence}")
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace DodoSSH.Client.Shell.Keyboard;
|
||||
|
||||
/// <summary>
|
||||
/// What a latched Ctrl or Alt does to the key pressed after it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The encodings xterm uses, and nothing cleverer.</b> Ctrl with a character is that character's
|
||||
/// control form — <c>c & 0x1F</c>, the rule caret notation names. Alt is an Escape in front, which is
|
||||
/// what readline's <c>M-b</c> and <c>M-f</c> and every Emacs binding expect from a terminal. Ctrl or Alt with
|
||||
/// a cursor key is the modified CSI form, <c>CSI 1 ; m X</c>, which is how Ctrl+← jumps a word in bash and
|
||||
/// zsh.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Anything else goes through untouched.</b> Ctrl with a pasted line or with a function key has no
|
||||
/// encoding a shell agrees on, and mangling one into something else would be worse than ignoring the
|
||||
/// latch. The latch still releases, because the person pressed the key it was latched for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class TerminalModifiers
|
||||
{
|
||||
/// <summary>Applies whichever modifiers are latched to one key's worth of bytes.</summary>
|
||||
internal static byte[] Apply(byte[] payload, bool control, bool alt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
if (!control && !alt)
|
||||
{
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ESC [ X for the six cursor keys that have a modified form: the arrows, Home and End.
|
||||
if (payload is [0x1B, (byte)'[', var final] && final is (byte)'A' or (byte)'B' or (byte)'C'
|
||||
or (byte)'D' or (byte)'H' or (byte)'F')
|
||||
{
|
||||
var modifier = 1 + (alt ? 2 : 0) + (control ? 4 : 0);
|
||||
|
||||
return [0x1B, (byte)'[', (byte)'1', (byte)';', (byte)('0' + modifier), final];
|
||||
}
|
||||
|
||||
var result = payload;
|
||||
|
||||
if (control && payload is [var single])
|
||||
{
|
||||
result = [ControlForm(single)];
|
||||
}
|
||||
|
||||
return alt ? [0x1B, .. result] : result;
|
||||
}
|
||||
|
||||
/// <summary>The control character for one byte, or the byte itself where there is none.</summary>
|
||||
/// <remarks>Space as NUL is the one addition to caret notation, and every terminal makes it.</remarks>
|
||||
private static byte ControlForm(byte c) =>
|
||||
c == (byte)' ' ? (byte)0x00
|
||||
: c < 0x80 && KeySequence.TryControl((char)c, out var control) ? control
|
||||
: c;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
|
||||
using DodoSSH.Client.Terminal;
|
||||
|
||||
namespace DodoSSH.Client.Shell.Keyboard;
|
||||
|
||||
/// <summary>One line the history kept: what ran, and where.</summary>
|
||||
internal sealed record HistoryEntry(string Command, string Host);
|
||||
|
||||
/// <summary>
|
||||
/// The commands typed on this phone, for the quick-access keyboard's HISTORY tab.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Only what the remote echoed, and that is the whole safety story.</b> A terminal is one input stream,
|
||||
/// and a <c>sudo</c> password typed into it looks exactly like a command typed into it — right up to the
|
||||
/// moment the shell does or does not echo it. So nothing is kept on the strength of what was typed. On Enter
|
||||
/// the line the cursor is on is read back out of the session's own output (see <see cref="EchoedLine"/>) and
|
||||
/// a command is kept only if it is there: a password prompt, which echoes nothing, leaves the line as the
|
||||
/// prompt alone and nothing is recorded. Full-screen programs are skipped outright, because "the line the
|
||||
/// cursor is on" in vi is a line of a file.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two ways to read the command back, the second stricter than the first.</b> The prompt is noted when the
|
||||
/// first key of a line is pressed; if the line on Enter still starts with it, what follows is the command —
|
||||
/// which is what lets a line finished with Tab completion or recalled with ↑ be kept as the shell ran it.
|
||||
/// When the prompt has moved (a redraw, a multi-line prompt), the line is kept only if it was typed key by
|
||||
/// key with nothing the client cannot follow, and the echo ends with exactly that text.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Memory only, and cleared on lock.</b> Commands carry hostnames, paths and sometimes tokens on the
|
||||
/// command line. The settings file is plaintext and the cache is the vault's, so this is neither: it lasts
|
||||
/// as long as the process, the LOCK button empties it, and the tab says so.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A line that fails any test is simply not kept. A history that misses a command costs one retype; one
|
||||
/// that kept a password would be showing it to whoever looks at the phone next.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class TypedCommandHistory
|
||||
{
|
||||
/// <summary>The most commands kept. Older ones fall off the end.</summary>
|
||||
internal const int Capacity = 100;
|
||||
|
||||
/// <summary>The longest command kept; anything longer is not something to retype from a list.</summary>
|
||||
internal const int MaximumLength = 512;
|
||||
|
||||
private readonly Dictionary<uint, LineInProgress> lines = [];
|
||||
|
||||
/// <summary>Most recent first, without duplicates.</summary>
|
||||
internal ObservableCollection<HistoryEntry> Entries { get; } = [];
|
||||
|
||||
/// <summary>Notes one key's worth of bytes about to be sent to a session.</summary>
|
||||
/// <param name="sessionId">The session they are going to.</param>
|
||||
/// <param name="host">What the tab is called, to label an entry with.</param>
|
||||
/// <param name="sent">The bytes, after any latched modifier — what the remote will actually see.</param>
|
||||
/// <param name="recent">
|
||||
/// The session's recent output. Called before the bytes go, so on Enter it holds the echo of the line and
|
||||
/// not yet the command's output.
|
||||
/// </param>
|
||||
internal void Observe(uint sessionId, string host, ReadOnlySpan<byte> sent, Func<RecentOutput?> recent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(recent);
|
||||
|
||||
if (!lines.TryGetValue(sessionId, out var line))
|
||||
{
|
||||
line = new LineInProgress();
|
||||
lines[sessionId] = line;
|
||||
}
|
||||
|
||||
foreach (var c in Encoding.UTF8.GetString(sent))
|
||||
{
|
||||
if (c is '\r' or '\n')
|
||||
{
|
||||
Commit(line, host, recent);
|
||||
line.Reset();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.Started)
|
||||
{
|
||||
line.Begin(recent());
|
||||
}
|
||||
|
||||
switch (c)
|
||||
{
|
||||
case '\x7f' or '\b':
|
||||
if (line.Typed.Length > 0)
|
||||
{
|
||||
line.Typed.Length--;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case '\x03' or '\x15':
|
||||
// Ctrl-C abandons the line, Ctrl-U empties it; either way the next key starts afresh.
|
||||
line.Reset();
|
||||
break;
|
||||
|
||||
case '\x1b':
|
||||
// A cursor key, Alt, a function key: the rest of these bytes are the sequence, and what
|
||||
// the line now holds is the shell's business. The prompt route can still read it back.
|
||||
line.Tainted = true;
|
||||
return;
|
||||
|
||||
case < ' ':
|
||||
// Tab, Ctrl-R, Ctrl-W and the rest: the line changed in a way not followed here.
|
||||
line.Tainted = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
line.Typed.Append(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notes that text is about to be typed into a session by something other than its keys — a history
|
||||
/// entry or a snippet — so that the Enter after it can still be read back.
|
||||
/// </summary>
|
||||
internal void NoteInserted(uint sessionId, Func<RecentOutput?> recent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(recent);
|
||||
|
||||
if (!lines.TryGetValue(sessionId, out var line))
|
||||
{
|
||||
line = new LineInProgress();
|
||||
lines[sessionId] = line;
|
||||
}
|
||||
|
||||
if (!line.Started)
|
||||
{
|
||||
line.Begin(recent());
|
||||
}
|
||||
|
||||
// Not typed key by key, so only the prompt route may keep it.
|
||||
line.Tainted = true;
|
||||
}
|
||||
|
||||
/// <summary>Drops what was being typed into a session that has gone.</summary>
|
||||
internal void Forget(uint sessionId) => lines.Remove(sessionId);
|
||||
|
||||
/// <summary>Forgets everything, for LOCK and for the tab's own CLEAR.</summary>
|
||||
internal void Clear()
|
||||
{
|
||||
Entries.Clear();
|
||||
lines.Clear();
|
||||
}
|
||||
|
||||
private void Commit(LineInProgress line, string host, Func<RecentOutput?> recent)
|
||||
{
|
||||
if (!line.Started || recent() is not { AlternateScreen: false } output)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var shown = EchoedLine.LastLine(output.Tail);
|
||||
var typed = line.Typed.ToString().Trim();
|
||||
string? command = null;
|
||||
|
||||
if (line.Prompt is { Length: > 0 } prompt
|
||||
&& shown.StartsWith(prompt, StringComparison.Ordinal)
|
||||
&& shown[prompt.Length..].Trim() is { Length: > 0 } afterPrompt)
|
||||
{
|
||||
// Typed key by key, the echo has to be exactly what was typed — a prompt that echoes a star per
|
||||
// character is still not a command. Tainted, the echo is the only account of the line there is.
|
||||
command = line.Tainted || string.Equals(afterPrompt, typed, StringComparison.Ordinal) ? afterPrompt : null;
|
||||
}
|
||||
else if (!line.Tainted && typed.Length > 0 && shown.TrimEnd().EndsWith(typed, StringComparison.Ordinal))
|
||||
{
|
||||
command = typed;
|
||||
}
|
||||
|
||||
if (command is null || command.Length > MaximumLength || !command.Any(char.IsLetterOrDigit))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = Entries.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (string.Equals(Entries[i].Command, command, StringComparison.Ordinal))
|
||||
{
|
||||
Entries.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
Entries.Insert(0, new HistoryEntry(command, host));
|
||||
|
||||
while (Entries.Count > Capacity)
|
||||
{
|
||||
Entries.RemoveAt(Entries.Count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class LineInProgress
|
||||
{
|
||||
internal StringBuilder Typed { get; } = new();
|
||||
|
||||
/// <summary>What the cursor's line said before the first key of this line.</summary>
|
||||
internal string? Prompt { get; private set; }
|
||||
|
||||
internal bool Started { get; private set; }
|
||||
|
||||
/// <summary>Whether something not followed here changed the line.</summary>
|
||||
internal bool Tainted { get; set; }
|
||||
|
||||
internal void Begin(RecentOutput? output)
|
||||
{
|
||||
Started = true;
|
||||
Prompt = output is { AlternateScreen: false } ? EchoedLine.LastLine(output.Tail).TrimEnd() : null;
|
||||
}
|
||||
|
||||
internal void Reset()
|
||||
{
|
||||
Typed.Clear();
|
||||
Prompt = null;
|
||||
Started = false;
|
||||
Tainted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ internal sealed class AvaloniaTerminalAssetProvider : ITerminalAssetProvider
|
||||
("/vendor/xterm.css", "vendor/xterm.css", "text/css; charset=utf-8"),
|
||||
("/vendor/addon-fit.js", "vendor/addon-fit.js", "text/javascript; charset=utf-8"),
|
||||
("/vendor/addon-webgl.js", "vendor/addon-webgl.js", "text/javascript; charset=utf-8"),
|
||||
("/vendor/addon-unicode11.js", "vendor/addon-unicode11.js", "text/javascript; charset=utf-8"),
|
||||
];
|
||||
|
||||
private readonly Dictionary<string, TerminalAsset> assets = new(StringComparer.Ordinal);
|
||||
|
||||
@@ -332,6 +332,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private readonly ClientSettingsStore settings;
|
||||
|
||||
/// <summary>
|
||||
/// The phone's keys for a terminal: the bar, the quick-access keyboard, and how both are arranged.
|
||||
/// </summary>
|
||||
internal TerminalKeyboardViewModel Keyboard { get; }
|
||||
|
||||
/// <remarks>
|
||||
/// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the
|
||||
/// keychain screen does. Null on a machine with no clipboard, which is a state that reports itself
|
||||
@@ -641,6 +646,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
this.workspace.RendererReattached += OnRendererReattached;
|
||||
|
||||
settings = new ClientSettingsStore(paths);
|
||||
Keyboard = CreateKeyboard();
|
||||
|
||||
updateScreen = CreateUpdateScreen(updates);
|
||||
|
||||
@@ -651,6 +657,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
StartSessionShellTracking();
|
||||
}
|
||||
|
||||
/// <summary>The phone's keys, wired to this shell's sessions.</summary>
|
||||
/// <remarks>
|
||||
/// Built on every head because it is only state and a settings read, and the desktop simply never draws
|
||||
/// it; a head-specific view model would be one more thing this constructor had to be told about per
|
||||
/// platform. The customise sheet takes the screen the way the connect sheet does, so it moves the same
|
||||
/// flag — see <see cref="IsTerminalShowing"/>.
|
||||
/// </remarks>
|
||||
private TerminalKeyboardViewModel CreateKeyboard()
|
||||
{
|
||||
var keyboard = new TerminalKeyboardViewModel(
|
||||
settings,
|
||||
CurrentTypingTarget,
|
||||
SendTerminalInputAsync,
|
||||
workspace.GetRecentOutput,
|
||||
workspace.PasteAsync,
|
||||
PasteIntoTerminalAsync);
|
||||
|
||||
keyboard.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName is nameof(TerminalKeyboardViewModel.IsCustomizing))
|
||||
{
|
||||
RaiseTerminalState();
|
||||
}
|
||||
};
|
||||
|
||||
return keyboard;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes this machine's own preferences off disk, before anything can read them.
|
||||
/// </summary>
|
||||
@@ -1431,17 +1465,28 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// screen missing here is one whose arrival darkens the tab that led to it and brings the shell's own
|
||||
/// header back over a screen that already has one.
|
||||
///
|
||||
/// <b>The keychain joined it too, and that is why the bar went from four entries to three.</b> Unlike
|
||||
/// the two above, that one is a move rather than an addition: a phone's bottom bar is for the places a
|
||||
/// session moves between, and the keychain is not one of those — hosts and connections are what
|
||||
/// somebody opens the application to do, and keys, credentials and tags are what they go and manage
|
||||
/// occasionally. The desktop keeps its rail entry, having room for nine, so this is the second thing
|
||||
/// the two heads deliberately arrange differently, after the hub itself.
|
||||
/// ◆ <b>The keychain and snippets left it again, for <see cref="IsVaultSurface"/>.</b> They had joined
|
||||
/// it on the argument that a bottom bar is for the places a session moves between, and keys and saved
|
||||
/// commands are managed occasionally. The 2026-10-04 redesign answers that differently: the first tab is
|
||||
/// the open vault rather than its host list, and the vault's three kinds of content — hosts, keychain,
|
||||
/// snippets — are a switch at the top of it. What stays here is what is about this phone and this
|
||||
/// account rather than about a vault's contents: the hub, logs, files, preferences and the vaults list.
|
||||
/// </remarks>
|
||||
internal bool IsMoreSurface =>
|
||||
IsShowingPages && Screen is ShellScreen.More or ShellScreen.Snippets or ShellScreen.Logs
|
||||
IsShowingPages && Screen is ShellScreen.More or ShellScreen.Logs
|
||||
or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences
|
||||
or ShellScreen.Vaults or ShellScreen.Keychain;
|
||||
or ShellScreen.Vaults;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the phone's VAULTS tab should light: one of the open vault's three kinds of content is up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The phone's counterpart of <see cref="IsMoreSurface"/>, and drawn by the Android head alone — the
|
||||
/// desktop's rail has an entry for each of the three. It also decides which pages wear the vault's own
|
||||
/// header and the HOSTS · KEYCHAIN · SNIPPETS switch under it; see PhoneShell.RefreshChrome.
|
||||
/// </remarks>
|
||||
internal bool IsVaultSurface =>
|
||||
IsShowingPages && Screen is ShellScreen.Hosts or ShellScreen.Keychain or ShellScreen.Snippets;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the terminal's WebView may be on screen at this instant.
|
||||
@@ -1500,6 +1545,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
IsTerminalSurface
|
||||
&& !IsConnectSheetOpen
|
||||
&& !IsForwardsSheetOpen
|
||||
&& !Keyboard.IsCustomizing
|
||||
&& !IsHostKeyDecisionShowing
|
||||
&& SelectedTab is { HasSession: true };
|
||||
|
||||
@@ -2425,6 +2471,102 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
internal bool HasSearchResults => SearchResults.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// The machine the palette's query names, when what was typed reads as <c>user@host[:port]</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The palette's answer to a machine that is not in the keychain.</b> Every other row it offers is a
|
||||
/// saved host, and a box somebody has just been given the address of had no way in on this head short of
|
||||
/// saving it first — the phone has had a manual box on its terminal screen all along. The query already
|
||||
/// is a box, and <c>user@host</c> is the grammar people type into one, so the palette reads it rather
|
||||
/// than growing a second field.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Read with <see cref="VaultViewModel.TryParseManualTarget"/>, which is the phone's box's own parser, so
|
||||
/// the two heads accept exactly the same addresses and refuse the rest with the same sentences.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only a query with an <c>@</c> in it is read at all. Without one, everything typed is a search, and a
|
||||
/// row offering to dial whatever three letters were typed so far would be noise on every keystroke.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasAdHocTarget))]
|
||||
[NotifyPropertyChangedFor(nameof(AdHocAddress))]
|
||||
[NotifyPropertyChangedFor(nameof(HasNothingToOffer))]
|
||||
private VaultViewModel.ManualEndpoint? adHocTarget;
|
||||
|
||||
/// <summary>Whether the palette is offering to connect to a typed address.</summary>
|
||||
internal bool HasAdHocTarget => AdHocTarget is not null;
|
||||
|
||||
/// <summary>The typed address as it will be dialled, port and all.</summary>
|
||||
internal string AdHocAddress => AdHocTarget?.Address ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Why a query with an <c>@</c> in it is not an address yet, or empty.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Said under the box rather than swallowed, because a query that looks like an address and produces no
|
||||
/// row to connect to is otherwise a palette that silently ignored the one thing it was asked. It doubles
|
||||
/// as the password step's own refusal line.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasAdHocRefusal))]
|
||||
[NotifyPropertyChangedFor(nameof(HasNothingToOffer))]
|
||||
private string adHocRefusal = string.Empty;
|
||||
|
||||
/// <summary>Whether there is a refusal to show under the box.</summary>
|
||||
internal bool HasAdHocRefusal => AdHocRefusal.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the highlight is on the typed address rather than on a saved host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A flag beside <see cref="SelectedSearchResult"/> rather than a row in <see cref="SearchResults"/>,
|
||||
/// because that list is the vault's own host rows and every reader of it — the ranking, the clicks, the
|
||||
/// tests — is entitled to find a host in each. At most one of the two is ever set; see
|
||||
/// <see cref="OnIsAdHocSelectedChanged"/> and <see cref="OnSelectedSearchResultChanged"/>.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isAdHocSelected;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the palette has moved on from the query to asking for the typed address's password.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A typed password and nothing else</b>, which is the manual path's own rule and for its reason: a
|
||||
/// key is the reason to save the machine as a host, and offering the keychain's keys here would be a
|
||||
/// second binding resolution beside the one saved hosts already go through.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A second step inside the palette rather than a dialog, so the whole thing stays on the keyboard:
|
||||
/// type the address, Enter, type the password, Enter. Escape goes back to the query rather than closing,
|
||||
/// because a mistyped address is the likeliest reason to want out of this step.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsAskingForSearchQuery))]
|
||||
private bool isAskingForAdHocPassword;
|
||||
|
||||
/// <summary>Whether the palette is showing its query box and results, rather than the password step.</summary>
|
||||
internal bool IsAskingForSearchQuery => !IsAskingForAdHocPassword;
|
||||
|
||||
/// <summary>The password typed for <see cref="AdHocTarget"/>.</summary>
|
||||
/// <remarks>
|
||||
/// Cleared whenever the palette closes or goes back to the query, on the same terms the phone's manual box
|
||||
/// clears its own: it is a secret nobody asked to keep, and the next address typed must not arrive with the
|
||||
/// last one's password already in the box.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string adHocPassword = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the palette has nothing to offer at all — no host, no typed address and no reason why not.
|
||||
/// </summary>
|
||||
internal bool HasNothingToOffer => !HasSearchResults && !HasAdHocTarget && !HasAdHocRefusal;
|
||||
|
||||
/// <summary>Opens the palette, or closes it if it is already open.</summary>
|
||||
[RelayCommand]
|
||||
private void ToggleSearch()
|
||||
@@ -2450,11 +2592,174 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private void CloseSearch()
|
||||
{
|
||||
IsSearching = false;
|
||||
IsAskingForAdHocPassword = false;
|
||||
AdHocPassword = string.Empty;
|
||||
SearchText = string.Empty;
|
||||
SearchResults.Clear();
|
||||
SelectedSearchResult = null;
|
||||
IsAdHocSelected = false;
|
||||
AdHocTarget = null;
|
||||
AdHocRefusal = string.Empty;
|
||||
|
||||
OnPropertyChanged(nameof(HasSearchResults));
|
||||
OnPropertyChanged(nameof(HasNothingToOffer));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves the palette's highlight by one row, the typed address first when there is one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Clamped rather than wrapped: a list that jumps from the last row to the first loses people. Here
|
||||
/// rather than in the palette's code-behind because the typed address is a row that is not in
|
||||
/// <see cref="SearchResults"/>, and which of the two highlights to set is the view model's to know.
|
||||
/// </remarks>
|
||||
internal void MoveSearchSelection(int delta)
|
||||
{
|
||||
var offset = HasAdHocTarget ? 1 : 0;
|
||||
var count = offset + SearchResults.Count;
|
||||
|
||||
if (count == 0 || IsAskingForAdHocPassword)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var current = IsAdHocSelected
|
||||
? 0
|
||||
: SelectedSearchResult is { } selected
|
||||
? offset + SearchResults.IndexOf(selected)
|
||||
: -1;
|
||||
|
||||
var next = Math.Clamp(current + delta, 0, count - 1);
|
||||
|
||||
if (offset == 1 && next == 0)
|
||||
{
|
||||
IsAdHocSelected = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedSearchResult = SearchResults[next - offset];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the typed address, for a click on its row: the pointer's version of Enter.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void ChooseAdHocTarget()
|
||||
{
|
||||
if (!HasAdHocTarget)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsAdHocSelected = true;
|
||||
AskForAdHocPassword();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts the palette back on its query, leaving what was typed there and dropping the password.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void BackToSearchQuery()
|
||||
{
|
||||
IsAskingForAdHocPassword = false;
|
||||
AdHocPassword = string.Empty;
|
||||
AdHocRefusal = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the typed address with the typed password, saving neither.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Through <see cref="VaultViewModel.ConnectToEndpointAsync"/>, which is the phone's manual box's own
|
||||
/// path, so a machine reached from here gets the same tab, the same host-key question and the same
|
||||
/// connection-log entry. What is written, if the user answers the host-key question, is a pin — the trust
|
||||
/// decision belongs to the endpoint rather than to a keychain item, and a machine reached this way is
|
||||
/// exactly the one whose key nobody has seen before.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The palette closes before the dial rather than after it, as it does for a saved host: the tab the
|
||||
/// connection opens is where its progress and its failure are drawn, and a palette left up for the length
|
||||
/// of a handshake would sit over the very card that reports it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Concurrent executions allowed, for the reason <c>VaultViewModel.ConnectAsync</c> gives: this command is
|
||||
/// still running for as long as the handshake takes, and without the flag a second address typed into the
|
||||
/// palette meanwhile would be refused as a silent no-op.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task ConnectToAdHocTargetAsync()
|
||||
{
|
||||
if (Vault is not { } vault || AdHocTarget is not { } endpoint)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (AdHocPassword.Length == 0)
|
||||
{
|
||||
AdHocRefusal = $"Type the password for {endpoint.Address}.";
|
||||
return;
|
||||
}
|
||||
|
||||
var password = AdHocPassword;
|
||||
|
||||
CloseSearch();
|
||||
|
||||
// No token, for the reason VaultViewModel.ConnectAsync gives at length: a connection is abandoned by
|
||||
// closing its tab, not by anything here.
|
||||
await vault.ConnectToEndpointAsync(endpoint, password, CancellationToken.None).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Moves the palette on to asking for the typed address's password.</summary>
|
||||
private void AskForAdHocPassword()
|
||||
{
|
||||
AdHocPassword = string.Empty;
|
||||
AdHocRefusal = string.Empty;
|
||||
IsAskingForAdHocPassword = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the query as an address, if it has an <c>@</c> in it, and says why not if it cannot be one.
|
||||
/// </summary>
|
||||
private void RefreshAdHocTarget(string query)
|
||||
{
|
||||
if (!query.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
AdHocTarget = null;
|
||||
AdHocRefusal = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
if (VaultViewModel.TryParseManualTarget(query, out var endpoint, out var reason))
|
||||
{
|
||||
AdHocTarget = endpoint;
|
||||
AdHocRefusal = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
AdHocTarget = null;
|
||||
AdHocRefusal = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>Clears the other half of the highlight, so at most one row is ever lit.</remarks>
|
||||
partial void OnIsAdHocSelectedChanged(bool value)
|
||||
{
|
||||
if (value)
|
||||
{
|
||||
SelectedSearchResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="OnIsAdHocSelectedChanged" />
|
||||
partial void OnSelectedSearchResultChanged(HostRowViewModel? value)
|
||||
{
|
||||
if (value is not null)
|
||||
{
|
||||
IsAdHocSelected = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2469,6 +2774,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[RelayCommand]
|
||||
private async Task ConnectToSearchResultAsync()
|
||||
{
|
||||
// The typed address has no keychain item behind it, so nothing can be resolved for it: the password
|
||||
// it needs has to be asked for first. See IsAskingForAdHocPassword.
|
||||
if (IsAdHocSelected && HasAdHocTarget)
|
||||
{
|
||||
AskForAdHocPassword();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Vault is not { } vault || SelectedSearchResult is not { } row)
|
||||
{
|
||||
return;
|
||||
@@ -2506,6 +2819,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
if (Vault is not { } vault)
|
||||
{
|
||||
SelectedSearchResult = null;
|
||||
IsAdHocSelected = false;
|
||||
AdHocTarget = null;
|
||||
AdHocRefusal = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2525,9 +2841,29 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
SearchResults.Add(host);
|
||||
}
|
||||
|
||||
SelectedSearchResult = SearchResults.FirstOrDefault();
|
||||
RefreshAdHocTarget(query);
|
||||
|
||||
// The typed address is highlighted when there is one, unless a saved host dials exactly that address:
|
||||
// that host is the same machine with whatever the keychain knows about it — a key, a saved password —
|
||||
// and Enter should reach it the way it was set up to be reached. The typed row is still one arrow
|
||||
// away for somebody who wants the password prompt instead.
|
||||
var saved = AdHocTarget is { } typed
|
||||
? SearchResults.FirstOrDefault(host =>
|
||||
string.Equals(host.Address, typed.Address, StringComparison.OrdinalIgnoreCase))
|
||||
: null;
|
||||
|
||||
if (HasAdHocTarget && saved is null)
|
||||
{
|
||||
IsAdHocSelected = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
IsAdHocSelected = false;
|
||||
SelectedSearchResult = saved ?? SearchResults.FirstOrDefault();
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasSearchResults));
|
||||
OnPropertyChanged(nameof(HasNothingToOffer));
|
||||
}
|
||||
|
||||
/// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>
|
||||
@@ -3181,6 +3517,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
if (token is null
|
||||
|| !Uri.TryCreate(vault.Session.Profile.ServerUrl, UriKind.Absolute, out var server))
|
||||
{
|
||||
vault.OfflineReason = "This machine has no remembered sign-in. Press SIGN IN in "
|
||||
+ "Preferences to start syncing.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3188,6 +3527,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
connection = resumed;
|
||||
rememberedToken = token;
|
||||
vault.OfflineReason = null;
|
||||
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
RaiseSyncState();
|
||||
@@ -3198,15 +3538,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
return resumed;
|
||||
}
|
||||
catch (OidcException exception)
|
||||
catch (OidcException exception) when (IsRefusedGrant(exception))
|
||||
{
|
||||
// The provider answered and said no: the session was revoked, or the token was rotated and
|
||||
// this machine kept the old one. Retrying costs a round trip a minute and can only ever get
|
||||
// the same answer, so the token goes and the user is told the one thing that fixes it.
|
||||
await ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Announce($"Your sign-in has expired, so this machine is offline: {exception.Message} "
|
||||
+ "Sign in again from Preferences to start syncing.");
|
||||
var message = $"Your sign-in has expired, so this machine is offline: {exception.Message} "
|
||||
+ "Sign in again from Preferences to start syncing.";
|
||||
|
||||
vault.OfflineReason = message;
|
||||
Announce(message);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -3214,11 +3557,29 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
// Everything else is a machine with no network, a server that is down, or a vault that was
|
||||
// locked mid-attempt — all of which are ordinary and all of which resolve themselves. The
|
||||
// titlebar already says OFFLINE; a socket error once a minute would say nothing more.
|
||||
// titlebar already says OFFLINE; a socket error once a minute would say nothing more. That
|
||||
// includes an OidcException with no refusal in it: discovery answering 502, or a token endpoint
|
||||
// whose body is a proxy's error page, is the identity provider restarting. Forgetting the token
|
||||
// for that used to strand a machine offline for good once the provider came back.
|
||||
vault.OfflineReason = $"The server could not be reached: {exception.Message} Changes are "
|
||||
+ "queued and will be sent once it can.";
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a failed resume was the provider refusing the token, rather than failing to answer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>invalid_grant</c> is what RFC 6749 §5.2 has a token endpoint say about a refresh token that is
|
||||
/// expired, revoked or already rotated — the one answer that a retry cannot change. Every other
|
||||
/// <see cref="OidcException"/> on this path is discovery or the token endpoint failing, which is a
|
||||
/// provider that is down and will be back.
|
||||
/// </remarks>
|
||||
private static bool IsRefusedGrant(OidcException exception) =>
|
||||
string.Equals(exception.ErrorCode, "invalid_grant", StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Writes the connection's current refresh token into the vault, if it has changed.
|
||||
/// </summary>
|
||||
@@ -3815,6 +4176,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
? null
|
||||
: new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync);
|
||||
|
||||
// The commands typed on this phone go with the keychain, for the reason the recent connections below
|
||||
// do: they are a record of where somebody works and what they ran there, and LOCK is the promise
|
||||
// that nothing like that is left on screen. See TypedCommandHistory.
|
||||
if (newValue is null)
|
||||
{
|
||||
Keyboard.ClearHistory();
|
||||
}
|
||||
|
||||
LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections);
|
||||
|
||||
// Emptied with the vault it was read out of. These rows are decrypted log entries — a host's name
|
||||
@@ -4230,6 +4599,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
open.HostLabel, open.Address, open.StartedAt, deviceName)),
|
||||
];
|
||||
|
||||
/// <summary>The session the phone's keys type into: the selected tab, once it has a shell.</summary>
|
||||
private TypingTarget? CurrentTypingTarget() =>
|
||||
SelectedTab is { HasSession: true } tab ? new TypingTarget(tab.SessionId, tab.Label) : null;
|
||||
|
||||
private InsertTarget CurrentInsertTarget() =>
|
||||
SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None;
|
||||
|
||||
@@ -5507,6 +5880,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsMoreShowing));
|
||||
OnPropertyChanged(nameof(IsBucketsShowing));
|
||||
OnPropertyChanged(nameof(IsMoreSurface));
|
||||
OnPropertyChanged(nameof(IsVaultSurface));
|
||||
|
||||
// The rail's switcher and its mode-dependent first row read the same three flags above, so
|
||||
// whatever moved them has to repaint these too — see the remarks on each.
|
||||
|
||||
@@ -0,0 +1,825 @@
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Shell.Keyboard;
|
||||
using DodoSSH.Client.Terminal;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>The quick-access keyboard's tabs.</summary>
|
||||
internal enum QuickAccessTab
|
||||
{
|
||||
Keys,
|
||||
Snippets,
|
||||
History,
|
||||
Appearance,
|
||||
}
|
||||
|
||||
/// <summary>Where typed bytes are going: a session that is open, and what its tab is called.</summary>
|
||||
internal sealed record TypingTarget(uint SessionId, string Host);
|
||||
|
||||
/// <summary>
|
||||
/// The phone's keys for a terminal: the bar above the software keyboard, the quick-access keyboard that can
|
||||
/// take its place, the customise screen, and the cursor gesture's switch.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Here rather than in the Android head, because almost none of it is about Android.</b> Which keys exist,
|
||||
/// how they are arranged, what a latched Ctrl does to the next one and what the history keeps are questions
|
||||
/// with right answers that can be tested on any machine, and the head is a project no test can reference.
|
||||
/// What stays in the head is what only a phone has: a software keyboard to hide and show, a touch stream to
|
||||
/// read a long press from, and the screen width that decides between a bottom panel and a side panel.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every key press, from the bar, the quick-access keyboard or the software keyboard itself, comes through
|
||||
/// <see cref="Send"/>.</b> That is what makes the latches mean one thing — Ctrl pressed on the bar applies to
|
||||
/// a letter typed on Gboard — and what lets the history see the whole of a line rather than the part typed on
|
||||
/// one of the three.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Customising saves as it goes.</b> There is no draft and no SAVE: every change on the customise screen is
|
||||
/// a change to the bar that is already drawn under the sheet's scrim, and to the settings file, the moment it
|
||||
/// is made. A keyboard is not a document, and "did that save" is not a question it should raise.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class TerminalKeyboardViewModel : ObservableObject
|
||||
{
|
||||
private readonly ClientSettingsStore settings;
|
||||
private readonly Func<TypingTarget?> target;
|
||||
private readonly Func<uint, ReadOnlyMemory<byte>, ValueTask> send;
|
||||
private readonly Func<uint, RecentOutput?> recent;
|
||||
private readonly Func<uint, string, bool, CancellationToken, Task<bool>> insert;
|
||||
private readonly Func<uint, Task> paste;
|
||||
|
||||
private readonly List<string> bar = [];
|
||||
private readonly List<string> groupOrder = [];
|
||||
private readonly HashSet<string> hiddenGroups = new(StringComparer.Ordinal);
|
||||
private readonly List<CustomTerminalKey> customKeys = [];
|
||||
|
||||
/// <summary>One button model per key id, shared wherever the key is drawn, so a latch lights all of them.</summary>
|
||||
private readonly Dictionary<string, KeyButtonViewModel> buttons = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly TypedCommandHistory history = new();
|
||||
|
||||
/// <param name="settings">Where the arrangement is kept.</param>
|
||||
/// <param name="target">The session keys go to right now, or null when no tab has one.</param>
|
||||
/// <param name="send">Writes bytes to a session.</param>
|
||||
/// <param name="recent">A session's recent output, for the history's echo test.</param>
|
||||
/// <param name="insert">Types text into a session as the snippets screen does — the workspace's paste.</param>
|
||||
/// <param name="paste">Pastes the clipboard into a session.</param>
|
||||
internal TerminalKeyboardViewModel(
|
||||
ClientSettingsStore settings,
|
||||
Func<TypingTarget?> target,
|
||||
Func<uint, ReadOnlyMemory<byte>, ValueTask> send,
|
||||
Func<uint, RecentOutput?> recent,
|
||||
Func<uint, string, bool, CancellationToken, Task<bool>> insert,
|
||||
Func<uint, Task> paste)
|
||||
{
|
||||
this.settings = settings;
|
||||
this.target = target;
|
||||
this.send = send;
|
||||
this.recent = recent;
|
||||
this.insert = insert;
|
||||
this.paste = paste;
|
||||
|
||||
var stored = settings.Read().TerminalKeyboard ?? new TerminalKeyboardSettings();
|
||||
|
||||
Load(stored);
|
||||
|
||||
selectedTab = Enum.TryParse<QuickAccessTab>(stored.QuickAccessTab, out var tab) ? tab : QuickAccessTab.Keys;
|
||||
isSidePanelOpen = stored.SidePanelOpen;
|
||||
cursorGestures = stored.CursorGestures;
|
||||
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
/// <summary>The bar, as rows of at most <see cref="TerminalKeyCatalog.BarRowWidth"/>.</summary>
|
||||
internal ObservableCollection<KeyRowViewModel> BarRows { get; } = [];
|
||||
|
||||
/// <summary>The KEYS tab: every group that is switched on, in order.</summary>
|
||||
internal ObservableCollection<KeyGroupViewModel> Groups { get; } = [];
|
||||
|
||||
/// <summary>The HISTORY tab.</summary>
|
||||
internal ObservableCollection<HistoryEntry> History => history.Entries;
|
||||
|
||||
/// <summary>The customise screen's bar list.</summary>
|
||||
internal ObservableCollection<BarEditorRowViewModel> BarEditor { get; } = [];
|
||||
|
||||
/// <summary>The customise screen's group list.</summary>
|
||||
internal ObservableCollection<GroupEditorRowViewModel> GroupEditor { get; } = [];
|
||||
|
||||
/// <summary>The customise screen's list of keys somebody added.</summary>
|
||||
internal ObservableCollection<CustomKeyRowViewModel> CustomKeyEditor { get; } = [];
|
||||
|
||||
/// <summary>Whether the bar has any keys on it; an empty bar is drawn as nothing at all.</summary>
|
||||
internal bool HasBar => bar.Count > 0;
|
||||
|
||||
/// <summary>Whether the HISTORY tab has anything to show.</summary>
|
||||
internal bool HasHistory => History.Count > 0;
|
||||
|
||||
/// <summary>Whether the bar can take another key.</summary>
|
||||
internal bool CanAddToBar => bar.Count < TerminalKeyCatalog.MaximumBarKeys;
|
||||
|
||||
/// <summary>The customise screen's count line.</summary>
|
||||
internal string BarCountText => $"{bar.Count} of {TerminalKeyCatalog.MaximumBarKeys}";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isControlLatched;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isAltLatched;
|
||||
|
||||
/// <summary>Whether the quick-access keyboard is up in place of the software keyboard.</summary>
|
||||
[ObservableProperty]
|
||||
private bool isQuickAccessOpen;
|
||||
|
||||
/// <summary>Whether a wide screen draws the quick-access keyboard beside the terminal.</summary>
|
||||
[ObservableProperty]
|
||||
private bool isSidePanelOpen;
|
||||
|
||||
/// <summary>Whether the customise screen is up.</summary>
|
||||
[ObservableProperty]
|
||||
private bool isCustomizing;
|
||||
|
||||
/// <summary>Whether long-press-and-drag on the terminal moves the cursor.</summary>
|
||||
[ObservableProperty]
|
||||
private bool cursorGestures;
|
||||
|
||||
[ObservableProperty]
|
||||
private QuickAccessTab selectedTab;
|
||||
|
||||
[ObservableProperty]
|
||||
private string newKeyLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string newKeySequence = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string customizeStatus = string.Empty;
|
||||
|
||||
internal bool IsKeysTab => SelectedTab is QuickAccessTab.Keys;
|
||||
|
||||
internal bool IsSnippetsTab => SelectedTab is QuickAccessTab.Snippets;
|
||||
|
||||
internal bool IsHistoryTab => SelectedTab is QuickAccessTab.History;
|
||||
|
||||
internal bool IsAppearanceTab => SelectedTab is QuickAccessTab.Appearance;
|
||||
|
||||
// ── typing ───────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Sends one key's worth of bytes to the selected session, through the latches and past the history.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Both latches release after any key, including one they did not change. The person pressed Ctrl and
|
||||
/// then a key; that was the key Ctrl was for, whatever it turned out to be.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Discarded rather than awaited: this is a keystroke, the workspace ignores a session that has gone,
|
||||
/// and awaiting would serialise every key behind a slow link.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void Send(byte[] bytes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bytes);
|
||||
|
||||
if (bytes.Length == 0 || target() is not { } to)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = TerminalModifiers.Apply(bytes, IsControlLatched, IsAltLatched);
|
||||
|
||||
IsControlLatched = false;
|
||||
IsAltLatched = false;
|
||||
|
||||
history.Observe(to.SessionId, to.Host, payload, () => recent(to.SessionId));
|
||||
OnPropertyChanged(nameof(HasHistory));
|
||||
|
||||
_ = send(to.SessionId, payload).AsTask();
|
||||
}
|
||||
|
||||
/// <summary>Sends the cursor gesture's arrows, which no latch applies to.</summary>
|
||||
/// <remarks>
|
||||
/// A gesture is not "the next key" in the sense a latch means — nobody pressing Ctrl on the bar and then
|
||||
/// dragging the terminal means Ctrl+arrow by it, and spending the latch here would surprise the letter
|
||||
/// typed afterwards. The history is still told, because the line has changed under it.
|
||||
/// </remarks>
|
||||
internal void SendArrow(ArrowDirection direction, int count)
|
||||
{
|
||||
if (count <= 0 || target() is not { } to)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string? id = direction switch
|
||||
{
|
||||
ArrowDirection.Left => "left",
|
||||
ArrowDirection.Down => "down",
|
||||
ArrowDirection.Up => "up",
|
||||
ArrowDirection.Right => "right",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (id is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var one = TerminalKeyCatalog.ById[id].Bytes;
|
||||
var payload = new byte[one.Length * count];
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
one.CopyTo(payload, i * one.Length);
|
||||
}
|
||||
|
||||
history.Observe(to.SessionId, to.Host, one, () => recent(to.SessionId));
|
||||
|
||||
_ = send(to.SessionId, payload).AsTask();
|
||||
}
|
||||
|
||||
/// <summary>What a key does when pressed, wherever it is drawn.</summary>
|
||||
internal void Press(TerminalKey key)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
switch (key.Kind)
|
||||
{
|
||||
case TerminalKeyKind.Control:
|
||||
IsControlLatched = !IsControlLatched;
|
||||
break;
|
||||
|
||||
case TerminalKeyKind.Alt:
|
||||
IsAltLatched = !IsAltLatched;
|
||||
break;
|
||||
|
||||
case TerminalKeyKind.Paste:
|
||||
if (target() is { } to)
|
||||
{
|
||||
history.NoteInserted(to.SessionId, () => recent(to.SessionId));
|
||||
_ = paste(to.SessionId);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
Send(key.Bytes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Notes that a session's tab is gone, so a half-typed line is not kept for it.</summary>
|
||||
internal void ForgetSession(uint sessionId) => history.Forget(sessionId);
|
||||
|
||||
/// <summary>Empties the history, which LOCK does as well as the tab's own CLEAR.</summary>
|
||||
[RelayCommand]
|
||||
internal void ClearHistory()
|
||||
{
|
||||
history.Clear();
|
||||
OnPropertyChanged(nameof(HasHistory));
|
||||
}
|
||||
|
||||
/// <summary>Types a history entry at the prompt, without pressing Enter.</summary>
|
||||
/// <remarks>
|
||||
/// The snippets screen's own route — typed rather than pasted for one line, and never with an Enter —
|
||||
/// for the reason that screen gives: the remote may not be at a prompt any more, and running a command
|
||||
/// out of a list is a thing to ask for rather than have happen.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task InsertHistoryAsync(HistoryEntry? entry)
|
||||
{
|
||||
if (entry is null || target() is not { } to)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
history.NoteInserted(to.SessionId, () => recent(to.SessionId));
|
||||
|
||||
await insert(to.SessionId, entry.Command, false, CancellationToken.None).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// ── the panels ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Raises or lowers the quick-access keyboard.</summary>
|
||||
[RelayCommand]
|
||||
private void ToggleQuickAccess() => IsQuickAccessOpen = !IsQuickAccessOpen;
|
||||
|
||||
/// <summary>Lowers the quick-access keyboard, for the software keyboard coming back.</summary>
|
||||
internal void CloseQuickAccess() => IsQuickAccessOpen = false;
|
||||
|
||||
/// <summary>Shows or hides the wide screen's side panel, and remembers which.</summary>
|
||||
[RelayCommand]
|
||||
private void ToggleSidePanel()
|
||||
{
|
||||
IsSidePanelOpen = !IsSidePanelOpen;
|
||||
Save();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectTab(QuickAccessTab tab)
|
||||
{
|
||||
SelectedTab = tab;
|
||||
Save();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleCursorGestures()
|
||||
{
|
||||
CursorGestures = !CursorGestures;
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedTabChanged(QuickAccessTab value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsKeysTab));
|
||||
OnPropertyChanged(nameof(IsSnippetsTab));
|
||||
OnPropertyChanged(nameof(IsHistoryTab));
|
||||
OnPropertyChanged(nameof(IsAppearanceTab));
|
||||
}
|
||||
|
||||
partial void OnIsControlLatchedChanged(bool value) => RefreshLatches();
|
||||
|
||||
partial void OnIsAltLatchedChanged(bool value) => RefreshLatches();
|
||||
|
||||
private void RefreshLatches()
|
||||
{
|
||||
foreach (var button in buttons.Values)
|
||||
{
|
||||
button.IsLatched = button.Key.Kind switch
|
||||
{
|
||||
TerminalKeyKind.Control => IsControlLatched,
|
||||
TerminalKeyKind.Alt => IsAltLatched,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── customising ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenCustomize()
|
||||
{
|
||||
CustomizeStatus = string.Empty;
|
||||
IsQuickAccessOpen = false;
|
||||
IsCustomizing = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseCustomize()
|
||||
{
|
||||
NewKeyLabel = string.Empty;
|
||||
NewKeySequence = string.Empty;
|
||||
CustomizeStatus = string.Empty;
|
||||
IsCustomizing = false;
|
||||
}
|
||||
|
||||
/// <summary>Puts the bar and the groups back as shipped. Keys somebody added are kept, off the bar.</summary>
|
||||
/// <remarks>
|
||||
/// A custom key is a thing somebody wrote, and a reset that deleted it would be destroying work to tidy a
|
||||
/// layout. It stays on the customise screen and in its own group, one tap from the bar again.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void ResetLayout()
|
||||
{
|
||||
bar.Clear();
|
||||
bar.AddRange(TerminalKeyCatalog.DefaultBar);
|
||||
groupOrder.Clear();
|
||||
hiddenGroups.Clear();
|
||||
NormaliseGroupOrder();
|
||||
|
||||
CustomizeStatus = "The bar and the groups are back as they shipped.";
|
||||
Commit();
|
||||
}
|
||||
|
||||
/// <summary>Adds the key the two boxes describe, to its own group and to the bar if there is room.</summary>
|
||||
[RelayCommand]
|
||||
private void AddCustomKey()
|
||||
{
|
||||
var label = NewKeyLabel.Trim();
|
||||
|
||||
if (label.Length is 0 or > 10)
|
||||
{
|
||||
CustomizeStatus = "A key needs a label of one to ten characters.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!KeySequence.TryParse(NewKeySequence, out var bytes) || bytes.Length == 0)
|
||||
{
|
||||
CustomizeStatus = NewKeySequence.Length == 0
|
||||
? "Say what the key sends."
|
||||
: "That sequence ends in a lone backslash or a \\x without two hex digits.";
|
||||
return;
|
||||
}
|
||||
|
||||
var id = TerminalKeyCatalog.CustomPrefix + Guid.CreateVersion7().ToString("N");
|
||||
|
||||
customKeys.Add(new CustomTerminalKey(id, label, NewKeySequence));
|
||||
NormaliseGroupOrder();
|
||||
|
||||
var onBar = CanAddToBar;
|
||||
|
||||
if (onBar)
|
||||
{
|
||||
bar.Add(id);
|
||||
}
|
||||
|
||||
CustomizeStatus = onBar
|
||||
? $"Added '{label}' to the bar."
|
||||
: $"Added '{label}' to YOUR KEYS. The bar is full; take a key off it to make room.";
|
||||
|
||||
NewKeyLabel = string.Empty;
|
||||
NewKeySequence = string.Empty;
|
||||
|
||||
Commit();
|
||||
}
|
||||
|
||||
private void ToggleOnBar(string id)
|
||||
{
|
||||
if (bar.Remove(id))
|
||||
{
|
||||
Commit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CanAddToBar)
|
||||
{
|
||||
CustomizeStatus = $"The bar holds {TerminalKeyCatalog.MaximumBarKeys} keys. Take one off first.";
|
||||
return;
|
||||
}
|
||||
|
||||
bar.Add(id);
|
||||
CustomizeStatus = string.Empty;
|
||||
Commit();
|
||||
}
|
||||
|
||||
private void MoveOnBar(string id, int by)
|
||||
{
|
||||
var from = bar.IndexOf(id);
|
||||
var to = from + by;
|
||||
|
||||
if (from < 0 || to < 0 || to >= bar.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
(bar[from], bar[to]) = (bar[to], bar[from]);
|
||||
Commit();
|
||||
}
|
||||
|
||||
private void MoveGroup(string id, int by)
|
||||
{
|
||||
var from = groupOrder.IndexOf(id);
|
||||
var to = from + by;
|
||||
|
||||
if (from < 0 || to < 0 || to >= groupOrder.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
(groupOrder[from], groupOrder[to]) = (groupOrder[to], groupOrder[from]);
|
||||
Commit();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Saves and redraws the KEYS tab only. The switch that called this is a row of the customise screen's
|
||||
/// own group list, and rebuilding that list would be pulling the control out from under the binding that
|
||||
/// is still busy telling us about it.
|
||||
/// </remarks>
|
||||
private void SetGroupShown(string id, bool shown)
|
||||
{
|
||||
var changed = shown ? hiddenGroups.Remove(id) : hiddenGroups.Add(id);
|
||||
|
||||
if (changed)
|
||||
{
|
||||
Save();
|
||||
RebuildGroups();
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteCustomKey(string id)
|
||||
{
|
||||
customKeys.RemoveAll(k => string.Equals(k.Id, id, StringComparison.Ordinal));
|
||||
bar.Remove(id);
|
||||
buttons.Remove(id);
|
||||
NormaliseGroupOrder();
|
||||
Commit();
|
||||
}
|
||||
|
||||
// ── state ────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private void Load(TerminalKeyboardSettings stored)
|
||||
{
|
||||
foreach (var custom in stored.CustomKeys ?? [])
|
||||
{
|
||||
if (custom is { Id: { } id, Label: { } label, Sequence: { } sequence }
|
||||
&& id.StartsWith(TerminalKeyCatalog.CustomPrefix, StringComparison.Ordinal)
|
||||
&& TerminalKeyCatalog.FromCustom(id, label, sequence) is not null
|
||||
&& customKeys.TrueForAll(k => !string.Equals(k.Id, id, StringComparison.Ordinal)))
|
||||
{
|
||||
customKeys.Add(custom);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var id in stored.Bar ?? [.. TerminalKeyCatalog.DefaultBar])
|
||||
{
|
||||
if (bar.Count < TerminalKeyCatalog.MaximumBarKeys && KeyFor(id) is not null && !bar.Contains(id))
|
||||
{
|
||||
bar.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
groupOrder.AddRange(stored.GroupOrder ?? []);
|
||||
hiddenGroups.UnionWith(stored.HiddenGroups ?? []);
|
||||
|
||||
NormaliseGroupOrder();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every group this build has, exactly once: stored order first, anything new at the end.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A group shipped after somebody arranged theirs lands at the bottom rather than not at all — they did
|
||||
/// not hide it, so it is not hidden — and one this build does not know is dropped.
|
||||
/// </remarks>
|
||||
private void NormaliseGroupOrder()
|
||||
{
|
||||
var known = TerminalKeyCatalog.Groups.Select(g => g.Id).ToList();
|
||||
|
||||
if (customKeys.Count > 0)
|
||||
{
|
||||
known.Add(TerminalKeyCatalog.CustomGroupId);
|
||||
}
|
||||
|
||||
var ordered = groupOrder
|
||||
.Where(id => known.Contains(id, StringComparer.Ordinal))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
ordered.AddRange(known.Where(id => !ordered.Contains(id, StringComparer.Ordinal)));
|
||||
|
||||
groupOrder.Clear();
|
||||
groupOrder.AddRange(ordered);
|
||||
}
|
||||
|
||||
private TerminalKey? KeyFor(string id) =>
|
||||
TerminalKeyCatalog.ById.TryGetValue(id, out var shipped) ? shipped
|
||||
: customKeys.Find(k => string.Equals(k.Id, id, StringComparison.Ordinal)) is { } custom
|
||||
? TerminalKeyCatalog.FromCustom(custom.Id, custom.Label, custom.Sequence)
|
||||
: null;
|
||||
|
||||
private TerminalKeyGroup? GroupFor(string id)
|
||||
{
|
||||
if (string.Equals(id, TerminalKeyCatalog.CustomGroupId, StringComparison.Ordinal))
|
||||
{
|
||||
return new TerminalKeyGroup(
|
||||
id,
|
||||
"YOUR KEYS",
|
||||
[.. customKeys.Select(k => KeyFor(k.Id)).OfType<TerminalKey>()]);
|
||||
}
|
||||
|
||||
return TerminalKeyCatalog.Groups.FirstOrDefault(g => string.Equals(g.Id, id, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private KeyButtonViewModel ButtonFor(TerminalKey key)
|
||||
{
|
||||
if (!buttons.TryGetValue(key.Id, out var button))
|
||||
{
|
||||
button = new KeyButtonViewModel(key, Press);
|
||||
buttons[key.Id] = button;
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
private void Commit()
|
||||
{
|
||||
Save();
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
private void Save() =>
|
||||
settings.Write(settings.Read() with
|
||||
{
|
||||
TerminalKeyboard = new TerminalKeyboardSettings(
|
||||
Bar: [.. bar],
|
||||
GroupOrder: [.. groupOrder],
|
||||
HiddenGroups: [.. hiddenGroups.Order(StringComparer.Ordinal)],
|
||||
CustomKeys: [.. customKeys],
|
||||
QuickAccessTab: SelectedTab.ToString(),
|
||||
SidePanelOpen: IsSidePanelOpen,
|
||||
CursorGestures: CursorGestures),
|
||||
});
|
||||
|
||||
/// <summary>Redraws the KEYS tab.</summary>
|
||||
private void RebuildGroups()
|
||||
{
|
||||
Groups.Clear();
|
||||
|
||||
foreach (var id in groupOrder)
|
||||
{
|
||||
if (!hiddenGroups.Contains(id) && GroupFor(id) is { Keys.Count: > 0 } group)
|
||||
{
|
||||
Groups.Add(new KeyGroupViewModel(group.Title, [.. group.Keys.Select(ButtonFor)]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Redraws everything that depends on the arrangement.</summary>
|
||||
/// <remarks>
|
||||
/// Wholesale rather than by diff. The lists are a few dozen rows, a change is a tap, and an incremental
|
||||
/// update is a second account of the arrangement that can disagree with the first.
|
||||
/// </remarks>
|
||||
private void Rebuild()
|
||||
{
|
||||
var barKeys = bar.Select(KeyFor).OfType<TerminalKey>().ToList();
|
||||
|
||||
BarRows.Clear();
|
||||
|
||||
foreach (var row in barKeys.Chunk(TerminalKeyCatalog.BarRowWidth))
|
||||
{
|
||||
BarRows.Add(new KeyRowViewModel([.. row.Select(ButtonFor)]));
|
||||
}
|
||||
|
||||
RebuildGroups();
|
||||
|
||||
GroupEditor.Clear();
|
||||
|
||||
for (var i = 0; i < groupOrder.Count; i++)
|
||||
{
|
||||
if (GroupFor(groupOrder[i]) is not { } group)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var id = group.Id;
|
||||
|
||||
GroupEditor.Add(new GroupEditorRowViewModel(
|
||||
group.Title,
|
||||
!hiddenGroups.Contains(id),
|
||||
shown => SetGroupShown(id, shown),
|
||||
i > 0 ? () => MoveGroup(id, -1) : null,
|
||||
i < groupOrder.Count - 1 ? () => MoveGroup(id, 1) : null,
|
||||
[.. group.Keys.Select(k => new KeyChoiceViewModel(k.Label, bar.Contains(k.Id), () => ToggleOnBar(k.Id)))]));
|
||||
}
|
||||
|
||||
BarEditor.Clear();
|
||||
|
||||
for (var i = 0; i < barKeys.Count; i++)
|
||||
{
|
||||
var id = barKeys[i].Id;
|
||||
|
||||
BarEditor.Add(new BarEditorRowViewModel(
|
||||
barKeys[i],
|
||||
i < TerminalKeyCatalog.BarRowWidth ? "TOP ROW" : "SECOND ROW",
|
||||
i > 0 ? () => MoveOnBar(id, -1) : null,
|
||||
i < barKeys.Count - 1 ? () => MoveOnBar(id, 1) : null,
|
||||
() => ToggleOnBar(id)));
|
||||
}
|
||||
|
||||
CustomKeyEditor.Clear();
|
||||
|
||||
foreach (var custom in customKeys)
|
||||
{
|
||||
var id = custom.Id;
|
||||
CustomKeyEditor.Add(new CustomKeyRowViewModel(custom.Label, custom.Sequence, () => DeleteCustomKey(id)));
|
||||
}
|
||||
|
||||
RefreshLatches();
|
||||
|
||||
OnPropertyChanged(nameof(HasBar));
|
||||
OnPropertyChanged(nameof(CanAddToBar));
|
||||
OnPropertyChanged(nameof(BarCountText));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>One key as drawn: on the bar, on the quick-access keyboard, or both at once.</summary>
|
||||
internal sealed partial class KeyButtonViewModel : ObservableObject
|
||||
{
|
||||
internal KeyButtonViewModel(TerminalKey key, Action<TerminalKey> press)
|
||||
{
|
||||
Key = key;
|
||||
PressCommand = new RelayCommand(() => press(key));
|
||||
}
|
||||
|
||||
internal TerminalKey Key { get; }
|
||||
|
||||
internal string Label => Key.Label;
|
||||
|
||||
internal string Hint => Key.Hint;
|
||||
|
||||
internal bool IsWide => Key.IsWide;
|
||||
|
||||
internal IRelayCommand PressCommand { get; }
|
||||
|
||||
/// <summary>Whether this is a modifier that is on, which has to look on.</summary>
|
||||
[ObservableProperty]
|
||||
private bool isLatched;
|
||||
}
|
||||
|
||||
/// <summary>One row of the bar; the markup divides its width evenly between the keys.</summary>
|
||||
internal sealed record KeyRowViewModel(IReadOnlyList<KeyButtonViewModel> Keys);
|
||||
|
||||
/// <summary>One group on the KEYS tab.</summary>
|
||||
internal sealed record KeyGroupViewModel(string Title, IReadOnlyList<KeyButtonViewModel> Keys);
|
||||
|
||||
/// <summary>One key on the customise screen's bar list.</summary>
|
||||
internal sealed class BarEditorRowViewModel
|
||||
{
|
||||
internal BarEditorRowViewModel(TerminalKey key, string row, Action? moveUp, Action? moveDown, Action remove)
|
||||
{
|
||||
Label = key.Label;
|
||||
Hint = key.Hint;
|
||||
Row = row;
|
||||
MoveUpCommand = new RelayCommand(() => moveUp?.Invoke(), () => moveUp is not null);
|
||||
MoveDownCommand = new RelayCommand(() => moveDown?.Invoke(), () => moveDown is not null);
|
||||
RemoveCommand = new RelayCommand(remove);
|
||||
}
|
||||
|
||||
internal string Label { get; }
|
||||
|
||||
internal string Hint { get; }
|
||||
|
||||
/// <summary>Which row of the bar the key lands on, since the first eight are the top row.</summary>
|
||||
internal string Row { get; }
|
||||
|
||||
internal IRelayCommand MoveUpCommand { get; }
|
||||
|
||||
internal IRelayCommand MoveDownCommand { get; }
|
||||
|
||||
internal IRelayCommand RemoveCommand { get; }
|
||||
}
|
||||
|
||||
/// <summary>One group on the customise screen: its switch, its place, and which of its keys are on the bar.</summary>
|
||||
internal sealed partial class GroupEditorRowViewModel : ObservableObject
|
||||
{
|
||||
private readonly Action<bool> setShown;
|
||||
|
||||
internal GroupEditorRowViewModel(
|
||||
string title,
|
||||
bool isShown,
|
||||
Action<bool> setShown,
|
||||
Action? moveUp,
|
||||
Action? moveDown,
|
||||
IReadOnlyList<KeyChoiceViewModel> keys)
|
||||
{
|
||||
Title = title;
|
||||
this.isShown = isShown;
|
||||
this.setShown = setShown;
|
||||
Keys = keys;
|
||||
MoveUpCommand = new RelayCommand(() => moveUp?.Invoke(), () => moveUp is not null);
|
||||
MoveDownCommand = new RelayCommand(() => moveDown?.Invoke(), () => moveDown is not null);
|
||||
}
|
||||
|
||||
internal string Title { get; }
|
||||
|
||||
internal IReadOnlyList<KeyChoiceViewModel> Keys { get; }
|
||||
|
||||
internal IRelayCommand MoveUpCommand { get; }
|
||||
|
||||
internal IRelayCommand MoveDownCommand { get; }
|
||||
|
||||
/// <summary>Whether the group is drawn on the KEYS tab.</summary>
|
||||
[ObservableProperty]
|
||||
private bool isShown;
|
||||
|
||||
partial void OnIsShownChanged(bool value) => setShown(value);
|
||||
}
|
||||
|
||||
/// <summary>A key on the customise screen, lit when it is on the bar; tapping it puts it on or takes it off.</summary>
|
||||
internal sealed class KeyChoiceViewModel
|
||||
{
|
||||
internal KeyChoiceViewModel(string label, bool isOnBar, Action toggle)
|
||||
{
|
||||
Label = label;
|
||||
IsOnBar = isOnBar;
|
||||
ToggleCommand = new RelayCommand(toggle);
|
||||
}
|
||||
|
||||
internal string Label { get; }
|
||||
|
||||
internal bool IsOnBar { get; }
|
||||
|
||||
internal IRelayCommand ToggleCommand { get; }
|
||||
}
|
||||
|
||||
/// <summary>A key somebody added, on the customise screen, with the way to delete it.</summary>
|
||||
internal sealed class CustomKeyRowViewModel
|
||||
{
|
||||
internal CustomKeyRowViewModel(string label, string sequence, Action delete)
|
||||
{
|
||||
Label = label;
|
||||
Sequence = sequence;
|
||||
DeleteCommand = new RelayCommand(delete);
|
||||
}
|
||||
|
||||
internal string Label { get; }
|
||||
|
||||
internal string Sequence { get; }
|
||||
|
||||
internal IRelayCommand DeleteCommand { get; }
|
||||
}
|
||||
@@ -941,6 +941,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
/// <summary>Whether the picker is showing hosts.</summary>
|
||||
internal bool ShowsHostPicker => Remote is RemoteKind.Host;
|
||||
|
||||
/// <summary>Whether a host — not a bucket — is open, so that a terminal could be opened beside it.</summary>
|
||||
internal bool IsHostConnected => IsConnected && Remote is RemoteKind.Host;
|
||||
|
||||
/// <summary>Whether the picker is showing buckets.</summary>
|
||||
internal bool ShowsBucketPicker => Remote is RemoteKind.Bucket;
|
||||
|
||||
@@ -1440,6 +1443,50 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
var many => $"{many} selected",
|
||||
};
|
||||
|
||||
/// <summary>Whether there is anything for an action to act on: a tick, or a chosen row.</summary>
|
||||
/// <remarks>
|
||||
/// The phone's question, and it is <see cref="RemoteBatch"/>'s own rule — ticks, or the selection when
|
||||
/// there are none — asked as a yes or no. It decides whether the files screen's selection bar is up,
|
||||
/// which is where that head keeps SAVE and the menu of the four things that change a file. Nothing on
|
||||
/// the desktop reads it.
|
||||
/// </remarks>
|
||||
internal bool HasRemoteBatch => HasCheckedRemote || SelectedRemoteEntry is not null;
|
||||
|
||||
/// <summary>What the phone's selection bar says: the count when rows are ticked, else the row's name.</summary>
|
||||
internal string RemoteBatchLabel =>
|
||||
HasCheckedRemote ? CheckedRemoteLabel : SelectedRemoteEntry?.Name ?? string.Empty;
|
||||
|
||||
/// <summary>Whether the phone's menu of things to do to the chosen rows is up.</summary>
|
||||
/// <remarks>
|
||||
/// The phone moved RENAME, MOVE, PERMS and DELETE off a permanent block of buttons — seven of them over
|
||||
/// half the screen, live with nothing chosen (audit M11) — and into a sheet raised from the selection
|
||||
/// bar's ⋮. The sheet's openness is kept here rather than in the view so the system back gesture, which
|
||||
/// the shell answers, can lower it. The desktop never sets it.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isEntryMenuOpen;
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenEntryMenu() => IsEntryMenuOpen = true;
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseEntryMenu() => IsEntryMenuOpen = false;
|
||||
|
||||
/// <summary>Lets go of everything chosen: the ticks and the selection both.</summary>
|
||||
/// <remarks>The ✕ on the phone's selection bar, which is how somebody says they did not mean it.</remarks>
|
||||
[RelayCommand]
|
||||
private void ClearRemoteBatch()
|
||||
{
|
||||
foreach (var row in RemoteEntries.Where(row => row.IsChecked))
|
||||
{
|
||||
row.IsChecked = false;
|
||||
}
|
||||
|
||||
SelectedRemoteEntry = null;
|
||||
IsEntryMenuOpen = false;
|
||||
RaiseBatchState();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="CheckedRemoteLabel" />
|
||||
internal string CheckedLocalLabel => CheckedLocalCount switch
|
||||
{
|
||||
@@ -1511,6 +1558,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
OnPropertyChanged(nameof(HasCheckedLocal));
|
||||
OnPropertyChanged(nameof(CheckedRemoteLabel));
|
||||
OnPropertyChanged(nameof(CheckedLocalLabel));
|
||||
OnPropertyChanged(nameof(HasRemoteBatch));
|
||||
OnPropertyChanged(nameof(RemoteBatchLabel));
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
OnPropertyChanged(nameof(CanUpload));
|
||||
RaiseItemActionState();
|
||||
@@ -4252,6 +4301,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
/// </remarks>
|
||||
partial void OnRemoteChanged(RemoteKind value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsHostConnected));
|
||||
|
||||
// PERMS is the one item action that is not offered on both destinations; see
|
||||
// CanChangeRemotePermissions.
|
||||
OnPropertyChanged(nameof(CanChangeRemotePermissions));
|
||||
@@ -4287,6 +4338,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
/// </remarks>
|
||||
partial void OnIsConnectedChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsHostConnected));
|
||||
|
||||
if (value)
|
||||
{
|
||||
LostConnection = null;
|
||||
@@ -4314,6 +4367,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
OnPropertyChanged(nameof(CanEditRemote));
|
||||
OnPropertyChanged(nameof(SelectedRemoteName));
|
||||
OnPropertyChanged(nameof(HasRemoteBatch));
|
||||
OnPropertyChanged(nameof(RemoteBatchLabel));
|
||||
|
||||
PendingRemoteDeletion = null;
|
||||
PendingRemoteMove = null;
|
||||
|
||||
@@ -2229,6 +2229,7 @@ internal sealed partial class VaultViewModel(
|
||||
// appearing inside a group's name field is the kind of thing that only shows up in a bug report.
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsEditingAVaultItem))]
|
||||
private bool isEditingTag;
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -3974,6 +3975,7 @@ internal sealed partial class VaultViewModel(
|
||||
// sharing them would mean a half-typed host reappearing inside a key editor.
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsEditingAVaultItem))]
|
||||
private bool isEditingKey;
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -4012,6 +4014,7 @@ internal sealed partial class VaultViewModel(
|
||||
/// this application that writes a key, and it is still SAVE.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsEditingAVaultItem))]
|
||||
private bool isGeneratingKey;
|
||||
|
||||
/// <summary>
|
||||
@@ -4039,6 +4042,7 @@ internal sealed partial class VaultViewModel(
|
||||
// them would mean a half-typed key reappearing inside a credential.
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsEditingAVaultItem))]
|
||||
private bool isEditingCredential;
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -4069,6 +4073,7 @@ internal sealed partial class VaultViewModel(
|
||||
// A fourth set, on the same reasoning as the third.
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsEditingAVaultItem))]
|
||||
private bool isEditingObjectStore;
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -4273,6 +4278,54 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
private string manualStatus = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The machine the hosts search box names, when what was typed into it reads as <c>user@host[:port]</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The phone's version of the desktop palette's typed-address row.</b> The manual box on the terminal
|
||||
/// screen is the other way in, and it is only drawn while nothing is open — a second machine reached while
|
||||
/// a first one's session is up had no box at all. The search well at the top of HOSTS is always there, the
|
||||
/// design already calls it "Search or connect…", and <c>user@host</c> is what people type into a box, so
|
||||
/// it reads the address rather than the screen growing a second field.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Read with <see cref="TryParseManualTarget"/>, the manual box's own parser, and only for a filter with an
|
||||
/// <c>@</c> in it: without one, everything typed is a search, and a card offering to dial three letters
|
||||
/// would arrive on every keystroke.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasFilterAddress))]
|
||||
[NotifyPropertyChangedFor(nameof(FilterAddressText))]
|
||||
private ManualEndpoint? filterAddress;
|
||||
|
||||
/// <summary>Whether the search box holds an address that can be connected to.</summary>
|
||||
internal bool HasFilterAddress => FilterAddress is not null;
|
||||
|
||||
/// <summary>The typed address as it will be dialled, port and all.</summary>
|
||||
internal string FilterAddressText => FilterAddress?.Address ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Why a search with an <c>@</c> in it is not an address, or why its CONNECT was refused; empty otherwise.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasFilterAddressRefusal))]
|
||||
private string filterAddressRefusal = string.Empty;
|
||||
|
||||
/// <summary>Whether there is a refusal to show under the search box.</summary>
|
||||
internal bool HasFilterAddressRefusal => FilterAddressRefusal.Length > 0;
|
||||
|
||||
/// <summary>The password typed for <see cref="FilterAddress"/>.</summary>
|
||||
/// <remarks>
|
||||
/// Its own box rather than <see cref="ManualPassword"/>, for the reason that one is not
|
||||
/// <see cref="ConnectPassword"/>: these are different screens, and a password typed on one is not a
|
||||
/// password offered on the other. Cleared when the address changes, so the next machine typed never
|
||||
/// arrives with the last one's password already in the box.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string filterAddressPassword = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The attempt an unanswered host-key question belongs to, so that trusting the key can replay it.
|
||||
/// </summary>
|
||||
@@ -6194,8 +6247,10 @@ internal sealed partial class VaultViewModel(
|
||||
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
||||
{
|
||||
LastSyncFailed = true;
|
||||
Status = "Offline. Changes are queued and will be sent as soon as this machine "
|
||||
+ "can reach the server again.";
|
||||
Status = OfflineReason is { } reason
|
||||
? $"Offline. {reason}"
|
||||
: "Offline. Changes are queued and will be sent as soon as this machine "
|
||||
+ "can reach the server again.";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6209,6 +6264,14 @@ internal sealed partial class VaultViewModel(
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Why the last attempt to get online failed, or null when nothing has said.</summary>
|
||||
/// <remarks>
|
||||
/// Set by the shell, which is the thing that knows: <see cref="ServerReconnectHandler" /> answers null
|
||||
/// for every reason on purpose, so that a background pass stays quiet. A press of SYNC NOW is somebody
|
||||
/// asking, though, and "offline" with no why is what left a machine stuck with no way to find out.
|
||||
/// </remarks>
|
||||
internal string? OfflineReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Finds a server to sync against, getting this machine online if it is not.
|
||||
/// </summary>
|
||||
@@ -6655,6 +6718,44 @@ internal sealed partial class VaultViewModel(
|
||||
[RelayCommand]
|
||||
private void CloseAddSheet() => IsAddSheetOpen = false;
|
||||
|
||||
/// <summary>Whether any of the keychain's five editors is open: a key, a generated key, a password,
|
||||
/// a tag or a bucket.</summary>
|
||||
/// <remarks>
|
||||
/// The phone draws the five as one page over the whole screen, as it draws the host editor, and stands its
|
||||
/// own chrome down for it; this is the one question that page and that chrome ask. It is
|
||||
/// <see cref="AVaultEditorIsInTheWay"/>'s test without the status line that method writes.
|
||||
/// </remarks>
|
||||
internal bool IsEditingAVaultItem =>
|
||||
IsEditingKey || IsEditingCredential || IsGeneratingKey || IsEditingObjectStore || IsEditingTag;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the phone's sheet of things to add to the keychain is up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The keychain on the phone could add nothing — its empty state said "add an SSH key or a password" over
|
||||
/// a screen with no way to (audit M9) — although every editor it needed was already here for the desktop.
|
||||
/// The phone's floating + raises this sheet, which offers the desktop's five, and choosing one lowers it
|
||||
/// and opens that editor. The desktop's own + is a flyout and never sets this.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isKeychainAddSheetOpen;
|
||||
|
||||
/// <summary>Raises the keychain's add sheet, unless an editor is already open.</summary>
|
||||
[RelayCommand]
|
||||
private void OpenKeychainAddSheet()
|
||||
{
|
||||
if (AVaultEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsKeychainAddSheetOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>Lowers the keychain's add sheet without choosing anything.</summary>
|
||||
[RelayCommand]
|
||||
private void CloseKeychainAddSheet() => IsKeychainAddSheetOpen = false;
|
||||
|
||||
// ---- ◆ Choosing hosts, and the seven things the bar can do to them ----
|
||||
|
||||
/// <summary>
|
||||
@@ -11427,7 +11528,103 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
ManualStatus = string.Empty;
|
||||
|
||||
await ConnectToAsync(
|
||||
await ConnectToEndpointAsync(endpoint, ManualPassword, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the address typed into the hosts search box, with the password typed under it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Through <see cref="ConnectToEndpointAsync"/>, so the tab, the host-key question and the connection-log
|
||||
/// entry are the ones the manual box and the desktop palette produce, and nothing is written to the
|
||||
/// keychain.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The search box and the password are cleared before the dial, from copies taken first. The list left
|
||||
/// filtered to an address would be a list of nothing to come back to, and the password is a secret nobody
|
||||
/// asked to keep.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It takes no cancellation token and allows concurrent executions, for the two reasons
|
||||
/// <see cref="ConnectAsync"/> carries.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task ConnectToFilterAddressAsync()
|
||||
{
|
||||
if (FilterAddress is not { } endpoint)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (FilterAddressPassword.Length == 0)
|
||||
{
|
||||
FilterAddressRefusal = $"Type the password for {endpoint.Address}.";
|
||||
return;
|
||||
}
|
||||
|
||||
var password = FilterAddressPassword;
|
||||
|
||||
// In this order: clearing the filter clears the address and the password with it — see
|
||||
// OnHostFilterChanged — and the endpoint and password above are what is dialled.
|
||||
HostFilter = string.Empty;
|
||||
|
||||
await ConnectToEndpointAsync(endpoint, password, CancellationToken.None).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Reads the hosts search box as an address, if it has an <c>@</c> in it.</summary>
|
||||
private void RefreshFilterAddress(string filter)
|
||||
{
|
||||
var previous = FilterAddress;
|
||||
var trimmed = filter.Trim();
|
||||
|
||||
if (!trimmed.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
FilterAddress = null;
|
||||
FilterAddressRefusal = string.Empty;
|
||||
}
|
||||
else if (TryParseManualTarget(trimmed, out var endpoint, out var reason))
|
||||
{
|
||||
FilterAddress = endpoint;
|
||||
FilterAddressRefusal = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
FilterAddress = null;
|
||||
FilterAddressRefusal = reason;
|
||||
}
|
||||
|
||||
// A different machine, or none, is a different question: the password typed for the last one goes.
|
||||
if (FilterAddress != previous)
|
||||
{
|
||||
FilterAddressPassword = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a terminal on a typed address with a typed password, saving neither.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The second half of <see cref="ConnectManuallyAsync(CancellationToken)"/>, split out for the desktop's
|
||||
/// quick-connect palette. That head has no manual box for <see cref="ManualTarget"/> to be typed into —
|
||||
/// the palette's own query is the box — so it parses with <see cref="TryParseManualTarget"/> and hands
|
||||
/// over what it read rather than writing into the phone's fields to have them read back here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The caller has already refused an unreadable address and an empty password, which is why neither is
|
||||
/// checked again: both refusals are sentences about a box, and only the caller knows which box.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal Task ConnectToEndpointAsync(
|
||||
ManualEndpoint endpoint,
|
||||
string password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoint);
|
||||
|
||||
return ConnectToAsync(
|
||||
// Labelled by what was typed rather than by the hostname alone. Two accounts on one box are two
|
||||
// different connections, and a strip showing the same name twice would be the tab equivalent of
|
||||
// the log entry this also names.
|
||||
@@ -11435,8 +11632,8 @@ internal sealed partial class VaultViewModel(
|
||||
$"{endpoint.Username}@{endpoint.Hostname}",
|
||||
endpoint.Hostname,
|
||||
endpoint.Port),
|
||||
new HostAuthentication(endpoint.Username, new SshPasswordCredential(ManualPassword)),
|
||||
cancellationToken).ConfigureAwait(true);
|
||||
new HostAuthentication(endpoint.Username, new SshPasswordCredential(password)),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -11461,7 +11658,7 @@ internal sealed partial class VaultViewModel(
|
||||
/// would make this the only field in the product with its own address grammar.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static bool TryParseManualTarget(
|
||||
internal static bool TryParseManualTarget(
|
||||
string typed,
|
||||
[NotNullWhen(true)] out ManualEndpoint? endpoint,
|
||||
[NotNullWhen(false)] out string? reason)
|
||||
@@ -11620,7 +11817,16 @@ internal sealed partial class VaultViewModel(
|
||||
/// credential rather than from the host — so a username on the shared record would be a field that is
|
||||
/// null for every connection but this one.
|
||||
/// </remarks>
|
||||
private sealed record ManualEndpoint(string Username, string Hostname, int Port);
|
||||
internal sealed record ManualEndpoint(string Username, string Hostname, int Port)
|
||||
{
|
||||
/// <summary>The address as it will be dialled, in the grammar it was typed in.</summary>
|
||||
/// <remarks>
|
||||
/// The same shape as <see cref="HostRowViewModel.Address"/> and the connection log's own entries, so
|
||||
/// the palette can compare what was typed against a saved host's address with a plain string match.
|
||||
/// </remarks>
|
||||
public string Address =>
|
||||
string.Create(CultureInfo.InvariantCulture, $"{Username}@{Hostname}:{Port}");
|
||||
}
|
||||
|
||||
/// <summary>Everything both connect paths share, from the tab appearing to the session opening.</summary>
|
||||
/// <remarks>
|
||||
@@ -13417,6 +13623,7 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
partial void OnHostFilterChanged(string value)
|
||||
{
|
||||
RefreshFilterAddress(value);
|
||||
RebuildVisibleHosts();
|
||||
RebuildHostSections();
|
||||
OnPropertyChanged(nameof(NoHostBoardMessage));
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
<script src="/vendor/xterm.js"></script>
|
||||
<script src="/vendor/addon-fit.js"></script>
|
||||
<script src="/vendor/addon-webgl.js"></script>
|
||||
<script src="/vendor/addon-unicode11.js"></script>
|
||||
<script src="/terminal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -387,6 +387,28 @@ function createSession(sessionId) {
|
||||
|
||||
const fit = new FitAddon.FitAddon();
|
||||
term.loadAddon(fit);
|
||||
|
||||
/*
|
||||
── CHARACTER WIDTHS FROM UNICODE 11, NOT 6 ────────────────────────────────────────────────────────
|
||||
|
||||
How many columns a character takes is not something the terminal and the remote can each decide for
|
||||
themselves: the shell's line editor counts columns to know where the cursor is, and the terminal
|
||||
counts them to know where to draw. When the two disagree, every character after the one they
|
||||
disagree about lands a column away from where the remote believes it is — a prompt that redraws over
|
||||
itself, a cursor one cell off, a line that wraps early.
|
||||
|
||||
xterm.js ships with Unicode 6's table, which predates emoji as a wide class. So 😀 was one column
|
||||
here and two on any current remote (glibc, musl and every modern wcwidth follow Unicode 9 and later),
|
||||
and a line with an emoji in it drifted from the first emoji on. Measured against xterm.js 6.0
|
||||
headless: 😀 took one cell as shipped and two with this addon, and CJK, combining marks and every
|
||||
escape sequence behaved the same either way.
|
||||
|
||||
Before open() so the first frame is measured with the right table. activeVersion is a proposed API,
|
||||
which is what allowProposedApi above is already on for.
|
||||
*/
|
||||
term.loadAddon(new Unicode11Addon.Unicode11Addon());
|
||||
term.unicode.activeVersion = '11';
|
||||
|
||||
term.open(pane);
|
||||
|
||||
// WebGL where it is available. Falling back rather than failing matters because a software
|
||||
|
||||
@@ -12,18 +12,21 @@ All of the following are MIT licensed, the same licence as this repository.
|
||||
| `xterm.css` | `@xterm/xterm` | 6.0.0 | `https://unpkg.com/@xterm/xterm@6.0.0/css/xterm.css` |
|
||||
| `addon-fit.js` | `@xterm/addon-fit` | 0.11.0 | `https://unpkg.com/@xterm/addon-fit@0.11.0/lib/addon-fit.js` |
|
||||
| `addon-webgl.js` | `@xterm/addon-webgl` | 0.19.0 | `https://unpkg.com/@xterm/addon-webgl@0.19.0/lib/addon-webgl.js` |
|
||||
| `addon-unicode11.js` | `@xterm/addon-unicode11` | 0.9.0 | `https://unpkg.com/@xterm/addon-unicode11@0.9.0/lib/addon-unicode11.js` |
|
||||
|
||||
Copyright (c) 2017 The xterm.js authors. See <https://github.com/xtermjs/xterm.js> for the full
|
||||
licence text.
|
||||
|
||||
These are the UMD builds, so they attach to `globalThis`: `Terminal`, `FitAddon.FitAddon` and
|
||||
`WebglAddon.WebglAddon`. `terminal.js` depends on those exact names, so check them after any upgrade
|
||||
These are the UMD builds, so they attach to `globalThis`: `Terminal`, `FitAddon.FitAddon`,
|
||||
`WebglAddon.WebglAddon` and `Unicode11Addon.Unicode11Addon`. `terminal.js` depends on those exact names, so check them after any upgrade
|
||||
— a bundle that switched to ES modules would load without error and leave `Terminal` undefined.
|
||||
|
||||
## Upgrading
|
||||
|
||||
1. Download the four files at the new versions and update the table above.
|
||||
2. Confirm the UMD global names are unchanged.
|
||||
1. Download the five files at the new versions and update the table above.
|
||||
2. Confirm the UMD global names are unchanged, and that `term.unicode.activeVersion = '11'` still selects
|
||||
the addon's table: a version string xterm no longer recognises throws, and an addon that registered
|
||||
under another name would leave the page on Unicode 6 widths without a word.
|
||||
3. Confirm the right-click still belongs to `terminal.js`. Two things about xterm are load-bearing for it
|
||||
and neither is a global: `rightClickSelectsWord` has to still be an option — the page sets it false, and
|
||||
an option xterm no longer knows is silently ignored — and xterm has to still register its own right-click
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.Unicode11Addon=t():e.Unicode11Addon=t()}(globalThis,(()=>(()=>{"use strict";var e={384:(e,t,s)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV11=void 0;const r=s(765),n=[[768,879],[1155,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1541],[1552,1562],[1564,1564],[1611,1631],[1648,1648],[1750,1757],[1759,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2045,2045],[2070,2073],[2075,2083],[2085,2087],[2089,2093],[2137,2139],[2259,2306],[2362,2362],[2364,2364],[2369,2376],[2381,2381],[2385,2391],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2558,2558],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2641,2641],[2672,2673],[2677,2677],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2810,2815],[2817,2817],[2876,2876],[2879,2879],[2881,2884],[2893,2893],[2902,2902],[2914,2915],[2946,2946],[3008,3008],[3021,3021],[3072,3072],[3076,3076],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3170,3171],[3201,3201],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3328,3329],[3387,3388],[3393,3396],[3405,3405],[3426,3427],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3981,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4151],[4153,4154],[4157,4158],[4184,4185],[4190,4192],[4209,4212],[4226,4226],[4229,4230],[4237,4237],[4253,4253],[4448,4607],[4957,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6158],[6277,6278],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6683,6683],[6742,6742],[6744,6750],[6752,6752],[6754,6754],[6757,6764],[6771,6780],[6783,6783],[6832,6846],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7040,7041],[7074,7077],[7080,7081],[7083,7085],[7142,7142],[7144,7145],[7149,7149],[7151,7153],[7212,7219],[7222,7223],[7376,7378],[7380,7392],[7394,7400],[7405,7405],[7412,7412],[7416,7417],[7616,7673],[7675,7679],[8203,8207],[8234,8238],[8288,8292],[8294,8303],[8400,8432],[11503,11505],[11647,11647],[11744,11775],[12330,12333],[12441,12442],[42607,42610],[42612,42621],[42654,42655],[42736,42737],[43010,43010],[43014,43014],[43019,43019],[43045,43046],[43204,43205],[43232,43249],[43263,43263],[43302,43309],[43335,43345],[43392,43394],[43443,43443],[43446,43449],[43452,43453],[43493,43493],[43561,43566],[43569,43570],[43573,43574],[43587,43587],[43596,43596],[43644,43644],[43696,43696],[43698,43700],[43703,43704],[43710,43711],[43713,43713],[43756,43757],[43766,43766],[44005,44005],[44008,44008],[44013,44013],[64286,64286],[65024,65039],[65056,65071],[65279,65279],[65529,65531]],i=[[66045,66045],[66272,66272],[66422,66426],[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[68325,68326],[68900,68903],[69446,69456],[69633,69633],[69688,69702],[69759,69761],[69811,69814],[69817,69818],[69821,69821],[69837,69837],[69888,69890],[69927,69931],[69933,69940],[70003,70003],[70016,70017],[70070,70078],[70089,70092],[70191,70193],[70196,70196],[70198,70199],[70206,70206],[70367,70367],[70371,70378],[70400,70401],[70459,70460],[70464,70464],[70502,70508],[70512,70516],[70712,70719],[70722,70724],[70726,70726],[70750,70750],[70835,70840],[70842,70842],[70847,70848],[70850,70851],[71090,71093],[71100,71101],[71103,71104],[71132,71133],[71219,71226],[71229,71229],[71231,71232],[71339,71339],[71341,71341],[71344,71349],[71351,71351],[71453,71455],[71458,71461],[71463,71467],[71727,71735],[71737,71738],[72148,72151],[72154,72155],[72160,72160],[72193,72202],[72243,72248],[72251,72254],[72263,72263],[72273,72278],[72281,72283],[72330,72342],[72344,72345],[72752,72758],[72760,72765],[72767,72767],[72850,72871],[72874,72880],[72882,72883],[72885,72886],[73009,73014],[73018,73018],[73020,73021],[73023,73029],[73031,73031],[73104,73105],[73109,73109],[73111,73111],[73459,73460],[78896,78904],[92912,92916],[92976,92982],[94031,94031],[94095,94098],[113821,113822],[113824,113827],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[121344,121398],[121403,121452],[121461,121461],[121476,121476],[121499,121503],[121505,121519],[122880,122886],[122888,122904],[122907,122913],[122915,122916],[122918,122922],[123184,123190],[123628,123631],[125136,125142],[125252,125258],[917505,917505],[917536,917631],[917760,917999]],o=[[4352,4447],[8986,8987],[9001,9002],[9193,9196],[9200,9200],[9203,9203],[9725,9726],[9748,9749],[9800,9811],[9855,9855],[9875,9875],[9889,9889],[9898,9899],[9917,9918],[9924,9925],[9934,9934],[9940,9940],[9962,9962],[9970,9971],[9973,9973],[9978,9978],[9981,9981],[9989,9989],[9994,9995],[10024,10Line truncated
|
||||
//# sourceMappingURL=addon-unicode11.js.map
|
||||
@@ -0,0 +1,157 @@
|
||||
namespace DodoSSH.Client.Terminal;
|
||||
|
||||
/// <summary>What one session's output has most recently looked like.</summary>
|
||||
/// <param name="Tail">The last bytes the remote wrote, oldest first.</param>
|
||||
/// <param name="AlternateScreen">
|
||||
/// Whether the remote has switched to the alternate screen — vi, less, htop — and not yet switched back.
|
||||
/// </param>
|
||||
public sealed record RecentOutput(byte[] Tail, bool AlternateScreen);
|
||||
|
||||
/// <summary>
|
||||
/// The last kilobyte a session's remote wrote, and whether a full-screen program is running.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This exists so that the phone's command history only keeps what the remote echoed.</b> A terminal is
|
||||
/// one input stream, and the client cannot tell a shell prompt from a <c>sudo</c> password prompt by what is
|
||||
/// typed into it — only by whether the characters came back. A password typed with echo off leaves the
|
||||
/// prompt line exactly as it was, and that is the test the history applies before it keeps anything. See
|
||||
/// <c>TypedCommandHistory</c> in the shell.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Kept beside the pump's read loop, and costing that loop almost nothing.</b> Each read copies at most
|
||||
/// <see cref="Capacity"/> bytes into a ring and runs one vectorised search over the read for the
|
||||
/// alternate-screen switches, with no allocation. Locked rather than lock-free
|
||||
/// because it is written from the read loop's thread and read from the UI thread on Enter, and an Enter is
|
||||
/// rare enough that a lock is the honest price.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing here outlives the session or leaves this process. It is the same bytes the renderer is shown,
|
||||
/// held for as long as it takes for the next kilobyte to push them out.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OutputTail
|
||||
{
|
||||
/// <summary>How much output is kept.</summary>
|
||||
/// <remarks>
|
||||
/// A prompt and a long command line with room to spare. A line longer than this is not recorded at all,
|
||||
/// which is the safe direction to fail in.
|
||||
/// </remarks>
|
||||
public const int Capacity = 1024;
|
||||
|
||||
/// <summary>The longest switch sequence, less one: how far a match can straddle two reads.</summary>
|
||||
private const int Overlap = 7;
|
||||
|
||||
private readonly Lock gate = new();
|
||||
private readonly byte[] ring = new byte[Capacity];
|
||||
private int start;
|
||||
private int count;
|
||||
private bool alternateScreen;
|
||||
|
||||
/// <summary>Takes a read's worth of output.</summary>
|
||||
public void Append(ReadOnlySpan<byte> output)
|
||||
{
|
||||
if (output.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
// The seam first — the end of what is already held and the start of the new bytes — so a switch
|
||||
// split across two reads is still seen whole; then the whole read, in which a later switch wins.
|
||||
// All of it rather than its tail: a program that switches screens and then paints sixty
|
||||
// kilobytes in the same read is the ordinary case, not the odd one.
|
||||
var carried = Math.Min(count, Overlap);
|
||||
Span<byte> seam = stackalloc byte[carried + Math.Min(output.Length, Overlap)];
|
||||
CopyLast(seam[..carried]);
|
||||
output[..(seam.Length - carried)].CopyTo(seam[carried..]);
|
||||
|
||||
alternateScreen = ScanForScreenSwitch(output, ScanForScreenSwitch(seam, alternateScreen));
|
||||
|
||||
foreach (var b in output.Length > Capacity ? output[^Capacity..] : output)
|
||||
{
|
||||
ring[(start + count) % Capacity] = b;
|
||||
|
||||
if (count < Capacity)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
else
|
||||
{
|
||||
start = (start + 1) % Capacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A copy of what is held, oldest byte first.</summary>
|
||||
public RecentOutput Snapshot()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var copy = new byte[count];
|
||||
CopyLast(copy);
|
||||
|
||||
return new RecentOutput(copy, alternateScreen);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Copies the last <c>destination.Length</c> held bytes. Called under the lock.</summary>
|
||||
private void CopyLast(Span<byte> destination)
|
||||
{
|
||||
var offset = count - destination.Length;
|
||||
|
||||
for (var i = 0; i < destination.Length; i++)
|
||||
{
|
||||
destination[i] = ring[(start + offset + i) % Capacity];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the screen is alternate after this output, given whether it was before.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The three DEC private modes programs use for it — 1049, which every modern one sends, and the older
|
||||
/// 47 and 1047 — set with <c>h</c> and reset with <c>l</c>. The last switch in the window wins.
|
||||
/// </remarks>
|
||||
private static bool ScanForScreenSwitch(ReadOnlySpan<byte> window, bool current)
|
||||
{
|
||||
ReadOnlySpan<byte> prefix = [0x1B, (byte)'[', (byte)'?'];
|
||||
|
||||
var state = current;
|
||||
var at = 0;
|
||||
|
||||
while (at < window.Length)
|
||||
{
|
||||
var found = window[at..].IndexOf(prefix);
|
||||
|
||||
if (found < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var digits = at + found + prefix.Length;
|
||||
var end = digits;
|
||||
|
||||
while (end < window.Length && window[end] is >= (byte)'0' and <= (byte)'9')
|
||||
{
|
||||
end++;
|
||||
}
|
||||
|
||||
if (end < window.Length && window[end] is (byte)'h' or (byte)'l')
|
||||
{
|
||||
var mode = window[digits..end];
|
||||
|
||||
if (mode.SequenceEqual("1049"u8) || mode.SequenceEqual("1047"u8) || mode.SequenceEqual("47"u8))
|
||||
{
|
||||
state = window[end] == (byte)'h';
|
||||
}
|
||||
}
|
||||
|
||||
at = digits;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,10 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
/// <summary>Bytes read from the remote and not yet sent to the renderer.</summary>
|
||||
public int BytesPending => backlog.Buffered;
|
||||
|
||||
/// <summary>The last of what the remote wrote, for the phone's command history.</summary>
|
||||
/// <remarks>See <see cref="OutputTail"/> for why a client keeps any of it.</remarks>
|
||||
public OutputTail Recent { get; } = new();
|
||||
|
||||
/// <summary>Total frames sent to the renderer, for the throughput harness.</summary>
|
||||
public long FramesSent { get; private set; }
|
||||
|
||||
@@ -353,6 +357,7 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
}
|
||||
|
||||
BytesRead += read;
|
||||
Recent.Append(buffer.AsSpan(0, read));
|
||||
|
||||
// Copied out of the rented buffer, because the backlog holds it until the renderer has
|
||||
// room and the next read is already about to overwrite this one.
|
||||
|
||||
@@ -280,6 +280,24 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What one live session's remote has most recently written, or null when the id names no session this
|
||||
/// workspace still has open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For the phone's command history, which keeps a typed line only once it has seen the remote echo it —
|
||||
/// see <see cref="OutputTail"/>. A copy, so the caller can take its time over it.
|
||||
/// </remarks>
|
||||
public RecentOutput? GetRecentOutput(uint sessionId)
|
||||
{
|
||||
lock (sessionGate)
|
||||
{
|
||||
return sessions.TryGetValue(sessionId, out var session) && !session.Run.IsCompleted
|
||||
? session.Pump.Recent.Snapshot()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A live session's flow-control window, or null when the id names no session this workspace still has
|
||||
/// open.
|
||||
|
||||
@@ -321,6 +321,263 @@ public sealed class QuickConnectTests : IAsyncLifetime
|
||||
Token);
|
||||
}
|
||||
|
||||
// ---- A machine that is not saved ----
|
||||
|
||||
/// <remarks>
|
||||
/// The palette's answer to an address somebody has just been given: the query reads as one, a row offers
|
||||
/// to dial it, and that row is the one Enter takes — nothing saved answers an address nobody has saved.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TypingAnAddressOffersToConnectWithoutSavingIt()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
shell.SearchText = "root@10.0.0.5:2222";
|
||||
Relayout(window);
|
||||
|
||||
shell.HasAdHocTarget.ShouldBeTrue();
|
||||
shell.IsAdHocSelected.ShouldBeTrue("the typed address is what the query is plainly about");
|
||||
shell.SelectedSearchResult.ShouldBeNull("exactly one row is lit");
|
||||
|
||||
palette.AdHocTargetRow.IsEffectivelyVisible.ShouldBeTrue();
|
||||
VisibleTexts(palette.AdHocTargetRow).ShouldContain("root@10.0.0.5:2222", StringComparer.Ordinal);
|
||||
VisibleTexts(palette.AdHocTargetRow).ShouldContain("not saved", StringComparer.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The default port is printed rather than implied, because it is what will be dialled — and it is what
|
||||
/// makes the row comparable with a saved host's address, which always carries one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAddressWithNoPortIsOfferedOnTwentyTwo()
|
||||
{
|
||||
await OnThePaletteAsync((_, _) =>
|
||||
{
|
||||
shell.SearchText = "root@box";
|
||||
|
||||
shell.AdHocAddress.ShouldBe("root@box:22");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A query with no <c>@</c> is a search and nothing else, so a name that matches no host offers no row to
|
||||
/// dial — but it does say what the box would take instead, because a name that matches nothing is often a
|
||||
/// machine that was never saved.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ANameThatMatchesNothingSaysHowToReachAnUnsavedMachine()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
shell.SearchText = "nothing-is-called-this";
|
||||
Relayout(window);
|
||||
|
||||
shell.HasAdHocTarget.ShouldBeFalse();
|
||||
palette.AdHocTargetRow.IsEffectivelyVisible.ShouldBeFalse();
|
||||
VisibleTexts(palette).ShouldContain(
|
||||
text => text.Contains("user@host", StringComparison.Ordinal),
|
||||
"the hint names the other thing the box takes");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A query that looks like an address and cannot be one says why under the box, rather than producing no
|
||||
/// row and no reason — which would read as a palette that ignored the one thing it was asked.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAddressThatCannotBeReadSaysWhy()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
shell.SearchText = "root@box:99999";
|
||||
Relayout(window);
|
||||
|
||||
shell.HasAdHocTarget.ShouldBeFalse();
|
||||
VisibleTexts(palette).ShouldContain(
|
||||
text => text.Contains("port", StringComparison.Ordinal),
|
||||
"the refusal is the manual parser's own sentence");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The password is asked for in the palette itself, and the caret goes with the question: typing an
|
||||
/// address and pressing Enter has to leave somebody typing a password, not hunting for the box.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EnterOnTheAddressAsksForItsPasswordInThePalette()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
shell.SearchText = "root@10.0.0.5";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
|
||||
shell.IsSearching.ShouldBeTrue("nothing has been dialled yet");
|
||||
shell.IsAskingForAdHocPassword.ShouldBeTrue();
|
||||
palette.PasswordBox.IsEffectivelyVisible.ShouldBeTrue();
|
||||
palette.QueryBox.IsEffectivelyVisible.ShouldBeFalse();
|
||||
palette.PasswordBox.IsFocused.ShouldBeTrue("the caret follows the question");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Escape from the password goes back to the address with what was typed intact, because a mistyped
|
||||
/// address is the likeliest reason to want out — and drops the password, which is a secret nobody asked
|
||||
/// to keep.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EscapeFromThePasswordGoesBackToTheAddress()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
shell.SearchText = "root@10.0.0.5";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
shell.AdHocPassword = "hunter2";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
|
||||
shell.IsSearching.ShouldBeTrue("Escape here steps back rather than closing");
|
||||
shell.IsAskingForAdHocPassword.ShouldBeFalse();
|
||||
shell.SearchText.ShouldBe("root@10.0.0.5");
|
||||
shell.AdHocPassword.ShouldBeEmpty();
|
||||
palette.QueryBox.IsFocused.ShouldBeTrue("the caret goes back to the address");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Enter with no password typed is refused in the palette rather than dialled: an SSH server asked for a
|
||||
/// password with an empty one answers "authentication failed", which is a sentence about the machine
|
||||
/// when the problem is the box.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnEmptyPasswordIsRefusedRatherThanDialled()
|
||||
{
|
||||
var started = new List<ConnectionAttemptEventArgs>();
|
||||
vault.ConnectionStarting += (_, attempt) => started.Add(attempt);
|
||||
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
shell.SearchText = "root@10.0.0.5";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
|
||||
shell.IsSearching.ShouldBeTrue();
|
||||
shell.HasAdHocRefusal.ShouldBeTrue();
|
||||
started.ShouldBeEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The whole path: an address, a password, and a tab for that address — with nothing added to the
|
||||
/// keychain. The dial itself fails in this suite, as every connect here does, and inside the vault's own
|
||||
/// handling; what the palette promises is the attempt and the closing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePasswordConnectsToTheAddressAndSavesNothing()
|
||||
{
|
||||
var started = new List<ConnectionAttemptEventArgs>();
|
||||
vault.ConnectionStarting += (_, attempt) => started.Add(attempt);
|
||||
var hostsBefore = vault.Hosts.Count;
|
||||
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
shell.SearchText = "root@10.0.0.5:2222";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
shell.AdHocPassword = "hunter2";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
|
||||
shell.IsSearching.ShouldBeFalse("connecting closes the palette, as it does for a saved host");
|
||||
shell.AdHocPassword.ShouldBeEmpty("the password does not outlive the palette");
|
||||
|
||||
var attempt = started.ShouldHaveSingleItem();
|
||||
attempt.Address.ShouldBe("root@10.0.0.5:2222");
|
||||
attempt.HostId.ShouldBeNull("nothing in the keychain stands behind this tab");
|
||||
});
|
||||
|
||||
vault.Hosts.Count.ShouldBe(hostsBefore, "connecting without saving saves nothing");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The one time the typed address does not take the highlight: a saved host dials exactly that address,
|
||||
/// so it is the same machine with whatever the keychain knows about it, and Enter should reach it the way
|
||||
/// it was set up to be reached. The typed row is still offered, one arrow away.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAddressASavedHostDialsHighlightsThatHost()
|
||||
{
|
||||
await OnThePaletteAsync((_, _) =>
|
||||
{
|
||||
shell.SearchText = "deploy@host-3.internal";
|
||||
|
||||
shell.HasAdHocTarget.ShouldBeTrue();
|
||||
shell.IsAdHocSelected.ShouldBeFalse();
|
||||
shell.SelectedSearchResult.ShouldNotBeNull().Label.ShouldBe("host-3");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The typed row sits above the list and the arrows treat it as the list's first row, clamped as the rest
|
||||
/// are — and the caret stays in the box throughout, for the reason the plain arrows test gives.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheArrowsMoveBetweenTheAddressAndTheSavedHosts()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
palette.QueryBox.Focus().ShouldBeTrue();
|
||||
|
||||
// Every seeded host's address starts with this, and none of them is exactly it.
|
||||
shell.SearchText = "deploy@host";
|
||||
shell.SearchResults.Count.ShouldBeGreaterThan(1, "an empty list would prove nothing");
|
||||
shell.IsAdHocSelected.ShouldBeTrue();
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
|
||||
shell.IsAdHocSelected.ShouldBeFalse();
|
||||
shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
|
||||
shell.IsAdHocSelected.ShouldBeTrue();
|
||||
shell.SelectedSearchResult.ShouldBeNull();
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
|
||||
shell.IsAdHocSelected.ShouldBeTrue("clamped rather than wrapped");
|
||||
|
||||
palette.QueryBox.IsFocused.ShouldBeTrue();
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>The pointer's version of Enter on the typed row: straight to its password.</remarks>
|
||||
[Fact]
|
||||
public async Task ClickingTheAddressAsksForItsPassword()
|
||||
{
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
shell.SearchText = "root@10.0.0.5";
|
||||
Relayout(window);
|
||||
|
||||
var row = palette.AdHocTargetRow;
|
||||
window.MouseDown(Centre(row, window), MouseButton.Left);
|
||||
window.MouseUp(Centre(row, window), MouseButton.Left);
|
||||
|
||||
shell.IsAskingForAdHocPassword.ShouldBeTrue();
|
||||
shell.IsSearching.ShouldBeTrue();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <summary>Opens the palette in a window the size the application's is, and runs one body against it.</summary>
|
||||
|
||||
@@ -491,7 +491,7 @@
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||
"DodoSSH.Client.Transfer": "[1.0.0, )",
|
||||
"Microsoft.TSS": "[2.1.1, )",
|
||||
"Velopack": "[1.2.0, )"
|
||||
"Velopack": "[1.2.161, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.api": {
|
||||
@@ -788,9 +788,9 @@
|
||||
},
|
||||
"Velopack": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.2.0, )",
|
||||
"resolved": "1.2.0",
|
||||
"contentHash": "Rz67gJL619fSBS6omaSINUxyDuwhIxkm5mmubf7uLd5Qgi6LLKaKCha+QFP6n+Bw/UjA0vutnH4JQfYzn6ANtw=="
|
||||
"requested": "[1.2.161, )",
|
||||
"resolved": "1.2.161",
|
||||
"contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,28 +15,39 @@ public sealed class ProbedDeviceKeyStoreTests
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <remarks>
|
||||
/// "Off the caller's thread" is proved by blocking, not by comparing thread ids. The probe waits for a
|
||||
/// release the test only gives once construction has returned, so a probe run on the thread that
|
||||
/// constructed the store could never be released and times out instead. Thread ids proved nothing: the
|
||||
/// test itself runs on a pool thread, and that same thread is free to pick up the queued probe the
|
||||
/// moment the test awaits, which is what failed on CI with the code doing exactly the right thing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheChoiceIsMadeOnce_OffTheCallersThread()
|
||||
{
|
||||
var asking = Environment.CurrentManagedThreadId;
|
||||
var probes = 0;
|
||||
var probedOn = 0;
|
||||
var releasedInTime = false;
|
||||
|
||||
using var release = new ManualResetEventSlim();
|
||||
var inner = Substitute.For<IDeviceKeyStore>();
|
||||
|
||||
var store = new ProbedDeviceKeyStore(() =>
|
||||
{
|
||||
Interlocked.Increment(ref probes);
|
||||
probedOn = Environment.CurrentManagedThreadId;
|
||||
releasedInTime = release.Wait(TimeSpan.FromSeconds(10));
|
||||
return inner;
|
||||
});
|
||||
|
||||
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
|
||||
var available = store.IsAvailableAsync(Token);
|
||||
available.IsCompleted.ShouldBeFalse("nothing has released the probe yet");
|
||||
release.Set();
|
||||
|
||||
(await available).ShouldBeTrue();
|
||||
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
|
||||
await store.TryLoadAsync(Token);
|
||||
|
||||
releasedInTime.ShouldBeTrue("the probe ran on the thread that asked, so nothing could release it");
|
||||
probes.ShouldBe(1);
|
||||
probedOn.ShouldNotBe(asking);
|
||||
|
||||
// Availability is the choice, not a second probe through the store it chose.
|
||||
await inner.DidNotReceive().IsAvailableAsync(Arg.Any<CancellationToken>());
|
||||
|
||||
@@ -114,6 +114,13 @@ public sealed partial class ShellFlowTests
|
||||
|
||||
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||
|
||||
// Waited for in its own right rather than taken as read from IsLive. Ended clears IsLive and then
|
||||
// ends the forwards in the same dispatcher turn, which the app's UI thread never splits — but this
|
||||
// harness's post runs the ending on the fake shell's thread, so a poll from here can land between.
|
||||
await EventuallyAsync(
|
||||
() => tab.Forwards.All(row => row.Detail == "Stopped with the session."),
|
||||
"the session's end should have reached its forwards");
|
||||
|
||||
tab.Forwards.ShouldAllBe(row => row.State == PortForwardState.Stopped);
|
||||
tab.Forwards.ShouldAllBe(row => row.Detail == "Stopped with the session.");
|
||||
tab.Forwards.ShouldAllBe(row => !row.CanToggle);
|
||||
|
||||
@@ -1130,6 +1130,116 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The phone's hosts search box, read as an address: the card that dials it appears for <c>user@host</c>
|
||||
/// and for nothing else. A plain search must not grow one on every keystroke.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("root@box", "root@box:22")]
|
||||
[InlineData(" deploy@build.internal:2222 ", "deploy@build.internal:2222")]
|
||||
public async Task AnAddressTypedIntoTheHostsSearch_IsOfferedAsAMachineToConnectTo(
|
||||
string typed,
|
||||
string offered)
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.HostFilter = "prod";
|
||||
vault.HasFilterAddress.ShouldBeFalse("a search without an @ is a search");
|
||||
vault.HasFilterAddressRefusal.ShouldBeFalse();
|
||||
|
||||
vault.HostFilter = typed;
|
||||
|
||||
vault.HasFilterAddress.ShouldBeTrue();
|
||||
vault.FilterAddressText.ShouldBe(offered);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The whole path from the search box: the address and the password typed there are what is dialled, a tab
|
||||
/// opens for it, and the keychain is exactly as it was — that is the point of connecting this way. The box
|
||||
/// and the password are both empty afterwards, so the list is not left filtered to a machine it does not
|
||||
/// hold and the password does not wait in the box for the next one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsSearchConnectsToATypedAddress_AndSavesNothing()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
var hostsBefore = vault.Hosts.Count;
|
||||
|
||||
vault.HostFilter = "deploy@build.internal:2222";
|
||||
vault.FilterAddressPassword = "hunter2";
|
||||
|
||||
await vault.ConnectToFilterAddressCommand.ExecuteAsync(null);
|
||||
|
||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||
request.Host.ShouldBe("build.internal");
|
||||
request.Port.ShouldBe(2222);
|
||||
request.Username.ShouldBe("deploy");
|
||||
request.Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("hunter2");
|
||||
|
||||
shell.Tabs.ShouldHaveSingleItem().Label.ShouldBe("deploy@build.internal");
|
||||
vault.Hosts.Count.ShouldBe(hostsBefore, "connecting without saving saves nothing");
|
||||
|
||||
vault.HostFilter.ShouldBeEmpty();
|
||||
vault.FilterAddressPassword.ShouldBeEmpty();
|
||||
vault.HasFilterAddress.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An empty password is refused under the box rather than sent, as the manual box refuses one — and the
|
||||
/// address stays where it was typed, so filling the password in is all that is left to do.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsSearchRefusesAnEmptyPassword_AndKeepsTheAddress()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.HostFilter = "root@box";
|
||||
|
||||
await vault.ConnectToFilterAddressCommand.ExecuteAsync(null);
|
||||
|
||||
vault.FilterAddressRefusal.ShouldContain("password");
|
||||
vault.HostFilter.ShouldBe("root@box");
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
shell.Tabs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A search that looks like an address and cannot be one says why, in the manual parser's own words,
|
||||
/// rather than offering nothing and leaving the box looking ignored.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAddressInTheHostsSearchThatCannotBeRead_SaysWhy()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.HostFilter = "root@box:70000";
|
||||
|
||||
vault.HasFilterAddress.ShouldBeFalse();
|
||||
vault.FilterAddressRefusal.ShouldContain("between 1 and 65535");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A password belongs to the machine it was typed for. Changing the address in the box has to take it
|
||||
/// away, or the next machine typed would be dialled with somebody else's password.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ChangingTheAddressInTheHostsSearch_DropsThePasswordTypedForTheLastOne()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.HostFilter = "root@box";
|
||||
vault.FilterAddressPassword = "hunter2";
|
||||
|
||||
vault.HostFilter = "root@other-box";
|
||||
|
||||
vault.FilterAddressPassword.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <b>The retry used to re-run whichever host was selected.</b> That was right while a selected host was
|
||||
/// the only way to connect; with a manual target it would answer "do you trust this key" by dialling a
|
||||
@@ -9702,6 +9812,42 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
resumeAttempts.ShouldBe(attempted);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The machine this was found on: the identity provider restarted, discovery answered 502 for a
|
||||
/// moment, and that was read as a refusal — so the remembered sign-in was deleted and the machine
|
||||
/// stayed OFFLINE long after the server was back, with SYNC NOW saying nothing about why.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AProviderThatIsDown_KeepsTheSignInAndComesBackOnline()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
await shell.Vault!.SyncOnOpenAsync(Token);
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
|
||||
resumeFailure = new OidcException(
|
||||
"Discovery at 'https://id.example/.well-known/openid-configuration' returned 502.");
|
||||
|
||||
var relaunch = Relaunch(resume: ResumeAsync);
|
||||
await using var _ = relaunch.ConfigureAwait(false);
|
||||
|
||||
await relaunch.StartAsync(Token);
|
||||
relaunch.Passphrase = Passphrase;
|
||||
await relaunch.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
await relaunch.Vault!.SyncCommand.ExecuteAsync(null);
|
||||
|
||||
relaunch.IsOnline.ShouldBeFalse();
|
||||
relaunch.Vault.Status.ShouldContain("returned 502", Case.Insensitive,
|
||||
"SYNC NOW must say why it is offline, not just that it is");
|
||||
|
||||
// The provider comes back, and the next pass resumes with the token it still has.
|
||||
resumeFailure = null;
|
||||
await relaunch.Vault.SyncOnOpenAsync(Token);
|
||||
|
||||
relaunch.IsOnline.ShouldBeTrue();
|
||||
resumedWith.ShouldBe("refresh-token-1");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The pass that runs when the vault opens used to be skipped in the application and nowhere else: the
|
||||
/// loop is started from inside the unlock command, so the busy flag it yields to was raised by the
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
using System.Text;
|
||||
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Shell.Keyboard;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Terminal;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The phone's terminal keys: what each sends, what a latch does to the next one, the cursor gesture's
|
||||
/// speeds, the history's refusal to keep what was not echoed, and the arrangement surviving a relaunch.
|
||||
/// </summary>
|
||||
public sealed class TerminalKeyboardTests : IDisposable
|
||||
{
|
||||
private readonly string directory =
|
||||
Path.Combine(Path.GetTempPath(), $"dodossh-keys-{Guid.CreateVersion7():N}");
|
||||
|
||||
private readonly List<byte[]> sent = [];
|
||||
|
||||
/// <summary>What the remote has drawn so far, as the history's echo test reads it.</summary>
|
||||
private string screen = "stan@box:~$ ";
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Ascii(string text) => Encoding.ASCII.GetBytes(text);
|
||||
|
||||
private static string Shown(byte[] bytes) => Encoding.ASCII.GetString(bytes);
|
||||
|
||||
// ── notation ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"\e:wq\r", "\x1b:wq\r")]
|
||||
[InlineData("^C", "\x03")]
|
||||
[InlineData("^c", "\x03")]
|
||||
[InlineData("^[", "\x1b")]
|
||||
[InlineData("^?", "\x7f")]
|
||||
[InlineData(@"\x1b[A", "\x1b[A")]
|
||||
[InlineData(@"\^C", "^C")]
|
||||
[InlineData("^1", "^1")]
|
||||
[InlineData("git status", "git status")]
|
||||
[InlineData(@"a\\b", @"a\b")]
|
||||
public void Notation_IsReadTheWayATerminalUserWritesIt(string notation, string expected)
|
||||
{
|
||||
KeySequence.TryParse(notation, out var bytes).ShouldBeTrue();
|
||||
|
||||
Shown(bytes).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"oops\")]
|
||||
[InlineData(@"\x1")]
|
||||
[InlineData(@"\xZZ")]
|
||||
public void MalformedNotation_IsRefusedRatherThanGuessedAt(string notation) =>
|
||||
KeySequence.TryParse(notation, out _).ShouldBeFalse();
|
||||
|
||||
// ── latches ──────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("c", true, false, "\x03")]
|
||||
[InlineData("C", true, false, "\x03")]
|
||||
[InlineData(" ", true, false, "\0")]
|
||||
[InlineData("b", false, true, "\u001bb")]
|
||||
[InlineData("\x1b[D", true, false, "\x1b[1;5D")]
|
||||
[InlineData("\x1b[C", true, true, "\x1b[1;7C")]
|
||||
[InlineData("\x1b[A", false, true, "\x1b[1;3A")]
|
||||
[InlineData("ls", true, false, "ls")]
|
||||
[InlineData("\x1b[15~", true, false, "\x1b[15~")]
|
||||
public void ALatchedModifier_EncodesTheNextKeyTheWayXtermDoes(string key, bool control, bool alt, string expected) =>
|
||||
Shown(TerminalModifiers.Apply(Ascii(key), control, alt)).ShouldBe(expected);
|
||||
|
||||
// ── the cursor gesture ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ADragInsideTheDeadZone_SendsNothing()
|
||||
{
|
||||
var gesture = new ArrowGesture();
|
||||
|
||||
gesture.Move(10, -8, 0).ShouldBe(0);
|
||||
gesture.Advance(2_000).ShouldBe(0);
|
||||
gesture.Direction.ShouldBe(ArrowDirection.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeavingTheDeadZone_SendsOneKeyAtOnce_AndTheSlowSpeedWaitsBeforeRepeating()
|
||||
{
|
||||
var gesture = new ArrowGesture();
|
||||
|
||||
gesture.Move(-30, 0, 1_000).ShouldBe(1);
|
||||
gesture.Direction.ShouldBe(ArrowDirection.Left);
|
||||
gesture.Speed.ShouldBe(1);
|
||||
|
||||
// Held: nothing more until the first-repeat delay is up, then one per interval.
|
||||
gesture.Advance(1_000 + ArrowGesture.FirstRepeatDelayMs - 1).ShouldBe(0);
|
||||
gesture.Advance(1_000 + ArrowGesture.FirstRepeatDelayMs).ShouldBe(1);
|
||||
gesture.Advance(1_000 + ArrowGesture.FirstRepeatDelayMs + ArrowGesture.IntervalMs(1)).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DraggingFurtherTheSameWay_ShiftsThroughTheThreeSpeeds()
|
||||
{
|
||||
var gesture = new ArrowGesture();
|
||||
|
||||
gesture.Move(0, -30, 0);
|
||||
gesture.Speed.ShouldBe(1);
|
||||
gesture.Direction.ShouldBe(ArrowDirection.Up);
|
||||
|
||||
gesture.Move(0, -ArrowGesture.SecondSpeedAt, 10);
|
||||
gesture.Speed.ShouldBe(2);
|
||||
|
||||
gesture.Move(0, -ArrowGesture.ThirdSpeedAt, 20);
|
||||
gesture.Speed.ShouldBe(3);
|
||||
|
||||
// A second at the fastest speed is many keys; the same second at the slowest is a handful.
|
||||
var fast = 0;
|
||||
|
||||
for (var t = 20L; t <= 1_020; t += 10)
|
||||
{
|
||||
fast += gesture.Advance(t);
|
||||
}
|
||||
|
||||
var slow = new ArrowGesture();
|
||||
var slowCount = slow.Move(0, -30, 0);
|
||||
|
||||
for (var t = 0L; t <= 1_000; t += 10)
|
||||
{
|
||||
slowCount += slow.Advance(t);
|
||||
}
|
||||
|
||||
fast.ShouldBeGreaterThan(slowCount * 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASmallDriftOffTheAxis_DoesNotTurnLeftIntoUp()
|
||||
{
|
||||
var gesture = new ArrowGesture();
|
||||
|
||||
gesture.Move(-40, 0, 0);
|
||||
gesture.Move(-40, -45, 10);
|
||||
|
||||
gesture.Direction.ShouldBe(ArrowDirection.Left);
|
||||
|
||||
// Clearly vertical now, past the hysteresis.
|
||||
gesture.Move(-20, -60, 20).ShouldBe(1);
|
||||
gesture.Direction.ShouldBe(ArrowDirection.Up);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComingBackToTheCentre_Stops()
|
||||
{
|
||||
var gesture = new ArrowGesture();
|
||||
|
||||
gesture.Move(50, 0, 0);
|
||||
gesture.Move(3, 2, 10).ShouldBe(0);
|
||||
|
||||
gesture.Direction.ShouldBe(ArrowDirection.None);
|
||||
gesture.Advance(5_000).ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── reading back the echo ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("\x1b[32mstan@box\x1b[0m:~$ ls -la", "stan@box:~$ ls -la")]
|
||||
[InlineData("old output\r\n$ git statsu\b \b\b \bus", "$ git status")]
|
||||
[InlineData("$ rm -rf build\r$ ls\x1b[K", "$ ls")]
|
||||
[InlineData("\x1b]0;stan@box: ~\a$ uptime", "$ uptime")]
|
||||
[InlineData("$ ech\x1b[1D\x1b[1@o", "$ ecoh")]
|
||||
public void TheCursorsLine_IsReadAsATerminalWouldDrawIt(string output, string expected) =>
|
||||
EchoedLine.LastLine(Ascii(output)).ShouldBe(expected);
|
||||
|
||||
// ── the history ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private RecentOutput Recent() => new(Ascii(screen), AlternateScreen: false);
|
||||
|
||||
/// <summary>Types text one key at a time, echoing each key the way an ordinary shell prompt does.</summary>
|
||||
private void TypeEchoed(TypedCommandHistory history, string text)
|
||||
{
|
||||
foreach (var c in text)
|
||||
{
|
||||
history.Observe(1, "box", Ascii(c.ToString()), Recent);
|
||||
screen += c;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACommandTheShellEchoed_IsKept()
|
||||
{
|
||||
var history = new TypedCommandHistory();
|
||||
|
||||
TypeEchoed(history, "uptime");
|
||||
history.Observe(1, "box", "\r"u8, Recent);
|
||||
|
||||
history.Entries.ShouldHaveSingleItem().ShouldBe(new HistoryEntry("uptime", "box"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APasswordTypedWithEchoOff_IsNeverKept()
|
||||
{
|
||||
var history = new TypedCommandHistory();
|
||||
screen = "[sudo] password for stan: ";
|
||||
|
||||
// Typed, but nothing comes back: the prompt line is exactly what it was.
|
||||
foreach (var c in "hunter2")
|
||||
{
|
||||
history.Observe(1, "box", Ascii(c.ToString()), Recent);
|
||||
}
|
||||
|
||||
history.Observe(1, "box", "\r"u8, Recent);
|
||||
|
||||
history.Entries.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APasswordTypedAfterAnArrowKey_IsStillNeverKept()
|
||||
{
|
||||
var history = new TypedCommandHistory();
|
||||
screen = "Password: ";
|
||||
|
||||
history.Observe(1, "box", "\x1b[D"u8, Recent);
|
||||
|
||||
foreach (var c in "s3cret")
|
||||
{
|
||||
history.Observe(1, "box", Ascii(c.ToString()), Recent);
|
||||
}
|
||||
|
||||
history.Observe(1, "box", "\r"u8, Recent);
|
||||
|
||||
history.Entries.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APromptThatEchoesStars_IsNotACommand()
|
||||
{
|
||||
var history = new TypedCommandHistory();
|
||||
screen = "Passphrase: ";
|
||||
|
||||
foreach (var c in "s3cret")
|
||||
{
|
||||
history.Observe(1, "box", Ascii(c.ToString()), Recent);
|
||||
screen += '*';
|
||||
}
|
||||
|
||||
history.Observe(1, "box", "\r"u8, Recent);
|
||||
|
||||
history.Entries.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ALineFinishedWithTab_IsKeptAsTheShellCompletedIt()
|
||||
{
|
||||
var history = new TypedCommandHistory();
|
||||
|
||||
TypeEchoed(history, "cd /var/lo");
|
||||
history.Observe(1, "box", "\t"u8, Recent);
|
||||
screen += "g/";
|
||||
history.Observe(1, "box", "\r"u8, Recent);
|
||||
|
||||
history.Entries.ShouldHaveSingleItem().Command.ShouldBe("cd /var/log/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NothingTypedIntoAFullScreenProgram_IsKept()
|
||||
{
|
||||
var history = new TypedCommandHistory();
|
||||
|
||||
RecentOutput InVi() => new(Ascii("~\r\n~\r\nhello"), AlternateScreen: true);
|
||||
|
||||
foreach (var c in "hello")
|
||||
{
|
||||
history.Observe(1, "box", Ascii(c.ToString()), InVi);
|
||||
}
|
||||
|
||||
history.Observe(1, "box", "\r"u8, InVi);
|
||||
|
||||
history.Entries.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunningACommandAgain_MovesItToTheTopRatherThanListingItTwice()
|
||||
{
|
||||
var history = new TypedCommandHistory();
|
||||
|
||||
foreach (var command in new[] { "ls", "df -h", "ls" })
|
||||
{
|
||||
screen = "$ ";
|
||||
TypeEchoed(history, command);
|
||||
history.Observe(1, "box", "\r"u8, Recent);
|
||||
}
|
||||
|
||||
history.Entries.Select(e => e.Command).ShouldBe(["ls", "df -h"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CtrlC_AbandonsTheLine()
|
||||
{
|
||||
var history = new TypedCommandHistory();
|
||||
|
||||
TypeEchoed(history, "rm -rf /tmp/x");
|
||||
history.Observe(1, "box", "\x03"u8, Recent);
|
||||
screen = "stan@box:~$ ";
|
||||
history.Observe(1, "box", "\r"u8, Recent);
|
||||
|
||||
history.Entries.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ── the view model ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private ClientSettingsStore Settings() => new(new ClientPaths(directory));
|
||||
|
||||
private TerminalKeyboardViewModel Keyboard(ClientSettingsStore? store = null) =>
|
||||
new(
|
||||
store ?? Settings(),
|
||||
() => new TypingTarget(1, "box"),
|
||||
(_, bytes) =>
|
||||
{
|
||||
sent.Add(bytes.ToArray());
|
||||
return ValueTask.CompletedTask;
|
||||
},
|
||||
_ => Recent(),
|
||||
(_, _, _, _) => Task.FromResult(true),
|
||||
_ => Task.CompletedTask);
|
||||
|
||||
private static KeyButtonViewModel Button(TerminalKeyboardViewModel keyboard, string id) =>
|
||||
keyboard.Groups.SelectMany(g => g.Keys).First(k => k.Key.Id == id);
|
||||
|
||||
[Fact]
|
||||
public void CtrlOnTheBar_AppliesToTheNextLetterFromTheSoftKeyboard_AndThenLetsGo()
|
||||
{
|
||||
var keyboard = Keyboard();
|
||||
|
||||
Button(keyboard, "ctrl").PressCommand.Execute(null);
|
||||
keyboard.IsControlLatched.ShouldBeTrue();
|
||||
Button(keyboard, "ctrl").IsLatched.ShouldBeTrue();
|
||||
|
||||
keyboard.Send(Ascii("c"));
|
||||
keyboard.Send(Ascii("c"));
|
||||
|
||||
sent.Select(Shown).ShouldBe(["\x03", "c"]);
|
||||
keyboard.IsControlLatched.ShouldBeFalse();
|
||||
Button(keyboard, "ctrl").IsLatched.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheBarAsShipped_IsTwoRows_ModifiersAboveCursorKeys()
|
||||
{
|
||||
var keyboard = Keyboard();
|
||||
|
||||
keyboard.BarRows.Count.ShouldBe(2);
|
||||
keyboard.BarRows[0].Keys.Select(k => k.Label).ShouldBe(["esc", "tab", "ctrl", "alt", "|", "-", "/", "~"]);
|
||||
keyboard.BarRows[1].Keys.Select(k => k.Label).ShouldBe(["←", "↓", "↑", "→", "paste"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ACustomisedBar_SurvivesARelaunch()
|
||||
{
|
||||
var store = Settings();
|
||||
var keyboard = Keyboard(store);
|
||||
|
||||
keyboard.OpenCustomizeCommand.Execute(null);
|
||||
keyboard.BarEditor[1].MoveUpCommand.Execute(null); // tab before esc
|
||||
keyboard.BarEditor.Single(r => r.Label == "paste").RemoveCommand.Execute(null);
|
||||
keyboard.NewKeyLabel = "wq";
|
||||
keyboard.NewKeySequence = @"\e:wq\r";
|
||||
keyboard.AddCustomKeyCommand.Execute(null);
|
||||
|
||||
var relaunched = Keyboard(store);
|
||||
var labels = relaunched.BarRows.SelectMany(r => r.Keys).Select(k => k.Label).ToList();
|
||||
|
||||
labels[0].ShouldBe("tab");
|
||||
labels[1].ShouldBe("esc");
|
||||
labels.ShouldNotContain("paste");
|
||||
labels[^1].ShouldBe("wq");
|
||||
relaunched.Groups.Last().Title.ShouldBe("YOUR KEYS");
|
||||
|
||||
relaunched.BarRows.SelectMany(r => r.Keys).Last().PressCommand.Execute(null);
|
||||
Shown(sent.Last()).ShouldBe("\x1b:wq\r");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHiddenGroup_IsLeftOffTheKeysTab_AndStaysHidden()
|
||||
{
|
||||
var store = Settings();
|
||||
var keyboard = Keyboard(store);
|
||||
|
||||
keyboard.GroupEditor.Single(g => g.Title == "TMUX").IsShown = false;
|
||||
|
||||
keyboard.Groups.Select(g => g.Title).ShouldNotContain("TMUX");
|
||||
Keyboard(store).Groups.Select(g => g.Title).ShouldNotContain("TMUX");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_PutsTheBarBack_ButKeepsKeysSomebodyWrote()
|
||||
{
|
||||
var keyboard = Keyboard();
|
||||
|
||||
keyboard.NewKeyLabel = "deploy";
|
||||
keyboard.NewKeySequence = "make deploy";
|
||||
keyboard.AddCustomKeyCommand.Execute(null);
|
||||
|
||||
keyboard.ResetLayoutCommand.Execute(null);
|
||||
|
||||
keyboard.BarRows.SelectMany(r => r.Keys).Select(k => k.Key.Id).ShouldBe(TerminalKeyCatalog.DefaultBar);
|
||||
keyboard.CustomKeyEditor.ShouldHaveSingleItem().Label.ShouldBe("deploy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AKeyWithANonsenseSequence_IsRefusedWithAReason()
|
||||
{
|
||||
var keyboard = Keyboard();
|
||||
|
||||
keyboard.NewKeyLabel = "bad";
|
||||
keyboard.NewKeySequence = @"\x1";
|
||||
keyboard.AddCustomKeyCommand.Execute(null);
|
||||
|
||||
keyboard.CustomKeyEditor.ShouldBeEmpty();
|
||||
keyboard.CustomizeStatus.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheBarHoldsSixteenKeys_AndSaysSoWhenFull()
|
||||
{
|
||||
var keyboard = Keyboard();
|
||||
|
||||
foreach (var choice in keyboard.GroupEditor.Single(g => g.Title == "FUNCTION").Keys.ToList())
|
||||
{
|
||||
// Rebuilt after every toggle, so look the chip up again each time.
|
||||
keyboard.GroupEditor.Single(g => g.Title == "FUNCTION").Keys
|
||||
.Single(k => k.Label == choice.Label).ToggleCommand.Execute(null);
|
||||
}
|
||||
|
||||
keyboard.BarRows.Sum(r => r.Keys.Count).ShouldBe(TerminalKeyCatalog.MaximumBarKeys);
|
||||
keyboard.CanAddToBar.ShouldBeFalse();
|
||||
keyboard.CustomizeStatus.ShouldContain("16");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheQuickAccessKeyboard_ReopensToTheTabLastUsed()
|
||||
{
|
||||
var store = Settings();
|
||||
|
||||
Keyboard(store).SelectTabCommand.Execute(QuickAccessTab.History);
|
||||
|
||||
var relaunched = Keyboard(store);
|
||||
|
||||
relaunched.SelectedTab.ShouldBe(QuickAccessTab.History);
|
||||
relaunched.IsHistoryTab.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnIdThisBuildDoesNotKnow_IsSkippedRatherThanRefused()
|
||||
{
|
||||
var store = Settings();
|
||||
store.Write(new ClientSettings(TerminalKeyboard: new TerminalKeyboardSettings(
|
||||
Bar: ["esc", "from-the-future", "up"],
|
||||
GroupOrder: ["vim", "nonsense"])));
|
||||
|
||||
var keyboard = Keyboard(store);
|
||||
|
||||
keyboard.BarRows.ShouldHaveSingleItem().Keys.Select(k => k.Key.Id).ShouldBe(["esc", "up"]);
|
||||
keyboard.Groups[0].Title.ShouldBe("VIM");
|
||||
keyboard.Groups.Count.ShouldBe(TerminalKeyCatalog.Groups.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheGesturesArrows_IgnoreALatchedCtrl()
|
||||
{
|
||||
var keyboard = Keyboard();
|
||||
|
||||
Button(keyboard, "ctrl").PressCommand.Execute(null);
|
||||
keyboard.SendArrow(ArrowDirection.Up, 2);
|
||||
|
||||
Shown(sent.Single()).ShouldBe("\x1b[A\x1b[A");
|
||||
keyboard.IsControlLatched.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -480,7 +480,7 @@
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||
"DodoSSH.Client.Transfer": "[1.0.0, )",
|
||||
"Microsoft.TSS": "[2.1.1, )",
|
||||
"Velopack": "[1.2.0, )"
|
||||
"Velopack": "[1.2.161, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.api": {
|
||||
@@ -777,9 +777,9 @@
|
||||
},
|
||||
"Velopack": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.2.0, )",
|
||||
"resolved": "1.2.0",
|
||||
"contentHash": "Rz67gJL619fSBS6omaSINUxyDuwhIxkm5mmubf7uLd5Qgi6LLKaKCha+QFP6n+Bw/UjA0vutnH4JQfYzn6ANtw=="
|
||||
"requested": "[1.2.161, )",
|
||||
"resolved": "1.2.161",
|
||||
"contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Text;
|
||||
|
||||
namespace DodoSSH.Client.Terminal.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// That a session keeps the last of its output for the phone's history to read back, and knows when a
|
||||
/// full-screen program has it.
|
||||
/// </summary>
|
||||
public sealed class OutputTailTests
|
||||
{
|
||||
private static byte[] Ascii(string text) => Encoding.ASCII.GetBytes(text);
|
||||
|
||||
[Fact]
|
||||
public void WhatIsHeld_IsTheLastKilobyte_InOrder()
|
||||
{
|
||||
var tail = new OutputTail();
|
||||
|
||||
tail.Append(Ascii(new string('a', OutputTail.Capacity)));
|
||||
tail.Append(Ascii("stan@box:~$ ls"));
|
||||
|
||||
var held = Encoding.ASCII.GetString(tail.Snapshot().Tail);
|
||||
|
||||
held.Length.ShouldBe(OutputTail.Capacity);
|
||||
held.ShouldEndWith("aaastan@box:~$ ls");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AReadLargerThanTheTail_KeepsItsEnd()
|
||||
{
|
||||
var tail = new OutputTail();
|
||||
|
||||
tail.Append(Ascii(new string('x', 5000) + "the end"));
|
||||
|
||||
Encoding.ASCII.GetString(tail.Snapshot().Tail).ShouldEndWith("xthe end");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheAlternateScreen_IsOnBetweenItsTwoSwitches()
|
||||
{
|
||||
var tail = new OutputTail();
|
||||
|
||||
tail.Append(Ascii("$ vi notes\r\n\x1b[?1049h\x1b[H~\r\n~"));
|
||||
tail.Snapshot().AlternateScreen.ShouldBeTrue();
|
||||
|
||||
tail.Append(Ascii("\x1b[?1049l$ "));
|
||||
tail.Snapshot().AlternateScreen.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASwitchSplitAcrossTwoReads_IsStillSeen()
|
||||
{
|
||||
var tail = new OutputTail();
|
||||
|
||||
tail.Append(Ascii("$ less log\r\n\x1b[?10"));
|
||||
tail.Append(Ascii("49h"));
|
||||
|
||||
tail.Snapshot().AlternateScreen.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASwitchAtTheStartOfAHugeRead_IsNotLostWithTheBytesThatFallOffTheTail()
|
||||
{
|
||||
var tail = new OutputTail();
|
||||
|
||||
// A full-screen program's first frame: the switch, and then a screenful of paint.
|
||||
tail.Append(Ascii("\x1b[?1049h" + new string('#', 60_000)));
|
||||
|
||||
tail.Snapshot().AlternateScreen.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OtherPrivateModes_AreNotTheAlternateScreen()
|
||||
{
|
||||
var tail = new OutputTail();
|
||||
|
||||
// Bracketed paste and cursor visibility, which every prompt sends.
|
||||
tail.Append(Ascii("\x1b[?2004h\x1b[?25h$ "));
|
||||
|
||||
tail.Snapshot().AlternateScreen.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -203,12 +203,22 @@ function FakeTerminal(options) {
|
||||
this.focuses = 0;
|
||||
this.disposed = false;
|
||||
this.pane = null;
|
||||
this.addons = [];
|
||||
|
||||
// The proposed unicode API, at xterm's own default, so a test can see which table the page picked.
|
||||
this.unicode = { activeVersion: '6' };
|
||||
|
||||
terminals.push(this);
|
||||
}
|
||||
|
||||
FakeTerminal.prototype.loadAddon = function () {};
|
||||
FakeTerminal.prototype.open = function (pane) { this.pane = pane; };
|
||||
FakeTerminal.prototype.loadAddon = function (addon) { this.addons.push(addon); };
|
||||
FakeTerminal.prototype.open = function (pane) {
|
||||
this.pane = pane;
|
||||
|
||||
// Taken at open() because that is when the first frame is laid out: a table chosen afterwards would leave
|
||||
// whatever was drawn first measured with the old one.
|
||||
this.unicodeAtOpen = this.unicode.activeVersion;
|
||||
};
|
||||
FakeTerminal.prototype.attachCustomKeyEventHandler = function (handler) { this.keys = handler; };
|
||||
FakeTerminal.prototype.onData = function (handler) { this.data = handler; };
|
||||
FakeTerminal.prototype.onResize = function (handler) { this.resized = handler; };
|
||||
@@ -242,6 +252,12 @@ var FitAddon = {
|
||||
},
|
||||
};
|
||||
|
||||
var Unicode11Addon = {
|
||||
Unicode11Addon: function () {
|
||||
this.kind = 'unicode11';
|
||||
},
|
||||
};
|
||||
|
||||
var WebglAddon = {
|
||||
WebglAddon: function () {
|
||||
this.onContextLoss = function () {};
|
||||
@@ -489,6 +505,16 @@ function typed(sessionId) {
|
||||
return terminalFor(sessionId).inputs.join('|');
|
||||
}
|
||||
|
||||
/** Which character-width table the pane was opened with. */
|
||||
function unicodeAtOpen(sessionId) {
|
||||
return terminalFor(sessionId).unicodeAtOpen;
|
||||
}
|
||||
|
||||
/** Whether that pane loaded the Unicode 11 addon. */
|
||||
function hasUnicode11(sessionId) {
|
||||
return terminalFor(sessionId).addons.some(function (addon) { return addon.kind === 'unicode11'; });
|
||||
}
|
||||
|
||||
/** How many times that pane has been asked for the caret. */
|
||||
function focuses(sessionId) {
|
||||
return terminalFor(sessionId).focuses;
|
||||
|
||||
@@ -94,6 +94,14 @@ internal sealed class RendererPage
|
||||
public string Typed(uint sessionId) =>
|
||||
engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"typed({sessionId})")).AsString();
|
||||
|
||||
/// <summary>Which character-width table that pane was opened with.</summary>
|
||||
public string UnicodeAtOpen(uint sessionId) =>
|
||||
engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"unicodeAtOpen({sessionId})")).AsString();
|
||||
|
||||
/// <summary>Whether that pane loaded the Unicode 11 addon.</summary>
|
||||
public bool HasUnicode11(uint sessionId) =>
|
||||
engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"hasUnicode11({sessionId})")).AsBoolean();
|
||||
|
||||
/// <summary>How many times that session's pane has been asked for the caret.</summary>
|
||||
public int Focuses(uint sessionId) =>
|
||||
(int)engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"focuses({sessionId})")).AsNumber();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace DodoSSH.Client.Terminal.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The renderer page measures characters with Unicode 11's widths, so an emoji takes the two columns the
|
||||
/// remote counts for it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What is asserted is that the page asks for the table, and asks before the first frame. Whether xterm then
|
||||
/// really draws 😀 across two cells is xterm's business, and the fake terminal in the harness is a stub with
|
||||
/// a memory, not an emulator; <c>docs/manual-checks.md</c> 1.13 is where a person looks at the real one.
|
||||
/// </remarks>
|
||||
public sealed class RendererUnicodeTests
|
||||
{
|
||||
[Fact]
|
||||
public void APane_LoadsTheUnicode11Addon()
|
||||
{
|
||||
var page = Opened();
|
||||
|
||||
page.HasUnicode11(7).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Before open() and not after it: a table chosen once the pane is open leaves whatever it drew first
|
||||
/// measured with Unicode 6, which is the drift this exists to stop.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void APane_IsOpenedWithUnicode11WidthsAlreadyActive()
|
||||
{
|
||||
var page = Opened();
|
||||
|
||||
page.UnicodeAtOpen(7).ShouldBe("11");
|
||||
}
|
||||
|
||||
private static RendererPage Opened()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
page.Do("accept(0)");
|
||||
page.Do("openSession(7)");
|
||||
|
||||
return page;
|
||||
}
|
||||
}
|
||||