Compare commits

...

2 Commits

Author SHA1 Message Date
hyung-hwan ddccd560a9 refactored the rpm detail page 2026-06-25 01:03:24 +09:00
hyung-hwan dabb5d7216 some code clean up 2026-06-25 00:48:48 +09:00
11 changed files with 320 additions and 274 deletions
+58 -51
View File
@@ -617,12 +617,49 @@ func ListCommitsBetween(repoPath, baseRef, headRef string, limit int) ([]Commit,
return commits, nil
}
// resolveSubtree returns the tree at path within commit (the commit's root tree
// when path is empty) along with the corresponding path prefix ("" or "path/").
// It returns ErrPathNotFound when path is missing or is not a directory. The
// caller owns the returned tree and must Free it.
func resolveSubtree(repo *git2go.Repository, commit *git2go.Commit, path string) (*git2go.Tree, string, error) {
var tree *git2go.Tree
var subEntry *git2go.TreeEntry
var sub *git2go.Tree
var err error
tree, err = commit.Tree()
if err != nil {
return nil, "", err
}
if path == "" {
return tree, "", nil
}
subEntry, err = tree.EntryByPath(path)
if err != nil {
tree.Free()
if git2go.IsErrorCode(err, git2go.ErrorCodeNotFound) {
return nil, "", ErrPathNotFound
}
return nil, "", err
}
if subEntry.Type != git2go.ObjectTree {
tree.Free()
return nil, "", ErrPathNotFound
}
sub, err = repo.LookupTree(subEntry.Id)
tree.Free()
if err != nil {
return nil, "", err
}
return sub, path + "/", nil
}
func ListTree(repoPath, ref, path string) ([]TreeEntry, error) {
var repo *git2go.Repository
var err error
var commit *git2go.Commit
var tree *git2go.Tree
var subEntry *git2go.TreeEntry
var prefix string
var entries []TreeEntry
var i uint64
var count uint64
@@ -642,49 +679,25 @@ func ListTree(repoPath, ref, path string) ([]TreeEntry, error) {
}
defer commit.Free()
tree, err = commit.Tree()
tree, prefix, err = resolveSubtree(repo, commit, path)
if err != nil {
return nil, err
}
defer tree.Free()
if path != "" {
subEntry, err = tree.EntryByPath(path)
if err != nil {
if git2go.IsErrorCode(err, git2go.ErrorCodeNotFound) {
return nil, ErrPathNotFound
}
return nil, err
}
if subEntry.Type != git2go.ObjectTree {
return nil, ErrPathNotFound
}
tree.Free()
tree, err = repo.LookupTree(subEntry.Id)
if err != nil {
return nil, err
}
defer tree.Free()
}
count = tree.EntryCount()
entries = make([]TreeEntry, 0, count)
for i = 0; i < count; i++ {
var entry *git2go.TreeEntry
var typeName string
var entryPath string
entry = tree.EntryByIndex(i)
typeName = "file"
if entry.Type == git2go.ObjectTree {
typeName = "dir"
}
entryPath = entry.Name
if path != "" {
entryPath = path + "/" + entry.Name
}
entries = append(entries, TreeEntry{
Name: entry.Name,
Path: entryPath,
Path: prefix + entry.Name,
Type: typeName,
})
}
@@ -707,7 +720,6 @@ func ListTreeWithCommits(repoPath, ref, path string) ([]TreeEntryCommit, error)
var err error
var commit *git2go.Commit
var tree *git2go.Tree
var subEntry *git2go.TreeEntry
var entries []TreeEntryCommit
var index map[string]int
var unresolved map[string]bool
@@ -734,32 +746,12 @@ func ListTreeWithCommits(repoPath, ref, path string) ([]TreeEntryCommit, error)
}
defer commit.Free()
tree, err = commit.Tree()
tree, prefix, err = resolveSubtree(repo, commit, path)
if err != nil {
return nil, err
}
defer tree.Free()
if path != "" {
subEntry, err = tree.EntryByPath(path)
if err != nil {
if git2go.IsErrorCode(err, git2go.ErrorCodeNotFound) {
return nil, ErrPathNotFound
}
return nil, err
}
if subEntry.Type != git2go.ObjectTree {
return nil, ErrPathNotFound
}
tree.Free()
tree, err = repo.LookupTree(subEntry.Id)
if err != nil {
return nil, err
}
defer tree.Free()
prefix = path + "/"
}
count = tree.EntryCount()
entries = make([]TreeEntryCommit, 0, count)
index = make(map[string]int, count)
@@ -796,6 +788,11 @@ func ListTreeWithCommits(repoPath, ref, path string) ([]TreeEntryCommit, error)
}
defer walk.Free()
walk.Sorting(git2go.SortTime | git2go.SortTopological)
// Attribution diffs each commit against its first parent, so the walk must
// follow first parents too — otherwise side-branch commits get visited and a
// merge (or an out-of-line commit) can be credited instead of the real
// authoring commit, and the cap is consumed by unrelated branches.
walk.SimplifyFirstParent()
err = walk.Push(commit.Id())
if err != nil {
return nil, err
@@ -845,10 +842,20 @@ func assignTreeCommit(repo *git2go.Repository, c *git2go.Commit, prefix string,
defer cTree.Free()
if c.ParentCount() == 0 {
// Root commit: attribute every remaining entry that exists here.
// Root commit: attribute every remaining entry that actually exists in
// this tree (it was introduced at the root). Entries absent here are left
// unresolved rather than falsely stamped with the root commit.
var key string
var keys []string
for key = range unresolved {
markTreeCommit(c, key, index, unresolved, entries)
keys = append(keys, key)
}
for _, key = range keys {
var entry *git2go.TreeEntry
entry, err = cTree.EntryByPath(key)
if err == nil && entry != nil {
markTreeCommit(c, key, index, unresolved, entries)
}
}
return nil
}
+24
View File
@@ -0,0 +1,24 @@
// copyText copies text to the clipboard, falling back to a hidden textarea +
// execCommand for browsers/contexts where the async Clipboard API is missing or
// blocked (e.g. non-secure origins).
export async function copyText(text: string): Promise<void> {
if (!text) return
try {
await navigator.clipboard.writeText(text)
return
} catch {
// fall back below
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
} finally {
document.body.removeChild(textarea)
}
}
+4 -9
View File
@@ -6,6 +6,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import { formatCommitTime } from '../time'
import { copyText } from '../clipboard'
// splitCommitMessage separates a git commit message into its subject (first
// line) and body (everything after the first blank-separated line).
@@ -18,12 +19,6 @@ export function splitCommitMessage(message: string | null | undefined): { subjec
return { subject: text.slice(0, newline).trim(), body: text.slice(newline + 1).replace(/^\n+/, '').trimEnd() }
}
function copyToClipboard(text: string): void {
if (!text) return
if (navigator.clipboard && 'writeText' in navigator.clipboard) {
navigator.clipboard.writeText(text).catch(() => {})
}
}
type CommitIdentityProps = {
author?: string
@@ -69,7 +64,7 @@ export function CommitIdentity(props: CommitIdentityProps) {
// No link target (e.g. the commit's own page): make the chip copy the hash.
hashNode = (
<Tooltip title="Copy commit id">
<Chip label={short} size="small" clickable onClick={() => copyToClipboard(hash)} sx={{ fontFamily: 'monospace' }} />
<Chip label={short} size="small" clickable onClick={() => copyText(hash)} sx={{ fontFamily: 'monospace' }} />
</Tooltip>
)
} else if (hashHref) {
@@ -102,7 +97,7 @@ export function CommitIdentity(props: CommitIdentityProps) {
{email && !showEmail ? <Tooltip title={email}>{authorNode}</Tooltip> : authorNode}
{copy && email ? (
<Tooltip title="Copy email">
<IconButton size="small" onClick={() => copyToClipboard(email)} aria-label="Copy email">
<IconButton size="small" onClick={() => copyText(email)} aria-label="Copy email">
<ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
@@ -110,7 +105,7 @@ export function CommitIdentity(props: CommitIdentityProps) {
{hashNode}
{copyHash && hash && !(hashChip && !hashHref) ? (
<Tooltip title="Copy commit id">
<IconButton size="small" onClick={() => copyToClipboard(hash)} aria-label="Copy commit id">
<IconButton size="small" onClick={() => copyText(hash)} aria-label="Copy commit id">
<ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
+6 -4
View File
@@ -82,6 +82,10 @@ export default function GoToFile({ repoId, refName, onSelect }: GoToFileProps) {
ensureLoaded()
setOpen(true)
}
// Keep the latest open handler in a ref so the global key listener can be
// registered once yet always call the current closure (no stale state).
const handleOpenRef = useRef(handleOpen)
handleOpenRef.current = handleOpen
// "t" opens the finder (when not already typing in a field), like Gitea.
useEffect(() => {
@@ -91,12 +95,11 @@ export default function GoToFile({ repoId, refName, onSelect }: GoToFileProps) {
const tag = el ? el.tagName.toLowerCase() : ''
if (tag === 'input' || tag === 'textarea' || (el && el.isContentEditable)) return
event.preventDefault()
handleOpen()
handleOpenRef.current()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [repoId, refName, loadedRef, loading])
}, [])
const scored: Scored[] = useMemo(() => {
const q = query.trim()
@@ -167,7 +170,6 @@ export default function GoToFile({ repoId, refName, onSelect }: GoToFileProps) {
inputRef={inputRef}
size="small"
fullWidth
autoFocus
placeholder="Go to file…"
value={query}
onChange={(event) => setQuery(event.target.value)}
+1 -1
View File
@@ -155,7 +155,7 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
sx={navItemSx(active)}
>
{item.icon}
<Typography variant="body2" sx={{ fontWeight: active ? 400 : 300, lineHeight: 1.2 }}>
<Typography variant="body2" sx={{ fontWeight: active ? 450 : 400, lineHeight: 1.2 }}>
{item.label}
</Typography>
</Box>
+2 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { api, BranchInfo, Project, Repo, RepoBranchesInfo } from '../api'
import { splitCommitMessage } from '../components/CommitMeta'
import { formatCommitTime } from '../time'
import ContextToolbar from '../components/ContextToolbar'
import ProjectPageFrame from '../components/ProjectPageFrame'
import RepoSubNav from '../components/RepoSubNav'
@@ -236,7 +237,7 @@ export default function RepoGitBranchesPage() {
getValue: (branch: BranchInfo) => branch.last_when || '',
renderCell: (branch: BranchInfo) => (
<Typography variant="body2" color="text.secondary" noWrap>
{branch.last_when || '-'}
{branch.last_when ? formatCommitTime(branch.last_when) : '-'}
</Typography>
)
},
+20 -23
View File
@@ -2,6 +2,7 @@ import { Box, Chip, Collapse, Divider, IconButton, List, ListItem, ListItemButto
import { type ReactNode, useEffect, useState } from 'react'
import { Link as RouterLink, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { api, Project, Repo, RepoCommitDetail } from '../api'
import { commitDetailPath, repoCodePath } from '../repoPaths'
import { CommitIdentity, CommitMessage } from '../components/CommitMeta'
import ContextToolbar from '../components/ContextToolbar'
import ProjectPageFrame from '../components/ProjectPageFrame'
@@ -106,8 +107,19 @@ export default function RepoGitCommitDetailPage() {
}
for (i = 0; i < blocks.length; i += 1) {
const block = blocks[i]
const header = block.split('\n', 1)[0] || ''
if (header.includes(` a/${file} `) || header.endsWith(` a/${file}`) || header.includes(` b/${file} `) || header.endsWith(` b/${file}`)) {
const blockLines = block.split('\n')
const header = blockLines[0] || ''
// Match on exact tokens rather than a substring of the header, so a
// file name that is a prefix of another (e.g. "foo" vs "foo bar")
// can't false-match, and same-path add/delete (header `a/x b/x`) is
// recognised. The +++/--- and rename lines cover modify/add/rename.
if (
header === `diff --git a/${file} b/${file}` ||
blockLines.includes(`+++ b/${file}`) ||
blockLines.includes(`--- a/${file}`) ||
blockLines.includes(`rename to ${file}`) ||
blockLines.includes(`rename from ${file}`)
) {
return block
}
}
@@ -387,29 +399,14 @@ export default function RepoGitCommitDetailPage() {
return <ChangeCircleOutlinedIcon fontSize="small" color="info" />
}
const fileCodePath = (filePath: string): string => {
var params: URLSearchParams
var ref: string
const fileCodePath = (filePath: string): string =>
filePath ? repoCodePath(projectId, repoId, { ref: hash || searchParams.get('ref') || '', path: filePath }) : '#'
if (!projectId || !repoId || !filePath) return '#'
ref = hash || searchParams.get('ref') || ''
params = new URLSearchParams()
if (ref) params.set('ref', ref)
params.set('path', filePath)
return `/projects/${projectId}/repos/${repoId}?${params.toString()}`
}
const commitPath = (commitHash: string): string =>
commitDetailPath(projectId, repoId, commitHash, searchParams.get('ref') || '')
const commitPath = (commitHash: string): string => {
if (!projectId || !repoId || !commitHash) return '#'
const ref = searchParams.get('ref') || ''
const query = ref ? `?ref=${encodeURIComponent(ref)}` : ''
return `/projects/${projectId}/repos/${repoId}/commits/${commitHash}${query}`
}
const codeViewPath = (commitHash: string): string => {
if (!projectId || !repoId || !commitHash) return '#'
return `/projects/${projectId}/repos/${repoId}?ref=${encodeURIComponent(commitHash)}`
}
const codeViewPath = (commitHash: string): string =>
commitHash ? repoCodePath(projectId, repoId, { ref: commitHash }) : '#'
const toggleFileDiffCollapsed = (filePath: string): void => {
setCollapsedDiffs((prev: Record<string, boolean>) => ({ ...prev, [filePath]: !prev[filePath] }))
+13 -44
View File
@@ -2,6 +2,8 @@ import { Box, Button, Divider, IconButton, List, ListItem, ListItemText, MenuIte
import { useEffect, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { api, Project, Repo, RepoCommit } from '../api'
import { commitDetailPath, repoCodePath } from '../repoPaths'
import { copyText } from '../clipboard'
import { CommitIdentity, splitCommitMessage } from '../components/CommitMeta'
import AccountTreeIcon from '@mui/icons-material/AccountTree'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
@@ -115,11 +117,14 @@ export default function RepoGitCommitsPage() {
cancelled = true
}
}
api.listRepoCommits(repoId, ref || undefined, pageSize, offset, query)
// Over-fetch one extra row so a full page doesn't falsely advertise a
// next page (which would yield an empty page on click).
api.listRepoCommits(repoId, ref || undefined, pageSize + 1, offset, query)
.then((list) => {
if (cancelled) return
setCommits(Array.isArray(list) ? list : [])
setHasNextPage(Array.isArray(list) && list.length >= pageSize)
const data: RepoCommit[] = Array.isArray(list) ? list : []
setCommits(data.slice(0, pageSize))
setHasNextPage(data.length > pageSize)
})
.catch(() => {
if (cancelled) return
@@ -161,47 +166,11 @@ export default function RepoGitCommitsPage() {
setPage((value: number) => value + 1)
}
const copyText = async (text: string) => {
let textarea: HTMLTextAreaElement
try {
await navigator.clipboard.writeText(text)
return
} catch {
// fallback below
}
textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
} finally {
document.body.removeChild(textarea)
}
}
const commitDetailPath = (commit: RepoCommit): string => {
if (!projectId || !repoId) return '#'
return `/projects/${projectId}/repos/${repoId}/commits/${commit.hash}${ref ? `?ref=${encodeURIComponent(ref)}` : ''}`
}
const repoAtCommitPath = (commit: RepoCommit): string => {
if (!projectId || !repoId) return '#'
return `/projects/${projectId}/repos/${repoId}?ref=${encodeURIComponent(commit.hash)}`
}
const fileAtCommitPath = (commit: RepoCommit): string => {
const params: URLSearchParams = new URLSearchParams()
if (!projectId || !repoId || !filePath) return '#'
params.set('ref', commit.hash)
params.set('path', filePath)
return `/projects/${projectId}/repos/${repoId}?${params.toString()}`
}
const commitDetailLink = (commit: RepoCommit): string => commitDetailPath(projectId, repoId, commit.hash, ref)
const repoAtCommitPath = (commit: RepoCommit): string => repoCodePath(projectId, repoId, { ref: commit.hash })
const fileAtCommitPath = (commit: RepoCommit): string =>
filePath ? repoCodePath(projectId, repoId, { ref: commit.hash, path: filePath }) : '#'
const commitColumns: RSTColumn<RepoCommit>[] = [
{
@@ -213,7 +182,7 @@ export default function RepoGitCommitsPage() {
renderCell: (commit: RepoCommit) => (
<Typography
component={Link}
to={commitDetailPath(commit)}
to={commitDetailLink(commit)}
color="primary"
sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textDecoration: 'none' }}
>
+59 -94
View File
@@ -4,6 +4,8 @@ import { Link as RouterLink, useParams, useSearchParams } from 'react-router-dom
import { api, Project, Repo, RepoCommit, RepoTreeEntry, RepoTreeEntryCommit } from '../api'
import { CommitIdentity, CommitMessage, splitCommitMessage } from '../components/CommitMeta'
import { formatCommitTime } from '../time'
import { commitDetailPath } from '../repoPaths'
import { copyText } from '../clipboard'
import Autocomplete from '../components/Autocomplete'
import AccountTreeIcon from '@mui/icons-material/AccountTree'
import CodeIcon from '@mui/icons-material/Code'
@@ -27,6 +29,17 @@ import TintedPanel from '../components/TintedPanel'
const RepoMarkdown = lazy(() => import('../components/RepoMarkdown'))
// Shared sx for the transparent outlined panels used throughout the right pane.
const outlinedPanelSx = { minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }
// sortTreeEntries orders directory entries with directories first, then by name.
function sortTreeEntries<T extends { type: string; name: string }>(entries: T[]): T[] {
return [...entries].sort((a, b) => {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
return a.name.localeCompare(b.name)
})
}
type RepoGitDetailPageProps = {
initialRepo?: Repo
}
@@ -161,14 +174,16 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
useEffect(() => {
if (ref) {
// Only rewrite the URL when ref actually differs, so we don't churn
// searchParams (which would re-run the branches effect that depends on it).
if (ref && searchParams.get('ref') !== ref) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev)
next.set('ref', ref)
return next
}, { replace: true })
}
}, [ref, setSearchParams])
}, [ref, searchParams, setSearchParams])
useEffect(() => {
if (!repoId) return
@@ -221,7 +236,9 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
setDirLoading(false)
})
return () => { cancelled = true }
}, [repoId, ref, path, branches, treeReloadTick, selectedFile, repo])
// Depend on repo.type (not the repo object) so a re-fetched repo with the
// same type doesn't trigger a redundant listing reload.
}, [repoId, ref, path, branches, treeReloadTick, selectedFile, repo?.type])
const clearReadme = () => {
setReadmeContent('')
@@ -336,28 +353,9 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
setSelectedCommit(null)
}, [repoId, ref, urlRef, urlDirPath, urlFilePath])
const handleOpen = async (entry: RepoTreeEntry) => {
if (!repoId) return
if (entry.type === 'dir') {
setPath(entry.path)
setPathSegments(entry.path.split('/').filter(Boolean))
setSelectedFile('')
setContent('')
setFileQuery('')
setIsImageFileSelected(false)
setImagePreviewRef('')
setPreviewTab('content')
setSelectedCommit(null)
return
}
await openFilePath(entry.path)
}
const handleBack = () => {
if (!path) return
const nextSegments = pathSegments.slice(0, -1)
setPath(nextSegments.join('/'))
setPathSegments(nextSegments)
// clearFileSelection returns the right pane to the directory listing,
// discarding any open file/diff/history state.
const clearFileSelection = () => {
setSelectedFile('')
setContent('')
setFileQuery('')
@@ -368,19 +366,31 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
setSelectedCommit(null)
}
const handleOpen = async (entry: RepoTreeEntry) => {
if (!repoId) return
if (entry.type === 'dir') {
setPath(entry.path)
setPathSegments(entry.path.split('/').filter(Boolean))
clearFileSelection()
return
}
await openFilePath(entry.path)
}
const handleBack = () => {
if (!path) return
const nextSegments = pathSegments.slice(0, -1)
setPath(nextSegments.join('/'))
setPathSegments(nextSegments)
clearFileSelection()
}
const handleBreadcrumb = (nextPath: string) => {
if (nextPath === path) {
// Already in this directory. If a file is open, clicking the current
// directory returns to its listing; otherwise just reload the tree.
if (selectedFile) {
setSelectedFile('')
setContent('')
setFileQuery('')
setIsImageFileSelected(false)
setImagePreviewRef('')
setPreviewTab('content')
setDiff('')
setSelectedCommit(null)
clearFileSelection()
return
}
setTreeReloadTick((prev) => prev + 1)
@@ -392,14 +402,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
} else {
setPathSegments(nextPath.split('/').filter(Boolean))
}
setSelectedFile('')
setContent('')
setFileQuery('')
setIsImageFileSelected(false)
setImagePreviewRef('')
setPreviewTab('content')
setDiff('')
setSelectedCommit(null)
clearFileSelection()
}
const pathParts = pathSegments
@@ -503,35 +506,9 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
setDiffRange({ from: '', to: '' })
})
return () => { cancelled = true }
}, [previewTab, repoId, selectedFile, selectedCommit, ref])
const copyText = async (text: string) => {
let ok = false
try {
await navigator.clipboard.writeText(text)
return
} catch {
// fallback below
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.top = '-1000px'
textarea.style.left = '-1000px'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
try {
ok = document.execCommand('copy')
} finally {
document.body.removeChild(textarea)
}
if (!ok) {
window.prompt('Copy URL', text)
}
}
// Depend on the commit hash, not the selectedCommit object, to avoid an
// extra diff fetch when the object identity changes but the hash doesn't.
}, [previewTab, repoId, selectedFile, selectedCommit?.hash, ref])
const buildCloneURL = (path: string) => {
if (!path) return ''
@@ -606,13 +583,8 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
return `/api/repos/${repoId}/blob/raw?${params.toString()}`
}
const commitDetailPath = (hash: string): string => {
var queryRef: string
if (!projectId || !repoId || !hash) return '#'
queryRef = imagePreviewRef || ref
return `/projects/${projectId}/repos/${repoId}/commits/${hash}${queryRef ? `?ref=${encodeURIComponent(queryRef)}` : ''}`
}
const commitDetailLink = (hash: string): string =>
commitDetailPath(projectId, repoId, hash, imagePreviewRef || ref)
const fileHistoryPath = (filePath: string): string => {
var params: URLSearchParams
@@ -773,12 +745,9 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
}
const renderDirectoryListing = () => {
const sorted = [...dirEntries].sort((a, b) => {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
return a.name.localeCompare(b.name)
})
const sorted = sortTreeEntries(dirEntries)
return (
<Paper variant="outlined" sx={{ mt: 1, minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }}>
<Paper variant="outlined" sx={{ mt: 1, ...outlinedPanelSx }}>
<List dense disablePadding>
{path ? (
<ListItem disablePadding divider>
@@ -902,11 +871,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
</ListItemButton>
</ListItem>
) : null}
{[...filteredTree]
.sort((a, b) => {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
return a.name.localeCompare(b.name)
})
{sortTreeEntries(filteredTree)
.map((entry) => (
<ListItem key={entry.path} disablePadding>
<ListItemButton onClick={() => handleOpen(entry)} sx={{ py: 0.4 }}>
@@ -946,7 +911,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
email={selectedCommit?.email}
when={selectedCommit?.when}
hash={selectedCommit?.hash}
hashHref={selectedCommit?.hash ? commitDetailPath(selectedCommit.hash) : undefined}
hashHref={selectedCommit?.hash ? commitDetailLink(selectedCommit.hash) : undefined}
hashChip
copyHash
/>
@@ -1035,7 +1000,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
</Box>
{previewTab === 'content' ? (
isImageFileSelected ? (
<Paper variant="outlined" sx={{ p: 1, mt: 1, minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }}>
<Paper variant="outlined" sx={{ p: 1, mt: 1, ...outlinedPanelSx }}>
<Box
component="img"
src={buildBlobRawUrl(selectedFile, imagePreviewRef || ref)}
@@ -1044,7 +1009,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
/>
</Paper>
) : (
<Paper variant="outlined" sx={{ p: 1, pr: 0.5, mt: 1, minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }}>
<Paper variant="outlined" sx={{ p: 1, pr: 0.5, mt: 1, ...outlinedPanelSx }}>
<LazyCodeBlock code={content} language={detectLanguage(selectedFile)} showLineNumbers />
</Paper>
)
@@ -1060,7 +1025,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
<Tooltip title={diffRange.from}>
<Chip
component={RouterLink}
to={commitDetailPath(diffRange.from)}
to={commitDetailLink(diffRange.from)}
label={diffRange.from.slice(0, 8)}
size="small"
clickable
@@ -1073,7 +1038,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
Initial version (no parent to compare against)
</Typography>
)}
<Paper variant="outlined" sx={{ p: 1, pr: 0.5, mt: 0.5, minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }}>
<Paper variant="outlined" sx={{ p: 1, pr: 0.5, mt: 0.5, ...outlinedPanelSx }}>
<LazyCodeBlock code={diff} language="diff" />
</Paper>
</>
@@ -1089,7 +1054,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
{renderDirectoryListing()}
{readmeContent ? (
readmeKind === 'markdown' ? (
<Paper variant="outlined" sx={{ p: 1, mt: 1, minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }}>
<Paper variant="outlined" sx={{ p: 1, mt: 1, ...outlinedPanelSx }}>
<Box>
<Suspense fallback={<Typography variant="body2" color="text.secondary">Loading README...</Typography>}>
<RepoMarkdown
@@ -1101,7 +1066,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
</Box>
</Paper>
) : (
<Paper variant="outlined" sx={{ p: 1, mt: 1, minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }}>
<Paper variant="outlined" sx={{ p: 1, mt: 1, ...outlinedPanelSx }}>
<LazyCodeBlock code={readmeContent} language="text" showLineNumbers />
</Paper>
)
+116 -47
View File
@@ -26,8 +26,10 @@ import PageAlert from '../components/PageAlert'
import SplitPanel from '../components/SplitPanel'
import { api, Project, Repo, RpmMirrorRun, RpmPackageDetail, RpmPackageSummary, RpmSubdirCreatePayload, RpmSubdirUpdatePayload, RpmTreeEntry } from '../api'
import { formatEpochTime } from '../time'
import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder'
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
import FileUploadIcon from '@mui/icons-material/FileUpload'
import FolderIcon from '@mui/icons-material/Folder'
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'
@@ -38,6 +40,7 @@ import ContextToolbar from '../components/ContextToolbar'
import ProjectPageFrame from '../components/ProjectPageFrame'
import RepoSubNav from '../components/RepoSubNav'
import LazyCodeBlock from '../components/LazyCodeBlock'
import PillNavButton from '../components/PillNavButton'
import RepoRpmDirectoryEditDialog from '../components/RepoRpmDirectoryEditDialog'
import RepoRpmDirectoryStatusDialog from '../components/RepoRpmDirectoryStatusDialog'
import SelectField from '../components/SelectField'
@@ -604,6 +607,13 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
const handleRpmBreadcrumb = (nextPath: string) => {
if (nextPath === rpmPath) {
// Already in this directory. If a file is open, clicking the current
// directory returns to its listing; otherwise just reload the tree.
if (rpmSelectedEntry) {
setRpmFileQuery('')
setRpmSelectedEntry(null)
return
}
setRpmTreeReloadTick((prev) => prev + 1)
return
}
@@ -815,6 +825,61 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
? rpmTree.filter((entry) => matchesFileQuery(entry.name, normalizedQuery))
: rpmTree
const renderRpmDirectoryListing = () => {
const sorted = [...rpmTree].sort((a, b) => {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
return a.name.localeCompare(b.name)
})
return (
<Paper variant="outlined" sx={{ mt: 1, minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }}>
<List dense disablePadding>
{rpmPath ? (
<ListItem disablePadding divider>
<ListItemButton onClick={handleRpmBack}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FolderIcon fontSize="small" color="info" />
<Typography variant="body2">..</Typography>
</Box>
</ListItemButton>
</ListItem>
) : null}
{sorted.length === 0 ? (
<ListItem>
<Typography variant="body2" color="text.secondary">This directory is empty.</Typography>
</ListItem>
) : null}
{sorted.map((entry) => (
<ListItem key={entry.path} disablePadding divider>
<ListItemButton onClick={() => handleRpmEntry(entry)}>
<Box sx={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', alignItems: 'center', gap: 2, width: '100%', minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
{entry.type === 'dir' ? (
<FolderIcon fontSize="small" color="info" />
) : (
<InsertDriveFileIcon fontSize="small" color="info" />
)}
<Typography variant="body2" noWrap>{entry.name}</Typography>
{entry.type === 'dir' && entry.is_repo_dir ? (
<Chip
size="small"
color={entry.repo_mode === 'mirror' ? 'warning' : 'default'}
label={entry.repo_mode === 'mirror' ? 'mirror' : 'local'}
sx={{ height: 18 }}
/>
) : null}
</Box>
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
{entry.type === 'dir' ? '' : `${entry.size} bytes`}
</Typography>
</Box>
</ListItemButton>
</ListItem>
))}
</List>
</Paper>
)
}
return (
<ProjectPageFrame
projectId={projectId ?? ''}
@@ -839,8 +904,8 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1 }}>
{canWrite ? (
<>
<Button
size="small"
<PillNavButton
icon={<CreateNewFolderIcon />}
onClick={() => {
setSubdirError(null)
setSubdirName('')
@@ -856,44 +921,21 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
setSubdirOpen(true)
}}
>
New Folder...
</Button>
<Button
size="small"
New Folder
</PillNavButton>
<PillNavButton
icon={<FileUploadIcon />}
onClick={() => {
setUploadError(null)
setUploadFiles([])
setUploadOpen(true)
}}
>
Upload RPM...
</Button>
Upload RPM
</PillNavButton>
</>
) : null}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.25, mb: 1, px: 0.5 }}>
<IconButton size="small" onClick={() => handleRpmBreadcrumb('')} aria-label="Root">
<HomeOutlinedIcon fontSize="small" />
</IconButton>
<Typography variant="body2" color="text.secondary">
/
</Typography>
{rpmPathParts.map((part, idx) => {
const nextPath = rpmPathParts.slice(0, idx + 1).join('/')
return (
<Box key={nextPath} sx={{ display: 'flex', alignItems: 'center', gap: 0 }}>
{idx > 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ mx: 0.25 }}>
/
</Typography>
) : null}
<Button size="small" onClick={() => handleRpmBreadcrumb(nextPath)} sx={{ textTransform: 'none', px: 0.5 }}>
{part}
</Button>
</Box>
)
})}
</Box>
<TextField
size="small"
placeholder="Search files"
@@ -922,6 +964,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
<ListItem key={entry.path} disablePadding>
<ListItemButton onClick={() => handleRpmEntry(entry)}>
<ListItemText
sx={{ my: 0 }}
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{entry.type === 'dir' ? (
@@ -929,7 +972,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
) : (
<InsertDriveFileIcon fontSize="small" color="info" />
)}
<Typography variant="body2">{entry.name}</Typography>
<Typography variant="body2" noWrap>{entry.name}</Typography>
{entry.type === 'dir' && entry.is_repo_dir ? (
<Chip
size="small"
@@ -1043,17 +1086,48 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
</List>
</TintedPanel>
)} right={(
<TintedPanel>
{rpmSelected && rpmSelectedEntry && rpmSelectedEntry.name.toLowerCase().endsWith('.rpm') ? (
<>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="subtitle2" color="text.secondary">
{rpmSelected.name}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
{rpmSelected.version}-{rpmSelected.release}.{rpmSelected.arch}
<TintedPanel sx={{ ml: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.25, mb: 1, px: 0.5 }}>
<IconButton size="small" onClick={() => handleRpmBreadcrumb('')} aria-label="Root">
<HomeOutlinedIcon fontSize="small" />
</IconButton>
<Typography variant="body2" color="text.secondary" sx={{ mx: 0.25 }}>
/
</Typography>
{rpmPathParts.map((part, idx) => {
const nextPath = rpmPathParts.slice(0, idx + 1).join('/')
return (
<Box key={nextPath} sx={{ display: 'flex', alignItems: 'center', gap: 0 }}>
{idx > 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ mx: 0.25 }}>
/
</Typography>
) : null}
<Button size="small"
onClick={() => handleRpmBreadcrumb(nextPath)}
sx={{ textTransform: 'none', minWidth: 0, px: 0.5 }}>
{part}
</Button>
</Box>
)
})}
{rpmSelectedEntry? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0 }}>
{rpmPathParts.length > 0? (
<Typography variant="body2" color="text.secondary" sx={{ mx: 0.25 }}>
/
</Typography>): null}
<Typography variant="body2" sx={{ px: 0.5 }}>
{rpmSelectedEntry.name}
</Typography>
</Box>
) : null}
</Box>
{rpmSelected && rpmSelectedEntry && rpmSelectedEntry.name.toLowerCase().endsWith('.rpm') ? (
<>
<Divider sx={{ mt: 0.5, mb: 0.5 }} />
<Tabs value={rpmTab} onChange={(_, value) => setRpmTab(value)}>
<Tab label="Metadata" value="meta" />
@@ -1154,9 +1228,6 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
</>
) : rpmSelectedEntry && isRepoMetaFile(rpmSelectedEntry) ? (
<Box>
<Typography variant="body2" color="text.secondary">
{rpmSelectedEntry.name}
</Typography>
<Divider sx={{ mt: 0.5, mb: 0.5 }} />
{rpmMetaLoading ? (
<Typography variant="body2" color="text.secondary">
@@ -1177,10 +1248,8 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
</Box>
) : (
<Box>
<Typography variant="body2" color="text.secondary">
Select a file to view details.
</Typography>
<Divider sx={{ mt: 0.5, mb: 0.5 }} />
{renderRpmDirectoryListing()}
</Box>
)}
</TintedPanel>
+17
View File
@@ -0,0 +1,17 @@
// Shared builders for repository route URLs, so the git pages don't each
// re-implement the same path shapes.
export function commitDetailPath(projectId: string | undefined, repoId: string | undefined, hash: string, ref?: string): string {
if (!projectId || !repoId || !hash) return '#'
const query = ref ? `?ref=${encodeURIComponent(ref)}` : ''
return `/projects/${projectId}/repos/${repoId}/commits/${hash}${query}`
}
export function repoCodePath(projectId: string | undefined, repoId: string | undefined, opts?: { ref?: string; path?: string }): string {
if (!projectId || !repoId) return '#'
const params = new URLSearchParams()
if (opts?.ref) params.set('ref', opts.ref)
if (opts?.path) params.set('path', opts.path)
const qs = params.toString()
return `/projects/${projectId}/repos/${repoId}${qs ? `?${qs}` : ''}`
}