k0-board 0.1.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-import/SKILL.md +83 -0
- package/CHANGELOG.md +57 -0
- package/LICENSE +21 -0
- package/README.md +688 -0
- package/bin/k0-board.js +456 -0
- package/bin/prompt.js +41 -0
- package/menubar/Info.plist +25 -0
- package/menubar/K0MenuBar.swift +561 -0
- package/menubar/build.sh +28 -0
- package/package.json +45 -0
- package/platform/contract.js +134 -0
- package/platform/darwin/extras.js +254 -0
- package/platform/darwin/index.js +24 -0
- package/platform/darwin/metrics.js +59 -0
- package/platform/darwin/power.js +133 -0
- package/platform/darwin/service.js +143 -0
- package/platform/darwin/shell.js +59 -0
- package/platform/darwin/terminal.js +324 -0
- package/platform/index.js +97 -0
- package/platform/linux/index.js +60 -0
- package/platform/linux/metrics.js +88 -0
- package/platform/linux/power.js +137 -0
- package/platform/linux/service.js +111 -0
- package/platform/linux/shell.js +97 -0
- package/platform/linux/tray.py +229 -0
- package/platform/shared/command.js +26 -0
- package/platform/shared/ps.js +40 -0
- package/platform/shared/run.js +99 -0
- package/platform/shared/tmux.js +242 -0
- package/platform/win32/index.js +53 -0
- package/platform/win32/metrics.js +72 -0
- package/platform/win32/power.js +109 -0
- package/platform/win32/powershell.js +44 -0
- package/platform/win32/service.js +111 -0
- package/platform/win32/shell.js +64 -0
- package/platform/win32/terminal.js +209 -0
- package/platform/win32/tray.ps1 +159 -0
- package/server/db.js +262 -0
- package/server/files.js +488 -0
- package/server/git.js +157 -0
- package/server/index.js +636 -0
- package/server/launcher.js +121 -0
- package/server/machine.js +213 -0
- package/server/mode.js +182 -0
- package/server/paths.js +67 -0
- package/server/pdf.js +110 -0
- package/server/projects.js +152 -0
- package/server/sessions.js +341 -0
- package/server/watcher.js +200 -0
- package/web/base.css +134 -0
- package/web/board.css +561 -0
- package/web/board.js +777 -0
- package/web/favicon.js +41 -0
- package/web/favicon.svg +8 -0
- package/web/files.css +535 -0
- package/web/files.html +63 -0
- package/web/files.js +877 -0
- package/web/fonts/caveat-latin-ext.woff2 +0 -0
- package/web/fonts/caveat-latin.woff2 +0 -0
- package/web/fuzzy.js +57 -0
- package/web/index.html +112 -0
- package/web/md.js +238 -0
- package/web/mentions.js +188 -0
- package/web/refs.js +176 -0
- package/web/title.js +26 -0
- package/web/units.js +33 -0
- package/web/view.js +248 -0
package/web/board.js
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import { titleCase } from '/title.js'
|
|
2
|
+
import { initView, refit } from '/view.js'
|
|
3
|
+
import { weight, gb } from '/units.js'
|
|
4
|
+
import { setFavicon } from '/favicon.js'
|
|
5
|
+
|
|
6
|
+
// ── Statuses ───────────────────────────────────────────────────────
|
|
7
|
+
// The order is the attention priority inside a column: first whoever is waiting for you,
|
|
8
|
+
// then whoever is grinding away, backlog at the bottom. Change it here and the dashboard changes.
|
|
9
|
+
// The codes stay the database's own: only the label you see is in English.
|
|
10
|
+
const ORDER = ['ASK', 'PLANNED', 'IDLE', 'WORKING', 'PLANNING', 'BACKLOG', 'COMPLETED']
|
|
11
|
+
|
|
12
|
+
const LABEL = {
|
|
13
|
+
ASK: 'Needs answer',
|
|
14
|
+
PLANNED: 'Needs approval',
|
|
15
|
+
IDLE: 'Your turn',
|
|
16
|
+
WORKING: 'Working',
|
|
17
|
+
PLANNING: 'Planning',
|
|
18
|
+
BACKLOG: 'Backlog',
|
|
19
|
+
COMPLETED: 'Done',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const $ = (s) => document.querySelector(s)
|
|
23
|
+
const api = async (url, opts) => {
|
|
24
|
+
const r = await fetch(url, { headers: { 'content-type': 'application/json' }, ...opts })
|
|
25
|
+
const body = await r.json()
|
|
26
|
+
if (!r.ok) throw new Error(body.error || 'Something went wrong')
|
|
27
|
+
return body
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let projects = []
|
|
31
|
+
let editing = null // id of the card open in the editor, null = a new one
|
|
32
|
+
let editingCard = null // the whole card: Start needs to know whether a session exists already
|
|
33
|
+
let chosenProject = null
|
|
34
|
+
let lastSignature = ''
|
|
35
|
+
|
|
36
|
+
// The filters are the legend itself: click one to switch it off, and it stays as you left it.
|
|
37
|
+
const hidden = new Set(JSON.parse(localStorage.getItem('k0-hidden') || '["COMPLETED"]'))
|
|
38
|
+
const saveHidden = () => localStorage.setItem('k0-hidden', JSON.stringify([...hidden]))
|
|
39
|
+
|
|
40
|
+
// The weight of each session: on to begin with, switched off from the gauge at the top.
|
|
41
|
+
let showLoad = localStorage.getItem('k0-load') !== '0'
|
|
42
|
+
let machine = null // how the computer is doing right now
|
|
43
|
+
let heaviest = null // the card eating the most, so only that one gets tinted
|
|
44
|
+
|
|
45
|
+
// ── Render ─────────────────────────────────────────────────────────
|
|
46
|
+
/** Coarse-grained time: to the second it was only noise. */
|
|
47
|
+
function since(ts, now) {
|
|
48
|
+
if (!ts) return ''
|
|
49
|
+
const m = Math.floor((now - ts) / 60000)
|
|
50
|
+
if (m < 1) return 'now'
|
|
51
|
+
if (m < 60) return `${m} min`
|
|
52
|
+
const h = Math.floor(m / 60)
|
|
53
|
+
if (h < 24) return `${h} h`
|
|
54
|
+
const d = Math.floor(h / 24)
|
|
55
|
+
return d === 1 ? 'yesterday' : `${d} days`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A stable tilt: it depends on the id, so a card does not dance on every refresh. */
|
|
59
|
+
const tilt = (id) => (((id * 37) % 5) - 2) * 0.5
|
|
60
|
+
|
|
61
|
+
function postit(card, now) {
|
|
62
|
+
const el = document.createElement('article')
|
|
63
|
+
el.className = `postit ${card.status}`
|
|
64
|
+
el.dataset.id = card.id
|
|
65
|
+
el.style.transform = `rotate(${tilt(card.id)}deg)`
|
|
66
|
+
|
|
67
|
+
const alive = card.session_id && card.session_alive && !card.completed_at
|
|
68
|
+
const dead = card.session_id && !card.session_alive && !card.completed_at
|
|
69
|
+
if (alive) el.title = 'Double-click to bring its terminal up front'
|
|
70
|
+
|
|
71
|
+
// On a finished card there is nothing left to edit: the bin takes the pencil's place.
|
|
72
|
+
const corner = card.completed_at
|
|
73
|
+
? { icon: ICON.del, label: 'Delete', cls: 'corner del', act: () => remove(card) }
|
|
74
|
+
: { icon: ICON.edit, label: 'Edit', cls: 'corner', act: () => openEditor(card) }
|
|
75
|
+
|
|
76
|
+
el.innerHTML = `
|
|
77
|
+
<button class="${corner.cls}" title="${corner.label}" aria-label="${corner.label}">${corner.icon}</button>
|
|
78
|
+
<div class="head"><span class="tag">${esc(card.project_name)}</span>${gitChip(
|
|
79
|
+
card.git,
|
|
80
|
+
// A session with a worktree of its own also has files of its own: that is the directory
|
|
81
|
+
// to open. But only while the worktree really exists — `own` comes from the server,
|
|
82
|
+
// which has just read that directory's git state.
|
|
83
|
+
card.git?.own ? card.work_path : card.project_path,
|
|
84
|
+
card.id
|
|
85
|
+
)}${loadChip(card)}</div>
|
|
86
|
+
<div class="title">${esc(card.title)}</div>
|
|
87
|
+
${card.description ? `<div class="desc">${esc(card.description)}</div>` : ''}
|
|
88
|
+
${card.prompt ? `<div class="prompt">${esc(card.prompt)}</div>` : ''}
|
|
89
|
+
<div class="foot">
|
|
90
|
+
${dead ? '<span class="dead">session closed</span>' : ''}
|
|
91
|
+
<span class="since" title="${LABEL[card.status]} for ${since(card.status_since, now)}">${since(card.status_since, now)}</span>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="actions"></div>`
|
|
94
|
+
|
|
95
|
+
// The only way to open the editor: clicking the card itself no longer does anything.
|
|
96
|
+
el.querySelector('.corner').onclick = (e) => {
|
|
97
|
+
e.stopPropagation()
|
|
98
|
+
corner.act()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const actions = el.querySelector('.actions')
|
|
102
|
+
const btn = (text, fn, title) => {
|
|
103
|
+
const b = document.createElement('button')
|
|
104
|
+
b.textContent = text
|
|
105
|
+
if (title) b.title = title
|
|
106
|
+
b.onclick = (e) => {
|
|
107
|
+
e.stopPropagation()
|
|
108
|
+
fn()
|
|
109
|
+
}
|
|
110
|
+
actions.append(b)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (card.completed_at) {
|
|
114
|
+
btn('Reopen', () => setCompleted(card.id, false))
|
|
115
|
+
} else if (!card.session_id) {
|
|
116
|
+
btn('Start', () => start(card.id, 'start'))
|
|
117
|
+
} else {
|
|
118
|
+
if (dead) btn('Resume', () => start(card.id, 'resume'))
|
|
119
|
+
// An idea in the backlog cannot be "done": it never started.
|
|
120
|
+
btn('Done', () => setCompleted(card.id, true), 'close this job and its terminal')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The git mark is a link: a double click on it would open two tabs and bring the terminal to
|
|
124
|
+
// the front into the bargain. Double clicks do not reach it.
|
|
125
|
+
el.querySelector('.git')?.addEventListener('dblclick', (e) => e.stopPropagation())
|
|
126
|
+
|
|
127
|
+
// Double click: back to this session's terminal, wherever it has ended up.
|
|
128
|
+
el.ondblclick = (e) => {
|
|
129
|
+
if (!alive) return
|
|
130
|
+
e.preventDefault()
|
|
131
|
+
focusTerminal(card.id)
|
|
132
|
+
}
|
|
133
|
+
return el
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Few icons, all in the same stroke: the pencil opens the editor, the bin throws away, the
|
|
137
|
+
// "+" gives birth to a card. The same "+" as the one in the bar at the top.
|
|
138
|
+
const ICON = {
|
|
139
|
+
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>',
|
|
140
|
+
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>',
|
|
141
|
+
plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const esc = (s) =>
|
|
145
|
+
String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
|
|
146
|
+
|
|
147
|
+
// ── The git mark ───────────────────────────────────────────────────
|
|
148
|
+
// One symbol, on the card and above the column: what is needed is to see at a glance whether
|
|
149
|
+
// anything is still outstanding. The rest — which branch, how many files, how many commits, and
|
|
150
|
+
// how many of those commits belong to this session — is read by hovering over it, the way the
|
|
151
|
+
// time at the bottom of a card already works.
|
|
152
|
+
const GIT_ICON = {
|
|
153
|
+
// Things that are nowhere yet: the filled dot editors use.
|
|
154
|
+
dirty: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="5.5"/></svg>',
|
|
155
|
+
// Safe locally, but not on the remote.
|
|
156
|
+
push: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V6M6 12l6-6 6 6"/></svg>',
|
|
157
|
+
// All saved: it is there, but it has to disappear into the background.
|
|
158
|
+
ok: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12.5l5.5 5.5L20 7"/></svg>',
|
|
159
|
+
// Where there is no git there is nothing to say: the lens still leads to the files.
|
|
160
|
+
files: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4.2-4.2"/></svg>',
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
164
|
+
|
|
165
|
+
function gitDetail(g) {
|
|
166
|
+
const bits = [g.where ? `worktree ${g.where}` : g.branch || 'detached HEAD']
|
|
167
|
+
bits.push(g.dirty ? `${plural(g.dirty, 'file')} not committed` : 'nothing to commit')
|
|
168
|
+
// With no remote configured there is no point talking about pushing: there is simply no push.
|
|
169
|
+
if (!g.remote) bits.push('no remote')
|
|
170
|
+
else if (g.unpushed > 0) {
|
|
171
|
+
const mine = g.own ? ', all from this session' : g.mine > 0 ? `, ${g.mine} from this session` : ''
|
|
172
|
+
bits.push(`${plural(g.unpushed, 'commit')} not pushed${mine}`)
|
|
173
|
+
} else bits.push('everything pushed')
|
|
174
|
+
return bits.join(' · ')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The git mark is also the door to the files: clicking it opens the viewer in another tab, and
|
|
179
|
+
* the board stays where it is. Where there is no mark — a repository without git, or git that
|
|
180
|
+
* has not answered yet — a lens goes in its place, so every card and every column still leads
|
|
181
|
+
* in.
|
|
182
|
+
*/
|
|
183
|
+
function gitChip(g, repo, card) {
|
|
184
|
+
const to = esc(`/files.html?repo=${encodeURIComponent(repo)}${card ? `&card=${card}` : ''}`)
|
|
185
|
+
const open = 'click to see the files'
|
|
186
|
+
if (!g) return `<a class="git none" href="${to}" target="_blank" title="${open}">${GIT_ICON.files}</a>`
|
|
187
|
+
const kind = g.dirty > 0 ? 'dirty' : g.unpushed > 0 ? 'push' : 'ok'
|
|
188
|
+
const n = kind === 'dirty' ? g.dirty : kind === 'push' ? g.unpushed : ''
|
|
189
|
+
return `<a class="git ${kind}" href="${to}" target="_blank" title="${esc(gitDetail(g))} · ${open}">${
|
|
190
|
+
GIT_ICON[kind]
|
|
191
|
+
}${n}</a>`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── What a session weighs ────────────────────────────────────────────
|
|
195
|
+
/**
|
|
196
|
+
* How much this session eats, counting everything it has opened underneath itself. The detail —
|
|
197
|
+
* what is doing the eating in there — is read by hovering, and it is the answer to the real
|
|
198
|
+
* question: why does this terminal weigh so much.
|
|
199
|
+
*/
|
|
200
|
+
function loadChip(card) {
|
|
201
|
+
if (!showLoad || !card.load) return ''
|
|
202
|
+
const l = card.load
|
|
203
|
+
const bits = l.top.map((t) => `${t.name} ${Math.round(t.rss / 1048576)} MB`)
|
|
204
|
+
if (l.cpu !== null) bits.push(`${Math.round(l.cpu * 100)}% CPU`)
|
|
205
|
+
bits.push(`${l.procs} process${l.procs === 1 ? '' : 'es'}`)
|
|
206
|
+
// Red on one only, and only while the machine is struggling: if they are all red they stop
|
|
207
|
+
// saying anything, and it is not true that they all need closing.
|
|
208
|
+
const hot = machine?.pressure !== 'ok' && card.id === heaviest
|
|
209
|
+
return `<span class="load${hot ? ' hot' : ''}" title="${esc(bits.join(' · '))}">${weight(l.rss)}</span>`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Just enough to compare one round with the next: if it changes, it redraws. */
|
|
213
|
+
const gitSig = (g) => (g ? `${g.dirty}/${g.unpushed}/${g.own ? 1 : 0}/${g.mine ?? ''}` : '')
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* How long this card has been where it is. Until it has ever changed status there is nothing in
|
|
217
|
+
* `session_event` and `status_since` is empty: then it counts from when it came into existence,
|
|
218
|
+
* otherwise a card just created would end up at the bottom of its group.
|
|
219
|
+
*/
|
|
220
|
+
const freshness = (c) => c.status_since ?? c.created_at ?? 0
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The order of attention: most urgent status first, and among equal statuses whichever entered
|
|
224
|
+
* it last. It holds between the cards inside a column and — applied to the leading card —
|
|
225
|
+
* between the columns on the board.
|
|
226
|
+
*/
|
|
227
|
+
const byAttention = (a, b) =>
|
|
228
|
+
ORDER.indexOf(a.status) - ORDER.indexOf(b.status) || freshness(b) - freshness(a)
|
|
229
|
+
|
|
230
|
+
function render(data) {
|
|
231
|
+
const visible = data.cards.filter((c) => !hidden.has(c.status))
|
|
232
|
+
const board = $('#board')
|
|
233
|
+
board.textContent = ''
|
|
234
|
+
|
|
235
|
+
const columns = data.columns
|
|
236
|
+
.map((col) => ({
|
|
237
|
+
col,
|
|
238
|
+
cards: visible.filter((c) => c.project_path === col.path).sort(byAttention),
|
|
239
|
+
}))
|
|
240
|
+
.filter(({ cards }) => cards.length)
|
|
241
|
+
|
|
242
|
+
// In driving mode the columns queue up by attention too: a column is worth as much as its
|
|
243
|
+
// leading card, and since the cards are sorted already, that first one is necessarily the most
|
|
244
|
+
// urgent it has. One red is enough to bring it left; when that red moves into working, the
|
|
245
|
+
// column slides right by itself. On a tie the alphabetical order from the server stands,
|
|
246
|
+
// because `sort` is stable.
|
|
247
|
+
//
|
|
248
|
+
// But ONLY there. In driving mode you see two or three columns at a time, and bringing the most
|
|
249
|
+
// urgent one to the left is the only way to see it without going to look for it. Full screen
|
|
250
|
+
// they are all in front of you already: there is nothing to bring to anybody, and a board that
|
|
251
|
+
// reshuffles itself while you work only loses your place. With the switch off the columns stand
|
|
252
|
+
// still in alphabetical order — the server's, which is not touched here.
|
|
253
|
+
if (data.mode === 'driving') columns.sort((a, b) => byAttention(a.cards[0], b.cards[0]))
|
|
254
|
+
|
|
255
|
+
for (const { col, cards } of columns) {
|
|
256
|
+
const wrap = document.createElement('section')
|
|
257
|
+
wrap.className = 'column'
|
|
258
|
+
// The "+" next to the name: a new card on THIS repository, without picking it.
|
|
259
|
+
const add = `New card in ${esc(col.name)}`
|
|
260
|
+
wrap.innerHTML = `<h2><button class="add" title="${add}" aria-label="${add}">${ICON.plus}</button>${esc(
|
|
261
|
+
col.name
|
|
262
|
+
)}<small>${cards.length}</small>${gitChip(col.git, col.path)}</h2>`
|
|
263
|
+
wrap.querySelector('.add').onclick = () => openEditor(null, col.path)
|
|
264
|
+
for (const c of cards) wrap.append(postit(c, data.now))
|
|
265
|
+
board.append(wrap)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
board.append(repoColumn(data.projects ?? [], new Set(columns.map((c) => c.col.path))))
|
|
269
|
+
$('#empty').style.display = columns.length ? 'none' : 'grid'
|
|
270
|
+
refit() // the board changed size: the view comes back inside its limits
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The last column: the OTHER repositories, the ones with no column because they have not one
|
|
275
|
+
* card between them. Repeating the ones already on the left would be saying the same thing
|
|
276
|
+
* twice. Headings only, in the order you last used them. It is there for two things: getting
|
|
277
|
+
* into the files of a directory you are not working on, and giving birth to the first card
|
|
278
|
+
* there without having to find it in a list.
|
|
279
|
+
*/
|
|
280
|
+
function repoColumn(projects, shown) {
|
|
281
|
+
// In alphabetical order, not by recency: these are thirty rows you scan with your eye looking
|
|
282
|
+
// for a name, and in a list that reorders itself you find nothing. Recency belongs in the
|
|
283
|
+
// repository picker, where you type; not here.
|
|
284
|
+
const others = projects
|
|
285
|
+
.filter((p) => !shown.has(p.path))
|
|
286
|
+
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
|
|
287
|
+
if (!others.length) return document.createDocumentFragment()
|
|
288
|
+
|
|
289
|
+
const wrap = document.createElement('section')
|
|
290
|
+
wrap.className = 'column repos'
|
|
291
|
+
wrap.innerHTML =
|
|
292
|
+
'<h2>Others<small>' +
|
|
293
|
+
others.length +
|
|
294
|
+
'</small></h2>' +
|
|
295
|
+
others
|
|
296
|
+
.map((p) => {
|
|
297
|
+
const add = `New card in ${esc(p.name)}`
|
|
298
|
+
return `<div class="repo" data-p="${esc(p.path)}">
|
|
299
|
+
<button class="add" title="${add}" aria-label="${add}">${ICON.plus}</button>
|
|
300
|
+
<span class="name">${esc(p.name)}</span>${gitChip(p.git, p.path)}
|
|
301
|
+
</div>`
|
|
302
|
+
})
|
|
303
|
+
.join('')
|
|
304
|
+
wrap.onclick = (e) => {
|
|
305
|
+
const b = e.target.closest('.add')
|
|
306
|
+
if (b) openEditor(null, b.closest('.repo').dataset.p)
|
|
307
|
+
}
|
|
308
|
+
return wrap
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── The load gauge ─────────────────────────────────────────────────
|
|
312
|
+
/**
|
|
313
|
+
* It lives outside `render`: it updates every round even when the board does not change, which
|
|
314
|
+
* is nearly always. The colour is the kernel's judgement, not a threshold invented here: green
|
|
315
|
+
* while it says all is well, amber at the first warning, red when it is critical.
|
|
316
|
+
*/
|
|
317
|
+
function renderMachine(m, cards) {
|
|
318
|
+
const el = $('#gauge')
|
|
319
|
+
if (!m?.mem) return el.setAttribute('hidden', '') // the first sample is not in yet
|
|
320
|
+
el.removeAttribute('hidden')
|
|
321
|
+
el.className = `gauge ${m.pressure}`
|
|
322
|
+
|
|
323
|
+
const ram = Math.round((m.mem.used / m.mem.total) * 100)
|
|
324
|
+
const cpu = m.cpu === null ? '—' : `${Math.round(m.cpu * 100)}%`
|
|
325
|
+
el.innerHTML = `<i></i>RAM ${ram}% · CPU ${cpu}`
|
|
326
|
+
|
|
327
|
+
const top = cards.find((c) => c.id === heaviest)
|
|
328
|
+
const lines = [
|
|
329
|
+
`${gb(m.mem.used)} of ${gb(m.mem.total)} in use · ${gb(m.swap.used)} on swap`,
|
|
330
|
+
`${m.alive} session${m.alive === 1 ? '' : 's'} running${m.outside ? `, ${m.outside} not on the dashboard` : ''}`,
|
|
331
|
+
]
|
|
332
|
+
if (top?.load) lines.push(`heaviest: ${top.title} — ${weight(top.load.rss)}`)
|
|
333
|
+
// What is heavy and is not k0: without it, the board would take blame that belongs to Chrome.
|
|
334
|
+
if (m.others?.length) lines.push(`outside k0: ${m.others.map((o) => `${o.name} ${gb(o.rss)}`).join(' · ')}`)
|
|
335
|
+
lines.push(showLoad ? 'click to hide the weight on each card' : 'click to show the weight on each card')
|
|
336
|
+
el.title = lines.join('\n')
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function renderFilters() {
|
|
340
|
+
$('#filters').innerHTML = ORDER.map(
|
|
341
|
+
(s) =>
|
|
342
|
+
`<button data-s="${s}" aria-pressed="${!hidden.has(s)}"><i style="background:var(--${s})"></i>${LABEL[s]}</button>`
|
|
343
|
+
).join('')
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── Actions ───────────────────────────────────────────────────────
|
|
347
|
+
function toast(msg, ms = 4000) {
|
|
348
|
+
const t = $('#toast')
|
|
349
|
+
t.textContent = msg
|
|
350
|
+
t.classList.add('show')
|
|
351
|
+
clearTimeout(toast.timer)
|
|
352
|
+
toast.timer = setTimeout(() => t.classList.remove('show'), ms)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function start(id, mode) {
|
|
356
|
+
toast(mode === 'resume' ? 'Resuming the session…' : 'Opening the terminal…', 30000)
|
|
357
|
+
try {
|
|
358
|
+
const r = await api(`/api/card/${id}/${mode}`, { method: 'POST' })
|
|
359
|
+
if (!r.up) toast(`${r.name} was launched, but I didn't see it come up within 30s`)
|
|
360
|
+
else if (r.pasted) toast(`${r.name} is ready, the prompt is in the terminal: hit enter`)
|
|
361
|
+
else if (r.autoSent) toast(`${r.name} started on its own (without the Accessibility permission I can't leave the prompt waiting)`, 8000)
|
|
362
|
+
else toast(`${r.name} is running, but I couldn't write the prompt`, 8000)
|
|
363
|
+
} catch (e) {
|
|
364
|
+
toast(`Couldn't do it: ${e.message}`, 8000)
|
|
365
|
+
}
|
|
366
|
+
refresh()
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function focusTerminal(id) {
|
|
370
|
+
const r = await api(`/api/card/${id}/focus`, { method: 'POST' })
|
|
371
|
+
if (!r.ok) toast(r.error)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The house confirmation, not the browser's: it is only ever asked before throwing a card away,
|
|
376
|
+
* which is the one thing there is no coming back from. "Done" is not — that one is undone with
|
|
377
|
+
* Reopen.
|
|
378
|
+
*/
|
|
379
|
+
function ask(text) {
|
|
380
|
+
const dlg = $('#confirm')
|
|
381
|
+
return new Promise((resolve) => {
|
|
382
|
+
$('#confirm-text').textContent = text
|
|
383
|
+
const close = (answer) => {
|
|
384
|
+
dlg.close()
|
|
385
|
+
resolve(answer)
|
|
386
|
+
}
|
|
387
|
+
$('#confirm-yes').onclick = () => close(true)
|
|
388
|
+
$('#confirm-no').onclick = () => close(false)
|
|
389
|
+
dlg.onclose = () => resolve(false) // Esc is a no too; the first answer is the one that counts
|
|
390
|
+
dlg.showModal()
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function remove(card) {
|
|
395
|
+
if (!(await ask(`Delete “${card.title}”?`))) return
|
|
396
|
+
await api(`/api/card/${card.id}`, { method: 'DELETE' })
|
|
397
|
+
refresh()
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function setCompleted(id, completed) {
|
|
401
|
+
await api(`/api/card/${id}`, { method: 'PATCH', body: JSON.stringify({ completed }) })
|
|
402
|
+
refresh()
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── Editor ─────────────────────────────────────────────────────────
|
|
406
|
+
/**
|
|
407
|
+
* `presetPath` is the repository of the column you pressed "+" on: it arrives already chosen and
|
|
408
|
+
* the cursor jumps straight to the title, which is the only thing missing.
|
|
409
|
+
*/
|
|
410
|
+
function openEditor(card, presetPath = null) {
|
|
411
|
+
editing = card?.id ?? null
|
|
412
|
+
editingCard = card ?? null
|
|
413
|
+
// A repository typed by hand does not count: only a real path counts, taken from the list or
|
|
414
|
+
// handed over by the column.
|
|
415
|
+
chosenProject = card?.project_path ?? presetPath ?? null
|
|
416
|
+
$('#editor-title').textContent = card ? 'Card' : 'New card'
|
|
417
|
+
$('#f-project').value = chosenProject ? nameOf(chosenProject) : ''
|
|
418
|
+
// Session already started: the repository cannot be moved out from under Claude's feet.
|
|
419
|
+
$('#f-project').disabled = !!card?.session_id
|
|
420
|
+
$('#f-project').classList.remove('bad')
|
|
421
|
+
$('#f-title').value = card?.title ?? ''
|
|
422
|
+
$('#f-desc').value = card?.description ?? ''
|
|
423
|
+
$('#f-prompt').value = card?.prompt ?? ''
|
|
424
|
+
$('#f-delete').style.display = card ? '' : 'none'
|
|
425
|
+
// A live session is not restarted: there is only saving to do there.
|
|
426
|
+
$('#f-start').style.display = card?.session_alive ? 'none' : ''
|
|
427
|
+
$('#f-start').textContent = card?.session_id ? 'Resume' : 'Start'
|
|
428
|
+
$('#f-project-list').hidden = true
|
|
429
|
+
$('#editor').showModal()
|
|
430
|
+
;(chosenProject ? $('#f-title') : $('#f-project')).focus()
|
|
431
|
+
loadProjects() // the repository order changes constantly: re-read on every opening
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const nameOf = (p) => projects.find((x) => x.path === p)?.name ?? p
|
|
435
|
+
|
|
436
|
+
let hits = [] // the repositories shown in the list right now
|
|
437
|
+
let active = 0 // where you are with the arrows: the row Enter picks
|
|
438
|
+
|
|
439
|
+
function chooseProject(path) {
|
|
440
|
+
chosenProject = path
|
|
441
|
+
$('#f-project').value = nameOf(path)
|
|
442
|
+
$('#f-project').classList.remove('bad')
|
|
443
|
+
$('#f-project-list').hidden = true
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** With a repository already chosen the whole list is shown: that is where you change it. */
|
|
447
|
+
const listQuery = () => (chosenProject ? '' : $('#f-project').value)
|
|
448
|
+
|
|
449
|
+
const matching = (query) => {
|
|
450
|
+
const q = query.trim().toLowerCase()
|
|
451
|
+
return projects.filter((p) => p.name.toLowerCase().includes(q)).slice(0, 40)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function renderProjectList(query = '') {
|
|
455
|
+
hits = matching(query)
|
|
456
|
+
// Redrawing must not lose the highlight: it only moves if it has fallen outside.
|
|
457
|
+
active = Math.max(0, Math.min(active, hits.length - 1))
|
|
458
|
+
const list = $('#f-project-list')
|
|
459
|
+
list.innerHTML = hits.length
|
|
460
|
+
? hits
|
|
461
|
+
.map(
|
|
462
|
+
(p, i) =>
|
|
463
|
+
`<div data-p="${esc(p.path)}" class="${p.path === chosenProject ? 'on' : ''}${
|
|
464
|
+
i === active ? ' active' : ''
|
|
465
|
+
}">${esc(p.name)}${p.last_used ? `<em>${since(p.last_used, Date.now())}</em>` : ''}</div>`
|
|
466
|
+
)
|
|
467
|
+
.join('')
|
|
468
|
+
: '<div style="color:var(--muted)">no repository with that name</div>'
|
|
469
|
+
list.hidden = false
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** On opening, the arrows start from the repository already chosen, not from the top. */
|
|
473
|
+
function openProjectList() {
|
|
474
|
+
const found = matching(listQuery()).findIndex((p) => p.path === chosenProject)
|
|
475
|
+
active = Math.max(0, found)
|
|
476
|
+
renderProjectList(listQuery())
|
|
477
|
+
$('#f-project-list').children[active]?.scrollIntoView({ block: 'nearest' })
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* On leaving the field: either there is a real repository, or there is nothing. If what you
|
|
482
|
+
* typed identifies exactly one, it takes it; otherwise the field empties, instead of sitting
|
|
483
|
+
* there with a name that does not exist.
|
|
484
|
+
*/
|
|
485
|
+
function resolveProject() {
|
|
486
|
+
const el = $('#f-project')
|
|
487
|
+
if (chosenProject) {
|
|
488
|
+
el.value = nameOf(chosenProject)
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
const q = el.value.trim().toLowerCase()
|
|
492
|
+
const exact = q && projects.find((p) => p.name.toLowerCase() === q)
|
|
493
|
+
const found = q ? projects.filter((p) => p.name.toLowerCase().includes(q)) : []
|
|
494
|
+
if (exact) return chooseProject(exact.path)
|
|
495
|
+
if (found.length === 1) return chooseProject(found[0].path)
|
|
496
|
+
el.value = ''
|
|
497
|
+
el.classList.remove('bad')
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Returns the saved card, or null if it could not be saved. */
|
|
501
|
+
async function save() {
|
|
502
|
+
if (!$('#editor-form').reportValidity()) return null
|
|
503
|
+
const title = titleCase($('#f-title').value).replace(/-+$/, '')
|
|
504
|
+
// The repository only counts if picked from the list: typed by hand it does not exist.
|
|
505
|
+
if (!chosenProject) {
|
|
506
|
+
$('#f-project').classList.add('bad')
|
|
507
|
+
$('#f-project').focus()
|
|
508
|
+
toast('Pick a repository from the list')
|
|
509
|
+
return null
|
|
510
|
+
}
|
|
511
|
+
if (!title) {
|
|
512
|
+
$('#f-title').focus()
|
|
513
|
+
toast('A title is required')
|
|
514
|
+
return null
|
|
515
|
+
}
|
|
516
|
+
const payload = {
|
|
517
|
+
title,
|
|
518
|
+
description: $('#f-desc').value.trim(),
|
|
519
|
+
project_path: chosenProject,
|
|
520
|
+
prompt: $('#f-prompt').value,
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
const saved = editing
|
|
524
|
+
? await api(`/api/card/${editing}`, { method: 'PATCH', body: JSON.stringify(payload) })
|
|
525
|
+
: await api('/api/card', { method: 'POST', body: JSON.stringify(payload) })
|
|
526
|
+
$('#editor').close()
|
|
527
|
+
refresh()
|
|
528
|
+
return saved
|
|
529
|
+
} catch (e) {
|
|
530
|
+
toast(e.message)
|
|
531
|
+
return null
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* What the four buttons say when you hover over them. The name of the mode on its own would
|
|
537
|
+
* not be enough: the part that really matters — the machine stays awake with the lid closed —
|
|
538
|
+
* is exactly the part that can be missing, and a control that promises something it does not
|
|
539
|
+
* do is worse than one that is switched off. The keys are the `reason` the server sends.
|
|
540
|
+
*/
|
|
541
|
+
const MODE_STATE = {
|
|
542
|
+
sleep: 'Sleep — the machine may fall asleep and stop what it is doing',
|
|
543
|
+
full: 'Holds even with the lid closed',
|
|
544
|
+
battery: 'On, but sleeping is allowed again: the battery is nearly flat',
|
|
545
|
+
'no-permission': 'On, but only with the lid open. Run `k0-board install` to cover the lid too',
|
|
546
|
+
unsupported: 'On, but this system does not let k0 control what the lid does',
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** What each one does: the title of the three buttons that are not the lit one. */
|
|
550
|
+
const MODE_WHAT = {
|
|
551
|
+
sleep: 'Sleep — let the machine fall asleep as it normally would',
|
|
552
|
+
away: 'Away — you are not here, but the machine keeps working. The screen may sleep',
|
|
553
|
+
nerd: 'Nerd — the machine and the screen both stay on',
|
|
554
|
+
driving: 'Driving — screen always on, and everything in large type',
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The four modes, put on the page. Two separate things: which button is lit — that is what
|
|
559
|
+
* you asked for — and `data-partial`, which is how well it is managing.
|
|
560
|
+
*
|
|
561
|
+
* The large type stays hung on `data-driving` on the `<html>` element, which is where the
|
|
562
|
+
* stylesheet already looks for it: no measurement is touched here, so there are never two
|
|
563
|
+
* versions of one.
|
|
564
|
+
*/
|
|
565
|
+
function setMode(mode, reason) {
|
|
566
|
+
const html = document.documentElement
|
|
567
|
+
const large = mode === 'driving'
|
|
568
|
+
// Only when it really changes: rewriting it every round would wake the layout for nothing.
|
|
569
|
+
if (large !== (html.dataset.driving === '')) {
|
|
570
|
+
if (large) html.dataset.driving = ''
|
|
571
|
+
else delete html.dataset.driving
|
|
572
|
+
}
|
|
573
|
+
for (const b of document.querySelectorAll('#modes button')) {
|
|
574
|
+
const its = b.dataset.mode === mode
|
|
575
|
+
b.setAttribute('aria-checked', its ? 'true' : 'false')
|
|
576
|
+
if (its && reason && reason !== 'full' && reason !== 'sleep') b.dataset.partial = ''
|
|
577
|
+
else delete b.dataset.partial
|
|
578
|
+
b.title = its ? (MODE_STATE[reason] ?? MODE_WHAT[mode]) : MODE_WHAT[b.dataset.mode]
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ── The loop ──────────────────────────────────────────────────────
|
|
583
|
+
async function refresh() {
|
|
584
|
+
try {
|
|
585
|
+
const data = await api('/api/board')
|
|
586
|
+
// The icon in the tab is tinted like the one in the menu bar: red if a session is waiting
|
|
587
|
+
// for you, blue if they are grinding away, outline if everything is still.
|
|
588
|
+
setFavicon(data.urgent)
|
|
589
|
+
// The mode is the server's to hold, because it shares it with the menu bar icon: here we
|
|
590
|
+
// simply obey, even when it was changed over there. The `reason` comes with it: if the lid
|
|
591
|
+
// is not covered, the lit button has to say so instead of pretending otherwise.
|
|
592
|
+
|
|
593
|
+
setMode(data.mode, data.reason)
|
|
594
|
+
machine = data.machine ?? null
|
|
595
|
+
// What is eating most right now: it is what tints that one red and nobody else.
|
|
596
|
+
heaviest =
|
|
597
|
+
data.cards.filter((c) => c.load).sort((a, b) => b.load.rss - a.load.rss)[0]?.id ?? null
|
|
598
|
+
renderMachine(machine, data.cards)
|
|
599
|
+
|
|
600
|
+
// Redraw only if something really changed, so the editor and the focus stay where they are.
|
|
601
|
+
// The git state is inside the signature: without it, the mark would stay the one from the
|
|
602
|
+
// first round. The weight goes in already rounded — see `weight` — otherwise the board
|
|
603
|
+
// would redraw on every sample of the load.
|
|
604
|
+
const sig = JSON.stringify([
|
|
605
|
+
showLoad,
|
|
606
|
+
// The column order depends on it: changing mode means the board has to be rebuilt.
|
|
607
|
+
data.mode,
|
|
608
|
+
// Not `heaviest` as it is: on a calm machine the heaviest changes constantly without
|
|
609
|
+
// anything changing on screen, and it would redraw for nothing.
|
|
610
|
+
machine?.pressure !== 'ok' ? heaviest : null,
|
|
611
|
+
data.cards.map((c) => [
|
|
612
|
+
c.id,
|
|
613
|
+
c.status,
|
|
614
|
+
c.session_alive,
|
|
615
|
+
c.title,
|
|
616
|
+
c.description,
|
|
617
|
+
c.prompt,
|
|
618
|
+
c.project_path,
|
|
619
|
+
gitSig(c.git),
|
|
620
|
+
c.load ? weight(c.load.rss) : '',
|
|
621
|
+
]),
|
|
622
|
+
data.columns.map((col) => [col.path, gitSig(col.git)]),
|
|
623
|
+
(data.projects ?? []).map((p) => [p.path, gitSig(p.git)]),
|
|
624
|
+
])
|
|
625
|
+
if (sig !== lastSignature) {
|
|
626
|
+
lastSignature = sig
|
|
627
|
+
render(data)
|
|
628
|
+
}
|
|
629
|
+
} catch {
|
|
630
|
+
/* server down: we try again next round */
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** The repository list, re-read every time the editor opens: the order goes stale fast. */
|
|
635
|
+
async function loadProjects() {
|
|
636
|
+
try {
|
|
637
|
+
projects = await api('/api/projects')
|
|
638
|
+
if (!$('#f-project-list').hidden) renderProjectList(listQuery())
|
|
639
|
+
} catch {
|
|
640
|
+
/* an old list: better than no list */
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function boot() {
|
|
645
|
+
initView() // before anything else: render() assumes the view is already there
|
|
646
|
+
renderFilters()
|
|
647
|
+
await loadProjects()
|
|
648
|
+
|
|
649
|
+
$('#filters').onclick = (e) => {
|
|
650
|
+
const b = e.target.closest('button')
|
|
651
|
+
if (!b) return
|
|
652
|
+
const s = b.dataset.s
|
|
653
|
+
hidden.has(s) ? hidden.delete(s) : hidden.add(s)
|
|
654
|
+
saveHidden()
|
|
655
|
+
renderFilters()
|
|
656
|
+
lastSignature = ''
|
|
657
|
+
refresh()
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// The gauge is always there; clicking it switches the weights on the cards off and on again,
|
|
661
|
+
// and the choice stays as you left it.
|
|
662
|
+
$('#gauge').onclick = () => {
|
|
663
|
+
showLoad = !showLoad
|
|
664
|
+
localStorage.setItem('k0-load', showLoad ? '1' : '0')
|
|
665
|
+
lastSignature = ''
|
|
666
|
+
refresh()
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// The four buttons are one control: the click is caught on the container, so there are not
|
|
670
|
+
// four handlers saying the same thing. It answers straight away without waiting — a click
|
|
671
|
+
// has to answer — but the last word belongs to the server, which by then has talked to the
|
|
672
|
+
// operating system and is the only one who knows whether the lid is really covered.
|
|
673
|
+
$('#modes').onclick = async (e) => {
|
|
674
|
+
const b = e.target.closest('button[data-mode]')
|
|
675
|
+
if (!b) return
|
|
676
|
+
const mode = b.dataset.mode
|
|
677
|
+
// Clicking the lit one again does nothing, and that is the point: you leave a mode, you do
|
|
678
|
+
// not switch it off. Without this, every re-click would be a round trip for nothing.
|
|
679
|
+
if (b.getAttribute('aria-checked') === 'true') return
|
|
680
|
+
const before = $('#modes button[aria-checked="true"]')?.dataset.mode
|
|
681
|
+
setMode(mode, mode === 'sleep' ? 'sleep' : 'full')
|
|
682
|
+
try {
|
|
683
|
+
const res = await api('/api/mode', { method: 'POST', body: JSON.stringify({ mode }) })
|
|
684
|
+
setMode(res.mode, res.reason)
|
|
685
|
+
if (res.reason === 'no-permission') toast(MODE_STATE['no-permission'])
|
|
686
|
+
if (res.reason === 'unsupported') toast(MODE_STATE.unsupported)
|
|
687
|
+
} catch (err) {
|
|
688
|
+
setMode(before, null) // it did not go through: go back instead of lying
|
|
689
|
+
toast(err.message)
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
$('#new-card').onclick = () => openEditor(null)
|
|
694
|
+
$('#f-close').onclick = () => $('#editor').close()
|
|
695
|
+
$('#editor-form').onsubmit = (e) => {
|
|
696
|
+
e.preventDefault()
|
|
697
|
+
save()
|
|
698
|
+
}
|
|
699
|
+
// Save and go: the same thing the button on the card does, without going through it.
|
|
700
|
+
$('#f-start').onclick = async () => {
|
|
701
|
+
const saved = await save()
|
|
702
|
+
if (!saved) return
|
|
703
|
+
start(saved.id, editingCard?.session_id ? 'resume' : 'start')
|
|
704
|
+
}
|
|
705
|
+
// Esc with the list open closes only the list: the dialog leaves on the second Esc.
|
|
706
|
+
$('#editor').oncancel = (e) => {
|
|
707
|
+
if ($('#f-project-list').hidden) return
|
|
708
|
+
e.preventDefault()
|
|
709
|
+
$('#f-project-list').hidden = true
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// The title is normalised as you type. The cursor is put back by normalising the part before
|
|
713
|
+
// it as well: that way you can correct in the middle of a word without being thrown to the
|
|
714
|
+
// end of the field.
|
|
715
|
+
$('#f-title').oninput = (e) => {
|
|
716
|
+
const el = e.target
|
|
717
|
+
const caret = titleCase(el.value.slice(0, el.selectionStart)).length
|
|
718
|
+
el.value = titleCase(el.value)
|
|
719
|
+
el.setSelectionRange(caret, caret)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// It opens when you click or type in it, not as soon as the field takes focus: otherwise it
|
|
723
|
+
// would cover the rest of the form every time you open the editor.
|
|
724
|
+
$('#f-project').onclick = () => openProjectList()
|
|
725
|
+
$('#f-project').oninput = (e) => {
|
|
726
|
+
// Typing undoes the choice: only a repository picked from the list counts.
|
|
727
|
+
chosenProject = null
|
|
728
|
+
active = 0
|
|
729
|
+
e.target.classList.remove('bad')
|
|
730
|
+
renderProjectList(e.target.value)
|
|
731
|
+
}
|
|
732
|
+
$('#f-project').onkeydown = (e) => {
|
|
733
|
+
const list = $('#f-project-list')
|
|
734
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
735
|
+
e.preventDefault()
|
|
736
|
+
if (list.hidden) return openProjectList() // the first arrow only opens it
|
|
737
|
+
if (!hits.length) return
|
|
738
|
+
active = (active + (e.key === 'ArrowDown' ? 1 : hits.length - 1)) % hits.length
|
|
739
|
+
renderProjectList(listQuery())
|
|
740
|
+
list.children[active]?.scrollIntoView({ block: 'nearest' })
|
|
741
|
+
return
|
|
742
|
+
}
|
|
743
|
+
if (e.key !== 'Enter') return
|
|
744
|
+
e.preventDefault() // no form submit: here Enter takes the highlighted row
|
|
745
|
+
if (hits[active]) {
|
|
746
|
+
chooseProject(hits[active].path)
|
|
747
|
+
$('#f-title').focus()
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
// The delay lets the list's mousedown win, which is what picks a row.
|
|
751
|
+
$('#f-project').onblur = () =>
|
|
752
|
+
setTimeout(() => {
|
|
753
|
+
$('#f-project-list').hidden = true
|
|
754
|
+
resolveProject()
|
|
755
|
+
}, 150)
|
|
756
|
+
$('#f-project-list').onmousedown = (e) => {
|
|
757
|
+
const row = e.target.closest('[data-p]')
|
|
758
|
+
if (!row) return
|
|
759
|
+
chooseProject(row.dataset.p)
|
|
760
|
+
$('#f-title').focus()
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
$('#f-delete').onclick = async () => {
|
|
764
|
+
if (!editing || !(await ask(`Delete “${$('#f-title').value}”?`))) return
|
|
765
|
+
await api(`/api/card/${editing}`, { method: 'DELETE' })
|
|
766
|
+
$('#editor').close()
|
|
767
|
+
refresh()
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
refresh()
|
|
771
|
+
setInterval(refresh, 1000)
|
|
772
|
+
|
|
773
|
+
// A direct link: k0 opened with the new card already in front of you.
|
|
774
|
+
if (location.hash === '#new' || location.hash === '#nuovo') openEditor(null)
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
boot()
|