Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/__tests__/e2e/global-search-file-seek.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { test, expect, type Page } from '@playwright/test';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

async function createSession(page: Page, title: string, workingDirectory: string) {
const res = await page.request.post('/api/chat/sessions', {
data: { title, working_directory: workingDirectory },
});
expect(res.ok()).toBeTruthy();
const data = await res.json();
return data.session.id as string;
}

test.describe('Global Search file deep-link seek UX', () => {
test('same-session repeat seek and cross-session seek both locate target file', async ({ page }) => {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const rootA = path.join(os.tmpdir(), `codepilot-search-a-${suffix}`);
const rootB = path.join(os.tmpdir(), `codepilot-search-b-${suffix}`);
const fileA = path.join(rootA, 'src', 'feature-a', 'target-a.ts');
const fileB = path.join(rootB, 'src', 'feature-b', 'target-b.ts');

await fs.mkdir(path.dirname(fileA), { recursive: true });
await fs.mkdir(path.dirname(fileB), { recursive: true });
await fs.writeFile(fileA, 'export const targetA = 1;\n', 'utf8');
await fs.writeFile(fileB, 'export const targetB = 2;\n', 'utf8');

// Add filler files to make vertical scrolling observable.
for (let i = 0; i < 120; i++) {
const fillerA = path.join(rootA, 'src', `filler-a-${String(i).padStart(3, '0')}.ts`);
const fillerB = path.join(rootB, 'src', `filler-b-${String(i).padStart(3, '0')}.ts`);
await fs.writeFile(fillerA, `export const a${i} = ${i};\n`, 'utf8');
await fs.writeFile(fillerB, `export const b${i} = ${i};\n`, 'utf8');
}

const sessionA = await createSession(page, `E2E Search Session A ${suffix}`, rootA);
const sessionB = await createSession(page, `E2E Search Session B ${suffix}`, rootB);

try {
// 1) First locate in session A.
await page.goto(`/chat/${sessionA}?file=${encodeURIComponent(fileA)}&seek=seek1`);
const panel = page.locator('div[style*="width: 280"]');
await expect(panel).toBeVisible({ timeout: 15_000 });
await expect(page.locator('#file-tree-highlight')).toContainText('target-a.ts', { timeout: 15_000 });

// 2) Re-seek same file in same session; should remain stable and highlighted.
await page.goto(`/chat/${sessionA}?file=${encodeURIComponent(fileA)}&seek=seek2`);
await expect(page.locator('#file-tree-highlight')).toContainText('target-a.ts', { timeout: 15_000 });
await expect(page).toHaveURL(new RegExp(`/chat/${sessionA}\\?`));
await expect(page).toHaveURL(/seek=seek2/);

// 3) Cross-session locate should still work after previous seeks.
await page.goto(`/chat/${sessionB}?file=${encodeURIComponent(fileB)}&seek=seek3`);
await expect(page.locator('#file-tree-highlight')).toContainText('target-b.ts', { timeout: 15_000 });
await expect(page).toHaveURL(new RegExp(`/chat/${sessionB}\\?`));
} finally {
await fs.rm(rootA, { recursive: true, force: true });
await fs.rm(rootB, { recursive: true, force: true });
}
});
});
101 changes: 101 additions & 0 deletions src/__tests__/e2e/global-search-modes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { test, expect, type Page } from '@playwright/test';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import Database from 'better-sqlite3';

function getDbPath() {
const dataDir = process.env.CLAUDE_GUI_DATA_DIR || path.join(os.homedir(), '.codepilot');
return path.join(dataDir, 'codepilot.db');
}

function addMessage(sessionId: string, role: 'user' | 'assistant', content: string) {
const db = new Database(getDbPath());
try {
const id = crypto.randomBytes(16).toString('hex');
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
db.prepare(
'INSERT INTO messages (id, session_id, role, content, created_at, token_usage) VALUES (?, ?, ?, ?, ?, ?)'
).run(id, sessionId, role, content, now, null);
db.prepare('UPDATE chat_sessions SET updated_at = ? WHERE id = ?').run(now, sessionId);
} finally {
db.close();
}
}

async function createSession(page: Page, title: string, workingDirectory: string) {
const res = await page.request.post('/api/chat/sessions', {
data: { title, working_directory: workingDirectory },
});
expect(res.ok()).toBeTruthy();
const data = await res.json();
return data.session.id as string;
}

test.describe('Global Search modes UX', () => {
test('supports all/session/message/file modes and keyboard open', async ({ page }) => {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const rootA = path.join(os.tmpdir(), `codepilot-search-modes-a-${suffix}`);
const rootB = path.join(os.tmpdir(), `codepilot-search-modes-b-${suffix}`);
const fileNameA = `alpha-${suffix}.ts`;
const filePathA = path.join(rootA, 'src', fileNameA);
const sessionTitleA = `Search Session Alpha ${suffix}`;
const sessionTitleB = `Search Session Beta ${suffix}`;
const messageTokenA = `message-token-alpha-${suffix}`;
const messageTokenB = `message-token-beta-${suffix}`;

await fs.mkdir(path.dirname(filePathA), { recursive: true });
await fs.mkdir(rootB, { recursive: true });
await fs.writeFile(filePathA, 'export const alpha = true;\n', 'utf8');

const sessionA = await createSession(page, sessionTitleA, rootA);
const sessionB = await createSession(page, sessionTitleB, rootB);
addMessage(sessionA, 'user', `User says ${messageTokenA}`);
addMessage(sessionB, 'assistant', `Assistant says ${messageTokenB}`);

const searchInput = page.locator(
'input[data-slot="command-input"], input[placeholder*="Search"], input[placeholder*="搜索"]'
).first();

try {
await page.goto(`/chat/${sessionA}`);

// Open global search from the sidebar trigger (language-agnostic fallback).
await page.getByRole('button', { name: /(搜索会话|Search sessions|Search)/i }).first().click();
await expect(searchInput).toBeVisible({ timeout: 10_000 });

// Default all-mode can find sessions, messages and files.
await searchInput.fill(suffix);
await expect(page.getByText(sessionTitleA).first()).toBeVisible();
await expect(page.getByText(fileNameA).first()).toBeVisible();
await expect(page.getByText(messageTokenA).first()).toBeVisible();

// session: prefix narrows to session result.
await searchInput.fill(`session:${sessionTitleA}`);
await expect(page.getByText(sessionTitleA).first()).toBeVisible();
await expect(page.getByText(fileNameA)).toHaveCount(0);

// message: prefix narrows to message snippets and supports navigation to target session.
await searchInput.fill(`message:${messageTokenB}`);
await expect(page.getByText(messageTokenB)).toBeVisible({ timeout: 10_000 });
await page.getByText(messageTokenB).first().click();
await expect(page).toHaveURL(new RegExp(`/chat/${sessionB}\\?message=`), { timeout: 10_000 });

// Re-open and verify file: prefix still works in the same UX flow.
await page.getByRole('button', { name: /(搜索会话|Search sessions|Search)/i }).first().click();
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await searchInput.fill(`file:${fileNameA}`);
await expect(page.getByText(/(Searching in|当前搜索范围)/)).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('file:')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(fileNameA)).toBeVisible({ timeout: 10_000 });
await page.getByText(fileNameA).first().click();
await expect(page).toHaveURL(new RegExp(`/chat/${sessionA}\\?file=`), { timeout: 10_000 });
} finally {
await page.request.delete(`/api/chat/sessions/${sessionA}`, { timeout: 5_000 }).catch(() => {});
await page.request.delete(`/api/chat/sessions/${sessionB}`, { timeout: 5_000 }).catch(() => {});
await fs.rm(rootA, { recursive: true, force: true });
await fs.rm(rootB, { recursive: true, force: true });
}
});
});
30 changes: 22 additions & 8 deletions src/app/api/app/updates/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ import { selectRecommendedReleaseAsset, type ReleaseAsset } from "@/lib/update-r

const GITHUB_REPO = "op7418/CodePilot";

function noUpdatePayload(currentVersion: string, runtimeInfo: ReturnType<typeof getRuntimeArchitectureInfo>) {
return {
latestVersion: currentVersion,
currentVersion,
updateAvailable: false,
releaseName: "",
releaseNotes: "",
publishedAt: "",
releaseUrl: "",
downloadUrl: "",
downloadAssetName: "",
detectedPlatform: runtimeInfo.platform,
detectedArch: runtimeInfo.processArch,
hostArch: runtimeInfo.hostArch,
runningUnderRosetta: runtimeInfo.runningUnderRosetta,
};
}

function compareSemver(a: string, b: string): number {
const pa = a.replace(/^v/, "").split(".").map(Number);
const pb = b.replace(/^v/, "").split(".").map(Number);
Expand All @@ -28,10 +46,7 @@ export async function GET() {
);

if (!res.ok) {
return NextResponse.json(
{ error: "Failed to fetch release info" },
{ status: 502 }
);
return NextResponse.json(noUpdatePayload(currentVersion, runtimeInfo));
}

const release = await res.json();
Expand All @@ -58,9 +73,8 @@ export async function GET() {
runningUnderRosetta: runtimeInfo.runningUnderRosetta,
});
} catch {
return NextResponse.json(
{ error: "Failed to check for updates" },
{ status: 500 }
);
const currentVersion = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const runtimeInfo = getRuntimeArchitectureInfo();
return NextResponse.json(noUpdatePayload(currentVersion, runtimeInfo));
}
}
157 changes: 157 additions & 0 deletions src/app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { NextRequest } from 'next/server';
import { getAllSessions, searchMessages } from '@/lib/db';
import { scanDirectory } from '@/lib/files';
import type { ChatSession, FileTreeNode } from '@/types';

const FILE_SCAN_DEPTH = 2;
const MAX_RESULTS_PER_TYPE = 10;

interface SearchResultSession {
type: 'session';
id: string;
title: string;
projectName: string;
updatedAt: string;
}

interface SearchResultMessage {
type: 'message';
sessionId: string;
sessionTitle: string;
messageId: string;
role: 'user' | 'assistant';
snippet: string;
createdAt: string;
contentType: 'user' | 'assistant' | 'tool';
}

interface SearchResultFile {
type: 'file';
sessionId: string;
sessionTitle: string;
path: string;
name: string;
nodeType: 'file' | 'directory';
}

export interface SearchResponse {
sessions: SearchResultSession[];
messages: SearchResultMessage[];
files: SearchResultFile[];
}

function parseQuery(raw: string): { scope: 'all' | 'sessions' | 'messages' | 'files'; query: string } {
const trimmed = raw.trim();
const lower = trimmed.toLowerCase();
if (lower.startsWith('session:') || lower.startsWith('sessions:')) {
const prefixLen = lower.startsWith('session:') ? 8 : 9;
return { scope: 'sessions', query: trimmed.slice(prefixLen).trim() };
}
if (lower.startsWith('message:') || lower.startsWith('messages:')) {
const prefixLen = lower.startsWith('message:') ? 8 : 9;
return { scope: 'messages', query: trimmed.slice(prefixLen).trim() };
}
if (lower.startsWith('file:') || lower.startsWith('files:')) {
const prefixLen = lower.startsWith('file:') ? 5 : 6;
return { scope: 'files', query: trimmed.slice(prefixLen).trim() };
}
return { scope: 'all', query: trimmed };
}

function filterSessions(sessions: ChatSession[], query: string): SearchResultSession[] {
const q = query.toLowerCase();
return sessions
.filter(
(s) =>
s.title.toLowerCase().includes(q) ||
s.project_name.toLowerCase().includes(q),
)
.slice(0, MAX_RESULTS_PER_TYPE)
.map((s) => ({
type: 'session' as const,
id: s.id,
title: s.title,
projectName: s.project_name,
updatedAt: s.updated_at,
}));
}

function collectNodes(
tree: FileTreeNode[],
sessionId: string,
sessionTitle: string,
query: string,
results: SearchResultFile[],
): void {
if (results.length >= MAX_RESULTS_PER_TYPE) return;
const q = query.toLowerCase();
for (const node of tree) {
if (results.length >= MAX_RESULTS_PER_TYPE) break;
if (node.name.toLowerCase().includes(q)) {
results.push({
type: 'file',
sessionId,
sessionTitle,
path: node.path,
name: node.name,
nodeType: node.type,
});
}
if (node.type === 'directory' && node.children) {
collectNodes(node.children, sessionId, sessionTitle, query, results);
}
}
}

export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const rawQuery = searchParams.get('q') || '';
const { scope, query } = parseQuery(rawQuery);

if (!query) {
return Response.json({ sessions: [], messages: [], files: [] });
}

const allSessions = getAllSessions();
const result: SearchResponse = { sessions: [], messages: [], files: [] };

if (scope === 'all' || scope === 'sessions') {
result.sessions = filterSessions(allSessions, query);
}

if (scope === 'all' || scope === 'messages') {
const messageRows = searchMessages(query, { limit: MAX_RESULTS_PER_TYPE });
result.messages = messageRows.map((r) => ({
type: 'message' as const,
sessionId: r.sessionId,
sessionTitle: r.sessionTitle,
messageId: r.messageId,
role: r.role,
snippet: r.snippet,
createdAt: r.createdAt,
contentType: r.contentType,
}));
}

if (scope === 'all' || scope === 'files') {
for (const session of allSessions) {
if (!session.working_directory) continue;
try {
const tree = await scanDirectory(session.working_directory, FILE_SCAN_DEPTH);
collectNodes(tree, session.id, session.title, query, result.files);
if (result.files.length >= MAX_RESULTS_PER_TYPE) break;
} catch {
// Skip inaccessible/invalid session directories instead of failing the whole search.
continue;
}
}
}

return Response.json(result);
} catch (error) {
const message = error instanceof Error ? error.stack || error.message : String(error);
console.error('[GET /api/search] Error:', message);
return Response.json({ error: message }, { status: 500 });
}
}
Loading
Loading