k0-board 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/k0-changelog/SKILL.md +96 -0
- package/CHANGELOG.md +134 -0
- package/README.md +206 -16
- package/bin/k0-board.js +37 -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 +58 -0
- package/server/git.js +135 -0
- package/server/guard.js +31 -0
- package/server/index.js +122 -35
- package/server/machine.js +9 -0
- package/server/servers.js +481 -0
- package/server/watcher.js +8 -1
- package/server/writer.js +128 -0
- package/web/board.css +133 -7
- package/web/board.js +263 -6
- 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.css
CHANGED
|
@@ -95,7 +95,8 @@ body {
|
|
|
95
95
|
|
|
96
96
|
/* The "+" sits next to k0, at the start of the bar: that is where a card is born.
|
|
97
97
|
The sign is drawn, not typed: a font glyph never sits in the middle of the circle. */
|
|
98
|
-
.new
|
|
98
|
+
.new,
|
|
99
|
+
.log {
|
|
99
100
|
flex: none;
|
|
100
101
|
padding: 0;
|
|
101
102
|
width: 34px;
|
|
@@ -105,7 +106,17 @@ body {
|
|
|
105
106
|
align-items: center;
|
|
106
107
|
justify-content: center;
|
|
107
108
|
}
|
|
108
|
-
.new svg
|
|
109
|
+
.new svg,
|
|
110
|
+
.log svg { width: 17px; height: 17px; display: block; }
|
|
111
|
+
|
|
112
|
+
/* The way out to the ChangeLog. It is a link, not a button, because it goes somewhere — and it
|
|
113
|
+
is drawn quieter than the plus, because starting work matters more than reading about it. */
|
|
114
|
+
.log {
|
|
115
|
+
border: 1px solid var(--line);
|
|
116
|
+
background: var(--paper);
|
|
117
|
+
color: var(--muted);
|
|
118
|
+
}
|
|
119
|
+
.log:hover { background: #fff; border-color: #c9c3b4; color: var(--ink); }
|
|
109
120
|
|
|
110
121
|
/* ── The four modes ───────────────────────────────────────────────────
|
|
111
122
|
At the end of the bar, a row of four: how awake the machine has to stay. Switched off they are
|
|
@@ -184,11 +195,47 @@ body {
|
|
|
184
195
|
too: a post-it is never taller than it is wide. */
|
|
185
196
|
:root { --card-w: 268px; }
|
|
186
197
|
.column { flex: 0 0 var(--card-w); }
|
|
198
|
+
/* Two groups with the name between them: on the left what you press — the `+` and the fold — and
|
|
199
|
+
on the right what tells you how things stand, the git mark and the globe. The count of post-its
|
|
200
|
+
that used to follow the name is gone: it took the room the name now has, and answered nothing.
|
|
201
|
+
Flex is what lets the name give up characters instead of pushing the marks off the end. */
|
|
187
202
|
.column > h2 {
|
|
203
|
+
display: flex;
|
|
204
|
+
align-items: center;
|
|
188
205
|
margin: 0 0 14px 4px;
|
|
189
206
|
font: 600 1.5rem/1 var(--hand);
|
|
190
207
|
color: #5f5a4e;
|
|
191
208
|
}
|
|
209
|
+
.column > h2 .name {
|
|
210
|
+
flex: 1;
|
|
211
|
+
min-width: 0;
|
|
212
|
+
overflow: hidden;
|
|
213
|
+
text-overflow: ellipsis;
|
|
214
|
+
white-space: nowrap;
|
|
215
|
+
color: inherit;
|
|
216
|
+
text-decoration: none;
|
|
217
|
+
}
|
|
218
|
+
/* With the server up the name is the way into the site, and it says so standing still — a dotted
|
|
219
|
+
line under it, there for as long as the server is. Waiting for a hover was the first idea and it
|
|
220
|
+
was the wrong one: the whole question the globe answers is "is this one up", and a heading you
|
|
221
|
+
have to go and touch to find out is a second place to ask it. Dotted rather than solid because
|
|
222
|
+
this is a hand-written heading, not a link in a paragraph: enough to say there is something
|
|
223
|
+
underneath, not enough to turn the row into a web page. Under the pointer it firms up. */
|
|
224
|
+
.column > h2 a.name {
|
|
225
|
+
/* Dashed, and the same green as the globe, so the two marks say one thing. The first attempt
|
|
226
|
+
was a grey dotted line at half opacity, which is in the stylesheet and invisible on the
|
|
227
|
+
board: next to a lit globe it read as nothing at all. What rescued it was the dash and the
|
|
228
|
+
colour, not weight — a hairline is enough once it is green, and anything thicker starts
|
|
229
|
+
underlining the heading rather than marking it. */
|
|
230
|
+
text-decoration: underline dashed;
|
|
231
|
+
text-decoration-thickness: 1px;
|
|
232
|
+
text-underline-offset: 4px;
|
|
233
|
+
text-decoration-color: var(--calm);
|
|
234
|
+
/* The ellipsis needs `overflow: hidden`, and that clips the underline too. This is the room it
|
|
235
|
+
needs to survive. */
|
|
236
|
+
padding-bottom: 3px;
|
|
237
|
+
}
|
|
238
|
+
.column > h2 a.name:hover { text-decoration-style: solid; text-decoration-color: currentColor; }
|
|
192
239
|
.column > h2 small {
|
|
193
240
|
font-family: var(--sans);
|
|
194
241
|
color: var(--muted);
|
|
@@ -198,8 +245,10 @@ body {
|
|
|
198
245
|
vertical-align: middle;
|
|
199
246
|
}
|
|
200
247
|
/* The column's "+": from here a card is born already addressed to this repository.
|
|
201
|
-
It sits to the left of the name, faded, and darkens when you hover over it.
|
|
202
|
-
|
|
248
|
+
It sits to the left of the name, faded, and darkens when you hover over it. The button that
|
|
249
|
+
puts the column away stands next to it and is cut from the same cloth. */
|
|
250
|
+
.column .add,
|
|
251
|
+
.column > h2 .fold {
|
|
203
252
|
padding: 0;
|
|
204
253
|
width: 19px;
|
|
205
254
|
height: 19px;
|
|
@@ -214,8 +263,17 @@ body {
|
|
|
214
263
|
align-items: center;
|
|
215
264
|
justify-content: center;
|
|
216
265
|
}
|
|
217
|
-
.column .add svg
|
|
266
|
+
.column .add svg,
|
|
267
|
+
.column > h2 .fold svg { width: 15px; height: 15px; display: block; }
|
|
218
268
|
.column .add:hover { opacity: 1; background: rgba(0, 0, 0, .07); }
|
|
269
|
+
/* And next to it, the one that puts the column away. It stands there in plain sight, exactly as
|
|
270
|
+
faint as the `+`: a button that only comes out on hover is a button nobody knows exists. Where
|
|
271
|
+
the column cannot be put away it goes fainter still and stops answering — it does not leave,
|
|
272
|
+
because a column runs and goes back to your turn every few seconds and the button would spend
|
|
273
|
+
the session blinking. */
|
|
274
|
+
.column > h2 .fold:hover { opacity: 1; background: rgba(0, 0, 0, .07); }
|
|
275
|
+
.column > h2 .fold[disabled] { opacity: .12; cursor: default; }
|
|
276
|
+
.column > h2 .fold[disabled]:hover { opacity: .12; background: none; }
|
|
219
277
|
|
|
220
278
|
.empty {
|
|
221
279
|
position: absolute;
|
|
@@ -390,11 +448,11 @@ body {
|
|
|
390
448
|
display: flex;
|
|
391
449
|
align-items: center;
|
|
392
450
|
gap: 4px;
|
|
393
|
-
padding:
|
|
451
|
+
padding: 4px 0;
|
|
394
452
|
min-width: 0;
|
|
395
453
|
}
|
|
396
454
|
.column.repos .name {
|
|
397
|
-
font: 500 1.
|
|
455
|
+
font: 500 1.35rem/1.25 var(--hand);
|
|
398
456
|
color: var(--muted);
|
|
399
457
|
min-width: 0;
|
|
400
458
|
overflow-wrap: anywhere;
|
|
@@ -402,6 +460,20 @@ body {
|
|
|
402
460
|
.column.repos .repo:hover .name { color: var(--ink); }
|
|
403
461
|
.column.repos .git { margin-left: auto; }
|
|
404
462
|
|
|
463
|
+
/* ── Old ──────────────────────────────────────────────────────────────
|
|
464
|
+
The repositories that do have work on the board, just not lately. Built like `Others` next to
|
|
465
|
+
it, because it is the same gesture — one click from being a column again — with two things
|
|
466
|
+
`Others` has nothing to say about: how much work is parked in there, and how long it has been
|
|
467
|
+
quiet. The name is a button here, not a label: it is what brings the column back. */
|
|
468
|
+
.column.old .repo { cursor: pointer; }
|
|
469
|
+
.column.old .name {
|
|
470
|
+
padding: 0;
|
|
471
|
+
border: none;
|
|
472
|
+
background: none;
|
|
473
|
+
text-align: left;
|
|
474
|
+
cursor: pointer;
|
|
475
|
+
}
|
|
476
|
+
|
|
405
477
|
/* ── The git mark ─────────────────────────────────────────────────────
|
|
406
478
|
One symbol: the dot if there is work that is nowhere yet, the arrow if it is committed but not
|
|
407
479
|
pushed, the tick if everything is safe. The detail is in the title, which comes out on hover.
|
|
@@ -433,6 +505,38 @@ body {
|
|
|
433
505
|
.git.none { color: rgba(0, 0, 0, .32); }
|
|
434
506
|
.column > h2 .git { margin-left: 8px; vertical-align: middle; }
|
|
435
507
|
|
|
508
|
+
/* ── The dev server ───────────────────────────────────────────────────
|
|
509
|
+
The last thing on the right, after the git mark: is this repository's site up. Grey is off,
|
|
510
|
+
green is up — the same green as the git tick, because it means the same thing — and red is
|
|
511
|
+
tried and could not. One click switches it, two restart it.
|
|
512
|
+
|
|
513
|
+
While it is coming up it breathes rather than spins. Breathing is already what a card does
|
|
514
|
+
while a session is working (see `.postit.WORKING` above), and there is no reason for the board
|
|
515
|
+
to have two different ways of saying the same thing. */
|
|
516
|
+
.column > h2 .globe {
|
|
517
|
+
flex: none;
|
|
518
|
+
padding: 0;
|
|
519
|
+
margin-left: 7px;
|
|
520
|
+
width: 19px;
|
|
521
|
+
height: 19px;
|
|
522
|
+
border: none;
|
|
523
|
+
background: none;
|
|
524
|
+
border-radius: 5px;
|
|
525
|
+
display: inline-flex;
|
|
526
|
+
align-items: center;
|
|
527
|
+
justify-content: center;
|
|
528
|
+
cursor: pointer;
|
|
529
|
+
color: rgba(0, 0, 0, .32);
|
|
530
|
+
}
|
|
531
|
+
.column > h2 .globe svg { width: 14px; height: 14px; display: block; }
|
|
532
|
+
.column > h2 .globe:hover { background: rgba(0, 0, 0, .07); }
|
|
533
|
+
.column > h2 .globe.up { color: var(--calm); }
|
|
534
|
+
.column > h2 .globe.failed { color: var(--alert); }
|
|
535
|
+
.column > h2 .globe.starting { color: var(--muted); cursor: default; animation: pulse 1.4s ease-in-out infinite; }
|
|
536
|
+
.column > h2 .globe[disabled] { cursor: default; }
|
|
537
|
+
.column > h2 .globe[disabled]:hover { background: none; }
|
|
538
|
+
@keyframes pulse { 50% { opacity: .3; } }
|
|
539
|
+
|
|
436
540
|
/* ── What a session weighs ────────────────────────────────────────────
|
|
437
541
|
At the head of the card, after the repository name and the git mark: repository · changes ·
|
|
438
542
|
load. Grey like the rest of the row — the colour comes from the gauge at the top — except for
|
|
@@ -491,6 +595,19 @@ body {
|
|
|
491
595
|
background: rgba(255, 255, 255, .72);
|
|
492
596
|
border-color: rgba(0, 0, 0, .14);
|
|
493
597
|
}
|
|
598
|
+
/* The one you reach for rarely: a link at the end of the row, so it is there without competing
|
|
599
|
+
with the button next to it. */
|
|
600
|
+
.postit .actions button.link {
|
|
601
|
+
font-weight: 500;
|
|
602
|
+
padding: 6px 2px;
|
|
603
|
+
background: none;
|
|
604
|
+
border-color: transparent;
|
|
605
|
+
box-shadow: none;
|
|
606
|
+
text-decoration: underline;
|
|
607
|
+
text-underline-offset: 3px;
|
|
608
|
+
opacity: .55;
|
|
609
|
+
}
|
|
610
|
+
.postit .actions button.link:hover, .postit .actions button.link:focus-visible { opacity: 1; }
|
|
494
611
|
|
|
495
612
|
/* ── Editor ───────────────────────────────────────────────────────────
|
|
496
613
|
The dialog and its buttons live in base.css, which belongs to both pages: the file viewer opens
|
|
@@ -568,9 +685,18 @@ html[data-driving] .modes button svg { width: 27px; height: 27px; }
|
|
|
568
685
|
html[data-driving] .column .add { width: 26px; height: 26px; }
|
|
569
686
|
html[data-driving] .column .add svg { width: 20px; height: 20px; }
|
|
570
687
|
|
|
688
|
+
/* Putting a column away: the same target as the `+`, and it grows with it. */
|
|
689
|
+
html[data-driving] .column > h2 .fold { width: 26px; height: 26px; }
|
|
690
|
+
html[data-driving] .column > h2 .fold svg { width: 20px; height: 20px; }
|
|
691
|
+
html[data-driving] .column > h2 .fold[disabled] { opacity: .12; }
|
|
692
|
+
|
|
571
693
|
/* The git lens: it is the door to the files, and at 12px it is a target for sitting still. */
|
|
572
694
|
html[data-driving] .git svg { width: 18px; height: 18px; }
|
|
573
695
|
|
|
696
|
+
/* The globe, which is a switch and so needs a target rather than a glyph. */
|
|
697
|
+
html[data-driving] .column > h2 .globe { width: 26px; height: 26px; }
|
|
698
|
+
html[data-driving] .column > h2 .globe svg { width: 20px; height: 20px; }
|
|
699
|
+
|
|
574
700
|
/* The corner pencil. As well as growing, it stays visible: normally it appears on hover, and a
|
|
575
701
|
target that expects to be found before it will show itself is, in driving mode, not a target. */
|
|
576
702
|
html[data-driving] .postit .corner { width: 34px; height: 34px; opacity: 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
|
|
@@ -108,10 +123,11 @@ function postit(card, now) {
|
|
|
108
123
|
}
|
|
109
124
|
|
|
110
125
|
const actions = el.querySelector('.actions')
|
|
111
|
-
const btn = (text, fn, title) => {
|
|
126
|
+
const btn = (text, fn, title, cls) => {
|
|
112
127
|
const b = document.createElement('button')
|
|
113
128
|
b.textContent = text
|
|
114
129
|
if (title) b.title = title
|
|
130
|
+
if (cls) b.className = cls
|
|
115
131
|
b.onclick = (e) => {
|
|
116
132
|
e.stopPropagation()
|
|
117
133
|
fn()
|
|
@@ -127,6 +143,16 @@ function postit(card, now) {
|
|
|
127
143
|
if (dead) btn('Resume', () => start(card.id, 'resume'))
|
|
128
144
|
// An idea in the backlog cannot be "done": it never started.
|
|
129
145
|
btn('Done', () => setCompleted(card.id, true), 'close this job and its terminal')
|
|
146
|
+
// Close gives the memory back without declaring the work over, and it comes after Done, as a
|
|
147
|
+
// link rather than a button: it is the rarer of the two and should not compete with it. Not
|
|
148
|
+
// while the session is working — stopping one mid-thought is not memory saved, it is work lost.
|
|
149
|
+
if (!dead && !BUSY.has(card.status))
|
|
150
|
+
btn(
|
|
151
|
+
'Close',
|
|
152
|
+
() => closeSession(card.id),
|
|
153
|
+
'stop the session and its terminal, and leave the card where it is',
|
|
154
|
+
'link'
|
|
155
|
+
)
|
|
130
156
|
}
|
|
131
157
|
|
|
132
158
|
// The git mark is a link: a double click on it would open two tabs and bring the terminal to
|
|
@@ -148,6 +174,11 @@ const ICON = {
|
|
|
148
174
|
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
175
|
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
176
|
plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
|
|
177
|
+
// Put this column away: it slides off to the right, where `Old` is.
|
|
178
|
+
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>',
|
|
179
|
+
// The dev server: a world, because what is behind it is a site. A meridian and a parallel are
|
|
180
|
+
// enough to read it at twelve pixels — more lines and it goes back to being a circle.
|
|
181
|
+
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
182
|
}
|
|
152
183
|
|
|
153
184
|
const esc = (s) =>
|
|
@@ -200,6 +231,95 @@ function gitChip(g, repo, card) {
|
|
|
200
231
|
}${n}</a>`
|
|
201
232
|
}
|
|
202
233
|
|
|
234
|
+
// ── The dev server ────────────────────────────────────────────────────
|
|
235
|
+
// The globe next to a repository's name: grey when its server is off, green when it is up,
|
|
236
|
+
// spinning while it is coming up, red when it tried and could not. One click switches it, two
|
|
237
|
+
// restart it, and when it is green the repository's name becomes the way into the site.
|
|
238
|
+
//
|
|
239
|
+
// The four states are the ones the server sends; nothing is guessed here. The only thing the
|
|
240
|
+
// page knows that the server does not is that a click has just happened and no answer has come
|
|
241
|
+
// back yet — which is what `switching` is for.
|
|
242
|
+
const switching = new Set()
|
|
243
|
+
|
|
244
|
+
/** Everything the globe knows, for the tooltip. Same idea as `gitDetail`: the detail is on hover. */
|
|
245
|
+
function serverDetail(s, state) {
|
|
246
|
+
const bits = []
|
|
247
|
+
if (state === 'up') bits.push(s.adopted ? `up on port ${s.port}, started outside k0` : `up on port ${s.port}`)
|
|
248
|
+
else if (state === 'starting') bits.push('starting…')
|
|
249
|
+
else if (state === 'failed') bits.push(s.error ? `it did not come up: ${s.error}` : 'it did not come up')
|
|
250
|
+
else bits.push('the server is off')
|
|
251
|
+
bits.push(s.command)
|
|
252
|
+
if (state === 'up') bits.push('click to stop, double-click to restart')
|
|
253
|
+
else if (state !== 'starting') bits.push('click to start')
|
|
254
|
+
// Where it went wrong is worth more than any sentence about it going wrong.
|
|
255
|
+
if (state === 'failed') bits.push(s.log)
|
|
256
|
+
return bits.join(' · ')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The globe, or nothing at all. A repository with no way to start a server does not get a
|
|
261
|
+
* button that would quietly do nothing — the server sends null and this draws none.
|
|
262
|
+
*/
|
|
263
|
+
function serverChip(s, repo) {
|
|
264
|
+
if (!s) return ''
|
|
265
|
+
const state = switching.has(repo) ? 'starting' : s.state
|
|
266
|
+
const title = serverDetail(s, state)
|
|
267
|
+
const off = s.can.run ? '' : ' disabled'
|
|
268
|
+
return `<button class="globe ${state}" title="${esc(title)}" aria-label="${esc(title)}"${off}>${ICON.globe}</button>`
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* One click switches, two restart, and they are fighting over the same first click.
|
|
273
|
+
*
|
|
274
|
+
* What settles it without being seen: when the server is OFF there is nothing to wait for,
|
|
275
|
+
* because "start" and "restart" are the same thing there, so the click goes straight through.
|
|
276
|
+
* Only STOPPING waits the quarter second it takes to learn whether a second click is coming —
|
|
277
|
+
* and even that is invisible, because the globe starts spinning the moment it is pressed.
|
|
278
|
+
*/
|
|
279
|
+
function wireGlobe(el, repo, s) {
|
|
280
|
+
if (!s.can.run) return
|
|
281
|
+
if (s.state === 'starting' || switching.has(repo)) {
|
|
282
|
+
el.disabled = true
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
if (s.state !== 'up') {
|
|
286
|
+
el.onclick = () => switchServer(repo, 'start')
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
let timer = null
|
|
290
|
+
el.onclick = () => {
|
|
291
|
+
if (timer) return // the second click of a double: let `ondblclick` have it
|
|
292
|
+
timer = setTimeout(() => {
|
|
293
|
+
timer = null
|
|
294
|
+
switchServer(repo, 'stop')
|
|
295
|
+
}, 250)
|
|
296
|
+
}
|
|
297
|
+
el.ondblclick = () => {
|
|
298
|
+
clearTimeout(timer)
|
|
299
|
+
timer = null
|
|
300
|
+
switchServer(repo, 'restart')
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function switchServer(repo, action) {
|
|
305
|
+
switching.add(repo)
|
|
306
|
+
lastSignature = '' // the globe has to start spinning now, not at the next round
|
|
307
|
+
await refresh()
|
|
308
|
+
try {
|
|
309
|
+
const s = await api('/api/server', { method: 'POST', body: JSON.stringify({ path: repo, action }) })
|
|
310
|
+
if (s.state === 'failed') toast(s.error || 'The server did not come up. Hover the globe for the log.', 8000)
|
|
311
|
+
} catch (err) {
|
|
312
|
+
toast(err.message)
|
|
313
|
+
} finally {
|
|
314
|
+
switching.delete(repo)
|
|
315
|
+
lastSignature = ''
|
|
316
|
+
refresh()
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Just enough to compare one round with the next, exactly as `gitSig` does for git. */
|
|
321
|
+
const serverSig = (s) => (s ? `${s.state}/${s.port ?? ''}/${s.adopted ? 1 : 0}` : '')
|
|
322
|
+
|
|
203
323
|
// ── What a session weighs ────────────────────────────────────────────
|
|
204
324
|
/**
|
|
205
325
|
* How much this session eats, counting everything it has opened underneath itself. The detail —
|
|
@@ -261,24 +381,140 @@ function render(data) {
|
|
|
261
381
|
// still in alphabetical order — the server's, which is not touched here.
|
|
262
382
|
if (data.mode === 'driving') columns.sort((a, b) => byAttention(a.cards[0], b.cards[0]))
|
|
263
383
|
|
|
264
|
-
|
|
384
|
+
// Which of them are still warm. Everything else folds away into `Old` — see recency.js for the
|
|
385
|
+
// four rules and for why the day is counted back from your last piece of work rather than from
|
|
386
|
+
// the clock.
|
|
387
|
+
const { open, alive } = split({
|
|
388
|
+
paths: columns.map((c) => c.col.path),
|
|
389
|
+
cards: data.cards,
|
|
390
|
+
folded,
|
|
391
|
+
held,
|
|
392
|
+
})
|
|
393
|
+
// A column open now is open for the rest of the visit, and a column that is open is not one
|
|
394
|
+
// you have put away: a session woke up in there, or you went and fetched it back yourself.
|
|
395
|
+
let forget = false
|
|
396
|
+
for (const p of open) {
|
|
397
|
+
held.add(p)
|
|
398
|
+
if (folded.delete(p)) forget = true
|
|
399
|
+
}
|
|
400
|
+
if (forget) saveFolded()
|
|
401
|
+
|
|
402
|
+
const openSet = new Set(open)
|
|
403
|
+
for (const { col, cards } of columns.filter(({ col }) => openSet.has(col.path))) {
|
|
265
404
|
const wrap = document.createElement('section')
|
|
266
405
|
wrap.className = 'column'
|
|
267
406
|
// The "+" next to the name: a new card on THIS repository, without picking it.
|
|
268
407
|
const add = `New card in ${esc(col.name)}`
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
408
|
+
// And next to it, the one that puts the column away without waiting for it to go quiet on
|
|
409
|
+
// its own. It sits there in plain sight next to the `+`, because a button you have to go
|
|
410
|
+
// hunting for with the pointer is a button nobody knows exists.
|
|
411
|
+
//
|
|
412
|
+
// Where the column cannot be put away it goes faint and refuses, rather than not being drawn
|
|
413
|
+
// at all. Not being drawn was the first idea and it was the wrong one: a column runs and
|
|
414
|
+
// goes back to your turn every few seconds, so the button would have spent the whole session
|
|
415
|
+
// blinking in and out — and it would have done it on exactly the columns you are working on.
|
|
416
|
+
// A control that stays where it was and says why it will not move is worth more than one
|
|
417
|
+
// that is only there when it agrees with you. The title carries the reason.
|
|
418
|
+
const live = alive.has(col.path)
|
|
419
|
+
const away = live ? `${col.name} has something going on: it cannot be put away` : `Put ${col.name} away`
|
|
420
|
+
const fold = `<button class="fold" title="${esc(away)}" aria-label="${esc(away)}"${
|
|
421
|
+
live ? ' disabled' : ''
|
|
422
|
+
}>${ICON.fold}</button>`
|
|
423
|
+
// Two groups and a name between them: what you press on the left, what tells you how things
|
|
424
|
+
// stand on the right. The count of post-its that used to sit after the name is gone — it
|
|
425
|
+
// took up the room the name now uses, and it never answered a question anybody had.
|
|
426
|
+
//
|
|
427
|
+
// With the server up, the name is the door to it. It is the right thing to click for the
|
|
428
|
+
// same reason the git mark is the right thing to click to reach the files: the obvious
|
|
429
|
+
// gesture on the obvious word, leaving the globe free to mean only on and off.
|
|
430
|
+
const url = col.server?.state === 'up' ? col.server.url : null
|
|
431
|
+
const name = url
|
|
432
|
+
? `<a class="name" href="${esc(url)}" target="_blank" title="${esc(url)}">${esc(col.name)}</a>`
|
|
433
|
+
: `<span class="name">${esc(col.name)}</span>`
|
|
434
|
+
wrap.innerHTML = `<h2><button class="add" title="${add}" aria-label="${add}">${
|
|
435
|
+
ICON.plus
|
|
436
|
+
}</button>${fold}${name}${gitChip(col.git, col.path)}${serverChip(col.server, col.path)}</h2>`
|
|
272
437
|
wrap.querySelector('.add').onclick = () => openEditor(null, col.path)
|
|
438
|
+
wrap.querySelector('.fold').onclick = () => putAway(col.path)
|
|
439
|
+
const globe = wrap.querySelector('.globe')
|
|
440
|
+
if (globe) wireGlobe(globe, col.path, col.server)
|
|
273
441
|
for (const c of cards) wrap.append(postit(c, data.now))
|
|
274
442
|
board.append(wrap)
|
|
275
443
|
}
|
|
276
444
|
|
|
445
|
+
board.append(oldColumn(columns.filter(({ col }) => !openSet.has(col.path))))
|
|
277
446
|
board.append(repoColumn(data.projects ?? [], new Set(columns.map((c) => c.col.path))))
|
|
278
447
|
$('#empty').style.display = columns.length ? 'none' : 'grid'
|
|
279
448
|
refit() // the board changed size: the view comes back inside its limits
|
|
280
449
|
}
|
|
281
450
|
|
|
451
|
+
/** Put a column away by hand, and let go of the place it was holding for this visit. */
|
|
452
|
+
function putAway(path) {
|
|
453
|
+
folded.add(path)
|
|
454
|
+
held.delete(path)
|
|
455
|
+
saveFolded()
|
|
456
|
+
lastSignature = ''
|
|
457
|
+
refresh()
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Fetch one back: it stays for the rest of the visit, and folds again on the next load. */
|
|
461
|
+
function bringBack(path) {
|
|
462
|
+
held.add(path)
|
|
463
|
+
folded.delete(path)
|
|
464
|
+
saveFolded()
|
|
465
|
+
lastSignature = ''
|
|
466
|
+
refresh()
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The second-to-last column: the repositories that DO have work on the board, just not lately —
|
|
471
|
+
* nothing touched in the day before your most recent piece of work, and nothing alive inside.
|
|
472
|
+
* They are the reason the board was three screens wide: a dozen of them, standing at full width
|
|
473
|
+
* between the three you are actually on.
|
|
474
|
+
*
|
|
475
|
+
* They are folded, not thrown away. Click the row and the column comes back at full width for the
|
|
476
|
+
* rest of the visit.
|
|
477
|
+
*
|
|
478
|
+
* It sits next to `Others` and is built the same way, down to the row: a `+`, a name, a git mark
|
|
479
|
+
* and nothing else. How many cards are parked in there and how long it has been quiet were both
|
|
480
|
+
* in here once, and both had to go — three numbers on a row you are meant to read at a glance is
|
|
481
|
+
* two too many, and the answer to either is one click away in the column itself.
|
|
482
|
+
*/
|
|
483
|
+
function oldColumn(columns) {
|
|
484
|
+
if (!columns.length) return document.createDocumentFragment()
|
|
485
|
+
|
|
486
|
+
const wrap = document.createElement('section')
|
|
487
|
+
wrap.className = 'column repos old'
|
|
488
|
+
// Alphabetical, for the same reason `Others` is: these are rows you scan with your eye looking
|
|
489
|
+
// for a name. In driving mode the columns above have queued up by urgency, and letting that
|
|
490
|
+
// order through to here would be a list that reshuffles itself with nothing urgent in it.
|
|
491
|
+
wrap.innerHTML =
|
|
492
|
+
'<h2>Old<small>' +
|
|
493
|
+
columns.length +
|
|
494
|
+
'</small></h2>' +
|
|
495
|
+
[...columns]
|
|
496
|
+
.sort((a, b) => a.col.name.localeCompare(b.col.name, undefined, { sensitivity: 'base' }))
|
|
497
|
+
.map(({ col }) => {
|
|
498
|
+
const add = `New card in ${esc(col.name)}`
|
|
499
|
+
const back = `Bring ${col.name} back onto the board`
|
|
500
|
+
return `<div class="repo" data-p="${esc(col.path)}">
|
|
501
|
+
<button class="add" title="${add}" aria-label="${add}">${ICON.plus}</button>
|
|
502
|
+
<button class="name" title="${esc(back)}">${esc(col.name)}</button>${gitChip(col.git, col.path)}
|
|
503
|
+
</div>`
|
|
504
|
+
})
|
|
505
|
+
.join('')
|
|
506
|
+
// The whole row brings the column back, not only the name: at this size, asking for the four
|
|
507
|
+
// words exactly is asking for a second click. The `+` and the git lens are the two things in
|
|
508
|
+
// there that mean something else, and they keep meaning it.
|
|
509
|
+
wrap.onclick = (e) => {
|
|
510
|
+
const path = e.target.closest('.repo')?.dataset.p
|
|
511
|
+
if (!path || e.target.closest('.git')) return
|
|
512
|
+
if (e.target.closest('.add')) openEditor(null, path)
|
|
513
|
+
else bringBack(path)
|
|
514
|
+
}
|
|
515
|
+
return wrap
|
|
516
|
+
}
|
|
517
|
+
|
|
282
518
|
/**
|
|
283
519
|
* The last column: the OTHER repositories, the ones with no column because they have not one
|
|
284
520
|
* card between them. Repeating the ones already on the left would be saying the same thing
|
|
@@ -406,6 +642,21 @@ async function remove(card) {
|
|
|
406
642
|
refresh()
|
|
407
643
|
}
|
|
408
644
|
|
|
645
|
+
/**
|
|
646
|
+
* The terminal goes, the card stays. Stopping the session takes a couple of seconds — it is asked
|
|
647
|
+
* to leave and given the time to — so it says what it is doing while it waits.
|
|
648
|
+
*/
|
|
649
|
+
async function closeSession(id) {
|
|
650
|
+
toast('Closing the terminal…', 10000)
|
|
651
|
+
try {
|
|
652
|
+
await api(`/api/card/${id}/close`, { method: 'POST' })
|
|
653
|
+
toast('Closed. Resume picks the conversation up where it was')
|
|
654
|
+
} catch (e) {
|
|
655
|
+
toast(`Couldn't do it: ${e.message}`, 8000)
|
|
656
|
+
}
|
|
657
|
+
refresh()
|
|
658
|
+
}
|
|
659
|
+
|
|
409
660
|
async function setCompleted(id, completed) {
|
|
410
661
|
await api(`/api/card/${id}`, { method: 'PATCH', body: JSON.stringify({ completed }) })
|
|
411
662
|
refresh()
|
|
@@ -627,10 +878,16 @@ async function refresh() {
|
|
|
627
878
|
c.description,
|
|
628
879
|
c.prompt,
|
|
629
880
|
c.project_path,
|
|
881
|
+
// What folds a column away and what brings it back: the day is measured from the
|
|
882
|
+
// freshest of these, so when none of them moves nothing can fold, and the board is
|
|
883
|
+
// spared a redraw on a timer.
|
|
884
|
+
c.updated_at,
|
|
630
885
|
gitSig(c.git),
|
|
631
886
|
c.load ? weight(c.load.rss) : '',
|
|
632
887
|
]),
|
|
633
|
-
|
|
888
|
+
// The server state belongs in here for the same reason the git state does: without it the
|
|
889
|
+
// globe would keep the colour it had on the first round for the rest of the visit.
|
|
890
|
+
data.columns.map((col) => [col.path, gitSig(col.git), serverSig(col.server)]),
|
|
634
891
|
(data.projects ?? []).map((p) => [p.path, gitSig(p.git)]),
|
|
635
892
|
])
|
|
636
893
|
if (sig !== lastSignature) {
|