99dfbbde58
* feat(plugins): add keybindings and modal window capabilities
Add two independent plugin UI capabilities.
Keybindings: plugins declare keybindings in the manifest under
ui.keybindings ({id, default, description}), validated backend-side
(combo grammar + unique ids, requires a UI bundle) and exposed to the
frontend. Plugin JS binds a handler via registry.registerKeybinding(id,
handler); a global dispatcher fires it. Combos are user-overridable in
Settings > Keyboard Shortcuts (namespaced plugin:{pluginId}:{id}) with
conflict detection against core and other plugins.
Modals: host.openModal({title?, content, size?, dismissible?}) returns a
{ close() } handle and renders in a single host-owned Dialog isolated by
PluginErrorBoundary, cleaned up on plugin unload.
Fixture plugin, PLUGIN-API.md / spec, and a Playwright e2e demonstrate a
keybinding handler opening a modal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): address review feedback on keybinding dispatch and validation
Core shortcuts now take precedence over plugin keybindings on a colliding
combo (defaultPrevented cooperation between the two capture-phase
dispatchers), a throwing plugin handler no longer aborts dispatch to other
plugins, and the keybinding combo grammar is tightened: the backend validator
and frontend parser both drop the undocumented control/super modifier
aliases and reject shift+digit/symbol combos that can never actually fire
(KeyboardEvent.key reports the shifted character, not the token).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): stop conflicting core shortcut from double-firing with plugin keybindings
The earlier defaultPrevented guard only covered the central useAppShortcuts
dispatcher. Per-component core shortcuts registered via useKeyboardShortcut
run in the bubble phase, after the capture-phase plugin keybinding dispatcher,
so a plugin-handled-and-prevented combo still triggered the core action too.
Guard useKeyboardShortcut (and use-editor-keybinds, another capture-phase core
listener) on event.defaultPrevented so exactly one action fires per combo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): make core shortcuts always win over plugin keybindings
Panel-search, task-switcher, and other core listeners never checked
event.defaultPrevented, so a plugin bound to the same combo (e.g. Cmd/Ctrl+F)
could still fire alongside them. Instead of patching every core listener,
usePluginShortcuts now computes the set of effective core shortcut combos
(registry, resolved through user overrides, plus FIND_IN_PANEL/SAVE) and
skips invoking any plugin handler whose combo is in that set, so the
matching core listener always handles the event exactly once.
Also adds a hook-level regression test for useEditorKeybinds' existing
defaultPrevented guard, and fixes a leaked capture-phase window listener in
use-keyboard-shortcut.test.ts that could bleed into later tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): keep Cmd+K closing the command panel after shortcut-precedence guard
cmdk's built-in vim bindings intercept Ctrl/Cmd+K and +P as list-navigation
shortcuts and call preventDefault() before the event reaches our window-level
useKeyboardShortcut listeners. Once that hook started bailing on
event.defaultPrevented (added to fix core/plugin shortcut double-firing), the
palette's own Cmd+K close/reopen toggle got silently suppressed whenever focus
was inside the open panel. Disable cmdk's vim bindings on the command palette
so its Ctrl/Cmd+K and +P always reach the app-level toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(plugins): document keybindings and modal window plugin APIs
Fill in the public author-facing docs that the internal plugin-keybindings
and modal-window contract (PLUGIN-API.md, spec.md) already covers: the
registry.registerKeybinding / host.openModal JS surface, the ui.keybindings
manifest field with its combo grammar, and a capability mention on the
Plugins overview page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
204 lines
5.6 KiB
TypeScript
204 lines
5.6 KiB
TypeScript
/**
|
|
* Builds the `PluginHostApi` (docs/plans/plugins/PLUGIN-API.md) passed
|
|
* into a plugin's `initialize(registry, host)`.
|
|
*/
|
|
import * as React from "react";
|
|
import type { StoreApi } from "zustand";
|
|
import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert";
|
|
import { Badge } from "@kandev/ui/badge";
|
|
import { Button } from "@kandev/ui/button";
|
|
import {
|
|
Card,
|
|
CardAction,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@kandev/ui/card";
|
|
import { Checkbox } from "@kandev/ui/checkbox";
|
|
import {
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@kandev/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@kandev/ui/dropdown-menu";
|
|
import { Input } from "@kandev/ui/input";
|
|
import { Label } from "@kandev/ui/label";
|
|
import {
|
|
Pagination,
|
|
PaginationContent,
|
|
PaginationEllipsis,
|
|
PaginationItem,
|
|
PaginationLink,
|
|
PaginationNext,
|
|
PaginationPrevious,
|
|
} from "@kandev/ui/pagination";
|
|
import { ScrollArea } from "@kandev/ui/scroll-area";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
|
import { Separator } from "@kandev/ui/separator";
|
|
import {
|
|
Sheet,
|
|
SheetClose,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
SheetTrigger,
|
|
} from "@kandev/ui/sheet";
|
|
import { Skeleton } from "@kandev/ui/skeleton";
|
|
import { Spinner } from "@kandev/ui/spinner";
|
|
import { Switch } from "@kandev/ui/switch";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs";
|
|
import { Textarea } from "@kandev/ui/textarea";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
|
import { Combobox } from "@/components/combobox";
|
|
import { PageTopbar } from "@/components/page-topbar";
|
|
import { TaskCreateDialog } from "@/components/task-create-dialog";
|
|
import { getBackendConfig } from "@/lib/config";
|
|
import { softNavigate } from "@/lib/routing/client-router";
|
|
import type { AppState } from "@/lib/state/store";
|
|
import { pluginModalManager } from "./modal-manager";
|
|
import type { PluginHostApi } from "./types";
|
|
|
|
/**
|
|
* Curated `@kandev/ui` subset exposed on `host.ui`, plus a handful of
|
|
* first-party app components (bottom of the map). Plugins must use these
|
|
* host instances rather than bundling their own copies — bundling is not an
|
|
* option for anything that touches React context or portals (Radix), since
|
|
* the plugin shares the host React instance and a second copy would split
|
|
* context and break refs/asChild. Pure-React libs (e.g. icon sets) bundle
|
|
* fine.
|
|
*/
|
|
const PLUGIN_UI: Record<string, unknown> = {
|
|
Alert,
|
|
AlertDescription,
|
|
AlertTitle,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
CardAction,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
Checkbox,
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
Input,
|
|
Label,
|
|
Pagination,
|
|
PaginationContent,
|
|
PaginationEllipsis,
|
|
PaginationItem,
|
|
PaginationLink,
|
|
PaginationNext,
|
|
PaginationPrevious,
|
|
ScrollArea,
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
Separator,
|
|
Sheet,
|
|
SheetClose,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
SheetTrigger,
|
|
Skeleton,
|
|
Spinner,
|
|
Switch,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
Tabs,
|
|
TabsContent,
|
|
TabsList,
|
|
TabsTrigger,
|
|
Textarea,
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
// App UI (not shadcn primitives), exposed so plugins compose kandev-native
|
|
// surfaces instead of re-implementing them:
|
|
// - Combobox: the app's Command+Popover picker (used by native toolbars).
|
|
Combobox,
|
|
// - PageTopbar: the first-party title bar. Plugin routes get one by default
|
|
// (registerRoute options.topbar); this export is for routes that opt out
|
|
// (`topbar: false`) and render their own chrome.
|
|
PageTopbar,
|
|
// - TaskCreateDialog: kandev's real create-task modal, prefilled via
|
|
// initialValues, so plugins hand off task creation to the native flow
|
|
// instead of POSTing directly.
|
|
TaskCreateDialog,
|
|
};
|
|
|
|
export function buildHostApi(
|
|
pluginId: string,
|
|
storeApi: StoreApi<AppState>,
|
|
theme: "light" | "dark",
|
|
): PluginHostApi {
|
|
return {
|
|
pluginId,
|
|
React,
|
|
jsx: React.createElement,
|
|
store: {
|
|
getState: storeApi.getState,
|
|
setState: storeApi.setState,
|
|
subscribe: storeApi.subscribe,
|
|
},
|
|
api: {
|
|
fetch: (path, init) => fetchPluginApi(pluginId, path, init),
|
|
// Getter so split-origin dev/desktop always sees the current backend
|
|
// origin, matching what fetchPluginApi resolves per call.
|
|
get baseUrl() {
|
|
return getBackendConfig().apiBaseUrl;
|
|
},
|
|
},
|
|
ui: PLUGIN_UI,
|
|
theme,
|
|
navigate: (href, options) => softNavigate(href, options?.replace ? "replace" : "push"),
|
|
openModal: (options) => pluginModalManager.openModal(pluginId, options),
|
|
};
|
|
}
|
|
|
|
/** fetch scoped to `/api/plugins/{pluginId}/...` via the kandev reverse proxy. */
|
|
function fetchPluginApi(pluginId: string, path: string, init?: RequestInit): Promise<Response> {
|
|
const { apiBaseUrl } = getBackendConfig();
|
|
const suffix = path.startsWith("/") ? path : `/${path}`;
|
|
const url = `${apiBaseUrl}/api/plugins/${encodeURIComponent(pluginId)}${suffix}`;
|
|
return fetch(url, init);
|
|
}
|