clay-server 2.32.0 → 2.32.1-beta.2
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/lib/daemon.js +18 -0
- package/lib/project-email.js +16 -0
- package/lib/project.js +38 -8
- package/lib/public/app.js +11 -0
- package/lib/public/css/command-palette.css +186 -0
- package/lib/public/css/filebrowser.css +73 -49
- package/lib/public/css/icon-strip.css +44 -2
- package/lib/public/css/input.css +10 -5
- package/lib/public/css/mates.css +18 -29
- package/lib/public/css/notifications-center.css +32 -0
- package/lib/public/css/overlays.css +5 -22
- package/lib/public/css/sidebar.css +288 -27
- package/lib/public/index.html +24 -23
- package/lib/public/modules/app-notifications.js +52 -0
- package/lib/public/modules/app-rendering.js +1 -1
- package/lib/public/modules/filebrowser.js +171 -0
- package/lib/public/modules/input.js +12 -11
- package/lib/public/modules/markdown.js +12 -0
- package/lib/public/modules/project-switcher.js +404 -0
- package/lib/public/modules/sidebar.js +14 -10
- package/lib/public/modules/terminal.js +8 -3
- package/lib/public/modules/tool-palette.js +561 -0
- package/lib/sdk-bridge.js +20 -7
- package/lib/server-settings.js +53 -0
- package/lib/sessions.js +4 -2
- package/lib/users-preferences.js +48 -0
- package/lib/users.js +4 -0
- package/lib/yoke/index.js +4 -3
- package/package.json +1 -1
|
@@ -35,6 +35,38 @@ export function initFileBrowser(_ctx) {
|
|
|
35
35
|
}
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
// --- Keyboard navigation ---
|
|
39
|
+
// Arrow keys move a "keyboard focus" ring through visible tree items,
|
|
40
|
+
// Enter activates (opens a file or toggles a folder), Left/Right
|
|
41
|
+
// collapses/expands folders and ascends/descends into them. The tree
|
|
42
|
+
// gets a tabindex so it can receive focus and keydown events.
|
|
43
|
+
if (ctx.fileTreeEl) {
|
|
44
|
+
ctx.fileTreeEl.setAttribute('tabindex', '0');
|
|
45
|
+
ctx.fileTreeEl.addEventListener('keydown', handleTreeKeyDown);
|
|
46
|
+
// When the user clicks any tree row, promote it to keyboard focus
|
|
47
|
+
// as well so subsequent arrow keys continue from that position.
|
|
48
|
+
ctx.fileTreeEl.addEventListener('click', function (e) {
|
|
49
|
+
var row = e.target && e.target.closest && e.target.closest('.file-tree-item');
|
|
50
|
+
if (row) setKbFocus(row);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Search input <-> tree keyboard bridge:
|
|
55
|
+
// Enter in the search box drops focus back onto the tree and
|
|
56
|
+
// highlights the first visible result so the user can keep
|
|
57
|
+
// navigating with arrows. ArrowDown does the same for convenience.
|
|
58
|
+
var searchInput = document.getElementById('fb-search-input');
|
|
59
|
+
if (searchInput) {
|
|
60
|
+
searchInput.addEventListener('keydown', function (e) {
|
|
61
|
+
if (e.key === 'Enter' || e.key === 'ArrowDown') {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
focusFileTree();
|
|
64
|
+
var items = getVisibleTreeItems();
|
|
65
|
+
if (items.length) setKbFocus(items[0]);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
38
70
|
// --- Drag-and-drop file paths into message input ---
|
|
39
71
|
var inputEl = document.getElementById("input");
|
|
40
72
|
var dropHintEl = null;
|
|
@@ -210,6 +242,145 @@ export function initFileBrowser(_ctx) {
|
|
|
210
242
|
}
|
|
211
243
|
}
|
|
212
244
|
|
|
245
|
+
// --- Keyboard navigation helpers ---
|
|
246
|
+
|
|
247
|
+
var _kbFocused = null;
|
|
248
|
+
|
|
249
|
+
export function focusFileTree() {
|
|
250
|
+
if (!ctx || !ctx.fileTreeEl) return;
|
|
251
|
+
ctx.fileTreeEl.focus();
|
|
252
|
+
if (!_kbFocused) {
|
|
253
|
+
var first = ctx.fileTreeEl.querySelector('.file-tree-item');
|
|
254
|
+
if (first) setKbFocus(first);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function setKbFocus(el) {
|
|
259
|
+
if (_kbFocused && _kbFocused !== el) _kbFocused.classList.remove('fb-kb-focus');
|
|
260
|
+
_kbFocused = el || null;
|
|
261
|
+
if (el) {
|
|
262
|
+
el.classList.add('fb-kb-focus');
|
|
263
|
+
if (el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isDirRow(row) {
|
|
268
|
+
// Folder rows carry the chevron; file rows use a spacer instead.
|
|
269
|
+
return !!(row && row.querySelector && row.querySelector('.file-tree-chevron'));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function getVisibleTreeItems() {
|
|
273
|
+
if (!ctx || !ctx.fileTreeEl) return [];
|
|
274
|
+
var all = ctx.fileTreeEl.querySelectorAll('.file-tree-item');
|
|
275
|
+
var out = [];
|
|
276
|
+
for (var i = 0; i < all.length; i++) {
|
|
277
|
+
var el = all[i];
|
|
278
|
+
// Skip anything nested inside a hidden .file-tree-children subtree.
|
|
279
|
+
var hidden = false;
|
|
280
|
+
var p = el.parentNode;
|
|
281
|
+
while (p && p !== ctx.fileTreeEl) {
|
|
282
|
+
if (p.classList && p.classList.contains('file-tree-children') && p.classList.contains('hidden')) {
|
|
283
|
+
hidden = true;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
p = p.parentNode;
|
|
287
|
+
}
|
|
288
|
+
if (!hidden) out.push(el);
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function moveKbFocus(delta) {
|
|
294
|
+
var items = getVisibleTreeItems();
|
|
295
|
+
if (items.length === 0) return;
|
|
296
|
+
var idx = _kbFocused ? items.indexOf(_kbFocused) : -1;
|
|
297
|
+
if (idx === -1) {
|
|
298
|
+
idx = delta > 0 ? 0 : items.length - 1;
|
|
299
|
+
} else {
|
|
300
|
+
idx = Math.max(0, Math.min(items.length - 1, idx + delta));
|
|
301
|
+
}
|
|
302
|
+
setKbFocus(items[idx]);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function kbExpandOrDescend() {
|
|
306
|
+
if (!_kbFocused) return;
|
|
307
|
+
if (!isDirRow(_kbFocused)) return;
|
|
308
|
+
if (!_kbFocused.classList.contains('expanded')) {
|
|
309
|
+
_kbFocused.click(); // triggers the existing expand handler
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
// Already expanded — move focus to first child.
|
|
313
|
+
var children = _kbFocused.nextElementSibling;
|
|
314
|
+
if (children && children.classList.contains('file-tree-children')) {
|
|
315
|
+
var first = children.querySelector('.file-tree-item');
|
|
316
|
+
if (first) setKbFocus(first);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function kbCollapseOrAscend() {
|
|
321
|
+
if (!_kbFocused) return;
|
|
322
|
+
if (isDirRow(_kbFocused) && _kbFocused.classList.contains('expanded')) {
|
|
323
|
+
_kbFocused.click(); // triggers existing collapse handler
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// On a file or a collapsed folder — move focus to the parent folder.
|
|
327
|
+
var container = _kbFocused.parentNode;
|
|
328
|
+
if (container && container.classList && container.classList.contains('file-tree-children')) {
|
|
329
|
+
var parentRow = container.previousElementSibling;
|
|
330
|
+
if (parentRow && parentRow.classList && parentRow.classList.contains('file-tree-item')) {
|
|
331
|
+
setKbFocus(parentRow);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function kbActivate() {
|
|
337
|
+
if (_kbFocused) _kbFocused.click();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function handleTreeKeyDown(e) {
|
|
341
|
+
// Any arrow key with an empty tree (e.g. "No files found" for a
|
|
342
|
+
// search query) hands focus back to the search input so the user
|
|
343
|
+
// can fix the query without clicking.
|
|
344
|
+
var isArrow = e.key === 'ArrowDown' || e.key === 'ArrowUp'
|
|
345
|
+
|| e.key === 'ArrowLeft' || e.key === 'ArrowRight';
|
|
346
|
+
if (isArrow && getVisibleTreeItems().length === 0) {
|
|
347
|
+
e.preventDefault();
|
|
348
|
+
var emptySearch = document.getElementById('fb-search-input');
|
|
349
|
+
if (emptySearch) {
|
|
350
|
+
if (_kbFocused) _kbFocused.classList.remove('fb-kb-focus');
|
|
351
|
+
_kbFocused = null;
|
|
352
|
+
emptySearch.focus();
|
|
353
|
+
try { emptySearch.select(); } catch (err) { /* ignore */ }
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); moveKbFocus(1); return; }
|
|
359
|
+
if (e.key === 'ArrowUp') {
|
|
360
|
+
e.preventDefault();
|
|
361
|
+
// At the top of the visible tree, pressing Up hands focus to the
|
|
362
|
+
// search box — a natural escape upward. Enter there brings focus
|
|
363
|
+
// back to the tree (handled by the search input's keydown below).
|
|
364
|
+
var items = getVisibleTreeItems();
|
|
365
|
+
if (items.length && _kbFocused === items[0]) {
|
|
366
|
+
var searchInput = document.getElementById('fb-search-input');
|
|
367
|
+
if (searchInput) {
|
|
368
|
+
if (_kbFocused) _kbFocused.classList.remove('fb-kb-focus');
|
|
369
|
+
searchInput.focus();
|
|
370
|
+
try { searchInput.select(); } catch (err) { /* ignore */ }
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
moveKbFocus(-1);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (e.key === 'ArrowRight'){ e.preventDefault(); kbExpandOrDescend(); return; }
|
|
378
|
+
if (e.key === 'ArrowLeft') { e.preventDefault(); kbCollapseOrAscend(); return; }
|
|
379
|
+
if (e.key === 'Enter') { e.preventDefault(); kbActivate(); return; }
|
|
380
|
+
if (e.key === 'Home') { e.preventDefault(); var items3 = getVisibleTreeItems(); if (items3.length) setKbFocus(items3[0]); return; }
|
|
381
|
+
if (e.key === 'End') { e.preventDefault(); var items4 = getVisibleTreeItems(); if (items4.length) setKbFocus(items4[items4.length - 1]); return; }
|
|
382
|
+
}
|
|
383
|
+
|
|
213
384
|
// --- File watch helpers ---
|
|
214
385
|
function sendWatch(filePath) {
|
|
215
386
|
if (ctx.ws && ctx.connected) {
|
|
@@ -816,9 +816,14 @@ export function initInput(_ctx) {
|
|
|
816
816
|
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
|
|
817
817
|
});
|
|
818
818
|
|
|
819
|
-
// Mate avatar overlay on @ button
|
|
819
|
+
// Mate avatar overlay on @ button.
|
|
820
|
+
// The overlay is a quiet hint that you can @-mention a Mate for advice
|
|
821
|
+
// on the current session. To keep it from feeling like constant motion
|
|
822
|
+
// in the user's peripheral vision (see issue #325 item 2), the avatar
|
|
823
|
+
// is picked once per session entry and does NOT rotate on a timer.
|
|
824
|
+
// The avatar is rendered desaturated in CSS and only regains color on
|
|
825
|
+
// hover, so the change moment itself stays visually quiet too.
|
|
820
826
|
var _lastOverlayIdx = -1;
|
|
821
|
-
var _overlayInterval = null;
|
|
822
827
|
|
|
823
828
|
function getAvailableMates() {
|
|
824
829
|
var mates = store.get('cachedMatesList') || [];
|
|
@@ -861,21 +866,17 @@ export function initInput(_ctx) {
|
|
|
861
866
|
}, 300);
|
|
862
867
|
}
|
|
863
868
|
|
|
864
|
-
function
|
|
869
|
+
function refreshMateOverlay() {
|
|
865
870
|
var available = getAvailableMates();
|
|
866
871
|
setOverlayAvatar(pickNextMate(available));
|
|
867
872
|
}
|
|
868
873
|
|
|
869
|
-
|
|
870
|
-
if (_overlayInterval) clearInterval(_overlayInterval);
|
|
871
|
-
rotateMateOverlay();
|
|
872
|
-
_overlayInterval = setInterval(rotateMateOverlay, 10000);
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// Update overlay when mate list changes
|
|
874
|
+
// Refresh on initial mate list load and on session transition only.
|
|
876
875
|
store.subscribe(function (state, prev) {
|
|
877
876
|
if (state.cachedMatesList !== prev.cachedMatesList) {
|
|
878
|
-
|
|
877
|
+
refreshMateOverlay();
|
|
878
|
+
} else if (state.activeSessionId !== prev.activeSessionId) {
|
|
879
|
+
refreshMateOverlay();
|
|
879
880
|
}
|
|
880
881
|
});
|
|
881
882
|
}
|
|
@@ -5,6 +5,18 @@ import { getMermaidThemeVars } from './theme.js';
|
|
|
5
5
|
// Initialize markdown parser
|
|
6
6
|
marked.use({ gfm: true, breaks: false });
|
|
7
7
|
|
|
8
|
+
// Open external links in a new tab
|
|
9
|
+
marked.use({
|
|
10
|
+
renderer: {
|
|
11
|
+
link({ href, title, text }) {
|
|
12
|
+
var isExternal = href && (href.startsWith('http://') || href.startsWith('https://'));
|
|
13
|
+
var titleAttr = title ? ' title="' + title + '"' : '';
|
|
14
|
+
var targetAttr = isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
|
15
|
+
return '<a href="' + href + '"' + titleAttr + targetAttr + '>' + text + '</a>';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
8
20
|
// Initialize mermaid
|
|
9
21
|
mermaid.initialize({
|
|
10
22
|
startOnLoad: false,
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
// project-switcher.js — macOS Cmd+Tab-style quick switcher.
|
|
2
|
+
//
|
|
3
|
+
// Two modes share the same overlay:
|
|
4
|
+
// Cmd/Ctrl+E → projects only (non-Mate), activates via switchProject
|
|
5
|
+
// Cmd/Ctrl+M → Mates only, activates via openDm
|
|
6
|
+
//
|
|
7
|
+
// While the modifier is still held, repeating the same shortcut cycles
|
|
8
|
+
// forward and Shift cycles back. Releasing the modifier commits —
|
|
9
|
+
// matching Cmd+Tab semantics. Enter commits explicitly; Escape cancels.
|
|
10
|
+
//
|
|
11
|
+
// Each mode has its own in-memory MRU list so the ordering stays
|
|
12
|
+
// mode-specific (visiting projects shouldn't reorder the mate list,
|
|
13
|
+
// and vice versa). No persistence — MRU is a transient hint and
|
|
14
|
+
// project rule forbids localStorage for preferences.
|
|
15
|
+
|
|
16
|
+
import { store } from './store.js';
|
|
17
|
+
import { refreshIcons } from './icons.js';
|
|
18
|
+
import { getCachedProjects, switchProject } from './app-projects.js';
|
|
19
|
+
import { openDm } from './app-dm.js';
|
|
20
|
+
import { mateAvatarUrl } from './avatar.js';
|
|
21
|
+
import { parseEmojis } from './markdown.js';
|
|
22
|
+
|
|
23
|
+
var _projectMru = []; // project slugs, most recent first
|
|
24
|
+
var _mateMru = []; // mate ids (mate_XXX), most recent first
|
|
25
|
+
var _overlay = null;
|
|
26
|
+
var _list = null;
|
|
27
|
+
var _headerEl = null;
|
|
28
|
+
var _open = false;
|
|
29
|
+
var _mode = 'project'; // 'project' | 'mate'
|
|
30
|
+
var _highlightedIndex = 0;
|
|
31
|
+
var _entries = [];
|
|
32
|
+
|
|
33
|
+
export function openSwitcherForMode(mode) {
|
|
34
|
+
if (mode !== 'project' && mode !== 'mate') return;
|
|
35
|
+
if (_open) closeSwitcher(false);
|
|
36
|
+
openSwitcher(mode);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function initProjectSwitcher() {
|
|
40
|
+
buildOverlayIfMissing();
|
|
41
|
+
_list = _overlay.querySelector('.cmd-palette-results');
|
|
42
|
+
_headerEl = _overlay.querySelector('.project-switcher-header');
|
|
43
|
+
|
|
44
|
+
// Seed MRUs from current state.
|
|
45
|
+
var seedSlug = store.get('currentSlug');
|
|
46
|
+
if (seedSlug) _projectMru = [seedSlug];
|
|
47
|
+
var seedTarget = store.get('dmTargetUser');
|
|
48
|
+
if (seedTarget && seedTarget.isMate && seedTarget.id) _mateMru = [seedTarget.id];
|
|
49
|
+
|
|
50
|
+
// Track MRUs by watching the store for navigation changes.
|
|
51
|
+
store.subscribe(function (state, prev) {
|
|
52
|
+
if (state.currentSlug && state.currentSlug !== prev.currentSlug) {
|
|
53
|
+
bumpMru(_projectMru, state.currentSlug);
|
|
54
|
+
_projectMru = _projectMru; // (keep reference; bumpMru mutates via reassignment)
|
|
55
|
+
}
|
|
56
|
+
var curMate = mateIdFrom(state.dmTargetUser);
|
|
57
|
+
var prevMate = mateIdFrom(prev.dmTargetUser);
|
|
58
|
+
if (curMate && curMate !== prevMate) bumpMru(_mateMru, curMate);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
var backdrop = _overlay.querySelector('.cmd-palette-backdrop');
|
|
62
|
+
if (backdrop) backdrop.addEventListener('click', function () { closeSwitcher(false); });
|
|
63
|
+
|
|
64
|
+
document.addEventListener('keydown', handleKeyDown, true);
|
|
65
|
+
document.addEventListener('keyup', handleKeyUp, true);
|
|
66
|
+
window.addEventListener('blur', function () { if (_open) closeSwitcher(false); });
|
|
67
|
+
|
|
68
|
+
// Wire hotkey hint pills next to the icon strip so the keybinding is
|
|
69
|
+
// discoverable in the same surface users use for clicking. Clicking
|
|
70
|
+
// the pill is equivalent to pressing the shortcut itself.
|
|
71
|
+
var isMac = /Mac|iPhone|iPod|iPad/i.test(navigator.platform);
|
|
72
|
+
var projectHintLabel = isMac ? '\u2318E' : 'Ctrl+E';
|
|
73
|
+
var mateHintLabel = isMac ? '\u2318M' : 'Ctrl+M';
|
|
74
|
+
var projectHint = document.getElementById('icon-strip-hint-project');
|
|
75
|
+
var mateHint = document.getElementById('icon-strip-hint-mate');
|
|
76
|
+
if (projectHint) {
|
|
77
|
+
projectHint.textContent = projectHintLabel;
|
|
78
|
+
projectHint.title = 'Switch project (' + projectHintLabel + ')';
|
|
79
|
+
projectHint.addEventListener('click', function () { openSwitcherForMode('project'); });
|
|
80
|
+
}
|
|
81
|
+
if (mateHint) {
|
|
82
|
+
mateHint.textContent = mateHintLabel;
|
|
83
|
+
mateHint.title = 'Switch mate (' + mateHintLabel + ')';
|
|
84
|
+
mateHint.addEventListener('click', function () { openSwitcherForMode('mate'); });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Mate hint visibility piggybacks on cachedMatesList: show the pill
|
|
88
|
+
// whenever the mate list has any entries, hide otherwise. The users
|
|
89
|
+
// strip inside the same wrapper also hides itself (sidebar-mates.js
|
|
90
|
+
// toggles that), and the wrapper shrinks to nothing when both are
|
|
91
|
+
// hidden — no extra visibility bookkeeping needed on the wrapper.
|
|
92
|
+
function refreshMateHintVisibility() {
|
|
93
|
+
if (!mateHint) return;
|
|
94
|
+
var mates = store.get('cachedMatesList') || [];
|
|
95
|
+
mateHint.classList.toggle('hidden', mates.length === 0);
|
|
96
|
+
}
|
|
97
|
+
refreshMateHintVisibility();
|
|
98
|
+
store.subscribe(function (state, prev) {
|
|
99
|
+
if (state.cachedMatesList !== prev.cachedMatesList) refreshMateHintVisibility();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildOverlayIfMissing() {
|
|
104
|
+
_overlay = document.getElementById('project-switcher');
|
|
105
|
+
if (_overlay) return;
|
|
106
|
+
var isMac = /Mac|iPhone|iPod|iPad/i.test(navigator.platform);
|
|
107
|
+
var modKbd = isMac ? '<kbd>\u2318</kbd>' : '<kbd>Ctrl</kbd>';
|
|
108
|
+
_overlay = document.createElement('div');
|
|
109
|
+
_overlay.id = 'project-switcher';
|
|
110
|
+
_overlay.className = 'cmd-palette hidden project-switcher';
|
|
111
|
+
_overlay.innerHTML =
|
|
112
|
+
'<div class="cmd-palette-backdrop"></div>' +
|
|
113
|
+
'<div class="cmd-palette-dialog project-switcher-dialog">' +
|
|
114
|
+
'<div class="project-switcher-header">Switch project</div>' +
|
|
115
|
+
'<div class="cmd-palette-results"></div>' +
|
|
116
|
+
'<div class="cmd-palette-footer project-switcher-footer">' +
|
|
117
|
+
'<span class="project-switcher-shortcuts">' + modKbd + '<kbd class="proj-next-key">E</kbd> next \u00b7 <kbd>\u21E7</kbd>' + modKbd + '<kbd class="proj-prev-key">E</kbd> prev \u00b7 <kbd>\u23CE</kbd> select \u00b7 <kbd>Esc</kbd> cancel</span>' +
|
|
118
|
+
'</div>' +
|
|
119
|
+
'</div>';
|
|
120
|
+
document.body.appendChild(_overlay);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function mateIdFrom(target) {
|
|
124
|
+
return target && target.isMate && target.id ? target.id : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function bumpMru(list, key) {
|
|
128
|
+
// Mutate the original array to preserve the module-level reference
|
|
129
|
+
// so subscribers / future reads see the update without a reassign.
|
|
130
|
+
var idx = list.indexOf(key);
|
|
131
|
+
if (idx !== -1) list.splice(idx, 1);
|
|
132
|
+
list.unshift(key);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function handleKeyDown(e) {
|
|
136
|
+
if (!e.key) return;
|
|
137
|
+
var isMod = (e.metaKey || e.ctrlKey) && !e.altKey;
|
|
138
|
+
var k = e.key.toLowerCase();
|
|
139
|
+
|
|
140
|
+
// Mode shortcuts: Cmd/Ctrl+E (projects) and Cmd/Ctrl+M (mates).
|
|
141
|
+
// Pressing the other shortcut while open switches mode; pressing the
|
|
142
|
+
// same shortcut cycles.
|
|
143
|
+
if (isMod && (k === 'e' || k === 'm')) {
|
|
144
|
+
var requestedMode = k === 'e' ? 'project' : 'mate';
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
e.stopPropagation();
|
|
147
|
+
if (!_open) {
|
|
148
|
+
openSwitcher(requestedMode);
|
|
149
|
+
} else if (_mode !== requestedMode) {
|
|
150
|
+
// Switched modes mid-flight. Rebuild the list for the new mode.
|
|
151
|
+
openSwitcher(requestedMode);
|
|
152
|
+
} else if (e.shiftKey) {
|
|
153
|
+
cycle(-1);
|
|
154
|
+
} else {
|
|
155
|
+
cycle(1);
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!_open) return;
|
|
161
|
+
|
|
162
|
+
if (e.key === 'Escape') {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
e.stopPropagation();
|
|
165
|
+
closeSwitcher(false);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (e.key === 'Enter') {
|
|
169
|
+
e.preventDefault();
|
|
170
|
+
e.stopPropagation();
|
|
171
|
+
closeSwitcher(true);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); cycle(1); return; }
|
|
175
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); cycle(-1); return; }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function handleKeyUp(e) {
|
|
179
|
+
if (!_open) return;
|
|
180
|
+
if (e.key === 'Meta' || e.key === 'Control') {
|
|
181
|
+
closeSwitcher(true);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function openSwitcher(mode) {
|
|
186
|
+
_mode = mode;
|
|
187
|
+
_entries = buildEntries(mode);
|
|
188
|
+
if (_entries.length === 0) {
|
|
189
|
+
// Nothing to switch to; don't show an empty modal.
|
|
190
|
+
if (_open) closeSwitcher(false);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (_headerEl) {
|
|
194
|
+
_headerEl.textContent = mode === 'mate' ? 'Switch mate' : 'Switch project';
|
|
195
|
+
}
|
|
196
|
+
updateFooterShortcut(mode);
|
|
197
|
+
renderEntries();
|
|
198
|
+
_highlightedIndex = pickInitialIndex();
|
|
199
|
+
paintHighlight();
|
|
200
|
+
_overlay.classList.remove('hidden');
|
|
201
|
+
_open = true;
|
|
202
|
+
refreshIcons();
|
|
203
|
+
// UI chrome matches the rest of Clay by swapping native emoji into
|
|
204
|
+
// Twemoji <img>s (same pattern as title bar, icon strip, project
|
|
205
|
+
// settings). The global COLR font fallback alone isn't reliable for
|
|
206
|
+
// UI surfaces because OS emoji fonts tend to match first.
|
|
207
|
+
parseEmojis(_list);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function updateFooterShortcut(mode) {
|
|
211
|
+
var nextKey = _overlay.querySelector('.proj-next-key');
|
|
212
|
+
var prevKey = _overlay.querySelector('.proj-prev-key');
|
|
213
|
+
var letter = mode === 'mate' ? 'M' : 'E';
|
|
214
|
+
if (nextKey) nextKey.textContent = letter;
|
|
215
|
+
if (prevKey) prevKey.textContent = letter;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function pickInitialIndex() {
|
|
219
|
+
// Highlight the "previous" selectable entry (Cmd+Tab semantics),
|
|
220
|
+
// while keeping the list in a stable natural order. Uses MRU to
|
|
221
|
+
// find which project/mate was visited before the current one, then
|
|
222
|
+
// locates its position in _entries.
|
|
223
|
+
if (_entries.length === 0) return 0;
|
|
224
|
+
var mru = _mode === 'mate' ? _mateMru : _projectMru;
|
|
225
|
+
var currentKey = currentActiveKey();
|
|
226
|
+
for (var m = 0; m < mru.length; m++) {
|
|
227
|
+
var key = mru[m];
|
|
228
|
+
if (key === currentKey) continue;
|
|
229
|
+
for (var i = 0; i < _entries.length; i++) {
|
|
230
|
+
if (_entries[i].key === key && !_entries[i].disabled) return i;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Fallback: first non-current, non-disabled entry.
|
|
234
|
+
for (var j = 0; j < _entries.length; j++) {
|
|
235
|
+
if (!_entries[j].disabled && _entries[j].key !== currentKey) return j;
|
|
236
|
+
}
|
|
237
|
+
for (var d = 0; d < _entries.length; d++) {
|
|
238
|
+
if (!_entries[d].disabled) return d;
|
|
239
|
+
}
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function currentActiveKey() {
|
|
244
|
+
if (_mode === 'mate') {
|
|
245
|
+
return mateIdFrom(store.get('dmTargetUser'));
|
|
246
|
+
}
|
|
247
|
+
return store.get('currentSlug');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function buildEntries(mode) {
|
|
251
|
+
if (mode === 'mate') return buildMateEntries();
|
|
252
|
+
return buildProjectEntries();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function buildProjectEntries() {
|
|
256
|
+
var projects = (getCachedProjects() || []).filter(function (p) { return !p.isMate; });
|
|
257
|
+
// Keep natural (server-provided) order so the list looks the same
|
|
258
|
+
// every time the switcher opens. MRU only drives initial highlight.
|
|
259
|
+
return projects.map(function (p) {
|
|
260
|
+
// Worktrees can end up "outside project path" (parent workspace
|
|
261
|
+
// unmounted or moved); those are effectively unreachable from
|
|
262
|
+
// this session and shouldn't be switchable.
|
|
263
|
+
var unreachable = p.isWorktree && p.worktreeAccessible === false;
|
|
264
|
+
return {
|
|
265
|
+
key: p.slug,
|
|
266
|
+
title: p.title || p.project || p.slug,
|
|
267
|
+
icon: p.icon || null,
|
|
268
|
+
fallbackIcon: 'folder',
|
|
269
|
+
isCurrent: p.slug === store.get('currentSlug'),
|
|
270
|
+
disabled: unreachable,
|
|
271
|
+
disabledReason: unreachable ? 'Outside project path' : null,
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function buildMateEntries() {
|
|
277
|
+
var mates = store.get('cachedMatesList') || [];
|
|
278
|
+
var currentMateId = mateIdFrom(store.get('dmTargetUser'));
|
|
279
|
+
// Natural list order; MRU is only for choosing the initial highlight.
|
|
280
|
+
return mates.map(function (m) {
|
|
281
|
+
return {
|
|
282
|
+
key: m.id,
|
|
283
|
+
title: m.displayName || m.name || m.id,
|
|
284
|
+
icon: null,
|
|
285
|
+
avatarUrl: mateAvatarUrl(m, 88),
|
|
286
|
+
fallbackIcon: 'user',
|
|
287
|
+
isCurrent: m.id === currentMateId,
|
|
288
|
+
disabled: false,
|
|
289
|
+
disabledReason: null,
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function closeSwitcher(commit) {
|
|
295
|
+
if (!_open) return;
|
|
296
|
+
_open = false;
|
|
297
|
+
_overlay.classList.add('hidden');
|
|
298
|
+
if (!commit) return;
|
|
299
|
+
var entry = _entries[_highlightedIndex];
|
|
300
|
+
if (!entry || entry.disabled) return;
|
|
301
|
+
if (_mode === 'mate') {
|
|
302
|
+
if (entry.key !== currentActiveKey()) openDm(entry.key);
|
|
303
|
+
} else {
|
|
304
|
+
if (entry.key !== store.get('currentSlug')) switchProject(entry.key);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function cycle(delta) {
|
|
309
|
+
if (_entries.length === 0) return;
|
|
310
|
+
// Skip over disabled entries so unreachable worktrees aren't part
|
|
311
|
+
// of the cycle. If every entry is disabled (shouldn't happen, since
|
|
312
|
+
// openSwitcher bails on empty lists), fall back to plain stepping.
|
|
313
|
+
var step = delta > 0 ? 1 : -1;
|
|
314
|
+
var idx = _highlightedIndex;
|
|
315
|
+
for (var i = 0; i < _entries.length; i++) {
|
|
316
|
+
idx = (idx + step + _entries.length) % _entries.length;
|
|
317
|
+
if (!_entries[idx].disabled) {
|
|
318
|
+
_highlightedIndex = idx;
|
|
319
|
+
paintHighlight();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function renderEntries() {
|
|
326
|
+
if (!_list) return;
|
|
327
|
+
_list.innerHTML = '';
|
|
328
|
+
for (var i = 0; i < _entries.length; i++) {
|
|
329
|
+
_list.appendChild(buildEntryNode(_entries[i], i));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function buildEntryNode(entry, index) {
|
|
334
|
+
var item = document.createElement('button');
|
|
335
|
+
item.type = 'button';
|
|
336
|
+
item.className = 'cmd-palette-item project-switcher-item';
|
|
337
|
+
if (entry.disabled) item.classList.add('disabled');
|
|
338
|
+
if (entry.disabled && entry.disabledReason) item.title = entry.disabledReason;
|
|
339
|
+
item.dataset.index = String(index);
|
|
340
|
+
|
|
341
|
+
var iconWrap = document.createElement('div');
|
|
342
|
+
iconWrap.className = 'cmd-palette-item-icon';
|
|
343
|
+
if (entry.icon) {
|
|
344
|
+
iconWrap.textContent = entry.icon;
|
|
345
|
+
} else if (entry.avatarUrl) {
|
|
346
|
+
var img = document.createElement('img');
|
|
347
|
+
img.src = entry.avatarUrl;
|
|
348
|
+
img.alt = '';
|
|
349
|
+
img.className = 'project-switcher-avatar';
|
|
350
|
+
iconWrap.appendChild(img);
|
|
351
|
+
} else {
|
|
352
|
+
var fallback = document.createElement('i');
|
|
353
|
+
fallback.setAttribute('data-lucide', entry.fallbackIcon || 'folder');
|
|
354
|
+
iconWrap.appendChild(fallback);
|
|
355
|
+
}
|
|
356
|
+
item.appendChild(iconWrap);
|
|
357
|
+
|
|
358
|
+
var body = document.createElement('div');
|
|
359
|
+
body.className = 'cmd-palette-item-body';
|
|
360
|
+
var titleRow = document.createElement('div');
|
|
361
|
+
titleRow.className = 'cmd-palette-item-title-row';
|
|
362
|
+
var title = document.createElement('span');
|
|
363
|
+
title.className = 'cmd-palette-item-title';
|
|
364
|
+
title.textContent = entry.title;
|
|
365
|
+
titleRow.appendChild(title);
|
|
366
|
+
if (entry.isCurrent) {
|
|
367
|
+
var badge = document.createElement('span');
|
|
368
|
+
badge.className = 'project-switcher-current';
|
|
369
|
+
badge.textContent = 'current';
|
|
370
|
+
titleRow.appendChild(badge);
|
|
371
|
+
}
|
|
372
|
+
if (entry.disabled && entry.disabledReason) {
|
|
373
|
+
var reason = document.createElement('span');
|
|
374
|
+
reason.className = 'project-switcher-disabled-reason';
|
|
375
|
+
reason.textContent = entry.disabledReason;
|
|
376
|
+
titleRow.appendChild(reason);
|
|
377
|
+
}
|
|
378
|
+
body.appendChild(titleRow);
|
|
379
|
+
item.appendChild(body);
|
|
380
|
+
|
|
381
|
+
item.addEventListener('mouseenter', function () {
|
|
382
|
+
if (entry.disabled) return;
|
|
383
|
+
_highlightedIndex = index;
|
|
384
|
+
paintHighlight();
|
|
385
|
+
});
|
|
386
|
+
item.addEventListener('click', function () {
|
|
387
|
+
if (entry.disabled) return;
|
|
388
|
+
_highlightedIndex = index;
|
|
389
|
+
closeSwitcher(true);
|
|
390
|
+
});
|
|
391
|
+
return item;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function paintHighlight() {
|
|
395
|
+
if (!_list) return;
|
|
396
|
+
var items = _list.querySelectorAll('.cmd-palette-item');
|
|
397
|
+
for (var i = 0; i < items.length; i++) {
|
|
398
|
+
items[i].classList.toggle('active', i === _highlightedIndex);
|
|
399
|
+
}
|
|
400
|
+
var active = items[_highlightedIndex];
|
|
401
|
+
if (active && active.scrollIntoView) {
|
|
402
|
+
active.scrollIntoView({ block: 'nearest' });
|
|
403
|
+
}
|
|
404
|
+
}
|