codebay 0.4.0 → 0.6.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/.mochi/manifest.json +272 -220
- package/.mochi/svelte-client/HydratableIsland-qyjx1d98.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-1y0r9tae.js +2 -0
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-89wtkb86.js +13 -0
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-eamnbyqk.js +1 -0
- package/.mochi/svelte-client/{_hydrate-SettingsView_8hmarwmf8onq-r9vvc7rx.js → _hydrate-SettingsView_8hmarwmf8onq-kdra4mcb.js} +3 -3
- package/.mochi/svelte-client/chunk-330mrspw.js +2 -0
- package/.mochi/svelte-client/{chunk-dz1019cy.js → chunk-4jgkak13.js} +1 -1
- package/.mochi/svelte-client/chunk-fqg2qzj8.js +2 -0
- package/.mochi/svelte-client/chunk-k5m4kt20.js +2 -0
- package/.mochi/svelte-client/{chunk-nmf4dq86.js → chunk-qj7zy2d9.js} +1 -1
- package/.mochi/svelte-client/{chunk-ssa0ad2f.js → chunk-rpdphch7.js} +1 -1
- package/.mochi/svelte-compile/{App-9qjy14bc.server.js → App-v5mwmv6t.server.js} +26 -2
- package/.mochi/svelte-compile/{Avatars-sq8mjn6k.server.js → Avatars-pzyz0tv3.server.js} +75 -15
- package/.mochi/svelte-compile/{Instance-13j59daw.server.js → Instance-49ksrp11.server.js} +15 -3
- package/.mochi/svelte-compile/{Settings-p0s2awg1.server.js → Settings-sz5d6qj7.server.js} +3 -3
- package/.mochi/svelte-compile/{chunk-dfe2km6a.js → chunk-q8rbesaw.js} +324 -123
- package/.mochi/svelte-css/Avatar-3pi30db6ozvf0.css +1 -0
- package/.mochi/svelte-css/AvatarEditor-2ewwtejaml4e2.css +1 -0
- package/.mochi/svelte-css/{AvatarsView-3hq37u4dgtljn.css → AvatarsView-2ughbzc26luyb.css} +1 -1
- package/.mochi/svelte-css/InstanceCard-3vnltxnbxkvay.css +1 -0
- package/.mochi/svelte-css/{InstanceView-wq85bxtusont.css → InstanceView-3vzow3ttxzlh3.css} +1 -1
- package/README.md +2 -0
- package/package.json +2 -2
- package/src/avatars/anchor.ts +1 -0
- package/src/avatars/ant-head.ts +17 -0
- package/src/avatars/apple.ts +17 -0
- package/src/avatars/bear.ts +1 -0
- package/src/avatars/bee.ts +1 -0
- package/src/avatars/bodybuilder.ts +17 -0
- package/src/avatars/cat.ts +1 -0
- package/src/avatars/cherry.ts +1 -0
- package/src/avatars/crab.ts +1 -0
- package/src/avatars/crown.ts +1 -0
- package/src/avatars/diamond.ts +1 -0
- package/src/avatars/dog.ts +1 -0
- package/src/avatars/editor.ts +17 -0
- package/src/avatars/fish.ts +1 -0
- package/src/avatars/flower.ts +1 -0
- package/src/avatars/fox.ts +1 -0
- package/src/avatars/frog.ts +1 -0
- package/src/avatars/ghost.ts +1 -0
- package/src/avatars/heart.ts +8 -8
- package/src/avatars/horse.ts +17 -0
- package/src/avatars/index.ts +20 -0
- package/src/avatars/invader.ts +1 -0
- package/src/avatars/key.ts +1 -0
- package/src/avatars/lightning.ts +1 -0
- package/src/avatars/mushroom.ts +1 -0
- package/src/avatars/octopus.ts +1 -0
- package/src/avatars/owl.ts +1 -0
- package/src/avatars/penguin.ts +1 -0
- package/src/avatars/planet.ts +1 -0
- package/src/avatars/praying-mantis.ts +17 -0
- package/src/avatars/rabbit.ts +1 -0
- package/src/avatars/robot.ts +1 -0
- package/src/avatars/rocket.ts +1 -0
- package/src/avatars/serialize.ts +4 -3
- package/src/avatars/skull.ts +6 -6
- package/src/avatars/snail.ts +1 -0
- package/src/avatars/snowflake.ts +17 -0
- package/src/avatars/star.ts +1 -0
- package/src/avatars/strawberry.ts +17 -0
- package/src/avatars/target.ts +1 -0
- package/src/avatars/tree.ts +1 -0
- package/src/avatars/types.ts +6 -3
- package/src/avatars/vangogh-almond-blossoms.ts +17 -0
- package/src/avatars/wasp.ts +17 -0
- package/src/avatars/whale.ts +1 -0
- package/src/avatars/windmill.ts +17 -0
- package/src/components/AppShell.svelte +13 -0
- package/src/components/Avatar.svelte +4 -1
- package/src/components/AvatarEditor.svelte +83 -5
- package/src/components/AvatarsView.svelte +14 -1
- package/src/components/DashboardView.svelte +24 -2
- package/src/components/InstanceCard.svelte +55 -6
- package/src/components/InstanceView.svelte +41 -8
- package/src/components/SettingsView.svelte +2 -2
- package/src/container-injections/attention-hooks.ts +19 -38
- package/src/container-injections/claude-aliases.ts +7 -23
- package/src/container-injections/claude-code-credentials.ts +12 -8
- package/src/container-injections/claude-code-custom.ts +35 -48
- package/src/container-injections/claude-code-ide-extension.ts +39 -0
- package/src/container-injections/claude-code-models.ts +8 -24
- package/src/container-injections/claude-model.ts +43 -0
- package/src/container-injections/claude-no-coauthor.ts +14 -16
- package/src/container-injections/claude-skip-permissions.ts +7 -17
- package/src/container-injections/claude-statusline.ts +40 -27
- package/src/container-injections/claude-trust.ts +73 -0
- package/src/container-injections/host-env-vars.ts +13 -24
- package/src/container-injections/tmux.ts +2 -11
- package/src/index.ts +2 -0
- package/src/lib/claude-settings.server.ts +35 -0
- package/src/lib/container-files.server.ts +219 -0
- package/src/lib/devcontainer.server.ts +38 -1
- package/src/lib/exec.server.ts +2 -7
- package/src/lib/injections.server.ts +6 -0
- package/src/lib/instances.server.ts +14 -0
- package/src/lib/theme.server.ts +7 -3
- package/src/shell.html +2 -0
- package/.mochi/svelte-client/HydratableIsland-79z9xq4b.js +0 -1
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-f5rzyjm8.js +0 -2
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-fxyd5qx4.js +0 -13
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-nv31ptdp.js +0 -1
- package/.mochi/svelte-client/chunk-6eexkr6p.js +0 -2
- package/.mochi/svelte-client/chunk-7znwaf7t.js +0 -2
- package/.mochi/svelte-client/chunk-zmmb1c4b.js +0 -2
- package/.mochi/svelte-css/Avatar-24th63awm9mna.css +0 -1
- package/.mochi/svelte-css/AvatarEditor-1ryrs1md5g36y.css +0 -1
- package/.mochi/svelte-css/InstanceCard-16f77aysm7lgp.css +0 -1
package/src/avatars/target.ts
CHANGED
package/src/avatars/tree.ts
CHANGED
package/src/avatars/types.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
// The string legend keeps the art human-editable — you can eyeball a creature in the source.
|
|
2
2
|
//
|
|
3
|
-
// '#' → on
|
|
4
|
-
// '
|
|
3
|
+
// '#' → on (lit pixel)
|
|
4
|
+
// '+' → gray (half-lit pixel)
|
|
5
|
+
// ' ' or '.' → off (unlit pixel)
|
|
5
6
|
export type AvatarArt = {
|
|
6
7
|
name: string; // also the uniqueness key
|
|
7
8
|
pixels: string[];
|
|
9
|
+
robot?: boolean; // true for the original AI-generated sprites, slated to be redrawn by hand
|
|
8
10
|
};
|
|
9
11
|
|
|
10
12
|
export const OFF = 0;
|
|
11
13
|
export const ON = 1;
|
|
14
|
+
export const GRAY = 2;
|
|
12
15
|
|
|
13
16
|
export const ROWS = 8;
|
|
14
17
|
export const COLS = 8;
|
|
@@ -17,7 +20,7 @@ export function decode(art: AvatarArt): number[] {
|
|
|
17
20
|
const cells: number[] = [];
|
|
18
21
|
for (const row of art.pixels) {
|
|
19
22
|
for (const ch of row) {
|
|
20
|
-
cells.push(ch === '#' ? ON : OFF);
|
|
23
|
+
cells.push(ch === '#' ? ON : ch === '+' ? GRAY : OFF);
|
|
21
24
|
}
|
|
22
25
|
}
|
|
23
26
|
return cells;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AvatarArt } from './types.ts';
|
|
2
|
+
|
|
3
|
+
const art: AvatarArt = {
|
|
4
|
+
name: 'vangogh-almond-blossoms',
|
|
5
|
+
pixels: [
|
|
6
|
+
'....#.#.',
|
|
7
|
+
'.....#..',
|
|
8
|
+
'####....',
|
|
9
|
+
'#...#..#',
|
|
10
|
+
'.#...##.',
|
|
11
|
+
'..#....#',
|
|
12
|
+
'.#.#....',
|
|
13
|
+
'..#.....'
|
|
14
|
+
]
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default art;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AvatarArt } from './types.ts';
|
|
2
|
+
|
|
3
|
+
const art: AvatarArt = {
|
|
4
|
+
name: 'wasp',
|
|
5
|
+
pixels: [
|
|
6
|
+
'.....##.',
|
|
7
|
+
'.##.###.',
|
|
8
|
+
'.###....',
|
|
9
|
+
'..#.#...',
|
|
10
|
+
'.#.#.#..',
|
|
11
|
+
'##..##..',
|
|
12
|
+
'##......',
|
|
13
|
+
'........'
|
|
14
|
+
]
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default art;
|
package/src/avatars/whale.ts
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AvatarArt } from './types.ts';
|
|
2
|
+
|
|
3
|
+
const art: AvatarArt = {
|
|
4
|
+
name: 'windmill',
|
|
5
|
+
pixels: [
|
|
6
|
+
'........',
|
|
7
|
+
'.#...#..',
|
|
8
|
+
'..#.#...',
|
|
9
|
+
'...#....',
|
|
10
|
+
'..#.#...',
|
|
11
|
+
'.#.#.#..',
|
|
12
|
+
'..###...',
|
|
13
|
+
'..###...'
|
|
14
|
+
]
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default art;
|
|
@@ -114,6 +114,19 @@
|
|
|
114
114
|
return () => window.removeEventListener('popstate', onPop);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
// Native unsaved-changes guard: a reload/close/external-nav tears down the code-server
|
|
118
|
+
// iframes and loses in-editor state. In-app /↔/ide transitions are pushState (no unload)
|
|
119
|
+
// and Settings/Details links open in new tabs, so this only fires on a true teardown.
|
|
120
|
+
$effect(() => {
|
|
121
|
+
if (running.length === 0) return;
|
|
122
|
+
function onBeforeUnload(e: BeforeUnloadEvent) {
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
e.returnValue = '';
|
|
125
|
+
}
|
|
126
|
+
window.addEventListener('beforeunload', onBeforeUnload);
|
|
127
|
+
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
|
128
|
+
});
|
|
129
|
+
|
|
117
130
|
// Mounted lazily, then kept mounted (hidden via CSS) so the editor survives tab switches.
|
|
118
131
|
const visited = new SvelteSet<string>();
|
|
119
132
|
$effect(() => {
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
onlostpointercapture={release}
|
|
97
97
|
>
|
|
98
98
|
{#each grid as cell, i (i)}
|
|
99
|
-
<span class="px" class:on={cell === 1}></span>
|
|
99
|
+
<span class="px" class:on={cell === 1} class:gray={cell === 2}></span>
|
|
100
100
|
{/each}
|
|
101
101
|
{#if interactive}
|
|
102
102
|
<span class="invert" aria-hidden="true"></span>
|
|
@@ -121,6 +121,9 @@
|
|
|
121
121
|
background-clip: content-box;
|
|
122
122
|
background-color: var(--rule-soft); /* unlit LED — faint */
|
|
123
123
|
}
|
|
124
|
+
.px.gray {
|
|
125
|
+
background-color: var(--led-gray); /* half-lit LED */
|
|
126
|
+
}
|
|
124
127
|
.px.on {
|
|
125
128
|
background-color: var(--ink);
|
|
126
129
|
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import Contrast from '@lucide/svelte/icons/contrast';
|
|
5
5
|
import ExternalLink from '@lucide/svelte/icons/external-link';
|
|
6
6
|
import toast from 'svelte-french-toast';
|
|
7
|
-
import { ROWS, COLS, ON, OFF, type AvatarArt } from '../avatars/types.ts';
|
|
7
|
+
import { ROWS, COLS, ON, OFF, GRAY, type AvatarArt } from '../avatars/types.ts';
|
|
8
|
+
import { MODES, nextValue, type Mode } from '../avatars/editor.ts';
|
|
8
9
|
import {
|
|
9
10
|
cellsToPixels,
|
|
10
11
|
normalizeName,
|
|
@@ -24,7 +25,26 @@
|
|
|
24
25
|
const slug = $derived(normalizeName(avatarName));
|
|
25
26
|
const art = $derived<AvatarArt>({ name: slug || 'my-avatar', pixels: cellsToPixels(cells) });
|
|
26
27
|
// Contributing needs a name and at least one lit pixel — a blank sprite is no sprite.
|
|
27
|
-
const canContribute = $derived(slug.length > 0 && cells.some((c) => c
|
|
28
|
+
const canContribute = $derived(slug.length > 0 && cells.some((c) => c !== OFF));
|
|
29
|
+
|
|
30
|
+
// Cycle is the default so both shades are reachable without switching mode.
|
|
31
|
+
let mode = $state<Mode>('cycle');
|
|
32
|
+
let modeButtons = $state<HTMLButtonElement[]>([]);
|
|
33
|
+
|
|
34
|
+
// Roving-tabindex arrow navigation, as a radiogroup is expected to behave.
|
|
35
|
+
function onModeKeydown(e: KeyboardEvent, i: number) {
|
|
36
|
+
const dir =
|
|
37
|
+
e.key === 'ArrowRight' || e.key === 'ArrowDown'
|
|
38
|
+
? 1
|
|
39
|
+
: e.key === 'ArrowLeft' || e.key === 'ArrowUp'
|
|
40
|
+
? -1
|
|
41
|
+
: 0;
|
|
42
|
+
if (dir === 0) return;
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
const next = (i + dir + MODES.length) % MODES.length;
|
|
45
|
+
mode = MODES[next]!.id;
|
|
46
|
+
modeButtons[next]?.focus();
|
|
47
|
+
}
|
|
28
48
|
|
|
29
49
|
// Capturing the pointer on the grid is what lets one stroke paint every cell it crosses.
|
|
30
50
|
let gridEl = $state<HTMLDivElement | null>(null);
|
|
@@ -51,7 +71,7 @@
|
|
|
51
71
|
if (i == null) return;
|
|
52
72
|
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
|
|
53
73
|
painting = true;
|
|
54
|
-
paintValue = cells[i]
|
|
74
|
+
paintValue = nextValue(mode, cells[i]!);
|
|
55
75
|
cells[i] = paintValue;
|
|
56
76
|
}
|
|
57
77
|
|
|
@@ -69,8 +89,9 @@
|
|
|
69
89
|
cells = Array(ROWS * COLS).fill(OFF);
|
|
70
90
|
}
|
|
71
91
|
|
|
92
|
+
// Only swaps off↔on; gray sits in the middle and is left untouched.
|
|
72
93
|
function invert() {
|
|
73
|
-
cells = cells.map((c) => (c === ON ? OFF : ON));
|
|
94
|
+
cells = cells.map((c) => (c === ON ? OFF : c === OFF ? ON : c));
|
|
74
95
|
}
|
|
75
96
|
|
|
76
97
|
async function copyModule() {
|
|
@@ -106,7 +127,7 @@
|
|
|
106
127
|
onlostpointercapture={stopPainting}
|
|
107
128
|
>
|
|
108
129
|
{#each cells as cell, i (i)}
|
|
109
|
-
<span class="cell" class:on={cell === ON}></span>
|
|
130
|
+
<span class="cell" class:on={cell === ON} class:gray={cell === GRAY}></span>
|
|
110
131
|
{/each}
|
|
111
132
|
</div>
|
|
112
133
|
</div>
|
|
@@ -134,6 +155,27 @@
|
|
|
134
155
|
/>
|
|
135
156
|
</label>
|
|
136
157
|
|
|
158
|
+
<div class="mode-field">
|
|
159
|
+
<span class="preview-label">Mode</span>
|
|
160
|
+
<div class="modes" role="radiogroup" aria-label="Drawing mode">
|
|
161
|
+
{#each MODES as m, i (m.id)}
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
class="mode"
|
|
165
|
+
class:active={mode === m.id}
|
|
166
|
+
role="radio"
|
|
167
|
+
aria-checked={mode === m.id}
|
|
168
|
+
tabindex={mode === m.id ? 0 : -1}
|
|
169
|
+
bind:this={modeButtons[i]}
|
|
170
|
+
onclick={() => (mode = m.id)}
|
|
171
|
+
onkeydown={(e) => onModeKeydown(e, i)}
|
|
172
|
+
>
|
|
173
|
+
{m.label}
|
|
174
|
+
</button>
|
|
175
|
+
{/each}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
137
179
|
<div class="tools">
|
|
138
180
|
<Button size="sm" icon={Eraser} onclick={clear}>Clear</Button>
|
|
139
181
|
<Button size="sm" icon={Contrast} onclick={invert}>Invert</Button>
|
|
@@ -224,6 +266,9 @@
|
|
|
224
266
|
background-clip: content-box;
|
|
225
267
|
background-color: var(--rule-soft); /* unlit LED — faint */
|
|
226
268
|
}
|
|
269
|
+
.cell.gray {
|
|
270
|
+
background-color: var(--led-gray); /* half-lit LED */
|
|
271
|
+
}
|
|
227
272
|
.cell.on {
|
|
228
273
|
background-color: var(--ink);
|
|
229
274
|
}
|
|
@@ -267,6 +312,39 @@
|
|
|
267
312
|
outline: none;
|
|
268
313
|
box-shadow: inset 3px 3px 0 var(--rule-soft);
|
|
269
314
|
}
|
|
315
|
+
.mode-field {
|
|
316
|
+
display: flex;
|
|
317
|
+
flex-direction: column;
|
|
318
|
+
gap: 6px;
|
|
319
|
+
}
|
|
320
|
+
.modes {
|
|
321
|
+
display: flex;
|
|
322
|
+
border: 1px solid var(--ink);
|
|
323
|
+
}
|
|
324
|
+
.mode {
|
|
325
|
+
flex: 1;
|
|
326
|
+
padding: 7px 6px;
|
|
327
|
+
border: none;
|
|
328
|
+
border-right: 1px solid var(--ink);
|
|
329
|
+
background: var(--bg);
|
|
330
|
+
color: var(--ink);
|
|
331
|
+
font-family: var(--font-mono);
|
|
332
|
+
font-size: 11px;
|
|
333
|
+
text-transform: uppercase;
|
|
334
|
+
letter-spacing: 0.06em;
|
|
335
|
+
cursor: pointer;
|
|
336
|
+
}
|
|
337
|
+
.mode:last-child {
|
|
338
|
+
border-right: none;
|
|
339
|
+
}
|
|
340
|
+
.mode.active {
|
|
341
|
+
background: var(--ink);
|
|
342
|
+
color: var(--bg);
|
|
343
|
+
}
|
|
344
|
+
.mode:focus-visible {
|
|
345
|
+
outline: none;
|
|
346
|
+
box-shadow: inset 0 0 0 2px var(--rule-soft);
|
|
347
|
+
}
|
|
270
348
|
.tools {
|
|
271
349
|
display: flex;
|
|
272
350
|
gap: 8px;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import AppBar from './AppBar.svelte';
|
|
4
4
|
import Avatar from './Avatar.svelte';
|
|
5
5
|
import AvatarEditor from './AvatarEditor.svelte';
|
|
6
|
+
import Bot from '@lucide/svelte/icons/bot';
|
|
6
7
|
import { avatars } from '../avatars/index.ts';
|
|
7
8
|
|
|
8
9
|
let { namePlaceholder }: { namePlaceholder: string } = $props();
|
|
@@ -25,7 +26,12 @@
|
|
|
25
26
|
{#each avatars as art (art.name)}
|
|
26
27
|
<li>
|
|
27
28
|
<Avatar {art} name={art.name} scale={8} interactive />
|
|
28
|
-
<span class="label">
|
|
29
|
+
<span class="label">
|
|
30
|
+
{#if art.robot}
|
|
31
|
+
<span class="robot-mark" title="AI-generated"><Bot size={11} /></span>
|
|
32
|
+
{/if}
|
|
33
|
+
{art.name}
|
|
34
|
+
</span>
|
|
29
35
|
</li>
|
|
30
36
|
{/each}
|
|
31
37
|
</ul>
|
|
@@ -106,10 +112,17 @@
|
|
|
106
112
|
gap: 8px;
|
|
107
113
|
}
|
|
108
114
|
.label {
|
|
115
|
+
display: inline-flex;
|
|
116
|
+
align-items: center;
|
|
117
|
+
gap: 4px;
|
|
109
118
|
font-family: var(--font-mono);
|
|
110
119
|
font-size: 11px;
|
|
111
120
|
text-transform: uppercase;
|
|
112
121
|
letter-spacing: 0.08em;
|
|
113
122
|
color: var(--ink-soft);
|
|
114
123
|
}
|
|
124
|
+
.robot-mark {
|
|
125
|
+
display: inline-flex;
|
|
126
|
+
color: var(--ink-faint);
|
|
127
|
+
}
|
|
115
128
|
</style>
|
|
@@ -16,14 +16,32 @@
|
|
|
16
16
|
pet
|
|
17
17
|
}: { preflight: Preflight; instances: Instance[]; loaded: boolean; pet?: AvatarArt } = $props();
|
|
18
18
|
|
|
19
|
+
type Action = 'start' | 'stop' | 'delete' | 'rebuild';
|
|
20
|
+
|
|
19
21
|
let browserOpen = $state(false);
|
|
20
22
|
let creating = $state(false);
|
|
21
23
|
let actionError = $state<string | null>(null);
|
|
22
24
|
let editingId = $state<string | null>(null);
|
|
23
25
|
let editingName = $state('');
|
|
26
|
+
// Per-instance in-flight action + the status it started from, so the busy overlay clears
|
|
27
|
+
// only once the live stream reports the resulting change (avoids flicker on the round-trip).
|
|
28
|
+
let pending = $state<Record<string, { action: Action; since: Instance['status'] }>>({});
|
|
24
29
|
|
|
25
30
|
const ready = $derived(preflight.docker && preflight.cli);
|
|
26
31
|
|
|
32
|
+
// Drop a pending entry once its instance is gone (deleted) or its status has moved on.
|
|
33
|
+
$effect(() => {
|
|
34
|
+
for (const [id, { since }] of Object.entries(pending)) {
|
|
35
|
+
const instance = instances.find((i) => i.id === id);
|
|
36
|
+
if (!instance || instance.status !== since) clearPending(id);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function clearPending(id: string) {
|
|
41
|
+
const { [id]: _, ...rest } = pending;
|
|
42
|
+
pending = rest;
|
|
43
|
+
}
|
|
44
|
+
|
|
27
45
|
async function createFrom(sourcePath: string, opts?: { branch?: string }) {
|
|
28
46
|
browserOpen = false;
|
|
29
47
|
creating = true;
|
|
@@ -42,7 +60,7 @@
|
|
|
42
60
|
}
|
|
43
61
|
}
|
|
44
62
|
|
|
45
|
-
async function act(id: string, action:
|
|
63
|
+
async function act(id: string, action: Action) {
|
|
46
64
|
actionError = null;
|
|
47
65
|
if (action === 'delete' && !confirm('Delete this instance and its copied files?')) return;
|
|
48
66
|
// Rebuild discards the container; the workspace copy lives on the host and survives.
|
|
@@ -51,11 +69,14 @@
|
|
|
51
69
|
!confirm('Recreate this container and re-run setup? The workspace is kept.')
|
|
52
70
|
)
|
|
53
71
|
return;
|
|
72
|
+
const since = instances.find((i) => i.id === id)?.status;
|
|
73
|
+
if (since) pending = { ...pending, [id]: { action, since } };
|
|
54
74
|
try {
|
|
55
75
|
await apiPost(`/api/instances/${id}/${action}`, undefined, `Failed to ${action}`);
|
|
56
|
-
// The live stream reflects the resulting state.
|
|
76
|
+
// The live stream reflects the resulting state; the $effect clears the busy overlay.
|
|
57
77
|
} catch (err) {
|
|
58
78
|
actionError = (err as Error).message;
|
|
79
|
+
clearPending(id);
|
|
59
80
|
}
|
|
60
81
|
}
|
|
61
82
|
|
|
@@ -138,6 +159,7 @@
|
|
|
138
159
|
<InstanceCard
|
|
139
160
|
{instance}
|
|
140
161
|
editing={editingId === instance.id}
|
|
162
|
+
pending={pending[instance.id]?.action ?? null}
|
|
141
163
|
bind:editingName
|
|
142
164
|
onact={(action) => act(instance.id, action)}
|
|
143
165
|
onstartrename={() => startRename(instance)}
|
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
import Button from './Button.svelte';
|
|
8
8
|
import { withPopupMarker } from '../lib/popup-nav.ts';
|
|
9
9
|
|
|
10
|
+
type Action = 'start' | 'stop' | 'delete' | 'rebuild';
|
|
11
|
+
|
|
10
12
|
let {
|
|
11
13
|
instance,
|
|
12
14
|
editing,
|
|
13
15
|
editingName = $bindable(),
|
|
16
|
+
pending = null,
|
|
14
17
|
onact,
|
|
15
18
|
onstartrename,
|
|
16
19
|
oncommitrename,
|
|
@@ -19,7 +22,9 @@
|
|
|
19
22
|
instance: Instance;
|
|
20
23
|
editing: boolean;
|
|
21
24
|
editingName: string;
|
|
22
|
-
|
|
25
|
+
/** The user-triggered action currently in flight, if any — drives the busy overlay. */
|
|
26
|
+
pending?: Action | null;
|
|
27
|
+
onact: (action: Action) => void;
|
|
23
28
|
onstartrename: () => void;
|
|
24
29
|
oncommitrename: () => void;
|
|
25
30
|
oncancelrename: () => void;
|
|
@@ -31,9 +36,21 @@
|
|
|
31
36
|
instance.status === 'stopped' ||
|
|
32
37
|
(instance.status === 'error' && !!instance.container_id)
|
|
33
38
|
);
|
|
39
|
+
|
|
40
|
+
const BUSY_LABEL: Record<Action, string> = {
|
|
41
|
+
start: 'Starting…',
|
|
42
|
+
stop: 'Stopping…',
|
|
43
|
+
delete: 'Deleting…',
|
|
44
|
+
rebuild: 'Rebuilding…'
|
|
45
|
+
};
|
|
34
46
|
</script>
|
|
35
47
|
|
|
36
|
-
<li class="card panel">
|
|
48
|
+
<li class="card panel" class:busy={!!pending} aria-busy={!!pending || undefined}>
|
|
49
|
+
{#if pending}
|
|
50
|
+
<div class="busy-overlay" aria-live="polite">
|
|
51
|
+
<span class="busy-label">{BUSY_LABEL[pending]}</span>
|
|
52
|
+
</div>
|
|
53
|
+
{/if}
|
|
37
54
|
<div class="card-head">
|
|
38
55
|
<Avatar id={instance.id} name={instance.name} interactive />
|
|
39
56
|
{#if editing}
|
|
@@ -109,16 +126,50 @@
|
|
|
109
126
|
|
|
110
127
|
<style>
|
|
111
128
|
.card {
|
|
129
|
+
position: relative;
|
|
112
130
|
background: var(--bg-card);
|
|
113
131
|
padding: 16px 16px 14px;
|
|
114
132
|
transition:
|
|
115
133
|
transform 0.08s steps(2),
|
|
116
134
|
box-shadow 0.08s steps(2);
|
|
117
135
|
}
|
|
118
|
-
.card:hover {
|
|
136
|
+
.card:hover:not(.busy) {
|
|
119
137
|
transform: translate(-1px, -1px);
|
|
120
138
|
box-shadow: 6px 6px 0 var(--ink);
|
|
121
139
|
}
|
|
140
|
+
/* Translucent scrim dims the card body and blocks clicks on the buttons beneath. */
|
|
141
|
+
.busy-overlay {
|
|
142
|
+
position: absolute;
|
|
143
|
+
inset: 0;
|
|
144
|
+
z-index: 1;
|
|
145
|
+
display: flex;
|
|
146
|
+
align-items: center;
|
|
147
|
+
justify-content: center;
|
|
148
|
+
background: color-mix(in srgb, var(--bg-card) 78%, transparent);
|
|
149
|
+
}
|
|
150
|
+
.busy-label {
|
|
151
|
+
font-family: var(--font-mono);
|
|
152
|
+
font-weight: 600;
|
|
153
|
+
font-size: 12px;
|
|
154
|
+
text-transform: uppercase;
|
|
155
|
+
letter-spacing: 0.1em;
|
|
156
|
+
padding: 4px 9px;
|
|
157
|
+
border: 1px solid var(--ink);
|
|
158
|
+
background: var(--ink);
|
|
159
|
+
color: var(--bg);
|
|
160
|
+
animation: lcd-blink 1.1s steps(1) infinite;
|
|
161
|
+
}
|
|
162
|
+
@keyframes lcd-blink {
|
|
163
|
+
50% {
|
|
164
|
+
background: transparent;
|
|
165
|
+
color: var(--ink);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
@media (prefers-reduced-motion: reduce) {
|
|
169
|
+
.busy-label {
|
|
170
|
+
animation: none;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
122
173
|
.card-head {
|
|
123
174
|
display: flex;
|
|
124
175
|
align-items: center;
|
|
@@ -167,9 +218,7 @@
|
|
|
167
218
|
font-family: var(--font-mono);
|
|
168
219
|
font-weight: 600;
|
|
169
220
|
font-size: 15px;
|
|
170
|
-
overflow:
|
|
171
|
-
text-overflow: ellipsis;
|
|
172
|
-
white-space: nowrap;
|
|
221
|
+
overflow-wrap: anywhere;
|
|
173
222
|
/* Reset button chrome so it reads as plain text until hovered. */
|
|
174
223
|
margin: 0;
|
|
175
224
|
padding: 2px 4px;
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { enhance } from 'mochi-framework';
|
|
12
12
|
import type { MochiEnhanceOptions } from 'mochi-framework';
|
|
13
13
|
import { onBackLinkClick, installPopupBackTrap } from '../lib/popup-nav.ts';
|
|
14
|
+
import { tick } from 'svelte';
|
|
14
15
|
|
|
15
16
|
let { id, injectionChecks = 0 }: { id: string; injectionChecks?: number } = $props();
|
|
16
17
|
|
|
@@ -18,11 +19,29 @@
|
|
|
18
19
|
let health = $state<InstanceHealth | null>(null);
|
|
19
20
|
let lastFetchedAt = $state<number | null>(null);
|
|
20
21
|
let logs = $state('');
|
|
22
|
+
let logEl = $state<HTMLDivElement | null>(null);
|
|
23
|
+
let following = $state(true);
|
|
21
24
|
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
// Pin to the newest output whenever logs grow — but only while following.
|
|
26
|
+
// tick() lets the <pre> text node grow before we scroll, so we reach the true bottom.
|
|
27
|
+
$effect(() => {
|
|
28
|
+
if (!following || logs.length === 0) return; // reading logs.length tracks appended chunks
|
|
29
|
+
tick().then(() => {
|
|
30
|
+
if (following) logEl?.scrollTo(0, logEl.scrollHeight);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// A user scrolling up pauses following; programmatic scroll-to-bottom keeps the
|
|
35
|
+
// gap ~0, so it never trips this — only re-clicking "Follow log" resumes.
|
|
36
|
+
function onLogScroll() {
|
|
37
|
+
if (!logEl) return;
|
|
38
|
+
const gap = logEl.scrollHeight - logEl.clientHeight - logEl.scrollTop;
|
|
39
|
+
if (gap > 40) following = false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toggleFollow() {
|
|
43
|
+
following = !following;
|
|
44
|
+
if (following) logEl?.scrollTo(0, logEl.scrollHeight);
|
|
26
45
|
}
|
|
27
46
|
|
|
28
47
|
$effect(() => installPopupBackTrap());
|
|
@@ -234,11 +253,16 @@
|
|
|
234
253
|
<div class="logwrap panel">
|
|
235
254
|
<div class="log-bar panel-bar">
|
|
236
255
|
<span>Boot log</span>
|
|
237
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
256
|
+
<div class="log-actions">
|
|
257
|
+
<button class="copy" class:active={following} onclick={toggleFollow}>
|
|
258
|
+
{following ? 'Following' : 'Follow log'}
|
|
259
|
+
</button>
|
|
260
|
+
<button class="copy" onclick={copyLogs} disabled={!logs}>
|
|
261
|
+
{copied ? 'Copied!' : 'Copy logs'}
|
|
262
|
+
</button>
|
|
263
|
+
</div>
|
|
240
264
|
</div>
|
|
241
|
-
<div class="logs" {
|
|
265
|
+
<div class="logs" bind:this={logEl} onscroll={onLogScroll}>
|
|
242
266
|
<pre>{logs || 'Waiting for output…'}<span class="caret"></span></pre>
|
|
243
267
|
</div>
|
|
244
268
|
{#if instance?.status === 'error' && instance.error}
|
|
@@ -504,6 +528,11 @@
|
|
|
504
528
|
align-items: center;
|
|
505
529
|
justify-content: space-between;
|
|
506
530
|
}
|
|
531
|
+
.log-actions {
|
|
532
|
+
display: flex;
|
|
533
|
+
gap: 6px;
|
|
534
|
+
align-items: center;
|
|
535
|
+
}
|
|
507
536
|
.copy {
|
|
508
537
|
font-family: var(--font-mono);
|
|
509
538
|
font-weight: 600;
|
|
@@ -520,6 +549,10 @@
|
|
|
520
549
|
background: transparent;
|
|
521
550
|
color: var(--bg);
|
|
522
551
|
}
|
|
552
|
+
.copy.active {
|
|
553
|
+
background: transparent;
|
|
554
|
+
color: var(--bg);
|
|
555
|
+
}
|
|
523
556
|
.copy:disabled {
|
|
524
557
|
opacity: 0.5;
|
|
525
558
|
cursor: not-allowed;
|
|
@@ -878,8 +878,8 @@
|
|
|
878
878
|
<div class="text">
|
|
879
879
|
<div class="name">Rebuild running containers (no cache)</div>
|
|
880
880
|
<div class="desc">
|
|
881
|
-
|
|
882
|
-
In-container edits are kept; stopped instances are left alone.
|
|
881
|
+
Rebuild every currently-running instance from scratch, without using any cached image
|
|
882
|
+
layers. In-container edits are kept; stopped instances are left alone.
|
|
883
883
|
</div>
|
|
884
884
|
{#if rebuildError}
|
|
885
885
|
<div class="msg error">{rebuildError}</div>
|