Implementing app search feature (#1302)
This PR implements app search feature and addresses the issue #1182. <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds a fast app search with a command-style dialog so users can find apps by name or chat content and jump to them quickly. Implements the search experience requested in #1182. - New Features - Search dialog (Ctrl+K or “Search Apps” button) with result snippets from matching chat titles/messages. - Searches across app names, chat titles, and message content; case-insensitive; supports partial matches; empty query lists all apps. - Selecting a result navigates to the app and closes the dialog. - New IPC endpoint search-app with Zod-validated results, debounced React Query hook, and preload allowlist update. - Added E2E tests for dialog open/close, shortcuts, matching behavior, empty state, and navigation. <!-- End of auto-generated description by cubic. -->
This commit is contained in:
committed by
GitHub
parent
2edd122d9b
commit
a547aa3ac1
@@ -1,6 +1,6 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { PlusCircle } from "lucide-react";
|
||||
import { PlusCircle, Search } from "lucide-react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import {
|
||||
@@ -13,13 +13,28 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
||||
import { useMemo, useState } from "react";
|
||||
import { AppSearchDialog } from "./AppSearchDialog";
|
||||
|
||||
export function AppList({ show }: { show?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom);
|
||||
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
|
||||
const { apps, loading, error } = useLoadApps();
|
||||
// search dialog state
|
||||
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
|
||||
|
||||
const allApps = useMemo(
|
||||
() =>
|
||||
apps.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
createdAt: a.createdAt,
|
||||
matchedChatTitle: null,
|
||||
matchedChatMessage: null,
|
||||
})),
|
||||
[apps],
|
||||
);
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
@@ -27,6 +42,7 @@ export function AppList({ show }: { show?: boolean }) {
|
||||
const handleAppClick = (id: number) => {
|
||||
setSelectedAppId(id);
|
||||
setSelectedChatId(null);
|
||||
setIsSearchDialogOpen(false);
|
||||
navigate({
|
||||
to: "/",
|
||||
search: { appId: id },
|
||||
@@ -39,58 +55,80 @@ export function AppList({ show }: { show?: boolean }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarGroup className="overflow-y-auto h-[calc(100vh-112px)]">
|
||||
<SidebarGroupLabel>Your Apps</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Button
|
||||
onClick={handleNewApp}
|
||||
variant="outline"
|
||||
className="flex items-center justify-start gap-2 mx-2 py-2"
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
<span>New App</span>
|
||||
</Button>
|
||||
<>
|
||||
<SidebarGroup
|
||||
className="overflow-y-auto h-[calc(100vh-112px)]"
|
||||
data-testid="app-list-container"
|
||||
>
|
||||
<SidebarGroupLabel>Your Apps</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Button
|
||||
onClick={handleNewApp}
|
||||
variant="outline"
|
||||
className="flex items-center justify-start gap-2 mx-2 py-2"
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
<span>New App</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsSearchDialogOpen(!isSearchDialogOpen)}
|
||||
variant="outline"
|
||||
className="flex items-center justify-start gap-2 mx-2 py-3"
|
||||
data-testid="search-apps-button"
|
||||
>
|
||||
<Search size={16} />
|
||||
<span>Search Apps</span>
|
||||
</Button>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-2 px-4 text-sm text-gray-500">
|
||||
Loading apps...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-2 px-4 text-sm text-red-500">
|
||||
Error loading apps
|
||||
</div>
|
||||
) : apps.length === 0 ? (
|
||||
<div className="py-2 px-4 text-sm text-gray-500">No apps found</div>
|
||||
) : (
|
||||
<SidebarMenu className="space-y-1" data-testid="app-list">
|
||||
{apps.map((app) => (
|
||||
<SidebarMenuItem key={app.id} className="mb-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleAppClick(app.id)}
|
||||
className={`justify-start w-full text-left py-3 hover:bg-sidebar-accent/80 ${
|
||||
selectedAppId === app.id
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: ""
|
||||
}`}
|
||||
data-testid={`app-list-item-${app.name}`}
|
||||
>
|
||||
<div className="flex flex-col w-full">
|
||||
<span className="truncate">{app.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatDistanceToNow(new Date(app.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
{loading ? (
|
||||
<div className="py-2 px-4 text-sm text-gray-500">
|
||||
Loading apps...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-2 px-4 text-sm text-red-500">
|
||||
Error loading apps
|
||||
</div>
|
||||
) : apps.length === 0 ? (
|
||||
<div className="py-2 px-4 text-sm text-gray-500">
|
||||
No apps found
|
||||
</div>
|
||||
) : (
|
||||
<SidebarMenu className="space-y-1" data-testid="app-list">
|
||||
{apps.map((app) => (
|
||||
<SidebarMenuItem key={app.id} className="mb-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleAppClick(app.id)}
|
||||
className={`justify-start w-full text-left py-3 hover:bg-sidebar-accent/80 ${
|
||||
selectedAppId === app.id
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: ""
|
||||
}`}
|
||||
data-testid={`app-list-item-${app.name}`}
|
||||
>
|
||||
<div className="flex flex-col w-full">
|
||||
<span className="truncate">{app.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatDistanceToNow(new Date(app.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<AppSearchDialog
|
||||
open={isSearchDialogOpen}
|
||||
onOpenChange={setIsSearchDialogOpen}
|
||||
onSelectApp={handleAppClick}
|
||||
allApps={allApps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
153
src/components/AppSearchDialog.tsx
Normal file
153
src/components/AppSearchDialog.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
} from "./ui/command";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSearchApps } from "@/hooks/useSearchApps";
|
||||
import type { AppSearchResult } from "@/lib/schemas";
|
||||
|
||||
type AppSearchDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelectApp: (appId: number) => void;
|
||||
allApps: AppSearchResult[];
|
||||
};
|
||||
|
||||
export function AppSearchDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelectApp,
|
||||
allApps,
|
||||
}: AppSearchDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState<T>(value);
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(handle);
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
const debouncedQuery = useDebouncedValue(searchQuery, 150);
|
||||
const { apps: searchResults } = useSearchApps(debouncedQuery);
|
||||
|
||||
// Show all apps if search is empty, otherwise show search results
|
||||
const appsToShow: AppSearchResult[] =
|
||||
debouncedQuery.trim() === "" ? allApps : searchResults;
|
||||
|
||||
const commandFilter = (
|
||||
value: string,
|
||||
search: string,
|
||||
keywords?: string[],
|
||||
): number => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return 1;
|
||||
const v = (value || "").toLowerCase();
|
||||
if (v.includes(q)) {
|
||||
// Higher score for earlier match in title/value
|
||||
return 100 - Math.max(0, v.indexOf(q));
|
||||
}
|
||||
const foundInKeywords = (keywords || []).some((k) =>
|
||||
(k || "").toLowerCase().includes(q),
|
||||
);
|
||||
return foundInKeywords ? 50 : 0;
|
||||
};
|
||||
|
||||
function getSnippet(
|
||||
text: string,
|
||||
query: string,
|
||||
radius = 50,
|
||||
): {
|
||||
before: string;
|
||||
match: string;
|
||||
after: string;
|
||||
raw: string;
|
||||
} {
|
||||
const q = query.trim();
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerQuery = q.toLowerCase();
|
||||
const idx = lowerText.indexOf(lowerQuery);
|
||||
if (idx === -1) {
|
||||
const raw =
|
||||
text.length > radius * 2 ? text.slice(0, radius * 2) + "…" : text;
|
||||
return { before: "", match: "", after: "", raw };
|
||||
}
|
||||
const start = Math.max(0, idx - radius);
|
||||
const end = Math.min(text.length, idx + q.length + radius);
|
||||
const before = (start > 0 ? "…" : "") + text.slice(start, idx);
|
||||
const match = text.slice(idx, idx + q.length);
|
||||
const after =
|
||||
text.slice(idx + q.length, end) + (end < text.length ? "…" : "");
|
||||
return { before, match, after, raw: before + match + after };
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
onOpenChange(!open);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", down);
|
||||
return () => document.removeEventListener("keydown", down);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
data-testid="app-search-dialog"
|
||||
filter={commandFilter}
|
||||
>
|
||||
<CommandInput
|
||||
placeholder="Search apps"
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
data-testid="app-search-input"
|
||||
/>
|
||||
<CommandList data-testid="app-search-list">
|
||||
<CommandEmpty data-testid="app-search-empty">
|
||||
No results found.
|
||||
</CommandEmpty>
|
||||
<CommandGroup heading="Apps" data-testid="app-search-group">
|
||||
{appsToShow.map((app) => {
|
||||
const isSearch = searchQuery.trim() !== "";
|
||||
let snippet = null;
|
||||
if (isSearch && app.matchedChatMessage) {
|
||||
snippet = getSnippet(app.matchedChatMessage, searchQuery);
|
||||
} else if (isSearch && app.matchedChatTitle) {
|
||||
snippet = getSnippet(app.matchedChatTitle, searchQuery);
|
||||
}
|
||||
return (
|
||||
<CommandItem
|
||||
key={app.id}
|
||||
onSelect={() => onSelectApp(app.id)}
|
||||
value={app.name + (snippet ? ` ${snippet.raw}` : "")}
|
||||
keywords={snippet ? [snippet.raw] : []}
|
||||
data-testid={`app-search-item-${app.id}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span>{app.name}</span>
|
||||
{snippet && (
|
||||
<span className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{snippet.before}
|
||||
<mark className="bg-transparent underline decoration-2 decoration-primary">
|
||||
{snippet.match}
|
||||
</mark>
|
||||
{snippet.after}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ function CommandDialog({
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
filter?: (value: string, search: string, keywords?: string[]) => number;
|
||||
"data-testid"?: string;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
@@ -53,6 +54,7 @@ function CommandDialog({
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
data-testid={props["data-testid"]}
|
||||
>
|
||||
<Command
|
||||
filter={filter}
|
||||
|
||||
22
src/hooks/useSearchApps.ts
Normal file
22
src/hooks/useSearchApps.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { AppSearchResult } from "@/lib/schemas";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
|
||||
export function useSearchApps(query: string) {
|
||||
const enabled = Boolean(query && query.trim().length > 0);
|
||||
|
||||
const { data, isFetching, isLoading } = useQuery({
|
||||
queryKey: ["search-apps", query],
|
||||
enabled,
|
||||
queryFn: async (): Promise<AppSearchResult[]> => {
|
||||
return IpcClient.getInstance().searchApps(query);
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
return {
|
||||
apps: data ?? [],
|
||||
loading: enabled ? isFetching || isLoading : false,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ipcMain, app } from "electron";
|
||||
import { db, getDatabasePath } from "../../db";
|
||||
import { apps, chats } from "../../db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { apps, chats, messages } from "../../db/schema";
|
||||
import { desc, eq, like } from "drizzle-orm";
|
||||
import type {
|
||||
App,
|
||||
CreateAppParams,
|
||||
@@ -50,6 +50,7 @@ import { normalizePath } from "../../../shared/normalizePath";
|
||||
import { isServerFunction } from "@/supabase_admin/supabase_utils";
|
||||
import { getVercelTeamSlug } from "../utils/vercel_utils";
|
||||
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
|
||||
import { AppSearchResult } from "@/lib/schemas";
|
||||
|
||||
const DEFAULT_COMMAND =
|
||||
"(pnpm install && pnpm run dev --port 32100) || (npm install --legacy-peer-deps && npm run dev -- --port 32100)";
|
||||
@@ -1340,6 +1341,91 @@ export function registerAppHandlers() {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
handle(
|
||||
"search-app",
|
||||
async (_, searchQuery: string): Promise<AppSearchResult[]> => {
|
||||
// Use parameterized query to prevent SQL injection
|
||||
const pattern = `%${searchQuery.replace(/[%_]/g, "\\$&")}%`;
|
||||
|
||||
// 1) Apps whose name matches
|
||||
const appNameMatches = await db
|
||||
.select({
|
||||
id: apps.id,
|
||||
name: apps.name,
|
||||
createdAt: apps.createdAt,
|
||||
})
|
||||
.from(apps)
|
||||
.where(like(apps.name, pattern))
|
||||
.orderBy(desc(apps.createdAt));
|
||||
|
||||
const appNameMatchesResult: AppSearchResult[] = appNameMatches.map(
|
||||
(r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
createdAt: r.createdAt,
|
||||
matchedChatTitle: null,
|
||||
matchedChatMessage: null,
|
||||
}),
|
||||
);
|
||||
|
||||
// 2) Apps whose chat title matches
|
||||
const chatTitleMatches = await db
|
||||
.select({
|
||||
id: apps.id,
|
||||
name: apps.name,
|
||||
createdAt: apps.createdAt,
|
||||
matchedChatTitle: chats.title,
|
||||
})
|
||||
.from(apps)
|
||||
.innerJoin(chats, eq(apps.id, chats.appId))
|
||||
.where(like(chats.title, pattern))
|
||||
.orderBy(desc(apps.createdAt));
|
||||
|
||||
const chatTitleMatchesResult: AppSearchResult[] = chatTitleMatches.map(
|
||||
(r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
createdAt: r.createdAt,
|
||||
matchedChatTitle: r.matchedChatTitle,
|
||||
matchedChatMessage: null,
|
||||
}),
|
||||
);
|
||||
|
||||
// 3) Apps whose chat message content matches
|
||||
const chatMessageMatches = await db
|
||||
.select({
|
||||
id: apps.id,
|
||||
name: apps.name,
|
||||
createdAt: apps.createdAt,
|
||||
matchedChatTitle: chats.title,
|
||||
matchedChatMessage: messages.content,
|
||||
})
|
||||
.from(apps)
|
||||
.innerJoin(chats, eq(apps.id, chats.appId))
|
||||
.innerJoin(messages, eq(chats.id, messages.chatId))
|
||||
.where(like(messages.content, pattern))
|
||||
.orderBy(desc(apps.createdAt));
|
||||
|
||||
// Flatten and dedupe by app id
|
||||
const allMatches: AppSearchResult[] = [
|
||||
...appNameMatchesResult,
|
||||
...chatTitleMatchesResult,
|
||||
...chatMessageMatches,
|
||||
];
|
||||
const uniqueApps = Array.from(
|
||||
new Map(allMatches.map((app) => [app.id, app])).values(),
|
||||
);
|
||||
|
||||
// Sort newest apps first
|
||||
uniqueApps.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
|
||||
return uniqueApps;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function getCommand({
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type UserSettings,
|
||||
type ContextPathResults,
|
||||
ChatSearchResultsSchema,
|
||||
AppSearchResultsSchema,
|
||||
} from "../lib/schemas";
|
||||
import type {
|
||||
AppOutput,
|
||||
@@ -66,6 +67,7 @@ import type {
|
||||
import type { Template } from "../shared/templates";
|
||||
import type {
|
||||
AppChatContext,
|
||||
AppSearchResult,
|
||||
ChatSearchResult,
|
||||
ProposalResult,
|
||||
} from "@/lib/schemas";
|
||||
@@ -312,6 +314,17 @@ export class IpcClient {
|
||||
return this.ipcRenderer.invoke("list-apps");
|
||||
}
|
||||
|
||||
// Search apps by name
|
||||
public async searchApps(searchQuery: string): Promise<AppSearchResult[]> {
|
||||
try {
|
||||
const data = await this.ipcRenderer.invoke("search-app", searchQuery);
|
||||
return AppSearchResultsSchema.parse(data);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async readAppFile(appId: number, filePath: string): Promise<string> {
|
||||
return this.ipcRenderer.invoke("read-app-file", {
|
||||
appId,
|
||||
|
||||
@@ -44,6 +44,20 @@ export type ChatSearchResult = z.infer<typeof ChatSearchResultSchema>;
|
||||
|
||||
export const ChatSearchResultsSchema = z.array(ChatSearchResultSchema);
|
||||
|
||||
// Zod schema for app search result objects returned by the search-app IPC
|
||||
export const AppSearchResultSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
createdAt: z.date(),
|
||||
matchedChatTitle: z.string().nullable(),
|
||||
matchedChatMessage: z.string().nullable(),
|
||||
});
|
||||
|
||||
// Type derived from AppSearchResultSchema
|
||||
export type AppSearchResult = z.infer<typeof AppSearchResultSchema>;
|
||||
|
||||
export const AppSearchResultsSchema = z.array(AppSearchResultSchema);
|
||||
|
||||
const providers = [
|
||||
"openai",
|
||||
"anthropic",
|
||||
|
||||
@@ -36,6 +36,7 @@ const validInvokeChannels = [
|
||||
"stop-app",
|
||||
"restart-app",
|
||||
"respond-to-app-input",
|
||||
"search-app",
|
||||
"list-versions",
|
||||
"revert-version",
|
||||
"checkout-version",
|
||||
|
||||
Reference in New Issue
Block a user