mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(web): Cmd+K command palette — fuzzy file search, project switching, quick actions
Cmd/Ctrl+K opens a palette over the viewer: fuzzy-match any synced file (word-start and streak-aware scoring, matched letters highlighted), switch projects, and run context actions (share/history/download of the open file, project history, upload, sign out). Esc, backdrop click, or Cmd+K again closes it; arrows + Enter drive it. The overlay CSS now re-asserts [hidden] since its display:flex otherwise overrides the UA rule and the palette could never dismiss. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
This commit is contained in:
co-authored by
Claude Fable 5
parent
34b2ca184c
commit
37f2123bbd
@@ -486,6 +486,166 @@ function sha256Fallback(bytes) {
|
||||
return [...H].map((x) => (x >>> 0).toString(16).padStart(8, "0")).join("");
|
||||
}
|
||||
|
||||
/* ---- command palette (⌘K / Ctrl+K) ----
|
||||
One box for everything: fuzzy-jump to any file, switch projects, and run
|
||||
quick actions (share, history, upload, download, sign out). */
|
||||
let paletteItems = [];
|
||||
let paletteSel = 0;
|
||||
|
||||
function paletteOpen() {
|
||||
buildPaletteItems("");
|
||||
$("palette-overlay").hidden = false;
|
||||
const input = $("palette-input");
|
||||
input.value = "";
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function paletteClose() {
|
||||
$("palette-overlay").hidden = true;
|
||||
}
|
||||
|
||||
/* Subsequence fuzzy match. Returns a score (higher = better) or -1, plus
|
||||
the matched positions for highlighting. */
|
||||
function fuzzy(query, text) {
|
||||
if (!query) return { score: 0, hits: [] };
|
||||
const q = query.toLowerCase();
|
||||
const t = text.toLowerCase();
|
||||
let ti = 0, score = 0, streak = 0;
|
||||
const hits = [];
|
||||
for (let qi = 0; qi < q.length; qi++) {
|
||||
const found = t.indexOf(q[qi], ti);
|
||||
if (found === -1) return null;
|
||||
streak = found === ti ? streak + 1 : 1;
|
||||
score += streak * 3; // consecutive runs
|
||||
if (found === 0 || "/ -_.".includes(t[found - 1])) score += 8; // word starts
|
||||
hits.push(found);
|
||||
ti = found + 1;
|
||||
}
|
||||
score -= Math.floor(t.length / 8); // mild preference for short targets
|
||||
return { score, hits };
|
||||
}
|
||||
|
||||
function highlight(text, hits) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "plabel";
|
||||
let last = 0;
|
||||
for (const h of hits) {
|
||||
if (h > last) span.append(text.slice(last, h));
|
||||
const b = document.createElement("b");
|
||||
b.textContent = text[h];
|
||||
span.append(b);
|
||||
last = h + 1;
|
||||
}
|
||||
span.append(text.slice(last));
|
||||
return span;
|
||||
}
|
||||
|
||||
/* Candidate sources: context-aware actions, then projects, then files. */
|
||||
function paletteCandidates() {
|
||||
const items = [];
|
||||
const add = (icon, label, kind, run) => items.push({ icon, label, kind, run });
|
||||
const hub = serverConfig.mode === "hub";
|
||||
|
||||
if (hub && currentProject && currentPath) {
|
||||
add("↗", "Share: " + currentPath, "action", () => $("share-btn").click());
|
||||
add("⌚", "History: " + currentPath, "action", () => showHistory({ path: currentPath }));
|
||||
add("↓", "Download: " + currentPath, "action", () => $("download").click());
|
||||
}
|
||||
if (hub && currentProject) {
|
||||
add("⌚", "History: whole project", "action", () => showHistory({ prefix: "" }));
|
||||
}
|
||||
if (!$("upload-btn").hidden) {
|
||||
add("+", "Upload a file…", "action", () => $("upload-btn").click());
|
||||
}
|
||||
if (hub) {
|
||||
for (const p of projects) {
|
||||
if (!currentProject || p.id !== currentProject.id) {
|
||||
add("▣", "Switch to project: " + p.name, "project", () => selectProject(p, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (serverConfig.auth && serverConfig.auth.enabled) {
|
||||
add("⏻", "Sign out", "action", () => { location.href = "/auth/logout"; });
|
||||
}
|
||||
for (const f of flatFiles) {
|
||||
add("◦", f.path, "file", () => openFile(f.path));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildPaletteItems(query) {
|
||||
const scored = [];
|
||||
for (const c of paletteCandidates()) {
|
||||
const m = fuzzy(query, c.label);
|
||||
if (m) scored.push({ ...c, score: m.score, hits: m.hits });
|
||||
}
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
paletteItems = scored.slice(0, 40);
|
||||
paletteSel = 0;
|
||||
renderPalette();
|
||||
}
|
||||
|
||||
function renderPalette() {
|
||||
const ul = $("palette-results");
|
||||
ul.innerHTML = "";
|
||||
if (paletteItems.length === 0) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "pempty";
|
||||
li.textContent = "No matches";
|
||||
ul.appendChild(li);
|
||||
return;
|
||||
}
|
||||
paletteItems.forEach((item, i) => {
|
||||
const li = document.createElement("li");
|
||||
if (i === paletteSel) li.classList.add("selected");
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "picon";
|
||||
icon.textContent = item.icon;
|
||||
const kind = document.createElement("span");
|
||||
kind.className = "pkind";
|
||||
kind.textContent = item.kind;
|
||||
li.append(icon, highlight(item.label, item.hits), kind);
|
||||
li.onclick = () => runPaletteItem(item);
|
||||
li.onmousemove = () => { if (paletteSel !== i) { paletteSel = i; renderPalette(); } };
|
||||
ul.appendChild(li);
|
||||
});
|
||||
const sel = ul.children[paletteSel];
|
||||
if (sel) sel.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function runPaletteItem(item) {
|
||||
paletteClose();
|
||||
item.run();
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
$("palette-overlay").hidden ? paletteOpen() : paletteClose();
|
||||
return;
|
||||
}
|
||||
if ($("palette-overlay").hidden) return;
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
paletteClose();
|
||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const n = paletteItems.length;
|
||||
if (n) {
|
||||
paletteSel = (paletteSel + (e.key === "ArrowDown" ? 1 : n - 1)) % n;
|
||||
renderPalette();
|
||||
}
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (paletteItems[paletteSel]) runPaletteItem(paletteItems[paletteSel]);
|
||||
}
|
||||
});
|
||||
|
||||
$("palette-input").addEventListener("input", (e) => buildPaletteItems(e.target.value));
|
||||
$("palette-overlay").addEventListener("click", (e) => {
|
||||
if (e.target.id === "palette-overlay") paletteClose();
|
||||
});
|
||||
|
||||
window.addEventListener("hashchange", () => {
|
||||
const { project, path } = parseHash();
|
||||
if (serverConfig.mode === "hub" && project && (!currentProject || currentProject.id !== project)) {
|
||||
|
||||
@@ -30,6 +30,14 @@
|
||||
<div class="empty">Select a file from the sidebar</div>
|
||||
</article>
|
||||
</main>
|
||||
<div id="palette-overlay" hidden>
|
||||
<div id="palette" role="dialog" aria-label="Search and quick actions">
|
||||
<input id="palette-input" type="text" placeholder="Search files, projects, actions…"
|
||||
autocomplete="off" spellcheck="false">
|
||||
<ul id="palette-results"></ul>
|
||||
<footer id="palette-hint">↑↓ navigate · ⏎ select · esc close</footer>
|
||||
</div>
|
||||
</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -133,6 +133,68 @@ body {
|
||||
.hact a { color: var(--accent); text-decoration: none; margin-left: 8px; }
|
||||
.empty { color: var(--text-faint); text-align: center; margin-top: 20vh; }
|
||||
|
||||
/* ---- command palette (⌘K) ---- */
|
||||
#palette-overlay[hidden] { display: none; }
|
||||
#palette-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, .45);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding-top: 12vh;
|
||||
z-index: 100;
|
||||
}
|
||||
#palette {
|
||||
width: min(560px, 90vw);
|
||||
background: var(--bg-side);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, .5);
|
||||
overflow: hidden;
|
||||
}
|
||||
#palette-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 14px 16px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
}
|
||||
#palette-results {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
#palette-results li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
#palette-results li.selected { background: var(--bg-active); color: var(--text); }
|
||||
#palette-results li .picon { width: 18px; flex: none; text-align: center; color: var(--text-faint); }
|
||||
#palette-results li .plabel { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
#palette-results li .plabel b { color: var(--accent); font-weight: 600; }
|
||||
#palette-results li .pkind { flex: none; font-size: 11px; color: var(--text-faint); }
|
||||
#palette-results .pempty { color: var(--text-faint); cursor: default; justify-content: center; }
|
||||
#palette-hint {
|
||||
padding: 7px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* ---- markdown ---- */
|
||||
.markdown { max-width: 820px; }
|
||||
.markdown h1, .markdown h2, .markdown h3, .markdown h4 {
|
||||
|
||||
Reference in New Issue
Block a user