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,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,
|
||||
|
||||
Reference in New Issue
Block a user