Compare commits
2 Commits
b9fdccb96b
...
ddccd560a9
| Author | SHA1 | Date | |
|---|---|---|---|
| ddccd560a9 | |||
| dabb5d7216 |
@@ -617,12 +617,49 @@ func ListCommitsBetween(repoPath, baseRef, headRef string, limit int) ([]Commit,
|
|||||||
return commits, nil
|
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) {
|
func ListTree(repoPath, ref, path string) ([]TreeEntry, error) {
|
||||||
var repo *git2go.Repository
|
var repo *git2go.Repository
|
||||||
var err error
|
var err error
|
||||||
var commit *git2go.Commit
|
var commit *git2go.Commit
|
||||||
var tree *git2go.Tree
|
var tree *git2go.Tree
|
||||||
var subEntry *git2go.TreeEntry
|
var prefix string
|
||||||
var entries []TreeEntry
|
var entries []TreeEntry
|
||||||
var i uint64
|
var i uint64
|
||||||
var count uint64
|
var count uint64
|
||||||
@@ -642,49 +679,25 @@ func ListTree(repoPath, ref, path string) ([]TreeEntry, error) {
|
|||||||
}
|
}
|
||||||
defer commit.Free()
|
defer commit.Free()
|
||||||
|
|
||||||
tree, err = commit.Tree()
|
tree, prefix, err = resolveSubtree(repo, commit, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer tree.Free()
|
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()
|
count = tree.EntryCount()
|
||||||
entries = make([]TreeEntry, 0, count)
|
entries = make([]TreeEntry, 0, count)
|
||||||
for i = 0; i < count; i++ {
|
for i = 0; i < count; i++ {
|
||||||
var entry *git2go.TreeEntry
|
var entry *git2go.TreeEntry
|
||||||
var typeName string
|
var typeName string
|
||||||
var entryPath string
|
|
||||||
entry = tree.EntryByIndex(i)
|
entry = tree.EntryByIndex(i)
|
||||||
typeName = "file"
|
typeName = "file"
|
||||||
if entry.Type == git2go.ObjectTree {
|
if entry.Type == git2go.ObjectTree {
|
||||||
typeName = "dir"
|
typeName = "dir"
|
||||||
}
|
}
|
||||||
entryPath = entry.Name
|
|
||||||
if path != "" {
|
|
||||||
entryPath = path + "/" + entry.Name
|
|
||||||
}
|
|
||||||
entries = append(entries, TreeEntry{
|
entries = append(entries, TreeEntry{
|
||||||
Name: entry.Name,
|
Name: entry.Name,
|
||||||
Path: entryPath,
|
Path: prefix + entry.Name,
|
||||||
Type: typeName,
|
Type: typeName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -707,7 +720,6 @@ func ListTreeWithCommits(repoPath, ref, path string) ([]TreeEntryCommit, error)
|
|||||||
var err error
|
var err error
|
||||||
var commit *git2go.Commit
|
var commit *git2go.Commit
|
||||||
var tree *git2go.Tree
|
var tree *git2go.Tree
|
||||||
var subEntry *git2go.TreeEntry
|
|
||||||
var entries []TreeEntryCommit
|
var entries []TreeEntryCommit
|
||||||
var index map[string]int
|
var index map[string]int
|
||||||
var unresolved map[string]bool
|
var unresolved map[string]bool
|
||||||
@@ -734,32 +746,12 @@ func ListTreeWithCommits(repoPath, ref, path string) ([]TreeEntryCommit, error)
|
|||||||
}
|
}
|
||||||
defer commit.Free()
|
defer commit.Free()
|
||||||
|
|
||||||
tree, err = commit.Tree()
|
tree, prefix, err = resolveSubtree(repo, commit, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer tree.Free()
|
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()
|
count = tree.EntryCount()
|
||||||
entries = make([]TreeEntryCommit, 0, count)
|
entries = make([]TreeEntryCommit, 0, count)
|
||||||
index = make(map[string]int, count)
|
index = make(map[string]int, count)
|
||||||
@@ -796,6 +788,11 @@ func ListTreeWithCommits(repoPath, ref, path string) ([]TreeEntryCommit, error)
|
|||||||
}
|
}
|
||||||
defer walk.Free()
|
defer walk.Free()
|
||||||
walk.Sorting(git2go.SortTime | git2go.SortTopological)
|
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())
|
err = walk.Push(commit.Id())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -845,10 +842,20 @@ func assignTreeCommit(repo *git2go.Repository, c *git2go.Commit, prefix string,
|
|||||||
defer cTree.Free()
|
defer cTree.Free()
|
||||||
|
|
||||||
if c.ParentCount() == 0 {
|
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 key string
|
||||||
|
var keys []string
|
||||||
for key = range unresolved {
|
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
|
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 ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
|
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
|
||||||
import { formatCommitTime } from '../time'
|
import { formatCommitTime } from '../time'
|
||||||
|
import { copyText } from '../clipboard'
|
||||||
|
|
||||||
// splitCommitMessage separates a git commit message into its subject (first
|
// splitCommitMessage separates a git commit message into its subject (first
|
||||||
// line) and body (everything after the first blank-separated line).
|
// 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() }
|
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 = {
|
type CommitIdentityProps = {
|
||||||
author?: string
|
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.
|
// No link target (e.g. the commit's own page): make the chip copy the hash.
|
||||||
hashNode = (
|
hashNode = (
|
||||||
<Tooltip title="Copy commit id">
|
<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>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
} else if (hashHref) {
|
} else if (hashHref) {
|
||||||
@@ -102,7 +97,7 @@ export function CommitIdentity(props: CommitIdentityProps) {
|
|||||||
{email && !showEmail ? <Tooltip title={email}>{authorNode}</Tooltip> : authorNode}
|
{email && !showEmail ? <Tooltip title={email}>{authorNode}</Tooltip> : authorNode}
|
||||||
{copy && email ? (
|
{copy && email ? (
|
||||||
<Tooltip title="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 }} />
|
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -110,7 +105,7 @@ export function CommitIdentity(props: CommitIdentityProps) {
|
|||||||
{hashNode}
|
{hashNode}
|
||||||
{copyHash && hash && !(hashChip && !hashHref) ? (
|
{copyHash && hash && !(hashChip && !hashHref) ? (
|
||||||
<Tooltip title="Copy commit id">
|
<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 }} />
|
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ export default function GoToFile({ repoId, refName, onSelect }: GoToFileProps) {
|
|||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
setOpen(true)
|
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.
|
// "t" opens the finder (when not already typing in a field), like Gitea.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,12 +95,11 @@ export default function GoToFile({ repoId, refName, onSelect }: GoToFileProps) {
|
|||||||
const tag = el ? el.tagName.toLowerCase() : ''
|
const tag = el ? el.tagName.toLowerCase() : ''
|
||||||
if (tag === 'input' || tag === 'textarea' || (el && el.isContentEditable)) return
|
if (tag === 'input' || tag === 'textarea' || (el && el.isContentEditable)) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleOpen()
|
handleOpenRef.current()
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKey)
|
document.addEventListener('keydown', onKey)
|
||||||
return () => document.removeEventListener('keydown', onKey)
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [])
|
||||||
}, [repoId, refName, loadedRef, loading])
|
|
||||||
|
|
||||||
const scored: Scored[] = useMemo(() => {
|
const scored: Scored[] = useMemo(() => {
|
||||||
const q = query.trim()
|
const q = query.trim()
|
||||||
@@ -167,7 +170,6 @@ export default function GoToFile({ repoId, refName, onSelect }: GoToFileProps) {
|
|||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
size="small"
|
size="small"
|
||||||
fullWidth
|
fullWidth
|
||||||
autoFocus
|
|
||||||
placeholder="Go to file…"
|
placeholder="Go to file…"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
|
|||||||
sx={navItemSx(active)}
|
sx={navItemSx(active)}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{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}
|
{item.label}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { api, BranchInfo, Project, Repo, RepoBranchesInfo } from '../api'
|
import { api, BranchInfo, Project, Repo, RepoBranchesInfo } from '../api'
|
||||||
import { splitCommitMessage } from '../components/CommitMeta'
|
import { splitCommitMessage } from '../components/CommitMeta'
|
||||||
|
import { formatCommitTime } from '../time'
|
||||||
import ContextToolbar from '../components/ContextToolbar'
|
import ContextToolbar from '../components/ContextToolbar'
|
||||||
import ProjectPageFrame from '../components/ProjectPageFrame'
|
import ProjectPageFrame from '../components/ProjectPageFrame'
|
||||||
import RepoSubNav from '../components/RepoSubNav'
|
import RepoSubNav from '../components/RepoSubNav'
|
||||||
@@ -236,7 +237,7 @@ export default function RepoGitBranchesPage() {
|
|||||||
getValue: (branch: BranchInfo) => branch.last_when || '',
|
getValue: (branch: BranchInfo) => branch.last_when || '',
|
||||||
renderCell: (branch: BranchInfo) => (
|
renderCell: (branch: BranchInfo) => (
|
||||||
<Typography variant="body2" color="text.secondary" noWrap>
|
<Typography variant="body2" color="text.secondary" noWrap>
|
||||||
{branch.last_when || '-'}
|
{branch.last_when ? formatCommitTime(branch.last_when) : '-'}
|
||||||
</Typography>
|
</Typography>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Box, Chip, Collapse, Divider, IconButton, List, ListItem, ListItemButto
|
|||||||
import { type ReactNode, useEffect, useState } from 'react'
|
import { type ReactNode, useEffect, useState } from 'react'
|
||||||
import { Link as RouterLink, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
import { Link as RouterLink, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { api, Project, Repo, RepoCommitDetail } from '../api'
|
import { api, Project, Repo, RepoCommitDetail } from '../api'
|
||||||
|
import { commitDetailPath, repoCodePath } from '../repoPaths'
|
||||||
import { CommitIdentity, CommitMessage } from '../components/CommitMeta'
|
import { CommitIdentity, CommitMessage } from '../components/CommitMeta'
|
||||||
import ContextToolbar from '../components/ContextToolbar'
|
import ContextToolbar from '../components/ContextToolbar'
|
||||||
import ProjectPageFrame from '../components/ProjectPageFrame'
|
import ProjectPageFrame from '../components/ProjectPageFrame'
|
||||||
@@ -106,8 +107,19 @@ export default function RepoGitCommitDetailPage() {
|
|||||||
}
|
}
|
||||||
for (i = 0; i < blocks.length; i += 1) {
|
for (i = 0; i < blocks.length; i += 1) {
|
||||||
const block = blocks[i]
|
const block = blocks[i]
|
||||||
const header = block.split('\n', 1)[0] || ''
|
const blockLines = block.split('\n')
|
||||||
if (header.includes(` a/${file} `) || header.endsWith(` a/${file}`) || header.includes(` b/${file} `) || header.endsWith(` b/${file}`)) {
|
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
|
return block
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,29 +399,14 @@ export default function RepoGitCommitDetailPage() {
|
|||||||
return <ChangeCircleOutlinedIcon fontSize="small" color="info" />
|
return <ChangeCircleOutlinedIcon fontSize="small" color="info" />
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileCodePath = (filePath: string): string => {
|
const fileCodePath = (filePath: string): string =>
|
||||||
var params: URLSearchParams
|
filePath ? repoCodePath(projectId, repoId, { ref: hash || searchParams.get('ref') || '', path: filePath }) : '#'
|
||||||
var ref: string
|
|
||||||
|
|
||||||
if (!projectId || !repoId || !filePath) return '#'
|
const commitPath = (commitHash: string): string =>
|
||||||
ref = hash || searchParams.get('ref') || ''
|
commitDetailPath(projectId, repoId, commitHash, 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 => {
|
const codeViewPath = (commitHash: string): string =>
|
||||||
if (!projectId || !repoId || !commitHash) return '#'
|
commitHash ? repoCodePath(projectId, repoId, { ref: commitHash }) : '#'
|
||||||
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 toggleFileDiffCollapsed = (filePath: string): void => {
|
const toggleFileDiffCollapsed = (filePath: string): void => {
|
||||||
setCollapsedDiffs((prev: Record<string, boolean>) => ({ ...prev, [filePath]: !prev[filePath] }))
|
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 { useEffect, useState } from 'react'
|
||||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { api, Project, Repo, RepoCommit } from '../api'
|
import { api, Project, Repo, RepoCommit } from '../api'
|
||||||
|
import { commitDetailPath, repoCodePath } from '../repoPaths'
|
||||||
|
import { copyText } from '../clipboard'
|
||||||
import { CommitIdentity, splitCommitMessage } from '../components/CommitMeta'
|
import { CommitIdentity, splitCommitMessage } from '../components/CommitMeta'
|
||||||
import AccountTreeIcon from '@mui/icons-material/AccountTree'
|
import AccountTreeIcon from '@mui/icons-material/AccountTree'
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||||
@@ -115,11 +117,14 @@ export default function RepoGitCommitsPage() {
|
|||||||
cancelled = true
|
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) => {
|
.then((list) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setCommits(Array.isArray(list) ? list : [])
|
const data: RepoCommit[] = Array.isArray(list) ? list : []
|
||||||
setHasNextPage(Array.isArray(list) && list.length >= pageSize)
|
setCommits(data.slice(0, pageSize))
|
||||||
|
setHasNextPage(data.length > pageSize)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
@@ -161,47 +166,11 @@ export default function RepoGitCommitsPage() {
|
|||||||
setPage((value: number) => value + 1)
|
setPage((value: number) => value + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyText = async (text: string) => {
|
|
||||||
let textarea: HTMLTextAreaElement
|
|
||||||
|
|
||||||
try {
|
const commitDetailLink = (commit: RepoCommit): string => commitDetailPath(projectId, repoId, commit.hash, ref)
|
||||||
await navigator.clipboard.writeText(text)
|
const repoAtCommitPath = (commit: RepoCommit): string => repoCodePath(projectId, repoId, { ref: commit.hash })
|
||||||
return
|
const fileAtCommitPath = (commit: RepoCommit): string =>
|
||||||
} catch {
|
filePath ? repoCodePath(projectId, repoId, { ref: commit.hash, path: filePath }) : '#'
|
||||||
// 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 commitColumns: RSTColumn<RepoCommit>[] = [
|
const commitColumns: RSTColumn<RepoCommit>[] = [
|
||||||
{
|
{
|
||||||
@@ -213,7 +182,7 @@ export default function RepoGitCommitsPage() {
|
|||||||
renderCell: (commit: RepoCommit) => (
|
renderCell: (commit: RepoCommit) => (
|
||||||
<Typography
|
<Typography
|
||||||
component={Link}
|
component={Link}
|
||||||
to={commitDetailPath(commit)}
|
to={commitDetailLink(commit)}
|
||||||
color="primary"
|
color="primary"
|
||||||
sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textDecoration: 'none' }}
|
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 { api, Project, Repo, RepoCommit, RepoTreeEntry, RepoTreeEntryCommit } from '../api'
|
||||||
import { CommitIdentity, CommitMessage, splitCommitMessage } from '../components/CommitMeta'
|
import { CommitIdentity, CommitMessage, splitCommitMessage } from '../components/CommitMeta'
|
||||||
import { formatCommitTime } from '../time'
|
import { formatCommitTime } from '../time'
|
||||||
|
import { commitDetailPath } from '../repoPaths'
|
||||||
|
import { copyText } from '../clipboard'
|
||||||
import Autocomplete from '../components/Autocomplete'
|
import Autocomplete from '../components/Autocomplete'
|
||||||
import AccountTreeIcon from '@mui/icons-material/AccountTree'
|
import AccountTreeIcon from '@mui/icons-material/AccountTree'
|
||||||
import CodeIcon from '@mui/icons-material/Code'
|
import CodeIcon from '@mui/icons-material/Code'
|
||||||
@@ -27,6 +29,17 @@ import TintedPanel from '../components/TintedPanel'
|
|||||||
|
|
||||||
const RepoMarkdown = lazy(() => import('../components/RepoMarkdown'))
|
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 = {
|
type RepoGitDetailPageProps = {
|
||||||
initialRepo?: Repo
|
initialRepo?: Repo
|
||||||
}
|
}
|
||||||
@@ -161,14 +174,16 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
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) => {
|
setSearchParams((prev) => {
|
||||||
const next = new URLSearchParams(prev)
|
const next = new URLSearchParams(prev)
|
||||||
next.set('ref', ref)
|
next.set('ref', ref)
|
||||||
return next
|
return next
|
||||||
}, { replace: true })
|
}, { replace: true })
|
||||||
}
|
}
|
||||||
}, [ref, setSearchParams])
|
}, [ref, searchParams, setSearchParams])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!repoId) return
|
if (!repoId) return
|
||||||
@@ -221,7 +236,9 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
setDirLoading(false)
|
setDirLoading(false)
|
||||||
})
|
})
|
||||||
return () => { cancelled = true }
|
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 = () => {
|
const clearReadme = () => {
|
||||||
setReadmeContent('')
|
setReadmeContent('')
|
||||||
@@ -336,28 +353,9 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
setSelectedCommit(null)
|
setSelectedCommit(null)
|
||||||
}, [repoId, ref, urlRef, urlDirPath, urlFilePath])
|
}, [repoId, ref, urlRef, urlDirPath, urlFilePath])
|
||||||
|
|
||||||
const handleOpen = async (entry: RepoTreeEntry) => {
|
// clearFileSelection returns the right pane to the directory listing,
|
||||||
if (!repoId) return
|
// discarding any open file/diff/history state.
|
||||||
if (entry.type === 'dir') {
|
const clearFileSelection = () => {
|
||||||
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)
|
|
||||||
setSelectedFile('')
|
setSelectedFile('')
|
||||||
setContent('')
|
setContent('')
|
||||||
setFileQuery('')
|
setFileQuery('')
|
||||||
@@ -368,19 +366,31 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
setSelectedCommit(null)
|
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) => {
|
const handleBreadcrumb = (nextPath: string) => {
|
||||||
if (nextPath === path) {
|
if (nextPath === path) {
|
||||||
// Already in this directory. If a file is open, clicking the current
|
// Already in this directory. If a file is open, clicking the current
|
||||||
// directory returns to its listing; otherwise just reload the tree.
|
// directory returns to its listing; otherwise just reload the tree.
|
||||||
if (selectedFile) {
|
if (selectedFile) {
|
||||||
setSelectedFile('')
|
clearFileSelection()
|
||||||
setContent('')
|
|
||||||
setFileQuery('')
|
|
||||||
setIsImageFileSelected(false)
|
|
||||||
setImagePreviewRef('')
|
|
||||||
setPreviewTab('content')
|
|
||||||
setDiff('')
|
|
||||||
setSelectedCommit(null)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setTreeReloadTick((prev) => prev + 1)
|
setTreeReloadTick((prev) => prev + 1)
|
||||||
@@ -392,14 +402,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
} else {
|
} else {
|
||||||
setPathSegments(nextPath.split('/').filter(Boolean))
|
setPathSegments(nextPath.split('/').filter(Boolean))
|
||||||
}
|
}
|
||||||
setSelectedFile('')
|
clearFileSelection()
|
||||||
setContent('')
|
|
||||||
setFileQuery('')
|
|
||||||
setIsImageFileSelected(false)
|
|
||||||
setImagePreviewRef('')
|
|
||||||
setPreviewTab('content')
|
|
||||||
setDiff('')
|
|
||||||
setSelectedCommit(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathParts = pathSegments
|
const pathParts = pathSegments
|
||||||
@@ -503,35 +506,9 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
setDiffRange({ from: '', to: '' })
|
setDiffRange({ from: '', to: '' })
|
||||||
})
|
})
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [previewTab, repoId, selectedFile, selectedCommit, ref])
|
// 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.
|
||||||
const copyText = async (text: string) => {
|
}, [previewTab, repoId, selectedFile, selectedCommit?.hash, ref])
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildCloneURL = (path: string) => {
|
const buildCloneURL = (path: string) => {
|
||||||
if (!path) return ''
|
if (!path) return ''
|
||||||
@@ -606,13 +583,8 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
return `/api/repos/${repoId}/blob/raw?${params.toString()}`
|
return `/api/repos/${repoId}/blob/raw?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const commitDetailPath = (hash: string): string => {
|
const commitDetailLink = (hash: string): string =>
|
||||||
var queryRef: string
|
commitDetailPath(projectId, repoId, hash, imagePreviewRef || ref)
|
||||||
|
|
||||||
if (!projectId || !repoId || !hash) return '#'
|
|
||||||
queryRef = imagePreviewRef || ref
|
|
||||||
return `/projects/${projectId}/repos/${repoId}/commits/${hash}${queryRef ? `?ref=${encodeURIComponent(queryRef)}` : ''}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileHistoryPath = (filePath: string): string => {
|
const fileHistoryPath = (filePath: string): string => {
|
||||||
var params: URLSearchParams
|
var params: URLSearchParams
|
||||||
@@ -773,12 +745,9 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderDirectoryListing = () => {
|
const renderDirectoryListing = () => {
|
||||||
const sorted = [...dirEntries].sort((a, b) => {
|
const sorted = sortTreeEntries(dirEntries)
|
||||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
})
|
|
||||||
return (
|
return (
|
||||||
<Paper variant="outlined" sx={{ mt: 1, minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }}>
|
<Paper variant="outlined" sx={{ mt: 1, ...outlinedPanelSx }}>
|
||||||
<List dense disablePadding>
|
<List dense disablePadding>
|
||||||
{path ? (
|
{path ? (
|
||||||
<ListItem disablePadding divider>
|
<ListItem disablePadding divider>
|
||||||
@@ -902,11 +871,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
) : null}
|
) : null}
|
||||||
{[...filteredTree]
|
{sortTreeEntries(filteredTree)
|
||||||
.sort((a, b) => {
|
|
||||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
})
|
|
||||||
.map((entry) => (
|
.map((entry) => (
|
||||||
<ListItem key={entry.path} disablePadding>
|
<ListItem key={entry.path} disablePadding>
|
||||||
<ListItemButton onClick={() => handleOpen(entry)} sx={{ py: 0.4 }}>
|
<ListItemButton onClick={() => handleOpen(entry)} sx={{ py: 0.4 }}>
|
||||||
@@ -946,7 +911,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
email={selectedCommit?.email}
|
email={selectedCommit?.email}
|
||||||
when={selectedCommit?.when}
|
when={selectedCommit?.when}
|
||||||
hash={selectedCommit?.hash}
|
hash={selectedCommit?.hash}
|
||||||
hashHref={selectedCommit?.hash ? commitDetailPath(selectedCommit.hash) : undefined}
|
hashHref={selectedCommit?.hash ? commitDetailLink(selectedCommit.hash) : undefined}
|
||||||
hashChip
|
hashChip
|
||||||
copyHash
|
copyHash
|
||||||
/>
|
/>
|
||||||
@@ -1035,7 +1000,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
{previewTab === 'content' ? (
|
{previewTab === 'content' ? (
|
||||||
isImageFileSelected ? (
|
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
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src={buildBlobRawUrl(selectedFile, imagePreviewRef || ref)}
|
src={buildBlobRawUrl(selectedFile, imagePreviewRef || ref)}
|
||||||
@@ -1044,7 +1009,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
/>
|
/>
|
||||||
</Paper>
|
</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 />
|
<LazyCodeBlock code={content} language={detectLanguage(selectedFile)} showLineNumbers />
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
@@ -1060,7 +1025,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
<Tooltip title={diffRange.from}>
|
<Tooltip title={diffRange.from}>
|
||||||
<Chip
|
<Chip
|
||||||
component={RouterLink}
|
component={RouterLink}
|
||||||
to={commitDetailPath(diffRange.from)}
|
to={commitDetailLink(diffRange.from)}
|
||||||
label={diffRange.from.slice(0, 8)}
|
label={diffRange.from.slice(0, 8)}
|
||||||
size="small"
|
size="small"
|
||||||
clickable
|
clickable
|
||||||
@@ -1073,7 +1038,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
Initial version (no parent to compare against)
|
Initial version (no parent to compare against)
|
||||||
</Typography>
|
</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" />
|
<LazyCodeBlock code={diff} language="diff" />
|
||||||
</Paper>
|
</Paper>
|
||||||
</>
|
</>
|
||||||
@@ -1089,7 +1054,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
{renderDirectoryListing()}
|
{renderDirectoryListing()}
|
||||||
{readmeContent ? (
|
{readmeContent ? (
|
||||||
readmeKind === 'markdown' ? (
|
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>
|
<Box>
|
||||||
<Suspense fallback={<Typography variant="body2" color="text.secondary">Loading README...</Typography>}>
|
<Suspense fallback={<Typography variant="body2" color="text.secondary">Loading README...</Typography>}>
|
||||||
<RepoMarkdown
|
<RepoMarkdown
|
||||||
@@ -1101,7 +1066,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</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 />
|
<LazyCodeBlock code={readmeContent} language="text" showLineNumbers />
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ import PageAlert from '../components/PageAlert'
|
|||||||
import SplitPanel from '../components/SplitPanel'
|
import SplitPanel from '../components/SplitPanel'
|
||||||
import { api, Project, Repo, RpmMirrorRun, RpmPackageDetail, RpmPackageSummary, RpmSubdirCreatePayload, RpmSubdirUpdatePayload, RpmTreeEntry } from '../api'
|
import { api, Project, Repo, RpmMirrorRun, RpmPackageDetail, RpmPackageSummary, RpmSubdirCreatePayload, RpmSubdirUpdatePayload, RpmTreeEntry } from '../api'
|
||||||
import { formatEpochTime } from '../time'
|
import { formatEpochTime } from '../time'
|
||||||
|
import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder'
|
||||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
|
||||||
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
|
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
|
||||||
|
import FileUploadIcon from '@mui/icons-material/FileUpload'
|
||||||
import FolderIcon from '@mui/icons-material/Folder'
|
import FolderIcon from '@mui/icons-material/Folder'
|
||||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
||||||
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'
|
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'
|
||||||
@@ -38,6 +40,7 @@ import ContextToolbar from '../components/ContextToolbar'
|
|||||||
import ProjectPageFrame from '../components/ProjectPageFrame'
|
import ProjectPageFrame from '../components/ProjectPageFrame'
|
||||||
import RepoSubNav from '../components/RepoSubNav'
|
import RepoSubNav from '../components/RepoSubNav'
|
||||||
import LazyCodeBlock from '../components/LazyCodeBlock'
|
import LazyCodeBlock from '../components/LazyCodeBlock'
|
||||||
|
import PillNavButton from '../components/PillNavButton'
|
||||||
import RepoRpmDirectoryEditDialog from '../components/RepoRpmDirectoryEditDialog'
|
import RepoRpmDirectoryEditDialog from '../components/RepoRpmDirectoryEditDialog'
|
||||||
import RepoRpmDirectoryStatusDialog from '../components/RepoRpmDirectoryStatusDialog'
|
import RepoRpmDirectoryStatusDialog from '../components/RepoRpmDirectoryStatusDialog'
|
||||||
import SelectField from '../components/SelectField'
|
import SelectField from '../components/SelectField'
|
||||||
@@ -604,6 +607,13 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
|||||||
|
|
||||||
const handleRpmBreadcrumb = (nextPath: string) => {
|
const handleRpmBreadcrumb = (nextPath: string) => {
|
||||||
if (nextPath === rpmPath) {
|
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)
|
setRpmTreeReloadTick((prev) => prev + 1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -815,6 +825,61 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
|||||||
? rpmTree.filter((entry) => matchesFileQuery(entry.name, normalizedQuery))
|
? rpmTree.filter((entry) => matchesFileQuery(entry.name, normalizedQuery))
|
||||||
: rpmTree
|
: 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 (
|
return (
|
||||||
<ProjectPageFrame
|
<ProjectPageFrame
|
||||||
projectId={projectId ?? ''}
|
projectId={projectId ?? ''}
|
||||||
@@ -839,8 +904,8 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1 }}>
|
||||||
{canWrite ? (
|
{canWrite ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<PillNavButton
|
||||||
size="small"
|
icon={<CreateNewFolderIcon />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSubdirError(null)
|
setSubdirError(null)
|
||||||
setSubdirName('')
|
setSubdirName('')
|
||||||
@@ -856,44 +921,21 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
|||||||
setSubdirOpen(true)
|
setSubdirOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
New Folder...
|
New Folder
|
||||||
</Button>
|
</PillNavButton>
|
||||||
<Button
|
<PillNavButton
|
||||||
size="small"
|
icon={<FileUploadIcon />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUploadError(null)
|
setUploadError(null)
|
||||||
setUploadFiles([])
|
setUploadFiles([])
|
||||||
setUploadOpen(true)
|
setUploadOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Upload RPM...
|
Upload RPM
|
||||||
</Button>
|
</PillNavButton>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</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
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="Search files"
|
placeholder="Search files"
|
||||||
@@ -922,6 +964,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
|||||||
<ListItem key={entry.path} disablePadding>
|
<ListItem key={entry.path} disablePadding>
|
||||||
<ListItemButton onClick={() => handleRpmEntry(entry)}>
|
<ListItemButton onClick={() => handleRpmEntry(entry)}>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
|
sx={{ my: 0 }}
|
||||||
primary={
|
primary={
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
{entry.type === 'dir' ? (
|
{entry.type === 'dir' ? (
|
||||||
@@ -929,7 +972,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
|||||||
) : (
|
) : (
|
||||||
<InsertDriveFileIcon fontSize="small" color="info" />
|
<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 ? (
|
{entry.type === 'dir' && entry.is_repo_dir ? (
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
@@ -1043,17 +1086,48 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
|||||||
</List>
|
</List>
|
||||||
</TintedPanel>
|
</TintedPanel>
|
||||||
)} right={(
|
)} right={(
|
||||||
<TintedPanel>
|
<TintedPanel sx={{ ml: 1 }}>
|
||||||
{rpmSelected && rpmSelectedEntry && rpmSelectedEntry.name.toLowerCase().endsWith('.rpm') ? (
|
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.25, mb: 1, px: 0.5 }}>
|
||||||
<>
|
<IconButton size="small" onClick={() => handleRpmBreadcrumb('')} aria-label="Root">
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<HomeOutlinedIcon fontSize="small" />
|
||||||
<Typography variant="subtitle2" color="text.secondary">
|
</IconButton>
|
||||||
{rpmSelected.name}
|
<Typography variant="body2" color="text.secondary" sx={{ mx: 0.25 }}>
|
||||||
</Typography>
|
/
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
</Typography>
|
||||||
{rpmSelected.version}-{rpmSelected.release}.{rpmSelected.arch}
|
|
||||||
|
{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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{rpmSelected && rpmSelectedEntry && rpmSelectedEntry.name.toLowerCase().endsWith('.rpm') ? (
|
||||||
|
<>
|
||||||
<Divider sx={{ mt: 0.5, mb: 0.5 }} />
|
<Divider sx={{ mt: 0.5, mb: 0.5 }} />
|
||||||
<Tabs value={rpmTab} onChange={(_, value) => setRpmTab(value)}>
|
<Tabs value={rpmTab} onChange={(_, value) => setRpmTab(value)}>
|
||||||
<Tab label="Metadata" value="meta" />
|
<Tab label="Metadata" value="meta" />
|
||||||
@@ -1154,9 +1228,6 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
|||||||
</>
|
</>
|
||||||
) : rpmSelectedEntry && isRepoMetaFile(rpmSelectedEntry) ? (
|
) : rpmSelectedEntry && isRepoMetaFile(rpmSelectedEntry) ? (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{rpmSelectedEntry.name}
|
|
||||||
</Typography>
|
|
||||||
<Divider sx={{ mt: 0.5, mb: 0.5 }} />
|
<Divider sx={{ mt: 0.5, mb: 0.5 }} />
|
||||||
{rpmMetaLoading ? (
|
{rpmMetaLoading ? (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
@@ -1177,10 +1248,8 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Select a file to view details.
|
|
||||||
</Typography>
|
|
||||||
<Divider sx={{ mt: 0.5, mb: 0.5 }} />
|
<Divider sx={{ mt: 0.5, mb: 0.5 }} />
|
||||||
|
{renderRpmDirectoryListing()}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</TintedPanel>
|
</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