diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7fe507098c..0d1e919081 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -69,6 +69,7 @@ "@types/lodash-es": "^4.17.12", "beautiful-react-hooks": "^5.0.3", "clsx": "^2.1.1", + "cmdk": "1.1.1", "date-fns": "^4.1.0", "diff": "^8.0.2", "html-pdf": "^3.0.1", diff --git a/apps/desktop/src/components/command-palette.tsx b/apps/desktop/src/components/command-palette.tsx new file mode 100644 index 0000000000..ba55205076 --- /dev/null +++ b/apps/desktop/src/components/command-palette.tsx @@ -0,0 +1,393 @@ +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, + CommandSeparator, +} from "@hypr/ui/components/ui/command"; +import { Select, SelectContent, SelectItem, SelectTrigger } from "@hypr/ui/components/ui/select"; +import { useNavigate } from "@tanstack/react-router"; +import { Command as CommandPrimitive } from "cmdk"; +import { + ArrowUpDownIcon, + BuildingIcon, + CalendarIcon, + FileTextIcon, + Search, + Settings2Icon, + UserIcon, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { useHypr } from "@/contexts/hypr"; +import { type SearchMatch } from "@/stores/search"; +import { commands as dbCommands } from "@hypr/plugin-db"; + +interface CommandPaletteProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const highlightText = (text: string, query: string) => { + if (!query.trim()) { + return text; + } + + const parts = text.split(new RegExp(`(${query})`, "gi")); + return parts.map((part, index) => + part.toLowerCase() === query.toLowerCase() + ? {part} + : part + ); +}; + +const extractContentSnippet = (htmlContent: string, query: string) => { + if (!htmlContent || !query.trim()) { + return null; + } + + const plainText = htmlContent + .replace(/<[^>]*>/g, " ") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); + + const lowerText = plainText.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const matchIndex = lowerText.indexOf(lowerQuery); + + if (matchIndex === -1) { + return null; + } + + const start = Math.max(0, matchIndex - 60); + const end = Math.min(plainText.length, start + 120); + + let snippet = plainText.slice(start, end); + + if (start > 0) { + snippet = "..." + snippet; + } + if (end < plainText.length) { + snippet = snippet + "..."; + } + + return snippet; +}; + +const sortSessionMatches = (matches: (SearchMatch & { type: "session" })[], sortBy: "latest" | "oldest") => { + return [...matches].sort((a, b) => { + const dateA = new Date(a.item.created_at).getTime(); + const dateB = new Date(b.item.created_at).getTime(); + + if (sortBy === "latest") { + return dateB - dateA; // newest first + } else { + return dateA - dateB; // oldest first + } + }); +}; + +export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { + const inputRef = useRef(null); + const { userId } = useHypr(); + const navigate = useNavigate(); + + // Local state for command palette only + const [query, setQuery] = useState(""); + const [matches, setMatches] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [sortBy, setSortBy] = useState<"latest" | "oldest">("latest"); + const [showConfig, setShowConfig] = useState(false); + + // Local search function (similar to the global one) + const performSearch = async (searchQuery: string) => { + if (!searchQuery.trim()) { + setMatches([]); + return; + } + + setIsSearching(true); + + try { + const [sessions, events, humans, organizations] = await Promise.all([ + dbCommands.listSessions({ type: "search", query: searchQuery, limit: 10, user_id: userId }), + dbCommands.listEvents({ type: "search", query: searchQuery, limit: 5, user_id: userId }), + dbCommands.listHumans({ search: [3, searchQuery] }), + dbCommands.listOrganizations({ search: [3, searchQuery] }), + ]); + + const results: SearchMatch[] = [ + ...sessions.map((session): SearchMatch => ({ type: "session", item: session })), + ...events.map((event): SearchMatch => ({ type: "event", item: event })), + ...humans.map((human): SearchMatch => ({ type: "human", item: human })), + ...organizations.map((org): SearchMatch => ({ type: "organization", item: org })), + ]; + + setMatches(results); + } catch (error) { + console.error("Search error:", error); + } finally { + setIsSearching(false); + } + }; + + // Debounced search + useEffect(() => { + const timeoutId = setTimeout(() => { + performSearch(query); + }, 200); + + return () => clearTimeout(timeoutId); + }, [query]); + + // Auto-focus when dialog opens + useEffect(() => { + if (open && inputRef.current) { + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + } + }, [open]); + + // Clear search when dialog closes + useEffect(() => { + if (!open) { + setQuery(""); + setMatches([]); + setShowConfig(false); + } + }, [open]); + + // Handle item selection + const handleSelectItem = (match: SearchMatch) => { + switch (match.type) { + case "session": + navigate({ to: "/app/note/$id", params: { id: match.item.id } }); + break; + case "event": + navigate({ to: "/app/new", search: { calendarEventId: match.item.id } }); + break; + case "human": + navigate({ to: "/app/human/$id", params: { id: match.item.id } }); + break; + case "organization": + navigate({ to: "/app/organization/$id", params: { id: match.item.id } }); + break; + } + onOpenChange(false); + }; + + // Group results by type + const sessionMatches = matches.filter(match => match.type === "session"); + const eventMatches = matches.filter(match => match.type === "event"); + const humanMatches = matches.filter(match => match.type === "human"); + const organizationMatches = matches.filter(match => match.type === "organization"); + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString(); + }; + + useEffect(() => { + if (open) { + // Override the hardcoded max-width + const style = document.createElement("style"); + style.textContent = ` + [role="dialog"][aria-modal="true"] { + width: 800px !important; + max-width: 90vw !important; + } + `; + document.head.appendChild(style); + + return () => { + document.head.removeChild(style); + }; + } + }, [open]); + + return ( + + {/* Custom Input with Filter Icon */} +
+ + + +
+ + {/* Configuration Bar - Only show when toggled AND there are results */} + {showConfig && ( +
+
+ +
+
+ )} + + {/* Only show results area when there's a query */} + {query && ( + + + {isSearching ? "Searching..." : "No results found."} + + + {/* Notes Section with content snippets */} + {sessionMatches.length > 0 && ( + + {sortSessionMatches(sessionMatches, sortBy).map((match) => { + const titleMatches = (match.item.title || "").toLowerCase().includes(query.toLowerCase()); + const snippet = !titleMatches + ? extractContentSnippet( + match.item.enhanced_memo_html || match.item.raw_memo_html || "", + query, + ) + : null; + + return ( + handleSelectItem(match)} + > + +
+
+ + {highlightText(match.item.title || "Untitled Note", query)} + +
+ + {formatDate(match.item.created_at)} + + {snippet && ( +
+ {highlightText(snippet, query)} +
+ )} +
+
+ ); + })} +
+ )} + + {/* Events Section with highlighting */} + {eventMatches.length > 0 && ( + <> + {sessionMatches.length > 0 && } + + {eventMatches.map((match) => ( + handleSelectItem(match)} + > + +
+ + {highlightText(match.item.name, query)} + + + {formatDate(match.item.start_date)} + +
+
+ ))} +
+ + )} + + {/* People Section with highlighting */} + {humanMatches.length > 0 && ( + <> + {(sessionMatches.length > 0 || eventMatches.length > 0) && } + + {humanMatches.map((match) => ( + handleSelectItem(match)} + > + +
+ + {highlightText(match.item.full_name || "Unknown Person", query)} + + {match.item.email && ( + + {highlightText(match.item.email, query)} + + )} +
+
+ ))} +
+ + )} + + {/* Organizations Section with highlighting */} + {organizationMatches.length > 0 && ( + <> + {(sessionMatches.length > 0 || eventMatches.length > 0 || humanMatches.length > 0) && ( + + )} + + {organizationMatches.map((match) => ( + handleSelectItem(match)} + > + +
+ + {highlightText(match.item.name, query)} + + {match.item.description && ( + + {highlightText(match.item.description, query)} + + )} +
+
+ ))} +
+ + )} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/components/search-bar.tsx b/apps/desktop/src/components/search-bar.tsx index 935909381b..fe2a119a4c 100644 --- a/apps/desktop/src/components/search-bar.tsx +++ b/apps/desktop/src/components/search-bar.tsx @@ -4,6 +4,7 @@ import clsx from "clsx"; import { LoaderIcon, SearchIcon, TagIcon, XIcon } from "lucide-react"; import { useState } from "react"; +import { CommandPalette } from "@/components/command-palette"; import { useHyprSearch } from "@/contexts/search"; import { commands as dbCommands } from "@hypr/plugin-db"; import { Popover, PopoverContent, PopoverTrigger } from "@hypr/ui/components/ui/popover"; @@ -14,7 +15,6 @@ export function SearchBar() { searchQuery, selectedTags, searchInputRef, - focusSearch, clearSearch, setSearchQuery, isSearching, @@ -45,6 +45,7 @@ export function SearchBar() { const [isFocused, setIsFocused] = useState(false); const [showHistory, setShowHistory] = useState(false); const [showTagSelector, setShowTagSelector] = useState(false); + const [showCommandPalette, setShowCommandPalette] = useState(false); // Get all available tags for filtering const { data: allTags = [] } = useQuery({ @@ -120,7 +121,7 @@ export function SearchBar() { isFocused && "bg-white", "transition-colors duration-200", ])} - onClick={() => focusSearch()} + onClick={() => setShowCommandPalette(true)} > {isSearching ? @@ -224,6 +225,11 @@ export function SearchBar() { )} + + ); } diff --git a/apps/desktop/src/contexts/search.tsx b/apps/desktop/src/contexts/search.tsx index f91034c415..c3a6538d45 100644 --- a/apps/desktop/src/contexts/search.tsx +++ b/apps/desktop/src/contexts/search.tsx @@ -1,8 +1,9 @@ -import { createContext, useContext, useRef } from "react"; +import { createContext, useContext, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { useStore } from "zustand"; import { useShallow } from "zustand/shallow"; +import { CommandPalette } from "@/components/command-palette"; import { createSearchStore, SearchStore } from "@/stores/search"; import { useHypr } from "./hypr"; @@ -28,17 +29,13 @@ export function SearchProvider({ storeRef.current.getState().setSearchInputRef(searchInputRef); } + const [showCommandPalette, setShowCommandPalette] = useState(false); + useHotkeys( "mod+k", (event) => { event.preventDefault(); - const store = storeRef.current!; - const state = store.getState(); - if (document.activeElement === state.searchInputRef?.current) { - state.clearSearch(); - } else { - state.focusSearch(); - } + setShowCommandPalette(true); }, { enableOnFormTags: true, @@ -110,6 +107,7 @@ export function SearchProvider({ return ( {children} + ); } diff --git a/crates/db-user/src/sessions_ops.rs b/crates/db-user/src/sessions_ops.rs index aa034ca793..81be2a9067 100644 --- a/crates/db-user/src/sessions_ops.rs +++ b/crates/db-user/src/sessions_ops.rs @@ -122,8 +122,24 @@ impl UserDatabase { specific: ListSessionFilterSpecific::Search { query }, }) => { conn.query( - "SELECT * FROM sessions WHERE user_id = ? AND title LIKE ? ORDER BY created_at DESC LIMIT ?", - vec![user_id, format!("%{}%", query), limit.unwrap_or(100).to_string()], + "SELECT * FROM sessions + WHERE user_id = ? AND ( + title LIKE ? OR + REPLACE(REPLACE(REPLACE(enhanced_memo_html, '<', ' '), '>', ' '), ' ', ' ') LIKE ? OR + REPLACE(REPLACE(REPLACE(raw_memo_html, '<', ' '), '>', ' '), ' ', ' ') LIKE ? + ) + ORDER BY + CASE WHEN title LIKE ? THEN 0 ELSE 1 END, + created_at DESC + LIMIT ?", + vec![ + user_id, + format!("%{}%", query), // title search + format!("%{}%", query), // enhanced_memo search (HTML stripped) + format!("%{}%", query), // raw_memo search (HTML stripped) + format!("%{}%", query), // title priority check + limit.unwrap_or(100).to_string(), + ], ) .await? } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2222dbc103..c39de9cb3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,6 +267,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: 1.1.1 + version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) date-fns: specifier: ^4.1.0 version: 4.1.0