diff --git a/apps/web/src/api/git_repositories.ts b/apps/web/src/api/git_repositories.ts index dc59090..864a30f 100644 --- a/apps/web/src/api/git_repositories.ts +++ b/apps/web/src/api/git_repositories.ts @@ -49,3 +49,61 @@ export async function createRepository( export async function deleteRepository(projectId: string, repoId: string): Promise { await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`); } + +export interface CommitHistoryEntry { + hash: string; + short_hash: string; + message: string; + author_name: string; + author_email: string; + author_date: string; + refs: string[]; + graph_symbol: string; + graph_depth: number; +} + +export interface CommitHistoryResponse { + commits: CommitHistoryEntry[]; + branches: string[]; + tags: string[]; +} + +export async function getRepositoryHistory( + projectId: string, + repoId: string, + branch?: string +): Promise { + const params = branch ? `?branch=${encodeURIComponent(branch)}` : ""; + const response = await apiClient.get(`/projects/${projectId}/repositories/${repoId}/history${params}`); + return response.data; +} + +export interface CommitDetail { + hash: string; + short_hash: string; + message: string; + author_name: string; + author_email: string; + author_date: string; + committer_name: string; + committer_email: string; + committer_date: string; + stats: { + additions: number; + deletions: number; + files_changed: number; + }; + diff: string; + parents: string[]; +} + +export async function getCommitDetail( + projectId: string, + repoId: string, + commitHash: string +): Promise { + const response = await apiClient.get( + `/projects/${projectId}/repositories/${repoId}/commits/${commitHash}` + ); + return response.data; +} diff --git a/apps/web/src/pages/git-history.tsx b/apps/web/src/pages/git-history.tsx new file mode 100644 index 0000000..503ca22 --- /dev/null +++ b/apps/web/src/pages/git-history.tsx @@ -0,0 +1,231 @@ +import { useCallback, useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry } from "../api/git_repositories"; + +export const GitHistoryPage = () => { + const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>(); + const navigate = useNavigate(); + const [commits, setCommits] = useState([]); + const [selectedCommit, setSelectedCommit] = useState(null); + const [commitDetail, setCommitDetail] = useState(null); + const [branches, setBranches] = useState([]); + const [selectedBranch, setSelectedBranch] = useState(""); + const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); + const [detailStatus, setDetailStatus] = useState<"idle" | "loading" | "ready" | "error">("idle"); + + const loadHistory = useCallback(async () => { + if (!projectId || !repoId) return; + setStatus("loading"); + try { + const data = await getRepositoryHistory(projectId, repoId, selectedBranch || undefined); + setCommits(data.commits); + setBranches(data.branches); + if (data.branches.length > 0 && !selectedBranch) { + setSelectedBranch(data.branches[0]); + } + setStatus("ready"); + } catch { + setStatus("error"); + } + }, [projectId, repoId, selectedBranch]); + + useEffect(() => { + void loadHistory(); + }, [loadHistory]); + + const handleCommitClick = async (hash: string) => { + if (!projectId || !repoId) return; + setSelectedCommit(hash); + setDetailStatus("loading"); + try { + const detail = await getCommitDetail(projectId, repoId, hash); + setCommitDetail(detail); + setDetailStatus("ready"); + } catch { + setDetailStatus("error"); + } + }; + + const handleCloseDetail = () => { + setSelectedCommit(null); + setCommitDetail(null); + setDetailStatus("idle"); + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleString(); + }; + + if (status === "loading") { + return ( +
+

Loading commit history...

+
+ ); + } + + if (status === "error") { + return ( +
+

Failed to load commit history

+ +
+ ); + } + + return ( +
+
+
+

Commit History

+

{commits.length} commits

+
+
+ {branches.length > 0 && ( + + )} + +
+
+ +
+
+ {commits.map((commit) => ( +
void handleCommitClick(commit.hash)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + void handleCommitClick(commit.hash); + } + }} + > +
+ + {commit.graph_symbol} + +
+
+
+ {commit.short_hash} + {commit.refs.length > 0 && ( + + {commit.refs.map((ref) => ( + + {ref} + + ))} + + )} +
+

{commit.message.split("\n")[0]}

+
+ {commit.author_name} + {formatDate(commit.author_date)} +
+
+
+ ))} +
+ + {selectedCommit && ( +
+
+

Commit Details

+ +
+ + {detailStatus === "loading" &&

Loading details...

} + + {detailStatus === "error" && ( +

Failed to load commit details

+ )} + + {detailStatus === "ready" && commitDetail && ( +
+
+

{commitDetail.hash}

+

{commitDetail.message}

+
+ +
+

Author

+

{commitDetail.author_name} <{commitDetail.author_email}>

+

{formatDate(commitDetail.author_date)}

+
+ +
+

Committer

+

{commitDetail.committer_name} <{commitDetail.committer_email}>

+

{formatDate(commitDetail.committer_date)}

+
+ +
+

Stats

+
+
+ {commitDetail.stats.files_changed} + Files +
+
+ +{commitDetail.stats.additions} + Additions +
+
+ -{commitDetail.stats.deletions} + Deletions +
+
+
+ + {commitDetail.parents.length > 0 && ( +
+

Parents

+
+ {commitDetail.parents.map((parent) => ( + + {parent.substring(0, 8)} + + ))} +
+
+ )} + + {commitDetail.diff && ( +
+

Diff

+
{commitDetail.diff}
+
+ )} +
+ )} +
+ )} +
+
+ ); +}; diff --git a/apps/web/src/pages/git-repositories.tsx b/apps/web/src/pages/git-repositories.tsx index 0e6091d..16720c4 100644 --- a/apps/web/src/pages/git-repositories.tsx +++ b/apps/web/src/pages/git-repositories.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { createRepository, @@ -16,6 +16,7 @@ type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | " export const GitRepositoriesPage = () => { const { projectId } = useParams<{ projectId: string }>(); + const navigate = useNavigate(); const [status, setStatus] = useState("loading"); const [repositories, setRepositories] = useState([]); const [showCreate, setShowCreate] = useState(false); @@ -188,6 +189,13 @@ export const GitRepositoriesPage = () => {

{repo.path}

+ {deleteConfirmId === repo.id ? (
Are you sure? diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index cb5f971..59ef873 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -7,6 +7,7 @@ import { LoginRedirectPage, NotFoundPage } from "./pages/placeholder"; import { ProfilePage } from "./pages/profile"; import { ProjectsPage } from "./pages/projects"; import { GitRepositoriesPage } from "./pages/git-repositories"; +import { GitHistoryPage } from "./pages/git-history"; import { SSHKeysPage } from "./pages/ssh-keys"; import { SettingsPage } from "./pages/settings"; import { ToolTypesPage } from "./pages/tool-types"; @@ -26,6 +27,7 @@ export const AppRouter = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 2b516d0..21cb41e 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -437,3 +437,260 @@ a { padding: 0.35rem 0.6rem; font-size: 0.85rem; } + +/* Git History Page Styles */ +.history-actions { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.branch-selector { + padding: 0.45rem 0.7rem; + border: 1px solid var(--border); + border-radius: 10px; + font: inherit; + background: var(--panel); + color: var(--ink); +} + +.history-container { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; + min-height: 60vh; +} + +.commit-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + overflow-y: auto; + max-height: 70vh; +} + +.commit-list.with-detail { + grid-column: 1; +} + +.commit-item { + display: flex; + gap: 0.75rem; + padding: 0.75rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.commit-item:hover { + background: #ece7df; +} + +.commit-item.selected { + border-color: var(--brand); + background: #f0f7f4; +} + +.commit-graph { + font-family: monospace; + font-size: 0.9rem; + color: var(--brand); + white-space: pre; + flex-shrink: 0; + min-width: 60px; +} + +.graph-line { + display: inline-block; +} + +.commit-content { + flex: 1; + min-width: 0; +} + +.commit-header { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.35rem; +} + +.commit-hash { + font-family: monospace; + font-size: 0.85rem; + color: var(--brand); + background: #f0f7f4; + padding: 0.15rem 0.4rem; + border-radius: 6px; +} + +.commit-refs { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} + +.ref-tag { + font-size: 0.75rem; + padding: 0.15rem 0.4rem; + background: var(--brand); + color: white; + border-radius: 999px; +} + +.commit-message { + margin: 0 0 0.35rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.commit-meta { + display: flex; + gap: 0.75rem; + font-size: 0.85rem; + color: var(--muted); +} + +.commit-detail-panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 14px; + padding: 1.25rem; + overflow-y: auto; + max-height: 70vh; +} + +.detail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border); +} + +.detail-header h3 { + margin: 0; +} + +.detail-content { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.detail-section { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.detail-section h4 { + margin: 0 0 0.5rem; + color: var(--muted); + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.commit-hash-full { + font-family: monospace; + font-size: 0.85rem; + color: var(--brand); + margin: 0; +} + +.commit-message-full { + margin: 0.5rem 0 0; + line-height: 1.5; + white-space: pre-wrap; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; +} + +.stat { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.75rem; + background: #f5f3ee; + border-radius: 10px; +} + +.stat.additions { + background: #f0fdf4; +} + +.stat.deletions { + background: #fef2f2; +} + +.stat-value { + font-size: 1.25rem; + font-weight: 700; + color: var(--ink); +} + +.stat.additions .stat-value { + color: #16a34a; +} + +.stat.deletions .stat-value { + color: #dc2626; +} + +.stat-label { + font-size: 0.8rem; + color: var(--muted); +} + +.parent-list { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.parent-hash { + font-family: monospace; + font-size: 0.85rem; + padding: 0.2rem 0.5rem; + background: #f5f3ee; + border-radius: 6px; +} + +.diff-content { + font-family: monospace; + font-size: 0.8rem; + line-height: 1.5; + background: #f5f3ee; + padding: 0.75rem; + border-radius: 10px; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; +} + +@media (min-width: 1024px) { + .history-container { + grid-template-columns: 1fr 400px; + } + + .commit-list.with-detail { + grid-column: 1; + } + + .commit-detail-panel { + grid-column: 2; + position: sticky; + top: 1rem; + } +}