claude-code-kanban 6.1.2 → 7.0.1
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/README.md +3 -2
- package/cli.js +200 -164
- package/lib/frame-policy.js +79 -0
- package/lib/http-error.js +6 -0
- package/lib/linked-docs.js +5 -7
- package/lib/one-line.js +6 -0
- package/lib/panes.js +174 -0
- package/lib/retention.js +14 -8
- package/lib/scratchpad-dir.js +41 -0
- package/lib/session-events.js +3 -4
- package/lib/terminal.js +46 -6
- package/lib/worktrees.js +11 -2
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +30 -22
- package/public/app.js +940 -111
- package/public/index.html +37 -0
- package/public/link-url.js +6 -3
- package/public/style.css +452 -8
- package/public/tree-highlight.js +74 -0
- package/server.js +133 -40
- package/skill-guides/dispatch.md +1 -1
package/public/app.js
CHANGED
|
@@ -624,7 +624,7 @@ async function fetchTasks(sessionId) {
|
|
|
624
624
|
const switched = sessionId !== currentSessionId;
|
|
625
625
|
currentSessionId = sessionId;
|
|
626
626
|
markOpenSessionRead();
|
|
627
|
-
if (switched) autoRevealLog(sessionId
|
|
627
|
+
if (switched) autoRevealLog(sessionId);
|
|
628
628
|
// A task change in the open session must not reset the messages, the agents or the owner
|
|
629
629
|
// filter: that reset rebuilds the panels and reads as a reload after each card move.
|
|
630
630
|
if (!refresh) {
|
|
@@ -799,8 +799,11 @@ function setMessagePanelVisible(open) {
|
|
|
799
799
|
}
|
|
800
800
|
|
|
801
801
|
// An empty board has nothing to show, so the log opens in its place. It is not the user's choice,
|
|
802
|
-
// so it is not stored, it closes again on a session with tasks, and a manual close sticks for that session.
|
|
803
|
-
|
|
802
|
+
// so it is not stored, it closes again on a session with tasks or panes, and a manual close sticks for that session.
|
|
803
|
+
// The pane layout loads after the switch, and applyPaneLayout asks again once it is in.
|
|
804
|
+
function autoRevealLog(sessionId) {
|
|
805
|
+
if (!paneLayouts.has(sessionId)) return;
|
|
806
|
+
const empty = !currentTasks.length && !paneLayout(sessionId).panes.length;
|
|
804
807
|
if (empty && !messagePanelOpen && !logDismissed.has(sessionId) && !wantsTerminal()) {
|
|
805
808
|
setMessagePanelVisible(true);
|
|
806
809
|
logAutoOpened = true;
|
|
@@ -2414,13 +2417,8 @@ function initModalResize() {
|
|
|
2414
2417
|
modal.classList.add('user-sized');
|
|
2415
2418
|
}
|
|
2416
2419
|
|
|
2417
|
-
const handle = document.createElement('div');
|
|
2418
|
-
handle.className = 'modal-resize-handle';
|
|
2419
|
-
handle.title = 'Drag to resize · double-click to reset';
|
|
2420
|
-
modal.appendChild(handle);
|
|
2421
|
-
|
|
2422
2420
|
let startW, startH, w, h;
|
|
2423
|
-
|
|
2421
|
+
const handle = createResizeGrip({
|
|
2424
2422
|
onStart() {
|
|
2425
2423
|
startW = modal.offsetWidth;
|
|
2426
2424
|
startH = modal.offsetHeight;
|
|
@@ -2446,16 +2444,25 @@ function initModalResize() {
|
|
|
2446
2444
|
store.setItem(hKey, `${h}px`);
|
|
2447
2445
|
}
|
|
2448
2446
|
},
|
|
2447
|
+
onReset() {
|
|
2448
|
+
modal.classList.remove('user-sized');
|
|
2449
|
+
store.removeItem(wKey);
|
|
2450
|
+
store.removeItem(hKey);
|
|
2451
|
+
},
|
|
2449
2452
|
});
|
|
2450
|
-
|
|
2451
|
-
handle.addEventListener('dblclick', () => {
|
|
2452
|
-
modal.classList.remove('user-sized');
|
|
2453
|
-
store.removeItem(wKey);
|
|
2454
|
-
store.removeItem(hKey);
|
|
2455
|
-
});
|
|
2453
|
+
modal.appendChild(handle);
|
|
2456
2454
|
});
|
|
2457
2455
|
}
|
|
2458
2456
|
|
|
2457
|
+
function createResizeGrip({ onReset, ...drag }) {
|
|
2458
|
+
const handle = document.createElement('div');
|
|
2459
|
+
handle.className = 'modal-resize-handle';
|
|
2460
|
+
handle.title = 'Drag to resize · double-click to reset';
|
|
2461
|
+
_initDragResize(handle, drag);
|
|
2462
|
+
handle.addEventListener('dblclick', onReset);
|
|
2463
|
+
return handle;
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2459
2466
|
const MODAL_ZOOM_KEY = 'modal-zoom';
|
|
2460
2467
|
const MODAL_ZOOM_MIN = 0.7;
|
|
2461
2468
|
const MODAL_ZOOM_MAX = 2.0;
|
|
@@ -2490,6 +2497,11 @@ function isAnyModalOpen() {
|
|
|
2490
2497
|
return document.querySelector('.modal-overlay.visible') !== null;
|
|
2491
2498
|
}
|
|
2492
2499
|
|
|
2500
|
+
// An open modal owns the zoom keys even when it has nothing to scale.
|
|
2501
|
+
function isZoomableOnScreen() {
|
|
2502
|
+
return isAnyModalOpen() ? isZoomableModalOpen() : isPaneDocOnScreen();
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2493
2505
|
const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
|
|
2494
2506
|
|
|
2495
2507
|
function zoomDelta(e) {
|
|
@@ -4877,7 +4889,7 @@ function initSessionGroupsDnd() {
|
|
|
4877
4889
|
sessionsList.addEventListener('contextmenu', sgOnContextMenu);
|
|
4878
4890
|
// The Menu key fires contextmenu at the focused element, which by then is our menu.
|
|
4879
4891
|
document.addEventListener('contextmenu', (e) => {
|
|
4880
|
-
if (e.target.closest?.('.sg-menu')) e.preventDefault();
|
|
4892
|
+
if (e.target.closest?.('.sg-menu, .pane-menu')) e.preventDefault();
|
|
4881
4893
|
});
|
|
4882
4894
|
sessionsList.addEventListener('keydown', (e) => {
|
|
4883
4895
|
const input = e.target.closest?.('.sg-name-input');
|
|
@@ -4918,11 +4930,8 @@ function sgCloseMenu() {
|
|
|
4918
4930
|
if (menu) menu.remove();
|
|
4919
4931
|
}
|
|
4920
4932
|
|
|
4921
|
-
//
|
|
4922
|
-
function
|
|
4923
|
-
const menu = document.getElementById('sg-menu');
|
|
4924
|
-
if (!menu || e.ctrlKey || e.altKey || e.metaKey) return;
|
|
4925
|
-
const items = [...menu.querySelectorAll('.sg-menu-item')];
|
|
4933
|
+
// Run in the capture phase: the global handler would otherwise read these keys as sidebar shortcuts.
|
|
4934
|
+
function menuKeydown(e, items, close) {
|
|
4926
4935
|
const i = items.indexOf(document.activeElement);
|
|
4927
4936
|
e.stopPropagation();
|
|
4928
4937
|
if (e.key === 'Enter' || e.key === ' ') return;
|
|
@@ -4932,10 +4941,23 @@ function sgMenuKeydown(e) {
|
|
|
4932
4941
|
else if (matchKey(e, 'ArrowUp')) next = i < 0 ? items.length - 1 : (i - 1 + items.length) % items.length;
|
|
4933
4942
|
else if (e.key === 'Home') next = 0;
|
|
4934
4943
|
else if (e.key === 'End') next = items.length - 1;
|
|
4935
|
-
else if (e.key === 'Escape' || e.key === 'Tab')
|
|
4944
|
+
else if (e.key === 'Escape' || e.key === 'Tab') close();
|
|
4936
4945
|
if (next !== null) items[next]?.focus();
|
|
4937
4946
|
}
|
|
4938
4947
|
|
|
4948
|
+
function placeMenu(menu, x, y) {
|
|
4949
|
+
document.body.appendChild(menu);
|
|
4950
|
+
const rect = menu.getBoundingClientRect();
|
|
4951
|
+
menu.style.left = `${Math.min(x, window.innerWidth - rect.width - 8)}px`;
|
|
4952
|
+
menu.style.top = `${Math.min(y, window.innerHeight - rect.height - 8)}px`;
|
|
4953
|
+
}
|
|
4954
|
+
|
|
4955
|
+
function sgMenuKeydown(e) {
|
|
4956
|
+
const menu = document.getElementById('sg-menu');
|
|
4957
|
+
if (!menu || e.ctrlKey || e.altKey || e.metaKey) return;
|
|
4958
|
+
menuKeydown(e, [...menu.querySelectorAll('.sg-menu-item')], sgCloseMenu);
|
|
4959
|
+
}
|
|
4960
|
+
|
|
4939
4961
|
function sgOpenMenuForKbSelection() {
|
|
4940
4962
|
const el = sessionsList.querySelector('.kb-selected');
|
|
4941
4963
|
if (!el?.matches('.session-item, .project-group-header')) return false;
|
|
@@ -4948,6 +4970,7 @@ function sgOpenMenuForKbSelection() {
|
|
|
4948
4970
|
|
|
4949
4971
|
function sgOpenMenu(x, y, kind, ref) {
|
|
4950
4972
|
sgCloseMenu();
|
|
4973
|
+
closePaneMenu();
|
|
4951
4974
|
const current = sgGroupOf(kind, ref);
|
|
4952
4975
|
const label = kind === 'project' ? 'project' : 'session';
|
|
4953
4976
|
const rows = sessionGroups
|
|
@@ -4969,10 +4992,7 @@ function sgOpenMenu(x, y, kind, ref) {
|
|
|
4969
4992
|
${rows}
|
|
4970
4993
|
<button class="sg-menu-item" role="menuitem" data-sg-move="__new__">+ New group…</button>
|
|
4971
4994
|
${current ? `<button class="sg-menu-item sg-menu-remove" role="menuitem" data-sg-move="__none__">Remove from “${escapeHtml(current.name)}”</button>` : ''}`;
|
|
4972
|
-
|
|
4973
|
-
const rect = menu.getBoundingClientRect();
|
|
4974
|
-
menu.style.left = `${Math.min(x, window.innerWidth - rect.width - 8)}px`;
|
|
4975
|
-
menu.style.top = `${Math.min(y, window.innerHeight - rect.height - 8)}px`;
|
|
4995
|
+
placeMenu(menu, x, y);
|
|
4976
4996
|
document.addEventListener('keydown', sgMenuKeydown, true);
|
|
4977
4997
|
menu.querySelector('.sg-menu-item')?.focus();
|
|
4978
4998
|
}
|
|
@@ -5814,7 +5834,8 @@ const SHORTCUT_TABS = [
|
|
|
5814
5834
|
{
|
|
5815
5835
|
title: 'View',
|
|
5816
5836
|
rows: [
|
|
5817
|
-
{ keys: ['
|
|
5837
|
+
{ keys: ['\\'], label: 'Toggle sidebar' },
|
|
5838
|
+
{ keys: ['[', ']'], label: 'Previous / next tab (Board and panes)' },
|
|
5818
5839
|
{ keys: ['T'], label: 'Toggle theme' },
|
|
5819
5840
|
{ keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
|
|
5820
5841
|
{ keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
|
|
@@ -6871,14 +6892,15 @@ const MODAL_CLOSERS = {
|
|
|
6871
6892
|
'terminal-manager-modal': () => closeTerminalManager(),
|
|
6872
6893
|
'new-session-modal': () => closeNewSession(),
|
|
6873
6894
|
'confirm-modal': () => closeConfirmModal?.(false),
|
|
6895
|
+
'pane-pop': () => closePanePop(),
|
|
6874
6896
|
};
|
|
6875
6897
|
|
|
6876
6898
|
document.addEventListener('keydown', (e) => {
|
|
6877
|
-
// Scale the open modal's reading surface instead of letting the browser zoom
|
|
6899
|
+
// Scale the open modal's reading surface, or the file pane's, instead of letting the browser zoom
|
|
6878
6900
|
// the whole page. Sits above the text-field guard so it still works with the
|
|
6879
6901
|
// caret in a field inside the modal.
|
|
6880
6902
|
const zoom = zoomDelta(e);
|
|
6881
|
-
if (zoom !== undefined &&
|
|
6903
|
+
if (zoom !== undefined && isZoomableOnScreen()) {
|
|
6882
6904
|
e.preventDefault();
|
|
6883
6905
|
adjustModalZoom(zoom);
|
|
6884
6906
|
return;
|
|
@@ -6979,11 +7001,15 @@ document.addEventListener('keydown', (e) => {
|
|
|
6979
7001
|
}
|
|
6980
7002
|
|
|
6981
7003
|
// Global shortcuts
|
|
6982
|
-
if (e
|
|
7004
|
+
if (matchKey(e, '\\')) {
|
|
6983
7005
|
e.preventDefault();
|
|
6984
7006
|
toggleSidebar();
|
|
6985
7007
|
return;
|
|
6986
7008
|
}
|
|
7009
|
+
if (matchKey(e, '[', ']') && cyclePane(e.key === ']' ? 1 : -1)) {
|
|
7010
|
+
e.preventDefault();
|
|
7011
|
+
return;
|
|
7012
|
+
}
|
|
6987
7013
|
if (e.code === 'KeyL' && e.shiftKey) {
|
|
6988
7014
|
e.preventDefault();
|
|
6989
7015
|
toggleMessagePanel();
|
|
@@ -7266,25 +7292,52 @@ function reviewItems() {
|
|
|
7266
7292
|
return (activeReview && reviewDrafts.get(activeReview.key)) || [];
|
|
7267
7293
|
}
|
|
7268
7294
|
|
|
7295
|
+
// Only one review is live. Each modal mounts on top of the stack, keyed by its panel, and
|
|
7296
|
+
// closing it brings back the review beneath. `reviewBase` (the pane on screen) is live only
|
|
7297
|
+
// when the stack is empty.
|
|
7298
|
+
const reviewStack = [];
|
|
7299
|
+
let reviewBase = null;
|
|
7300
|
+
|
|
7269
7301
|
// `context(range)` adds view-specific citation fields to a comment on the host DOM;
|
|
7270
7302
|
// `onComment()` runs after a comment is added; `onSent()` runs after a batch is delivered.
|
|
7271
|
-
function mountReview({
|
|
7272
|
-
|
|
7273
|
-
|
|
7274
|
-
|
|
7275
|
-
|
|
7276
|
-
|
|
7277
|
-
|
|
7278
|
-
|
|
7279
|
-
|
|
7280
|
-
|
|
7281
|
-
}
|
|
7282
|
-
|
|
7303
|
+
function mountReview(opts) {
|
|
7304
|
+
dropReviewEntry(opts.panelEl);
|
|
7305
|
+
reviewStack.push({ sessionId: currentSessionId, ...opts });
|
|
7306
|
+
showTopReview();
|
|
7307
|
+
}
|
|
7308
|
+
|
|
7309
|
+
// Unmounts only the review that panel holds, so a modal under another one keeps it.
|
|
7310
|
+
function unmountReview(panelEl) {
|
|
7311
|
+
dropReviewEntry(panelEl);
|
|
7312
|
+
showTopReview();
|
|
7313
|
+
}
|
|
7314
|
+
|
|
7315
|
+
function setBaseReview(opts) {
|
|
7316
|
+
if (reviewBase === opts) return;
|
|
7317
|
+
reviewBase = opts;
|
|
7318
|
+
showTopReview();
|
|
7319
|
+
}
|
|
7320
|
+
|
|
7321
|
+
function dropReviewEntry(panelEl) {
|
|
7322
|
+
const i = reviewStack.findIndex((o) => o.panelEl === panelEl);
|
|
7323
|
+
if (i >= 0) reviewStack.splice(i, 1);
|
|
7324
|
+
}
|
|
7325
|
+
|
|
7326
|
+
function showTopReview() {
|
|
7327
|
+
const opts = reviewStack.at(-1) || reviewBase;
|
|
7328
|
+
if (activeReview && activeReview.opts === opts) return;
|
|
7329
|
+
stopReview();
|
|
7330
|
+
if (opts) startReview(opts);
|
|
7331
|
+
}
|
|
7332
|
+
|
|
7333
|
+
function startReview(opts) {
|
|
7334
|
+
const { contentEl, panelEl, hostEl, source, frame, context, onComment, onSent, sessionId } = opts;
|
|
7283
7335
|
if (!sessionId) return;
|
|
7284
7336
|
const ctl = new AbortController();
|
|
7285
7337
|
const { signal } = ctl;
|
|
7286
7338
|
const key = reviewKey(sessionId, source);
|
|
7287
7339
|
activeReview = {
|
|
7340
|
+
opts,
|
|
7288
7341
|
contentEl,
|
|
7289
7342
|
panelEl,
|
|
7290
7343
|
hostEl,
|
|
@@ -7312,9 +7365,7 @@ function mountReview({
|
|
|
7312
7365
|
renderReviewPanel();
|
|
7313
7366
|
}
|
|
7314
7367
|
|
|
7315
|
-
|
|
7316
|
-
function unmountReview(ownPanel) {
|
|
7317
|
-
if (ownPanel && activeReview?.panelEl !== ownPanel) return;
|
|
7368
|
+
function stopReview() {
|
|
7318
7369
|
closeReviewPop();
|
|
7319
7370
|
if (!activeReview) return;
|
|
7320
7371
|
const { ctl, panelEl, hostEl } = activeReview;
|
|
@@ -7424,7 +7475,7 @@ function postToReviewFrame(type, id) {
|
|
|
7424
7475
|
|
|
7425
7476
|
// Runs inside the preview iframe as source text, so it must not close over anything outside
|
|
7426
7477
|
// its own body and arguments.
|
|
7427
|
-
function reviewBridge(textBefore, headingBefore, rangeAt) {
|
|
7478
|
+
function reviewBridge(textBefore, headingBefore, rangeAt, comboOf, fieldSelector) {
|
|
7428
7479
|
const P = 'cck-review:';
|
|
7429
7480
|
const marks = new Map();
|
|
7430
7481
|
let pending = null;
|
|
@@ -7501,11 +7552,51 @@ function reviewBridge(textBefore, headingBefore, rangeAt) {
|
|
|
7501
7552
|
};
|
|
7502
7553
|
document.addEventListener('mousedown', clear);
|
|
7503
7554
|
document.addEventListener('scroll', clear, true);
|
|
7555
|
+
// The sandbox has no allow-top-navigation and the board never sees events in here, so web links
|
|
7556
|
+
// and the keys the board claims go up as messages. A srcdoc document resolves relative hrefs,
|
|
7557
|
+
// `#id` too, against the board's URL, and loading that here ends on an error page because the
|
|
7558
|
+
// board refuses framing; a hash goes to location.hash instead, which stays on about:srcdoc.
|
|
7559
|
+
const openLink = (e) => {
|
|
7560
|
+
if (e.defaultPrevented || (e.type === 'auxclick' && e.button !== 1)) return;
|
|
7561
|
+
const a = e.target.closest?.('a[href]');
|
|
7562
|
+
const href = a?.getAttribute('href');
|
|
7563
|
+
if (!href) return;
|
|
7564
|
+
if (/^https?:\/\//i.test(href)) {
|
|
7565
|
+
e.preventDefault();
|
|
7566
|
+
send({ type: 'link', href: a.href });
|
|
7567
|
+
return;
|
|
7568
|
+
}
|
|
7569
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return;
|
|
7570
|
+
e.preventDefault();
|
|
7571
|
+
if (href.startsWith('#') && e.type === 'click') location.hash = href;
|
|
7572
|
+
};
|
|
7573
|
+
document.addEventListener('click', openLink);
|
|
7574
|
+
document.addEventListener('auxclick', openLink);
|
|
7575
|
+
// The terminal frame's claim format (terminal-frame.js keySigs).
|
|
7576
|
+
const keySigs = (e) => {
|
|
7577
|
+
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta']
|
|
7578
|
+
.filter(Boolean)
|
|
7579
|
+
.join('+');
|
|
7580
|
+
return [e.code && `${mods}|c:${e.code}`, typeof e.key === 'string' && `${mods}|k:${e.key.toLowerCase()}`].filter(
|
|
7581
|
+
Boolean,
|
|
7582
|
+
);
|
|
7583
|
+
};
|
|
7584
|
+
let claims = new Set();
|
|
7585
|
+
let forward = new Set();
|
|
7586
|
+
document.addEventListener('keydown', (e) => {
|
|
7587
|
+
if (e.defaultPrevented || e.target.closest?.(fieldSelector)) return;
|
|
7588
|
+
if (!forward.has(comboOf(e)) && !keySigs(e).some((s) => claims.has(s))) return;
|
|
7589
|
+
e.preventDefault();
|
|
7590
|
+
send({ type: 'key', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey });
|
|
7591
|
+
});
|
|
7504
7592
|
addEventListener('message', (e) => {
|
|
7505
7593
|
if (e.source !== parent || typeof e.data?.type !== 'string' || !e.data.type.startsWith(P)) return;
|
|
7506
7594
|
const type = e.data.type.slice(P.length);
|
|
7507
7595
|
const range = marks.get(e.data.id);
|
|
7508
|
-
if (type === '
|
|
7596
|
+
if (type === 'claims') {
|
|
7597
|
+
claims = new Set(e.data.keys.map((c) => keySigs(c)[0]));
|
|
7598
|
+
forward = new Set(e.data.forward);
|
|
7599
|
+
} else if (type === 'mark' && pending) {
|
|
7509
7600
|
marks.set(e.data.id, pending);
|
|
7510
7601
|
hl?.add(pending);
|
|
7511
7602
|
pending = null;
|
|
@@ -7527,7 +7618,34 @@ function reviewBridge(textBefore, headingBefore, rangeAt) {
|
|
|
7527
7618
|
});
|
|
7528
7619
|
}
|
|
7529
7620
|
|
|
7530
|
-
const
|
|
7621
|
+
const TEXT_FIELD_SELECTOR = 'input, textarea, select, [contenteditable]';
|
|
7622
|
+
|
|
7623
|
+
const REVIEW_BRIDGE_TAG = `<script>(${reviewBridge})(${reviewTextBefore}, ${reviewHeadingBefore}, ${reviewRangeAt}, ${ClaudeHub.comboOf}, ${JSON.stringify(TEXT_FIELD_SELECTOR)});</script>`;
|
|
7624
|
+
|
|
7625
|
+
// Frames from createPreviewFrame, which carry the bridge. A URL pane is another site and must
|
|
7626
|
+
// not drive the board.
|
|
7627
|
+
const bridgedFrames = new WeakSet();
|
|
7628
|
+
|
|
7629
|
+
function bridgedFrameEls() {
|
|
7630
|
+
return [...document.querySelectorAll('iframe[srcdoc]')].filter((f) => bridgedFrames.has(f));
|
|
7631
|
+
}
|
|
7632
|
+
|
|
7633
|
+
function sendBridgeClaims(frames, claims) {
|
|
7634
|
+
for (const f of frames) f.contentWindow?.postMessage({ type: `${REVIEW_MSG}claims`, ...claims }, '*');
|
|
7635
|
+
}
|
|
7636
|
+
|
|
7637
|
+
window.addEventListener('message', (e) => {
|
|
7638
|
+
const type =
|
|
7639
|
+
typeof e.data?.type === 'string' && e.data.type.startsWith(REVIEW_MSG) && e.data.type.slice(REVIEW_MSG.length);
|
|
7640
|
+
if (type !== 'link' && type !== 'key') return;
|
|
7641
|
+
const frame = bridgedFrameEls().find((f) => f.contentWindow === e.source);
|
|
7642
|
+
if (!frame) return;
|
|
7643
|
+
if (type === 'key') replayKey(e.data, frame);
|
|
7644
|
+
else {
|
|
7645
|
+
const url = linkUrl(e.data.href);
|
|
7646
|
+
if (url) hub.openExternal(url);
|
|
7647
|
+
}
|
|
7648
|
+
});
|
|
7531
7649
|
|
|
7532
7650
|
function reviewPopEl() {
|
|
7533
7651
|
let el = document.getElementById('review-pop');
|
|
@@ -7774,14 +7892,20 @@ function addSessionPreviewPath(sessionId, filePath) {
|
|
|
7774
7892
|
const key = canonicalPath(filePath);
|
|
7775
7893
|
const paths = getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key);
|
|
7776
7894
|
paths.unshift(filePath);
|
|
7777
|
-
|
|
7895
|
+
saveSessionPreviewPaths(sessionId, paths);
|
|
7778
7896
|
}
|
|
7779
7897
|
|
|
7780
7898
|
function removeSessionPreviewPath(sessionId, filePath) {
|
|
7781
7899
|
if (!sessionId) return;
|
|
7782
7900
|
const key = canonicalPath(filePath);
|
|
7783
|
-
|
|
7784
|
-
|
|
7901
|
+
saveSessionPreviewPaths(
|
|
7902
|
+
sessionId,
|
|
7903
|
+
getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key),
|
|
7904
|
+
);
|
|
7905
|
+
}
|
|
7906
|
+
|
|
7907
|
+
function saveSessionPreviewPaths(sessionId, paths) {
|
|
7908
|
+
if (paths.length) store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths.slice(0, MAX_LINKED_DOCS)));
|
|
7785
7909
|
else store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
7786
7910
|
}
|
|
7787
7911
|
|
|
@@ -7811,7 +7935,7 @@ async function mergeServerLinkedDocs() {
|
|
|
7811
7935
|
const known = new Set(local.map(canonicalPath));
|
|
7812
7936
|
const missing = (paths || []).filter((p) => !known.has(canonicalPath(p)));
|
|
7813
7937
|
if (!missing.length) continue;
|
|
7814
|
-
|
|
7938
|
+
saveSessionPreviewPaths(sessionId, [...missing, ...local]);
|
|
7815
7939
|
afterLinkedDocsChanged(sessionId);
|
|
7816
7940
|
}
|
|
7817
7941
|
}
|
|
@@ -7854,19 +7978,29 @@ function renderFrontmatterBlock(fm) {
|
|
|
7854
7978
|
// allow-same-origin puts it on an opaque origin, so it cannot touch this app's
|
|
7855
7979
|
// storage, DOM or API. srcdoc has no base URL, so the server has already embedded
|
|
7856
7980
|
// the document's local assets (lib/inline-assets.js); remote refs load normally.
|
|
7857
|
-
function
|
|
7858
|
-
bodyEl.innerHTML = '';
|
|
7981
|
+
function createPreviewFrame(className, srcdoc) {
|
|
7859
7982
|
const frame = document.createElement('iframe');
|
|
7860
|
-
frame.className =
|
|
7983
|
+
frame.className = className;
|
|
7861
7984
|
frame.setAttribute('sandbox', 'allow-scripts allow-popups');
|
|
7862
7985
|
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
7986
|
+
frame.srcdoc = srcdoc;
|
|
7987
|
+
bridgedFrames.add(frame);
|
|
7988
|
+
frame.addEventListener('load', () => sendBridgeClaims([frame], keyClaims()));
|
|
7989
|
+
return frame;
|
|
7990
|
+
}
|
|
7991
|
+
|
|
7992
|
+
function renderHtmlPreview(bodyEl, content) {
|
|
7993
|
+
bodyEl.innerHTML = '';
|
|
7863
7994
|
// After the document's end the parser still adds the script to <body>, and it runs after
|
|
7864
7995
|
// the page's own scripts.
|
|
7865
|
-
frame
|
|
7866
|
-
bodyEl.appendChild(frame);
|
|
7996
|
+
bodyEl.appendChild(createPreviewFrame('preview-html-frame', content + REVIEW_BRIDGE_TAG));
|
|
7867
7997
|
}
|
|
7868
7998
|
|
|
7869
|
-
function
|
|
7999
|
+
function isAbsolutePath(p) {
|
|
8000
|
+
return /^([a-zA-Z]:[\\/]|[\\/])/.test(p);
|
|
8001
|
+
}
|
|
8002
|
+
|
|
8003
|
+
function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
|
|
7870
8004
|
if (bodyEl.dataset.relLinkBound) return;
|
|
7871
8005
|
bodyEl.addEventListener('click', (e) => {
|
|
7872
8006
|
const a = e.target.closest('a[href]');
|
|
@@ -7874,11 +8008,10 @@ function bindPreviewRelativeLinks(bodyEl) {
|
|
|
7874
8008
|
const href = a.getAttribute('href');
|
|
7875
8009
|
if (!href || href.startsWith('#')) return;
|
|
7876
8010
|
const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
|
|
7877
|
-
const isAbsolutePath = href.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(href);
|
|
7878
8011
|
if (isAbsoluteUrl) return;
|
|
7879
8012
|
const cleanHref = href.replace(/#.*$/, '');
|
|
7880
8013
|
e.preventDefault();
|
|
7881
|
-
openPreviewByPath(cleanHref, isAbsolutePath ? undefined :
|
|
8014
|
+
openPreviewByPath(cleanHref, isAbsolutePath(href) ? undefined : baseOf(a), openFileInEditor);
|
|
7882
8015
|
});
|
|
7883
8016
|
bodyEl.dataset.relLinkBound = '1';
|
|
7884
8017
|
}
|
|
@@ -7901,41 +8034,50 @@ function renderSourcePreview(filePath, content) {
|
|
|
7901
8034
|
return `<pre class="preview-source"><code class="hljs">${escapeHtml(content)}</code></pre>`;
|
|
7902
8035
|
}
|
|
7903
8036
|
|
|
7904
|
-
|
|
7905
|
-
|
|
7906
|
-
|
|
7907
|
-
document.getElementById('preview-modal-title').textContent = fileName;
|
|
7908
|
-
const bodyEl = document.getElementById('preview-modal-body');
|
|
7909
|
-
const isHtml = kind === 'html';
|
|
7910
|
-
document.querySelector('#preview-modal .modal').classList.toggle('preview-html', isHtml);
|
|
7911
|
-
bindPreviewRelativeLinks(bodyEl);
|
|
7912
|
-
if (isHtml) {
|
|
7913
|
-
renderHtmlPreview(bodyEl, content);
|
|
7914
|
-
} else if (kind === 'text') {
|
|
8037
|
+
// Every kind but html, which needs a frame.
|
|
8038
|
+
function renderPreviewContent(bodyEl, filePath, content, kind) {
|
|
8039
|
+
if (kind === 'text') {
|
|
7915
8040
|
bodyEl.innerHTML = renderSourcePreview(filePath, content);
|
|
7916
8041
|
} else if (kind === 'image') {
|
|
7917
8042
|
const img = document.createElement('img');
|
|
7918
8043
|
img.className = 'preview-image';
|
|
7919
8044
|
img.src = `/api/preview/image?${new URLSearchParams({ path: filePath })}`;
|
|
7920
|
-
img.alt =
|
|
8045
|
+
img.alt = filePath.split(/[\\/]/).pop();
|
|
7921
8046
|
bodyEl.replaceChildren(img);
|
|
7922
8047
|
} else {
|
|
7923
8048
|
const { fm, body } = splitFrontmatter(content);
|
|
7924
8049
|
bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
|
|
7925
8050
|
}
|
|
8051
|
+
}
|
|
8052
|
+
|
|
8053
|
+
function openPreviewModal(filePath, content, kind) {
|
|
8054
|
+
currentPreviewPath = filePath;
|
|
8055
|
+
const fileName = filePath.split(/[\\/]/).pop();
|
|
8056
|
+
document.getElementById('preview-modal-title').textContent = fileName;
|
|
8057
|
+
const bodyEl = document.getElementById('preview-modal-body');
|
|
8058
|
+
const isHtml = kind === 'html';
|
|
8059
|
+
document.querySelector('#preview-modal .modal').classList.toggle('preview-html', isHtml);
|
|
8060
|
+
bindPreviewRelativeLinks(bodyEl);
|
|
8061
|
+
if (isHtml) renderHtmlPreview(bodyEl, content);
|
|
8062
|
+
else renderPreviewContent(bodyEl, filePath, content, kind);
|
|
7926
8063
|
document.getElementById('preview-modal-meta').textContent = filePath;
|
|
7927
8064
|
document.getElementById('preview-modal').classList.add('visible');
|
|
7928
8065
|
updatePreviewLinkBtn();
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
|
|
7935
|
-
|
|
7936
|
-
|
|
7937
|
-
|
|
7938
|
-
|
|
8066
|
+
const panelEl = document.getElementById('preview-review-panel');
|
|
8067
|
+
const opts = fileReviewOpts(filePath, kind, bodyEl, isHtml ? bodyEl.querySelector('iframe') : null);
|
|
8068
|
+
if (!opts) return unmountReview(panelEl);
|
|
8069
|
+
mountReview({ ...opts, panelEl, hostEl: document.querySelector('#preview-modal .modal'), onSent: closePreviewModal });
|
|
8070
|
+
}
|
|
8071
|
+
|
|
8072
|
+
// Null for an image, which takes no comments.
|
|
8073
|
+
function fileReviewOpts(filePath, kind, contentEl, frame) {
|
|
8074
|
+
if (kind === 'image') return null;
|
|
8075
|
+
return {
|
|
8076
|
+
contentEl,
|
|
8077
|
+
frame,
|
|
8078
|
+
source: { kind: 'file', label: pathBasename(filePath), path: filePath },
|
|
8079
|
+
context: kind === 'text' ? (range) => ({ line: sourceLineOf(contentEl, range) }) : null,
|
|
8080
|
+
};
|
|
7939
8081
|
}
|
|
7940
8082
|
|
|
7941
8083
|
function sourceLineOf(bodyEl, range) {
|
|
@@ -7951,6 +8093,8 @@ function isPreviewLinkedToCurrentSession() {
|
|
|
7951
8093
|
}
|
|
7952
8094
|
|
|
7953
8095
|
function updatePreviewLinkBtn() {
|
|
8096
|
+
const paneBtn = document.getElementById('preview-pane-btn');
|
|
8097
|
+
if (paneBtn) paneBtn.style.display = paneSessionId() ? '' : 'none';
|
|
7954
8098
|
const btn = document.getElementById('preview-link-btn');
|
|
7955
8099
|
if (!btn) return;
|
|
7956
8100
|
if (!currentSessionId) {
|
|
@@ -8219,11 +8363,35 @@ function openLinkedDoc(p, baseDir) {
|
|
|
8219
8363
|
postAndToast(url, body(p), toast);
|
|
8220
8364
|
}
|
|
8221
8365
|
|
|
8366
|
+
const LINKED_DOCS_COLLAPSED = 10;
|
|
8367
|
+
const linkedDocsExpanded = new Set();
|
|
8368
|
+
|
|
8369
|
+
function showAllToggle(expandedSet, key, list, limit, btnAttrs) {
|
|
8370
|
+
const expanded = expandedSet.has(key);
|
|
8371
|
+
const shown = expanded ? list : list.slice(0, limit);
|
|
8372
|
+
const moreHtml =
|
|
8373
|
+
list.length > limit
|
|
8374
|
+
? `<button type="button" class="expand-toggle-btn list-more" ${btnAttrs}>${expanded ? 'Show less' : `Show all ${list.length}`}</button>`
|
|
8375
|
+
: '';
|
|
8376
|
+
return { shown, moreHtml };
|
|
8377
|
+
}
|
|
8378
|
+
|
|
8379
|
+
function toggleSetMember(set, key) {
|
|
8380
|
+
if (!set.delete(key)) set.add(key);
|
|
8381
|
+
}
|
|
8382
|
+
|
|
8222
8383
|
function renderLinkedDocsHtml(sessionId) {
|
|
8223
8384
|
const paths = getSessionPreviewPaths(sessionId);
|
|
8385
|
+
const { shown, moreHtml } = showAllToggle(
|
|
8386
|
+
linkedDocsExpanded,
|
|
8387
|
+
sessionId,
|
|
8388
|
+
paths,
|
|
8389
|
+
LINKED_DOCS_COLLAPSED,
|
|
8390
|
+
'data-linked-docs-more',
|
|
8391
|
+
);
|
|
8224
8392
|
// Kicked off from the render rather than the three call sites, so a new surface that
|
|
8225
8393
|
// shows linked docs cannot forget it. Returns at once once every path is cached.
|
|
8226
|
-
const rows =
|
|
8394
|
+
const rows = shown.map((p) => ({ p, opener: linkedDocOpener(p) }));
|
|
8227
8395
|
const filePaths = rows.filter((r) => r.opener !== 'url').map((r) => r.p);
|
|
8228
8396
|
loadPreviewKinds(sessionId, filePaths);
|
|
8229
8397
|
const baseDir = getSessionBaseDir(sessionId);
|
|
@@ -8248,7 +8416,7 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
8248
8416
|
.join('');
|
|
8249
8417
|
// Rendered even when empty — the add button has to stay reachable.
|
|
8250
8418
|
const body = paths.length
|
|
8251
|
-
? `<ul class="linked-doc-list">${items}</ul
|
|
8419
|
+
? `<ul class="linked-doc-list">${items}</ul>${moreHtml}`
|
|
8252
8420
|
: '<div class="linked-docs-empty">No linked documents yet</div>';
|
|
8253
8421
|
return `<div class="linked-docs-section panel-section">
|
|
8254
8422
|
<div class="panel-section-header">
|
|
@@ -8467,13 +8635,14 @@ function scratchRowsHtml(rows) {
|
|
|
8467
8635
|
function scratchFilesInnerHtml(sessionId) {
|
|
8468
8636
|
const list = scratchFilesSection.get(sessionId);
|
|
8469
8637
|
if (!list.length) return '';
|
|
8470
|
-
const
|
|
8471
|
-
|
|
8472
|
-
|
|
8473
|
-
list
|
|
8474
|
-
|
|
8475
|
-
|
|
8476
|
-
|
|
8638
|
+
const { shown, moreHtml } = showAllToggle(
|
|
8639
|
+
scratchFilesExpanded,
|
|
8640
|
+
sessionId,
|
|
8641
|
+
list,
|
|
8642
|
+
SCRATCH_FILES_COLLAPSED,
|
|
8643
|
+
`onclick="toggleScratchFiles('${escAttrJs(sessionId)}')"`,
|
|
8644
|
+
);
|
|
8645
|
+
return `${scratchRowsHtml(shown)}${moreHtml}`;
|
|
8477
8646
|
}
|
|
8478
8647
|
|
|
8479
8648
|
// Delegated at the document because the whole zen panel is re-rendered on every SSE tick,
|
|
@@ -8513,8 +8682,7 @@ function renderScratchFilesHtml(sessionId) {
|
|
|
8513
8682
|
|
|
8514
8683
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
8515
8684
|
function toggleScratchFiles(sessionId) {
|
|
8516
|
-
|
|
8517
|
-
else scratchFilesExpanded.add(sessionId);
|
|
8685
|
+
toggleSetMember(scratchFilesExpanded, sessionId);
|
|
8518
8686
|
scratchFilesSection.repaint(sessionId);
|
|
8519
8687
|
}
|
|
8520
8688
|
|
|
@@ -8523,10 +8691,17 @@ function toggleScratchFiles(sessionId) {
|
|
|
8523
8691
|
function bindLinkedDocsHandlers(container, sessionId) {
|
|
8524
8692
|
if (!container) return;
|
|
8525
8693
|
container.addEventListener('click', (e) => {
|
|
8526
|
-
const hit = e.target.closest(
|
|
8694
|
+
const hit = e.target.closest(
|
|
8695
|
+
'.linked-doc-link, .linked-doc-copy, .linked-doc-remove, .linked-docs-add-btn, [data-linked-docs-more]',
|
|
8696
|
+
);
|
|
8527
8697
|
if (!hit || hit.target === '_blank') return;
|
|
8528
8698
|
e.preventDefault();
|
|
8529
|
-
if (hit.
|
|
8699
|
+
if (hit.hasAttribute('data-linked-docs-more')) {
|
|
8700
|
+
toggleSetMember(linkedDocsExpanded, sessionId);
|
|
8701
|
+
const wrap = document.createElement('div');
|
|
8702
|
+
wrap.innerHTML = renderLinkedDocsHtml(sessionId);
|
|
8703
|
+
container.replaceChildren(...wrap.firstElementChild.childNodes);
|
|
8704
|
+
} else if (hit.classList.contains('linked-docs-add-btn')) {
|
|
8530
8705
|
startLinkedDocInput(container, sessionId);
|
|
8531
8706
|
} else if (hit.classList.contains('linked-doc-copy')) {
|
|
8532
8707
|
copyWithFeedback(hit.dataset.path, hit);
|
|
@@ -8656,6 +8831,7 @@ function setupEventSource() {
|
|
|
8656
8831
|
console.warn('[SSE] Reconnected after drop — forcing full refresh');
|
|
8657
8832
|
fetchSessions().catch(() => {});
|
|
8658
8833
|
if (currentSessionId) fetchTasks(currentSessionId);
|
|
8834
|
+
refetchPaneLayout();
|
|
8659
8835
|
if (terminalAvailable())
|
|
8660
8836
|
loadTerminals()
|
|
8661
8837
|
.then(renderSessions)
|
|
@@ -8763,6 +8939,10 @@ function setupEventSource() {
|
|
|
8763
8939
|
handleSessionPinEvent(data);
|
|
8764
8940
|
}
|
|
8765
8941
|
|
|
8942
|
+
if (data.type === 'pane:changed') {
|
|
8943
|
+
applyPaneLayout(data.sessionId, data.layout);
|
|
8944
|
+
}
|
|
8945
|
+
|
|
8766
8946
|
if (data.type === 'team-update') {
|
|
8767
8947
|
const teamSession = sessions.find((s) => s.isTeam && s.teamName === data.teamName);
|
|
8768
8948
|
if (teamSession) {
|
|
@@ -11633,6 +11813,644 @@ function filterByOwner(value) {
|
|
|
11633
11813
|
|
|
11634
11814
|
//#endregion
|
|
11635
11815
|
|
|
11816
|
+
//#region PANES
|
|
11817
|
+
// Layouts come from /api/panes and `pane:changed` (the pane contract, lib/panes.js). Every write
|
|
11818
|
+
// bumps the layout's rev and the event carries the whole layout, so a copy is replaced only by
|
|
11819
|
+
// a higher rev: the echo of this tab's own write and a late GET both lose to what it holds.
|
|
11820
|
+
// Which tab is on screen is this browser tab's own state, never the server's.
|
|
11821
|
+
const PANE_EMPTY_LAYOUT = { panes: [] };
|
|
11822
|
+
const paneLayouts = new Map();
|
|
11823
|
+
let paneLayoutSid = null;
|
|
11824
|
+
|
|
11825
|
+
// Targets the server's probe found refusing framing, kept for the page's life; a reload asks again.
|
|
11826
|
+
const paneRefused = new Set();
|
|
11827
|
+
|
|
11828
|
+
// Every origin above a pane's frame. Firefox has no ancestorOrigins, so under the hub there it
|
|
11829
|
+
// names the board only; a refused frame then shows the browser's own error.
|
|
11830
|
+
function paneAncestors() {
|
|
11831
|
+
return [location.origin, ...(location.ancestorOrigins || [])];
|
|
11832
|
+
}
|
|
11833
|
+
|
|
11834
|
+
function paneFrameable(pane) {
|
|
11835
|
+
return pane.kind !== 'url' || !paneRefused.has(pane.target);
|
|
11836
|
+
}
|
|
11837
|
+
|
|
11838
|
+
// allow-scripts with allow-same-origin on the board's or the hub's origin would lift the sandbox.
|
|
11839
|
+
// The server refuses the board's own names, but under the hub the page is served from the
|
|
11840
|
+
// hub's proxy port, which only the page knows. Any loopback name reaches the same port.
|
|
11841
|
+
const PANE_LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
11842
|
+
|
|
11843
|
+
function paneOwnOrigin(pane) {
|
|
11844
|
+
if (pane.kind !== 'url') return false;
|
|
11845
|
+
const u = new URL(pane.target);
|
|
11846
|
+
const loopback = PANE_LOOPBACK.has(u.hostname);
|
|
11847
|
+
return paneAncestors().some((origin) => {
|
|
11848
|
+
const a = new URL(origin);
|
|
11849
|
+
const host = a.hostname === u.hostname || (loopback && PANE_LOOPBACK.has(a.hostname));
|
|
11850
|
+
return a.protocol === u.protocol && a.port === u.port && host;
|
|
11851
|
+
});
|
|
11852
|
+
}
|
|
11853
|
+
|
|
11854
|
+
async function probePaneFraming(sid, pane) {
|
|
11855
|
+
const q = new URLSearchParams({ ancestors: paneAncestors().join(',') });
|
|
11856
|
+
const { frameable } = await paneRequest(
|
|
11857
|
+
'GET',
|
|
11858
|
+
`/api/panes/${encodeURIComponent(sid)}/${encodeURIComponent(pane.id)}/framing?${q}`,
|
|
11859
|
+
);
|
|
11860
|
+
if (frameable === false) paneRefused.add(pane.target);
|
|
11861
|
+
return frameable;
|
|
11862
|
+
}
|
|
11863
|
+
|
|
11864
|
+
function paneLayout(sid) {
|
|
11865
|
+
return paneLayouts.get(sid) || PANE_EMPTY_LAYOUT;
|
|
11866
|
+
}
|
|
11867
|
+
|
|
11868
|
+
function applyPaneLayout(sid, layout) {
|
|
11869
|
+
const held = paneLayouts.get(sid);
|
|
11870
|
+
if (!layout || (held && layout.rev <= held.rev)) return;
|
|
11871
|
+
paneLayouts.set(sid, layout);
|
|
11872
|
+
const targets = new Map(layout.panes.map((p) => [p.id, p.target]));
|
|
11873
|
+
for (const p of held?.panes || []) {
|
|
11874
|
+
if (targets.get(p.id) !== p.target) unmountPane(sid, p.id);
|
|
11875
|
+
}
|
|
11876
|
+
if (!targets.has(getActivePaneId(sid))) setActivePaneId(sid, 'board');
|
|
11877
|
+
if (sid !== paneSessionId()) return;
|
|
11878
|
+
syncPanes();
|
|
11879
|
+
autoRevealLog(sid);
|
|
11880
|
+
}
|
|
11881
|
+
|
|
11882
|
+
async function fetchPaneLayout(sid) {
|
|
11883
|
+
try {
|
|
11884
|
+
const r = await fetch(`/api/panes/${encodeURIComponent(sid)}`);
|
|
11885
|
+
if (r.ok) applyPaneLayout(sid, await r.json());
|
|
11886
|
+
} catch {}
|
|
11887
|
+
}
|
|
11888
|
+
|
|
11889
|
+
function refetchPaneLayout() {
|
|
11890
|
+
const sid = paneSessionId();
|
|
11891
|
+
if (sid) fetchPaneLayout(sid);
|
|
11892
|
+
}
|
|
11893
|
+
|
|
11894
|
+
async function paneRequest(method, path, body) {
|
|
11895
|
+
try {
|
|
11896
|
+
const r = await fetch(path, {
|
|
11897
|
+
method,
|
|
11898
|
+
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
11899
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
11900
|
+
});
|
|
11901
|
+
const data = await r.json().catch(() => ({}));
|
|
11902
|
+
return r.ok ? data : { error: data.error || `${method} failed (${r.status})`, code: data.code };
|
|
11903
|
+
} catch {
|
|
11904
|
+
return { error: 'The board is not reachable', code: 'network' };
|
|
11905
|
+
}
|
|
11906
|
+
}
|
|
11907
|
+
|
|
11908
|
+
// sessionStorage `cck.paneActive`: {[sessionId]: paneId} in recency order.
|
|
11909
|
+
const PANE_ACTIVE_KEY = 'cck.paneActive';
|
|
11910
|
+
const PANE_ACTIVE_CAP = 20;
|
|
11911
|
+
let paneActive = {};
|
|
11912
|
+
try {
|
|
11913
|
+
const map = JSON.parse(sessionStorage.getItem(PANE_ACTIVE_KEY));
|
|
11914
|
+
if (map && typeof map === 'object') paneActive = map;
|
|
11915
|
+
} catch {}
|
|
11916
|
+
|
|
11917
|
+
function getActivePaneId(sid) {
|
|
11918
|
+
return paneActive[sid] || 'board';
|
|
11919
|
+
}
|
|
11920
|
+
|
|
11921
|
+
function setActivePaneId(sid, id) {
|
|
11922
|
+
if (getActivePaneId(sid) === id) return;
|
|
11923
|
+
delete paneActive[sid];
|
|
11924
|
+
if (id !== 'board') paneActive[sid] = id;
|
|
11925
|
+
paneActive = Object.fromEntries(Object.entries(paneActive).slice(-PANE_ACTIVE_CAP));
|
|
11926
|
+
try {
|
|
11927
|
+
sessionStorage.setItem(PANE_ACTIVE_KEY, JSON.stringify(paneActive));
|
|
11928
|
+
} catch {}
|
|
11929
|
+
}
|
|
11930
|
+
|
|
11931
|
+
const PANE_FRAME_LIMIT = 3;
|
|
11932
|
+
const PANE_URL_SANDBOX = 'allow-scripts allow-same-origin allow-forms allow-popups allow-downloads';
|
|
11933
|
+
const PANE_ICONS = {
|
|
11934
|
+
board:
|
|
11935
|
+
'<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="10" rx="1"/><rect x="17" y="4" width="4" height="13" rx="1"/></svg>',
|
|
11936
|
+
url: '<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg>',
|
|
11937
|
+
file: '<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
|
|
11938
|
+
warning:
|
|
11939
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12" y2="17"/></svg>',
|
|
11940
|
+
};
|
|
11941
|
+
// Map order is recency: the first entry is the one evicted. Keys are `${sessionId}/${paneId}`,
|
|
11942
|
+
// and the limit spans sessions, so a session switch cannot pile up frames.
|
|
11943
|
+
const paneFrames = new Map();
|
|
11944
|
+
const paneReviews = new WeakMap();
|
|
11945
|
+
const paneViews = document.getElementById('pane-views');
|
|
11946
|
+
const paneTabs = document.getElementById('pane-tabs');
|
|
11947
|
+
const panePin = document.getElementById('pane-pin');
|
|
11948
|
+
const paneStrip = document.getElementById('pane-strip');
|
|
11949
|
+
const panePop = document.getElementById('pane-pop');
|
|
11950
|
+
const panePopInput = document.getElementById('pane-pop-input');
|
|
11951
|
+
const panePopDocs = document.getElementById('pane-pop-docs');
|
|
11952
|
+
const panePopDetect = document.getElementById('pane-pop-detect');
|
|
11953
|
+
let paneTabsHtml = '';
|
|
11954
|
+
|
|
11955
|
+
function paneSessionId() {
|
|
11956
|
+
return viewMode === 'session' ? currentSessionId : null;
|
|
11957
|
+
}
|
|
11958
|
+
|
|
11959
|
+
function activePane(sid) {
|
|
11960
|
+
const id = getActivePaneId(sid);
|
|
11961
|
+
return paneLayout(sid).panes.find((p) => p.id === id) || null;
|
|
11962
|
+
}
|
|
11963
|
+
|
|
11964
|
+
function syncPanes() {
|
|
11965
|
+
const sid = paneSessionId();
|
|
11966
|
+
if (sid !== paneLayoutSid) {
|
|
11967
|
+
paneLayoutSid = sid;
|
|
11968
|
+
if (sid) fetchPaneLayout(sid);
|
|
11969
|
+
}
|
|
11970
|
+
const layout = sid ? paneLayout(sid) : null;
|
|
11971
|
+
const pane = sid ? activePane(sid) : null;
|
|
11972
|
+
sessionView.classList.toggle('has-panes', !!layout);
|
|
11973
|
+
sessionView.classList.toggle('pane-mode', !!pane);
|
|
11974
|
+
if (!layout || wantsTerminal()) closePanePop();
|
|
11975
|
+
const key = pane ? mountPane(sid, pane) : null;
|
|
11976
|
+
for (const el of paneViews.children) el.classList.toggle('on', el.dataset.key === key);
|
|
11977
|
+
// Off screen, a framed page keeps focus and takes keys the user aims at the board.
|
|
11978
|
+
const focused = document.activeElement;
|
|
11979
|
+
if (paneViews.contains(focused) && (wantsTerminal() || !focused.closest('.pane-view.on'))) focused.blur();
|
|
11980
|
+
syncPaneReview();
|
|
11981
|
+
if (!layout) return;
|
|
11982
|
+
for (const b of document.querySelectorAll('.pane-tool')) b.disabled = !pane;
|
|
11983
|
+
renderPaneTabs(sid, layout);
|
|
11984
|
+
}
|
|
11985
|
+
|
|
11986
|
+
function renderPaneTabs(sid, layout) {
|
|
11987
|
+
const active = getActivePaneId(sid);
|
|
11988
|
+
const tab = (id, cls, inner, tip) =>
|
|
11989
|
+
`<div class="pane-tab ${escapeHtml(cls)}${active === id ? ' on' : ''}" role="tab" data-pane="${escapeHtml(id)}" title="${escapeHtml(tip)}">${inner}</div>`;
|
|
11990
|
+
const board = tab(
|
|
11991
|
+
'board',
|
|
11992
|
+
'pinned',
|
|
11993
|
+
`${PANE_ICONS.board}<span class="pane-title">Board</span><span class="pin-meta">${currentTasks.length}</span>`,
|
|
11994
|
+
'Board',
|
|
11995
|
+
);
|
|
11996
|
+
const panes = layout.panes.map((p) => {
|
|
11997
|
+
const state = !paneFrameable(p) ? 'blocked' : paneFrames.has(`${sid}/${p.id}`) ? 'loaded' : '';
|
|
11998
|
+
const stateTip = state === 'blocked' ? 'Site refuses framing' : state ? 'Loaded' : 'Unloaded (loads on open)';
|
|
11999
|
+
return tab(
|
|
12000
|
+
p.id,
|
|
12001
|
+
'',
|
|
12002
|
+
`${p.kind === 'url' ? PANE_ICONS.url : PANE_ICONS.file}<span class="pane-title">${escapeHtml(p.title)}</span><span class="state ${escapeHtml(state)}" title="${escapeHtml(stateTip)}"></span><button class="pane-x" data-close="${escapeHtml(p.id)}" title="Close pane" aria-label="Close pane">×</button>`,
|
|
12003
|
+
p.target,
|
|
12004
|
+
);
|
|
12005
|
+
});
|
|
12006
|
+
const pin = board + (panes.length ? '<span class="pane-sep"></span>' : '');
|
|
12007
|
+
const html = panes.join('');
|
|
12008
|
+
// syncPanes runs on every task refresh and search keystroke; skip the rebuild and the reflows.
|
|
12009
|
+
if (pin + html === paneTabsHtml) return;
|
|
12010
|
+
paneTabsHtml = pin + html;
|
|
12011
|
+
panePin.innerHTML = pin;
|
|
12012
|
+
paneTabs.innerHTML = html;
|
|
12013
|
+
paneTabs.querySelector('.pane-tab.on')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
12014
|
+
updatePaneFades();
|
|
12015
|
+
}
|
|
12016
|
+
|
|
12017
|
+
function updatePaneFades() {
|
|
12018
|
+
const { scrollLeft, scrollWidth, clientWidth } = paneTabs;
|
|
12019
|
+
paneTabs.classList.toggle('fade-l', scrollLeft > 1);
|
|
12020
|
+
paneTabs.classList.toggle('fade-r', scrollLeft + clientWidth < scrollWidth - 1);
|
|
12021
|
+
}
|
|
12022
|
+
|
|
12023
|
+
function paneCardHtml(title, text, button) {
|
|
12024
|
+
return `<div class="pane-card"><div class="pane-card-box">${PANE_ICONS.warning}<h3>${escapeHtml(title)}</h3><p>${escapeHtml(text)}</p><button class="btn btn-primary" data-pane-open>${escapeHtml(button)}</button></div></div>`;
|
|
12025
|
+
}
|
|
12026
|
+
|
|
12027
|
+
// Returns the key of the view to show.
|
|
12028
|
+
function mountPane(sid, pane) {
|
|
12029
|
+
const key = `${sid}/${pane.id}`;
|
|
12030
|
+
const existing = paneFrames.get(key);
|
|
12031
|
+
if (existing) {
|
|
12032
|
+
paneFrames.delete(key);
|
|
12033
|
+
paneFrames.set(key, existing);
|
|
12034
|
+
return key;
|
|
12035
|
+
}
|
|
12036
|
+
const view = document.createElement('div');
|
|
12037
|
+
view.className = 'pane-view';
|
|
12038
|
+
view.dataset.key = key;
|
|
12039
|
+
paneViews.appendChild(view);
|
|
12040
|
+
paneFrames.set(key, view);
|
|
12041
|
+
loadPaneView(sid, pane, view);
|
|
12042
|
+
while (paneFrames.size > PANE_FRAME_LIMIT) {
|
|
12043
|
+
const [oldKey, oldView] = paneFrames.entries().next().value;
|
|
12044
|
+
paneFrames.delete(oldKey);
|
|
12045
|
+
oldView.remove();
|
|
12046
|
+
}
|
|
12047
|
+
return key;
|
|
12048
|
+
}
|
|
12049
|
+
|
|
12050
|
+
async function loadPaneView(sid, pane, view) {
|
|
12051
|
+
if (paneOwnOrigin(pane)) {
|
|
12052
|
+
view.insertAdjacentHTML(
|
|
12053
|
+
'beforeend',
|
|
12054
|
+
paneCardHtml('A pane cannot show the board or the hub', pane.target, 'Open in new tab'),
|
|
12055
|
+
);
|
|
12056
|
+
return;
|
|
12057
|
+
}
|
|
12058
|
+
if (!paneFrameable(pane)) {
|
|
12059
|
+
view.insertAdjacentHTML(
|
|
12060
|
+
'beforeend',
|
|
12061
|
+
paneCardHtml(
|
|
12062
|
+
`${new URL(pane.target).host} does not allow framing`,
|
|
12063
|
+
'The site sends X-Frame-Options or a frame-ancestors policy. The pane keeps the link with the session.',
|
|
12064
|
+
'Open in new tab',
|
|
12065
|
+
),
|
|
12066
|
+
);
|
|
12067
|
+
return;
|
|
12068
|
+
}
|
|
12069
|
+
if (pane.kind === 'url') {
|
|
12070
|
+
const frame = document.createElement('iframe');
|
|
12071
|
+
frame.className = 'pane-frame';
|
|
12072
|
+
frame.setAttribute('sandbox', PANE_URL_SANDBOX);
|
|
12073
|
+
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
12074
|
+
frame.title = pane.title;
|
|
12075
|
+
frame.src = pane.target;
|
|
12076
|
+
view.appendChild(frame);
|
|
12077
|
+
// Framed at once; a refusal found by the probe swaps the frame for the card.
|
|
12078
|
+
if ((await probePaneFraming(sid, pane)) === false) {
|
|
12079
|
+
frame.remove();
|
|
12080
|
+
loadPaneView(sid, pane, view);
|
|
12081
|
+
if (sid === paneSessionId()) syncPanes();
|
|
12082
|
+
}
|
|
12083
|
+
return;
|
|
12084
|
+
}
|
|
12085
|
+
let data = null;
|
|
12086
|
+
try {
|
|
12087
|
+
const r = await fetch(`/api/preview?${new URLSearchParams({ path: pane.target })}`);
|
|
12088
|
+
if (r.ok) data = await r.json();
|
|
12089
|
+
} catch {}
|
|
12090
|
+
if (data?.kind === 'html') {
|
|
12091
|
+
const frame = createPreviewFrame('pane-frame', data.content + REVIEW_BRIDGE_TAG);
|
|
12092
|
+
frame.title = pane.title;
|
|
12093
|
+
view.appendChild(frame);
|
|
12094
|
+
attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, frame, frame));
|
|
12095
|
+
return;
|
|
12096
|
+
}
|
|
12097
|
+
if (data?.kind) {
|
|
12098
|
+
const doc = document.createElement('div');
|
|
12099
|
+
doc.className = 'pane-doc';
|
|
12100
|
+
doc.dataset.path = data.path;
|
|
12101
|
+
const body = document.createElement('div');
|
|
12102
|
+
body.className = 'pane-doc-body rendered-md modal-zoomable';
|
|
12103
|
+
renderPreviewContent(body, data.path, data.content, data.kind);
|
|
12104
|
+
doc.append(body, paneDocGrip(doc));
|
|
12105
|
+
view.appendChild(doc);
|
|
12106
|
+
attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, body, null));
|
|
12107
|
+
return;
|
|
12108
|
+
}
|
|
12109
|
+
const [title, text] = !data
|
|
12110
|
+
? ['Preview unavailable', 'The server could not read this file.']
|
|
12111
|
+
: data.exists === false
|
|
12112
|
+
? ['File not found', pane.target]
|
|
12113
|
+
: ['Nothing to preview', 'cck cannot render this file.'];
|
|
12114
|
+
const button = data && data.exists !== false ? 'Open in editor' : 'Try in preview';
|
|
12115
|
+
view.insertAdjacentHTML('beforeend', paneCardHtml(title, text, button));
|
|
12116
|
+
}
|
|
12117
|
+
|
|
12118
|
+
// One reading width for every pane, kept apart from the modals': a modal's saved size
|
|
12119
|
+
// includes its padding and review panel.
|
|
12120
|
+
const PANE_DOC_W_KEY = 'pane-doc-width';
|
|
12121
|
+
|
|
12122
|
+
function applyPaneDocWidth(w) {
|
|
12123
|
+
if (w) document.documentElement.style.setProperty('--pane-doc-w', w);
|
|
12124
|
+
else document.documentElement.style.removeProperty('--pane-doc-w');
|
|
12125
|
+
}
|
|
12126
|
+
applyPaneDocWidth(store.getItem(PANE_DOC_W_KEY));
|
|
12127
|
+
|
|
12128
|
+
// The drag sizes only this sheet; the shared width lands once on release, so a
|
|
12129
|
+
// drag does not restyle the whole page on every move.
|
|
12130
|
+
function paneDocGrip(doc) {
|
|
12131
|
+
let startW;
|
|
12132
|
+
return createResizeGrip({
|
|
12133
|
+
onStart() {
|
|
12134
|
+
startW = doc.offsetWidth;
|
|
12135
|
+
},
|
|
12136
|
+
// The sheet is centered, so both edges move: double the delta to keep the grip under the cursor.
|
|
12137
|
+
onMove(dx) {
|
|
12138
|
+
doc.style.maxWidth = `${Math.max(MODAL_MIN_W, startW + dx * 2)}px`;
|
|
12139
|
+
},
|
|
12140
|
+
onEnd() {
|
|
12141
|
+
const w = doc.style.maxWidth;
|
|
12142
|
+
if (!w) return;
|
|
12143
|
+
doc.style.maxWidth = '';
|
|
12144
|
+
store.setItem(PANE_DOC_W_KEY, w);
|
|
12145
|
+
applyPaneDocWidth(w);
|
|
12146
|
+
},
|
|
12147
|
+
onReset() {
|
|
12148
|
+
store.removeItem(PANE_DOC_W_KEY);
|
|
12149
|
+
applyPaneDocWidth(null);
|
|
12150
|
+
},
|
|
12151
|
+
});
|
|
12152
|
+
}
|
|
12153
|
+
|
|
12154
|
+
function activePaneView() {
|
|
12155
|
+
return wantsTerminal() ? null : paneViews.querySelector('.pane-view.on');
|
|
12156
|
+
}
|
|
12157
|
+
|
|
12158
|
+
function isPaneDocOnScreen() {
|
|
12159
|
+
return !!activePaneView()?.querySelector('.modal-zoomable');
|
|
12160
|
+
}
|
|
12161
|
+
|
|
12162
|
+
function attachPaneReview(view, sessionId, opts) {
|
|
12163
|
+
if (!opts) return;
|
|
12164
|
+
const panelEl = document.createElement('aside');
|
|
12165
|
+
panelEl.className = 'review-panel';
|
|
12166
|
+
panelEl.hidden = true;
|
|
12167
|
+
view.appendChild(panelEl);
|
|
12168
|
+
paneReviews.set(view, { ...opts, panelEl, hostEl: view, sessionId });
|
|
12169
|
+
syncPaneReview();
|
|
12170
|
+
}
|
|
12171
|
+
|
|
12172
|
+
function syncPaneReview() {
|
|
12173
|
+
const view = activePaneView();
|
|
12174
|
+
setBaseReview((view && paneReviews.get(view)) || null);
|
|
12175
|
+
}
|
|
12176
|
+
|
|
12177
|
+
function unmountPane(sid, paneId) {
|
|
12178
|
+
const key = `${sid}/${paneId}`;
|
|
12179
|
+
paneFrames.get(key)?.remove();
|
|
12180
|
+
paneFrames.delete(key);
|
|
12181
|
+
}
|
|
12182
|
+
|
|
12183
|
+
function selectPane(id) {
|
|
12184
|
+
const sid = paneSessionId();
|
|
12185
|
+
if (!sid) return;
|
|
12186
|
+
setActivePaneId(sid, id);
|
|
12187
|
+
closePanePop();
|
|
12188
|
+
syncPanes();
|
|
12189
|
+
}
|
|
12190
|
+
|
|
12191
|
+
// Returns false when the tabs are not on screen, so the key keeps its other meaning.
|
|
12192
|
+
function cyclePane(step) {
|
|
12193
|
+
const sid = paneSessionId();
|
|
12194
|
+
if (!sid || wantsTerminal()) return false;
|
|
12195
|
+
const ids = ['board', ...paneLayout(sid).panes.map((p) => p.id)];
|
|
12196
|
+
const i = ids.indexOf(getActivePaneId(sid));
|
|
12197
|
+
selectPane(ids[(i + step + ids.length) % ids.length]);
|
|
12198
|
+
return true;
|
|
12199
|
+
}
|
|
12200
|
+
|
|
12201
|
+
async function closePane(paneId) {
|
|
12202
|
+
const sid = paneSessionId();
|
|
12203
|
+
if (!sid) return;
|
|
12204
|
+
if (getActivePaneId(sid) === paneId) {
|
|
12205
|
+
const panes = paneLayout(sid).panes;
|
|
12206
|
+
const i = panes.findIndex((p) => p.id === paneId);
|
|
12207
|
+
setActivePaneId(sid, (panes[i + 1] || panes[i - 1])?.id || 'board');
|
|
12208
|
+
}
|
|
12209
|
+
unmountPane(sid, paneId);
|
|
12210
|
+
syncPanes();
|
|
12211
|
+
const res = await paneRequest('DELETE', `/api/panes/${encodeURIComponent(sid)}/${encodeURIComponent(paneId)}`);
|
|
12212
|
+
if (res.layout) applyPaneLayout(sid, res.layout);
|
|
12213
|
+
else if (res.code === 'no_pane') fetchPaneLayout(sid);
|
|
12214
|
+
else showToast(res.error, 'error');
|
|
12215
|
+
}
|
|
12216
|
+
|
|
12217
|
+
// With no id, the active pane.
|
|
12218
|
+
function paneById(id) {
|
|
12219
|
+
const sid = paneSessionId();
|
|
12220
|
+
if (!sid) return null;
|
|
12221
|
+
return id == null ? activePane(sid) : paneLayout(sid).panes.find((p) => p.id === id) || null;
|
|
12222
|
+
}
|
|
12223
|
+
|
|
12224
|
+
function reloadPane(id) {
|
|
12225
|
+
const pane = paneById(id);
|
|
12226
|
+
if (!pane) return;
|
|
12227
|
+
unmountPane(paneSessionId(), pane.id);
|
|
12228
|
+
paneRefused.delete(pane.target);
|
|
12229
|
+
syncPanes();
|
|
12230
|
+
}
|
|
12231
|
+
|
|
12232
|
+
function openPaneExternally(id) {
|
|
12233
|
+
const pane = paneById(id);
|
|
12234
|
+
if (!pane) return;
|
|
12235
|
+
openLinkedDoc(pane.target, getSessionBaseDir(paneSessionId()));
|
|
12236
|
+
}
|
|
12237
|
+
|
|
12238
|
+
function copyPaneTarget(id) {
|
|
12239
|
+
const pane = paneById(id);
|
|
12240
|
+
if (!pane) return;
|
|
12241
|
+
navigator.clipboard.writeText(pane.target).then(
|
|
12242
|
+
() => showToast(pane.kind === 'url' ? 'URL copied' : 'Path copied', 'success'),
|
|
12243
|
+
() => showToast('Copy failed', 'error'),
|
|
12244
|
+
);
|
|
12245
|
+
}
|
|
12246
|
+
|
|
12247
|
+
function closePaneMenu() {
|
|
12248
|
+
document.removeEventListener('keydown', paneMenuKeydown, true);
|
|
12249
|
+
document.getElementById('pane-menu')?.remove();
|
|
12250
|
+
}
|
|
12251
|
+
|
|
12252
|
+
function paneMenuKeydown(e) {
|
|
12253
|
+
const menu = document.getElementById('pane-menu');
|
|
12254
|
+
if (!menu || e.ctrlKey || e.altKey || e.metaKey) return;
|
|
12255
|
+
menuKeydown(e, [...menu.querySelectorAll('.pane-menu-item')], closePaneMenu);
|
|
12256
|
+
}
|
|
12257
|
+
|
|
12258
|
+
function openPaneMenu(x, y, id) {
|
|
12259
|
+
const pane = paneById(id);
|
|
12260
|
+
if (!pane) return;
|
|
12261
|
+
closePaneMenu();
|
|
12262
|
+
sgCloseMenu();
|
|
12263
|
+
const url = pane.kind === 'url';
|
|
12264
|
+
const item = (act, label) =>
|
|
12265
|
+
`<button class="pane-menu-item" role="menuitem" data-pane-act="${escapeHtml(act)}">${label}</button>`;
|
|
12266
|
+
const menu = document.createElement('div');
|
|
12267
|
+
menu.id = 'pane-menu';
|
|
12268
|
+
menu.className = 'pane-menu';
|
|
12269
|
+
menu.setAttribute('role', 'menu');
|
|
12270
|
+
menu.setAttribute('aria-label', 'Pane actions');
|
|
12271
|
+
menu.dataset.pane = pane.id;
|
|
12272
|
+
menu.innerHTML =
|
|
12273
|
+
`<div class="pane-menu-label" title="${escapeHtml(pane.target)}">${escapeHtml(pane.target)}</div>` +
|
|
12274
|
+
item('copy', url ? 'Copy URL' : 'Copy path') +
|
|
12275
|
+
item('open', url ? 'Open in new tab' : 'Open in preview') +
|
|
12276
|
+
item('reload', 'Reload') +
|
|
12277
|
+
item('close', 'Close pane');
|
|
12278
|
+
placeMenu(menu, x, y);
|
|
12279
|
+
document.addEventListener('keydown', paneMenuKeydown, true);
|
|
12280
|
+
menu.querySelector('.pane-menu-item').focus();
|
|
12281
|
+
}
|
|
12282
|
+
|
|
12283
|
+
const PANE_MENU_ACTIONS = {
|
|
12284
|
+
copy: copyPaneTarget,
|
|
12285
|
+
open: openPaneExternally,
|
|
12286
|
+
reload: reloadPane,
|
|
12287
|
+
close: closePane,
|
|
12288
|
+
};
|
|
12289
|
+
|
|
12290
|
+
// The server takes only absolute paths, and linked docs can be relative to the session's folder.
|
|
12291
|
+
function paneTargetFrom(raw) {
|
|
12292
|
+
const value = raw.trim().replace(/^(["'])(.*)\1$/, '$2');
|
|
12293
|
+
if (!value) return null;
|
|
12294
|
+
const url = linkUrl(value);
|
|
12295
|
+
if (!url) return { kind: 'file', target: value };
|
|
12296
|
+
if (paneOwnOrigin({ kind: 'url', target: url })) return { error: 'A pane cannot show the board or the hub' };
|
|
12297
|
+
return { kind: 'url', target: url };
|
|
12298
|
+
}
|
|
12299
|
+
|
|
12300
|
+
// The same target again answers the existing pane, so this also switches to it.
|
|
12301
|
+
async function addPane(raw) {
|
|
12302
|
+
const sid = paneSessionId();
|
|
12303
|
+
const t = sid && paneTargetFrom(raw);
|
|
12304
|
+
if (!t) return;
|
|
12305
|
+
if (t.error) {
|
|
12306
|
+
showToast(t.error);
|
|
12307
|
+
return;
|
|
12308
|
+
}
|
|
12309
|
+
const res = await paneRequest('POST', `/api/panes/${encodeURIComponent(sid)}`, {
|
|
12310
|
+
target: t.target,
|
|
12311
|
+
base: getSessionBaseDir(sid) || undefined,
|
|
12312
|
+
});
|
|
12313
|
+
if (!res.pane) {
|
|
12314
|
+
showToast(res.error, 'error');
|
|
12315
|
+
return;
|
|
12316
|
+
}
|
|
12317
|
+
setActivePaneId(sid, res.pane.id);
|
|
12318
|
+
applyPaneLayout(sid, res.layout);
|
|
12319
|
+
if (sid !== paneSessionId()) return;
|
|
12320
|
+
closePanePop();
|
|
12321
|
+
syncPanes();
|
|
12322
|
+
}
|
|
12323
|
+
|
|
12324
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
12325
|
+
function openPreviewInPane() {
|
|
12326
|
+
const filePath = currentPreviewPath;
|
|
12327
|
+
if (!filePath || !paneSessionId()) return;
|
|
12328
|
+
closePreviewModal();
|
|
12329
|
+
addPane(filePath);
|
|
12330
|
+
}
|
|
12331
|
+
|
|
12332
|
+
let panePopMatches = [];
|
|
12333
|
+
// -1 means Enter adds the typed value.
|
|
12334
|
+
let panePopIdx = -1;
|
|
12335
|
+
|
|
12336
|
+
function describePaneInput() {
|
|
12337
|
+
const doc = panePopMatches[panePopIdx];
|
|
12338
|
+
const t = doc ? null : paneTargetFrom(panePopInput.value);
|
|
12339
|
+
panePopDetect.classList.toggle('error', !!t?.error);
|
|
12340
|
+
panePopDetect.textContent = doc
|
|
12341
|
+
? `Enter opens ${linkedDocLabel(doc)}`
|
|
12342
|
+
: !t
|
|
12343
|
+
? 'Search linked docs · http(s) → url pane · a file path → file pane'
|
|
12344
|
+
: t.error || (t.kind === 'url' ? 'Kind: url · framed live' : 'Kind: file · rendered like the preview');
|
|
12345
|
+
}
|
|
12346
|
+
|
|
12347
|
+
function renderPanePopDocs() {
|
|
12348
|
+
const docs = getSessionPreviewPaths(paneSessionId());
|
|
12349
|
+
const query = panePopInput.value.trim();
|
|
12350
|
+
panePopMatches = query ? rankFolders(docs, query) : docs;
|
|
12351
|
+
panePopIdx = query && panePopMatches.length ? 0 : -1;
|
|
12352
|
+
panePopDocs.innerHTML = panePopMatches.length
|
|
12353
|
+
? panePopMatches
|
|
12354
|
+
.map(
|
|
12355
|
+
(d, i) =>
|
|
12356
|
+
`<button class="pane-pop-row${i === panePopIdx ? ' active' : ''}" data-doc="${escapeHtml(d)}" title="${escapeHtml(d)}">${linkUrl(d) ? PANE_ICONS.url : PANE_ICONS.file}<span>${escapeHtml(linkedDocLabel(d))}</span></button>`,
|
|
12357
|
+
)
|
|
12358
|
+
.join('')
|
|
12359
|
+
: `<div class="pane-pop-empty">${docs.length ? 'No linked doc matches. Enter adds what you typed.' : 'This session has no linked docs.'}</div>`;
|
|
12360
|
+
describePaneInput();
|
|
12361
|
+
}
|
|
12362
|
+
|
|
12363
|
+
function movePanePopHighlight(step) {
|
|
12364
|
+
panePopIdx = Math.max(-1, Math.min(panePopMatches.length - 1, panePopIdx + step));
|
|
12365
|
+
panePopDocs.querySelectorAll('.pane-pop-row').forEach((row, i) => {
|
|
12366
|
+
row.classList.toggle('active', i === panePopIdx);
|
|
12367
|
+
if (i === panePopIdx) row.scrollIntoView({ block: 'nearest' });
|
|
12368
|
+
});
|
|
12369
|
+
describePaneInput();
|
|
12370
|
+
}
|
|
12371
|
+
|
|
12372
|
+
function openPanePop() {
|
|
12373
|
+
if (!paneSessionId()) return;
|
|
12374
|
+
panePopInput.value = '';
|
|
12375
|
+
renderPanePopDocs();
|
|
12376
|
+
panePop.classList.add('visible');
|
|
12377
|
+
panePopInput.focus();
|
|
12378
|
+
}
|
|
12379
|
+
|
|
12380
|
+
function closePanePop() {
|
|
12381
|
+
panePop.classList.remove('visible');
|
|
12382
|
+
}
|
|
12383
|
+
|
|
12384
|
+
paneStrip.addEventListener('click', (e) => {
|
|
12385
|
+
const close = e.target.closest('[data-close]');
|
|
12386
|
+
if (close) {
|
|
12387
|
+
closePane(close.dataset.close);
|
|
12388
|
+
return;
|
|
12389
|
+
}
|
|
12390
|
+
const tab = e.target.closest('.pane-tab');
|
|
12391
|
+
if (tab) selectPane(tab.dataset.pane);
|
|
12392
|
+
});
|
|
12393
|
+
paneStrip.addEventListener('auxclick', (e) => {
|
|
12394
|
+
const tab = e.target.closest('.pane-tab');
|
|
12395
|
+
if (e.button === 1 && tab && tab.dataset.pane !== 'board') closePane(tab.dataset.pane);
|
|
12396
|
+
});
|
|
12397
|
+
paneStrip.addEventListener('contextmenu', (e) => {
|
|
12398
|
+
const tab = e.target.closest('.pane-tab');
|
|
12399
|
+
if (!tab || tab.dataset.pane === 'board') return;
|
|
12400
|
+
e.preventDefault();
|
|
12401
|
+
openPaneMenu(e.clientX, e.clientY, tab.dataset.pane);
|
|
12402
|
+
});
|
|
12403
|
+
document.addEventListener('click', (e) => {
|
|
12404
|
+
const menu = document.getElementById('pane-menu');
|
|
12405
|
+
if (!menu) return;
|
|
12406
|
+
const act = e.target.closest('.pane-menu-item')?.dataset.paneAct;
|
|
12407
|
+
if (act) PANE_MENU_ACTIONS[act](menu.dataset.pane);
|
|
12408
|
+
if (act || !menu.contains(e.target)) closePaneMenu();
|
|
12409
|
+
});
|
|
12410
|
+
// A click inside a pane's frame never reaches this document, but it takes focus from the window.
|
|
12411
|
+
window.addEventListener('blur', closePaneMenu);
|
|
12412
|
+
paneViews.addEventListener('click', (e) => {
|
|
12413
|
+
if (e.target.closest('[data-pane-open]')) openPaneExternally();
|
|
12414
|
+
});
|
|
12415
|
+
bindPreviewRelativeLinks(paneViews, (a) => a.closest('.pane-doc')?.dataset.path);
|
|
12416
|
+
paneTabs.addEventListener('scroll', updatePaneFades, { passive: true });
|
|
12417
|
+
paneTabs.addEventListener(
|
|
12418
|
+
'wheel',
|
|
12419
|
+
(e) => {
|
|
12420
|
+
if (!e.deltaY || Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
|
|
12421
|
+
if (paneTabs.scrollWidth <= paneTabs.clientWidth) return;
|
|
12422
|
+
e.preventDefault();
|
|
12423
|
+
paneTabs.scrollLeft += e.deltaY;
|
|
12424
|
+
},
|
|
12425
|
+
{ passive: false },
|
|
12426
|
+
);
|
|
12427
|
+
new ResizeObserver(updatePaneFades).observe(paneTabs);
|
|
12428
|
+
document.getElementById('pane-add').addEventListener('click', openPanePop);
|
|
12429
|
+
document.getElementById('pane-reload').addEventListener('click', () => reloadPane());
|
|
12430
|
+
document.getElementById('pane-open').addEventListener('click', () => openPaneExternally());
|
|
12431
|
+
panePopDocs.addEventListener('click', (e) => {
|
|
12432
|
+
const row = e.target.closest('[data-doc]');
|
|
12433
|
+
if (row) addPane(row.dataset.doc);
|
|
12434
|
+
});
|
|
12435
|
+
panePopInput.addEventListener('input', renderPanePopDocs);
|
|
12436
|
+
panePop.addEventListener('keydown', (e) => {
|
|
12437
|
+
if (e.key === 'Escape') {
|
|
12438
|
+
e.preventDefault();
|
|
12439
|
+
e.stopPropagation();
|
|
12440
|
+
closePanePop();
|
|
12441
|
+
return;
|
|
12442
|
+
}
|
|
12443
|
+
if (e.target !== panePopInput) return;
|
|
12444
|
+
if (e.key === 'Enter') {
|
|
12445
|
+
e.preventDefault();
|
|
12446
|
+
addPane(panePopMatches[panePopIdx] ?? panePopInput.value);
|
|
12447
|
+
} else if (matchKey(e, 'ArrowDown', 'ArrowUp')) {
|
|
12448
|
+
e.preventDefault();
|
|
12449
|
+
movePanePopHighlight(e.key === 'ArrowDown' ? 1 : -1);
|
|
12450
|
+
}
|
|
12451
|
+
});
|
|
12452
|
+
//#endregion
|
|
12453
|
+
|
|
11636
12454
|
//#region TERMINAL
|
|
11637
12455
|
// The kanban and the terminal share one slot: terminal mode hides #main-content and shows
|
|
11638
12456
|
// the pane. The PTY lives on the server (lib/terminal.js) and outlives the pane, so
|
|
@@ -11761,8 +12579,6 @@ function terminalPaneFocused() {
|
|
|
11761
12579
|
return document.getElementById('terminal-pane').contains(document.activeElement);
|
|
11762
12580
|
}
|
|
11763
12581
|
|
|
11764
|
-
const TEXT_FIELD_SELECTOR = 'input, textarea, select, [contenteditable]';
|
|
11765
|
-
|
|
11766
12582
|
function inPageField(e) {
|
|
11767
12583
|
const field = e.target?.closest?.(TEXT_FIELD_SELECTOR);
|
|
11768
12584
|
return !!field && !document.getElementById('terminal-pane').contains(field);
|
|
@@ -11853,6 +12669,7 @@ function syncTerminal() {
|
|
|
11853
12669
|
btn.classList.toggle('active', on);
|
|
11854
12670
|
}
|
|
11855
12671
|
sessionView.classList.toggle('terminal-mode', on);
|
|
12672
|
+
syncPanes();
|
|
11856
12673
|
pushTerminalClaims();
|
|
11857
12674
|
if (!on) {
|
|
11858
12675
|
// A hidden xterm textarea drops focus, but a display:none iframe keeps it and would take keys unseen.
|
|
@@ -12032,7 +12849,7 @@ function onTerminalFrameMessage(t, m) {
|
|
|
12032
12849
|
termFrame.reload = false;
|
|
12033
12850
|
reconnectTerminal();
|
|
12034
12851
|
}
|
|
12035
|
-
} else if (t === 'key')
|
|
12852
|
+
} else if (t === 'key') replayKey(m, document.getElementById('terminal-key-proxy'));
|
|
12036
12853
|
// Chrome does not always fire focusin on the iframe element when its content takes focus.
|
|
12037
12854
|
else if (t === 'focused') terminalHadFocus = terminalPaneFocused();
|
|
12038
12855
|
else if (t === 'opening') {
|
|
@@ -12052,8 +12869,7 @@ function onTerminalFrameMessage(t, m) {
|
|
|
12052
12869
|
// The frame hands back the keys the board acts on. Replaying them on a textarea inside the pane gives
|
|
12053
12870
|
// the document listeners the target they saw when xterm lived here: the shortcut runs, the SDK
|
|
12054
12871
|
// forwards a hub key, and the text-field guard keeps the board's own keys out.
|
|
12055
|
-
function
|
|
12056
|
-
const target = document.getElementById('terminal-key-proxy');
|
|
12872
|
+
function replayKey(m, target) {
|
|
12057
12873
|
target.dispatchEvent(
|
|
12058
12874
|
new KeyboardEvent('keydown', {
|
|
12059
12875
|
key: m.key,
|
|
@@ -12104,13 +12920,20 @@ let terminalClaimsQueued = false;
|
|
|
12104
12920
|
|
|
12105
12921
|
// The claims depend on state (the hub's welcome, a slow connect, the session), so every change re-sends
|
|
12106
12922
|
// them. One state change calls this several times, and the probe is about 1,500 shortcut checks.
|
|
12923
|
+
function keyClaims() {
|
|
12924
|
+
return { keys: terminalClaims(), forward: hub.forwardCombos() };
|
|
12925
|
+
}
|
|
12926
|
+
|
|
12107
12927
|
function pushTerminalClaims() {
|
|
12108
|
-
if (
|
|
12928
|
+
if (terminalClaimsQueued) return;
|
|
12109
12929
|
terminalClaimsQueued = true;
|
|
12110
12930
|
queueMicrotask(() => {
|
|
12111
12931
|
terminalClaimsQueued = false;
|
|
12932
|
+
const bridged = bridgedFrameEls();
|
|
12933
|
+
if (!termFrame.inited && !bridged.length) return;
|
|
12934
|
+
const claims = keyClaims();
|
|
12935
|
+
sendBridgeClaims(bridged, claims);
|
|
12112
12936
|
if (!termFrame.inited) return;
|
|
12113
|
-
const claims = { keys: terminalClaims(), forward: hub.forwardCombos() };
|
|
12114
12937
|
const sig = JSON.stringify(claims);
|
|
12115
12938
|
if (sig === termFrame.claims) return;
|
|
12116
12939
|
termFrame.claims = sig;
|
|
@@ -13143,6 +13966,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
13143
13966
|
if (lang === 'mermaid') {
|
|
13144
13967
|
return `<pre class="mermaid" data-original="${escapeHtml(text)}">${escapeHtml(text)}</pre>`;
|
|
13145
13968
|
}
|
|
13969
|
+
if (treeHighlight.wantsTree(lang, text)) {
|
|
13970
|
+
return `<pre><code class="hljs language-tree">${treeHighlight.highlightTree(text)}</code></pre>`;
|
|
13971
|
+
}
|
|
13146
13972
|
let highlighted;
|
|
13147
13973
|
if (lang && hljs.getLanguage(lang)) {
|
|
13148
13974
|
highlighted = hljs.highlight(text, { language: lang }).value;
|
|
@@ -13512,20 +14338,23 @@ document.getElementById('help-docs')?.addEventListener('click', (e) => {
|
|
|
13512
14338
|
hub.openExternal(e.currentTarget.href);
|
|
13513
14339
|
});
|
|
13514
14340
|
|
|
13515
|
-
|
|
13516
|
-
if (!hub.inHub) return;
|
|
13517
|
-
const a = e.target.closest?.('a[href]');
|
|
13518
|
-
if (!a) return;
|
|
13519
|
-
const href = a.getAttribute('href');
|
|
13520
|
-
if (!href) return;
|
|
14341
|
+
function webUrl(href) {
|
|
13521
14342
|
let url;
|
|
13522
14343
|
try {
|
|
13523
14344
|
url = new URL(href, window.location.href);
|
|
13524
14345
|
} catch (_) {
|
|
13525
|
-
return;
|
|
14346
|
+
return null;
|
|
13526
14347
|
}
|
|
13527
|
-
|
|
13528
|
-
|
|
14348
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? url : null;
|
|
14349
|
+
}
|
|
14350
|
+
|
|
14351
|
+
document.addEventListener('click', (e) => {
|
|
14352
|
+
const a = e.target.closest?.('a[href]');
|
|
14353
|
+
if (!a) return;
|
|
14354
|
+
const href = a.getAttribute('href');
|
|
14355
|
+
if (!href) return;
|
|
14356
|
+
const url = webUrl(href);
|
|
14357
|
+
if (!url || url.origin === window.location.origin) return;
|
|
13529
14358
|
e.preventDefault();
|
|
13530
14359
|
e.stopPropagation();
|
|
13531
14360
|
hub.openExternal(url.href);
|