opencode-docker-panel 0.4.8 → 0.5.1

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/dist/panel.js CHANGED
@@ -6,15 +6,16 @@ import { createTextNode as _$createTextNode } from "@opentui/solid";
6
6
  import { insertNode as _$insertNode } from "@opentui/solid";
7
7
  import { setProp as _$setProp } from "@opentui/solid";
8
8
  import { createElement as _$createElement } from "@opentui/solid";
9
- import { Index, Show, createSignal, onCleanup } from "solid-js";
9
+ import { Index, Show, createEffect, createSignal, onCleanup } from "solid-js";
10
10
  import { ACTION_LABEL, availableActions, buildArgs, runDockerArgs } from "./commands.js";
11
11
  import { findComposeFile } from "./compose.js";
12
12
  import { runDockerLogs } from "./logs.js";
13
- import { createDockerPolling, selectRows } from "./poll.js";
13
+ import { createDockerPolling, createEngineProbe, selectRows } from "./poll.js";
14
+ import { ENGINE_ACTION_LABEL, buildRuntimeArgs } from "./runtime.js";
14
15
  const LOG_VIEW_LINES = 15;
15
16
  const LOG_REFRESH_MS = 2000;
16
17
 
17
- /** The argv a stack needs, in one place so the menu footer and the run cannot drift apart. */
18
+ /** One source for the argv, so the menu footer and the run cannot drift apart */
18
19
  function stackArgs(target) {
19
20
  return ["compose", "-f", target.file, "-p", target.project, "up", "-d"];
20
21
  }
@@ -37,7 +38,7 @@ function hasStaleRows(state) {
37
38
  return (state.kind === "unavailable" || state.kind === "stale") && state.containers.length > 0;
38
39
  }
39
40
 
40
- /** A fixed viewport is the only way to keep the height predictable: opentui has no scrollable container. */
41
+ /** Fixed viewport: opentui has no scrollable container, so the height has to be predictable */
41
42
  function LogsView(props) {
42
43
  const lines = () => props.result?.lines ?? [];
43
44
  const end = () => Math.max(0, lines().length - props.offset);
@@ -161,6 +162,7 @@ function LogsView(props) {
161
162
  export function DockerPanel(props) {
162
163
  const [open, setOpen] = createSignal(true);
163
164
  const [busy, setBusy] = createSignal(false);
165
+ const [stopping, setStopping] = createSignal(false);
164
166
  const [logs, setLogs] = createSignal(null);
165
167
  const [offset, setOffset] = createSignal(0);
166
168
  const theme = () => props.theme;
@@ -222,22 +224,45 @@ export function DockerPanel(props) {
222
224
  if (logTimer) clearInterval(logTimer);
223
225
  if (logs()) props.ui.dialog.clear();
224
226
  });
227
+ const engine = createEngineProbe();
228
+ // The probe only has to be re-read when the poll changes its verdict: an available docker proves the
229
+ // engine answers, and a probe on every poll would stack several four second calls on the same pipe,
230
+ // while never re-reading it would leave a stale stopped engine once docker starts from the tray
231
+ let lastKind = "";
232
+ const polling = createDockerPolling(props.intervalMs, props.reload, next => {
233
+ refreshStack();
234
+ if (next.kind === lastKind) return;
235
+ lastKind = next.kind;
236
+ void engine.refresh();
237
+ });
238
+
239
+ // A stop the container poll could see beats a probe that has not answered yet: the poll knows the
240
+ // Desktop pipe is missing in a quarter second, the probe needs four seconds to admit the same
241
+ const engineDown = () => polling.state().kind === "stopped" || engine.state() === "stopped";
225
242
 
226
- // Read the compose file when the panel mounts and again on every poll, never while the host is
227
- // assembling the render tree.
243
+ // Read on mount and on every poll, never while the host assembles the render tree
228
244
  const [stackSignal, setStackSignal] = createSignal(null);
229
245
  const [pendingStack, setPendingStack] = createSignal(null);
230
246
  const derivePendingStack = stack => {
231
- setPendingStack(stack && !polling.state().containers.some(item => item.composeProject === stack.project) ? stack : null);
247
+ setPendingStack(stack && !engineDown() && !polling.state().containers.some(item => item.composeProject === stack.project) ? stack : null);
232
248
  };
233
249
  const refreshStack = () => {
234
250
  const stack = findComposeFile(props.agentDir);
235
251
  setStackSignal(stack);
236
252
  derivePendingStack(stack);
237
253
  };
238
- const polling = createDockerPolling(props.intervalMs, props.reload, refreshStack);
239
254
 
240
- /** One entry point for every action, so the busy guard and the refresh cannot be forgotten. */
255
+ // A stopped engine makes every `docker logs` call fail, so the window keeps its last lines and the
256
+ // polling stops rather than spawning a doomed process every two seconds
257
+ createEffect(() => {
258
+ const state = engine.state();
259
+ if (state === "stopped" || state === "absent") {
260
+ if (logTimer) clearInterval(logTimer);
261
+ logTimer = undefined;
262
+ }
263
+ });
264
+
265
+ /** Single entry point, so the busy guard and the refresh cannot be skipped */
241
266
  const run = async (action, title, args) => {
242
267
  if (busy()) return;
243
268
  setBusy(true);
@@ -250,53 +275,111 @@ export function DockerPanel(props) {
250
275
  });
251
276
  await polling.refreshAfterAction();
252
277
  };
253
-
254
- /**
255
- * The stack the agent directory declares, but only while none of its containers is on the machine.
256
- * Computed once per poll instead of inside the markup: a `Show` and its own label both read this,
257
- * and re-deriving it during render is what the crash traced back to.
278
+ /**
279
+ * Opens the same actions for a container the sidebar does not show, and carries the one engine
280
+ * action last.
281
+ *
282
+ * The engine entry stays available with no containers at all, because that is exactly when Docker is
283
+ * down and it is the only way to turn it back on.
258
284
  */
259
-
260
- /** The list of every container, from which picking one opens the same actions as its row does. */
261
285
  const pickContainer = async all => {
262
- const choice = await props.ui.dialog.select({
286
+ const action = engine.state() === "running" ? "engine-stop" : undefined;
287
+ const pending = props.ui.dialog.select({
263
288
  title: `All containers (${all.length})`,
264
289
  placeholder: "Container",
265
- options: all.map(container => ({
290
+ options: [...all.map(container => ({
266
291
  title: container.name,
267
- value: container,
292
+ value: {
293
+ kind: "container",
294
+ container
295
+ },
268
296
  description: detailLabel(container, props.pinned),
269
297
  footer: container.state
270
- }))
298
+ })), ...(action ? [{
299
+ title: ENGINE_ACTION_LABEL[action],
300
+ value: {
301
+ kind: "action",
302
+ action
303
+ },
304
+ description: "stops every container of every project",
305
+ footer: `docker ${buildRuntimeArgs(action).join(" ")}`
306
+ }] : [])]
271
307
  });
308
+ props.ui.dialog.set({
309
+ size: "large"
310
+ });
311
+ const choice = await pending;
272
312
  if (!choice) return;
273
- // The list is a snapshot: a container can stop while it is open, so re-read it before offering actions.
274
- const fresh = polling.state().containers.find(item => item.name === choice.name);
313
+ if (choice.kind === "action") {
314
+ await runRuntime(choice.action);
315
+ return;
316
+ }
317
+ // The list is a snapshot, a container can stop while it is open, so re-read before acting
318
+ const fresh = polling.state().containers.find(item => item.name === choice.container.name);
275
319
  if (fresh) await openMenu(fresh);
276
320
  };
277
321
 
278
- /**
279
- * The single door to the containers the sidebar does not show. Clicking the header lands on the
280
- * list itself, so there is no menu to pick through first.
281
- */
322
+ /** Single door to the containers the sidebar does not show, so the header opens the list itself */
282
323
  const openBrowse = async () => {
283
324
  if (busy()) return;
284
- const all = polling.state().containers;
285
- if (all.length === 0) {
325
+ await pickContainer(polling.state().containers);
326
+ };
327
+
328
+ /**
329
+ * Both engine actions ask first, and Stop names what it is about to end.
330
+ *
331
+ * Start confirms too: it brings up a virtual machine and starts consuming memory, so a misclick is
332
+ * not free even though nothing is running that it would interrupt.
333
+ */
334
+ const runRuntime = async action => {
335
+ if (busy()) return;
336
+ const args = buildRuntimeArgs(action);
337
+ const title = ENGINE_ACTION_LABEL[action];
338
+ if (action === "engine-stop") {
339
+ const running = polling.state().containers.filter(item => item.state === "running");
340
+ const names = running.length > 0 ? running.map(item => item.name).join(", ") : "nothing the panel knows about";
341
+ const confirmed = await props.ui.dialog.confirm({
342
+ title: "Stop Docker Desktop?",
343
+ message: `Stops the engine, so every container stops with it. Visible to the panel: ${names}.\n\nNot reversible from the panel.`,
344
+ label: {
345
+ confirm: "Stop",
346
+ cancel: "Cancel"
347
+ }
348
+ });
349
+ if (!confirmed) return;
350
+ } else {
351
+ const confirmed = await props.ui.dialog.confirm({
352
+ title: "Start Docker Desktop?",
353
+ message: `Brings up the engine and its virtual machine.\n\ndocker ${args.join(" ")}`,
354
+ label: {
355
+ confirm: "Start",
356
+ cancel: "Cancel"
357
+ }
358
+ });
359
+ if (!confirmed) return;
360
+ }
361
+ // The containers are about to go, so the rows go with them instead of staying on screen for a
362
+ // poll or two while the engine finishes shutting down
363
+ if (action === "engine-stop") setStopping(true);
364
+ setBusy(true);
365
+ try {
366
+ const result = await runDockerArgs(args);
286
367
  props.ui.toast.show({
287
- title: "Docker",
288
- message: "no containers",
289
- variant: "info"
368
+ title,
369
+ message: result.ok ? `${title}: ${result.message}` : result.message,
370
+ variant: result.ok ? "success" : "error"
290
371
  });
291
- return;
372
+ // busy stays on for the whole transition, otherwise working disappears while the VM boots
373
+ // A command that failed will never bring the engine up, and waiting for one costs two minutes
374
+ if (action === "engine-start" && result.ok) await engine.waitForRunning();
375
+ await polling.refreshAfterAction();
376
+ } finally {
377
+ setStopping(false);
378
+ setBusy(false);
292
379
  }
293
- await pickContainer(all);
294
380
  };
295
381
 
296
- /**
297
- * A stack has no container to be the subject of the action, and its file carries build, command and
298
- * entrypoint, so it asks once and shows the exact argv in the prompt.
299
- */
382
+ /** A stack has no container to act on and its file carries build and command lines, so confirm with the exact argv */
300
383
  const openStack = async () => {
301
384
  if (busy()) return;
302
385
  const stack = pendingStack();
@@ -315,7 +398,7 @@ export function DockerPanel(props) {
315
398
  const openMenu = async container => {
316
399
  if (busy()) return;
317
400
  const isPinned = props.pinned.includes(container.name);
318
- // Read once per menu: compose can build images, so the action must not wait on a second read.
401
+ // Read once per menu, compose can build images and the action must not wait on a second read
319
402
  const stack = stackSignal()?.project === container.composeProject ? stackSignal() : null;
320
403
  const options = [...availableActions(container, stack).map(item => ({
321
404
  title: ACTION_LABEL[item],
@@ -334,11 +417,15 @@ export function DockerPanel(props) {
334
417
  footer: "stored by the host, survives a restart"
335
418
  }];
336
419
  try {
337
- const choice = await props.ui.dialog.select({
420
+ const pending = props.ui.dialog.select({
338
421
  title: container.name,
339
422
  placeholder: "Action",
340
423
  options
341
424
  });
425
+ props.ui.dialog.set({
426
+ size: "large"
427
+ });
428
+ const choice = await pending;
342
429
  if (!choice) return;
343
430
  if (choice === "logs") {
344
431
  openLogs(container);
@@ -375,14 +462,17 @@ export function DockerPanel(props) {
375
462
  };
376
463
 
377
464
  // A click in opentui is a mousedown plus a mouseup, and the host dismisses an overlay from the
378
- // next mouse event it sees. Every dialog is therefore armed on the release, and only mounted after
379
- // that release has finished propagating, so the release cannot land on its own backdrop.
465
+ // next mouse event it sees, so every dialog is armed on the release and mounted after it propagates
380
466
  const arm = (open, event) => {
381
467
  event.stopPropagation();
382
468
  setTimeout(() => void open(), 0);
383
469
  };
384
- const visible = () => selectRows(polling.state().containers, props.pinned);
470
+ const visible = () => stopping() ? [] : selectRows(polling.state().containers, props.pinned);
385
471
  const hasRows = () => visible().length > 0;
472
+ const hidden = () => Math.max(0, polling.state().containers.length - visible().length);
473
+ // The line stays when nothing is hidden because it is also the only way into the list, which is where
474
+ // stopping the engine lives
475
+ const browseLabel = () => hidden() > 0 ? `${hidden()} more, click for all` : `${polling.state().containers.length} container${polling.state().containers.length === 1 ? "" : "s"}, click for all`;
386
476
  const pressHeader = () => {
387
477
  if (hasRows()) {
388
478
  setOpen(value => !value);
@@ -404,7 +494,7 @@ export function DockerPanel(props) {
404
494
  _$insertNode(_el$21, _$createTextNode(`Docker`));
405
495
  _$insert(_el$20, _$createComponent(Show, {
406
496
  get when() {
407
- return polling.state().containers.length > 0;
497
+ return _$memo(() => !!!stopping())() && polling.state().containers.length > 0;
408
498
  },
409
499
  get children() {
410
500
  var _el$23 = _$createElement("span"),
@@ -419,52 +509,80 @@ export function DockerPanel(props) {
419
509
  return _el$23;
420
510
  }
421
511
  }), null);
422
- _$insert(_el$20, _$createComponent(Show, {
512
+ _$insert(_el$19, _$createComponent(Show, {
423
513
  get when() {
424
- return _$memo(() => !!!hasRows())() && polling.state().containers.length > 0;
514
+ return hasStaleRows(polling.state());
425
515
  },
426
516
  get children() {
427
- var _el$26 = _$createElement("span");
428
- _$insertNode(_el$26, _$createTextNode(` click for the list`));
429
- _$effect(_$p => _$setProp(_el$26, "style", {
430
- fg: theme().text.muted
431
- }, _$p));
517
+ var _el$26 = _$createElement("text");
518
+ _$insertNode(_el$26, _$createTextNode(` stale`));
519
+ _$effect(_$p => _$setProp(_el$26, "fg", theme().text.feedback.warning.base, _$p));
432
520
  return _el$26;
433
521
  }
434
522
  }), null);
435
523
  _$insert(_el$19, _$createComponent(Show, {
436
524
  get when() {
437
- return hasStaleRows(polling.state());
525
+ return busy();
438
526
  },
439
527
  get children() {
440
528
  var _el$28 = _$createElement("text");
441
- _$insertNode(_el$28, _$createTextNode(` stale`));
442
- _$effect(_$p => _$setProp(_el$28, "fg", theme().text.feedback.warning.base, _$p));
529
+ _$insertNode(_el$28, _$createTextNode(` working`));
530
+ _$effect(_$p => _$setProp(_el$28, "fg", theme().text.muted, _$p));
443
531
  return _el$28;
444
532
  }
445
533
  }), null);
446
- _$insert(_el$19, _$createComponent(Show, {
534
+ _$insert(_el$18, _$createComponent(Show, {
447
535
  get when() {
448
- return busy();
536
+ return _$memo(() => !!(!stopping() && !hasRows()))() && polling.state().containers.length > 0;
449
537
  },
450
538
  get children() {
451
- var _el$30 = _$createElement("text");
452
- _$insertNode(_el$30, _$createTextNode(` working`));
539
+ var _el$30 = _$createElement("text"),
540
+ _el$31 = _$createTextNode(` click for the list`);
541
+ _$insertNode(_el$30, _el$31);
542
+ _$setProp(_el$30, "onMouseUp", event => arm(openBrowse, event));
453
543
  _$effect(_$p => _$setProp(_el$30, "fg", theme().text.muted, _$p));
454
544
  return _el$30;
455
545
  }
456
546
  }), null);
457
547
  _$insert(_el$18, _$createComponent(Show, {
458
548
  get when() {
459
- return _$memo(() => polling.state().containers.length === 0)() && polling.state().detail.length > 0;
549
+ return _$memo(() => !!engineDown())() && !busy();
550
+ },
551
+ get children() {
552
+ var _el$33 = _$createElement("text"),
553
+ _el$34 = _$createElement("b"),
554
+ _el$36 = _$createElement("span");
555
+ _$insertNode(_el$33, _el$34);
556
+ _$insertNode(_el$33, _el$36);
557
+ _$setProp(_el$33, "onMouseUp", event => arm(() => void runRuntime("engine-start"), event));
558
+ _$insertNode(_el$34, _$createTextNode(`Start Docker Desktop`));
559
+ _$insertNode(_el$36, _$createTextNode(` · click to start`));
560
+ _$effect(_p$ => {
561
+ var _v$6 = theme().text.base,
562
+ _v$7 = {
563
+ fg: theme().text.muted
564
+ };
565
+ _v$6 !== _p$.e && (_p$.e = _$setProp(_el$33, "fg", _v$6, _p$.e));
566
+ _v$7 !== _p$.t && (_p$.t = _$setProp(_el$36, "style", _v$7, _p$.t));
567
+ return _p$;
568
+ }, {
569
+ e: undefined,
570
+ t: undefined
571
+ });
572
+ return _el$33;
573
+ }
574
+ }), null);
575
+ _$insert(_el$18, _$createComponent(Show, {
576
+ get when() {
577
+ return _$memo(() => !!(!engineDown() && polling.state().containers.length === 0))() && polling.state().detail.length > 0;
460
578
  },
461
579
  get children() {
462
- var _el$32 = _$createElement("text"),
463
- _el$33 = _$createTextNode(` `);
464
- _$insertNode(_el$32, _el$33);
465
- _$insert(_el$32, () => polling.state().detail, null);
466
- _$effect(_$p => _$setProp(_el$32, "fg", theme().text.muted, _$p));
467
- return _el$32;
580
+ var _el$38 = _$createElement("text"),
581
+ _el$39 = _$createTextNode(` `);
582
+ _$insertNode(_el$38, _el$39);
583
+ _$insert(_el$38, () => polling.state().detail, null);
584
+ _$effect(_$p => _$setProp(_el$38, "fg", theme().text.muted, _$p));
585
+ return _el$38;
468
586
  }
469
587
  }), null);
470
588
  _$insert(_el$18, _$createComponent(Show, {
@@ -477,88 +595,86 @@ export function DockerPanel(props) {
477
595
  return visible();
478
596
  },
479
597
  children: container => (() => {
480
- var _el$43 = _$createElement("box"),
481
- _el$44 = _$createElement("text"),
482
- _el$46 = _$createElement("text"),
483
- _el$47 = _$createTextNode(` `),
484
- _el$48 = _$createElement("span");
485
- _$insertNode(_el$43, _el$44);
486
- _$insertNode(_el$43, _el$46);
487
- _$setProp(_el$43, "flexDirection", "row");
488
- _$setProp(_el$43, "gap", 1);
489
- _$setProp(_el$43, "onMouseUp", event => arm(() => openMenu(container()), event));
490
- _$insertNode(_el$44, _$createTextNode(`•`));
491
- _$setProp(_el$44, "flexShrink", 0);
492
- _$insertNode(_el$46, _el$47);
493
- _$insertNode(_el$46, _el$48);
494
- _$setProp(_el$46, "wrapMode", "word");
495
- _$insert(_el$46, () => container().name, _el$47);
496
- _$insert(_el$48, () => detailLabel(container(), props.pinned));
598
+ var _el$48 = _$createElement("box"),
599
+ _el$49 = _$createElement("text"),
600
+ _el$51 = _$createElement("text"),
601
+ _el$52 = _$createTextNode(` `),
602
+ _el$53 = _$createElement("span");
603
+ _$insertNode(_el$48, _el$49);
604
+ _$insertNode(_el$48, _el$51);
605
+ _$setProp(_el$48, "flexDirection", "row");
606
+ _$setProp(_el$48, "gap", 1);
607
+ _$setProp(_el$48, "onMouseUp", event => arm(() => openMenu(container()), event));
608
+ _$insertNode(_el$49, _$createTextNode(`•`));
609
+ _$setProp(_el$49, "flexShrink", 0);
610
+ _$insertNode(_el$51, _el$52);
611
+ _$insertNode(_el$51, _el$53);
612
+ _$setProp(_el$51, "wrapMode", "word");
613
+ _$insert(_el$51, () => container().name, _el$52);
614
+ _$insert(_el$53, () => detailLabel(container(), props.pinned));
497
615
  _$effect(_p$ => {
498
- var _v$8 = dotColor(theme(), container().state),
499
- _v$9 = theme().text.base,
500
- _v$0 = {
616
+ var _v$0 = dotColor(theme(), container().state),
617
+ _v$1 = theme().text.base,
618
+ _v$10 = {
501
619
  fg: theme().text.muted
502
620
  };
503
- _v$8 !== _p$.e && (_p$.e = _$setProp(_el$44, "fg", _v$8, _p$.e));
504
- _v$9 !== _p$.t && (_p$.t = _$setProp(_el$46, "fg", _v$9, _p$.t));
505
- _v$0 !== _p$.a && (_p$.a = _$setProp(_el$48, "style", _v$0, _p$.a));
621
+ _v$0 !== _p$.e && (_p$.e = _$setProp(_el$49, "fg", _v$0, _p$.e));
622
+ _v$1 !== _p$.t && (_p$.t = _$setProp(_el$51, "fg", _v$1, _p$.t));
623
+ _v$10 !== _p$.a && (_p$.a = _$setProp(_el$53, "style", _v$10, _p$.a));
506
624
  return _p$;
507
625
  }, {
508
626
  e: undefined,
509
627
  t: undefined,
510
628
  a: undefined
511
629
  });
512
- return _el$43;
630
+ return _el$48;
513
631
  })()
514
632
  }), _$createComponent(Show, {
515
633
  get when() {
516
- return _$memo(() => !!hasRows())() && polling.state().containers.length > visible().length;
634
+ return hasRows();
517
635
  },
518
636
  get children() {
519
- var _el$34 = _$createElement("text"),
520
- _el$35 = _$createTextNode(` `),
521
- _el$36 = _$createTextNode(` more, click for all`);
522
- _$insertNode(_el$34, _el$35);
523
- _$insertNode(_el$34, _el$36);
524
- _$setProp(_el$34, "onMouseUp", event => arm(openBrowse, event));
525
- _$insert(_el$34, () => polling.state().containers.length - visible().length, _el$36);
526
- _$effect(_$p => _$setProp(_el$34, "fg", theme().text.muted, _$p));
527
- return _el$34;
637
+ var _el$40 = _$createElement("text"),
638
+ _el$41 = _$createTextNode(` `);
639
+ _$insertNode(_el$40, _el$41);
640
+ _$setProp(_el$40, "onMouseUp", event => arm(openBrowse, event));
641
+ _$insert(_el$40, browseLabel, null);
642
+ _$effect(_$p => _$setProp(_el$40, "fg", theme().text.muted, _$p));
643
+ return _el$40;
528
644
  }
529
645
  })];
530
646
  }
531
647
  }), null);
532
648
  _$insert(_el$18, _$createComponent(Show, {
533
649
  get when() {
534
- return pendingStack();
650
+ return _$memo(() => !!pendingStack())() && !engineDown();
535
651
  },
536
652
  get children() {
537
- var _el$37 = _$createElement("text"),
538
- _el$38 = _$createElement("b"),
539
- _el$40 = _$createElement("span"),
540
- _el$41 = _$createTextNode(` · `),
541
- _el$42 = _$createTextNode(` is not running here`);
542
- _$insertNode(_el$37, _el$38);
543
- _$insertNode(_el$37, _el$40);
544
- _$setProp(_el$37, "onMouseUp", event => arm(() => void openStack(), event));
545
- _$insertNode(_el$38, _$createTextNode(`Up stack`));
546
- _$insertNode(_el$40, _el$41);
547
- _$insertNode(_el$40, _el$42);
548
- _$insert(_el$40, () => pendingStack()?.project, _el$42);
653
+ var _el$42 = _$createElement("text"),
654
+ _el$43 = _$createElement("b"),
655
+ _el$45 = _$createElement("span"),
656
+ _el$46 = _$createTextNode(` · `),
657
+ _el$47 = _$createTextNode(` is not running here`);
658
+ _$insertNode(_el$42, _el$43);
659
+ _$insertNode(_el$42, _el$45);
660
+ _$setProp(_el$42, "onMouseUp", event => arm(() => void openStack(), event));
661
+ _$insertNode(_el$43, _$createTextNode(`Up stack`));
662
+ _$insertNode(_el$45, _el$46);
663
+ _$insertNode(_el$45, _el$47);
664
+ _$insert(_el$45, () => pendingStack()?.project, _el$47);
549
665
  _$effect(_p$ => {
550
- var _v$6 = theme().text.base,
551
- _v$7 = {
666
+ var _v$8 = theme().text.base,
667
+ _v$9 = {
552
668
  fg: theme().text.muted
553
669
  };
554
- _v$6 !== _p$.e && (_p$.e = _$setProp(_el$37, "fg", _v$6, _p$.e));
555
- _v$7 !== _p$.t && (_p$.t = _$setProp(_el$40, "style", _v$7, _p$.t));
670
+ _v$8 !== _p$.e && (_p$.e = _$setProp(_el$42, "fg", _v$8, _p$.e));
671
+ _v$9 !== _p$.t && (_p$.t = _$setProp(_el$45, "style", _v$9, _p$.t));
556
672
  return _p$;
557
673
  }, {
558
674
  e: undefined,
559
675
  t: undefined
560
676
  });
561
- return _el$37;
677
+ return _el$42;
562
678
  }
563
679
  }), null);
564
680
  _$effect(_$p => _$setProp(_el$20, "fg", theme().text.base, _$p));
package/dist/poll.js CHANGED
@@ -1,24 +1,37 @@
1
1
  import { createSignal, onCleanup } from "solid-js";
2
2
  import { runDockerPs } from "./docker.js";
3
+ import { runDesktopStatus } from "./runtime.js";
3
4
  const INITIAL = {
4
5
  kind: "empty",
5
6
  containers: [],
6
7
  detail: "loading"
7
8
  };
8
9
 
9
- /** docker reports the old state for a moment after start or stop, so an action is polled twice. */
10
+ /** docker reports the old state for a moment after start or stop, so an action is polled twice */
10
11
  const SETTLE_MS = 1200;
12
+
13
+ /**
14
+ * A Docker Desktop cold start boots a virtual machine, which takes a minute and answers `unknown` while
15
+ * it goes. Polling faster than the container loop is what makes the panel notice it came up at all,
16
+ * and the deadline is what stops a dead engine from holding `working` forever.
17
+ */
18
+ export const ENGINE_TRANSITION_MS = 2000;
19
+ export const ENGINE_DEADLINE_MS = 120000;
11
20
  export function keepContainers(previous, next) {
21
+ // A stopped engine is not a blip, it means every container really is down, so the rows go rather than
22
+ // linger as a lie. Only a poll that could not reach docker keeps what was known
12
23
  if (next.kind === "unavailable" || next.kind === "stale") return previous;
13
24
  return next.containers;
14
25
  }
15
26
 
16
- /** The sidebar belongs to what runs right now, so it shows five running containers and nothing else. */
27
+ /** The sidebar belongs to what runs right now, not to every container that exists */
17
28
  export const MAX_RUNNING = 5;
18
29
 
19
- /**
20
- * A pinned container is shown whatever its state, and spends the running budget when it happens to run,
21
- * so pinning everything shows everything. Stopped containers get no row: the dialog is their only door.
30
+ /**
31
+ * A pinned container is shown whatever its state and spends the running budget when it happens to
32
+ * run, so pinning everything shows everything.
33
+ *
34
+ * Stopped containers get no row, the dialog is their only door.
22
35
  */
23
36
  export function selectRows(all, pinned) {
24
37
  const isPinned = item => pinned.includes(item.name);
@@ -26,12 +39,59 @@ export function selectRows(all, pinned) {
26
39
  const running = all.filter(item => !isPinned(item) && item.state === "running");
27
40
  return [...fixed, ...running.slice(0, Math.max(0, MAX_RUNNING - fixed.length))];
28
41
  }
29
- /** The host does not repaint this slot when only a signal changes, so the panel is remounted on a real change. */
42
+ /**
43
+ * The engine has its own probe because `docker ps` cannot report it: on a stopped engine the ps call
44
+ * fails to connect and says only `unavailable`, which is also what a missing CLI or a locked socket
45
+ * produces. `docker desktop status` is the only thing that tells those apart.
46
+ */
47
+ export function createEngineProbe() {
48
+ const [state, setState] = createSignal("unknown");
49
+ let disposed = false;
50
+ let probing = false;
51
+
52
+ /**
53
+ * A stopped engine takes over four seconds to answer, which is longer than the container poll, so a
54
+ * probe per poll would leave several of them waiting on the same pipe at once. One at a time is the
55
+ * whole guard: a call that arrives while one is in flight reads the last known state instead.
56
+ */
57
+ const refresh = async () => {
58
+ if (probing) return state();
59
+ probing = true;
60
+ try {
61
+ const next = await runDesktopStatus();
62
+ if (!disposed) setState(next);
63
+ return next;
64
+ } finally {
65
+ probing = false;
66
+ }
67
+ };
68
+ onCleanup(() => {
69
+ disposed = true;
70
+ });
71
+
72
+ /** Polls on a deadline rather than on a timer, so disposal has nothing to cancel */
73
+ const waitForRunning = async (deadlineMs = ENGINE_DEADLINE_MS) => {
74
+ const until = Date.now() + deadlineMs;
75
+ for (;;) {
76
+ if ((await refresh()) === "running") return true;
77
+ if (disposed || Date.now() >= until) return false;
78
+ await new Promise(resolve => setTimeout(resolve, ENGINE_TRANSITION_MS));
79
+ }
80
+ };
81
+ void refresh();
82
+ return {
83
+ state,
84
+ refresh,
85
+ waitForRunning
86
+ };
87
+ }
88
+
89
+ /** The host does not repaint this slot when only a signal changes, so a real change remounts the panel */
30
90
  function signature(containers) {
31
91
  return containers.map(item => `${item.name}:${item.state}`).join("|");
32
92
  }
33
93
 
34
- /** A container in a crash loop changes state on every poll, and each remount costs a fresh docker ps. */
94
+ /** A container in a crash loop changes state on every poll and each remount costs a fresh docker ps */
35
95
  const RELOAD_COOLDOWN_MS = 10000;
36
96
  export function createDockerPolling(intervalMs, onContentChange, onPoll) {
37
97
  const [state, setState] = createSignal(INITIAL);
@@ -49,7 +109,7 @@ export function createDockerPolling(intervalMs, onContentChange, onPoll) {
49
109
  ...next,
50
110
  containers: visible
51
111
  });
52
- onPoll?.();
112
+ onPoll?.(next);
53
113
  if (next.kind === "unavailable" || next.kind === "stale") return;
54
114
  const current = signature(visible);
55
115
  if (current === last) return;