Skip to content

Commit ffbdc0e

Browse files
committed
Merge branch 'pr-494'
2 parents 5be5995 + 6261b02 commit ffbdc0e

16 files changed

Lines changed: 968 additions & 100 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
import fs from 'node:fs/promises';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
6+
async function createSession(page: Page, title: string, workingDirectory: string) {
7+
const res = await page.request.post('/api/chat/sessions', {
8+
data: { title, working_directory: workingDirectory },
9+
});
10+
expect(res.ok()).toBeTruthy();
11+
const data = await res.json();
12+
return data.session.id as string;
13+
}
14+
15+
test.describe('Global Search file deep-link seek UX', () => {
16+
test('same-session repeat seek and cross-session seek both locate target file', async ({ page }) => {
17+
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
18+
const rootA = path.join(os.tmpdir(), `codepilot-search-a-${suffix}`);
19+
const rootB = path.join(os.tmpdir(), `codepilot-search-b-${suffix}`);
20+
const fileA = path.join(rootA, 'src', 'feature-a', 'target-a.ts');
21+
const fileB = path.join(rootB, 'src', 'feature-b', 'target-b.ts');
22+
23+
await fs.mkdir(path.dirname(fileA), { recursive: true });
24+
await fs.mkdir(path.dirname(fileB), { recursive: true });
25+
await fs.writeFile(fileA, 'export const targetA = 1;\n', 'utf8');
26+
await fs.writeFile(fileB, 'export const targetB = 2;\n', 'utf8');
27+
28+
// Add filler files to make vertical scrolling observable.
29+
for (let i = 0; i < 120; i++) {
30+
const fillerA = path.join(rootA, 'src', `filler-a-${String(i).padStart(3, '0')}.ts`);
31+
const fillerB = path.join(rootB, 'src', `filler-b-${String(i).padStart(3, '0')}.ts`);
32+
await fs.writeFile(fillerA, `export const a${i} = ${i};\n`, 'utf8');
33+
await fs.writeFile(fillerB, `export const b${i} = ${i};\n`, 'utf8');
34+
}
35+
36+
const sessionA = await createSession(page, `E2E Search Session A ${suffix}`, rootA);
37+
const sessionB = await createSession(page, `E2E Search Session B ${suffix}`, rootB);
38+
39+
try {
40+
// 1) First locate in session A.
41+
await page.goto(`/chat/${sessionA}?file=${encodeURIComponent(fileA)}&seek=seek1`);
42+
const panel = page.locator('div[style*="width: 280"]');
43+
await expect(panel).toBeVisible({ timeout: 15_000 });
44+
await expect(page.locator('#file-tree-highlight')).toContainText('target-a.ts', { timeout: 15_000 });
45+
46+
// 2) Re-seek same file in same session; should remain stable and highlighted.
47+
await page.goto(`/chat/${sessionA}?file=${encodeURIComponent(fileA)}&seek=seek2`);
48+
await expect(page.locator('#file-tree-highlight')).toContainText('target-a.ts', { timeout: 15_000 });
49+
await expect(page).toHaveURL(new RegExp(`/chat/${sessionA}\\?`));
50+
await expect(page).toHaveURL(/seek=seek2/);
51+
52+
// 3) Cross-session locate should still work after previous seeks.
53+
await page.goto(`/chat/${sessionB}?file=${encodeURIComponent(fileB)}&seek=seek3`);
54+
await expect(page.locator('#file-tree-highlight')).toContainText('target-b.ts', { timeout: 15_000 });
55+
await expect(page).toHaveURL(new RegExp(`/chat/${sessionB}\\?`));
56+
} finally {
57+
await fs.rm(rootA, { recursive: true, force: true });
58+
await fs.rm(rootB, { recursive: true, force: true });
59+
}
60+
});
61+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
import fs from 'node:fs/promises';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
import crypto from 'node:crypto';
6+
import Database from 'better-sqlite3';
7+
8+
function getDbPath() {
9+
const dataDir = process.env.CLAUDE_GUI_DATA_DIR || path.join(os.homedir(), '.codepilot');
10+
return path.join(dataDir, 'codepilot.db');
11+
}
12+
13+
function addMessage(sessionId: string, role: 'user' | 'assistant', content: string) {
14+
const db = new Database(getDbPath());
15+
try {
16+
const id = crypto.randomBytes(16).toString('hex');
17+
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
18+
db.prepare(
19+
'INSERT INTO messages (id, session_id, role, content, created_at, token_usage) VALUES (?, ?, ?, ?, ?, ?)'
20+
).run(id, sessionId, role, content, now, null);
21+
db.prepare('UPDATE chat_sessions SET updated_at = ? WHERE id = ?').run(now, sessionId);
22+
} finally {
23+
db.close();
24+
}
25+
}
26+
27+
async function createSession(page: Page, title: string, workingDirectory: string) {
28+
const res = await page.request.post('/api/chat/sessions', {
29+
data: { title, working_directory: workingDirectory },
30+
});
31+
expect(res.ok()).toBeTruthy();
32+
const data = await res.json();
33+
return data.session.id as string;
34+
}
35+
36+
test.describe('Global Search modes UX', () => {
37+
test('supports all/session/message/file modes and keyboard open', async ({ page }) => {
38+
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
39+
const rootA = path.join(os.tmpdir(), `codepilot-search-modes-a-${suffix}`);
40+
const rootB = path.join(os.tmpdir(), `codepilot-search-modes-b-${suffix}`);
41+
const fileNameA = `alpha-${suffix}.ts`;
42+
const filePathA = path.join(rootA, 'src', fileNameA);
43+
const sessionTitleA = `Search Session Alpha ${suffix}`;
44+
const sessionTitleB = `Search Session Beta ${suffix}`;
45+
const messageTokenA = `message-token-alpha-${suffix}`;
46+
const messageTokenB = `message-token-beta-${suffix}`;
47+
48+
await fs.mkdir(path.dirname(filePathA), { recursive: true });
49+
await fs.mkdir(rootB, { recursive: true });
50+
await fs.writeFile(filePathA, 'export const alpha = true;\n', 'utf8');
51+
52+
const sessionA = await createSession(page, sessionTitleA, rootA);
53+
const sessionB = await createSession(page, sessionTitleB, rootB);
54+
addMessage(sessionA, 'user', `User says ${messageTokenA}`);
55+
addMessage(sessionB, 'assistant', `Assistant says ${messageTokenB}`);
56+
57+
const searchInput = page.locator(
58+
'input[data-slot="command-input"], input[placeholder*="Search"], input[placeholder*="搜索"]'
59+
).first();
60+
61+
try {
62+
await page.goto(`/chat/${sessionA}`);
63+
64+
// Open global search from the sidebar trigger (language-agnostic fallback).
65+
await page.getByRole('button', { name: /(|Search sessions|Search)/i }).first().click();
66+
await expect(searchInput).toBeVisible({ timeout: 10_000 });
67+
68+
// Default all-mode can find sessions, messages and files.
69+
await searchInput.fill(suffix);
70+
await expect(page.getByText(sessionTitleA).first()).toBeVisible();
71+
await expect(page.getByText(fileNameA).first()).toBeVisible();
72+
await expect(page.getByText(messageTokenA).first()).toBeVisible();
73+
74+
// session: prefix narrows to session result.
75+
await searchInput.fill(`session:${sessionTitleA}`);
76+
await expect(page.getByText(sessionTitleA).first()).toBeVisible();
77+
await expect(page.getByText(fileNameA)).toHaveCount(0);
78+
79+
// message: prefix narrows to message snippets and supports navigation to target session.
80+
await searchInput.fill(`message:${messageTokenB}`);
81+
await expect(page.getByText(messageTokenB)).toBeVisible({ timeout: 10_000 });
82+
await page.getByText(messageTokenB).first().click();
83+
await expect(page).toHaveURL(new RegExp(`/chat/${sessionB}\\?message=`), { timeout: 10_000 });
84+
85+
// Re-open and verify file: prefix still works in the same UX flow.
86+
await page.getByRole('button', { name: /(|Search sessions|Search)/i }).first().click();
87+
await expect(searchInput).toBeVisible({ timeout: 10_000 });
88+
await searchInput.fill(`file:${fileNameA}`);
89+
await expect(page.getByText(/(Searching in|)/)).toBeVisible({ timeout: 10_000 });
90+
await expect(page.getByText('file:')).toBeVisible({ timeout: 10_000 });
91+
await expect(page.getByText(fileNameA)).toBeVisible({ timeout: 10_000 });
92+
await page.getByText(fileNameA).first().click();
93+
await expect(page).toHaveURL(new RegExp(`/chat/${sessionA}\\?file=`), { timeout: 10_000 });
94+
} finally {
95+
await page.request.delete(`/api/chat/sessions/${sessionA}`, { timeout: 5_000 }).catch(() => {});
96+
await page.request.delete(`/api/chat/sessions/${sessionB}`, { timeout: 5_000 }).catch(() => {});
97+
await fs.rm(rootA, { recursive: true, force: true });
98+
await fs.rm(rootB, { recursive: true, force: true });
99+
}
100+
});
101+
});

src/app/api/app/updates/route.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@ import { selectRecommendedReleaseAsset, type ReleaseAsset } from "@/lib/update-r
44

55
const GITHUB_REPO = "op7418/CodePilot";
66

7+
function noUpdatePayload(currentVersion: string, runtimeInfo: ReturnType<typeof getRuntimeArchitectureInfo>) {
8+
return {
9+
latestVersion: currentVersion,
10+
currentVersion,
11+
updateAvailable: false,
12+
releaseName: "",
13+
releaseNotes: "",
14+
publishedAt: "",
15+
releaseUrl: "",
16+
downloadUrl: "",
17+
downloadAssetName: "",
18+
detectedPlatform: runtimeInfo.platform,
19+
detectedArch: runtimeInfo.processArch,
20+
hostArch: runtimeInfo.hostArch,
21+
runningUnderRosetta: runtimeInfo.runningUnderRosetta,
22+
};
23+
}
24+
725
function compareSemver(a: string, b: string): number {
826
const pa = a.replace(/^v/, "").split(".").map(Number);
927
const pb = b.replace(/^v/, "").split(".").map(Number);
@@ -28,10 +46,7 @@ export async function GET() {
2846
);
2947

3048
if (!res.ok) {
31-
return NextResponse.json(
32-
{ error: "Failed to fetch release info" },
33-
{ status: 502 }
34-
);
49+
return NextResponse.json(noUpdatePayload(currentVersion, runtimeInfo));
3550
}
3651

3752
const release = await res.json();
@@ -58,9 +73,8 @@ export async function GET() {
5873
runningUnderRosetta: runtimeInfo.runningUnderRosetta,
5974
});
6075
} catch {
61-
return NextResponse.json(
62-
{ error: "Failed to check for updates" },
63-
{ status: 500 }
64-
);
76+
const currentVersion = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
77+
const runtimeInfo = getRuntimeArchitectureInfo();
78+
return NextResponse.json(noUpdatePayload(currentVersion, runtimeInfo));
6579
}
6680
}

src/app/api/search/route.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { NextRequest } from 'next/server';
2+
import { getAllSessions, searchMessages } from '@/lib/db';
3+
import { scanDirectory } from '@/lib/files';
4+
import type { ChatSession, FileTreeNode } from '@/types';
5+
6+
const FILE_SCAN_DEPTH = 2;
7+
const MAX_RESULTS_PER_TYPE = 10;
8+
9+
interface SearchResultSession {
10+
type: 'session';
11+
id: string;
12+
title: string;
13+
projectName: string;
14+
updatedAt: string;
15+
}
16+
17+
interface SearchResultMessage {
18+
type: 'message';
19+
sessionId: string;
20+
sessionTitle: string;
21+
messageId: string;
22+
role: 'user' | 'assistant';
23+
snippet: string;
24+
createdAt: string;
25+
contentType: 'user' | 'assistant' | 'tool';
26+
}
27+
28+
interface SearchResultFile {
29+
type: 'file';
30+
sessionId: string;
31+
sessionTitle: string;
32+
path: string;
33+
name: string;
34+
nodeType: 'file' | 'directory';
35+
}
36+
37+
export interface SearchResponse {
38+
sessions: SearchResultSession[];
39+
messages: SearchResultMessage[];
40+
files: SearchResultFile[];
41+
}
42+
43+
function parseQuery(raw: string): { scope: 'all' | 'sessions' | 'messages' | 'files'; query: string } {
44+
const trimmed = raw.trim();
45+
const lower = trimmed.toLowerCase();
46+
if (lower.startsWith('session:') || lower.startsWith('sessions:')) {
47+
const prefixLen = lower.startsWith('session:') ? 8 : 9;
48+
return { scope: 'sessions', query: trimmed.slice(prefixLen).trim() };
49+
}
50+
if (lower.startsWith('message:') || lower.startsWith('messages:')) {
51+
const prefixLen = lower.startsWith('message:') ? 8 : 9;
52+
return { scope: 'messages', query: trimmed.slice(prefixLen).trim() };
53+
}
54+
if (lower.startsWith('file:') || lower.startsWith('files:')) {
55+
const prefixLen = lower.startsWith('file:') ? 5 : 6;
56+
return { scope: 'files', query: trimmed.slice(prefixLen).trim() };
57+
}
58+
return { scope: 'all', query: trimmed };
59+
}
60+
61+
function filterSessions(sessions: ChatSession[], query: string): SearchResultSession[] {
62+
const q = query.toLowerCase();
63+
return sessions
64+
.filter(
65+
(s) =>
66+
s.title.toLowerCase().includes(q) ||
67+
s.project_name.toLowerCase().includes(q),
68+
)
69+
.slice(0, MAX_RESULTS_PER_TYPE)
70+
.map((s) => ({
71+
type: 'session' as const,
72+
id: s.id,
73+
title: s.title,
74+
projectName: s.project_name,
75+
updatedAt: s.updated_at,
76+
}));
77+
}
78+
79+
function collectNodes(
80+
tree: FileTreeNode[],
81+
sessionId: string,
82+
sessionTitle: string,
83+
query: string,
84+
results: SearchResultFile[],
85+
): void {
86+
if (results.length >= MAX_RESULTS_PER_TYPE) return;
87+
const q = query.toLowerCase();
88+
for (const node of tree) {
89+
if (results.length >= MAX_RESULTS_PER_TYPE) break;
90+
if (node.name.toLowerCase().includes(q)) {
91+
results.push({
92+
type: 'file',
93+
sessionId,
94+
sessionTitle,
95+
path: node.path,
96+
name: node.name,
97+
nodeType: node.type,
98+
});
99+
}
100+
if (node.type === 'directory' && node.children) {
101+
collectNodes(node.children, sessionId, sessionTitle, query, results);
102+
}
103+
}
104+
}
105+
106+
export async function GET(request: NextRequest) {
107+
try {
108+
const { searchParams } = new URL(request.url);
109+
const rawQuery = searchParams.get('q') || '';
110+
const { scope, query } = parseQuery(rawQuery);
111+
112+
if (!query) {
113+
return Response.json({ sessions: [], messages: [], files: [] });
114+
}
115+
116+
const allSessions = getAllSessions();
117+
const result: SearchResponse = { sessions: [], messages: [], files: [] };
118+
119+
if (scope === 'all' || scope === 'sessions') {
120+
result.sessions = filterSessions(allSessions, query);
121+
}
122+
123+
if (scope === 'all' || scope === 'messages') {
124+
const messageRows = searchMessages(query, { limit: MAX_RESULTS_PER_TYPE });
125+
result.messages = messageRows.map((r) => ({
126+
type: 'message' as const,
127+
sessionId: r.sessionId,
128+
sessionTitle: r.sessionTitle,
129+
messageId: r.messageId,
130+
role: r.role,
131+
snippet: r.snippet,
132+
createdAt: r.createdAt,
133+
contentType: r.contentType,
134+
}));
135+
}
136+
137+
if (scope === 'all' || scope === 'files') {
138+
for (const session of allSessions) {
139+
if (!session.working_directory) continue;
140+
try {
141+
const tree = await scanDirectory(session.working_directory, FILE_SCAN_DEPTH);
142+
collectNodes(tree, session.id, session.title, query, result.files);
143+
if (result.files.length >= MAX_RESULTS_PER_TYPE) break;
144+
} catch {
145+
// Skip inaccessible/invalid session directories instead of failing the whole search.
146+
continue;
147+
}
148+
}
149+
}
150+
151+
return Response.json(result);
152+
} catch (error) {
153+
const message = error instanceof Error ? error.stack || error.message : String(error);
154+
console.error('[GET /api/search] Error:', message);
155+
return Response.json({ error: message }, { status: 500 });
156+
}
157+
}

0 commit comments

Comments
 (0)