Skip to content
Open
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
51 changes: 49 additions & 2 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Presentation,
Search as SearchIcon,
Sun,
HelpCircle,
} from "lucide-react";
import { undoFileOp } from "@kw/stores/fileOpsStore";
import type { KiwiTreeHandle } from "./components/KiwiTree";
Expand Down Expand Up @@ -47,7 +48,14 @@ import { usePinnedPages } from "./hooks/usePinnedPages";
import { useKeybindings } from "./hooks/useKeybindings";
import { useUIConfig } from "./hooks/useUIConfig";
import { usePreferences } from "./hooks/usePreferences";
import { formatChordDisplay, matchBoundAction, type KeybindingAction } from "./lib/kiwiKeybindings";
import {
formatChordDisplay,
isShortcutBlockedTarget,
isStandaloneHelpKey,
matchBoundAction,
shouldDispatchKeybinding,
type KeybindingAction,
} from "./lib/kiwiKeybindings";
import { resolveOverlayDismiss } from "./lib/overlayDismiss";
import { hasDeepLinkPath, resolveDashboardPath, resolveStartPage, shouldApplyStartPage } from "./lib/startPage";
import { formatDocumentTitle } from "./lib/pageTitle";
Expand Down Expand Up @@ -312,10 +320,30 @@ export default function App() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
const state = stateRef.current;

if (
isStandaloneHelpKey(e) &&
!isShortcutBlockedTarget(e.target) &&
!state.editing
) {
e.preventDefault();
setShortcutsOpen((v) => !v);
return;
}

const action = matchBoundAction(e, bindings);
if (!action) return;

const state = stateRef.current;
if (
!shouldDispatchKeybinding(action, e.target, {
editing: state.editing,
splitEditing: false,
})
) {
return;
}

switch (action) {
case "search":
e.preventDefault();
Expand Down Expand Up @@ -966,6 +994,25 @@ const handleSpaceSwitch = useCallback(() => {
bindings={bindings}
conflicts={conflicts}
/>
{!shortcutsOpen && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="fixed bottom-4 right-4 z-30 h-9 w-9 rounded-full shadow-md bg-background/95 backdrop-blur-sm"
aria-label="Keyboard shortcuts"
onClick={() => setShortcutsOpen(true)}
>
<HelpCircle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
Keyboard shortcuts ({formatChordDisplay(bindings.shortcuts_help)})
</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
);
}
Expand Down
93 changes: 51 additions & 42 deletions ui/src/components/KeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { Keyboard } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@kw/components/ui/dialog";
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@kw/components/ui/command";
import {
formatChordDisplay,
SHORTCUT_SECTIONS,
shortcutSearchValue,
type KeybindingAction,
} from "../lib/kiwiKeybindings";

Expand All @@ -18,45 +21,51 @@ type Props = {
conflicts?: { chord: string; actions: string[] }[];
};

function ShortcutKeys({ chord }: { chord: string }) {
return (
<kbd className="px-2 py-0.5 rounded border border-border bg-muted font-mono text-xs text-muted-foreground shrink-0">
{formatChordDisplay(chord)}
</kbd>
);
}

export function KeyboardShortcuts({ open, onOpenChange, bindings, conflicts = [] }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Keyboard className="h-4 w-4" />
Keyboard shortcuts
</DialogTitle>
</DialogHeader>
{conflicts.length > 0 && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
Conflicting bindings detected:{" "}
{conflicts.map((c) => `${c.actions.join(" / ")} (${formatChordDisplay(c.chord)})`).join("; ")}
</div>
)}
<div className="space-y-4">
{SHORTCUT_SECTIONS.map((s) => (
<div key={s.section}>
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-2">
{s.section}
</div>
<div className="space-y-1.5">
{s.items.map((item) => (
<div
key={item.action}
className="flex items-center justify-between text-sm"
>
<span>{item.label}</span>
<kbd className="px-2 py-0.5 rounded border border-border bg-muted font-mono text-xs text-muted-foreground">
{formatChordDisplay(bindings[item.action])}
</kbd>
</div>
))}
</div>
</div>
))}
<CommandDialog
open={open}
onOpenChange={onOpenChange}
contentClassName="sm:max-w-md"
commandProps={{ shouldFilter: true }}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2 text-sm font-medium">
<Keyboard className="h-4 w-4" />
Keyboard shortcuts
</div>
{conflicts.length > 0 && (
<div className="mx-3 mt-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
Conflicting bindings detected:{" "}
{conflicts.map((c) => `${c.actions.join(" / ")} (${formatChordDisplay(c.chord)})`).join("; ")}
</div>
</DialogContent>
</Dialog>
)}
<CommandInput placeholder="Search shortcuts…" />
<CommandList className="max-h-[min(60vh,28rem)]">
<CommandEmpty>No shortcuts found.</CommandEmpty>
{SHORTCUT_SECTIONS.map((section) => (
<CommandGroup key={section.section} heading={section.section}>
{section.items.map((item) => (
<CommandItem
key={item.action}
value={shortcutSearchValue(section.section, item.label, bindings[item.action])}
className="flex items-center justify-between gap-3 aria-selected:bg-accent"
onSelect={() => {}}
>
<span className="flex-1">{item.label}</span>
<ShortcutKeys chord={bindings[item.action]} />
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
);
}
71 changes: 71 additions & 0 deletions ui/src/lib/kiwiKeybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@
buildChordIndex,
eventMatchesChord,
formatChordDisplay,
isMacPlatform,
isShortcutBlockedTarget,
isStandaloneHelpKey,
isTypingTarget,
matchBoundAction,
mergeKeybindings,
normalizeChord,
shouldDispatchKeybinding,
} from "./kiwiKeybindings";

describe("normalizeChord", () => {
Expand Down Expand Up @@ -101,3 +106,69 @@
expect(formatChordDisplay("mod+k")).toMatch(/K/i);
});
});

describe("isTypingTarget", () => {
it("detects input elements", () => {
const input = { tagName: "INPUT", isContentEditable: false } as HTMLElement;
const button = { tagName: "BUTTON", isContentEditable: false } as HTMLElement;
expect(isTypingTarget(input)).toBe(true);
expect(isTypingTarget(button)).toBe(false);
});
});

describe("isShortcutBlockedTarget", () => {
it("blocks CodeMirror and ProseMirror editor surfaces", () => {
const cmChild = {
tagName: "DIV",
isContentEditable: false,
closest: (sel: string) => (sel.includes("cm-editor") ? {} : null),
} as unknown as HTMLElement;
const proseChild = {
tagName: "P",
isContentEditable: false,
closest: (sel: string) => (sel.includes("ProseMirror") ? {} : null),
} as unknown as HTMLElement;
expect(isShortcutBlockedTarget(cmChild)).toBe(true);
expect(isShortcutBlockedTarget(proseChild)).toBe(true);
});
});

describe("isStandaloneHelpKey", () => {
it("matches bare question mark without modifiers", () => {
const help = {
key: "?",
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: true,
} as KeyboardEvent;
const modHelp = { ...help, metaKey: true } as KeyboardEvent;
expect(isStandaloneHelpKey(help)).toBe(true);
expect(isStandaloneHelpKey(modHelp)).toBe(false);
});
});

describe("shouldDispatchKeybinding", () => {
it("allows help and close overlay while typing", () => {
const input = { tagName: "INPUT", isContentEditable: false } as HTMLElement;
expect(shouldDispatchKeybinding("shortcuts_help", input, { editing: true, splitEditing: false })).toBe(true);
expect(shouldDispatchKeybinding("close_overlay", input, { editing: true, splitEditing: false })).toBe(true);
});

it("blocks navigation shortcuts in inputs and while editing", () => {
const input = { tagName: "INPUT", isContentEditable: false } as HTMLElement;
const body = { tagName: "BODY", isContentEditable: false, closest: () => null } as unknown as HTMLElement;
expect(shouldDispatchKeybinding("search", input, { editing: false, splitEditing: false })).toBe(false);
expect(shouldDispatchKeybinding("search", body, { editing: true, splitEditing: false })).toBe(false);
expect(shouldDispatchKeybinding("search", body, { editing: false, splitEditing: false })).toBe(true);
});
});

describe("isMacPlatform", () => {
it("detects macOS from platform string", () => {
const original = navigator.platform;

Check failure on line 169 in ui/src/lib/kiwiKeybindings.test.ts

View workflow job for this annotation

GitHub Actions / test

src/lib/kiwiKeybindings.test.ts > isMacPlatform > detects macOS from platform string

ReferenceError: navigator is not defined ❯ src/lib/kiwiKeybindings.test.ts:169:22
Object.defineProperty(navigator, "platform", { value: "MacIntel", configurable: true });
expect(isMacPlatform()).toBe(true);
Object.defineProperty(navigator, "platform", { value: original, configurable: true });
});
});
49 changes: 48 additions & 1 deletion ui/src/lib/kiwiKeybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,15 @@ export function eventMatchesChord(e: KeyboardEvent, chord: string): boolean {
return eventKey === parsed.key;
}

export function isMacPlatform(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator as Navigator & { userAgentData?: { platform?: string } };
const platform = ua.userAgentData?.platform ?? navigator.platform;
return /mac/i.test(platform);
}

export function formatChordDisplay(chord: string): string {
const isMac = typeof navigator !== "undefined" && navigator.platform.includes("Mac");
const isMac = isMacPlatform();
const parsed = parseChord(chord);
const parts: string[] = [];
if (parsed.mod) parts.push(isMac ? "⌘" : "Ctrl");
Expand Down Expand Up @@ -168,6 +175,46 @@ export type ShortcutSection = {
items: { action: KeybindingAction; label: string }[];
};

export function isTypingTarget(target: EventTarget | null): boolean {
if (!target || typeof target !== "object") return false;
const el = target as HTMLElement;
if (el.isContentEditable) return true;
const tag = el.tagName ?? "";
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
}

const SHORTCUT_BLOCKED_SELECTOR = ".cm-editor, .ProseMirror, [cmdk-input-wrapper]";

export function isShortcutBlockedTarget(target: EventTarget | null): boolean {
if (isTypingTarget(target)) return true;
const el = target as HTMLElement | null;
if (el && typeof el.closest === "function" && el.closest(SHORTCUT_BLOCKED_SELECTOR)) return true;
return false;
}

/** Bare `?` (no modifiers) opens the cheat sheet when not typing in an editor/input. */
export function isStandaloneHelpKey(e: KeyboardEvent): boolean {
if (e.metaKey || e.ctrlKey || e.altKey) return false;
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key.toLowerCase();
return key === "?";
}

export function shouldDispatchKeybinding(
action: KeybindingAction,
target: EventTarget | null,
ctx: { editing: boolean; splitEditing: boolean },
): boolean {
if (action === "close_overlay" || action === "shortcuts_help") return true;
if (action === "save" || action === "toggle_mode") return true;
if (isShortcutBlockedTarget(target)) return false;
if (ctx.editing || ctx.splitEditing) return false;
return true;
}

export function shortcutSearchValue(section: string, label: string, chord: string): string {
return `${label} ${section} ${formatChordDisplay(chord)} ${chord}`;
}

export const SHORTCUT_SECTIONS: ShortcutSection[] = [
{
section: "Navigation",
Expand Down
Loading