claude-code-kanban 7.12.0 → 7.13.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/hub-app.json +1 -1
- package/lib/worktrees.js +27 -9
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/visualize/SKILL.md +3 -2
- package/plugin/plugins/claude-code-kanban/skills/visualize/template.html +26 -8
- package/public/app.js +321 -77
- package/public/style.css +110 -3
package/hub-app.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"icon": "columns",
|
|
6
6
|
"run": { "entry": "server.js", "defaultPort": 3541 },
|
|
7
7
|
"publishes": ["session.changed"],
|
|
8
|
-
"keys": { "keeps": ["ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s", "ctrl+shift+d", "ctrl+shift+k", "ctrl+shift+p", "ctrl+shift+z"] },
|
|
8
|
+
"keys": { "keeps": ["ctrl+alt+1", "ctrl+alt+2", "ctrl+alt+3", "ctrl+alt+4", "ctrl+alt+5", "ctrl+alt+6", "ctrl+alt+7", "ctrl+alt+8", "ctrl+alt+9", "ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s", "ctrl+shift+d", "ctrl+shift+k", "ctrl+shift+l", "ctrl+shift+m", "ctrl+shift+p", "ctrl+shift+z"] },
|
|
9
9
|
"plugin": {
|
|
10
10
|
"marketplace": "plugin",
|
|
11
11
|
"name": "claude-code-kanban",
|
package/lib/worktrees.js
CHANGED
|
@@ -34,6 +34,10 @@ function fromGitFile(dir, text) {
|
|
|
34
34
|
return { repo: path.resolve(dir, sub[1], ...sub[2].split(/[/\\]modules[/\\]/)), name: sub[3] };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function pick(entry) {
|
|
38
|
+
return { repo: entry.repo, name: entry.name };
|
|
39
|
+
}
|
|
40
|
+
|
|
37
41
|
function fromPathShape(dir) {
|
|
38
42
|
const m = CLAUDE_WORKTREE_RE.exec(dir);
|
|
39
43
|
return m ? { repo: m[1], name: m[2] } : null;
|
|
@@ -67,20 +71,34 @@ function createWorktreeStore({ load, save, read = readGitFile, now = Date.now, d
|
|
|
67
71
|
});
|
|
68
72
|
}
|
|
69
73
|
|
|
74
|
+
// Walks up from `dir` the way git discovers its repo, so a session started in a subfolder of a
|
|
75
|
+
// worktree resolves too. A saved ancestor answers for a worktree whose checkout is gone; the
|
|
76
|
+
// claude path shape answers for one that was never seen.
|
|
77
|
+
function lookUp(dir) {
|
|
78
|
+
for (let d = dir, up; ; d = up) {
|
|
79
|
+
try {
|
|
80
|
+
return fromGitFile(d, read(d));
|
|
81
|
+
} catch (e) {
|
|
82
|
+
// An ordinary checkout's `.git` is a directory, so the read throws EISDIR. That is the
|
|
83
|
+
// answer, and it costs one syscall instead of a stat followed by a read.
|
|
84
|
+
if (e.code !== 'ENOENT') return null;
|
|
85
|
+
}
|
|
86
|
+
const shaped = fromPathShape(d);
|
|
87
|
+
if (shaped) return shaped;
|
|
88
|
+
up = path.dirname(d);
|
|
89
|
+
if (up === d) return null;
|
|
90
|
+
const hit = hits.get(up);
|
|
91
|
+
if (hit) return pick(hit);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
70
95
|
function resolve(dir) {
|
|
71
96
|
if (!dir) return null;
|
|
72
97
|
const hit = hits.get(dir);
|
|
73
|
-
if (hit) return
|
|
98
|
+
if (hit) return pick(hit);
|
|
74
99
|
if (misses.has(dir)) return null;
|
|
75
100
|
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
worktree = fromGitFile(dir, read(dir));
|
|
79
|
-
} catch (e) {
|
|
80
|
-
// An ordinary checkout's `.git` is a directory, so the read throws EISDIR. That is the
|
|
81
|
-
// answer, and it costs one syscall instead of a stat followed by a read.
|
|
82
|
-
if (e.code === 'ENOENT') worktree = fromPathShape(dir);
|
|
83
|
-
}
|
|
101
|
+
const worktree = lookUp(dir);
|
|
84
102
|
|
|
85
103
|
if (!worktree) {
|
|
86
104
|
misses.add(dir);
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.9",
|
|
4
4
|
"description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, board moves and reviews delivered to the session, and skills to drive the board from a session"
|
|
5
5
|
}
|
|
@@ -17,7 +17,7 @@ The page is a document to read: its text and static exhibits are in the HTML, an
|
|
|
17
17
|
1. `.kicker`, then `<title>` and `<h1>`: a name of 2 to 5 words. `.sub`: the question, in one line.
|
|
18
18
|
2. `.lead`: the answer in 1 or 2 sentences, the pick in `<b>`.
|
|
19
19
|
3. Sections. Each `<h2>` is a claim that can be false ("A breaker alone trips again on release", not "Breaker"), with one exhibit and at most 2 short paragraphs under it.
|
|
20
|
-
4. Close with what to do next and `.sources
|
|
20
|
+
4. Close with what to do next and `.sources`, one `li` per source. Each `[n]` in the text is an `a.cite` to its row.
|
|
21
21
|
3. **Invent each exhibit.** Ask what form makes this claim obvious at a glance, then build that, the template's blocks or something new: a slope chart, an annotated timeline, small multiples, a cutaway, a ranked strip, a map. The Blocks table is a palette of ready parts, not a menu to choose from. Before you draw an SVG, read the matching guide in the `show` skill — [chart](../show/references/chart.md), [diagram](../show/references/diagram.md) and its type file, [mockup](../show/references/mockup.md) — for patterns: shapes, arrows and labels. Their sizes and directions are for cards; on a page, the Principles below set them. Label masks fill with `--color-bg-subtle`, the figure's background. When the source or the request has a value the reader would want to change, or states one after another, the exhibit can be interactive: a slider or toggle that computes the figure again, a stepper through states, or a model that runs over time. Use the simplest one that proves the claim, and follow [Interactive exhibits](#interactive-exhibits). Never add a control on an input you made up: a claim that rests on fixed facts gets a static exhibit.
|
|
22
22
|
4. **Check.** Render the page in light and in dark, and look at each render, not the source. Done when no text is clipped, no arrow crosses a label, `scrollWidth` equals `clientWidth`, and, when the topic comes from a source such as a file or a conversation, every section and number of it is named where it landed on the page or reported to the user as left out. For an interactive exhibit, also: no console errors, and the numbers the text states are the ones the exhibit shows at its default settings.
|
|
23
23
|
5. **Show.** When `$CCK_URL` is set, the session runs in the cck board: run `claude-code-kanban pane add <page> --title '<h1>'` and tell the user the page waits in that tab. Else give the user the page path.
|
|
@@ -62,6 +62,7 @@ Ready parts in the template's CSS; its body has one of each.
|
|
|
62
62
|
| Callout | `div.callout` | A warning |
|
|
63
63
|
| Steps | `ol.steps` | What to do next |
|
|
64
64
|
| Tag, dot, legend | `span.tag`, `span.dot`, `.legend > span > i` | Labels and keys |
|
|
65
|
-
| Sources |
|
|
65
|
+
| Sources | `.sources > ol > li#src-n > code + span`, then `p` | Where each claim comes from: one source per `li`, numbered in order (CSS prints `[n]`), the path or `a` link, and in `span` what it backs; a closing line, such as how estimates are marked, goes in the `p` |
|
|
66
|
+
| Citation | `a.cite[href="#src-n"]` holding `[n]` | An in-text marker that jumps to its source row |
|
|
66
67
|
|
|
67
68
|
`data-tone` takes `accent`, `info`, `success`, `warning`, `danger` or `muted` on any block, legend keys included.
|
|
@@ -111,7 +111,8 @@ td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
|
111
111
|
td.c { text-align: center; white-space: nowrap; }
|
|
112
112
|
td.yes { --tone: var(--color-success); } td.no { --tone: var(--color-danger); } td.part { --tone: var(--color-warning); }
|
|
113
113
|
td.yes, td.no, td.part { color: var(--tone); font-weight: 600; background: color-mix(in srgb, var(--tone) 13%, transparent); }
|
|
114
|
-
td.yes::before { content: "
|
|
114
|
+
td.yes::before, td.part::before, td.no::before { content: ""; display: inline-block; width: 8px; height: 8px; border: 1.5px solid; border-radius: 50%; margin-right: 6px; vertical-align: 1px; }
|
|
115
|
+
td.yes::before { background: currentColor; } td.part::before { background: linear-gradient(90deg, currentColor 50%, transparent 50%); }
|
|
115
116
|
tr[data-tone] > td:first-child { box-shadow: inset 3px 0 0 var(--tone); background: color-mix(in srgb, var(--tone) 10%, transparent); }
|
|
116
117
|
|
|
117
118
|
figure { margin: 0 0 20px; padding: 24px; border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-bg-subtle) radial-gradient(color-mix(in srgb, var(--color-text) 9%, transparent) 1px, transparent 1.5px) 0 0 / 18px 18px; }
|
|
@@ -131,9 +132,18 @@ ol.steps > li { counter-increment: step; position: relative; padding: 2px 0 20px
|
|
|
131
132
|
ol.steps > li::before { content: counter(step); position: absolute; left: 0; top: 0; width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font: 600 13px var(--font-mono); color: var(--color-bg); background: var(--color-accent); }
|
|
132
133
|
ol.steps > li:not(:last-child)::after { content: ""; position: absolute; left: 15px; top: 36px; bottom: 4px; width: 1px; background: var(--color-border); }
|
|
133
134
|
|
|
134
|
-
.sources { margin-top: 88px; padding-top: 16px; border-top: 1px solid var(--color-border); font:
|
|
135
|
-
.sources
|
|
136
|
-
.sources
|
|
135
|
+
.sources { margin-top: 88px; padding-top: 16px; border-top: 1px solid var(--color-border); font: 13px/20px var(--font-sans); color: var(--color-text-muted); }
|
|
136
|
+
.sources::before { content: "Sources"; display: block; font: 500 12px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; margin: 0 0 10px; }
|
|
137
|
+
.sources ol { list-style: none; counter-reset: src; padding: 0; margin: 0; }
|
|
138
|
+
.sources li { counter-increment: src; position: relative; max-width: none; margin: 0; padding: 4px 8px 4px calc(5ch + 8px); border-radius: 6px; scroll-margin-top: 24px; }
|
|
139
|
+
.sources li::before { content: "[" counter(src) "]"; position: absolute; left: 8px; font: 12.5px/20px var(--font-mono); }
|
|
140
|
+
.sources li:target { background: color-mix(in srgb, var(--color-accent) 12%, transparent); }
|
|
141
|
+
.sources li > span { margin-left: 10px; }
|
|
142
|
+
.sources code { font-size: 12.5px; color: var(--color-text); background: none; border: 0; padding: 0; overflow-wrap: anywhere; }
|
|
143
|
+
.sources a { overflow-wrap: anywhere; }
|
|
144
|
+
.sources > p { max-width: none; margin: 12px 0 0; padding-left: 8px; }
|
|
145
|
+
a.cite { font: 500 .8em var(--font-mono); text-decoration: none; white-space: nowrap; }
|
|
146
|
+
a.cite:hover { text-decoration: underline; }
|
|
137
147
|
|
|
138
148
|
@media (max-width: 600px) {
|
|
139
149
|
main { padding-top: 36px; }
|
|
@@ -160,7 +170,7 @@ ol.steps > li:not(:last-child)::after { content: ""; position: absolute; left: 1
|
|
|
160
170
|
</div>
|
|
161
171
|
|
|
162
172
|
<div class="kpis">
|
|
163
|
-
<div class="kpi"><span>Errors in 7 days</span><b>133.5K</b><small>26% of minutes [1]</small></div>
|
|
173
|
+
<div class="kpi"><span>Errors in 7 days</span><b>133.5K</b><small>26% of minutes <a class="cite" href="#src-1">[1]</a></small></div>
|
|
164
174
|
<div class="kpi" data-tone="danger"><span>Peak after release</span><b>30,000</b><small>RU per minute, estimate</small></div>
|
|
165
175
|
</div>
|
|
166
176
|
|
|
@@ -180,7 +190,7 @@ ol.steps > li:not(:last-child)::after { content: ""; position: absolute; left: 1
|
|
|
180
190
|
<div class="card" data-tone="accent">
|
|
181
191
|
<h3><span class="dot" data-tone="accent">1</span>Option A<span class="tag" data-tone="accent">pick</span></h3>
|
|
182
192
|
<p class="verdict">The verdict, in one line.</p>
|
|
183
|
-
<p>What it is, in one sentence
|
|
193
|
+
<p>What it is, in one sentence. <a class="cite" href="#src-2">[2]</a></p>
|
|
184
194
|
<pre>on 429: wait, then retry</pre>
|
|
185
195
|
<ul><li class="pro">A strong point.</li><li class="con">A weak point.</li></ul>
|
|
186
196
|
</div>
|
|
@@ -194,7 +204,8 @@ ol.steps > li:not(:last-child)::after { content: ""; position: absolute; left: 1
|
|
|
194
204
|
<thead><tr><th>Option</th><th>Global</th><th>Lines</th></tr></thead>
|
|
195
205
|
<tbody>
|
|
196
206
|
<tr data-tone="accent"><td><span class="dot" data-tone="accent">1</span>Option A</td><td class="c yes">yes</td><td class="num">1,229</td></tr>
|
|
197
|
-
<tr><td><span class="dot">2</span>Option B</td><td class="c
|
|
207
|
+
<tr><td><span class="dot">2</span>Option B</td><td class="c part">partial</td><td class="num">640</td></tr>
|
|
208
|
+
<tr><td><span class="dot">3</span>Option C</td><td class="c no">no</td><td class="num">300</td></tr>
|
|
198
209
|
</tbody>
|
|
199
210
|
</table></div>
|
|
200
211
|
<ul class="points">
|
|
@@ -209,7 +220,14 @@ ol.steps > li:not(:last-child)::after { content: ""; position: absolute; left: 1
|
|
|
209
220
|
<ol class="steps"><li>First action.</li><li>Second action.</li></ol>
|
|
210
221
|
</section>
|
|
211
222
|
|
|
212
|
-
<div class="sources"
|
|
223
|
+
<div class="sources">
|
|
224
|
+
<ol>
|
|
225
|
+
<li id="src-1"><code>logs/errors-7d.csv</code><span>error count and share of minutes</span></li>
|
|
226
|
+
<li id="src-2"><code>services/api/src/middleware/rate-limit/retry-after-handler.ts:42</code><span>the retry on 429</span></li>
|
|
227
|
+
<li id="src-3"><a href="https://example.com">Title of the page</a><span>line counts in the table</span></li>
|
|
228
|
+
</ol>
|
|
229
|
+
<p>Estimates are marked as estimates.</p>
|
|
230
|
+
</div>
|
|
213
231
|
</main>
|
|
214
232
|
</body>
|
|
215
233
|
</html>
|
package/public/app.js
CHANGED
|
@@ -3918,7 +3918,7 @@ function renderSessions() {
|
|
|
3918
3918
|
const renderSessionCard = (session) => {
|
|
3919
3919
|
if (session.placeholder) return renderPlaceholderCard(session, childDepth.get(session.id));
|
|
3920
3920
|
const total = session.taskCount;
|
|
3921
|
-
const
|
|
3921
|
+
const pct = (n) => Math.round((n / total) * 100);
|
|
3922
3922
|
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
3923
3923
|
const isLive = isSessionLive(session);
|
|
3924
3924
|
const sessionName = sessionDisplayName(session);
|
|
@@ -3951,7 +3951,7 @@ function renderSessions() {
|
|
|
3951
3951
|
const sid = escAttrJs(session.id);
|
|
3952
3952
|
const progressHtml =
|
|
3953
3953
|
total > 0
|
|
3954
|
-
? `<div class="session-progress" title="${escapeHtml(session.
|
|
3954
|
+
? `<div class="session-progress" title="${escapeHtml(`${session.pending} pending, ${session.inProgress} in progress, ${session.completed} completed`)}"><div class="progress-bar"><div class="progress-fill done" style="width: ${pct(session.completed)}%"></div>${session.inProgress > 0 ? `<div class="progress-fill wip" style="width: ${pct(session.inProgress)}%"></div>` : ''}</div><span class="progress-text">${session.completed + session.inProgress}/${total}</span></div>`
|
|
3955
3955
|
: '';
|
|
3956
3956
|
const metricsHtml = progressHtml + (showCtx ? renderContextBar(session) : '');
|
|
3957
3957
|
return `
|
|
@@ -6391,6 +6391,12 @@ const SHORTCUT_TABS = [
|
|
|
6391
6391
|
title: 'Tabs',
|
|
6392
6392
|
rows: [
|
|
6393
6393
|
{ keys: ['[', ']'], label: 'Previous / next tab (a split counts as one)' },
|
|
6394
|
+
{
|
|
6395
|
+
keys: ['Ctrl', 'Alt', '1…9'],
|
|
6396
|
+
combo: true,
|
|
6397
|
+
yields: 'ctrl+alt+1',
|
|
6398
|
+
label: 'Go to tab or group N (the board is 1)',
|
|
6399
|
+
},
|
|
6394
6400
|
{ keys: ['Alt', '[ / ]'], combo: true, label: 'Change the tab in the focused side of a split' },
|
|
6395
6401
|
{ keys: ['|'], label: 'Split with the tab before it / unsplit (or drag a tab onto a side)' },
|
|
6396
6402
|
{ keys: ['{', '}'], label: 'Focus left / right side of a split' },
|
|
@@ -6433,8 +6439,8 @@ const SHORTCUT_TABS = [
|
|
|
6433
6439
|
{ keys: ['>'], label: 'Toggle sticky' },
|
|
6434
6440
|
{ keys: ['Shift', 'F10'], combo: true, label: 'Session menu: move to group, fork (or Menu key)' },
|
|
6435
6441
|
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session (pinned: to Pinned group)' },
|
|
6436
|
-
{ keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
6437
|
-
{ keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
|
|
6442
|
+
{ keys: ['Ctrl', 'Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
6443
|
+
{ keys: ['Ctrl', 'Shift', 'M'], combo: true, label: 'Open last message' },
|
|
6438
6444
|
{ keys: ['↑', '↓'], label: 'Previous / next message in detail' },
|
|
6439
6445
|
{ keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt; send review comments' },
|
|
6440
6446
|
],
|
|
@@ -6542,6 +6548,8 @@ function termKbd() {
|
|
|
6542
6548
|
function helpKeys(row, mac = IS_MAC, keyLabel = hub.keyLabel) {
|
|
6543
6549
|
const live = row.action && keyLabel(row.action);
|
|
6544
6550
|
if (live) return live;
|
|
6551
|
+
// `yields`: the row's key gives way when the hub binds that combo.
|
|
6552
|
+
if (row.yields && hub.forwardCombos().includes(row.yields)) return [];
|
|
6545
6553
|
if (!mac) return row.keys;
|
|
6546
6554
|
if (row.hubMod) return ['⌃', '⌥', ...row.keys.filter((k) => k !== 'Ctrl' && k !== 'Alt')];
|
|
6547
6555
|
return row.keys.map((k) => MAC_KEYS[k] || k);
|
|
@@ -7548,6 +7556,18 @@ function shiftOnly(e, code) {
|
|
|
7548
7556
|
return e.code === code && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey;
|
|
7549
7557
|
}
|
|
7550
7558
|
|
|
7559
|
+
function toggleLastMessage() {
|
|
7560
|
+
if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
|
|
7561
|
+
closeMsgDetailModal();
|
|
7562
|
+
} else if (isWaitingFresh()) {
|
|
7563
|
+
msgDetailFollowLatest = true;
|
|
7564
|
+
showWaitingDetail();
|
|
7565
|
+
} else if (currentMessages.length) {
|
|
7566
|
+
msgDetailFollowLatest = true;
|
|
7567
|
+
showMsgDetail(currentMessages.length - 1);
|
|
7568
|
+
}
|
|
7569
|
+
}
|
|
7570
|
+
|
|
7551
7571
|
function dismissSessionFromList(sid) {
|
|
7552
7572
|
const prevIdx = selectedSessionIdx;
|
|
7553
7573
|
if (!setSessionDismissed(sid, true)) return;
|
|
@@ -7813,6 +7833,12 @@ document.addEventListener('keydown', (e) => {
|
|
|
7813
7833
|
}
|
|
7814
7834
|
return;
|
|
7815
7835
|
}
|
|
7836
|
+
// The hub binds Ctrl+Alt+digit on macOS for its app keys.
|
|
7837
|
+
if (hubModDown(e) && /^Digit[1-9]$/.test(e.code) && !hub.forwards(e) && selectPaneByNumber(Number(e.code.slice(5)))) {
|
|
7838
|
+
e.preventDefault();
|
|
7839
|
+
focusActivePane();
|
|
7840
|
+
return;
|
|
7841
|
+
}
|
|
7816
7842
|
if (altOnly && e.code === 'KeyW' && closeFocusedPane()) {
|
|
7817
7843
|
e.preventDefault();
|
|
7818
7844
|
return;
|
|
@@ -7843,16 +7869,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
7843
7869
|
}
|
|
7844
7870
|
if (shiftOnly(e, 'KeyM')) {
|
|
7845
7871
|
e.preventDefault();
|
|
7846
|
-
|
|
7847
|
-
if (msgDetailModal.classList.contains('visible')) {
|
|
7848
|
-
closeMsgDetailModal();
|
|
7849
|
-
} else if (isWaitingFresh()) {
|
|
7850
|
-
msgDetailFollowLatest = true;
|
|
7851
|
-
showWaitingDetail();
|
|
7852
|
-
} else if (currentMessages.length) {
|
|
7853
|
-
msgDetailFollowLatest = true;
|
|
7854
|
-
showMsgDetail(currentMessages.length - 1);
|
|
7855
|
-
}
|
|
7872
|
+
toggleLastMessage();
|
|
7856
7873
|
return;
|
|
7857
7874
|
}
|
|
7858
7875
|
if (shiftOnly(e, 'KeyS')) {
|
|
@@ -8619,20 +8636,22 @@ const ZOOM_CLAIMS = ['ctrlKey', 'metaKey'].flatMap((mod) =>
|
|
|
8619
8636
|
),
|
|
8620
8637
|
);
|
|
8621
8638
|
|
|
8622
|
-
// The
|
|
8623
|
-
//
|
|
8624
|
-
//
|
|
8639
|
+
// The board keys a pane gets before the board's inPane return, so they work while a previewed page
|
|
8640
|
+
// has focus; a new key there needs a row here. Matched by key where the board matches by key, so
|
|
8641
|
+
// they follow the keyboard layout, and by code where it matches by code. A page that handles one
|
|
8642
|
+
// first keeps it, and text fields keep every key.
|
|
8625
8643
|
const PANE_CLAIMS = [
|
|
8626
|
-
...['\\', '[', ']'].map((key) => ({ key, code: '' })),
|
|
8627
|
-
...['|', '{', '}'].map((key) => ({ shiftKey: true, key, code: '' })),
|
|
8644
|
+
...['\\', '[', ']', 'ContextMenu', 'Escape'].map((key) => ({ key, code: '' })),
|
|
8645
|
+
...['|', '{', '}', 'F10'].map((key) => ({ shiftKey: true, key, code: '' })),
|
|
8628
8646
|
...['BracketLeft', 'BracketRight', 'KeyW'].map((code) => ({ altKey: true, key: 'Unidentified', code })),
|
|
8647
|
+
...['KeyL', 'KeyM', 'KeyP', 'KeyS', 'KeyZ'].map((code) => ({ shiftKey: true, key: 'Unidentified', code })),
|
|
8648
|
+
...'123456789'.split('').map((n) => ({ ctrlKey: true, altKey: true, key: 'Unidentified', code: `Digit${n}` })),
|
|
8629
8649
|
];
|
|
8630
8650
|
|
|
8631
|
-
// Ctrl+D
|
|
8651
|
+
// Ctrl+D and Alt+Enter act on the show card (showCardKey); other pages keep them. Alt+W and Esc are in PANE_CLAIMS.
|
|
8632
8652
|
const SHOW_CLAIMS = [
|
|
8633
8653
|
...['ctrlKey', 'metaKey'].map((mod) => ({ [mod]: true, key: 'd', code: '' })),
|
|
8634
8654
|
{ altKey: true, key: 'Enter', code: '' },
|
|
8635
|
-
{ key: 'Escape', code: '' },
|
|
8636
8655
|
];
|
|
8637
8656
|
|
|
8638
8657
|
// Claimed only while comments wait, so a previewed page keeps Ctrl+Enter otherwise.
|
|
@@ -13233,6 +13252,7 @@ function setActivePaneId(sid, id) {
|
|
|
13233
13252
|
if (getActivePaneId(sid) === id) return;
|
|
13234
13253
|
paneActive = savePaneState(PANE_ACTIVE_KEY, paneActive, sid, id === 'board' ? null : id);
|
|
13235
13254
|
notePairFocus(sid, id);
|
|
13255
|
+
noteGroupFocus(sid, id);
|
|
13236
13256
|
}
|
|
13237
13257
|
|
|
13238
13258
|
// {[sessionId]: [{l, r, f}]}; sides are pane ids or 'board', `f` the side focused
|
|
@@ -13293,18 +13313,75 @@ function neighborPair(sid, id, pairs) {
|
|
|
13293
13313
|
return i > 0 ? { l: free[i - 1], r: id } : { l: id, r: free[1] };
|
|
13294
13314
|
}
|
|
13295
13315
|
|
|
13296
|
-
//
|
|
13297
|
-
|
|
13316
|
+
// {[sessionId]: [{id, name, color, ids, closed, last}]}; `ids` are pane ids, `last` the one shown
|
|
13317
|
+
// last. Like pairs, groups live in this browser tab only. A group shows at the place of its first
|
|
13318
|
+
// tab, so grouping leaves the server's order alone; a drag saves the order on screen.
|
|
13319
|
+
const PANE_GROUPS_KEY = 'pane-groups';
|
|
13320
|
+
const PANE_GROUP_COLORS = ['blue', 'purple', 'teal', 'orange', 'yellow', 'pink', 'green', 'red'];
|
|
13321
|
+
let paneGroups = loadPaneState(PANE_GROUPS_KEY);
|
|
13322
|
+
|
|
13323
|
+
// Tabs that are gone, or in an earlier group, drop out, and so do groups left empty.
|
|
13324
|
+
function getPaneGroups(sid) {
|
|
13325
|
+
if (!paneGroups[sid]) return [];
|
|
13326
|
+
const live = new Set(paneLayout(sid).panes.map((p) => p.id));
|
|
13327
|
+
const taken = new Set();
|
|
13328
|
+
const groups = [];
|
|
13329
|
+
for (const g of paneGroups[sid]) {
|
|
13330
|
+
const ids = g.ids.filter((id) => live.has(id) && !taken.has(id));
|
|
13331
|
+
for (const id of ids) taken.add(id);
|
|
13332
|
+
const color = PANE_GROUP_COLORS.includes(g.color) ? g.color : PANE_GROUP_COLORS[0];
|
|
13333
|
+
if (ids.length) groups.push({ id: g.id, name: g.name || '', color, ids, closed: !!g.closed, last: g.last });
|
|
13334
|
+
}
|
|
13335
|
+
return groups;
|
|
13336
|
+
}
|
|
13337
|
+
|
|
13338
|
+
function setPaneGroups(sid, groups) {
|
|
13339
|
+
paneGroups = savePaneState(PANE_GROUPS_KEY, paneGroups, sid, groups.length ? groups : null);
|
|
13340
|
+
}
|
|
13341
|
+
|
|
13342
|
+
// A pair is in the group of either side, and the board is in none.
|
|
13343
|
+
function groupOfTabs(groups, ids) {
|
|
13344
|
+
return ids.includes('board') ? null : groups.find((g) => ids.some((t) => g.ids.includes(t))) || null;
|
|
13345
|
+
}
|
|
13346
|
+
|
|
13347
|
+
function noteGroupFocus(sid, id) {
|
|
13348
|
+
const pair = pairOf(sid, id);
|
|
13349
|
+
const group = groupOfTabs(getPaneGroups(sid), pair ? [pair.l, pair.r] : [id]);
|
|
13350
|
+
if (group && (group.closed || group.last !== id)) updatePaneGroup(sid, group.id, { closed: false, last: id });
|
|
13351
|
+
}
|
|
13352
|
+
|
|
13353
|
+
// The strip as the number keys count it, {group, items}: a group is one stop, open or collapsed.
|
|
13354
|
+
// An item is {ids, pair, group}: a lone tab, or a pair at the place of its first tab. A group's
|
|
13355
|
+
// items follow its first one.
|
|
13356
|
+
function paneStripStops(sid) {
|
|
13298
13357
|
const pairs = getPanePairs(sid);
|
|
13299
|
-
const
|
|
13358
|
+
const groups = getPaneGroups(sid);
|
|
13359
|
+
const stops = [];
|
|
13360
|
+
const groupStops = new Map();
|
|
13361
|
+
const seen = new Set();
|
|
13300
13362
|
for (const id of paneTabIds(sid)) {
|
|
13301
13363
|
const pair = pairs.find((p) => splitSideOf(p, id)) || null;
|
|
13302
|
-
if (
|
|
13303
|
-
|
|
13364
|
+
if (pair && seen.has(pair)) continue;
|
|
13365
|
+
if (pair) seen.add(pair);
|
|
13366
|
+
const ids = pair ? [pair.l, pair.r] : [id];
|
|
13367
|
+
const group = groupOfTabs(groups, ids);
|
|
13368
|
+
const item = { ids, pair, group };
|
|
13369
|
+
const stop = group && groupStops.get(group);
|
|
13370
|
+
if (stop) stop.items.push(item);
|
|
13371
|
+
else {
|
|
13372
|
+
stops.push({ group, items: [item] });
|
|
13373
|
+
if (group) groupStops.set(group, stops.at(-1));
|
|
13374
|
+
}
|
|
13304
13375
|
}
|
|
13305
|
-
return
|
|
13376
|
+
return stops;
|
|
13306
13377
|
}
|
|
13307
13378
|
|
|
13379
|
+
const paneStripItems = (sid) => paneStripStops(sid).flatMap((s) => s.items);
|
|
13380
|
+
|
|
13381
|
+
// The items a click or a key can reach: a collapsed group hides its tabs.
|
|
13382
|
+
const isOpenItem = (it) => !it.group?.closed;
|
|
13383
|
+
const openStripItems = (sid) => paneStripItems(sid).filter(isOpenItem);
|
|
13384
|
+
|
|
13308
13385
|
function unpairPanes(sid, pair, keep) {
|
|
13309
13386
|
setPanePairs(sid, withoutTabs(getPanePairs(sid), pair.l));
|
|
13310
13387
|
if (keep) setActivePaneId(sid, keep);
|
|
@@ -13498,7 +13575,7 @@ function paneMenuLabel(pane, label) {
|
|
|
13498
13575
|
|
|
13499
13576
|
function renderPaneTabs(sid, layout, split) {
|
|
13500
13577
|
// A rebuild mid-drag removes the dragged tab, and then no dragend fires. Mid-rename it drops the input.
|
|
13501
|
-
if (paneDragId ||
|
|
13578
|
+
if (paneDragId || stripRename) return;
|
|
13502
13579
|
const active = getActivePaneId(sid);
|
|
13503
13580
|
const state = (id) => (active === id ? ' on' : split && splitSideOf(split, id) ? ' shown' : '');
|
|
13504
13581
|
const tab = (id, cls, inner, tip) =>
|
|
@@ -13527,9 +13604,17 @@ function renderPaneTabs(sid, layout, split) {
|
|
|
13527
13604
|
pair
|
|
13528
13605
|
? `<div class="pane-pair${pair === split ? ' on' : ''}">${ids.map(tabHtml).join('<span class="pane-pair-sep"></span>')}</div>`
|
|
13529
13606
|
: tabHtml(ids[0]);
|
|
13530
|
-
const
|
|
13531
|
-
|
|
13532
|
-
|
|
13607
|
+
const groupHtml = (g, items) => {
|
|
13608
|
+
const n = items.flatMap((it) => it.ids).length;
|
|
13609
|
+
const tip = g.closed ? `Expand group (${n} tabs)` : 'Collapse group';
|
|
13610
|
+
const label =
|
|
13611
|
+
(g.name ? `<span>${escapeHtml(g.name)}</span>` : '') +
|
|
13612
|
+
(g.closed ? `<span class="pane-group-count">${n}</span>` : '');
|
|
13613
|
+
return `<div class="pane-group${g.closed ? ' closed' : ''}" style="${paneGroupStyle(g.color)}"><button class="pane-group-chip" data-group="${escapeHtml(g.id)}" title="${escapeHtml(tip)}">${label}</button>${g.closed ? '' : items.map(itemHtml).join('')}</div>`;
|
|
13614
|
+
};
|
|
13615
|
+
const [first, ...rest] = paneStripStops(sid);
|
|
13616
|
+
const pin = itemHtml(first.items[0]) + (rest.length ? '<span class="pane-sep"></span>' : '');
|
|
13617
|
+
const html = rest.map(({ group, items }) => (group ? groupHtml(group, items) : itemHtml(items[0]))).join('');
|
|
13533
13618
|
// syncPanes runs on every task refresh and search keystroke; skip the rebuild and the reflows.
|
|
13534
13619
|
if (pin + html === paneTabsHtml) return;
|
|
13535
13620
|
paneTabsHtml = pin + html;
|
|
@@ -13897,17 +13982,90 @@ function cyclePane(step) {
|
|
|
13897
13982
|
return true;
|
|
13898
13983
|
}
|
|
13899
13984
|
|
|
13985
|
+
function selectStripItem({ ids, pair }) {
|
|
13986
|
+
selectPane(pair ? pair[pair.f || 'l'] : ids[0]);
|
|
13987
|
+
}
|
|
13988
|
+
|
|
13989
|
+
function selectPaneByNumber(n) {
|
|
13990
|
+
const sid = paneSessionId();
|
|
13991
|
+
if (!sid || wantsTerminal()) return false;
|
|
13992
|
+
const stop = paneStripStops(sid)[n - 1];
|
|
13993
|
+
if (stop) selectStripItem(stop.items.find((it) => it.ids.includes(stop.group?.last)) || stop.items[0]);
|
|
13994
|
+
return true;
|
|
13995
|
+
}
|
|
13996
|
+
|
|
13900
13997
|
function cyclePaneStrip(step) {
|
|
13901
13998
|
const sid = paneSessionId();
|
|
13902
13999
|
if (!sid || wantsTerminal()) return false;
|
|
13903
14000
|
const active = getActivePaneId(sid);
|
|
13904
|
-
const items =
|
|
14001
|
+
const items = openStripItems(sid);
|
|
13905
14002
|
const i = items.findIndex((it) => it.ids.includes(active));
|
|
13906
|
-
|
|
13907
|
-
selectPane(pair ? pair[pair.f || 'l'] : ids[0]);
|
|
14003
|
+
selectStripItem(items[(i + step + items.length) % items.length]);
|
|
13908
14004
|
return true;
|
|
13909
14005
|
}
|
|
13910
14006
|
|
|
14007
|
+
function updatePaneGroup(sid, groupId, change) {
|
|
14008
|
+
const groups = getPaneGroups(sid);
|
|
14009
|
+
const group = groups.find((g) => g.id === groupId);
|
|
14010
|
+
if (!group) return;
|
|
14011
|
+
Object.assign(group, change);
|
|
14012
|
+
setPaneGroups(sid, groups);
|
|
14013
|
+
}
|
|
14014
|
+
|
|
14015
|
+
// `to` is a group id, 'new', or null to take the tab out of its group. A split moves as one.
|
|
14016
|
+
function groupPaneTab(id, to) {
|
|
14017
|
+
const sid = paneSessionId();
|
|
14018
|
+
const item = sid && paneStripItems(sid).find((it) => it.ids.includes(id));
|
|
14019
|
+
if (!item || item.ids.includes('board')) return;
|
|
14020
|
+
const groups = getPaneGroups(sid);
|
|
14021
|
+
for (const g of groups) g.ids = g.ids.filter((t) => !item.ids.includes(t));
|
|
14022
|
+
let target = groups.find((g) => g.id === to);
|
|
14023
|
+
if (to === 'new') {
|
|
14024
|
+
const used = new Set(groups.filter((g) => g.ids.length).map((g) => g.color));
|
|
14025
|
+
const color =
|
|
14026
|
+
PANE_GROUP_COLORS.find((c) => !used.has(c)) || PANE_GROUP_COLORS[groups.length % PANE_GROUP_COLORS.length];
|
|
14027
|
+
target = { id: `g${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, name: '', color, ids: [] };
|
|
14028
|
+
groups.push(target);
|
|
14029
|
+
}
|
|
14030
|
+
if (target) Object.assign(target, { ids: [...target.ids, ...item.ids], closed: false });
|
|
14031
|
+
setPaneGroups(sid, groups);
|
|
14032
|
+
syncPanes();
|
|
14033
|
+
if (to === 'new') beginPaneGroupRename(target.id);
|
|
14034
|
+
}
|
|
14035
|
+
|
|
14036
|
+
// A collapsed group cannot hold the tab on screen, so collapsing it moves to the next open item.
|
|
14037
|
+
function togglePaneGroup(groupId) {
|
|
14038
|
+
const sid = paneSessionId();
|
|
14039
|
+
const group = sid && getPaneGroups(sid).find((g) => g.id === groupId);
|
|
14040
|
+
if (!group) return;
|
|
14041
|
+
const closing = !group.closed;
|
|
14042
|
+
updatePaneGroup(sid, groupId, { closed: closing });
|
|
14043
|
+
const items = paneStripItems(sid);
|
|
14044
|
+
const i = items.findIndex((it) => it.ids.includes(getActivePaneId(sid)));
|
|
14045
|
+
if (!closing || items[i]?.group?.id !== groupId) {
|
|
14046
|
+
syncPanes();
|
|
14047
|
+
return;
|
|
14048
|
+
}
|
|
14049
|
+
selectStripItem(items.slice(i).find(isOpenItem) || items.slice(0, i).findLast(isOpenItem));
|
|
14050
|
+
}
|
|
14051
|
+
|
|
14052
|
+
function ungroupPanes(groupId) {
|
|
14053
|
+
const sid = paneSessionId();
|
|
14054
|
+
if (!sid) return;
|
|
14055
|
+
setPaneGroups(
|
|
14056
|
+
sid,
|
|
14057
|
+
getPaneGroups(sid).filter((g) => g.id !== groupId),
|
|
14058
|
+
);
|
|
14059
|
+
syncPanes();
|
|
14060
|
+
}
|
|
14061
|
+
|
|
14062
|
+
function colorPaneGroup(groupId, color) {
|
|
14063
|
+
const sid = paneSessionId();
|
|
14064
|
+
if (!sid) return;
|
|
14065
|
+
updatePaneGroup(sid, groupId, { color });
|
|
14066
|
+
syncPanes();
|
|
14067
|
+
}
|
|
14068
|
+
|
|
13911
14069
|
function isMenuKey(e) {
|
|
13912
14070
|
return e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey);
|
|
13913
14071
|
}
|
|
@@ -14053,44 +14211,105 @@ async function deleteBoardTasks(onlyCompleted) {
|
|
|
14053
14211
|
await refreshCurrentView();
|
|
14054
14212
|
}
|
|
14055
14213
|
|
|
14056
|
-
|
|
14214
|
+
// The save function of the tab or group name being edited.
|
|
14215
|
+
let stripRename = null;
|
|
14216
|
+
|
|
14217
|
+
function beginStripRename(el, { className, value, label, maxLength, save }) {
|
|
14218
|
+
stripRename = save;
|
|
14219
|
+
const input = document.createElement('input');
|
|
14220
|
+
input.className = `strip-rename ${className}`;
|
|
14221
|
+
input.value = value;
|
|
14222
|
+
if (maxLength) input.maxLength = maxLength;
|
|
14223
|
+
input.setAttribute('aria-label', label);
|
|
14224
|
+
el.replaceWith(input);
|
|
14225
|
+
input.focus();
|
|
14226
|
+
input.select();
|
|
14227
|
+
}
|
|
14228
|
+
|
|
14229
|
+
async function endStripRename(input, save) {
|
|
14230
|
+
const onSave = stripRename;
|
|
14231
|
+
if (!onSave) return;
|
|
14232
|
+
stripRename = null;
|
|
14233
|
+
paneTabsHtml = '';
|
|
14234
|
+
if (save) await onSave(input.value.trim());
|
|
14235
|
+
syncPanes();
|
|
14236
|
+
}
|
|
14057
14237
|
|
|
14058
14238
|
function beginPaneRename(id) {
|
|
14059
14239
|
const pane = paneById(id);
|
|
14060
14240
|
const tab = paneStrip.querySelector(`.pane-tab[data-pane="${CSS.escape(id)}"]`);
|
|
14061
14241
|
const title = tab?.querySelector('.pane-title');
|
|
14062
14242
|
if (!pane || !title) return;
|
|
14063
|
-
paneRenameId = id;
|
|
14064
14243
|
tab.draggable = false;
|
|
14065
|
-
|
|
14066
|
-
|
|
14067
|
-
|
|
14068
|
-
|
|
14069
|
-
|
|
14070
|
-
|
|
14071
|
-
|
|
14244
|
+
beginStripRename(title, {
|
|
14245
|
+
className: 'pane-title-input',
|
|
14246
|
+
value: pane.title,
|
|
14247
|
+
label: 'Pane title',
|
|
14248
|
+
save: async (text) => {
|
|
14249
|
+
const sid = paneSessionId();
|
|
14250
|
+
if (!sid || !paneById(id) || text === paneById(id).title) return;
|
|
14251
|
+
const res = await paneRequest('PATCH', apiPath`/api/panes/${sid}/${id}`, { title: text });
|
|
14252
|
+
if (res.error) showToast(res.error, 'error');
|
|
14253
|
+
else applyPaneLayout(sid, res.layout);
|
|
14254
|
+
},
|
|
14255
|
+
});
|
|
14072
14256
|
}
|
|
14073
14257
|
|
|
14074
|
-
|
|
14075
|
-
|
|
14076
|
-
|
|
14077
|
-
paneRenameId = null;
|
|
14078
|
-
paneTabsHtml = '';
|
|
14258
|
+
const PANE_GROUP_NAME_MAX = 40;
|
|
14259
|
+
|
|
14260
|
+
function beginPaneGroupRename(groupId) {
|
|
14079
14261
|
const sid = paneSessionId();
|
|
14080
|
-
const
|
|
14081
|
-
const
|
|
14082
|
-
if (
|
|
14083
|
-
|
|
14084
|
-
|
|
14085
|
-
|
|
14086
|
-
|
|
14087
|
-
|
|
14088
|
-
|
|
14089
|
-
|
|
14262
|
+
const group = sid && getPaneGroups(sid).find((g) => g.id === groupId);
|
|
14263
|
+
const chip = paneStrip.querySelector(`[data-group="${CSS.escape(groupId)}"]`);
|
|
14264
|
+
if (!group || !chip) return;
|
|
14265
|
+
beginStripRename(chip, {
|
|
14266
|
+
className: 'pane-group-input',
|
|
14267
|
+
value: group.name,
|
|
14268
|
+
label: 'Group name',
|
|
14269
|
+
maxLength: PANE_GROUP_NAME_MAX,
|
|
14270
|
+
save: (name) => updatePaneGroup(sid, groupId, { name: name.slice(0, PANE_GROUP_NAME_MAX) }),
|
|
14271
|
+
});
|
|
14090
14272
|
}
|
|
14091
14273
|
|
|
14092
|
-
const paneMenuItem = (act, label) =>
|
|
14093
|
-
`<button class="pane-menu-item" role="menuitem" data-pane-act="${escapeHtml(act)}">${label}</button>`;
|
|
14274
|
+
const paneMenuItem = (act, label, arg) =>
|
|
14275
|
+
`<button class="pane-menu-item" role="menuitem" data-pane-act="${escapeHtml(act)}"${arg == null ? '' : ` data-arg="${escapeHtml(arg)}"`}>${label}</button>`;
|
|
14276
|
+
const paneGroupStyle = (color) => escapeHtml(`--g: var(--owner-${color})`);
|
|
14277
|
+
const paneGroupDot = (g) => `<span class="pane-menu-dot" style="${paneGroupStyle(g.color)}"></span>`;
|
|
14278
|
+
|
|
14279
|
+
function paneGroupMenuItems(id) {
|
|
14280
|
+
const sid = paneSessionId();
|
|
14281
|
+
const item = paneStripItems(sid).find((it) => it.ids.includes(id));
|
|
14282
|
+
if (!item || item.ids.includes('board')) return '';
|
|
14283
|
+
const others = getPaneGroups(sid).filter((g) => g.id !== item.group?.id);
|
|
14284
|
+
return (
|
|
14285
|
+
paneMenuItem('group', 'Add to new group', 'new') +
|
|
14286
|
+
others
|
|
14287
|
+
.map((g) => paneMenuItem('group', `Add to ${paneGroupDot(g)}${escapeHtml(g.name || 'unnamed group')}`, g.id))
|
|
14288
|
+
.join('') +
|
|
14289
|
+
(item.group ? paneMenuItem('group', 'Remove from group', '') : '')
|
|
14290
|
+
);
|
|
14291
|
+
}
|
|
14292
|
+
|
|
14293
|
+
function openPaneGroupMenu(x, y, groupId) {
|
|
14294
|
+
const sid = paneSessionId();
|
|
14295
|
+
const group = sid && getPaneGroups(sid).find((g) => g.id === groupId);
|
|
14296
|
+
if (!group) return;
|
|
14297
|
+
const swatches = PANE_GROUP_COLORS.map(
|
|
14298
|
+
(c) =>
|
|
14299
|
+
`<button class="pane-menu-item pane-menu-swatch${c === group.color ? ' on' : ''}" role="menuitemradio" aria-checked="${escapeHtml(String(c === group.color))}" aria-label="${escapeHtml(c)}" title="${escapeHtml(c)}" data-pane-act="groupColor" data-arg="${escapeHtml(c)}" style="${paneGroupStyle(c)}"></button>`,
|
|
14300
|
+
).join('');
|
|
14301
|
+
showPaneMenu(
|
|
14302
|
+
x,
|
|
14303
|
+
y,
|
|
14304
|
+
group.id,
|
|
14305
|
+
'Group actions',
|
|
14306
|
+
`<div class="pane-menu-label">${escapeHtml(group.name || 'Unnamed group')}</div>` +
|
|
14307
|
+
paneMenuItem('groupRename', 'Rename') +
|
|
14308
|
+
paneMenuItem('groupToggle', group.closed ? 'Expand' : 'Collapse') +
|
|
14309
|
+
`<div class="pane-menu-swatches">${swatches}</div>` +
|
|
14310
|
+
paneMenuItem('ungroup', 'Ungroup'),
|
|
14311
|
+
);
|
|
14312
|
+
}
|
|
14094
14313
|
|
|
14095
14314
|
function openPaneMenu(x, y, id) {
|
|
14096
14315
|
if (id === 'board') {
|
|
@@ -14123,6 +14342,7 @@ function openPaneMenu(x, y, id) {
|
|
|
14123
14342
|
: paneMenuItem('link', paneIsLinked(pane) ? 'Remove from linked documents' : 'Add to linked documents')) +
|
|
14124
14343
|
paneMenuItem('rename', 'Rename') +
|
|
14125
14344
|
paneMenuItem('reload', 'Reload') +
|
|
14345
|
+
paneGroupMenuItems(pane.id) +
|
|
14126
14346
|
paneMenuItem('close', 'Close pane'),
|
|
14127
14347
|
);
|
|
14128
14348
|
return true;
|
|
@@ -14165,6 +14385,11 @@ const PANE_MENU_ACTIONS = {
|
|
|
14165
14385
|
rename: beginPaneRename,
|
|
14166
14386
|
reload: reloadPane,
|
|
14167
14387
|
close: closePane,
|
|
14388
|
+
group: (id, to) => groupPaneTab(id, to || null),
|
|
14389
|
+
groupRename: beginPaneGroupRename,
|
|
14390
|
+
groupToggle: togglePaneGroup,
|
|
14391
|
+
groupColor: colorPaneGroup,
|
|
14392
|
+
ungroup: ungroupPanes,
|
|
14168
14393
|
deleteCompleted: () => deleteBoardTasks(true),
|
|
14169
14394
|
deleteAll: () => deleteBoardTasks(false),
|
|
14170
14395
|
};
|
|
@@ -14354,18 +14579,23 @@ function closePanePop() {
|
|
|
14354
14579
|
}
|
|
14355
14580
|
|
|
14356
14581
|
paneStrip.addEventListener('keydown', (e) => {
|
|
14357
|
-
const input = e.target.closest?.('.
|
|
14582
|
+
const input = e.target.closest?.('.strip-rename');
|
|
14358
14583
|
if (input && (e.key === 'Enter' || e.key === 'Escape')) {
|
|
14359
14584
|
e.preventDefault();
|
|
14360
|
-
|
|
14585
|
+
endStripRename(input, e.key === 'Enter');
|
|
14361
14586
|
}
|
|
14362
14587
|
});
|
|
14363
14588
|
paneStrip.addEventListener('focusout', (e) => {
|
|
14364
|
-
const input = e.target.closest?.('.
|
|
14365
|
-
if (input)
|
|
14589
|
+
const input = e.target.closest?.('.strip-rename');
|
|
14590
|
+
if (input) endStripRename(input, true);
|
|
14366
14591
|
});
|
|
14367
14592
|
paneStrip.addEventListener('click', (e) => {
|
|
14368
|
-
if (e.target.closest('.
|
|
14593
|
+
if (e.target.closest('.strip-rename')) return;
|
|
14594
|
+
const chip = e.target.closest('[data-group]');
|
|
14595
|
+
if (chip) {
|
|
14596
|
+
togglePaneGroup(chip.dataset.group);
|
|
14597
|
+
return;
|
|
14598
|
+
}
|
|
14369
14599
|
const close = e.target.closest('[data-close]');
|
|
14370
14600
|
if (close) {
|
|
14371
14601
|
closePane(close.dataset.close);
|
|
@@ -14381,16 +14611,19 @@ paneStrip.addEventListener('auxclick', (e) => {
|
|
|
14381
14611
|
if (e.button === 1 && tab && tab.dataset.pane !== 'board') closePane(tab.dataset.pane);
|
|
14382
14612
|
});
|
|
14383
14613
|
paneStrip.addEventListener('contextmenu', (e) => {
|
|
14614
|
+
const chip = e.target.closest('[data-group]');
|
|
14384
14615
|
const tab = e.target.closest('.pane-tab');
|
|
14385
|
-
if (!tab) return;
|
|
14616
|
+
if (!chip && !tab) return;
|
|
14386
14617
|
e.preventDefault();
|
|
14387
|
-
|
|
14618
|
+
if (chip) openPaneGroupMenu(e.clientX, e.clientY, chip.dataset.group);
|
|
14619
|
+
else openPaneMenu(e.clientX, e.clientY, tab.dataset.pane);
|
|
14388
14620
|
});
|
|
14389
14621
|
document.addEventListener('click', (e) => {
|
|
14390
14622
|
const menu = document.getElementById('pane-menu');
|
|
14391
14623
|
if (!menu) return;
|
|
14392
|
-
const
|
|
14393
|
-
|
|
14624
|
+
const item = e.target.closest('.pane-menu-item');
|
|
14625
|
+
const act = item?.dataset.paneAct;
|
|
14626
|
+
if (act) PANE_MENU_ACTIONS[act](menu.dataset.pane, item.dataset.arg);
|
|
14394
14627
|
if (act) closePaneMenu();
|
|
14395
14628
|
else if (!menu.contains(e.target)) closePaneMenu(true);
|
|
14396
14629
|
});
|
|
@@ -14466,15 +14699,18 @@ paneStrip.addEventListener('dragstart', (e) => {
|
|
|
14466
14699
|
});
|
|
14467
14700
|
|
|
14468
14701
|
// A split shows as one strip item, so a drop moves the dragged tab's whole item; the first item
|
|
14469
|
-
// holds the board and stays put.
|
|
14702
|
+
// holds the board and stays put. A moved item joins the group it lands in, and a drop on a
|
|
14703
|
+
// group's chip adds it to that group. Elsewhere on the strip, a split tab's drop ends its split.
|
|
14470
14704
|
function paneDropAction(e) {
|
|
14471
14705
|
const sid = paneSessionId();
|
|
14472
14706
|
const tab = e.target.closest?.('.pane-tab');
|
|
14707
|
+
const chip = e.target.closest?.('[data-group]');
|
|
14473
14708
|
if (!sid || !paneDragId) return null;
|
|
14474
|
-
if (tab) {
|
|
14709
|
+
if (chip || tab) {
|
|
14475
14710
|
const [fixed, ...movable] = paneStripItems(sid);
|
|
14476
14711
|
const itemOf = (id) => movable.find((it) => it.ids.includes(id));
|
|
14477
14712
|
const from = itemOf(paneDragId);
|
|
14713
|
+
if (chip) return from ? { kind: 'group', sid, el: chip, group: chip.dataset.group } : null;
|
|
14478
14714
|
const to = itemOf(tab.dataset.pane);
|
|
14479
14715
|
if (from && to && from !== to) {
|
|
14480
14716
|
const el = to.pair ? tab.closest('.pane-pair') : tab;
|
|
@@ -14488,9 +14724,11 @@ function paneDropAction(e) {
|
|
|
14488
14724
|
|
|
14489
14725
|
function markPaneDrop(action) {
|
|
14490
14726
|
paneStrip.classList.toggle('drop-hot', action?.kind === 'unpair');
|
|
14491
|
-
for (const el of paneStrip.querySelectorAll('.pane-tab, .pane-pair')) {
|
|
14492
|
-
|
|
14493
|
-
el.classList.toggle('drop-
|
|
14727
|
+
for (const el of paneStrip.querySelectorAll('.pane-tab, .pane-pair, .pane-group-chip')) {
|
|
14728
|
+
const on = el === action?.el;
|
|
14729
|
+
el.classList.toggle('drop-in', on && action.kind === 'group');
|
|
14730
|
+
el.classList.toggle('drop-before', on && action.kind === 'move' && !action.after);
|
|
14731
|
+
el.classList.toggle('drop-after', on && action.kind === 'move' && action.after);
|
|
14494
14732
|
}
|
|
14495
14733
|
}
|
|
14496
14734
|
|
|
@@ -14517,11 +14755,15 @@ paneStrip.addEventListener('drop', (e) => {
|
|
|
14517
14755
|
if (!action) return;
|
|
14518
14756
|
e.preventDefault();
|
|
14519
14757
|
const { sid, pair } = action;
|
|
14758
|
+
const id = paneDragId;
|
|
14520
14759
|
const shown = pair && !!splitSideOf(pair, getActivePaneId(sid));
|
|
14521
|
-
const keep = shown ? pair[otherSide(splitSideOf(pair,
|
|
14760
|
+
const keep = shown ? pair[otherSide(splitSideOf(pair, id))] : null;
|
|
14522
14761
|
endPaneDrag();
|
|
14523
|
-
if (action.kind === '
|
|
14524
|
-
else
|
|
14762
|
+
if (action.kind === 'group') groupPaneTab(id, action.group);
|
|
14763
|
+
else if (action.kind === 'move') {
|
|
14764
|
+
if (action.from.group?.id !== action.to.group?.id) groupPaneTab(id, action.to.group?.id ?? null);
|
|
14765
|
+
movePaneItem(action);
|
|
14766
|
+
} else unpairPanes(sid, pair, keep);
|
|
14525
14767
|
});
|
|
14526
14768
|
paneDrop.addEventListener('dragover', (e) => {
|
|
14527
14769
|
if (!paneDragId) return;
|
|
@@ -14780,6 +15022,8 @@ function terminalShortcut(e, probe = false) {
|
|
|
14780
15022
|
// Ctrl+Shift+Z is redo in a text field, so only the terminal's own textarea gives it up.
|
|
14781
15023
|
if (ctrlShift && e.code === 'KeyZ') return inPageField(e) ? null : toggleZenMode;
|
|
14782
15024
|
if (ctrlShift && e.code === 'KeyK') return kanbotAvailable() ? () => toggleKanbot() : null;
|
|
15025
|
+
if (ctrlShift && e.code === 'KeyL') return toggleMessagePanel;
|
|
15026
|
+
if (ctrlShift && e.code === 'KeyM') return toggleLastMessage;
|
|
14783
15027
|
if (ctrlShift && e.code === 'KeyD') return dismissContextSession;
|
|
14784
15028
|
if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
|
|
14785
15029
|
return () => openNewSession(null, e.code === 'KeyR');
|
package/public/style.css
CHANGED
|
@@ -1058,10 +1058,14 @@ body.booting .boot-splash {
|
|
|
1058
1058
|
position: absolute;
|
|
1059
1059
|
inset: -1px;
|
|
1060
1060
|
border-left: 3px solid var(--accent);
|
|
1061
|
-
border-radius: inherit;
|
|
1062
1061
|
pointer-events: none;
|
|
1063
1062
|
}
|
|
1064
1063
|
|
|
1064
|
+
.session-item.unread::before,
|
|
1065
|
+
.session-item.permission-pending::before {
|
|
1066
|
+
border-radius: inherit;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1065
1069
|
.session-item.permission-pending::before,
|
|
1066
1070
|
.sp-row.permission-pending::before {
|
|
1067
1071
|
border-left-color: var(--warning);
|
|
@@ -1200,7 +1204,6 @@ body.booting .boot-splash {
|
|
|
1200
1204
|
|
|
1201
1205
|
/* The live-workflow bar takes the muted fill too: its card already carries a pulsing
|
|
1202
1206
|
accent dot, and a second accent bar under it shouts. */
|
|
1203
|
-
.session-item:not(.active) .progress-fill,
|
|
1204
1207
|
.wf-live-bar .progress-fill {
|
|
1205
1208
|
background: color-mix(in srgb, var(--text-tertiary) 55%, transparent);
|
|
1206
1209
|
}
|
|
@@ -1212,6 +1215,14 @@ body.booting .boot-splash {
|
|
|
1212
1215
|
font-variant-numeric: tabular-nums;
|
|
1213
1216
|
}
|
|
1214
1217
|
|
|
1218
|
+
.session-progress .progress-bar {
|
|
1219
|
+
display: flex;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
.progress-fill.done {
|
|
1223
|
+
background: var(--success);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1215
1226
|
.context-bar-labels {
|
|
1216
1227
|
display: flex;
|
|
1217
1228
|
justify-content: space-between;
|
|
@@ -4485,6 +4496,75 @@ body.show-resizing iframe {
|
|
|
4485
4496
|
background: var(--border);
|
|
4486
4497
|
}
|
|
4487
4498
|
|
|
4499
|
+
.pane-group {
|
|
4500
|
+
position: relative;
|
|
4501
|
+
display: flex;
|
|
4502
|
+
gap: 2px;
|
|
4503
|
+
min-width: 0;
|
|
4504
|
+
}
|
|
4505
|
+
|
|
4506
|
+
/* Over the tabs, so a tab's own background does not hide it. */
|
|
4507
|
+
.pane-group:not(.closed)::after {
|
|
4508
|
+
content: '';
|
|
4509
|
+
position: absolute;
|
|
4510
|
+
inset: auto 0 1px;
|
|
4511
|
+
z-index: 1;
|
|
4512
|
+
height: 2px;
|
|
4513
|
+
background: var(--g);
|
|
4514
|
+
pointer-events: none;
|
|
4515
|
+
}
|
|
4516
|
+
|
|
4517
|
+
.pane-group-chip,
|
|
4518
|
+
.pane-group-input {
|
|
4519
|
+
flex: none;
|
|
4520
|
+
align-self: center;
|
|
4521
|
+
height: 18px;
|
|
4522
|
+
margin: 0 2px;
|
|
4523
|
+
padding: 0 8px;
|
|
4524
|
+
border: 0;
|
|
4525
|
+
border-radius: 6px;
|
|
4526
|
+
font-family: var(--font-mono);
|
|
4527
|
+
font-size: 11px;
|
|
4528
|
+
font-weight: 600;
|
|
4529
|
+
letter-spacing: 0.3px;
|
|
4530
|
+
}
|
|
4531
|
+
|
|
4532
|
+
.pane-group-chip {
|
|
4533
|
+
min-width: 14px;
|
|
4534
|
+
max-width: 140px;
|
|
4535
|
+
overflow: hidden;
|
|
4536
|
+
text-overflow: ellipsis;
|
|
4537
|
+
white-space: nowrap;
|
|
4538
|
+
background: var(--g);
|
|
4539
|
+
color: var(--bg-deep);
|
|
4540
|
+
cursor: pointer;
|
|
4541
|
+
}
|
|
4542
|
+
|
|
4543
|
+
.pane-group-chip:hover {
|
|
4544
|
+
filter: brightness(1.12);
|
|
4545
|
+
}
|
|
4546
|
+
|
|
4547
|
+
.pane-group-count {
|
|
4548
|
+
font-weight: 400;
|
|
4549
|
+
opacity: 0.75;
|
|
4550
|
+
}
|
|
4551
|
+
|
|
4552
|
+
.pane-group-count:not(:first-child) {
|
|
4553
|
+
margin-left: 6px;
|
|
4554
|
+
}
|
|
4555
|
+
|
|
4556
|
+
.pane-group-chip.drop-in {
|
|
4557
|
+
box-shadow: 0 0 0 2px var(--accent);
|
|
4558
|
+
}
|
|
4559
|
+
|
|
4560
|
+
.pane-group-input {
|
|
4561
|
+
width: 110px;
|
|
4562
|
+
color: var(--text-primary);
|
|
4563
|
+
background: var(--bg-deep);
|
|
4564
|
+
box-shadow: inset 0 0 0 1px var(--g);
|
|
4565
|
+
outline: none;
|
|
4566
|
+
}
|
|
4567
|
+
|
|
4488
4568
|
.pane-strip.drop-hot {
|
|
4489
4569
|
background: var(--accent-dim);
|
|
4490
4570
|
}
|
|
@@ -9014,7 +9094,6 @@ select.form-input option:checked {
|
|
|
9014
9094
|
align-items: baseline;
|
|
9015
9095
|
gap: 8px;
|
|
9016
9096
|
padding: 6px 8px;
|
|
9017
|
-
border-radius: 4px;
|
|
9018
9097
|
cursor: pointer;
|
|
9019
9098
|
font-size: 12px;
|
|
9020
9099
|
}
|
|
@@ -10438,4 +10517,32 @@ pre.mermaid[data-mermaid-error]::before {
|
|
|
10438
10517
|
color: var(--text-primary);
|
|
10439
10518
|
font-weight: 600;
|
|
10440
10519
|
}
|
|
10520
|
+
.pane-menu-dot {
|
|
10521
|
+
flex: none;
|
|
10522
|
+
width: 8px;
|
|
10523
|
+
height: 8px;
|
|
10524
|
+
border-radius: 50%;
|
|
10525
|
+
background: var(--g);
|
|
10526
|
+
}
|
|
10527
|
+
.pane-menu-swatches {
|
|
10528
|
+
display: flex;
|
|
10529
|
+
gap: 4px;
|
|
10530
|
+
padding: 4px 8px;
|
|
10531
|
+
}
|
|
10532
|
+
.pane-menu-item.pane-menu-swatch {
|
|
10533
|
+
flex: none;
|
|
10534
|
+
width: 16px;
|
|
10535
|
+
height: 16px;
|
|
10536
|
+
padding: 0;
|
|
10537
|
+
border-radius: 50%;
|
|
10538
|
+
background: var(--g);
|
|
10539
|
+
}
|
|
10540
|
+
.pane-menu-item.pane-menu-swatch:hover,
|
|
10541
|
+
.pane-menu-item.pane-menu-swatch:focus-visible,
|
|
10542
|
+
.pane-menu-swatch.on {
|
|
10543
|
+
background: var(--g);
|
|
10544
|
+
box-shadow:
|
|
10545
|
+
0 0 0 2px var(--bg-elevated),
|
|
10546
|
+
0 0 0 3px var(--text-primary);
|
|
10547
|
+
}
|
|
10441
10548
|
/* #endregion */
|