383 lines
19 KiB
TypeScript
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
|