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>
678 lines
26 KiB
TypeScript
678 lines
26 KiB
TypeScript
import * as React from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { cn } from '@/lib/utils';
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
|
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,
|
|
GitHubPullRequestSummary,
|
|
} from '@/lib/api/types';
|
|
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
type GitHubTab = 'issues' | 'prs';
|
|
|
|
interface GitHubIntegrationDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSelect: (result: {
|
|
type: 'issue' | 'pr';
|
|
item: GitHubIssue | GitHubPullRequestSummary;
|
|
includeDiff?: boolean;
|
|
} | null) => void;
|
|
}
|
|
|
|
interface ValidationResult {
|
|
isValid: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
export function GitHubIntegrationDialog({
|
|
open,
|
|
onOpenChange,
|
|
onSelect,
|
|
}: GitHubIntegrationDialogProps) {
|
|
const { t } = useI18n();
|
|
const isMobile = useUIStore((state) => state.isMobile);
|
|
const { github } = useRuntimeAPIs();
|
|
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
|
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
|
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
|
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
|
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
|
|
|
const projectDirectory = activeProject?.path ?? null;
|
|
const projectRef: ProjectRef | null = React.useMemo(() => {
|
|
if (projectDirectory && activeProject) {
|
|
return { id: activeProject.id, path: projectDirectory };
|
|
}
|
|
return null;
|
|
}, [activeProject, projectDirectory]);
|
|
|
|
// State
|
|
const [activeTab, setActiveTab] = React.useState<GitHubTab>('issues');
|
|
const [searchQuery, setSearchQuery] = React.useState('');
|
|
const [issues, setIssues] = React.useState<GitHubIssueSummary[]>([]);
|
|
const [prs, setPrs] = React.useState<GitHubPullRequestSummary[]>([]);
|
|
const [loading, setLoading] = React.useState(false);
|
|
const [loadingMore, setLoadingMore] = React.useState(false);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [selectedIssue, setSelectedIssue] = React.useState<GitHubIssue | null>(null);
|
|
const [selectedPr, setSelectedPr] = React.useState<GitHubPullRequestSummary | null>(null);
|
|
const [includeDiff, setIncludeDiff] = React.useState(false);
|
|
const [validations, setValidations] = React.useState<Map<string, ValidationResult>>(new Map());
|
|
const [page, setPage] = React.useState(1);
|
|
const [hasMore, setHasMore] = React.useState(false);
|
|
|
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 350);
|
|
|
|
const loadData = React.useCallback(async (query?: string) => {
|
|
if (!projectDirectory || !github) return;
|
|
if (githubAuthChecked && githubAuthStatus?.connected === false) return;
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
setPage(1);
|
|
setHasMore(false);
|
|
|
|
try {
|
|
if (activeTab === 'issues' && github.issuesList) {
|
|
const result = await github.issuesList(projectDirectory, { page: 1, query });
|
|
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 (activeTab === 'prs' && github.prsList) {
|
|
const result = await github.prsList(projectDirectory, { page: 1, query });
|
|
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) {
|
|
setError(err instanceof Error ? err.message : t('session.githubIntegration.error.loadDataFailed'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [projectDirectory, github, githubAuthChecked, githubAuthStatus, activeTab, t]);
|
|
|
|
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;
|
|
if (!hasMore) return;
|
|
|
|
setLoadingMore(true);
|
|
|
|
try {
|
|
const nextPage = page + 1;
|
|
|
|
if (activeTab === 'issues' && github.issuesList) {
|
|
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 = 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);
|
|
setHasMore(Boolean(result.hasMore));
|
|
}
|
|
}
|
|
} catch {
|
|
// Silently fail on load more errors
|
|
} finally {
|
|
setLoadingMore(false);
|
|
}
|
|
}, [projectDirectory, github, activeTab, page, hasMore, loading, loadingMore, debouncedSearchQuery]);
|
|
|
|
// Reset state when dialog opens/closes
|
|
React.useEffect(() => {
|
|
if (!open) {
|
|
setActiveTab('issues');
|
|
setSearchQuery('');
|
|
setIssues([]);
|
|
setPrs([]);
|
|
setSelectedIssue(null);
|
|
setSelectedPr(null);
|
|
setIncludeDiff(false);
|
|
setError(null);
|
|
setValidations(new Map());
|
|
setPage(1);
|
|
setHasMore(false);
|
|
return;
|
|
}
|
|
|
|
void loadData();
|
|
}, [open, loadData]);
|
|
|
|
// Validate branches for worktree creation
|
|
const validateBranch = React.useCallback(async (branchName: string) => {
|
|
if (!projectRef || !branchName) return;
|
|
|
|
// Check cache first
|
|
if (validations.has(branchName)) return;
|
|
|
|
try {
|
|
const result = await validateWorktreeCreate(projectRef, {
|
|
mode: 'new',
|
|
branchName,
|
|
worktreeName: branchName,
|
|
});
|
|
|
|
const blockingError = result.errors.find((entry) => entry.code === 'branch_in_use');
|
|
|
|
setValidations(prev => new Map(prev).set(branchName, {
|
|
isValid: !blockingError,
|
|
error: blockingError
|
|
? t(blockingError.code === 'branch_exists'
|
|
? 'session.githubIntegration.validation.branchAlreadyExists'
|
|
: 'session.githubIntegration.validation.branchAlreadyCheckedOut')
|
|
: null,
|
|
}));
|
|
} catch {
|
|
setValidations(prev => new Map(prev).set(branchName, {
|
|
isValid: false,
|
|
error: t('session.githubIntegration.validation.failed'),
|
|
}));
|
|
}
|
|
}, [projectRef, validations, t]);
|
|
|
|
// Validate PR branches when loaded
|
|
React.useEffect(() => {
|
|
if (!open || activeTab !== 'prs') return;
|
|
|
|
prs.forEach(pr => {
|
|
if (pr.head) {
|
|
void validateBranch(pr.head);
|
|
}
|
|
});
|
|
}, [open, activeTab, prs, validateBranch]);
|
|
|
|
// GitHub connection check
|
|
const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true;
|
|
|
|
const openGitHubSettings = () => {
|
|
setSettingsPage('github');
|
|
setSettingsDialogOpen(true);
|
|
};
|
|
|
|
// Handle selection
|
|
const handleSelectIssue = (issue: GitHubIssueSummary) => {
|
|
setSelectedIssue(issue as GitHubIssue);
|
|
setSelectedPr(null);
|
|
};
|
|
|
|
const handleSelectPr = (pr: GitHubPullRequestSummary) => {
|
|
setSelectedPr(pr);
|
|
setSelectedIssue(null);
|
|
};
|
|
|
|
const handleConfirm = () => {
|
|
if (selectedIssue) {
|
|
onSelect({
|
|
type: 'issue',
|
|
item: selectedIssue,
|
|
});
|
|
} else if (selectedPr) {
|
|
onSelect({
|
|
type: 'pr',
|
|
item: selectedPr,
|
|
includeDiff,
|
|
});
|
|
}
|
|
onOpenChange(false);
|
|
};
|
|
|
|
const handleClear = () => {
|
|
setSelectedIssue(null);
|
|
setSelectedPr(null);
|
|
setIncludeDiff(false);
|
|
};
|
|
|
|
// Check if selection is valid
|
|
const canConfirm = selectedIssue || (selectedPr && validations.get(selectedPr.head ?? '')?.isValid !== false);
|
|
|
|
// Check if PR is blocked
|
|
const isPrBlocked = (pr: GitHubPullRequestSummary): boolean => {
|
|
if (!pr.head) return true;
|
|
const validation = validations.get(pr.head);
|
|
return validation?.isValid === false;
|
|
};
|
|
|
|
// Content for the dialog (shared between mobile and desktop)
|
|
const dialogContent = (
|
|
<>
|
|
{!isGitHubConnected ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-4">
|
|
<Icon name="github" className="h-12 w-12 text-muted-foreground" />
|
|
<div className="text-center">
|
|
<p className="typography-ui-label text-foreground">{t('session.githubIntegration.connect.title')}</p>
|
|
<p className="typography-small text-muted-foreground mt-1">
|
|
{t('session.githubIntegration.connect.description')}
|
|
</p>
|
|
</div>
|
|
<Button onClick={openGitHubSettings} size="sm">{t('session.githubIntegration.connect.action')}</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Search */}
|
|
<div className="relative mt-2">
|
|
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder={activeTab === 'issues'
|
|
? t('session.githubIntegration.search.issuesPlaceholder')
|
|
: t('session.githubIntegration.search.prsPlaceholder')}
|
|
className="h-8 pl-9"
|
|
/>
|
|
</div>
|
|
|
|
{/* List Content */}
|
|
<div className="mt-2 h-[300px] overflow-hidden">
|
|
<div className="h-full overflow-y-auto">
|
|
{/* Loading */}
|
|
{loading && (
|
|
<div className="flex items-center justify-center h-full">
|
|
<Icon name="loader-4" className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="flex items-center gap-2 p-2 rounded-md bg-destructive/10 text-destructive">
|
|
<Icon name="error-warning" className="h-4 w-4" />
|
|
<span className="typography-small">{error}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Issues List */}
|
|
{!loading && !error && activeTab === 'issues' && (
|
|
<div className="space-y-0.5 min-h-full">
|
|
{issues.length > 0 ? (
|
|
issues.map(issue => (
|
|
<button
|
|
key={`${issue.sourceRepo?.owner ?? ''}-${issue.sourceRepo?.repo ?? ''}-${issue.number}`}
|
|
onClick={() => handleSelectIssue(issue)}
|
|
className={cn(
|
|
'w-full text-left px-2 py-1.5 rounded transition-colors',
|
|
selectedIssue?.number === issue.number
|
|
? 'bg-interactive-selection text-interactive-selection-foreground'
|
|
: 'hover:bg-interactive-hover'
|
|
)}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<span className="text-muted-foreground shrink-0 typography-micro">#{issue.number}</span>
|
|
<div className="min-w-0 flex-1">
|
|
<span className="typography-small line-clamp-2">{issue.title}</span>
|
|
{issue.sourceRepo?.source === 'upstream' ? (
|
|
<span className="typography-micro px-1 py-0.5 rounded bg-status-info/10 text-status-info mt-0.5 inline-block">
|
|
{issue.sourceRepo.owner}/{issue.sourceRepo.repo}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))
|
|
) : (
|
|
<div className="flex items-center justify-center h-[300px] text-center typography-small text-muted-foreground">
|
|
{t('session.githubIntegration.empty.noIssuesFound')}
|
|
</div>
|
|
)}
|
|
|
|
{hasMore && !loadingMore && (
|
|
<div className="flex justify-center pt-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => void loadMore()}
|
|
className="h-7 text-xs"
|
|
>
|
|
{t('session.githubIntegration.actions.loadMore')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{loadingMore && (
|
|
<div className="flex items-center justify-center py-2">
|
|
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* PRs List */}
|
|
{!loading && !error && activeTab === 'prs' && (
|
|
<div className="space-y-0.5 min-h-full">
|
|
{prs.length > 0 ? (
|
|
prs.map(pr => {
|
|
const blocked = isPrBlocked(pr);
|
|
const validation = pr.head ? validations.get(pr.head) : undefined;
|
|
|
|
return (
|
|
<button
|
|
key={`${pr.sourceRepo?.owner ?? ''}-${pr.sourceRepo?.repo ?? ''}-${pr.number}`}
|
|
onClick={() => !blocked && handleSelectPr(pr)}
|
|
disabled={blocked}
|
|
className={cn(
|
|
'w-full text-left px-2 py-1.5 rounded transition-colors',
|
|
selectedPr?.number === pr.number
|
|
? 'bg-interactive-selection text-interactive-selection-foreground'
|
|
: blocked
|
|
? 'opacity-50 cursor-not-allowed'
|
|
: 'hover:bg-interactive-hover'
|
|
)}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<span className="text-muted-foreground shrink-0 typography-micro">#{pr.number}</span>
|
|
<div className="min-w-0 flex-1">
|
|
<span className="typography-small line-clamp-1">{pr.title}</span>
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
<span className="typography-micro text-muted-foreground">
|
|
{pr.head} → {pr.base}
|
|
</span>
|
|
{pr.sourceRepo?.source === 'upstream' ? (
|
|
<span className="typography-micro px-1 py-0.5 rounded bg-status-info/10 text-status-info">
|
|
{pr.sourceRepo.owner}/{pr.sourceRepo.repo}
|
|
</span>
|
|
) : null}
|
|
{blocked && validation?.error && (
|
|
<span className="typography-micro text-destructive">
|
|
{validation.error}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})
|
|
) : (
|
|
<div className="flex items-center justify-center h-[300px] text-center typography-small text-muted-foreground">
|
|
{t('session.githubIntegration.empty.noPullRequestsFound')}
|
|
</div>
|
|
)}
|
|
|
|
{hasMore && !loadingMore && (
|
|
<div className="flex justify-center pt-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => void loadMore()}
|
|
className="h-7 text-xs"
|
|
>
|
|
{t('session.githubIntegration.actions.loadMore')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{loadingMore && (
|
|
<div className="flex items-center justify-center py-2">
|
|
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
// Footer content
|
|
const footerContent = (
|
|
<div className={cn(
|
|
'w-full',
|
|
isMobile ? 'flex flex-col gap-2' : 'flex flex-row items-center'
|
|
)}>
|
|
{/* Left side: Selected Item / Checkbox */}
|
|
<div className={cn(
|
|
'flex items-center gap-4',
|
|
isMobile ? 'w-full justify-center order-1' : 'flex-1'
|
|
)}>
|
|
{/* Selected Issue/PR display - hidden on mobile (shown in header instead) */}
|
|
{!isMobile && (selectedIssue || selectedPr) && (
|
|
<div className="flex items-center gap-2 px-2 h-8 rounded-md bg-muted/50 border border-border/50">
|
|
<Icon name="check" className="h-3.5 w-3.5 text-status-success shrink-0" />
|
|
<span className="typography-small truncate max-w-[150px]">
|
|
{selectedIssue
|
|
? t('session.githubIntegration.selected.issueNumber', { number: selectedIssue.number })
|
|
: t('session.githubIntegration.selected.prNumber', { number: selectedPr?.number ?? '' })}
|
|
</span>
|
|
<button
|
|
onClick={handleClear}
|
|
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
|
|
>
|
|
<Icon name="close" className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Include Diff Checkbox - only show when PR tab is active and PR is selected */}
|
|
{activeTab === 'prs' && selectedPr && (
|
|
<label className="flex items-center gap-2 cursor-pointer h-8">
|
|
<Checkbox
|
|
checked={includeDiff}
|
|
onChange={(checked) => setIncludeDiff(checked)}
|
|
ariaLabel={t('session.githubIntegration.includeDiffAria')}
|
|
/>
|
|
<span className="typography-small text-foreground">
|
|
{t('session.githubIntegration.includeDiff')}
|
|
</span>
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right side: Buttons */}
|
|
<div className={cn(
|
|
'flex gap-2',
|
|
isMobile ? 'w-full order-2' : 'justify-end'
|
|
)}>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onOpenChange(false)}
|
|
className={cn(isMobile && 'flex-1')}
|
|
>
|
|
{t('session.githubIntegration.actions.cancel')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleConfirm}
|
|
disabled={!canConfirm}
|
|
className={cn(isMobile && 'flex-1')}
|
|
>
|
|
{t('session.githubIntegration.actions.select')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{isMobile ? (
|
|
<MobileOverlayPanel
|
|
open={open}
|
|
title={t('session.githubIntegration.title')}
|
|
onClose={() => onOpenChange(false)}
|
|
footer={!isGitHubConnected ? undefined : footerContent}
|
|
renderHeader={(closeButton) => (
|
|
<div className="flex flex-col gap-2 px-3 py-2 border-b border-border/40">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="typography-ui-label font-semibold text-foreground">{t('session.githubIntegration.title')}</h2>
|
|
{closeButton}
|
|
</div>
|
|
{/* Tabs - using SortableTabsStrip */}
|
|
<div className="w-full">
|
|
<SortableTabsStrip
|
|
items={[
|
|
{ id: 'issues', label: t('session.githubIntegration.tabs.issues'), icon: <Icon name="git-branch" className="h-3.5 w-3.5" /> },
|
|
{ id: 'prs', label: t('session.githubIntegration.tabs.pullRequests'), icon: <Icon name="git-pull-request" className="h-3.5 w-3.5" /> },
|
|
]}
|
|
activeId={activeTab}
|
|
onSelect={(id) => {
|
|
setActiveTab(id as GitHubTab);
|
|
setSearchQuery('');
|
|
}}
|
|
variant="active-pill"
|
|
layoutMode="fit"
|
|
/>
|
|
</div>
|
|
|
|
{/* Selected Item Inline Display */}
|
|
{(selectedIssue || selectedPr) && (
|
|
<div className="flex items-center gap-2 px-2 py-1 rounded-md bg-muted/50 border border-border/50">
|
|
<Icon name="check" className="h-3.5 w-3.5 text-status-success shrink-0" />
|
|
<span className="typography-small truncate flex-1">
|
|
{selectedIssue
|
|
? t('session.githubIntegration.selected.issueNumber', { number: selectedIssue.number })
|
|
: t('session.githubIntegration.selected.prNumber', { number: selectedPr?.number ?? '' })}
|
|
</span>
|
|
<button
|
|
onClick={handleClear}
|
|
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
|
|
>
|
|
<Icon name="close" className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
>
|
|
{dialogContent}
|
|
</MobileOverlayPanel>
|
|
) : (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
|
|
<DialogHeader className="flex flex-row items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<DialogTitle className="flex items-center gap-2 shrink-0">
|
|
<Icon name="github" className="h-5 w-5" />
|
|
{t('session.githubIntegration.title')}
|
|
</DialogTitle>
|
|
|
|
{/* Tabs - using SortableTabsStrip */}
|
|
<div className="w-[220px]">
|
|
<SortableTabsStrip
|
|
items={[
|
|
{ id: 'issues', label: t('session.githubIntegration.tabs.issues'), icon: <Icon name="git-branch" className="h-3.5 w-3.5" /> },
|
|
{ id: 'prs', label: t('session.githubIntegration.tabs.pullRequests'), icon: <Icon name="git-pull-request" className="h-3.5 w-3.5" /> },
|
|
]}
|
|
activeId={activeTab}
|
|
onSelect={(id) => {
|
|
setActiveTab(id as GitHubTab);
|
|
setSearchQuery('');
|
|
}}
|
|
variant="active-pill"
|
|
layoutMode="fit"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
{dialogContent}
|
|
|
|
{/* Footer */}
|
|
<DialogFooter className="mt-1">
|
|
{footerContent}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</>
|
|
);
|
|
}
|