feat: server-side GitHub search for issue/PR pickers (#1352)
Replace local-only filtering in GitHub issue/PR picker dialogs with server-side GitHub Search API queries. Search text is sent as a query parameter to the server, which uses the GitHub Search API (issuesAndPullRequests endpoint) with repo: qualifiers including fork network support. Results are debounced at 350ms to respect API rate limits. - Add query parameter to GitHubAPI issuesList/prsList interface - Server routes use Search API when query is present, standard list endpoint when absent - Fork networks handled via repo:owner/repo OR repo:owner/upstream - PR search fetches full PR details after Search API for head/base/draft fields - Remove local filter memos from all three picker dialogs - Add debounced search effect with abort controller cleanup - Update VS Code backend and webview API for parity - Update search placeholders in all locales Closes #1350 Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
5589ca991a
commit
7b1b3167a4
@@ -18,6 +18,7 @@ import { validateWorktreeCreate } from '@/lib/worktrees/worktreeManager';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import type {
|
||||
GitHubIssue,
|
||||
GitHubIssueSummary,
|
||||
@@ -80,8 +81,9 @@ export function GitHubIntegrationDialog({
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [hasMore, setHasMore] = React.useState(false);
|
||||
|
||||
// Load GitHub data
|
||||
const loadData = React.useCallback(async () => {
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 350);
|
||||
|
||||
const loadData = React.useCallback(async (query?: string) => {
|
||||
if (!projectDirectory || !github) return;
|
||||
if (githubAuthChecked && githubAuthStatus?.connected === false) return;
|
||||
|
||||
@@ -92,7 +94,7 @@ export function GitHubIntegrationDialog({
|
||||
|
||||
try {
|
||||
if (activeTab === 'issues' && github.issuesList) {
|
||||
const result = await github.issuesList(projectDirectory, { page: 1 });
|
||||
const result = await github.issuesList(projectDirectory, { page: 1, query });
|
||||
if (result.connected === false) {
|
||||
setError(t('session.githubIntegration.error.notConnected'));
|
||||
setIssues([]);
|
||||
@@ -102,7 +104,7 @@ export function GitHubIntegrationDialog({
|
||||
setHasMore(Boolean(result.hasMore));
|
||||
}
|
||||
} else if (activeTab === 'prs' && github.prsList) {
|
||||
const result = await github.prsList(projectDirectory, { page: 1 });
|
||||
const result = await github.prsList(projectDirectory, { page: 1, query });
|
||||
if (result.connected === false) {
|
||||
setError(t('session.githubIntegration.error.notConnected'));
|
||||
setPrs([]);
|
||||
@@ -119,7 +121,66 @@ export function GitHubIntegrationDialog({
|
||||
}
|
||||
}, [projectDirectory, github, githubAuthChecked, githubAuthStatus, activeTab, t]);
|
||||
|
||||
// Load more data
|
||||
React.useEffect(() => {
|
||||
if (!open || !projectDirectory) return;
|
||||
if (githubAuthChecked && githubAuthStatus?.connected === false) return;
|
||||
if (!github) return;
|
||||
if (!debouncedSearchQuery.trim()) {
|
||||
void loadData();
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPage(1);
|
||||
setHasMore(false);
|
||||
|
||||
const apiCall = activeTab === 'issues' && github.issuesList
|
||||
? github.issuesList(projectDirectory, { page: 1, query: debouncedSearchQuery.trim() })
|
||||
: activeTab === 'prs' && github.prsList
|
||||
? github.prsList(projectDirectory, { page: 1, query: debouncedSearchQuery.trim() })
|
||||
: null;
|
||||
|
||||
if (!apiCall) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
apiCall
|
||||
.then((result) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if ('issues' in result) {
|
||||
if (result.connected === false) {
|
||||
setError(t('session.githubIntegration.error.notConnected'));
|
||||
setIssues([]);
|
||||
} else {
|
||||
setIssues(result.issues ?? []);
|
||||
setPage(result.page ?? 1);
|
||||
setHasMore(Boolean(result.hasMore));
|
||||
}
|
||||
} else if ('prs' in result) {
|
||||
if (result.connected === false) {
|
||||
setError(t('session.githubIntegration.error.notConnected'));
|
||||
setPrs([]);
|
||||
} else {
|
||||
setPrs(result.prs ?? []);
|
||||
setPage(result.page ?? 1);
|
||||
setHasMore(Boolean(result.hasMore));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(err instanceof Error ? err.message : t('session.githubIntegration.error.loadDataFailed'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [open, projectDirectory, github, githubAuthChecked, githubAuthStatus, activeTab, debouncedSearchQuery, loadData, t]);
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (!projectDirectory || !github) return;
|
||||
if (loading || loadingMore) return;
|
||||
@@ -131,14 +192,18 @@ export function GitHubIntegrationDialog({
|
||||
const nextPage = page + 1;
|
||||
|
||||
if (activeTab === 'issues' && github.issuesList) {
|
||||
const result = await github.issuesList(projectDirectory, { page: nextPage });
|
||||
const result = debouncedSearchQuery.trim()
|
||||
? await github.issuesList(projectDirectory, { page: nextPage, query: debouncedSearchQuery.trim() })
|
||||
: await github.issuesList(projectDirectory, { page: nextPage });
|
||||
if (result.connected !== false) {
|
||||
setIssues(prev => [...prev, ...(result.issues ?? [])]);
|
||||
setPage(result.page ?? nextPage);
|
||||
setHasMore(Boolean(result.hasMore));
|
||||
}
|
||||
} else if (activeTab === 'prs' && github.prsList) {
|
||||
const result = await github.prsList(projectDirectory, { page: nextPage });
|
||||
const result = debouncedSearchQuery.trim()
|
||||
? await github.prsList(projectDirectory, { page: nextPage, query: debouncedSearchQuery.trim() })
|
||||
: await github.prsList(projectDirectory, { page: nextPage });
|
||||
if (result.connected !== false) {
|
||||
setPrs(prev => [...prev, ...(result.prs ?? [])]);
|
||||
setPage(result.page ?? nextPage);
|
||||
@@ -150,7 +215,7 @@ export function GitHubIntegrationDialog({
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [projectDirectory, github, activeTab, page, hasMore, loading, loadingMore]);
|
||||
}, [projectDirectory, github, activeTab, page, hasMore, loading, loadingMore, debouncedSearchQuery]);
|
||||
|
||||
// Reset state when dialog opens/closes
|
||||
React.useEffect(() => {
|
||||
@@ -215,25 +280,6 @@ export function GitHubIntegrationDialog({
|
||||
});
|
||||
}, [open, activeTab, prs, validateBranch]);
|
||||
|
||||
// Filtered results
|
||||
const filteredIssues = React.useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return issues;
|
||||
return issues.filter(issue => {
|
||||
if (String(issue.number) === q.replace(/^#/, '')) return true;
|
||||
return issue.title.toLowerCase().includes(q);
|
||||
});
|
||||
}, [issues, searchQuery]);
|
||||
|
||||
const filteredPrs = React.useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return prs;
|
||||
return prs.filter(pr => {
|
||||
if (String(pr.number) === q.replace(/^#/, '')) return true;
|
||||
return pr.title.toLowerCase().includes(q);
|
||||
});
|
||||
}, [prs, searchQuery]);
|
||||
|
||||
// GitHub connection check
|
||||
const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true;
|
||||
|
||||
@@ -337,8 +383,8 @@ export function GitHubIntegrationDialog({
|
||||
{/* Issues List */}
|
||||
{!loading && !error && activeTab === 'issues' && (
|
||||
<div className="space-y-0.5 min-h-full">
|
||||
{filteredIssues.length > 0 ? (
|
||||
filteredIssues.map(issue => (
|
||||
{issues.length > 0 ? (
|
||||
issues.map(issue => (
|
||||
<button
|
||||
key={`${issue.sourceRepo?.owner ?? ''}-${issue.sourceRepo?.repo ?? ''}-${issue.number}`}
|
||||
onClick={() => handleSelectIssue(issue)}
|
||||
@@ -391,8 +437,8 @@ export function GitHubIntegrationDialog({
|
||||
{/* PRs List */}
|
||||
{!loading && !error && activeTab === 'prs' && (
|
||||
<div className="space-y-0.5 min-h-full">
|
||||
{filteredPrs.length > 0 ? (
|
||||
filteredPrs.map(pr => {
|
||||
{prs.length > 0 ? (
|
||||
prs.map(pr => {
|
||||
const blocked = isPrBlocked(pr);
|
||||
const validation = pr.head ? validations.get(pr.head) : undefined;
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import { parseModelIdentifier } from '@/lib/modelIdentifier';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
|
||||
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult, GitHubIssueSummary, GitHubRepoSelector } from '@/lib/api/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
@@ -99,6 +100,10 @@ export function GitHubIssuePickerDialog({
|
||||
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const directNumber = React.useMemo(() => parseIssueNumber(query), [query]);
|
||||
const debouncedQuery = useDebouncedValue(query, 350);
|
||||
const isTextSearch = debouncedQuery.trim().length > 0 && !directNumber;
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
if (!projectDirectory) {
|
||||
setResult(null);
|
||||
@@ -137,6 +142,38 @@ export function GitHubIssuePickerDialog({
|
||||
}
|
||||
}, [github, githubAuthChecked, githubAuthStatus, projectDirectory, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !projectDirectory) return;
|
||||
if (githubAuthChecked && githubAuthStatus?.connected === false) return;
|
||||
if (!github?.issuesList) return;
|
||||
if (!debouncedQuery.trim() || directNumber) {
|
||||
void refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
github.issuesList(projectDirectory, { page: 1, query: debouncedQuery.trim() })
|
||||
.then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setResult(next);
|
||||
setIssues(next.issues ?? []);
|
||||
setPage(next.page ?? 1);
|
||||
setHasMore(Boolean(next.hasMore));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [open, projectDirectory, github, githubAuthChecked, githubAuthStatus, debouncedQuery, directNumber, refresh, t]);
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (!projectDirectory) return;
|
||||
if (!github?.issuesList) return;
|
||||
@@ -146,7 +183,9 @@ export function GitHubIssuePickerDialog({
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const nextPage = page + 1;
|
||||
const next = await github.issuesList(projectDirectory, { page: nextPage });
|
||||
const next = isTextSearch
|
||||
? await github.issuesList(projectDirectory, { page: nextPage, query: debouncedQuery.trim() })
|
||||
: await github.issuesList(projectDirectory, { page: nextPage });
|
||||
setResult(next);
|
||||
setIssues((prev) => [...prev, ...(next.issues ?? [])]);
|
||||
setPage(next.page ?? nextPage);
|
||||
@@ -157,7 +196,7 @@ export function GitHubIssuePickerDialog({
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory, t]);
|
||||
}, [github, hasMore, isLoading, isLoadingMore, isTextSearch, debouncedQuery, page, projectDirectory, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -194,17 +233,6 @@ export function GitHubIssuePickerDialog({
|
||||
setSettingsDialogOpen(true);
|
||||
}, [setSettingsDialogOpen, setSettingsPage]);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return issues;
|
||||
return issues.filter((issue) => {
|
||||
if (String(issue.number) === q.replace(/^#/, '')) return true;
|
||||
return issue.title.toLowerCase().includes(q);
|
||||
});
|
||||
}, [issues, query]);
|
||||
|
||||
const directNumber = React.useMemo(() => parseIssueNumber(query), [query]);
|
||||
|
||||
const resolveDefaultAgentName = React.useCallback((): string | undefined => {
|
||||
const configState = useConfigStore.getState();
|
||||
const visibleAgents = configState.getVisibleAgents();
|
||||
@@ -519,11 +547,11 @@ export function GitHubIssuePickerDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{filtered.length === 0 && !isLoading && connected && github && projectDirectory ? (
|
||||
<div className="text-center text-muted-foreground py-8">{query ? t('session.githubIssuePicker.empty.noIssuesFound') : t('session.githubIssuePicker.empty.noOpenIssuesFound')}</div>
|
||||
{issues.length === 0 && !isLoading && connected && github && projectDirectory ? (
|
||||
<div className="text-center text-muted-foreground py-8">{debouncedQuery.trim() ? t('session.githubIssuePicker.empty.noIssuesFound') : t('session.githubIssuePicker.empty.noOpenIssuesFound')}</div>
|
||||
) : null}
|
||||
|
||||
{filtered.map((issue) => (
|
||||
{issues.map((issue) => (
|
||||
<div
|
||||
key={`${issue.sourceRepo?.owner ?? ''}-${issue.sourceRepo?.repo ?? ''}-${issue.number}`}
|
||||
className={cn(
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult, GitHubRepoSelector } from '@/lib/api/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
@@ -88,6 +89,10 @@ export function GitHubPrPickerDialog({
|
||||
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const directNumber = React.useMemo(() => parsePrNumber(query), [query]);
|
||||
const debouncedQuery = useDebouncedValue(query, 350);
|
||||
const isTextSearch = debouncedQuery.trim().length > 0 && !directNumber;
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
if (!projectDirectory) {
|
||||
setResult(null);
|
||||
@@ -126,6 +131,38 @@ export function GitHubPrPickerDialog({
|
||||
}
|
||||
}, [github, githubAuthChecked, githubAuthStatus, projectDirectory, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !projectDirectory) return;
|
||||
if (githubAuthChecked && githubAuthStatus?.connected === false) return;
|
||||
if (!github?.prsList) return;
|
||||
if (!debouncedQuery.trim() || directNumber) {
|
||||
void refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
github.prsList(projectDirectory, { page: 1, query: debouncedQuery.trim() })
|
||||
.then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setResult(next);
|
||||
setPrs(next.prs ?? []);
|
||||
setPage(next.page ?? 1);
|
||||
setHasMore(Boolean(next.hasMore));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [open, projectDirectory, github, githubAuthChecked, githubAuthStatus, debouncedQuery, directNumber, refresh, t]);
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (!projectDirectory) return;
|
||||
if (!github?.prsList) return;
|
||||
@@ -135,7 +172,9 @@ export function GitHubPrPickerDialog({
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const nextPage = page + 1;
|
||||
const next = await github.prsList(projectDirectory, { page: nextPage });
|
||||
const next = isTextSearch
|
||||
? await github.prsList(projectDirectory, { page: nextPage, query: debouncedQuery.trim() })
|
||||
: await github.prsList(projectDirectory, { page: nextPage });
|
||||
setResult(next);
|
||||
setPrs((prev) => [...prev, ...(next.prs ?? [])]);
|
||||
setPage(next.page ?? nextPage);
|
||||
@@ -146,7 +185,7 @@ export function GitHubPrPickerDialog({
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory, t]);
|
||||
}, [github, hasMore, isLoading, isLoadingMore, isTextSearch, debouncedQuery, page, projectDirectory, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -182,17 +221,6 @@ export function GitHubPrPickerDialog({
|
||||
setSettingsDialogOpen(true);
|
||||
}, [setSettingsDialogOpen, setSettingsPage]);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return prs;
|
||||
return prs.filter((pr) => {
|
||||
if (String(pr.number) === q.replace(/^#/, '')) return true;
|
||||
return pr.title.toLowerCase().includes(q);
|
||||
});
|
||||
}, [prs, query]);
|
||||
|
||||
const directNumber = React.useMemo(() => parsePrNumber(query), [query]);
|
||||
|
||||
const attachPr = React.useCallback(async (prNumber: number, sourceRepo?: GitHubRepoSelector | null) => {
|
||||
if (!projectDirectory) {
|
||||
toast.error(t('session.githubPrPicker.error.noActiveProject'));
|
||||
@@ -341,11 +369,11 @@ export function GitHubPrPickerDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{filtered.length === 0 && !isLoading && connected && github && projectDirectory ? (
|
||||
<div className="text-center text-muted-foreground py-8">{query ? t('session.githubPrPicker.empty.noPullRequestsFound') : t('session.githubPrPicker.empty.noOpenPullRequestsFound')}</div>
|
||||
{prs.length === 0 && !isLoading && connected && github && projectDirectory ? (
|
||||
<div className="text-center text-muted-foreground py-8">{debouncedQuery.trim() ? t('session.githubPrPicker.empty.noPullRequestsFound') : t('session.githubPrPicker.empty.noOpenPullRequestsFound')}</div>
|
||||
) : null}
|
||||
|
||||
{filtered.map((pr) => (
|
||||
{prs.map((pr) => (
|
||||
<div
|
||||
key={`${pr.sourceRepo?.owner ?? ''}-${pr.sourceRepo?.repo ?? ''}-${pr.number}`}
|
||||
className={cn(
|
||||
|
||||
Reference in New Issue
Block a user