# dsh-lite-git — implementation contract

This document is the part of the design that is *not* visible from the outside:
the wire between the two halves, where state lives, the invariants the code is
built around, and — just as importantly — what has **not** been verified.

It is written for whoever changes this plugin next, including a future me who has
forgotten why the discard order is what it is.

---

## 1. What this is, and the shape of the split

A GitHub Desktop-shaped Git panel that lives in DSH's built-in right sidebar.
Three tab types, all session-scoped because that is what the right column is:

| Tab | Kind | Address | Opened by |
|---|---|---|---|
| The panel | `lite-git` | page (`sidebar://lite-git`) | the guide card in the right column |
| The trash | `lite-git-trash` | page (`sidebar://lite-git-trash`) | a button in the panel |
| A diff | `lite-git-diff` | **resource** (`dsh-resource://dsh-lite-git/diff/current`) | double-clicking a file, or a trash entry |

The split between the halves is total: the browser half never touches the disk,
never shells out, and never sees a path it did not get from the host.

```
right sidebar tab (browser)            host process
──────────────────────────            ─────────────
GitPanel / TrashBody / DiffBody
        │
        │  POST /api/dsh-lite-git/rpc
        │  { action, ...payload }            connection.fetch.register
        ▼                                    → admit() (fence + cookie)
   createRoute.fetch ─────────────────────►  → createHandlers[action]
                                                   │
                                                   ├─ git.js    ctx.subprocess
                                                   ├─ repo.js   the operations
                                                   └─ store.js  <DSH_HOME>/dsh-lite-git
```

**Why one route and not one action per route.** `connection.fetch` matches on the
exact pathname and method, and every call already carries a `sessionId` plus the
few fields the action needs. One `POST` with an `action` field keeps the wire
contract in one place — mirrored verbatim in the browser half — instead of
spreading fifteen path literals across two languages.

**Why it must be `connection.fetch` and never `webServer.register`.** The web
server resolves `kind: 'exact'` routes *before* its `/api` prefix route, and
everything that makes an `/api` request safe hangs off that prefix route:
`connection.admit(req)` runs the Host/Origin fence and the browser-cookie check
before `bridge()` dispatches. An exact route on the same path shadows all of it,
turning this into an unauthenticated git surface reachable by anything that can
guess the path. `test/index.test.mjs` and `test/check.mjs` both assert the
carrier, and the check script proves its own assertion is sensitive by running
the predicate against a deliberately broken copy of the source.

---

## 2. The wire contract

`POST /api/dsh-lite-git/rpc`, body `{ action, ...payload }`, `requestBody:
'buffered'`.

Every response is JSON with `cache-control: no-store`. Success is
`{ ok: true, ...data }`. Failure is `{ ok: false, error: { code, message } }`
**and** a meaningful HTTP status — both, on purpose, because the browser half
reads the body and a non-2xx status must not be the only signal. The `message`
the host sends is already user-facing (Chinese), and the browser half localises
`code` when it recognises it, falling back to that message.

### Actions

| action | payload | returns |
|---|---|---|
| `status` | `sessionId` | the whole panel state (see below), or the picker's candidate list when nothing is bound |
| `bind` | `sessionId`, `repoRoot: string \| null` | `{ repoRoot, bound }` or `{ cleared: true }` |
| `repos` | `sessionId` | `{ candidates, scanned, scanTruncated, repoRoot, source }` |
| `stage` | `sessionId`, `paths: string[]`, `staged: boolean` | fresh status |
| `commit` | `sessionId`, `summary`, `description?` | `{ commit, ...status }` |
| `commitAndPush` | same | `{ commit, push: { ok, ... } , ...status }` |
| `push` | `sessionId` | `{ action: 'push', outcome, ...status }` |
| `fetch` | `sessionId` | `{ action: 'fetch', outcome, ...status }` |
| `discard` | `sessionId`, `paths: string[] \| null`, `hardDelete` | `{ discard, ...status }` |
| `fileDiff` | `sessionId`, `path` | `{ diff }` |
| `trashList` | `sessionId`, `all?` | `{ entries, total, showAll, repoRoot }` |
| `trashDiff` | `id` | `{ entry, text }` |
| `trashDelete` | `id` | `{ removed }` |
| `trashClear` | `sessionId`, `all?` | `{ removed }` |
| `runScript` | `sessionId` | `{ ran, exitCode?, stdout?, stderr? }` — nothing is thrown for a non-zero exit |
| `saveScript` | `sessionId`, `script` | `{ summaryScript, script }` — an empty `script` clears it |
| `prefs` | `sessionId`, `trashHardDelete?`, `trashShowAllRepos?` | `{ prefs }` |
| `openers` | — | `{ openers: [{ id, label? }], last }` — no session, no repository: the menu is about the machine |
| `openFile` | `sessionId`, `path`, `opener?` | `{ opened: { id, path } }` — the program runs on the host, and the choice becomes `prefs.openWith` |

`paths: null` on `discard` means *every changed file*; an explicit array means
exactly those paths. An array that matches nothing is an `E_BAD_REQUEST`, and a
clean working tree is refused too, so "discard nothing" never writes an empty
archive.

### The status payload

```
{
  workspacePath, workspaceTitle, source: 'binding' | 'detected' | 'none',
  workspaceIsRepoRoot: boolean,
  repoRoot, repoName, head: { oid, short, subject },
  branch: { oid, head, upstream, ahead, behind, detached, unborn },
  remotes: string[],
  files: [{ path, origPath?, index, worktree, kind, untracked, conflicted,
            staged, partial, added, deleted, binary, synthesized? }],
  counts: { files, staged, partial, conflicted, additions, deletions },
  primary: 'push' | 'publish' | 'fetch',
  prefs: { trashHardDelete, trashShowAllRepos, openWith },
  summaryScript
}
```

`workspaceIsRepoRoot` is `true` when the workspace directory is itself the root of
the bound repository, compared through `workspaceKey` so two spellings of one
directory do not read as different ones. It is always present: `false` when
nothing is bound. The panel greys out its unbind button on `true`, because
clearing the binding there changes nothing — the next read re-detects the same
directory — and a press that reports success while changing nothing is worse than
an inert button. Every action that returns a fresh status carries it too.

`summaryScript` is the stored script for this repository, falling back to the
`summaryScript` plugin setting, and `''` when neither exists. There is no built-in
starting script: the panel shows exactly this value, so `''` is an empty editor.

`head` is the commit a discarded diff would be taken *against*, which is why the
trash can say "against `a1b2c3d` *init*" and mean it.

### Failure codes

`E_BAD_REQUEST` · `E_UNKNOWN_ACTION` · `E_NO_WORKSPACE` · `E_NO_REPO` ·
`E_NO_GIT` · `E_EMPTY_SUMMARY` · `E_NOTHING_STAGED` · `E_CONFLICT` ·
`E_GIT_FAILED` · `E_NO_REMOTE` · `E_NO_UPSTREAM` · `E_AUTH` · `E_PUSH_REJECTED` ·
`E_TRASH_NOT_FOUND` · `E_TRASH_NOT_RESTORABLE` · `E_NO_FILE` · `E_NO_OPENER` ·
`E_INTERNAL`.

`E_AUTH` and `E_PUSH_REJECTED` exist so the panel can say the one useful
sentence for each instead of pasting git's stderr. Both are derived by pattern
matching git's output in `classifyGitFailure`, which reads **stdout as well as
stderr** — `git commit` with nothing staged exits 1 with an empty stderr and
"nothing to commit" on stdout, and a stderr-only matcher reports that as an
unexplained internal failure.

---

## 3. Where state lives

`<DSH_HOME>/dsh-lite-git/`, falling back to `~/.dsh`. **Do not read
`$DSH_HOME` alone**: the Host process usually does not have it set (the variable
belongs to the shell DSH spawns), so a state file written under a bare
`process.env.DSH_HOME` lands somewhere else or fails to resolve at all. This is
the same trap `dsh-tailscale-remote` documents.

```
bindings.json   { version, bindings: { <canonical workspace path>: { repoRoot, workspacePath, updatedAt } } }
prefs.json      { version, trashHardDelete, trashShowAllRepos, openWith }
scripts.json    { version, scripts: { <canonical repository path>: script } }
trash/index.json  { version, entries: [ ... ] }
trash/<id>.diff   the archived diff, one file per discard operation
```

Every write goes through a temp file plus a rename, so a crash mid-write cannot
truncate a file, and every read-modify-write is serialized per path — two tabs
issuing overlapping mutations is the normal case here, not an edge case.

**Why not the settings service or a storage domain.** A binding is keyed by an
arbitrary absolute workspace path, and a `storageDomain` record key is restricted
to `^[a-zA-Z0-9_-]+$` because it becomes a path segment; `C:\Users\…\<a folder
with spaces>` cannot be one without hashing it, at which point the "native"
option is doing less than the twelve lines here. A settings `Config` would put a
growing map of machine paths into the user-visible settings form, which is worse.

**The binding key** is canonical, separator-normalized, and case-folded on
Windows (`workspaceKey`), so `C:\Foo\`, `C:\Foo` and `c:\foo` are one record. The
original spelling is stored alongside for display.

**A stale binding is dropped, not reported.** If the bound directory is gone, or
is no longer a repository, `resolveBinding` clears the record and re-detects. A
broken binding the user cannot clear from the UI is worse than re-deciding.

---

## 4. The invariant: a tick *is* the index

This is the claim that makes the plugin safe next to a terminal, and the claim a
pathspec-limited commit would quietly break.

- Ticking a file runs `git add -A -- :(literal)<path>`.
- Unticking runs `git restore --staged -- :(literal)<path>`.
- A file reads as ticked when **all** of its change is in the index
  (`index !== '.'`), and as *indeterminate* when the index and the worktree are
  both dirty (`MM`) — one tick cannot represent half a file, so the box shows a
  dash and either click restates the file as a whole.
- Commit runs a plain `git commit -F -` with **no pathspec**. What the panel
  shows, what `git status` shows, and what gets committed cannot drift apart.
- Untracked and `.gitignore`d files are never ticked: they are not in the index
  and cannot be.

`:(literal)` matters: without it, discarding a file named `docs` would also reach
`docs/anything`, and a file named `a[1].txt` would be treated as a pattern.

The two user-visible consequences, stated plainly: the plugin *does* mutate your
index (that is the point — GitHub Desktop does the same), and a file you staged
by hand shows up ticked, because it is.

**Which paths a refresh ticks.** The list is re-read on open, on return, and when
the refresh button is pressed, and that re-read is where select-by-default runs.
It runs per *path*, against the list the panel last saw:

- a path that has just appeared is `git add`ed;
- a path that was already in the list is left exactly as it is, so a refresh
  never quietly re-takes a file the user unticked — and a new file appearing
  never drags the previous batch back in with it;
- the first look after a mount has no earlier list to compare against, so it
  falls back to "tick everything **while the index is empty**". A non-empty index
  means a terminal, or an earlier session, already chose; re-ticking on top of a
  choice the panel cannot see the history of is the one thing it must not do
  unasked;
- a commit forgets the remembered list, so the post-commit list is looked at as a
  first look and the remaining changes are re-offered under that same rule;
- conflicted paths and half-staged (`MM`) paths are never ticked automatically.
  The first would record a resolution nobody made; the second is a state somebody
  chose that a single tick cannot express.

Remembering the list is also the loop guard: the paths are recorded before the
`git add` leaves, so a failing request does not retry on the next render.

---

## 5. Discarding, in order, and why

```
1. read the full status                      ── decide the target set
2. refuse if any target is conflicted        ── E_CONFLICT, one sentence
3. build the archive                         ── BEFORE anything is destroyed
     tracked:   git diff --no-color --find-renames HEAD -- <literal paths>
                (+ git diff --numstat … in the same round, for the per-file table)
     untracked: synthesizeNewFileDiff per path, from the file's own bytes
4. cap it on a line boundary                 ── limitDiff, records droppedLines
5. write the trash entry + blob              ── unless hardDelete
6. restore
     tracked:   git restore --source=HEAD --staged --worktree -- <literal paths>
     untracked: delete the file, then prune directories the discard emptied
```

Steps 3 and 6 are not interchangeable. `git restore` destroys the original, so
the archive has to exist first; that is the whole reason the plugin has a trash.

**Why tracked and untracked are restored by different means.** A mixed
pathspec — one untracked path alongside tracked ones — makes the *entire*
`git restore` fail with `pathspec … did not match any file(s) known to git` and
restores nothing (verified on git 2.54). Splitting them is not tidiness; it is
correctness.

**Why untracked paths are deleted rather than restored.** They are not in HEAD,
so `git restore --source=HEAD` cannot reach them. Their content is captured in
step 3 or lost, which is exactly why the synthesized diff exists.

**One entry per discard operation, not one per file.** Discarding a single file
archives one blob with one file in it; "Discard all" archives **one blob holding
every file's diff**, with `scope: 'all'`. That is what was asked for, and it also
means a whole-tree discard is one write rather than N.

**Conflicts are refused, not resolved.** Discarding a conflicted path would mean
`git restore` against HEAD in the middle of a merge, which can disturb the merge
state. The panel says one sentence instead, and `test/e2e.mjs` S12 proves both
the single-file and the discard-all paths refuse.

**`hardDelete` is a per-operation choice, remembered.** The confirmation box's
"delete for good" starts unticked for a first-time user (the safe answer keeps a
copy) and is persisted after each discard, so it mirrors the user's last
decision rather than latching.

---

## 6. The trash

An entry records, so that a diff can still be read months later:

```
{ id, at, repoRoot, repoName, workspacePath, sessionId,
  scope: 'file' | 'all',
  branch: { head, detached },
  base: { oid, short, subject } | null,     ← the commit the diff is against
  files: [{ path, origPath?, kind, added, deleted, binary, synthesized?, oversized? }],
  bytes, truncated, droppedLines, blob }
```

`base` is HEAD at the moment of the discard. That is the answer to "which version
is this a diff against" — and it is recorded rather than inferred, because HEAD
moves.

The list is filtered to the currently bound repository by default; `total` still
reports the whole index, and a toggle shows everything. Clearing with the filter
on clears only the visible repository's entries.

Reading an entry whose `.diff` was deleted by hand still lists it: the metadata
is worth keeping even when the blob is gone, and the read reports the blob as
missing rather than pretending the entry vanished.

---

## 7. The right-sidebar tab identities

```
id:   dsh-lite-git/git     kind: lite-git         page   keepMounted, one guide card (order 30)
id:   dsh-lite-git/trash   kind: lite-git-trash   page
id:   dsh-lite-git/diff    kind: lite-git-diff    resource, patterns: dsh-resource://dsh-lite-git/diff/**
```

`definition.id` is **also the slot key** the body and title register under. Two
consequences the code leans on: a rename in one place without the other silently
renders nothing, and `test/client.test.mjs` asserts every slot key is one of the
three ids.

**Why the diff tab is a resource with a fixed address.** Re-opening a resource
address reveals the tab that is already open — the host looks the content id up
across the whole layout before planning a new tab — and the `params` payload
re-navigates that tab (each open bumps `navigation.revision`). A page kind would
reuse only within the active dock pane, so a tab the user had floated would get a
second copy. The fixed address *is* the "one diff tab, ever" requirement, and the
diff's own chip is a title seat because it has to read the live
`navigation.params` to name the file currently on screen — a title captured at
open time never changes.

**Why `keepMounted` on the panel and not the trash.** The panel holds the tick
state that mirrors the index and a half-written Summary; peeking at a diff must
not lose either. The trash holds only what it re-reads on every visit, so
retaining it would buy nothing.

**The panel re-reads on `tab.visible`.** Coming back to the panel refreshes
quietly, because the working tree may have changed under it — an agent in the
same session is a normal author of those changes.

---

## 7a. Handing a file to the user's own program

The diff tab's header carries two controls: one button that opens the file in the
program used last, and a caret that opens a menu of every program the host offers.

**It has to be the host.** The file is on the host's disk and the program has to
run where the file is. A browser tab cannot start `notepad.exe`, and on a remote
client (Tailscale, a second machine) it should not even try: "open this file"
means on the machine holding the repository. The one-line confirmation after a
press exists for exactly that case — locally the window appearing *is* the answer,
remotely nothing else would be.

**It is not a "run this command" endpoint.** The repository root is resolved by
the host, the path is confined to it by the same `absoluteIn` check every other
file action uses, the program has to be one the host itself listed, and the
arguments come from that listing — a client names an id, never an executable.
Four refusals follow from that and are asserted in `test/e2e.mjs` S24: a path
above the root, an absolute path outside it, an id nobody offered, and a file that
is no longer on disk (404, `E_NO_FILE`).

**The three built-ins are ids, not names.** `default`, then the platform's plain
editor (`notepad` / `editor`) and its "show in folder" (`reveal`). The copy lives
in the client's two dictionaries, so the same id reads in either language; only a
`Config.openWith` entry carries a label, because only the user knows what to call
it. A Linux build offers two, not three, rather than naming an editor that may not
be installed.

**`{path}` and `{dir}` are template arguments, not a shell string.** Each item is
already one argument all the way to the child, so a path with a space stays one
argument. An entry with neither placeholder has the path appended, which is the
obvious reading of `command: notepad.exe` with nothing else written.

**The program's exit is not waited for.** An editor stays open for as long as the
user keeps it, so a request that waited would sit on it until `timeoutMs` killed
the thing it launched. What the host does check, before spawning, is that the
program resolves — a missing `openWith` command answers `E_NO_OPENER` instead of
a child that fails a moment after the panel has already said it worked.

**The choice is remembered, as `prefs.openWith`.** Recorded by the action rather
than by a second call, so the button cannot open with one program while the
preference says another. An id that no longer names anything (a config edited
since) reads as "the default" rather than as an empty menu.

---

## 8. Configuration

Every field is volatile, which is what lets the settings page edit them without a
restart — and is why `cordis.patch.yml` deliberately carries **no** `config`
block: values written into the profile's own patch layer must not be shadowed by
this package on the next upgrade.

| field | default | why |
|---|---|---|
| `gitPath` | `''` | Empty means "whatever is on PATH". Set it when git is somewhere unusual. |
| `stateDir` | `''` | Empty means `<DSH_HOME>/dsh-lite-git`. |
| `timeoutMs` | `30000` | Per-command. A push to a slow remote may need more. |
| `maxDiffBytes` | `524288` | Ceiling on one archived diff. A discarding user is having a bad day already; a 200 MB generated file must not become a 200 MB write that then fails. |
| `maxFileBytes` | `2097152` | Larger untracked files are listed but their content is not read, archived, or diffed. |
| `trashLimit` | `200` | Entries kept; the oldest are dropped and their blobs deleted. |
| `scanDepth` | `3` | Directory depth the picker scans below the workspace root. |
| `openWith` | `[]` | Extra programs for the diff tab's menu, on top of the platform's three. A row is `{ label, command, args }`; each arg is one argument, `{path}`/`{dir}` are substituted, and an entry with neither gets the path appended. A half-written row (no label or no command) is skipped rather than thrown on, so a typo cannot park the plugin. |

---

## 9. Verified, and not

### Verified

| what | how |
|---|---|
| porcelain v2 parsing, including a rename, a staged delete, a conflict, a non-UTF-8-hostile path, and a path containing a space | `test/parse.test.mjs`, 47 cases; the conflict fixture pins the nine-field `u` record |
| diff assembly, truncation on line boundaries, per-file splitting | `test/diff.test.mjs` |
| state files, the binding key, corruption tolerance, write serialization | `test/store.test.mjs` |
| the route carrier, single registration under a hostile service order, error classification | `test/index.test.mjs` |
| every registration identity, the wire literals, the dictionaries, and the hook-free components' rendered trees | `test/client.test.mjs` |
| that every registered seat **renders** — the bodies, hidden states, and no-session states | `test/seats.test.mjs` |
| the whole host stack against **real git**, mutation by mutation | `test/e2e.mjs`, 318 assertions over 24 sections that build their own fixtures (S24 starts a real child for the open-with menu) |
| the manifest, the patch layer, the tarball's contents, the two copies of the wire contract, the dictionaries, the workflow | `test/check.mjs` |
| that the client bundle would not abort the Host boot | `scripts/check-client-bundle.mjs` |

The e2e suite is deliberately hostile about §4: after ticking one file it reads
`git status --porcelain` back and asserts git agrees, which is the only way to
show the index claim rather than assume it.

### Not verified

- **`ctx.subprocess` is doubled in the tests.** The shipped service lives inside
  DSH's own bundle and is not importable from a plugin repository, so
  `test/helpers/subprocess.mjs` reproduces its shapes on top of
  `node:child_process`. Those shapes are not guessed: the live contract was read
  off a running DSH (Cordis Inspect, host provider `Service`, key `subprocess`)
  and the double is pinned to it — including the part that is easy to get wrong,
  that `spawn` **throws synchronously** for an already-aborted signal, or for an
  argv/cwd/env/grace it rejects, instead of returning a handle. `S20` exists
  because a caller that only `await`s the handle never sees that throw, and it
  used to reach the route layer unclassified. What the double still does **not**
  reproduce: Windows Job containment, the environment scrubbing, spill files
  (`readFrom`'s `spillPath`), and the `control` duplex.
- **What the live run established.** The plugin loads into a running DSH and all
  three right-sidebar seats register and are active — `dsh-lite-git/git` and
  `dsh-lite-git/trash` (pages) and `dsh-lite-git/diff` (resource) — read
  off the live client slot tree at `sidebar.right.pane.tab`, next to the shipped
  occupants. That is evidence about *mounting*; the behaviour claims are the e2e
  suite's job, and the seam between them is the doubled `ctx.subprocess` above.
- **No headless-DOM render test.** The panel, the trash and the diff are driven
  by hooks, and the three components with no hooks in them (the row, the
  confirmation, the picker) *are* asserted on their element trees. What is not
  covered is real layout: whether the panel actually fills the column, whether a
  diff row's background is the token it claims. A browser harness with a
  hand-written React shim would be needed, and the two bugs that motivate one in
  other plugins in this family were both DOM-measurement bugs — this plugin
  measures nothing and augments no host DOM, so the harness was judged not worth
  its fixture-fidelity cost. That judgement is worth revisiting if the UI grows
  anything hover-dependent or portalled.
- **What a 401 from `/api` does and does not prove.** `connection.admit()` — the
  Host/Origin fence and the browser-cookie check — runs on the `/api` prefix
  route *before* dispatch, so an unauthenticated request to a path nobody serves
  answers 401 as well. `test/live.mjs` therefore reports 401 as *inconclusive*
  rather than as "mounted"; only a 200 (or, when authenticated, a 404) is
  evidence about whether this plugin is actually registered.
- **`openTabIn` / `openResourceIn` are outside the published type contract.**
  They are prototype methods on the sidebar-right controller and are called by
  shipped plugins (including `dsh-deepseek-webchat`), but three source comments
  label them "not part of `ISidebarRight`". This is the first thing to re-check
  on a DSH upgrade.
- **The tab strip's height is now known, and it is load-bearing.** The docking
  kit's `_tabStrip` is `height:28px; padding:10px 6px 0`, so a tab body starts at
  y=38, and the shell's own sidebar tabs (files, browser, deliverables,
  document preview) each open with `height:38px; border-bottom:.5px solid
  var(--dsw-alias-border-l3)`. That is what puts their rule on the same row as
  the conversation header's, which the shell draws at the bottom of the strip
  (measured live: y=76). This panel's header follows the same numbers — a
  border-box 38px box with no vertical padding — because a content-box one with
  8px/6px padding plus a 1px rule came to 39px and left two rules one pixel
  apart. Both the shape and the arithmetic are asserted in `test/client.test.mjs`
  ("puts the header rule exactly on the conversation header line"); the numbers
  were confirmed by measuring the live app, not by reading the kit.
- **Git's path encoding on this machine.** `core.quotepath=false` plus `-z`
  produced UTF-8 paths in every fixture that was checked (including a non-ASCII
  name), and the parsers split on NUL rather than on quoting. A repository whose
  paths git emits in another encoding is untested.

---

## 10. Known limitations

- One repository per workspace, one workspace per binding. No submodules, no
  worktrees, no multiple remotes beyond "prefer `origin`, else the first".
- Push and Fetch always act on a single remote. There is no branch switching, no
  history list, and no stash — that was the agreed scope.
- `git fetch --prune` is what Fetch does, matching GitHub Desktop. It deletes
  remote-tracking refs whose branch the remote no longer has.
- Commit hooks run. A hook that blocks or hangs is the user's own hook; the child
  is killed at `graceMs` only if the command times out, and `timeoutMs` is the
  knob for that.
- The trash keeps the diffs, not the files. Restoring replays the archived diff
  with `git apply` and then stages the paths it produced, so a restored change
  comes back ticked like any other changed file. A diff cut short at the archive
  size limit, or one whose diff is only `Binary files … differ`, is refused for
  that reason rather than restored incompletely.
- Binary content is not archived — the diff says `Binary files … differ`, which
  is honest but is not a backup.
- An untracked file over `maxFileBytes` is deleted with only a stub in the
  archive. The confirmation box does not yet warn about that specifically.
- "Open with" starts the program on the **host**, so a remote client sees the
  window on the machine that holds the repository, not on the screen in front of
  them. It is also confined to files inside the bound repository: a diff of a trash
  entry has nothing on disk to hand over, and the control is not offered there.
