Compare commits

..

4 Commits

12 changed files with 469 additions and 301 deletions
@@ -72,11 +72,11 @@ export default function ProjectFormDialog(props: ProjectFormDialogProps) {
value={props.homePage}
onChange={(event) => props.onHomePageChange(event.target.value as ProjectHomePageValue)}
>
<MenuItem value="info">Info</MenuItem>
<MenuItem value="repos">Repositories</MenuItem>
<MenuItem value="issues">Issues</MenuItem>
<MenuItem value="wiki">Wiki</MenuItem>
<MenuItem value="files">Files</MenuItem>
<MenuItem value="info">Settings</MenuItem>
</SelectField>
<Box sx={{ mt: 1 }}>
<Tabs
+8 -8
View File
@@ -42,14 +42,6 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
return (
<Paper sx={paperSx}>
<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
component={Link}
to={`/projects/${projectId}/repos`}
@@ -82,6 +74,14 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
>
Files
</Button>
<Button
component={Link}
to={`/projects/${projectId}/info`}
startIcon={<InfoOutlinedIcon />}
sx={{ justifyContent: 'flex-start', minWidth: 0, width: 'fit-content' }}
>
Settings
</Button>
</Box>
{right ? <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>{right}</Box> : null}
</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 Button from '@mui/material/Button'
import IconButton from '@mui/material/IconButton'
import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import Typography from '@mui/material/Typography'
import FormDialogContent from './FormDialogContent'
import PageAlert from './PageAlert'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import { RpmMirrorRun } from '../api'
type RepoRpmDirectoryStatusDialogProps = {
@@ -13,6 +15,7 @@ type RepoRpmDirectoryStatusDialogProps = {
name: string
path: string
mode: 'local' | 'mirror'
repoUrl: string
syncStatus: string
syncStep: string
syncError: string
@@ -42,6 +45,24 @@ export default function RepoRpmDirectoryStatusDialog(props: RepoRpmDirectoryStat
<Typography variant="body2">Directory: {props.name}</Typography>
<Typography variant="body2">Path: {props.path || '.'}</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' ? (
<>
<Typography variant="body2">
@@ -52,7 +52,7 @@ function isSameSessionTarget(left: SSHSession | null, right: SSHSession): boolea
export default function SSHSessionFileCopyDialog(props: SSHSessionFileCopyDialogProps) {
const [pathsText, setPathsText] = useState('')
const [targetDir, setTargetDir] = useState('.')
const [overwrite, setOverwrite] = useState(true)
const [overwrite, setOverwrite] = useState(false)
const [targetSessionID, setTargetSessionID] = useState('')
const [targetSessions, setTargetSessions] = useState<SSHSession[]>([])
const [sourcePassword, setSourcePassword] = useState('')
@@ -75,7 +75,7 @@ export default function SSHSessionFileCopyDialog(props: SSHSessionFileCopyDialog
if (!props.open) return
setPathsText('')
setTargetDir('.')
setOverwrite(true)
setOverwrite(false)
setTargetSessionID('')
setTargetSessions([])
setSourcePassword('')
@@ -33,7 +33,7 @@ function formatFileSize(size: number): string {
export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDialogProps) {
const [targetDir, setTargetDir] = useState('.')
const [overwrite, setOverwrite] = useState(true)
const [overwrite, setOverwrite] = useState(false)
const [files, setFiles] = useState<File[]>([])
const [password, setPassword] = useState('')
const [otpCode, setOTPCode] = useState('')
@@ -46,7 +46,7 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
useEffect(() => {
if (!props.open) return
setTargetDir('.')
setOverwrite(true)
setOverwrite(false)
setFiles([])
setPassword('')
setOTPCode('')
+65
View File
@@ -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>
)
}
+6 -45
View File
@@ -3,8 +3,7 @@ import { Box, Button, Chip, DialogContent, MenuItem, Paper, TextField, ToggleBut
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, Project, ProjectGroupRole, ProjectMember, ProjectUpdatePayload, User, UserGroup } from '../api'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import ProjectOverviewCard from '../components/ProjectOverviewCard'
import ProjectNavBar from '../components/ProjectNavBar'
import HeaderActionButton from '../components/HeaderActionButton'
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}
</Box>
<Box sx={{ display: 'grid', gap: 1 }}>
<SectionCard
title="Overview"
<ProjectOverviewCard
project={project}
actions={
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<HeaderActionButton variant="outlined" startIcon={<EditIcon />} onClick={openEdit} disabled={!project}>
@@ -352,49 +351,10 @@ export default function ProjectHomePage() {
</HeaderActionButton>
</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
title="Members"
collapsible
actions={
canManageMembers ? (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
@@ -486,6 +446,7 @@ export default function ProjectHomePage() {
</SectionCard>
<SectionCard
title="Group Roles"
collapsible
actions={
canManageMembers ? (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
+4 -2
View File
@@ -15,6 +15,7 @@ import { Link, useParams } from 'react-router-dom'
import { api, DockerManifestDetail, DockerTagInfo, Project, Repo } from '../api'
import ProjectNavBar from '../components/ProjectNavBar'
import RepoSubNav from '../components/RepoSubNav'
import SplitPanel from '../components/SplitPanel'
import TintedPanel from '../components/TintedPanel'
import PageAlert from '../components/PageAlert'
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
@@ -321,7 +322,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
{loadError}
</Typography>
) : null}
<Box sx={{ display: 'grid', gridTemplateColumns: '280px minmax(0, 1fr)', gap: 1, mb: 1 }}>
<SplitPanel defaultRatio={0.25} sx={{ mb: 1 }} left={(
<TintedPanel>
<PageAlert severity="warning" message={imagesError} onClose={() => setImagesError(null)} sx={{ mb: 1 }} />
<List dense>
@@ -379,6 +380,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
) : null}
</List>
</TintedPanel>
)} right={(
<TintedPanel>
<PageAlert severity="warning" message={tagsError} onClose={() => setTagsError(null)} sx={{ mb: 1 }} />
{selectedImage !== '' || images.includes('') ? (
@@ -467,7 +469,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
</Typography>
)}
</TintedPanel>
</Box>
)} />
<ConfirmDeleteDialog
open={deleteTagOpen}
title="Delete tag"
+6 -19
View File
@@ -7,14 +7,13 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'
import FolderIcon from '@mui/icons-material/Folder'
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 OpenInNewIcon from '@mui/icons-material/OpenInNew'
import LazyCodeBlock from '../components/LazyCodeBlock'
import PageAlert from '../components/PageAlert'
import ProjectNavBar from '../components/ProjectNavBar'
import RepoSubNav from '../components/RepoSubNav'
import SplitPanel from '../components/SplitPanel'
import TintedPanel from '../components/TintedPanel'
const RepoMarkdown = lazy(() => import('../components/RepoMarkdown'))
@@ -46,7 +45,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
const [history, setHistory] = useState<RepoCommit[]>([])
const [diff, setDiff] = useState<string>('')
const [previewTab, setPreviewTab] = useState<'content' | 'history' | 'diff'>('content')
const [sidebarOpen, setSidebarOpen] = useState(true)
const [selectedCommit, setSelectedCommit] = useState<RepoCommit | null>(null)
const [readmeContent, setReadmeContent] = useState<string>('')
const [readmeKind, setReadmeKind] = useState<'markdown' | 'text' | ''>('')
@@ -539,6 +537,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
if (!match) return 'markup'
return match[1]
}
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
@@ -670,11 +669,9 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
) : null}
</Box>
<Divider sx={{ mb: 1 }} />
<Box sx={{ display: 'grid', gridTemplateColumns: `${sidebarOpen ? '280px' : '44px'} minmax(0, 1fr)`, gap: 1 }}>
<SplitPanel defaultRatio={0.25} left={(
<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 }}>
<IconButton
size="small"
@@ -707,9 +704,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
)
})}
</Box>
<IconButton size="small" onClick={() => setSidebarOpen((prev) => !prev)} aria-label="Collapse sidebar">
<ChevronLeftIcon fontSize="small" />
</IconButton>
</Box>
<PageAlert severity="warning" message={treeError} onClose={() => setTreeError(null)} sx={{ mb: 1 }} />
<List dense>
@@ -767,15 +761,8 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
</ListItem>
))}
</List>
</>
) : (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<IconButton size="small" onClick={() => setSidebarOpen((prev) => !prev)} aria-label="Expand sidebar">
<ChevronRightIcon fontSize="small" />
</IconButton>
</Box>
)}
</TintedPanel>
)} right={(
<TintedPanel>
{selectedFile ? (
<Box sx={{ mt: 0 }}>
@@ -962,7 +949,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
</Box>
)}
</TintedPanel>
</Box>
)} />
</Box>
)
}
+212 -217
View File
@@ -23,9 +23,8 @@ import {
import { useEffect, useRef, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import PageAlert from '../components/PageAlert'
import SplitPanel from '../components/SplitPanel'
import { api, Project, Repo, RpmMirrorRun, RpmPackageDetail, RpmPackageSummary, RpmSubdirCreatePayload, RpmSubdirUpdatePayload, RpmTreeEntry } from '../api'
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
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 [rpmMetaLoading, setRpmMetaLoading] = useState(false)
const [rpmTab, setRpmTab] = useState<'meta' | 'files' | 'changelog'>('meta')
const [sidebarOpen, setSidebarOpen] = useState(true)
const [subdirOpen, setSubdirOpen] = useState(false)
const [subdirName, setSubdirName] = useState('')
const [subdirType, setSubdirType] = useState<'container' | 'repo'>('container')
@@ -662,6 +660,16 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
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 = () => {
if (!repo || repo.type !== 'rpm' || !repo.rpm_url || !rpmSelectedEntry) return ''
const base = repo.rpm_url.replace(/\/+$/, '')
@@ -844,229 +852,215 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
{loadError}
</Typography>
) : 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>
{sidebarOpen ? (
<>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{canWrite ? (
<>
<Button
size="small"
onClick={() => {
setSubdirError(null)
setSubdirName('')
setSubdirType('container')
setSubdirMode('local')
setSubdirAllowDelete(false)
setSubdirSyncIntervalSec('300')
setSubdirRemoteURL('')
setSubdirConnectHost('')
setSubdirHostHeader('')
setSubdirTLSServerName('')
setSubdirTLSInsecureSkipVerify(false)
setSubdirOpen(true)
}}
>
New Folder...
</Button>
<Button
size="small"
onClick={() => {
setUploadError(null)
setUploadFiles([])
setUploadOpen(true)
}}
>
Upload RPM...
</Button>
</>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1 }}>
{canWrite ? (
<>
<Button
size="small"
onClick={() => {
setSubdirError(null)
setSubdirName('')
setSubdirType('container')
setSubdirMode('local')
setSubdirAllowDelete(false)
setSubdirSyncIntervalSec('300')
setSubdirRemoteURL('')
setSubdirConnectHost('')
setSubdirHostHeader('')
setSubdirTLSServerName('')
setSubdirTLSInsecureSkipVerify(false)
setSubdirOpen(true)
}}
>
New Folder...
</Button>
<Button
size="small"
onClick={() => {
setUploadError(null)
setUploadFiles([])
setUploadOpen(true)
}}
>
Upload RPM...
</Button>
</>
) : null}
</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}
<IconButton size="small" onClick={() => setSidebarOpen((prev) => !prev)} aria-label="Collapse sidebar">
<ChevronLeftIcon fontSize="small" />
</IconButton>
<Button size="small" onClick={() => handleRpmBreadcrumb(nextPath)} sx={{ textTransform: 'none', px: 0.5 }}>
{part}
</Button>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.25, mb: 1, px: 0.5 }}>
<IconButton size="small" onClick={() => handleRpmBreadcrumb('')} aria-label="Root">
<HomeOutlinedIcon fontSize="small" />
</IconButton>
<Typography variant="body2" color="text.secondary">
/
</Typography>
{rpmPathParts.map((part, idx) => {
const nextPath = rpmPathParts.slice(0, idx + 1).join('/')
return (
<Box key={nextPath} sx={{ display: 'flex', alignItems: 'center', gap: 0 }}>
{idx > 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ mx: 0.25 }}>
/
</Typography>
) : null}
<Button size="small" onClick={() => handleRpmBreadcrumb(nextPath)} sx={{ textTransform: 'none', px: 0.5 }}>
{part}
</Button>
</Box>
)
})}
</Box>
<TextField
size="small"
placeholder="Search files"
value={rpmFileQuery}
onChange={(event) => setRpmFileQuery(event.target.value)}
fullWidth
sx={{ mb: 1, px: 0.5 }}
/>
<PageAlert severity="warning" message={rpmTreeError} onClose={() => setRpmTreeError(null)} sx={{ mb: 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}
)
})}
</Box>
<TextField
size="small"
placeholder="Search files"
value={rpmFileQuery}
onChange={(event) => setRpmFileQuery(event.target.value)}
fullWidth
sx={{ mb: 1, px: 0.5 }}
/>
<PageAlert severity="warning" message={rpmTreeError} onClose={() => setRpmTreeError(null)} sx={{ mb: 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' ? (
<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"
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>
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}
</ListItem>
))}
{!filteredTree.length && !rpmTreeError ? (
<Typography variant="body2" color="text.secondary" sx={{ px: 1, py: 1 }}>
{normalizedQuery ? 'No matching files.' : 'No files found.'}
</Typography>
{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' ? (
<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}
</List>
</>
) : (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<IconButton size="small" onClick={() => setSidebarOpen((prev) => !prev)} aria-label="Expand sidebar">
<ChevronRightIcon fontSize="small" />
</IconButton>
</Box>
)}
</ListItem>
))}
{!filteredTree.length && !rpmTreeError ? (
<Typography variant="body2" color="text.secondary" sx={{ px: 1, py: 1 }}>
{normalizedQuery ? 'No matching files.' : 'No files found.'}
</Typography>
) : null}
</List>
</TintedPanel>
)} right={(
<TintedPanel>
{rpmSelected && rpmSelectedEntry && rpmSelectedEntry.name.toLowerCase().endsWith('.rpm') ? (
<>
@@ -1208,7 +1202,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
</Box>
)}
</TintedPanel>
</Box>
)} />
<Dialog open={subdirOpen} onClose={() => setSubdirOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>New Subdirectory</DialogTitle>
<FormDialogContent>
@@ -1362,6 +1356,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
name={statusName}
path={statusPath}
mode={statusMode}
repoUrl={statusRepoUrl}
syncStatus={statusSyncStatus}
syncStep={statusSyncStep}
syncError={statusSyncError}
+84 -5
View File
@@ -21,7 +21,7 @@ import {
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
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 ProjectNavBar from '../components/ProjectNavBar'
import HeaderActionButton from '../components/HeaderActionButton'
@@ -29,6 +29,9 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
import RepositoryFormDialog from '../components/RepositoryFormDialog'
import SectionCard from '../components/SectionCard'
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() {
const { projectId } = useParams()
@@ -73,6 +76,14 @@ export default function ReposPage() {
const [page, setPage] = useState(0)
const [pageSize, setPageSize] = 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(() => {
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 (
<Box>
<Typography variant="h5" sx={{ mb: 1 }}>
@@ -370,10 +418,20 @@ export default function ReposPage() {
</Box>
{projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null}
</Box>
<SectionCard
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
<Typography variant="h6">Repositories</Typography>
<Box sx={{ display: 'grid', gap: 1 }}>
<ProjectOverviewCard
project={project}
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
size="small"
label="Search"
@@ -556,6 +614,27 @@ export default function ReposPage() {
</Button>
</Box>
</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
open={createOpen}
title="New Repository"