Compare commits
9 Commits
91779ffe6f
...
724a6b2c6c
| Author | SHA1 | Date | |
|---|---|---|---|
| 724a6b2c6c | |||
| 9b3518b04a | |||
| c989fb469f | |||
| 1b022146c9 | |||
| 25e17804ec | |||
| c44418050a | |||
| d238530d7b | |||
| 49459a23de | |||
| 4b5a969218 |
@@ -20,6 +20,7 @@ type CommitDetail struct {
|
||||
Email string `json:"email"`
|
||||
When string `json:"when"`
|
||||
Message string `json:"message"`
|
||||
Parents []string `json:"parents"`
|
||||
Files []CommitFile `json:"files"`
|
||||
}
|
||||
|
||||
@@ -294,9 +295,12 @@ func ListFileHistory(repoPath string, ref string, filePath string, limit int) ([
|
||||
return commits, nil
|
||||
}
|
||||
|
||||
func DiffFile(repoPath, ref, path string) (string, error) {
|
||||
// DiffFile returns the patch for path as it changed in the commit named by ref,
|
||||
// diffed against that commit's first parent. It also returns the two commit
|
||||
// hashes involved: to is the commit's hash, from is its first parent's hash
|
||||
// (empty for a root commit, in which case the diff is empty).
|
||||
func DiffFile(repoPath, ref, path string) (diffText string, fromHash string, toHash string, err error) {
|
||||
var repo *git2go.Repository
|
||||
var err error
|
||||
var commit *git2go.Commit
|
||||
var parent *git2go.Commit
|
||||
var commitTree *git2go.Tree
|
||||
@@ -307,50 +311,52 @@ func DiffFile(repoPath, ref, path string) (string, error) {
|
||||
|
||||
repo, err = git2go.OpenRepository(repoPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", "", err
|
||||
}
|
||||
defer repo.Free()
|
||||
|
||||
commit, err = resolveCommit(repo, ref)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", "", err
|
||||
}
|
||||
defer commit.Free()
|
||||
toHash = commit.Id().String()
|
||||
|
||||
if commit.ParentCount() == 0 {
|
||||
return "", nil
|
||||
return "", "", toHash, nil
|
||||
}
|
||||
parent = commit.Parent(0)
|
||||
defer parent.Free()
|
||||
fromHash = parent.Id().String()
|
||||
|
||||
commitTree, err = commit.Tree()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", "", err
|
||||
}
|
||||
defer commitTree.Free()
|
||||
parentTree, err = parent.Tree()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", "", err
|
||||
}
|
||||
defer parentTree.Free()
|
||||
|
||||
diffOpts, err = git2go.DefaultDiffOptions()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", "", err
|
||||
}
|
||||
diffOpts.Pathspec = []string{path}
|
||||
|
||||
diff, err = repo.DiffTreeToTree(parentTree, commitTree, &diffOpts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", "", err
|
||||
}
|
||||
defer diff.Free()
|
||||
|
||||
buf, err = diff.ToBuf(git2go.DiffFormatPatch)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", "", err
|
||||
}
|
||||
return string(buf), nil
|
||||
return string(buf), fromHash, toHash, nil
|
||||
}
|
||||
|
||||
func GetCommitDetail(repoPath, hash string) (CommitDetail, error) {
|
||||
@@ -365,8 +371,11 @@ func GetCommitDetail(repoPath, hash string) (CommitDetail, error) {
|
||||
var findOpts git2go.DiffFindOptions
|
||||
var diff *git2go.Diff
|
||||
var files []CommitFile
|
||||
var parents []string
|
||||
var parentCount uint
|
||||
var numDeltas int
|
||||
var i int
|
||||
var p uint
|
||||
|
||||
repo, err = git2go.OpenRepository(repoPath)
|
||||
if err != nil {
|
||||
@@ -384,6 +393,11 @@ func GetCommitDetail(repoPath, hash string) (CommitDetail, error) {
|
||||
}
|
||||
defer commit.Free()
|
||||
|
||||
parentCount = commit.ParentCount()
|
||||
for p = 0; p < parentCount; p++ {
|
||||
parents = append(parents, commit.ParentId(p).String())
|
||||
}
|
||||
|
||||
commitTree, err = commit.Tree()
|
||||
if err != nil {
|
||||
return CommitDetail{}, err
|
||||
@@ -454,6 +468,7 @@ func GetCommitDetail(repoPath, hash string) (CommitDetail, error) {
|
||||
Email: commit.Author().Email,
|
||||
When: commit.Author().When.UTC().Format(timeFormat),
|
||||
Message: strings.TrimSpace(commit.Message()),
|
||||
Parents: parents,
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3838,6 +3838,8 @@ func (api *API) RepoFileDiff(w http.ResponseWriter, r *http.Request, params map[
|
||||
var ref string
|
||||
var path string
|
||||
var diff string
|
||||
var fromHash string
|
||||
var toHash string
|
||||
var unlock func()
|
||||
var ok bool
|
||||
|
||||
@@ -3850,12 +3852,12 @@ func (api *API) RepoFileDiff(w http.ResponseWriter, r *http.Request, params map[
|
||||
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "path required")
|
||||
return
|
||||
}
|
||||
diff, err = git.DiffFile(repo.Path, ref, path)
|
||||
diff, fromHash, toHash, err = git.DiffFile(repo.Path, ref, path)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"path": path, "diff": diff})
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"path": path, "diff": diff, "from": fromHash, "to": toHash})
|
||||
}
|
||||
|
||||
func (api *API) RepoCommitDetail(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
|
||||
+2
-1
@@ -299,6 +299,7 @@ export interface RepoCommit {
|
||||
}
|
||||
|
||||
export interface RepoCommitDetail extends RepoCommit {
|
||||
parents: string[]
|
||||
files: { path: string; status: string }[]
|
||||
}
|
||||
|
||||
@@ -2027,7 +2028,7 @@ export const api = {
|
||||
const params = new URLSearchParams()
|
||||
if (ref) params.set('ref', ref)
|
||||
params.set('path', path)
|
||||
return request<{ path: string; diff: string }>(`/api/repos/${repoId}/diff?${params.toString()}`)
|
||||
return request<{ path: string; diff: string; from: string; to: string }>(`/api/repos/${repoId}/diff?${params.toString()}`)
|
||||
},
|
||||
getRepo: (repoId: string) => request<Repo>(`/api/repos/${repoId}`),
|
||||
getRepoCommitDetail: (repoId: string, hash: string) =>
|
||||
|
||||
@@ -8,6 +8,7 @@ import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import FormDialogContent from './FormDialogContent'
|
||||
import { AuthProvider, AuthProviderGroupMapping, UserGroup } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
|
||||
type AuthProviderDetailsDialogProps = {
|
||||
item: AuthProvider | null
|
||||
@@ -19,7 +20,7 @@ function fmt(value: number | undefined): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
function yesNo(value: boolean): string {
|
||||
|
||||
@@ -41,6 +41,9 @@ export default function CodeBlock({ code = '', language = 'diff', showLineNumber
|
||||
padding: 0,
|
||||
whiteSpace: 'pre',
|
||||
overflow: 'auto',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
minWidth: 0,
|
||||
backgroundColor: 'transparent',
|
||||
lineHeight: 1.4,
|
||||
fontSize: '0.85rem',
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Box, Button, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
||||
import type { BoxProps } from '@mui/material/Box'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
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'
|
||||
|
||||
// splitCommitMessage separates a git commit message into its subject (first
|
||||
// line) and body (everything after the first blank-separated line).
|
||||
export function splitCommitMessage(message: string | null | undefined): { subject: string; body: string } {
|
||||
const text = (message || '').replace(/\r\n/g, '\n')
|
||||
const newline = text.indexOf('\n')
|
||||
if (newline < 0) {
|
||||
return { subject: text.trim(), body: '' }
|
||||
}
|
||||
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
|
||||
email?: string
|
||||
when?: string
|
||||
hash?: string
|
||||
// When set, the short hash links to this route. Rendered as a Chip when
|
||||
// hashChip is true, otherwise as an inline link (safe inside list rows).
|
||||
hashHref?: string
|
||||
hashChip?: boolean
|
||||
// Show the email explicitly as "author <email>"; otherwise it appears in a
|
||||
// tooltip on the author name.
|
||||
showEmail?: boolean
|
||||
// Show an email copy button. Only use outside of <a>/<button> rows.
|
||||
copy?: boolean
|
||||
// Show a copy-commit-id button next to the hash (suppressed when the hash is
|
||||
// itself a copy-chip). Only use outside of <a>/<button> rows.
|
||||
copyHash?: boolean
|
||||
sx?: BoxProps['sx']
|
||||
}
|
||||
|
||||
// CommitIdentity renders the author / email / short-hash / local-time metadata
|
||||
// line for a commit. Its root is an inline span so it is valid inside a
|
||||
// ListItemText secondary slot.
|
||||
export function CommitIdentity(props: CommitIdentityProps) {
|
||||
const { author, email, when, hash, hashHref, hashChip, showEmail, copy, copyHash, sx } = props
|
||||
const authorLabel = showEmail && email ? `${author || 'Unknown'} <${email}>` : author || 'Unknown'
|
||||
const authorNode = (
|
||||
<Typography variant="body2" color="text.secondary" component="span" noWrap>
|
||||
{authorLabel}
|
||||
</Typography>
|
||||
)
|
||||
let hashNode = null
|
||||
if (hash) {
|
||||
const short = hash.slice(0, 8)
|
||||
if (hashChip && hashHref) {
|
||||
hashNode = (
|
||||
<Tooltip title={hash}>
|
||||
<Chip component={RouterLink} to={hashHref} label={short} size="small" clickable sx={{ fontFamily: 'monospace' }} />
|
||||
</Tooltip>
|
||||
)
|
||||
} else if (hashChip) {
|
||||
// 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' }} />
|
||||
</Tooltip>
|
||||
)
|
||||
} else if (hashHref) {
|
||||
hashNode = (
|
||||
<Tooltip title={hash}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
component={RouterLink}
|
||||
to={hashHref}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
sx={{ fontFamily: 'monospace', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
|
||||
>
|
||||
{short}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
)
|
||||
} else {
|
||||
hashNode = (
|
||||
<Tooltip title={hash}>
|
||||
<Typography variant="body2" color="text.secondary" component="span" sx={{ fontFamily: 'monospace' }}>
|
||||
{short}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Box component="span" sx={[{ display: 'inline-flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', minWidth: 0 }, ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]}>
|
||||
{email && !showEmail ? <Tooltip title={email}>{authorNode}</Tooltip> : authorNode}
|
||||
{copy && email ? (
|
||||
<Tooltip title="Copy email">
|
||||
<IconButton size="small" onClick={() => copyToClipboard(email)} aria-label="Copy email">
|
||||
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{hashNode}
|
||||
{copyHash && hash && !(hashChip && !hashHref) ? (
|
||||
<Tooltip title="Copy commit id">
|
||||
<IconButton size="small" onClick={() => copyToClipboard(hash)} aria-label="Copy commit id">
|
||||
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{when ? (
|
||||
<Typography variant="body2" color="text.secondary" component="span" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{formatCommitTime(when)}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
type CommitMessageProps = {
|
||||
message: string | null | undefined
|
||||
// inline: single-line subject with an expand toggle to reveal the body
|
||||
// (for tight headers). Default (full): subject + body shown, with a
|
||||
// "Show more/less" toggle only when the body is long.
|
||||
inline?: boolean
|
||||
collapseAfterLines?: number
|
||||
}
|
||||
|
||||
// CommitMessage shows a commit's subject and, when present, its body. The body
|
||||
// preserves line breaks in a monospace block.
|
||||
export function CommitMessage(props: CommitMessageProps) {
|
||||
const { message, inline, collapseAfterLines = 8 } = props
|
||||
const { subject, body } = splitCommitMessage(message)
|
||||
const hasBody = body.length > 0
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ flex: 1, minWidth: 0 }} noWrap={!expanded}>
|
||||
{subject || 'No commit message'}
|
||||
</Typography>
|
||||
{hasBody ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
aria-label={expanded ? 'Collapse commit message' : 'Expand commit message'}
|
||||
>
|
||||
{expanded ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
) : null}
|
||||
</Box>
|
||||
{expanded && hasBody ? (
|
||||
<Typography variant="body2" color="text.secondary" component="pre" sx={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', m: 0, mt: 0.5 }}>
|
||||
{body}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const bodyLines = hasBody ? body.split('\n').length : 0
|
||||
const collapsible = bodyLines > collapseAfterLines
|
||||
return (
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{subject || 'No commit message'}
|
||||
</Typography>
|
||||
{hasBody ? (
|
||||
<>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
component="pre"
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'monospace',
|
||||
m: 0,
|
||||
mt: 0.5,
|
||||
...(collapsible && !expanded ? { maxHeight: 160, overflow: 'hidden' } : {})
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</Typography>
|
||||
{collapsible ? (
|
||||
<Button size="small" onClick={() => setExpanded((value) => !value)} sx={{ textTransform: 'none', px: 0, minWidth: 0 }}>
|
||||
{expanded ? 'Show less' : 'Show more'}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export default function LazyCodeBlock(props: LazyCodeBlockProps) {
|
||||
p: 0,
|
||||
whiteSpace: 'pre',
|
||||
overflow: 'auto',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
minWidth: 0,
|
||||
//fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace'
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@ import TextField from '@mui/material/TextField'
|
||||
import FormDialogContent from './FormDialogContent'
|
||||
import MonospaceTextField from './MonospaceTextField'
|
||||
import { PKICADetail } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
|
||||
type PKICADetailsDialogProps = {
|
||||
item: PKICADetail | null
|
||||
@@ -23,7 +24,7 @@ function fmt(value: number | undefined): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
export default function PKICADetailsDialog(props: PKICADetailsDialogProps) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { ReactNode } from 'react'
|
||||
import FormDialogContent from './FormDialogContent'
|
||||
import MonospaceTextField from './MonospaceTextField'
|
||||
import { PKICertDetail } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
|
||||
type PKICertDetailsDialogProps = {
|
||||
item: PKICertDetail | null
|
||||
@@ -33,7 +34,7 @@ function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
export default function PKICertDetailsDialog(props: PKICertDetailsDialogProps) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import FormDialogContent from './FormDialogContent'
|
||||
import { PKIClientProfile } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
|
||||
type PKIClientProfileDetailsDialogProps = {
|
||||
item: PKIClientProfile | null
|
||||
@@ -17,7 +18,7 @@ function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
export default function PKIClientProfileDetailsDialog(props: PKIClientProfileDetailsDialogProps) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import SectionCard from './SectionCard'
|
||||
import { Project } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
type ProjectOverviewCardProps = {
|
||||
@@ -24,7 +25,7 @@ export default function ProjectOverviewCard({ project, actions }: ProjectOvervie
|
||||
) : null}
|
||||
{project.created_at ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Created: {new Date(project.created_at * 1000).toLocaleString()}
|
||||
Created: {formatEpochTime(project.created_at)}
|
||||
</Typography>
|
||||
) : null}
|
||||
{project.updated_by_name || project.updated_by ? (
|
||||
@@ -34,7 +35,7 @@ export default function ProjectOverviewCard({ project, actions }: ProjectOvervie
|
||||
) : null}
|
||||
{project.updated_at ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Last updated: {new Date(project.updated_at * 1000).toLocaleString()}
|
||||
Last updated: {formatEpochTime(project.updated_at)}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
@@ -9,6 +9,7 @@ import FormDialogContent from './FormDialogContent'
|
||||
import PageAlert from './PageAlert'
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import { RpmMirrorRun } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
|
||||
type RepoRpmDirectoryStatusDialogProps = {
|
||||
open: boolean
|
||||
@@ -114,7 +115,7 @@ export default function RepoRpmDirectoryStatusDialog(props: RepoRpmDirectoryStat
|
||||
{props.runs.length ? (
|
||||
props.runs.map((run: RpmMirrorRun) => (
|
||||
<Typography key={run.id} variant="caption" color={run.status === 'failed' ? 'error' : 'text.secondary'}>
|
||||
{new Date((run.started_at || 0) * 1000).toLocaleString()} · {run.status} · {run.done}/{run.total} · fail {run.failed} · del {run.deleted}
|
||||
{formatEpochTime(run.started_at)} · {run.status} · {run.done}/{run.total} · fail {run.failed} · del {run.deleted}
|
||||
</Typography>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -16,11 +16,22 @@ type RepoNavItem = {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
function storedRepoCodePath(repoId: string, fallback: string, basePath: string): string {
|
||||
var stored: string | null
|
||||
|
||||
if (typeof window === 'undefined') return fallback
|
||||
stored = window.sessionStorage.getItem(`repo-nav:${repoId}:code`)
|
||||
if (!stored) return fallback
|
||||
if (!stored.startsWith(basePath)) return fallback
|
||||
return stored
|
||||
}
|
||||
|
||||
export default function RepoSubNav(props: RepoSubNavProps) {
|
||||
const { projectId, repoId, branchRef, repoType, inline } = props
|
||||
const location = useLocation()
|
||||
const base: string = `/projects/${projectId}/repos/${repoId}`
|
||||
const refSuffix: string = branchRef ? `?ref=${encodeURIComponent(branchRef)}` : ''
|
||||
const codePath: string = storedRepoCodePath(repoId, `${base}${refSuffix}`, base)
|
||||
const currentPath: string = location.pathname.replace(/\/+$/, '')
|
||||
const basePath: string = base.replace(/\/+$/, '')
|
||||
const isRPM: boolean = repoType === 'rpm'
|
||||
@@ -34,7 +45,7 @@ export default function RepoSubNav(props: RepoSubNavProps) {
|
||||
] : [
|
||||
{
|
||||
label: 'Code',
|
||||
path: `${base}${refSuffix}`,
|
||||
path: codePath,
|
||||
active: currentPath === basePath,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import FormDialogContent from './FormDialogContent'
|
||||
import { SSHAccessProfile } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
|
||||
type SSHAccessProfileDetailsDialogProps = {
|
||||
item: SSHAccessProfile | null
|
||||
@@ -17,7 +18,7 @@ function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
export default function SSHAccessProfileDetailsDialog(props: SSHAccessProfileDetailsDialogProps) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import FormDialogContent from './FormDialogContent'
|
||||
import type { SSHSignUserKeyResponse } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
|
||||
type SSHCertificateIssuedDialogProps = {
|
||||
open: boolean
|
||||
@@ -27,7 +28,7 @@ function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
export default function SSHCertificateIssuedDialog(props: SSHCertificateIssuedDialogProps) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import TextField from '@mui/material/TextField'
|
||||
import FormDialogContent from './FormDialogContent'
|
||||
import MonospaceTextField from './MonospaceTextField'
|
||||
import { SSHCredential } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
|
||||
type SSHCredentialDetailsDialogProps = {
|
||||
item: SSHCredential | null
|
||||
@@ -18,7 +19,7 @@ function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
export default function SSHCredentialDetailsDialog(props: SSHCredentialDetailsDialogProps) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import FormDialogContent from './FormDialogContent'
|
||||
import { SSHPrincipalGrant } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
|
||||
type SSHPrincipalGrantDetailsDialogProps = {
|
||||
item: SSHPrincipalGrant | null
|
||||
@@ -16,7 +17,7 @@ function fmtTime(value: number | undefined): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
function fmtLimit(value: number | undefined, suffix: string): string {
|
||||
|
||||
@@ -6,6 +6,7 @@ import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import FormDialogContent from './FormDialogContent'
|
||||
import { SSHServer } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
|
||||
type SSHServerDetailsDialogProps = {
|
||||
item: SSHServer | null
|
||||
@@ -17,7 +18,7 @@ function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
function formatHostKeyPolicy(value: string | undefined): string {
|
||||
|
||||
@@ -10,6 +10,7 @@ import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import FormDialogContent from './FormDialogContent'
|
||||
import { api, SSHServer, SSHServerGroup } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import CompactListItemText from './CompactListItemText'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
@@ -24,7 +25,7 @@ function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
export default function SSHServerGroupDetailsDialog(props: SSHServerGroupDetailsDialogProps) {
|
||||
|
||||
@@ -9,18 +9,18 @@ type SplitPanelProps = {
|
||||
}
|
||||
|
||||
export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: SplitPanelProps) {
|
||||
const [splitRatio, setSplitRatio] = useState(defaultRatio)
|
||||
const [splitDragging, setSplitDragging] = useState(false)
|
||||
const [splitRatio, setSplitRatio] = useState<number>(defaultRatio)
|
||||
const [splitDragging, setSplitDragging] = useState<boolean>(false)
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const container = containerRef.current
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>): void => {
|
||||
const container: HTMLDivElement | null = containerRef.current
|
||||
if (!splitDragging || !container) return
|
||||
const rect = container.getBoundingClientRect()
|
||||
const rect: DOMRect = container.getBoundingClientRect()
|
||||
setSplitRatio(Math.max(0.1, Math.min(0.9, (event.clientX - rect.left) / rect.width)))
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
const handlePointerUp = (): void => {
|
||||
setSplitDragging(false)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: Spli
|
||||
{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `minmax(0, ${splitRatio}fr) 12px minmax(0, ${1 - splitRatio}fr)`,
|
||||
alignItems: 'stretch',
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
userSelect: splitDragging ? 'none' : 'auto',
|
||||
cursor: splitDragging ? 'col-resize' : 'auto',
|
||||
},
|
||||
@@ -40,7 +44,21 @@ export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: Spli
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
>
|
||||
{left}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
'& > *': {
|
||||
flex: '1 1 auto',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{left}
|
||||
</Box>
|
||||
<Box
|
||||
onPointerDown={() => setSplitDragging(true)}
|
||||
sx={{
|
||||
@@ -59,7 +77,21 @@ export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: Spli
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{right}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
'& > *': {
|
||||
flex: '1 1 auto',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{right}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useEffect, useState } from 'react'
|
||||
import CompactListItemText from './CompactListItemText'
|
||||
import FormDialogContent from './FormDialogContent'
|
||||
import { api, UserGroup, UserGroupMember } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
|
||||
type UserGroupDetailsDialogProps = {
|
||||
item: UserGroup | null
|
||||
@@ -25,7 +26,7 @@ function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
function yesNo(value: boolean | undefined): string {
|
||||
|
||||
@@ -17,6 +17,7 @@ import HeaderActionButton from '../components/HeaderActionButton'
|
||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import { AdminAPIKey, api } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import SelectField from '../components/SelectField'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
@@ -25,7 +26,7 @@ function formatUnix(value: number) {
|
||||
if (!value || value <= 0) {
|
||||
return 'Never'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
export default function AdminApiKeysPage() {
|
||||
|
||||
@@ -20,6 +20,7 @@ import PKIClientProfileDetailsDialog from '../components/PKIClientProfileDetails
|
||||
import PKICADetailsDialog from '../components/PKICADetailsDialog'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import { api, PKICA, PKICADetail, PKIClientProfile, PKIClientProfileUpsertPayload, User, UserGroup } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import { AppBrand, useBrand } from '../branding'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
@@ -28,7 +29,7 @@ function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
function parsePermissions(value: string): string[] {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { PKICAEditDialog, PKIIntermediateCADialog, PKIRootCADialog } from '../co
|
||||
import { PKICertImportDialog, PKICertIssueDialog, PKICertRevokeDialog } from '../components/PKICertFormDialogs'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import { ACMEOrder, ACMEOrderCreatePayload, ACMEProfile, ACMEProfileUpsertPayload, api, PKICA, PKICADetail, PKICert, PKICertDetail, PKICertImportPayload, PKICertIssuePayload, PKIIntermediateCARequest, PKIRootCARequest } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import SelectField from '../components/SelectField'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
@@ -34,7 +35,7 @@ function fmt(ts: number): string {
|
||||
if (!ts || ts <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(ts * 1000).toLocaleString()
|
||||
return formatEpochTime(ts)
|
||||
}
|
||||
|
||||
function fmtPKICertActor(cert: PKICert | PKICertDetail | null | undefined): string {
|
||||
|
||||
@@ -22,6 +22,7 @@ import SectionCard from '../components/SectionCard'
|
||||
import SSHCredentialDetailsDialog from '../components/SSHCredentialDetailsDialog'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import { api, SSHCredential, SSHCredentialUpsertPayload } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
type FormState = {
|
||||
@@ -42,7 +43,7 @@ function emptyForm(): FormState {
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (!value || value <= 0) return '-'
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
export default function AdminSSHCredentialsPage() {
|
||||
|
||||
@@ -22,6 +22,7 @@ import SectionCard from '../components/SectionCard'
|
||||
import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetailsDialog'
|
||||
import SSHPrincipalGrantFormDialog, { SSHPrincipalGrantFormState } from '../components/SSHPrincipalGrantFormDialog'
|
||||
import { api, SSHPrincipalGrant, SSHPrincipalGrantUpsertPayload, User, UserGroup } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import Autocomplete from '../components/Autocomplete'
|
||||
import SelectField from '../components/SelectField'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
@@ -36,7 +37,7 @@ function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
function toDateTimeInput(value: number): string {
|
||||
|
||||
@@ -20,6 +20,7 @@ import SSHServerGroupMembersDialog from '../components/SSHServerGroupMembersDial
|
||||
import SSHHostKeysDialog from '../components/SSHHostKeysDialog'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import { api, SSHServer, SSHServerGroup, SSHServerGroupUpsertPayload, SSHServerHostKey, SSHServerUpsertPayload } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
const emptyForm = (): SSHServerFormState => ({
|
||||
@@ -41,7 +42,7 @@ const emptyGroupForm = (): SSHServerGroupFormState => ({
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (!value || value <= 0) return '-'
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
export default function AdminSSHServersPage() {
|
||||
|
||||
@@ -18,12 +18,10 @@ import SSHTranscriptDialog from '../components/SSHTranscriptDialog'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import PaginationControls from '../components/PaginationControls'
|
||||
import { api, SSHAccessProfile, SSHFileTransfer, SSHFileTransferListResponse, SSHServer, SSHSession, SSHSessionListResponse } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
function sessionTitle(item: SSHSession): string {
|
||||
|
||||
@@ -22,12 +22,13 @@ import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetai
|
||||
import SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog'
|
||||
import UserDetailsDialog from '../components/UserDetailsDialog'
|
||||
import { api, SSHPrincipalGrant, SSHUserCA, SSHUserCAIssuance, User } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import Autocomplete from '../components/Autocomplete'
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (!value || value <= 0) { return '-' }
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
function downloadText(filename: string, text: string) {
|
||||
|
||||
@@ -25,13 +25,14 @@ import SSHUserCAFormDialog, { SSHUserCAFormState } from '../components/SSHUserCA
|
||||
import SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import { api, SSHSignUserKeyResponse, SSHUserCA, SSHUserCACreatePayload, SSHUserCASignPayload, SSHUserCAUpdatePayload } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
function downloadText(filename: string, text: string) {
|
||||
|
||||
@@ -23,17 +23,18 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import { api, CertPrincipalBinding, PKICert, PKICertDetail, PrincipalAPIKey, PrincipalProjectRole, Project, ServicePrincipal } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import SelectField from '../components/SelectField'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
function fmt(ts: number): string {
|
||||
if (!ts || ts <= 0) return '-'
|
||||
return new Date(ts * 1000).toLocaleString()
|
||||
return formatEpochTime(ts)
|
||||
}
|
||||
|
||||
function formatUnix(ts: number): string {
|
||||
if (!ts || ts <= 0) return 'Never'
|
||||
return new Date(ts * 1000).toLocaleString()
|
||||
return formatEpochTime(ts)
|
||||
}
|
||||
|
||||
function fmtPKICertActor(cert: PKICert | PKICertDetail | null | undefined): string {
|
||||
|
||||
@@ -20,6 +20,7 @@ import SectionCard from '../components/SectionCard'
|
||||
import TintedPanel from '../components/TintedPanel'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import { api, SubjectPermission, User, UserGroup, UserGroupMember, UserGroupUpsertPayload } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import UserGroupFormDialog, { UserGroupFormState } from '../components/UserGroupFormDialog'
|
||||
|
||||
const projectCreatePermission: string = 'project.create'
|
||||
@@ -35,7 +36,7 @@ const groupOptionPermissions: { permission: string, label: string, chip: string
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (!value || value <= 0) return '-'
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
function defaultUserGroupForm(): UserGroupFormState {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import { api, APIKey } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
|
||||
@@ -30,7 +31,7 @@ function formatUnix(value: number) {
|
||||
if (!value || value <= 0) {
|
||||
return 'Never'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
|
||||
@@ -22,13 +22,14 @@ import PKICertDetailsDialog from '../components/PKICertDetailsDialog'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import { api, PKICADetail, PKIClientIssueResponse, PKIClientIssuance, PKIClientProfile, PKICertDetail } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
function parseCSV(value: string): string[] {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '../api'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import { formatEpochTime } from '../time'
|
||||
|
||||
type LayoutContext = {
|
||||
user: User | null
|
||||
@@ -58,7 +59,7 @@ function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
function DashboardCard(props: DashboardCardProps) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { api, DockerManifestDetail, DockerTagInfo, Project, Repo } from '../api'
|
||||
import { formatCommitTime } from '../time'
|
||||
import ContextToolbar from '../components/ContextToolbar'
|
||||
import ProjectPageFrame from '../components/ProjectPageFrame'
|
||||
import RepoSubNav from '../components/RepoSubNav'
|
||||
@@ -431,7 +432,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
||||
<Typography variant="body2">OS: {detail.config?.os || 'n/a'}</Typography>
|
||||
<Typography variant="body2">Architecture: {detail.config?.architecture || 'n/a'}</Typography>
|
||||
<Typography variant="body2">
|
||||
Created: {detail.config?.created ? new Date(detail.config.created).toLocaleString() : 'n/a'}
|
||||
Created: {detail.config?.created ? formatCommitTime(detail.config.created) : 'n/a'}
|
||||
</Typography>
|
||||
<Divider sx={{ my: 0.5 }} />
|
||||
<Typography variant="body2">Layers: {detail.layers?.length || 0}</Typography>
|
||||
|
||||
@@ -3,6 +3,7 @@ import Dialog from '../components/ModalDialog'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { api, BranchInfo, Project, Repo, RepoBranchesInfo } from '../api'
|
||||
import { CommitIdentity, splitCommitMessage } from '../components/CommitMeta'
|
||||
import ContextToolbar from '../components/ContextToolbar'
|
||||
import ProjectPageFrame from '../components/ProjectPageFrame'
|
||||
import RepoSubNav from '../components/RepoSubNav'
|
||||
@@ -263,17 +264,15 @@ export default function RepoGitBranchesPage() {
|
||||
</Typography>
|
||||
) : null}
|
||||
{branch.last_hash ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{branch.last_hash.slice(0, 8)} • {branch.last_author} • {branch.last_when}
|
||||
</Typography>
|
||||
<CommitIdentity author={branch.last_author} when={branch.last_when} hash={branch.last_hash} />
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No commits
|
||||
</Typography>
|
||||
)}
|
||||
{branch.last_message ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{branch.last_message}
|
||||
<Typography variant="body2" color="text.secondary" noWrap>
|
||||
{splitCommitMessage(branch.last_message).subject}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { Box, Divider, IconButton, List, ListItem, ListItemButton, ListItemText, Paper, Tab, Tabs, Tooltip, Typography } from '@mui/material'
|
||||
import { Box, Chip, Divider, IconButton, List, ListItem, ListItemButton, ListItemText, Paper, Tab, Tabs, Tooltip, Typography } from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { 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 { CommitIdentity, CommitMessage } from '../components/CommitMeta'
|
||||
import LazyCodeBlock from '../components/LazyCodeBlock'
|
||||
import ContextToolbar from '../components/ContextToolbar'
|
||||
import ProjectPageFrame from '../components/ProjectPageFrame'
|
||||
import RepoSubNav from '../components/RepoSubNav'
|
||||
import SplitPanel from '../components/SplitPanel'
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'
|
||||
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'
|
||||
import ChangeCircleOutlinedIcon from '@mui/icons-material/ChangeCircleOutlined'
|
||||
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
|
||||
import ChevronLeftOutlinedIcon from '@mui/icons-material/ChevronLeftOutlined'
|
||||
import ChevronRightOutlinedIcon from '@mui/icons-material/ChevronRightOutlined'
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||
|
||||
export default function RepoGitCommitDetailPage() {
|
||||
const { projectId, repoId, hash } = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const [detail, setDetail] = useState<RepoCommitDetail | null>(null)
|
||||
const [project, setProject] = useState<Project | null>(null)
|
||||
const [repo, setRepo] = useState<Repo | null>(null)
|
||||
@@ -26,9 +26,6 @@ export default function RepoGitCommitDetailPage() {
|
||||
const [fileDiff, setFileDiff] = useState<string>('')
|
||||
const [selectedFile, setSelectedFile] = useState<string>('')
|
||||
const [diffView, setDiffView] = useState<'unified' | 'split'>('unified')
|
||||
const [filesOpen, setFilesOpen] = useState(true)
|
||||
const [showLeft, setShowLeft] = useState(true)
|
||||
const [showRight, setShowRight] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!repoId || !hash) return
|
||||
@@ -107,7 +104,7 @@ export default function RepoGitCommitDetailPage() {
|
||||
const lines = raw.replace(/\r\n/g, '\n').split('\n')
|
||||
let leftLine = 0
|
||||
let rightLine = 0
|
||||
const hunkRe = /^@@ -(\d+)(?:,\d+)? \\+(\\d+)(?:,\\d+)? @@/
|
||||
const hunkRe = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/
|
||||
const rows = lines.map((line) => {
|
||||
const prefix = line.charAt(0)
|
||||
const isAdd = prefix === '+'
|
||||
@@ -140,146 +137,58 @@ export default function RepoGitCommitDetailPage() {
|
||||
isMeta
|
||||
}
|
||||
})
|
||||
const columns =
|
||||
showLeft && showRight ? 'minmax(0, 1fr) minmax(0, 1fr)' : showLeft ? 'minmax(0, 1fr) 0' : '0 minmax(0, 1fr)'
|
||||
const hiddenCol = {
|
||||
visibility: 'hidden',
|
||||
width: 0,
|
||||
minWidth: 0,
|
||||
maxWidth: 0,
|
||||
padding: 0,
|
||||
border: '0',
|
||||
overflow: 'hidden'
|
||||
}
|
||||
return (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
const monoFont = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
const renderSide = (side: 'left' | 'right', label: string) => (
|
||||
<Box sx={{ width: '100%', overflowX: 'auto', fontFamily: monoFont, fontSize: 12 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: columns,
|
||||
gap: 0,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
fontSize: 12,
|
||||
width: '100%'
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
...(showLeft ? {} : hiddenCol)
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>
|
||||
OLD
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{showRight ? (
|
||||
<IconButton size="small" onClick={() => setShowLeft(false)} aria-label="Hide left">
|
||||
<ChevronLeftOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton size="small" onClick={() => setShowRight(true)} aria-label="Show right">
|
||||
<ChevronRightOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
...(showRight ? {} : hiddenCol)
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>
|
||||
NEW
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{showLeft ? (
|
||||
<IconButton size="small" onClick={() => setShowRight(false)} aria-label="Hide right">
|
||||
<ChevronRightOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton size="small" onClick={() => setShowLeft(true)} aria-label="Show left">
|
||||
<ChevronLeftOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ overflowX: 'auto', ...(showLeft ? {} : hiddenCol) }}>
|
||||
{rows.map((row, idx) => (
|
||||
<Box
|
||||
key={`left-${idx}`}
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
whiteSpace: 'pre',
|
||||
backgroundColor: row.isDel ? 'rgba(239,68,68,0.08)' : row.isHunk || row.isMeta ? 'rgba(59,130,246,0.08)' : 'transparent'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '3ch minmax(0, 1fr)', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
color: '#9ca3af',
|
||||
textAlign: 'right',
|
||||
userSelect: 'none',
|
||||
backgroundColor: 'rgba(148,163,184,0.08)',
|
||||
borderRight: '1px solid rgba(148,163,184,0.25)',
|
||||
pr: 0.5
|
||||
}}
|
||||
>
|
||||
{row.leftNum || ''}
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, whiteSpace: 'pre' }}>{row.left}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ overflowX: 'auto', ...(showRight ? {} : hiddenCol) }}>
|
||||
{rows.map((row, idx) => (
|
||||
<Box
|
||||
key={`right-${idx}`}
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
whiteSpace: 'pre',
|
||||
backgroundColor: row.isAdd ? 'rgba(16,185,129,0.08)' : row.isHunk || row.isMeta ? 'rgba(59,130,246,0.08)' : 'transparent'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '3ch minmax(0, 1fr)', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
color: '#9ca3af',
|
||||
textAlign: 'right',
|
||||
userSelect: 'none',
|
||||
backgroundColor: 'rgba(148,163,184,0.08)',
|
||||
borderRight: '1px solid rgba(148,163,184,0.25)',
|
||||
pr: 0.5
|
||||
}}
|
||||
>
|
||||
{row.rightNum || ''}
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, whiteSpace: 'pre' }}>{row.right}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
{rows.map((row, idx) => {
|
||||
const text = side === 'left' ? row.left : row.right
|
||||
const num = side === 'left' ? row.leftNum : row.rightNum
|
||||
const changed = side === 'left' ? row.isDel : row.isAdd
|
||||
const changedBg = side === 'left' ? 'rgba(239,68,68,0.08)' : 'rgba(16,185,129,0.08)'
|
||||
const backgroundColor = changed ? changedBg : row.isHunk || row.isMeta ? 'rgba(59,130,246,0.08)' : 'transparent'
|
||||
return (
|
||||
<Box key={`${side}-${idx}`} sx={{ px: 1, py: 0.25, whiteSpace: 'pre', backgroundColor }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '3ch minmax(0, 1fr)', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
color: '#9ca3af',
|
||||
textAlign: 'right',
|
||||
userSelect: 'none',
|
||||
backgroundColor: 'rgba(148,163,184,0.08)',
|
||||
borderRight: '1px solid rgba(148,163,184,0.25)',
|
||||
pr: 0.5
|
||||
}}
|
||||
>
|
||||
{num || ''}
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, whiteSpace: 'pre' }}>{text}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
return (
|
||||
<SplitPanel
|
||||
defaultRatio={0.5}
|
||||
sx={{ height: 'auto', minHeight: 0 }}
|
||||
left={renderSide('left', 'OLD')}
|
||||
right={renderSide('right', 'NEW')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderStatusIcon = (status: string) => {
|
||||
@@ -289,11 +198,37 @@ export default function RepoGitCommitDetailPage() {
|
||||
return <ChangeCircleOutlinedIcon fontSize="small" color="info" />
|
||||
}
|
||||
|
||||
const fileCodePath = (filePath: string): string => {
|
||||
var params: URLSearchParams
|
||||
var ref: string
|
||||
|
||||
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 => {
|
||||
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)}`
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectPageFrame
|
||||
projectId={projectId ?? ''}
|
||||
project={project}
|
||||
currentLabel="Commit"
|
||||
fillHeight
|
||||
contentSx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, minWidth: 0 }}
|
||||
contextToolbar={
|
||||
<ContextToolbar
|
||||
kind="Repository"
|
||||
@@ -308,40 +243,25 @@ export default function RepoGitCommitDetailPage() {
|
||||
{loadError}
|
||||
</Typography>
|
||||
) : null}
|
||||
{detail ? (
|
||||
<Paper sx={{ p: 2, mb: 1 }}>
|
||||
<Typography variant="subtitle1">{detail.message}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{detail.hash}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{detail.author} • {detail.email} • {detail.when}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : null}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: `${filesOpen ? '280px' : '44px'} minmax(0, 1fr)`, gap: 1 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: filesOpen ? 'space-between' : 'center', mb: 1 }}>
|
||||
{filesOpen ? <Typography variant="subtitle2">Files</Typography> : null}
|
||||
<IconButton size="small" onClick={() => setFilesOpen((prev) => !prev)}>
|
||||
{filesOpen ? <ChevronLeftIcon fontSize="small" /> : <ChevronRightIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
<SplitPanel defaultRatio={0.28} sx={{ flex: 1, minHeight: 0 }} left={(
|
||||
<Paper sx={{ p: 2, height: '100%', minWidth: 0, minHeight: 0, overflow: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="subtitle2">Files</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
<List dense sx={{ minWidth: '100%', width: 'max-content' }}>
|
||||
{detail?.files.map((file) => (
|
||||
<ListItem key={file.path} divider disablePadding>
|
||||
<ListItemButton onClick={() => handleFileDiff(file.path)}>
|
||||
<ListItemButton onClick={() => handleFileDiff(file.path)} sx={{ minWidth: 'max-content' }}>
|
||||
<ListItemText
|
||||
sx={{ minWidth: 0 }}
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
|
||||
<Tooltip title={filesOpen ? file.status : `${file.status} • ${file.path}`}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 'max-content' }}>
|
||||
<Tooltip title={file.status}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>{renderStatusIcon(file.status)}</Box>
|
||||
</Tooltip>
|
||||
{filesOpen ? (
|
||||
<Typography variant="body2" sx={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{file.path}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{file.path}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
@@ -350,36 +270,109 @@ export default function RepoGitCommitDetailPage() {
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{selectedFile ? `File Diff: ${selectedFile}` : 'Commit Diff'}
|
||||
</Typography>
|
||||
<Tabs
|
||||
value={diffView}
|
||||
onChange={(_, value) => setDiffView(value)}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
<Tab label="Unified" value="unified" />
|
||||
<Tab label="Split" value="split" />
|
||||
</Tabs>
|
||||
{diffView === 'split' ? null : null}
|
||||
{selectedFile ? (
|
||||
fileDiff ? (
|
||||
diffView === 'unified' ? (
|
||||
<LazyCodeBlock code={fileDiff} language="diff" showLineNumbers />
|
||||
)} right={(
|
||||
<Paper sx={{ p: 2, height: '100%', minWidth: 0, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
|
||||
<Tooltip title="Back">
|
||||
<IconButton size="small" onClick={() => navigate(-1)} aria-label="Back">
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<CommitIdentity
|
||||
author={detail?.author}
|
||||
email={detail?.email}
|
||||
when={detail?.when}
|
||||
hash={detail?.hash}
|
||||
hashHref={detail?.hash ? codeViewPath(detail.hash) : undefined}
|
||||
hashChip
|
||||
showEmail
|
||||
copy
|
||||
copyHash
|
||||
/>
|
||||
</Box>
|
||||
{detail?.message ? (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<CommitMessage message={detail.message} />
|
||||
</Box>
|
||||
) : null}
|
||||
<Divider sx={{ mt: 0.5, mb: 0.5 }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Tabs value={diffView} onChange={(_, value) => setDiffView(value)}>
|
||||
<Tab label="Unified" value="unified" />
|
||||
<Tab label="Split" value="split" />
|
||||
</Tabs>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
|
||||
{selectedFile ? (
|
||||
<>
|
||||
<Tooltip title="Back to commit diff">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSelectedFile('')
|
||||
setFileDiff('')
|
||||
}}
|
||||
aria-label="Back to commit diff"
|
||||
>
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={selectedFile}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color="text.secondary"
|
||||
component={RouterLink}
|
||||
to={fileCodePath(selectedFile)}
|
||||
sx={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
|
||||
>
|
||||
{selectedFile}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
renderSplitDiff(fileDiff)
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
||||
Commit Diff
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{detail?.parents && detail.parents.length > 0 ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.75, mb: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{detail.parents.length > 1 ? 'Compared with first parent' : 'Compared with parent'}
|
||||
</Typography>
|
||||
{detail.parents.map((parentHash) => (
|
||||
<Tooltip key={parentHash} title={parentHash}>
|
||||
<Chip
|
||||
component={RouterLink}
|
||||
to={commitPath(parentHash)}
|
||||
label={parentHash.slice(0, 8)}
|
||||
size="small"
|
||||
clickable
|
||||
sx={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
{selectedFile ? (
|
||||
fileDiff ? (
|
||||
diffView === 'unified' ? (
|
||||
<LazyCodeBlock code={fileDiff} language="diff" showLineNumbers />
|
||||
) : (
|
||||
renderSplitDiff(fileDiff)
|
||||
)
|
||||
) : (
|
||||
<Typography>No diff available.</Typography>
|
||||
)
|
||||
) : diff ? (
|
||||
diffView === 'unified' ? <LazyCodeBlock code={diff} language="diff" showLineNumbers /> : renderSplitDiff(diff)
|
||||
) : (
|
||||
<Typography>No diff available.</Typography>
|
||||
)
|
||||
) : diff ? (
|
||||
diffView === 'unified' ? <LazyCodeBlock code={diff} language="diff" showLineNumbers /> : renderSplitDiff(diff)
|
||||
) : (
|
||||
<Typography>No diff available.</Typography>
|
||||
)}
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)} />
|
||||
</ProjectPageFrame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Box, Button, Divider, List, ListItem, ListItemButton, ListItemText, Men
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { api, Project, Repo, RepoCommit } from '../api'
|
||||
import { CommitIdentity, splitCommitMessage } from '../components/CommitMeta'
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree'
|
||||
import Autocomplete from '../components/Autocomplete'
|
||||
import ContextToolbar from '../components/ContextToolbar'
|
||||
import ProjectPageFrame from '../components/ProjectPageFrame'
|
||||
@@ -110,7 +112,7 @@ export default function RepoGitCommitsPage() {
|
||||
<Paper sx={{ p: 2, mb: 1, backgroundColor: 'transparent' }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Box sx={{ minWidth: 220, flex: '0 0 auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle2">Branch</Typography>
|
||||
<AccountTreeIcon />
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={branches}
|
||||
@@ -152,7 +154,10 @@ export default function RepoGitCommitsPage() {
|
||||
component={Link}
|
||||
to={`/projects/${projectId}/repos/${repoId}/commits/${commit.hash}?ref=${encodeURIComponent(ref)}`}
|
||||
>
|
||||
<ListItemText primary={commit.message} secondary={`${commit.hash.slice(0, 7)} • ${commit.author} • ${commit.when}`} />
|
||||
<ListItemText
|
||||
primary={splitCommitMessage(commit.message).subject || commit.message}
|
||||
secondary={<CommitIdentity author={commit.author} email={commit.email} when={commit.when} hash={commit.hash} />}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
@@ -192,7 +197,10 @@ export default function RepoGitCommitsPage() {
|
||||
<List dense>
|
||||
{compareCommits.map((commit) => (
|
||||
<ListItem key={commit.hash} divider>
|
||||
<ListItemText primary={commit.message} secondary={`${commit.author} • ${commit.when}`} />
|
||||
<ListItemText
|
||||
primary={splitCommitMessage(commit.message).subject || commit.message}
|
||||
secondary={<CommitIdentity author={commit.author} email={commit.email} when={commit.when} />}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Box, Button, Divider, IconButton, List, ListItem, ListItemButton, ListItemText, Paper, Popover, Tab, Tabs, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { Box, Button, Chip, Divider, IconButton, List, ListItem, ListItemButton, ListItemText, Paper, Popover, Tab, Tabs, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import { Link as RouterLink, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { api, Project, Repo, RepoCommit, RepoTreeEntry } from '../api'
|
||||
import { CommitIdentity, CommitMessage, splitCommitMessage } from '../components/CommitMeta'
|
||||
import Autocomplete from '../components/Autocomplete'
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree'
|
||||
import CodeIcon from '@mui/icons-material/Code'
|
||||
@@ -48,6 +49,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
const [imagePreviewRef, setImagePreviewRef] = useState('')
|
||||
const [history, setHistory] = useState<RepoCommit[]>([])
|
||||
const [diff, setDiff] = useState<string>('')
|
||||
const [diffRange, setDiffRange] = useState<{ from: string; to: string }>({ from: '', to: '' })
|
||||
const [previewTab, setPreviewTab] = useState<'content' | 'history' | 'diff'>('content')
|
||||
const [selectedCommit, setSelectedCommit] = useState<RepoCommit | null>(null)
|
||||
const [readmeContent, setReadmeContent] = useState<string>('')
|
||||
@@ -59,7 +61,11 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
const initProjectRef = useRef<string | null>(null)
|
||||
const lastTreeKey = useRef<string>('')
|
||||
const refValue = useRef<string>('')
|
||||
const lastQueryFileKey = useRef<string>('')
|
||||
const lastQueryDirKey = useRef<string>('')
|
||||
const urlRef = searchParams.get('ref') || ''
|
||||
const urlFilePath = searchParams.get('path') || ''
|
||||
const urlDirPath = searchParams.get('dir') || ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!repoId) return
|
||||
@@ -119,6 +125,23 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
refValue.current = ref
|
||||
}, [ref])
|
||||
|
||||
useEffect(() => {
|
||||
var params: URLSearchParams
|
||||
var href: string
|
||||
|
||||
if (!projectId || !repoId) return
|
||||
if (typeof window === 'undefined') return
|
||||
params = new URLSearchParams()
|
||||
if (ref) params.set('ref', ref)
|
||||
if (selectedFile) {
|
||||
params.set('path', selectedFile)
|
||||
} else if (path) {
|
||||
params.set('dir', path)
|
||||
}
|
||||
href = `/projects/${projectId}/repos/${repoId}${params.toString() ? `?${params.toString()}` : ''}`
|
||||
window.sessionStorage.setItem(`repo-nav:${repoId}:code`, href)
|
||||
}, [projectId, repoId, ref, selectedFile, path])
|
||||
|
||||
useEffect(() => {
|
||||
if (isImageFileSelected && selectedFile) {
|
||||
setImagePreviewRef(ref)
|
||||
@@ -217,6 +240,75 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
})
|
||||
}, [repoId, ref, tree, selectedFile, readmePath, readmeKind])
|
||||
|
||||
const openFilePath = async (filePath: string): Promise<void> => {
|
||||
var parentPath: string
|
||||
var blob: { content: string }
|
||||
var list: RepoCommit[]
|
||||
var nextHistory: RepoCommit[]
|
||||
|
||||
if (!repoId || !filePath) return
|
||||
parentPath = filePath.split('/').slice(0, -1).join('/')
|
||||
setPath(parentPath)
|
||||
setPathSegments(parentPath ? parentPath.split('/').filter(Boolean) : [])
|
||||
setSelectedFile(filePath)
|
||||
setPreviewTab('content')
|
||||
setDiff('')
|
||||
setFileQuery('')
|
||||
setIsImageFileSelected(isImageFile(filePath))
|
||||
setImagePreviewRef(ref)
|
||||
if (!isImageFile(filePath)) {
|
||||
try {
|
||||
blob = await api.getRepoBlob(repoId, ref || undefined, filePath)
|
||||
setContent(blob.content)
|
||||
} catch {
|
||||
setContent('')
|
||||
}
|
||||
} else {
|
||||
setContent('')
|
||||
}
|
||||
try {
|
||||
list = await api.listRepoFileHistory(repoId, ref || undefined, filePath)
|
||||
nextHistory = Array.isArray(list) ? list : []
|
||||
setHistory(nextHistory)
|
||||
setSelectedCommit(nextHistory.length ? nextHistory[0] : null)
|
||||
} catch {
|
||||
setHistory([])
|
||||
setSelectedCommit(null)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
var key: string
|
||||
|
||||
if (!repoId || !urlFilePath) return
|
||||
if (!ref && urlRef) return
|
||||
key = `${repoId}:${ref}:${urlFilePath}`
|
||||
if (lastQueryFileKey.current === key) return
|
||||
lastQueryFileKey.current = key
|
||||
openFilePath(urlFilePath)
|
||||
}, [repoId, ref, urlRef, urlFilePath])
|
||||
|
||||
useEffect(() => {
|
||||
var key: string
|
||||
|
||||
if (!repoId || !urlDirPath || urlFilePath) return
|
||||
if (!ref && urlRef) return
|
||||
key = `${repoId}:${ref}:${urlDirPath}`
|
||||
if (lastQueryDirKey.current === key) return
|
||||
lastQueryDirKey.current = key
|
||||
setPath(urlDirPath)
|
||||
setPathSegments(urlDirPath.split('/').filter(Boolean))
|
||||
setSelectedFile('')
|
||||
setContent('')
|
||||
setFileQuery('')
|
||||
setIsImageFileSelected(false)
|
||||
setImagePreviewRef('')
|
||||
setPreviewTab('content')
|
||||
setDiff('')
|
||||
setHistory([])
|
||||
setSelectedCommit(null)
|
||||
}, [repoId, ref, urlRef, urlDirPath, urlFilePath])
|
||||
|
||||
const handleOpen = async (entry: RepoTreeEntry) => {
|
||||
if (!repoId) return
|
||||
if (entry.type === 'dir') {
|
||||
@@ -232,31 +324,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
setSelectedCommit(null)
|
||||
return
|
||||
}
|
||||
setSelectedFile(entry.path)
|
||||
setPreviewTab('content')
|
||||
setDiff('')
|
||||
setIsImageFileSelected(isImageFile(entry.path))
|
||||
setImagePreviewRef(ref)
|
||||
if (!isImageFile(entry.path)) {
|
||||
try {
|
||||
// get the content of the select file and set it to the content view
|
||||
const blob = await api.getRepoBlob(repoId, ref || undefined, entry.path)
|
||||
setContent(blob.content)
|
||||
} catch {
|
||||
setContent('')
|
||||
}
|
||||
} else {
|
||||
setContent('')
|
||||
}
|
||||
try {
|
||||
const list = await api.listRepoFileHistory(repoId, ref || undefined, entry.path)
|
||||
const nextHistory = Array.isArray(list) ? list : []
|
||||
setHistory(nextHistory)
|
||||
setSelectedCommit(nextHistory.length ? nextHistory[0] : null)
|
||||
} catch {
|
||||
setHistory([])
|
||||
setSelectedCommit(null)
|
||||
}
|
||||
await openFilePath(entry.path)
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
@@ -405,11 +473,29 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDiff = async () => {
|
||||
// Fetch the diff for the file as it changed in the selected commit (the most
|
||||
// recent commit touching the file by default, or whatever was picked in the
|
||||
// History tab). Refetches whenever the selected commit changes while the Diff
|
||||
// tab is open. Using the branch ref here would diff only the branch tip, which
|
||||
// is empty for any file the tip commit did not touch.
|
||||
useEffect(() => {
|
||||
if (previewTab !== 'diff') return
|
||||
if (!repoId || !selectedFile) return
|
||||
const result = await api.getRepoFileDiff(repoId, ref || undefined, selectedFile)
|
||||
setDiff(result.diff || '')
|
||||
}
|
||||
let cancelled = false
|
||||
const diffRef = selectedCommit?.hash || ref
|
||||
api.getRepoFileDiff(repoId, diffRef || undefined, selectedFile)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
setDiff(result.diff || '')
|
||||
setDiffRange({ from: result.from || '', to: result.to || '' })
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setDiff('')
|
||||
setDiffRange({ from: '', to: '' })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [previewTab, repoId, selectedFile, selectedCommit, ref])
|
||||
|
||||
const copyText = async (text: string) => {
|
||||
let ok = false
|
||||
@@ -512,6 +598,14 @@ 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 resolveReadmeAsset = (src: string) => {
|
||||
if (!src) return ''
|
||||
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:')) return src
|
||||
@@ -612,7 +706,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
}
|
||||
|
||||
const renderPathBreadcrumb = () => {
|
||||
return (<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0 }}>
|
||||
return (<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'nowrap', gap: 0 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleBreadcrumb('')}
|
||||
@@ -661,6 +755,8 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
projectId={projectId ?? ''}
|
||||
project={project}
|
||||
currentLabel={repo?.name || 'Repository'}
|
||||
fillHeight
|
||||
contentSx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, minWidth: 0 }}
|
||||
contextToolbar={
|
||||
<ContextToolbar
|
||||
kind="Repository"
|
||||
@@ -676,11 +772,10 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
|
||||
<SplitPanel defaultRatio={0.25} left={(
|
||||
<TintedPanel>
|
||||
<Box sx={{ display: 'flex', alignItems: 'stretch', gap: 1, mb: 1 }}>
|
||||
<Paper sx={{ p: 1, display: 'flex', alignItems: 'center', gap: 1, minWidth: 240, backgroundColor: 'transparent' }}>
|
||||
<SplitPanel defaultRatio={0.30} sx={{ flex: 1, minHeight: 0 }} left={(
|
||||
<TintedPanel sx={{ minWidth: 0, minHeight: 0, height: '100%', overflow: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'stretch' }}>
|
||||
<Paper sx={{ p: 1, display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, width: '100%', backgroundColor: 'transparent' }}>
|
||||
<AccountTreeIcon />
|
||||
{branches.length ? (
|
||||
<Autocomplete
|
||||
@@ -700,11 +795,14 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
sx={{ minWidth: 180 }}
|
||||
/>
|
||||
) : null}
|
||||
{renderClonePopover()}
|
||||
<Box sx={{ marginLeft: 'auto' }}>
|
||||
{renderClonePopover()}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<PageAlert severity="warning" message={treeError} onClose={() => setTreeError(null)} sx={{ mb: 1 }} />
|
||||
|
||||
<List dense>
|
||||
<Box sx={{ px: 1, pb: 1 }}>
|
||||
<TextField
|
||||
@@ -759,27 +857,23 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
</List>
|
||||
</TintedPanel>
|
||||
)} right={(
|
||||
<TintedPanel>
|
||||
<TintedPanel sx={{ height: '100%', minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
{renderPathBreadcrumb()}
|
||||
</Box>
|
||||
{selectedFile ? (
|
||||
<Box sx={{ mt: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selectedCommit?.author || 'Unknown'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ flex: 1, minWidth: 0 }}>
|
||||
{selectedCommit?.message || 'No commit message'}
|
||||
</Typography>
|
||||
<Tooltip title={selectedCommit?.hash || ''}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ marginLeft: 'auto', whiteSpace: 'nowrap' }}>
|
||||
{selectedCommit?.hash ? selectedCommit.hash.slice(0, 8) : ''}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{selectedCommit?.when || ''}
|
||||
</Typography>
|
||||
<CommitIdentity
|
||||
author={selectedCommit?.author}
|
||||
email={selectedCommit?.email}
|
||||
when={selectedCommit?.when}
|
||||
hash={selectedCommit?.hash}
|
||||
hashHref={selectedCommit?.hash ? commitDetailPath(selectedCommit.hash) : undefined}
|
||||
hashChip
|
||||
copyHash
|
||||
/>
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<CommitMessage message={selectedCommit?.message || ''} inline />
|
||||
</Box>
|
||||
<Divider sx={{ mt: 0.5, mb: 0.5 }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@@ -788,7 +882,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
onChange={(_, value) => {
|
||||
setPreviewTab(value)
|
||||
if (value === 'history' && history.length === 0) handleHistory()
|
||||
if (value === 'diff' && diff === '') handleDiff()
|
||||
}}
|
||||
>
|
||||
<Tab label="Code" value="content" />
|
||||
@@ -859,7 +952,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
</Box>
|
||||
{previewTab === 'content' ? (
|
||||
isImageFileSelected ? (
|
||||
<Paper variant="outlined" sx={{ p: 1, mt: 1, backgroundColor: 'transparent' }}>
|
||||
<Paper variant="outlined" sx={{ p: 1, mt: 1, minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={buildBlobRawUrl(selectedFile, imagePreviewRef || ref)}
|
||||
@@ -868,7 +961,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
/>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper variant="outlined" sx={{ p: 1, pr: 0.5, mt: 1, backgroundColor: 'transparent' }}>
|
||||
<Paper variant="outlined" sx={{ p: 1, pr: 0.5, mt: 1, minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }}>
|
||||
<LazyCodeBlock code={content} language={detectLanguage(selectedFile)} showLineNumbers />
|
||||
</Paper>
|
||||
)
|
||||
@@ -881,18 +974,15 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
<ListItem key={commit.hash} divider disablePadding>
|
||||
<ListItemButton onClick={() => handleHistorySelect(commit.hash)}>
|
||||
<ListItemText
|
||||
primary={commit.message}
|
||||
primary={splitCommitMessage(commit.message).subject || commit.message}
|
||||
secondary={
|
||||
<Box component="span" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }} >
|
||||
<Tooltip title={commit.hash}>
|
||||
<Typography variant="body2" color="text.secondary" component="span">
|
||||
{commit.hash.slice(0, 8)}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
<Typography variant="body2" color="text.secondary" component="span">
|
||||
• {commit.author} • {commit.when}
|
||||
</Typography>
|
||||
</Box>
|
||||
<CommitIdentity
|
||||
author={commit.author}
|
||||
email={commit.email}
|
||||
when={commit.when}
|
||||
hash={commit.hash}
|
||||
hashHref={commitDetailPath(commit.hash)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ListItemButton>
|
||||
@@ -908,9 +998,32 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
) : null}
|
||||
{previewTab === 'diff' ? (
|
||||
diff ? (
|
||||
<Paper variant="outlined" sx={{ p: 1, pr: 0.5, mt: 1, backgroundColor: 'transparent' }}>
|
||||
<LazyCodeBlock code={diff} language="diff" />
|
||||
</Paper>
|
||||
<>
|
||||
{diffRange.from ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Compared with parent
|
||||
</Typography>
|
||||
<Tooltip title={diffRange.from}>
|
||||
<Chip
|
||||
component={RouterLink}
|
||||
to={commitDetailPath(diffRange.from)}
|
||||
label={diffRange.from.slice(0, 8)}
|
||||
size="small"
|
||||
clickable
|
||||
sx={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
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' }}>
|
||||
<LazyCodeBlock code={diff} language="diff" />
|
||||
</Paper>
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
No diff available.
|
||||
@@ -920,7 +1033,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
</Box>
|
||||
) : readmeContent ? (
|
||||
readmeKind === 'markdown' ? (
|
||||
<Paper variant="outlined" sx={{ p: 1, mt: 1, backgroundColor: 'transparent' }}>
|
||||
<Paper variant="outlined" sx={{ p: 1, mt: 1, minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }}>
|
||||
<Box>
|
||||
<Suspense fallback={<Typography variant="body2" color="text.secondary">Loading README...</Typography>}>
|
||||
<RepoMarkdown
|
||||
@@ -932,7 +1045,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
</Box>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper variant="outlined" sx={{ p: 1, mt: 1, backgroundColor: 'transparent' }}>
|
||||
<Paper variant="outlined" sx={{ p: 1, mt: 1, minWidth: 0, maxWidth: '100%', backgroundColor: 'transparent' }}>
|
||||
<LazyCodeBlock code={readmeContent} language="text" showLineNumbers />
|
||||
</Paper>
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useParams, useSearchParams } from 'react-router-dom'
|
||||
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 DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
|
||||
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
|
||||
import FolderIcon from '@mui/icons-material/Folder'
|
||||
@@ -1097,7 +1098,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Build Time: {rpmDetail.build_time ? new Date(rpmDetail.build_time * 1000).toLocaleString() : 'n/a'}
|
||||
Build Time: {rpmDetail.build_time ? formatEpochTime(rpmDetail.build_time) : 'n/a'}
|
||||
</Typography>
|
||||
<Typography variant="body2">Size: {rpmDetail.size ? `${rpmDetail.size} bytes` : 'n/a'}</Typography>
|
||||
{Array.isArray(rpmDetail.requires) && rpmDetail.requires.length ? (
|
||||
@@ -1131,7 +1132,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
{rpmDetail.changelogs.map((item, index) => (
|
||||
<ListItem key={`${item.date}-${index}`} sx={{ display: 'block' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{item.date ? new Date(item.date * 1000).toLocaleString() : 'n/a'}
|
||||
{item.date ? formatEpochTime(item.date) : 'n/a'}
|
||||
{item.author ? ` · ${item.author}` : ''}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
|
||||
@@ -22,13 +22,14 @@ import SSHCertificateIssuedDialog from '../components/SSHCertificateIssuedDialog
|
||||
import SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import { api, SSHPrincipalGrant, SSHSignUserKeyResponse, SSHUserCA, SSHUserCAIssuance, SSHUserCASelfSignPayload } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import SelectField from '../components/SelectField'
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (!value || value <= 0) {
|
||||
return '-'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
async function copyText(text: string): Promise<boolean> {
|
||||
|
||||
@@ -22,6 +22,7 @@ import SectionCard from '../components/SectionCard'
|
||||
import SSHCredentialDetailsDialog from '../components/SSHCredentialDetailsDialog'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import { api, SSHCredential, SSHCredentialUpsertPayload } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
|
||||
type FormState = {
|
||||
@@ -42,7 +43,7 @@ function emptyForm(): FormState {
|
||||
|
||||
function fmt(value: number): string {
|
||||
if (!value || value <= 0) return '-'
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
export default function SSHCredentialsPage() {
|
||||
|
||||
@@ -39,6 +39,7 @@ import PageAlert from '../components/PageAlert'
|
||||
import Autocomplete from '../components/Autocomplete'
|
||||
import PaginationControls from '../components/PaginationControls'
|
||||
import { api, SSHAccessProfile, SSHFileTransfer, SSHFileTransferListResponse, SSHServer, SSHSession, SSHSessionConnectResponse, SSHSessionListResponse } from '../api'
|
||||
import { formatEpochTime } from '../time'
|
||||
|
||||
type SSHSessionStreamMessage = {
|
||||
session_id?: string
|
||||
@@ -138,8 +139,7 @@ function buildInitialTerminalInfo(password?: string, otpCode?: string): Terminal
|
||||
}
|
||||
|
||||
function formatTimestamp(value: number): string {
|
||||
if (!value) { return '-' }
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
return formatEpochTime(value)
|
||||
}
|
||||
|
||||
function formatTransferBytes(value: number): string {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Commit timestamps arrive from the backend as UTC strings formatted like
|
||||
// "2026-06-22 03:12:11Z". formatCommitTime converts such a value to the
|
||||
// viewer's local time with the timezone offset shown, e.g.
|
||||
// "2026-06-22 11:12:11 +08:00". The original string is returned unchanged if it
|
||||
// cannot be parsed.
|
||||
export function formatCommitTime(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
// The backend uses a space between date and time; ISO 8601 needs a 'T'.
|
||||
const date = new Date(trimmed.replace(' ', 'T'))
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return formatLocalWithOffset(date)
|
||||
}
|
||||
|
||||
// formatEpochTime converts a Unix timestamp in seconds to the same local time
|
||||
// with offset format as formatCommitTime, returning '-' for missing values.
|
||||
export function formatEpochTime(seconds: number | null | undefined): string {
|
||||
if (!seconds || seconds <= 0) return '-'
|
||||
const date = new Date(seconds * 1000)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
return formatLocalWithOffset(date)
|
||||
}
|
||||
|
||||
function formatLocalWithOffset(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const year = date.getFullYear()
|
||||
const month = pad(date.getMonth() + 1)
|
||||
const day = pad(date.getDate())
|
||||
const hours = pad(date.getHours())
|
||||
const minutes = pad(date.getMinutes())
|
||||
const seconds = pad(date.getSeconds())
|
||||
// getTimezoneOffset() is minutes that local is behind UTC, so negate it.
|
||||
const offsetMinutes = -date.getTimezoneOffset()
|
||||
const sign = offsetMinutes >= 0 ? '+' : '-'
|
||||
const absMinutes = Math.abs(offsetMinutes)
|
||||
const offsetHours = pad(Math.floor(absMinutes / 60))
|
||||
const offsetMins = pad(absMinutes % 60)
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${sign}${offsetHours}:${offsetMins}`
|
||||
}
|
||||
Reference in New Issue
Block a user