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:
Mohamed Aziz Mejri
2025-09-17 23:03:07 +01:00
committed by GitHub
parent 2edd122d9b
commit a547aa3ac1
9 changed files with 675 additions and 55 deletions

View File

@@ -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({

View File

@@ -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,