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"` Email string `json:"email"`
When string `json:"when"` When string `json:"when"`
Message string `json:"message"` Message string `json:"message"`
Parents []string `json:"parents"`
Files []CommitFile `json:"files"` Files []CommitFile `json:"files"`
} }
@@ -294,9 +295,12 @@ func ListFileHistory(repoPath string, ref string, filePath string, limit int) ([
return commits, nil 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 repo *git2go.Repository
var err error
var commit *git2go.Commit var commit *git2go.Commit
var parent *git2go.Commit var parent *git2go.Commit
var commitTree *git2go.Tree var commitTree *git2go.Tree
@@ -307,50 +311,52 @@ func DiffFile(repoPath, ref, path string) (string, error) {
repo, err = git2go.OpenRepository(repoPath) repo, err = git2go.OpenRepository(repoPath)
if err != nil { if err != nil {
return "", err return "", "", "", err
} }
defer repo.Free() defer repo.Free()
commit, err = resolveCommit(repo, ref) commit, err = resolveCommit(repo, ref)
if err != nil { if err != nil {
return "", err return "", "", "", err
} }
defer commit.Free() defer commit.Free()
toHash = commit.Id().String()
if commit.ParentCount() == 0 { if commit.ParentCount() == 0 {
return "", nil return "", "", toHash, nil
} }
parent = commit.Parent(0) parent = commit.Parent(0)
defer parent.Free() defer parent.Free()
fromHash = parent.Id().String()
commitTree, err = commit.Tree() commitTree, err = commit.Tree()
if err != nil { if err != nil {
return "", err return "", "", "", err
} }
defer commitTree.Free() defer commitTree.Free()
parentTree, err = parent.Tree() parentTree, err = parent.Tree()
if err != nil { if err != nil {
return "", err return "", "", "", err
} }
defer parentTree.Free() defer parentTree.Free()
diffOpts, err = git2go.DefaultDiffOptions() diffOpts, err = git2go.DefaultDiffOptions()
if err != nil { if err != nil {
return "", err return "", "", "", err
} }
diffOpts.Pathspec = []string{path} diffOpts.Pathspec = []string{path}
diff, err = repo.DiffTreeToTree(parentTree, commitTree, &diffOpts) diff, err = repo.DiffTreeToTree(parentTree, commitTree, &diffOpts)
if err != nil { if err != nil {
return "", err return "", "", "", err
} }
defer diff.Free() defer diff.Free()
buf, err = diff.ToBuf(git2go.DiffFormatPatch) buf, err = diff.ToBuf(git2go.DiffFormatPatch)
if err != nil { if err != nil {
return "", err return "", "", "", err
} }
return string(buf), nil return string(buf), fromHash, toHash, nil
} }
func GetCommitDetail(repoPath, hash string) (CommitDetail, error) { func GetCommitDetail(repoPath, hash string) (CommitDetail, error) {
@@ -365,8 +371,11 @@ func GetCommitDetail(repoPath, hash string) (CommitDetail, error) {
var findOpts git2go.DiffFindOptions var findOpts git2go.DiffFindOptions
var diff *git2go.Diff var diff *git2go.Diff
var files []CommitFile var files []CommitFile
var parents []string
var parentCount uint
var numDeltas int var numDeltas int
var i int var i int
var p uint
repo, err = git2go.OpenRepository(repoPath) repo, err = git2go.OpenRepository(repoPath)
if err != nil { if err != nil {
@@ -384,6 +393,11 @@ func GetCommitDetail(repoPath, hash string) (CommitDetail, error) {
} }
defer commit.Free() defer commit.Free()
parentCount = commit.ParentCount()
for p = 0; p < parentCount; p++ {
parents = append(parents, commit.ParentId(p).String())
}
commitTree, err = commit.Tree() commitTree, err = commit.Tree()
if err != nil { if err != nil {
return CommitDetail{}, err return CommitDetail{}, err
@@ -454,6 +468,7 @@ func GetCommitDetail(repoPath, hash string) (CommitDetail, error) {
Email: commit.Author().Email, Email: commit.Author().Email,
When: commit.Author().When.UTC().Format(timeFormat), When: commit.Author().When.UTC().Format(timeFormat),
Message: strings.TrimSpace(commit.Message()), Message: strings.TrimSpace(commit.Message()),
Parents: parents,
Files: files, Files: files,
}, nil }, 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 ref string
var path string var path string
var diff string var diff string
var fromHash string
var toHash string
var unlock func() var unlock func()
var ok bool 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") WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "path required")
return return
} }
diff, err = git.DiffFile(repo.Path, ref, path) diff, fromHash, toHash, err = git.DiffFile(repo.Path, ref, path)
if err != nil { if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return 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) { 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 { export interface RepoCommitDetail extends RepoCommit {
parents: string[]
files: { path: string; status: string }[] files: { path: string; status: string }[]
} }
@@ -2027,7 +2028,7 @@ export const api = {
const params = new URLSearchParams() const params = new URLSearchParams()
if (ref) params.set('ref', ref) if (ref) params.set('ref', ref)
params.set('path', path) 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}`), getRepo: (repoId: string) => request<Repo>(`/api/repos/${repoId}`),
getRepoCommitDetail: (repoId: string, hash: string) => getRepoCommitDetail: (repoId: string, hash: string) =>
@@ -8,6 +8,7 @@ import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
import { AuthProvider, AuthProviderGroupMapping, UserGroup } from '../api' import { AuthProvider, AuthProviderGroupMapping, UserGroup } from '../api'
import { formatEpochTime } from '../time'
type AuthProviderDetailsDialogProps = { type AuthProviderDetailsDialogProps = {
item: AuthProvider | null item: AuthProvider | null
@@ -19,7 +20,7 @@ function fmt(value: number | undefined): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
function yesNo(value: boolean): string { function yesNo(value: boolean): string {
+3
View File
@@ -41,6 +41,9 @@ export default function CodeBlock({ code = '', language = 'diff', showLineNumber
padding: 0, padding: 0,
whiteSpace: 'pre', whiteSpace: 'pre',
overflow: 'auto', overflow: 'auto',
width: '100%',
maxWidth: '100%',
minWidth: 0,
backgroundColor: 'transparent', backgroundColor: 'transparent',
lineHeight: 1.4, lineHeight: 1.4,
fontSize: '0.85rem', 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, p: 0,
whiteSpace: 'pre', whiteSpace: 'pre',
overflow: 'auto', overflow: 'auto',
width: '100%',
maxWidth: '100%',
minWidth: 0,
//fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace' //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 FormDialogContent from './FormDialogContent'
import MonospaceTextField from './MonospaceTextField' import MonospaceTextField from './MonospaceTextField'
import { PKICADetail } from '../api' import { PKICADetail } from '../api'
import { formatEpochTime } from '../time'
type PKICADetailsDialogProps = { type PKICADetailsDialogProps = {
item: PKICADetail | null item: PKICADetail | null
@@ -23,7 +24,7 @@ function fmt(value: number | undefined): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
export default function PKICADetailsDialog(props: PKICADetailsDialogProps) { export default function PKICADetailsDialog(props: PKICADetailsDialogProps) {
@@ -9,6 +9,7 @@ import type { ReactNode } from 'react'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
import MonospaceTextField from './MonospaceTextField' import MonospaceTextField from './MonospaceTextField'
import { PKICertDetail } from '../api' import { PKICertDetail } from '../api'
import { formatEpochTime } from '../time'
type PKICertDetailsDialogProps = { type PKICertDetailsDialogProps = {
item: PKICertDetail | null item: PKICertDetail | null
@@ -33,7 +34,7 @@ function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
export default function PKICertDetailsDialog(props: PKICertDetailsDialogProps) { export default function PKICertDetailsDialog(props: PKICertDetailsDialogProps) {
@@ -6,6 +6,7 @@ import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
import { PKIClientProfile } from '../api' import { PKIClientProfile } from '../api'
import { formatEpochTime } from '../time'
type PKIClientProfileDetailsDialogProps = { type PKIClientProfileDetailsDialogProps = {
item: PKIClientProfile | null item: PKIClientProfile | null
@@ -17,7 +18,7 @@ function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
export default function PKIClientProfileDetailsDialog(props: PKIClientProfileDetailsDialogProps) { export default function PKIClientProfileDetailsDialog(props: PKIClientProfileDetailsDialogProps) {
@@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import SectionCard from './SectionCard' import SectionCard from './SectionCard'
import { Project } from '../api' import { Project } from '../api'
import { formatEpochTime } from '../time'
import { ReactNode } from 'react' import { ReactNode } from 'react'
type ProjectOverviewCardProps = { type ProjectOverviewCardProps = {
@@ -24,7 +25,7 @@ export default function ProjectOverviewCard({ project, actions }: ProjectOvervie
) : null} ) : null}
{project.created_at ? ( {project.created_at ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Created: {new Date(project.created_at * 1000).toLocaleString()} Created: {formatEpochTime(project.created_at)}
</Typography> </Typography>
) : null} ) : null}
{project.updated_by_name || project.updated_by ? ( {project.updated_by_name || project.updated_by ? (
@@ -34,7 +35,7 @@ export default function ProjectOverviewCard({ project, actions }: ProjectOvervie
) : null} ) : null}
{project.updated_at ? ( {project.updated_at ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Last updated: {new Date(project.updated_at * 1000).toLocaleString()} Last updated: {formatEpochTime(project.updated_at)}
</Typography> </Typography>
) : null} ) : null}
</Box> </Box>
@@ -9,6 +9,7 @@ import FormDialogContent from './FormDialogContent'
import PageAlert from './PageAlert' import PageAlert from './PageAlert'
import ContentCopyIcon from '@mui/icons-material/ContentCopy' import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import { RpmMirrorRun } from '../api' import { RpmMirrorRun } from '../api'
import { formatEpochTime } from '../time'
type RepoRpmDirectoryStatusDialogProps = { type RepoRpmDirectoryStatusDialogProps = {
open: boolean open: boolean
@@ -114,7 +115,7 @@ export default function RepoRpmDirectoryStatusDialog(props: RepoRpmDirectoryStat
{props.runs.length ? ( {props.runs.length ? (
props.runs.map((run: RpmMirrorRun) => ( props.runs.map((run: RpmMirrorRun) => (
<Typography key={run.id} variant="caption" color={run.status === 'failed' ? 'error' : 'text.secondary'}> <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> </Typography>
)) ))
) : ( ) : (
+12 -1
View File
@@ -16,11 +16,22 @@ type RepoNavItem = {
active: boolean 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) { export default function RepoSubNav(props: RepoSubNavProps) {
const { projectId, repoId, branchRef, repoType, inline } = props const { projectId, repoId, branchRef, repoType, inline } = props
const location = useLocation() const location = useLocation()
const base: string = `/projects/${projectId}/repos/${repoId}` const base: string = `/projects/${projectId}/repos/${repoId}`
const refSuffix: string = branchRef ? `?ref=${encodeURIComponent(branchRef)}` : '' const refSuffix: string = branchRef ? `?ref=${encodeURIComponent(branchRef)}` : ''
const codePath: string = storedRepoCodePath(repoId, `${base}${refSuffix}`, base)
const currentPath: string = location.pathname.replace(/\/+$/, '') const currentPath: string = location.pathname.replace(/\/+$/, '')
const basePath: string = base.replace(/\/+$/, '') const basePath: string = base.replace(/\/+$/, '')
const isRPM: boolean = repoType === 'rpm' const isRPM: boolean = repoType === 'rpm'
@@ -34,7 +45,7 @@ export default function RepoSubNav(props: RepoSubNavProps) {
] : [ ] : [
{ {
label: 'Code', label: 'Code',
path: `${base}${refSuffix}`, path: codePath,
active: currentPath === basePath, active: currentPath === basePath,
}, },
{ {
@@ -6,6 +6,7 @@ import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
import { SSHAccessProfile } from '../api' import { SSHAccessProfile } from '../api'
import { formatEpochTime } from '../time'
type SSHAccessProfileDetailsDialogProps = { type SSHAccessProfileDetailsDialogProps = {
item: SSHAccessProfile | null item: SSHAccessProfile | null
@@ -17,7 +18,7 @@ function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
export default function SSHAccessProfileDetailsDialog(props: SSHAccessProfileDetailsDialogProps) { export default function SSHAccessProfileDetailsDialog(props: SSHAccessProfileDetailsDialogProps) {
@@ -11,6 +11,7 @@ import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
import type { SSHSignUserKeyResponse } from '../api' import type { SSHSignUserKeyResponse } from '../api'
import { formatEpochTime } from '../time'
type SSHCertificateIssuedDialogProps = { type SSHCertificateIssuedDialogProps = {
open: boolean open: boolean
@@ -27,7 +28,7 @@ function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
export default function SSHCertificateIssuedDialog(props: SSHCertificateIssuedDialogProps) { export default function SSHCertificateIssuedDialog(props: SSHCertificateIssuedDialogProps) {
@@ -7,6 +7,7 @@ import TextField from '@mui/material/TextField'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
import MonospaceTextField from './MonospaceTextField' import MonospaceTextField from './MonospaceTextField'
import { SSHCredential } from '../api' import { SSHCredential } from '../api'
import { formatEpochTime } from '../time'
type SSHCredentialDetailsDialogProps = { type SSHCredentialDetailsDialogProps = {
item: SSHCredential | null item: SSHCredential | null
@@ -18,7 +19,7 @@ function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
export default function SSHCredentialDetailsDialog(props: SSHCredentialDetailsDialogProps) { export default function SSHCredentialDetailsDialog(props: SSHCredentialDetailsDialogProps) {
@@ -6,6 +6,7 @@ import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
import { SSHPrincipalGrant } from '../api' import { SSHPrincipalGrant } from '../api'
import { formatEpochTime } from '../time'
type SSHPrincipalGrantDetailsDialogProps = { type SSHPrincipalGrantDetailsDialogProps = {
item: SSHPrincipalGrant | null item: SSHPrincipalGrant | null
@@ -16,7 +17,7 @@ function fmtTime(value: number | undefined): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
function fmtLimit(value: number | undefined, suffix: string): string { 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 TextField from '@mui/material/TextField'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
import { SSHServer } from '../api' import { SSHServer } from '../api'
import { formatEpochTime } from '../time'
type SSHServerDetailsDialogProps = { type SSHServerDetailsDialogProps = {
item: SSHServer | null item: SSHServer | null
@@ -17,7 +18,7 @@ function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
function formatHostKeyPolicy(value: string | undefined): string { function formatHostKeyPolicy(value: string | undefined): string {
@@ -10,6 +10,7 @@ import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
import { api, SSHServer, SSHServerGroup } from '../api' import { api, SSHServer, SSHServerGroup } from '../api'
import { formatEpochTime } from '../time'
import CompactListItemText from './CompactListItemText' import CompactListItemText from './CompactListItemText'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -24,7 +25,7 @@ function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
export default function SSHServerGroupDetailsDialog(props: SSHServerGroupDetailsDialogProps) { export default function SSHServerGroupDetailsDialog(props: SSHServerGroupDetailsDialogProps) {
+40 -8
View File
@@ -9,18 +9,18 @@ type SplitPanelProps = {
} }
export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: SplitPanelProps) { export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: SplitPanelProps) {
const [splitRatio, setSplitRatio] = useState(defaultRatio) const [splitRatio, setSplitRatio] = useState<number>(defaultRatio)
const [splitDragging, setSplitDragging] = useState(false) const [splitDragging, setSplitDragging] = useState<boolean>(false)
const containerRef = useRef<HTMLDivElement | null>(null) const containerRef = useRef<HTMLDivElement | null>(null)
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => { const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>): void => {
const container = containerRef.current const container: HTMLDivElement | null = containerRef.current
if (!splitDragging || !container) return 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))) setSplitRatio(Math.max(0.1, Math.min(0.9, (event.clientX - rect.left) / rect.width)))
} }
const handlePointerUp = () => { const handlePointerUp = (): void => {
setSplitDragging(false) setSplitDragging(false)
} }
@@ -31,6 +31,10 @@ export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: Spli
{ {
display: 'grid', display: 'grid',
gridTemplateColumns: `minmax(0, ${splitRatio}fr) 12px minmax(0, ${1 - splitRatio}fr)`, gridTemplateColumns: `minmax(0, ${splitRatio}fr) 12px minmax(0, ${1 - splitRatio}fr)`,
alignItems: 'stretch',
height: '100%',
minHeight: 0,
minWidth: 0,
userSelect: splitDragging ? 'none' : 'auto', userSelect: splitDragging ? 'none' : 'auto',
cursor: splitDragging ? 'col-resize' : 'auto', cursor: splitDragging ? 'col-resize' : 'auto',
}, },
@@ -40,7 +44,21 @@ export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: Spli
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onPointerLeave={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 <Box
onPointerDown={() => setSplitDragging(true)} onPointerDown={() => setSplitDragging(true)}
sx={{ 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> </Box>
) )
} }
@@ -14,6 +14,7 @@ import { useEffect, useState } from 'react'
import CompactListItemText from './CompactListItemText' import CompactListItemText from './CompactListItemText'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
import { api, UserGroup, UserGroupMember } from '../api' import { api, UserGroup, UserGroupMember } from '../api'
import { formatEpochTime } from '../time'
type UserGroupDetailsDialogProps = { type UserGroupDetailsDialogProps = {
item: UserGroup | null item: UserGroup | null
@@ -25,7 +26,7 @@ function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
function yesNo(value: boolean | undefined): string { 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 { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import { AdminAPIKey, api } from '../api' import { AdminAPIKey, api } from '../api'
import { formatEpochTime } from '../time'
import SelectField from '../components/SelectField' import SelectField from '../components/SelectField'
import CompactListItemText from '../components/CompactListItemText' import CompactListItemText from '../components/CompactListItemText'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
@@ -25,7 +26,7 @@ function formatUnix(value: number) {
if (!value || value <= 0) { if (!value || value <= 0) {
return 'Never' return 'Never'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
export default function AdminApiKeysPage() { export default function AdminApiKeysPage() {
@@ -20,6 +20,7 @@ import PKIClientProfileDetailsDialog from '../components/PKIClientProfileDetails
import PKICADetailsDialog from '../components/PKICADetailsDialog' import PKICADetailsDialog from '../components/PKICADetailsDialog'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import { api, PKICA, PKICADetail, PKIClientProfile, PKIClientProfileUpsertPayload, User, UserGroup } from '../api' import { api, PKICA, PKICADetail, PKIClientProfile, PKIClientProfileUpsertPayload, User, UserGroup } from '../api'
import { formatEpochTime } from '../time'
import { AppBrand, useBrand } from '../branding' import { AppBrand, useBrand } from '../branding'
import CompactListItemText from '../components/CompactListItemText' import CompactListItemText from '../components/CompactListItemText'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
@@ -28,7 +29,7 @@ function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
function parsePermissions(value: string): string[] { 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 { PKICertImportDialog, PKICertIssueDialog, PKICertRevokeDialog } from '../components/PKICertFormDialogs'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import { ACMEOrder, ACMEOrderCreatePayload, ACMEProfile, ACMEProfileUpsertPayload, api, PKICA, PKICADetail, PKICert, PKICertDetail, PKICertImportPayload, PKICertIssuePayload, PKIIntermediateCARequest, PKIRootCARequest } from '../api' 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 SelectField from '../components/SelectField'
import CompactListItemText from '../components/CompactListItemText' import CompactListItemText from '../components/CompactListItemText'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
@@ -34,7 +35,7 @@ function fmt(ts: number): string {
if (!ts || ts <= 0) { if (!ts || ts <= 0) {
return '-' return '-'
} }
return new Date(ts * 1000).toLocaleString() return formatEpochTime(ts)
} }
function fmtPKICertActor(cert: PKICert | PKICertDetail | null | undefined): string { function fmtPKICertActor(cert: PKICert | PKICertDetail | null | undefined): string {
@@ -22,6 +22,7 @@ import SectionCard from '../components/SectionCard'
import SSHCredentialDetailsDialog from '../components/SSHCredentialDetailsDialog' import SSHCredentialDetailsDialog from '../components/SSHCredentialDetailsDialog'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import { api, SSHCredential, SSHCredentialUpsertPayload } from '../api' import { api, SSHCredential, SSHCredentialUpsertPayload } from '../api'
import { formatEpochTime } from '../time'
import CompactListItemText from '../components/CompactListItemText' import CompactListItemText from '../components/CompactListItemText'
type FormState = { type FormState = {
@@ -42,7 +43,7 @@ function emptyForm(): FormState {
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) return '-' if (!value || value <= 0) return '-'
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
export default function AdminSSHCredentialsPage() { export default function AdminSSHCredentialsPage() {
@@ -22,6 +22,7 @@ import SectionCard from '../components/SectionCard'
import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetailsDialog' import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetailsDialog'
import SSHPrincipalGrantFormDialog, { SSHPrincipalGrantFormState } from '../components/SSHPrincipalGrantFormDialog' import SSHPrincipalGrantFormDialog, { SSHPrincipalGrantFormState } from '../components/SSHPrincipalGrantFormDialog'
import { api, SSHPrincipalGrant, SSHPrincipalGrantUpsertPayload, User, UserGroup } from '../api' import { api, SSHPrincipalGrant, SSHPrincipalGrantUpsertPayload, User, UserGroup } from '../api'
import { formatEpochTime } from '../time'
import Autocomplete from '../components/Autocomplete' import Autocomplete from '../components/Autocomplete'
import SelectField from '../components/SelectField' import SelectField from '../components/SelectField'
import CompactListItemText from '../components/CompactListItemText' import CompactListItemText from '../components/CompactListItemText'
@@ -36,7 +37,7 @@ function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
function toDateTimeInput(value: number): string { function toDateTimeInput(value: number): string {
+2 -1
View File
@@ -20,6 +20,7 @@ import SSHServerGroupMembersDialog from '../components/SSHServerGroupMembersDial
import SSHHostKeysDialog from '../components/SSHHostKeysDialog' import SSHHostKeysDialog from '../components/SSHHostKeysDialog'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import { api, SSHServer, SSHServerGroup, SSHServerGroupUpsertPayload, SSHServerHostKey, SSHServerUpsertPayload } from '../api' import { api, SSHServer, SSHServerGroup, SSHServerGroupUpsertPayload, SSHServerHostKey, SSHServerUpsertPayload } from '../api'
import { formatEpochTime } from '../time'
import CompactListItemText from '../components/CompactListItemText' import CompactListItemText from '../components/CompactListItemText'
const emptyForm = (): SSHServerFormState => ({ const emptyForm = (): SSHServerFormState => ({
@@ -41,7 +42,7 @@ const emptyGroupForm = (): SSHServerGroupFormState => ({
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) return '-' if (!value || value <= 0) return '-'
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
export default function AdminSSHServersPage() { export default function AdminSSHServersPage() {
+2 -4
View File
@@ -18,12 +18,10 @@ import SSHTranscriptDialog from '../components/SSHTranscriptDialog'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import PaginationControls from '../components/PaginationControls' import PaginationControls from '../components/PaginationControls'
import { api, SSHAccessProfile, SSHFileTransfer, SSHFileTransferListResponse, SSHServer, SSHSession, SSHSessionListResponse } from '../api' import { api, SSHAccessProfile, SSHFileTransfer, SSHFileTransferListResponse, SSHServer, SSHSession, SSHSessionListResponse } from '../api'
import { formatEpochTime } from '../time'
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) { return formatEpochTime(value)
return '-'
}
return new Date(value * 1000).toLocaleString()
} }
function sessionTitle(item: SSHSession): string { function sessionTitle(item: SSHSession): string {
@@ -22,12 +22,13 @@ import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetai
import SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog' import SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog'
import UserDetailsDialog from '../components/UserDetailsDialog' import UserDetailsDialog from '../components/UserDetailsDialog'
import { api, SSHPrincipalGrant, SSHUserCA, SSHUserCAIssuance, User } from '../api' import { api, SSHPrincipalGrant, SSHUserCA, SSHUserCAIssuance, User } from '../api'
import { formatEpochTime } from '../time'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import Autocomplete from '../components/Autocomplete' import Autocomplete from '../components/Autocomplete'
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) { return '-' } if (!value || value <= 0) { return '-' }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
function downloadText(filename: string, text: string) { 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 SSHCertificateInspectDialog from '../components/SSHCertificateInspectDialog'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import { api, SSHSignUserKeyResponse, SSHUserCA, SSHUserCACreatePayload, SSHUserCASignPayload, SSHUserCAUpdatePayload } from '../api' import { api, SSHSignUserKeyResponse, SSHUserCA, SSHUserCACreatePayload, SSHUserCASignPayload, SSHUserCAUpdatePayload } from '../api'
import { formatEpochTime } from '../time'
import CompactListItemText from '../components/CompactListItemText' import CompactListItemText from '../components/CompactListItemText'
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
function downloadText(filename: string, text: string) { function downloadText(filename: string, text: string) {
@@ -23,17 +23,18 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import { api, CertPrincipalBinding, PKICert, PKICertDetail, PrincipalAPIKey, PrincipalProjectRole, Project, ServicePrincipal } from '../api' import { api, CertPrincipalBinding, PKICert, PKICertDetail, PrincipalAPIKey, PrincipalProjectRole, Project, ServicePrincipal } from '../api'
import { formatEpochTime } from '../time'
import SelectField from '../components/SelectField' import SelectField from '../components/SelectField'
import CompactListItemText from '../components/CompactListItemText' import CompactListItemText from '../components/CompactListItemText'
function fmt(ts: number): string { function fmt(ts: number): string {
if (!ts || ts <= 0) return '-' if (!ts || ts <= 0) return '-'
return new Date(ts * 1000).toLocaleString() return formatEpochTime(ts)
} }
function formatUnix(ts: number): string { function formatUnix(ts: number): string {
if (!ts || ts <= 0) return 'Never' if (!ts || ts <= 0) return 'Never'
return new Date(ts * 1000).toLocaleString() return formatEpochTime(ts)
} }
function fmtPKICertActor(cert: PKICert | PKICertDetail | null | undefined): string { 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 TintedPanel from '../components/TintedPanel'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import { api, SubjectPermission, User, UserGroup, UserGroupMember, UserGroupUpsertPayload } from '../api' import { api, SubjectPermission, User, UserGroup, UserGroupMember, UserGroupUpsertPayload } from '../api'
import { formatEpochTime } from '../time'
import UserGroupFormDialog, { UserGroupFormState } from '../components/UserGroupFormDialog' import UserGroupFormDialog, { UserGroupFormState } from '../components/UserGroupFormDialog'
const projectCreatePermission: string = 'project.create' const projectCreatePermission: string = 'project.create'
@@ -35,7 +36,7 @@ const groupOptionPermissions: { permission: string, label: string, chip: string
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) return '-' if (!value || value <= 0) return '-'
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
function defaultUserGroupForm(): UserGroupFormState { function defaultUserGroupForm(): UserGroupFormState {
+2 -1
View File
@@ -23,6 +23,7 @@ import { useEffect, useMemo, useState } from 'react'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import { api, APIKey } from '../api' import { api, APIKey } from '../api'
import { formatEpochTime } from '../time'
import CompactListItemText from '../components/CompactListItemText' import CompactListItemText from '../components/CompactListItemText'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
@@ -30,7 +31,7 @@ function formatUnix(value: number) {
if (!value || value <= 0) { if (!value || value <= 0) {
return 'Never' return 'Never'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
export default function ApiKeysPage() { export default function ApiKeysPage() {
@@ -22,13 +22,14 @@ import PKICertDetailsDialog from '../components/PKICertDetailsDialog'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import { api, PKICADetail, PKIClientIssueResponse, PKIClientIssuance, PKIClientProfile, PKICertDetail } from '../api' import { api, PKICADetail, PKIClientIssueResponse, PKIClientIssuance, PKIClientProfile, PKICertDetail } from '../api'
import { formatEpochTime } from '../time'
import CompactListItemText from '../components/CompactListItemText' import CompactListItemText from '../components/CompactListItemText'
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
function parseCSV(value: string): string[] { function parseCSV(value: string): string[] {
+2 -1
View File
@@ -28,6 +28,7 @@ import {
} from '../api' } from '../api'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import { formatEpochTime } from '../time'
type LayoutContext = { type LayoutContext = {
user: User | null user: User | null
@@ -58,7 +59,7 @@ function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
function DashboardCard(props: DashboardCardProps) { function DashboardCard(props: DashboardCardProps) {
+2 -1
View File
@@ -12,6 +12,7 @@ import {
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { api, DockerManifestDetail, DockerTagInfo, Project, Repo } from '../api' import { api, DockerManifestDetail, DockerTagInfo, Project, Repo } from '../api'
import { formatCommitTime } from '../time'
import ContextToolbar from '../components/ContextToolbar' import ContextToolbar from '../components/ContextToolbar'
import ProjectPageFrame from '../components/ProjectPageFrame' import ProjectPageFrame from '../components/ProjectPageFrame'
import RepoSubNav from '../components/RepoSubNav' import RepoSubNav from '../components/RepoSubNav'
@@ -431,7 +432,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
<Typography variant="body2">OS: {detail.config?.os || 'n/a'}</Typography> <Typography variant="body2">OS: {detail.config?.os || 'n/a'}</Typography>
<Typography variant="body2">Architecture: {detail.config?.architecture || 'n/a'}</Typography> <Typography variant="body2">Architecture: {detail.config?.architecture || 'n/a'}</Typography>
<Typography variant="body2"> <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> </Typography>
<Divider sx={{ my: 0.5 }} /> <Divider sx={{ my: 0.5 }} />
<Typography variant="body2">Layers: {detail.layers?.length || 0}</Typography> <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 { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { api, BranchInfo, Project, Repo, RepoBranchesInfo } from '../api' import { api, BranchInfo, Project, Repo, RepoBranchesInfo } from '../api'
import { CommitIdentity, splitCommitMessage } from '../components/CommitMeta'
import ContextToolbar from '../components/ContextToolbar' import ContextToolbar from '../components/ContextToolbar'
import ProjectPageFrame from '../components/ProjectPageFrame' import ProjectPageFrame from '../components/ProjectPageFrame'
import RepoSubNav from '../components/RepoSubNav' import RepoSubNav from '../components/RepoSubNav'
@@ -263,17 +264,15 @@ export default function RepoGitBranchesPage() {
</Typography> </Typography>
) : null} ) : null}
{branch.last_hash ? ( {branch.last_hash ? (
<Typography variant="body2" color="text.secondary"> <CommitIdentity author={branch.last_author} when={branch.last_when} hash={branch.last_hash} />
{branch.last_hash.slice(0, 8)} {branch.last_author} {branch.last_when}
</Typography>
) : ( ) : (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
No commits No commits
</Typography> </Typography>
)} )}
{branch.last_message ? ( {branch.last_message ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary" noWrap>
{branch.last_message} {splitCommitMessage(branch.last_message).subject}
</Typography> </Typography>
) : null} ) : null}
</Box> </Box>
+188 -195
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 { 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 { api, Project, Repo, RepoCommitDetail } from '../api'
import { CommitIdentity, CommitMessage } from '../components/CommitMeta'
import LazyCodeBlock from '../components/LazyCodeBlock' import LazyCodeBlock from '../components/LazyCodeBlock'
import ContextToolbar from '../components/ContextToolbar' import ContextToolbar from '../components/ContextToolbar'
import ProjectPageFrame from '../components/ProjectPageFrame' import ProjectPageFrame from '../components/ProjectPageFrame'
import RepoSubNav from '../components/RepoSubNav' import RepoSubNav from '../components/RepoSubNav'
import SplitPanel from '../components/SplitPanel'
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline' import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline' import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'
import ChangeCircleOutlinedIcon from '@mui/icons-material/ChangeCircleOutlined' import ChangeCircleOutlinedIcon from '@mui/icons-material/ChangeCircleOutlined'
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline' import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft' import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
import ChevronLeftOutlinedIcon from '@mui/icons-material/ChevronLeftOutlined'
import ChevronRightOutlinedIcon from '@mui/icons-material/ChevronRightOutlined'
export default function RepoGitCommitDetailPage() { export default function RepoGitCommitDetailPage() {
const { projectId, repoId, hash } = useParams() const { projectId, repoId, hash } = useParams()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const navigate = useNavigate()
const [detail, setDetail] = useState<RepoCommitDetail | null>(null) const [detail, setDetail] = useState<RepoCommitDetail | null>(null)
const [project, setProject] = useState<Project | null>(null) const [project, setProject] = useState<Project | null>(null)
const [repo, setRepo] = useState<Repo | null>(null) const [repo, setRepo] = useState<Repo | null>(null)
@@ -26,9 +26,6 @@ export default function RepoGitCommitDetailPage() {
const [fileDiff, setFileDiff] = useState<string>('') const [fileDiff, setFileDiff] = useState<string>('')
const [selectedFile, setSelectedFile] = useState<string>('') const [selectedFile, setSelectedFile] = useState<string>('')
const [diffView, setDiffView] = useState<'unified' | 'split'>('unified') const [diffView, setDiffView] = useState<'unified' | 'split'>('unified')
const [filesOpen, setFilesOpen] = useState(true)
const [showLeft, setShowLeft] = useState(true)
const [showRight, setShowRight] = useState(true)
useEffect(() => { useEffect(() => {
if (!repoId || !hash) return if (!repoId || !hash) return
@@ -107,7 +104,7 @@ export default function RepoGitCommitDetailPage() {
const lines = raw.replace(/\r\n/g, '\n').split('\n') const lines = raw.replace(/\r\n/g, '\n').split('\n')
let leftLine = 0 let leftLine = 0
let rightLine = 0 let rightLine = 0
const hunkRe = /^@@ -(\d+)(?:,\d+)? \\+(\\d+)(?:,\\d+)? @@/ const hunkRe = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/
const rows = lines.map((line) => { const rows = lines.map((line) => {
const prefix = line.charAt(0) const prefix = line.charAt(0)
const isAdd = prefix === '+' const isAdd = prefix === '+'
@@ -140,146 +137,58 @@ export default function RepoGitCommitDetailPage() {
isMeta isMeta
} }
}) })
const columns = const monoFont = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
showLeft && showRight ? 'minmax(0, 1fr) minmax(0, 1fr)' : showLeft ? 'minmax(0, 1fr) 0' : '0 minmax(0, 1fr)' const renderSide = (side: 'left' | 'right', label: string) => (
const hiddenCol = { <Box sx={{ width: '100%', overflowX: 'auto', fontFamily: monoFont, fontSize: 12 }}>
visibility: 'hidden',
width: 0,
minWidth: 0,
maxWidth: 0,
padding: 0,
border: '0',
overflow: 'hidden'
}
return (
<Box sx={{ width: '100%' }}>
<Box <Box
sx={{ sx={{
display: 'grid', px: 1,
gridTemplateColumns: columns, py: 0.5,
gap: 0, borderBottom: '1px solid',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', borderColor: 'divider',
fontSize: 12, fontWeight: 600
width: '100%'
}} }}
> >
<Box <Typography variant="caption" sx={{ fontWeight: 700 }}>
sx={{ {label}
px: 1, </Typography>
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>
</Box> </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> </Box>
) )
return (
<SplitPanel
defaultRatio={0.5}
sx={{ height: 'auto', minHeight: 0 }}
left={renderSide('left', 'OLD')}
right={renderSide('right', 'NEW')}
/>
)
} }
const renderStatusIcon = (status: string) => { const renderStatusIcon = (status: string) => {
@@ -289,11 +198,37 @@ export default function RepoGitCommitDetailPage() {
return <ChangeCircleOutlinedIcon fontSize="small" color="info" /> 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 ( return (
<ProjectPageFrame <ProjectPageFrame
projectId={projectId ?? ''} projectId={projectId ?? ''}
project={project} project={project}
currentLabel="Commit" currentLabel="Commit"
fillHeight
contentSx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, minWidth: 0 }}
contextToolbar={ contextToolbar={
<ContextToolbar <ContextToolbar
kind="Repository" kind="Repository"
@@ -308,40 +243,25 @@ export default function RepoGitCommitDetailPage() {
{loadError} {loadError}
</Typography> </Typography>
) : null} ) : null}
{detail ? ( <SplitPanel defaultRatio={0.28} sx={{ flex: 1, minHeight: 0 }} left={(
<Paper sx={{ p: 2, mb: 1 }}> <Paper sx={{ p: 2, height: '100%', minWidth: 0, minHeight: 0, overflow: 'auto' }}>
<Typography variant="subtitle1">{detail.message}</Typography> <Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Typography variant="body2" color="text.secondary"> <Typography variant="subtitle2">Files</Typography>
{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>
</Box> </Box>
<List dense> <List dense sx={{ minWidth: '100%', width: 'max-content' }}>
{detail?.files.map((file) => ( {detail?.files.map((file) => (
<ListItem key={file.path} divider disablePadding> <ListItem key={file.path} divider disablePadding>
<ListItemButton onClick={() => handleFileDiff(file.path)}> <ListItemButton onClick={() => handleFileDiff(file.path)} sx={{ minWidth: 'max-content' }}>
<ListItemText <ListItemText
sx={{ minWidth: 0 }}
primary={ primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 'max-content' }}>
<Tooltip title={filesOpen ? file.status : `${file.status}${file.path}`}> <Tooltip title={file.status}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>{renderStatusIcon(file.status)}</Box> <Box sx={{ display: 'flex', alignItems: 'center' }}>{renderStatusIcon(file.status)}</Box>
</Tooltip> </Tooltip>
{filesOpen ? ( <Typography variant="body2" sx={{ whiteSpace: 'nowrap' }}>
<Typography variant="body2" sx={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}> {file.path}
{file.path} </Typography>
</Typography>
) : null}
</Box> </Box>
} }
/> />
@@ -350,36 +270,109 @@ export default function RepoGitCommitDetailPage() {
))} ))}
</List> </List>
</Paper> </Paper>
<Paper sx={{ p: 2 }}> )} right={(
<Typography variant="subtitle2" gutterBottom> <Paper sx={{ p: 2, height: '100%', minWidth: 0, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
{selectedFile ? `File Diff: ${selectedFile}` : 'Commit Diff'} <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
</Typography> <Tooltip title="Back">
<Tabs <IconButton size="small" onClick={() => navigate(-1)} aria-label="Back">
value={diffView} <ArrowBackIcon fontSize="small" />
onChange={(_, value) => setDiffView(value)} </IconButton>
sx={{ mb: 1 }} </Tooltip>
> <CommitIdentity
<Tab label="Unified" value="unified" /> author={detail?.author}
<Tab label="Split" value="split" /> email={detail?.email}
</Tabs> when={detail?.when}
{diffView === 'split' ? null : null} hash={detail?.hash}
{selectedFile ? ( hashHref={detail?.hash ? codeViewPath(detail.hash) : undefined}
fileDiff ? ( hashChip
diffView === 'unified' ? ( showEmail
<LazyCodeBlock code={fileDiff} language="diff" showLineNumbers /> 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> <Typography>No diff available.</Typography>
) )}
) : diff ? ( </Box>
diffView === 'unified' ? <LazyCodeBlock code={diff} language="diff" showLineNumbers /> : renderSplitDiff(diff)
) : (
<Typography>No diff available.</Typography>
)}
</Paper> </Paper>
</Box> )} />
</ProjectPageFrame> </ProjectPageFrame>
) )
} }
+11 -3
View File
@@ -2,6 +2,8 @@ import { Box, Button, Divider, List, ListItem, ListItemButton, ListItemText, Men
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom' import { Link, useParams, useSearchParams } from 'react-router-dom'
import { api, Project, Repo, RepoCommit } from '../api' import { api, Project, Repo, RepoCommit } from '../api'
import { CommitIdentity, splitCommitMessage } from '../components/CommitMeta'
import AccountTreeIcon from '@mui/icons-material/AccountTree'
import Autocomplete from '../components/Autocomplete' import Autocomplete from '../components/Autocomplete'
import ContextToolbar from '../components/ContextToolbar' import ContextToolbar from '../components/ContextToolbar'
import ProjectPageFrame from '../components/ProjectPageFrame' import ProjectPageFrame from '../components/ProjectPageFrame'
@@ -110,7 +112,7 @@ export default function RepoGitCommitsPage() {
<Paper sx={{ p: 2, mb: 1, backgroundColor: 'transparent' }}> <Paper sx={{ p: 2, mb: 1, backgroundColor: 'transparent' }}>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}> <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<Box sx={{ minWidth: 220, flex: '0 0 auto', display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ minWidth: 220, flex: '0 0 auto', display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2">Branch</Typography> <AccountTreeIcon />
<Autocomplete <Autocomplete
freeSolo freeSolo
options={branches} options={branches}
@@ -152,7 +154,10 @@ export default function RepoGitCommitsPage() {
component={Link} component={Link}
to={`/projects/${projectId}/repos/${repoId}/commits/${commit.hash}?ref=${encodeURIComponent(ref)}`} 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> </ListItemButton>
</ListItem> </ListItem>
))} ))}
@@ -192,7 +197,10 @@ export default function RepoGitCommitsPage() {
<List dense> <List dense>
{compareCommits.map((commit) => ( {compareCommits.map((commit) => (
<ListItem key={commit.hash} divider> <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> </ListItem>
))} ))}
</List> </List>
+186 -73
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 { 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 { api, Project, Repo, RepoCommit, RepoTreeEntry } from '../api'
import { CommitIdentity, CommitMessage, splitCommitMessage } from '../components/CommitMeta'
import Autocomplete from '../components/Autocomplete' import Autocomplete from '../components/Autocomplete'
import AccountTreeIcon from '@mui/icons-material/AccountTree' import AccountTreeIcon from '@mui/icons-material/AccountTree'
import CodeIcon from '@mui/icons-material/Code' import CodeIcon from '@mui/icons-material/Code'
@@ -48,6 +49,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
const [imagePreviewRef, setImagePreviewRef] = useState('') const [imagePreviewRef, setImagePreviewRef] = useState('')
const [history, setHistory] = useState<RepoCommit[]>([]) const [history, setHistory] = useState<RepoCommit[]>([])
const [diff, setDiff] = useState<string>('') const [diff, setDiff] = useState<string>('')
const [diffRange, setDiffRange] = useState<{ from: string; to: string }>({ from: '', to: '' })
const [previewTab, setPreviewTab] = useState<'content' | 'history' | 'diff'>('content') const [previewTab, setPreviewTab] = useState<'content' | 'history' | 'diff'>('content')
const [selectedCommit, setSelectedCommit] = useState<RepoCommit | null>(null) const [selectedCommit, setSelectedCommit] = useState<RepoCommit | null>(null)
const [readmeContent, setReadmeContent] = useState<string>('') const [readmeContent, setReadmeContent] = useState<string>('')
@@ -59,7 +61,11 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
const initProjectRef = useRef<string | null>(null) const initProjectRef = useRef<string | null>(null)
const lastTreeKey = useRef<string>('') const lastTreeKey = useRef<string>('')
const refValue = useRef<string>('') const refValue = useRef<string>('')
const lastQueryFileKey = useRef<string>('')
const lastQueryDirKey = useRef<string>('')
const urlRef = searchParams.get('ref') || '' const urlRef = searchParams.get('ref') || ''
const urlFilePath = searchParams.get('path') || ''
const urlDirPath = searchParams.get('dir') || ''
useEffect(() => { useEffect(() => {
if (!repoId) return if (!repoId) return
@@ -119,6 +125,23 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
refValue.current = ref refValue.current = ref
}, [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(() => { useEffect(() => {
if (isImageFileSelected && selectedFile) { if (isImageFileSelected && selectedFile) {
setImagePreviewRef(ref) setImagePreviewRef(ref)
@@ -217,6 +240,75 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
}) })
}, [repoId, ref, tree, selectedFile, readmePath, readmeKind]) }, [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) => { const handleOpen = async (entry: RepoTreeEntry) => {
if (!repoId) return if (!repoId) return
if (entry.type === 'dir') { if (entry.type === 'dir') {
@@ -232,31 +324,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
setSelectedCommit(null) setSelectedCommit(null)
return return
} }
setSelectedFile(entry.path) await openFilePath(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)
}
} }
const handleBack = () => { 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 if (!repoId || !selectedFile) return
const result = await api.getRepoFileDiff(repoId, ref || undefined, selectedFile) let cancelled = false
setDiff(result.diff || '') 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) => { const copyText = async (text: string) => {
let ok = false let ok = false
@@ -512,6 +598,14 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
return `/api/repos/${repoId}/blob/raw?${params.toString()}` return `/api/repos/${repoId}/blob/raw?${params.toString()}`
} }
const commitDetailPath = (hash: string): string => {
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) => { const resolveReadmeAsset = (src: string) => {
if (!src) return '' if (!src) return ''
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:')) return src if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:')) return src
@@ -612,7 +706,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
} }
const renderPathBreadcrumb = () => { 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 <IconButton
size="small" size="small"
onClick={() => handleBreadcrumb('')} onClick={() => handleBreadcrumb('')}
@@ -661,6 +755,8 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
projectId={projectId ?? ''} projectId={projectId ?? ''}
project={project} project={project}
currentLabel={repo?.name || 'Repository'} currentLabel={repo?.name || 'Repository'}
fillHeight
contentSx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, minWidth: 0 }}
contextToolbar={ contextToolbar={
<ContextToolbar <ContextToolbar
kind="Repository" kind="Repository"
@@ -676,11 +772,10 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
</Typography> </Typography>
) : null} ) : null}
<SplitPanel defaultRatio={0.30} sx={{ flex: 1, minHeight: 0 }} left={(
<SplitPanel defaultRatio={0.25} left={( <TintedPanel sx={{ minWidth: 0, minHeight: 0, height: '100%', overflow: 'auto' }}>
<TintedPanel> <Box sx={{ display: 'flex', alignItems: 'stretch' }}>
<Box sx={{ display: 'flex', alignItems: 'stretch', gap: 1, mb: 1 }}> <Paper sx={{ p: 1, display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, width: '100%', backgroundColor: 'transparent' }}>
<Paper sx={{ p: 1, display: 'flex', alignItems: 'center', gap: 1, minWidth: 240, backgroundColor: 'transparent' }}>
<AccountTreeIcon /> <AccountTreeIcon />
{branches.length ? ( {branches.length ? (
<Autocomplete <Autocomplete
@@ -700,11 +795,14 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
sx={{ minWidth: 180 }} sx={{ minWidth: 180 }}
/> />
) : null} ) : null}
{renderClonePopover()} <Box sx={{ marginLeft: 'auto' }}>
{renderClonePopover()}
</Box>
</Paper> </Paper>
</Box> </Box>
<PageAlert severity="warning" message={treeError} onClose={() => setTreeError(null)} sx={{ mb: 1 }} /> <PageAlert severity="warning" message={treeError} onClose={() => setTreeError(null)} sx={{ mb: 1 }} />
<List dense> <List dense>
<Box sx={{ px: 1, pb: 1 }}> <Box sx={{ px: 1, pb: 1 }}>
<TextField <TextField
@@ -759,27 +857,23 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
</List> </List>
</TintedPanel> </TintedPanel>
)} right={( )} right={(
<TintedPanel> <TintedPanel sx={{ height: '100%', minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
{renderPathBreadcrumb()} {renderPathBreadcrumb()}
</Box> </Box>
{selectedFile ? ( {selectedFile ? (
<Box sx={{ mt: 0 }}> <Box sx={{ mt: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <CommitIdentity
<Typography variant="body2" color="text.secondary"> author={selectedCommit?.author}
{selectedCommit?.author || 'Unknown'} email={selectedCommit?.email}
</Typography> when={selectedCommit?.when}
<Typography variant="body2" color="text.secondary" sx={{ flex: 1, minWidth: 0 }}> hash={selectedCommit?.hash}
{selectedCommit?.message || 'No commit message'} hashHref={selectedCommit?.hash ? commitDetailPath(selectedCommit.hash) : undefined}
</Typography> hashChip
<Tooltip title={selectedCommit?.hash || ''}> copyHash
<Typography variant="body2" color="text.secondary" sx={{ marginLeft: 'auto', whiteSpace: 'nowrap' }}> />
{selectedCommit?.hash ? selectedCommit.hash.slice(0, 8) : ''} <Box sx={{ mt: 0.5 }}>
</Typography> <CommitMessage message={selectedCommit?.message || ''} inline />
</Tooltip>
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
{selectedCommit?.when || ''}
</Typography>
</Box> </Box>
<Divider sx={{ mt: 0.5, mb: 0.5 }} /> <Divider sx={{ mt: 0.5, mb: 0.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -788,7 +882,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
onChange={(_, value) => { onChange={(_, value) => {
setPreviewTab(value) setPreviewTab(value)
if (value === 'history' && history.length === 0) handleHistory() if (value === 'history' && history.length === 0) handleHistory()
if (value === 'diff' && diff === '') handleDiff()
}} }}
> >
<Tab label="Code" value="content" /> <Tab label="Code" value="content" />
@@ -859,7 +952,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
</Box> </Box>
{previewTab === 'content' ? ( {previewTab === 'content' ? (
isImageFileSelected ? ( 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 <Box
component="img" component="img"
src={buildBlobRawUrl(selectedFile, imagePreviewRef || ref)} src={buildBlobRawUrl(selectedFile, imagePreviewRef || ref)}
@@ -868,7 +961,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
/> />
</Paper> </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 /> <LazyCodeBlock code={content} language={detectLanguage(selectedFile)} showLineNumbers />
</Paper> </Paper>
) )
@@ -881,18 +974,15 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
<ListItem key={commit.hash} divider disablePadding> <ListItem key={commit.hash} divider disablePadding>
<ListItemButton onClick={() => handleHistorySelect(commit.hash)}> <ListItemButton onClick={() => handleHistorySelect(commit.hash)}>
<ListItemText <ListItemText
primary={commit.message} primary={splitCommitMessage(commit.message).subject || commit.message}
secondary={ secondary={
<Box component="span" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }} > <CommitIdentity
<Tooltip title={commit.hash}> author={commit.author}
<Typography variant="body2" color="text.secondary" component="span"> email={commit.email}
{commit.hash.slice(0, 8)} when={commit.when}
</Typography> hash={commit.hash}
</Tooltip> hashHref={commitDetailPath(commit.hash)}
<Typography variant="body2" color="text.secondary" component="span"> />
{commit.author} {commit.when}
</Typography>
</Box>
} }
/> />
</ListItemButton> </ListItemButton>
@@ -908,9 +998,32 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
) : null} ) : null}
{previewTab === 'diff' ? ( {previewTab === 'diff' ? (
diff ? ( diff ? (
<Paper variant="outlined" sx={{ p: 1, pr: 0.5, mt: 1, backgroundColor: 'transparent' }}> <>
<LazyCodeBlock code={diff} language="diff" /> {diffRange.from ? (
</Paper> <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 }}> <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
No diff available. No diff available.
@@ -920,7 +1033,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
</Box> </Box>
) : readmeContent ? ( ) : readmeContent ? (
readmeKind === 'markdown' ? ( 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> <Box>
<Suspense fallback={<Typography variant="body2" color="text.secondary">Loading README...</Typography>}> <Suspense fallback={<Typography variant="body2" color="text.secondary">Loading README...</Typography>}>
<RepoMarkdown <RepoMarkdown
@@ -932,7 +1045,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
</Box> </Box>
</Paper> </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 /> <LazyCodeBlock code={readmeContent} language="text" showLineNumbers />
</Paper> </Paper>
) )
+3 -2
View File
@@ -25,6 +25,7 @@ import { useParams, useSearchParams } from 'react-router-dom'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import SplitPanel from '../components/SplitPanel' import SplitPanel from '../components/SplitPanel'
import { api, Project, Repo, RpmMirrorRun, RpmPackageDetail, RpmPackageSummary, RpmSubdirCreatePayload, RpmSubdirUpdatePayload, RpmTreeEntry } from '../api' import { api, Project, Repo, RpmMirrorRun, RpmPackageDetail, RpmPackageSummary, RpmSubdirCreatePayload, RpmSubdirUpdatePayload, RpmTreeEntry } from '../api'
import { formatEpochTime } from '../time'
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline' import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline' import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
import FolderIcon from '@mui/icons-material/Folder' import FolderIcon from '@mui/icons-material/Folder'
@@ -1097,7 +1098,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
)} )}
</Typography> </Typography>
<Typography variant="body2"> <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>
<Typography variant="body2">Size: {rpmDetail.size ? `${rpmDetail.size} bytes` : 'n/a'}</Typography> <Typography variant="body2">Size: {rpmDetail.size ? `${rpmDetail.size} bytes` : 'n/a'}</Typography>
{Array.isArray(rpmDetail.requires) && rpmDetail.requires.length ? ( {Array.isArray(rpmDetail.requires) && rpmDetail.requires.length ? (
@@ -1131,7 +1132,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
{rpmDetail.changelogs.map((item, index) => ( {rpmDetail.changelogs.map((item, index) => (
<ListItem key={`${item.date}-${index}`} sx={{ display: 'block' }}> <ListItem key={`${item.date}-${index}`} sx={{ display: 'block' }}>
<Typography variant="body2" color="text.secondary"> <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}` : ''} {item.author ? ` · ${item.author}` : ''}
</Typography> </Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}> <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 SSHUserCADetailsDialog from '../components/SSHUserCADetailsDialog'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import { api, SSHPrincipalGrant, SSHSignUserKeyResponse, SSHUserCA, SSHUserCAIssuance, SSHUserCASelfSignPayload } from '../api' import { api, SSHPrincipalGrant, SSHSignUserKeyResponse, SSHUserCA, SSHUserCAIssuance, SSHUserCASelfSignPayload } from '../api'
import { formatEpochTime } from '../time'
import SelectField from '../components/SelectField' import SelectField from '../components/SelectField'
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
} }
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
async function copyText(text: string): Promise<boolean> { 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 SSHCredentialDetailsDialog from '../components/SSHCredentialDetailsDialog'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import { api, SSHCredential, SSHCredentialUpsertPayload } from '../api' import { api, SSHCredential, SSHCredentialUpsertPayload } from '../api'
import { formatEpochTime } from '../time'
import CompactListItemText from '../components/CompactListItemText' import CompactListItemText from '../components/CompactListItemText'
type FormState = { type FormState = {
@@ -42,7 +43,7 @@ function emptyForm(): FormState {
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) return '-' if (!value || value <= 0) return '-'
return new Date(value * 1000).toLocaleString() return formatEpochTime(value)
} }
export default function SSHCredentialsPage() { export default function SSHCredentialsPage() {
+2 -2
View File
@@ -39,6 +39,7 @@ import PageAlert from '../components/PageAlert'
import Autocomplete from '../components/Autocomplete' import Autocomplete from '../components/Autocomplete'
import PaginationControls from '../components/PaginationControls' import PaginationControls from '../components/PaginationControls'
import { api, SSHAccessProfile, SSHFileTransfer, SSHFileTransferListResponse, SSHServer, SSHSession, SSHSessionConnectResponse, SSHSessionListResponse } from '../api' import { api, SSHAccessProfile, SSHFileTransfer, SSHFileTransferListResponse, SSHServer, SSHSession, SSHSessionConnectResponse, SSHSessionListResponse } from '../api'
import { formatEpochTime } from '../time'
type SSHSessionStreamMessage = { type SSHSessionStreamMessage = {
session_id?: string session_id?: string
@@ -138,8 +139,7 @@ function buildInitialTerminalInfo(password?: string, otpCode?: string): Terminal
} }
function formatTimestamp(value: number): string { function formatTimestamp(value: number): string {
if (!value) { return '-' } return formatEpochTime(value)
return new Date(value * 1000).toLocaleString()
} }
function formatTransferBytes(value: number): string { 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}`
}