Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 8 additions & 21 deletions packages/shell/src/lib/app/commands.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { Identity } from "@commontools/identity";
import { AppView, isAppView } from "./view.ts";
import { AppStateConfigKey } from "./state.ts";

export type Command =
| { type: "set-view"; view: AppView }
| { type: "set-identity"; identity: Identity }
| { type: "clear-authentication" }
| { type: "set-show-charm-list-view"; show: boolean }
| { type: "set-show-debugger-view"; show: boolean }
| { type: "set-show-quick-jump-view"; show: boolean }
| { type: "set-show-sidebar"; show: boolean }
| { type: "set-identity"; identity: Identity | undefined }
| { type: "set-config"; key: AppStateConfigKey; value: boolean }
| { type: "toggle-favorite"; charmId: string };

export function isCommand(value: unknown): value is Command {
Expand All @@ -20,25 +17,15 @@ export function isCommand(value: unknown): value is Command {
}
switch (value.type) {
case "set-identity": {
return "identity" in value && value.identity instanceof Identity;
return "identity" in value &&
(value.identity === undefined || value.identity instanceof Identity);
}
case "set-view": {
return "view" in value && isAppView(value.view);
}
case "clear-authentication": {
return true;
}
case "set-show-charm-list-view": {
return "show" in value && typeof value.show === "boolean";
}
case "set-show-debugger-view": {
return "show" in value && typeof value.show === "boolean";
}
case "set-show-quick-jump-view": {
return "show" in value && typeof value.show === "boolean";
}
case "set-show-sidebar": {
return "show" in value && typeof value.show === "boolean";
case "set-config": {
return "key" in value && typeof value.key === "string" &&
"value" in value && typeof value.value === "boolean";
}
case "toggle-favorite": {
return "charmId" in value && typeof value.charmId === "string";
Expand Down
36 changes: 17 additions & 19 deletions packages/shell/src/lib/app/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,31 @@ export interface AppState {
identity?: Identity;
view: AppView;
apiUrl: URL;
config: AppStateConfig;
}

export interface AppStateConfig {
showShellCharmListView?: boolean;
showDebuggerView?: boolean;
showQuickJumpView?: boolean;
showSidebar?: boolean;
}

export type AppStateConfigKey = keyof AppStateConfig;

export type AppStateSerialized = Omit<AppState, "identity" | "apiUrl"> & {
identity?: TransferrableInsecureCryptoKeyPair | null;
apiUrl: string;
};

export function createAppState(
initial: Pick<AppState, "view" | "apiUrl" | "identity"> & {
config?: AppStateConfig;
},
): AppState {
return Object.assign({}, initial, { config: initial.config ?? {} });
}

export function clone(state: AppState): AppState {
const view = typeof state.view === "object"
? Object.assign({}, state.view)
Expand All @@ -45,28 +59,12 @@ export function applyCommand(
case "set-view": {
next.view = command.view;
if ("charmId" in command.view && command.view.charmId) {
next.showShellCharmListView = false;
next.config.showShellCharmListView = false;
}
break;
}
case "clear-authentication": {
next.identity = undefined;
break;
}
case "set-show-charm-list-view": {
next.showShellCharmListView = command.show;
break;
}
case "set-show-debugger-view": {
next.showDebuggerView = command.show;
break;
}
case "set-show-quick-jump-view": {
next.showQuickJumpView = command.show;
break;
}
case "set-show-sidebar": {
next.showSidebar = command.show;
case "set-config": {
next.config[command.key] = command.value;
break;
}
}
Expand Down
38 changes: 35 additions & 3 deletions packages/shell/src/lib/debugger-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,14 @@ export interface WatchedCell {
* - Watched cell subscriptions with console logging
*/
export class DebuggerController implements ReactiveController {
private host: ReactiveControllerHost;
private host: ReactiveControllerHost & HTMLElement;
private runtime?: RuntimeInternals;
private visible = false;
private telemetryMarkers: RuntimeTelemetryMarkerResult[] = [];
private updateVersion = 0;
private watchedCells = new Map<string, WatchedCell>();

constructor(host: ReactiveControllerHost) {
constructor(host: ReactiveControllerHost & HTMLElement) {
this.host = host;
this.host.addController(this);
}
Expand All @@ -61,12 +61,17 @@ export class DebuggerController implements ReactiveController {
this.visible = savedVisible === "true";
}

// Listen for storage events (cross-tab sync)
globalThis.addEventListener("storage", this.handleStorageChange);
this.host.addEventListener("ct-cell-watch", this.handleCellWatch);
this.host.addEventListener("ct-cell-unwatch", this.handleCellUnwatch);
this.host.addEventListener("clear-telemetry", this.handleClearTelemetry);
}

hostDisconnected() {
globalThis.removeEventListener("storage", this.handleStorageChange);
this.host.removeEventListener("ct-cell-watch", this.handleCellWatch);
this.host.removeEventListener("ct-cell-unwatch", this.handleCellUnwatch);
this.host.removeEventListener("clear-telemetry", this.handleClearTelemetry);
// Clean up all watched cell subscriptions to prevent memory leaks
this.unwatchAll();
}
Expand Down Expand Up @@ -340,4 +345,31 @@ export class DebuggerController implements ReactiveController {
const shortId = id.split(":").pop()?.slice(-6) ?? "???";
return `#${shortId}`;
}

private handleCellWatch = (e: Event) => {
const event = e as CustomEvent<{ cell: unknown; label?: string }>;
const { cell, label } = event.detail;
// Cell type from @commontools/runner
if (cell && typeof (cell as any).sink === "function") {
this.watchCell(cell as any, label);
}
};

private handleCellUnwatch = (e: Event) => {
const event = e as CustomEvent<{ cell: unknown; label?: string }>;
const { cell } = event.detail;
// Find and remove the watch by matching the cell
if (cell && typeof (cell as any).getAsNormalizedFullLink === "function") {
const link = (cell as any).getAsNormalizedFullLink();
const watches = this.getWatchedCells();
const watch = watches.find((w) => w.cellLink.id === link.id);
if (watch) {
this.unwatchCell(watch.id);
}
}
};

private handleClearTelemetry = () => {
this.clearTelemetry();
};
}
72 changes: 60 additions & 12 deletions packages/shell/src/lib/keyboard-router.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
import { ReactiveController, ReactiveControllerHost } from "lit";
import { BaseView } from "../views/BaseView.ts";
import { navigate } from "./navigate.ts";
import { AppState } from "./app/mod.ts";

// Reactive controller host is XAppView, define some interfaces
// to avoid a recursive dependency.
type ReactiveAppHost = ReactiveControllerHost & BaseView & { app: AppState };

export type ShortcutSpec = {
name?: string;
code?: string;
Expand Down Expand Up @@ -45,12 +54,61 @@ type RegisteredShortcut = {
*
* Not (yet): scopes, modality, capture-phase arbitration. Can be extended.
*/
export class KeyboardRouter {
export class KeyboardController implements ReactiveController {
private host: ReactiveAppHost;

#unsubShortcuts: Array<() => void> = [];
#shortcuts: RegisteredShortcut[] = [];
#alt = false;
#ctrl = false;
#meta = false;
#shift = false;

constructor(host: ReactiveAppHost) {
this.host = host;
this.host.addController(this);
}

hostConnected() {
document.addEventListener("keydown", this.#onKeyDown);
document.addEventListener("keyup", this.#onKeyUp);

// Register global shortcuts via keyboard router
const isMac = navigator.platform.toLowerCase().includes("mac");
const mod = isMac ? { meta: true } : { ctrl: true };
this.#unsubShortcuts.push(
this.register(
{ code: "KeyO", ...mod, shift: true, preventDefault: true },
() => {
this.host.command({
type: "set-config",
key: "showQuickJumpView",
value: true,
});
},
),
);
this.#unsubShortcuts.push(
this.register(
{ code: "KeyW", alt: true, preventDefault: true },
() => {
const app = this.host.app;
const spaceName = app && "spaceName" in app.view
? app.view.spaceName
: "common-knowledge";
navigate({ spaceName });
},
),
);
}

hostDisconnected() {
document.removeEventListener("keydown", this.#onKeyDown);
document.removeEventListener("keyup", this.#onKeyUp);
for (const off of this.#unsubShortcuts) off();
this.#unsubShortcuts.length = 0;
}

#onKeyDown = (e: KeyboardEvent) => {
this.#updateMods(e, true);

Expand Down Expand Up @@ -85,21 +143,11 @@ export class KeyboardRouter {
if (s.stopPropagation) e.stopPropagation();
best.handler(e);
};

#onKeyUp = (e: KeyboardEvent) => {
this.#updateMods(e, false);
};

constructor() {
document.addEventListener("keydown", this.#onKeyDown);
document.addEventListener("keyup", this.#onKeyUp);
}

dispose() {
document.removeEventListener("keydown", this.#onKeyDown);
document.removeEventListener("keyup", this.#onKeyUp);
this.#shortcuts = [];
}

register(spec: ShortcutSpec, handler: ShortcutHandler): () => void {
const normalized: RegisteredShortcut = {
spec: {
Expand Down
Loading