c59b517fdfa4a649b3e1f870bd97d7d23a6e8c29
23
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c8507b44fe | Give the application a settings area built from what really exists | ||
|
|
43c939b697 | Give the window its v5b chrome and each session surface its own shell | ||
|
|
281e828e25 | Sweep out the group-card navigation nothing reaches any more | ||
|
|
dca2e888d6 | Redraw quick connect in v5's shape, auth word and all | ||
|
|
2ba7c14e35 | Restyle the drawer, pin folders on a host, and say when it was last connected | ||
|
|
369109dd4e | Write the disabled accent glow as 'none', which this Avalonia can parse | ||
|
|
bea0279937 | Repaint both heads in the v5 palette and embed its three fonts | ||
|
|
507cd9ff88 |
Choose more than one host card on the desktop, the way the phone already can
The chosen-hosts set has been in VaultViewModel since the phone's connect card became a contextual action bar: a set of entity ids, a tick on the row, and seven things that can be done to it. Only one head could fill it. The desktop's grid answered a press with one selection — the card the drawer, CONNECT and the context menu are about — so filing eleven imported machines under a heading was eleven drags, and clearing out a vault was eleven rounds of the deletion question. So the pointer gets three ways into the same set. Ctrl-clicks a card to tick it, Shift-clicks to tick the run between the anchor and the card, and drags a band out over the space between and below the cards to tick everything it touches. Esc, CLEAR, a plain click on a card and a click on the empty space each drop it, and Ctrl+A takes every card being drawn — VisibleHosts, so with something in the find box that is the ones on screen and not the ones it is hiding, which is the version of that shortcut whose result can be checked before Delete is pressed. TWO SELECTIONS ON ONE SCREEN, AND KEEPING THEM FROM DISAGREEING IS MOST OF THE CHANGE. Ctrl and Shift are answered on the tunnel and marked handled, so the ListBox never moves its own mark onto the card: a Ctrl-click that also selected would light the card it had just unticked and open the drawer on a machine somebody is removing from a set. A plain press drops the set unless it lands on a ticked card, and that case is deferred to the release, because the press may be the start of a drag of all of it. After any ordinary click exactly one card is in play, which is what makes every command on the screen unambiguous again. The context menu is where the seven live, and it is one markup with two halves gated on IsChoosingHosts. Connect, Browse files and Edit… are drawn only for a single ticked host, as the phone's sheet collapses them and for the same reason; the other four read better for a count. A right click on a card outside the set drops the set first, so a Delete… about the card under the pointer can never be offered while six sit ticked behind the menu — the same rule OnContextRequested has always enforced for the selection, reached from the other direction. No bar of buttons: the phone raises one because it has no other way to hold seven entries, and a strip repeating a menu that already exists would be a second home for the wording that matters most. What the desktop gains instead is a count beside the HOSTS heading, CLEAR, and a sentence saying where the actions are. A drag that starts on a ticked card carries every ticked card. The payload is a list rather than a row now, and a drop of more than one goes through FileChosenHostsUnder, which makes the refusals once — an open editor, and a group belonging to one keychain — and reports a count instead of forty status lines. Moving whichever card the pointer happened to be holding and leaving the other five where they are is a gesture that quietly does a fraction of what it looks like it does, and the five left behind look filed. The three panels the set's actions raise had never been drawn in a window: the vault picker with its key question, the group picker, and the deletion question. All three sit above the grid rather than over it, which is the arrangement the GROUPS section and the phone's list already use and for the reason written there — the ticked cards are the information the question exists to give, so the grid shortens instead. A DEFECT FOUND BEHIND IT, AND IT WAS ALREADY LIVE ON THE PHONE. The deletion question names a count and the run that answers it reads the set again, and nothing kept the two the same set: the panel is deliberately above a live list, so one more tick between "Delete these 6 hosts?" and pressing DELETE deleted seven, with the seventh named in nothing the user had read. It needed a deliberate act on a phone and a second's work with a band, which is what turned it up. VaultViewModel now remembers which hosts the question was asked about and drops the question when the set stops being them — the question rather than the set, because what somebody has just chosen is what they meant. It also covers the case nobody performs: a colleague's deletion arriving mid-question and shrinking the set under it. VERIFIED. 354 tests in App.Tests and 111 in App.Layout.Tests, build clean, no new warnings. Six gesture tests drive real pointer and key input through the headless window — the modifier click and what it must not do to the selection, the run and its re-measurement from the anchor, the band and the click that drops the set, Ctrl+A under a filter, and the menu's two halves — plus a DragOver carrying two hosts. Four layout tests measure the strip and the three panels at the window's minimum; the vault panel binds a key to its host first, or it would measure the short shape and certify the tall one. Two flow tests cover the multi-drop's write and its refusal, and the deletion question dropping itself. manual-checks gains 7.6a for dragging a set, which no test can see for the reason 7.6 gives, and 7.7a for the gestures — the rectangle actually being painted and the tick and the fill being legible together are the two things the harness cannot look at. |
||
|
|
7b7fd7b2ef |
Make a vault the thing you create, and let a window set one aside
Everything a shared vault needs was already here and arranged the wrong way round. A vault has to belong to a team, so creating one meant going to the teams screen, founding an organisation, and only then adding a vault to it — which the NEW VAULT button named after the team, so a team with three of them held three vaults called the same thing and nothing told them apart. Somebody who wants to share four servers with two colleagues is not asking to found anything. So the form asks for a name and nothing else. The team is derived from it, slug included, and created with this account as its owner; the vault goes inside; and the members, roles, invitations and key holders that hang off a team are all on screen the moment it exists. The tab strip's New vault entry lands there with the new vault selected, which is where the next thing anybody wants to do already is. That is two calls, and the first can succeed alone. When it does the team is kept: the id is minted once into pendingVaultTeamId, so pressing CREATE again resends the identical create — which the server treats as the same team — and retries the vault, and the message says all of that rather than "creating the vault failed". Archiving the orphan instead would be a client deleting something on the user's behalf because a later step failed, which is the kind of tidying that eventually archives a team somebody has just been added to. A slug taken by somebody else is retried once with a disambiguated one and never in a loop; a name with no a-z or 0-9 anywhere in it falls back to the team's own id rather than to a refusal pointing at a field nobody was shown. The other half is the caret beside Vaults. Being in four teams means four teams' machines in front of you all day, and the answer is a switch per vault rather than four sign-ins. Switching one off takes its hosts, groups, keys and pins off the screens that list them and does nothing else: it still syncs, its key stays in the keyring, it stays choosable as somewhere to file a new item, and a shown host that authenticates with a key filed in it still connects. That last one is what shaped the design. TryBuildAuthentication resolves a binding out of the keychain's typed list and a cross-vault binding is legal, so filtering the reload loops — the obvious implementation — would have turned a preference about reading into an outage. Only the projections a person reads consult IsVaultShown; every Reload*Async stays whole, including the dialled-endpoint set that decides which pins are described as unused, because that is a hint which invites deleting trust. Snippets, logs and buckets needed no code and the comment says so out loud: all three read ActiveVaultId alone, and the personal vault is drawn in the menu ticked and cannot be switched off — it is the active vault, the group and tag editors' target, and the save picker's fallback, so hiding it would empty half the application rather than filter it. The preference is a column on the cache's vault row, which is what makes it survive both a relaunch and the /me refresh that runs every minute: Apply does not touch it, deliberately, because the server has never been told which vaults this machine is showing. It is in the encrypted cache rather than settings.json because it is a list of vault ids and that file's own doc comment says what may go in it. VaultSession cannot see the type at all — ReadableVaults is what the sync loop walks, and a filter reaching it would be a vault that quietly stopped syncing, found out weeks later from a host that was never there. The strip's note refusing a MenuFlyout stands and is unchanged. This flyout sidesteps the question rather than answering it: the handler selects the Vaults tab first, which collapses the renderer, so nothing native is under the popup by the time it opens — the move QuickConnect already makes. A headless test asserts that ordering, which is as far as headless can go with no native window, and manual check 1.6 is the other half. The phone is out of scope on purpose: it has no tab strip and its teams screen's vault section is read-only. The plumbing is in Client.Shell, so it can adopt this later; until then nothing there is ever hidden, which is today's behaviour. 1514 tests pass. Fifteen are new in VaultVisibilityTests, and the ones worth naming are the guards: a hidden vault still syncs, still holds keys that authenticate hosts on screen, still appears in the save picker, and still counts towards which pins nothing dials. Not fixed, and noted here because it is next door: VaultGrantService's team-vault create refuses a taken vault id rather than returning the existing vault, while VaultSharing's own remark claims a create whose response was lost is safe to resend. A lost 200 therefore leaves a vault whose key the client's catch already zeroed, openable by nobody. |
||
|
|
e3dfe5c371 |
Make the host pane something you ask for, and draw it as cards
THE DRAWER USED TO ARRIVE WITH THE SELECTION. IsDrawerOpen read "a host is selected", so touching any card took 304 pixels off the grid — including every card arrowed past on the way to the one somebody wanted. Choosing among forty machines was charged the price of the pane for one of them. A pencil now appears on the card under the pointer and on the selected card, and that is what opens it; IsHostPaneOpen is the flag, and the grid's context menu gained Details… so the pane is reachable without a pointer, which a hover-only control is not. Once open it follows the selection rather than pinning the host it was opened on: a pane about one host beside a grid marking a different one is two answers to the same question. Losing the selection closes it and clears the flag, or a filter matching nothing would leave the pane armed to spring open again on the next card merely selected — which is the behaviour the pencil exists to remove. The pencil is drawn over the card rather than in a column of its own. A column would have cost the name 30 pixels of a 232-pixel tile, permanently, for a control that is only there while the pointer is; the dot and the pencil stack in the two corners of that edge instead. IsVisible and not opacity, because a button at zero opacity still takes the click and the card underneath does not. A HEADER, A BODY THAT SCROLLS, AND A FOOTER, which is the one structural change in the pane. The header names what the drawer is about and carries the two things true of every panel; the footer carries the one thing each panel is for — CONNECT, or SAVE, or the question about deleting. Only the middle scrolls, so the button somebody came here to press can no longer be below the fold, which CONNECT could be on a host with fifteen tags. That also widens what the layout harness certifies: it skips anything inside a ScrollViewer, and the control each panel exists to offer is now outside one. THE SAME THREE CARDS TWICE. Address, General, Connection — first as rows stating what the host is, then as boxes for changing it. The detail pane's rows are buttons that open the editor: the design draws every fact as a filled box, and rather than draw an input that refuses the pointer, pressing one leads to the same card with a real box in it. Nothing here saves as you type, and that is not timidity — saving validates the key-or-credential exclusion and writes one encrypted payload, so a box committing per keystroke would be a save per character and a half-typed hostname on the wire. Every value the pane prints is the resolved one, and says "inherited" beside it where a group supplied it. The number is the same either way and the edit is not: clearing a group's default moves every host that never overrode it. A HOST CARD IS TWO LINES AND NO CHIPS. The subtitle is now "ssh, root, pci, eu-west-1" — the transport, the resolved account, then every tag — replacing both the user@host:port line and the wrapped row of tag chips under it. The address went to the card's tooltip rather than nowhere: a card is read while scanning forty machines, where the name and the kind of machine are what is being looked for, and an address is what you read once you have found it. "ssh" is a constant today and is printed anyway, which is the one thing here that argues with this codebase's own rule about constants dressed as readings. It is the first item of a list whose other items vary, and a list beginning with the account on one card and a tag on the next has no shape to scan. The remark on HostRowViewModel.Summary says so rather than leaving it to be discovered. WHAT THE DESIGN DRAWS AND THIS PANE HAS NOT GOT: Share this host, Add Telnet, "SSH ID, Certificate, FIDO2", the backspace-key mapping row, the vault picker's chevron and Show more. Sharing is per vault and not per item, every session here is an SSH channel, there are no identity or certificate item types, nothing carries a terminal setting to the renderer, and an item cannot be moved between vaults at all. Six controls with nothing behind them, listed in docs/design-import-gaps.md with what ships instead, and none drawn disabled. The credentials row is marked with ◆ rather than the ⚿ the nav rail uses for the keychain. U+26BF is outside both faces this application substitutes for the design's fonts, so it lands on whatever the platform's fallback has; every other glyph in the pane is from Geometric Shapes, which both carry. |
||
|
|
f9d08b738c |
Take the group headings out of the host grid, and drop onto a group card
A first group turned the wrap of host cards into an accordion: SidebarRows interleaves a full-width fold-away heading — chevron, name, count — between the cards, and in a grid that reads as a dropdown somebody left open. The desktop grid binds VisibleHosts now. The headings and the fold stay for the phone, whose list has no room for the row of group cards the desktop draws above the grid. What a heading said, the card says: HostRowViewModel.GroupLabel, resolved once per reload like the tag names, drawn as an accent chip and absent from a host in no group — or in one that has been deleted, which is the same thing to look at. What a heading also was is the drop target, and that moves to the group cards. Two things go with it. A host dropped onto another host card used to be filed beside it, which was legible while a heading named the band of cards it landed in and is guesswork now; it is refused. And UNGROUPED was how a host was dragged back out of a group; the way out is the picker in its own editor, which is the one place "no group" can be said in words. A drag held at either edge of the grid scrolls it. Without that the gesture only works for whoever can see both ends of it: the group cards are the first thing in the scroller, the host may be the fortieth card down, and a drag cannot use the wheel. A step per drag event rather than a timer, so it follows the pointer and stops when it stops. The two heading-shaped tests are replaced. TheHostsGridHoldsCardsAndNoGroupHeadings asserts the grid's contents rather than only measuring them, because a heading that came back would lay out perfectly cleanly. TheGroupCardsAreWhatAcceptsADroppedHost raises a real DragOver over both kinds of card and checks the effects and the mark — the nearest a headless test gets to a gesture no headless test can synthesise. manual-checks 3.1-3.2 and 7.6-7.9 follow. |
||
|
|
208443b932 |
Move SFTP and S3 into the tab strip, and the host list into a card grid
Four asks in one pass over the desktop head, and two of them are furniture moving rather than anything new. THE STRIP IS THE WINDOW'S NOW, not the terminal's. Vaults, SFTP and S3 sit at its head and cannot be closed, and open terminals follow them. SFTP and S3 left the nav rail to get there, which is the one semantic change: they are the two destinations you stay in while something runs, and a rail entry is for somewhere you go and come back from. So the rail belongs to the Vaults tab and collapses with it, which also means SFTP, S3 and a terminal each get the full 1016 rather than the 826 a page gets. The tab is expressed as "a page, and not one of the two the strip took" rather than as a fourth ShellSurface. Both are still ShellScreen members and have to be — that is what they are on the phone, where they are two rows in a hub — so a surface each would have been a second way to say a thing Screen already says. IsTransfersShowing and IsBucketsShowing light the other two tabs unchanged. What is new is one field: the page Vaults comes back to, because it is the one tab with sub-navigation and therefore the one that can return to the wrong place. That is not the hidden field ShellSurface argues against — that one would be a second copy of "which page"; this is a tab remembering its own. THE HOSTS SCREEN IS A GRID, and the 268-pixel sidebar is gone. That column was choosing among forty machines and editing one of them at two-thirds width, and it was narrow so the editor beneath it could be a column at all. Cards took the first job at full width; a 304-pixel drawer took the second and collapses when nothing is selected. Pressing a group card narrows the grid; SHOW ALL is the way back. The group editor moved into the drawer as well, which finally makes IsEditingGroup mean the same thing on both heads — it was the phone's alone, because the desktop's editor was a bar that was always on screen. AreHostsExpanded and ToggleHosts went with the control that used them. They folded the whole list away under one heading, an affordance that existed because the column was narrow. Folding a single group is a different thing and is still here. THE TYPE SCALE IS A POINT LARGER and the text ramp is white. The base size was never stated anywhere — a bare TextBlock took TextElement's default of 12 — so raising the scale meant naming it, on Window and on UserControl. The second selector is not redundancy: the layout harness hosts a UserControl in a window it builds itself, and without it the suite would measure every screen a point smaller than it ships, silently. A selector on TextBlock would have been the obvious way and is wrong, because a style setter beats an inherited value and would collapse every deliberate step back to one number. #E3E7F4 is a blue-tinted white on blue-black surfaces, which costs contrast twice — once for being darker than white and once for sharing a hue with what it is drawn on. Pure white is 18.3:1 against the canvas where that was 15.5:1. Every step below moved with the top, so the intervals the design chose are kept and TextDim clears 9:1 against 6.4:1. The palette is shared, so the phone has both changes too. TWO DEFECTS THE HARNESS STRUCTURALLY CANNOT SEE, found by rendering the screen rather than by measuring it, and both now covered. The tile was 232 and was first written as 248, from arithmetic that left out the scrolling stack's own margins. Every layout test passed — the harness asks whether a control is inside the window, never how many fit on a line — so the grid quietly became one column wide at exactly the minimum this application guarantees, which is the shape cards exist to avoid. TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen counts columns instead, and fails at 248. And a card's text ran past its own border, because a horizontal StackPanel measures children with infinite width: a TextBlock inside one never learns it is short of room, so TextTrimming never fires. Both card rows are grids with a star column that gives way and an Auto column that does not — a hostname with its tail cut is still the machine you were looking for, where a badge or the word naming an auth method is not. The keychain header changed shape for the same class of reason. It was Auto,Auto,*,Auto with the buttons last, so the slack column was the only thing absorbing a change of width and five buttons fell off the right edge the moment the type grew. That is how GENERATE lost the word KEY once already. The summary sits in the star column and trims now, so the buttons always get their width. HostSidebarTests became HostGridTests and moved to the grid with the gestures it drives. docs/design-import-gaps.md gains a v3 section naming the five toolbar controls in the design with nothing behind them — a view-mode switch, a tag filter, a calendar, a share control and Serial — and manual-checks.md and the README follow the controls that moved. |
||
|
|
16e0051e89 |
Draw the tags that have been storable and invisible since the domain landed
`Tag` has been a full item kind for three commits — a table, a migration, a codec, a merge, a cipher — and `HostSecret.TagIds` has merged per tag so two people tagging one host both keep theirs. Nothing drew a chip. The tags a client could store were ones nothing here could see. Chips on host rows, both heads, from names resolved through the tag list rather than ids: a tag that does not resolve is left out rather than drawn, because it means the tag was deleted elsewhere or belongs to a vault this session cannot read, and a host with one chip fewer is the honest answer where a host wearing a GUID is not. The id stays on the host, so the chip comes back if the tag does. The picker is chips that toggle, matching the chips on the row behind it. A list of names to tick would make the user match an entry to a chip they can see two inches away. The box under it creates a tag and puts it on straight away, because that is when a tag is usually wanted — while tagging a host and finding it does not exist yet. Unlike every other field in that editor it writes to the keychain immediately, since a host can only name an id that exists; cancelling therefore leaves the tag behind, which is honest rather than hidden. A name that already exists is used rather than repeated: two tags called "staging" are storable and must stay storable, because two people creating one offline is how it happens, but typing it into a box beside a chip of the same name is a slip. Renaming and deleting needed a home, or the picker fills with names nobody uses and never empties. That home is a TAGS category on the keychain screen, where every other item kind is managed — and renaming is the whole reason a tag is an item rather than a string repeated inside twenty payloads: it is one write, and no host is touched. The delete confirmation counts the hosts wearing it, which is the difference between a tidy-up and losing a filter somebody relies on. The desktop host editor now scrolls, and that is not a tidy-up. A picker's height is a chip per tag in the keychain, wrapped, so somebody with fifteen tags has an editor half again as tall as somebody with three; no fixed height holds that, and trimming other fields to buy room only moves the failure to whoever has sixteen. The layout suite caught it the moment its seeder grew tags — which is why the seeder now creates ten rather than three, enough to drive the pane onto its cap so the capped shape is what gets measured rather than one no real keychain produces. The cost is named where it is paid: the harness skips anything inside a ScrollViewer, so from here it certifies that pane fits the column rather than that every field in it does. Two smaller things fell out. Five buttons overflowed the keychain header by a few pixels, so GENERATE lost the word KEY — its tooltip carries what the word did. And TotalItemCount had been counting keys and credentials while ALL showed four kinds; it counts all five now, because a number under a chip that disagrees with the rows it opens is worse than no number. An adversarial review of this change found two defects it had introduced, both green against the full suite. NewTag filed into the "new items go to" picker while the tag list only ever holds the active vault's — so with a team vault selected a tag would be created, queued for push, reported as added, and then invisible, with no row, no count, no picker entry and nothing able to rename or delete it, because there is no active-vault switcher to go and find it with. The comment on the host editor's own create path states that exact rule; this was the one place that broke it, and NewObjectStore, whose list is likewise active-vault-only, already ignored the picker. And the tag editor was the only one of five that did not disarm a pending deletion when it opened, so arming a key's deletion and then pressing + TAG left a live DELETE for an item the user was no longer looking at, directly above the boxes they were typing into. Both are fixed, both have a test, and the first was checked against the broken version before being kept. The same review caught a doc comment that had been inserted between SnippetRowViewModel's summary and its declaration, silently taking it over. Verified by the whole suite on a clean build: 1413 tests over nineteen projects, none failing. Both heads build. The rectangles the layout suite cannot reach are phase 9 of docs/manual-checks.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
359087f1ce |
Give the tabs their hover back, and the plus the shape its comment claims
Two things v2 broke in the last commit, both found by reading resolved brushes rather than markup, and both the same mistake: Avalonia has no specificity, so the later declaration wins, and a rule that restated the base instead of excepting from it went in below the rules it was supposed to be underneath. Making a tab a pill gave it a Background of its own, which the flat tab it replaced never had. That one detail moved where the hover has to live. `Button.flat:pointerover` is declared far above and had been supplying it; the moment `Button.tab`'s template rule set a Background, it won, and every tab in the strip stopped answering the pointer. Silently — a tab that no longer lights is not a crash and not a layout change, and nothing in a suite that measures heights and reachability can see it. The `+` lost more than that. It sits below as an exception — no outline, because it is not one of the things being chosen between — and a `.tab` rule declared after it was overriding the exception itself. It drew as a filled, outlined pill identical to a tab, contradicting the comment directly above it. So the base pill and its hover come first now and the exceptions follow, which is the order the rest of this file already uses and the order the Border.rowmark note further down was written about. The `+` clears the fill as well as the border, because an exception to a rule that sets both has to say both. The titlebar's search box had the same shape of error in geometry rather than colour. The design draws it at exactly 380 and centred, and stating that as a Width on the inner Border is what made it wrong: the button around it is free to shrink when the account name or the vault chip beside it is long, and a Border that will not shrink with it arranges outside its own parent — over the name on one side and over the window buttons on the other. MaxWidth on a stretching button gives the same 380 whenever there is room and gives way when there is not. The regression test is the point of this commit rather than an afterthought. It is the only test in that suite that reads a brush, and the gap it fills is exactly the one these two went through: everything else measures rectangles. It hovers a tab through the real input path and asserts the fill changes, then asserts the `+` is neither filled like a tab nor outlined like one. Checked against the broken ordering before being kept — it fails there and passes here, which is the only thing that makes a regression test worth committing. Verified by the whole suite: 1310 tests over nineteen projects, none failing, the layout suite now 70 cases. Both heads build. Still nothing seen on a display. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2 |
||
|
|
3627021420 |
Give the desktop the second design too, and the window the size it now needs
The desktop v2 design is the other half of the one the phone took last commit, and this is its chrome: a 190-pixel labelled sidebar where the 54-pixel icon rail was, a titlebar with the search box centred in it, and session tabs drawn as pills. The palette was already here — it is shared, and moved when the phone's did — so what this changes is shape rather than colour. **The window's minimum grew, and by exactly what v2 added.** The sidebar is 136 wider and the chrome 14 taller, so 880x560 became 1016x574. That is not a round number somebody liked: it leaves every screen the same 826x464 it was designed against, which is the arithmetic the layout suite is built on. Four of the tables stop fitting at 690 wide, so widening the sidebar and leaving the window alone would have broken them somewhere no test was looking. LayoutHarness carries the new constants and the suite still passes at the minimum, which is the whole reason it exists. The rail's five-character abbreviations are gone with the width that caused them — PINS and SNIPS are Pins and Snippets again — and each row gains a glyph and a count. A count is drawn only where one is real, so SFTP, Logs and Preferences show nothing rather than a zero: a transfer queue's depth is not how many files a screen holds, and a log has no total until it is read. The count beside Pins is the vault's own, not the Pins screen's VisiblePins, which is the filtered list and would have made the sidebar count whatever somebody had just typed into a filter box on another screen. Teams has no count for a related reason: they are read from the server when that screen is opened rather than on unlock, so a number there would read 0 until somebody had already been to look. One colour moved with it, finishing what the repalette started: the live-session summaries on the unlock and sign-out cards were Info, so the two heads disagreed about a fact the phone paints green. They match again. **Buckets became a destination rather than a mode**, which is what the design draws and what the phone already does. The HOST / BUCKET pair inside the files screen is gone; ShellScreen.Buckets draws the same TransfersScreen with the other picker, and the sidebar entry is what sets it. That also settles an old disagreement rather than merely moving it: TotalItemCount is keys plus passwords and excludes buckets, so the number beside the keychain used to disagree with the list under it, and now counts what that screen shows. There is one session behind both file destinations, so asking for the other kind while something is open is refused rather than obeyed — and refusing means staying put. An earlier turn of this had it move anyway and only decline to switch the picker, which put the S3 entry in the sidebar over a screen still listing an SFTP host: two pieces of chrome disagreeing about where you are, which is worse than the navigation simply not happening. The message that says so goes to Transfers.Status, which turned out to be drawn in the same grid cell as the connected chip — survivable while it was mostly read before connecting, and not once a refusal reports itself there. It has its own column now. The design has nine entries' worth of screens and draws five. Pins, Teams, Import and Preferences are built, working screens, so they keep their entries — the sidebar is labelled now and has the room, and dropping an entry would have stranded a screen rather than simplified anything. The Team vault card the design pins to the foot is not drawn: it is a second route to a screen already in the list, carrying a seat count nothing here produces. **The status bar survives the design that deletes it**, cut down to one thing. Two of the three facts it carried moved into the titlebar with v2 — the sync word is beside its dot and the shortcut hint is inside the box that uses it — so those are gone from it rather than printed twice. The third is Vault.Status, the only channel this application has for saying a save failed or a merge picked a winner. The design is a mock-up of an afternoon that goes well and has nowhere to put a sentence like that; dropping the bar would have meant dropping the sentence or repeating it on nine screens. What v2 draws and this does not is in docs/design-import-gaps.md, and it is the same list as the phone's for the same reasons: the forwarding screen and both its chips, the host detail's fingerprint, tags and last-session cards, the keychain's rotate button, the logs' FOLLOW pill and severity filters, and the session footer's latency. The terminal is not inset behind a rounded frame either — it is a native child window that composites above everything Avalonia paints, so the frame would clip nothing, which is the same answer the phone gave. **The light theme is not built.** Its accent is #6D5AE6, a different hue rather than a tint of the dark one, so it needs every colour doubled, a variant to switch on, the renderer's own page switching with it, and contrast checked twice. That is a piece of work rather than a setting, and it is separable from the layout — which is why this commit is the layout. The screens themselves are restyled through the shared vocabulary rather than rebuilt: corner radii, chips, cards and the accent's ink, all in App.axaml, so every screen moves at once. Their layouts are left alone deliberately. The design draws read-only detail panes and these screens carry the editors and forms it has no equivalent of, so replacing a layout with the mock-up's would have lost the half that is actually used. Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the 68 layout cases that stand up real Avalonia and measure every screen at the new minimum. Both heads build. Not run on a machine with a display — see docs/manual-checks.md for what wants looking at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2 |
||
|
|
5593f337b6 |
Give the phone the second design, and both heads the palette it arrives with
The Android v2 design is what this head draws now: four destinations in a bottom bar — Hosts, Terminal, Keychain, More — with snippets, SFTP, S3, logs and preferences one tap deeper behind the last. The first design's four had nothing behind them, which is what made a hub worth building. The palette moved from green-black to blue-black, and it moved in the shared project because that is where it lives and the desktop v2 specifies the same seventeen tokens. One colour changed meaning rather than value, and it is the only semantic change in the file. Green used to *be* the accent, so Ellipse.dot.live filled with Accent and "the thing to press" and "a shell is open on this host" were the same colour by construction. v2 makes the accent blue and keeps a green for status alone, which finally separates them: Live is that green and nothing merely interactive may use it. The accent is also two colours now — Accent fills, AccentText writes — because a row of chips in the fill colour is a row of things that all look like the primary action. A palette is not one file, which is the part worth knowing before the next one. Nine hex literals lived outside it: the nav bar's own label colours, the accessory keys and their Ctrl-latched state, two scrims, the window background Android paints before Avalonia has a frame, and the launcher vector. The two C# sites now resolve from the dictionary by name rather than restating it. The renderer's page cannot — it is served to a WebView over a loopback socket — so terminal.css and terminal.js keep hand-copied values and say so at both sites. ShellScreen gained More and Buckets, appended rather than slotted in. SFTP and S3 are one screen over one TransfersViewModel differing only in which picker they offer, and the kind is set by the button that navigates rather than on arrival — doing it in OnScreenChanged made every arrival at Transfers force the picker back to hosts, including the desktop's own rail arriving at a screen with a bucket already open. It refuses to change kind while a session is live, because there is one session behind both destinations and switching under it would title a screen S3 while it listed an SFTP host. What the design draws and this does not, on the usual grounds. The FORWARDING screen: nothing here forwards anything, so every toggle would be a control with no effect — it is a paragraph on the hub naming the absence, for the reason the desktop keeps TEAMS in its rail. The terminal's `23 ms · fwd 5432`. An ED25519 badge and a SHA256 line on keychain cards, which need an algorithm field and a fingerprint the item type does not have. An `agent` chip, for an agent that does not exist. Snippet run history and exit codes. The Logs FOLLOW pill, which claims a live tail over records that are written once at close and read when the screen opens, and the severity filter, which has nothing to count — that chip row is spent on the real choice, which of the two logs. S3 bucket totals and lifecycle. And the + on HOSTS, which would open a host editor this head has not got. SFTP is browse, open and delete. Both transfer commands work, and what they work against is the local pane: QueueDownloads writes to Path.Combine(LocalPath, name), and LocalPath starts at SpecialFolder.UserProfile, which on Android is the application's own private directory. A download would have reported success and left the file where the person who asked for it cannot open it, which is worse than not offering it — a refusal is visible and a file in /data/user/0/ is not. The queue is not drawn either, since nothing here can put anything in it. Both return with the document picker. The foreground service still counts zero transfers, and the reason moved rather than went away. Four defects worth naming, because three of them are the kind that compile. A Button as a ListBox ItemTemplate swallows the pointer press before the list sees it, so the files listing selected nothing and every command reading the selection did nothing — the row is a Border now and the phone-only single-tap-to-open is a Tapped handler, which also keeps a desktop single click from walking into directories. Avalonia type selectors are exact, so TextBlock.fingerprint never matched SelectableTextBlock and every fingerprint on this head rendered proportional and unwrapped: that was breaking the never-truncated rule on the host-key sheet already. The new two-level hierarchy had no handler for the system back gesture, so back left the application from a log screen. And the tab's close cross had shrunk to a 30x32 target flush against the select target, which is the one control here that ends a shell with no confirmation and no undo. Fingerprint unlock is raised on arriving at the lock screen rather than waiting for its button, which is still there. Only at launch: a lock the user asked for is not answered with an immediate request to unlock, which makes LOCK look inert and trains the reflex of authenticating at a prompt nobody asked for. And once, because a declined gesture leaves the passphrase box exactly where it was and a prompt that came back after being dismissed would be a modal you cannot get out of to type into it. Two fixes fall on the desktop. Its file listing coloured directories with Info and executables with Accent, which was blue against green and is now two steps of one blue; an executable is Live now. And a bucket's folders were drawn with a 0001-01-01 timestamp, because a prefix has no modification time — blank now, for the reason a directory's size is blank. Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the layout suite that stands up real Avalonia and parses every desktop screen. Both heads build. Not verified on a device — nothing in this head ever has been; see docs/android-port.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2 |
||
|
|
4300d917a8 |
Stop making people wait for a handshake, and give the host list a pointer
Connecting held the vault's busy gate, which meant a window that did nothing visible for as long as a machine took to answer — and against one that is merely asleep, that is the whole timeout. The gate is gone from that one command. A tab now appears in the strip in the same turn as the click, carrying "connecting…" rather than a pane, and the terminal's rectangle draws a card naming the host and the address being dialled. Every other screen stays usable, and two connections can be in flight at once. That splits the vault's one connection event into three, carrying an attempt id, because "which tab is this about" can no longer be answered by "the most recent one". The id also buys the two kinds of not-connecting their different endings: a refusal stays in the strip as a tab holding its reason, since by then the user is quite likely three screens away and a status line they are not looking at is not where a failure should end; a host key question takes the tab away and puts the window back on HOSTS, because the prompt is drawn there and a tab claiming failure would be competing with the thing about to resume it. ConnectAsync takes no CancellationToken any more, and that is load-bearing rather than tidying. A [RelayCommand] over a method that takes one generates a command that cancels the previous execution's token on every invocation — so asking for a second machine silently abandoned the first, measured as the first tab disappearing with "Cancelled." the instant the second was asked for. Giving up on a connection is closing its tab, and a session that lands after that is adopted rather than dropped: a shell running with nothing naming it cannot be closed at all. A tab is marked active on IsShowing rather than IsSelected. The selection survives navigating away — that is what makes the strip a way back to a terminal instead of a way to lose one — so a tab lit while preferences filled the window was a second "you are here" mark pointing at something nobody could see. The nav rail's own entries have always made this distinction. The host list grows the two gestures it looked like it already had. A right click selects the row under the pointer before opening a menu of Connect, Edit and Delete — the menu is on the list rather than in the item template, so its entries are the vault's own commands and not a row's, and it is cancelled outright over a group heading. Dragging a host onto a heading files it there, onto a host files it beside that one, and onto UNGROUPED takes it out of a group; the write is one field of one host through the same repository a save uses, refused while the editor is open because a drop is a gesture on the list and not on a half-typed form. Clicking a result in the palette connects, which is what a list of hosts under a search box looks like it does. It went through the shell's own command, so the pointer and Enter take one path. And the files screen's two pickers followed the vault's lists once, at unlock: a host or a bucket created afterwards could not be picked until the keychain had been locked and opened again, with nothing on screen explaining why the machine plainly in the host list was missing. They follow the collections now, re-finding the selection by id across the rebuild a sync pass causes every minute. 165 shell tests and 69 layout tests green, including the connecting tab, both failure endings, two connections at once, a connection in flight across a lock, and the right click acting on the row under the pointer rather than on the selection. The drag itself is in docs/manual-checks.md with the rest of phase 7 — headless Avalonia has no platform drag, and a test that claimed to have dropped something would pass while confirming nothing. |
||
|
|
2caedd93ff |
Merge branch 'main' into the Android head
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs, import, teams — plus the ObjectStore and Import projects behind two of them, and moved WindowsDeviceKeyStore into the desktop head's Platform folder. Five of those view models landed in a directory this branch had already moved, so they join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android head then gets transitively and will use neither of at first — scoped storage means there is no ~/.ssh/config to import, and file transfer is out of its first scope. Desktop suites green at 155 and 64. |
||
|
|
fe9d7fc289 |
Give DodoSSH a phone, and a shared shell for both heads to drive
The Android head from docs/android-port.md, taken as far as its step 6. Step 3, the spike, is answered and its throwaway screen is gone: libsodium.so and libe_sqlite3.so are both in the arm64 APK, so NSec resolves its native half on Android despite shipping no Android build, and the local cache opens. Two findings the audit could not have had: Avalonia.Controls.WebView only ships net10.0-android36.0, which settles the open "which Android versions" question at targetSdk 36; and Android has blocked cleartext HTTP since API 28, so the terminal renderer needs a network security config scoped to 127.0.0.1 or the WebView loads nothing. DodoSSH.Client.Shell is new and is why the phone can exist: the view models, the terminal renderer files and the palette moved there so both heads drive one state machine and draw from one set of tokens. The desktop head is otherwise untouched and its 144 tests still pass. The platform pieces behind interfaces that already existed: the profile directory from filesDir, a device key wrapped by a StrongBox-backed key that a fingerprint releases, and a foreground service so a shell outliving a vault lock stays true on a platform that stops backgrounded processes. Sign-in is deliberately absent rather than approximated. It needs an app link, because reusing the desktop loopback listener is the attack RFC 8252 section 8.3 names. |
||
|
|
d07b336868 |
Free the terminal from the Hosts screen, and fill the room it left
The WebView sat inside the Hosts grid, so navigating to Files or the keychain hid every open terminal and the strip that named them. A connection you had opened was invisible from four of the five screens. The window now has two surfaces rather than one: a nav rail that says which page you are on, and a terminal strip that is always there and switches the whole content area to a shell. Screen keeps meaning "which page" and never becomes a sixth kind of page, which is why this is two properties instead of one enum with a terminal member in it. Every screen lives inside one wrapper panel that collapses when a terminal is showing. That is not tidiness — the WebView hosts a Win32 child window that composites above everything Avalonia draws, so a screen left visible over its rectangle is a screen sliced in half, and this window has shipped that defect once already. One decision point, IsTerminalShowing, and a nested panel rather than five compound bindings nobody would remember to extend. The focus choreography is the part no test in this repo can see. Every reveal path now focuses in the same turn the WebView appeared, so all three of them post at DispatcherPriority.Loaded and let the native control re-push its bounds first. Going the other way had a real bug: the screen-changed branch called a bare Focus() where it had to release the keyboard from the native child, so switching from a terminal to Files silently ate the first keystrokes. Rare before this commit and the primary gesture after it. The tab strip grew a cross inside each tab, a plus that opens the quick-connect palette, and middle-click close. Nested buttons are correct here: Avalonia handles a left press on the cross and deliberately does not handle other buttons, which is exactly what lets middle-click bubble up from the cross as well as the tab. The test is PointerUpdateKind rather than IsMiddleButtonPressed, because the latter reports button state and is also true for a left press made while the middle button happens to be held. The handler is on the tab and not the strip, so the background closes nothing by construction. Plus opens the palette rather than a flyout, since a menu dropping into the WebView's rectangle may or may not composite above a child HWND and this repo does not make rendering claims it has not photographed. Everything a user reads now says keychain. The wire, the database and the cryptographic spec still say vault, deliberately: renaming those is a migration and a protocol change for a word. That split is written down rather than left to be rediscovered as an inconsistency. Four things that were squeezed into the keychain's category rail, or into nothing at all, now have screens. Pinned host keys get one, with fingerprints never truncated and a filter that matches them, because comparing what you have against what the operator published is the whole workflow; the approved date is read out of the item's UUIDv7 rather than added as a column, and says so, since it means first approval and not last use. Keys can be generated in the client, which needed the openssh-key-v1 container written by hand — there is no BCL or NSec helper, and the PKCS#8 route is unverified in the SSH library this uses. The armour carries no passphrase: encrypting it needs bcrypt_pbkdf, which is Blowfish with a swizzle, in a project whose crypto is otherwise entirely libsodium, for a protection the key's own remarks argue is redundant inside a vault. Generation fills the existing editor and stops, so SAVE stays the one thing that writes. ~/.ssh/config can be imported behind a preview that is ticked per row and writes nothing until the button; IdentityFile records the path and imports the key material only on an explicit opt-in, because reading somebody's private key into a vault is precisely the act this product exists to make deliberate. Match blocks and ProxyJump are reported rather than obeyed — one cannot be evaluated statically and the other has nothing behind it to route with, and a preview that implied otherwise would be worse than one that admits it. Files can be dragged in all four directions that are honestly available. Remote to Explorer does not ship and is not pretended to: the shell wants the bytes during the drop, which needs a virtual file and a native COM data object, outside what Avalonia offers. Note for the next person that Avalonia 12 replaced the drag model outright — DataObject and DataFormats are no-op stubs and IDataObject is not in the reference assembly, so every tutorial written for 11 does not compile here. Hosts can be grouped, flat and never nested. A parent id merged as a scalar lets two offline clients each re-parent A under B and B under A, producing a cycle inside an encrypted payload that no server can police and every reader would have to detect for ever. Membership lives in that payload rather than in the one plaintext concession ADR 0001 allows, whose test is that the relay cannot function without it — nothing on the server reads a group, so what plaintext would hand over is a clustering of the estate for nothing. The plaintext column reserved for it is dropped, provably always null, and the server now refuses a client that sends one; it was never populated, was copied on apply, and was not cleared on delete, so a group id would have outlived the host it described. Snippets insert through xterm rather than through the pump, because xterm is the only thing that knows whether the remote has bracketed paste on, and that is what makes a shell treat embedded newlines as text instead of as execute. The host process moves opaque bytes and never parses output, so it would have to guess, and guessing wrong runs every line. Running is off by default and the copy says the text goes into whatever is there — the terminal has no notion of being at a prompt, and may be in vi or at a password prompt with echo off, so the Enter the user presses themselves is the entire safety property. Connections and keychain changes are recorded as synced encrypted items, which is what makes them auditable by a team later and costs the server knowledge of connection rate and timing from row counts alone. ADR 0001 already concedes it cannot hide that class of metadata; the trade is now written into it rather than left implicit. A connection entry is written once, at close, which is what makes a synced log tractable: nothing to merge, one outbox row, no chance of colliding with itself. Live sessions come from memory, not from the log. The write is void by contract and posts to a bounded channel, because putting an encrypt-and-write on the teardown path of every session is how closing the application comes to take four seconds. A ticket opened before a lock still closes afterwards, since a shell outlives the vault. The activity log hooks the one generic repository every kind writes through, so it cannot miss a caller — which is also why the log kinds themselves declare they are not audited, or the first entry would write an entry about writing an entry. It records the names of the fields that changed and never their values; a log with an old password in it would be a plaintext credential store with no vault around it. Retention is 90 days or 5,000 entries, whichever bites first, pruned on the sync loop rather than on a second timer. That log traffic then broke the status line, which is worth recording because the fix is a shape and not a patch: background sync counted its own log rows as pushed items, so the quiet rule stopped being quiet and every action's message was overwritten a second later by a sync report. The report now separates log rows from user items and the rule reads the latter. S3 buckets appear as a remote in the file browser, behind the same interface an SFTP session implements, so the queue and both panes did not have to learn what they are talking to. Uploads go through a pipe, because the queue wants to write and the SDK wants to read; memory is then bounded by the part size instead of buffering a file to disk twice. Finally, the Windows device key store moved out of the session project, which was the one thing keeping it from being portable — everything else in it is platform-neutral, and a Windows CNG dependency in the middle of the vault code meant a second head could not reference it without dragging Windows along. The seam that made the move free was already there. docs/android-port.md is the audit behind that: what ports, what does not, in order of cost, the four decisions taken, and an inventory of every screen and state the interface has to carry, written so a design can be made from it directly. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 1240 tests at zero warnings, including the end-to-end suite against real containers. The manual checks that headless Avalonia cannot make — the drag from Explorer, a generated key against a real host, twelve tabs at the minimum window width — are listed in docs/manual-checks.md and are still outstanding. |
||
|
|
9a76eced14 |
Give hosts and terminals their own screen, and the rest of the vault another
Rebuilds the client's shell from an imported design: a titlebar and nav rail it draws itself, real multi-session tabs over the one WebView, a Ctrl+K host search, and a vault screen that merges keys, passwords and pinned host keys into one table. Hosts left the vault column for their own screen beside the terminal, which is what the design asks for and turned out to be the better split anyway. Two screens the design shows have nothing behind them yet — file transfer and teams — and say so plainly rather than rendering invented data; every other gap between the design and this build is recorded in docs/design-import-gaps.md. |
||
|
|
c5dec2d68e |
Measure the vault column instead of arguing about it
Nothing in this repository loaded a .axaml, so the one class of defect this window has actually shipped — a control arranged past the edge of its container, where it cannot be clicked — was the one class nothing could catch. The setup screens rendered sliced once, with their buttons unreachable. The vault column is the next candidate: 340 pixels wide, two lists and two editors, and the only thing keeping it from clipping its own Save button at the window's 520-pixel minimum is a state rule that one editor may be open at a time. That rule was added on the strength of an argument. This adds an Avalonia.Headless project that lays real XAML out at a real size and reports what a user could not reach, and the argument is now a number: with both editors open the column overflows, so the rule is load-bearing rather than defensive. BothEditorsAtOnce_DoNotFit_WhichIsWhyTheRuleExists is the test, and it says what to do if it ever starts passing — the column has room, so delete the rule, not the test. Two findings arrived by measuring rather than by reasoning, and the first one changed the design. MainWindow cannot be shown headlessly at all. Showing it attaches the terminal's NativeWebView, whose Win32 adapter initialises WebView2 on attach, and WebView2 refuses a non-STA thread — which is exactly why Program.Main carries [STAThread] and is written down in that comment. A HeadlessUnitTestSession owns its dispatcher thread and offers no apartment choice, so the whole window is out of reach at any size. That is pinned as a test asserting RPC_E_CHANGED_MODE by HResult rather than by message, so a future Avalonia that makes the adapter lazy will fail it and the harness can be widened. So the column had to become its own control to be measurable, which is the extraction the type-selector rework wanted anyway. Keyboard release moved with it: MainWindow used to call Focus() on HostList by name, and now asks VaultColumn.KeyboardTarget. The window decides that the keyboard should leave the terminal and the column decides where it lands — which is the seam the rework needs, because once the column shows one list at a time, "which list owns the keyboard" is a question only the column can answer. The second finding is the way this kind of test lies quietly. The hint class lived in MainWindow.Styles and carries TextWrapping. A Window's styles reach its whole tree, so nothing about the application depended on where it lived — but a control laid out on its own loses them, and every hint paragraph would have measured as a single line. The harness would have passed while measuring heights that were all too small. The three shared classes now live in App.axaml, which changes no rendering and makes the measurement honest. The detector is calibrated in both directions, because a clipping detector that never fires reads as a guarantee: a deliberately clipped Save button is caught by name, and a list longer than its viewport is exempt. Scrolling is how a list is supposed to handle more rows than fit, and without that exemption the host list would fail the moment it had content. It also mis-fired once and the rule is narrower for it — an empty ListBox is zero pixels tall and correct, so "arranged with no size" now applies only to controls the theme gives a height to. Skia rather than the headless drawing stub, deliberately. The stub's font manager invents glyph metrics, and text height is an input to every stacked panel in this column, so measuring against it would produce numbers that are self-consistent and unrelated to the application. A separate test project rather than more tests in DodoSSH.Client.App.Tests. Avalonia's application, dispatcher and platform are process-global singletons initialised once, and that project's identity is the shell's state machine without Avalonia — the whole reason sign-in is a delegate. The fakes needed to reach a real unlocked vault are shared from DodoSSH.Client.Session.Tests by source link: a project reference would make one test project a library of another, and a copy would be a third implementation of the same decision table drifting from the other two. 855 tests green, 10 of them new. Zero warnings, dotnet format clean. Not done, and this is groundwork rather than the item itself: the type selector. The column still holds both lists at once, so a third item type would still recreate the defect the one-editor rule works around. What is different is that the rework can now be checked instead of eyeballed — including the claim it is being made for, that one editor at a time stops being a runtime rule and becomes a fact about what is in the visual tree. What this harness will never catch is the terminal's native child window compositing over Avalonia content. That is a Win32 property of a real window, no headless surface reproduces it, and it is the reason the WebView is collapsed rather than covered. |
||
|
|
5fccd53824 |
Add the Avalonia app and the xterm renderer, and fix two real bugs
The terminal works end to end. A new integration test drives a real sshd in a container through a real PTY, the real pump, the real loopback WebSocket with its token and origin checks, and a ClientWebSocket standing in for the page: the login banner arrives, typed input round-trips, and `stty size` reports the 100x30 the session asked for. The only untested link left is xterm drawing bytes it was handed. The WebView is de-risked on Windows, which was the plan's largest risk. Not by assertion: with the app running there is an established TCP connection from msedgewebview2 to the data plane port, so WebView2 launched, navigated to the loopback page, executed terminal.js, and completed the WebSocket handshake against the real token and origin checks. Linux remains unproven and the package's own release notes now corroborate the concern -- Linux uses a WPE backend, and it ships a NativeWebDialog described as useful where embedded WebViews may be unavailable. Two bugs found by building it, both of which would have shipped: - ShellStream.Write buffers and needs an explicit Flush. Without one a keystroke is accepted, reported as written, and never reaches the remote: the terminal displays output perfectly and simply stops responding to input. SSH.NET's own WriteLine flushes, which is why the earlier spike never hit it. Found by isolating the pump against real SSH and reading BytesRead=51 -- banner and prompt through, nothing after. - The Windows app manifest needs a supportedOS list, or Avalonia's native control host fails outright and the terminal never starts. Also fixed a genuinely flaky test I happened to catch: SyncCursorTests tampered with the *last* base64url character, whose low bits the decoder ignores when the input length is not a multiple of three -- so a tampered cursor sometimes decoded to identical bytes and verified. It failed roughly one run in thirty, depending on a random key. Now tampers the penultimate character, which is fully significant at every length; 40 consecutive runs are clean. xterm 6.0.0 plus the fit and webgl addons are vendored as UMD bundles rather than built with npm, so a clean clone needs only the .NET SDK. Provenance and licences are recorded next to them, along with the UMD global names terminal.js depends on -- a bundle that switched to ES modules would load without error and leave Terminal undefined. The renderer acknowledges output from term.write's completion callback, not on receipt. Acknowledging early would return flow-control credit for bytes the screen has not caught up with, which is the one thing the credit window exists to measure. TerminalWorkspace moved into DodoSSH.Client.Terminal: it has no Avalonia dependency, and having it there is what let the end-to-end test exist at all. 404 tests pass, zero warnings on a clean rebuild, format clean. |