k0-board 0.1.2 → 0.3.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/.claude/skills/k0-changelog/SKILL.md +96 -0
- package/CHANGELOG.md +185 -1
- package/README.md +289 -20
- package/bin/k0-board.js +51 -3
- package/package.json +6 -3
- package/platform/contract.js +30 -0
- package/platform/darwin/index.js +18 -3
- package/platform/darwin/servers.js +16 -0
- package/platform/index.js +9 -1
- package/platform/linux/index.js +18 -1
- package/platform/linux/servers.js +41 -0
- package/platform/shared/listeners.js +121 -0
- package/platform/shared/servers.js +111 -0
- package/platform/win32/index.js +15 -1
- package/platform/win32/servers.js +73 -0
- package/server/changelog.js +242 -0
- package/server/db.js +69 -0
- package/server/git.js +135 -0
- package/server/guard.js +31 -0
- package/server/idle.js +56 -0
- package/server/index.js +177 -36
- package/server/launcher.js +5 -1
- package/server/machine.js +9 -0
- package/server/paths.js +5 -4
- package/server/servers.js +481 -0
- package/server/settings.js +121 -0
- package/server/watcher.js +8 -1
- package/server/writer.js +128 -0
- package/web/board.css +137 -7
- package/web/board.js +285 -10
- package/web/changelog.css +185 -0
- package/web/changelog.html +43 -0
- package/web/changelog.js +298 -0
- package/web/index.html +8 -2
- package/web/recency.js +86 -0
- package/web/view.js +3 -1
package/web/board.js
CHANGED
|
@@ -2,6 +2,7 @@ import { titleCase } from '/title.js'
|
|
|
2
2
|
import { initView, refit } from '/view.js'
|
|
3
3
|
import { weight, gb } from '/units.js'
|
|
4
4
|
import { setFavicon } from '/favicon.js'
|
|
5
|
+
import { split } from '/recency.js'
|
|
5
6
|
|
|
6
7
|
// ── Statuses ───────────────────────────────────────────────────────
|
|
7
8
|
// The order is the attention priority inside a column: first whoever is waiting for you,
|
|
@@ -19,6 +20,10 @@ const LABEL = {
|
|
|
19
20
|
COMPLETED: 'Done',
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
// The statuses where something is really running. The server holds the same rule — a browser
|
|
24
|
+
// cannot import a module that reads the filesystem — the way `ORDER` is already written twice.
|
|
25
|
+
const BUSY = new Set(['WORKING', 'PLANNING'])
|
|
26
|
+
|
|
22
27
|
const $ = (s) => document.querySelector(s)
|
|
23
28
|
const api = async (url, opts) => {
|
|
24
29
|
const r = await fetch(url, { headers: { 'content-type': 'application/json' }, ...opts })
|
|
@@ -37,6 +42,16 @@ let lastSignature = ''
|
|
|
37
42
|
const hidden = new Set(JSON.parse(localStorage.getItem('k0-hidden') || '["COMPLETED"]'))
|
|
38
43
|
const saveHidden = () => localStorage.setItem('k0-hidden', JSON.stringify([...hidden]))
|
|
39
44
|
|
|
45
|
+
// The repositories you have put away by hand. Unlike `held` this outlives the page: putting a
|
|
46
|
+
// column away is a decision, not a glance.
|
|
47
|
+
const folded = new Set(JSON.parse(localStorage.getItem('k0-folded') || '[]'))
|
|
48
|
+
const saveFolded = () => localStorage.setItem('k0-folded', JSON.stringify([...folded]))
|
|
49
|
+
|
|
50
|
+
// The columns open in this visit. Within one visit the set only ever grows: a column does not
|
|
51
|
+
// fold out from under you while you are working — it folds the next time the page loads. It is
|
|
52
|
+
// deliberately not remembered, so that reloading is what tidies the board up.
|
|
53
|
+
const held = new Set()
|
|
54
|
+
|
|
40
55
|
// The weight of each session: on to begin with, switched off from the gauge at the top.
|
|
41
56
|
let showLoad = localStorage.getItem('k0-load') !== '0'
|
|
42
57
|
let machine = null // how the computer is doing right now
|
|
@@ -96,7 +111,13 @@ function postit(card, now) {
|
|
|
96
111
|
: ''
|
|
97
112
|
}
|
|
98
113
|
<div class="foot">
|
|
99
|
-
${
|
|
114
|
+
${
|
|
115
|
+
// Whoever shut the window, said in the same breath and the same weight — this line is
|
|
116
|
+
// already only an italic, and it stays one. Which of the two it is does not need its own
|
|
117
|
+
// place in the redraw signature: `auto_closed` never moves without `session_alive`
|
|
118
|
+
// moving with it, and that one is in there.
|
|
119
|
+
dead ? `<span class="dead">${card.auto_closed ? 'closed automatically' : 'session closed'}</span>` : ''
|
|
120
|
+
}
|
|
100
121
|
<span class="since" title="${LABEL[card.status]} for ${since(card.status_since, now)}">${since(card.status_since, now)}</span>
|
|
101
122
|
</div>
|
|
102
123
|
<div class="actions"></div>`
|
|
@@ -108,10 +129,11 @@ function postit(card, now) {
|
|
|
108
129
|
}
|
|
109
130
|
|
|
110
131
|
const actions = el.querySelector('.actions')
|
|
111
|
-
const btn = (text, fn, title) => {
|
|
132
|
+
const btn = (text, fn, title, cls) => {
|
|
112
133
|
const b = document.createElement('button')
|
|
113
134
|
b.textContent = text
|
|
114
135
|
if (title) b.title = title
|
|
136
|
+
if (cls) b.className = cls
|
|
115
137
|
b.onclick = (e) => {
|
|
116
138
|
e.stopPropagation()
|
|
117
139
|
fn()
|
|
@@ -127,6 +149,16 @@ function postit(card, now) {
|
|
|
127
149
|
if (dead) btn('Resume', () => start(card.id, 'resume'))
|
|
128
150
|
// An idea in the backlog cannot be "done": it never started.
|
|
129
151
|
btn('Done', () => setCompleted(card.id, true), 'close this job and its terminal')
|
|
152
|
+
// Close gives the memory back without declaring the work over, and it comes after Done, as a
|
|
153
|
+
// link rather than a button: it is the rarer of the two and should not compete with it. Not
|
|
154
|
+
// while the session is working — stopping one mid-thought is not memory saved, it is work lost.
|
|
155
|
+
if (!dead && !BUSY.has(card.status))
|
|
156
|
+
btn(
|
|
157
|
+
'Close',
|
|
158
|
+
() => closeSession(card.id),
|
|
159
|
+
'stop the session and its terminal, and leave the card where it is',
|
|
160
|
+
'link'
|
|
161
|
+
)
|
|
130
162
|
}
|
|
131
163
|
|
|
132
164
|
// The git mark is a link: a double click on it would open two tabs and bring the terminal to
|
|
@@ -148,6 +180,11 @@ const ICON = {
|
|
|
148
180
|
edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>',
|
|
149
181
|
del: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg>',
|
|
150
182
|
plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
|
|
183
|
+
// Put this column away: it slides off to the right, where `Old` is.
|
|
184
|
+
fold: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6l6 6-6 6"/><path d="M19 5v14"/></svg>',
|
|
185
|
+
// The dev server: a world, because what is behind it is a site. A meridian and a parallel are
|
|
186
|
+
// enough to read it at twelve pixels — more lines and it goes back to being a circle.
|
|
187
|
+
globe: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><ellipse cx="12" cy="12" rx="4" ry="9"/></svg>',
|
|
151
188
|
}
|
|
152
189
|
|
|
153
190
|
const esc = (s) =>
|
|
@@ -200,6 +237,95 @@ function gitChip(g, repo, card) {
|
|
|
200
237
|
}${n}</a>`
|
|
201
238
|
}
|
|
202
239
|
|
|
240
|
+
// ── The dev server ────────────────────────────────────────────────────
|
|
241
|
+
// The globe next to a repository's name: grey when its server is off, green when it is up,
|
|
242
|
+
// spinning while it is coming up, red when it tried and could not. One click switches it, two
|
|
243
|
+
// restart it, and when it is green the repository's name becomes the way into the site.
|
|
244
|
+
//
|
|
245
|
+
// The four states are the ones the server sends; nothing is guessed here. The only thing the
|
|
246
|
+
// page knows that the server does not is that a click has just happened and no answer has come
|
|
247
|
+
// back yet — which is what `switching` is for.
|
|
248
|
+
const switching = new Set()
|
|
249
|
+
|
|
250
|
+
/** Everything the globe knows, for the tooltip. Same idea as `gitDetail`: the detail is on hover. */
|
|
251
|
+
function serverDetail(s, state) {
|
|
252
|
+
const bits = []
|
|
253
|
+
if (state === 'up') bits.push(s.adopted ? `up on port ${s.port}, started outside k0` : `up on port ${s.port}`)
|
|
254
|
+
else if (state === 'starting') bits.push('starting…')
|
|
255
|
+
else if (state === 'failed') bits.push(s.error ? `it did not come up: ${s.error}` : 'it did not come up')
|
|
256
|
+
else bits.push('the server is off')
|
|
257
|
+
bits.push(s.command)
|
|
258
|
+
if (state === 'up') bits.push('click to stop, double-click to restart')
|
|
259
|
+
else if (state !== 'starting') bits.push('click to start')
|
|
260
|
+
// Where it went wrong is worth more than any sentence about it going wrong.
|
|
261
|
+
if (state === 'failed') bits.push(s.log)
|
|
262
|
+
return bits.join(' · ')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The globe, or nothing at all. A repository with no way to start a server does not get a
|
|
267
|
+
* button that would quietly do nothing — the server sends null and this draws none.
|
|
268
|
+
*/
|
|
269
|
+
function serverChip(s, repo) {
|
|
270
|
+
if (!s) return ''
|
|
271
|
+
const state = switching.has(repo) ? 'starting' : s.state
|
|
272
|
+
const title = serverDetail(s, state)
|
|
273
|
+
const off = s.can.run ? '' : ' disabled'
|
|
274
|
+
return `<button class="globe ${state}" title="${esc(title)}" aria-label="${esc(title)}"${off}>${ICON.globe}</button>`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* One click switches, two restart, and they are fighting over the same first click.
|
|
279
|
+
*
|
|
280
|
+
* What settles it without being seen: when the server is OFF there is nothing to wait for,
|
|
281
|
+
* because "start" and "restart" are the same thing there, so the click goes straight through.
|
|
282
|
+
* Only STOPPING waits the quarter second it takes to learn whether a second click is coming —
|
|
283
|
+
* and even that is invisible, because the globe starts spinning the moment it is pressed.
|
|
284
|
+
*/
|
|
285
|
+
function wireGlobe(el, repo, s) {
|
|
286
|
+
if (!s.can.run) return
|
|
287
|
+
if (s.state === 'starting' || switching.has(repo)) {
|
|
288
|
+
el.disabled = true
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
if (s.state !== 'up') {
|
|
292
|
+
el.onclick = () => switchServer(repo, 'start')
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
let timer = null
|
|
296
|
+
el.onclick = () => {
|
|
297
|
+
if (timer) return // the second click of a double: let `ondblclick` have it
|
|
298
|
+
timer = setTimeout(() => {
|
|
299
|
+
timer = null
|
|
300
|
+
switchServer(repo, 'stop')
|
|
301
|
+
}, 250)
|
|
302
|
+
}
|
|
303
|
+
el.ondblclick = () => {
|
|
304
|
+
clearTimeout(timer)
|
|
305
|
+
timer = null
|
|
306
|
+
switchServer(repo, 'restart')
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function switchServer(repo, action) {
|
|
311
|
+
switching.add(repo)
|
|
312
|
+
lastSignature = '' // the globe has to start spinning now, not at the next round
|
|
313
|
+
await refresh()
|
|
314
|
+
try {
|
|
315
|
+
const s = await api('/api/server', { method: 'POST', body: JSON.stringify({ path: repo, action }) })
|
|
316
|
+
if (s.state === 'failed') toast(s.error || 'The server did not come up. Hover the globe for the log.', 8000)
|
|
317
|
+
} catch (err) {
|
|
318
|
+
toast(err.message)
|
|
319
|
+
} finally {
|
|
320
|
+
switching.delete(repo)
|
|
321
|
+
lastSignature = ''
|
|
322
|
+
refresh()
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Just enough to compare one round with the next, exactly as `gitSig` does for git. */
|
|
327
|
+
const serverSig = (s) => (s ? `${s.state}/${s.port ?? ''}/${s.adopted ? 1 : 0}` : '')
|
|
328
|
+
|
|
203
329
|
// ── What a session weighs ────────────────────────────────────────────
|
|
204
330
|
/**
|
|
205
331
|
* How much this session eats, counting everything it has opened underneath itself. The detail —
|
|
@@ -261,24 +387,140 @@ function render(data) {
|
|
|
261
387
|
// still in alphabetical order — the server's, which is not touched here.
|
|
262
388
|
if (data.mode === 'driving') columns.sort((a, b) => byAttention(a.cards[0], b.cards[0]))
|
|
263
389
|
|
|
264
|
-
|
|
390
|
+
// Which of them are still warm. Everything else folds away into `Old` — see recency.js for the
|
|
391
|
+
// four rules and for why the day is counted back from your last piece of work rather than from
|
|
392
|
+
// the clock.
|
|
393
|
+
const { open, alive } = split({
|
|
394
|
+
paths: columns.map((c) => c.col.path),
|
|
395
|
+
cards: data.cards,
|
|
396
|
+
folded,
|
|
397
|
+
held,
|
|
398
|
+
})
|
|
399
|
+
// A column open now is open for the rest of the visit, and a column that is open is not one
|
|
400
|
+
// you have put away: a session woke up in there, or you went and fetched it back yourself.
|
|
401
|
+
let forget = false
|
|
402
|
+
for (const p of open) {
|
|
403
|
+
held.add(p)
|
|
404
|
+
if (folded.delete(p)) forget = true
|
|
405
|
+
}
|
|
406
|
+
if (forget) saveFolded()
|
|
407
|
+
|
|
408
|
+
const openSet = new Set(open)
|
|
409
|
+
for (const { col, cards } of columns.filter(({ col }) => openSet.has(col.path))) {
|
|
265
410
|
const wrap = document.createElement('section')
|
|
266
411
|
wrap.className = 'column'
|
|
267
412
|
// The "+" next to the name: a new card on THIS repository, without picking it.
|
|
268
413
|
const add = `New card in ${esc(col.name)}`
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
414
|
+
// And next to it, the one that puts the column away without waiting for it to go quiet on
|
|
415
|
+
// its own. It sits there in plain sight next to the `+`, because a button you have to go
|
|
416
|
+
// hunting for with the pointer is a button nobody knows exists.
|
|
417
|
+
//
|
|
418
|
+
// Where the column cannot be put away it goes faint and refuses, rather than not being drawn
|
|
419
|
+
// at all. Not being drawn was the first idea and it was the wrong one: a column runs and
|
|
420
|
+
// goes back to your turn every few seconds, so the button would have spent the whole session
|
|
421
|
+
// blinking in and out — and it would have done it on exactly the columns you are working on.
|
|
422
|
+
// A control that stays where it was and says why it will not move is worth more than one
|
|
423
|
+
// that is only there when it agrees with you. The title carries the reason.
|
|
424
|
+
const live = alive.has(col.path)
|
|
425
|
+
const away = live ? `${col.name} has something going on: it cannot be put away` : `Put ${col.name} away`
|
|
426
|
+
const fold = `<button class="fold" title="${esc(away)}" aria-label="${esc(away)}"${
|
|
427
|
+
live ? ' disabled' : ''
|
|
428
|
+
}>${ICON.fold}</button>`
|
|
429
|
+
// Two groups and a name between them: what you press on the left, what tells you how things
|
|
430
|
+
// stand on the right. The count of post-its that used to sit after the name is gone — it
|
|
431
|
+
// took up the room the name now uses, and it never answered a question anybody had.
|
|
432
|
+
//
|
|
433
|
+
// With the server up, the name is the door to it. It is the right thing to click for the
|
|
434
|
+
// same reason the git mark is the right thing to click to reach the files: the obvious
|
|
435
|
+
// gesture on the obvious word, leaving the globe free to mean only on and off.
|
|
436
|
+
const url = col.server?.state === 'up' ? col.server.url : null
|
|
437
|
+
const name = url
|
|
438
|
+
? `<a class="name" href="${esc(url)}" target="_blank" title="${esc(url)}">${esc(col.name)}</a>`
|
|
439
|
+
: `<span class="name">${esc(col.name)}</span>`
|
|
440
|
+
wrap.innerHTML = `<h2><button class="add" title="${add}" aria-label="${add}">${
|
|
441
|
+
ICON.plus
|
|
442
|
+
}</button>${fold}${name}${gitChip(col.git, col.path)}${serverChip(col.server, col.path)}</h2>`
|
|
272
443
|
wrap.querySelector('.add').onclick = () => openEditor(null, col.path)
|
|
444
|
+
wrap.querySelector('.fold').onclick = () => putAway(col.path)
|
|
445
|
+
const globe = wrap.querySelector('.globe')
|
|
446
|
+
if (globe) wireGlobe(globe, col.path, col.server)
|
|
273
447
|
for (const c of cards) wrap.append(postit(c, data.now))
|
|
274
448
|
board.append(wrap)
|
|
275
449
|
}
|
|
276
450
|
|
|
451
|
+
board.append(oldColumn(columns.filter(({ col }) => !openSet.has(col.path))))
|
|
277
452
|
board.append(repoColumn(data.projects ?? [], new Set(columns.map((c) => c.col.path))))
|
|
278
453
|
$('#empty').style.display = columns.length ? 'none' : 'grid'
|
|
279
454
|
refit() // the board changed size: the view comes back inside its limits
|
|
280
455
|
}
|
|
281
456
|
|
|
457
|
+
/** Put a column away by hand, and let go of the place it was holding for this visit. */
|
|
458
|
+
function putAway(path) {
|
|
459
|
+
folded.add(path)
|
|
460
|
+
held.delete(path)
|
|
461
|
+
saveFolded()
|
|
462
|
+
lastSignature = ''
|
|
463
|
+
refresh()
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Fetch one back: it stays for the rest of the visit, and folds again on the next load. */
|
|
467
|
+
function bringBack(path) {
|
|
468
|
+
held.add(path)
|
|
469
|
+
folded.delete(path)
|
|
470
|
+
saveFolded()
|
|
471
|
+
lastSignature = ''
|
|
472
|
+
refresh()
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The second-to-last column: the repositories that DO have work on the board, just not lately —
|
|
477
|
+
* nothing touched in the day before your most recent piece of work, and nothing alive inside.
|
|
478
|
+
* They are the reason the board was three screens wide: a dozen of them, standing at full width
|
|
479
|
+
* between the three you are actually on.
|
|
480
|
+
*
|
|
481
|
+
* They are folded, not thrown away. Click the row and the column comes back at full width for the
|
|
482
|
+
* rest of the visit.
|
|
483
|
+
*
|
|
484
|
+
* It sits next to `Others` and is built the same way, down to the row: a `+`, a name, a git mark
|
|
485
|
+
* and nothing else. How many cards are parked in there and how long it has been quiet were both
|
|
486
|
+
* in here once, and both had to go — three numbers on a row you are meant to read at a glance is
|
|
487
|
+
* two too many, and the answer to either is one click away in the column itself.
|
|
488
|
+
*/
|
|
489
|
+
function oldColumn(columns) {
|
|
490
|
+
if (!columns.length) return document.createDocumentFragment()
|
|
491
|
+
|
|
492
|
+
const wrap = document.createElement('section')
|
|
493
|
+
wrap.className = 'column repos old'
|
|
494
|
+
// Alphabetical, for the same reason `Others` is: these are rows you scan with your eye looking
|
|
495
|
+
// for a name. In driving mode the columns above have queued up by urgency, and letting that
|
|
496
|
+
// order through to here would be a list that reshuffles itself with nothing urgent in it.
|
|
497
|
+
wrap.innerHTML =
|
|
498
|
+
'<h2>Old<small>' +
|
|
499
|
+
columns.length +
|
|
500
|
+
'</small></h2>' +
|
|
501
|
+
[...columns]
|
|
502
|
+
.sort((a, b) => a.col.name.localeCompare(b.col.name, undefined, { sensitivity: 'base' }))
|
|
503
|
+
.map(({ col }) => {
|
|
504
|
+
const add = `New card in ${esc(col.name)}`
|
|
505
|
+
const back = `Bring ${col.name} back onto the board`
|
|
506
|
+
return `<div class="repo" data-p="${esc(col.path)}">
|
|
507
|
+
<button class="add" title="${add}" aria-label="${add}">${ICON.plus}</button>
|
|
508
|
+
<button class="name" title="${esc(back)}">${esc(col.name)}</button>${gitChip(col.git, col.path)}
|
|
509
|
+
</div>`
|
|
510
|
+
})
|
|
511
|
+
.join('')
|
|
512
|
+
// The whole row brings the column back, not only the name: at this size, asking for the four
|
|
513
|
+
// words exactly is asking for a second click. The `+` and the git lens are the two things in
|
|
514
|
+
// there that mean something else, and they keep meaning it.
|
|
515
|
+
wrap.onclick = (e) => {
|
|
516
|
+
const path = e.target.closest('.repo')?.dataset.p
|
|
517
|
+
if (!path || e.target.closest('.git')) return
|
|
518
|
+
if (e.target.closest('.add')) openEditor(null, path)
|
|
519
|
+
else bringBack(path)
|
|
520
|
+
}
|
|
521
|
+
return wrap
|
|
522
|
+
}
|
|
523
|
+
|
|
282
524
|
/**
|
|
283
525
|
* The last column: the OTHER repositories, the ones with no column because they have not one
|
|
284
526
|
* card between them. Repeating the ones already on the left would be saying the same thing
|
|
@@ -323,7 +565,7 @@ function repoColumn(projects, shown) {
|
|
|
323
565
|
* is nearly always. The colour is the kernel's judgement, not a threshold invented here: green
|
|
324
566
|
* while it says all is well, amber at the first warning, red when it is critical.
|
|
325
567
|
*/
|
|
326
|
-
function renderMachine(m, cards) {
|
|
568
|
+
function renderMachine(m, cards, idleHours = 0) {
|
|
327
569
|
const el = $('#gauge')
|
|
328
570
|
if (!m?.mem) return el.setAttribute('hidden', '') // the first sample is not in yet
|
|
329
571
|
el.removeAttribute('hidden')
|
|
@@ -331,7 +573,12 @@ function renderMachine(m, cards) {
|
|
|
331
573
|
|
|
332
574
|
const ram = Math.round((m.mem.used / m.mem.total) * 100)
|
|
333
575
|
const cpu = m.cpu === null ? '—' : `${Math.round(m.cpu * 100)}%`
|
|
334
|
-
|
|
576
|
+
// Whether anybody is looking after the memory for you, and after how long. It belongs on this
|
|
577
|
+
// chip and nowhere else: this is where the memory is already being talked about, and a thing
|
|
578
|
+
// that quietly closes your windows should not be something you have to know about beforehand.
|
|
579
|
+
// Its presence is the whole message — switched off, there is nothing here rather than a nought.
|
|
580
|
+
const closes = idleHours > 0 ? `<span class="idle">· closes at ${idleHours}h</span>` : ''
|
|
581
|
+
el.innerHTML = `<i></i>RAM ${ram}% · CPU ${cpu}${closes}`
|
|
335
582
|
|
|
336
583
|
const top = cards.find((c) => c.id === heaviest)
|
|
337
584
|
const lines = [
|
|
@@ -341,6 +588,13 @@ function renderMachine(m, cards) {
|
|
|
341
588
|
if (top?.load) lines.push(`heaviest: ${top.title} — ${weight(top.load.rss)}`)
|
|
342
589
|
// What is heavy and is not k0: without it, the board would take blame that belongs to Chrome.
|
|
343
590
|
if (m.others?.length) lines.push(`outside k0: ${m.others.map((o) => `${o.name} ${gb(o.rss)}`).join(' · ')}`)
|
|
591
|
+
// The chip has room for three words; the sentence that explains them goes here, and it is also
|
|
592
|
+
// the only place that can say the closing is switched off — the chip says that by staying quiet.
|
|
593
|
+
lines.push(
|
|
594
|
+
idleHours > 0
|
|
595
|
+
? `a terminal nobody touches for ${idleHours}h is closed and its memory given back — the card stays, Resume picks it up`
|
|
596
|
+
: 'closing forgotten terminals is switched off'
|
|
597
|
+
)
|
|
344
598
|
lines.push(showLoad ? 'click to hide the weight on each card' : 'click to show the weight on each card')
|
|
345
599
|
el.title = lines.join('\n')
|
|
346
600
|
}
|
|
@@ -406,6 +660,21 @@ async function remove(card) {
|
|
|
406
660
|
refresh()
|
|
407
661
|
}
|
|
408
662
|
|
|
663
|
+
/**
|
|
664
|
+
* The terminal goes, the card stays. Stopping the session takes a couple of seconds — it is asked
|
|
665
|
+
* to leave and given the time to — so it says what it is doing while it waits.
|
|
666
|
+
*/
|
|
667
|
+
async function closeSession(id) {
|
|
668
|
+
toast('Closing the terminal…', 10000)
|
|
669
|
+
try {
|
|
670
|
+
await api(`/api/card/${id}/close`, { method: 'POST' })
|
|
671
|
+
toast('Closed. Resume picks the conversation up where it was')
|
|
672
|
+
} catch (e) {
|
|
673
|
+
toast(`Couldn't do it: ${e.message}`, 8000)
|
|
674
|
+
}
|
|
675
|
+
refresh()
|
|
676
|
+
}
|
|
677
|
+
|
|
409
678
|
async function setCompleted(id, completed) {
|
|
410
679
|
await api(`/api/card/${id}`, { method: 'PATCH', body: JSON.stringify({ completed }) })
|
|
411
680
|
refresh()
|
|
@@ -606,7 +875,7 @@ async function refresh() {
|
|
|
606
875
|
// What is eating most right now: it is what tints that one red and nobody else.
|
|
607
876
|
heaviest =
|
|
608
877
|
data.cards.filter((c) => c.load).sort((a, b) => b.load.rss - a.load.rss)[0]?.id ?? null
|
|
609
|
-
renderMachine(machine, data.cards)
|
|
878
|
+
renderMachine(machine, data.cards, data.closeIdleAfterHours)
|
|
610
879
|
|
|
611
880
|
// Redraw only if something really changed, so the editor and the focus stay where they are.
|
|
612
881
|
// The git state is inside the signature: without it, the mark would stay the one from the
|
|
@@ -627,10 +896,16 @@ async function refresh() {
|
|
|
627
896
|
c.description,
|
|
628
897
|
c.prompt,
|
|
629
898
|
c.project_path,
|
|
899
|
+
// What folds a column away and what brings it back: the day is measured from the
|
|
900
|
+
// freshest of these, so when none of them moves nothing can fold, and the board is
|
|
901
|
+
// spared a redraw on a timer.
|
|
902
|
+
c.updated_at,
|
|
630
903
|
gitSig(c.git),
|
|
631
904
|
c.load ? weight(c.load.rss) : '',
|
|
632
905
|
]),
|
|
633
|
-
|
|
906
|
+
// The server state belongs in here for the same reason the git state does: without it the
|
|
907
|
+
// globe would keep the colour it had on the first round for the rest of the visit.
|
|
908
|
+
data.columns.map((col) => [col.path, gitSig(col.git), serverSig(col.server)]),
|
|
634
909
|
(data.projects ?? []).map((p) => [p.path, gitSig(p.git)]),
|
|
635
910
|
])
|
|
636
911
|
if (sig !== lastSignature) {
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/* The ChangeLog: k0's third page. Colours and fonts live in base.css.
|
|
2
|
+
|
|
3
|
+
This one is a sheet of paper, not a board — it is the only page in k0 you read top to bottom
|
|
4
|
+
rather than look at. So it scrolls, it has a measure narrow enough to read comfortably, and
|
|
5
|
+
nothing on it moves. */
|
|
6
|
+
@import url('/base.css');
|
|
7
|
+
|
|
8
|
+
body {
|
|
9
|
+
margin: 0;
|
|
10
|
+
background: var(--bg);
|
|
11
|
+
color: var(--ink);
|
|
12
|
+
font-family: var(--sans);
|
|
13
|
+
font-size: 1rem;
|
|
14
|
+
line-height: 1.55;
|
|
15
|
+
-webkit-font-smoothing: antialiased;
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
height: 100%;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* ── The top bar ─────────────────────────────────────────────────────── */
|
|
22
|
+
.topbar {
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
gap: 14px;
|
|
26
|
+
padding: 10px 18px;
|
|
27
|
+
background: var(--paper);
|
|
28
|
+
border-bottom: 1px solid var(--line);
|
|
29
|
+
flex: none;
|
|
30
|
+
}
|
|
31
|
+
.topbar .home {
|
|
32
|
+
font: 700 1.2rem/1 var(--mono);
|
|
33
|
+
letter-spacing: 1px;
|
|
34
|
+
color: var(--ink);
|
|
35
|
+
text-decoration: none;
|
|
36
|
+
}
|
|
37
|
+
.topbar .home:hover { color: var(--muted); }
|
|
38
|
+
.topbar h1 {
|
|
39
|
+
margin: 0;
|
|
40
|
+
font: 600 1rem/1 var(--sans);
|
|
41
|
+
color: var(--muted);
|
|
42
|
+
flex: 1;
|
|
43
|
+
}
|
|
44
|
+
.topbar .when {
|
|
45
|
+
font-size: .82rem;
|
|
46
|
+
font-weight: 600;
|
|
47
|
+
color: var(--muted);
|
|
48
|
+
flex: none;
|
|
49
|
+
white-space: nowrap;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* The four windows, as one connected control rather than four loose buttons: they are a scale
|
|
53
|
+
of how far back to look, and exactly one of them is always the answer. */
|
|
54
|
+
.periods { display: flex; flex: none; }
|
|
55
|
+
.periods button {
|
|
56
|
+
border-radius: 0;
|
|
57
|
+
border-left-width: 0;
|
|
58
|
+
padding: 5px 13px;
|
|
59
|
+
font-size: .82rem;
|
|
60
|
+
font-weight: 600;
|
|
61
|
+
color: var(--muted);
|
|
62
|
+
}
|
|
63
|
+
.periods button:first-child { border-left-width: 1px; border-radius: 8px 0 0 8px; }
|
|
64
|
+
.periods button:last-child { border-radius: 0 8px 8px 0; }
|
|
65
|
+
.periods button[aria-checked='true'] {
|
|
66
|
+
background: var(--ink);
|
|
67
|
+
border-color: var(--ink);
|
|
68
|
+
color: var(--paper);
|
|
69
|
+
}
|
|
70
|
+
.periods button[aria-checked='true'] + button { border-left-color: var(--ink); }
|
|
71
|
+
|
|
72
|
+
/* ── The sheet ───────────────────────────────────────────────────────── */
|
|
73
|
+
.sheet {
|
|
74
|
+
flex: 1;
|
|
75
|
+
overflow-y: auto;
|
|
76
|
+
padding: 30px 18px 80px;
|
|
77
|
+
}
|
|
78
|
+
.sheet > * { max-width: 44rem; margin-inline: auto; }
|
|
79
|
+
|
|
80
|
+
.intro { font-size: 1.08rem; }
|
|
81
|
+
.intro p { margin: 0 0 14px; }
|
|
82
|
+
.intro p.note { color: var(--muted); font-size: .95rem; }
|
|
83
|
+
.blank { color: var(--muted); text-align: center; padding: 40px 0; }
|
|
84
|
+
|
|
85
|
+
/* The repositories you have put away for this visit. Not a preference and not remembered: the
|
|
86
|
+
page is written afresh every time it is opened, so a filter that outlived it would be a
|
|
87
|
+
filter you had forgotten setting. */
|
|
88
|
+
.chips { display: flex; flex-wrap: wrap; gap: 7px; margin: 6px auto 22px; }
|
|
89
|
+
.chips button {
|
|
90
|
+
border-radius: 999px;
|
|
91
|
+
padding: 4px 12px;
|
|
92
|
+
font-size: .78rem;
|
|
93
|
+
font-weight: 600;
|
|
94
|
+
font-family: var(--mono);
|
|
95
|
+
color: var(--ink);
|
|
96
|
+
}
|
|
97
|
+
.chips button[aria-pressed='false'] { color: var(--muted); background: transparent; opacity: .6; }
|
|
98
|
+
|
|
99
|
+
/* ── One repository ──────────────────────────────────────────────────── */
|
|
100
|
+
.repo {
|
|
101
|
+
background: var(--paper);
|
|
102
|
+
border: 1px solid var(--line);
|
|
103
|
+
border-radius: 12px;
|
|
104
|
+
padding: 20px 24px;
|
|
105
|
+
margin-bottom: 16px;
|
|
106
|
+
}
|
|
107
|
+
.repo h2 {
|
|
108
|
+
margin: 0 0 12px;
|
|
109
|
+
font: 700 .95rem/1 var(--mono);
|
|
110
|
+
letter-spacing: .5px;
|
|
111
|
+
}
|
|
112
|
+
.prose > :first-child { margin-top: 0; }
|
|
113
|
+
.prose > :last-child { margin-bottom: 0; }
|
|
114
|
+
.prose p { margin: 0 0 12px; }
|
|
115
|
+
/* The bold opening line of a block is the whole point of it: it says what is different now. */
|
|
116
|
+
.prose p > strong:only-child { font-size: 1.05rem; }
|
|
117
|
+
.prose ul, .prose ol { margin: 0 0 12px; padding-left: 20px; }
|
|
118
|
+
.prose li { margin-bottom: 3px; }
|
|
119
|
+
.prose code {
|
|
120
|
+
font: .85em var(--mono);
|
|
121
|
+
background: var(--bg);
|
|
122
|
+
border-radius: 4px;
|
|
123
|
+
padding: 1px 5px;
|
|
124
|
+
}
|
|
125
|
+
.prose h3 { margin: 16px 0 8px; font-size: .95rem; }
|
|
126
|
+
.prose a { color: inherit; }
|
|
127
|
+
|
|
128
|
+
/* ── The facts underneath ────────────────────────────────────────────── */
|
|
129
|
+
/* Folded away by default. The written summary is what you came for; this is what to open when
|
|
130
|
+
you do not believe it. */
|
|
131
|
+
.facts { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 10px; }
|
|
132
|
+
.facts summary {
|
|
133
|
+
cursor: pointer;
|
|
134
|
+
font-size: .78rem;
|
|
135
|
+
color: var(--muted);
|
|
136
|
+
list-style: none;
|
|
137
|
+
}
|
|
138
|
+
.facts summary::-webkit-details-marker { display: none; }
|
|
139
|
+
.facts summary::before { content: '▸ '; }
|
|
140
|
+
.facts[open] summary::before { content: '▾ '; }
|
|
141
|
+
|
|
142
|
+
.commits { list-style: none; margin: 12px 0 0; padding: 0; font-size: .88rem; }
|
|
143
|
+
.commits li { display: flex; align-items: baseline; gap: 9px; margin-bottom: 5px; }
|
|
144
|
+
.commits i {
|
|
145
|
+
flex: none;
|
|
146
|
+
width: 7px;
|
|
147
|
+
height: 7px;
|
|
148
|
+
border-radius: 999px;
|
|
149
|
+
background: var(--calm);
|
|
150
|
+
}
|
|
151
|
+
/* The one distinction the whole page is built on: out of here, or still only here. */
|
|
152
|
+
.commits i.local { background: var(--warn); }
|
|
153
|
+
|
|
154
|
+
.pending { margin: 12px 0 0; font-size: .82rem; color: var(--warn); }
|
|
155
|
+
|
|
156
|
+
/* ── While the words are being written ───────────────────────────────── */
|
|
157
|
+
.waiting { margin-bottom: 26px; }
|
|
158
|
+
.waiting .bar {
|
|
159
|
+
height: 3px;
|
|
160
|
+
background: var(--line);
|
|
161
|
+
border-radius: 2px;
|
|
162
|
+
overflow: hidden;
|
|
163
|
+
}
|
|
164
|
+
.waiting .bar i {
|
|
165
|
+
display: block;
|
|
166
|
+
height: 100%;
|
|
167
|
+
width: 32%;
|
|
168
|
+
border-radius: 2px;
|
|
169
|
+
background: var(--ink);
|
|
170
|
+
animation: sweep 1.5s ease-in-out infinite;
|
|
171
|
+
}
|
|
172
|
+
@keyframes sweep {
|
|
173
|
+
from { transform: translateX(-105%); }
|
|
174
|
+
to { transform: translateX(420%); }
|
|
175
|
+
}
|
|
176
|
+
/* Handwriting, like the other places where k0 speaks rather than reports. */
|
|
177
|
+
.waiting .phrase {
|
|
178
|
+
margin: 10px 0 0;
|
|
179
|
+
font: 600 1.25rem/1 var(--hand);
|
|
180
|
+
color: var(--muted);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
@media (prefers-reduced-motion: reduce) {
|
|
184
|
+
.waiting .bar i { animation: none; width: 100%; opacity: .35; }
|
|
185
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>k0 — ChangeLog</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
8
|
+
<link rel="stylesheet" href="/changelog.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<header class="topbar">
|
|
12
|
+
<a class="home" href="/" title="Back to the dashboard">k0</a>
|
|
13
|
+
<h1>ChangeLog</h1>
|
|
14
|
+
|
|
15
|
+
<!-- Four windows and no date picker. A summary is something you glance at, and every extra
|
|
16
|
+
control is one more decision to make before reading a word. -->
|
|
17
|
+
<div class="periods" id="periods" role="radiogroup" aria-label="Period">
|
|
18
|
+
<button type="button" role="radio" data-period="today" aria-checked="false">Today</button>
|
|
19
|
+
<button type="button" role="radio" data-period="yesterday" aria-checked="false">Yesterday</button>
|
|
20
|
+
<button type="button" role="radio" data-period="week" aria-checked="false">Week</button>
|
|
21
|
+
<button type="button" role="radio" data-period="month" aria-checked="false">Month</button>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<span class="when" id="when"></span>
|
|
25
|
+
</header>
|
|
26
|
+
|
|
27
|
+
<main class="sheet" id="sheet">
|
|
28
|
+
<!-- While the words are being written the facts are already here, so the page is never a
|
|
29
|
+
spinner on an empty screen: it is the summary, filling in. -->
|
|
30
|
+
<div class="waiting" id="waiting" hidden>
|
|
31
|
+
<div class="bar"><i></i></div>
|
|
32
|
+
<p class="phrase" id="phrase"></p>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="intro" id="intro"></div>
|
|
36
|
+
<div class="chips" id="chips" hidden></div>
|
|
37
|
+
<div class="repos" id="repos"></div>
|
|
38
|
+
<p class="blank" id="blank" hidden></p>
|
|
39
|
+
</main>
|
|
40
|
+
|
|
41
|
+
<script type="module" src="/changelog.js"></script>
|
|
42
|
+
</body>
|
|
43
|
+
</html>
|