feat(poweruser): command/shortcut registries, Buzzbee tokens, palette 2.0

- Command registry with 27 commands across 5 categories
- Shortcut registry with 18 shortcuts + remapping support
- useShortcutManager hook with collision detection
- Command history store (last 20 commands)
- Command palette 2.0: fuzzy search, >/#/@ prefixes, NLP preview
- Interactive shortcut remapping in Settings
- Buzzbee design token migration (light + dark)
- Compact density with real CSS tokens
- Motion duration variables
This commit is contained in:
2026-09-09 01:13:49 +00:00
parent f0f7a0b524
commit c98e1c69f2
13 changed files with 1570 additions and 381 deletions
+1
View File
@@ -0,0 +1 @@
/home/user/projects/dev/ProjectE/apps/web/node_modules
+530 -161
View File
@@ -17,6 +17,9 @@ import {
LogOut,
Inbox,
FileBarChart,
Hash,
ChevronRight,
Clock,
type LucideIcon,
} from "lucide-react";
import {
@@ -28,9 +31,16 @@ import {
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { Badge } from "@/components/ui/badge";
import { useThemeStore } from "@/lib/stores/use-theme-store";
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useCommandStore } from "@/lib/commands";
import { useCommandHistoryStore } from "@/lib/stores/use-command-history-store";
import { parseTaskInput } from "@/lib/nlp";
import type { Command } from "@/lib/commands/registry";
// ─── Navigation ──────────────────────────────────────────────────────────
interface NavItem {
label: string;
@@ -53,13 +63,8 @@ const navItems: NavItem[] = [
{ label: "Settings", href: "/settings", icon: Settings },
];
interface QuickAction {
label: string;
icon: LucideIcon;
action: () => void;
}
// ─── Recent pages (for empty state) ─────────────────────────────────────
// Recent items store (last 5 visited pages)
const RECENT_KEY = "project-e-recent-pages";
function getRecentPages(): string[] {
try {
@@ -74,15 +79,54 @@ function addRecentPage(href: string) {
localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, 5)));
}
// ─── Recent entities ─────────────────────────────────────────────────────
interface RecentEntity {
id: string;
title: string;
type: string;
link: string;
}
function getRecentEntities(): RecentEntity[] {
try {
return JSON.parse(localStorage.getItem("project-e-recent-entities") || "[]");
} catch {
return [];
}
}
// ─── Fuzzy match ─────────────────────────────────────────────────────────
function fuzzyMatch(query: string, text: string): boolean {
const lower = query.toLowerCase();
const target = text.toLowerCase();
if (target.includes(lower)) return true;
let qi = 0;
for (let ti = 0; ti < target.length && qi < lower.length; ti++) {
if (target[ti] === lower[qi]) qi++;
}
return qi === lower.length;
}
// ─── Component ───────────────────────────────────────────────────────────
export function CommandPalette() {
const navigate = useNavigate();
const location = useLocation();
const { mode, setMode } = useThemeStore();
const activeDomainId = useApiDomain();
const { commands: registryCommands, run } = useCommandStore();
const { record, getRecent } = useCommandHistoryStore();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [searchResults, setSearchResults] = useState<
Array<{ type: string; items: Array<{ id: string; title: string; link?: string }> }>
>([]);
const [agentResults, setAgentResults] = useState<
Array<{ id: string; name: string }>
>([]);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isMobile, setIsMobile] = useState(false);
@@ -105,8 +149,9 @@ export function CommandPalette() {
if (location.pathname !== "/login") addRecentPage(location.pathname);
}, [location.pathname]);
// Quick actions
const quickActions: QuickAction[] = [
// ─── Quick actions ────────────────────────────────────────────────────
const quickActions = [
{
label: "New task",
icon: ListTodo,
@@ -149,8 +194,7 @@ export function CommandPalette() {
},
];
// Theme actions
const themeActions: QuickAction[] = [
const themeActions = [
{
label: mode === "dark" ? "Switch to light mode" : "Switch to dark mode",
icon: mode === "dark" ? Sun : Moon,
@@ -158,8 +202,7 @@ export function CommandPalette() {
},
];
// Settings actions
const settingsActions: QuickAction[] = [
const settingsActions = [
{
label: "Log out",
icon: LogOut,
@@ -171,78 +214,210 @@ export function CommandPalette() {
},
];
// Search handler
const handleSearch = useCallback(async (query: string) => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
// ─── Input parsing ────────────────────────────────────────────────────
if (!query.trim()) {
setSearchResults([]);
const prefix = query.startsWith(">") || query.startsWith("#") || query.startsWith("@")
? query[0] as ">" | "#" | "@"
: null;
const rawInput = prefix ? query.slice(1).trim() : query.trim();
const isEmpty = !query;
const isCommandMode = prefix === ">";
const isTagMode = prefix === "#";
const isMentionMode = prefix === "@";
// ─── Recent commands (top 5) ──────────────────────────────────────────
const recentCommandIds = getRecent(5);
const recentCommands = recentCommandIds
.map((id) => registryCommands.find((c) => c.id === id))
.filter(Boolean) as Command[];
// ─── Matched commands (fuzzy on keywords) ─────────────────────────────
const matchedCommands = rawInput
? registryCommands.filter((cmd) => {
if (isCommandMode || (!prefix && rawInput)) {
// Match on title, keywords, or id
if (fuzzyMatch(rawInput, cmd.title)) return true;
if (cmd.keywords?.some((kw: string) => fuzzyMatch(rawInput, kw))) return true;
if (fuzzyMatch(rawInput, cmd.id)) return true;
}
return false;
})
: [];
// ─── Create task item (NLP preview) ───────────────────────────────────
const showCreateTask =
rawInput.length > 0 &&
!isCommandMode &&
!isTagMode &&
!isMentionMode;
const parsedTask = showCreateTask ? parseTaskInput(rawInput) : null;
const createTaskAction = useCallback(() => {
if (!parsedTask) return;
record("create-task-inline");
useCreateDialogStore.getState().openCreate("task", {
title: parsedTask.title,
dueDate: parsedTask.dueDate || undefined,
priority: parsedTask.priority || undefined,
tags: parsedTask.tags,
});
navigate({ to: "/tasks" });
}, [parsedTask, record, navigate]);
// ─── Debounced API search ─────────────────────────────────────────────
const handleSearch = useCallback(
(value: string) => {
setQuery(value);
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
const input = value.trim();
if (!input || isCommandMode || isMentionMode) {
setSearchResults([]);
return;
}
// Tag mode: just search tags from the API
if (isTagMode) {
const tagQuery = rawInput;
if (!tagQuery) {
setSearchResults([]);
return;
}
searchTimeoutRef.current = setTimeout(async () => {
try {
const res = await fetch(
`/api/tags?q=${encodeURIComponent(tagQuery)}&limit=5` +
(activeDomainId ? "&domain=" + activeDomainId : "")
);
if (res.ok) {
const data = await res.json();
setSearchResults([
{
type: "Tags",
items: (data.items || []).map((t: { id: string; name: string }) => ({
id: t.id,
title: t.name,
})),
},
]);
}
} catch {
// Ignore
}
}, 300);
return;
}
// Regular search (no prefix or bare phrase)
if (!prefix || prefix === "#") {
searchTimeoutRef.current = setTimeout(async () => {
try {
const res = await fetch(
`/api/search?q=${encodeURIComponent(input)}&limit=5` +
(activeDomainId ? "&domain=" + activeDomainId : "")
);
if (res.ok) {
const data = await res.json();
const flat: Array<{
type: string;
id: string;
title: string;
link?: string;
}> = data.results || [];
const grouped: Record<
string,
Array<{ id: string; title: string; link?: string }>
> = {};
for (const r of flat) {
const key =
r.type.charAt(0).toUpperCase() + r.type.slice(1) + "s";
(grouped[key] = grouped[key] || []).push({
id: r.id,
title: r.title,
link: r.link,
});
}
setSearchResults(
Object.entries(grouped).map(([type, items]) => ({ type, items }))
);
}
} catch {
// Ignore search errors
}
}, 300);
}
},
[activeDomainId, isCommandMode, isMentionMode, isTagMode, prefix, rawInput]
);
// ─── Agent mention search ─────────────────────────────────────────────
useEffect(() => {
if (!isMentionMode || !rawInput) {
setAgentResults([]);
return;
}
const controller = new AbortController();
fetch(
`/api/agents?q=${encodeURIComponent(rawInput)}` +
(activeDomainId ? "&domain=" + activeDomainId : ""),
{ signal: controller.signal }
)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data?.items) setAgentResults(data.items);
})
.catch(() => {});
return () => controller.abort();
}, [isMentionMode, rawInput, activeDomainId]);
// Check for @mention
if (query.startsWith("@")) {
const mentionQuery = query.slice(1).trim();
if (mentionQuery) {
try {
const res = await fetch(`/api/agents?q=${encodeURIComponent(mentionQuery)}` + (activeDomainId ? "&domain=" + activeDomainId : ""));
if (res.ok) {
const data = await res.json();
setSearchResults([
{
type: "Agents",
items: (data.items || []).map((a: { id: string; name: string }) => ({
id: a.id,
title: a.name,
})),
},
]);
}
} catch {
// Ignore
}
// ─── Execute command ──────────────────────────────────────────────────
const executeCommand = useCallback(
(commandId: string) => {
record(commandId);
const cmd = registryCommands.find((c) => c.id === commandId);
if (cmd) {
setOpen(false);
cmd.run({ navigate });
}
return;
}
},
[registryCommands, record, navigate]
);
// Debounced API search
searchTimeoutRef.current = setTimeout(async () => {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=5` + (activeDomainId ? "&domain=" + activeDomainId : ""));
if (res.ok) {
const data = await res.json();
// The API returns a flat list of SearchResult objects; group them by
// entity type for the grouped render below.
const flat: Array<{ type: string; id: string; title: string; link?: string }> = data.results || [];
const grouped: Record<string, Array<{ id: string; title: string; link?: string }>> = {};
for (const r of flat) {
const key = r.type.charAt(0).toUpperCase() + r.type.slice(1) + "s";
(grouped[key] = grouped[key] || []).push({ id: r.id, title: r.title, link: r.link });
}
setSearchResults(Object.entries(grouped).map(([type, items]) => ({ type, items })));
}
} catch {
// Ignore search errors
}
}, 300);
}, [activeDomainId]);
const runCommand = useCallback(
(command: () => void) => {
const runQuickAction = useCallback(
(action: () => void) => {
setOpen(false);
command();
action();
},
[]
);
// Recent pages
const runNav = useCallback(
(href: string) => {
setOpen(false);
navigate({ to: href });
},
[navigate]
);
// ─── Recent pages (for empty state) ───────────────────────────────────
const recentPages = getRecentPages();
const recentNavItems = recentPages
.map((href) => navItems.find((item) => item.href === href))
.filter(Boolean) as NavItem[];
// ─── Render ───────────────────────────────────────────────────────────
return (
<CommandDialog
open={open}
@@ -251,112 +426,301 @@ export function CommandPalette() {
className={isMobile ? "max-w-full h-full rounded-none border-0" : "max-w-lg"}
>
<CommandInput
placeholder="Type a command or search..."
placeholder={
isCommandMode
? "Search commands..."
: isTagMode
? "Search tags..."
: isMentionMode
? "Mention an agent..."
: "Type a command or search..."
}
onValueChange={handleSearch}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandEmpty>
{isCommandMode
? "No commands found."
: isTagMode
? "No tags found."
: isMentionMode
? "No agents found."
: "No results found."}
</CommandEmpty>
{/* Recent items */}
{recentNavItems.length > 0 && (
<CommandGroup heading="Recent">
{recentNavItems.map((item) => (
<CommandItem
key={item.href}
onSelect={() => runCommand(() => navigate({ to: item.href }))}
>
<item.icon className="mr-2 h-4 w-4" />
{item.label}
</CommandItem>
))}
</CommandGroup>
)}
{/* Navigation */}
<CommandGroup heading="Jump to">
{navItems.map((item) => (
<CommandItem
key={item.href}
onSelect={() => runCommand(() => navigate({ to: item.href }))}
>
<item.icon className="mr-2 h-4 w-4" />
{item.label}
</CommandItem>
))}
</CommandGroup>
{/* Quick Actions */}
<CommandGroup heading="Create">
{quickActions.map((action) => (
<CommandItem
key={action.label}
onSelect={() => runCommand(action.action)}
>
<action.icon className="mr-2 h-4 w-4" />
{action.label}
</CommandItem>
))}
</CommandGroup>
{/* Theme */}
<CommandGroup heading="Theme">
{themeActions.map((action) => (
<CommandItem
key={action.label}
onSelect={() => runCommand(action.action)}
>
<action.icon className="mr-2 h-4 w-4" />
{action.label}
</CommandItem>
))}
</CommandGroup>
{/* Settings */}
<CommandGroup heading="Settings">
{settingsActions.map((action) => (
<CommandItem
key={action.label}
onSelect={() => runCommand(action.action)}
>
<action.icon className="mr-2 h-4 w-4" />
{action.label}
</CommandItem>
))}
</CommandGroup>
{/* Search Results */}
{searchResults.length > 0 && (
{/* ─── Empty state (no input) ────────────────────────────────── */}
{isEmpty && (
<>
<CommandSeparator />
{searchResults.map((group) => (
<CommandGroup key={group.type} heading={group.type}>
{group.items.map((item) => (
{/* Recent pages */}
{recentNavItems.length > 0 && (
<CommandGroup heading="Recent">
{recentNavItems.map((item) => (
<CommandItem
key={item.id}
onSelect={() => {
// The search API returns singular types ("task", "note", ...)
// and each result carries a ready-made detail link (e.g.
// "/tasks/{id}"). Domains have no detail route, so land on the
// dashboard (the domain-scoped home). Agent mentions have no
// detail page either, so just dismiss the palette.
if (group.type === "Agents") {
runCommand(() => {});
return;
}
const link = group.type === "Domains" ? "/" : item.link!;
runCommand(() => navigate({ to: link }));
}}
key={item.href}
onSelect={() => runNav(item.href)}
>
<Search className="mr-2 h-4 w-4" />
{item.title}
<item.icon className="mr-2 h-4 w-4" />
{item.label}
</CommandItem>
))}
</CommandGroup>
))}
)}
{/* Recent entities */}
{getRecentEntities().length > 0 && (
<CommandGroup heading="Recent Entities">
{getRecentEntities().slice(0, 3).map((entity) => (
<CommandItem
key={entity.id}
onSelect={() => runNav(entity.link)}
>
<Clock className="mr-2 h-4 w-4" />
<span className="truncate">{entity.title}</span>
<Badge variant="secondary" className="ml-auto text-[10px]">
{entity.type}
</Badge>
</CommandItem>
))}
</CommandGroup>
)}
{/* Recent commands */}
{recentCommands.length > 0 && (
<CommandGroup heading="Recent Commands">
{recentCommands.map((cmd) => (
<CommandItem
key={cmd.id}
onSelect={() => executeCommand(cmd.id)}
>
<Clock className="mr-2 h-4 w-4" />
{cmd.title}
</CommandItem>
))}
</CommandGroup>
)}
<CommandSeparator />
{/* Navigation */}
<CommandGroup heading="Jump to">
{navItems.map((item) => (
<CommandItem
key={item.href}
onSelect={() => runNav(item.href)}
>
<item.icon className="mr-2 h-4 w-4" />
{item.label}
</CommandItem>
))}
</CommandGroup>
{/* Quick Actions */}
<CommandGroup heading="Create">
{quickActions.map((action) => (
<CommandItem
key={action.label}
onSelect={() => runQuickAction(action.action)}
>
<action.icon className="mr-2 h-4 w-4" />
{action.label}
</CommandItem>
))}
</CommandGroup>
{/* Theme */}
<CommandGroup heading="Theme">
{themeActions.map((action) => (
<CommandItem
key={action.label}
onSelect={() => runQuickAction(action.action)}
>
<action.icon className="mr-2 h-4 w-4" />
{action.label}
</CommandItem>
))}
</CommandGroup>
{/* Settings */}
<CommandGroup heading="Settings">
{settingsActions.map((action) => (
<CommandItem
key={action.label}
onSelect={() => runQuickAction(action.action)}
>
<action.icon className="mr-2 h-4 w-4" />
{action.label}
</CommandItem>
))}
</CommandGroup>
</>
)}
{/* Footer hint */}
{/* ─── Command mode (">" prefix) ────────────────────────────── */}
{isCommandMode && (
<CommandGroup heading="Commands">
{matchedCommands.length > 0 ? (
matchedCommands.map((cmd) => (
<CommandItem
key={cmd.id}
onSelect={() => executeCommand(cmd.id)}
>
{cmd.icon && <cmd.icon className="mr-2 h-4 w-4" />}
{!cmd.icon && <ChevronRight className="mr-2 h-4 w-4" />}
{cmd.title}
{cmd.keywords && cmd.keywords.length > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{cmd.keywords[0]}
</span>
)}
</CommandItem>
))
) : (
<CommandEmpty>No commands match.</CommandEmpty>
)}
</CommandGroup>
)}
{/* ─── Tag mode ("#" prefix) ────────────────────────────────── */}
{isTagMode && (
<>
{searchResults.length > 0 ? (
searchResults.map((group) => (
<CommandGroup key={group.type} heading={group.type}>
{group.items.map((item) => (
<CommandItem
key={item.id}
onSelect={() => runNav(item.link || "/")}
>
<Hash className="mr-2 h-4 w-4" />
{item.title}
</CommandItem>
))}
</CommandGroup>
))
) : rawInput ? (
<CommandEmpty>No tags found.</CommandEmpty>
) : (
<CommandGroup heading="Tags">
<CommandEmpty>Type to search tags...</CommandEmpty>
</CommandGroup>
)}
</>
)}
{/* ─── Agent mention ("@" prefix) ───────────────────────────── */}
{isMentionMode && (
<CommandGroup heading="Agents">
{agentResults.length > 0 ? (
agentResults.map((agent) => (
<CommandItem
key={agent.id}
onSelect={() => {
record("mention-agent");
setOpen(false);
}}
>
<Bot className="mr-2 h-4 w-4" />
{agent.name}
</CommandItem>
))
) : rawInput ? (
<CommandEmpty>No agents found.</CommandEmpty>
) : (
<CommandEmpty>Type to search agents...</CommandEmpty>
)}
</CommandGroup>
)}
{/* ─── Typing: no prefix — commands + search + create task ──── */}
{!isEmpty && !prefix && (
<>
{/* Create task with NLP preview */}
{showCreateTask && parsedTask && (
<CommandGroup heading="Create">
<CommandItem onSelect={createTaskAction}>
<Plus className="mr-2 h-4 w-4" />
<span className="truncate">Create task: &apos;{parsedTask.title}&apos;</span>
<div className="ml-auto flex gap-1 shrink-0">
{parsedTask.dueDate && (
<Badge variant="secondary" className="text-[10px]">
{new Date(parsedTask.dueDate).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</Badge>
)}
{parsedTask.priority && (
<Badge
variant="secondary"
className={
parsedTask.priority === "urgent"
? "text-[10px] bg-red-500/10 text-red-600"
: parsedTask.priority === "high"
? "text-[10px] bg-orange-500/10 text-orange-600"
: "text-[10px]"
}
>
{parsedTask.priority}
</Badge>
)}
{parsedTask.tags.length > 0 && (
<Badge variant="secondary" className="text-[10px]">
{parsedTask.tags[0]}
{parsedTask.tags.length > 1 && ` +${parsedTask.tags.length - 1}`}
</Badge>
)}
</div>
</CommandItem>
</CommandGroup>
)}
{/* Matched commands */}
{matchedCommands.length > 0 && (
<CommandGroup heading="Commands">
{matchedCommands.map((cmd) => (
<CommandItem
key={cmd.id}
onSelect={() => executeCommand(cmd.id)}
>
{cmd.icon && <cmd.icon className="mr-2 h-4 w-4" />}
{!cmd.icon && <ChevronRight className="mr-2 h-4 w-4" />}
{cmd.title}
</CommandItem>
))}
</CommandGroup>
)}
{/* Search results */}
{searchResults.length > 0 && (
<>
<CommandSeparator />
{searchResults.map((group) => (
<CommandGroup key={group.type} heading={group.type}>
{group.items.map((item) => (
<CommandItem
key={item.id}
onSelect={() => {
if (group.type === "Agents") {
runQuickAction(() => {});
return;
}
const link =
group.type === "Domains" ? "/" : item.link!;
runNav(link);
}}
>
<Search className="mr-2 h-4 w-4" />
{item.title}
</CommandItem>
))}
</CommandGroup>
))}
</>
)}
</>
)}
{/* ─── Footer hint ──────────────────────────────────────────── */}
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground">
<span>
<kbd className="rounded border bg-muted px-1"></kbd> navigate
@@ -367,6 +731,11 @@ export function CommandPalette() {
<span>
<kbd className="rounded border bg-muted px-1">esc</kbd> close
</span>
<span className="hidden sm:inline">
<kbd className="rounded border bg-muted px-1">&gt;</kbd> commands{" "}
<kbd className="rounded border bg-muted px-1">#</kbd> tags{" "}
<kbd className="rounded border bg-muted px-1">@</kbd> agents
</span>
</div>
</CommandList>
</CommandDialog>
+11 -125
View File
@@ -1,131 +1,17 @@
import { useEffect, useRef } from "react";
import { useNavigate } from "@tanstack/react-router";
import { useKeyboardShortcutsStore } from "@/lib/stores/use-keyboard-shortcuts-store";
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
import { install, uninstall } from "@github/hotkey";
import { useShortcutManager } from "@/lib/shortcuts/use-shortcut-manager";
/**
* Thin wrapper that installs all keyboard shortcuts from the central registry
* via {@link useShortcutManager}. The shortcut manager always installs hotkeys
* (unconditionally, to satisfy React's rules-of-hooks), but the underlying
* handler checks the `enabled` flag from the store before dispatching.
*/
export function useKeyboardShortcuts() {
const navigate = useNavigate();
const { enabled } = useKeyboardShortcutsStore();
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!enabled) return;
// Create a hidden container for @github/hotkey installs
const container = document.createElement("div");
container.setAttribute("aria-hidden", "true");
container.style.display = "none";
document.body.appendChild(container);
containerRef.current = container;
// Helper: install a hotkey on a synthetic element
const addHotkey = (hotkey: string, handler: () => void) => {
const el = document.createElement("span");
el.setAttribute("data-hotkey", hotkey);
el.addEventListener("hotkey-fire", (e: Event) => {
e.preventDefault();
handler();
});
container.appendChild(el);
install(el, hotkey);
return el;
};
// g+letter navigation sequences
const navMap: Record<string, string> = {
"g i": "/inbox",
"g d": "/",
"g t": "/tasks",
"g h": "/habits",
"g p": "/projects",
"g n": "/notes",
"g c": "/calendar",
"g g": "/graph",
"g r": "/reports",
"g s": "/settings",
};
for (const [seq, path] of Object.entries(navMap)) {
addHotkey(seq, () => {
navigate({ to: path });
});
}
// n+letter new-entity sequences — navigate AND open the create dialog on
// the target page (the page's effect consumes the store request).
const newMap: Record<string, { path: string; type: "task" | "habit" | "project" | "note" }> = {
"n t": { path: "/tasks", type: "task" },
"n h": { path: "/habits", type: "habit" },
"n p": { path: "/projects", type: "project" },
"n n": { path: "/notes", type: "note" },
};
for (const [seq, { path, type }] of Object.entries(newMap)) {
addHotkey(seq, () => {
useCreateDialogStore.getState().openCreate(type);
navigate({ to: path });
});
}
// Global keydown handler for single-key shortcuts and Cmd+K
const handleKeyDown = (e: KeyboardEvent) => {
// Never override native behavior inside controls or modal UI
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable ||
target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]')
) {
return;
}
// Cmd+K / Ctrl+K — open command palette
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
document.dispatchEvent(new CustomEvent("open-command-palette"));
return;
}
// Cmd+N / Ctrl+N — new task
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "n") {
e.preventDefault();
useCreateDialogStore.getState().openCreate("task");
navigate({ to: "/tasks" });
return;
}
// Single-key shortcuts (no modifiers)
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch (e.key) {
case "?":
e.preventDefault();
document.dispatchEvent(new CustomEvent("open-shortcuts-help"));
break;
case "/":
e.preventDefault();
document.dispatchEvent(new CustomEvent("open-command-palette"));
break;
case "c":
e.preventDefault();
document.dispatchEvent(new CustomEvent("open-command-palette"));
break;
}
};
document.addEventListener("keydown", handleKeyDown);
// Cleanup
return () => {
document.removeEventListener("keydown", handleKeyDown);
for (const child of container.children) {
uninstall(child as HTMLElement);
}
document.body.removeChild(container);
containerRef.current = null;
};
}, [navigate, enabled]);
// Always call the hook (rules of hooks). The manager's effect installs
// hotkeys unconditionally — the `enabled` guard lives in the dispatch
// path so toggling the flag disables shortcuts without reinstalling.
useShortcutManager(enabled);
}
+72 -82
View File
@@ -7,79 +7,101 @@
/* Typography */
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "Cascadia Code", "Source Code Pro", Menlo,
Consolas, "DejaVu Sans Mono", monospace;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "Cascadia Code", "Source Code Pro",
Menlo, Consolas, "DejaVu Sans Mono", monospace;
/* Density — compact is the new default */
--density-scale: 0.85;
/* Control heights & sizing */
--control-height: 40px;
--control-height-sm: 32px;
--control-height-lg: 48px;
--size: 16px;
--size-sm: 12px;
--size-md: 20px;
--size-lg: 24px;
--size-xl: 32px;
--size-xxl: 48px;
/* Light mode — clean, near-white with subtle warmth */
--background: 0 0% 99%;
--foreground: 222 47% 5%;
--card: 0 0% 100%;
--card-foreground: 222 47% 5%;
/* Motion */
--duration-fast: 0.1s;
--duration-normal: 0.2s;
--duration-slow: 0.3s;
--ease-default: cubic-bezier(0.645, 0.045, 0.355, 1);
/* Light mode — Buzzbee palette */
--background: 0 0% 100%;
--foreground: 0 0% 6.7%;
--card: 220 13% 97%;
--card-foreground: 0 0% 6.7%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 5%;
--primary: 222 47% 11%;
--primary-foreground: 0 0% 98%;
--secondary: 220 14% 96%;
--secondary-foreground: 222 47% 11%;
--muted: 220 14% 96%;
--muted-foreground: 220 9% 46%;
--accent: 220 14% 96%;
--accent-foreground: 222 47% 11%;
--popover-foreground: 0 0% 6.7%;
--primary: 217 91% 60%;
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96%;
--secondary-foreground: 0 0% 18%;
--muted: 210 40% 96%;
--muted-foreground: 220 9% 45%;
--accent: 210 100% 95%;
--accent-foreground: 222 100% 27%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 217 91% 60%;
--radius: 0.375rem;
--destructive-foreground: 0 0% 100%;
--border: 214 32% 88%;
--input: 214 32% 88%;
--ring: 210 100% 75%;
--radius: 0.5rem;
/* Interactive accent — pinned vibrant blue */
--accent-hsl: 217 91% 60%;
/* Shell surfaces */
--sidebar-bg: 0 0% 100%;
--sidebar-border: 220 13% 91%;
--topbar-bg: 0 0% 99%;
--sidebar-border: 214 32% 88%;
--topbar-bg: 0 0% 100%;
}
/* Density modes */
.density-compact {
--density-scale: 0.85;
--control-height: 28px;
--control-height-sm: 21px;
--control-height-lg: 35px;
--size: 12px;
--size-sm: 8px;
--size-md: 16px;
--size-lg: 16px;
--size-xl: 24px;
--size-xxl: 32px;
}
.density-spacious {
--density-scale: 1.15;
}
/* Dark mode — deep navy with elevated surfaces */
/* Dark mode — Buzzbee dark palette */
.dark {
--background: 222 47% 5%;
--foreground: 210 40% 98%;
--card: 222 40% 8%;
--card-foreground: 210 40% 98%;
--popover: 222 40% 8%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222 47% 11%;
--secondary: 217 33% 14%;
--secondary-foreground: 210 40% 98%;
--muted: 217 33% 14%;
--muted-foreground: 215 20% 65%;
--accent: 217 33% 14%;
--accent-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--border: 217 33% 17%;
--input: 217 33% 17%;
--ring: 217 91% 60%;
--accent-hsl: 217 91% 60%;
--background: 0 0% 8%;
--foreground: 0 0% 86%;
--card: 0 0% 11%;
--card-foreground: 0 0% 86%;
--popover: 0 0% 15%;
--popover-foreground: 0 0% 86%;
--primary: 212 70% 55%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 19%;
--secondary-foreground: 0 0% 86%;
--muted: 0 0% 19%;
--muted-foreground: 0 0% 49%;
--accent: 216 47% 13%;
--accent-foreground: 205 80% 85%;
--destructive: 0 60% 63%;
--destructive-foreground: 0 0% 100%;
--border: 0 0% 24%;
--input: 0 0% 24%;
--ring: 212 60% 50%;
--accent-hsl: 212 70% 55%;
/* Shell surfaces — slightly elevated from background */
--sidebar-bg: 222 42% 7%;
--sidebar-border: 217 33% 17%;
--topbar-bg: 222 47% 5%;
--sidebar-bg: 0 0% 11%;
--sidebar-border: 0 0% 24%;
--topbar-bg: 0 0% 8%;
}
}
@@ -103,35 +125,3 @@
transition-duration: 0.001s !important;
animation-iteration-count: 1 !important;
}
@layer utilities {
/* Density: multiply vertical rhythm inside <main> */
.density-compact main .space-y-1 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-1 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(0.25rem * var(--density-scale));
}
.density-compact main .space-y-2 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-2 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(0.5rem * var(--density-scale));
}
.density-compact main .space-y-3 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-3 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(0.75rem * var(--density-scale));
}
.density-compact main .space-y-4 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-4 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(1rem * var(--density-scale));
}
.density-compact main .space-y-5 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-5 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(1.25rem * var(--density-scale));
}
.density-compact main .space-y-6 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-6 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(1.5rem * var(--density-scale));
}
.density-compact main .space-y-8 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-8 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(2rem * var(--density-scale));
}
}
+19
View File
@@ -0,0 +1,19 @@
import { create } from "zustand";
import { commands, type Command, type CommandContext } from "./registry";
interface CommandState {
commands: Command[];
run: (id: string, ctx: CommandContext) => void;
}
export const useCommandStore = create<CommandState>()((set) => ({
commands,
run: (id, ctx) => {
const cmd = commands.find((c) => c.id === id);
if (cmd) cmd.run(ctx);
},
}));
export function useCommands() {
return useCommandStore();
}
+315
View File
@@ -0,0 +1,315 @@
import type { LucideIcon } from "lucide-react";
import {
LayoutDashboard,
ListTodo,
Flame,
FolderKanban,
NotebookPen,
CalendarDays,
Share2,
Settings,
Bot,
FileBarChart,
Inbox,
Plus,
Sun,
Moon,
Key,
Globe,
Tag,
List,
Webhook,
Upload,
} from "lucide-react";
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
import { useThemeStore } from "@/lib/stores/use-theme-store";
export interface Command {
id: string;
title: string;
keywords: string[];
icon: LucideIcon;
category: "navigation" | "create" | "theme" | "settings" | "help";
run: (ctx: CommandContext) => void;
shortcut?: string;
}
export interface CommandContext {
navigate: (opts: { to: string }) => void;
}
function navigateTo(path: string) {
return (ctx: CommandContext) => ctx.navigate({ to: path });
}
function navigateToSettingsTab(tab: string) {
return (ctx: CommandContext) => {
ctx.navigate({ to: "/settings" });
// Dispatch after navigation so the settings page is mounted
requestAnimationFrame(() => {
document.dispatchEvent(new CustomEvent("open-settings-tab", { detail: tab }));
});
};
}
function openCreate(type: "task" | "habit" | "project" | "note" | "event", path: string) {
return (ctx: CommandContext) => {
useCreateDialogStore.getState().openCreate(type);
ctx.navigate({ to: path });
};
}
function toggleTheme() {
return (_ctx: CommandContext) => {
const { mode, setMode } = useThemeStore.getState();
setMode(mode === "dark" ? "light" : "dark");
};
}
function dispatchEvent(eventName: string) {
return (_ctx: CommandContext) => {
document.dispatchEvent(new CustomEvent(eventName));
};
}
export const commands: Command[] = [
// ── Navigation ─────────────────────────────────────────────────────────
{
id: "nav-inbox",
title: "Go to Inbox",
keywords: ["inbox", "messages", "notifications"],
icon: Inbox,
category: "navigation",
run: navigateTo("/inbox"),
shortcut: "g+i",
},
{
id: "nav-dashboard",
title: "Go to Dashboard",
keywords: ["dashboard", "home", "overview"],
icon: LayoutDashboard,
category: "navigation",
run: navigateTo("/"),
shortcut: "g+d",
},
{
id: "nav-tasks",
title: "Go to Tasks",
keywords: ["tasks", "todo", "list"],
icon: ListTodo,
category: "navigation",
run: navigateTo("/tasks"),
shortcut: "g+t",
},
{
id: "nav-habits",
title: "Go to Habits",
keywords: ["habits", "streaks", "tracker"],
icon: Flame,
category: "navigation",
run: navigateTo("/habits"),
shortcut: "g+h",
},
{
id: "nav-projects",
title: "Go to Projects",
keywords: ["projects", "workspaces"],
icon: FolderKanban,
category: "navigation",
run: navigateTo("/projects"),
shortcut: "g+p",
},
{
id: "nav-notes",
title: "Go to Notes",
keywords: ["notes", "documents", "wiki"],
icon: NotebookPen,
category: "navigation",
run: navigateTo("/notes"),
shortcut: "g+n",
},
{
id: "nav-calendar",
title: "Go to Calendar",
keywords: ["calendar", "events", "schedule"],
icon: CalendarDays,
category: "navigation",
run: navigateTo("/calendar"),
shortcut: "g+c",
},
{
id: "nav-graph",
title: "Go to Graph",
keywords: ["graph", "knowledge", "network", "connections"],
icon: Share2,
category: "navigation",
run: navigateTo("/graph"),
shortcut: "g+g",
},
{
id: "nav-reports",
title: "Go to Reports",
keywords: ["reports", "analytics", "stats"],
icon: FileBarChart,
category: "navigation",
run: navigateTo("/reports"),
shortcut: "g+r",
},
{
id: "nav-settings",
title: "Go to Settings",
keywords: ["settings", "preferences", "config"],
icon: Settings,
category: "navigation",
run: navigateTo("/settings"),
shortcut: "g+s",
},
{
id: "nav-agents-activity",
title: "Go to Agent Activity",
keywords: ["agents", "activity", "logs"],
icon: Bot,
category: "navigation",
run: navigateTo("/agents/activity"),
},
// ── Create ─────────────────────────────────────────────────────────────
{
id: "create-task",
title: "New Task",
keywords: ["task", "todo", "new", "create"],
icon: ListTodo,
category: "create",
run: openCreate("task", "/tasks"),
shortcut: "n+t",
},
{
id: "create-habit",
title: "New Habit",
keywords: ["habit", "streak", "new", "create"],
icon: Flame,
category: "create",
run: openCreate("habit", "/habits"),
shortcut: "n+h",
},
{
id: "create-project",
title: "New Project",
keywords: ["project", "new", "create"],
icon: FolderKanban,
category: "create",
run: openCreate("project", "/projects"),
shortcut: "n+p",
},
{
id: "create-note",
title: "New Note",
keywords: ["note", "document", "new", "create"],
icon: NotebookPen,
category: "create",
run: openCreate("note", "/notes"),
shortcut: "n+n",
},
{
id: "create-event",
title: "New Event",
keywords: ["event", "calendar", "new", "create"],
icon: CalendarDays,
category: "create",
run: openCreate("event", "/calendar"),
},
// ── Theme ──────────────────────────────────────────────────────────────
{
id: "theme-toggle",
title: "Toggle Light/Dark Mode",
keywords: ["theme", "dark", "light", "mode", "appearance"],
icon: Moon,
category: "theme",
run: toggleTheme(),
},
// ── Settings tabs ──────────────────────────────────────────────────────
{
id: "settings-appearance",
title: "Open Appearance Settings",
keywords: ["appearance", "theme", "font", "density"],
icon: Sun,
category: "settings",
run: navigateToSettingsTab("appearance"),
},
{
id: "settings-domains",
title: "Open Domains Settings",
keywords: ["domains", "workspaces"],
icon: Globe,
category: "settings",
run: navigateToSettingsTab("domains"),
},
{
id: "settings-tags",
title: "Open Tags Settings",
keywords: ["tags", "labels"],
icon: Tag,
category: "settings",
run: navigateToSettingsTab("tags"),
},
{
id: "settings-custom-fields",
title: "Open Custom Fields Settings",
keywords: ["custom", "fields", "schema"],
icon: List,
category: "settings",
run: navigateToSettingsTab("custom-fields"),
},
{
id: "settings-shortcuts",
title: "Open Keyboard Shortcuts Settings",
keywords: ["keyboard", "shortcuts", "bindings", "keys"],
icon: Key,
category: "settings",
run: navigateToSettingsTab("shortcuts"),
},
{
id: "settings-agents",
title: "Open Agents Settings",
keywords: ["agents", "permissions", "api"],
icon: Bot,
category: "settings",
run: navigateToSettingsTab("agents"),
},
{
id: "settings-webhooks",
title: "Open Webhooks Settings",
keywords: ["webhooks", "integrations"],
icon: Webhook,
category: "settings",
run: navigateToSettingsTab("webhooks"),
},
{
id: "settings-import-export",
title: "Open Import/Export Settings",
keywords: ["import", "export", "backup", "data"],
icon: Upload,
category: "settings",
run: navigateToSettingsTab("import-export"),
},
// ── Help ───────────────────────────────────────────────────────────────
{
id: "help-shortcuts",
title: "Show Keyboard Shortcuts",
keywords: ["shortcuts", "help", "keys", "bindings"],
icon: Key,
category: "help",
run: dispatchEvent("open-shortcuts-help"),
},
{
id: "open-palette",
title: "Open Command Palette",
keywords: ["palette", "command", "search"],
icon: Plus,
category: "help",
run: dispatchEvent("open-command-palette"),
},
];
+150
View File
@@ -0,0 +1,150 @@
export interface ShortcutDefinition {
id: string;
defaultKeys: string; // @github/hotkey format
description: string;
category: "navigation" | "create" | "general" | "palette";
commandId: string; // references a Command.id
}
export const shortcuts: ShortcutDefinition[] = [
// ── Navigation (g+ sequences) ──────────────────────────────────────────
{
id: "nav-dashboard",
defaultKeys: "g d",
description: "Go to Dashboard",
category: "navigation",
commandId: "nav-dashboard",
},
{
id: "nav-tasks",
defaultKeys: "g t",
description: "Go to Tasks",
category: "navigation",
commandId: "nav-tasks",
},
{
id: "nav-habits",
defaultKeys: "g h",
description: "Go to Habits",
category: "navigation",
commandId: "nav-habits",
},
{
id: "nav-projects",
defaultKeys: "g p",
description: "Go to Projects",
category: "navigation",
commandId: "nav-projects",
},
{
id: "nav-notes",
defaultKeys: "g n",
description: "Go to Notes",
category: "navigation",
commandId: "nav-notes",
},
{
id: "nav-calendar",
defaultKeys: "g c",
description: "Go to Calendar",
category: "navigation",
commandId: "nav-calendar",
},
{
id: "nav-graph",
defaultKeys: "g g",
description: "Go to Graph",
category: "navigation",
commandId: "nav-graph",
},
{
id: "nav-reports",
defaultKeys: "g r",
description: "Go to Reports",
category: "navigation",
commandId: "nav-reports",
},
{
id: "nav-settings",
defaultKeys: "g s",
description: "Go to Settings",
category: "navigation",
commandId: "nav-settings",
},
{
id: "nav-inbox",
defaultKeys: "g i",
description: "Go to Inbox",
category: "navigation",
commandId: "nav-inbox",
},
// ── Create (n+ sequences) ──────────────────────────────────────────────
{
id: "create-task",
defaultKeys: "n t",
description: "New Task",
category: "create",
commandId: "create-task",
},
{
id: "create-habit",
defaultKeys: "n h",
description: "New Habit",
category: "create",
commandId: "create-habit",
},
{
id: "create-project",
defaultKeys: "n p",
description: "New Project",
category: "create",
commandId: "create-project",
},
{
id: "create-note",
defaultKeys: "n n",
description: "New Note",
category: "create",
commandId: "create-note",
},
// ── General ────────────────────────────────────────────────────────────
{
id: "help-shortcuts",
defaultKeys: "?",
description: "Show Keyboard Shortcuts",
category: "general",
commandId: "help-shortcuts",
},
// ── Palette ────────────────────────────────────────────────────────────
{
id: "palette-open",
defaultKeys: "/",
description: "Open Command Palette",
category: "palette",
commandId: "open-palette",
},
{
id: "palette-open-c",
defaultKeys: "c",
description: "Open Command Palette",
category: "palette",
commandId: "open-palette",
},
{
id: "palette-cmd-k",
defaultKeys: "Meta+k",
description: "Open Command Palette",
category: "palette",
commandId: "open-palette",
},
{
id: "new-task-cmd-n",
defaultKeys: "Meta+n",
description: "New Task",
category: "create",
commandId: "create-task",
},
];
@@ -0,0 +1,190 @@
import { useEffect, useRef, useCallback } from "react";
import { useNavigate } from "@tanstack/react-router";
import { install, uninstall } from "@github/hotkey";
import { shortcuts, type ShortcutDefinition } from "./registry";
import { useCommandStore } from "@/lib/commands";
import type { CommandContext } from "@/lib/commands/registry";
const REMAPS_KEY = "project-e-shortcut-remaps";
function loadRemaps(): Record<string, string> {
try {
return JSON.parse(localStorage.getItem(REMAPS_KEY) || "{}");
} catch {
return {};
}
}
function saveRemaps(remaps: Record<string, string>) {
localStorage.setItem(REMAPS_KEY, JSON.stringify(remaps));
}
function getEffectiveKeys(shortcut: ShortcutDefinition, remaps: Record<string, string>): string {
return remaps[shortcut.id] || shortcut.defaultKeys;
}
function findCollision(
shortcutId: string,
newKeys: string,
remaps: Record<string, string>,
): ShortcutDefinition | null {
for (const s of shortcuts) {
if (s.id === shortcutId) continue;
const effective = getEffectiveKeys(s, remaps);
if (effective === newKeys) return s;
}
return null;
}
/**
* Hook that installs all keyboard shortcuts from the registry.
* Uses @github/hotkey for multi-key sequences and a global keydown
* handler for modifier combos (Cmd/Ctrl + key).
*
* Must be called inside a React component (uses hooks).
*/
export function useShortcutManager(enabled = true) {
const navigate = useNavigate();
const containerRef = useRef<HTMLDivElement | null>(null);
const remapsRef = useRef<Record<string, string>>(loadRemaps());
const runCommand = useCommandStore((s) => s.run);
const buildContext = useCallback(
(): CommandContext => ({
navigate: (opts) => navigate(opts),
}),
[navigate],
);
useEffect(() => {
if (!enabled) return;
const ctx = buildContext();
// Hidden container for @github/hotkey synthetic elements
const container = document.createElement("div");
container.setAttribute("aria-hidden", "true");
container.style.display = "none";
document.body.appendChild(container);
containerRef.current = container;
const hotkeyElements: HTMLElement[] = [];
// Install @github/hotkey for sequences and single keys
for (const shortcut of shortcuts) {
const keys = getEffectiveKeys(shortcut, remapsRef.current);
// Skip modifier combos — they're handled by keydown below
if (
keys.includes("Meta+") ||
keys.includes("Control+") ||
keys.includes("Alt+") ||
keys.includes("Shift+")
) {
continue;
}
const el = document.createElement("span");
el.setAttribute("data-hotkey", keys);
el.addEventListener("hotkey-fire", (e: Event) => {
e.preventDefault();
runCommand(shortcut.commandId, ctx);
});
container.appendChild(el);
install(el, keys);
hotkeyElements.push(el);
}
// Global keydown handler for modifier combos (Cmd/Ctrl+K, Cmd/Ctrl+N)
const handleKeyDown = (e: KeyboardEvent) => {
// Never override native behavior inside controls or modal UI
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable ||
target.closest(
'[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]',
)
) {
return;
}
const isMod = e.metaKey || e.ctrlKey;
if (!isMod) return;
for (const shortcut of shortcuts) {
const keys = getEffectiveKeys(shortcut, remapsRef.current);
if (!keys.includes("Meta+") && !keys.includes("Control+")) continue;
// Parse the key from the shortcut definition
// e.g., "Meta+k" → "k", "Ctrl+n" → "n"
const parts = keys.split("+");
const shortcutKey = parts[parts.length - 1].toLowerCase();
if (e.key.toLowerCase() === shortcutKey) {
e.preventDefault();
runCommand(shortcut.commandId, ctx);
return;
}
}
};
document.addEventListener("keydown", handleKeyDown);
// Cleanup
return () => {
document.removeEventListener("keydown", handleKeyDown);
for (const el of hotkeyElements) {
uninstall(el);
}
document.body.removeChild(container);
containerRef.current = null;
};
}, [enabled, buildContext, runCommand]);
/** Save a custom key binding for a shortcut. Returns false if there's a collision. */
const saveRemap = useCallback(
(shortcutId: string, newKeys: string): boolean => {
const remaps = { ...remapsRef.current };
// Check for collision with another shortcut
const collision = findCollision(shortcutId, newKeys, remaps);
if (collision) {
return false;
}
remaps[shortcutId] = newKeys;
remapsRef.current = remaps;
saveRemaps(remaps);
// Trigger re-render by reloading the page (shortcuts need reinstall)
window.location.reload();
return true;
},
[],
);
/** Reset a shortcut to its default keys. */
const resetRemap = useCallback((shortcutId: string) => {
const remaps = { ...remapsRef.current };
delete remaps[shortcutId];
remapsRef.current = remaps;
saveRemaps(remaps);
// Trigger re-render by reloading the page
window.location.reload();
}, []);
/** Get the effective keys for a shortcut (with remaps applied). */
const getKeys = useCallback(
(shortcut: ShortcutDefinition): string => {
return getEffectiveKeys(shortcut, remapsRef.current);
},
[],
);
return { saveRemap, resetRemap, getKeys };
}
@@ -0,0 +1,27 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface CommandHistoryState {
history: string[]; // command IDs, most recent first
record: (commandId: string) => void;
getRecent: (count?: number) => string[];
}
export const useCommandHistoryStore = create<CommandHistoryState>()(
persist(
(set, get) => ({
history: [],
record: (commandId) =>
set((state) => ({
history: [
commandId,
...state.history.filter((id) => id !== commandId),
].slice(0, 20),
})),
getRecent: (count = 5) => get().history.slice(0, count),
}),
{
name: "project-e-command-history",
}
)
);
@@ -2,16 +2,26 @@ import { create } from "zustand";
type ItemType = "task" | "project" | "habit" | "note" | "event" | null;
export interface CreatePrefill {
title?: string;
dueDate?: string;
priority?: string;
tags?: string[];
}
interface CreateDialogState {
type: ItemType;
open: boolean;
openCreate: (type: ItemType) => void;
prefill: CreatePrefill | null;
openCreate: (type: ItemType, prefill?: CreatePrefill) => void;
closeCreate: () => void;
}
export const useCreateDialogStore = create<CreateDialogState>((set) => ({
type: null,
open: false,
openCreate: (type: ItemType) => set({ type, open: true }),
closeCreate: () => set({ type: null, open: false }),
prefill: null,
openCreate: (type: ItemType, prefill?: CreatePrefill) =>
set({ type, open: true, prefill: prefill || null }),
closeCreate: () => set({ type: null, open: false, prefill: null }),
}));
+240 -9
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import { createRoute } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -398,18 +398,239 @@ function CustomFieldsTab() {
// ─── Keyboard Shortcuts Tab ────────────────────────────────────────────────
import { shortcuts as shortcutRegistry, type ShortcutDefinition } from "@/lib/shortcuts/registry";
function loadRemaps(): Record<string, string> {
try {
return JSON.parse(localStorage.getItem("project-e-shortcut-remaps") || "{}");
} catch {
return {};
}
}
function saveRemaps(remaps: Record<string, string>) {
localStorage.setItem("project-e-shortcut-remaps", JSON.stringify(remaps));
window.dispatchEvent(new CustomEvent("shortcuts-changed"));
}
function formatKeyCombo(keys: string[]): string {
return keys.join("+");
}
function ShortcutsTab() {
const [remaps, setRemaps] = useState<Record<string, string>>(loadRemaps);
const [recordingId, setRecordingId] = useState<string | null>(null);
const [pendingKeys, setPendingKeys] = useState<string[]>([]);
const [collisionWarning, setCollisionWarning] = useState<string | null>(null);
const recordingRef = useRef<HTMLDivElement | null>(null);
const grouped = useMemo(() => {
const map = new Map<string, ShortcutDefinition[]>();
for (const s of shortcutRegistry) {
const cat = s.category || "general";
if (!map.has(cat)) map.set(cat, []);
map.get(cat)!.push(s);
}
return map;
}, []);
const categoryLabels: Record<string, string> = {
navigation: "Navigation",
create: "Create",
general: "General",
palette: "Palette",
};
// Keydown handler while recording
useEffect(() => {
if (!recordingId) return;
const handler = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") {
setRecordingId(null);
setPendingKeys([]);
setCollisionWarning(null);
return;
}
if (e.key === "Enter") {
if (pendingKeys.length === 0) return;
const combo = formatKeyCombo(pendingKeys);
// Check for collisions with other shortcuts
const existing = shortcutRegistry.find((s) => {
if (s.id === recordingId) return false;
const currentKeys = remaps[s.id] || s.defaultKeys;
return currentKeys === combo;
});
if (existing) {
setCollisionWarning(
`"${combo}" is already assigned to "${existing.description}". Choose a different combination.`
);
return;
}
setRemaps((prev) => {
const next = { ...prev, [recordingId]: combo };
saveRemaps(next);
return next;
});
setRecordingId(null);
setPendingKeys([]);
setCollisionWarning(null);
return;
}
// Build key combo from event
const parts: string[] = [];
if (e.ctrlKey || e.metaKey) parts.push("Mod");
if (e.altKey) parts.push("Alt");
if (e.shiftKey) parts.push("Shift");
const key = e.key.toLowerCase();
if (!["control", "meta", "alt", "shift"].includes(key)) {
parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key);
}
setPendingKeys(parts);
};
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [recordingId, pendingKeys, remaps]);
const handleReset = (id: string) => {
setRemaps((prev) => {
const next = { ...prev };
delete next[id];
saveRemaps(next);
return next;
});
};
const getCurrentKeys = (shortcut: ShortcutDefinition): string => {
return remaps[shortcut.id] || shortcut.defaultKeys;
};
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Keyboard Shortcuts</h3>
<div className="space-y-1">
{Object.entries(SHORTCUTS_MAP).map(([key, desc]) => (
<div key={key} className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-muted/50">
<span className="text-sm">{desc}</span>
<kbd className="px-2 py-0.5 text-xs font-mono bg-muted rounded border">{key}</kbd>
</div>
))}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Keyboard Shortcuts</h3>
<Button
variant="outline"
size="sm"
onClick={() => {
setRemaps({});
saveRemaps({});
}}
>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Reset All
</Button>
</div>
<p className="text-sm text-muted-foreground">
Click "Record" to remap a shortcut. Press your desired key combination, then Enter to save or Esc to cancel.
</p>
{recordingId && (
<div
ref={recordingRef}
className="flex items-center justify-between p-3 rounded-lg border border-primary bg-primary/5"
>
<div>
<p className="text-sm font-medium">Recording shortcut for:</p>
<p className="text-xs text-muted-foreground">
{shortcutRegistry.find((s) => s.id === recordingId)?.description}
</p>
</div>
<div className="flex items-center gap-2">
<kbd className="px-2 py-1 text-sm font-mono bg-muted rounded border min-w-[6rem] text-center">
{pendingKeys.length > 0 ? formatKeyCombo(pendingKeys) : "Press keys..."}
</kbd>
<Button
size="sm"
variant="ghost"
onClick={() => {
setRecordingId(null);
setPendingKeys([]);
setCollisionWarning(null);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
)}
{collisionWarning && (
<div className="p-3 rounded-lg border border-destructive bg-destructive/5 text-sm text-destructive">
{collisionWarning}
</div>
)}
{Array.from(grouped.entries()).map(([category, shortcuts]) => (
<div key={category}>
<h4 className="text-sm font-medium text-muted-foreground mb-2 mt-4 first:mt-0">
{categoryLabels[category] || category}
</h4>
<div className="space-y-1">
{shortcuts.map((shortcut) => {
const isRecording = recordingId === shortcut.id;
const isRemapped = !!remaps[shortcut.id];
return (
<div
key={shortcut.id}
className={cn(
"flex items-center justify-between py-2 px-3 rounded-lg transition-colors",
isRecording ? "bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/50"
)}
>
<span className="text-sm">{shortcut.description}</span>
<div className="flex items-center gap-2">
<kbd
className={cn(
"px-2 py-0.5 text-xs font-mono rounded border",
isRemapped ? "bg-primary/10 border-primary/30" : "bg-muted"
)}
>
{getCurrentKeys(shortcut)}
</kbd>
{isRecording ? (
<span className="text-xs text-primary animate-pulse">Listening...</span>
) : (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
setRecordingId(shortcut.id);
setPendingKeys([]);
setCollisionWarning(null);
}}
>
Record
</Button>
{isRemapped && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground"
onClick={() => handleReset(shortcut.id)}
>
Reset
</Button>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
))}
</div>
);
}
@@ -894,6 +1115,16 @@ function ErrorLogTab() {
function SettingsPage() {
const [activeTab, setActiveTab] = useState("appearance");
// Listen for open-settings-tab event dispatched by the command registry
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (typeof detail === "string") setActiveTab(detail);
};
document.addEventListener("open-settings-tab", handler);
return () => document.removeEventListener("open-settings-tab", handler);
}, []);
return (
<div className="flex flex-col md:flex-row gap-2 md:gap-6 h-auto md:h-[calc(100vh-5rem)]">
{/* Tab bar - horizontal scrollable on mobile, vertical sidebar on md+ */}
+1 -1
View File
@@ -53,7 +53,7 @@ export default {
},
},
borderRadius: {
lg: "var(--radius)",
lg: "10px",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
+1
View File
@@ -0,0 +1 @@
/home/user/projects/dev/ProjectE/node_modules