Files
lms-web-fe/src/lms-renderer/features/workspace/WorkspaceSourceControlView.tsx
T
2026-07-15 13:41:00 +09:00

383 lines
19 KiB
TypeScript

import { RefreshCw } from 'lucide-react'
import type { ModuleRow } from './types'
type GitChangedFile = {
path: string
status: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked'
staged: boolean
unstaged: boolean
}
type GitCommitInfo = {
hash: string
shortHash: string
author: string
date: string
message: string
}
type GitStatusInfo = {
ok: boolean
branch: string | null
ahead: number
behind: number
files: GitChangedFile[]
selectedFilePath: string | null
diff: string
commits: GitCommitInfo[]
message?: string
}
type GitSubmissionInfo = {
id: string
submittedAt: string
moduleName: string
commit: GitCommitInfo
}
type DiffViewLine = {
key: string
type: 'file' | 'hunk' | 'context' | 'add' | 'delete' | 'note'
oldLine: number | null
newLine: number | null
content: string
}
type GitTarget = {
moduleName: string
filePath: string
}
type GitSubmissionGroup = {
id: string
submittedAt: string
submissions: GitSubmissionInfo[]
}
type Props = {
dark: boolean
borderSoft: string
textStrong: string
textMid: string
textDim: string
gitPanelTab: 'source' | 'history'
gitChangedFileCount: number
gitChangedModules: ModuleRow[]
gitModuleStatuses: Record<string, GitStatusInfo>
gitError: string | null
gitLoading: boolean
gitSubmissionsLoading: boolean
gitSubmissionDiffLoading: boolean
gitSubmissions: GitSubmissionInfo[]
gitSubmissionGroups: GitSubmissionGroup[]
selectedGitTarget: GitTarget | null
selectedGitFile: GitChangedFile | null
selectedGitStatus: GitStatusInfo | null
selectedGitSubmission: GitSubmissionInfo | null
selectedGitSubmissionDiff: string
onGitPanelTabChange: (tab: 'source' | 'history') => void
onRefresh: () => void
onSelectGitFile: (moduleName: string, filePath: string) => void
onOpenGitChangedFile: (moduleName: string, filePath: string) => void
onOpenGitFileContextMenu: (module: ModuleRow, file: GitChangedFile, x: number, y: number) => void
onSelectGitSubmission: (submission: GitSubmissionInfo) => void
parseDiffViewLines: (diff: string) => DiffViewLine[]
renderDiffLine: (line: DiffViewLine) => React.JSX.Element
getGitStatusLabel: (status: GitChangedFile['status']) => string
getGitStatusTone: (status: GitChangedFile['status']) => string
formatGitCommitDate: (date: string) => string
}
function WorkspaceSourceControlView({
dark,
borderSoft,
textStrong,
textMid,
textDim,
gitPanelTab,
gitChangedFileCount,
gitChangedModules,
gitModuleStatuses,
gitError,
gitLoading,
gitSubmissionsLoading,
gitSubmissionDiffLoading,
gitSubmissions,
gitSubmissionGroups,
selectedGitTarget,
selectedGitFile,
selectedGitStatus,
selectedGitSubmission,
selectedGitSubmissionDiff,
onGitPanelTabChange,
onRefresh,
onSelectGitFile,
onOpenGitChangedFile,
onOpenGitFileContextMenu,
onSelectGitSubmission,
parseDiffViewLines,
renderDiffLine,
getGitStatusLabel,
getGitStatusTone,
formatGitCommitDate
}: Props): React.JSX.Element {
const refreshing = gitPanelTab === 'history' ? gitSubmissionsLoading : gitLoading
return (
<section
className={['min-w-0 overflow-hidden', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}
style={{ gridColumn: '4 / span 3', gridRow: 2 }}
>
<div className={['flex h-full min-h-0 flex-col', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
<div className={['flex h-10 shrink-0 items-end justify-between px-3', dark ? 'bg-[#252525]' : 'bg-[#f7f8fb]'].join(' ')}>
<div className="flex items-end">
{[
{ key: 'source' as const, label: '소스 제어' },
{ key: 'history' as const, label: '제출 내역' }
].map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => onGitPanelTabChange(tab.key)}
className={[
'h-8 min-w-[92px] cursor-pointer border-x border-t px-3 text-[12px] font-bold transition first:rounded-tl-md last:rounded-tr-md',
gitPanelTab === tab.key
? dark
? 'border-white/10 bg-[#2b2b2b] text-white'
: 'border-[#e1e6ef] bg-white text-[#111520]'
: dark
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
: 'border-[#e1e6ef] bg-[#eef1f6] text-[#687284] hover:text-[#111520]'
].join(' ')}
>
{tab.label}
</button>
))}
</div>
<div className="flex items-center gap-2 pb-2">
<button
type="button"
onClick={onRefresh}
disabled={refreshing}
className={[
'flex h-7 items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold transition',
refreshing ? 'cursor-default opacity-45' : 'cursor-pointer',
dark
? 'border-white/15 bg-white/[0.04] text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white'
: 'border-[#cfd6e4] bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ')}
aria-label="새로고침"
>
<RefreshCw size={14} className={refreshing ? 'animate-spin' : ''} />
새로고침
</button>
</div>
</div>
{gitPanelTab === 'source' ? (
<div className={['grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)]', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
<aside className={['flex min-h-0 flex-col border-r', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
<div className={['flex h-11 items-center justify-between border-b px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
<span className={['truncate text-[12px] font-bold', textMid].join(' ')}>변경 파일</span>
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitChangedFileCount}개</span>
</div>
<div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
{gitError && (
<div className={['mb-3 rounded-lg border px-3 py-2 text-[12px] font-semibold leading-relaxed', dark ? 'border-[#ef4444]/24 bg-[#ef4444]/10 text-[#fca5a5]' : 'border-[#fecaca] bg-[#fff1f2] text-[#dc2626]'].join(' ')}>
{gitError}
</div>
)}
{gitLoading && !Object.keys(gitModuleStatuses).length ? (
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
Git 상태 불러오는 중
</div>
) : gitChangedFileCount === 0 ? (
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
변경된 파일 없음
</div>
) : (
<div className="grid gap-3">
{gitChangedModules.map((module) => {
const status = gitModuleStatuses[module.name]
return (
<div key={module.name} className={['overflow-hidden rounded-lg border', dark ? 'border-white/10' : 'border-[#e1e6ef]'].join(' ')}>
<div className={['flex items-center justify-between px-3 py-2', dark ? 'bg-[#1f1f1f]' : 'bg-[#f8fafc]'].join(' ')}>
<div className="min-w-0">
<p className={['truncate text-[12px] font-black', textStrong].join(' ')}>{module.name}</p>
<p className={['mt-0.5 truncate font-mono text-[10px]', textDim].join(' ')}>
{status?.branch ?? 'main'}
</p>
</div>
<span className={['shrink-0 rounded px-2 py-0.5 text-[10px] font-black', dark ? 'bg-white/8 text-white/58' : 'bg-[#edf0f5] text-[#687284]'].join(' ')}>
{status?.files.length ?? 0}
</span>
</div>
<div className="grid gap-1 p-1.5">
{(status?.files ?? []).map((file) => {
const active = selectedGitTarget?.moduleName === module.name && selectedGitTarget.filePath === file.path
return (
<button
key={`${module.name}-${file.path}`}
type="button"
onClick={() => onSelectGitFile(module.name, file.path)}
onDoubleClick={() => onOpenGitChangedFile(module.name, file.path)}
onContextMenu={(event) => {
event.preventDefault()
onOpenGitFileContextMenu(module, file, event.clientX, event.clientY)
}}
className={[
'min-w-0 rounded-md border px-3 py-2 text-left transition',
active
? dark ? 'border-[#2563eb]/50 bg-[#2563eb]/16' : 'border-[#2563eb]/35 bg-[#edf6ff]'
: dark ? 'border-transparent hover:bg-white/[0.045]' : 'border-transparent hover:bg-[#f3f5f9]'
].join(' ')}
>
<div className="flex min-w-0 items-center gap-2">
<span className={['shrink-0 rounded px-1.5 py-0.5 text-[10px] font-black', getGitStatusTone(file.status)].join(' ')}>
{getGitStatusLabel(file.status)}
</span>
<span className={['truncate text-[12px] font-bold', dark ? 'text-white/78' : 'text-[#111520]'].join(' ')}>
{file.path.split('/').pop()}
</span>
</div>
<p className={['mt-1 truncate font-mono text-[10px]', textDim].join(' ')}>{file.path}</p>
</button>
)
})}
</div>
</div>
)
})}
</div>
)}
</div>
</aside>
<section className={['min-h-0 border-t', borderSoft].join(' ')}>
<div className={['flex h-11 items-center justify-between px-4', dark ? 'bg-[#171717]' : 'bg-[#f9fafc]'].join(' ')}>
<span className={['block min-w-0 truncate text-[12px] font-bold', textMid].join(' ')}>
{selectedGitTarget ? `${selectedGitTarget.moduleName} / ${selectedGitTarget.filePath}` : ''}
</span>
{selectedGitTarget && selectedGitFile?.status !== 'deleted' && (
<button
type="button"
onClick={() => onOpenGitChangedFile(selectedGitTarget.moduleName, selectedGitTarget.filePath)}
className={['h-7 cursor-pointer rounded-md border px-3 text-[11px] font-bold transition', dark ? 'border-white/12 text-white/58 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}
>
파일 열기
</button>
)}
</div>
<div className={['thin-y-scrollbar h-[calc(100%-44px)] overflow-auto p-4 font-mono text-[11px] leading-5', dark ? 'bg-[#171717] text-white/68' : 'bg-[#f9fafc] text-[#374151]'].join(' ')}>
{gitLoading && !selectedGitStatus ? (
<div className={['grid h-full place-items-center font-sans text-[12px] font-semibold', textDim].join(' ')}>
변경 내용 불러오는 중
</div>
) : selectedGitStatus?.diff ? (
<div className={['overflow-hidden rounded-md border text-[11px]', dark ? 'border-white/10 bg-[#111111]' : 'border-[#e1e6ef] bg-white'].join(' ')}>
{parseDiffViewLines(selectedGitStatus.diff).map(renderDiffLine)}
</div>
) : (
<div className="h-full" />
)}
</div>
</section>
</div>
) : (
<div className={['grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)]', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
<aside className={['flex min-h-0 flex-col border-r', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
<div className={['flex h-11 items-center justify-between border-b px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
<span className={['text-[12px] font-bold', textMid].join(' ')}>제출 목록</span>
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitSubmissionGroups.length}개</span>
</div>
<div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
{gitError && (
<div className={['mb-3 rounded-lg border px-3 py-2 text-[12px] font-semibold leading-relaxed', dark ? 'border-[#ef4444]/24 bg-[#ef4444]/10 text-[#fca5a5]' : 'border-[#fecaca] bg-[#fff1f2] text-[#dc2626]'].join(' ')}>
{gitError}
</div>
)}
{gitSubmissionsLoading && !gitSubmissions.length ? (
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
제출 내역 불러오는 중
</div>
) : !gitSubmissionGroups.length ? (
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
제출 내역 없음
</div>
) : (
<div className="grid gap-3">
{gitSubmissionGroups.map((group, groupIndex) => (
<div key={group.id} className={['overflow-hidden rounded-lg border', dark ? 'border-white/10' : 'border-[#e1e6ef]'].join(' ')}>
<div className={['px-3 py-2', dark ? 'bg-[#1f1f1f]' : 'bg-[#f8fafc]'].join(' ')}>
<p className={['text-[12px] font-black', textStrong].join(' ')}>제출 {gitSubmissionGroups.length - groupIndex}</p>
<p className={['mt-0.5 text-[10px] font-bold', textDim].join(' ')}>
{formatGitCommitDate(group.submittedAt)} · {group.submissions.length}개 모듈
</p>
</div>
<div className="grid gap-1 p-1.5">
{group.submissions.map((submission) => {
const active = selectedGitSubmission?.moduleName === submission.moduleName && selectedGitSubmission.commit.hash === submission.commit.hash
return (
<button
key={`${submission.moduleName}-${submission.commit.hash}`}
type="button"
onClick={() => onSelectGitSubmission(submission)}
className={[
'min-w-0 rounded-md border px-3 py-2 text-left transition',
active
? dark ? 'border-[#2563eb]/50 bg-[#2563eb]/16' : 'border-[#2563eb]/35 bg-[#edf6ff]'
: dark ? 'border-transparent hover:bg-white/[0.045]' : 'border-transparent hover:bg-[#f3f5f9]'
].join(' ')}
>
<div className="flex min-w-0 items-start justify-between gap-2">
<span className={['min-w-0 truncate text-[12px] font-black', textStrong].join(' ')}>{submission.moduleName}</span>
<span className={['shrink-0 font-mono text-[10px]', dark ? 'text-[#9dbdff]' : 'text-[#1d5fae]'].join(' ')}>{submission.commit.shortHash}</span>
</div>
<p className={['mt-1 truncate text-[11px] font-bold', textDim].join(' ')}>{submission.commit.message}</p>
</button>
)
})}
</div>
</div>
))}
</div>
)}
</div>
</aside>
<section className={['min-h-0 border-t', borderSoft].join(' ')}>
<div className={['flex h-11 items-center justify-between px-4', dark ? 'bg-[#171717]' : 'bg-[#f9fafc]'].join(' ')}>
<span className={['block min-w-0 truncate text-[12px] font-bold', textMid].join(' ')}>
{selectedGitSubmission ? `${selectedGitSubmission.moduleName} / ${selectedGitSubmission.commit.shortHash}` : ''}
</span>
{selectedGitSubmission && (
<span className={['shrink-0 text-[11px] font-bold', textDim].join(' ')}>
{formatGitCommitDate(selectedGitSubmission.submittedAt)}
</span>
)}
</div>
<div className={['thin-y-scrollbar h-[calc(100%-44px)] overflow-auto p-4 font-mono text-[11px] leading-5', dark ? 'bg-[#171717] text-white/68' : 'bg-[#f9fafc] text-[#374151]'].join(' ')}>
{gitSubmissionDiffLoading ? (
<div className={['grid h-full place-items-center font-sans text-[12px] font-semibold', textDim].join(' ')}>
변경 내용 불러오는 중
</div>
) : selectedGitSubmissionDiff ? (
<div className={['overflow-hidden rounded-md border text-[11px]', dark ? 'border-white/10 bg-[#111111]' : 'border-[#e1e6ef] bg-white'].join(' ')}>
{parseDiffViewLines(selectedGitSubmissionDiff).map(renderDiffLine)}
</div>
) : (
<div className="h-full" />
)}
</div>
</section>
</div>
)}
</div>
</section>
)
}
export default WorkspaceSourceControlView