import { css, html } from "lit";
import { property, state } from "lit/decorators.js";
import { BaseView } from "./BaseView.ts";
import { RuntimeInternals } from "../lib/runtime.ts";
import { Task } from "@lit/task";
import { CharmController } from "@commontools/charm/ops";
import { charmId } from "@commontools/charm";
import { Cell } from "@commontools/runner";
import { CellEventTarget, CellUpdateEvent } from "../lib/cell-event-target.ts";
import { NAME } from "@commontools/runner";
import { navigate } from "../lib/navigate.ts";
type CharmItem = { id: string; name: string };
export class XQuickJumpView extends BaseView {
static override styles = css`
:host {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: none;
align-items: center;
justify-content: center;
z-index: 9999;
}
:host([visible]) {
display: flex;
}
.backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.3);
}
.panel {
position: relative;
width: min(720px, 90vw);
background: #fff;
border: var(--border-width, 2px) solid var(--border-color, #000);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2);
overflow: hidden;
display: flex;
flex-direction: column;
}
.input {
padding: 10px 12px;
border: 0;
border-bottom: var(--border-width, 2px) solid var(--border-color, #000);
outline: none;
font-size: 14px;
width: 100%;
box-sizing: border-box;
}
.list {
max-height: 300px;
overflow: auto;
padding: 6px 0;
margin: 0;
list-style: none;
}
.item {
padding: 8px 12px;
cursor: pointer;
display: flex;
gap: 8px;
align-items: baseline;
}
.item[aria-selected="true"] {
background: #f0f4ff;
}
.item:hover {
background: #f0f4ff;
}
.name {
flex: 1;
color: var(--text-primary, #000);
}
.id {
color: #666;
font-size: 12px;
}
`;
@property({ type: Boolean, reflect: true })
visible = false;
@property({ attribute: false })
rt?: RuntimeInternals;
@state()
private query = "";
@state()
private selectedIndex = 0;
private inputEl?: HTMLInputElement | null;
private charmListSubscription?: CellEventTarget[]>;
private nameSubscriptions: Map> =
new Map();
private _charms = new Task(this, {
task: async ([rt]) => {
if (!rt) return undefined;
const manager = rt.cc().manager();
await manager.synced();
return rt.cc().getAllCharms();
},
args: () => [this.rt],
});
override updated(changed: Map) {
super.updated(changed);
if (changed.has("rt")) {
this.teardownSubscriptions();
this.setupSubscriptions();
}
if (changed.has("visible") && this.visible) {
// Focus input when opened
this.updateComplete.then(() => {
this.inputEl = this.renderRoot.querySelector("input.input");
this.inputEl?.focus();
this.inputEl?.select();
});
}
}
private setupSubscriptions() {
const rt = this.rt;
if (!rt) return;
const charmsCell = rt.cc().manager().getCharms();
this.charmListSubscription = new CellEventTarget(charmsCell);
this.charmListSubscription.addEventListener(
"update",
this.onCharmListUpdate,
);
// Initialize name subscriptions with current list
try {
const list = charmsCell.get();
this.resetNameSubscriptions(list);
} catch {
// ignore
}
}
private teardownSubscriptions() {
if (this.charmListSubscription) {
this.charmListSubscription.removeEventListener(
"update",
this.onCharmListUpdate,
);
this.charmListSubscription = undefined;
}
for (const [_, target] of this.nameSubscriptions) {
target.removeEventListener("update", this.onCharmNameUpdate);
}
this.nameSubscriptions.clear();
}
private onCharmListUpdate = (e: Event) => {
const event = e as CellUpdateEvent[]>;
const list = event.detail ?? [];
this.resetNameSubscriptions(list);
// Rebuild controllers list used by getItems
this._charms.run();
};
private resetNameSubscriptions(list: readonly Cell[]) {
// Remove old
for (const [_, target] of this.nameSubscriptions) {
target.removeEventListener("update", this.onCharmNameUpdate);
}
this.nameSubscriptions.clear();
// Add new
for (const c of list) {
const id = charmId(c as Cell);
if (!id) continue;
const nameCell = (c as Cell).key(NAME) as Cell;
const target = new CellEventTarget(nameCell);
target.addEventListener("update", this.onCharmNameUpdate);
this.nameSubscriptions.set(id, target);
}
}
private onCharmNameUpdate = (_e: Event) => {
// Any name change should refresh render so c.name() reflects latest value
this.requestUpdate();
};
private close() {
this.query = "";
this.selectedIndex = 0;
this.command({
type: "set-config",
key: "showQuickJumpView",
value: false,
});
}
private getItems(): CharmItem[] {
const list = this._charms.value || [];
return list.map((c: CharmController) => ({
id: c.id,
name: c.name() ?? "Untitled Charm",
}));
}
private containsInsensitive(a: string, b: string): boolean {
return a.toLowerCase().includes(b.toLowerCase());
}
private score(item: CharmItem, q: string): number {
if (!q) return 0;
const n = item.name.toLowerCase();
const i = item.id.toLowerCase();
const ql = q.toLowerCase();
let s = 0;
if (n === ql) s += 1000;
if (n.startsWith(ql)) s += 500;
if (this.containsInsensitive(n, ql)) s += 200;
if (this.containsInsensitive(i, ql)) s += 100;
// simple subsequence bonus
let qi = 0;
for (let ni = 0; ni < n.length && qi < ql.length; ni++) {
if (n[ni] === ql[qi]) qi++;
}
if (qi === ql.length) s += 50;
return s;
}
private filtered(): CharmItem[] {
const items = this.getItems();
const q = this.query.trim();
if (!q) return items.slice(0, 20);
return items
.map((it) => ({ it, s: this.score(it, q) }))
.filter((x) => x.s > 0)
.sort((a, b) => b.s - a.s)
.slice(0, 20)
.map((x) => x.it);
}
private onKeyDown = (e: KeyboardEvent) => {
if (!this.visible) return;
if (e.key === "Escape") {
e.preventDefault();
this.close();
return;
}
const results = this.filtered();
if (e.key === "ArrowDown") {
e.preventDefault();
this.selectedIndex = Math.min(
results.length - 1,
this.selectedIndex + 1,
);
} else if (e.key === "ArrowUp") {
e.preventDefault();
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
} else if (e.key === "Enter") {
e.preventDefault();
const sel = results[this.selectedIndex];
if (sel) this.navigateTo(sel.id);
}
};
private onInput = (e: Event) => {
const t = e.target as HTMLInputElement;
this.query = t.value;
this.selectedIndex = 0;
};
private onClickBackdrop = (e: Event) => {
e.preventDefault();
this.close();
};
private onClickItem = (id: string) => {
this.navigateTo(id);
};
private navigateTo(id: string) {
const spaceName = this.rt?.cc().manager().getSpaceName();
if (!spaceName) return;
navigate({ spaceName, charmId: id });
this.close();
}
override connectedCallback(): void {
super.connectedCallback();
document.addEventListener("keydown", this.onKeyDown);
this.setupSubscriptions();
}
override disconnectedCallback(): void {
document.removeEventListener("keydown", this.onKeyDown);
this.teardownSubscriptions();
super.disconnectedCallback();
}
override render() {
if (!this.visible) {
return html`
`;
}
const results = this.filtered();
const selected = this.selectedIndex;
return html`
${results.map((r, idx) =>
html`
- {
this.selectedIndex = idx;
}}"
@click="${() => this.onClickItem(r.id)}"
>
${r.name}
${r.id}
`
)} ${results.length === 0
? html`
No matches
`
: ""}
`;
}
}
globalThis.customElements.define("x-quick-jump-view", XQuickJumpView);
|