Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f51f42b6c | |||
| 34046ee7a8 | |||
| 1e685baebe | |||
| 3684adfe26 |
@@ -72,11 +72,11 @@ export default function ProjectFormDialog(props: ProjectFormDialogProps) {
|
|||||||
value={props.homePage}
|
value={props.homePage}
|
||||||
onChange={(event) => props.onHomePageChange(event.target.value as ProjectHomePageValue)}
|
onChange={(event) => props.onHomePageChange(event.target.value as ProjectHomePageValue)}
|
||||||
>
|
>
|
||||||
<MenuItem value="info">Info</MenuItem>
|
|
||||||
<MenuItem value="repos">Repositories</MenuItem>
|
<MenuItem value="repos">Repositories</MenuItem>
|
||||||
<MenuItem value="issues">Issues</MenuItem>
|
<MenuItem value="issues">Issues</MenuItem>
|
||||||
<MenuItem value="wiki">Wiki</MenuItem>
|
<MenuItem value="wiki">Wiki</MenuItem>
|
||||||
<MenuItem value="files">Files</MenuItem>
|
<MenuItem value="files">Files</MenuItem>
|
||||||
|
<MenuItem value="info">Settings</MenuItem>
|
||||||
</SelectField>
|
</SelectField>
|
||||||
<Box sx={{ mt: 1 }}>
|
<Box sx={{ mt: 1 }}>
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -42,14 +42,6 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
|
|||||||
return (
|
return (
|
||||||
<Paper sx={paperSx}>
|
<Paper sx={paperSx}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||||
<Button
|
|
||||||
component={Link}
|
|
||||||
to={`/projects/${projectId}/info`}
|
|
||||||
startIcon={<InfoOutlinedIcon />}
|
|
||||||
sx={{ justifyContent: 'flex-start', minWidth: 0, width: 'fit-content' }}
|
|
||||||
>
|
|
||||||
Info
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
component={Link}
|
component={Link}
|
||||||
to={`/projects/${projectId}/repos`}
|
to={`/projects/${projectId}/repos`}
|
||||||
@@ -82,6 +74,14 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
|
|||||||
>
|
>
|
||||||
Files
|
Files
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to={`/projects/${projectId}/info`}
|
||||||
|
startIcon={<InfoOutlinedIcon />}
|
||||||
|
sx={{ justifyContent: 'flex-start', minWidth: 0, width: 'fit-content' }}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
{right ? <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>{right}</Box> : null}
|
{right ? <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>{right}</Box> : null}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import SectionCard from './SectionCard'
|
||||||
|
import { Project } from '../api'
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
type ProjectOverviewCardProps = {
|
||||||
|
project: Project | null
|
||||||
|
actions?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectOverviewCard({ project, actions }: ProjectOverviewCardProps) {
|
||||||
|
return (
|
||||||
|
<SectionCard title="Overview" collapsible actions={actions}>
|
||||||
|
{project ? (
|
||||||
|
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
||||||
|
{project.created_by_name || project.created_by ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Created by: {project.created_by_name || project.created_by}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
{project.created_at ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Created: {new Date(project.created_at * 1000).toLocaleString()}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
{project.updated_by_name || project.updated_by ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Last updated by: {project.updated_by_name || project.updated_by}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
{project.updated_at ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Last updated: {new Date(project.updated_at * 1000).toLocaleString()}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
{project.description ? (
|
||||||
|
<Box sx={{ '& p': { mt: 0, mb: 0.8 }, '& p:last-child': { mb: 0 } }}>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{project.description}</ReactMarkdown>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
No description.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Loading project...
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
|
import IconButton from '@mui/material/IconButton'
|
||||||
import Dialog from './ModalDialog'
|
import Dialog from './ModalDialog'
|
||||||
import DialogActions from '@mui/material/DialogActions'
|
import DialogActions from '@mui/material/DialogActions'
|
||||||
import DialogTitle from '@mui/material/DialogTitle'
|
import DialogTitle from '@mui/material/DialogTitle'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import FormDialogContent from './FormDialogContent'
|
import FormDialogContent from './FormDialogContent'
|
||||||
import PageAlert from './PageAlert'
|
import PageAlert from './PageAlert'
|
||||||
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||||
import { RpmMirrorRun } from '../api'
|
import { RpmMirrorRun } from '../api'
|
||||||
|
|
||||||
type RepoRpmDirectoryStatusDialogProps = {
|
type RepoRpmDirectoryStatusDialogProps = {
|
||||||
@@ -13,6 +15,7 @@ type RepoRpmDirectoryStatusDialogProps = {
|
|||||||
name: string
|
name: string
|
||||||
path: string
|
path: string
|
||||||
mode: 'local' | 'mirror'
|
mode: 'local' | 'mirror'
|
||||||
|
repoUrl: string
|
||||||
syncStatus: string
|
syncStatus: string
|
||||||
syncStep: string
|
syncStep: string
|
||||||
syncError: string
|
syncError: string
|
||||||
@@ -42,6 +45,24 @@ export default function RepoRpmDirectoryStatusDialog(props: RepoRpmDirectoryStat
|
|||||||
<Typography variant="body2">Directory: {props.name}</Typography>
|
<Typography variant="body2">Directory: {props.name}</Typography>
|
||||||
<Typography variant="body2">Path: {props.path || '.'}</Typography>
|
<Typography variant="body2">Path: {props.path || '.'}</Typography>
|
||||||
<Typography variant="body2">Mode: {props.mode}</Typography>
|
<Typography variant="body2">Mode: {props.mode}</Typography>
|
||||||
|
{props.repoUrl ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" sx={{ flexShrink: 0 }}>RPM Repository URL:</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
component="a"
|
||||||
|
href={props.repoUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
sx={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'primary.main' }}
|
||||||
|
>
|
||||||
|
{props.repoUrl}
|
||||||
|
</Typography>
|
||||||
|
<IconButton size="small" onClick={() => navigator.clipboard?.writeText(props.repoUrl)}>
|
||||||
|
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
{props.mode === 'mirror' ? (
|
{props.mode === 'mirror' ? (
|
||||||
<>
|
<>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function isSameSessionTarget(left: SSHSession | null, right: SSHSession): boolea
|
|||||||
export default function SSHSessionFileCopyDialog(props: SSHSessionFileCopyDialogProps) {
|
export default function SSHSessionFileCopyDialog(props: SSHSessionFileCopyDialogProps) {
|
||||||
const [pathsText, setPathsText] = useState('')
|
const [pathsText, setPathsText] = useState('')
|
||||||
const [targetDir, setTargetDir] = useState('.')
|
const [targetDir, setTargetDir] = useState('.')
|
||||||
const [overwrite, setOverwrite] = useState(true)
|
const [overwrite, setOverwrite] = useState(false)
|
||||||
const [targetSessionID, setTargetSessionID] = useState('')
|
const [targetSessionID, setTargetSessionID] = useState('')
|
||||||
const [targetSessions, setTargetSessions] = useState<SSHSession[]>([])
|
const [targetSessions, setTargetSessions] = useState<SSHSession[]>([])
|
||||||
const [sourcePassword, setSourcePassword] = useState('')
|
const [sourcePassword, setSourcePassword] = useState('')
|
||||||
@@ -75,7 +75,7 @@ export default function SSHSessionFileCopyDialog(props: SSHSessionFileCopyDialog
|
|||||||
if (!props.open) return
|
if (!props.open) return
|
||||||
setPathsText('')
|
setPathsText('')
|
||||||
setTargetDir('.')
|
setTargetDir('.')
|
||||||
setOverwrite(true)
|
setOverwrite(false)
|
||||||
setTargetSessionID('')
|
setTargetSessionID('')
|
||||||
setTargetSessions([])
|
setTargetSessions([])
|
||||||
setSourcePassword('')
|
setSourcePassword('')
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function formatFileSize(size: number): string {
|
|||||||
|
|
||||||
export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDialogProps) {
|
export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDialogProps) {
|
||||||
const [targetDir, setTargetDir] = useState('.')
|
const [targetDir, setTargetDir] = useState('.')
|
||||||
const [overwrite, setOverwrite] = useState(true)
|
const [overwrite, setOverwrite] = useState(false)
|
||||||
const [files, setFiles] = useState<File[]>([])
|
const [files, setFiles] = useState<File[]>([])
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [otpCode, setOTPCode] = useState('')
|
const [otpCode, setOTPCode] = useState('')
|
||||||
@@ -46,7 +46,7 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!props.open) return
|
if (!props.open) return
|
||||||
setTargetDir('.')
|
setTargetDir('.')
|
||||||
setOverwrite(true)
|
setOverwrite(false)
|
||||||
setFiles([])
|
setFiles([])
|
||||||
setPassword('')
|
setPassword('')
|
||||||
setOTPCode('')
|
setOTPCode('')
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Box, { BoxProps } from '@mui/material/Box'
|
||||||
|
import { PointerEvent as ReactPointerEvent, ReactNode, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
type SplitPanelProps = {
|
||||||
|
left: ReactNode
|
||||||
|
right: ReactNode
|
||||||
|
defaultRatio?: number
|
||||||
|
sx?: BoxProps['sx']
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: SplitPanelProps) {
|
||||||
|
const [splitRatio, setSplitRatio] = useState(defaultRatio)
|
||||||
|
const [splitDragging, setSplitDragging] = useState(false)
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!splitDragging || !container) return
|
||||||
|
const rect = container.getBoundingClientRect()
|
||||||
|
setSplitRatio(Math.max(0.1, Math.min(0.9, (event.clientX - rect.left) / rect.width)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerUp = () => {
|
||||||
|
setSplitDragging(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={containerRef}
|
||||||
|
sx={[
|
||||||
|
{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `minmax(0, ${splitRatio}fr) 12px minmax(0, ${1 - splitRatio}fr)`,
|
||||||
|
userSelect: splitDragging ? 'none' : 'auto',
|
||||||
|
cursor: splitDragging ? 'col-resize' : 'auto',
|
||||||
|
},
|
||||||
|
...(Array.isArray(sx) ? sx : sx ? [sx] : []),
|
||||||
|
]}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerLeave={handlePointerUp}
|
||||||
|
>
|
||||||
|
{left}
|
||||||
|
<Box
|
||||||
|
onPointerDown={() => setSplitDragging(true)}
|
||||||
|
sx={{
|
||||||
|
cursor: 'col-resize',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'stretch',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none',
|
||||||
|
'&::before': {
|
||||||
|
content: '""',
|
||||||
|
display: 'block',
|
||||||
|
width: '2px',
|
||||||
|
justifySelf: 'center',
|
||||||
|
backgroundColor: splitDragging ? 'primary.main' : 'divider',
|
||||||
|
borderRadius: 999,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{right}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,8 +3,7 @@ import { Box, Button, Chip, DialogContent, MenuItem, Paper, TextField, ToggleBut
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { api, Project, ProjectGroupRole, ProjectMember, ProjectUpdatePayload, User, UserGroup } from '../api'
|
import { api, Project, ProjectGroupRole, ProjectMember, ProjectUpdatePayload, User, UserGroup } from '../api'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ProjectOverviewCard from '../components/ProjectOverviewCard'
|
||||||
import remarkGfm from 'remark-gfm'
|
|
||||||
import ProjectNavBar from '../components/ProjectNavBar'
|
import ProjectNavBar from '../components/ProjectNavBar'
|
||||||
import HeaderActionButton from '../components/HeaderActionButton'
|
import HeaderActionButton from '../components/HeaderActionButton'
|
||||||
import AddIcon from '@mui/icons-material/Add'
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
@@ -340,8 +339,8 @@ export default function ProjectHomePage() {
|
|||||||
{projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null}
|
{projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null}
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||||
<SectionCard
|
<ProjectOverviewCard
|
||||||
title="Overview"
|
project={project}
|
||||||
actions={
|
actions={
|
||||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
<HeaderActionButton variant="outlined" startIcon={<EditIcon />} onClick={openEdit} disabled={!project}>
|
<HeaderActionButton variant="outlined" startIcon={<EditIcon />} onClick={openEdit} disabled={!project}>
|
||||||
@@ -352,49 +351,10 @@ export default function ProjectHomePage() {
|
|||||||
</HeaderActionButton>
|
</HeaderActionButton>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
{project ? (
|
|
||||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
|
||||||
{project.created_by_name || project.created_by ? (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Created by: {project.created_by_name || project.created_by}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
{project.created_at ? (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Created: {new Date(project.created_at * 1000).toLocaleString()}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
{project.updated_by_name || project.updated_by ? (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Last updated by: {project.updated_by_name || project.updated_by}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
{project.updated_at ? (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Last updated: {new Date(project.updated_at * 1000).toLocaleString()}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
{project.description ? (
|
|
||||||
<Box sx={{ '& p': { m: 0 } }}>
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{project.description}</ReactMarkdown>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
No description.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Loading project...
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</SectionCard>
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Members"
|
title="Members"
|
||||||
|
collapsible
|
||||||
actions={
|
actions={
|
||||||
canManageMembers ? (
|
canManageMembers ? (
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
@@ -486,6 +446,7 @@ export default function ProjectHomePage() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Group Roles"
|
title="Group Roles"
|
||||||
|
collapsible
|
||||||
actions={
|
actions={
|
||||||
canManageMembers ? (
|
canManageMembers ? (
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Link, useParams } from 'react-router-dom'
|
|||||||
import { api, DockerManifestDetail, DockerTagInfo, Project, Repo } from '../api'
|
import { api, DockerManifestDetail, DockerTagInfo, Project, Repo } from '../api'
|
||||||
import ProjectNavBar from '../components/ProjectNavBar'
|
import ProjectNavBar from '../components/ProjectNavBar'
|
||||||
import RepoSubNav from '../components/RepoSubNav'
|
import RepoSubNav from '../components/RepoSubNav'
|
||||||
|
import SplitPanel from '../components/SplitPanel'
|
||||||
import TintedPanel from '../components/TintedPanel'
|
import TintedPanel from '../components/TintedPanel'
|
||||||
import PageAlert from '../components/PageAlert'
|
import PageAlert from '../components/PageAlert'
|
||||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
|
||||||
@@ -321,7 +322,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
|||||||
{loadError}
|
{loadError}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: '280px minmax(0, 1fr)', gap: 1, mb: 1 }}>
|
<SplitPanel defaultRatio={0.25} sx={{ mb: 1 }} left={(
|
||||||
<TintedPanel>
|
<TintedPanel>
|
||||||
<PageAlert severity="warning" message={imagesError} onClose={() => setImagesError(null)} sx={{ mb: 1 }} />
|
<PageAlert severity="warning" message={imagesError} onClose={() => setImagesError(null)} sx={{ mb: 1 }} />
|
||||||
<List dense>
|
<List dense>
|
||||||
@@ -379,6 +380,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</List>
|
</List>
|
||||||
</TintedPanel>
|
</TintedPanel>
|
||||||
|
)} right={(
|
||||||
<TintedPanel>
|
<TintedPanel>
|
||||||
<PageAlert severity="warning" message={tagsError} onClose={() => setTagsError(null)} sx={{ mb: 1 }} />
|
<PageAlert severity="warning" message={tagsError} onClose={() => setTagsError(null)} sx={{ mb: 1 }} />
|
||||||
{selectedImage !== '' || images.includes('') ? (
|
{selectedImage !== '' || images.includes('') ? (
|
||||||
@@ -467,7 +469,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</TintedPanel>
|
</TintedPanel>
|
||||||
</Box>
|
)} />
|
||||||
<ConfirmDeleteDialog
|
<ConfirmDeleteDialog
|
||||||
open={deleteTagOpen}
|
open={deleteTagOpen}
|
||||||
title="Delete tag"
|
title="Delete tag"
|
||||||
|
|||||||
@@ -7,14 +7,13 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
|||||||
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'
|
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'
|
||||||
import FolderIcon from '@mui/icons-material/Folder'
|
import FolderIcon from '@mui/icons-material/Folder'
|
||||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
||||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
|
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
|
|
||||||
import FileDownloadIcon from '@mui/icons-material/FileDownload'
|
import FileDownloadIcon from '@mui/icons-material/FileDownload'
|
||||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
|
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
|
||||||
import LazyCodeBlock from '../components/LazyCodeBlock'
|
import LazyCodeBlock from '../components/LazyCodeBlock'
|
||||||
import PageAlert from '../components/PageAlert'
|
import PageAlert from '../components/PageAlert'
|
||||||
import ProjectNavBar from '../components/ProjectNavBar'
|
import ProjectNavBar from '../components/ProjectNavBar'
|
||||||
import RepoSubNav from '../components/RepoSubNav'
|
import RepoSubNav from '../components/RepoSubNav'
|
||||||
|
import SplitPanel from '../components/SplitPanel'
|
||||||
import TintedPanel from '../components/TintedPanel'
|
import TintedPanel from '../components/TintedPanel'
|
||||||
|
|
||||||
const RepoMarkdown = lazy(() => import('../components/RepoMarkdown'))
|
const RepoMarkdown = lazy(() => import('../components/RepoMarkdown'))
|
||||||
@@ -46,7 +45,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
const [history, setHistory] = useState<RepoCommit[]>([])
|
const [history, setHistory] = useState<RepoCommit[]>([])
|
||||||
const [diff, setDiff] = useState<string>('')
|
const [diff, setDiff] = useState<string>('')
|
||||||
const [previewTab, setPreviewTab] = useState<'content' | 'history' | 'diff'>('content')
|
const [previewTab, setPreviewTab] = useState<'content' | 'history' | 'diff'>('content')
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
|
||||||
const [selectedCommit, setSelectedCommit] = useState<RepoCommit | null>(null)
|
const [selectedCommit, setSelectedCommit] = useState<RepoCommit | null>(null)
|
||||||
const [readmeContent, setReadmeContent] = useState<string>('')
|
const [readmeContent, setReadmeContent] = useState<string>('')
|
||||||
const [readmeKind, setReadmeKind] = useState<'markdown' | 'text' | ''>('')
|
const [readmeKind, setReadmeKind] = useState<'markdown' | 'text' | ''>('')
|
||||||
@@ -539,6 +537,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
if (!match) return 'markup'
|
if (!match) return 'markup'
|
||||||
return match[1]
|
return match[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||||
@@ -670,11 +669,9 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
<Divider sx={{ mb: 1 }} />
|
<Divider sx={{ mb: 1 }} />
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: `${sidebarOpen ? '280px' : '44px'} minmax(0, 1fr)`, gap: 1 }}>
|
<SplitPanel defaultRatio={0.25} left={(
|
||||||
<TintedPanel>
|
<TintedPanel>
|
||||||
{sidebarOpen ? (
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||||
<>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0 }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
@@ -707,9 +704,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="small" onClick={() => setSidebarOpen((prev) => !prev)} aria-label="Collapse sidebar">
|
|
||||||
<ChevronLeftIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</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>
|
||||||
@@ -767,15 +761,8 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
|
||||||
<IconButton size="small" onClick={() => setSidebarOpen((prev) => !prev)} aria-label="Expand sidebar">
|
|
||||||
<ChevronRightIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</TintedPanel>
|
</TintedPanel>
|
||||||
|
)} right={(
|
||||||
<TintedPanel>
|
<TintedPanel>
|
||||||
{selectedFile ? (
|
{selectedFile ? (
|
||||||
<Box sx={{ mt: 0 }}>
|
<Box sx={{ mt: 0 }}>
|
||||||
@@ -962,7 +949,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</TintedPanel>
|
</TintedPanel>
|
||||||
</Box>
|
)} />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ import {
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import PageAlert from '../components/PageAlert'
|
import PageAlert from '../components/PageAlert'
|
||||||
|
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 ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
|
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
|
|
||||||
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'
|
||||||
@@ -61,7 +60,6 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
|||||||
const [rpmMetaError, setRpmMetaError] = useState<string | null>(null)
|
const [rpmMetaError, setRpmMetaError] = useState<string | null>(null)
|
||||||
const [rpmMetaLoading, setRpmMetaLoading] = useState(false)
|
const [rpmMetaLoading, setRpmMetaLoading] = useState(false)
|
||||||
const [rpmTab, setRpmTab] = useState<'meta' | 'files' | 'changelog'>('meta')
|
const [rpmTab, setRpmTab] = useState<'meta' | 'files' | 'changelog'>('meta')
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
|
||||||
const [subdirOpen, setSubdirOpen] = useState(false)
|
const [subdirOpen, setSubdirOpen] = useState(false)
|
||||||
const [subdirName, setSubdirName] = useState('')
|
const [subdirName, setSubdirName] = useState('')
|
||||||
const [subdirType, setSubdirType] = useState<'container' | 'repo'>('container')
|
const [subdirType, setSubdirType] = useState<'container' | 'repo'>('container')
|
||||||
@@ -662,6 +660,16 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
|||||||
return new Response(stream).text()
|
return new Response(stream).text()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statusRepoUrl = (() => {
|
||||||
|
if (!repo?.rpm_url) return ''
|
||||||
|
let base = repo.rpm_url.replace(/\/+$/, '')
|
||||||
|
if (!base.includes('://')) {
|
||||||
|
base = window.location.origin + base
|
||||||
|
}
|
||||||
|
const path = statusPath.replace(/^\/+/, '')
|
||||||
|
return path ? `${base}/${path}` : base
|
||||||
|
})()
|
||||||
|
|
||||||
const rpmFileLink = () => {
|
const rpmFileLink = () => {
|
||||||
if (!repo || repo.type !== 'rpm' || !repo.rpm_url || !rpmSelectedEntry) return ''
|
if (!repo || repo.type !== 'rpm' || !repo.rpm_url || !rpmSelectedEntry) return ''
|
||||||
const base = repo.rpm_url.replace(/\/+$/, '')
|
const base = repo.rpm_url.replace(/\/+$/, '')
|
||||||
@@ -844,229 +852,215 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
|||||||
{loadError}
|
{loadError}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: `${sidebarOpen ? '280px' : '44px'} minmax(0, 1fr)`, gap: 1, mb: 1 }}>
|
<SplitPanel defaultRatio={0.3} sx={{ mb: 1 }} left={(
|
||||||
<TintedPanel>
|
<TintedPanel>
|
||||||
{sidebarOpen ? (
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1 }}>
|
||||||
<>
|
{canWrite ? (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
<>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
<Button
|
||||||
{canWrite ? (
|
size="small"
|
||||||
<>
|
onClick={() => {
|
||||||
<Button
|
setSubdirError(null)
|
||||||
size="small"
|
setSubdirName('')
|
||||||
onClick={() => {
|
setSubdirType('container')
|
||||||
setSubdirError(null)
|
setSubdirMode('local')
|
||||||
setSubdirName('')
|
setSubdirAllowDelete(false)
|
||||||
setSubdirType('container')
|
setSubdirSyncIntervalSec('300')
|
||||||
setSubdirMode('local')
|
setSubdirRemoteURL('')
|
||||||
setSubdirAllowDelete(false)
|
setSubdirConnectHost('')
|
||||||
setSubdirSyncIntervalSec('300')
|
setSubdirHostHeader('')
|
||||||
setSubdirRemoteURL('')
|
setSubdirTLSServerName('')
|
||||||
setSubdirConnectHost('')
|
setSubdirTLSInsecureSkipVerify(false)
|
||||||
setSubdirHostHeader('')
|
setSubdirOpen(true)
|
||||||
setSubdirTLSServerName('')
|
}}
|
||||||
setSubdirTLSInsecureSkipVerify(false)
|
>
|
||||||
setSubdirOpen(true)
|
New Folder...
|
||||||
}}
|
</Button>
|
||||||
>
|
<Button
|
||||||
New Folder...
|
size="small"
|
||||||
</Button>
|
onClick={() => {
|
||||||
<Button
|
setUploadError(null)
|
||||||
size="small"
|
setUploadFiles([])
|
||||||
onClick={() => {
|
setUploadOpen(true)
|
||||||
setUploadError(null)
|
}}
|
||||||
setUploadFiles([])
|
>
|
||||||
setUploadOpen(true)
|
Upload RPM...
|
||||||
}}
|
</Button>
|
||||||
>
|
</>
|
||||||
Upload RPM...
|
) : null}
|
||||||
</Button>
|
</Box>
|
||||||
</>
|
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.25, mb: 1, px: 0.5 }}>
|
||||||
|
<IconButton size="small" onClick={() => handleRpmBreadcrumb('')} aria-label="Root">
|
||||||
|
<HomeOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
/
|
||||||
|
</Typography>
|
||||||
|
{rpmPathParts.map((part, idx) => {
|
||||||
|
const nextPath = rpmPathParts.slice(0, idx + 1).join('/')
|
||||||
|
return (
|
||||||
|
<Box key={nextPath} sx={{ display: 'flex', alignItems: 'center', gap: 0 }}>
|
||||||
|
{idx > 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mx: 0.25 }}>
|
||||||
|
/
|
||||||
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
<IconButton size="small" onClick={() => setSidebarOpen((prev) => !prev)} aria-label="Collapse sidebar">
|
<Button size="small" onClick={() => handleRpmBreadcrumb(nextPath)} sx={{ textTransform: 'none', px: 0.5 }}>
|
||||||
<ChevronLeftIcon fontSize="small" />
|
{part}
|
||||||
</IconButton>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
)
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.25, mb: 1, px: 0.5 }}>
|
})}
|
||||||
<IconButton size="small" onClick={() => handleRpmBreadcrumb('')} aria-label="Root">
|
</Box>
|
||||||
<HomeOutlinedIcon fontSize="small" />
|
<TextField
|
||||||
</IconButton>
|
size="small"
|
||||||
<Typography variant="body2" color="text.secondary">
|
placeholder="Search files"
|
||||||
/
|
value={rpmFileQuery}
|
||||||
</Typography>
|
onChange={(event) => setRpmFileQuery(event.target.value)}
|
||||||
{rpmPathParts.map((part, idx) => {
|
fullWidth
|
||||||
const nextPath = rpmPathParts.slice(0, idx + 1).join('/')
|
sx={{ mb: 1, px: 0.5 }}
|
||||||
return (
|
/>
|
||||||
<Box key={nextPath} sx={{ display: 'flex', alignItems: 'center', gap: 0 }}>
|
<PageAlert severity="warning" message={rpmTreeError} onClose={() => setRpmTreeError(null)} sx={{ mb: 1 }} />
|
||||||
{idx > 0 ? (
|
<List dense>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mx: 0.25 }}>
|
{rpmPath ? (
|
||||||
/
|
<ListItem disablePadding>
|
||||||
</Typography>
|
<ListItemButton onClick={handleRpmBack}>
|
||||||
) : null}
|
<ListItemText
|
||||||
<Button size="small" onClick={() => handleRpmBreadcrumb(nextPath)} sx={{ textTransform: 'none', px: 0.5 }}>
|
primary={
|
||||||
{part}
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
</Button>
|
<FolderIcon fontSize="small" color="info" />
|
||||||
</Box>
|
<Typography variant="body2">..</Typography>
|
||||||
)
|
</Box>
|
||||||
})}
|
}
|
||||||
</Box>
|
/>
|
||||||
<TextField
|
</ListItemButton>
|
||||||
size="small"
|
</ListItem>
|
||||||
placeholder="Search files"
|
) : null}
|
||||||
value={rpmFileQuery}
|
{filteredTree.map((entry) => (
|
||||||
onChange={(event) => setRpmFileQuery(event.target.value)}
|
<ListItem key={entry.path} disablePadding>
|
||||||
fullWidth
|
<ListItemButton onClick={() => handleRpmEntry(entry)}>
|
||||||
sx={{ mb: 1, px: 0.5 }}
|
<ListItemText
|
||||||
/>
|
primary={
|
||||||
<PageAlert severity="warning" message={rpmTreeError} onClose={() => setRpmTreeError(null)} sx={{ mb: 1 }} />
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<List dense>
|
|
||||||
{rpmPath ? (
|
|
||||||
<ListItem disablePadding>
|
|
||||||
<ListItemButton onClick={handleRpmBack}>
|
|
||||||
<ListItemText
|
|
||||||
primary={
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<FolderIcon fontSize="small" color="info" />
|
|
||||||
<Typography variant="body2">..</Typography>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
</ListItem>
|
|
||||||
) : null}
|
|
||||||
{filteredTree.map((entry) => (
|
|
||||||
<ListItem key={entry.path} disablePadding>
|
|
||||||
<ListItemButton onClick={() => handleRpmEntry(entry)}>
|
|
||||||
<ListItemText
|
|
||||||
primary={
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
{entry.type === 'dir' ? (
|
|
||||||
<FolderIcon fontSize="small" color="info" />
|
|
||||||
) : (
|
|
||||||
<InsertDriveFileIcon fontSize="small" color="info" />
|
|
||||||
)}
|
|
||||||
<Typography variant="body2">{entry.name}</Typography>
|
|
||||||
{entry.type === 'dir' && entry.is_repo_dir ? (
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
color={entry.repo_mode === 'mirror' ? 'warning' : 'default'}
|
|
||||||
label={entry.repo_mode === 'mirror' ? 'mirror' : 'local'}
|
|
||||||
sx={{ height: 18 }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
{canWrite ? (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pr: 0.5 }}>
|
|
||||||
{entry.type === 'dir' && entry.is_repo_dir ? (
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={async (event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
await openStatusDialog(entry)
|
|
||||||
}}
|
|
||||||
aria-label={`View status for ${entry.name}`}
|
|
||||||
>
|
|
||||||
<MonitorHeartOutlinedIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
) : null}
|
|
||||||
{entry.type === 'dir' && entry.name.toLowerCase() !== 'repodata' ? (
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={async (event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
setRenameError(null)
|
|
||||||
setRenamePath(entry.path)
|
|
||||||
setRenameName(entry.name)
|
|
||||||
setRenameNewName(entry.name)
|
|
||||||
setRenameIsRepoDir(Boolean(entry.is_repo_dir))
|
|
||||||
setRenameMode(entry.repo_mode === 'mirror' ? 'mirror' : 'local')
|
|
||||||
setRenameAllowDelete(false)
|
|
||||||
setRenameSyncIntervalSec('300')
|
|
||||||
setRenameRemoteURL('')
|
|
||||||
setRenameConnectHost('')
|
|
||||||
setRenameHostHeader('')
|
|
||||||
setRenameTLSServerName('')
|
|
||||||
setRenameTLSInsecureSkipVerify(false)
|
|
||||||
if (repoId && entry.is_repo_dir) {
|
|
||||||
try {
|
|
||||||
const cfg = await api.getRpmSubdir(repoId, entry.path)
|
|
||||||
setRenameMode(cfg.mode === 'mirror' ? 'mirror' : 'local')
|
|
||||||
setRenameAllowDelete(Boolean(cfg.allow_delete))
|
|
||||||
setRenameSyncIntervalSec(String(cfg.sync_interval_sec || 300))
|
|
||||||
setRenameRemoteURL(cfg.remote_url || '')
|
|
||||||
setRenameConnectHost(cfg.connect_host || '')
|
|
||||||
setRenameHostHeader(cfg.host_header || '')
|
|
||||||
setRenameTLSServerName(cfg.tls_server_name || '')
|
|
||||||
setRenameTLSInsecureSkipVerify(Boolean(cfg.tls_insecure_skip_verify))
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to load repo directory settings'
|
|
||||||
setRenameError(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setRenameOpen(true)
|
|
||||||
}}
|
|
||||||
aria-label={`Rename folder ${entry.name}`}
|
|
||||||
>
|
|
||||||
<DriveFileRenameOutlineIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
) : null}
|
|
||||||
{entry.type === 'dir' ? (
|
{entry.type === 'dir' ? (
|
||||||
<IconButton
|
<FolderIcon fontSize="small" color="info" />
|
||||||
|
) : (
|
||||||
|
<InsertDriveFileIcon fontSize="small" color="info" />
|
||||||
|
)}
|
||||||
|
<Typography variant="body2">{entry.name}</Typography>
|
||||||
|
{entry.type === 'dir' && entry.is_repo_dir ? (
|
||||||
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(event) => {
|
color={entry.repo_mode === 'mirror' ? 'warning' : 'default'}
|
||||||
event.stopPropagation()
|
label={entry.repo_mode === 'mirror' ? 'mirror' : 'local'}
|
||||||
setDeleteError(null)
|
sx={{ height: 18 }}
|
||||||
setDeletePath(entry.path)
|
/>
|
||||||
setDeleteName(entry.name)
|
|
||||||
setDeleteIsFile(false)
|
|
||||||
setDeleteConfirm('')
|
|
||||||
setDeleteOpen(true)
|
|
||||||
}}
|
|
||||||
aria-label={`Delete folder ${entry.name}`}
|
|
||||||
>
|
|
||||||
<DeleteOutlineIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
) : null}
|
|
||||||
{entry.type !== 'dir' && entry.name.toLowerCase().endsWith('.rpm') ? (
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
setDeleteError(null)
|
|
||||||
setDeletePath(entry.path)
|
|
||||||
setDeleteName(entry.name)
|
|
||||||
setDeleteIsFile(true)
|
|
||||||
setDeleteConfirm('')
|
|
||||||
setDeleteOpen(true)
|
|
||||||
}}
|
|
||||||
aria-label={`Delete file ${entry.name}`}
|
|
||||||
>
|
|
||||||
<DeleteOutlineIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
{canWrite ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pr: 0.5 }}>
|
||||||
|
{entry.type === 'dir' && entry.is_repo_dir ? (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={async (event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
await openStatusDialog(entry)
|
||||||
|
}}
|
||||||
|
aria-label={`View status for ${entry.name}`}
|
||||||
|
>
|
||||||
|
<MonitorHeartOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
) : null}
|
) : null}
|
||||||
</ListItem>
|
{entry.type === 'dir' && entry.name.toLowerCase() !== 'repodata' ? (
|
||||||
))}
|
<IconButton
|
||||||
{!filteredTree.length && !rpmTreeError ? (
|
size="small"
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ px: 1, py: 1 }}>
|
onClick={async (event) => {
|
||||||
{normalizedQuery ? 'No matching files.' : 'No files found.'}
|
event.stopPropagation()
|
||||||
</Typography>
|
setRenameError(null)
|
||||||
|
setRenamePath(entry.path)
|
||||||
|
setRenameName(entry.name)
|
||||||
|
setRenameNewName(entry.name)
|
||||||
|
setRenameIsRepoDir(Boolean(entry.is_repo_dir))
|
||||||
|
setRenameMode(entry.repo_mode === 'mirror' ? 'mirror' : 'local')
|
||||||
|
setRenameAllowDelete(false)
|
||||||
|
setRenameSyncIntervalSec('300')
|
||||||
|
setRenameRemoteURL('')
|
||||||
|
setRenameConnectHost('')
|
||||||
|
setRenameHostHeader('')
|
||||||
|
setRenameTLSServerName('')
|
||||||
|
setRenameTLSInsecureSkipVerify(false)
|
||||||
|
if (repoId && entry.is_repo_dir) {
|
||||||
|
try {
|
||||||
|
const cfg = await api.getRpmSubdir(repoId, entry.path)
|
||||||
|
setRenameMode(cfg.mode === 'mirror' ? 'mirror' : 'local')
|
||||||
|
setRenameAllowDelete(Boolean(cfg.allow_delete))
|
||||||
|
setRenameSyncIntervalSec(String(cfg.sync_interval_sec || 300))
|
||||||
|
setRenameRemoteURL(cfg.remote_url || '')
|
||||||
|
setRenameConnectHost(cfg.connect_host || '')
|
||||||
|
setRenameHostHeader(cfg.host_header || '')
|
||||||
|
setRenameTLSServerName(cfg.tls_server_name || '')
|
||||||
|
setRenameTLSInsecureSkipVerify(Boolean(cfg.tls_insecure_skip_verify))
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to load repo directory settings'
|
||||||
|
setRenameError(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setRenameOpen(true)
|
||||||
|
}}
|
||||||
|
aria-label={`Rename folder ${entry.name}`}
|
||||||
|
>
|
||||||
|
<DriveFileRenameOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
{entry.type === 'dir' ? (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setDeleteError(null)
|
||||||
|
setDeletePath(entry.path)
|
||||||
|
setDeleteName(entry.name)
|
||||||
|
setDeleteIsFile(false)
|
||||||
|
setDeleteConfirm('')
|
||||||
|
setDeleteOpen(true)
|
||||||
|
}}
|
||||||
|
aria-label={`Delete folder ${entry.name}`}
|
||||||
|
>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
{entry.type !== 'dir' && entry.name.toLowerCase().endsWith('.rpm') ? (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setDeleteError(null)
|
||||||
|
setDeletePath(entry.path)
|
||||||
|
setDeleteName(entry.name)
|
||||||
|
setDeleteIsFile(true)
|
||||||
|
setDeleteConfirm('')
|
||||||
|
setDeleteOpen(true)
|
||||||
|
}}
|
||||||
|
aria-label={`Delete file ${entry.name}`}
|
||||||
|
>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
</List>
|
</ListItem>
|
||||||
</>
|
))}
|
||||||
) : (
|
{!filteredTree.length && !rpmTreeError ? (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ px: 1, py: 1 }}>
|
||||||
<IconButton size="small" onClick={() => setSidebarOpen((prev) => !prev)} aria-label="Expand sidebar">
|
{normalizedQuery ? 'No matching files.' : 'No files found.'}
|
||||||
<ChevronRightIcon fontSize="small" />
|
</Typography>
|
||||||
</IconButton>
|
) : null}
|
||||||
</Box>
|
</List>
|
||||||
)}
|
|
||||||
</TintedPanel>
|
</TintedPanel>
|
||||||
|
)} right={(
|
||||||
<TintedPanel>
|
<TintedPanel>
|
||||||
{rpmSelected && rpmSelectedEntry && rpmSelectedEntry.name.toLowerCase().endsWith('.rpm') ? (
|
{rpmSelected && rpmSelectedEntry && rpmSelectedEntry.name.toLowerCase().endsWith('.rpm') ? (
|
||||||
<>
|
<>
|
||||||
@@ -1208,7 +1202,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</TintedPanel>
|
</TintedPanel>
|
||||||
</Box>
|
)} />
|
||||||
<Dialog open={subdirOpen} onClose={() => setSubdirOpen(false)} maxWidth="sm" fullWidth>
|
<Dialog open={subdirOpen} onClose={() => setSubdirOpen(false)} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>New Subdirectory</DialogTitle>
|
<DialogTitle>New Subdirectory</DialogTitle>
|
||||||
<FormDialogContent>
|
<FormDialogContent>
|
||||||
@@ -1362,6 +1356,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
|||||||
name={statusName}
|
name={statusName}
|
||||||
path={statusPath}
|
path={statusPath}
|
||||||
mode={statusMode}
|
mode={statusMode}
|
||||||
|
repoUrl={statusRepoUrl}
|
||||||
syncStatus={statusSyncStatus}
|
syncStatus={statusSyncStatus}
|
||||||
syncStep={statusSyncStep}
|
syncStep={statusSyncStep}
|
||||||
syncError={statusSyncError}
|
syncError={statusSyncError}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import PageAlert from '../components/PageAlert'
|
import PageAlert from '../components/PageAlert'
|
||||||
import { api, AvailableRepo, Project, Repo, RepoStats } from '../api'
|
import { api, AvailableRepo, Project, ProjectUpdatePayload, Repo, RepoStats } from '../api'
|
||||||
import Autocomplete from '../components/Autocomplete'
|
import Autocomplete from '../components/Autocomplete'
|
||||||
import ProjectNavBar from '../components/ProjectNavBar'
|
import ProjectNavBar from '../components/ProjectNavBar'
|
||||||
import HeaderActionButton from '../components/HeaderActionButton'
|
import HeaderActionButton from '../components/HeaderActionButton'
|
||||||
@@ -29,6 +29,9 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
|
|||||||
import RepositoryFormDialog from '../components/RepositoryFormDialog'
|
import RepositoryFormDialog from '../components/RepositoryFormDialog'
|
||||||
import SectionCard from '../components/SectionCard'
|
import SectionCard from '../components/SectionCard'
|
||||||
import SelectField from '../components/SelectField'
|
import SelectField from '../components/SelectField'
|
||||||
|
import ProjectFormDialog from '../components/ProjectFormDialog'
|
||||||
|
import { ProjectDescriptionTab, ProjectHomePageValue } from '../components/ProjectFormDialog'
|
||||||
|
import ProjectOverviewCard from '../components/ProjectOverviewCard'
|
||||||
|
|
||||||
export default function ReposPage() {
|
export default function ReposPage() {
|
||||||
const { projectId } = useParams()
|
const { projectId } = useParams()
|
||||||
@@ -73,6 +76,14 @@ export default function ReposPage() {
|
|||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
const [pageSizeInput, setPageSizeInput] = useState('20')
|
const [pageSizeInput, setPageSizeInput] = useState('20')
|
||||||
|
const [projectEditOpen, setProjectEditOpen] = useState(false)
|
||||||
|
const [projectEditSlug, setProjectEditSlug] = useState('')
|
||||||
|
const [projectEditName, setProjectEditName] = useState('')
|
||||||
|
const [projectEditDescription, setProjectEditDescription] = useState('')
|
||||||
|
const [projectEditHomePage, setProjectEditHomePage] = useState<ProjectHomePageValue>('info')
|
||||||
|
const [projectEditDescTab, setProjectEditDescTab] = useState<ProjectDescriptionTab>('write')
|
||||||
|
const [projectEditError, setProjectEditError] = useState<string | null>(null)
|
||||||
|
const [savingProjectEdit, setSavingProjectEdit] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId) return
|
if (!projectId) return
|
||||||
@@ -336,6 +347,43 @@ export default function ReposPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openProjectEdit = () => {
|
||||||
|
if (!project) return
|
||||||
|
setProjectEditSlug(project.slug)
|
||||||
|
setProjectEditName(project.name)
|
||||||
|
setProjectEditDescription(project.description || '')
|
||||||
|
setProjectEditHomePage(project.home_page || 'info')
|
||||||
|
setProjectEditDescTab('write')
|
||||||
|
setProjectEditError(null)
|
||||||
|
setProjectEditOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProjectEditSave = async () => {
|
||||||
|
if (!projectId) return
|
||||||
|
if (/\s/.test(projectEditSlug)) {
|
||||||
|
setProjectEditError('Slug cannot contain whitespace.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload: ProjectUpdatePayload = {
|
||||||
|
slug: projectEditSlug.trim(),
|
||||||
|
name: projectEditName.trim(),
|
||||||
|
description: projectEditDescription.trim(),
|
||||||
|
home_page: projectEditHomePage
|
||||||
|
}
|
||||||
|
setProjectEditError(null)
|
||||||
|
setSavingProjectEdit(true)
|
||||||
|
try {
|
||||||
|
const updated = await api.updateProject(projectId, payload)
|
||||||
|
setProject(updated)
|
||||||
|
setProjectEditOpen(false)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to update project'
|
||||||
|
setProjectEditError(message)
|
||||||
|
} finally {
|
||||||
|
setSavingProjectEdit(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h5" sx={{ mb: 1 }}>
|
<Typography variant="h5" sx={{ mb: 1 }}>
|
||||||
@@ -370,10 +418,20 @@ export default function ReposPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
{projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null}
|
{projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null}
|
||||||
</Box>
|
</Box>
|
||||||
<SectionCard
|
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||||
title={
|
<ProjectOverviewCard
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
|
project={project}
|
||||||
<Typography variant="h6">Repositories</Typography>
|
actions={
|
||||||
|
<HeaderActionButton variant="outlined" startIcon={<EditIcon />} onClick={openProjectEdit} disabled={!project}>
|
||||||
|
Edit Project
|
||||||
|
</HeaderActionButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SectionCard
|
||||||
|
collapsible
|
||||||
|
title={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant="h6">Repositories</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
label="Search"
|
label="Search"
|
||||||
@@ -556,6 +614,27 @@ export default function ReposPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
</Box>
|
||||||
|
<ProjectFormDialog
|
||||||
|
open={projectEditOpen}
|
||||||
|
title="Edit Project"
|
||||||
|
error={projectEditError}
|
||||||
|
slug={projectEditSlug}
|
||||||
|
onSlugChange={setProjectEditSlug}
|
||||||
|
name={projectEditName}
|
||||||
|
onNameChange={setProjectEditName}
|
||||||
|
description={projectEditDescription}
|
||||||
|
onDescriptionChange={setProjectEditDescription}
|
||||||
|
homePage={projectEditHomePage}
|
||||||
|
onHomePageChange={setProjectEditHomePage}
|
||||||
|
descriptionTab={projectEditDescTab}
|
||||||
|
onDescriptionTabChange={setProjectEditDescTab}
|
||||||
|
busy={savingProjectEdit}
|
||||||
|
saveText="Save"
|
||||||
|
busyText="Saving..."
|
||||||
|
onSave={handleProjectEditSave}
|
||||||
|
onClose={() => setProjectEditOpen(false)}
|
||||||
|
/>
|
||||||
<RepositoryFormDialog
|
<RepositoryFormDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
title="New Repository"
|
title="New Repository"
|
||||||
|
|||||||
Reference in New Issue
Block a user