@ashley-shrok/viewmodel-shell 0.4.8 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +20 -0
- package/dist/index.d.ts +31 -1
- package/dist/index.js +99 -3
- package/dist/server.d.ts +7 -0
- package/dist/server.js +7 -0
- package/dist/tui.d.ts +13 -0
- package/dist/tui.js +55 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,6 +33,22 @@ If your backend is .NET: copy `demo/Tasks/AspNetCore/ViewModels.cs` from the [Gi
|
|
|
33
33
|
|
|
34
34
|
For other backends, implement the same JSON shape: a `GET` returning `{ vm, state }`, and a `POST` that takes `multipart/form-data` with `_action` and `_state` form fields and returns the next `{ vm, state }`. See [AGENTS.md](https://github.com/ashley-shrok/ViewModelShell/blob/main/AGENTS.md) for the full wire format.
|
|
35
35
|
|
|
36
|
+
## Authenticated downloads
|
|
37
|
+
|
|
38
|
+
When a header-authenticated consumer (e.g. `Authorization: Bearer <jwt>` via `ShellOptions.getRequestHeaders()`) needs to offer a file download, return a `"download"` side-effect from your action handler — the shell fetches the URL with the same headers merged in, parses `Content-Disposition` + `Content-Type`, and triggers a browser "Save As":
|
|
39
|
+
|
|
40
|
+
```csharp
|
|
41
|
+
return new ShellResponse<MyState>(BuildVm(state), state)
|
|
42
|
+
.WithEffect(ShellSideEffect.Download("/api/invoices/42/pdf", "invoice-42.pdf"));
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
return { vm: buildVm(state), state,
|
|
47
|
+
sideEffects: [shellSideEffect.download("/api/invoices/42/pdf", "invoice-42.pdf")] };
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The download endpoint stays auth-gated and the server authorizes in the action handler — no signed-URL machinery. See [AGENTS.md](https://github.com/ashley-shrok/ViewModelShell/blob/main/AGENTS.md#client-side-effects) for the wire format and pattern.
|
|
51
|
+
|
|
36
52
|
## Terminal (TUI)
|
|
37
53
|
|
|
38
54
|
The same backend renders in a terminal — same wire, no backend change. Point the CLI at any ViewModel Shell endpoint:
|
package/dist/browser.d.ts
CHANGED
|
@@ -6,6 +6,10 @@ export declare class BrowserAdapter implements Adapter {
|
|
|
6
6
|
render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
|
|
7
7
|
navigate(url: string): void;
|
|
8
8
|
storage(scope: "local" | "session", key: string, value: string): void;
|
|
9
|
+
/** Save an authenticated-download blob via the browser's native Save-As.
|
|
10
|
+
* contentType is informational — the Blob's own .type takes precedence in
|
|
11
|
+
* browsers. We accept the arg for adapter symmetry (other adapters use it). */
|
|
12
|
+
saveFile(data: Blob, filename: string, _contentType: string): void;
|
|
9
13
|
transport(input: string, init: {
|
|
10
14
|
method?: string;
|
|
11
15
|
headers?: Record<string, string>;
|
package/dist/browser.js
CHANGED
|
@@ -68,6 +68,26 @@ export class BrowserAdapter {
|
|
|
68
68
|
const store = scope === "session" ? sessionStorage : localStorage;
|
|
69
69
|
store.setItem(key, value);
|
|
70
70
|
}
|
|
71
|
+
/** Save an authenticated-download blob via the browser's native Save-As.
|
|
72
|
+
* contentType is informational — the Blob's own .type takes precedence in
|
|
73
|
+
* browsers. We accept the arg for adapter symmetry (other adapters use it). */
|
|
74
|
+
saveFile(data, filename, _contentType) {
|
|
75
|
+
const url = URL.createObjectURL(data);
|
|
76
|
+
try {
|
|
77
|
+
const a = document.createElement("a");
|
|
78
|
+
a.href = url;
|
|
79
|
+
a.download = filename;
|
|
80
|
+
a.style.display = "none";
|
|
81
|
+
document.body.appendChild(a);
|
|
82
|
+
a.click();
|
|
83
|
+
a.remove();
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
// Revoke async so the browser has time to start the download. The 0ms
|
|
87
|
+
// setTimeout is the established pattern (Chromium/Firefox/Safari).
|
|
88
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
71
91
|
async transport(input, init, hooks) {
|
|
72
92
|
const onUploadProgress = hooks?.onUploadProgress;
|
|
73
93
|
if (!onUploadProgress) {
|
package/dist/index.d.ts
CHANGED
|
@@ -23,6 +23,15 @@ export interface Adapter {
|
|
|
23
23
|
}, hooks?: {
|
|
24
24
|
onUploadProgress?: (sent: number, total: number) => void;
|
|
25
25
|
}): Promise<Response>;
|
|
26
|
+
/** Save an authenticated-download blob to platform-appropriate storage. The
|
|
27
|
+
* shell fetches the URL with getRequestHeaders() merged, parses
|
|
28
|
+
* Content-Disposition filename + Content-Type, and calls saveFile().
|
|
29
|
+
* No safe no-op exists — a silently-dropped authenticated download is a
|
|
30
|
+
* correctness/security bug (cf. navigate/storage). If a "download"
|
|
31
|
+
* side-effect arrives and this method is absent, the shell fails loudly.
|
|
32
|
+
* May return void or Promise<void>; the shell awaits the return value so
|
|
33
|
+
* async I/O errors surface via onError. */
|
|
34
|
+
saveFile?(data: Blob, filename: string, contentType: string): void | Promise<void>;
|
|
26
35
|
}
|
|
27
36
|
export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode;
|
|
28
37
|
export interface PageNode {
|
|
@@ -199,10 +208,17 @@ export interface ShellOptions {
|
|
|
199
208
|
pollInterval?: number;
|
|
200
209
|
}
|
|
201
210
|
export interface ShellSideEffect {
|
|
202
|
-
/** "set-local-storage" | "set-session-storage" — unknown types are silently ignored. */
|
|
211
|
+
/** "set-local-storage" | "set-session-storage" | "download" — unknown types are silently ignored. */
|
|
203
212
|
type: string;
|
|
213
|
+
/** For "set-local-storage" / "set-session-storage": the storage key. */
|
|
204
214
|
key?: string;
|
|
215
|
+
/** For "set-local-storage" / "set-session-storage": the storage value. */
|
|
205
216
|
value?: string;
|
|
217
|
+
/** For "download": the URL to fetch (shell merges getRequestHeaders() into the request). */
|
|
218
|
+
url?: string;
|
|
219
|
+
/** For "download": optional filename hint. Response Content-Disposition wins
|
|
220
|
+
* when present; this is the fallback before the URL basename. */
|
|
221
|
+
filename?: string;
|
|
206
222
|
}
|
|
207
223
|
export interface ShellResponse {
|
|
208
224
|
vm: ViewNode;
|
|
@@ -231,4 +247,18 @@ export declare class ViewModelShell {
|
|
|
231
247
|
private failCapability;
|
|
232
248
|
private processResponse;
|
|
233
249
|
private schedulePoll;
|
|
250
|
+
/**
|
|
251
|
+
* Authenticated download: fetch the URL with getRequestHeaders() merged
|
|
252
|
+
* (Bearer / anti-forgery / etc.), parse the response filename + content
|
|
253
|
+
* type, and hand the bytes to the adapter's platform-specific save verb.
|
|
254
|
+
*
|
|
255
|
+
* Deliberately uses core `fetch`, NOT adapter.transport. The existing
|
|
256
|
+
* transport override (XHR for upload-progress) constructs Response from
|
|
257
|
+
* xhr.responseText (a string) and would corrupt binary blobs. Download
|
|
258
|
+
* progress is a future, opt-in extension on its own seam.
|
|
259
|
+
*
|
|
260
|
+
* Errors (missing capability / non-OK status / adapter throw) surface
|
|
261
|
+
* via onError; the download() call itself never throws into the caller.
|
|
262
|
+
*/
|
|
263
|
+
private download;
|
|
234
264
|
}
|
package/dist/index.js
CHANGED
|
@@ -101,9 +101,10 @@ export class ViewModelShell {
|
|
|
101
101
|
failCapability(capability, detail) {
|
|
102
102
|
const err = new Error(`[ViewModelShell] Adapter is missing the "${capability}" capability but the ` +
|
|
103
103
|
`server response requires it (${detail}). This is a hard failure, not a no-op: ` +
|
|
104
|
-
`a silently-dropped ${capability} (e.g. an auth token never persisted,
|
|
105
|
-
`redirect that never happens
|
|
106
|
-
|
|
104
|
+
`a silently-dropped ${capability} (e.g. an auth token never persisted, a ` +
|
|
105
|
+
`redirect that never happens, or an authenticated download silently swallowed) ` +
|
|
106
|
+
`is a correctness/security bug. Implement ${capability}() on your Adapter, ` +
|
|
107
|
+
`or (for redirect) pass ShellOptions.onRedirect.`);
|
|
107
108
|
this.options.onError ? this.options.onError(err) : console.error("[ViewModelShell]", err);
|
|
108
109
|
}
|
|
109
110
|
processResponse(body) {
|
|
@@ -121,6 +122,12 @@ export class ViewModelShell {
|
|
|
121
122
|
else
|
|
122
123
|
this.failCapability("storage", `side-effect "${effect.type}" key="${effect.key}"`);
|
|
123
124
|
}
|
|
125
|
+
else if (effect.type === "download" && effect.url != null) {
|
|
126
|
+
// Fire-and-forget. Surfaces errors via onError. Does not block the
|
|
127
|
+
// render/redirect branch below — downloads are a side channel, like
|
|
128
|
+
// storage, and a slow download MUST NOT delay the user-visible update.
|
|
129
|
+
void this.download(effect.url, effect.filename);
|
|
130
|
+
}
|
|
124
131
|
}
|
|
125
132
|
if (body.redirect) {
|
|
126
133
|
if (this.options.onRedirect) {
|
|
@@ -150,4 +157,93 @@ export class ViewModelShell {
|
|
|
150
157
|
this.dispatch({ name: "poll" }, true);
|
|
151
158
|
}, delay);
|
|
152
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Authenticated download: fetch the URL with getRequestHeaders() merged
|
|
162
|
+
* (Bearer / anti-forgery / etc.), parse the response filename + content
|
|
163
|
+
* type, and hand the bytes to the adapter's platform-specific save verb.
|
|
164
|
+
*
|
|
165
|
+
* Deliberately uses core `fetch`, NOT adapter.transport. The existing
|
|
166
|
+
* transport override (XHR for upload-progress) constructs Response from
|
|
167
|
+
* xhr.responseText (a string) and would corrupt binary blobs. Download
|
|
168
|
+
* progress is a future, opt-in extension on its own seam.
|
|
169
|
+
*
|
|
170
|
+
* Errors (missing capability / non-OK status / adapter throw) surface
|
|
171
|
+
* via onError; the download() call itself never throws into the caller.
|
|
172
|
+
*/
|
|
173
|
+
async download(url, hintFilename) {
|
|
174
|
+
const adapter = this.options.adapter;
|
|
175
|
+
if (!adapter.saveFile) {
|
|
176
|
+
this.failCapability("saveFile", `download from "${url}"`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const extraHeaders = this.options.getRequestHeaders
|
|
181
|
+
? await this.options.getRequestHeaders()
|
|
182
|
+
: {};
|
|
183
|
+
const res = await fetch(url, { headers: extraHeaders });
|
|
184
|
+
if (!res.ok) {
|
|
185
|
+
throw new Error(`Download from ${url} failed: ${res.status} ${res.statusText}`);
|
|
186
|
+
}
|
|
187
|
+
const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
|
|
188
|
+
const filename = parseContentDispositionFilename(res.headers.get("Content-Disposition")) ??
|
|
189
|
+
hintFilename ??
|
|
190
|
+
basenameFromUrl(url) ??
|
|
191
|
+
"download";
|
|
192
|
+
const blob = await res.blob();
|
|
193
|
+
await adapter.saveFile(blob, filename, contentType);
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
197
|
+
this.options.onError ? this.options.onError(error) : console.error("[ViewModelShell]", error);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// ─── Download helpers (file-private; no platform globals) ────────────────────
|
|
202
|
+
// `Blob` and `URL` are universals (browser, Node 18+, Deno, Bun) — confirmed
|
|
203
|
+
// off the check:core-globals denylist. `URL.createObjectURL` is browser-only
|
|
204
|
+
// and stays in BrowserAdapter behind the capability seam.
|
|
205
|
+
/**
|
|
206
|
+
* Parse a `Content-Disposition` header for a filename. RFC 5987 `filename*`
|
|
207
|
+
* (UTF-8, percent-encoded, internationalized) wins over the plain `filename`
|
|
208
|
+
* parameter when both are present — that's the spec-canonical preference.
|
|
209
|
+
* Returns null when the header is absent or no filename parameter is found.
|
|
210
|
+
*/
|
|
211
|
+
function parseContentDispositionFilename(header) {
|
|
212
|
+
if (header == null)
|
|
213
|
+
return null;
|
|
214
|
+
// RFC 5987 extended form: filename*=UTF-8''<percent-encoded>
|
|
215
|
+
const ext = /filename\*\s*=\s*([^']*)'[^']*'([^;]+)/i.exec(header);
|
|
216
|
+
if (ext) {
|
|
217
|
+
try {
|
|
218
|
+
const decoded = decodeURIComponent(ext[2].trim());
|
|
219
|
+
if (decoded.length > 0)
|
|
220
|
+
return decoded;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Malformed percent-encoding — fall through to plain `filename`.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Plain form: filename="..." or filename=...
|
|
227
|
+
const plain = /filename\s*=\s*"([^"]*)"|filename\s*=\s*([^;]+)/i.exec(header);
|
|
228
|
+
if (plain) {
|
|
229
|
+
const val = (plain[1] ?? plain[2] ?? "").trim();
|
|
230
|
+
if (val.length > 0)
|
|
231
|
+
return val;
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Extract a basename from a URL (relative or absolute). Returns null if the
|
|
237
|
+
* path is empty or ends with `/`. Uses a dummy base so relative URLs parse;
|
|
238
|
+
* the base is discarded.
|
|
239
|
+
*/
|
|
240
|
+
function basenameFromUrl(url) {
|
|
241
|
+
try {
|
|
242
|
+
const u = new URL(url, "http://_/");
|
|
243
|
+
const last = u.pathname.split("/").pop();
|
|
244
|
+
return last && last.length > 0 ? decodeURIComponent(last) : null;
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
153
249
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -25,6 +25,13 @@ export declare function shellRedirect<TState = unknown>(url: string): ShellRespo
|
|
|
25
25
|
export declare const shellSideEffect: {
|
|
26
26
|
setLocalStorage: (key: string, value: string) => ShellSideEffect;
|
|
27
27
|
setSessionStorage: (key: string, value: string) => ShellSideEffect;
|
|
28
|
+
/** Server-decided authenticated download. The shell fetches `url` with
|
|
29
|
+
* getRequestHeaders() merged (Bearer / anti-forgery / etc.), parses
|
|
30
|
+
* Content-Disposition + Content-Type, and saves via Adapter.saveFile.
|
|
31
|
+
* `filename` is a fallback used only when Content-Disposition is absent.
|
|
32
|
+
* The conditional spread keeps `filename` ABSENT (not undefined) on the
|
|
33
|
+
* JSON wire, matching the .NET WhenWritingNull null-omission contract. */
|
|
34
|
+
download: (url: string, filename?: string) => ShellSideEffect;
|
|
28
35
|
};
|
|
29
36
|
/**
|
|
30
37
|
* Web Fetch API–native request handler factory. Auto-detects content-type
|
package/dist/server.js
CHANGED
|
@@ -50,6 +50,13 @@ export function shellRedirect(url) {
|
|
|
50
50
|
export const shellSideEffect = {
|
|
51
51
|
setLocalStorage: (key, value) => ({ type: "set-local-storage", key, value }),
|
|
52
52
|
setSessionStorage: (key, value) => ({ type: "set-session-storage", key, value }),
|
|
53
|
+
/** Server-decided authenticated download. The shell fetches `url` with
|
|
54
|
+
* getRequestHeaders() merged (Bearer / anti-forgery / etc.), parses
|
|
55
|
+
* Content-Disposition + Content-Type, and saves via Adapter.saveFile.
|
|
56
|
+
* `filename` is a fallback used only when Content-Disposition is absent.
|
|
57
|
+
* The conditional spread keeps `filename` ABSENT (not undefined) on the
|
|
58
|
+
* JSON wire, matching the .NET WhenWritingNull null-omission contract. */
|
|
59
|
+
download: (url, filename) => ({ type: "download", url, ...(filename != null ? { filename } : {}) }),
|
|
53
60
|
};
|
|
54
61
|
// ─── Action handler factory ──────────────────────────────────────────────────
|
|
55
62
|
/**
|
package/dist/tui.d.ts
CHANGED
|
@@ -62,12 +62,18 @@ export declare class TuiAdapter implements Adapter {
|
|
|
62
62
|
* interactive TTY; "content" = legacy intrinsic-content size, no
|
|
63
63
|
* alt-screen (the opt-out escape hatch — pre-0.4.5 behavior). */
|
|
64
64
|
private readonly viewport;
|
|
65
|
+
/** `sidebar` rail width as a fraction of the terminal (fill path only).
|
|
66
|
+
* Default 1/3; clamped to a sane [0.15, 0.6] so a bad value can't break
|
|
67
|
+
* the layout. Adapter-level styling knob (NOT wire — appearance, not
|
|
68
|
+
* arrangement). */
|
|
69
|
+
private readonly sidebarFraction;
|
|
65
70
|
/** Set once ESC[?1049h was written, so dispose() emits the paired
|
|
66
71
|
* ESC[?1049l exactly once (idempotent restore — same discipline as the
|
|
67
72
|
* cursor restore Ink does on unmount). */
|
|
68
73
|
private altEntered;
|
|
69
74
|
constructor(opts?: {
|
|
70
75
|
viewport?: "fill" | "content";
|
|
76
|
+
sidebarFraction?: number;
|
|
71
77
|
});
|
|
72
78
|
/** Injected by tui-cli.ts: lets a keyboard Ctrl-C (which, under Ink's raw
|
|
73
79
|
* mode, is delivered as input 0x03 and never raises SIGINT) reach the
|
|
@@ -112,6 +118,13 @@ export declare class TuiAdapter implements Adapter {
|
|
|
112
118
|
* or an unparseable EXISTING store surfaces the loud interstitial + a
|
|
113
119
|
* stderr line and does NOT clobber a possibly-unrelated user file. */
|
|
114
120
|
storage(scope: "local" | "session", key: string, value: string): void;
|
|
121
|
+
/** Save an authenticated-download blob to the terminal user's filesystem.
|
|
122
|
+
* Directory precedence: $XDG_DOWNLOAD_DIR → ~/Downloads (if it exists) →
|
|
123
|
+
* process.cwd(). The chosen path is printed to stderr so the operator
|
|
124
|
+
* can find it. The filename is sanitized (no path components / no `..`)
|
|
125
|
+
* so a hostile/buggy server can't escape the download dir; this is the
|
|
126
|
+
* TUI counterpart to the browser's built-in download-name sanitization. */
|
|
127
|
+
saveFile(data: Blob, filename: string, _contentType: string): Promise<void>;
|
|
115
128
|
/** Test-only: read back a session value. The wire contract has no storage
|
|
116
129
|
* read; this exists solely so a unit test can prove the write landed
|
|
117
130
|
* (same rationale as exporting osc52 for a deterministic test). */
|
package/dist/tui.js
CHANGED
|
@@ -4,7 +4,7 @@ import { render as inkRender, Box, Text, useInput, useStdin, useStdout, } from "
|
|
|
4
4
|
import TextInput from "ink-text-input";
|
|
5
5
|
import SelectInput from "ink-select-input";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
const NO_CTX = {
|
|
@@ -990,6 +990,7 @@ function App(props) {
|
|
|
990
990
|
interactive,
|
|
991
991
|
fill: fillViewport,
|
|
992
992
|
fillCols: fillViewport ? vp.cols : undefined,
|
|
993
|
+
railFraction: props.railFraction,
|
|
993
994
|
draft: (k) => draftFor(k),
|
|
994
995
|
onFieldChange: (k, v) => setDraft((s) => ({ ...s, [k]: v })),
|
|
995
996
|
onFieldSubmit,
|
|
@@ -1046,12 +1047,19 @@ export class TuiAdapter {
|
|
|
1046
1047
|
* interactive TTY; "content" = legacy intrinsic-content size, no
|
|
1047
1048
|
* alt-screen (the opt-out escape hatch — pre-0.4.5 behavior). */
|
|
1048
1049
|
viewport;
|
|
1050
|
+
/** `sidebar` rail width as a fraction of the terminal (fill path only).
|
|
1051
|
+
* Default 1/3; clamped to a sane [0.15, 0.6] so a bad value can't break
|
|
1052
|
+
* the layout. Adapter-level styling knob (NOT wire — appearance, not
|
|
1053
|
+
* arrangement). */
|
|
1054
|
+
sidebarFraction;
|
|
1049
1055
|
/** Set once ESC[?1049h was written, so dispose() emits the paired
|
|
1050
1056
|
* ESC[?1049l exactly once (idempotent restore — same discipline as the
|
|
1051
1057
|
* cursor restore Ink does on unmount). */
|
|
1052
1058
|
altEntered = false;
|
|
1053
1059
|
constructor(opts) {
|
|
1054
1060
|
this.viewport = opts?.viewport ?? "fill";
|
|
1061
|
+
const f = opts?.sidebarFraction ?? 1 / 3;
|
|
1062
|
+
this.sidebarFraction = Math.min(0.6, Math.max(0.15, f));
|
|
1055
1063
|
}
|
|
1056
1064
|
/** Injected by tui-cli.ts: lets a keyboard Ctrl-C (which, under Ink's raw
|
|
1057
1065
|
* mode, is delivered as input 0x03 and never raises SIGINT) reach the
|
|
@@ -1107,7 +1115,7 @@ export class TuiAdapter {
|
|
|
1107
1115
|
}
|
|
1108
1116
|
/** The interactive element used by render() and by interaction unit tests. */
|
|
1109
1117
|
createApp(vm, onAction, opts) {
|
|
1110
|
-
return (_jsx(App, { vm: vm, onAction: onAction, requestExit: opts?.requestExit ?? this.requestExit, interstitial: this.interstitial, viewport: this.viewport, renderWith: (v, rctx) => this.renderNode(v, 0, "comfortable", undefined, rctx) }));
|
|
1118
|
+
return (_jsx(App, { vm: vm, onAction: onAction, requestExit: opts?.requestExit ?? this.requestExit, interstitial: this.interstitial, viewport: this.viewport, railFraction: this.sidebarFraction, renderWith: (v, rctx) => this.renderNode(v, 0, "comfortable", undefined, rctx) }));
|
|
1111
1119
|
}
|
|
1112
1120
|
/** Render a full-screen loud notice through the SINGLE existing Ink instance
|
|
1113
1121
|
* (never a 2nd inkRender, never a raw stdout write that would corrupt Ink's
|
|
@@ -1182,6 +1190,37 @@ export class TuiAdapter {
|
|
|
1182
1190
|
this.showInterstitial(`Storage write FAILED — your data was NOT saved.\n\n key: ${key}\n error: ${m}`);
|
|
1183
1191
|
}
|
|
1184
1192
|
}
|
|
1193
|
+
/** Save an authenticated-download blob to the terminal user's filesystem.
|
|
1194
|
+
* Directory precedence: $XDG_DOWNLOAD_DIR → ~/Downloads (if it exists) →
|
|
1195
|
+
* process.cwd(). The chosen path is printed to stderr so the operator
|
|
1196
|
+
* can find it. The filename is sanitized (no path components / no `..`)
|
|
1197
|
+
* so a hostile/buggy server can't escape the download dir; this is the
|
|
1198
|
+
* TUI counterpart to the browser's built-in download-name sanitization. */
|
|
1199
|
+
async saveFile(data, filename, _contentType) {
|
|
1200
|
+
const xdg = process.env.XDG_DOWNLOAD_DIR;
|
|
1201
|
+
const home = homedir();
|
|
1202
|
+
const dir = xdg && xdg.trim()
|
|
1203
|
+
? xdg
|
|
1204
|
+
: existsSync(join(home, "Downloads")) ? join(home, "Downloads") : process.cwd();
|
|
1205
|
+
mkdirSync(dir, { recursive: true });
|
|
1206
|
+
// Strip path separators (POSIX + Windows) and any leading dots so a
|
|
1207
|
+
// server-supplied "../etc/passwd" or "foo/bar" becomes a flat basename.
|
|
1208
|
+
// Empty/dot-only results fall back to literal "download".
|
|
1209
|
+
const sanitized = filename
|
|
1210
|
+
.split(/[/\\]/).pop() // drop everything before the last separator
|
|
1211
|
+
.replace(/^\.+/, "") // strip leading dots (no hidden files via traversal)
|
|
1212
|
+
.trim();
|
|
1213
|
+
const safeName = sanitized.length > 0 ? sanitized : "download";
|
|
1214
|
+
const out = join(dir, safeName);
|
|
1215
|
+
const buf = Buffer.from(await data.arrayBuffer());
|
|
1216
|
+
writeFileSync(out, buf);
|
|
1217
|
+
try {
|
|
1218
|
+
process.stderr.write(`vms-tui: saved ${out}\n`);
|
|
1219
|
+
}
|
|
1220
|
+
catch {
|
|
1221
|
+
/* stderr unavailable — the file landed regardless */
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1185
1224
|
/** Test-only: read back a session value. The wire contract has no storage
|
|
1186
1225
|
* read; this exists solely so a unit test can prove the write landed
|
|
1187
1226
|
* (same rationale as exporting osc52 for a deterministic test). */
|
|
@@ -1275,7 +1314,15 @@ export class TuiAdapter {
|
|
|
1275
1314
|
// ORIGINAL flex props, byte-identical.
|
|
1276
1315
|
const cols = topLevel && rctx.fillCols ? rctx.fillCols : 0;
|
|
1277
1316
|
const filling = cols > 0;
|
|
1278
|
-
|
|
1317
|
+
// 0.4.9 — PROPORTIONAL rail (default 1/3, clamped [24,56]) instead of
|
|
1318
|
+
// a hardcoded 24 (unusable on wide terminals: 24/146 ≈ 16%). Never
|
|
1319
|
+
// below the old 24 on small terminals; capped so ultra-wide keeps the
|
|
1320
|
+
// detail pane dominant. Tunable via `new TuiAdapter({ sidebarFraction })`.
|
|
1321
|
+
// Only the filling branches use RAIL; the non-fill path keeps the
|
|
1322
|
+
// literal flexBasis:24/minWidth:18 (byte-identical).
|
|
1323
|
+
const RAIL = filling
|
|
1324
|
+
? Math.min(56, Math.max(24, Math.round(cols * (rctx.railFraction ?? 1 / 3))))
|
|
1325
|
+
: 24;
|
|
1279
1326
|
const g = sp.gap || 1;
|
|
1280
1327
|
const mw = Math.max(1, cols - RAIL - g);
|
|
1281
1328
|
if (rest.length === 0) {
|
|
@@ -1284,7 +1331,11 @@ export class TuiAdapter {
|
|
|
1284
1331
|
// numeric width (a default-row box would not width-stretch it).
|
|
1285
1332
|
_jsx(Box, { flexDirection: "column", flexShrink: 0, ...fillW, children: rail })) : (_jsx(Box, { flexShrink: 0, flexBasis: 24, minWidth: 18, children: rail }));
|
|
1286
1333
|
}
|
|
1287
|
-
return (_jsxs(Box, { flexDirection: "row", gap: g, ...fillW, children: [filling ? (
|
|
1334
|
+
return (_jsxs(Box, { flexDirection: "row", gap: g, ...fillW, children: [filling ? (
|
|
1335
|
+
// flexDirection:column ⇒ the rail's section align-stretches to
|
|
1336
|
+
// the numeric RAIL width (a default-row box sizes it to content
|
|
1337
|
+
// — same lesson as the single-child branch).
|
|
1338
|
+
_jsx(Box, { flexDirection: "column", flexShrink: 0, width: RAIL, children: rail })) : (_jsx(Box, { flexShrink: 0, flexBasis: 24, minWidth: 18, children: rail })), filling ? (
|
|
1288
1339
|
// Single numeric-width column directly holding the detail
|
|
1289
1340
|
// sections — exactly the `stack` shape the oracle proves scales
|
|
1290
1341
|
// (a card section align-stretches to a numeric-width parent).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A server-driven UI framework where the wire format is structured enough that agents can build full-stack apps without ever opening a browser and all UI tests are pure unit tests with no browser runtime. Server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to DOM. Backend-agnostic — a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|