mirror of
https://github.com/paperclipai/paperclip
synced 2026-05-05 22:52:06 +02:00
Compare commits
1 Commits
pap-3598/c
...
pap-feedba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a48090a17 |
@@ -7,6 +7,7 @@ import { useSidebar } from "../context/SidebarContext";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { normalizeKeyboardShortcutKey } from "../lib/keyboardShortcuts";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
CommandDialog,
|
||||
@@ -43,7 +44,7 @@ export function CommandPalette() {
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
if (normalizeKeyboardShortcutKey(e.key) === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
|
||||
50
ui/src/hooks/useKeyboardShortcuts.test.tsx
Normal file
50
ui/src/hooks/useKeyboardShortcuts.test.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function ShortcutHarness({
|
||||
onNewIssue,
|
||||
}: {
|
||||
onNewIssue: () => void;
|
||||
}) {
|
||||
useKeyboardShortcuts({ onNewIssue });
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useKeyboardShortcuts", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("treats uppercase C as the new issue shortcut", () => {
|
||||
const root = createRoot(container);
|
||||
const onNewIssue = vi.fn();
|
||||
|
||||
act(() => {
|
||||
root.render(<ShortcutHarness onNewIssue={onNewIssue} />);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "C" }));
|
||||
});
|
||||
|
||||
expect(onNewIssue).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
||||
import {
|
||||
isKeyboardShortcutTextInputTarget,
|
||||
normalizeKeyboardShortcutKey,
|
||||
} from "../lib/keyboardShortcuts";
|
||||
|
||||
interface ShortcutHandlers {
|
||||
enabled?: boolean;
|
||||
@@ -18,25 +21,27 @@ export function useKeyboardShortcuts({
|
||||
if (!enabled) return;
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const key = normalizeKeyboardShortcutKey(e.key);
|
||||
|
||||
// Don't fire shortcuts when typing in inputs
|
||||
if (isKeyboardShortcutTextInputTarget(e.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// C → New Issue
|
||||
if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
if (key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
onNewIssue?.();
|
||||
}
|
||||
|
||||
// [ → Toggle Sidebar
|
||||
if (e.key === "[" && !e.metaKey && !e.ctrlKey) {
|
||||
if (key === "[" && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
onToggleSidebar?.();
|
||||
}
|
||||
|
||||
// ] → Toggle Panel
|
||||
if (e.key === "]" && !e.metaKey && !e.ctrlKey) {
|
||||
if (key === "]" && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
onTogglePanel?.();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasBlockingShortcutDialog,
|
||||
isKeyboardShortcutTextInputTarget,
|
||||
normalizeKeyboardShortcutKey,
|
||||
resolveInboxQuickArchiveKeyAction,
|
||||
} from "./keyboardShortcuts";
|
||||
|
||||
@@ -39,6 +40,14 @@ describe("keyboardShortcuts helpers", () => {
|
||||
|
||||
expect(hasBlockingShortcutDialog(root)).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes single-character shortcuts without changing named keys", () => {
|
||||
expect(normalizeKeyboardShortcutKey("C")).toBe("c");
|
||||
expect(normalizeKeyboardShortcutKey("Y")).toBe("y");
|
||||
expect(normalizeKeyboardShortcutKey("[")).toBe("[");
|
||||
expect(normalizeKeyboardShortcutKey("Enter")).toBe("Enter");
|
||||
});
|
||||
|
||||
it("archives only the first clean y press", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
@@ -54,6 +63,21 @@ describe("keyboardShortcuts helpers", () => {
|
||||
})).toBe("archive");
|
||||
});
|
||||
|
||||
it("treats uppercase shortcut letters the same as lowercase", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
expect(resolveInboxQuickArchiveKeyAction({
|
||||
armed: true,
|
||||
defaultPrevented: false,
|
||||
key: "Y",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: button,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("archive");
|
||||
});
|
||||
|
||||
it("disarms on the first non-y keypress", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
|
||||
|
||||
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
|
||||
|
||||
export function normalizeKeyboardShortcutKey(key: string): string {
|
||||
return key.length === 1 ? key.toLowerCase() : key;
|
||||
}
|
||||
|
||||
export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
@@ -49,6 +53,6 @@ export function resolveInboxQuickArchiveKeyAction({
|
||||
if (defaultPrevented) return "disarm";
|
||||
if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore";
|
||||
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) return "disarm";
|
||||
if (key === "y") return "archive";
|
||||
if (normalizeKeyboardShortcutKey(key) === "y") return "archive";
|
||||
return "disarm";
|
||||
}
|
||||
|
||||
@@ -22,7 +22,11 @@ import {
|
||||
createIssueDetailLocationState,
|
||||
createIssueDetailPath,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
||||
import {
|
||||
hasBlockingShortcutDialog,
|
||||
isKeyboardShortcutTextInputTarget,
|
||||
normalizeKeyboardShortcutKey,
|
||||
} from "../lib/keyboardShortcuts";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { IssueRow } from "../components/IssueRow";
|
||||
@@ -1413,6 +1417,7 @@ export function Inbox() {
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
const key = normalizeKeyboardShortcutKey(e.key);
|
||||
|
||||
// Don't capture when typing in inputs/textareas or with modifier keys
|
||||
const target = e.target;
|
||||
@@ -1436,7 +1441,7 @@ export function Inbox() {
|
||||
const itemCount = st.workItems.length;
|
||||
if (itemCount === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
switch (key) {
|
||||
case "j": {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "next"));
|
||||
@@ -1464,7 +1469,7 @@ export function Inbox() {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "U": {
|
||||
case "u": {
|
||||
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
||||
e.preventDefault();
|
||||
const item = st.workItems[st.selectedIndex];
|
||||
|
||||
Reference in New Issue
Block a user