Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddccd560a9 | |||
| dabb5d7216 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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] }))
|
||||
|
||||
@@ -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' }}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}` : ''}`
|
||||
}
|
||||
Reference in New Issue
Block a user