Skip to content

Commit 9bcd099

Browse files
committed
feat: global search across reviews and projects
Backend: - SearchHandler with cross-project search by commit message, author, hash, branch, and project name/URL - GET /api/search?q=<query>&limit=<n> route in protected group Frontend: - GlobalSearch component with debounced input + popover dropdown - Shows matching projects and reviews with status tags - Click to navigate to detail page - Dark mode aware, integrated in MainLayout header - Search API service and types added
1 parent 46ffdc9 commit 9bcd099

File tree

5 files changed

+329
-0
lines changed

5 files changed

+329
-0
lines changed

backend/cmd/server/routes.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ func registerRoutes(r *gin.Engine, svc *appServices) {
6565
dashboardHandler := handlers.NewDashboardHandler(models.GetDB())
6666
protected.GET("/dashboard/stats", dashboardHandler.GetStats)
6767

68+
// Global Search
69+
searchHandler := handlers.NewSearchHandler(models.GetDB())
70+
protected.GET("/search", searchHandler.Search)
71+
6872
// Projects (read for all users)
6973
projectHandler := handlers.NewProjectHandler(models.GetDB())
7074
protected.GET("/projects", projectHandler.List)
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package handlers
2+
3+
import (
4+
"strconv"
5+
6+
"github.com/gin-gonic/gin"
7+
"github.com/huangang/codesentry/backend/internal/models"
8+
"github.com/huangang/codesentry/backend/pkg/response"
9+
"gorm.io/gorm"
10+
)
11+
12+
// SearchHandler provides a global search across review logs, projects, and members.
13+
type SearchHandler struct {
14+
db *gorm.DB
15+
}
16+
17+
func NewSearchHandler(db *gorm.DB) *SearchHandler {
18+
return &SearchHandler{db: db}
19+
}
20+
21+
type SearchResult struct {
22+
Reviews []ReviewSearchItem `json:"reviews"`
23+
Projects []ProjectSearchItem `json:"projects"`
24+
Total int `json:"total"`
25+
}
26+
27+
type ReviewSearchItem struct {
28+
ID uint `json:"id"`
29+
ProjectID uint `json:"project_id"`
30+
ProjectName string `json:"project_name"`
31+
CommitHash string `json:"commit_hash"`
32+
CommitMessage string `json:"commit_message"`
33+
Author string `json:"author"`
34+
Branch string `json:"branch"`
35+
Score *float64 `json:"score"`
36+
ReviewStatus string `json:"review_status"`
37+
CreatedAt string `json:"created_at"`
38+
}
39+
40+
type ProjectSearchItem struct {
41+
ID uint `json:"id"`
42+
Name string `json:"name"`
43+
URL string `json:"url"`
44+
Platform string `json:"platform"`
45+
}
46+
47+
// Search performs a global search across reviews and projects.
48+
func (h *SearchHandler) Search(c *gin.Context) {
49+
q := c.Query("q")
50+
if q == "" || len(q) < 2 {
51+
response.BadRequest(c, "search query must be at least 2 characters")
52+
return
53+
}
54+
55+
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
56+
if limit < 1 || limit > 50 {
57+
limit = 20
58+
}
59+
60+
result := SearchResult{}
61+
pattern := "%" + q + "%"
62+
63+
// Search review logs
64+
var reviews []models.ReviewLog
65+
h.db.Model(&models.ReviewLog{}).
66+
Preload("Project").
67+
Where("commit_message LIKE ? OR author LIKE ? OR commit_hash LIKE ? OR branch LIKE ?",
68+
pattern, pattern, pattern, pattern).
69+
Order("created_at DESC").
70+
Limit(limit).
71+
Find(&reviews)
72+
73+
for _, r := range reviews {
74+
projectName := ""
75+
if r.Project != nil {
76+
projectName = r.Project.Name
77+
}
78+
result.Reviews = append(result.Reviews, ReviewSearchItem{
79+
ID: r.ID,
80+
ProjectID: r.ProjectID,
81+
ProjectName: projectName,
82+
CommitHash: r.CommitHash,
83+
CommitMessage: r.CommitMessage,
84+
Author: r.Author,
85+
Branch: r.Branch,
86+
Score: r.Score,
87+
ReviewStatus: r.ReviewStatus,
88+
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
89+
})
90+
}
91+
92+
// Search projects
93+
var projects []models.Project
94+
h.db.Model(&models.Project{}).
95+
Where("name LIKE ? OR url LIKE ?", pattern, pattern).
96+
Limit(10).
97+
Find(&projects)
98+
99+
for _, p := range projects {
100+
result.Projects = append(result.Projects, ProjectSearchItem{
101+
ID: p.ID,
102+
Name: p.Name,
103+
URL: p.URL,
104+
Platform: p.Platform,
105+
})
106+
}
107+
108+
result.Total = len(result.Reviews) + len(result.Projects)
109+
response.Success(c, result)
110+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import React, { useState, useCallback, useRef, useEffect } from 'react';
2+
import { Input, Popover, List, Tag, Typography, Space, Empty, Spin } from 'antd';
3+
import { SearchOutlined, FileSearchOutlined, ProjectOutlined } from '@ant-design/icons';
4+
import { useNavigate } from 'react-router-dom';
5+
import { useTranslation } from 'react-i18next';
6+
import { searchApi } from '../services';
7+
import type { SearchReviewItem, SearchProjectItem } from '../services';
8+
import { useThemeStore } from '../stores/themeStore';
9+
10+
const { Text } = Typography;
11+
12+
const GlobalSearch: React.FC = () => {
13+
const { t } = useTranslation();
14+
const navigate = useNavigate();
15+
const isDark = useThemeStore((state) => state.isDark);
16+
const [query, setQuery] = useState('');
17+
const [loading, setLoading] = useState(false);
18+
const [reviews, setReviews] = useState<SearchReviewItem[]>([]);
19+
const [projects, setProjects] = useState<SearchProjectItem[]>([]);
20+
const [open, setOpen] = useState(false);
21+
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
22+
23+
const doSearch = useCallback(async (q: string) => {
24+
if (q.length < 2) {
25+
setReviews([]);
26+
setProjects([]);
27+
return;
28+
}
29+
setLoading(true);
30+
try {
31+
const res = await searchApi.search(q, 10);
32+
setReviews(res.data?.reviews || []);
33+
setProjects(res.data?.projects || []);
34+
setOpen(true);
35+
} catch {
36+
setReviews([]);
37+
setProjects([]);
38+
} finally {
39+
setLoading(false);
40+
}
41+
}, []);
42+
43+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
44+
const val = e.target.value;
45+
setQuery(val);
46+
if (debounceRef.current) clearTimeout(debounceRef.current);
47+
debounceRef.current = setTimeout(() => doSearch(val), 300);
48+
};
49+
50+
useEffect(() => {
51+
return () => {
52+
if (debounceRef.current) clearTimeout(debounceRef.current);
53+
};
54+
}, []);
55+
56+
const handleReviewClick = (id: number) => {
57+
setOpen(false);
58+
setQuery('');
59+
navigate(`/admin/review-logs?highlight=${id}`);
60+
};
61+
62+
const handleProjectClick = (id: number) => {
63+
setOpen(false);
64+
setQuery('');
65+
navigate(`/admin/projects?highlight=${id}`);
66+
};
67+
68+
const getStatusColor = (status: string) => {
69+
const map: Record<string, string> = {
70+
completed: 'green',
71+
failed: 'red',
72+
analyzing: 'blue',
73+
pending: 'orange',
74+
};
75+
return map[status] || 'default';
76+
};
77+
78+
const content = (
79+
<div style={{ width: 420, maxHeight: 480, overflow: 'auto' }}>
80+
{loading ? (
81+
<div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>
82+
) : reviews.length === 0 && projects.length === 0 ? (
83+
<Empty description={t('common.noData')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
84+
) : (
85+
<>
86+
{projects.length > 0 && (
87+
<>
88+
<Text type="secondary" style={{ fontSize: 12, padding: '8px 12px', display: 'block' }}>
89+
<ProjectOutlined /> {t('menu.projects')} ({projects.length})
90+
</Text>
91+
<List
92+
size="small"
93+
dataSource={projects}
94+
renderItem={(item) => (
95+
<List.Item
96+
style={{ cursor: 'pointer', padding: '8px 12px' }}
97+
onClick={() => handleProjectClick(item.id)}
98+
>
99+
<Space>
100+
<Tag>{item.platform}</Tag>
101+
<Text strong>{item.name}</Text>
102+
</Space>
103+
</List.Item>
104+
)}
105+
/>
106+
</>
107+
)}
108+
{reviews.length > 0 && (
109+
<>
110+
<Text type="secondary" style={{ fontSize: 12, padding: '8px 12px', display: 'block' }}>
111+
<FileSearchOutlined /> {t('menu.reviewLogs')} ({reviews.length})
112+
</Text>
113+
<List
114+
size="small"
115+
dataSource={reviews}
116+
renderItem={(item) => (
117+
<List.Item
118+
style={{ cursor: 'pointer', padding: '8px 12px' }}
119+
onClick={() => handleReviewClick(item.id)}
120+
>
121+
<div style={{ width: '100%' }}>
122+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
123+
<Text strong ellipsis style={{ maxWidth: 260 }}>
124+
{item.commit_message || item.commit_hash?.slice(0, 8)}
125+
</Text>
126+
<Tag color={getStatusColor(item.review_status)} style={{ marginLeft: 8 }}>
127+
{item.review_status}
128+
</Tag>
129+
</div>
130+
<div style={{ fontSize: 12, color: isDark ? '#94a3b8' : '#94a3b8', marginTop: 2 }}>
131+
<span>{item.author}</span>
132+
<span style={{ margin: '0 6px' }}>·</span>
133+
<span>{item.project_name}</span>
134+
{item.score !== null && (
135+
<>
136+
<span style={{ margin: '0 6px' }}>·</span>
137+
<span>{item.score}</span>
138+
</>
139+
)}
140+
</div>
141+
</div>
142+
</List.Item>
143+
)}
144+
/>
145+
</>
146+
)}
147+
</>
148+
)}
149+
</div>
150+
);
151+
152+
return (
153+
<Popover
154+
content={content}
155+
trigger="click"
156+
open={open && query.length >= 2}
157+
onOpenChange={(v) => { if (!v) setOpen(false); }}
158+
placement="bottomLeft"
159+
arrow={false}
160+
overlayStyle={{ padding: 0 }}
161+
>
162+
<Input
163+
prefix={<SearchOutlined style={{ color: isDark ? '#64748b' : '#94a3b8' }} />}
164+
placeholder={t('common.search')}
165+
value={query}
166+
onChange={handleChange}
167+
onFocus={() => { if (reviews.length > 0 || projects.length > 0) setOpen(true); }}
168+
allowClear
169+
style={{
170+
width: 220,
171+
borderRadius: 8,
172+
background: isDark ? 'rgba(30, 41, 59, 0.8)' : 'rgba(241, 245, 249, 0.8)',
173+
border: isDark ? '1px solid #334155' : '1px solid #e2e8f0',
174+
}}
175+
/>
176+
</Popover>
177+
);
178+
};
179+
180+
export default GlobalSearch;

frontend/src/layouts/MainLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { useThemeStore } from '../stores/themeStore';
2929
import { usePermission } from '../hooks';
3030
import { authApi } from '../services';
3131
import { stopProactiveRefresh } from '../services/api';
32+
import GlobalSearch from '../components/GlobalSearch';
3233

3334
const { Header, Sider, Content } = Layout;
3435
const { Text } = Typography;
@@ -334,6 +335,7 @@ const MainLayout: React.FC = () => {
334335
{currentPageTitle}
335336
</Text>
336337
</Space>
338+
{!isMobile && <GlobalSearch />}
337339
<Space size={isMobile ? 'small' : 'large'}>
338340
<Tooltip title="GitHub">
339341
<Button

frontend/src/services/index.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,3 +461,36 @@ export const aiUsageApi = {
461461
getProviderBreakdown: (params?: { start_date?: string; end_date?: string }) =>
462462
api.get<ProviderUsage[]>('/ai-usage/providers', { params }),
463463
};
464+
465+
// ---- Global Search ----
466+
467+
export interface SearchReviewItem {
468+
id: number;
469+
project_id: number;
470+
project_name: string;
471+
commit_hash: string;
472+
commit_message: string;
473+
author: string;
474+
branch: string;
475+
score: number | null;
476+
review_status: string;
477+
created_at: string;
478+
}
479+
480+
export interface SearchProjectItem {
481+
id: number;
482+
name: string;
483+
url: string;
484+
platform: string;
485+
}
486+
487+
export interface SearchResult {
488+
reviews: SearchReviewItem[] | null;
489+
projects: SearchProjectItem[] | null;
490+
total: number;
491+
}
492+
493+
export const searchApi = {
494+
search: (q: string, limit?: number) =>
495+
api.get<SearchResult>('/search', { params: { q, limit } }),
496+
};

0 commit comments

Comments
 (0)