Compare commits

...

9 Commits

45 changed files with 788 additions and 338 deletions
+26 -11
View File
@@ -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
}
+4 -2
View File
@@ -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
View File
@@ -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 {
+3
View File
@@ -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',
+202
View File
@@ -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>
))
) : (
+12 -1
View File
@@ -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) {
+38 -6
View File
@@ -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',
},
@@ -39,8 +43,22 @@ export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: Spli
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<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
},
}}
/>
<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 {
+2 -1
View File
@@ -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[] {
+2 -1
View File
@@ -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 {
+2 -1
View File
@@ -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() {
+2 -4
View File
@@ -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) {
+2 -1
View File
@@ -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 {
+2 -1
View File
@@ -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 {
+2 -1
View File
@@ -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[] {
+2 -1
View File
@@ -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) {
+2 -1
View File
@@ -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>
+4 -5
View File
@@ -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>
+162 -169
View File
@@ -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,96 +137,30 @@ 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'
}
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={{
px: 1,
py: 0.5,
borderBottom: '1px solid',
borderColor: 'divider',
fontWeight: 600
}}
>
<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 sx={{ width: '100%' }}>
<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%'
}}
>
<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 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={{
@@ -241,45 +172,23 @@ export default function RepoGitCommitDetailPage() {
pr: 0.5
}}
>
{row.leftNum || ''}
{num || ''}
</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 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' }}>
<Typography variant="body2" sx={{ whiteSpace: 'nowrap' }}>
{file.path}
</Typography>
) : null}
</Box>
}
/>
@@ -350,19 +270,91 @@ 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 }}
>
)} 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>
{diffView === 'split' ? null : null}
<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>
</>
) : (
<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' ? (
@@ -378,8 +370,9 @@ export default function RepoGitCommitDetailPage() {
) : (
<Typography>No diff available.</Typography>
)}
</Paper>
</Box>
</Paper>
)} />
</ProjectPageFrame>
)
}
+11 -3
View File
@@ -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>
+182 -69
View File
@@ -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)
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}
<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' }}>
<>
{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>
)
+3 -2
View File
@@ -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' }}>
+2 -1
View File
@@ -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> {
+2 -1
View File
@@ -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() {
+2 -2
View File
@@ -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 {
+40
View File
@@ -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}`
}