did something

This commit is contained in:
2026-05-19 22:55:26 +02:00
parent ec9a286d33
commit 2a81bda00e
12 changed files with 5774 additions and 213 deletions
+5156
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -10,8 +10,25 @@
"preview": "vite preview"
},
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-cpp": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-java": "^6.0.0",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.0",
"@codemirror/lang-markdown": "^6.0.0",
"@codemirror/lang-python": "^6.0.0",
"@codemirror/lang-rust": "^6.0.0",
"@codemirror/lang-xml": "^6.0.0",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.0.0",
"@codemirror/merge": "^6.12.1",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/theme-one-dark": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@tanstack/react-query": "^5.100.9",
"react": "^19.2.5",
"react-dom": "^19.2.5",
+181 -60
View File
@@ -1,5 +1,8 @@
import { Link } from 'react-router-dom'
import { useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { useRepoTree } from '../../api/queries/repos'
import { getCSRFToken } from '../../api/client'
import { Skeleton } from '../../ui/Skeleton'
interface TreeBrowserProps {
@@ -32,23 +35,141 @@ function formatSize(bytes: number): string {
}
export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { data: entries, isLoading, isError } = useRepoTree(owner, repo, ref, path)
const fileInputRef = useRef<HTMLInputElement>(null)
const folderInputRef = useRef<HTMLInputElement>(null)
const zipInputRef = useRef<HTMLInputElement>(null)
const [uploadStatus, setUploadStatus] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
async function handleUpload(files: FileList | null, isZip = false) {
if (!files || files.length === 0) return
setUploading(true)
setUploadStatus(`Uploading ${isZip ? 'archive' : `${files.length} file${files.length > 1 ? 's' : ''}`}`)
try {
const csrfToken = await getCSRFToken()
const form = new FormData()
form.append('branch', ref || 'main')
form.append('message', isZip ? 'Upload archive' : `Upload ${files.length} file${files.length > 1 ? 's' : ''}`)
if (isZip) {
form.append('zip', files[0])
} else {
for (const file of Array.from(files)) {
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name
const f = new File([file], relativePath, { type: file.type })
form.append('file[]', f, relativePath)
}
}
const res = await fetch(`/api/v1/repos/${owner}/${repo}/upload`, {
method: 'POST',
credentials: 'include',
headers: { 'X-CSRF-Token': csrfToken },
body: form,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || 'Upload failed')
}
const result = await res.json()
setUploadStatus(`Committed ${result.committed} file${result.committed !== 1 ? 's' : ''}`)
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'tree'] })
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'files'] })
setTimeout(() => setUploadStatus(null), 3000)
} catch (err) {
setUploadStatus(`Error: ${(err as Error).message}`)
setTimeout(() => setUploadStatus(null), 5000)
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
if (folderInputRef.current) folderInputRef.current.value = ''
if (zipInputRef.current) zipInputRef.current.value = ''
}
}
if (isLoading) return <TreeSkeleton />
if (isError) return <p className="text-xs text-[var(--c-danger)] p-4">Failed to load file tree.</p>
if (!entries?.length) return (
<div className="border border-dashed border-[var(--c-border)] rounded p-6 text-center text-xs text-[var(--c-muted)]">
No files yet push your first commit to see them here.
</div>
)
const dirs = entries.filter(e => e.type === 'tree').sort((a, b) => a.name.localeCompare(b.name))
const files = entries.filter(e => e.type === 'blob').sort((a, b) => a.name.localeCompare(b.name))
const dirs = (entries ?? []).filter(e => e.type === 'tree').sort((a, b) => a.name.localeCompare(b.name))
const files = (entries ?? []).filter(e => e.type === 'blob').sort((a, b) => a.name.localeCompare(b.name))
const sorted = [...dirs, ...files]
return (
<div className="border border-[var(--c-border)] rounded overflow-hidden bg-[var(--c-surface)]">
{/* Path breadcrumb inside tree */}
{/* Upload toolbar */}
<div className="flex items-center gap-2 px-3 py-2 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)] flex-wrap">
<button
onClick={() => navigate(`/repos/${owner}/${repo}/blob?ref=${encodeURIComponent(ref || 'main')}&new=true`)}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border border-[var(--c-border)] text-[var(--c-text)] hover:bg-[var(--c-surface)] bg-[var(--c-surface)]"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New file
</button>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border border-[var(--c-border)] text-[var(--c-text)] hover:bg-[var(--c-surface)] bg-[var(--c-surface)] disabled:opacity-50"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
</svg>
Upload files
</button>
<button
onClick={() => folderInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border border-[var(--c-border)] text-[var(--c-text)] hover:bg-[var(--c-surface)] bg-[var(--c-surface)] disabled:opacity-50"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v8.25" />
</svg>
Upload folder
</button>
<button
onClick={() => zipInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border border-[var(--c-border)] text-[var(--c-text)] hover:bg-[var(--c-surface)] bg-[var(--c-surface)] disabled:opacity-50"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z" />
</svg>
Upload ZIP
</button>
{uploadStatus && (
<span className={`text-xs ml-1 ${uploadStatus.startsWith('Error') ? 'text-[var(--c-danger)]' : 'text-[var(--c-success)]'}`}>
{uploadStatus}
</span>
)}
{/* Hidden file inputs */}
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={e => handleUpload(e.target.files)} />
<input
ref={folderInputRef}
type="file"
className="hidden"
// @ts-expect-error webkitdirectory is not in React types
webkitdirectory=""
multiple
onChange={e => handleUpload(e.target.files)}
/>
<input ref={zipInputRef} type="file" accept=".zip" className="hidden" onChange={e => handleUpload(e.target.files, true)} />
</div>
{/* Path breadcrumb */}
{path && (
<div className="flex items-center gap-1 px-3 py-2 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)] text-xs text-[var(--c-muted)]">
<Link to={`/repos/${owner}/${repo}`} className="hover:text-[var(--c-brand)]">{repo}</Link>
@@ -67,58 +188,58 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
</div>
)}
<table className="w-full text-sm border-collapse">
<colgroup>
<col className="w-auto" />
<col className="w-48 hidden sm:table-column" />
<col className="w-28" />
</colgroup>
<tbody>
{sorted.map(entry => {
const entryPath = path ? `${path}/${entry.name}` : entry.name
const isDir = entry.type === 'tree'
const href = isDir
? `/repos/${owner}/${repo}?path=${encodeURIComponent(entryPath)}&ref=${ref}`
: `/repos/${owner}/${repo}/blob?ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(entryPath)}`
{sorted.length === 0 ? (
<div className="p-6 text-center text-xs text-[var(--c-muted)]">
No files yet push your first commit or upload files above.
</div>
) : (
<table className="w-full text-sm border-collapse">
<colgroup>
<col className="w-auto" />
<col className="w-48 hidden sm:table-column" />
<col className="w-28" />
</colgroup>
<tbody>
{sorted.map(entry => {
const entryPath = path ? `${path}/${entry.name}` : entry.name
const isDir = entry.type === 'tree'
const href = isDir
? `/repos/${owner}/${repo}?path=${encodeURIComponent(entryPath)}&ref=${ref}`
: `/repos/${owner}/${repo}/blob?ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(entryPath)}`
return (
<tr key={entry.hash} className="border-b border-[var(--c-border)] last:border-b-0 hover:bg-[var(--c-surface-raised)]">
{/* Name */}
<td className="px-3 py-2">
<div className="flex items-center gap-2">
{isDir ? (
<svg width="16" height="16" fill="var(--c-brand)" viewBox="0 0 24 24" className="shrink-0">
<path d="M19.5 21h-15A2.25 2.25 0 0 1 2.25 18.75V6.75A2.25 2.25 0 0 1 4.5 4.5h4.086c.398 0 .779.158 1.06.44l1.415 1.414c.28.281.661.44 1.06.44H19.5A2.25 2.25 0 0 1 21.75 9v9.75A2.25 2.25 0 0 1 19.5 21Z" />
</svg>
) : (
<svg width="16" height="16" fill="none" stroke="var(--c-muted)" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
)}
<Link
to={href}
className={isDir ? 'text-[var(--c-brand)] hover:underline font-medium' : 'text-[var(--c-text)] hover:text-[var(--c-brand)]'}
>
{entry.name}
</Link>
{!isDir && entry.size > 0 && (
<span className="text-[10px] text-[var(--c-muted)] hidden sm:inline">{formatSize(entry.size)}</span>
)}
</div>
</td>
{/* Commit message */}
<td className="px-3 py-2 text-xs text-[var(--c-muted)] truncate max-w-0 hidden sm:table-cell">
<span className="truncate block" title={entry.commitMsg}>{entry.commitMsg}</span>
</td>
{/* Date */}
<td className="px-3 py-2 text-xs text-[var(--c-muted)] whitespace-nowrap text-right">
{relativeTime(entry.commitDate)}
</td>
</tr>
)
})}
</tbody>
</table>
return (
<tr key={entry.hash} className="border-b border-[var(--c-border)] last:border-b-0 hover:bg-[var(--c-surface-raised)]">
<td className="px-3 py-2">
<div className="flex items-center gap-2">
{isDir ? (
<svg width="16" height="16" fill="var(--c-brand)" viewBox="0 0 24 24" className="shrink-0">
<path d="M19.5 21h-15A2.25 2.25 0 0 1 2.25 18.75V6.75A2.25 2.25 0 0 1 4.5 4.5h4.086c.398 0 .779.158 1.06.44l1.415 1.414c.28.281.661.44 1.06.44H19.5A2.25 2.25 0 0 1 21.75 9v9.75A2.25 2.25 0 0 1 19.5 21Z" />
</svg>
) : (
<svg width="16" height="16" fill="none" stroke="var(--c-muted)" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
)}
<Link to={href} className={isDir ? 'text-[var(--c-brand)] hover:underline font-medium' : 'text-[var(--c-text)] hover:text-[var(--c-brand)]'}>
{entry.name}
</Link>
{!isDir && entry.size > 0 && (
<span className="text-[10px] text-[var(--c-muted)] hidden sm:inline">{formatSize(entry.size)}</span>
)}
</div>
</td>
<td className="px-3 py-2 text-xs text-[var(--c-muted)] truncate max-w-0 hidden sm:table-cell">
<span className="truncate block" title={entry.commitMsg}>{entry.commitMsg}</span>
</td>
<td className="px-3 py-2 text-xs text-[var(--c-muted)] whitespace-nowrap text-right">
{relativeTime(entry.commitDate)}
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
)
}
+4
View File
@@ -43,6 +43,10 @@
--c-warning: #FBBF24;
}
html {
font-size: 17px;
}
body {
margin: 0;
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
+180 -138
View File
@@ -4,6 +4,8 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { useRepo, useRepoBlob, useUpdateBlob } from '../api/queries/repos'
import { RepoListSkeleton } from '../ui/Skeleton'
import { CodeEditor } from '../components/repos/CodeEditor'
import { FileSideTree } from '../components/repos/FileSideTree'
export default function BlobPage() {
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
@@ -14,12 +16,17 @@ export default function BlobPage() {
const [commitMsg, setCommitMsg] = useState('')
const [preview, setPreview] = useState(false)
// New-file mode: ?new=true&path=<desired-path>
const isNew = searchParams.get('new') === 'true'
const [newPath, setNewPath] = useState(searchParams.get('path') ?? '')
const ref = searchParams.get('ref') ?? ''
const filePath = searchParams.get('path') ?? ''
const filePath = isNew ? newPath : (searchParams.get('path') ?? '')
const fileName = filePath.split('/').pop() ?? filePath
const fileExt = fileName.includes('.') ? fileName.split('.').pop() ?? '' : ''
const { data: repo } = useRepo(owner, repoName)
const { data: blob, isLoading, isError } = useRepoBlob(owner, repoName, ref, filePath)
const { data: blob, isLoading, isError } = useRepoBlob(owner, repoName, ref, isNew ? '' : filePath)
const updateBlob = useUpdateBlob(owner, repoName)
const branch = ref || repo?.defaultBranch || 'main'
@@ -33,167 +40,202 @@ export default function BlobPage() {
}
function cancelEdit() {
setEditing(false)
setPreview(false)
if (isNew) {
navigate(-1)
} else {
setEditing(false)
setPreview(false)
}
}
async function handleCommit() {
if (!commitMsg.trim() || !filePath) return
const path = isNew ? newPath.trim() : filePath
if (!commitMsg.trim() || !path) return
await updateBlob.mutateAsync({
path: filePath,
path,
content: editContent,
message: commitMsg.trim(),
branch,
})
setEditing(false)
navigate(`/repos/${owner}/${repoName}/blob?ref=${encodeURIComponent(branch)}&path=${encodeURIComponent(filePath)}`, { replace: true })
navigate(`/repos/${owner}/${repoName}/blob?ref=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}`, { replace: true })
}
if (isLoading) return <div className="p-6"><RepoListSkeleton /></div>
if (isError || !blob) return <div className="p-6 text-sm text-[var(--c-danger)]">File not found.</div>
const isEditingState = editing || isNew
const lines = blob.content.split('\n')
const pathParts = filePath.split('/')
// For new file, start in edit mode with empty content.
if (isNew && !editing && editContent === '') {
setEditContent('')
setCommitMsg('Add new file')
setEditing(true)
}
const pathParts = filePath.split('/').filter(Boolean)
const content = blob?.content ?? ''
return (
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-4">
<div className="flex h-full min-h-0" style={{ height: 'calc(100vh - 56px)' }}>
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-sm flex-wrap">
<Link to="/repos" className="text-[var(--c-brand)] hover:underline">Repositories</Link>
<span className="text-[var(--c-muted)]">/</span>
<Link to={`/repos/${owner}/${repoName}`} className="text-[var(--c-brand)] hover:underline">{repoName}</Link>
{pathParts.map((seg, i) => {
const partial = pathParts.slice(0, i + 1).join('/')
const isLast = i === pathParts.length - 1
return (
<span key={partial} className="flex items-center gap-1">
<span className="text-[var(--c-muted)]">/</span>
{isLast
? <span className="font-semibold text-[var(--c-text)]">{seg}</span>
: <Link to={`/repos/${owner}/${repoName}?path=${encodeURIComponent(partial)}&ref=${encodeURIComponent(branch)}`} className="text-[var(--c-brand)] hover:underline">{seg}</Link>
}
</span>
)
})}
{/* Left file tree — hidden on small screens */}
<div className="hidden md:flex">
<FileSideTree
owner={owner}
repo={repoName}
branch={branch}
activePath={filePath}
/>
</div>
{/* File card */}
<div className="border border-[var(--c-border)] rounded bg-[var(--c-surface)] overflow-hidden">
{/* Main content */}
<div className="flex-1 min-w-0 overflow-auto">
<div className="w-full px-4 md:px-6 py-6 space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] gap-3 flex-wrap">
<div className="flex items-center gap-2 text-sm">
{/* Branch pill */}
<span className="flex items-center gap-1 px-2 py-0.5 border border-[var(--c-border)] rounded text-xs text-[var(--c-muted)] bg-[var(--c-surface)]">
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
</svg>
{branch}
</span>
<span className="text-[var(--c-muted)]">{repoName}</span>
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-sm flex-wrap">
<Link to="/repos" className="text-[var(--c-brand)] hover:underline">Repositories</Link>
<span className="text-[var(--c-muted)]">/</span>
<span className="font-medium text-[var(--c-text)]">{fileName}</span>
<button
onClick={() => navigator.clipboard.writeText(filePath)}
className="text-[var(--c-muted)] hover:text-[var(--c-text)]"
title="Copy path"
>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
</svg>
</button>
<Link to={`/repos/${owner}/${repoName}`} className="text-[var(--c-brand)] hover:underline">{repoName}</Link>
{pathParts.map((seg, i) => {
const partial = pathParts.slice(0, i + 1).join('/')
const isLast = i === pathParts.length - 1
return (
<span key={partial} className="flex items-center gap-1">
<span className="text-[var(--c-muted)]">/</span>
{isLast
? <span className="font-semibold text-[var(--c-text)]">{seg}</span>
: <Link to={`/repos/${owner}/${repoName}?path=${encodeURIComponent(partial)}&ref=${encodeURIComponent(branch)}`} className="text-[var(--c-brand)] hover:underline">{seg}</Link>
}
</span>
)
})}
{isNew && <span className="text-[var(--c-muted)] font-semibold">New file</span>}
</div>
{!editing && (
<div className="flex items-center gap-1">
{isMarkdown && (
<button
onClick={() => setPreview(p => !p)}
className={`px-3 py-1.5 text-xs font-medium rounded border ${preview ? 'border-[var(--c-brand)] text-[var(--c-brand)] bg-[var(--c-brand-tint)]' : 'border-[var(--c-border)] text-[var(--c-muted)] hover:bg-[var(--c-surface-muted)]'}`}
>
{preview ? 'Source' : 'Preview'}
</button>
{/* File card */}
{isLoading && !isNew && <RepoListSkeleton />}
{(isError && !isNew) && <div className="text-sm text-[var(--c-danger)] p-4">File not found.</div>}
{(!isLoading || isNew) && (
<div className="border border-[var(--c-border)] rounded bg-[var(--c-surface)] overflow-hidden">
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] gap-3 flex-wrap">
<div className="flex items-center gap-2 text-sm">
<span className="flex items-center gap-1 px-2 py-0.5 border border-[var(--c-border)] rounded text-xs text-[var(--c-muted)] bg-[var(--c-surface)]">
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
</svg>
{branch}
</span>
{isNew ? (
<input
value={newPath}
onChange={e => setNewPath(e.target.value)}
placeholder="path/to/new-file.ts"
className="border border-[var(--c-border)] rounded px-2 py-0.5 text-xs focus:outline-none focus:border-[var(--c-brand-focus)] text-[var(--c-text)] bg-[var(--c-surface)]"
style={{ minWidth: 200 }}
/>
) : (
<>
<span className="text-[var(--c-muted)]">{repoName}</span>
<span className="text-[var(--c-muted)]">/</span>
<span className="font-medium text-[var(--c-text)]">{fileName}</span>
<button
onClick={() => navigator.clipboard.writeText(filePath)}
className="text-[var(--c-muted)] hover:text-[var(--c-text)]"
title="Copy path"
>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
</svg>
</button>
</>
)}
</div>
{!isEditingState && (
<div className="flex items-center gap-1">
{isMarkdown && (
<button
onClick={() => setPreview(p => !p)}
className={`px-3 py-1.5 text-xs font-medium rounded border ${preview ? 'border-[var(--c-brand)] text-[var(--c-brand)] bg-[var(--c-brand-tint)]' : 'border-[var(--c-border)] text-[var(--c-muted)] hover:bg-[var(--c-surface-muted)]'}`}
>
{preview ? 'Source' : 'Preview'}
</button>
)}
<button
onClick={startEdit}
className="px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] flex items-center gap-1.5"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" />
</svg>
Edit
</button>
<button
onClick={() => navigator.clipboard.writeText(content)}
className="px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded text-[var(--c-muted)] hover:bg-[var(--c-surface-muted)]"
>
Copy
</button>
</div>
)}
</div>
{/* Content area */}
{isEditingState ? (
<div className="flex flex-col">
<CodeEditor
value={editContent}
onChange={setEditContent}
language={isNew ? (newPath.split('.').pop() ?? '') : fileExt}
minHeight="400px"
/>
<div className="p-4 bg-[var(--c-surface-raised)] border-t border-[var(--c-border)] space-y-3">
<div>
<label className="block text-xs font-semibold text-[var(--c-text)] mb-1">Commit message</label>
<input
value={commitMsg}
onChange={e => setCommitMsg(e.target.value)}
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--c-brand-focus)]"
placeholder="Describe your changes…"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCommit}
disabled={updateBlob.isPending || !commitMsg.trim() || (isNew && !newPath.trim())}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50"
>
{updateBlob.isPending ? 'Committing…' : isNew ? 'Create file' : 'Commit changes'}
</button>
<button onClick={cancelEdit} className="px-4 py-2 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]">
Cancel
</button>
{updateBlob.isError && (
<span className="text-xs text-[var(--c-danger)]">{(updateBlob.error as Error)?.message}</span>
)}
</div>
</div>
</div>
) : isMarkdown && preview ? (
<div className="px-6 py-5 prose prose-sm max-w-none text-[var(--c-text)]
prose-headings:text-[var(--c-text)] prose-headings:font-semibold prose-headings:border-b prose-headings:border-[var(--c-border)] prose-headings:pb-1
prose-a:text-[var(--c-brand)] prose-code:bg-[var(--c-surface-muted)] prose-code:px-1 prose-code:rounded
prose-pre:bg-[var(--c-surface-muted)] prose-pre:border prose-pre:border-[var(--c-border)] prose-pre:rounded">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
) : (
<CodeEditor
value={content}
language={fileExt}
readOnly
minHeight="400px"
/>
)}
<button
onClick={startEdit}
className="px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] flex items-center gap-1.5"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" />
</svg>
Edit
</button>
<button
onClick={() => navigator.clipboard.writeText(blob.content)}
className="px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded text-[var(--c-muted)] hover:bg-[var(--c-surface-muted)]"
>
Copy
</button>
</div>
)}
</div>
{/* Content */}
{editing ? (
<div className="flex flex-col">
<textarea
value={editContent}
onChange={e => setEditContent(e.target.value)}
className="w-full font-mono text-xs text-[var(--c-text)] bg-[var(--c-surface)] p-4 resize-none focus:outline-none border-b border-[var(--c-border)]"
style={{ minHeight: Math.max(300, lines.length * 20) }}
spellCheck={false}
/>
<div className="p-4 bg-[var(--c-surface-raised)] border-t border-[var(--c-border)] space-y-3">
<div>
<label className="block text-xs font-semibold text-[var(--c-text)] mb-1">Commit message</label>
<input
value={commitMsg}
onChange={e => setCommitMsg(e.target.value)}
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--c-brand-focus)]"
placeholder="Describe your changes…"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCommit}
disabled={updateBlob.isPending || !commitMsg.trim()}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50"
>
{updateBlob.isPending ? 'Committing…' : 'Commit changes'}
</button>
<button onClick={cancelEdit} className="px-4 py-2 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]">
Cancel
</button>
{updateBlob.isError && (
<span className="text-xs text-[var(--c-danger)]">{(updateBlob.error as Error)?.message}</span>
)}
</div>
</div>
</div>
) : isMarkdown && preview ? (
<div className="px-6 py-5 prose prose-sm max-w-none text-[var(--c-text)]
prose-headings:text-[var(--c-text)] prose-headings:font-semibold prose-headings:border-b prose-headings:border-[var(--c-border)] prose-headings:pb-1
prose-a:text-[var(--c-brand)] prose-code:bg-[var(--c-surface-muted)] prose-code:px-1 prose-code:rounded
prose-pre:bg-[var(--c-surface-muted)] prose-pre:border prose-pre:border-[var(--c-border)] prose-pre:rounded">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{blob.content}</ReactMarkdown>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse font-mono text-xs">
<tbody>
{lines.map((line, i) => (
<tr key={i} className="hover:bg-[#FFFBDD]">
<td className="select-none text-right text-[var(--c-muted)] px-4 py-0.5 w-12 border-r border-[var(--c-border)] bg-[var(--c-surface-raised)] sticky left-0">
{i + 1}
</td>
<td className="px-4 py-0.5 text-[var(--c-text)] whitespace-pre">{line || ' '}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
+16 -6
View File
@@ -18,6 +18,7 @@ export default function RepoPage() {
const [showBranches, setShowBranches] = useState(false)
const [showClone, setShowClone] = useState(false)
const [cloneTab, setCloneTab] = useState<'https' | 'ssh'>('https')
const [cloneCopied, setCloneCopied] = useState(false)
const branchRef = useRef<HTMLDivElement>(null)
const cloneRef = useRef<HTMLDivElement>(null)
@@ -47,8 +48,8 @@ export default function RepoPage() {
const branch = ref || repo.defaultBranch
const cloneUrl = `${window.location.origin}/${owner}/${repoName}.git`
const sshHost = instance?.sshHost ?? window.location.hostname
const sshPort = instance?.sshPort ?? '2222'
const sshHost = instance?.sshHost || window.location.hostname
const sshPort = instance?.sshPort || '2222'
const sshUrl = sshPort === '22'
? `git@${sshHost}:${owner}/${repoName}.git`
: `ssh://git@${sshHost}:${sshPort}/${owner}/${repoName}.git`
@@ -162,15 +163,24 @@ export default function RepoPage() {
{cloneTab === 'https' ? cloneUrl : sshUrl}
</code>
<button
onClick={() => navigator.clipboard.writeText(cloneTab === 'https' ? cloneUrl : sshUrl)}
className="text-[10px] text-[var(--c-brand)] hover:underline shrink-0"
onClick={() => {
navigator.clipboard.writeText(cloneTab === 'https' ? cloneUrl : sshUrl)
setCloneCopied(true)
setTimeout(() => setCloneCopied(false), 1500)
}}
className={`text-[10px] font-medium shrink-0 transition-colors ${
cloneCopied
? 'text-[var(--c-success)]'
: 'text-[var(--c-brand)] hover:underline'
}`}
>
Copy
{cloneCopied ? 'Copied!' : 'Copy'}
</button>
</div>
{cloneTab === 'ssh' && (
<p className="text-[10px] text-[var(--c-muted)] mt-1.5">
Requires an SSH key added to your account settings.
Requires an SSH key added to your{' '}
<Link to="/settings" className="text-[var(--c-brand)] hover:underline">account settings</Link>.
</p>
)}
</div>