Compare commits

...

2 Commits

Author SHA1 Message Date
hyung-hwan 1e184f075e touching up frontend/src/pages/RepoGitDetailPage.tsx 2026-06-22 01:10:53 +09:00
hyung-hwan 76fe4f6830 some git detail page touch up 2026-06-22 00:32:15 +09:00
3 changed files with 210 additions and 194 deletions
+1
View File
@@ -43,6 +43,7 @@ export default function CodeBlock({ code = '', language = 'diff', showLineNumber
overflow: 'auto',
backgroundColor: 'transparent',
lineHeight: 1.4,
fontSize: '0.85rem',
display: 'grid',
gridTemplateColumns: showLineNumbers ? 'auto 1fr' : '1fr',
columnGap: showLineNumbers ? 12 : 0
+1 -1
View File
@@ -22,7 +22,7 @@ export default function LazyCodeBlock(props: LazyCodeBlockProps) {
p: 0,
whiteSpace: 'pre',
overflow: 'auto',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace'
//fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace'
}}
>
<code>{code}</code>
+208 -193
View File
@@ -3,12 +3,15 @@ import { lazy, Suspense, useEffect, useRef, useState } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import { api, Project, Repo, RepoCommit, RepoTreeEntry } from '../api'
import Autocomplete from '../components/Autocomplete'
import AccountTreeIcon from '@mui/icons-material/AccountTree'
import CodeIcon from '@mui/icons-material/Code'
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 FileDownloadIcon from '@mui/icons-material/FileDownload'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import TerminalIcon from '@mui/icons-material/Terminal'
import LazyCodeBlock from '../components/LazyCodeBlock'
import PageAlert from '../components/PageAlert'
import ContextToolbar from '../components/ContextToolbar'
@@ -167,37 +170,39 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
})
}, [repoId, ref, path, branches, treeReloadTick])
const clearReadme = () => {
setReadmeContent('')
setReadmeKind('')
setReadmePath('')
}
useEffect(() => {
if (!repoId || !ref) {
setReadmeContent('')
setReadmeKind('')
setReadmePath('')
clearReadme()
return
}
if (!repo) return
if (repo && repo.type && repo.type !== 'git') {
setReadmeContent('')
setReadmeKind('')
setReadmePath('')
if (repo.type && repo.type !== 'git') {
clearReadme()
return
}
if (selectedFile) {
setReadmeContent('')
setReadmeKind('')
setReadmePath('')
clearReadme()
return
}
const entries = tree || []
let readme = entries.find((entry) => entry.name.toLowerCase() === 'readme.md')
let kind: 'markdown' | 'text' | '' = readme ? 'markdown' : ''
let kind: 'markdown' | 'text' | '' = (readme? 'markdown' : '')
if (!readme) {
readme = entries.find((entry) => entry.name.toLowerCase() === 'readme.txt')
kind = readme ? 'text' : ''
}
if (!readme || readme.type !== 'file') {
setReadmeContent('')
setReadmeKind('')
setReadmePath('')
clearReadme()
return
}
if (readmePath === readme.path && readmeKind === kind) return
@@ -208,9 +213,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
setReadmePath(readme.path)
})
.catch(() => {
setReadmeContent('')
setReadmeKind('')
setReadmePath('')
clearReadme()
})
}, [repoId, ref, tree, selectedFile, readmePath, readmeKind])
@@ -236,6 +239,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
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 {
@@ -539,6 +543,119 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
return match[1]
}
const renderClonePopover = () => {
if (!repo?.clone_url) return null
return (
<>
<IconButton onClick={(event) => setCodeAnchor(event.currentTarget)}>
<CodeIcon />
</IconButton>
<Popover
open={Boolean(codeAnchor)}
anchorEl={codeAnchor}
onClose={() => setCodeAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
PaperProps={{
sx: {
p: '8px !important',
minWidth: 480,
border: '1px solid',
borderColor: 'divider',
boxShadow: 'none'
}
}}
>
<Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TerminalIcon />
<Typography variant="subtitle2" color="text.secondary">
Clone
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 48 }}>
/git
</Typography>
<TextField
value={buildCloneURL(repo.clone_url || '')}
size="small"
InputProps={{ readOnly: true }}
sx={{ flex: 1 }}
/>
<Tooltip title="Copy /git URL">
<IconButton onClick={() => copyText(buildCloneURL(repo.clone_url || ''))} size="small">
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 48 }}>
/git-id
</Typography>
<TextField
value={`${window.location.origin}/git-id/${repo.id}.git`}
size="small"
InputProps={{ readOnly: true }}
sx={{ flex: 1 }}
/>
<Tooltip title="Copy /git-id URL">
<IconButton onClick={() => copyText(`${window.location.origin}/git-id/${repo.id}.git`)} size="small">
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Popover>
</>
)
}
const renderPathBreadcrumb = () => {
return (<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0 }}>
<IconButton
size="small"
onClick={() => handleBreadcrumb('')}
aria-label="Root"
sx={{ p: 0.25 }}
>
<HomeOutlinedIcon fontSize="small" />
</IconButton>
<Typography variant="body2" color="text.secondary" sx={{ mx: 0.25 }}>
/
</Typography>
{pathParts.map((part, idx) => {
const nextPath = pathParts.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={() => handleBreadcrumb(nextPath)}
sx={{ textTransform: 'none', minWidth: 0, px: 0.5 }}
>
{part}
</Button>
</Box>
)
})}
{selectedFile ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0 }}>
<Typography variant="body2" color="text.secondary" sx={{ mx: 0.25 }}>
/
</Typography>
<Typography variant="body2" sx={{ px: 0.5 }}>
{selectedFile.split('/').pop()}
</Typography>
</Box>
) : null}
</Box>)
}
return (
<ProjectPageFrame
projectId={projectId ?? ''}
@@ -558,193 +675,94 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
{loadError}
</Typography>
) : null}
<Box sx={{ display: 'flex', alignItems: 'stretch', gap: 1, mb: 1 }}>
<Paper sx={{ p: 1, display: 'flex', alignItems: 'center', gap: 1, minWidth: 240, backgroundColor: 'transparent' }}>
<Typography variant="subtitle2" color="text.secondary">
Branch
</Typography>
{branches.length ? (
<Autocomplete
freeSolo
options={branches}
value={ref}
inputValue={ref}
onChange={(_, value) => setRef(String(value || ''))}
onInputChange={(_, value, reason) => {
if (reason === 'input') setRef(value)
if (reason === 'clear') setRef('')
}}
renderInput={(params) => <TextField {...params} size="small" placeholder="branch or ref" />}
slotProps={{
popper: { sx: { zIndex: 1400 } }
}}
sx={{ minWidth: 180 }}
/>
) : null}
{defaultBranch ? (
<Typography variant="caption" color="text.secondary">
HEAD: {defaultBranch}
</Typography>
) : null}
</Paper>
{repo?.clone_url ? (
<>
<Button size="small" variant="outlined" onClick={(event) => setCodeAnchor(event.currentTarget)}>
Clone
</Button>
<Popover
open={Boolean(codeAnchor)}
anchorEl={codeAnchor}
onClose={() => setCodeAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
PaperProps={{
sx: {
p: '8px !important',
minWidth: 480,
border: '1px solid',
borderColor: 'divider',
boxShadow: 'none'
}
}}
>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="subtitle2" color="text.secondary">
Clone
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 48 }}>
/git
</Typography>
<TextField
value={buildCloneURL(repo.clone_url || '')}
size="small"
InputProps={{ readOnly: true }}
sx={{ flex: 1 }}
/>
<Tooltip title="Copy /git URL">
<IconButton onClick={() => copyText(buildCloneURL(repo.clone_url || ''))} size="small">
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 48 }}>
/git-id
</Typography>
<TextField
value={`${window.location.origin}/git-id/${repo.id}.git`}
size="small"
InputProps={{ readOnly: true }}
sx={{ flex: 1 }}
/>
<Tooltip title="Copy /git-id URL">
<IconButton onClick={() => copyText(`${window.location.origin}/git-id/${repo.id}.git`)} size="small">
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Popover>
</>
) : null}
</Box>
<Divider sx={{ mb: 1 }} />
<SplitPanel defaultRatio={0.25} left={(
<TintedPanel>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0 }}>
<IconButton
size="small"
onClick={() => handleBreadcrumb('')}
aria-label="Root"
sx={{ p: 0.25 }}
>
<HomeOutlinedIcon fontSize="small" />
</IconButton>
<Typography variant="body2" color="text.secondary" sx={{ mx: 0.25 }}>
/
</Typography>
{pathParts.map((part, idx) => {
const nextPath = pathParts.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={() => handleBreadcrumb(nextPath)}
sx={{ textTransform: 'none', minWidth: 0, px: 0.5 }}
>
{part}
</Button>
<Box sx={{ display: 'flex', alignItems: 'stretch', gap: 1, mb: 1 }}>
<Paper sx={{ p: 1, display: 'flex', alignItems: 'center', gap: 1, minWidth: 240, backgroundColor: 'transparent' }}>
<AccountTreeIcon />
{branches.length ? (
<Autocomplete
freeSolo
options={branches}
value={ref}
inputValue={ref}
onChange={(_, value) => setRef(String(value || ''))}
onInputChange={(_, value, reason) => {
if (reason === 'input') setRef(value)
if (reason === 'clear') setRef('')
}}
renderInput={(params) => <TextField {...params} size="small" placeholder="branch or ref" />}
slotProps={{
popper: { sx: { zIndex: 1400 } }
}}
sx={{ minWidth: 180 }}
/>
) : null}
{renderClonePopover()}
</Paper>
</Box>
<PageAlert severity="warning" message={treeError} onClose={() => setTreeError(null)} sx={{ mb: 1 }} />
<List dense>
<Box sx={{ px: 1, pb: 1 }}>
<TextField
size="small"
placeholder="Search files"
value={fileQuery}
onChange={(event) => setFileQuery(event.target.value)}
fullWidth
/>
</Box>
{path ? (
<ListItem disablePadding>
<ListItemButton onClick={handleBack}>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FolderIcon fontSize="small" color="info" />
<Typography variant="body2">..</Typography>
</Box>
)
})}
</Box>
</Box>
<PageAlert severity="warning" message={treeError} onClose={() => setTreeError(null)} sx={{ mb: 1 }} />
<List dense>
<Box sx={{ px: 1, pb: 1 }}>
<TextField
size="small"
placeholder="Search files"
value={fileQuery}
onChange={(event) => setFileQuery(event.target.value)}
fullWidth
}
/>
</Box>
{path ? (
<ListItem disablePadding>
<ListItemButton onClick={handleBack}>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FolderIcon fontSize="small" color="info" />
<Typography variant="body2">..</Typography>
</Box>
}
/>
</ListItemButton>
</ListItem>
) : null}
</ListItemButton>
</ListItem>
) : null}
{[...filteredTree]
.sort((a, b) => {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
return a.name.localeCompare(b.name)
})
.map((entry) => (
<ListItem key={entry.path} disablePadding>
<ListItemButton onClick={() => handleOpen(entry)}>
<ListItemText
primary={
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title={entry.type === 'dir' ? 'directory' : 'file'}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{entry.type === 'dir' ? (
<FolderIcon fontSize="small" color="info" />
) : (
<InsertDriveFileIcon fontSize="small" color="info" />
)}
</Box>
</Tooltip>
<Typography variant="body2">{entry.name}</Typography>
<ListItem key={entry.path} disablePadding>
<ListItemButton onClick={() => handleOpen(entry)}>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title={entry.type === 'dir' ? 'directory' : 'file'}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{entry.type === 'dir' ? (
<FolderIcon fontSize="small" color="info" />
) : (
<InsertDriveFileIcon fontSize="small" color="info" />
)}
</Box>
<Box />
</Box>
}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Tooltip>
<Typography variant="body2">{entry.name}</Typography>
</Box>
}
/>
</ListItemButton>
</ListItem>
))}
</List>
</TintedPanel>
)} right={(
<TintedPanel>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
{renderPathBreadcrumb()}
</Box>
{selectedFile ? (
<Box sx={{ mt: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
@@ -837,9 +855,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
</>
)
) : null}
<Typography variant="subtitle2" color="text.secondary">
{selectedFile}
</Typography>
</Box>
</Box>
{previewTab === 'content' ? (