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/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 { width: 17px; height: 17px; display: block; }
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
- .column .add {
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 { width: 15px; height: 15px; display: block; }
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: 3px 0;
451
+ padding: 4px 0;
394
452
  min-width: 0;
395
453
  }
396
454
  .column.repos .name {
397
- font: 500 1.1rem/1.25 var(--hand);
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
- for (const { col, cards } of columns) {
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
- wrap.innerHTML = `<h2><button class="add" title="${add}" aria-label="${add}">${ICON.plus}</button>${esc(
270
- col.name
271
- )}<small>${cards.length}</small>${gitChip(col.git, col.path)}</h2>`
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
- data.columns.map((col) => [col.path, gitSig(col.git)]),
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) {