From 9aa1024620572ad354f4411171735cffddda9d2d Mon Sep 17 00:00:00 2001 From: yinquanwang <19114012@bjtu.edu.cn> Date: Thu, 19 Mar 2026 23:00:16 +0800 Subject: [PATCH] feat(git): add remote repository info display - Add GitRemoteSection component to display remotes and remote branches - Add getRemotes() and fetchRemotes() methods to git service - Add /api/git/remotes and /api/git/fetch API endpoints - Add GitRemote type definition - Add i18n translations for remote section (en/zh) - Integrate Remote section in GitPanel between Status and Branch sections The new section shows: - Remote repository names and URLs - Remote branches list - Fetch button to update remote info --- src/app/api/git/fetch/route.ts | 21 +++ src/app/api/git/remotes/route.ts | 19 +++ src/components/git/GitPanel.tsx | 11 ++ src/components/git/GitRemoteSection.tsx | 168 ++++++++++++++++++++++++ src/i18n/en.ts | 6 + src/i18n/zh.ts | 6 + src/lib/git/service.ts | 27 +++- src/types/index.ts | 5 + 8 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 src/app/api/git/fetch/route.ts create mode 100644 src/app/api/git/remotes/route.ts create mode 100644 src/components/git/GitRemoteSection.tsx diff --git a/src/app/api/git/fetch/route.ts b/src/app/api/git/fetch/route.ts new file mode 100644 index 00000000..12536806 --- /dev/null +++ b/src/app/api/git/fetch/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server'; +import * as gitService from '@/lib/git/service'; + +export async function POST(req: NextRequest) { + const body = await req.json(); + const cwd = body.cwd; + + if (!cwd) { + return NextResponse.json({ error: 'cwd is required' }, { status: 400 }); + } + + try { + await gitService.fetchRemotes(cwd); + return NextResponse.json({ success: true }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to fetch remotes' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/git/remotes/route.ts b/src/app/api/git/remotes/route.ts new file mode 100644 index 00000000..141f0158 --- /dev/null +++ b/src/app/api/git/remotes/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server'; +import * as gitService from '@/lib/git/service'; + +export async function GET(req: NextRequest) { + const cwd = req.nextUrl.searchParams.get('cwd'); + if (!cwd) { + return NextResponse.json({ error: 'cwd is required' }, { status: 400 }); + } + + try { + const remotes = await gitService.getRemotes(cwd); + return NextResponse.json({ remotes }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to get remotes' }, + { status: 500 } + ); + } +} diff --git a/src/components/git/GitPanel.tsx b/src/components/git/GitPanel.tsx index c7b82d5b..41c30dd8 100644 --- a/src/components/git/GitPanel.tsx +++ b/src/components/git/GitPanel.tsx @@ -9,6 +9,7 @@ import { GitStatusSection } from "./GitStatusSection"; import { GitBranchSelector } from "./GitBranchSelector"; import { GitHistorySection } from "./GitHistorySection"; import { GitWorktreeSection } from "./GitWorktreeSection"; +import { GitRemoteSection } from "./GitRemoteSection"; import { GitCommitDetailDialog } from "./GitCommitDetailDialog"; import { DeriveWorktreeDialog } from "./DeriveWorktreeDialog"; @@ -19,6 +20,7 @@ export function GitPanel() { // Collapsible sections const [statusOpen, setStatusOpen] = useState(true); + const [remoteOpen, setRemoteOpen] = useState(false); const [branchOpen, setBranchOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [worktreeOpen, setWorktreeOpen] = useState(false); @@ -61,6 +63,15 @@ export function GitPanel() { + {/* Remote section */} + setRemoteOpen(!remoteOpen)} + > + + + {/* Branch section */} ([]); + const [branches, setBranches] = useState([]); + const [loading, setLoading] = useState(true); + const [fetching, setFetching] = useState(false); + + const loadData = useCallback(async () => { + if (!cwd) return; + setLoading(true); + try { + const [remotesRes, branchesRes] = await Promise.all([ + fetch(`/api/git/remotes?cwd=${encodeURIComponent(cwd)}`), + fetch(`/api/git/branches?cwd=${encodeURIComponent(cwd)}`), + ]); + const remotesData = await remotesRes.json(); + const branchesData = await branchesRes.json(); + setRemotes(remotesData.remotes || []); + setBranches(branchesData.branches || []); + } catch { + // ignore + } finally { + setLoading(false); + } + }, [cwd]); + + useEffect(() => { + loadData(); + }, [loadData]); + + // Listen for git-refresh events + useEffect(() => { + const handleRefresh = () => loadData(); + window.addEventListener('git-refresh', handleRefresh); + return () => window.removeEventListener('git-refresh', handleRefresh); + }, [loadData]); + + const handleFetch = async () => { + if (!cwd || fetching) return; + setFetching(true); + try { + const res = await fetch('/api/git/fetch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cwd }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({ error: 'Fetch failed' })); + showToast({ type: 'error', message: data.error || 'Fetch failed' }); + return; + } + showToast({ type: 'success', message: t('git.fetchSuccess') }); + await loadData(); + } catch (err) { + showToast({ type: 'error', message: err instanceof Error ? err.message : 'Fetch failed' }); + } finally { + setFetching(false); + } + }; + + const remoteBranches = branches.filter(b => b.isRemote); + + // Sort remote branches: current remote first, then alphabetically + const sortedRemoteBranches = [...remoteBranches].sort((a, b) => { + return a.name.localeCompare(b.name); + }); + + if (loading) { + return ( +
+ {t('git.loading')} +
+ ); + } + + if (remotes.length === 0) { + return ( +
+ {t('git.noRemotes')} +
+ ); + } + + return ( +
+ {/* Remotes list */} +
+ {remotes.map(remote => ( +
+
+ + {remote.name} +
+
+ {formatUrl(remote.url)} +
+
+ ))} +
+ + {/* Remote branches */} + {sortedRemoteBranches.length > 0 && ( +
+
+ + {t('git.remoteBranches')} +
+
+ {sortedRemoteBranches.map(branch => ( +
+ {branch.name} +
+ ))} +
+
+ )} + + {/* Fetch button */} +
+ +
+
+ ); +} + +function formatUrl(url: string): string { + // Strip credentials from URLs for display + // git@github.com:user/repo.git -> github.com/user/repo + // https://user:pass@github.com/user/repo.git -> github.com/user/repo + try { + // SSH format: git@github.com:user/repo.git + const sshMatch = url.match(/^git@([^:]+):(.+?)(\.git)?$/); + if (sshMatch) { + return `${sshMatch[1]}/${sshMatch[2]}`; + } + + // HTTPS format + const parsed = new URL(url); + return `${parsed.hostname}${parsed.pathname.replace(/\.git$/, '')}`; + } catch { + return url; + } +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 479d50f5..b10c9094 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1027,6 +1027,12 @@ const en = { 'git.branchSection': 'Branches', 'git.historySection': 'History', 'git.worktreeSection': 'Worktrees', + 'git.remoteSection': 'Remotes', + 'git.remoteBranches': 'Remote Branches', + 'git.noRemotes': 'No remotes configured', + 'git.fetch': 'Fetch', + 'git.fetching': 'Fetching...', + 'git.fetchSuccess': 'Fetched successfully', 'git.commitSection': 'Commit', 'git.staged': 'Staged', 'git.unstaged': 'Unstaged', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index c239873d..548aa968 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -1024,6 +1024,12 @@ const zh: Record = { 'git.branchSection': '分支', 'git.historySection': '历史', 'git.worktreeSection': '工作树', + 'git.remoteSection': '远程仓库', + 'git.remoteBranches': '远程分支', + 'git.noRemotes': '未配置远程仓库', + 'git.fetch': '获取', + 'git.fetching': '获取中...', + 'git.fetchSuccess': '获取成功', 'git.commitSection': '提交', 'git.staged': '已暂存', 'git.unstaged': '未暂存', diff --git a/src/lib/git/service.ts b/src/lib/git/service.ts index e367b5ca..0b64989f 100644 --- a/src/lib/git/service.ts +++ b/src/lib/git/service.ts @@ -1,6 +1,6 @@ import { execFile } from 'child_process'; import path from 'path'; -import type { GitStatus, GitChangedFile, GitBranch, GitLogEntry, GitCommitDetail, GitWorktree } from '@/types'; +import type { GitStatus, GitChangedFile, GitBranch, GitLogEntry, GitCommitDetail, GitWorktree, GitRemote } from '@/types'; function runGit(args: string[], opts: { cwd: string; timeoutMs?: number }): Promise { if (!path.isAbsolute(opts.cwd)) { @@ -383,3 +383,28 @@ export async function deriveWorktree(cwd: string, branch: string, targetPath: st return targetPath; } + +export async function getRemotes(cwd: string): Promise { + const output = await runGit(['remote', '-v'], { cwd }); + const remotes: GitRemote[] = []; + const seen = new Set(); + + for (const line of output.split('\n')) { + if (!line.trim()) continue; + // Format: "origin git@github.com:user/repo.git (fetch)" or "origin https://github.com/user/repo.git (push)" + const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/); + if (match && !seen.has(match[1])) { + seen.add(match[1]); + remotes.push({ + name: match[1], + url: match[2], + }); + } + } + + return remotes; +} + +export async function fetchRemotes(cwd: string): Promise { + await runGit(['fetch', '--all'], { cwd, timeoutMs: 60000 }); +} diff --git a/src/types/index.ts b/src/types/index.ts index 424f08ff..d0f88e64 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1080,3 +1080,8 @@ export interface GitWorktree { bare: boolean; dirty: boolean; } + +export interface GitRemote { + name: string; + url: string; +}