staysfixed 0.1.0 → 0.2.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.
@@ -0,0 +1,286 @@
1
+ /**
2
+ * The live watch window, from the run's point of view.
3
+ *
4
+ * A run describes itself into an event stream and carries on. This subscribes to
5
+ * that stream, opens a panel beside the app, and forwards everything to it. It
6
+ * has no other effect on the run: it takes nothing away from it, it holds
7
+ * nothing up, and when the window cannot open the run does not notice.
8
+ *
9
+ * Because the stream hands a new listener everything that already happened, a
10
+ * panel that takes two seconds to open still draws the screens photographed
11
+ * before it was there.
12
+ *
13
+ * The panel opens before the run does, and the app it belongs beside is opened
14
+ * inside the run — so the two are introduced afterwards, through `snapTo`. That
15
+ * is the whole of the seam: the run says "here is the app", the panel moves
16
+ * itself flush against it, and nothing about the app's page is touched either way.
17
+ */
18
+
19
+ import path from 'node:path';
20
+
21
+ import { warn, detail } from '../core/log.js';
22
+ import { messageOf } from '../core/errors.js';
23
+ import { loadGuards } from '../guard/load.js';
24
+ import { openPanel } from './window.js';
25
+
26
+ /** Panel width when nobody says otherwise. */
27
+ const DEFAULT_WIDTH = 460;
28
+
29
+ /**
30
+ * What the panel is told to do.
31
+ *
32
+ * `snap` is written down here rather than in `src/types.js` so that whether the
33
+ * two windows are pushed together stays a decision of the watch code. When
34
+ * `WatchOptions` grows a `snap` of its own this intersection quietly becomes a
35
+ * no-op.
36
+ *
37
+ * @typedef {import('../types.js').WatchOptions & {snap?: boolean}} PanelOptions
38
+ */
39
+
40
+ /**
41
+ * What the CLI hands over. Anything the person did not type is left out, so the
42
+ * settings file still gets a say.
43
+ * @typedef {object} WatchFlags
44
+ * @property {boolean} [enabled]
45
+ * @property {'left'|'right'} [side]
46
+ * @property {number} [width]
47
+ * @property {number} [height]
48
+ * @property {boolean} [keepOpen]
49
+ * @property {boolean} [foreground]
50
+ * @property {boolean} [snap]
51
+ * @property {'dark'|'light'|'system'} [theme]
52
+ */
53
+
54
+ /**
55
+ * @typedef {object} AttachOptions
56
+ * @property {import('../types.js').Project} [project]
57
+ * @property {import('./window.js').PlanInput} [plan] Given only when the caller knows better than the project does.
58
+ * @property {PanelOptions} [watch]
59
+ * @property {{width: number, height: number}} [appViewport]
60
+ */
61
+
62
+ /**
63
+ * A run's handle on its panel.
64
+ *
65
+ * `snapTo` is how the run introduces the app once it is open. It is safe to call
66
+ * when there is no panel, when the panel cannot move windows, and when the
67
+ * person asked for `--no-snap`: in every one of those cases it does nothing.
68
+ *
69
+ * @typedef {object} Watcher
70
+ * @property {() => Promise<void>} stop
71
+ * @property {(app: import('../types.js').LaunchedApp) => Promise<void>} snapTo
72
+ */
73
+
74
+ /**
75
+ * The one part of a panel this file asks for by name.
76
+ *
77
+ * `snapTo` is built in `window.js` and is optional here on purpose: a panel that
78
+ * cannot move windows is still a perfectly good panel, and is simply never asked
79
+ * to move.
80
+ *
81
+ * @typedef {{snapTo?: (app: import('../types.js').LaunchedApp) => Promise<void>}} Snappable
82
+ */
83
+
84
+ /**
85
+ * A watcher that is not watching anything. Handed back whenever the panel could
86
+ * not open, so every caller has the same shape to stop, whatever happened.
87
+ * @returns {Watcher}
88
+ */
89
+ function noWatcher() {
90
+ return { stop: async () => {}, snapTo: async () => {} };
91
+ }
92
+
93
+ /**
94
+ * The guards, for the opening list.
95
+ *
96
+ * They live in files, so this reads the folder the same way the run will. It is
97
+ * cheap, and it is the difference between a window that shows the whole plan
98
+ * from the first frame and one that fills in as it goes. A guards folder that
99
+ * will not load is the run's problem to report, not the panel's — here it just
100
+ * means the guards appear as they start.
101
+ *
102
+ * @param {import('../types.js').Project|undefined} project
103
+ * @returns {Promise<import('./panel.js').PanelRow[]>}
104
+ */
105
+ async function guardRows(project) {
106
+ if (!project) return [];
107
+ try {
108
+ const guards = await loadGuards(project);
109
+ return guards
110
+ .filter((guard) => guard && guard.skip !== true)
111
+ .map((guard) => ({ name: String(guard.name), describe: guard.because ? String(guard.because) : undefined }));
112
+ } catch {
113
+ return [];
114
+ }
115
+ }
116
+
117
+ /**
118
+ * @param {import('../types.js').Project|undefined} project
119
+ * @returns {import('./panel.js').PanelRow[]}
120
+ */
121
+ function screenRows(project) {
122
+ const screens = project?.config?.screens ?? [];
123
+ return screens
124
+ .filter((screen) => screen && screen.skip !== true)
125
+ .map((screen) => ({ name: String(screen.name), describe: screen.describe ? String(screen.describe) : undefined }));
126
+ }
127
+
128
+ /**
129
+ * @param {AttachOptions} opts
130
+ * @returns {Promise<import('./window.js').PlanInput>}
131
+ */
132
+ async function planFor(opts) {
133
+ const given = opts.plan ?? {};
134
+ const project = opts.project;
135
+ return {
136
+ project: given.project ?? (project ? path.basename(project.paths.root) : undefined),
137
+ app: given.app ?? project?.config?.app,
138
+ screens: given.screens ?? screenRows(project),
139
+ guards: given.guards ?? (await guardRows(project)),
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Open a panel and point it at a run.
145
+ *
146
+ * Never throws. A live view is worth having and worth nothing next to the
147
+ * pictures, so every way this can go wrong ends the same way: a warning in
148
+ * plain words, a watcher that does nothing, and a run that carries on.
149
+ *
150
+ * @param {import('../types.js').RunEvents} events
151
+ * @param {AttachOptions} [opts]
152
+ * @returns {Promise<Watcher>}
153
+ */
154
+ export async function attachWatcher(events, opts = {}) {
155
+ const watch = opts.watch ?? {};
156
+ if (watch.enabled === false) return noWatcher();
157
+ if (!events || typeof events.on !== 'function') return noWatcher();
158
+
159
+ /** @type {import('./window.js').Panel|null} */
160
+ let panel = null;
161
+ try {
162
+ panel = await openPanel({
163
+ project: opts.project,
164
+ plan: await planFor(opts),
165
+ watch,
166
+ appViewport: opts.appViewport ?? opts.project?.config?.viewport,
167
+ });
168
+ } catch (e) {
169
+ // openPanel reports its own trouble and hands back null; this is only here
170
+ // for whatever it could not have seen coming.
171
+ warn(`The watch window could not open, so this run has no live view. ${messageOf(e)}`);
172
+ panel = null;
173
+ }
174
+ if (!panel) return noWatcher();
175
+ const open = panel;
176
+
177
+ // Everything that already happened arrives here first, in order, before the
178
+ // first live event does.
179
+ const unsubscribe = events.on((event) => {
180
+ // Queued, never awaited: the run must not wait on a window.
181
+ void open.push(event);
182
+ });
183
+
184
+ // `--no-snap` is honoured here, not in the panel, so that "leave both windows
185
+ // exactly where they are" holds however the panel is built.
186
+ const maySnap = watch.snap !== false;
187
+ const snappable = /** @type {Snappable} */ (/** @type {unknown} */ (open));
188
+
189
+ /** @type {Promise<void>|null} */
190
+ let stopping = null;
191
+
192
+ return {
193
+ stop: () => {
194
+ stopping ??= (async () => {
195
+ try {
196
+ unsubscribe();
197
+ } catch {
198
+ // Already gone. Nothing left to stop listening to.
199
+ }
200
+ await open.close().catch(() => {});
201
+ })();
202
+ return stopping;
203
+ },
204
+
205
+ snapTo: async (app) => {
206
+ if (!maySnap || typeof snappable.snapTo !== 'function') return;
207
+ try {
208
+ await snappable.snapTo(app);
209
+ } catch (e) {
210
+ // Where two windows sit is a nicety. Losing it must never cost a run,
211
+ // and it is not worth a warning in the middle of a clean one.
212
+ detail(`The panel could not put itself beside the app. ${messageOf(e)}`);
213
+ }
214
+ },
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Settle what the panel should do: the settings file first, then anything typed
220
+ * on the command line, then the defaults written down in `src/types.js`.
221
+ *
222
+ * `--watch` can only ever turn the panel on. A person who did not type it has
223
+ * not said no to it — they have said nothing — so it never switches off a panel
224
+ * the settings file asked for.
225
+ *
226
+ * @param {{watch?: import('../types.js').WatchOptions|boolean}|null} [config]
227
+ * @param {WatchFlags|null} [cli]
228
+ * @returns {PanelOptions}
229
+ */
230
+ export function watchOptionsFrom(config, cli) {
231
+ const raw = config?.watch;
232
+ // Read as a PanelOptions on the way in: a settings file is free to carry a
233
+ // `snap` that the shared `WatchOptions` shape does not yet describe.
234
+ const settings = /** @type {PanelOptions} */ (
235
+ raw === true ? { enabled: true } : raw && typeof raw === 'object' ? raw : {}
236
+ );
237
+ const flags = cli ?? {};
238
+
239
+ const width = firstNumber(flags.width, settings.width) ?? DEFAULT_WIDTH;
240
+ const height = firstNumber(flags.height, settings.height);
241
+ const side = flags.side ?? settings.side ?? 'right';
242
+ const theme = /** @type {any} */ (flags).theme ?? settings.theme;
243
+
244
+ /** @type {PanelOptions} */
245
+ const merged = {
246
+ enabled: flags.enabled === true || settings.enabled === true,
247
+ width,
248
+ side: side === 'left' ? 'left' : 'right',
249
+ keepOpen: firstBoolean(flags.keepOpen, settings.keepOpen) ?? true,
250
+ foreground: firstBoolean(flags.foreground, settings.foreground) ?? false,
251
+ // On by default: the point of the panel is that it reads as a side panel of
252
+ // the app it is checking, and it cannot do that sitting somewhere else.
253
+ snap: firstBoolean(flags.snap, settings.snap) ?? true,
254
+ // Dark unless somebody asks otherwise. The panel opens on a brand new browser
255
+ // profile, and a fresh profile insists the computer is in light mode however it
256
+ // is actually set, so this is stated rather than detected.
257
+ theme: theme === 'light' || theme === 'system' ? theme : 'dark',
258
+ };
259
+ // Left out rather than guessed: with no height the panel is as tall as the app.
260
+ if (height !== undefined) merged.height = height;
261
+ return merged;
262
+ }
263
+
264
+ /**
265
+ * @param {...unknown} values
266
+ * @returns {number|undefined}
267
+ */
268
+ function firstNumber(...values) {
269
+ for (const value of values) {
270
+ if (value === undefined || value === null || value === '') continue;
271
+ const n = Number(value);
272
+ if (Number.isFinite(n) && n > 0) return Math.round(n);
273
+ }
274
+ return undefined;
275
+ }
276
+
277
+ /**
278
+ * @param {...unknown} values
279
+ * @returns {boolean|undefined}
280
+ */
281
+ function firstBoolean(...values) {
282
+ for (const value of values) {
283
+ if (typeof value === 'boolean') return value;
284
+ }
285
+ return undefined;
286
+ }