feat: add global work search (#195)
This commit is contained in:
1 parent
de20406a45
commit
7651e5b090
8 files changed
+359
-17
No files matched your search
+40
-1
@@ -3,8 +3,47 @@
|
||||
if (typeof module === 'object' && module.exports) module.exports = filterCommands;
|
||||
if (root) root.filterCommands = filterCommands;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function filterCommands(commands, filter) {
|
||||
function filterCommands(commands, filter) {
|
||||
const term = String(filter || '').toLowerCase();
|
||||
return commands.filter(command => command.name.toLowerCase().includes(term));
|
||||
}
|
||||
|
||||
filterCommands.nextSelection = function nextSelection(current, key, count) {
|
||||
if (count < 1) return -1;
|
||||
if (key === 'ArrowDown') return (current + 1 + count) % count;
|
||||
if (key === 'ArrowUp') return (current - 1 + count) % count;
|
||||
return current;
|
||||
};
|
||||
|
||||
filterCommands.createGlobalSearchController = function createGlobalSearchController(options) {
|
||||
const search = options.search;
|
||||
const onState = options.onState;
|
||||
const delay = options.delay === undefined ? 250 : options.delay;
|
||||
let timer = null;
|
||||
let generation = 0;
|
||||
|
||||
return {
|
||||
setQuery(value) {
|
||||
const query = String(value || '').trim();
|
||||
generation += 1;
|
||||
const current = generation;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
if (query.length < 2) {
|
||||
onState({ status: 'idle', query, items: [] });
|
||||
return;
|
||||
}
|
||||
onState({ status: 'loading', query, items: [] });
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
const items = await search(query);
|
||||
if (current === generation) onState({ status: 'ready', query, items });
|
||||
} catch (error) {
|
||||
if (current === generation) onState({ status: 'error', query, items: [], error });
|
||||
}
|
||||
}, delay);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return filterCommands;
|
||||
});
|
||||
+93
-13
@@ -40,8 +40,11 @@ input[type=\"text\"], textarea { width: 100%; padding: 8px; border-radius: 8px;
|
||||
textarea { resize: vertical; min-height: 120px; }
|
||||
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
|
||||
#cmd-palette.open { display: block; }
|
||||
.cmd-item { padding: 10px; cursor: pointer; border-radius: 10px; color:#e5e7eb; }
|
||||
.cmd-item:hover { background: #10233a; }
|
||||
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
||||
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
|
||||
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
|
||||
.cmd-status { padding:10px; color:#93a4b8; font-size:13px; }
|
||||
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
|
||||
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
|
||||
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
|
||||
.modal { background: #0b1526; border: 1px solid #2a496e; border-radius: 14px; padding: 14px; width: min(1100px, 94vw); max-height: 92vh; overflow: auto; }
|
||||
@@ -332,8 +335,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
</main>
|
||||
|
||||
<div id="cmd-palette" role="dialog" aria-label="Command palette">
|
||||
<input id="cmd-input" type="text" placeholder="Type a command, e.g. whiteboard, markdown..." />
|
||||
<div id="cmd-results" class="stack" style="margin-top:8px;"></div>
|
||||
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
|
||||
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results" style="margin-top:8px;max-height:min(65vh,520px);overflow-y:auto;"></div>
|
||||
</div>
|
||||
|
||||
<div id="whiteboard-modal">
|
||||
@@ -1759,17 +1762,95 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
{ name: 'Refresh now', run: load },
|
||||
{ name: 'Scroll issues', run: () => qs('#work').scrollIntoView({ behavior:'smooth', block:'start' }) },
|
||||
];
|
||||
let commandSearchState = { status:'idle', query:'', items:[] };
|
||||
let commandItems = [];
|
||||
let commandSelection = -1;
|
||||
async function searchGlobalWork(query) {
|
||||
const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10', {
|
||||
headers: { Accept:'application/json' },
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Search is temporarily unavailable.');
|
||||
return Array.isArray(payload.items) ? payload.items : [];
|
||||
}
|
||||
const commandSearch = filterCommands.createGlobalSearchController({
|
||||
search: searchGlobalWork,
|
||||
onState: state => {
|
||||
commandSearchState = state;
|
||||
renderCommands(state.query);
|
||||
},
|
||||
});
|
||||
function safeSearchUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
||||
} catch (_) { return ''; }
|
||||
}
|
||||
function runCommandItem(item) {
|
||||
if (item.command) item.command.run();
|
||||
else {
|
||||
const url = safeSearchUrl(item.result.url);
|
||||
if (url) window.location.assign(url);
|
||||
}
|
||||
qs('#cmd-palette').classList.remove('open');
|
||||
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
||||
qs('#cmd-input').value = '';
|
||||
}
|
||||
function renderCommands(filter) {
|
||||
const el = qs('#cmd-results');
|
||||
const items = filterCommands(commands, filter);
|
||||
el.innerHTML = items.map((c, idx) => '<div class="cmd-item" data-idx="' + idx + '">' + escapeHtml(c.name) + '</div>').join('');
|
||||
const local = filterCommands(commands, filter).map(command => ({ command }));
|
||||
const remote = commandSearchState.query === String(filter || '').trim()
|
||||
? commandSearchState.items.map(result => ({ result })) : [];
|
||||
commandItems = local.concat(remote);
|
||||
if (commandSelection >= commandItems.length) commandSelection = -1;
|
||||
let html = local.length ? '<div class="cmd-group">Commands</div>' : '';
|
||||
html += local.map((item, idx) => '<div class="cmd-item' + (idx === commandSelection ? ' selected' : '') + '" role="option" aria-selected="' + (idx === commandSelection) + '" data-idx="' + idx + '"><span>' + escapeHtml(item.command.name) + '</span><span class="cmd-meta">Command</span></div>').join('');
|
||||
if (remote.length) html += '<div class="cmd-group">Issues and pull requests</div>';
|
||||
html += remote.map((item, remoteIdx) => {
|
||||
const idx = local.length + remoteIdx;
|
||||
const result = item.result;
|
||||
return '<div class="cmd-item' + (idx === commandSelection ? ' selected' : '') + '" role="option" aria-selected="' + (idx === commandSelection) + '" data-idx="' + idx + '"><span>' + escapeHtml(result.title) + '</span><span class="cmd-meta">' + escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' + escapeHtml(result.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + escapeHtml(result.state) + '</span></div>';
|
||||
}).join('');
|
||||
if (commandSearchState.status === 'loading') html += '<div class="cmd-status">Searching accessible work…</div>';
|
||||
else if (commandSearchState.status === 'error') html += '<div class="cmd-status">Search unavailable. Keep typing or retry.</div>';
|
||||
else if (String(filter || '').trim().length >= 2 && !remote.length) html += '<div class="cmd-status">No matching issues or pull requests.</div>';
|
||||
el.innerHTML = html;
|
||||
el.querySelectorAll('.cmd-item').forEach((item) => {
|
||||
item.addEventListener('click', () => { items[Number(item.dataset.idx)].run(); qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').value=''; });
|
||||
item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)]));
|
||||
});
|
||||
}
|
||||
qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); });
|
||||
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
|
||||
function openCommandPalette() {
|
||||
qs('#cmd-palette').classList.add('open');
|
||||
qs('#cmd-input').setAttribute('aria-expanded', 'true');
|
||||
qs('#cmd-input').focus();
|
||||
commandSelection = -1;
|
||||
renderCommands(qs('#cmd-input').value);
|
||||
}
|
||||
qs('#open-palette').addEventListener('click', openCommandPalette);
|
||||
qs('#cmd-input').addEventListener('input', (e) => {
|
||||
commandSelection = -1;
|
||||
renderCommands(e.target.value);
|
||||
commandSearch.setQuery(e.target.value);
|
||||
});
|
||||
qs('#cmd-input').addEventListener('keydown', event => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
commandSelection = filterCommands.nextSelection(commandSelection, event.key, commandItems.length);
|
||||
renderCommands(event.currentTarget.value);
|
||||
const selected = qs('#cmd-results .selected');
|
||||
if (selected) selected.scrollIntoView({ block:'nearest' });
|
||||
} else if (event.key === 'Enter' && commandSelection >= 0) {
|
||||
event.preventDefault();
|
||||
runCommandItem(commandItems[commandSelection]);
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && qs('#cmd-palette').classList.contains('open')) {
|
||||
e.preventDefault();
|
||||
qs('#cmd-palette').classList.remove('open');
|
||||
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape' && findingWork) {
|
||||
e.preventDefault();
|
||||
closeFindWorkSheet();
|
||||
@@ -1793,11 +1874,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
if ((e.metaKey||e.ctrlKey) && e.key==='k') {
|
||||
e.preventDefault();
|
||||
qs('#cmd-palette').classList.toggle('open');
|
||||
if (qs('#cmd-palette').classList.contains('open')) {
|
||||
qs('#cmd-input').focus();
|
||||
renderCommands('');
|
||||
}
|
||||
qs('#cmd-palette').classList.remove('open');
|
||||
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
||||
} else openCommandPalette();
|
||||
}
|
||||
});
|
||||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||||
|
||||
Reference in new issue
Block a user