Archived
Add keyboard action palette
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
export interface AppAction {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
shortcut?: string;
|
||||||
|
group?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
run: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enabledActions(actions: AppAction[]): AppAction[] {
|
||||||
|
return actions.filter((action) => action.enabled !== false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createAppActions } from "./appActions";
|
||||||
|
import { initialAppState, type AppState } from "./appState";
|
||||||
|
|
||||||
|
function createContext(statePatch: Partial<AppState> = {}) {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const context = {
|
||||||
|
state: { ...initialAppState(), ...statePatch },
|
||||||
|
openActionPalette: vi.fn(() => { calls.push("openActionPalette"); }),
|
||||||
|
focusPrompt: vi.fn(() => { calls.push("focusPrompt"); }),
|
||||||
|
addProject: vi.fn(() => { calls.push("addProject"); }),
|
||||||
|
selectMainView: vi.fn((view: AppState["mainView"]) => { calls.push(`selectMainView:${view}`); }),
|
||||||
|
refreshFiles: vi.fn(() => { calls.push("refreshFiles"); }),
|
||||||
|
refreshGit: vi.fn(() => { calls.push("refreshGit"); }),
|
||||||
|
startSession: vi.fn(() => { calls.push("startSession"); }),
|
||||||
|
stopActiveWork: vi.fn(() => { calls.push("stopActiveWork"); }),
|
||||||
|
};
|
||||||
|
return { context, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createAppActions", () => {
|
||||||
|
it("disables workspace and session actions when no workspace/session is selected", () => {
|
||||||
|
const { context } = createContext();
|
||||||
|
const actions = createAppActions(context);
|
||||||
|
|
||||||
|
expect(actions.find((action) => action.id === "view.files")?.enabled).toBe(false);
|
||||||
|
expect(actions.find((action) => action.id === "session.start")?.enabled).toBe(false);
|
||||||
|
expect(actions.find((action) => action.id === "session.stop")?.enabled).toBe(false);
|
||||||
|
expect(actions.find((action) => action.id === "actions.show")?.enabled).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables workspace actions when a workspace is selected", () => {
|
||||||
|
const { context } = createContext({ selectedWorkspace: testWorkspace() });
|
||||||
|
const actions = createAppActions(context);
|
||||||
|
|
||||||
|
expect(actions.find((action) => action.id === "view.files")?.enabled).toBe(true);
|
||||||
|
expect(actions.find((action) => action.id === "session.start")?.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes refresh current to the active workspace tool", () => {
|
||||||
|
const { context, calls } = createContext({
|
||||||
|
selectedWorkspace: testWorkspace(),
|
||||||
|
workspaceTool: "git",
|
||||||
|
});
|
||||||
|
const action = createAppActions(context).find((candidate) => candidate.id === "workspace.refresh-current");
|
||||||
|
|
||||||
|
if (action !== undefined) void action.run();
|
||||||
|
|
||||||
|
expect(calls).toEqual(["refreshGit"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only enables stop while a session is actively working", () => {
|
||||||
|
const selectedSession = testSession();
|
||||||
|
const inactive = createAppActions(createContext({ selectedSession }).context);
|
||||||
|
const active = createAppActions(createContext({ selectedSession, status: testStatus({ isStreaming: true }) }).context);
|
||||||
|
|
||||||
|
expect(inactive.find((action) => action.id === "session.stop")?.enabled).toBe(false);
|
||||||
|
expect(active.find((action) => action.id === "session.stop")?.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function testWorkspace(): AppState["selectedWorkspace"] {
|
||||||
|
return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitWorktree: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function testSession(): AppState["selectedSession"] {
|
||||||
|
return { id: "s1", path: "/tmp/project/.pi/sessions/s1", cwd: "/tmp/project", created: "now", modified: "now", messageCount: 0, firstMessage: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function testStatus(patch: Partial<NonNullable<AppState["status"]>> = {}): AppState["status"] {
|
||||||
|
return {
|
||||||
|
sessionId: "s1",
|
||||||
|
isStreaming: false,
|
||||||
|
isCompacting: false,
|
||||||
|
isBashRunning: false,
|
||||||
|
pendingMessageCount: 0,
|
||||||
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
cost: 0,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { AppAction } from "./actions";
|
||||||
|
import type { AppState } from "./appState";
|
||||||
|
|
||||||
|
export interface AppActionContext {
|
||||||
|
state: AppState;
|
||||||
|
openActionPalette: () => void;
|
||||||
|
focusPrompt: () => void;
|
||||||
|
addProject: () => void | Promise<void>;
|
||||||
|
selectMainView: (view: AppState["mainView"]) => void;
|
||||||
|
refreshFiles: () => void | Promise<void>;
|
||||||
|
refreshGit: () => void | Promise<void>;
|
||||||
|
startSession: () => void | Promise<void>;
|
||||||
|
stopActiveWork: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAppActions(context: AppActionContext): AppAction[] {
|
||||||
|
const hasWorkspace = context.state.selectedWorkspace !== undefined;
|
||||||
|
const hasSession = context.state.selectedSession !== undefined;
|
||||||
|
const isBusy = isActive(context.state.status);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "actions.show",
|
||||||
|
title: "Show Actions",
|
||||||
|
description: "Open the command palette",
|
||||||
|
shortcut: "mod+k",
|
||||||
|
group: "General",
|
||||||
|
run: context.openActionPalette,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.focus",
|
||||||
|
title: "Focus Prompt",
|
||||||
|
description: "Move keyboard focus to the message composer",
|
||||||
|
group: "General",
|
||||||
|
enabled: hasSession,
|
||||||
|
run: context.focusPrompt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "project.add",
|
||||||
|
title: "Add Project",
|
||||||
|
group: "Project",
|
||||||
|
run: context.addProject,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "view.chat",
|
||||||
|
title: "Go to Chat",
|
||||||
|
shortcut: "mod+1",
|
||||||
|
group: "Navigation",
|
||||||
|
run: () => { context.selectMainView("chat"); },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "view.files",
|
||||||
|
title: "Go to Files",
|
||||||
|
shortcut: "mod+2",
|
||||||
|
group: "Navigation",
|
||||||
|
enabled: hasWorkspace,
|
||||||
|
run: () => { context.selectMainView("files"); },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "view.git",
|
||||||
|
title: "Go to Git",
|
||||||
|
shortcut: "mod+3",
|
||||||
|
group: "Navigation",
|
||||||
|
enabled: hasWorkspace,
|
||||||
|
run: () => { context.selectMainView("git"); },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace.refresh-files",
|
||||||
|
title: "Refresh Files",
|
||||||
|
shortcut: "mod+shift+f",
|
||||||
|
group: "Workspace",
|
||||||
|
enabled: hasWorkspace,
|
||||||
|
run: context.refreshFiles,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace.refresh-git",
|
||||||
|
title: "Refresh Git",
|
||||||
|
shortcut: "mod+shift+g",
|
||||||
|
group: "Workspace",
|
||||||
|
enabled: hasWorkspace,
|
||||||
|
run: context.refreshGit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace.refresh-current",
|
||||||
|
title: "Refresh Current Panel",
|
||||||
|
shortcut: "mod+shift+r",
|
||||||
|
group: "Workspace",
|
||||||
|
enabled: hasWorkspace,
|
||||||
|
run: () => context.state.workspaceTool === "git" ? context.refreshGit() : context.refreshFiles(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "session.start",
|
||||||
|
title: "Start Session",
|
||||||
|
shortcut: "mod+enter",
|
||||||
|
group: "Session",
|
||||||
|
enabled: hasWorkspace,
|
||||||
|
run: context.startSession,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "session.stop",
|
||||||
|
title: "Stop Active Work",
|
||||||
|
shortcut: "mod+.",
|
||||||
|
group: "Session",
|
||||||
|
enabled: hasSession && isBusy,
|
||||||
|
run: context.stopActiveWork,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(status: AppState["status"]): boolean {
|
||||||
|
return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export interface AppState {
|
|||||||
sessionStatuses: Record<string, SessionStatus>;
|
sessionStatuses: Record<string, SessionStatus>;
|
||||||
sessionActivities: Record<string, SessionActivity>;
|
sessionActivities: Record<string, SessionActivity>;
|
||||||
commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
|
commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
|
||||||
|
actionPaletteOpen: boolean;
|
||||||
workspaceTool: "files" | "git";
|
workspaceTool: "files" | "git";
|
||||||
mainView: "chat" | "files" | "git";
|
mainView: "chat" | "files" | "git";
|
||||||
fileTree: FileTreeEntry[];
|
fileTree: FileTreeEntry[];
|
||||||
@@ -48,6 +49,7 @@ export function initialAppState(): AppState {
|
|||||||
sessionStatuses: {},
|
sessionStatuses: {},
|
||||||
sessionActivities: {},
|
sessionActivities: {},
|
||||||
commandDialog: undefined,
|
commandDialog: undefined,
|
||||||
|
actionPaletteOpen: false,
|
||||||
workspaceTool: "files",
|
workspaceTool: "files",
|
||||||
mainView: "chat",
|
mainView: "chat",
|
||||||
fileTree: [],
|
fileTree: [],
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { LitElement, html, type PropertyValues } from "lit";
|
||||||
|
import { customElement, property, query, state } from "lit/decorators.js";
|
||||||
|
import type { AppAction } from "../actions";
|
||||||
|
import { formatShortcut } from "../keyboardShortcuts";
|
||||||
|
import { actionPaletteStyles } from "./shared";
|
||||||
|
|
||||||
|
@customElement("action-palette")
|
||||||
|
export class ActionPalette extends LitElement {
|
||||||
|
@property({ attribute: false }) actions: AppAction[] = [];
|
||||||
|
@property({ attribute: false }) onRun?: (actionId: string) => void;
|
||||||
|
@property({ attribute: false }) onCancel?: () => void;
|
||||||
|
@query("input") private input?: HTMLInputElement;
|
||||||
|
@state() private queryText = "";
|
||||||
|
@state() private selectedIndex = 0;
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
const actions = this.filteredActions();
|
||||||
|
return html`
|
||||||
|
<div class="backdrop" @mousedown=${() => this.onCancel?.()}>
|
||||||
|
<section @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}>
|
||||||
|
<header>
|
||||||
|
<input
|
||||||
|
.value=${this.queryText}
|
||||||
|
placeholder="Search actions..."
|
||||||
|
@input=${(event: Event) => {
|
||||||
|
if (event.target instanceof HTMLInputElement) {
|
||||||
|
this.queryText = event.target.value;
|
||||||
|
this.selectedIndex = 0;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button title="Close" @click=${() => this.onCancel?.()}>×</button>
|
||||||
|
</header>
|
||||||
|
<div class="options">
|
||||||
|
${actions.length === 0 ? html`<div class="empty">No actions found.</div>` : actions.map((action, index) => html`
|
||||||
|
<button class=${index === this.selectedIndex ? "selected" : ""} @click=${() => { this.run(action); }}>
|
||||||
|
<span class="main">
|
||||||
|
<strong>${action.title}</strong>
|
||||||
|
${action.description !== undefined && action.description !== "" ? html`<small>${action.description}</small>` : null}
|
||||||
|
</span>
|
||||||
|
${action.shortcut !== undefined ? html`<kbd>${formatShortcut(action.shortcut)}</kbd>` : null}
|
||||||
|
${action.group !== undefined && action.group !== "" ? html`<small class="group">${action.group}</small>` : null}
|
||||||
|
</button>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
override firstUpdated() {
|
||||||
|
this.input?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override updated(changed: PropertyValues) {
|
||||||
|
if (!changed.has("actions") && !changed.has("queryText")) return;
|
||||||
|
const maxIndex = Math.max(0, this.filteredActions().length - 1);
|
||||||
|
if (this.selectedIndex > maxIndex) this.selectedIndex = maxIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private filteredActions(): AppAction[] {
|
||||||
|
const query = this.queryText.trim().toLowerCase();
|
||||||
|
return this.actions
|
||||||
|
.filter((action) => action.enabled !== false)
|
||||||
|
.filter((action) => {
|
||||||
|
if (query === "") return true;
|
||||||
|
const haystack = [action.title, action.description ?? "", action.group ?? "", action.shortcut ?? ""].join(" ").toLowerCase();
|
||||||
|
return haystack.includes(query);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleKeyDown(event: KeyboardEvent) {
|
||||||
|
const actions = this.filteredActions();
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.onCancel?.();
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (actions.length > 0) this.selectedIndex = (this.selectedIndex + 1) % actions.length;
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (actions.length > 0) this.selectedIndex = (this.selectedIndex - 1 + actions.length) % actions.length;
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
const action = actions[this.selectedIndex];
|
||||||
|
if (action !== undefined) this.run(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private run(action: AppAction) {
|
||||||
|
this.onRun?.(action.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = actionPaletteStyles;
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { LitElement, html } from "lit";
|
import { LitElement, html } from "lit";
|
||||||
import { customElement, query, state } from "lit/decorators.js";
|
import { customElement, query, state } from "lit/decorators.js";
|
||||||
import type { Project, SessionInfo, Workspace } from "../api";
|
import type { Project, SessionInfo, Workspace } from "../api";
|
||||||
|
import type { AppAction } from "../actions";
|
||||||
|
import { createAppActions } from "../appActions";
|
||||||
import { initialAppState, type AppState } from "../appState";
|
import { initialAppState, type AppState } from "../appState";
|
||||||
import { FileExplorerController } from "../controllers/fileExplorerController";
|
import { FileExplorerController } from "../controllers/fileExplorerController";
|
||||||
import { GitController } from "../controllers/gitController";
|
import { GitController } from "../controllers/gitController";
|
||||||
import { ProjectController } from "../controllers/projectController";
|
import { ProjectController } from "../controllers/projectController";
|
||||||
import { SessionController } from "../controllers/sessionController";
|
import { SessionController } from "../controllers/sessionController";
|
||||||
import { WorkspaceController } from "../controllers/workspaceController";
|
import { WorkspaceController } from "../controllers/workspaceController";
|
||||||
|
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
||||||
import { readRoute, writeRoute } from "../route";
|
import { readRoute, writeRoute } from "../route";
|
||||||
import "./ProjectList";
|
import "./ProjectList";
|
||||||
import "./WorkspaceList";
|
import "./WorkspaceList";
|
||||||
@@ -17,6 +20,7 @@ import "./PromptEditor";
|
|||||||
import type { PromptEditor } from "./PromptEditor";
|
import type { PromptEditor } from "./PromptEditor";
|
||||||
import "./StatusBar";
|
import "./StatusBar";
|
||||||
import "./CommandPicker";
|
import "./CommandPicker";
|
||||||
|
import "./ActionPalette";
|
||||||
import "./WorkspacePanel";
|
import "./WorkspacePanel";
|
||||||
import { appStyles } from "./shared";
|
import { appStyles } from "./shared";
|
||||||
|
|
||||||
@@ -52,17 +56,27 @@ export class PiWebApp extends LitElement {
|
|||||||
(patch) => { this.setState(patch); },
|
(patch) => { this.setState(patch); },
|
||||||
() => { this.updateUrl(); },
|
() => { this.updateUrl(); },
|
||||||
);
|
);
|
||||||
|
private readonly keyboard = new KeyboardShortcutDispatcher();
|
||||||
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
|
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
|
||||||
|
private readonly onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (this.keyboard.handle(event, this.getActions())) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
override connectedCallback(): void {
|
override connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
window.addEventListener("popstate", this.onPopState);
|
window.addEventListener("popstate", this.onPopState);
|
||||||
|
window.addEventListener("keydown", this.onKeyDown);
|
||||||
this.sessions.connectStatusUpdates();
|
this.sessions.connectStatusUpdates();
|
||||||
void this.loadProjectsAndRestoreRoute();
|
void this.loadProjectsAndRestoreRoute();
|
||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback(): void {
|
override disconnectedCallback(): void {
|
||||||
window.removeEventListener("popstate", this.onPopState);
|
window.removeEventListener("popstate", this.onPopState);
|
||||||
|
window.removeEventListener("keydown", this.onKeyDown);
|
||||||
|
this.keyboard.reset();
|
||||||
this.sessions.dispose();
|
this.sessions.dispose();
|
||||||
this.git.dispose();
|
this.git.dispose();
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
@@ -162,6 +176,25 @@ export class PiWebApp extends LitElement {
|
|||||||
return html`<workspace-panel .workspace=${this.state.selectedWorkspace} .tool=${this.state.workspaceTool} .fileTree=${this.state.fileTree} .expandedDirs=${this.state.expandedDirs} .selectedFilePath=${this.state.selectedFilePath} .selectedFileContent=${this.state.selectedFileContent} .fileTreeStale=${this.state.fileTreeStale} .gitStatus=${this.state.gitStatus} .selectedDiffPath=${this.state.selectedDiffPath} .selectedDiff=${this.state.selectedDiff} .gitStale=${this.state.gitStale} .onSelectTool=${(tool: "files" | "git") => { this.selectWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}></workspace-panel>`;
|
return html`<workspace-panel .workspace=${this.state.selectedWorkspace} .tool=${this.state.workspaceTool} .fileTree=${this.state.fileTree} .expandedDirs=${this.state.expandedDirs} .selectedFilePath=${this.state.selectedFilePath} .selectedFileContent=${this.state.selectedFileContent} .fileTreeStale=${this.state.fileTreeStale} .gitStatus=${this.state.gitStatus} .selectedDiffPath=${this.state.selectedDiffPath} .selectedDiff=${this.state.selectedDiff} .gitStale=${this.state.gitStale} .onSelectTool=${(tool: "files" | "git") => { this.selectWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}></workspace-panel>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getActions(): AppAction[] {
|
||||||
|
return createAppActions({
|
||||||
|
state: this.state,
|
||||||
|
openActionPalette: () => { this.setState({ actionPaletteOpen: true }); },
|
||||||
|
focusPrompt: () => { this.promptEditor?.focusInput(); },
|
||||||
|
addProject: () => this.projects.addProject(),
|
||||||
|
selectMainView: (view) => { this.selectMainView(view); },
|
||||||
|
refreshFiles: () => this.files.refreshFiles(),
|
||||||
|
refreshGit: () => this.git.refreshGit(),
|
||||||
|
startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()),
|
||||||
|
stopActiveWork: () => this.sessions.stopActiveWork(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private runAction(actionId: string) {
|
||||||
|
const action = this.getActions().find((candidate) => candidate.id === actionId && candidate.enabled !== false);
|
||||||
|
if (action !== undefined) void action.run();
|
||||||
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const state = this.state;
|
const state = this.state;
|
||||||
return html`
|
return html`
|
||||||
@@ -191,6 +224,7 @@ export class PiWebApp extends LitElement {
|
|||||||
<div class="mobile-panel">${this.renderWorkspacePanel()}</div>
|
<div class="mobile-panel">${this.renderWorkspacePanel()}</div>
|
||||||
</main>
|
</main>
|
||||||
${this.renderWorkspacePanel()}
|
${this.renderWorkspacePanel()}
|
||||||
|
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(actionId: string) => { this.setState({ actionPaletteOpen: false }); this.runAction(actionId); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,26 @@ export const commandPickerStyles = css`
|
|||||||
small { display: block; margin-top: 4px; color: #8b949e; }
|
small { display: block; margin-top: 4px; color: #8b949e; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const actionPaletteStyles = css`
|
||||||
|
:host { position: fixed; inset: 0; z-index: 20; color: #e6edf3; font: 14px system-ui, sans-serif; }
|
||||||
|
.backdrop { display: grid; align-items: start; justify-items: center; width: 100%; height: 100%; background: #0008; padding-top: min(12vh, 90px); box-sizing: border-box; }
|
||||||
|
section { width: min(720px, calc(100vw - 40px)); max-height: min(640px, calc(100vh - 40px)); display: flex; flex-direction: column; border: 1px solid #30363d; border-radius: 12px; background: #0d1117; box-shadow: 0 20px 60px #000b; overflow: hidden; }
|
||||||
|
header { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 10px; border-bottom: 1px solid #30363d; }
|
||||||
|
input { min-width: 0; border: 0; outline: none; background: transparent; color: #e6edf3; font: 16px system-ui, sans-serif; padding: 8px; }
|
||||||
|
input::placeholder { color: #6e7681; }
|
||||||
|
button { border: 0; background: transparent; color: #e6edf3; cursor: pointer; }
|
||||||
|
header button { color: #8b949e; font-size: 22px; padding: 2px 8px; }
|
||||||
|
.options { min-height: 0; overflow: auto; }
|
||||||
|
.options button { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 12px; width: 100%; padding: 10px 12px; border-bottom: 1px solid #21262d; text-align: left; }
|
||||||
|
.options button.selected, .options button:hover { background: #0d2847; }
|
||||||
|
.main { min-width: 0; }
|
||||||
|
strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
small { display: block; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.group { grid-column: 1 / -1; font-size: 12px; }
|
||||||
|
kbd { align-self: center; border: 1px solid #30363d; border-radius: 6px; background: #161b22; color: #8b949e; padding: 2px 6px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
|
||||||
|
.empty { padding: 24px; color: #8b949e; text-align: center; }
|
||||||
|
`;
|
||||||
|
|
||||||
export const promptEditorStyles = css`
|
export const promptEditorStyles = css`
|
||||||
:host { display: block; color: #e6edf3; font: 14px system-ui, sans-serif; }
|
:host { display: block; color: #e6edf3; font: 14px system-ui, sans-serif; }
|
||||||
footer { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 12px; border-top: 1px solid #30363d; }
|
footer { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 12px; border-top: 1px solid #30363d; }
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { AppAction } from "./actions";
|
||||||
|
import { KeyboardShortcutDispatcher, type ShortcutKeyEvent } from "./keyboardShortcuts";
|
||||||
|
|
||||||
|
function keyEvent(key: string, modifiers: Partial<ShortcutKeyEvent> = {}): ShortcutKeyEvent {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
isComposing: false,
|
||||||
|
target: null,
|
||||||
|
...modifiers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function action(shortcut: string, enabled = true) {
|
||||||
|
const run = vi.fn();
|
||||||
|
const value: AppAction = {
|
||||||
|
id: shortcut,
|
||||||
|
title: shortcut,
|
||||||
|
shortcut,
|
||||||
|
enabled,
|
||||||
|
run,
|
||||||
|
};
|
||||||
|
return { value, run };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("KeyboardShortcutDispatcher", () => {
|
||||||
|
it("runs an enabled matching modified shortcut", () => {
|
||||||
|
const dispatcher = new KeyboardShortcutDispatcher();
|
||||||
|
const { value, run } = action("mod+k");
|
||||||
|
|
||||||
|
const handled = dispatcher.handle(keyEvent("k", { metaKey: true }), [value]);
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores plain letters so normal typing is never captured", () => {
|
||||||
|
const dispatcher = new KeyboardShortcutDispatcher();
|
||||||
|
const { value, run } = action("r");
|
||||||
|
|
||||||
|
const handled = dispatcher.handle(keyEvent("r"), [value]);
|
||||||
|
|
||||||
|
expect(handled).toBe(false);
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores disabled matching shortcuts", () => {
|
||||||
|
const dispatcher = new KeyboardShortcutDispatcher();
|
||||||
|
const { value, run } = action("mod+enter", false);
|
||||||
|
|
||||||
|
const handled = dispatcher.handle(keyEvent("Enter", { ctrlKey: true }), [value]);
|
||||||
|
|
||||||
|
expect(handled).toBe(false);
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires shift for shift shortcuts", () => {
|
||||||
|
const dispatcher = new KeyboardShortcutDispatcher();
|
||||||
|
const { value, run } = action("mod+shift+r");
|
||||||
|
|
||||||
|
expect(dispatcher.handle(keyEvent("r", { ctrlKey: true }), [value])).toBe(false);
|
||||||
|
expect(dispatcher.handle(keyEvent("r", { ctrlKey: true, shiftKey: true }), [value])).toBe(true);
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { AppAction } from "./actions";
|
||||||
|
|
||||||
|
const sequenceTimeoutMs = 1200;
|
||||||
|
|
||||||
|
export interface ShortcutKeyEvent {
|
||||||
|
key: string;
|
||||||
|
metaKey: boolean;
|
||||||
|
ctrlKey: boolean;
|
||||||
|
altKey: boolean;
|
||||||
|
shiftKey: boolean;
|
||||||
|
isComposing: boolean;
|
||||||
|
target: EventTarget | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KeyboardShortcutDispatcher {
|
||||||
|
private pendingTokens: string[] = [];
|
||||||
|
private pendingTimer: number | undefined;
|
||||||
|
|
||||||
|
handle(event: ShortcutKeyEvent, actions: AppAction[]): boolean {
|
||||||
|
const token = eventToken(event);
|
||||||
|
if (token === undefined || !isModifiedShortcut(token)) return false;
|
||||||
|
|
||||||
|
const shortcuts = actions
|
||||||
|
.filter((action) => action.shortcut !== undefined && action.enabled !== false)
|
||||||
|
.map((action) => ({ action, tokens: normalizeShortcut(action.shortcut ?? "") }))
|
||||||
|
.filter((entry) => entry.tokens.length > 0);
|
||||||
|
|
||||||
|
const sequence = this.pendingTokens.length > 0 && !isModifiedShortcut(token)
|
||||||
|
? [...this.pendingTokens, token]
|
||||||
|
: [token];
|
||||||
|
const exact = shortcuts.find((entry) => sameTokens(entry.tokens, sequence));
|
||||||
|
if (exact !== undefined) {
|
||||||
|
this.clearPending();
|
||||||
|
void exact.action.run();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPrefix = shortcuts.some((entry) => startsWithTokens(entry.tokens, sequence));
|
||||||
|
if (hasPrefix) {
|
||||||
|
this.setPending(sequence);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearPending();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.clearPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setPending(tokens: string[]): void {
|
||||||
|
this.clearPending();
|
||||||
|
this.pendingTokens = tokens;
|
||||||
|
this.pendingTimer = window.setTimeout(() => {
|
||||||
|
this.pendingTokens = [];
|
||||||
|
this.pendingTimer = undefined;
|
||||||
|
}, sequenceTimeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPending(): void {
|
||||||
|
this.pendingTokens = [];
|
||||||
|
if (this.pendingTimer !== undefined) {
|
||||||
|
window.clearTimeout(this.pendingTimer);
|
||||||
|
this.pendingTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatShortcut(shortcut: string): string {
|
||||||
|
return normalizeShortcut(shortcut)
|
||||||
|
.map((token) => token
|
||||||
|
.split("+")
|
||||||
|
.map((part) => {
|
||||||
|
if (part === "mod") return isMac() ? "⌘" : "Ctrl";
|
||||||
|
if (part === "shift") return "Shift";
|
||||||
|
if (part === "alt") return isMac() ? "⌥" : "Alt";
|
||||||
|
if (part === "ctrl") return "Ctrl";
|
||||||
|
if (part === "enter") return "Enter";
|
||||||
|
if (part === "escape") return "Esc";
|
||||||
|
if (part === ".") return ".";
|
||||||
|
return part.length === 1 ? part.toUpperCase() : `${part.charAt(0).toUpperCase()}${part.slice(1)}`;
|
||||||
|
})
|
||||||
|
.join("+"))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventToken(event: ShortcutKeyEvent): string | undefined {
|
||||||
|
if (event.isComposing) return undefined;
|
||||||
|
const key = normalizeKey(event.key);
|
||||||
|
if (key === undefined) return undefined;
|
||||||
|
const modifiers: string[] = [];
|
||||||
|
if (event.metaKey || event.ctrlKey) modifiers.push("mod");
|
||||||
|
if (event.altKey) modifiers.push("alt");
|
||||||
|
if (event.shiftKey) modifiers.push("shift");
|
||||||
|
modifiers.push(key);
|
||||||
|
return modifiers.join("+");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeShortcut(shortcut: string): string[] {
|
||||||
|
return shortcut
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/u)
|
||||||
|
.filter((token) => token !== "")
|
||||||
|
.map((token) => token.split("+").filter((part) => part !== "").join("+"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKey(key: string): string | undefined {
|
||||||
|
if (key === " ") return "space";
|
||||||
|
if (key.length === 1) return key.toLowerCase();
|
||||||
|
const normalized = key.toLowerCase();
|
||||||
|
if (["enter", "escape", "tab", "arrowup", "arrowdown", "arrowleft", "arrowright", "backspace", "delete"].includes(normalized)) return normalized;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameTokens(left: string[], right: string[]): boolean {
|
||||||
|
return left.length === right.length && startsWithTokens(left, right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsWithTokens(tokens: string[], prefix: string[]): boolean {
|
||||||
|
return prefix.every((token, index) => tokens[index] === token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModifiedShortcut(token: string): boolean {
|
||||||
|
return token.includes("+");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMac(): boolean {
|
||||||
|
return navigator.userAgent.toLowerCase().includes("mac");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user