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:
Symlink
+1
@@ -0,0 +1 @@
|
||||
/home/user/projects/dev/ProjectE/apps/web/node_modules
|
||||
@@ -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: '{parsedTask.title}'</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">></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>
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
];
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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+ */}
|
||||
|
||||
@@ -53,7 +53,7 @@ export default {
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
lg: "10px",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/home/user/projects/dev/ProjectE/node_modules
|
||||
Reference in New Issue
Block a user