Skip to content
Clone

feat(web/app): add the repository domain

Port browsing (tree, blob, README, PDF), history and diffs, branches, tags,
releases, settings and protection rules. Ref and commit events from kenshell
are subscribed per repository so ref lists and commit views revalidate quietly.
ZhenYi committed
parent cc0c515 commit 830e0ed Browse files

54 files changed

+8350 −0

File tree 54
Added view/app/src/domains/repo/branches/model/branch.ts View view/app/src/domains/repo/branches/model/branch.ts
@@ -0,0 +1,23 @@
1/**
2 * Branch-name rules mirrored from the server (`share/validate.rs`) so the form
3 * can explain the problem before a round trip. The server stays authoritative —
4 * this only rejects names it would certainly refuse.
5 */
6import { t } from '@app/i18n'
7
8export const MAX_BRANCH_NAME = 250
9
10export function validateBranchName(raw: string): string | null {
11 const name = raw.trim()
12 if (!name) return t('repo.branch.validate.empty')
13 if ([...name].length > MAX_BRANCH_NAME) return t('repo.branch.validate.tooLong', { count: MAX_BRANCH_NAME })
14 if (name.startsWith('-')) return t('repo.branch.validate.startsWithDash')
15 if (name.endsWith('/')) return t('repo.branch.validate.endsWithSlash')
16 if (name.endsWith('.lock')) return t('repo.branch.validate.endsWithLock')
17 if (name.includes('..')) return t('repo.branch.validate.doubleDot')
18 if (name.includes('@{')) return t('repo.branch.validate.atBrace')
19 if (name.includes('//')) return t('repo.branch.validate.doubleSlash')
20 if (name.includes('\\')) return t('repo.branch.validate.backslash')
21 if (Array.from(name).some((c) => /\s/.test(c) || c < ' ')) return t('repo.branch.validate.whitespace')
22 return null
23}
Added view/app/src/domains/repo/branches/ui/branches-tab.tsx View view/app/src/domains/repo/branches/ui/branches-tab.tsx
@@ -0,0 +1,308 @@
1import { Alert, CopyChip, ErrorPanel, PanelNote } from '@app/components'
2import { createBranch, deleteBranch, listBranches, renameBranch, setDefaultBranch } from '@app/domains/repo/model'
3import { FormModal, type FormValues } from '@app/domains/shared'
4import { useT } from '@app/i18n'
5import { ApiFailure, fmtRelative, fmtTime, repoKey, shortSha, useAsync } from '@app/lib'
6import { Button } from '@app/ui'
7import { Eye, GitBranch, Plus, Search, Star } from 'lucide-react'
8import { useState } from 'react'
9import { validateBranchName } from '../model/branch'
10import { SubmodulesSection } from './submodules-section'
11import { TagsSection } from './tags-section'
12
13/** One page is plenty for a branch list; anything beyond that is filtered. */
14const PAGE_LIMIT = 200
15
16export interface BranchesTabProps {
17 space: string
18 repo: string
19 /** Revision currently checked out in the browser. */
20 rev: string
21 defaultBranch: string
22 /** Repo write access (space owner / repo collaborator server-side). */
23 canWrite: boolean
24 /** Point the browser at this branch without leaving the tab. */
25 onCheckout: (name: string) => void
26 /** Browse this branch in the code view. */
27 onBrowse: (name: string) => void
28 /** Repo metadata (counts, default branch) may have changed. */
29 onChanged: () => void
30}
31
32/** Branch management: list, create, rename, set default, delete. */
33export function BranchesTab({
34 space,
35 repo,
36 rev,
37 defaultBranch,
38 canWrite,
39 onCheckout,
40 onBrowse,
41 onChanged,
42}: BranchesTabProps) {
43 const { t } = useT()
44 const [tick, setTick] = useState(0)
45 const [query, setQuery] = useState('')
46 const [creating, setCreating] = useState(false)
47 const [renaming, setRenaming] = useState<string | null>(null)
48 const [pendingDelete, setPendingDelete] = useState<string | null>(null)
49 const [notice, setNotice] = useState<string | null>(null)
50 const [actionError, setActionError] = useState<string | null>(null)
51 const [busy, setBusy] = useState(false)
52 const data = useAsync(() => listBranches(space, repo, PAGE_LIMIT), [space, repo, tick], {
53 cacheKey: repoKey(space, repo, `refs:branches:${PAGE_LIMIT}`),
54 })
55 const failure = data.error as { code?: number; message?: string } | undefined
56
57 const refresh = () => {
58 setTick((t) => t + 1)
59 onChanged()
60 }
61 const rows = (data.data?.items ?? []).filter((branch) =>
62 branch.name.toLowerCase().includes(query.trim().toLowerCase()),
63 )
64 const total = data.data?.total ?? rows.length
65
66 /** One mutating action: report success/failure inline, then refresh. */
67 async function run(action: () => Promise<unknown>, done: string) {
68 setBusy(true)
69 setNotice(null)
70 setActionError(null)
71 try {
72 await action()
73 setNotice(done)
74 refresh()
75 } catch (err) {
76 setActionError(messageOf(err, t('repo.error.actionFailed')))
77 } finally {
78 setBusy(false)
79 }
80 }
81
82 return (
83 <div className="repo-branches pb-6">
84 <div className="repo-section-toolbar mb-5 flex flex-wrap items-center gap-3">
85 <h3 className="mr-2 text-[17px] font-semibold">
86 {t('repo.branches.title')}{' '}
87 <span className="text-[12.5px] font-normal text-shell-ink-muted">
88 {t('repo.branches.total', { count: total })}
89 </span>
90 </h3>
91 <label className="repo-branch-search relative">
92 <Search
93 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-shell-ink-muted"
94 aria-hidden
95 />
96 <input
97 aria-label={t('repo.branches.filter')}
98 value={query}
99 onChange={(e) => setQuery(e.target.value)}
100 placeholder={t('repo.branches.filterPlaceholder')}
101 className="h-10 w-56 rounded-lg border border-shell-hairline bg-shell-surface pl-9 pr-3 text-[13px] outline-none transition-colors focus:border-shell-tile"
102 />
103 </label>
104 {canWrite ? (
105 <Button className="ml-auto gap-1.5 text-[13px]" disabled={busy} onClick={() => setCreating(true)}>
106 <Plus className="h-4 w-4" aria-hidden /> {t('repo.branch.create')}
107 </Button>
108 ) : null}
109 </div>
110 {notice || actionError ? (
111 <div className="mb-3">
112 {notice ? <Alert tone="success">{notice}</Alert> : null}
113 {actionError ? <Alert tone="error">{actionError}</Alert> : null}
114 </div>
115 ) : null}
116
117 {data.loading && !data.data ? (
118 <PanelNote text={t('repo.branches.loading')} />
119 ) : failure ? (
120 <ErrorPanel
121 status={failure.code ?? 0}
122 what={t('repo.branches.listName')}
123 note={failure.message}
124 onRetry={() => setTick((n) => n + 1)}
125 />
126 ) : rows.length === 0 ? (
127 <PanelNote text={query ? t('repo.branches.noMatch') : t('repo.branches.none')} />
128 ) : (
129 <ul className="repo-branch-list divide-y divide-shell-hairline overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface">
130 {rows.map((branch) => {
131 const isDefault = branch.name === defaultBranch
132 const isCurrent = branch.name === rev
133 const locked = branch.is_protected || isDefault
134 return (
135 <li key={branch.name} className="repo-branch-row flex flex-wrap items-center gap-3 px-5 py-4">
136 <GitBranch className="h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />
137 <span className="min-w-0 break-all font-mono text-[13px] font-semibold">{branch.name}</span>
138 {isDefault ? (
139 <span className="flex items-center gap-1 rounded-full bg-shell-active px-2 py-0.5 text-[11px]">
140 <Star className="h-3 w-3" aria-hidden /> {t('repo.branch.defaultBadge')}
141 </span>
142 ) : null}
143 {isCurrent ? (
144 <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px] text-shell-ink">
145 {t('repo.branch.current')}
146 </span>
147 ) : null}
148 {branch.is_protected ? (
149 <span
150 className="rounded-full bg-shell-active px-2 py-0.5 text-[11px]"
151 title={t('repo.branch.protectedNote')}
152 >
153 {t('repo.branch.protected')}
154 </span>
155 ) : null}
156 <CopyChip text={branch.commit_sha} label={shortSha(branch.commit_sha, 8)} />
157 <span
158 className="text-[12px] text-shell-ink-muted"
159 title={t('repo.branch.updatedAt', { time: fmtTime(branch.updated_at) })}
160 >
161 {t('repo.branch.updated', { time: fmtRelative(branch.updated_at) })}
162 </span>
163 <div className="repo-branch-actions ml-auto flex flex-wrap items-center gap-1.5">
164 <Button
165 variant="secondary"
166 className="h-8 gap-1 text-[12.5px]"
167 disabled={busy}
168 onClick={() => onBrowse(branch.name)}
169 >
170 <Eye className="h-3.5 w-3.5" aria-hidden /> {t('repo.action.browseCode')}
171 </Button>
172 {canWrite ? (
173 <>
174 <Button
175 variant="secondary"
176 className="h-8 text-[12.5px]"
177 disabled={isDefault || busy}
178 title={isDefault ? t('repo.branch.alreadyDefault') : t('repo.branch.makeDefaultHint')}
179 onClick={() =>
180 void run(
181 () => setDefaultBranch(space, repo, branch.name),
182 t('repo.branch.defaultSet', { name: branch.name }),
183 )
184 }
185 >
186 {t('repo.action.setDefault')}
187 </Button>
188 <Button
189 variant="secondary"
190 className="h-8 text-[12.5px]"
191 disabled={branch.is_protected || busy}
192 title={branch.is_protected ? t('repo.branch.protectedRenameBlocked') : undefined}
193 onClick={() => setRenaming(branch.name)}
194 >
195 {t('repo.action.rename')}
196 </Button>
197 <Button
198 variant="secondary"
199 className="repo-delete-button h-8 text-[12.5px]"
200 disabled={locked || busy}
201 title={locked ? t('repo.branch.deleteBlocked') : undefined}
202 onClick={() => {
203 if (pendingDelete !== branch.name) {
204 setPendingDelete(branch.name)
205 return
206 }
207 setPendingDelete(null)
208 void run(
209 async () => {
210 await deleteBranch(space, repo, branch.name)
211 if (isCurrent) onCheckout(defaultBranch)
212 },
213 t('repo.branch.deletedName', { name: branch.name }),
214 )
215 }}
216 >
217 {pendingDelete === branch.name ? t('repo.action.confirmDelete') : t('common.action.delete')}
218 </Button>
219 {pendingDelete === branch.name ? (
220 <Button
221 variant="secondary"
222 className="h-8 text-[12.5px]"
223 onClick={() => setPendingDelete(null)}
224 >
225 {t('common.action.cancel')}
226 </Button>
227 ) : null}
228 </>
229 ) : null}
230 </div>
231 </li>
232 )
233 })}
234 </ul>
235 )}
236 {data.data && data.data.total > data.data.items.length ? (
237 <p className="pt-2 text-[12px] text-shell-ink-muted">{t('repo.branches.limitNote', { count: PAGE_LIMIT })}</p>
238 ) : null}
239
240 <TagsSection space={space} repo={repo} rev={rev} canWrite={canWrite} onBrowse={onBrowse} onChanged={onChanged} />
241 <SubmodulesSection space={space} repo={repo} rev={rev} />
242
243 <FormModal
244 title={t('repo.branch.create')}
245 description={t('repo.branch.createHint')}
246 open={creating}
247 wide
248 confirmLabel={t('repo.branch.createSubmit')}
249 fields={[
250 { key: 'name', kind: 'text', label: t('repo.branch.name'), placeholder: 'feature/login' },
251 {
252 key: 'start_point',
253 kind: 'text',
254 label: t('repo.branch.startPoint'),
255 initialValue: rev,
256 hint: t('repo.branch.startPointHint'),
257 },
258 ]}
259 onClose={() => setCreating(false)}
260 onSubmit={async (values: FormValues) => {
261 const name = String(values.name ?? '').trim()
262 const invalid = validateBranchName(name)
263 if (invalid) throw new ApiFailure(invalid, 400)
264 const start = String(values.start_point ?? '').trim() || rev
265 // Throwing keeps the dialog open with the message inline.
266 await createBranch(space, repo, name, start)
267 setNotice(t('repo.branch.createdName', { name }))
268 refresh()
269 }}
270 />
271 <FormModal
272 title={`${t('repo.branch.rename')}${renaming ? ` · ${renaming}` : ''}`}
273 description={t('repo.branch.renameHint')}
274 open={Boolean(renaming)}
275 wide
276 danger
277 confirmLabel={t('repo.branch.renameSubmit')}
278 fields={[
279 {
280 key: 'new_name',
281 kind: 'text',
282 label: t('repo.branch.newName'),
283 initialValue: renaming ?? '',
284 placeholder: 'release/1.0',
285 },
286 ]}
287 onClose={() => setRenaming(null)}
288 onSubmit={async (values: FormValues) => {
289 const from = renaming
290 const next = String(values.new_name ?? '').trim()
291 if (!from) return
292 const invalid = validateBranchName(next)
293 if (invalid) throw new ApiFailure(invalid, 400)
294 if (next === from) throw new ApiFailure(t('repo.branch.sameName'), 400)
295 await renameBranch(space, repo, from, next)
296 setNotice(t('repo.branch.renamed', { from, next }))
297 setRenaming(null)
298 refresh()
299 if (from === rev) onCheckout(next)
300 }}
301 />
302 </div>
303 )
304}
305
306function messageOf(err: unknown, fallback: string): string {
307 return err instanceof ApiFailure ? err.message : fallback
308}
Added view/app/src/domains/repo/branches/ui/submodules-section.tsx View view/app/src/domains/repo/branches/ui/submodules-section.tsx
@@ -0,0 +1,64 @@
1import { CopyChip, PanelNote } from '@app/components'
2import { listSubmodules, resolveSubmodule } from '@app/domains/repo/model'
3import { useT } from '@app/i18n'
4import { shortSha, useAsync } from '@app/lib'
5import { Button } from '@app/ui'
6import { Boxes } from 'lucide-react'
7import { useState } from 'react'
8
9/** Read-only `.gitmodules` listing at the current revision. */
10export function SubmodulesSection({ space, repo, rev }: { space: string; repo: string; rev: string }) {
11 const { t } = useT()
12 const [resolved, setResolved] = useState<Record<string, string>>({})
13 const [busyPath, setBusyPath] = useState<string | null>(null)
14 const data = useAsync(() => (rev ? listSubmodules(space, repo, rev) : Promise.resolve([])), [space, repo, rev])
15 if (data.loading && !data.data) return null
16 const rows = data.data ?? []
17 if (!rows.length) return null
18 return (
19 <div className="mt-10">
20 <h3 className="mb-3 flex items-center gap-2 text-[17px] font-semibold">
21 <Boxes className="h-4.5 w-4.5 text-shell-ink-muted" aria-hidden />
22 {t('repo.submodules.title')}
23 </h3>
24 <ul className="divide-y divide-shell-hairline overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface">
25 {rows.map((sub) => (
26 <li key={sub.path} className="flex flex-wrap items-center gap-3 px-5 py-3.5 text-[13px]">
27 <span className="min-w-0 break-all font-mono font-semibold">{sub.path}</span>
28 <span className="min-w-0 truncate font-mono text-[12px] text-shell-ink-muted" title={sub.url}>
29 {sub.url}
30 </span>
31 {sub.commit_sha ? (
32 <span className="flex items-center gap-1.5 text-[12px] text-shell-ink-muted">
33 @ <CopyChip text={sub.commit_sha} label={shortSha(sub.commit_sha, 8)} />
34 </span>
35 ) : null}
36 {sub.branch ? (
37 <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px]">{sub.branch}</span>
38 ) : null}
39 <span className="ml-auto flex items-center gap-2">
40 {resolved[sub.path] ? (
41 <span className="font-mono text-[12px] text-shell-ink-muted">→ {resolved[sub.path]}</span>
42 ) : null}
43 <Button
44 variant="secondary"
45 className="h-7 text-[12px]"
46 disabled={busyPath === sub.path || resolved[sub.path] !== undefined}
47 onClick={() => {
48 setBusyPath(sub.path)
49 void resolveSubmodule(space, repo, rev, sub.path)
50 .then((target) => setResolved((prev) => ({ ...prev, [sub.path]: target })))
51 .catch(() => setResolved((prev) => ({ ...prev, [sub.path]: t('repo.submodules.unresolved') })))
52 .finally(() => setBusyPath(null))
53 }}
54 >
55 {t('repo.submodules.resolve')}
56 </Button>
57 </span>
58 </li>
59 ))}
60 </ul>
61 {data.error ? <PanelNote text={t('repo.submodules.loadFailed')} /> : null}
62 </div>
63 )
64}
Added view/app/src/domains/repo/branches/ui/tags-section.tsx View view/app/src/domains/repo/branches/ui/tags-section.tsx
@@ -0,0 +1,240 @@
1import { Alert, CopyChip, ErrorPanel, Modal, PanelNote } from '@app/components'
2import { createTag, deleteTag, fetchTag, listTags, type TagRow } from '@app/domains/repo/model'
3import { FormModal, type FormValues } from '@app/domains/shared'
4import { useT } from '@app/i18n'
5import { ApiFailure, fmtRelative, fmtTime, repoKey, shortSha, useAsync } from '@app/lib'
6import { Button } from '@app/ui'
7import { Eye, GitCommitHorizontal, Plus, Tag } from 'lucide-react'
8import { useState } from 'react'
9
10const PAGE_LIMIT = 200
11
12export interface TagsSectionProps {
13 space: string
14 repo: string
15 /** Repo write access (space owner / repo collaborator server-side). */
16 canWrite: boolean
17 /** Offer the current revision as the default tag target. */
18 rev: string
19 onBrowse: (name: string) => void
20 onChanged: () => void
21}
22
23/** Tag management: list, inspect annotated payload, create, delete. */
24export function TagsSection({ space, repo, rev, canWrite, onBrowse, onChanged }: TagsSectionProps) {
25 const { t } = useT()
26 const [tick, setTick] = useState(0)
27 const [creating, setCreating] = useState(false)
28 const [pendingDelete, setPendingDelete] = useState<string | null>(null)
29 const [detail, setDetail] = useState<TagRow | null>(null)
30 const [notice, setNotice] = useState<string | null>(null)
31 const [error, setError] = useState<string | null>(null)
32 const [busy, setBusy] = useState(false)
33
34 const data = useAsync(() => listTags(space, repo, PAGE_LIMIT), [space, repo, tick], {
35 cacheKey: repoKey(space, repo, `refs:tags:${PAGE_LIMIT}:${tick}`),
36 })
37 const rows = data.data?.items ?? []
38 const failure = data.error as { code?: number; message?: string } | undefined
39 const refresh = () => {
40 setTick((t) => t + 1)
41 onChanged()
42 }
43
44 async function run(action: () => Promise<unknown>, done: string) {
45 setBusy(true)
46 setNotice(null)
47 setError(null)
48 try {
49 await action()
50 setNotice(done)
51 refresh()
52 } catch (err) {
53 setError(err instanceof ApiFailure ? err.message : t('repo.error.actionFailed'))
54 } finally {
55 setBusy(false)
56 }
57 }
58
59 return (
60 <div className="mt-10">
61 <div className="repo-section-toolbar mb-5 flex flex-wrap items-center gap-3">
62 <h3 className="mr-2 text-[17px] font-semibold">
63 {t('repo.tags.title')}{' '}
64 <span className="text-[12.5px] font-normal text-shell-ink-muted">
65 {t('repo.tags.total', { count: data.data?.total ?? 0 })}
66 </span>
67 </h3>
68 {canWrite ? (
69 <Button className="ml-auto gap-1.5 text-[13px]" disabled={busy} onClick={() => setCreating(true)}>
70 <Plus className="h-4 w-4" aria-hidden /> {t('repo.tag.create')}
71 </Button>
72 ) : null}
73 </div>
74 {notice || error ? (
75 <div className="mb-3">
76 {notice ? <Alert tone="success">{notice}</Alert> : null}
77 {error ? <Alert tone="error">{error}</Alert> : null}
78 </div>
79 ) : null}
80
81 {data.loading && !data.data ? (
82 <PanelNote text={t('repo.tags.loading')} />
83 ) : failure ? (
84 <ErrorPanel
85 status={failure.code ?? 0}
86 what={t('repo.tags.listName')}
87 note={failure.message}
88 onRetry={() => setTick((n) => n + 1)}
89 />
90 ) : rows.length === 0 ? (
91 <PanelNote text={t('repo.tags.none')} />
92 ) : (
93 <ul className="repo-branch-list divide-y divide-shell-hairline overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface">
94 {rows.map((tag) => (
95 <li key={tag.name} className="repo-branch-row flex flex-wrap items-center gap-3 px-5 py-4">
96 <Tag className="h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />
97 <span className="min-w-0 break-all font-mono text-[13px] font-semibold">{tag.name}</span>
98 {tag.tag_type === 'Annotated' ? (
99 <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px]" title={tag.message ?? undefined}>
100 {t('repo.tag.annotated')}
101 </span>
102 ) : (
103 <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px]">
104 {t('repo.tag.lightweight')}
105 </span>
106 )}
107 <CopyChip text={tag.target_sha} label={shortSha(tag.target_sha, 8)} />
108 {tag.tagger_name ? <span className="text-[12px] text-shell-ink-muted">{tag.tagger_name}</span> : null}
109 {tag.created_at ? (
110 <span className="text-[12px] text-shell-ink-muted">{fmtRelative(tag.created_at)}</span>
111 ) : null}
112 <div className="repo-branch-actions ml-auto flex flex-wrap items-center gap-1.5">
113 <Button variant="secondary" className="h-8 gap-1 text-[12.5px]" onClick={() => onBrowse(tag.name)}>
114 <Eye className="h-3.5 w-3.5" aria-hidden /> {t('repo.action.browseCode')}
115 </Button>
116 <Button
117 variant="secondary"
118 className="h-8 gap-1 text-[12.5px]"
119 onClick={() => void run(async () => setDetail(await fetchTag(space, repo, tag.name)), '')}
120 >
121 <GitCommitHorizontal className="h-3.5 w-3.5" aria-hidden /> {t('repo.action.details')}
122 </Button>
123 {canWrite ? (
124 <>
125 <Button
126 variant="secondary"
127 className="repo-delete-button h-8 text-[12.5px]"
128 disabled={busy}
129 onClick={() => {
130 if (pendingDelete !== tag.name) {
131 setPendingDelete(tag.name)
132 return
133 }
134 setPendingDelete(null)
135 void run(() => deleteTag(space, repo, tag.name), t('repo.tag.deletedName', { name: tag.name }))
136 }}
137 >
138 {pendingDelete === tag.name ? t('repo.action.confirmDelete') : t('common.action.delete')}
139 </Button>
140 {pendingDelete === tag.name ? (
141 <Button variant="secondary" className="h-8 text-[12.5px]" onClick={() => setPendingDelete(null)}>
142 {t('common.action.cancel')}
143 </Button>
144 ) : null}
145 </>
146 ) : null}
147 </div>
148 </li>
149 ))}
150 </ul>
151 )}
152
153 <FormModal
154 title={t('repo.tag.create')}
155 description={t('repo.tag.createHint')}
156 open={creating}
157 wide
158 confirmLabel={t('repo.tag.createSubmit')}
159 fields={[
160 { key: 'name', kind: 'text', label: t('repo.tag.name'), placeholder: 'v1.0.0' },
161 {
162 key: 'target',
163 kind: 'text',
164 label: t('repo.tag.target'),
165 initialValue: rev,
166 hint: t('repo.tag.targetHint'),
167 },
168 { key: 'annotated', kind: 'toggle', label: t('repo.tag.annotated') },
169 {
170 key: 'message',
171 kind: 'textarea',
172 label: t('repo.tag.messageLabel'),
173 rows: 3,
174 placeholder: t('repo.tag.messagePlaceholder'),
175 },
176 ]}
177 onClose={() => setCreating(false)}
178 onSubmit={async (values: FormValues) => {
179 const name = String(values.name ?? '').trim()
180 if (!name) throw new ApiFailure(t('repo.tag.nameRequired'), 400)
181 await createTag(space, repo, {
182 name,
183 target: String(values.target ?? '').trim() || rev,
184 annotated: Boolean(values.annotated),
185 message: String(values.message ?? '').trim() || null,
186 })
187 setNotice(t('repo.tag.createdName', { name }))
188 setCreating(false)
189 refresh()
190 }}
191 />
192
193 {detail ? (
194 <Modal
195 title={t('repo.tag.detailTitle', { name: detail.name })}
196 open
197 onClose={() => setDetail(null)}
198 footer={
199 <Button variant="ghost" onClick={() => setDetail(null)}>
200 {t('common.action.close')}
201 </Button>
202 }
203 >
204 <dl className="space-y-2 text-[13px]">
205 <div className="flex gap-3">
206 <dt className="w-20 shrink-0 text-shell-ink-muted">{t('repo.tag.type')}</dt>
207 <dd>{detail.tag_type === 'Annotated' ? t('repo.tag.annotated') : t('repo.tag.lightweight')}</dd>
208 </div>
209 <div className="flex gap-3">
210 <dt className="w-20 shrink-0 text-shell-ink-muted">{t('repo.tag.targetCommit')}</dt>
211 <dd className="min-w-0 break-all font-mono">{detail.target_sha}</dd>
212 </div>
213 <div className="flex gap-3">
214 <dt className="w-20 shrink-0 text-shell-ink-muted">{t('repo.tag.tagger')}</dt>
215 <dd>
216 {detail.tagger_name
217 ? `${detail.tagger_name}${detail.tagger_email ? ` <${detail.tagger_email}>` : ''}`
218 : '—'}
219 </dd>
220 </div>
221 {detail.created_at ? (
222 <div className="flex gap-3">
223 <dt className="w-20 shrink-0 text-shell-ink-muted">{t('repo.tag.createdAt')}</dt>
224 <dd>{fmtTime(detail.created_at)}</dd>
225 </div>
226 ) : null}
227 {detail.message ? (
228 <div>
229 <dt className="mb-1 text-shell-ink-muted">{t('repo.tag.message')}</dt>
230 <dd className="whitespace-pre-wrap rounded-lg border border-shell-hairline bg-shell-field px-3 py-2">
231 {detail.message}
232 </dd>
233 </div>
234 ) : null}
235 </dl>
236 </Modal>
237 ) : null}
238 </div>
239 )
240}
Added view/app/src/domains/repo/branches/index.ts View view/app/src/domains/repo/branches/index.ts
@@ -0,0 +1,4 @@
1// Public API of features/repo-branches — branch management (list / create /
2// rename / set-default / delete). Consumers import from '@app/domains/repo/branches',
3// never from deeper files.
4export { BranchesTab, type BranchesTabProps } from './ui/branches-tab'
Added view/app/src/domains/repo/browse/model/blob-kind.ts View view/app/src/domains/repo/browse/model/blob-kind.ts
@@ -0,0 +1,18 @@
1/**
2 * Which blob paths the code viewer can render as something richer than source
3 * lines. Kept separate from the markdown pipeline because the file page asks
4 * this question before it knows which renderer it will need.
5 */
6
7const MARKDOWN_RE = /\.(md|markdown)$/i
8const PDF_RE = /\.pdf$/i
9
10/** Markdown documents render as a formatted page, not numbered source. */
11export function isMarkdownPath(path: string): boolean {
12 return MARKDOWN_RE.test(path)
13}
14
15/** PDFs render in the browser's own viewer instead of the binary notice. */
16export function isPdfPath(path: string): boolean {
17 return PDF_RE.test(path)
18}
Added view/app/src/domains/repo/browse/model/pdf-stream.ts View view/app/src/domains/repo/browse/model/pdf-stream.ts
@@ -0,0 +1,133 @@
1/**
2 * Framed page stream from `html/blob-pages`.
3 *
4 * The server rasterises a PDF page by page and pushes
5 * `u32 header_len | header | u32 payload_len | payload` frames, where the
6 * header is JSON tagged by `kind`: `doc`, `page`, `page_error`, `done`. Reading
7 * it incrementally is what makes a 200-page document open on page one — a
8 * buffered `fetch().then(r => r.arrayBuffer())` would hold the whole file.
9 */
10export interface PdfPage {
11 page: number
12 total: number
13 width: number
14 height: number
15 /** Object URL of the page PNG; owned by the caller. */
16 url: string
17}
18
19interface FrameHeader {
20 kind?: string
21 page?: number
22 total?: number
23 pages?: number
24 available?: number
25 truncated?: boolean
26 width?: number
27 height?: number
28 error?: string
29}
30
31export interface PdfStreamHandlers {
32 /** Total page count, known before the first page has been rasterised. */
33 onDocument?: (info: { pages: number; available: number; truncated: boolean }) => void
34 onPage: (page: PdfPage) => void
35 /** One page failed to rasterise; the stream continues. */
36 onPageError?: (info: { page: number; total: number; error: string }) => void
37}
38
39/**
40 * Rasterisation scale that makes a page fill `containerWidth` CSS pixels.
41 *
42 * One CSS pixel maps to `devicePixelRatio` device pixels, so that is the honest
43 * target; the ceiling keeps a long document from streaming hundreds of
44 * megabytes of PNG it would only downscale again.
45 */
46export function fitScale(containerWidth: number): number {
47 const dpr = typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1
48 const target = (containerWidth * Math.min(dpr, 2)) / 595.28
49 return Math.min(2, Math.max(0.75, Math.round(target * 100) / 100))
50}
51
52/** Read a framed page stream, decoding headers as they arrive. */
53export async function readPdfStream(url: string, handlers: PdfStreamHandlers, signal?: AbortSignal): Promise<void> {
54 const res = await fetch(url, { signal, credentials: 'same-origin' })
55 if (!res.ok || !res.body) {
56 const err = new Error(`pdf stream failed: ${res.status}`) as Error & { status: number }
57 err.status = res.status
58 throw err
59 }
60 const reader = res.body.getReader()
61 let buffer: Uint8Array<ArrayBuffer> = new Uint8Array(0)
62 try {
63 for (;;) {
64 const { value, done } = await reader.read()
65 if (done) break
66 if (value) buffer = concat(buffer, new Uint8Array(value))
67 let offset = 0
68 for (;;) {
69 const head = readFrame(buffer, offset)
70 if (!head) break
71 offset = head.next
72 const header = head.header
73 if (header.kind === 'doc' && header.pages !== undefined) {
74 handlers.onDocument?.({
75 pages: header.pages,
76 available: header.available ?? header.pages,
77 truncated: header.truncated ?? false,
78 })
79 } else if (header.kind === 'page' && header.page) {
80 handlers.onPage({
81 page: header.page,
82 total: header.total ?? header.page,
83 width: header.width ?? 0,
84 height: header.height ?? 0,
85 url: URL.createObjectURL(new Blob([head.payload], { type: 'image/png' })),
86 })
87 } else if (header.kind === 'page_error' && header.page) {
88 handlers.onPageError?.({
89 page: header.page,
90 total: header.total ?? header.page,
91 error: header.error ?? 'render failed',
92 })
93 } else if (header.kind === 'done') {
94 return
95 }
96 }
97 if (offset > 0) buffer = buffer.slice(offset)
98 }
99 } finally {
100 reader.cancel().catch(() => {})
101 }
102}
103
104/** One decoded frame, or `null` while the buffer does not hold a complete one. */
105function readFrame(
106 buffer: Uint8Array<ArrayBuffer>,
107 offset: number,
108): { header: FrameHeader; payload: Uint8Array<ArrayBuffer>; next: number } | null {
109 if (buffer.length - offset < 8) return null
110 const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
111 const headLen = view.getUint32(offset)
112 const headEnd = offset + 4 + headLen
113 if (buffer.length < headEnd + 4) return null
114 const payloadLen = view.getUint32(headEnd)
115 const payloadStart = headEnd + 4
116 const next = payloadStart + payloadLen
117 if (buffer.length < next) return null
118 let header: FrameHeader
119 try {
120 header = JSON.parse(new TextDecoder().decode(buffer.subarray(offset + 4, headEnd))) as FrameHeader
121 } catch {
122 return null
123 }
124 return { header, payload: buffer.slice(payloadStart, next), next }
125}
126
127function concat(a: Uint8Array<ArrayBuffer>, b: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> {
128 if (a.length === 0) return b
129 const out = new Uint8Array(a.length + b.length)
130 out.set(a, 0)
131 out.set(b, a.length)
132 return out
133}
Added view/app/src/domains/repo/browse/model/prefetch.ts View view/app/src/domains/repo/browse/model/prefetch.ts
@@ -0,0 +1,29 @@
1import {
2 fetchRepo,
3 fetchTreeJson,
4 htmlFragmentUrl,
5 listBranches,
6 listTags,
7 prefetchHtmlFragment,
8} from '@app/domains/repo/model'
9import { prefetch, repoKey } from '@app/lib'
10
11/**
12 * Hover prefetch for the repo column: the detail row first (it carries the
13 * default revision), then the ref-picker lists and the root tree the code tab
14 * opens with. Keys mirror the mount-time `useAsync` calls exactly, so a click
15 * during the prefetch rides the same in-flight requests. Public repos render
16 * server-built HTML fragments instead of the JSON tree, so both paths warm.
17 */
18export async function prefetchRepoView(space: string, repo: string): Promise<void> {
19 if (!space || !repo) return
20 void prefetch(repoKey(space, repo, 'refs:branches:100'), () => listBranches(space, repo).catch(() => null))
21 void prefetch(repoKey(space, repo, 'refs:tags:100'), () => listTags(space, repo).catch(() => null))
22 const detail = await prefetch(repoKey(space, repo, 'detail'), () => fetchRepo(space, repo))
23 const rev = detail?.default_branch
24 if (rev) {
25 void prefetch(repoKey(space, repo, `tree:${rev}:`), () => fetchTreeJson(space, repo, rev, ''))
26 void prefetchHtmlFragment(htmlFragmentUrl(space, repo, 'tree', { revision: rev, path: '' }))
27 void prefetchHtmlFragment(htmlFragmentUrl(space, repo, 'readme', { revision: rev, path: '' }))
28 }
29}
Added view/app/src/domains/repo/browse/model/readme.ts View view/app/src/domains/repo/browse/model/readme.ts
@@ -0,0 +1,253 @@
1/**
2 * README fragment pipeline.
3 *
4 * The server-rendered README (`/repos/{s}/{r}/html/readme`) passes raw CommonMark
5 * HTML straight through, so it is only ever rendered inside an opaque-origin
6 * sandbox iframe. Before that we parse it in a *detached* document (never
7 * attached → nothing executes) to:
8 * 1. drop the server's Tailwind card chrome — the utilities do not exist in
9 * the sandbox, so an unsized inline `<svg>` icon renders at the 300×150
10 * default and blows out the layout;
11 * 2. neutralise script-ish markup (`<script>`, `on*`, `javascript:`);
12 * 3. inline repo-relative `<img>` sources as data URIs — there is no
13 * raw-bytes endpoint, and a srcdoc iframe cannot attach our session cookie
14 * to a relative request anyway.
15 */
16
17import { fetchBlob, fetchTreeJson, repoWebHref } from '@app/domains/repo/model'
18import { marked } from 'marked'
19
20/** Anything bigger is not worth base64-ing into an attribute. */
21const MAX_INLINE_BYTES = 4 * 1024 * 1024
22const MAX_INLINE_IMAGES = 24
23
24const MIME_BY_EXT: Record<string, string> = {
25 apng: 'image/apng',
26 avif: 'image/avif',
27 bmp: 'image/bmp',
28 gif: 'image/gif',
29 ico: 'image/x-icon',
30 jpeg: 'image/jpeg',
31 jpg: 'image/jpeg',
32 png: 'image/png',
33 svg: 'image/svg+xml',
34 webp: 'image/webp',
35}
36
37const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i
38
39/**
40 * Map a README href onto a repo path: `assets/logo.jpg` with the README in
41 * `docs` → `docs/assets/logo.jpg`, while a leading slash is repo-rooted
42 * (`/img/x.png` → `img/x.png`), matching how forge UIs render markdown.
43 * Returns null for anything that is not a repo path (absolute URL, anchor,
44 * protocol-relative).
45 */
46export function resolveRepoPath(baseDir: string, href: string): string | null {
47 if (!href || SCHEME_RE.test(href) || href.startsWith('//') || href.startsWith('#')) return null
48 const clean = href.split('#')[0].split('?')[0]
49 if (!clean) return null
50 let decoded = clean
51 try {
52 decoded = decodeURIComponent(clean)
53 } catch {
54 /* keep the raw form when the author did not escape a % */
55 }
56 const fromRoot = decoded.startsWith('/')
57 const prefix = fromRoot ? '' : baseDir
58 const out: string[] = []
59 for (const segment of `${prefix}/`.split('/').concat(decoded.replace(/^\/+/, '').split('/'))) {
60 if (!segment || segment === '.') continue
61 if (segment === '..') {
62 out.pop()
63 continue
64 }
65 out.push(segment)
66 }
67 return out.length ? out.join('/') : null
68}
69
70function bytesToBase64(bytes: number[]): string {
71 let binary = ''
72 const chunk = 0x8000
73 for (let i = 0; i < bytes.length; i += chunk) {
74 binary += String.fromCharCode(...bytes.slice(i, i + chunk))
75 }
76 return btoa(binary)
77}
78
79/* Cached across renders/READMEs so browsing back and forth is free. Bounded
80 * because a data URI can be megabytes: insertion-order eviction. */
81function bounded<T>(limit: number) {
82 const cache = new Map<string, T>()
83 return {
84 get(key: string): T | undefined {
85 const hit = cache.get(key)
86 if (hit !== undefined) {
87 cache.delete(key)
88 cache.set(key, hit)
89 }
90 return hit
91 },
92 has(key: string) {
93 return cache.has(key)
94 },
95 set(key: string, value: T) {
96 cache.set(key, value)
97 while (cache.size > limit) cache.delete(cache.keys().next().value as string)
98 },
99 }
100}
101
102const entryCache = bounded<RepoEntry>(512)
103const dataUrlCache = bounded<string | null>(24)
104
105/** What a repo path points at, so README links can open a file or a folder. */
106export type RepoEntry = { kind: 'blob'; oid: string } | { kind: 'tree' } | { kind: 'missing' }
107
108export async function statRepoPath(space: string, repo: string, rev: string, path: string): Promise<RepoEntry> {
109 const cacheKey = `${space}/${repo}@${rev}:${path}`
110 const hit = entryCache.get(cacheKey)
111 if (hit) return hit
112 const slash = path.lastIndexOf('/')
113 const dir = slash === -1 ? '' : path.slice(0, slash)
114 const name = slash === -1 ? path : path.slice(slash + 1)
115 let entry: RepoEntry = { kind: 'missing' }
116 try {
117 const rows = await fetchTreeJson(space, repo, rev, dir)
118 const row = rows.find((candidate) => candidate.path === path || candidate.name === name)
119 if (row) {
120 if (row.mode === 'Tree' || row.mode === 'Submodule') entry = { kind: 'tree' }
121 // Without a hex oid the blob endpoints are unreachable (they reject short
122 // shas), so report it as missing rather than firing a doomed request.
123 else if (row.oid) entry = { kind: 'blob', oid: row.oid }
124 }
125 } catch {
126 entry = { kind: 'missing' }
127 }
128 entryCache.set(cacheKey, entry)
129 return entry
130}
131
132async function blobForPath(space: string, repo: string, rev: string, path: string): Promise<string | null> {
133 const entry = await statRepoPath(space, repo, rev, path)
134 return entry.kind === 'blob' ? entry.oid : null
135}
136
137async function inlineImage(space: string, repo: string, rev: string, path: string): Promise<string | null> {
138 const cacheKey = `${space}/${repo}@${rev}:${path}`
139 if (dataUrlCache.has(cacheKey)) return dataUrlCache.get(cacheKey) ?? null
140 let dataUrl: string | null = null
141 const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase()
142 const mime = MIME_BY_EXT[ext]
143 const oid = mime ? await blobForPath(space, repo, rev, path) : null
144 if (oid) {
145 try {
146 const blob = await fetchBlob(space, repo, oid)
147 // `truncated` means the server cut the payload → a half image is worse
148 // than a missing one.
149 if (!blob.truncated && blob.size <= MAX_INLINE_BYTES && blob.data?.length) {
150 dataUrl = `data:${mime};base64,${bytesToBase64(Array.from(blob.data))}`
151 }
152 } catch {
153 dataUrl = null
154 }
155 }
156 dataUrlCache.set(cacheKey, dataUrl)
157 return dataUrl
158}
159
160export interface PrepareReadmeArgs {
161 html: string
162 space: string
163 repo: string
164 rev: string
165 /** Directory the README lives in (`` for the repo root). */
166 baseDir: string
167}
168
169/** Turns a README fragment into sandbox-ready markup. */
170export async function prepareReadmeHtml({ html, space, repo, rev, baseDir }: PrepareReadmeArgs): Promise<string> {
171 const doc = new DOMParser().parseFromString(html, 'text/html')
172 const root = doc.querySelector('.markdown-body') ?? doc.body
173 root?.querySelectorAll('script,style,link,meta,base,object,embed,form').forEach((node) => node.remove())
174 for (const element of Array.from(root?.querySelectorAll('*') ?? [])) {
175 for (const attr of Array.from(element.attributes)) {
176 const name = attr.name.toLowerCase()
177 const dangerousTarget = name === 'href' || name === 'src' || name === 'xlink:href'
178 if (name.startsWith('on') || (dangerousTarget && /^\s*javascript:/i.test(attr.value))) {
179 element.removeAttribute(attr.name)
180 }
181 }
182 }
183
184 const images = Array.from(root?.querySelectorAll('img') ?? [])
185 await Promise.all(
186 images.map(async (image, index) => {
187 const source = image.getAttribute('src')
188 if (!source) return
189 const target = resolveRepoPath(baseDir, source)
190 // Absolute / data sources are the author's business, we leave them be.
191 if (!target) return
192 image.removeAttribute('srcset')
193 const dataUrl = index < MAX_INLINE_IMAGES ? await inlineImage(space, repo, rev, target) : null
194 if (dataUrl) {
195 image.setAttribute('src', dataUrl)
196 } else {
197 // A relative path cannot be fetched by the sandbox itself (no raw-bytes
198 // endpoint, no session cookie) — keep the box and say which file.
199 image.setAttribute('data-missing', target)
200 image.removeAttribute('src')
201 }
202 }),
203 )
204
205 /**
206 * Re-anchor in-repo links onto the app's own URLs.
207 *
208 * The frame is `srcdoc`, so it has no base of its own: the browser resolves
209 * `CHANGES.md` or `../docs/x.md` against `/app/<space>/repo/<repo>`, and the
210 * anchor's own href is what "open in new tab" / "copy link address" use. The
211 * click proxy already re-routes the *primary* click, but leaving the raw
212 * relative href behind ships a broken URL to every other affordance. Each
213 * link is resolved against the repo tree, so a folder opens the folder and a
214 * file opens the file — matching what the proxy would do with that click.
215 */
216 const links = Array.from(root?.querySelectorAll('a[href]') ?? [])
217 await Promise.all(
218 links.map(async (anchor) => {
219 const href = anchor.getAttribute('href')
220 if (!href) return
221 const target = resolveRepoPath(baseDir, href)
222 if (!target) return
223 const entry = await statRepoPath(space, repo, rev, target)
224 if (entry.kind === 'tree') {
225 anchor.setAttribute('href', repoWebHref(space, repo, { rev, path: target }))
226 } else if (entry.kind === 'blob') {
227 anchor.setAttribute('href', repoWebHref(space, repo, { rev, file: target }))
228 } else {
229 // Nothing at that path in this revision: leave the author's text alone
230 // rather than invent a URL that 404s a different way.
231 anchor.setAttribute('data-missing', target)
232 }
233 }),
234 )
235
236 return root?.innerHTML ?? ''
237}
238
239export interface PrepareMarkdownArgs extends Omit<PrepareReadmeArgs, 'html'> {
240 /** Markdown source, i.e. the raw text of a .md / .markdown blob. */
241 text: string
242}
243
244/**
245 * Render markdown *source* (an opened .md file) through the same pipeline the
246 * server-rendered README uses: CommonMark → detached-document sanitise → inline
247 * repo-relative images. GFM + soft line breaks match how forge UIs render a
248 * README, so the two entry points agree on the same document.
249 */
250export async function prepareMarkdownHtml({ text, space, repo, rev, baseDir }: PrepareMarkdownArgs): Promise<string> {
251 const html = marked.parse(text, { gfm: true, breaks: true, async: false })
252 return prepareReadmeHtml({ html, space, repo, rev, baseDir })
253}
Added view/app/src/domains/repo/browse/model/tree-html.ts View view/app/src/domains/repo/browse/model/tree-html.ts
@@ -0,0 +1,46 @@
1/**
2 * Post-processes the server-rendered tree fragment before it is injected:
3 * every row gains a 16px icon — Atom Material file/folder icons served from
4 * `public/file-icons` (resolution rules in `tree-icons.ts`) — and its name is
5 * wrapped in a span, so a long entry ellipsizes next to the icon instead of
6 * pushing the commit column around.
7 */
8import { treeIconUrl } from './tree-icons'
9
10/** The ".." row keeps an outline arrow: it is an affordance, not a file type. */
11const PARENT_ICON =
12 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>'
13
14/** Returns the fragment's table with icons injected; falls back verbatim. */
15export function prepareTreeHtml(html: string): string {
16 if (!html) return html
17 const doc = new DOMParser().parseFromString(html, 'text/html')
18 const table = doc.querySelector("[data-component='git-tree']")
19 if (!table) return html
20 for (const row of Array.from(table.querySelectorAll('tbody tr'))) {
21 const anchor = row.querySelector('td:first-child a')
22 if (!anchor || anchor.querySelector('.repo-tree-icon')) continue
23 const name = row.getAttribute('data-name') ?? anchor.textContent?.trim() ?? ''
24 const mode = (row.getAttribute('data-mode') ?? '').toLowerCase()
25 const icon = doc.createElement('span')
26 icon.className = 'repo-tree-icon'
27 icon.setAttribute('aria-hidden', 'true')
28 if (name === '..') {
29 icon.innerHTML = PARENT_ICON
30 } else {
31 const img = doc.createElement('img')
32 img.src = treeIconUrl(name, mode === 'tree' || mode === 'submodule')
33 img.alt = ''
34 img.width = 16
35 img.height = 16
36 img.decoding = 'async'
37 icon.appendChild(img)
38 }
39 const label = doc.createElement('span')
40 label.className = 'repo-tree-name'
41 while (anchor.firstChild) label.appendChild(anchor.firstChild)
42 anchor.appendChild(icon)
43 anchor.appendChild(label)
44 }
45 return doc.body.innerHTML
46}
Added view/app/src/domains/repo/browse/model/tree-icons.ts View view/app/src/domains/repo/browse/model/tree-icons.ts
@@ -0,0 +1,222 @@
1/**
2 * Generated by scripts/gen-tree-icons.mjs - do not edit by hand.
3 *
4 * Atom Material Icons (MIT): https://www.npmjs.com/package/atom-material-icons
5 * The patterns keep the upstream order: the first one matching a file name
6 * wins, and anything unmatched falls back to `default.svg`. The icon files
7 * live in `public/file-icons`.
8 */
9export type TreeIconRule = readonly [pattern: RegExp, icon: string]
10
11const RAW_RULES: ReadonlyArray<readonly [string, string]> = [
12 ['^CODE_OF_CONDUCT\\.(md|txt)$', 'github'],
13 ['^CONTRIBUTING\\.(md|txt)$', 'github'],
14 ['^COMMIT_MESSAGE_CONVENTION\\.(md|txt)$', 'github'],
15 ['.*TEMPLATE\\.(md|txt)$', 'github'],
16 ['^(README|readme)(\\.(md|txt))?$', 'readme'],
17 ['.*\\.(jpg|a?png|jpeg|gif|bmp|bpg|djvu|dpx|jps|jng|jxr)', 'image'],
18 ['.*\\.(webp|ico|dcm|ecw|fig|hdp|hdr|heic|heif|icns|iff)$', 'image'],
19 ['.*\\.(pcx|pict|pxr|raw|sgi|tga|tiff|vsdx|wbmp?|px[mbgs])$', 'image'],
20 ['.*\\.svg', 'svg'],
21 ['.*\\.(psb|psd)$', 'psd'],
22 ['^angular(-cli)?\\.json$', 'angular'],
23 ['^angular\\.(min\\.)?js$', 'angular'],
24 ['.*\\.(ng\\.html|ng\\.js|module\\.[tj]s|ng-template)$', 'angular'],
25 ['^(ansible|site|webservers|dbservers|playbook)\\.ya?ml$', 'ansible'],
26 ['.*\\.(ad|adoc|asciidoc|asc)$', 'asciidoc'],
27 ['.*\\.(asm|nasm|inc|ms|agc|aes|lst|aea|arm|avr|argus|mitigus)$', 'asm'],
28 ['^(AUTHORS|authors)(\\.(md|txt))?$', 'authors'],
29 ['.*\\.azcli$', 'azure'],
30 ['^azure-pipelines\\.ya?ml$', 'azure'],
31 ['^\\.(babelrc(\\.js)?|languagebabel|babel)$', 'babel'],
32 ['^babel(\\.[\\w\\-]+)?\\.json$', 'babel'],
33 ['.*\\.(bak|old|orig)$', 'backup'],
34 ['.*\\.(bzl|bazel)$', 'bazel'],
35 ['(\\.bazelrc|bazel\\.rc)$', 'bazel'],
36 ['.*\\.(blend|bphys)$', 'blender'],
37 ['.*\\.(c|objc|idc|w|nc|upc|cats|wbm|xpm)$', 'c'],
38 ['.*\\.(crt|cert|x509|pks)$', 'certificate'],
39 ['^(CHANGELOG|changelog)(\\.(md|txt))?', 'log'],
40 ['^\\.circleci$', 'circleci'],
41 ['^circle\\.ya?ml$', 'circleci'],
42 ['.*\\.cmake$', 'cmake'],
43 ['^CMakeLists(\\.txt)?', 'cmake'],
44 ['.*\\.commitlintrc(\\.js(on)?|\\.ya?ml)?$', 'commitlint'],
45 ['^commitlint\\.config\\.js$', 'commitlint'],
46 ['^composer\\.lock$', 'lock'],
47 ['.*\\.css$', 'css'],
48 ['.*\\.(csv|tsv)$', 'csv'],
49 ['^cypress\\.json$', 'cypress'],
50 ['.*\\.(cpp|c\\+\\+|cc|ii|mii|cxx|ipp|tcc|inl)$', 'cpp'],
51 ['.*\\.csx?$', 'csharp'],
52 ['.*\\.dart$', 'dart'],
53 ['.*\\.(dbi?|ddl|dict|od|schema)$', 'db'],
54 ['.*\\.diffs?$', 'diff'],
55 ['.*\\.?[dD]ockerfile$', 'docker'],
56 ['^docker-compose(.*)\\.(yml|yaml)$', 'docker'],
57 ['^\\.editorconfig$', 'editorconfig'],
58 ['.*\\.(ex|eex|exs|elixir|leex)$', 'ex'],
59 ['.*\\.elm$', 'elm'],
60 ['.*\\.eslintrc(\\.js(on)?|\\.y(a)?ml)?$', 'eslint'],
61 ['.*\\.eslintcache$', 'eslint'],
62 ['.*\\.(erc|erlang|beam|[ehxy]rl)$', 'erlang'],
63 ['^Emake(file)?$', 'makefile'],
64 ['.*\\.(xls|xlsx|xlm|xlt|xlsb|odx)$', 'excel'],
65 ['.*\\.firebaserc$', 'firebase'],
66 ['^firestore\\.(indexes\\.json|rules)$', 'firebase'],
67 ['^firebase\\.json$', 'firebase'],
68 ['.*\\.(fs|fsi|fsx|fsproj)$', 'fsharp'],
69 ['^\\.gitlab-ci\\.yml$', 'gitlab'],
70 ['^\\.git.*', 'git'],
71 ['^go\\.(mod|sub)$', 'go'],
72 ['.*\\.gradle$', 'gradle'],
73 ['gradlew$', 'gradle'],
74 ['.*\\.(gql|graphql)$', 'graphql'],
75 ['\\.graphqlconfig$', 'graphql'],
76 ['[gG]ulpfile.*', 'gulpfile'],
77 ['.*\\.(hcl|workflow)$', 'hcl'],
78 ['.*\\.(haskell|hs|hsc|c2hs|lhs|hsig|hs-boot)$', 'haskell'],
79 ['^haskellconfig\\.json$', 'haskell'],
80 ['.*\\.helm$', 'helm'],
81 ['^(Chart|values|helmfile)\\.ya?ml$', 'helm'],
82 ['history.*$', 'history'],
83 ['.*\\.huskyrc(.[cm]?js(on)?)?$', 'husky'],
84 ['.*\\.(js[bms]|jspre|jscript|jslib|dust|htc|pjs|ssjs)$', 'js'],
85 ['^([jJ])enkinsfile.*', 'jenkins'],
86 ['^jest(\\.(config|setup|teardown|babel))?\\.(js(on|x)?|[cm]js|tsx?)$', 'jest'],
87 ['^\\.jestrc.*', 'jest'],
88 ['.*\\.json5$', 'json5'],
89 ['.*\\.(julia|jl)$', 'julia'],
90 ['.*\\.(key|pem|gpg|pub)', 'key'],
91 ['.*\\.(kt|kts|ktm|kotlin)$', 'kotlin'],
92 ['^kubernetes.*\\.ya?ml$', 'kubernetes'],
93 ['^(deployment|configmap|service)\\.ya?ml$', 'kubernetes'],
94 ['^\\.?kazelcfg\\.json$', 'kubernetes'],
95 ['.*\\.less$', 'less'],
96 ['^(license|LICENSE)(\\.(txt|md))?$', 'license'],
97 ['^(licence|LICENCE)(\\.(txt|md))?$', 'license'],
98 ['.*\\.lintstagedrc\\.(json|yml)$', 'lint-staged'],
99 ['^lint-staged\\.config\\.js$', 'lint-staged'],
100 ['.*\\.log(\\.[0-9]+)?', 'log'],
101 ['.*\\.(lua|pd_lua|rbxs|wlua|luacheckrc|rockspec)$', 'lua'],
102 ['^Lakefile$', 'lua'],
103 ['^((BSD|GNU)?([mM])ake|mk)file$', 'makefile'],
104 ['.*(mk|mak|make|mmk|mms|am|bb)$', 'makefile'],
105 ['manifest\\.json$', 'manifest'],
106 ['^pom\\.xml$', 'maven'],
107 ['.*\\.mdx$', 'mdx'],
108 ['.*\\.(mongodb|bson)$', 'mongodb'],
109 ['^netlify\\.toml$', 'netlify'],
110 ['.*\\.(nginx|conf)$', 'nginx'],
111 ['.*\\.(nim|nimble)$', 'nim'],
112 ['^(server|app|main)\\.(js|ts|es6)$', 'nodejs'],
113 ['^\\.node-version$', 'nodejs'],
114 ['^package\\.json$', 'npm'],
115 ['^package-lock\\.json$', 'lock'],
116 ['^\\.npmrc$', 'npm'],
117 ['.*\\.(nvmrc|esmrc)$', 'nodejs'],
118 ['.*\\.mm?$', 'objc'],
119 ['.*\\.(ocaml|ml|mli|cmx|ocamllex|ocamlyacc)$', 'ocaml'],
120 ['.*\\.(patch|meld)$', 'patch'],
121 ['.*\\.pdf$', 'pdf'],
122 ['^perl[56]?-?config\\.json$', 'perl'],
123 ['Pipfile\\.lock$', 'lock'],
124 ['.*\\.plist$', 'plist'],
125 ['^pnpm-(lock|workspace|debug)\\.yaml', 'pnpm'],
126 ['^pnpmfile\\.js', 'pnpm'],
127 ['.*\\.(ppt|pptx|pps|ppsx|pot|potx|ppa|ppam)$', 'powerpoint'],
128 ['.*\\.(powershell|ps1|psd1|psc1|psm1|ps1xml|pssc|cmd)$', 'powershell'],
129 ['^\\.prettierrc(\\.js(on)?|\\.y(a)?ml)?$', 'prettier'],
130 ['^prettier\\.config\\.js$', 'prettier'],
131 ['prisma\\.yml', 'prisma'],
132 ['.*\\.properties$', 'source'],
133 ['^(python-?config\\.json|pyproject\\.toml)$', 'python'],
134 ['.*\\.r$', 'r'],
135 ['.*\\.react-cli$', 'react'],
136 ['.*\\.rdb', 'redis'],
137 ['^redis-config\\.ya?ml', 'redis'],
138 ['.*\\.rst$', 'rst'],
139 ['^rollup.*', 'rollup'],
140 ['^\\.ruby-version$', 'ruby'],
141 ['.*\\.(rs|rust)$', 'rust'],
142 ['^\\.rvmrc$', 'ruby'],
143 ['.*\\.sass$', 'sass'],
144 ['.*\\.scala$', 'scala'],
145 ['.*\\.scss$', 'sass'],
146 ['.*\\.(settings?|options?|opts?)$', 'settings'],
147 ['.*\\.(sh|zsh|fish|bash|fishrc|zshrc|bashrc|tcsh)$', 'shell'],
148 ['.*\\.sketch$', 'sketch'],
149 ['.*\\.sol$', 'solidity'],
150 ['.*\\.sqlite3?$', 'sqlite'],
151 ['.*\\.(story|stories)\\.(jsx?|tsx?)$', 'storybook'],
152 ['^(\\.stylelintrc(\\.(js|json|ya?ml))?|stylelint\\.config\\.js(on)?)$', 'stylelint'],
153 ['.*\\.styl$', 'stylus'],
154 ['.*\\.svelte', 'svelte'],
155 ['svelte\\.config\\.js(on)?', 'svelte'],
156 ['.*\\.(swagger|swag)$', 'swagger'],
157 ['((swagger|api)\\.(yml|yaml|json))$', 'swagger'],
158 ['.*\\.swift$', 'swift'],
159 ['^tailwind.*\\.[jt]s$', 'tailwindcss'],
160 ['.*\\.(tf|tfstate|tfvars)$', 'terraform'],
161 ['.*\\.(tex|cls|sty|dtx|ltx)$', 'tex'],
162 ['.*\\.toml$', 'toml'],
163 ['^(TODO|todo).*', 'todo'],
164 ['.*\\.(todo|taskpaper)$', 'todo'],
165 ['\\.?tsconfig.*\\.(js|json)$', 'tsconfig'],
166 ['.*\\.travis\\.yml$', 'travis'],
167 ['^(VERSION|version)$', 'version'],
168 ['.*\\.vue$', 'vue'],
169 ['.*\\.(wat|wasm|wast)$', 'webassembly'],
170 ['^webpack\\.(common|config|dev|prod)\\.(js|es|es6|coffee|ts)$', 'webpack'],
171 ['.*\\.(doc|docx|docm|docxml|dotm|dotx|wri|odt|odtx)$', 'word'],
172 ['(\\.yarnrc|yarn.js(on)?)', 'yarn'],
173 ['^yarn\\.lock$', 'lock'],
174 ['^\\.yarn(clean|-metadata|-integrity)$', 'yarn'],
175 ['^now\\.json$', 'zeit'],
176 ['.*\\.zig$', 'zig'],
177 ['.*\\.(prefs?)$', 'config'],
178 ['^[^.]+$', 'binary'],
179 ['.*\\.(axf|elf|la|objdump|bsdiff|bin|dat|pak)$', 'binary'],
180 ['.*\\.(gco?|gcode|[cdhk]nc|prx|swp|rnd)$', 'binary'],
181 ['.*\\.(z|xz|7z|7zip|gzip|zip|rar|tar)$', 'archive'],
182 ['.*\\.(gz|pzip|saz|tgz|bz2|xar|egg|sit)$', 'archive'],
183 ['.*\\.(lzo?|lzma|tlz|iso|whl|xar|ear|egg|sit)$', 'archive'],
184 ['.*\\.(mp3|aac|aiff|au|flac|m4a|mpc|mp|mka)$', 'audio'],
185 ['.*\\.(mpp|ogg|oga|opus|ra|rm|wav|wma|snd|mid|cda)$', 'audio'],
186 ['.*\\.(ac3|m4p|f4a|f4b|it|s3m|sndh)$', 'audio'],
187 ['.*\\.(avi|mp4|m4v|mpg|mpeg|mov|mkv|flv|h264)$', 'video'],
188 ['.*\\.(webm|wmv|ogv|3gp|m4v|3gpp|ogm|dvd|divx|qt|yuv)$', 'video'],
189 ['.*\\.(asx|asf|mks|mk3d|ogg)$', 'video'],
190 ['.*\\.(clj[cxs]?|clojure|hic)$', 'clojure'],
191 ['.*\\.(html|htm|xhtml|mhtml|dtml)$', 'html'],
192 ['.*\\.(go|gohtml)$', 'go'],
193 ['.*\\.(java|jar|war)$', 'java'],
194 ['.*\\.(jsx|tsx)$', 'jsx'],
195 ['.*\\.([cm]?js|es\\d)$', 'js'],
196 ['.*\\.(json|cson|jsonc|rsj)$', 'json'],
197 ['.*\\.(perl|plx?|pm|ph|al|psgi)$', 'perl'],
198 ['.*\\.(py|ipy|python|py[pt3iwzc]|whl|pep)$', 'python'],
199 ['.*\\.(pyde|gypi?|tac|xpy|wsgi|smk|rpy)$', 'python'],
200 ['.*\\.rb$', 'ruby'],
201 ['.*\\.(php[st]?)$', 'php'],
202 ['.*\\.(txt|rtf)$', 'text'],
203 ['.*\\.(ts|tslint)$', 'typeScript'],
204 ['.*\\.(ttf|ttc|pfb|pfm|otf|dfont|pfa|afm|eot|woff2?)$', 'font'],
205 ['.*\\.lock$', 'lock'],
206 ['.*\\.(md|markdown|mson|mk?down|mkd|pmd)$', 'markdown'],
207 ['.*\\.(xml|fxml|jrxml|jnlp|pom|rng|tld|wsdl|xsd|xsl|xslt|xul)$', 'xml'],
208 ['.*\\.(yml|yaml|info)$', 'yaml'],
209]
210
211export const TREE_FILE_ICON_RULES: readonly TreeIconRule[] = RAW_RULES.map(
212 ([pattern, icon]): TreeIconRule => [new RegExp(pattern), icon],
213)
214
215/** Icon file for a tree row; directories share one folder icon. */
216export function treeIconUrl(name: string, isDir: boolean): string {
217 if (isDir) return '/file-icons/folder.svg'
218 for (const [pattern, icon] of TREE_FILE_ICON_RULES) {
219 if (pattern.test(name)) return `/file-icons/${icon}.svg`
220 }
221 return '/file-icons/default.svg'
222}
Added view/app/src/domains/repo/browse/model/use-fragment-navigation.ts View view/app/src/domains/repo/browse/model/use-fragment-navigation.ts
@@ -0,0 +1,51 @@
1import { parentPath, parseWebHref } from '@app/domains/repo/model'
2import { type MouseEvent, useCallback } from 'react'
3
4export interface FragmentRoutes {
5 space: string
6 repo: string
7 rev: string
8 /** Path the fragment was rendered for (drives the `..` row). */
9 path: string
10 onNavigate: (path: string) => void
11 onOpenFile: (file: { path: string; oid?: string }) => void
12 onOpenCommit: (sha: string) => void
13 onShowHistory: (path: string) => void
14}
15
16/**
17 * Click proxy for a `dangerouslySetInnerHTML` fragment: keeps navigation inside
18 * the SPA. Unknown/unimplemented targets (blame, raw download, other revisions)
19 * are swallowed rather than allowed to navigate the app away.
20 */
21export function useFragmentNavigation(routes: FragmentRoutes) {
22 const { space, repo, rev, path, onNavigate, onOpenFile, onOpenCommit, onShowHistory } = routes
23 return useCallback(
24 (event: MouseEvent<HTMLDivElement>) => {
25 const anchor = (event.target as HTMLElement).closest('a')
26 if (!anchor) return
27 const href = anchor.getAttribute('href') ?? ''
28 if (href.startsWith('#')) return
29 event.preventDefault()
30 // The anchor's own href is per-link precise; a tree row also wraps the
31 // last-commit link, so row metadata can only ever be the fallback.
32 const target = parseWebHref(href, space, repo, rev)
33 if (target) {
34 if (target.kind === 'tree') onNavigate(target.path)
35 else if (target.kind === 'blob') onOpenFile({ path: target.path })
36 else if (target.kind === 'commit') onOpenCommit(target.sha)
37 else if (target.kind === 'history') onShowHistory(target.path)
38 else onNavigate(parentPath(path))
39 return
40 }
41 const row = anchor.closest('tr')
42 const mode = row?.dataset.mode
43 const rowPath = row?.dataset.path
44 if (mode === 'tree' && rowPath !== undefined) onNavigate(rowPath)
45 else if (mode === 'blob' && rowPath) onOpenFile({ path: rowPath })
46 // blame / raw download / foreign revisions: stay put rather than letting
47 // the browser navigate the SPA away.
48 },
49 [space, repo, rev, path, onNavigate, onOpenFile, onOpenCommit, onShowHistory],
50 )
51}
Added view/app/src/domains/repo/browse/ui/blame-view.tsx View view/app/src/domains/repo/browse/ui/blame-view.tsx
@@ -0,0 +1,94 @@
1import { CopyChip, ErrorPanel, PanelNote } from '@app/components'
2import { blameLineMap, fetchBlame, fetchBlob, fetchCommit } from '@app/domains/repo/model'
3import { useT } from '@app/i18n'
4import { fmtRelative, repoKey, shortSha, useAsync } from '@app/lib'
5import { useState } from 'react'
6
7export interface BlameViewProps {
8 space: string
9 repo: string
10 rev: string
11 path: string
12 oid: string
13 onOpenCommit: (sha: string) => void
14}
15
16/**
17 * Blame 视图(私有仓库 JSON 模式的文件渲染):blob 行 + 每行归属的提交。
18 * 作者信息按去重后的 sha 逐个取(上限 16 个,更多时回退显示缩略 oid),
19 * 相邻同范围只画一次 gutter,避免长文件里每行重复。
20 */
21export function BlameView({ space, repo, rev, path, oid, onOpenCommit }: BlameViewProps) {
22 const { t } = useT()
23 const [retry, setRetry] = useState(0)
24 const state = useAsync(
25 async () => {
26 const [blob, ranges] = await Promise.all([fetchBlob(space, repo, oid), fetchBlame(space, repo, rev, path)])
27 if (blob.is_binary) return { binary: true as const }
28 const text = new TextDecoder('utf-8', { fatal: false }).decode(new Uint8Array(blob.data ?? []))
29 const lines = text.split('\n')
30 if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
31 const map = blameLineMap(ranges, lines.length)
32 const unique = [...new Set(ranges.map((r) => r.commit_sha))].slice(0, 16)
33 const commits = await Promise.all(unique.map((sha) => fetchCommit(space, repo, sha).catch(() => null)))
34 const bySha = new Map<string, NonNullable<(typeof commits)[number]>>()
35 for (const commit of commits) if (commit) bySha.set(commit.id, commit)
36 return { binary: false as const, lines, map, bySha }
37 },
38 [space, repo, rev, path, oid, retry],
39 { cacheKey: repoKey(space, repo, `blame:${rev}:${path}:${retry ? 'r' : ''}`) },
40 )
41 const failure = state.error as { code?: number; message?: string } | undefined
42 if (state.loading && !state.data) return <PanelNote text={t('repo.blame.loading')} />
43 if (failure)
44 return (
45 <ErrorPanel
46 status={failure.code ?? 0}
47 what={t('repo.blame.what')}
48 note={failure.message}
49 onRetry={() => setRetry((r) => r + 1)}
50 />
51 )
52 const data = state.data
53 if (!data || data.binary) return <PanelNote text={t('repo.blame.unsupported')} />
54 if (!data.map.some(Boolean)) return <PanelNote text={t('repo.blame.empty')} />
55
56 return (
57 <div className="overflow-x-auto rounded-lg border-2 border-shell-hairline">
58 <table className="w-full border-collapse font-mono text-[12px] leading-5">
59 <tbody>
60 {data.lines.map((line, index) => {
61 const range = data.map[index] ?? null
62 const changed = range != null && range !== (data.map[index - 1] ?? null)
63 const commit = range ? data.bySha.get(range.commit_sha) : null
64 return (
65 <tr key={index}>
66 <td className="w-70 min-w-70 select-none border-r border-shell-hairline bg-shell-field px-2 align-top text-[11px] text-shell-ink-muted">
67 {changed && range ? (
68 <button
69 type="button"
70 onClick={() => onOpenCommit(range.commit_sha)}
71 className="block max-w-full truncate text-left hover:underline"
72 title={`${range.commit_sha}\n${commit?.message?.split('\n')[0] ?? range.abbreviated_oid}`}
73 >
74 {commit
75 ? `${commit.author_name} · ${shortSha(range.abbreviated_oid || range.commit_sha, 8)} · ${fmtRelative(commit.author_time ?? commit.committer_time)}`
76 : shortSha(range.abbreviated_oid || range.commit_sha, 10)}
77 </button>
78 ) : null}
79 </td>
80 <td className="w-10 select-none border-r border-shell-hairline px-2 text-right align-top text-[11px] text-shell-ink-muted">
81 {index + 1}
82 </td>
83 <td className="whitespace-pre-wrap break-all px-2">{line || ' '}</td>
84 </tr>
85 )
86 })}
87 </tbody>
88 </table>
89 <p className="border-t border-shell-hairline px-3 py-1.5 text-[11px] text-shell-ink-muted">
90 <CopyChip text={rev} label={t('repo.revision', { rev: shortSha(rev, 8) })} /> {t('repo.blame.hint')}
91 </p>
92 </div>
93 )
94}
Added view/app/src/domains/repo/browse/ui/code-tab.tsx View view/app/src/domains/repo/browse/ui/code-tab.tsx
@@ -0,0 +1,1069 @@
1import { ErrorPanel, PanelNote } from '@app/components'
2import {
3 embedCssText,
4 fetchBlob,
5 fetchTreeJson,
6 htmlFragmentUrl,
7 listCommitsPage,
8 parentPath,
9 parseRepoAppHref,
10 primaryCloneUrl,
11 type RepoDetail,
12 useHtmlFragment,
13} from '@app/domains/repo/model'
14import { InitialAvatar } from '@app/domains/shared'
15import { useT } from '@app/i18n'
16import { fmtRelative, fmtTime, formatBytes, repoKey, shortSha, useAsync } from '@app/lib'
17import { ArrowLeft, ArrowUp, ChevronRight, Eye, FileCode, FolderGit2, History, Users } from 'lucide-react'
18import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
19import { isMarkdownPath, isPdfPath } from '../model/blob-kind'
20import { prepareMarkdownHtml, prepareReadmeHtml, resolveRepoPath, statRepoPath } from '../model/readme'
21import { prepareTreeHtml } from '../model/tree-html'
22import { treeIconUrl } from '../model/tree-icons'
23import { type FragmentRoutes, useFragmentNavigation } from '../model/use-fragment-navigation'
24import { BlameView } from './blame-view'
25import { PdfViewer } from './pdf-viewer'
26
27export interface CodeTabProps {
28 space: string
29 repo: string
30 rev: string
31 repoDto: RepoDetail
32 refs?: ReactNode
33 /** Clone menu pinned to the right end of the refs row. */
34 clone?: ReactNode
35 mode: 'html' | 'json'
36 /** Directory being listed ('' = repo root). Owned by the route. */
37 path: string
38 /** Opened file, or null for the tree view. Owned by the route. */
39 file: FileRef | null
40 line?: number
41 /** Enter a directory ('' = repo root); leaves the file view. */
42 onNavigate: (path: string) => void
43 openFile: (file: FileRef) => void
44 closeFile: () => void
45 /** Open a commit's own diff page (tree rows link to it). */
46 onOpenCommit: (sha: string) => void
47 /** Show the commit history for a path (file header links to it). */
48 onShowHistory: (path: string) => void
49}
50
51export type FileRef = { path: string; oid?: string }
52
53const README_RE = /^readme(\.md|\.markdown|\.txt)?$/i
54
55export function CodeTab({
56 space,
57 repo,
58 rev,
59 repoDto,
60 refs,
61 clone,
62 mode,
63 path,
64 file,
65 line,
66 onNavigate,
67 openFile,
68 closeFile,
69 onOpenCommit,
70 onShowHistory,
71}: CodeTabProps) {
72 // Path and file arrive from the route: the URL is the only place this view
73 // state exists, so a refresh, a back click or a pasted link rebuilds it.
74 const routes = useMemo<FragmentRoutes>(
75 () => ({
76 space,
77 repo,
78 rev,
79 path: file?.path ?? path,
80 onNavigate,
81 onOpenFile: openFile,
82 onOpenCommit,
83 onShowHistory,
84 }),
85 [space, repo, rev, file?.path, path, onNavigate, openFile, onOpenCommit, onShowHistory],
86 )
87
88 if (repoDto.commits_count === 0)
89 return <EmptyRepo repoPath={`${space}/${repo}`} cloneUrl={primaryCloneUrl(repoDto, null)} />
90 if (file) {
91 return (
92 <FileView
93 refs={refs}
94 clone={clone}
95 space={space}
96 repo={repo}
97 rev={rev}
98 mode={mode}
99 file={file}
100 line={line}
101 routes={routes}
102 onBack={closeFile}
103 />
104 )
105 }
106 return (
107 <DirectoryView
108 refs={refs}
109 clone={clone}
110 space={space}
111 repo={repo}
112 rev={rev}
113 path={path}
114 mode={mode}
115 routes={routes}
116 />
117 )
118}
119
120/* ------------------------------------------------------------ directory -- */
121
122function DirectoryView({
123 refs,
124 clone,
125 space,
126 repo,
127 rev,
128 path,
129 mode,
130 routes,
131}: {
132 space: string
133 repo: string
134 rev: string
135 path: string
136 mode: 'html' | 'json'
137 routes: FragmentRoutes
138 refs?: ReactNode
139 clone?: ReactNode
140}) {
141 return (
142 <div className="repo-code-view">
143 <div className="repo-tree-card">
144 {refs ? (
145 <div className="repo-refs-row">
146 {refs}
147 {clone ? <div className="repo-refs-clone">{clone}</div> : null}
148 </div>
149 ) : null}
150 <DirectoryCommitBar
151 space={space}
152 repo={repo}
153 rev={rev}
154 path={path}
155 onNavigate={routes.onNavigate}
156 onOpenCommit={routes.onOpenCommit}
157 onShowHistory={routes.onShowHistory}
158 />
159 {mode === 'html' ? (
160 <HtmlTree space={space} repo={repo} rev={rev} path={path} routes={routes} />
161 ) : (
162 <JsonTree
163 space={space}
164 repo={repo}
165 rev={rev}
166 path={path}
167 onNavigate={routes.onNavigate}
168 onOpenFile={routes.onOpenFile}
169 />
170 )}
171 </div>
172 {mode === 'html' ? (
173 <HtmlReadme space={space} repo={repo} rev={rev} path={path} routes={routes} />
174 ) : (
175 <JsonReadme space={space} repo={repo} rev={rev} path={path} />
176 )}
177 </div>
178 )
179}
180
181function CodeHeader({ path, leaf, onNavigate }: { path: string; leaf?: string; onNavigate: (p: string) => void }) {
182 return (
183 <div className="repo-code-header flex flex-wrap items-center gap-2.5">
184 <Breadcrumbs path={path} leaf={leaf} onNavigate={onNavigate} className="repo-code-header-path" />
185 </div>
186 )
187}
188
189function Breadcrumbs({
190 path,
191 leaf,
192 onNavigate,
193 className = 'min-w-0 flex-1',
194}: {
195 path: string
196 leaf?: string
197 onNavigate: (p: string) => void
198 className?: string
199}) {
200 const { t } = useT()
201 const segments = path ? path.split('/') : []
202 return (
203 <nav
204 aria-label={t('repo.breadcrumb.label')}
205 className={`repo-breadcrumbs flex flex-wrap items-center gap-1 text-[13px] ${className}`}
206 >
207 <button type="button" className="font-semibold hover:underline" onClick={() => onNavigate('')}>
208 {t('repo.breadcrumb.root')}
209 </button>
210 {segments.map((segment, index) => (
211 <span key={index} className="flex items-center gap-1">
212 <ChevronRight className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />
213 <button
214 type="button"
215 aria-current={index === segments.length - 1 ? 'location' : undefined}
216 className={index === segments.length - 1 ? 'font-semibold' : 'hover:underline'}
217 onClick={() => onNavigate(segments.slice(0, index + 1).join('/'))}
218 >
219 {segment}
220 </button>
221 </span>
222 ))}
223 {leaf ? (
224 <span className="flex items-center gap-1">
225 <ChevronRight className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />
226 <span aria-current="location" className="font-semibold">
227 {leaf}
228 </span>
229 </span>
230 ) : null}
231 </nav>
232 )
233}
234
235/** Latest commit for the directory being listed — the strip above the table. */
236function DirectoryCommitBar({
237 space,
238 repo,
239 rev,
240 path,
241 onNavigate,
242 onOpenCommit,
243 onShowHistory,
244}: {
245 space: string
246 repo: string
247 rev: string
248 path: string
249 onNavigate: (path: string) => void
250 onOpenCommit: (sha: string) => void
251 onShowHistory: (path: string) => void
252}) {
253 const { t } = useT()
254 const state = useAsync(
255 () => listCommitsPage(space, repo, { revision: rev, skip: 0, limit: 1, path: path || undefined }),
256 [space, repo, rev, path],
257 { cacheKey: repoKey(space, repo, `latest-commit:${rev}:${path}`) },
258 )
259 const commit = state.data?.commits?.[0]
260 return (
261 <div className="repo-commit-bar flex items-center gap-2">
262 <Breadcrumbs path={path} onNavigate={onNavigate} />
263 {commit ? (
264 <button
265 type="button"
266 onClick={() => onOpenCommit(commit.id)}
267 title={commit.message}
268 className="repo-commit-bar-main ml-auto flex min-w-0 items-center gap-2.5 text-left"
269 >
270 <InitialAvatar name={commit.author_name || '?'} size={22} />
271 <span className="shrink-0 text-[12.5px] font-medium">
272 {commit.author_name || t('repo.commit.unknownAuthor')}
273 </span>
274 <span className="repo-commit-subject min-w-0 truncate text-[12.5px] text-shell-ink-muted">
275 {commit.message.split('\n')[0]}
276 </span>
277 <code className="shrink-0 rounded bg-shell-field px-1.5 py-0.5 font-mono text-[11px] text-shell-ink-muted">
278 {shortSha(commit.id)}
279 </code>
280 <span className="shrink-0 text-[12px] text-shell-ink-muted" title={fmtTime(commit.author_time)}>
281 {fmtRelative(commit.author_time)}
282 </span>
283 </button>
284 ) : (
285 <span className="min-w-0 flex-1" />
286 )}
287 <button
288 type="button"
289 onClick={() => onShowHistory(path)}
290 className="repo-commit-history flex shrink-0 items-center gap-1.5"
291 >
292 <History className="h-3.5 w-3.5" aria-hidden />
293 {t('repo.commit.history')}
294 </button>
295 </div>
296 )
297}
298
299function HtmlTree({
300 space,
301 repo,
302 rev,
303 path,
304 routes,
305}: {
306 space: string
307 repo: string
308 rev: string
309 path: string
310 routes: FragmentRoutes
311}) {
312 const { t } = useT()
313 const [retry, setRetry] = useState(0)
314 const frag = useHtmlFragment(htmlFragmentUrl(space, repo, 'tree', { revision: rev, path, _r: retry || undefined }))
315 const onClick = useFragmentNavigation(routes)
316 const html = useMemo(() => (frag && frag.ok ? prepareTreeHtml(frag.html) : null), [frag])
317
318 if (!frag) return <PanelNote text={t('repo.tree.loading')} />
319 if (!frag.ok) {
320 return <ErrorPanel status={frag.status} what={t('repo.tree.what')} onRetry={() => setRetry((r) => r + 1)} />
321 }
322 return (
323 <div
324 className="repo-embed repo-embed-scroll pt-3"
325 onClick={onClick}
326 dangerouslySetInnerHTML={{ __html: html ?? '' }}
327 />
328 )
329}
330
331function JsonTree({
332 space,
333 repo,
334 rev,
335 path,
336 onNavigate,
337 onOpenFile,
338}: {
339 space: string
340 repo: string
341 rev: string
342 path: string
343 onNavigate: (p: string) => void
344 onOpenFile: (f: { path: string; oid?: string }) => void
345}) {
346 const { t } = useT()
347 const [retry, setRetry] = useState(0)
348 const state = useAsync(() => fetchTreeJson(space, repo, rev, path), [space, repo, rev, path, retry], {
349 cacheKey: repoKey(space, repo, `tree:${rev}:${path}`),
350 })
351 if (state.loading && !state.data) return <PanelNote text={t('repo.tree.loading')} />
352 if (state.error) {
353 return (
354 <ErrorPanel
355 status={(state.error as { code?: number }).code ?? 0}
356 what={t('repo.tree.what')}
357 onRetry={() => setRetry((value) => value + 1)}
358 />
359 )
360 }
361 const rows = [...(state.data ?? [])].sort((a, b) => {
362 const aDir = a.mode === 'Tree' ? 0 : 1
363 const bDir = b.mode === 'Tree' ? 0 : 1
364 return aDir - bDir || a.name.localeCompare(b.name, 'en', { sensitivity: 'base' })
365 })
366 if (rows.length === 0) return <PanelNote text={t('repo.tree.empty')} />
367 return (
368 <div className="overflow-x-auto">
369 <table aria-label={t('repo.tree.tableLabel')} className="repo-file-table w-full border-collapse text-[13px]">
370 <thead>
371 <tr className="text-left font-mono text-[10px] uppercase tracking-[1.2px] text-shell-ink-muted">
372 <th className="px-2 py-1.5">{t('repo.tree.name')}</th>
373 <th className="px-2 py-1.5 text-right">{t('repo.tree.size')}</th>
374 </tr>
375 </thead>
376 <tbody>
377 {path ? (
378 <tr className="border-t border-shell-hairline">
379 <td colSpan={2} className="px-2 py-1.5">
380 <button
381 type="button"
382 className="flex items-center gap-1.5 font-semibold hover:underline"
383 onClick={() => onNavigate(parentPath(path))}
384 >
385 <ArrowUp className="h-4 w-4" aria-hidden /> {t('repo.tree.parent')}
386 </button>
387 </td>
388 </tr>
389 ) : null}
390 {rows.map((row) => {
391 const isDir = row.mode === 'Tree' || row.mode === 'Submodule'
392 return (
393 <tr key={row.path} className="border-t border-shell-hairline/60 hover:bg-shell-active/40">
394 <td className="px-2 py-1.5">
395 <button
396 type="button"
397 className="flex items-center gap-1.5 hover:underline"
398 onClick={() =>
399 isDir ? onNavigate(row.path) : onOpenFile({ path: row.path, oid: row.oid ?? undefined })
400 }
401 >
402 <span className="repo-tree-icon" aria-hidden>
403 <img src={treeIconUrl(row.name, isDir)} alt="" width={16} height={16} decoding="async" />
404 </span>
405 {row.name}
406 {row.mode === 'Symlink' ? <span className="text-[11px] text-shell-ink-muted">→</span> : null}
407 </button>
408 </td>
409 <td className="px-2 py-1.5 text-right font-mono text-[12px] text-shell-ink-muted">
410 {isDir ? '—' : formatBytes(row.size)}
411 </td>
412 </tr>
413 )
414 })}
415 </tbody>
416 </table>
417 </div>
418 )
419}
420
421/* --------------------------------------------------------------- readme -- */
422
423/** Sandbox → host protocol. The iframe is opaque-origin, so postMessage is the
424 * only channel; the host validates `event.source` against its own frame. */
425interface ReadmeMessage {
426 type?: string
427 kind?: string
428 height?: number
429 href?: string
430 top?: number
431}
432
433/** Appended after embed.css: the server markup leans on Tailwind utilities
434 * that do not exist inside the sandbox, so unsized media must be capped here
435 * or an inline `<svg>` icon renders at the 300×150 replaced-element default. */
436const SANDBOX_CSS = `
437*,*::before,*::after{box-sizing:border-box}
438html,body{margin:0;padding:0;overflow:hidden}
439body{padding:24px 28px;background:var(--shell-surface,#fff);color:var(--shell-ink,#1c1e22);font-family:var(--font-sans,BlinkMacSystemFont,'Segoe UI',system-ui,Roboto,'Noto Sans',Ubuntu,Cantarell,'Helvetica Neue',Arial,'PingFang SC','Source Han Sans SC','Microsoft YaHei UI','Microsoft YaHei','Noto Sans CJK SC',sans-serif);font-size:14px;line-height:1.85}
440.repo-embed .markdown-body{max-width:900px;margin:0 auto}
441.repo-embed .markdown-body a{color:var(--shell-ink);text-underline-offset:3px}
442@media(max-width:600px){body{padding:18px}}
443.repo-embed img,.repo-embed video,.repo-embed canvas,.repo-embed svg{max-width:100%}
444.repo-embed img,.repo-embed video{height:auto}
445.repo-embed svg:not([width]):not([height]){width:1em;height:1em;flex:none}
446.repo-embed img[data-missing]{display:inline-block;min-width:160px;min-height:44px;border:1px dashed var(--shell-hairline,#dde0e6);border-radius:6px;background:var(--shell-field,#f2f3f6)}
447.repo-embed [data-component='git-readme']{border:0;border-radius:0;background:transparent;overflow:visible}
448`
449
450/** Reports its own content height (so the page scrolls, not the frame) and
451 * forwards every click to the host, which owns routing. */
452const SANDBOX_SCRIPT = `
453(function () {
454 var last = 0
455 function contentHeight() {
456 // Only the body's own box: the host feeds the reported number back in as
457 // the frame height, so including any viewport-derived metric (documentElement
458 // scroll/offset height) would re-report the frame it just set and ratchet the
459 // frame up by the host's rounding padding on every observer tick.
460 var b = document.body
461 if (!b) return 0
462 return Math.ceil(Math.max(b.scrollHeight, b.offsetHeight, b.getBoundingClientRect().height))
463 }
464 function report() {
465 var h = contentHeight()
466 if (!h || h === last) return
467 last = h
468 parent.postMessage({ type: 'gitdata:readme', kind: 'height', height: h }, '*')
469 }
470 function send(payload) {
471 payload.type = 'gitdata:readme'
472 parent.postMessage(payload, '*')
473 }
474 document.addEventListener('click', function (event) {
475 var link = event.target && event.target.closest ? event.target.closest('a') : null
476 if (!link) return
477 var href = link.getAttribute('href') || ''
478 if (href.charAt(0) === '#') {
479 event.preventDefault()
480 var id = href.slice(1)
481 try { id = decodeURIComponent(id) } catch (err) {}
482 var target = null
483 try {
484 target = document.getElementById(id) || document.querySelector('[name="' + id + '"]')
485 } catch (err) {
486 target = document.getElementById(id)
487 }
488 if (target) send({ kind: 'anchor', top: target.getBoundingClientRect().top + (window.pageYOffset || 0) })
489 return
490 }
491 if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.indexOf('//') === 0) {
492 // External: let the browser do it (allow-popups-to-escape-sandbox). A
493 // host-side window.open() from a postMessage callback has no user
494 // activation and would be popup-blocked.
495 link.target = '_blank'
496 link.rel = 'noopener noreferrer'
497 return
498 }
499 event.preventDefault()
500 send({ kind: 'link', href: href })
501 })
502 // Media, fonts and late layout grow the document after the first paint, so a
503 // single measurement is never enough: observe both boxes, re-report on every
504 // embedded-resource load, and honour the host's explicit remeasure request.
505 function schedule() {
506 report()
507 setTimeout(report, 80)
508 setTimeout(report, 400)
509 setTimeout(report, 1500)
510 }
511 window.addEventListener('load', schedule)
512 window.addEventListener('resize', report)
513 if (window.ResizeObserver && document.body) {
514 var ro = new ResizeObserver(report)
515 // Body only: watching the root element makes the loop above fire whenever
516 // the host resizes the frame, which it does in response to this very report.
517 ro.observe(document.body)
518 }
519 document.addEventListener('load', function (event) {
520 var tag = event.target && event.target.tagName
521 if (tag === 'IMG' || tag === 'VIDEO' || tag === 'SOURCE' || tag === 'IFRAME') schedule()
522 }, true)
523 if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
524 document.fonts.ready.then(report).catch(function () {})
525 }
526 window.addEventListener('message', function (event) {
527 var data = event.data
528 if (data && data.type === 'gitdata:remeasure') {
529 last = 0
530 schedule()
531 }
532 })
533 report()
534 schedule()
535})()
536`
537
538/** The same Google-hosted webfont the app shell loads, so README code renders
539 * in JetBrains Mono; prose follows the shell's system font stack. */
540const README_FONTS_IMPORT =
541 '@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&display=swap");'
542
543function readmeDocument(bodyHtml: string): string {
544 return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>${README_FONTS_IMPORT}:root{${shellVarDeclarations()}}${embedCssText}${SANDBOX_CSS}</style></head><body class="repo-embed"><div class="markdown-body">${bodyHtml}</div><script>${SANDBOX_SCRIPT}</script></body></html>`
545}
546
547/**
548 * Renders already-prepared markdown markup in an opaque-origin sandbox. Shared
549 * by the directory README and an opened `.md` file so both previews use one
550 * document shell, one height protocol and one link router.
551 */
552function MarkdownFrame({
553 html,
554 baseDir,
555 routes,
556 title = 'README',
557 className = 'mt-5',
558}: {
559 html: string
560 /** Directory the markup lives in — resolves its repo-relative links. */
561 baseDir: string
562 routes: FragmentRoutes
563 title?: string
564 className?: string
565}) {
566 const doc = useMemo(() => readmeDocument(html), [html])
567 const frameRef = useRef<HTMLIFrameElement | null>(null)
568 // No fixed height: the frame grows with its content and the repo page does
569 // the scrolling, so the README never gets its own scrollbar.
570 const [height, setHeight] = useState(200)
571
572 useEffect(() => {
573 const onMessage = (event: MessageEvent) => {
574 const frame = frameRef.current
575 const data = event.data as ReadmeMessage | null
576 if (!frame || !data || typeof data !== 'object' || data.type !== 'gitdata:readme') return
577 if (event.source !== frame.contentWindow) return
578 if (data.kind === 'height' && Number.isFinite(data.height)) {
579 // +2 absorbs sub-pixel rounding and the 1px document/body border, so the
580 // frame can never grow its own scrollbar: the repo page is the scroller.
581 const next = Math.min(Math.max(Math.round(data.height as number) + 2, 80), 40000)
582 setHeight((current) => (current === next ? current : next))
583 } else if (data.kind === 'link' && typeof data.href === 'string') {
584 // Rewritten links carry the answer already (`file=` vs `path=`), so no
585 // tree lookup is needed to tell a file from a folder.
586 const appRoute = parseRepoAppHref(data.href, routes.space, routes.repo)
587 if (appRoute) {
588 if (appRoute.file) routes.onOpenFile({ path: appRoute.file })
589 else if (appRoute.path) routes.onNavigate(appRoute.path)
590 return
591 }
592 // A relative href the rewriter could not resolve (e.g. no blob oid).
593 const target = resolveRepoPath(baseDir, data.href)
594 if (target) {
595 void (async () => {
596 const entry = await statRepoPath(routes.space, routes.repo, routes.rev, target)
597 if (entry.kind === 'blob') routes.onOpenFile({ path: target, oid: entry.oid })
598 else if (entry.kind === 'tree') routes.onNavigate(target)
599 })()
600 }
601 } else if (data.kind === 'anchor' && Number.isFinite(data.top)) {
602 const scroller = frame.closest('[data-repo-scroll]')
603 if (scroller) {
604 const box = frame.getBoundingClientRect()
605 const host = scroller.getBoundingClientRect()
606 scroller.scrollTo({
607 top: box.top - host.top + scroller.scrollTop + (data.top as number) - 8,
608 behavior: 'smooth',
609 })
610 }
611 }
612 }
613 window.addEventListener('message', onMessage)
614 return () => window.removeEventListener('message', onMessage)
615 }, [baseDir, routes])
616
617 // Ask the frame to re-measure: layout can settle after the initial report
618 // (late media, host scrollbar changing the frame width, window resize).
619 useEffect(() => {
620 if (!doc) return
621 const ping = () => frameRef.current?.contentWindow?.postMessage({ type: 'gitdata:remeasure' }, '*')
622 const timers = [120, 600, 1600, 3200].map((ms) => window.setTimeout(ping, ms))
623 window.addEventListener('resize', ping)
624 return () => {
625 timers.forEach((id) => window.clearTimeout(id))
626 window.removeEventListener('resize', ping)
627 }
628 }, [doc])
629
630 if (!doc) return null
631 return (
632 <iframe
633 ref={frameRef}
634 title={title}
635 sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
636 className={`repo-readme w-full rounded-xl bg-shell-surface ${className}`}
637 style={{ height }}
638 srcDoc={doc}
639 />
640 )
641}
642
643/** Directory README: fetch the server-rendered fragment, then sandbox it. */
644function HtmlReadme({
645 space,
646 repo,
647 rev,
648 path,
649 routes,
650}: {
651 space: string
652 repo: string
653 rev: string
654 path: string
655 routes: FragmentRoutes
656}) {
657 const fragment = useHtmlFragment(htmlFragmentUrl(space, repo, 'readme', { revision: rev, path }))
658 // Keyed state (same trick as useHtmlFragment): a stale README from another
659 // directory must never flash while the new one is still being prepared.
660 const sourceKey = `${space}/${repo}@${rev}:${path}`
661 const [prepared, setPrepared] = useState<{ key: string; html: string } | null>(null)
662 useEffect(() => {
663 if (!fragment || !fragment.ok || !fragment.html) return
664 let active = true
665 prepareReadmeHtml({ html: fragment.html, space, repo, rev, baseDir: path }).then((html) => {
666 if (active) setPrepared({ key: sourceKey, html })
667 })
668 return () => {
669 active = false
670 }
671 }, [fragment, space, repo, rev, path, sourceKey])
672 const html = prepared && prepared.key === sourceKey ? prepared.html : null
673 if (!html) return null
674 return <MarkdownFrame html={html} baseDir={path} routes={routes} />
675}
676
677/**
678 * Opened markdown *file* (`?file=README.md`). A markdown blob is a document, not
679 * source code: the raw text is rendered through the same pipeline as the README
680 * preview so the file page matches what the directory listing shows. Images and
681 * relative links resolve against the file's own directory.
682 */
683function HtmlMarkdownFile({
684 space,
685 repo,
686 rev,
687 path,
688 oid,
689 routes,
690 className,
691}: {
692 space: string
693 repo: string
694 rev: string
695 path: string
696 oid?: string
697 routes: FragmentRoutes
698 className?: string
699}) {
700 const { t } = useT()
701 const [retry, setRetry] = useState(0)
702 const state = useAsync(
703 async () => {
704 // A deep link (`?file=README.md`) carries no oid, so resolve one from the
705 // tree exactly like the README's own image inlining does.
706 const entry = oid ? { kind: 'blob', oid } : await statRepoPath(space, repo, rev, path)
707 if (entry.kind !== 'blob') return null
708 return fetchBlob(space, repo, entry.oid)
709 },
710 [space, repo, rev, path, oid, retry],
711 // Same shape and key as JsonBlob: the blob is content-addressed, so the two
712 // renderers can share one cached payload.
713 { cacheKey: oid ? repoKey(space, repo, `blob:${oid}`) : null },
714 )
715 const sourceKey = `${space}/${repo}@${rev}:${path}`
716 const [prepared, setPrepared] = useState<{ key: string; html: string } | null>(null)
717 useEffect(() => {
718 const blob = state.data
719 if (!blob || blob.is_binary) return
720 let active = true
721 prepareMarkdownHtml({
722 text: decodeBlob(blob.data),
723 space,
724 repo,
725 rev,
726 baseDir: parentPath(path),
727 }).then((html) => {
728 if (active) setPrepared({ key: sourceKey, html })
729 })
730 return () => {
731 active = false
732 }
733 }, [state.data, space, repo, rev, path, sourceKey])
734 if (state.error) {
735 return (
736 <ErrorPanel
737 status={(state.error as { code?: number }).code ?? 0}
738 what={t('repo.file.what')}
739 onRetry={() => setRetry((value) => value + 1)}
740 />
741 )
742 }
743 const html = prepared && prepared.key === sourceKey ? prepared.html : null
744 if (!html) {
745 // `undefined` is still loading; a resolved `null` means the path has no
746 // previewable blob (missing, or binary) — never leave the spinner running.
747 return state.data === undefined ? (
748 <PanelNote text={t('repo.file.loading')} />
749 ) : (
750 <PanelNote text={t('repo.file.unavailable')} />
751 )
752 }
753 return <MarkdownFrame html={html} baseDir={parentPath(path)} routes={routes} title={path} className={className} />
754}
755
756const SHELL_VARS = [
757 '--shell-surface',
758 '--shell-field',
759 '--shell-active',
760 '--shell-tile',
761 '--shell-ink',
762 '--shell-ink-muted',
763 '--shell-hairline',
764 '--font-sans',
765 '--font-mono',
766]
767
768function shellVarDeclarations(): string {
769 const computed = getComputedStyle(document.documentElement)
770 return SHELL_VARS.map((name) => `${name}:${computed.getPropertyValue(name).trim()}`).join(';')
771}
772
773function JsonReadme({ space, repo, rev, path }: { space: string; repo: string; rev: string; path: string }) {
774 const readme = useAsync(
775 async () => {
776 const rows = await fetchTreeJson(space, repo, rev, path)
777 const entry = rows.find((row) => README_RE.test(row.name) && (row.mode === 'Blob' || row.mode === 'Exec'))
778 if (!entry?.oid) return null
779 const blob = await fetchBlob(space, repo, entry.oid)
780 if (blob.is_binary) return null
781 return { name: entry.name, text: decodeBlob(blob.data) }
782 },
783 [space, repo, rev, path],
784 { cacheKey: repoKey(space, repo, `readme:${rev}:${path}`) },
785 )
786 const result = readme.data
787 if (!result || readme.loading) return null
788 return (
789 <div className="repo-readme mt-5 overflow-hidden rounded-xl bg-shell-surface">
790 <div className="border-b-2 border-shell-hairline px-4 py-2 text-[12.5px] font-semibold">{result.name}</div>
791 <pre className="max-h-[520px] overflow-auto p-4 font-mono text-[12px] leading-5">{result.text}</pre>
792 </div>
793 )
794}
795
796/* ---------------------------------------------------------------- file --- */
797
798function FileView({
799 refs,
800 clone,
801 space,
802 repo,
803 rev,
804 mode,
805 file,
806 line,
807 routes,
808 onBack,
809}: {
810 space: string
811 repo: string
812 rev: string
813 mode: 'html' | 'json'
814 file: FileRef
815 line?: number
816 refs?: ReactNode
817 clone?: ReactNode
818 routes: FragmentRoutes
819 onBack: () => void
820}) {
821 const { t } = useT()
822 const [blame, setBlame] = useState(false)
823 // Markdown blobs open as a document, matching how the directory listing
824 // renders its README; the toggle still reaches the raw source when needed.
825 const [source, setSource] = useState(false)
826 const isMarkdown = isMarkdownPath(file.path)
827 const isPdf = isPdfPath(file.path)
828 const dir = file.path.includes('/') ? file.path.slice(0, file.path.lastIndexOf('/')) : ''
829 const leaf = file.path.includes('/') ? file.path.slice(file.path.lastIndexOf('/') + 1) : file.path
830 return (
831 <div className="repo-code-view">
832 <CodeHeader path={dir} leaf={leaf} onNavigate={routes.onNavigate} />
833 {refs ? (
834 <div className="repo-refs-row">
835 {refs}
836 {clone ? <div className="repo-refs-clone">{clone}</div> : null}
837 </div>
838 ) : null}
839 <div className="repo-file-toolbar flex flex-wrap items-center gap-2">
840 <button
841 type="button"
842 onClick={onBack}
843 className="flex items-center gap-1 rounded-md px-2 py-1 text-[13px] transition-colors hover:bg-shell-active"
844 >
845 <ArrowLeft className="h-4 w-4" aria-hidden /> {t('repo.file.backToList')}
846 </button>
847 <span className="min-w-0 break-all font-mono text-[13px] font-semibold">{file.path}</span>
848 {isMarkdown && !isPdf ? (
849 <div
850 role="group"
851 aria-label={t('repo.file.markdownView')}
852 className="flex shrink-0 items-center rounded-md bg-shell-field p-0.5"
853 >
854 <button
855 type="button"
856 aria-pressed={!source && !blame}
857 onClick={() => {
858 setSource(false)
859 setBlame(false)
860 }}
861 className={`flex items-center gap-1.5 rounded px-2.5 py-1 text-[12.5px] transition-colors ${
862 !source && !blame
863 ? 'bg-shell-active font-semibold text-shell-ink'
864 : 'text-shell-ink-muted hover:text-shell-ink'
865 }`}
866 >
867 <Eye className="h-3.5 w-3.5" aria-hidden /> {t('repo.file.preview')}
868 </button>
869 <button
870 type="button"
871 aria-pressed={source}
872 onClick={() => {
873 setSource(true)
874 setBlame(false)
875 }}
876 className={`flex items-center gap-1.5 rounded px-2.5 py-1 text-[12.5px] transition-colors ${
877 source ? 'bg-shell-active font-semibold text-shell-ink' : 'text-shell-ink-muted hover:text-shell-ink'
878 }`}
879 >
880 <FileCode className="h-3.5 w-3.5" aria-hidden /> {t('repo.file.source')}
881 </button>
882 </div>
883 ) : null}
884 <button
885 type="button"
886 aria-pressed={blame}
887 disabled={!file.oid}
888 title={file.oid ? t('repo.file.blameHint') : t('repo.file.blameJsonHint')}
889 onClick={() => {
890 setBlame((value) => !value)
891 setSource(true)
892 }}
893 className="flex items-center gap-1.5 rounded-md bg-shell-field px-2.5 py-1 text-[12.5px] transition-colors hover:brightness-95 aria-pressed:bg-shell-active aria-pressed:font-semibold disabled:opacity-40"
894 >
895 <Users className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden /> blame
896 </button>
897 <button
898 type="button"
899 onClick={() => routes.onShowHistory(file.path)}
900 className="ml-auto flex items-center gap-1.5 rounded-md bg-shell-field px-2.5 py-1 text-[12.5px] transition-colors hover:brightness-95"
901 >
902 <History className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden /> {t('repo.commit.history')}
903 </button>
904 </div>
905 {blame && file.oid ? (
906 <BlameView
907 space={space}
908 repo={repo}
909 rev={rev}
910 path={file.path}
911 oid={file.oid}
912 onOpenCommit={routes.onOpenCommit}
913 />
914 ) : isPdf ? (
915 <PdfViewer space={space} repo={repo} rev={rev} path={file.path} />
916 ) : isMarkdown && !source ? (
917 <HtmlMarkdownFile
918 space={space}
919 repo={repo}
920 rev={rev}
921 path={file.path}
922 oid={file.oid}
923 routes={routes}
924 className="mt-3"
925 />
926 ) : mode === 'html' ? (
927 <HtmlBlob space={space} repo={repo} rev={rev} path={file.path} line={line} routes={routes} />
928 ) : (
929 <JsonBlob space={space} repo={repo} oid={file.oid} path={file.path} line={line} />
930 )}
931 </div>
932 )
933}
934
935function HtmlBlob({
936 space,
937 repo,
938 rev,
939 path,
940 line,
941 routes,
942}: {
943 space: string
944 repo: string
945 rev: string
946 path: string
947 line?: number
948 routes: FragmentRoutes
949}) {
950 const { t } = useT()
951 const [retry, setRetry] = useState(0)
952 const frag = useHtmlFragment(
953 htmlFragmentUrl(space, repo, 'blob-content', {
954 revision: rev,
955 path,
956 _r: retry || undefined,
957 }),
958 )
959 const onClick = useFragmentNavigation(routes)
960 const ref = useRef<HTMLDivElement | null>(null)
961 useEffect(() => {
962 if (!line || !ref.current) return
963 const target = ref.current.querySelector<HTMLElement>(`#L${line}`)
964 if (!target) return
965 target.classList.add('bg-[#f0b232]/20')
966 target.scrollIntoView({ block: 'center' })
967 return () => target.classList.remove('bg-[#f0b232]/20')
968 }, [line, frag?.html])
969 if (!frag) return <PanelNote text={t('repo.file.loading')} />
970 if (!frag.ok)
971 return <ErrorPanel status={frag.status} what={t('repo.file.what')} onRetry={() => setRetry((r) => r + 1)} />
972 return (
973 <div
974 ref={ref}
975 className="repo-embed repo-embed-scroll"
976 onClick={onClick}
977 dangerouslySetInnerHTML={{ __html: frag.html }}
978 />
979 )
980}
981
982function JsonBlob({
983 space,
984 repo,
985 oid,
986 line,
987}: {
988 space: string
989 repo: string
990 oid?: string
991 path: string
992 line?: number
993}) {
994 const { t } = useT()
995 const [retry, setRetry] = useState(0)
996 const state = useAsync(
997 () => (oid ? fetchBlob(space, repo, oid) : Promise.reject(new Error(t('repo.file.missingOid')))),
998 [space, repo, oid, retry],
999 // A blob is content-addressed: the same oid can never return new data.
1000 { cacheKey: oid ? repoKey(space, repo, `blob:${oid}`) : null },
1001 )
1002 if (state.error)
1003 return (
1004 <ErrorPanel
1005 status={0}
1006 what={t('repo.file.what')}
1007 onRetry={() => setRetry((value) => value + 1)}
1008 note={String((state.error as Error).message)}
1009 />
1010 )
1011 if (!state.data) return <PanelNote text={t('repo.file.loading')} />
1012 const blob = state.data
1013 if (blob.is_binary) {
1014 return <PanelNote text={t('repo.file.binaryDetail', { size: formatBytes(blob.size) })} />
1015 }
1016 const text = decodeBlob(blob.data)
1017 return (
1018 <div>
1019 {blob.truncated ? (
1020 <div className="mb-2 rounded-md border-2 border-[#f0b232]/50 bg-[#f0b232]/10 px-3 py-2 text-[12.5px] text-[#8a6100]">
1021 {t('repo.file.truncated')}
1022 </div>
1023 ) : null}
1024 <ol className="overflow-x-auto rounded-lg border-2 border-shell-hairline bg-shell-field py-3 font-mono text-[12px] leading-5">
1025 {text.split('\n').map((value, index) => {
1026 const number = index + 1
1027 return (
1028 <li
1029 id={`L${number}`}
1030 key={number}
1031 className={`grid grid-cols-[3.5rem_minmax(0,1fr)] px-3 ${number === line ? 'bg-[#f0b232]/20' : ''}`}
1032 >
1033 <a href={`#L${number}`} className="select-none pr-3 text-right text-shell-ink-muted hover:underline">
1034 {number}
1035 </a>
1036 <code className="whitespace-pre">{value || ' '}</code>
1037 </li>
1038 )
1039 })}
1040 </ol>
1041 </div>
1042 )
1043}
1044
1045/* --------------------------------------------------------------- shared -- */
1046
1047function decodeBlob(data: number[] | undefined): string {
1048 return new TextDecoder('utf-8', { fatal: false }).decode(new Uint8Array(data ?? []))
1049}
1050
1051function EmptyRepo({ repoPath, cloneUrl }: { repoPath: string; cloneUrl: string | null }) {
1052 const { t } = useT()
1053 return (
1054 <div className="repo-empty rounded-2xl border border-shell-hairline bg-shell-surface px-6 py-12 text-center">
1055 <span className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-shell-field text-shell-ink-muted">
1056 <FolderGit2 className="h-7 w-7" aria-hidden />
1057 </span>
1058 <p className="mb-2 text-[20px] font-semibold">{t('repo.empty.startTitle')}</p>
1059 <p className="mx-auto mb-6 max-w-sm text-[13px] leading-6 text-shell-ink-muted">{t('repo.empty.startNote')}</p>
1060 {cloneUrl ? (
1061 <pre className="mx-auto max-w-[560px] overflow-x-auto rounded-xl border border-shell-hairline bg-shell-field p-5 text-left font-mono text-[12px] leading-7">
1062 {`git clone ${cloneUrl}\ncd ${repoPath.split('/').pop() ?? repoPath}\n${t('repo.empty.addFiles')}\ngit add . && git commit -m "initial commit" && git push`}
1063 </pre>
1064 ) : (
1065 <p className="text-[12.5px] text-shell-ink-muted">{t('repo.empty.cloneNotConfigured')}</p>
1066 )}
1067 </div>
1068 )
1069}
Added view/app/src/domains/repo/browse/ui/pdf-viewer.tsx View view/app/src/domains/repo/browse/ui/pdf-viewer.tsx
@@ -0,0 +1,291 @@
1import { htmlFragmentUrl } from '@app/domains/repo/model'
2import { useT } from '@app/i18n'
3import { ChevronLeft, ChevronRight, Minus, Plus } from 'lucide-react'
4import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
5import { fitScale, type PdfPage, readPdfStream } from '../model/pdf-stream'
6
7/** One streaming request's worth of state; keyed so late frames are ignored. */
8interface PdfRun {
9 key: string
10 pages: Map<number, PdfPage>
11 failed: number[]
12 total: number
13 truncated: boolean
14 status: 'loading' | 'streaming' | 'done' | 'error'
15 httpError: number
16}
17
18const NO_PAGES: Map<number, PdfPage> = new Map()
19const NO_FAILURES: number[] = []
20
21/**
22 * Opened PDF (`?file=paper.pdf`).
23 *
24 * A `blob:`/`data:` frame never reaches the browser's PDF plugin and the plugin
25 * does not render inside a same-origin `<iframe>` either, so the pages are
26 * rasterised by the server and streamed in one at a time. Frames land in the
27 * list as they arrive, which is what makes a 200-page document readable after
28 * the first page instead of after the last.
29 */
30export function PdfViewer({
31 space,
32 repo,
33 rev,
34 path,
35 className,
36}: {
37 space: string
38 repo: string
39 rev: string
40 path: string
41 className?: string
42}) {
43 const { t } = useT()
44 const hostRef = useRef<HTMLDivElement | null>(null)
45 const scrollRef = useRef<HTMLDivElement | null>(null)
46 const [run, setRun] = useState<PdfRun | null>(null)
47 const [current, setCurrent] = useState(1)
48 const [zoom, setZoom] = useState(1)
49 const [width, setWidth] = useState(0)
50 const [retry, setRetry] = useState(0)
51 // Re-rasterising (zoom, resize) rebuilds the page list, so the page the
52 // reader was on has to survive the swap.
53 const pendingPage = useRef<number | null>(null)
54
55 // Pages of a superseded run are never rendered: their object URLs have been
56 // revoked, so keeping them on screen would flash broken images.
57 const runKey = width ? `${space}/${repo}@${rev}:${path}:${zoom}:${retry}` : ''
58 const active = run && run.key === runKey ? run : null
59 const pages = active?.pages ?? NO_PAGES
60 const failed = active?.failed ?? NO_FAILURES
61 const total = active?.total ?? 0
62 const truncated = active?.truncated ?? false
63 const status = active?.status ?? 'loading'
64 const httpError = active?.httpError ?? 0
65
66 // The rasterisation scale follows the viewport the reader is laid out in: a
67 // page rendered wide enough for the column stays sharp at the device pixel
68 // ratio without streaming pixels the layout would only downscale again.
69 //
70 // This measures the wrapper, not the scrollport: the scrollport gains and
71 // loses a scrollbar as pages stream in, and reacting to that would restart
72 // the whole stream for a 14px difference. The threshold absorbs the same
73 // jitter from nested scrollbars and sub-pixel rounding.
74 useEffect(() => {
75 const host = hostRef.current
76 if (!host) return
77 let applied = 0
78 let timer: ReturnType<typeof setTimeout> | undefined
79 const measure = () => {
80 clearTimeout(timer)
81 timer = setTimeout(() => {
82 const next = host.clientWidth
83 if (next <= 0 || Math.abs(next - applied) < 24) return
84 applied = next
85 setWidth(next)
86 }, 250)
87 }
88 measure()
89 const observer = new ResizeObserver(measure)
90 observer.observe(host)
91 return () => {
92 clearTimeout(timer)
93 observer.disconnect()
94 }
95 }, [])
96
97 // Page images are object URLs owned by this component: every run revokes its
98 // own URLs, so switching file, zoom level or retrying cannot leak them.
99 useEffect(() => {
100 if (!width) return
101 const url = htmlFragmentUrl(space, repo, 'blob-pages', {
102 revision: rev,
103 path,
104 scale: fitScale(width) * zoom,
105 })
106 const key = runKey
107 const controller = new AbortController()
108 const created: string[] = []
109 const fresh: PdfRun = {
110 key,
111 pages: new Map(),
112 failed: [],
113 total: 0,
114 truncated: false,
115 status: 'loading',
116 httpError: 0,
117 }
118 setRun(fresh)
119 const patch = (next: Partial<PdfRun>) => setRun((prev) => (prev && prev.key === key ? { ...prev, ...next } : prev))
120 readPdfStream(
121 url,
122 {
123 onDocument: (info) => patch({ total: info.pages, truncated: info.truncated, status: 'streaming' }),
124 onPage: (page) => {
125 if (controller.signal.aborted) {
126 URL.revokeObjectURL(page.url)
127 return
128 }
129 created.push(page.url)
130 setRun((prev) => {
131 if (!prev || prev.key !== key) return prev
132 return { ...prev, pages: new Map(prev.pages).set(page.page, page) }
133 })
134 },
135 onPageError: (info) =>
136 setRun((prev) => (prev && prev.key === key ? { ...prev, failed: [...prev.failed, info.page] } : prev)),
137 },
138 controller.signal,
139 ).then(
140 () => patch({ status: 'done' }),
141 (err: unknown) => {
142 if (controller.signal.aborted) return
143 patch({ httpError: (err as { status?: number }).status ?? 0, status: 'error' })
144 },
145 )
146 return () => {
147 controller.abort()
148 for (const url of created) URL.revokeObjectURL(url)
149 }
150 // runKey folds in every input the request depends on.
151 // eslint-disable-next-line react-hooks/exhaustive-deps
152 }, [runKey, width])
153
154 const order = useMemo(() => [...pages.values()].sort((a, b) => a.page - b.page), [pages])
155 const lastPage = order.length ? order[order.length - 1].page : 0
156
157 // A re-rasterisation restarts the list from page one; put the reader back on
158 // the page they were reading as soon as that page has arrived again.
159 useEffect(() => {
160 const wanted = pendingPage.current
161 if (wanted === null) return
162 const host = scrollRef.current
163 const target = host?.querySelector<HTMLElement>(`[data-pdf-page="${wanted}"]`)
164 if (!host || !target) return
165 host.scrollTop = target.offsetTop - 12
166 pendingPage.current = null
167 }, [order])
168
169 const zoomBy = useCallback(
170 (delta: number) => {
171 pendingPage.current = current
172 setZoom((value) => Math.min(2.4, Math.max(0.6, Math.round((value + delta) * 10) / 10)))
173 },
174 [current],
175 )
176
177 // Follow the reading position rather than the last streamed page, so the
178 // counter cannot run ahead of the reader while frames are still arriving.
179 const onScroll = useCallback(() => {
180 const host = scrollRef.current
181 if (!host) return
182 const top = host.scrollTop
183 let nearest = order.length ? order[0].page : 1
184 for (const item of host.querySelectorAll<HTMLElement>('[data-pdf-page]')) {
185 if (item.offsetTop - 32 <= top) nearest = Number(item.dataset.pdfPage)
186 else break
187 }
188 setCurrent(nearest)
189 }, [order])
190
191 const goTo = useCallback((page: number) => {
192 const host = scrollRef.current
193 const target = host?.querySelector<HTMLElement>(`[data-pdf-page="${page}"]`)
194 if (!host || !target) return
195 host.scrollTo({ top: target.offsetTop - 12, behavior: 'smooth' })
196 setCurrent(page)
197 }, [])
198
199 if (status === 'error' && !order.length) {
200 return (
201 <div className={`rounded-xl border border-shell-hairline bg-shell-surface ${className ?? 'mt-3'}`}>
202 <p className="px-4 py-10 text-center text-[13px] text-shell-ink-muted">
203 {httpError === 400 ? t('repo.file.pdfUnreadable') : t('repo.file.pdfFailed')}
204 </p>
205 <div className="flex justify-center pb-6">
206 <button
207 type="button"
208 onClick={() => setRetry((value) => value + 1)}
209 className="rounded-md border-2 border-shell-hairline bg-shell-field px-4 py-1.5 text-[13px] transition-colors hover:border-shell-ink"
210 >
211 {t('common.action.retry')}
212 </button>
213 </div>
214 </div>
215 )
216 }
217
218 return (
219 <div ref={hostRef} className={className ?? 'mt-3'}>
220 <div className="repo-pdf-toolbar">
221 <button
222 type="button"
223 aria-label={t('repo.file.pdfPrev')}
224 disabled={current <= 1}
225 onClick={() => goTo(current - 1)}
226 className="repo-pdf-step"
227 >
228 <ChevronLeft className="h-4 w-4" aria-hidden />
229 </button>
230 <span className="repo-pdf-count">
231 {t('repo.file.pdfCounter', { page: current, total: total || lastPage || 1 })}
232 </span>
233 <button
234 type="button"
235 aria-label={t('repo.file.pdfNext')}
236 disabled={current >= lastPage}
237 onClick={() => goTo(current + 1)}
238 className="repo-pdf-step"
239 >
240 <ChevronRight className="h-4 w-4" aria-hidden />
241 </button>
242 <span className="repo-pdf-zoom">
243 <button
244 type="button"
245 aria-label={t('repo.file.pdfZoomOut')}
246 disabled={zoom <= 0.6}
247 onClick={() => zoomBy(-0.2)}
248 className="repo-pdf-step"
249 >
250 <Minus className="h-4 w-4" aria-hidden />
251 </button>
252 <span className="repo-pdf-count">{Math.round(zoom * 100)}%</span>
253 <button
254 type="button"
255 aria-label={t('repo.file.pdfZoomIn')}
256 disabled={zoom >= 2.4}
257 onClick={() => zoomBy(0.2)}
258 className="repo-pdf-step"
259 >
260 <Plus className="h-4 w-4" aria-hidden />
261 </button>
262 </span>
263 </div>
264 <div ref={scrollRef} onScroll={onScroll} className="repo-pdf-scroll" tabIndex={0} role="region" aria-label={path}>
265 {order.map((page) => (
266 <figure key={page.page} data-pdf-page={page.page} className="repo-pdf-page">
267 <img
268 src={page.url}
269 width={page.width || undefined}
270 height={page.height || undefined}
271 alt={t('repo.file.pdfPageAlt', { page: page.page })}
272 decoding="async"
273 />
274 </figure>
275 ))}
276 {failed.map((page) => (
277 <p key={`f${page}`} className="repo-pdf-note">
278 {t('repo.file.pdfPageFailed', { page })}
279 </p>
280 ))}
281 {status === 'loading' && !order.length ? <p className="repo-pdf-note">{t('repo.file.pdfLoading')}</p> : null}
282 {status === 'streaming' && order.length ? (
283 <p className="repo-pdf-note">
284 {t('repo.file.pdfStreaming', { done: order.length, total: total || order.length })}
285 </p>
286 ) : null}
287 {truncated ? <p className="repo-pdf-note">{t('repo.file.pdfTruncated')}</p> : null}
288 </div>
289 </div>
290 )
291}
Added view/app/src/domains/repo/browse/index.ts View view/app/src/domains/repo/browse/index.ts
@@ -0,0 +1,5 @@
1// Public API of features/repo-browse — tree/blob browsing for the repo area.
2// Consumers import from '@app/domains/repo/browse', never from deeper files.
3
4export { prefetchRepoView } from './model/prefetch'
5export { CodeTab, type CodeTabProps } from './ui/code-tab'
Added view/app/src/domains/repo/history/ui/commit-insights.tsx View view/app/src/domains/repo/history/ui/commit-insights.tsx
@@ -0,0 +1,156 @@
1import { modalTextareaClass } from '@app/components'
2import {
3 type CodeComment,
4 createCodeComment,
5 deleteCodeComment,
6 resolveCodeComment,
7 unresolveCodeComment,
8} from '@app/domains/repo/model'
9import { useT } from '@app/i18n'
10import { Button } from '@app/ui'
11import { Trash2 } from 'lucide-react'
12import { useState } from 'react'
13
14/** 行内评论卡片:钉在差异行下方,带作者、过期标记与解决/删除操作。 */
15export function CodeLineCommentCard({
16 space,
17 repo,
18 comment,
19 onChanged,
20}: {
21 space: string
22 repo: string
23 comment: CodeComment
24 onChanged: () => void
25}) {
26 const { t } = useT()
27 const [working, setWorking] = useState(false)
28 const [error, setError] = useState<string | null>(null)
29
30 async function run(fn: () => Promise<unknown>, failure: string) {
31 setWorking(true)
32 setError(null)
33 try {
34 await fn()
35 onChanged()
36 } catch (f) {
37 setError(f instanceof Error ? f.message : failure)
38 } finally {
39 setWorking(false)
40 }
41 }
42
43 return (
44 <div className="border-t border-shell-hairline bg-shell-field/60 px-3 py-2 font-sans">
45 <div className="mb-1 flex flex-wrap items-center gap-2 font-sans text-[11.5px]">
46 <span className="font-medium text-shell-ink">
47 {comment.author?.display_name || comment.author?.username || t('repo.comment.unknownUser')}
48 </span>
49 <span className="font-mono text-shell-ink-muted">
50 {comment.path}:{comment.line}
51 </span>
52 {comment.resolved ? <span className="text-[#1a7f37]">{t('repo.comment.resolved')}</span> : null}
53 {comment.outdated ? <span className="text-[#8a6100]">{t('repo.comment.outdated')}</span> : null}
54 <button
55 type="button"
56 disabled={working}
57 onClick={() =>
58 void run(
59 () =>
60 comment.resolved
61 ? unresolveCodeComment(space, repo, comment.id)
62 : resolveCodeComment(space, repo, comment.id),
63 t('repo.comment.updateFailed'),
64 )
65 }
66 className="ml-auto text-shell-ink-muted hover:text-shell-ink"
67 >
68 {comment.resolved ? t('repo.comment.unresolve') : t('repo.comment.resolve')}
69 </button>
70 <button
71 type="button"
72 disabled={working}
73 onClick={() => void run(() => deleteCodeComment(space, repo, comment.id), t('repo.comment.deleteFailed'))}
74 className="text-shell-ink-muted hover:text-[#b42318]"
75 aria-label={t('repo.comment.delete')}
76 >
77 <Trash2 className="h-3.5 w-3.5" aria-hidden />
78 </button>
79 </div>
80 <p className="whitespace-pre-wrap break-words font-sans text-[12.5px] leading-5 text-shell-ink">{comment.body}</p>
81 {error ? (
82 <p className="mt-1 text-[11.5px] text-[#b42318]" role="alert">
83 {error}
84 </p>
85 ) : null}
86 </div>
87 )
88}
89
90/** 行内评论输入框:由差异视图按行内嵌渲染。 */
91export function LineCommentComposer({
92 space,
93 repo,
94 path,
95 line,
96 commitSha,
97 onDone,
98 onCancel,
99}: {
100 space: string
101 repo: string
102 path: string
103 line: number
104 commitSha: string
105 onDone: () => void
106 onCancel: () => void
107}) {
108 const { t } = useT()
109 const [body, setBody] = useState('')
110 const [busy, setBusy] = useState(false)
111 const [error, setError] = useState<string | null>(null)
112
113 async function submit() {
114 if (!body.trim()) return
115 setBusy(true)
116 setError(null)
117 try {
118 await createCodeComment(space, repo, {
119 path,
120 line,
121 commit_sha: commitSha,
122 body: body.trim(),
123 })
124 onDone()
125 } catch (f) {
126 setError(f instanceof Error ? f.message : t('repo.comment.createFailed'))
127 } finally {
128 setBusy(false)
129 }
130 }
131
132 return (
133 <div className="space-y-1.5 border-t border-shell-hairline bg-shell-field px-3 py-2">
134 <p className="font-mono text-[11.5px] text-shell-ink-muted">
135 {path}:{line}
136 </p>
137 <textarea
138 autoFocus
139 value={body}
140 onChange={(event) => setBody(event.target.value)}
141 rows={2}
142 placeholder={t('repo.comment.placeholder')}
143 className={modalTextareaClass}
144 />
145 {error ? <p className="text-[12px] text-[#b42318]">{error}</p> : null}
146 <div className="flex items-center gap-2">
147 <Button className="h-7 text-[12px]" disabled={busy} onClick={() => void submit()}>
148 {t('repo.comment.submit')}
149 </Button>
150 <Button variant="ghost" className="h-7 text-[12px]" onClick={onCancel}>
151 {t('common.action.cancel')}
152 </Button>
153 </div>
154 </div>
155 )
156}
Added view/app/src/domains/repo/history/ui/commit-view.tsx View view/app/src/domains/repo/history/ui/commit-view.tsx
@@ -0,0 +1,528 @@
1import { CopyChip, ErrorPanel, PanelNote } from '@app/components'
2import {
3 applyNumstat,
4 type CodeComment,
5 type DiffFile,
6 type DiffLine,
7 type DiffStyle,
8 EMPTY_TREE,
9 fetchCommit,
10 fetchDiffStyle,
11 fetchNumstat,
12 fetchRawDiff,
13 listCodeComments,
14 parseHunks,
15 splitDiffFiles,
16 toSplitRows,
17} from '@app/domains/repo/model'
18import { t, useT } from '@app/i18n'
19import { fmtRelative, fmtTime, repoKey, settingsKey, shortSha, useAsync } from '@app/lib'
20import { Button } from '@app/ui'
21import { ArrowLeft, ChevronDown, ChevronRight } from 'lucide-react'
22import { Fragment, useMemo, useState } from 'react'
23import { CodeLineCommentCard, LineCommentComposer } from './commit-insights'
24
25export interface CommitViewProps {
26 space: string
27 repo: string
28 sha: string
29 onBack: () => void
30 /** Open another commit's detail (parent chips). */
31 onOpenCommit: (sha: string) => void
32 /** Browse the tree as of this commit. */
33 onBrowseRevision: (revision: string) => void
34 /** Diff layout override, owned by the route (`''` = follow appearance pref). */
35 diffStyle: DiffStyle | ''
36 onDiffStyleChange: (style: DiffStyle | '') => void
37}
38
39/** Above either of these, file diffs start collapsed. */
40const COLLAPSE_ABOVE_FILES = 8
41const COLLAPSE_ABOVE_BYTES = 200_000
42
43const STATUS_LABEL: Record<DiffFile['status'], string> = {
44 get added() {
45 return t('repo.commit.status.added')
46 },
47 get deleted() {
48 return t('repo.commit.status.deleted')
49 },
50 get renamed() {
51 return t('repo.commit.status.renamed')
52 },
53 get copied() {
54 return t('repo.commit.status.copied')
55 },
56 get modified() {
57 return t('repo.commit.status.modified')
58 },
59 get binary() {
60 return t('repo.commit.status.binary')
61 },
62}
63
64const STATUS_TONE: Record<DiffFile['status'], string> = {
65 added: 'text-[#1a7f37]',
66 deleted: 'text-[#b42318]',
67 renamed: 'text-shell-ink-muted',
68 copied: 'text-shell-ink-muted',
69 modified: 'text-shell-ink-muted',
70 binary: 'text-[#8a6100]',
71}
72
73/** One commit on its own: metadata plus the patch against its first parent
74 * (root commits diff against the empty tree, same as the server does). */
75export function CommitView({
76 space,
77 repo,
78 sha,
79 onBack,
80 onOpenCommit,
81 onBrowseRevision,
82 diffStyle,
83 onDiffStyleChange,
84}: CommitViewProps) {
85 const { t, tc } = useT()
86 const [retry, setRetry] = useState(0)
87 const [commentsVersion, setCommentsVersion] = useState(0)
88 const [expanded, setExpanded] = useState<Record<string, boolean>>({})
89 // The toolbar toggle writes the route, so a shared link carries the layout;
90 // no override in the URL means "follow the saved appearance preference".
91 const savedStyle = useAsync(() => fetchDiffStyle().catch(() => 'unified' as DiffStyle), [], {
92 cacheKey: settingsKey('diff-style'),
93 })
94 const style: DiffStyle = diffStyle || savedStyle.data || 'unified'
95 const state = useAsync(
96 async () => {
97 const commit = await fetchCommit(space, repo, sha)
98 if (!commit) return null
99 const base = commit.parents[0] ?? EMPTY_TREE
100 const [patch, numstat] = await Promise.all([
101 fetchRawDiff(space, repo, base, commit.id),
102 // numstat is a nicety (authoritative +/- including renames); the patch
103 // already carries counts, so a failure here must not sink the page.
104 fetchNumstat(space, repo, base, commit.id).catch(() => []),
105 ])
106 return { commit, base, files: applyNumstat(splitDiffFiles(patch), numstat), bytes: patch.length }
107 },
108 [space, repo, sha, retry],
109 {
110 // A commit is immutable: the same sha can never render different content.
111 cacheKey: repoKey(space, repo, `commit:${sha}`),
112 },
113 )
114
115 // 行级评论锚定该提交,按「文件 + 显示行号」归组后行内渲染。
116 const comments = useAsync(
117 () => listCodeComments(space, repo, { revision: sha, limit: 200 }),
118 [space, repo, sha, commentsVersion],
119 )
120 const commentsByLine = useMemo(() => {
121 const map = new Map<string, CodeComment[]>()
122 for (const row of comments.data?.items ?? []) {
123 const key = `${row.path}#${row.line}`
124 const list = map.get(key) ?? []
125 list.push(row)
126 map.set(key, list)
127 }
128 return map
129 }, [comments.data])
130
131 const data = state.data
132 const files = data?.files ?? []
133 const additions = files.reduce((sum, file) => sum + file.additions, 0)
134 const deletions = files.reduce((sum, file) => sum + file.deletions, 0)
135 const autoExpand = Boolean(data) && files.length <= COLLAPSE_ABOVE_FILES && (data?.bytes ?? 0) <= COLLAPSE_ABOVE_BYTES
136 const failure = state.error as { code?: number; message?: string } | undefined
137
138 return (
139 <div className="repo-commit-detail pb-6">
140 <div className="mb-3 flex flex-wrap items-center gap-2">
141 <Button variant="secondary" className="h-8 gap-1 text-[12.5px]" onClick={onBack}>
142 <ArrowLeft className="h-3.5 w-3.5" aria-hidden /> {t('repo.commit.backToHistory')}
143 </Button>
144 {data ? (
145 <Button
146 variant="secondary"
147 className="ml-auto h-8 text-[12.5px]"
148 onClick={() => onBrowseRevision(data.commit.id)}
149 >
150 {t('repo.commit.browseRevision')}
151 </Button>
152 ) : null}
153 </div>
154
155 {state.loading && !data ? <PanelNote text={t('repo.commit.loading')} /> : null}
156 {!state.loading && failure ? (
157 <ErrorPanel
158 status={failure.code ?? 0}
159 what={t('repo.commit.what')}
160 note={failure.message}
161 onRetry={() => setRetry((r) => r + 1)}
162 />
163 ) : null}
164 {!state.loading && !failure && !data ? <PanelNote text={t('repo.commit.missing')} /> : null}
165
166 {data ? (
167 <>
168 <section className="repo-commit-summary mb-5 rounded-xl border border-shell-hairline bg-shell-surface p-5">
169 <h3 className="break-words text-[18px] font-semibold leading-7">
170 {data.commit.message.split('\n')[0] || t('repo.commit.noMessage')}
171 </h3>
172 {data.commit.message.split('\n').slice(1).join('\n').trim() ? (
173 <pre className="mt-2 whitespace-pre-wrap font-sans text-[13px] leading-6 text-shell-ink-muted">
174 {data.commit.message.split('\n').slice(1).join('\n').trim()}
175 </pre>
176 ) : null}
177 <dl className="mt-3 grid gap-1 text-[12.5px] text-shell-ink-muted sm:grid-cols-2">
178 <div className="flex flex-wrap items-center gap-1.5">
179 <dt className="font-mono text-[10px] uppercase tracking-[1.2px]">{t('repo.commit.author')}</dt>
180 <dd className="text-shell-ink">{data.commit.author_name || t('common.state.unknown')}</dd>
181 {data.commit.author_email ? <dd>&lt;{data.commit.author_email}&gt;</dd> : null}
182 <dd title={fmtTime(data.commit.author_time)}>{fmtRelative(data.commit.author_time)}</dd>
183 </div>
184 <div className="flex flex-wrap items-center gap-1.5">
185 <dt className="font-mono text-[10px] uppercase tracking-[1.2px]">{t('repo.commit.committer')}</dt>
186 <dd className="text-shell-ink">{data.commit.committer_name || t('common.state.unknown')}</dd>
187 <dd title={fmtTime(data.commit.committer_time)}>{fmtRelative(data.commit.committer_time)}</dd>
188 </div>
189 </dl>
190 <div className="mt-3 flex flex-wrap items-center gap-2 text-[12px]">
191 <CopyChip text={data.commit.id} label={shortSha(data.commit.id, 10)} />
192 <span className="text-shell-ink-muted">
193 {data.commit.parents.length === 0
194 ? t('repo.commit.root')
195 : t('repo.commit.compareTo', { sha: shortSha(data.base, 10) })}
196 </span>
197 {data.commit.parents.map((parent) => (
198 <button
199 key={parent}
200 type="button"
201 onClick={() => onOpenCommit(parent)}
202 className="rounded bg-shell-active/70 px-1.5 py-0.5 font-mono text-[11px] text-shell-ink-muted transition-colors hover:text-shell-ink"
203 title={parent}
204 >
205 {t('repo.commit.parentChip', { sha: shortSha(parent, 8) })}
206 </button>
207 ))}
208 </div>
209 </section>
210
211 <div className="mb-2 flex flex-wrap items-center gap-3 text-[13px]">
212 <span className="font-semibold">{tc('repo.commit.filesChanged', files.length)}</span>
213 <span className="text-[#1a7f37]">+{additions}</span>
214 <span className="text-[#b42318]">−{deletions}</span>
215 {!autoExpand && files.length > 0 ? (
216 <span className="text-[12px] text-shell-ink-muted">{t('repo.commit.collapsedHint')}</span>
217 ) : null}
218 <div className="ml-auto flex items-center gap-0.5 rounded-md bg-shell-field p-0.5">
219 {(['unified', 'split'] as const).map((value) => (
220 <button
221 key={value}
222 type="button"
223 aria-pressed={style === value}
224 title={diffStyle === '' ? t('repo.diff.followAppearance') : undefined}
225 onClick={() => onDiffStyleChange(value)}
226 className={`rounded px-2 py-0.5 text-[12px] transition-colors ${
227 style === value
228 ? 'bg-shell-active font-semibold text-shell-ink'
229 : 'text-shell-ink-muted hover:text-shell-ink'
230 }`}
231 >
232 {value === 'unified' ? t('repo.diff.unified') : t('repo.diff.split')}
233 </button>
234 ))}
235 </div>
236 </div>
237
238 {files.length === 0 ? <PanelNote text={t('repo.commit.noFiles')} /> : null}
239 <div className="space-y-3">
240 {files.map((file) => {
241 const open = expanded[file.path] ?? autoExpand
242 return (
243 <section
244 key={file.path}
245 className="repo-diff-card overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface"
246 >
247 <button
248 type="button"
249 aria-expanded={open}
250 onClick={() => setExpanded((prev) => ({ ...prev, [file.path]: !open }))}
251 className="flex w-full items-center gap-2 border-b border-shell-hairline bg-shell-field px-3 py-2 text-left transition-colors hover:brightness-95"
252 >
253 {open ? (
254 <ChevronDown className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
255 ) : (
256 <ChevronRight className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
257 )}
258 <span className={`shrink-0 font-mono text-[11px] font-semibold ${STATUS_TONE[file.status]}`}>
259 {STATUS_LABEL[file.status]}
260 </span>
261 <span className="min-w-0 flex-1 truncate font-mono text-[12.5px]" title={file.path}>
262 {file.oldPath && file.oldPath !== file.path ? `${file.oldPath} → ${file.path}` : file.path}
263 </span>
264 <span className="shrink-0 font-mono text-[11.5px]">
265 <span className="text-[#1a7f37]">+{file.additions}</span>{' '}
266 <span className="text-[#b42318]">−{file.deletions}</span>
267 </span>
268 </button>
269 {open ? (
270 <FileDiff
271 file={file}
272 style={style}
273 space={space}
274 repo={repo}
275 commitSha={data.commit.id}
276 commentsByLine={commentsByLine}
277 onCommented={() => setCommentsVersion((v) => v + 1)}
278 />
279 ) : null}
280 </section>
281 )
282 })}
283 </div>
284 </>
285 ) : null}
286 </div>
287 )
288}
289
290function FileDiff({
291 file,
292 style,
293 space,
294 repo,
295 commitSha,
296 commentsByLine,
297 onCommented,
298}: {
299 file: DiffFile
300 style: DiffStyle
301 space: string
302 repo: string
303 commitSha: string
304 commentsByLine: Map<string, CodeComment[]>
305 onCommented: () => void
306}) {
307 // 当前展开的评论输入框锚点(同一时刻只开一个)。
308 const [draftLine, setDraftLine] = useState<number | null>(null)
309 // Parsed only while the card is mounted, so a 500-file commit stays cheap.
310 const { hunks, truncated } = useMemo(() => parseHunks(file.raw), [file.raw])
311 if (file.status === 'binary') return <PanelNote text={t('repo.diff.binary')} />
312 if (hunks.length === 0) return <PanelNote text={t('repo.diff.metaOnly')} />
313
314 const threadsAt = (line: number) => commentsByLine.get(`${file.path}#${line}`) ?? []
315 const hasThread = (line: number | null | undefined) =>
316 line != null && (threadsAt(line).length > 0 || draftLine === line)
317 const commentsAt = (line: number | null | undefined) => (line == null ? 0 : threadsAt(line).length)
318 // 评论与输入框都挂在行下方,而不是汇总到一个独立的面板里。
319 const renderThreads = (numbers: Array<number | null | undefined>) => {
320 const unique = [...new Set(numbers.filter((value): value is number => value != null))]
321 return (
322 <>
323 {unique.flatMap((line) =>
324 threadsAt(line).map((comment) => (
325 <CodeLineCommentCard key={comment.id} space={space} repo={repo} comment={comment} onChanged={onCommented} />
326 )),
327 )}
328 {unique.map((line) =>
329 draftLine === line ? (
330 <LineCommentComposer
331 key={`draft:${line}`}
332 space={space}
333 repo={repo}
334 path={file.path}
335 line={line}
336 commitSha={commitSha}
337 onCancel={() => setDraftLine(null)}
338 onDone={() => {
339 setDraftLine(null)
340 onCommented()
341 }}
342 />
343 ) : null,
344 )}
345 </>
346 )
347 }
348
349 if (style === 'split') {
350 return (
351 <SplitDiff
352 hunks={hunks}
353 truncated={truncated}
354 onRequestComment={setDraftLine}
355 hasThread={hasThread}
356 renderThreads={renderThreads}
357 />
358 )
359 }
360 return (
361 <div className="overflow-x-auto">
362 <table className="w-full border-collapse font-mono text-[12px] leading-5">
363 <tbody>
364 {hunks.map((hunk) => (
365 <Fragment key={hunk.header}>
366 <tr>
367 <td colSpan={4} className="bg-shell-field px-3 py-1 text-[11.5px] text-shell-ink-muted">
368 {hunk.header}
369 </td>
370 </tr>
371 {hunk.lines.map((line, index) => {
372 const lineNo = line.kind === 'del' ? line.oldNo : line.newNo
373 return (
374 <Fragment key={`${hunk.header}:${index}`}>
375 <DiffRow line={line} onRequestComment={setDraftLine} commentCount={commentsAt(lineNo)} />
376 {hasThread(lineNo) ? (
377 <tr>
378 <td colSpan={4} className="p-0">
379 {renderThreads([lineNo])}
380 </td>
381 </tr>
382 ) : null}
383 </Fragment>
384 )
385 })}
386 </Fragment>
387 ))}
388 </tbody>
389 </table>
390 {truncated ? (
391 <p className="border-t border-shell-hairline px-3 py-2 text-[12px] text-[#8a6100]">
392 {t('repo.diff.truncated', { count: 1500 })}
393 </p>
394 ) : null}
395 </div>
396 )
397}
398
399function DiffRow({
400 line,
401 onRequestComment,
402 commentCount = 0,
403}: {
404 line: DiffLine
405 onRequestComment?: (line: number) => void
406 commentCount?: number
407}) {
408 const { t, tc } = useT()
409 const tone =
410 line.kind === 'add' ? 'bg-[rgba(35,165,90,0.12)]' : line.kind === 'del' ? 'bg-[rgba(242,63,67,0.10)]' : ''
411 const lineNo = line.kind === 'del' ? line.oldNo : line.newNo
412 return (
413 <tr className={`group/row ${tone}`}>
414 <td className="w-10 shrink-0 select-none border-r border-shell-hairline px-2 text-right text-[11px] text-shell-ink-muted">
415 {line.oldNo ?? ''}
416 </td>
417 <td className="w-10 shrink-0 select-none border-r border-shell-hairline px-2 text-right text-[11px] text-shell-ink-muted">
418 {line.newNo ?? ''}
419 </td>
420 <td className="w-4 select-none px-1 text-center text-[11px] text-shell-ink-muted">
421 {lineNo != null && onRequestComment ? (
422 <span className="inline-flex items-center gap-0.5">
423 <button
424 type="button"
425 title={commentCount > 0 ? tc('repo.diff.lineComments', commentCount) : t('repo.diff.commentOnLine')}
426 onClick={() => onRequestComment(lineNo)}
427 className={`rounded px-0.5 transition-opacity hover:text-shell-ink focus:opacity-100 ${
428 commentCount > 0
429 ? 'bg-[#f0b232]/30 text-shell-ink'
430 : 'bg-shell-active text-shell-ink-muted opacity-0 group-hover/row:opacity-100'
431 }`}
432 >
433 +
434 </button>
435 {commentCount > 0 ? <span className="text-[10px] text-shell-ink-muted">{commentCount}</span> : null}
436 </span>
437 ) : line.kind === 'add' ? (
438 '+'
439 ) : line.kind === 'del' ? (
440 '−'
441 ) : (
442 ''
443 )}
444 </td>
445 <td className="whitespace-pre-wrap break-all px-2 py-0.5">{line.text || ' '}</td>
446 </tr>
447 )
448}
449
450function SplitDiff({
451 hunks,
452 truncated,
453 onRequestComment,
454 hasThread,
455 renderThreads,
456}: {
457 hunks: { header: string; lines: DiffLine[] }[]
458 truncated: boolean
459 onRequestComment?: (line: number) => void
460 hasThread: (line: number | null | undefined) => boolean
461 renderThreads: (numbers: Array<number | null | undefined>) => React.ReactNode
462}) {
463 const cell = (line: DiffLine | null, side: 'old' | 'new') => {
464 const tone =
465 line?.kind === 'add' ? 'bg-[rgba(35,165,90,0.12)]' : line?.kind === 'del' ? 'bg-[rgba(242,63,67,0.10)]' : ''
466 const lineNo = line ? (side === 'old' ? line.oldNo : line.newNo) : null
467 return (
468 <td className={`group/row w-1/2 border-r border-shell-hairline px-2 py-0.5 align-top last:border-r-0 ${tone}`}>
469 <span className="inline-block w-9 select-none text-right text-[11px] text-shell-ink-muted">{lineNo ?? ''}</span>
470 {lineNo != null && onRequestComment ? (
471 <button
472 type="button"
473 title={t('repo.diff.commentOnLine')}
474 onClick={() => onRequestComment(lineNo)}
475 className="ml-1 inline-block rounded bg-shell-active px-0.5 text-[11px] text-shell-ink-muted opacity-0 transition-opacity hover:text-shell-ink focus:opacity-100 group-hover/row:opacity-100"
476 >
477 +
478 </button>
479 ) : null}
480 <span className="ml-1 inline-block w-3 select-none text-center text-[11px] text-shell-ink-muted">
481 {line ? (line.kind === 'add' ? '+' : line.kind === 'del' ? '−' : '') : ''}
482 </span>
483 <span className="ml-1 whitespace-pre-wrap break-all">{line ? line.text : ''}</span>
484 </td>
485 )
486 }
487 return (
488 <div className="overflow-x-auto">
489 <table className="w-full border-collapse font-mono text-[12px] leading-5">
490 <tbody>
491 {hunks.map((hunk) => (
492 <Fragment key={hunk.header}>
493 <tr>
494 <td colSpan={2} className="bg-shell-field px-3 py-1 text-[11.5px] text-shell-ink-muted">
495 {hunk.header}
496 </td>
497 </tr>
498 {toSplitRows(hunk.lines).map((row, index) => {
499 const numbers = [row.left?.oldNo, row.left?.newNo, row.right?.oldNo, row.right?.newNo]
500 const thread = numbers.some(hasThread)
501 return (
502 <Fragment key={`${hunk.header}:${index}`}>
503 <tr>
504 {cell(row.left, 'old')}
505 {cell(row.right, 'new')}
506 </tr>
507 {thread ? (
508 <tr>
509 <td colSpan={2} className="p-0">
510 {renderThreads(numbers)}
511 </td>
512 </tr>
513 ) : null}
514 </Fragment>
515 )
516 })}
517 </Fragment>
518 ))}
519 </tbody>
520 </table>
521 {truncated ? (
522 <p className="border-t border-shell-hairline px-3 py-2 text-[12px] text-[#8a6100]">
523 {t('repo.diff.truncated', { count: 1500 })}
524 </p>
525 ) : null}
526 </div>
527 )
528}
Added view/app/src/domains/repo/history/ui/commits-tab.tsx View view/app/src/domains/repo/history/ui/commits-tab.tsx
@@ -0,0 +1,240 @@
1import { CopyChip, ErrorPanel, PanelNote } from '@app/components'
2import { type CommitRow, listCommitsPage, listTags } from '@app/domains/repo/model'
3import { InitialAvatar } from '@app/domains/shared'
4import { useT } from '@app/i18n'
5import { fmtDay, fmtRelative, fmtTime, repoKey, shortSha, useAsync } from '@app/lib'
6import { Button, Checkbox } from '@app/ui'
7import { ChevronLeft, ChevronRight, GitCommitHorizontal, GitMerge, Tag } from 'lucide-react'
8import { useMemo, useState } from 'react'
9
10const PAGE_SIZE = 50
11const MAX_TAG_CHIPS = 2
12
13export interface CommitsTabProps {
14 space: string
15 repo: string
16 rev: string
17 /** Restrict history to one path (file / directory log). */
18 path?: string
19 /** Open this commit's own diff view. */
20 onOpenCommit: (sha: string) => void
21 /** Change the path filter ('' = whole repo). */
22 onShowHistory: (path: string) => void
23 /** First-parent-only history, owned by the route. */
24 firstParent: boolean
25 onFirstParentChange: (on: boolean) => void
26}
27
28interface CommitDay {
29 key: string
30 label: string
31 commits: CommitRow[]
32}
33
34/** One page of commits, folded under their calendar day (newest first). */
35function groupByDay(commits: CommitRow[]): CommitDay[] {
36 const days: CommitDay[] = []
37 for (const commit of commits) {
38 const date = new Date(commit.committer_time * 1000)
39 const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
40 let day = days[days.length - 1]
41 if (!day || day.key !== key) {
42 day = { key, label: fmtDay(commit.committer_time), commits: [] }
43 days.push(day)
44 }
45 day.commits.push(commit)
46 }
47 return days
48}
49
50/** Commit history for the selected revision, straight from
51 * `GET /repos/{s}/{r}/commits`. Mounted with `key={rev}` so switching revision
52 * restarts paging instead of showing page N of a new history. Commits group by
53 * day with a card row each; clicking a subject or 查看改动 opens its patch. */
54export function CommitsTab({
55 space,
56 repo,
57 rev,
58 path,
59 onOpenCommit,
60 onShowHistory,
61 firstParent,
62 onFirstParentChange,
63}: CommitsTabProps) {
64 const { t, tc } = useT()
65 // Only paging and the manual retry live here; the revision, path filter and
66 // first-parent toggle are route state, so this view is rebuildable from a URL.
67 const [skip, setSkip] = useState(0)
68 const [retry, setRetry] = useState(0)
69 const state = useAsync(
70 () => listCommitsPage(space, repo, { revision: rev, skip, limit: PAGE_SIZE, path, firstParent }),
71 [space, repo, rev, path, skip, firstParent, retry],
72 { cacheKey: repoKey(space, repo, `commits:${rev}:${path}:${skip}:${firstParent}`) },
73 )
74
75 const page = state.data
76 const commits = page?.commits ?? []
77 const total = page?.total ?? 0
78 const pages = Math.max(1, Math.ceil(total / PAGE_SIZE))
79 const current = Math.floor(skip / PAGE_SIZE) + 1
80 const failure = state.error as { code?: number } | undefined
81
82 // Tags ride along so a release marker shows on its own commit, the way the
83 // GitLab history does it. A repo without tags just yields nothing.
84 const tags = useAsync(() => listTags(space, repo, 100).catch(() => null), [space, repo], {
85 cacheKey: repoKey(space, repo, 'commits:tags'),
86 })
87 const tagsBySha = useMemo(() => {
88 const map = new Map<string, string[]>()
89 for (const tag of tags.data?.items ?? []) {
90 const list = map.get(tag.target_sha)
91 if (list) list.push(tag.name)
92 else map.set(tag.target_sha, [tag.name])
93 }
94 return map
95 }, [tags.data])
96 const days = useMemo(() => groupByDay(page?.commits ?? []), [page])
97
98 return (
99 <div className="repo-history">
100 <div className="repo-section-toolbar mb-4 flex flex-wrap items-center gap-3">
101 <h3 className="min-w-0 break-all text-[17px] font-semibold">
102 {t('repo.commit.history')}{' '}
103 <span className="font-mono text-[12.5px] font-normal text-shell-ink-muted">· {rev}</span>
104 {path ? <span className="font-mono text-[12.5px] font-normal text-shell-ink-muted">· {path}</span> : null}
105 </h3>
106 {page ? (
107 <span className="text-[12.5px] text-shell-ink-muted">{t('repo.commits.total', { count: total })}</span>
108 ) : null}
109 {path ? (
110 <Button variant="secondary" className="h-7 text-[12px]" onClick={() => onShowHistory('')}>
111 {t('repo.commits.viewAll')}
112 </Button>
113 ) : null}
114 <label className="ml-auto flex items-center gap-2 text-[12.5px] text-shell-ink-muted">
115 <Checkbox
116 checked={firstParent}
117 onChange={(value) => {
118 onFirstParentChange(value)
119 setSkip(0)
120 }}
121 aria-label={t('repo.commits.firstParent')}
122 />
123 <span className="cursor-pointer">{t('repo.commits.firstParent')}</span>
124 </label>
125 </div>
126
127 {state.loading && !page ? (
128 <PanelNote text={t('repo.commits.loading')} />
129 ) : failure ? (
130 <ErrorPanel
131 status={failure.code ?? 0}
132 what={t('repo.commit.history')}
133 note={t('repo.commits.missing')}
134 onRetry={() => setRetry((r) => r + 1)}
135 />
136 ) : page && commits.length === 0 ? (
137 <PanelNote text={total === 0 ? t('repo.commits.none') : t('repo.commits.pageEmpty')} />
138 ) : (
139 <div className="flex flex-col">
140 {days.map((day, dayIndex) => (
141 <section key={day.key + '-' + dayIndex} className={dayIndex > 0 ? 'mt-5' : ''}>
142 <h4 className="repo-commit-day mb-2 flex items-center gap-2.5 px-1">
143 <GitCommitHorizontal className="h-3.5 w-3.5 shrink-0" aria-hidden />
144 <span>{day.label}</span>
145 <span className="repo-commit-day-rule" aria-hidden />
146 </h4>
147 <ul className="repo-commit-list">
148 {day.commits.map((commit) => {
149 const [subject, ...rest] = commit.message.split('\n')
150 const body = rest.join(' ').replace(/\s+/g, ' ').trim()
151 const isMerge = commit.parents.length > 1
152 const isTip = skip === 0 && commit.id === commits[0]?.id
153 const commitTags = tagsBySha.get(commit.id) ?? []
154 return (
155 <li key={commit.id} className="repo-commit-row flex items-start gap-3 px-4 py-3">
156 <InitialAvatar name={commit.author_name || '?'} size={26} />
157 <div className="min-w-0 flex-1">
158 <button
159 type="button"
160 onClick={() => onOpenCommit(commit.id)}
161 title={commit.message}
162 className="repo-commit-subject block max-w-full text-left text-[13.5px] font-medium leading-6"
163 >
164 {subject || t('repo.commit.noMessage')}
165 </button>
166 {body ? (
167 <p className="mt-0.5 line-clamp-1 text-[12px] leading-5 text-shell-ink-muted" title={body}>
168 {body}
169 </p>
170 ) : null}
171 <p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px] text-shell-ink-muted">
172 <span className="font-medium text-shell-ink">
173 {commit.author_name || t('repo.commit.unknownAuthor')}
174 </span>
175 <span title={fmtTime(commit.committer_time)}>{fmtRelative(commit.committer_time)}</span>
176 {commitTags.slice(0, MAX_TAG_CHIPS).map((name) => (
177 <span key={name} className="repo-commit-tag" title={name}>
178 <Tag className="h-3 w-3" aria-hidden />
179 {name}
180 </span>
181 ))}
182 {commitTags.length > MAX_TAG_CHIPS ? (
183 <span className="repo-commit-tag">+{commitTags.length - MAX_TAG_CHIPS}</span>
184 ) : null}
185 {isMerge ? (
186 <span className="inline-flex items-center gap-1">
187 <GitMerge className="h-3.5 w-3.5" aria-hidden />
188 {tc('repo.commit.parents', commit.parents.length)}
189 </span>
190 ) : null}
191 {isTip ? (
192 <span className="rounded-full bg-shell-field px-2 py-0.5 text-[11px]">
193 {t('repo.commit.latest')}
194 </span>
195 ) : null}
196 </p>
197 </div>
198 <div className="repo-commit-actions flex shrink-0 items-center gap-2 pt-0.5">
199 <CopyChip text={commit.id} label={shortSha(commit.id, 8)} />
200 <Button
201 variant="secondary"
202 className="h-7 px-2.5 text-[12px]"
203 onClick={() => onOpenCommit(commit.id)}
204 >
205 {t('repo.action.viewChanges')}
206 </Button>
207 </div>
208 </li>
209 )
210 })}
211 </ul>
212 </section>
213 ))}
214 </div>
215 )}
216
217 {page && total > PAGE_SIZE ? (
218 <div className="flex items-center justify-between gap-3 pt-3">
219 <Button
220 variant="secondary"
221 className="h-8 gap-1 text-[12.5px]"
222 disabled={skip === 0 || state.loading}
223 onClick={() => setSkip(Math.max(0, skip - PAGE_SIZE))}
224 >
225 <ChevronLeft className="h-3.5 w-3.5" aria-hidden /> {t('repo.pager.prev')}
226 </Button>
227 <span className="text-[12.5px] text-shell-ink-muted">{t('repo.pager.page', { current, pages })}</span>
228 <Button
229 variant="secondary"
230 className="h-8 gap-1 text-[12.5px]"
231 disabled={!page.hasMore || state.loading}
232 onClick={() => setSkip(skip + PAGE_SIZE)}
233 >
234 {t('repo.pager.next')} <ChevronRight className="h-3.5 w-3.5" aria-hidden />
235 </Button>
236 </div>
237 ) : null}
238 </div>
239 )
240}
Added view/app/src/domains/repo/history/index.ts View view/app/src/domains/repo/history/index.ts
@@ -0,0 +1,5 @@
1// Public API of features/repo-history — commit history and per-commit diff
2// views. Consumers import from '@app/domains/repo/history', never from deeper files.
3
4export { CommitView, type CommitViewProps } from './ui/commit-view'
5export { CommitsTab, type CommitsTabProps } from './ui/commits-tab'
Added view/app/src/domains/repo/model/archives.ts View view/app/src/domains/repo/model/archives.ts
@@ -0,0 +1,50 @@
1/**
2 * Archives domain: on-demand archive / bundle creation and short-lived
3 * download links.
4 */
5
6import { unwrapApi } from '@app/lib'
7import type { EnvelopeArchiveResultDto } from '@app/services/api'
8import Apis from '@app/services/api'
9
10export type ArchiveResult = NonNullable<EnvelopeArchiveResultDto['data']>
11export type ArchiveFormat = 'tar' | 'tar_gz' | 'tar_bz2' | 'zip'
12
13export const createArchive = (
14 space: string,
15 repo: string,
16 cmd: { revision: string; format: ArchiveFormat; path_filter?: string },
17) =>
18 unwrapApi<ArchiveResult>(
19 Apis.archives.create_archive({
20 pathParams: { space_name: space, repo_name: repo },
21 data: { ...cmd, path_filter: cmd.path_filter || null },
22 }),
23 )
24
25export const createBundle = (space: string, repo: string) =>
26 unwrapApi<ArchiveResult>(Apis.archives.create_bundle({ pathParams: { space_name: space, repo_name: repo } }))
27
28/** `GET /archives/{revision}?format=` deserialises the serde *variant* names,
29 * while the POST body takes the snake_case tags — map between the two. */
30const QUERY_FORMAT: Record<ArchiveFormat, string> = {
31 tar: 'Tar',
32 tar_gz: 'TarGz',
33 tar_bz2: 'TarBz2',
34 zip: 'Zip',
35}
36
37/** Re-issue a short-lived download URL for an already built archive. */
38export const queryArchive = (
39 space: string,
40 repo: string,
41 revision: string,
42 format: ArchiveFormat,
43 pathFilter?: string,
44) =>
45 unwrapApi<ArchiveResult>(
46 Apis.archives.get_archive({
47 pathParams: { space_name: space, repo_name: repo, revision },
48 params: { format: QUERY_FORMAT[format], path_filter: pathFilter || undefined },
49 }),
50 )
Added view/app/src/domains/repo/model/blame.ts View view/app/src/domains/repo/model/blame.ts
@@ -0,0 +1,62 @@
1/**
2 * Line-attribution (blame) and submodule listings — advanced read endpoints
3 * under `/repos/{s}/{r}`.
4 */
5
6import { unwrapApi } from '@app/lib'
7import type { EnvelopeVecBlameRangeDto, EnvelopeVecSubmoduleDto } from '@app/services/api'
8import Apis from '@app/services/api'
9
10export type BlameRange = NonNullable<NonNullable<EnvelopeVecBlameRangeDto['data']>[number]>
11
12export type SubmoduleRow = NonNullable<NonNullable<EnvelopeVecSubmoduleDto['data']>[number]>
13
14/** Per-range attribution for `path` at `revision`. */
15export const fetchBlame = (space: string, repo: string, revision: string, path: string) =>
16 unwrapApi<BlameRange[] | null>(
17 Apis.blame.blame({
18 pathParams: { space_name: space, repo_name: repo },
19 params: { revision, path },
20 }),
21 ).then((rows) => rows ?? [])
22
23/** Conflict content (with markers) for one path between two revisions. */
24export const fetchConflictDetail = (space: string, repo: string, base: string, head: string, path: string) =>
25 unwrapApi<{ path: string; content?: string | null }>(
26 Apis.conflicts.conflict_detail({
27 pathParams: { space_name: space, repo_name: repo },
28 params: { base, head, path },
29 }),
30 )
31
32/** Resolve a submodule path at `revision` to its repository path (space/name). */
33export const resolveSubmodule = (space: string, repo: string, revision: string, path: string) =>
34 unwrapApi<string>(
35 Apis.submodules.submodule_resolve({
36 pathParams: { space_name: space, repo_name: repo },
37 params: { revision, path },
38 }),
39 )
40
41/** `.gitmodules` entries visible at `revision`. */
42export const listSubmodules = (space: string, repo: string, revision: string) =>
43 unwrapApi<SubmoduleRow[] | null>(
44 Apis.submodules.submodule_list({
45 pathParams: { space_name: space, repo_name: repo },
46 params: { revision },
47 }),
48 ).then((rows) => rows ?? [])
49
50/**
51 * Spread blame ranges over 1-based line numbers: entry i describes line i+1.
52 * Ranges are inclusive (`starting_line..ending_line`) per the backend dto.
53 */
54export function blameLineMap(ranges: BlameRange[], lineCount: number): (BlameRange | null)[] {
55 const lines: (BlameRange | null)[] = Array.from({ length: lineCount }, () => null)
56 for (const range of ranges) {
57 const start = Math.max(1, range.starting_line)
58 const end = Math.min(lineCount, range.ending_line)
59 for (let line = start; line <= end; line += 1) lines[line - 1] = range
60 }
61 return lines
62}
Added view/app/src/domains/repo/model/code-comments.ts View view/app/src/domains/repo/model/code-comments.ts
@@ -0,0 +1,83 @@
1/** 代码行级评论领域:浏览页与提交页共用的行级评论读写。 */
2
3import { unwrapApi } from '@app/lib'
4import type { CreateCodeCommentCmd, EnvelopeCodeCommentDto } from '@app/services/api'
5import Apis from '@app/services/api'
6
7export type CodeComment = NonNullable<EnvelopeCodeCommentDto['data']>
8
9/** 分页响应在生成器里是泛型 `Page`,行类型由此显式声明。 */
10export interface CodeCommentPageData {
11 items: CodeComment[]
12 total: number
13 limit: number
14 offset: number
15}
16
17export const listCodeComments = (
18 space: string,
19 repo: string,
20 params: { path?: string; revision?: string; resolved?: boolean; limit?: number; offset?: number } = {},
21) =>
22 Apis.codeComments
23 .list_code_comments({
24 pathParams: { space_name: space, repo_name: repo },
25 params: {
26 path: params.path,
27 revision: params.revision,
28 resolved: params.resolved,
29 limit: params.limit,
30 offset: params.offset,
31 },
32 })
33 .then((res) => res as unknown as { data?: CodeCommentPageData })
34 .then((res) => res.data ?? { items: [], total: 0, limit: 0, offset: 0 })
35
36export const createCodeComment = (space: string, repo: string, cmd: CreateCodeCommentCmd) =>
37 unwrapApi<CodeComment>(
38 Apis.codeComments.create_code_comment({
39 pathParams: { space_name: space, repo_name: repo },
40 data: cmd,
41 }),
42 )
43
44export const updateCodeComment = (space: string, repo: string, commentId: string, body: string) =>
45 unwrapApi<CodeComment>(
46 Apis.codeComments.update_code_comment({
47 pathParams: { space_name: space, repo_name: repo, comment_id: commentId },
48 data: { body },
49 }),
50 )
51
52export const deleteCodeComment = (space: string, repo: string, commentId: string) =>
53 unwrapApi<unknown>(
54 Apis.codeComments.delete_code_comment({
55 pathParams: { space_name: space, repo_name: repo, comment_id: commentId },
56 }),
57 )
58
59export const resolveCodeComment = (space: string, repo: string, commentId: string) =>
60 unwrapApi<CodeComment>(
61 Apis.codeComments.resolve_code_comment({
62 pathParams: { space_name: space, repo_name: repo, comment_id: commentId },
63 }),
64 )
65
66export const unresolveCodeComment = (space: string, repo: string, commentId: string) =>
67 unwrapApi<CodeComment>(
68 Apis.codeComments.unresolve_code_comment({
69 pathParams: { space_name: space, repo_name: repo, comment_id: commentId },
70 }),
71 )
72
73/** 把评论按 `path#line` 归组,供 diff / 代码视图按行渲染。 */
74export function groupCommentsByLine(rows: CodeComment[]): Map<string, CodeComment[]> {
75 const map = new Map<string, CodeComment[]>()
76 for (const row of rows) {
77 const key = `${row.path}#${row.line}`
78 const list = map.get(key) ?? []
79 list.push(row)
80 map.set(key, list)
81 }
82 return map
83}
Added view/app/src/domains/repo/model/commits.ts View view/app/src/domains/repo/model/commits.ts
@@ -0,0 +1,70 @@
1/**
2 * Commit-history domain: the commit list endpoints (including raw patch text
3 * and numstat) that feed the history tab and the commit view.
4 */
5
6import { unwrapApi } from '@app/lib'
7import type { EnvelopeCommitsPage } from '@app/services/api'
8import Apis from '@app/services/api'
9import { type DiffStyle, type NumstatRow, normalizeDiffStyle } from './diff'
10
11export type CommitsPage = NonNullable<EnvelopeCommitsPage['data']>
12export type CommitRow = NonNullable<CommitsPage['items']>[number]
13
14/** One page of commit history for `revision` (optionally filtered to a path). */
15export async function listCommitsPage(
16 space: string,
17 repo: string,
18 opts: { revision: string; skip: number; limit: number; path?: string; firstParent?: boolean },
19): Promise<{ commits: CommitRow[]; total: number; hasMore: boolean }> {
20 const page = await unwrapApi<CommitsPage>(
21 Apis.commits.list_commits({
22 pathParams: { space_name: space, repo_name: repo },
23 params: {
24 revision: opts.revision,
25 skip: opts.skip,
26 max_count: opts.limit,
27 first_parent: opts.firstParent ?? false,
28 ...(opts.path ? { path: opts.path } : {}),
29 },
30 }),
31 )
32 return {
33 commits: page.items ?? [],
34 total: page.total ?? 0,
35 hasMore: Boolean(page.has_more),
36 }
37}
38
39/** Single commit by sha — reuse the list endpoint so the payload shape (hex
40 * ids, unix times) matches everything else on the page. */
41export async function fetchCommit(space: string, repo: string, sha: string): Promise<CommitRow | null> {
42 const page = await listCommitsPage(space, repo, { revision: sha, skip: 0, limit: 1 })
43 return page.commits[0] ?? null
44}
45
46/** Per-file +/- counts for `base..head` (`view=numstat`; `shortstat` 500s on
47 * the server today, so totals are summed client-side). */
48export async function fetchNumstat(space: string, repo: string, base: string, head: string): Promise<NumstatRow[]> {
49 const data = await unwrapApi<unknown>(
50 Apis.diffs.get_diff({
51 pathParams: { space_name: space, repo_name: repo },
52 params: { base, head, view: 'numstat' },
53 }),
54 )
55 return Array.isArray(data) ? (data as NumstatRow[]) : []
56}
57
58// TODO: crosses the repo/account boundary — inject the viewer's saved style
59// from the caller (or a preferences slice) instead of reaching into
60// Apis.account here. Kept as-is during the structural split.
61/** The viewer's saved diff layout. Read here rather than imported from
62 * another slice — features own their API surface. */
63export async function fetchDiffStyle(): Promise<DiffStyle> {
64 const prefs = await unwrapApi<{ diff_view_style?: string | null }>(Apis.account.get_preferences())
65 return normalizeDiffStyle(prefs?.diff_view_style)
66}
67
68/** Raw unified patch text for `base..head`. */
69export const fetchRawDiff = (space: string, repo: string, base: string, head: string) =>
70 unwrapApi<string>(Apis.diffs.diff_raw({ pathParams: { space_name: space, repo_name: repo }, params: { base, head } }))
Added view/app/src/domains/repo/model/diff.ts View view/app/src/domains/repo/model/diff.ts
@@ -0,0 +1,241 @@
1/**
2 * Unified-diff parsing for the commit view.
3 *
4 * The backend exposes per-commit diffs as raw patch text
5 * (`GET /repos/{s}/{r}/diff/raw?base=&head=`) plus a numstat list, and there is
6 * no structured JSON diff — so we parse the patch here. File sections are kept
7 * as raw text and hunks are parsed lazily: an initial-import commit can carry
8 * megabytes of patch, and rendering every line of every file at once would
9 * freeze the page.
10 */
11
12import { getLocale, translate } from '@app/i18n'
13
14export type DiffFileStatus = 'added' | 'deleted' | 'renamed' | 'copied' | 'modified' | 'binary'
15
16export interface DiffFile {
17 path: string
18 oldPath: string | null
19 status: DiffFileStatus
20 /** Patch section for this file, parsed on demand by `parseHunks`. */
21 raw: string
22 additions: number
23 deletions: number
24}
25
26export interface DiffLine {
27 kind: 'context' | 'add' | 'del'
28 oldNo: number | null
29 newNo: number | null
30 text: string
31}
32
33export interface DiffHunk {
34 header: string
35 lines: DiffLine[]
36}
37
38/** Git's empty tree — the base for root commits (mirrors the server). */
39export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
40
41/** `diff --git` appears at the start of a file section; the header line of the
42 * whole patch is empty, so we only split on real boundaries. */
43export function splitDiffFiles(text: string): DiffFile[] {
44 if (!text.trim()) return []
45 const chunks = text.split(/(?=^diff --git )/m).filter((chunk) => chunk.startsWith('diff --git '))
46 return chunks.map((chunk) => {
47 const header = /^diff --git a\/(.*?) b\/(.*?)$/m.exec(chunk)
48 let path = header ? unquoteGitPath(header[2] ?? '') : ''
49 let oldPath: string | null = header ? unquoteGitPath(header[1] ?? '') : null
50
51 const plus = /^\+\+\s+(.*)$/m.exec(chunk) ?? /^\+\+\+\s+(.*)$/m.exec(chunk)
52 const minus = /^---\s+(.*)$/m.exec(chunk)
53 const plusTarget = plus?.[1]?.trim() ?? ''
54 const minusTarget = minus?.[1]?.trim() ?? ''
55 if (plusTarget && plusTarget !== '/dev/null') path = stripAB(plusTarget)
56 if (minusTarget && minusTarget !== '/dev/null') oldPath = stripAB(minusTarget)
57 const renamed = /^rename from (.*)$/m.exec(chunk)
58 if (renamed) oldPath = unquoteGitPath(renamed[1].trim())
59
60 let status: DiffFileStatus = 'modified'
61 if (/^new file mode /m.test(chunk) || minusTarget === '/dev/null') status = 'added'
62 else if (/^deleted file mode /m.test(chunk) || plusTarget === '/dev/null') status = 'deleted'
63 else if (/^rename from /m.test(chunk)) status = 'renamed'
64 else if (/^copy from /m.test(chunk)) status = 'copied'
65 else if (/^Binary files /m.test(chunk)) status = 'binary'
66 // An added file has no predecessor to show.
67 if (status === 'added') oldPath = null
68
69 let additions = 0
70 let deletions = 0
71 for (const line of chunk.split('\n')) {
72 if (line.startsWith('+++') || line.startsWith('---')) continue
73 if (line.startsWith('+')) additions += 1
74 else if (line.startsWith('-')) deletions += 1
75 }
76 return {
77 path: path || translate(getLocale(), 'repo.unknownPath'),
78 oldPath,
79 status,
80 raw: chunk,
81 additions,
82 deletions,
83 }
84 })
85}
86
87/** `a/foo.py` / `"a/foo.py"` → `foo.py`. Git always prefixes the a/ b/ side,
88 * and the prefix can hide inside the quotes, so unquote first. */
89function stripAB(value: string): string {
90 const token = value.split(/\s/)[0] ?? ''
91 const unquoted = unquoteGitPath(token)
92 return unquoted === token ? token.replace(/^[ab]\//, '') : unquoted.replace(/^[ab]\//, '')
93}
94
95/**
96 * Git C-quotes paths with unusual bytes: `"caf\303\251.png"`, `"a\tb"`.
97 * Octal escapes are raw bytes, so decode them as UTF-8; anything we cannot
98 * parse is returned untouched rather than guessed at.
99 */
100export function unquoteGitPath(value: string): string {
101 if (!value.startsWith('"') || !value.endsWith('"')) return value
102 const inner = value.slice(1, -1)
103 if (!inner.includes('\\')) return inner
104 const bytes: number[] = []
105 for (let i = 0; i < inner.length; i += 1) {
106 const char = inner[i]
107 if (char !== '\\') {
108 bytes.push(...new TextEncoder().encode(char))
109 continue
110 }
111 const next = inner[i + 1]
112 const octal = /^\\([0-7]{1,3})/.exec(inner.slice(i))
113 if (octal) {
114 bytes.push(Number.parseInt(octal[1], 8))
115 i += octal[1].length
116 continue
117 }
118 const simple: Record<string, string> = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', '"': '"', '\\': '\\' }
119 if (next && simple[next] !== undefined) {
120 bytes.push(...new TextEncoder().encode(simple[next]))
121 i += 1
122 continue
123 }
124 bytes.push(...new TextEncoder().encode(next ?? ''))
125 i += 1
126 }
127 return new TextDecoder('utf-8', { fatal: false }).decode(new Uint8Array(bytes))
128}
129
130/** Line cap so one giant file cannot take the page down. */
131export const MAX_DIFF_LINES = 1500
132
133export function parseHunks(raw: string, cap = MAX_DIFF_LINES): { hunks: DiffHunk[]; truncated: boolean } {
134 const hunks: DiffHunk[] = []
135 let current: DiffHunk | null = null
136 let oldNo = 0
137 let newNo = 0
138 let used = 0
139 let truncated = false
140 for (const line of raw.split('\n')) {
141 if (line.startsWith('@@')) {
142 const match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
143 oldNo = match ? Number(match[1]) : 0
144 newNo = match ? Number(match[2]) : 0
145 current = { header: line, lines: [] }
146 hunks.push(current)
147 continue
148 }
149 if (!current) continue
150 if (
151 line.startsWith('index ') ||
152 line.startsWith('\\ ') ||
153 /^(new|old|deleted|rename|copy|Binary|---|\+\+\+) /.test(line)
154 ) {
155 continue
156 }
157 if (line.startsWith('+')) {
158 current.lines.push({ kind: 'add', oldNo: null, newNo: newNo++, text: line.slice(1) })
159 used += 1
160 } else if (line.startsWith('-')) {
161 current.lines.push({ kind: 'del', oldNo: oldNo++, newNo: null, text: line.slice(1) })
162 used += 1
163 } else {
164 // Trailing-whitespace-stripped context lines arrive as an empty string.
165
166 current.lines.push({
167 kind: 'context',
168 oldNo: oldNo++,
169 newNo: newNo++,
170 text: line.startsWith(' ') ? line.slice(1) : line,
171 })
172 used += 1
173 }
174 if (used >= cap) {
175 truncated = true
176 break
177 }
178 }
179 return { hunks, truncated }
180}
181
182/** numstat rows keyed by path — authoritative +/- counts (patch counting above
183 * is only a fallback for files numstat does not list). */
184export interface NumstatRow {
185 path: string
186 old_path?: string | null
187 additions: number
188 deletions: number
189}
190
191export function applyNumstat(files: DiffFile[], rows: NumstatRow[]): DiffFile[] {
192 if (!rows.length) return files
193 const byPath = new Map(rows.map((row) => [row.path, row]))
194 return files.map((file) => {
195 const row = byPath.get(file.path) ?? (file.oldPath ? byPath.get(file.oldPath) : undefined)
196 if (!row) return file
197 return { ...file, additions: row.additions, deletions: row.deletions }
198 })
199}
200
201/* ---- split (side-by-side) rendering ------------------------------------- */
202
203export interface SplitRow {
204 left: DiffLine | null
205 right: DiffLine | null
206}
207
208/**
209 * Pair a hunk's linear lines into side-by-side rows: a deletion followed by an
210 * addition becomes one row, context repeats on both sides, and lone adds/dels
211 * leave the opposite cell blank.
212 */
213export function toSplitRows(lines: DiffLine[]): SplitRow[] {
214 const rows: SplitRow[] = []
215 for (let i = 0; i < lines.length; i += 1) {
216 const line = lines[i]
217 if (line.kind === 'del') {
218 const next = lines[i + 1]
219 if (next?.kind === 'add') {
220 rows.push({ left: line, right: next })
221 i += 1
222 } else {
223 rows.push({ left: line, right: null })
224 }
225 continue
226 }
227 if (line.kind === 'add') {
228 rows.push({ left: null, right: line })
229 continue
230 }
231 rows.push({ left: line, right: line })
232 }
233 return rows
234}
235
236export type DiffStyle = 'unified' | 'split'
237
238/** Anything the server does not recognise behaves as unified there too. */
239export function normalizeDiffStyle(value: string | null | undefined): DiffStyle {
240 return value === 'split' ? 'split' : 'unified'
241}
Added view/app/src/domains/repo/model/fragment.ts View view/app/src/domains/repo/model/fragment.ts
@@ -0,0 +1,108 @@
1/**
2 * HTML-fragment domain: url building and fetching of the server-rendered
3 * fragments under `/repos/{s}/{r}/html/*`, plus the `useHtmlFragment` hook.
4 *
5 * Fragments are maud-escaped (safe to inject) EXCEPT the README, which
6 * passes raw CommonMark HTML through — that one is sandboxed in an iframe.
7 */
8
9import { PREFETCH_FRESH_MS } from '@app/lib'
10import { trackFetch } from '@app/services/api'
11import { peekCache, readCache, writeCache } from '@app/services/storage'
12import { useEffect, useState } from 'react'
13
14const enc = encodeURIComponent
15
16export const repoBase = (space: string, repo: string) => `/api/v1/repos/${enc(space)}/${enc(repo)}`
17
18export function htmlFragmentUrl(
19 space: string,
20 repo: string,
21 fragment: string,
22 params: Record<string, string | number | undefined> = {},
23): string {
24 const q = new URLSearchParams()
25 for (const [key, value] of Object.entries(params)) {
26 if (value !== undefined && value !== '') q.set(key, String(value))
27 }
28 const qs = q.toString()
29 return `${repoBase(space, repo)}/html/${fragment}${qs ? `?${qs}` : ''}`
30}
31
32export interface HtmlFragment {
33 ok: boolean
34 status: number
35 html: string
36}
37
38/** Never throws on HTTP errors — callers branch on status (401 → JSON mode). */
39export async function fetchHtmlFragment(url: string, signal?: AbortSignal): Promise<HtmlFragment> {
40 try {
41 const res = await trackFetch(url, { headers: { accept: 'text/html' }, signal })
42 const html = await res.text()
43 return { ok: res.ok, status: res.status, html }
44 } catch (err) {
45 if ((err as Error).name === 'AbortError') throw err
46 return { ok: false, status: 0, html: '' }
47 }
48}
49
50/**
51 * Fetch a server-rendered HTML fragment, local-first: a previously stored
52 * fragment for the same url paints immediately (first render when it is in
53 * the memory tier), the live fetch always runs and replaces it — mutable
54 * targets (a tree at a moving branch head) stay stale-while-revalidate, and
55 * only `ok` responses are ever stored. Returns `null` while nothing is
56 * available for the current url — stale results from another url never flash.
57 * Pass a cache-busting param to retry (it changes the url, hence the key).
58 */
59const htmlFragKey = (url: string) => `frag:${url}`
60
61/**
62 * Warm the fragment cache for one url (repo hover prefetch). Throttled by the
63 * same freshness window as `prefetch`, silent on failure, and only `ok`
64 * responses are stored — a 401/404 probe result must never be replayed.
65 */
66export async function prefetchHtmlFragment(url: string): Promise<void> {
67 const hit = peekCache<HtmlFragment>(htmlFragKey(url))
68 if (hit?.data.ok && Date.now() - hit.at < PREFETCH_FRESH_MS) return
69 try {
70 const frag = await fetchHtmlFragment(url)
71 if (frag.ok) writeCache(htmlFragKey(url), frag)
72 } catch {
73 /* a prefetch is an optimization; the live hook surfaces real failures */
74 }
75}
76
77export function useHtmlFragment(url: string | null): HtmlFragment | null {
78 const [state, setState] = useState<{ url: string; frag: HtmlFragment } | null>(() => {
79 if (!url) return null
80 const hit = peekCache<HtmlFragment>(htmlFragKey(url))
81 return hit?.data.ok ? { url, frag: hit.data } : null
82 })
83 useEffect(() => {
84 if (!url) return
85 let active = true
86 // Disk hydration: only fills in while this url still renders nothing, so
87 // it can never clobber the live response that usually wins the race.
88 void readCache<HtmlFragment>(htmlFragKey(url)).then((hit) => {
89 if (!active || !hit?.data.ok) return
90 setState((prev) => (prev && prev.url === url ? prev : { url, frag: hit.data }))
91 })
92 const controller = new AbortController()
93 fetchHtmlFragment(url, controller.signal).then(
94 (frag) => {
95 if (!active) return
96 if (frag.ok) writeCache(htmlFragKey(url), frag)
97 setState({ url, frag })
98 },
99 // Only aborts reject (fetchHtmlFragment absorbs HTTP/network errors).
100 () => {},
101 )
102 return () => {
103 active = false
104 controller.abort()
105 }
106 }, [url])
107 return state && state.url === url ? state.frag : null
108}
Added view/app/src/domains/repo/model/href.ts View view/app/src/domains/repo/model/href.ts
@@ -0,0 +1,103 @@
1/**
2 * Web-path routing for server-rendered fragments.
3 *
4 * The HTML fragments link with forge-style web paths (`/demo/repo/tree/main/docs/`,
5 * `/demo/repo/commit/<sha>`, `/demo/repo/commits/main/app/main.py`,
6 * `/demo/repo` for the `..` row) — none of which exist as SPA routes. Map them
7 * back onto in-app state so every link in a fragment keeps working.
8 */
9
10export type WebTarget =
11 | { kind: 'tree'; path: string }
12 | { kind: 'blob'; path: string }
13 | { kind: 'commit'; sha: string }
14 | { kind: 'history'; path: string }
15 | { kind: 'up' }
16 | null
17
18export function parentPath(path: string): string {
19 return path.split('/').slice(0, -1).join('/')
20}
21
22/**
23 * Canonical in-app URL for a repo file, folder or any other repo view.
24 *
25 * GitHub-style README links are repo-relative (`../README.md`, `docs/`), but a
26 * `srcdoc` iframe has no URL of its own — it inherits the *parent document's*
27 * base, so the browser resolves them against `/app/<space>/repo/<repo>` and a
28 * copied or middle-clicked link loses the repo: `CHANGES.md` becomes
29 * `/app/minimax/repo/CHANGES.md`, whose last segment the router reads as the
30 * repo name. Rewriting every in-repo link to this absolute shape is what keeps
31 * "open in new tab" and "copy link address" honest.
32 */
33export function repoWebHref(
34 space: string,
35 repo: string,
36 parts: { path?: string; file?: string; rev?: string; tab?: string; extra?: Record<string, string> },
37): string {
38 const params = new URLSearchParams()
39 if (parts.rev) params.set('rev', parts.rev)
40 if (parts.tab && parts.tab !== 'code') params.set('tab', parts.tab)
41 if (parts.path) params.set('path', parts.path)
42 if (parts.file) params.set('file', parts.file)
43 for (const [key, value] of Object.entries(parts.extra ?? {})) params.set(key, value)
44 const query = params.toString()
45 const base = `/app/${encodeURIComponent(space)}/repo/${encodeURIComponent(repo)}`
46 return query ? `${base}?${query}` : base
47}
48
49/**
50 * Inverse of `repoWebHref`: read back an in-app repo URL produced by it.
51 *
52 * The README sandbox forwards its clicks to the host, and after link rewriting
53 * those hrefs are absolute app URLs rather than repo-relative paths, so the
54 * host needs to turn one back into a route. Returns null for any other URL
55 * (external site, another repo, a non-repo route).
56 */
57export function parseRepoAppHref(
58 href: string,
59 space: string,
60 repo: string,
61): { path?: string; file?: string; rev?: string; tab?: string } | null {
62 const prefix = `/app/${encodeURIComponent(space)}/repo/${encodeURIComponent(repo)}`
63 const [pathname, search = ''] = href.split('?')
64 if (pathname !== prefix) return null
65 const params = new URLSearchParams(search)
66 const out: { path?: string; file?: string; rev?: string; tab?: string } = {}
67 const path = params.get('path')
68 const file = params.get('file')
69 const rev = params.get('rev')
70 const tab = params.get('tab')
71 if (path) out.path = path
72 if (file) out.file = file
73 if (rev) out.rev = rev
74 if (tab) out.tab = tab
75 return out
76}
77
78export function parseWebHref(href: string, space: string, repo: string, rev: string): WebTarget {
79 const clean = href.split('#')[0].split('?')[0]
80 const prefix = `/${space}/${repo}`
81 if (clean === prefix || clean === `${prefix}/`) return { kind: 'up' }
82 if (!clean.startsWith(`${prefix}/`)) return null
83 let rest = clean.slice(prefix.length + 1)
84 try {
85 rest = decodeURIComponent(rest)
86 } catch {
87 /* leave the raw form when the author did not escape a % */
88 }
89 const match = /^(tree|blob|commits|commit)\/?([^/]*)\/?(.*)$/.exec(rest)
90 if (!match) return null
91 const [, kind, ref, tail] = match
92 if (kind === 'commit') {
93 // A commit link carries the full object id and no revision segment.
94 return /^[0-9a-f]{7,64}$/i.test(ref) ? { kind: 'commit', sha: ref.toLowerCase() } : null
95 }
96 // Never follow a link pinned to a different revision — it would silently
97 // change what "current revision" means for the rest of the page.
98 if (ref !== rev) return null
99 const path = tail.replace(/\/+$/, '')
100 if (kind === 'tree') return path ? { kind: 'tree', path } : null
101 if (kind === 'blob') return path ? { kind: 'blob', path } : null
102 return { kind: 'history', path }
103}
Added view/app/src/domains/repo/model/index.ts View view/app/src/domains/repo/model/index.ts
@@ -0,0 +1,174 @@
1/**
2 * Public API of entities/repo — the repository domain model shared by the
3 * repo features and the workspace page's repo area. Consumers import from
4 * '@app/domains/repo/model', never from deeper files.
5 *
6 * Importing this barrel also loads `ui/embed.css` globally: it styles
7 * server-rendered fragments wherever they appear (file tree, README sandbox,
8 * aside language bar), so every repo surface is guaranteed to have it.
9 */
10import '../embed.css'
11
12export { default as embedCssText } from '../embed.css?raw'
13export {
14 type ArchiveFormat,
15 type ArchiveResult,
16 createArchive,
17 createBundle,
18 queryArchive,
19} from './archives'
20// advanced reads: blame & submodules
21export {
22 type BlameRange,
23 blameLineMap,
24 fetchBlame,
25 fetchConflictDetail,
26 listSubmodules,
27 resolveSubmodule,
28 type SubmoduleRow,
29} from './blame'
30// code line comments
31export {
32 type CodeComment,
33 type CodeCommentPageData,
34 createCodeComment,
35 deleteCodeComment,
36 groupCommentsByLine,
37 listCodeComments,
38 resolveCodeComment,
39 unresolveCodeComment,
40 updateCodeComment,
41} from './code-comments'
42// commits & diffs
43export {
44 type CommitRow,
45 type CommitsPage,
46 fetchCommit,
47 fetchDiffStyle,
48 fetchNumstat,
49 fetchRawDiff,
50 listCommitsPage,
51} from './commits'
52export {
53 applyNumstat,
54 type DiffFile,
55 type DiffFileStatus,
56 type DiffHunk,
57 type DiffLine,
58 type DiffStyle,
59 EMPTY_TREE,
60 type NumstatRow,
61 normalizeDiffStyle,
62 parseHunks,
63 splitDiffFiles,
64 toSplitRows,
65} from './diff'
66// html fragments & web-path mapping
67export {
68 fetchHtmlFragment,
69 type HtmlFragment,
70 htmlFragmentUrl,
71 prefetchHtmlFragment,
72 useHtmlFragment,
73} from './fragment'
74export { parentPath, parseRepoAppHref, parseWebHref, repoWebHref } from './href'
75export {
76 type CodeActivity,
77 type ContributorRow,
78 fetchCodeActivity,
79 type LanguageStat,
80 listContributors,
81 readLanguageStats,
82 rescanLanguages,
83} from './insights'
84export {
85 createLfsLock,
86 type LfsLockPage,
87 listLfsLocks,
88 unlockLfsById,
89 unlockLfsPath,
90 verifyLfsLocks,
91} from './lfs'
92// protection rules (branch policies + whitelists)
93export {
94 addBranchWhitelist,
95 addTagWhitelist,
96 type BranchPolicy,
97 type BranchWhitelistRow,
98 type CreatePolicyInput,
99 createPolicy,
100 deletePolicy,
101 getPolicy,
102 listBranchWhitelists,
103 listPolicies,
104 listTagWhitelists,
105 listUserOptions,
106 removeBranchWhitelist,
107 removeTagWhitelist,
108 type TagWhitelistRow,
109 type UserOption,
110 updatePolicy,
111 type WhitelistKind,
112} from './protection'
113// refs (branches & tags)
114export {
115 type BranchRow,
116 createBranch,
117 createTag,
118 deleteBranch,
119 deleteTag,
120 fetchTag,
121 listBranches,
122 listTags,
123 renameBranch,
124 setDefaultBranch,
125 type TagRow,
126} from './refs'
127// releases
128export {
129 createRelease,
130 deleteRelease,
131 getRelease,
132 listReleases,
133 type Release,
134 type ReleaseListPage,
135 updateRelease,
136} from './releases'
137export {
138 type FetchResult,
139 fetchRemote,
140 type ImportResult,
141 importRemote,
142 listRemotes,
143 type PushResult,
144 pushRemote,
145 type RemoteRow,
146 removeRemote,
147 upsertRemote,
148} from './remotes'
149// repo metadata + clone urls
150export {
151 archiveRepo,
152 type CloneTarget,
153 cloneTargets,
154 fetchRepo,
155 listSpaceRepos,
156 primaryCloneUrl,
157 type RepoDetail,
158 type RepoSummary,
159 setRepoWatch,
160 starRepo,
161 unarchiveRepo,
162 unstarRepo,
163 updateRepo,
164} from './repo'
165// settings domains
166export { addTopic, listTopics, removeTopic, type TopicRow } from './topics'
167// tree & blob
168export {
169 type BlobPayload,
170 fetchBlob,
171 fetchTreeJson,
172 type TreeEntry,
173 type TreeMode,
174} from './tree'
Added view/app/src/domains/repo/model/insights.ts View view/app/src/domains/repo/model/insights.ts
@@ -0,0 +1,31 @@
1/** Insights domain: language statistics, contributors and code activity. */
2
3import { unwrapApi } from '@app/lib'
4import type {
5 EnvelopeCodeActivityDto,
6 EnvelopeVecContributorStatDto,
7 EnvelopeVecLanguageStatDto,
8} from '@app/services/api'
9import Apis from '@app/services/api'
10
11export type LanguageStat = NonNullable<EnvelopeVecLanguageStatDto['data']>[number]
12export type ContributorRow = NonNullable<EnvelopeVecContributorStatDto['data']>[number]
13export type CodeActivity = NonNullable<EnvelopeCodeActivityDto['data']>
14
15export const readLanguageStats = (space: string, repo: string) =>
16 unwrapApi<LanguageStat[]>(Apis.analysis.languages_read({ pathParams: { space_name: space, repo_name: repo } }))
17
18/** Rescans the tree and persists the result (also updates primary_language). */
19export const rescanLanguages = (space: string, repo: string) =>
20 unwrapApi<LanguageStat[]>(Apis.analysis.languages({ pathParams: { space_name: space, repo_name: repo } }))
21
22export const listContributors = (space: string, repo: string) =>
23 unwrapApi<ContributorRow[]>(Apis.analysis.contributors({ pathParams: { space_name: space, repo_name: repo } }))
24
25export const fetchCodeActivity = (space: string, repo: string, branch?: string) =>
26 unwrapApi<CodeActivity>(
27 Apis.analysis.code_activity({
28 pathParams: { space_name: space, repo_name: repo },
29 params: { branch: branch || undefined },
30 }),
31 )
Added view/app/src/domains/repo/model/lfs.ts View view/app/src/domains/repo/model/lfs.ts
@@ -0,0 +1,50 @@
1/** LFS domain: lock list/create/unlock and the ours/theirs verify split. */
2
3import { unwrapApi } from '@app/lib'
4import type { EnvelopeLfsLockListResponse, EnvelopeLfsVerifyLocksResponse } from '@app/services/api'
5import Apis from '@app/services/api'
6
7export type LfsLockRow = NonNullable<NonNullable<EnvelopeLfsLockListResponse['data']>['locks']>[number]
8export type LfsLockPage = NonNullable<EnvelopeLfsLockListResponse['data']>
9export type LfsVerifyPage = NonNullable<EnvelopeLfsVerifyLocksResponse['data']>
10
11export const listLfsLocks = (space: string, repo: string, cursor?: string, limit = 50) =>
12 unwrapApi<LfsLockPage>(
13 Apis.lfs.list_locks({
14 pathParams: { space_name: space, repo_name: repo },
15 params: { cursor: cursor || undefined, limit },
16 }),
17 )
18
19export const createLfsLock = (space: string, repo: string, path: string, oid?: string) =>
20 unwrapApi<unknown>(
21 Apis.lfs.lock({
22 pathParams: { space_name: space, repo_name: repo },
23 data: { path, oid: oid || null },
24 }),
25 )
26
27export const unlockLfsPath = (space: string, repo: string, path: string, force = false) =>
28 unwrapApi<null>(
29 Apis.lfs.unlock({
30 pathParams: { space_name: space, repo_name: repo },
31 params: { path, force },
32 }),
33 )
34
35export const unlockLfsById = (space: string, repo: string, lockId: string, force = false) =>
36 unwrapApi<null>(
37 Apis.lfs.unlock_by_id({
38 pathParams: { space_name: space, repo_name: repo, lock_id: lockId },
39 params: { force },
40 }),
41 )
42
43/** Splits locks into ours / theirs, which is what `force` unlocks are for. */
44export const verifyLfsLocks = (space: string, repo: string, limit = 50) =>
45 unwrapApi<LfsVerifyPage>(
46 Apis.lfs.verify_locks({
47 pathParams: { space_name: space, repo_name: repo },
48 params: { limit },
49 }),
50 )
Added view/app/src/domains/repo/model/protection.ts View view/app/src/domains/repo/model/protection.ts
@@ -0,0 +1,156 @@
1/**
2 * Protection-rule domain: branch-protection policies (CRUD) and the push /
3 * merge / approval whitelists attached to them, plus the tag-protection
4 * whitelist endpoints (tag *policies* themselves have no HTTP surface yet —
5 * whitelist rows can only be managed against a known policy UUID).
6 */
7
8import { type ApiEnvelope, unwrapApi } from '@app/lib'
9import type {
10 EnvelopeBranchPolicyDto,
11 EnvelopeVecBranchWhitelistDto,
12 EnvelopeVecTagWhitelistDto,
13 WhitelistKind,
14} from '@app/services/api'
15import Apis from '@app/services/api'
16
17export type { WhitelistKind }
18
19export type BranchPolicy = NonNullable<EnvelopeBranchPolicyDto['data']>
20export type BranchWhitelistRow = NonNullable<NonNullable<EnvelopeVecBranchWhitelistDto['data']>[number]>
21export type TagWhitelistRow = NonNullable<NonNullable<EnvelopeVecTagWhitelistDto['data']>[number]>
22
23export const listPolicies = (space: string, repo: string) =>
24 unwrapApi<BranchPolicy[] | null>(
25 Apis.protection.list_policies({ pathParams: { space_name: space, repo_name: repo } }),
26 ).then((rows) => rows ?? [])
27
28/** Fresh copy of one policy (edit dialog opens from the list row). */
29export const getPolicy = (space: string, repo: string, policyId: string) =>
30 unwrapApi<BranchPolicy>(
31 Apis.protection.get_policy({ pathParams: { space_name: space, repo_name: repo, policy_id: policyId } }),
32 )
33
34export const createPolicy = (space: string, repo: string, cmd: CreatePolicyInput) =>
35 unwrapApi<BranchPolicy>(
36 Apis.protection.create_policy({
37 pathParams: { space_name: space, repo_name: repo },
38 data: {
39 branch_pattern: cmd.branch_pattern,
40 required_approvals: cmd.required_approvals,
41 enable_push: cmd.enable_push,
42 enable_force_push: cmd.enable_force_push,
43 enable_push_whitelist: cmd.enable_push_whitelist,
44 enable_merge_whitelist: cmd.enable_merge_whitelist,
45 enable_approvals_whitelist: cmd.enable_approvals_whitelist,
46 block_on_rejected_reviews: cmd.block_on_rejected_reviews,
47 block_on_change_requested_reviews: cmd.block_on_change_requested_reviews,
48 block_on_outdated_branch: cmd.block_on_outdated_branch,
49 dismiss_stale_approvals: cmd.dismiss_stale_approvals,
50 require_signed_commits: cmd.require_signed_commits,
51 protected_file_patterns: cmd.protected_file_patterns || null,
52 unprotected_file_patterns: cmd.unprotected_file_patterns || null,
53 },
54 }),
55 )
56
57export const updatePolicy = (space: string, repo: string, policyId: string, cmd: Partial<CreatePolicyInput>) =>
58 unwrapApi<BranchPolicy>(
59 Apis.protection.update_policy({
60 pathParams: { space_name: space, repo_name: repo, policy_id: policyId },
61 data: {
62 ...cmd,
63 protected_file_patterns: cmd.protected_file_patterns ?? null,
64 unprotected_file_patterns: cmd.unprotected_file_patterns ?? null,
65 },
66 }),
67 )
68
69export const deletePolicy = (space: string, repo: string, policyId: string) =>
70 unwrapApi<null>(
71 Apis.protection.delete_policy({ pathParams: { space_name: space, repo_name: repo, policy_id: policyId } }),
72 )
73
74export const listBranchWhitelists = (space: string, repo: string, policyId: string) =>
75 unwrapApi<BranchWhitelistRow[] | null>(
76 Apis.branch_protection.list_branch_whitelists({
77 pathParams: { space_name: space, repo_name: repo, policy_id: policyId },
78 }),
79 ).then((rows) => rows ?? [])
80
81export const addBranchWhitelist = (
82 space: string,
83 repo: string,
84 policyId: string,
85 cmd: { kind: WhitelistKind; user_id?: number | null; group_id?: string | null },
86) =>
87 unwrapApi<BranchWhitelistRow>(
88 Apis.branch_protection.add_branch_whitelist({
89 pathParams: { space_name: space, repo_name: repo, policy_id: policyId },
90 data: { kind: cmd.kind, user_id: cmd.user_id ?? null, group_id: cmd.group_id ?? null },
91 }),
92 )
93
94export const removeBranchWhitelist = (space: string, repo: string, policyId: string, whitelistId: string) =>
95 unwrapApi<null>(
96 Apis.branch_protection.remove_branch_whitelist({
97 pathParams: { space_name: space, repo_name: repo, policy_id: policyId, whitelist_id: whitelistId },
98 }),
99 )
100
101export const listTagWhitelists = (space: string, repo: string, policyId: string) =>
102 unwrapApi<TagWhitelistRow[] | null>(
103 Apis.tag_protection.list_tag_whitelists({
104 pathParams: { space_name: space, repo_name: repo, policy_id: policyId },
105 }),
106 ).then((rows) => rows ?? [])
107
108export const addTagWhitelist = (space: string, repo: string, policyId: string, userId: number) =>
109 unwrapApi<TagWhitelistRow>(
110 Apis.tag_protection.add_tag_whitelist({
111 pathParams: { space_name: space, repo_name: repo, policy_id: policyId },
112 data: { user_id: userId },
113 }),
114 )
115
116export const removeTagWhitelist = (space: string, repo: string, policyId: string, userId: number) =>
117 unwrapApi<null>(
118 Apis.tag_protection.remove_tag_whitelist({
119 pathParams: { space_name: space, repo_name: repo, policy_id: policyId, user_id: userId },
120 }),
121 )
122
123/** Create/update payload minus server-managed fields. */
124export interface CreatePolicyInput {
125 branch_pattern: string
126 required_approvals?: number | null
127 enable_push?: boolean | null
128 enable_force_push?: boolean | null
129 enable_push_whitelist?: boolean | null
130 enable_merge_whitelist?: boolean | null
131 enable_approvals_whitelist?: boolean | null
132 block_on_rejected_reviews?: boolean | null
133 block_on_change_requested_reviews?: boolean | null
134 block_on_outdated_branch?: boolean | null
135 dismiss_stale_approvals?: boolean | null
136 require_signed_commits?: boolean | null
137 protected_file_patterns?: string | null
138 unprotected_file_patterns?: string | null
139}
140
141/** Space members as user options for the whitelist pickers. */
142export interface UserOption {
143 user_id: string
144 username: string
145 display_name: string
146}
147
148export const listUserOptions = async (space: string): Promise<UserOption[]> => {
149 // The generic Page schema types `items` structurally; members rows are cast.
150 const page = await unwrapApi<{ items?: { user?: UserOption | null }[] } | null>(
151 Apis.space.list_space_members({ pathParams: { space_name: space }, params: { limit: 100 } }) as unknown as Promise<
152 ApiEnvelope<{ items?: { user?: UserOption | null }[] } | null>
153 >,
154 )
155 return (page?.items ?? []).map((row) => row.user).filter((u): u is UserOption => Boolean(u?.user_id))
156}
Added view/app/src/domains/repo/model/refs.ts View view/app/src/domains/repo/model/refs.ts
@@ -0,0 +1,96 @@
1/**
2 * Refs domain: branches and tags — wire types and endpoints. The branch list
3 * is the source of truth for the ref picker and the branch management tab.
4 */
5
6import { unwrapApi } from '@app/lib'
7import type { EnvelopePageBranchDto, EnvelopePageTagDto } from '@app/services/api'
8import Apis from '@app/services/api'
9
10export type BranchRow = NonNullable<NonNullable<EnvelopePageBranchDto['data']>['items']>[number]
11export type TagRow = NonNullable<NonNullable<EnvelopePageTagDto['data']>['items']>[number]
12export type RefKind = 'branch' | 'tag'
13
14/** Server-side total matters: `RepoDto.branches_count` can lag behind the
15 * actual refs (imported repos), and the branch list is the truth. */
16export async function listBranches(
17 space: string,
18 repo: string,
19 limit = 100,
20): Promise<{ items: BranchRow[]; total: number }> {
21 const page = await unwrapApi<{ items?: BranchRow[]; total?: number }>(
22 Apis.branches.list_branches({
23 pathParams: { space_name: space, repo_name: repo },
24 params: { limit },
25 }),
26 )
27 const items = page.items ?? []
28 return { items, total: page.total ?? items.length }
29}
30
31export const createBranch = (space: string, repo: string, name: string, startPoint: string) =>
32 unwrapApi<BranchRow>(
33 Apis.branches.create_branch({
34 pathParams: { space_name: space, repo_name: repo },
35 data: { name, start_point: startPoint || null },
36 }),
37 )
38
39export const renameBranch = (space: string, repo: string, name: string, newName: string) =>
40 unwrapApi<null>(
41 Apis.branches.rename_branch({
42 pathParams: { space_name: space, repo_name: repo, name },
43 data: { new_name: newName },
44 }),
45 )
46
47/** `force` is what lets the server delete a branch nothing else contains. */
48export const deleteBranch = (space: string, repo: string, name: string, force = false) =>
49 unwrapApi<null>(
50 Apis.branches.delete_branch({
51 pathParams: { space_name: space, repo_name: repo, name },
52 params: { force },
53 }),
54 )
55
56export const setDefaultBranch = (space: string, repo: string, name: string) =>
57 unwrapApi<null>(
58 Apis.branches.set_default_branch({
59 pathParams: { space_name: space, repo_name: repo, name },
60 }),
61 )
62
63export async function listTags(space: string, repo: string, limit = 100): Promise<{ items: TagRow[]; total: number }> {
64 const page = await unwrapApi<{ items?: TagRow[]; total?: number }>(
65 Apis.tags.list_tags({
66 pathParams: { space_name: space, repo_name: repo },
67 params: { limit },
68 }),
69 )
70 const items = page.items ?? []
71 return { items, total: page.total ?? items.length }
72}
73
74/** Single tag incl. tagger/message for annotated tags. */
75export const fetchTag = (space: string, repo: string, name: string) =>
76 unwrapApi<TagRow>(Apis.tags.get_tag({ pathParams: { space_name: space, repo_name: repo, name } }))
77
78export const createTag = (
79 space: string,
80 repo: string,
81 cmd: { name: string; target: string; message?: string | null; annotated?: boolean },
82) =>
83 unwrapApi<TagRow>(
84 Apis.tags.create_tag({
85 pathParams: { space_name: space, repo_name: repo },
86 data: {
87 name: cmd.name,
88 target: cmd.target,
89 message: cmd.message ?? null,
90 annotated: cmd.annotated ?? false,
91 },
92 }),
93 )
94
95export const deleteTag = (space: string, repo: string, name: string) =>
96 unwrapApi<null>(Apis.tags.delete_tag({ pathParams: { space_name: space, repo_name: repo, name } }))
Added view/app/src/domains/repo/model/releases.ts View view/app/src/domains/repo/model/releases.ts
@@ -0,0 +1,50 @@
1/** 发布记录领域:仓库 release 的读写。 */
2
3import { unwrapApi } from '@app/lib'
4import type { CreateReleaseCmd, EnvelopeReleaseDto, UpdateReleaseCmd } from '@app/services/api'
5import Apis from '@app/services/api'
6
7export type Release = NonNullable<EnvelopeReleaseDto['data']>
8
9/** 生成器把分页响应用泛型 `Page` 表达,因此在这里声明行类型。 */
10export interface ReleaseListPage {
11 items: Release[]
12 total: number
13 limit: number
14 offset: number
15}
16
17export const listReleases = (
18 space: string,
19 repo: string,
20 params: { limit?: number; offset?: number; draft?: boolean } = {},
21) =>
22 Apis.releases
23 .list_releases({
24 pathParams: { space_name: space, repo_name: repo },
25 params: { limit: params.limit, offset: params.offset, draft: params.draft },
26 })
27 .then((res) => res as unknown as { data?: ReleaseListPage })
28 .then((res) => res.data ?? { items: [], total: 0, limit: 0, offset: 0 })
29
30export const getRelease = (space: string, repo: string, tag: string) =>
31 unwrapApi<Release>(Apis.releases.get_release({ pathParams: { space_name: space, repo_name: repo, tag } }))
32
33export const createRelease = (space: string, repo: string, cmd: CreateReleaseCmd) =>
34 unwrapApi<Release>(
35 Apis.releases.create_release({
36 pathParams: { space_name: space, repo_name: repo },
37 data: cmd,
38 }),
39 )
40
41export const updateRelease = (space: string, repo: string, tag: string, cmd: UpdateReleaseCmd) =>
42 unwrapApi<Release>(
43 Apis.releases.update_release({
44 pathParams: { space_name: space, repo_name: repo, tag },
45 data: cmd,
46 }),
47 )
48
49export const deleteRelease = (space: string, repo: string, tag: string) =>
50 unwrapApi<unknown>(Apis.releases.delete_release({ pathParams: { space_name: space, repo_name: repo, tag } }))
Added view/app/src/domains/repo/model/remotes.ts View view/app/src/domains/repo/model/remotes.ts
@@ -0,0 +1,58 @@
1/**
2 * Remotes domain: remote CRUD plus fetch / push / mirror-import.
3 * Tokens are per-request only — the server never stores them.
4 */
5
6import { unwrapApi } from '@app/lib'
7import type {
8 EnvelopeFetchResultDto,
9 EnvelopeImportResultDto,
10 EnvelopePushResultDto,
11 EnvelopeVecRemoteDto,
12} from '@app/services/api'
13import Apis from '@app/services/api'
14
15export type RemoteRow = NonNullable<EnvelopeVecRemoteDto['data']>[number]
16export type FetchResult = NonNullable<EnvelopeFetchResultDto['data']>
17export type PushResult = NonNullable<EnvelopePushResultDto['data']>
18export type ImportResult = NonNullable<EnvelopeImportResultDto['data']>
19
20export const listRemotes = (space: string, repo: string) =>
21 unwrapApi<RemoteRow[]>(Apis.remotes.list_remotes({ pathParams: { space_name: space, repo_name: repo } }))
22
23export const upsertRemote = (space: string, repo: string, name: string, url: string) =>
24 unwrapApi<null>(
25 Apis.remotes.upsert_remote({
26 pathParams: { space_name: space, repo_name: repo, name },
27 data: { url },
28 }),
29 )
30
31export const removeRemote = (space: string, repo: string, name: string) =>
32 unwrapApi<null>(Apis.remotes.remove({ pathParams: { space_name: space, repo_name: repo, name } }))
33
34/** `token` is sent per request only — it is never stored on the server. */
35export const fetchRemote = (space: string, repo: string, name: string, token?: string) =>
36 unwrapApi<FetchResult>(
37 Apis.remotes.fetch({
38 pathParams: { space_name: space, repo_name: repo },
39 data: { remote: name, auth: token ? { token } : null },
40 }),
41 )
42
43export const pushRemote = (space: string, repo: string, name: string, refspec?: string, token?: string) =>
44 unwrapApi<PushResult>(
45 Apis.remotes.push({
46 pathParams: { space_name: space, repo_name: repo },
47 data: { remote: name, refspec: refspec || null, auth: token ? { token } : null },
48 }),
49 )
50
51/** Mirror-import every ref from an external URL into this repo. */
52export const importRemote = (space: string, repo: string, url: string, token?: string) =>
53 unwrapApi<ImportResult>(
54 Apis.remotes.clone_import({
55 pathParams: { space_name: space, repo_name: repo },
56 data: { url, auth: token ? { token } : null },
57 }),
58 )
Added view/app/src/domains/repo/model/repo.ts View view/app/src/domains/repo/model/repo.ts
@@ -0,0 +1,83 @@
1/**
2 * Repository metadata domain: wire type, JSON endpoints and clone-url helpers.
3 */
4
5import { unwrapApi } from '@app/lib'
6import type { EnvelopePageRepoSummaryDto, EnvelopeRepoDto, UpdateRepoCmd, WatchMode } from '@app/services/api'
7import Apis from '@app/services/api'
8
9export type RepoDetail = NonNullable<EnvelopeRepoDto['data']>
10
11/** Row of the space's repo list (`GET /repos/{space}`). */
12export type RepoSummary = NonNullable<NonNullable<EnvelopePageRepoSummaryDto['data']>['items']>[number]
13
14/**
15 * Every repo the viewer can see in one space, name-sorted.
16 *
17 * The endpoint paginates, but the pickers that need this list (linking a PR to
18 * an issue, choosing an import target) address a repo by name and cannot work
19 * against a page, so the whole list is requested in one go.
20 */
21export const listSpaceRepos = (space: string) =>
22 unwrapApi<{ items?: RepoSummary[] }>(
23 Apis.repos.list_repos({
24 pathParams: { space_name: space },
25 params: { limit: 100, sort: 'name' },
26 }),
27 ).then((page) => page?.items ?? [])
28
29export const fetchRepo = (space: string, repo: string) =>
30 unwrapApi<RepoDetail>(Apis.repos.get_repo({ pathParams: { space_name: space, repo_name: repo } }))
31
32export const updateRepo = (space: string, repo: string, cmd: UpdateRepoCmd) =>
33 unwrapApi<RepoDetail>(Apis.repos.update_repo({ pathParams: { space_name: space, repo_name: repo }, data: cmd }))
34
35export const archiveRepo = (space: string, repo: string) =>
36 unwrapApi<unknown>(Apis.repos.archive_repo({ pathParams: { space_name: space, repo_name: repo } }))
37
38export const unarchiveRepo = (space: string, repo: string) =>
39 unwrapApi<unknown>(Apis.repos.unarchive_repo({ pathParams: { space_name: space, repo_name: repo } }))
40
41export const starRepo = (space: string, repo: string) =>
42 unwrapApi<unknown>(Apis.repos.star_repo({ pathParams: { space_name: space, repo_name: repo } }))
43
44export const unstarRepo = (space: string, repo: string) =>
45 unwrapApi<unknown>(Apis.repos.unstar_repo({ pathParams: { space_name: space, repo_name: repo } }))
46
47export const setRepoWatch = (space: string, repo: string, mode: WatchMode) =>
48 unwrapApi<unknown>(
49 Apis.repos.upsert_watch({
50 pathParams: { space_name: space, repo_name: repo },
51 data: { mode },
52 }),
53 )
54
55/* ---- clone urls ---------------------------------------------------------- */
56
57export interface CloneTarget {
58 /** Wire id from the API (`https` covers the http:// scheme too). */
59 protocol: 'https' | 'ssh'
60 label: string
61 url: string
62}
63
64/**
65 * Clone addresses as the backend reports them (`RepoDto.http_clone_url` /
66 * `ssh_clone_url`, derived from `[git].base_url` / `http_clone_base_url` /
67 * `ssh_clone_*`). Never fabricate them client-side — the git transport may
68 * listen on another host/port than the web app.
69 */
70export function cloneTargets(repo: { http_clone_url?: string | null; ssh_clone_url?: string | null }): CloneTarget[] {
71 const targets: CloneTarget[] = []
72 if (repo.http_clone_url) targets.push({ protocol: 'https', label: 'HTTPS', url: repo.http_clone_url })
73 if (repo.ssh_clone_url) targets.push({ protocol: 'ssh', label: 'SSH', url: repo.ssh_clone_url })
74 return targets
75}
76
77/** Best url for command snippets: HTTPS first (works without a key setup). */
78export function primaryCloneUrl(
79 repo: { http_clone_url?: string | null; ssh_clone_url?: string | null },
80 fallback: string | null = null,
81): string | null {
82 return cloneTargets(repo)[0]?.url ?? fallback
83}
Added view/app/src/domains/repo/model/topics.ts View view/app/src/domains/repo/model/topics.ts
@@ -0,0 +1,25 @@
1/** Topics domain: repo topic chips — wire type and endpoints. */
2
3import { unwrapApi } from '@app/lib'
4import type { EnvelopeVecTopicDto } from '@app/services/api'
5import Apis from '@app/services/api'
6
7export type TopicRow = NonNullable<EnvelopeVecTopicDto['data']>[number]
8
9export const listTopics = (space: string, repo: string) =>
10 unwrapApi<TopicRow[]>(Apis.repos.list_repo_topics({ pathParams: { space_name: space, repo_name: repo } }))
11
12/** The server creates the topic on demand, so adding is idempotent. */
13export const addTopic = (space: string, repo: string, topic: string) =>
14 unwrapApi<TopicRow>(
15 Apis.repos.add_topic_to_repo({
16 pathParams: { space_name: space, repo_name: repo, topic_name: topic },
17 }),
18 )
19
20export const removeTopic = (space: string, repo: string, topic: string) =>
21 unwrapApi<null>(
22 Apis.repos.remove_topic_from_repo({
23 pathParams: { space_name: space, repo_name: repo, topic_name: topic },
24 }),
25 )
Added view/app/src/domains/repo/model/tree.ts View view/app/src/domains/repo/model/tree.ts
@@ -0,0 +1,100 @@
1/**
2 * Tree & blob domain: wire types, row normalization and the tree/blob
3 * endpoints.
4 *
5 * `GET /repos/{s}/{r}/tree/{rev}` is serialised as a bare `serde_json::Value`
6 * on the server, so the generated client types it as `unknown`. The real shape
7 * comes from gitbaby's `TreeEntry`:
8 * • `oid` → `{ "Sha1": [20 raw bytes] }` (or `Sha256`), not hex — and the
9 * blob endpoints only accept the full hex oid;
10 * • `name` → already repo-root-relative (`assets/logo.jpg`), so joining it
11 * onto the requested directory again yields `assets/assets/…`.
12 * Normalise both once, here.
13 */
14
15import { unwrapApi } from '@app/lib'
16import type { EnvelopeBlobContentDto } from '@app/services/api'
17import Apis from '@app/services/api'
18
19export type BlobPayload = NonNullable<EnvelopeBlobContentDto['data']>
20
21/** gitbaby `TreeEntry` wire mode tag. */
22export type TreeMode = 'Blob' | 'Exec' | 'Symlink' | 'Tree' | 'Submodule' | 'Commit'
23
24/** Normalized tree row — see `normalizeTreeRows` for what the raw payload looks like. */
25export interface TreeEntry {
26 /** Repo-root-relative path. */
27 path: string
28 /** Basename, for display. */
29 name: string
30 mode: TreeMode
31 /** Full hex object id, or null when the server sent something unparseable. */
32 oid: string | null
33 size: number | null
34}
35
36const MODES: TreeMode[] = ['Blob', 'Exec', 'Symlink', 'Tree', 'Submodule', 'Commit']
37
38function bytesToHex(value: unknown): string | null {
39 if (!Array.isArray(value) || value.length === 0) return null
40 let out = ''
41 for (const byte of value) {
42 if (typeof byte !== 'number' || !Number.isInteger(byte) || byte < 0 || byte > 255) return null
43 out += byte.toString(16).padStart(2, '0')
44 }
45 return out
46}
47
48/** `{ Sha1: […] }` / `[…]` / plain hex string → lowercase hex oid. */
49function oidToHex(oid: unknown): string | null {
50 if (typeof oid === 'string') return /^[0-9a-f]{4,64}$/i.test(oid) ? oid.toLowerCase() : null
51 const direct = bytesToHex(oid)
52 if (direct) return direct
53 if (oid && typeof oid === 'object' && !Array.isArray(oid)) {
54 for (const value of Object.values(oid as Record<string, unknown>)) {
55 const hex = bytesToHex(value)
56 if (hex) return hex
57 }
58 }
59 return null
60}
61
62function basename(path: string): string {
63 const trimmed = path.replace(/\/+$/, '')
64 const slash = trimmed.lastIndexOf('/')
65 return slash === -1 ? trimmed : trimmed.slice(slash + 1)
66}
67
68function normalizeTreeRows(rows: unknown, dir: string): TreeEntry[] {
69 if (!Array.isArray(rows)) return []
70 const out: TreeEntry[] = []
71 for (const raw of rows) {
72 if (!raw || typeof raw !== 'object') continue
73 const entry = raw as Record<string, unknown>
74 const rawName = typeof entry.name === 'string' ? entry.name.trim() : ''
75 if (!rawName || rawName === '.') continue
76 const path = dir && !rawName.startsWith(`${dir}/`) ? `${dir}/${rawName}` : rawName
77 const mode = MODES.includes(entry.mode as TreeMode) ? (entry.mode as TreeMode) : 'Blob'
78 out.push({
79 path,
80 name: basename(path),
81 mode,
82 oid: oidToHex(entry.oid),
83 size: typeof entry.size === 'number' ? entry.size : null,
84 })
85 }
86 return out
87}
88
89export async function fetchTreeJson(space: string, repo: string, revision: string, path: string): Promise<TreeEntry[]> {
90 const data = await unwrapApi<unknown>(
91 Apis.trees.get_tree({
92 pathParams: { space_name: space, repo_name: repo, revision },
93 params: { path, show_trees: true },
94 }),
95 )
96 return normalizeTreeRows(data, path)
97}
98
99export const fetchBlob = (space: string, repo: string, oid: string) =>
100 unwrapApi<BlobPayload>(Apis.blobs.blob_content({ pathParams: { space_name: space, repo_name: repo, oid } }))
Added view/app/src/domains/repo/releases/ui/releases-tab.tsx View view/app/src/domains/repo/releases/ui/releases-tab.tsx
@@ -0,0 +1,384 @@
1import {
2 Alert,
3 ErrorPanel,
4 Markdown,
5 Modal,
6 modalInputClass,
7 modalLabelClass,
8 modalTextareaClass,
9 PanelNote,
10} from '@app/components'
11import {
12 createRelease,
13 deleteRelease,
14 getRelease,
15 listReleases,
16 listTags,
17 type Release,
18 updateRelease,
19} from '@app/domains/repo/model'
20import { useT } from '@app/i18n'
21import { fmtRelative, useAsync } from '@app/lib'
22import { Button } from '@app/ui'
23import { Loader2, Pencil, Plus, Tag, Trash2 } from 'lucide-react'
24import { useMemo, useState } from 'react'
25
26export interface ReleasesTabProps {
27 space: string
28 repo: string
29 /** Opened release tag ('' = list). Owned by the route. */
30 selected: string
31 onSelect: (tag: string) => void
32 /** Whether the viewer may write (draft/publish/edit/delete). */
33 canWrite: boolean
34}
35
36/** 发布页签:草稿与已发布 release 的列表 / 详情。 */
37export function ReleasesTab({ space, repo, selected, onSelect, canWrite }: ReleasesTabProps) {
38 const { t } = useT()
39 const [tick, setTick] = useState(0)
40 const [busy, setBusy] = useState(false)
41 const [error, setError] = useState<string | null>(null)
42 const [notice, setNotice] = useState<string | null>(null)
43 const [creating, setCreating] = useState(false)
44
45 const list = useAsync(() => listReleases(space, repo, { limit: 50 }), [space, repo, tick])
46 const detail = useAsync(
47 () => (selected ? getRelease(space, repo, selected) : Promise.resolve(null)),
48 [space, repo, selected, tick],
49 )
50 const failure = (list.error ?? detail.error) as { code?: number; message?: string } | undefined
51
52 async function run<T>(fn: () => Promise<T>, ok: string) {
53 setBusy(true)
54 setError(null)
55 setNotice(null)
56 try {
57 await fn()
58 setNotice(ok)
59 setTick((t) => t + 1)
60 } catch (f) {
61 setError(f instanceof Error ? f.message : t('repo.error.actionFailedShort'))
62 } finally {
63 setBusy(false)
64 }
65 }
66
67 if (list.loading && !list.data) return <PanelNote text={t('repo.release.loading')} />
68 if (failure)
69 return (
70 <ErrorPanel
71 status={failure.code ?? 0}
72 what={t('repo.release.what')}
73 note={failure.message}
74 onRetry={() => setTick((n) => n + 1)}
75 />
76 )
77
78 return (
79 <div className="space-y-3">
80 <div className="flex flex-wrap items-center gap-2">
81 {canWrite ? (
82 <Button className="h-8 text-[12.5px]" disabled={busy} onClick={() => setCreating(true)}>
83 <Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
84 {t('repo.release.create')}
85 </Button>
86 ) : null}
87 {selected ? (
88 <Button variant="secondary" className="h-8 text-[12.5px]" onClick={() => onSelect('')}>
89 {t('repo.release.backToList')}
90 </Button>
91 ) : null}
92 </div>
93
94 {notice ? (
95 <p className="rounded-lg border border-shell-hairline bg-shell-field px-3 py-2 text-[12.5px] text-shell-ink-muted">
96 {notice}
97 </p>
98 ) : null}
99 {error ? (
100 <p className="text-[12.5px] text-[#b42318]" role="alert">
101 {error}
102 </p>
103 ) : null}
104
105 {selected ? (
106 detail.data ? (
107 <ReleaseDetail
108 release={detail.data}
109 canWrite={canWrite}
110 busy={busy}
111 onSave={(title, content) =>
112 void run(() => updateRelease(space, repo, selected, { title, content }), t('repo.release.savedDraft'))
113 }
114 onPublish={() =>
115 void run(() => updateRelease(space, repo, selected, { is_draft: false }), t('repo.release.publishedNow'))
116 }
117 onTogglePrerelease={(next) =>
118 void run(
119 () => updateRelease(space, repo, selected, { is_prerelease: next }),
120 t('repo.release.prereleaseUpdated'),
121 )
122 }
123 onDelete={() =>
124 void run(async () => {
125 await deleteRelease(space, repo, selected)
126 onSelect('')
127 }, t('repo.release.deletedNow'))
128 }
129 />
130 ) : (
131 <PanelNote text={t('repo.release.notFound')} />
132 )
133 ) : (
134 <ReleaseList releases={list.data?.items ?? []} onSelect={onSelect} />
135 )}
136
137 {creating ? (
138 <CreateReleaseModal
139 space={space}
140 repo={repo}
141 busy={busy}
142 taken={(list.data?.items ?? []).map((item) => item.tag_name)}
143 onClose={() => setCreating(false)}
144 onSubmit={(tag, title, draft) =>
145 void run(async () => {
146 await createRelease(space, repo, { tag_name: tag, title, is_draft: draft })
147 setCreating(false)
148 onSelect(tag)
149 }, t('repo.release.created'))
150 }
151 />
152 ) : null}
153 </div>
154 )
155}
156
157function ReleaseList({ releases, onSelect }: { releases: Release[]; onSelect: (tag: string) => void }) {
158 const { t, tc } = useT()
159 if (!releases.length) {
160 return <PanelNote text={t('repo.release.none')} />
161 }
162 return (
163 <ul className="space-y-2">
164 {releases.map((release) => (
165 <li key={release.id}>
166 <button
167 type="button"
168 onClick={() => onSelect(release.tag_name)}
169 className="flex w-full flex-wrap items-center gap-2 rounded-xl border border-shell-hairline bg-shell-surface px-4 py-3 text-left transition-colors hover:border-shell-tile"
170 >
171 <Tag className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
172 <span className="font-medium">{release.title || release.tag_name}</span>
173 <span className="font-mono text-[12px] text-shell-ink-muted">{release.tag_name}</span>
174 {release.is_draft ? (
175 <Badge tone="amber">{t('repo.release.draft')}</Badge>
176 ) : (
177 <Badge tone="green">{t('repo.release.publishedBadge')}</Badge>
178 )}
179 {release.is_prerelease ? <Badge tone="violet">{t('repo.release.prerelease')}</Badge> : null}
180 <span className="ml-auto text-[11.5px] text-shell-ink-muted">
181 {tc('repo.release.commitCount', release.num_commits)} · {fmtRelative(release.created_at)}
182 </span>
183 </button>
184 </li>
185 ))}
186 </ul>
187 )
188}
189
190function ReleaseDetail({
191 release,
192 canWrite,
193 busy,
194 onSave,
195 onPublish,
196 onTogglePrerelease,
197 onDelete,
198}: {
199 release: Release
200 canWrite: boolean
201 busy: boolean
202 onSave: (title: string, content: string) => void
203 onPublish: () => void
204 onTogglePrerelease: (next: boolean) => void
205 onDelete: () => void
206}) {
207 const { t } = useT()
208 const [title, setTitle] = useState(release.title)
209 const [content, setContent] = useState(release.content ?? '')
210 const editable = canWrite && release.is_draft
211
212 return (
213 <section className="space-y-3 rounded-xl border border-shell-hairline bg-shell-surface p-4">
214 <header className="flex flex-wrap items-center gap-2">
215 <Tag className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
216 <span className="font-mono text-[13px]">{release.tag_name}</span>
217 {release.is_draft ? (
218 <Badge tone="amber">{t('repo.release.draft')}</Badge>
219 ) : (
220 <Badge tone="green">{t('repo.release.publishedBadge')}</Badge>
221 )}
222 {release.is_prerelease ? <Badge tone="violet">{t('repo.release.prerelease')}</Badge> : null}
223 <span className="text-[11.5px] text-shell-ink-muted">
224 {release.publisher_name || release.publisher?.display_name || t('repo.release.system')} ·{' '}
225 {fmtRelative(release.updated_at)}
226 </span>
227 </header>
228
229 {editable ? (
230 <>
231 <label className={modalLabelClass}>
232 {t('repo.release.titleField')}
233 <input value={title} onChange={(event) => setTitle(event.target.value)} className={modalInputClass} />
234 </label>
235 <label className={modalLabelClass}>
236 {t('repo.release.body')}
237 <textarea
238 value={content}
239 onChange={(event) => setContent(event.target.value)}
240 rows={12}
241 className={modalTextareaClass}
242 />
243 </label>
244 <div className="flex flex-wrap items-center gap-2">
245 <Button className="h-8 text-[12.5px]" disabled={busy} onClick={() => onSave(title, content)}>
246 {t('repo.release.saveDraft')}
247 </Button>
248 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={onPublish}>
249 {t('repo.release.publish')}
250 </Button>
251 <Button
252 variant="ghost"
253 className="h-8 text-[12px]"
254 disabled={busy}
255 onClick={() => onTogglePrerelease(!release.is_prerelease)}
256 >
257 {release.is_prerelease ? t('repo.release.unmarkPrerelease') : t('repo.release.markPrerelease')}
258 </Button>
259 <Button
260 variant="ghost"
261 className="ml-auto h-8 text-[12px] text-[#b42318]"
262 disabled={busy}
263 onClick={onDelete}
264 >
265 <Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
266 {t('common.action.delete')}
267 </Button>
268 </div>
269 </>
270 ) : (
271 <>
272 <h3 className="text-[15px] font-semibold">{release.title || release.tag_name}</h3>
273 {release.content ? (
274 <Markdown>{release.content}</Markdown>
275 ) : (
276 <p className="text-[13px] text-shell-ink-muted">{t('repo.release.noBody')}</p>
277 )}
278 {canWrite ? <p className="text-[12px] text-shell-ink-muted">{t('repo.release.publishedImmutable')}</p> : null}
279 </>
280 )}
281 </section>
282 )
283}
284
285function CreateReleaseModal({
286 space,
287 repo,
288 busy,
289 taken,
290 onClose,
291 onSubmit,
292}: {
293 space: string
294 repo: string
295 busy: boolean
296 /** 已有 release 的标签;这些标签不能再建一条记录。 */
297 taken: string[]
298 onClose: () => void
299 onSubmit: (tag: string, title: string, draft: boolean) => void
300}) {
301 const { t } = useT()
302 const [tag, setTag] = useState('')
303 const [title, setTitle] = useState('')
304 const [draft, setDraft] = useState(true)
305 // Release 只能建立在已经存在的标签上,因此标签必须来自仓库里真实的 refs,
306 // 而不是让用户手输一个可能不存在的名字。
307 const tags = useAsync(() => listTags(space, repo, 200), [space, repo, taken.join(',')])
308 const owned = useMemo(() => new Set(taken), [taken])
309 const rows = useMemo(() => (tags.data?.items ?? []).filter((item) => !owned.has(item.name)), [tags.data, owned])
310 const empty = Boolean(tags.data) && rows.length === 0
311 return (
312 <Modal open title={t('repo.release.new')} onClose={onClose}>
313 <div className="space-y-3">
314 <Alert tone="info">{t('repo.release.pickTagNote')}</Alert>
315 <label className={modalLabelClass}>
316 {t('repo.tag.name')}
317 {tags.loading && !tags.data ? (
318 <span className="mt-1 block text-[12.5px] text-shell-ink-muted">{t('repo.tags.loading')}</span>
319 ) : empty ? (
320 <span className="mt-1 block text-[12.5px] text-shell-ink-muted">{t('repo.release.noTags')}</span>
321 ) : (
322 <select
323 value={tag}
324 onChange={(event) => {
325 const next = event.target.value
326 setTag(next)
327 if (!title.trim()) setTitle(next)
328 }}
329 className={modalInputClass}
330 >
331 <option value="">{t('repo.release.selectTag')}</option>
332 {rows.map((item) => (
333 <option key={item.name} value={item.name}>
334 {item.name}
335 {item.created_at ? t('repo.release.tagAge', { age: fmtRelative(item.created_at) }) : ''}
336 </option>
337 ))}
338 </select>
339 )}
340 </label>
341 {tags.error ? (
342 <p className="text-[12.5px] text-[#b42318]" role="alert">
343 {t('repo.release.tagsFailed')}
344 </p>
345 ) : null}
346 <label className={modalLabelClass}>
347 {t('repo.release.titleOptional')}
348 <input value={title} onChange={(event) => setTitle(event.target.value)} className={modalInputClass} />
349 </label>
350 <label className="flex items-center gap-2 text-[13px]">
351 <input type="checkbox" checked={draft} onChange={(event) => setDraft(event.target.checked)} />
352 {t('repo.release.saveAsDraft')}
353 </label>
354 <div className="flex justify-end gap-2">
355 <Button variant="secondary" className="h-8 text-[12.5px]" onClick={onClose}>
356 {t('common.action.cancel')}
357 </Button>
358 <Button
359 className="h-8 text-[12.5px]"
360 disabled={busy || !tag}
361 onClick={() => onSubmit(tag, title.trim() || tag, draft)}
362 >
363 {busy ? (
364 <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
365 ) : (
366 <Pencil className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
367 )}
368 {t('common.action.create')}
369 </Button>
370 </div>
371 </div>
372 </Modal>
373 )
374}
375
376const BADGE_TONE = {
377 green: 'text-[#1a7f37] bg-[rgba(35,165,90,0.12)]',
378 amber: 'text-[#8a6100] bg-[rgba(240,178,50,0.16)]',
379 violet: 'text-[#6937c3] bg-[rgba(120,82,196,0.12)]',
380} as const
381
382function Badge({ tone, children }: { tone: keyof typeof BADGE_TONE; children: React.ReactNode }) {
383 return <span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${BADGE_TONE[tone]}`}>{children}</span>
384}
Added view/app/src/domains/repo/releases/index.ts View view/app/src/domains/repo/releases/index.ts
@@ -0,0 +1 @@
1export { ReleasesTab, type ReleasesTabProps } from './ui/releases-tab'
Added view/app/src/domains/repo/settings/model/lfs.ts View view/app/src/domains/repo/settings/model/lfs.ts
@@ -0,0 +1,93 @@
1/**
2 * Git LFS file locks — client-side mirrors of the server rules plus readable
3 * explanations for the failure modes.
4 *
5 * The backend answers `POST /repos/{s}/{r}/lfs/locks` with:
6 * 401 unauthorized session cookie missing / expired
7 * 403 forbidden caller lacks Write (or Admin for `force`)
8 * 404 not found repo resolved, but `enable_lfs` is off (`GitError::NotFound`)
9 * 409 conflict unique-key violation
10 * 400 <detail> `validate_lock_path` / oid normalisation message
11 * 500 internal server error e.g. the (repo_id, oid) foreign key into `git_lfs`
12 * Only the 400/409 bodies carry a real message, so every other status needs to
13 * be explained from the code alone — otherwise a rejected write looks identical
14 * to a dead button.
15 */
16import { t } from '@app/i18n'
17import { ApiFailure } from '@app/lib'
18
19/** `MAX_LOCK_PATH_LEN` in `lib/git/src/lfs/validate.rs`. */
20export const MAX_LOCK_PATH_LEN = 1024
21
22/**
23 * Git LFS paths are repo-root relative and forward-slash separated; normalise
24 * what the user typed before validating or sending it.
25 */
26export function normalizeLockPath(raw: string): string {
27 let path = raw.trim().replace(/\\/g, '/')
28 while (path.startsWith('./')) path = path.slice(2)
29 return path.replace(/^\/+/, '')
30}
31
32/** Mirrors `validate_lock_path`; returns the problem in Chinese, or null. */
33export function lockPathProblem(raw: string): string | null {
34 const path = normalizeLockPath(raw)
35 if (!path) return t('repo.locks.validate.required')
36 if (path.includes('\0')) return t('repo.locks.validate.nul')
37 if (path.length > MAX_LOCK_PATH_LEN) return t('repo.locks.validate.tooLong', { count: MAX_LOCK_PATH_LEN })
38 if (path.endsWith('/')) return t('repo.locks.validate.trailingSlash')
39 if (path.split('/').includes('..')) return t('repo.locks.validate.dotdot')
40 return null
41}
42
43/** `ApiError::payload(...)` strings — no information beyond the status code. */
44const GENERIC_ENVELOPE_MESSAGES = new Set([
45 'bad request',
46 'unauthorized',
47 'forbidden',
48 'not found',
49 'conflict',
50 'resource already exists',
51 'internal server error',
52 'request failed validation',
53])
54
55/** Server-side `GitError::InvalidArgument` texts worth phrasing in Chinese. */
56function explainBadRequest(detail: string): string | null {
57 const text = detail.toLowerCase()
58 if (text.includes('must not be empty')) return t('repo.locks.validate.empty')
59 if (text.includes('exceeds')) return t('repo.locks.validate.tooLongDetail', { detail })
60 if (text.includes('nul')) return t('repo.locks.validate.nul')
61 if (text.includes('invalid oid')) return t('repo.locks.validate.badOid')
62 return null
63}
64
65const STATUS_EXPLANATION: Record<number, string> = {
66 401: t('repo.locks.explanation.401'),
67 403: t('repo.locks.explanation.403'),
68 404: t('repo.locks.explanation.404'),
69 409: t('repo.locks.explanation.409'),
70 500: t('repo.locks.explanation.500'),
71}
72
73/** Turn any thrown value into a sentence that says *why* the write failed. */
74export function lfsLockFailureText(err: unknown): string {
75 if (err instanceof ApiFailure) {
76 if (err.code === 400) {
77 const detail = err.message && err.message !== 'request failed' ? err.message : ''
78 const translated = detail ? explainBadRequest(detail) : null
79 if (translated) return translated
80 return detail ? t('repo.locks.rejected', { detail }) : t('repo.locks.invalid')
81 }
82 const explanation = STATUS_EXPLANATION[err.code]
83 const detail = err.message && err.message !== 'request failed' ? err.message : ''
84 // `ApiError` answers 401/403/404/500 (and the 409 unique-key case) with a
85 // fixed English constant, so repeat it only when it carries real detail —
86 // which `GitApiError::BadRequest`/`Conflict(msg)` do.
87 const informative = detail && !GENERIC_ENVELOPE_MESSAGES.has(detail.toLowerCase()) ? detail : ''
88 if (explanation && informative) return t('repo.locks.explainedWithDetail', { explanation, detail: informative })
89 return explanation ?? informative ?? t('repo.locks.failedWithCode', { code: err.code })
90 }
91 const text = (err as Error | undefined)?.message
92 return text && text !== 'request failed' ? text : t('repo.error.actionFailed')
93}
Added view/app/src/domains/repo/settings/model/sections.ts View view/app/src/domains/repo/settings/model/sections.ts
@@ -0,0 +1,70 @@
1/**
2 * The repo settings area's second-level sections, in strip order.
3 *
4 * The ids double as the `?section=` value of the repo route (written by
5 * `pages/workspace`'s `useRepoViewLocation`), which is why they live in the
6 * model instead of the component: the page reads and validates them, the
7 * settings UI renders them, and neither may drift from the other.
8 */
9import { t } from '@app/i18n'
10
11export type RepoSettingsSection =
12 | 'general'
13 | 'features'
14 | 'topics'
15 | 'remotes'
16 | 'archives'
17 | 'insights'
18 | 'locks'
19 | 'protection'
20
21export const REPO_SETTINGS_SECTIONS: ReadonlyArray<{ id: RepoSettingsSection; label: string }> = [
22 {
23 id: 'general',
24 get label() {
25 return t('repo.settings.nav.general')
26 },
27 },
28 {
29 id: 'features',
30 get label() {
31 return t('repo.settings.nav.features')
32 },
33 },
34 {
35 id: 'topics',
36 get label() {
37 return t('repo.settings.nav.topics')
38 },
39 },
40 {
41 id: 'remotes',
42 get label() {
43 return t('repo.settings.nav.remotes')
44 },
45 },
46 {
47 id: 'archives',
48 get label() {
49 return t('repo.settings.nav.archives')
50 },
51 },
52 {
53 id: 'insights',
54 get label() {
55 return t('repo.settings.nav.insights')
56 },
57 },
58 {
59 id: 'locks',
60 get label() {
61 return t('repo.settings.nav.locks')
62 },
63 },
64 {
65 id: 'protection',
66 get label() {
67 return t('repo.settings.nav.protection')
68 },
69 },
70]
Added view/app/src/domains/repo/settings/ui/sections/archives-settings.tsx View view/app/src/domains/repo/settings/ui/sections/archives-settings.tsx
@@ -0,0 +1,208 @@
1import {
2 type ArchiveFormat,
3 archiveRepo,
4 createArchive,
5 createBundle,
6 queryArchive,
7 unarchiveRepo,
8} from '@app/domains/repo/model'
9import { t, useT } from '@app/i18n'
10import { formatBytes } from '@app/lib'
11import { Button } from '@app/ui'
12import { useState } from 'react'
13import { Fact, Field, inputCls, Notice, SectionHead } from '../parts'
14import { useAction } from '../use-action'
15
16const FORMATS: Array<{ value: ArchiveFormat; get label(): string }> = [
17 {
18 value: 'zip',
19 get label() {
20 return 'zip'
21 },
22 },
23 {
24 value: 'tar_gz',
25 get label() {
26 return 'tar.gz'
27 },
28 },
29 {
30 value: 'tar_bz2',
31 get label() {
32 return 'tar.bz2'
33 },
34 },
35 {
36 value: 'tar',
37 get label() {
38 return t('repo.archives.formatTar')
39 },
40 },
41]
42
43/**
44 * Archive state (`POST /archive|/unarchive`) plus on-demand downloads
45 * (`POST /archives`, `POST /archives/bundle`, `GET /archives/{revision}`).
46 * Download links are short-lived, so they are shown only after a request.
47 */
48export function ArchivesSettings({
49 space,
50 repo,
51 repoDto,
52 onChanged,
53}: {
54 space: string
55 repo: string
56 repoDto: { is_archived: boolean; default_branch: string; archived_at?: string | null }
57 onChanged: () => void
58}) {
59 const { t } = useT()
60 const { busy, notice, error, run } = useAction()
61 const [confirmArchive, setConfirmArchive] = useState(false)
62 const [revision, setRevision] = useState(repoDto.default_branch)
63 const [format, setFormat] = useState<ArchiveFormat>('zip')
64 const [pathFilter, setPathFilter] = useState('')
65 const [link, setLink] = useState<{ url: string; size: number; label: string } | null>(null)
66
67 async function toggleArchived(next: boolean) {
68 setConfirmArchive(false)
69 const ok = await run(
70 () => (next ? archiveRepo(space, repo) : unarchiveRepo(space, repo)),
71 next ? t('repo.archives.archivedNote') : t('repo.archives.restored'),
72 )
73 if (ok) onChanged()
74 }
75
76 return (
77 <div className="max-w-[720px]">
78 <SectionHead title={t('repo.settings.nav.archives')} sub={t('repo.archives.sub')} />
79 <Notice notice={notice} error={error} />
80
81 <div className="mb-8 rounded-lg border-2 border-shell-hairline bg-shell-field p-4">
82 <h4 className="mb-1 text-[13.5px] font-bold">
83 {t('repo.archives.status', {
84 state: repoDto.is_archived ? t('repo.archives.statusArchived') : t('repo.archives.statusWritable'),
85 })}
86 </h4>
87 <p className="mb-3 text-[12.5px] text-shell-ink-muted">{t('repo.archives.archiveNote')}</p>
88 {repoDto.is_archived ? (
89 <Button variant="secondary" disabled={busy} onClick={() => void toggleArchived(false)}>
90 {t('repo.archives.restore')}
91 </Button>
92 ) : confirmArchive ? (
93 <div className="flex gap-2">
94 <Button
95 className="bg-[#f23f43] hover:brightness-95"
96 disabled={busy}
97 onClick={() => void toggleArchived(true)}
98 >
99 {t('repo.archives.confirmArchive')}
100 </Button>
101 <Button variant="secondary" onClick={() => setConfirmArchive(false)}>
102 {t('common.action.cancel')}
103 </Button>
104 </div>
105 ) : (
106 <Button variant="secondary" disabled={busy} onClick={() => setConfirmArchive(true)}>
107 {t('repo.archives.archive')}
108 </Button>
109 )}
110 </div>
111
112 <h4 className="mb-3 text-[13px] font-bold">{t('repo.archives.snapshotTitle')}</h4>
113 <div className="grid gap-3 sm:grid-cols-2">
114 <Field label={t('repo.archives.revision')}>
115 <input
116 className={inputCls}
117 value={revision}
118 onChange={(e) => setRevision(e.target.value)}
119 placeholder="main / v1.0 / sha"
120 />
121 </Field>
122 <Field label={t('repo.archives.format')}>
123 <select className={inputCls} value={format} onChange={(e) => setFormat(e.target.value as ArchiveFormat)}>
124 {FORMATS.map((item) => (
125 <option key={item.value} value={item.value}>
126 {item.label}
127 </option>
128 ))}
129 </select>
130 </Field>
131 </div>
132 <Field label={t('repo.archives.pathPrefix')} hint={t('repo.archives.pathPrefixHint')}>
133 <input className={inputCls} value={pathFilter} onChange={(e) => setPathFilter(e.target.value)} />
134 </Field>
135 <div className="mb-4 flex flex-wrap gap-2">
136 <Button
137 disabled={busy || !revision.trim()}
138 onClick={() =>
139 void run(async () => {
140 const result = await createArchive(space, repo, {
141 revision: revision.trim(),
142 format,
143 path_filter: pathFilter.trim() || undefined,
144 })
145 setLink(
146 result.download_url
147 ? { url: result.download_url, size: result.size, label: `${revision.trim()} (${format})` }
148 : null,
149 )
150 }, t('repo.archives.created'))
151 }
152 >
153 {t('repo.archives.createAndLink')}
154 </Button>
155 <Button
156 variant="secondary"
157 disabled={busy || !revision.trim()}
158 title={t('repo.archives.reuseHint')}
159 onClick={() =>
160 void run(async () => {
161 const result = await queryArchive(space, repo, revision.trim(), format, pathFilter.trim() || undefined)
162 setLink(
163 result.download_url
164 ? { url: result.download_url, size: result.size, label: `${revision.trim()} (${format})` }
165 : null,
166 )
167 }, t('repo.archives.linkRefreshed'))
168 }
169 >
170 {t('repo.archives.reuse')}
171 </Button>
172 <Button
173 variant="secondary"
174 disabled={busy}
175 onClick={() =>
176 void run(async () => {
177 const result = await createBundle(space, repo)
178 setLink(
179 result.download_url
180 ? { url: result.download_url, size: result.size, label: t('repo.archives.bundleLabel') }
181 : null,
182 )
183 }, t('repo.archives.bundleCreated'))
184 }
185 >
186 {t('repo.archives.bundle')}
187 </Button>
188 </div>
189 {link ? (
190 <div className="mb-6 rounded-lg border-2 border-[#f0b232]/50 bg-shell-field p-3">
191 <dl className="mb-2 space-y-1 text-[12.5px] text-shell-ink-muted">
192 <Fact label={t('repo.archives.factContent')}>{link.label}</Fact>
193 <Fact label={t('repo.archives.factSize')}>{formatBytes(link.size)}</Fact>
194 </dl>
195 <button
196 type="button"
197 onClick={() => window.open(link.url, '_blank', 'noopener,noreferrer')}
198 className="block max-w-full break-all text-left font-mono text-[12px] text-shell-ink underline decoration-shell-hairline underline-offset-2"
199 title={t('repo.archives.openDownload')}
200 >
201 {link.url}
202 </button>
203 <p className="mt-1.5 text-[11.5px] text-shell-ink-muted">{t('repo.archives.linkNote')}</p>
204 </div>
205 ) : null}
206 </div>
207 )
208}
Added view/app/src/domains/repo/settings/ui/sections/features-settings.tsx View view/app/src/domains/repo/settings/ui/sections/features-settings.tsx
@@ -0,0 +1,153 @@
1import { type RepoDetail, updateRepo } from '@app/domains/repo/model'
2import { type MessageKey, useT } from '@app/i18n'
3import { Button } from '@app/ui'
4import { ArrowDown, ArrowUp } from 'lucide-react'
5import { useState } from 'react'
6import { Notice, SectionHead } from '../parts'
7
8/** 可由设置重排序的中间页签,顺序与站点默认一致。 */
9const REORDERABLE: string[] = ['commits', 'pulls', 'branches', 'releases']
10
11const TAB_KEYS: Record<string, MessageKey> = {
12 commits: 'shell.repo.tabs.commits',
13 pulls: 'shell.repo.tabs.pulls',
14 branches: 'shell.repo.tabs.branches',
15 releases: 'shell.repo.tabs.releases',
16}
17
18/** 只保留已知中间页签、去重,缺失项按默认顺序补齐。 */
19function normalize(order: readonly string[]): string[] {
20 const out: string[] = []
21 for (const id of order) if (REORDERABLE.includes(id) && !out.includes(id)) out.push(id)
22 for (const id of REORDERABLE) if (!out.includes(id)) out.push(id)
23 return out
24}
25
26/**
27 * 仓库页签配置:是否允许合并请求,以及中间页签的顺序。只有空间所有者能保存(服务端 PATCH repo 一律 owner-only)。
28 */
29export function FeaturesSettings({
30 space,
31 repo,
32 repoDto,
33 onChanged,
34}: {
35 space: string
36 repo: string
37 repoDto: RepoDetail
38 onChanged: () => void
39}) {
40 const { t } = useT()
41 const [draft, setDraft] = useState({
42 allow_pull_requests: repoDto.allow_pull_requests,
43 })
44 const [order, setOrder] = useState<string[]>(() => normalize(repoDto.tab_order))
45 const [busy, setBusy] = useState(false)
46 const [notice, setNotice] = useState<string | null>(null)
47 const [error, setError] = useState<string | null>(null)
48
49 const savedOrder = normalize(repoDto.tab_order)
50 const dirty = draft.allow_pull_requests !== repoDto.allow_pull_requests || order.join(',') !== savedOrder.join(',')
51
52 const move = (index: number, delta: -1 | 1) => {
53 setOrder((prev) => {
54 const target = index + delta
55 if (target < 0 || target >= prev.length) return prev
56 const next = [...prev]
57 ;[next[index], next[target]] = [next[target], next[index]]
58 return next
59 })
60 }
61
62 const save = async () => {
63 setBusy(true)
64 setNotice(null)
65 setError(null)
66 try {
67 await updateRepo(space, repo, { ...draft, tab_order: order })
68 setNotice(t('repo.featuresSettings.saved'))
69 onChanged()
70 } catch (err) {
71 setError(err instanceof Error ? err.message : t('repo.error.saveFailed'))
72 } finally {
73 setBusy(false)
74 }
75 }
76
77 const rows: Array<{ key: keyof typeof draft; label: string; hint: string }> = [
78 {
79 key: 'allow_pull_requests',
80 label: t('repo.featuresSettings.allowPr'),
81 hint: t('repo.featuresSettings.allowPrHint'),
82 },
83 ]
84
85 return (
86 <div>
87 <SectionHead title={t('repo.settings.nav.features')} sub={t('repo.featuresSettings.sub')} />
88 <Notice notice={notice} error={error} />
89
90 <ul className="mb-6 space-y-3">
91 {rows.map((row) => (
92 <li
93 key={row.key}
94 className="flex flex-wrap items-start gap-3 rounded-xl border border-shell-hairline bg-shell-field px-4 py-3"
95 >
96 <label className="flex flex-1 cursor-pointer items-start gap-3">
97 <input
98 type="checkbox"
99 className="mt-0.5 h-4 w-4 shrink-0 accent-[var(--shell-action)]"
100 checked={draft[row.key]}
101 onChange={(event) => setDraft((prev) => ({ ...prev, [row.key]: event.target.checked }))}
102 />
103 <span className="min-w-0">
104 <span className="block text-[13.5px] font-medium">{row.label}</span>
105 <span className="mt-1 block text-[12px] leading-5 text-shell-ink-muted">{row.hint}</span>
106 </span>
107 </label>
108 </li>
109 ))}
110 </ul>
111
112 <h4 className="mb-1 text-[14px] font-semibold">{t('repo.featuresSettings.orderTitle')}</h4>
113 <p className="mb-3 text-[12px] leading-5 text-shell-ink-muted">{t('repo.featuresSettings.orderHint')}</p>
114 <ul className="mb-6 space-y-2">
115 {order.map((id, index) => {
116 const label = t(TAB_KEYS[id])
117 return (
118 <li
119 key={id}
120 className="flex items-center gap-3 rounded-xl border border-shell-hairline bg-shell-field px-4 py-2.5"
121 >
122 <span className="min-w-0 flex-1 text-[13.5px] font-medium">{label}</span>
123 <button
124 type="button"
125 aria-label={t('repo.featuresSettings.moveUp', { name: label })}
126 title={t('repo.featuresSettings.moveUp', { name: label })}
127 disabled={index === 0}
128 onClick={() => move(index, -1)}
129 className="rounded-md p-1.5 text-shell-ink-muted transition-colors hover:bg-shell-active hover:text-shell-ink disabled:pointer-events-none disabled:opacity-40"
130 >
131 <ArrowUp className="h-3.5 w-3.5" aria-hidden />
132 </button>
133 <button
134 type="button"
135 aria-label={t('repo.featuresSettings.moveDown', { name: label })}
136 title={t('repo.featuresSettings.moveDown', { name: label })}
137 disabled={index === order.length - 1}
138 onClick={() => move(index, 1)}
139 className="rounded-md p-1.5 text-shell-ink-muted transition-colors hover:bg-shell-active hover:text-shell-ink disabled:pointer-events-none disabled:opacity-40"
140 >
141 <ArrowDown className="h-3.5 w-3.5" aria-hidden />
142 </button>
143 </li>
144 )
145 })}
146 </ul>
147
148 <Button disabled={busy || !dirty} onClick={() => void save()}>
149 {t('common.action.save')}
150 </Button>
151 </div>
152 )
153}
Added view/app/src/domains/repo/settings/ui/sections/general-settings.tsx View view/app/src/domains/repo/settings/ui/sections/general-settings.tsx
@@ -0,0 +1,146 @@
1import { listBranches, type RepoDetail, updateRepo } from '@app/domains/repo/model'
2import { useT } from '@app/i18n'
3import { fmtTime, formatBytes, repoKey, useAsync } from '@app/lib'
4import { Button, Checkbox } from '@app/ui'
5import { useState } from 'react'
6import { areaCls, Fact, Field, inputCls, Notice, SectionHead } from '../parts'
7import { useAction } from '../use-action'
8
9export interface SettingsSectionProps {
10 space: string
11 repo: string
12 repoDto: RepoDetail
13 /** Refetch the repo after a mutation (counts and flags change). */
14 onChanged: () => void
15}
16
17/** `PATCH /repos/{s}/{r}` — description, homepage, visibility, LFS, default branch. */
18export function GeneralSettings({ space, repo, repoDto, onChanged }: SettingsSectionProps) {
19 const { t } = useT()
20 const branches = useAsync(() => listBranches(space, repo).catch(() => null), [space, repo], {
21 cacheKey: repoKey(space, repo, 'refs:branches:100'),
22 })
23 const { busy, notice, error, run } = useAction()
24 const [form, setForm] = useState({
25 description: repoDto.description ?? '',
26 website: repoDto.website ?? '',
27 defaultBranch: repoDto.default_branch,
28 isPrivate: repoDto.is_private,
29 enableLfs: repoDto.enable_lfs,
30 })
31
32 const branchNames = (branches.data?.items ?? []).map((b) => b.name)
33 if (form.defaultBranch && !branchNames.includes(form.defaultBranch)) branchNames.unshift(form.defaultBranch)
34
35 const dirty =
36 form.description !== (repoDto.description ?? '') ||
37 form.website !== (repoDto.website ?? '') ||
38 form.defaultBranch !== repoDto.default_branch ||
39 form.isPrivate !== repoDto.is_private ||
40 form.enableLfs !== repoDto.enable_lfs
41
42 return (
43 <div className="max-w-[640px]">
44 <SectionHead title={t('repo.settings.nav.general')} sub={t('repo.settings.generalSub')} />
45 <Notice notice={notice} error={error} />
46 <Field label={t('repo.settings.field.description')} hint={`${form.description.length} / 512`}>
47 <textarea
48 rows={2}
49 maxLength={512}
50 className={areaCls}
51 value={form.description}
52 onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
53 />
54 </Field>
55 <Field label={t('repo.settings.field.website')} hint={t('repo.settings.field.websiteHint')}>
56 <input
57 className={inputCls}
58 placeholder="https://example.com"
59 value={form.website}
60 onChange={(e) => setForm((f) => ({ ...f, website: e.target.value }))}
61 />
62 </Field>
63 <Field
64 label={t('repo.settings.defaultBranch')}
65 hint={branches.error ? t('repo.settings.field.defaultBranchLoadFailed') : undefined}
66 >
67 <select
68 className={inputCls}
69 value={form.defaultBranch}
70 onChange={(e) => setForm((f) => ({ ...f, defaultBranch: e.target.value }))}
71 >
72 {branchNames.map((name) => (
73 <option key={name} value={name}>
74 {name}
75 </option>
76 ))}
77 </select>
78 </Field>
79 <label className="mb-3 flex items-center gap-2.5 text-[13.5px]">
80 <Checkbox
81 checked={form.isPrivate}
82 onChange={(v) => setForm((f) => ({ ...f, isPrivate: v }))}
83 aria-label={t('repo.settings.field.privateLabel')}
84 />
85 {t('repo.settings.field.private')}
86 </label>
87 <label className="mb-5 flex items-center gap-2.5 text-[13.5px]">
88 <Checkbox
89 checked={form.enableLfs}
90 onChange={(v) => setForm((f) => ({ ...f, enableLfs: v }))}
91 aria-label={t('repo.settings.field.enableLfs')}
92 />
93 {t('repo.settings.field.enableLfs')}
94 </label>
95 <div className="mb-8 flex items-center gap-3">
96 <Button
97 disabled={busy || !dirty}
98 onClick={() =>
99 void run(async () => {
100 await updateRepo(space, repo, {
101 description: form.description.trim() || null,
102 website: form.website.trim() || null,
103 default_branch: form.defaultBranch || null,
104 is_private: form.isPrivate,
105 enable_lfs: form.enableLfs,
106 })
107 onChanged()
108 }, t('repo.settings.generalSaved'))
109 }
110 >
111 {busy ? t('repo.action.saving') : t('repo.settings.saveSettings')}
112 </Button>
113 {dirty && !busy ? <span className="text-[12px] text-shell-ink-muted">{t('repo.settings.unsaved')}</span> : null}
114 </div>
115
116 <h4 className="mb-2 text-[13px] font-bold">{t('repo.settings.readonlyInfo')}</h4>
117 <dl className="space-y-1.5 text-[12.5px] text-shell-ink-muted">
118 <Fact label={t('repo.settings.fact.id')}>{repoDto.id}</Fact>
119 <Fact label={t('repo.settings.fact.objectFormat')}>{repoDto.object_format}</Fact>
120 <Fact label={t('repo.settings.fact.createdAt')}>{fmtTime(repoDto.created_at)}</Fact>
121 <Fact label={t('repo.settings.fact.updatedAt')}>{fmtTime(repoDto.updated_at)}</Fact>
122 <Fact label={t('repo.settings.fact.lastPushed')}>
123 {repoDto.last_pushed_at ? fmtTime(repoDto.last_pushed_at) : t('repo.settings.fact.never')}
124 </Fact>
125 <Fact label={t('repo.settings.fact.sizes')}>
126 {formatBytes(repoDto.git_size)} / {formatBytes(repoDto.lfs_size)}
127 </Fact>
128 <Fact label={t('repo.settings.fact.storageLimit')}>
129 {repoDto.storage_limit_size ? formatBytes(repoDto.storage_limit_size) : t('repo.settings.fact.unlimited')}
130 </Fact>
131 <Fact label={t('repo.settings.fact.counts')}>
132 {repoDto.branches_count} / {repoDto.tags_count} / {repoDto.commits_count}
133 </Fact>
134 <Fact label={t('repo.settings.fact.social')}>
135 {repoDto.pulls_count} / {repoDto.stars_count} / {repoDto.watchers_count}
136 </Fact>
137 <Fact label={t('repo.settings.fact.license')}>
138 {repoDto.license_name ?? t('repo.settings.fact.licenseUnknown')}
139 </Fact>
140 <Fact label={t('repo.settings.fact.primaryLanguage')}>
141 {repoDto.primary_language ?? t('repo.settings.fact.languageUnknown')}
142 </Fact>
143 </dl>
144 </div>
145 )
146}
Added view/app/src/domains/repo/settings/ui/sections/insights-settings.tsx View view/app/src/domains/repo/settings/ui/sections/insights-settings.tsx
@@ -0,0 +1,140 @@
1import { fetchCodeActivity, listContributors, readLanguageStats, rescanLanguages } from '@app/domains/repo/model'
2import { useT } from '@app/i18n'
3import { formatBytes, repoKey, useAsync } from '@app/lib'
4import { Button } from '@app/ui'
5import { useState } from 'react'
6import { inputCls, ListRow, Notice, SectionHead } from '../parts'
7import { useAction } from '../use-action'
8
9/**
10 * Repo analysis endpoints: `GET|POST /analysis/languages` (read / rescan),
11 * `GET /analysis/contributors`, `GET /analysis/code-activity?branch=`.
12 * The POST is the one that refreshes `primary_language` everywhere else.
13 */
14export function InsightsSettings({ space, repo, rev }: { space: string; repo: string; rev: string }) {
15 const { t, tc } = useT()
16 const [tick, setTick] = useState(0)
17 const { busy, notice, error, run } = useAction()
18 const [branch, setBranch] = useState(rev)
19 const languages = useAsync(() => readLanguageStats(space, repo).catch(() => []), [space, repo, tick], {
20 cacheKey: repoKey(space, repo, 'insights:languages'),
21 })
22 const contributors = useAsync(() => listContributors(space, repo).catch(() => []), [space, repo, tick], {
23 cacheKey: repoKey(space, repo, 'insights:contributors'),
24 })
25 const activity = useAsync(
26 () => fetchCodeActivity(space, repo, branch.trim() || undefined).catch(() => null),
27 [space, repo, branch, tick],
28 { cacheKey: repoKey(space, repo, `insights:activity:${branch.trim()}`) },
29 )
30
31 const stats = languages.data ?? []
32 const people = contributors.data ?? []
33 const totalSize = stats.reduce((sum, row) => sum + row.size, 0)
34
35 return (
36 <div className="max-w-[720px]">
37 <SectionHead title={t('repo.settings.nav.insights')} sub={t('repo.insights.sub')} />
38 <Notice notice={notice} error={error} />
39 <div className="mb-6 flex flex-wrap items-center gap-2">
40 <Button
41 disabled={busy}
42 onClick={() =>
43 void run(async () => {
44 await rescanLanguages(space, repo)
45 setTick((n) => n + 1)
46 }, t('repo.insights.rescanned'))
47 }
48 >
49 {busy ? t('repo.insights.scanning') : t('repo.insights.rescan')}
50 </Button>
51 <span className="text-[12px] text-shell-ink-muted">{t('repo.insights.rescanNote')}</span>
52 </div>
53
54 <h4 className="mb-2 text-[13px] font-bold">{t('repo.insights.languages', { count: stats.length })}</h4>
55 {languages.loading && !languages.data ? (
56 <p className="mb-6 text-[13px] text-shell-ink-muted">{t('common.action.loading')}</p>
57 ) : stats.length === 0 ? (
58 <p className="mb-6 text-[13px] text-shell-ink-muted">{t('repo.insights.languagesEmpty')}</p>
59 ) : (
60 <ul className="mb-6 space-y-2">
61 {stats.map((row) => (
62 <ListRow key={row.language}>
63 <span className="min-w-[140px] font-medium">
64 {row.language}
65 {row.is_primary ? (
66 <span className="ml-1.5 rounded-full bg-shell-active px-1.5 py-0.5 text-[10px]">
67 {t('repo.insights.primary')}
68 </span>
69 ) : null}
70 </span>
71 <span className="font-mono text-[12px] text-shell-ink-muted">
72 {(row.percentage_bp / 100).toFixed(2)}%
73 </span>
74 <span className="ml-auto font-mono text-[12px] text-shell-ink-muted">{formatBytes(row.size)}</span>
75 </ListRow>
76 ))}
77 <li className="px-3 pt-1 text-[11.5px] text-shell-ink-muted">
78 {t('repo.insights.total', { size: formatBytes(totalSize) })}
79 </li>
80 </ul>
81 )}
82
83 <h4 className="mb-2 text-[13px] font-bold">{t('repo.insights.contributors', { count: people.length })}</h4>
84 {people.length === 0 ? (
85 <p className="mb-6 text-[13px] text-shell-ink-muted">{t('repo.insights.contributorsEmpty')}</p>
86 ) : (
87 <ul className="mb-6 space-y-2">
88 {people.map((person) => (
89 <ListRow key={`${person.name}:${person.email}`}>
90 <span className="min-w-[120px] font-medium">{person.name || t('common.state.unknown')}</span>
91 <span className="truncate font-mono text-[12px] text-shell-ink-muted">{person.email}</span>
92 <span className="ml-auto shrink-0 font-mono text-[12px]">
93 {tc('repo.insights.commits', person.commits)} ·{' '}
94 <span className="text-[#1a7f37]">+{person.additions}</span>{' '}
95 <span className="text-[#b42318]">−{person.deletions}</span>
96 </span>
97 </ListRow>
98 ))}
99 </ul>
100 )}
101
102 <h4 className="mb-2 text-[13px] font-bold">{t('repo.insights.activity')}</h4>
103 <div className="mb-3 flex gap-2">
104 <input
105 className={`${inputCls} max-w-[220px]`}
106 value={branch}
107 onChange={(e) => setBranch(e.target.value)}
108 placeholder={t('repo.insights.branchPlaceholder')}
109 />
110 <Button variant="secondary" disabled={busy} onClick={() => setTick((n) => n + 1)}>
111 {t('common.action.refresh')}
112 </Button>
113 </div>
114 {activity.data ? (
115 <div className="rounded-lg border-2 border-shell-hairline bg-shell-field p-3 text-[12.5px]">
116 <p className="mb-2">
117 {t('repo.insights.activityTotal', { count: activity.data.total_commits })} ·{' '}
118 <span className="text-[#1a7f37]">+{activity.data.total_additions}</span>{' '}
119 <span className="text-[#b42318]">−{activity.data.total_deletions}</span>
120 </p>
121 <ul className="space-y-1">
122 {(activity.data.authors ?? []).slice(0, 12).map((author) => (
123 <li key={`${author.name}:${author.email}`} className="flex flex-wrap gap-2">
124 <span className="min-w-[120px]">{author.name || t('common.state.unknown')}</span>
125 <span className="font-mono text-shell-ink-muted">{author.commits}</span>
126 <span className="ml-auto font-mono text-shell-ink-muted">
127 +{author.additions} −{author.deletions}
128 </span>
129 </li>
130 ))}
131 </ul>
132 </div>
133 ) : (
134 <p className="text-[13px] text-shell-ink-muted">
135 {activity.loading ? t('common.action.loading') : t('repo.insights.activityEmpty')}
136 </p>
137 )}
138 </div>
139 )
140}
Added view/app/src/domains/repo/settings/ui/sections/locks-settings.tsx View view/app/src/domains/repo/settings/ui/sections/locks-settings.tsx
@@ -0,0 +1,327 @@
1import { Alert } from '@app/components'
2import {
3 createLfsLock,
4 fetchTreeJson,
5 listLfsLocks,
6 parentPath,
7 type TreeEntry,
8 unlockLfsById,
9 unlockLfsPath,
10 updateRepo,
11 verifyLfsLocks,
12} from '@app/domains/repo/model'
13import { useT } from '@app/i18n'
14import { fmtTime, formatBytes, repoKey, useAsync } from '@app/lib'
15import { Button } from '@app/ui'
16import { useState } from 'react'
17import { lfsLockFailureText, lockPathProblem, normalizeLockPath } from '../../model/lfs'
18import { inputCls, ListRow, Notice, SectionHead } from '../parts'
19import { useAction } from '../use-action'
20
21interface LocksSettingsProps {
22 space: string
23 repo: string
24 /** Revision the path browser walks; defaults to whatever the repo view uses. */
25 rev: string
26 enableLfs: boolean
27 /** Async refresh of the repo DTO after `enable_lfs` is flipped. */
28 onRepoChanged: () => void
29}
30
31/**
32 * Git LFS file locks: `GET /lfs/locks` (cursor paging), `POST /lfs/locks`,
33 * `POST /lfs/locks/verify` (ours vs theirs) and `DELETE /lfs/locks/{id}`.
34 *
35 * Writing a lock needs three things to line up, and each one used to fail
36 * silently: the repo must have LFS enabled, the caller needs Write, and the
37 * path has to satisfy the server rules. The section therefore (a) offers the
38 * `enable_lfs` switch inline, (b) lets the path be picked from the real tree
39 * instead of typed by hand, and (c) translates every status code into a
40 * sentence (`model/lfs.ts`) so a rejected write never looks like a dead button.
41 */
42export function LocksSettings({ space, repo, rev, enableLfs, onRepoChanged }: LocksSettingsProps) {
43 const { t } = useT()
44 const [tick, setTick] = useState(0)
45 const [cursor, setCursor] = useState<string | null>(null)
46 const [raw, setRaw] = useState('')
47 const [picked, setPicked] = useState(false)
48 const [touched, setTouched] = useState(false)
49 const [pendingDelete, setPendingDelete] = useState<string | null>(null)
50 const { busy, notice, error, run } = useAction()
51 const page = useAsync(() => listLfsLocks(space, repo, cursor ?? undefined), [space, repo, cursor, tick], {
52 cacheKey: repoKey(space, repo, `lfs-locks:${cursor ?? ''}`),
53 })
54 // `verify` runs a server-side scan (it reports fixed/stale locks), so its
55 // result must never be served from the store — it stays a live query.
56 const verify = useAsync(() => verifyLfsLocks(space, repo).catch(() => null), [space, repo, tick])
57
58 const locks = page.data?.locks ?? []
59 // `verify` tells us which locks belong to the caller; anything else needs force.
60 const mine = new Set((verify.data?.ours ?? []).map((lock) => lock.id))
61 const theirs = verify.data?.theirs?.length ?? 0
62
63 const path = normalizeLockPath(raw)
64 const problem = lockPathProblem(raw)
65 const hint = touched && problem ? problem : null
66
67 /** Re-throws with the localized reason so `Notice` explains the rejection. */
68 function explain<T>(action: Promise<T>): Promise<T> {
69 return action.catch((err) => {
70 throw new Error(lfsLockFailureText(err))
71 })
72 }
73
74 async function act(action: () => Promise<unknown>, done: string) {
75 const ok = await run(() => explain(action()), done)
76 if (ok) {
77 setTouched(false)
78 setTick((n) => n + 1)
79 }
80 return ok
81 }
82
83 function submit() {
84 setTouched(true)
85 if (problem || busy) return
86 void act(() => createLfsLock(space, repo, path), t('repo.locks.locked', { path }))
87 }
88
89 return (
90 <div className="max-w-[720px]">
91 <SectionHead title={t('repo.settings.nav.locks')} sub={t('repo.locks.sub')} />
92
93 {!enableLfs ? (
94 <div className="mb-4 flex flex-wrap items-center gap-3">
95 <Alert tone="info">{t('repo.locks.disabledNote')}</Alert>
96 <Button
97 variant="secondary"
98 className="h-8 shrink-0 text-[12.5px]"
99 disabled={busy}
100 onClick={() =>
101 void act(() => updateRepo(space, repo, { enable_lfs: true }), t('repo.locks.enabled')).then((ok) => {
102 if (ok) onRepoChanged()
103 })
104 }
105 >
106 {t('repo.settings.field.enableLfs')}
107 </Button>
108 </div>
109 ) : null}
110
111 <Notice notice={notice} error={error} />
112 {verify.data ? (
113 <p className="mb-4 text-[12.5px] text-shell-ink-muted">
114 {t('repo.locks.summary', { ours: verify.data.ours?.length ?? 0, theirs })}
115 </p>
116 ) : null}
117
118 <div className="mb-1.5 flex flex-wrap gap-2">
119 <input
120 className={inputCls}
121 placeholder={t('repo.locks.pathPlaceholder')}
122 aria-invalid={hint ? true : undefined}
123 value={raw}
124 onChange={(e) => {
125 setRaw(e.target.value)
126 if (touched) setTouched(false)
127 }}
128 onKeyDown={(e) => {
129 if (e.key === 'Enter') {
130 e.preventDefault()
131 submit()
132 }
133 }}
134 />
135 <Button variant="secondary" disabled={busy} onClick={() => setPicked((p) => !p)}>
136 {t('repo.locks.pickFile')}
137 </Button>
138 <Button disabled={busy} onClick={submit}>
139 {busy ? t('repo.locks.submitting') : t('repo.locks.create')}
140 </Button>
141 {path && !problem ? (
142 <Button
143 variant="secondary"
144 disabled={busy}
145 onClick={() => void act(() => unlockLfsPath(space, repo, path), t('repo.locks.unlocked', { path }))}
146 >
147 {t('repo.locks.releasePath')}
148 </Button>
149 ) : null}
150 </div>
151 <p className={`mb-4 text-[11.5px] ${hint ? 'text-error-foreground' : 'text-shell-ink-muted'}`}>
152 {hint ?? t('repo.locks.pathHint')}
153 </p>
154
155 {picked ? (
156 <PathPicker
157 space={space}
158 repo={repo}
159 rev={rev}
160 onPick={(next) => {
161 setRaw(next)
162 setPicked(false)
163 setTouched(false)
164 }}
165 onClose={() => setPicked(false)}
166 />
167 ) : null}
168
169 {page.loading && !page.data ? (
170 <p className="text-[13px] text-shell-ink-muted">{t('repo.locks.loading')}</p>
171 ) : locks.length === 0 ? (
172 <p className="text-[13px] text-shell-ink-muted">{t('repo.locks.empty')}</p>
173 ) : (
174 <ul className="mb-4 space-y-2">
175 {locks.map((lock) => (
176 <ListRow key={lock.id}>
177 <span className="min-w-0 flex-1 truncate font-mono text-[12.5px]" title={lock.path}>
178 {lock.path}
179 </span>
180 <span className="text-[12px] text-shell-ink-muted">{lock.owner?.name ?? t('common.state.unknown')}</span>
181 <span className="text-[12px] text-shell-ink-muted">{fmtTime(lock.locked_at)}</span>
182 <Button
183 variant="secondary"
184 className="h-7 text-[12px]"
185 disabled={busy}
186 onClick={() => {
187 if (pendingDelete !== lock.id) {
188 setPendingDelete(lock.id)
189 return
190 }
191 setPendingDelete(null)
192 const isMine = mine.size === 0 || mine.has(lock.id)
193 void act(
194 () => unlockLfsById(space, repo, lock.id, !isMine),
195 isMine
196 ? t('repo.locks.released', { path: lock.path })
197 : t('repo.locks.forceReleased', { path: lock.path }),
198 )
199 }}
200 >
201 {pendingDelete === lock.id
202 ? t('repo.locks.confirmRelease')
203 : mine.size > 0 && !mine.has(lock.id)
204 ? t('repo.locks.forceRelease')
205 : t('repo.locks.release')}
206 </Button>
207 </ListRow>
208 ))}
209 </ul>
210 )}
211
212 <div className="flex flex-wrap gap-2">
213 {page.data?.next_cursor ? (
214 <Button
215 variant="secondary"
216 className="h-8 text-[12.5px]"
217 disabled={busy}
218 onClick={() => setCursor(page.data!.next_cursor!)}
219 >
220 {t('repo.locks.loadMore')}
221 </Button>
222 ) : cursor ? (
223 <Button variant="secondary" className="h-8 text-[12.5px]" onClick={() => setCursor(null)}>
224 {t('repo.locks.firstPage')}
225 </Button>
226 ) : null}
227 <Button
228 variant="ghost"
229 className="h-8 text-[12.5px]"
230 disabled={page.loading}
231 onClick={() => setTick((n) => n + 1)}
232 >
233 {page.loading ? t('repo.action.refreshing') : t('common.action.refresh')}
234 </Button>
235 </div>
236 </div>
237 )
238}
239
240/** Read-only browser over `GET /tree/{rev}` that feeds the lock path input. */
241function PathPicker({
242 space,
243 repo,
244 rev,
245 onPick,
246 onClose,
247}: {
248 space: string
249 repo: string
250 rev: string
251 onPick: (path: string) => void
252 onClose: () => void
253}) {
254 const { t } = useT()
255 const [dir, setDir] = useState('')
256 const [tick, setTick] = useState(0)
257 const state = useAsync(() => fetchTreeJson(space, repo, rev, dir), [space, repo, rev, dir, tick], {
258 // Same key as the code tab's tree: one payload, two consumers.
259 cacheKey: repoKey(space, repo, `tree:${rev}:${dir}`),
260 })
261 const rows = state.data ?? []
262 const dirs = rows.filter((row) => row.mode === 'Tree')
263 const files = rows.filter((row) => row.mode !== 'Tree')
264 const failed = state.error ? t('repo.locks.treeFailed', { reason: lfsLockFailureText(state.error) }) : null
265
266 return (
267 <div className="mb-5 rounded-lg border-2 border-shell-hairline bg-shell-field p-2">
268 <div className="mb-1 flex items-center gap-2 px-1">
269 <span className="min-w-0 flex-1 truncate font-mono text-[12px] text-shell-ink-muted">
270 {rev} / {dir || '.'}
271 </span>
272 {dir ? (
273 <Button variant="ghost" className="h-7 text-[12px]" onClick={() => setDir(parentPath(dir))}>
274 {t('repo.locks.parentDir')}
275 </Button>
276 ) : null}
277 <Button variant="ghost" className="h-7 text-[12px]" onClick={() => setTick((n) => n + 1)}>
278 {t('common.action.refresh')}
279 </Button>
280 <Button variant="ghost" className="h-7 text-[12px]" onClick={onClose}>
281 {t('common.action.close')}
282 </Button>
283 </div>
284 {failed ? <p className="px-1 py-2 text-[12.5px] text-error-foreground">{failed}</p> : null}
285 {!failed && state.loading && rows.length === 0 ? (
286 <p className="px-1 py-2 text-[12.5px] text-shell-ink-muted">{t('repo.tree.loading')}</p>
287 ) : null}
288 {!failed && !state.loading && rows.length === 0 ? (
289 <p className="px-1 py-2 text-[12.5px] text-shell-ink-muted">{t('repo.tree.empty')}</p>
290 ) : null}
291 <ul className="max-h-64 overflow-y-auto">
292 {[...dirs, ...files].map((row) => (
293 <PickerRow key={row.path} row={row} onPick={onPick} onEnter={setDir} />
294 ))}
295 </ul>
296 </div>
297 )
298}
299
300function PickerRow({
301 row,
302 onPick,
303 onEnter,
304}: {
305 row: TreeEntry
306 onPick: (path: string) => void
307 onEnter: (path: string) => void
308}) {
309 const isDir = row.mode === 'Tree'
310 return (
311 <li>
312 <button
313 type="button"
314 className="flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left text-[12.5px] hover:bg-shell-active"
315 onClick={() => (isDir ? onEnter(row.path) : onPick(row.path))}
316 >
317 <span className="w-4 shrink-0 text-center text-shell-ink-muted">{isDir ? '▸' : '·'}</span>
318 <span className="min-w-0 flex-1 truncate font-mono" title={row.path}>
319 {row.name}
320 </span>
321 {row.size !== null ? (
322 <span className="shrink-0 text-[11.5px] text-shell-ink-muted">{formatBytes(row.size)}</span>
323 ) : null}
324 </button>
325 </li>
326 )
327}
Added view/app/src/domains/repo/settings/ui/sections/protection-settings.tsx View view/app/src/domains/repo/settings/ui/sections/protection-settings.tsx
@@ -0,0 +1,609 @@
1import { ErrorPanel, Modal, modalInputClass, modalLabelClass, PanelNote } from '@app/components'
2import {
3 addBranchWhitelist,
4 addTagWhitelist,
5 type BranchPolicy,
6 type BranchWhitelistRow,
7 type CreatePolicyInput,
8 createPolicy,
9 deletePolicy,
10 getPolicy,
11 listBranchWhitelists,
12 listPolicies,
13 listTagWhitelists,
14 listUserOptions,
15 removeBranchWhitelist,
16 removeTagWhitelist,
17 type TagWhitelistRow,
18 updatePolicy,
19 type WhitelistKind,
20} from '@app/domains/repo/model'
21import { t, useT } from '@app/i18n'
22import { useAsync } from '@app/lib'
23import { Button } from '@app/ui'
24import { Pencil, Plus, Trash2 } from 'lucide-react'
25import { useState } from 'react'
26import { areaCls, Field, inputCls, Notice, SectionHead } from '../parts'
27
28const KIND_LABEL: Record<WhitelistKind, string> = {
29 get Push() {
30 return t('repo.protection.kind.push')
31 },
32 get Merge() {
33 return t('repo.protection.kind.merge')
34 },
35 get Approval() {
36 return t('repo.protection.kind.approval')
37 },
38}
39
40const BOOL_FIELDS: Array<{ key: keyof CreatePolicyInput; get label(): string; hint?: string }> = [
41 {
42 key: 'enable_push',
43 get label() {
44 return t('repo.protection.field.enablePush')
45 },
46 },
47 {
48 key: 'enable_force_push',
49 get label() {
50 return t('repo.protection.field.enableForcePush')
51 },
52 },
53 {
54 key: 'enable_push_whitelist',
55 get label() {
56 return t('repo.protection.field.enablePushWhitelist')
57 },
58 },
59 {
60 key: 'enable_merge_whitelist',
61 get label() {
62 return t('repo.protection.field.enableMergeWhitelist')
63 },
64 },
65 {
66 key: 'enable_approvals_whitelist',
67 get label() {
68 return t('repo.protection.field.enableApprovalsWhitelist')
69 },
70 },
71 {
72 key: 'block_on_rejected_reviews',
73 get label() {
74 return t('repo.protection.field.blockOnRejected')
75 },
76 },
77 {
78 key: 'block_on_change_requested_reviews',
79 get label() {
80 return t('repo.protection.field.blockOnChangeRequested')
81 },
82 },
83 {
84 key: 'block_on_outdated_branch',
85 get label() {
86 return t('repo.protection.field.blockOnOutdated')
87 },
88 },
89 {
90 key: 'dismiss_stale_approvals',
91 get label() {
92 return t('repo.protection.field.dismissStale')
93 },
94 },
95 {
96 key: 'require_signed_commits',
97 get label() {
98 return t('repo.protection.field.requireSigned')
99 },
100 },
101]
102
103export interface ProtectionSettingsProps {
104 space: string
105 repo: string
106}
107
108/** 设置 · 保护规则:分支保护策略 CRUD、策略白名单,以及标签保护白名单。 */
109export function ProtectionSettings({ space, repo }: ProtectionSettingsProps) {
110 const { t } = useT()
111 const [tick, setTick] = useState(0)
112 const [notice, setNotice] = useState<string | null>(null)
113 const [error, setError] = useState<string | null>(null)
114 const [creating, setCreating] = useState(false)
115 const [editing, setEditing] = useState<BranchPolicy | null>(null)
116
117 const policies = useAsync(() => listPolicies(space, repo), [space, repo, tick])
118 const rows = policies.data ?? []
119 const failure = policies.error as { code?: number; message?: string } | undefined
120 const refresh = () => setTick((n) => n + 1)
121
122 async function run(label: string, fn: () => Promise<unknown>) {
123 setNotice(null)
124 setError(null)
125 try {
126 await fn()
127 setNotice(label + t('repo.protection.doneSuffix'))
128 refresh()
129 } catch (f) {
130 setError(
131 label +
132 t('repo.protection.failedSuffix') +
133 (f instanceof Error ? f.message : t('repo.protection.unknownError')),
134 )
135 }
136 }
137
138 return (
139 <div>
140 <SectionHead title={t('repo.settings.nav.protection')} sub={t('repo.protection.sub')} />
141 <Notice notice={notice} error={error} />
142 <div className="mb-4 flex items-center justify-between">
143 <p className="text-[12.5px] text-shell-ink-muted">{t('repo.protection.orderNote')}</p>
144 <Button className="h-8 gap-1 text-[12.5px]" onClick={() => setCreating(true)}>
145 <Plus className="h-3.5 w-3.5" aria-hidden />
146 {t('repo.protection.create')}
147 </Button>
148 </div>
149
150 {policies.loading && !policies.data ? <PanelNote text={t('repo.protection.loading')} /> : null}
151 {!policies.loading && failure ? (
152 <ErrorPanel
153 status={failure.code ?? 0}
154 what={t('repo.protection.what')}
155 note={failure.message}
156 onRetry={refresh}
157 />
158 ) : null}
159 {!policies.loading && !failure && !rows.length ? <PanelNote text={t('repo.protection.empty')} /> : null}
160
161 <div className="space-y-3">
162 {rows.map((policy) => (
163 <PolicyCard
164 key={policy.id}
165 space={space}
166 repo={repo}
167 policy={policy}
168 onEdit={() =>
169 void getPolicy(space, repo, policy.id)
170 .then(setEditing)
171 .catch(() => setEditing(policy))
172 }
173 onDelete={() => {
174 if (!window.confirm(t('repo.protection.deleteConfirm', { pattern: policy.branch_pattern }))) return
175 void run(t('repo.protection.deleteLabel'), () => deletePolicy(space, repo, policy.id))
176 }}
177 />
178 ))}
179 </div>
180
181 <TagProtectionPanel space={space} repo={repo} />
182
183 {creating || editing ? (
184 <PolicyFormDialog
185 space={space}
186 repo={repo}
187 policy={editing}
188 onClose={() => {
189 setCreating(false)
190 setEditing(null)
191 }}
192 onSaved={(message) => {
193 setCreating(false)
194 setEditing(null)
195 setNotice(message)
196 refresh()
197 }}
198 />
199 ) : null}
200 </div>
201 )
202}
203
204/* ------------------------------------------------------------ policy card */
205
206function PolicyCard({
207 space,
208 repo,
209 policy,
210 onEdit,
211 onDelete,
212}: {
213 space: string
214 repo: string
215 policy: BranchPolicy
216 onEdit: () => void
217 onDelete: () => void
218}) {
219 const { t } = useT()
220 const [open, setOpen] = useState(false)
221 const chips = [
222 policy.required_approvals > 0
223 ? t('repo.protection.chip.approvals', { count: policy.required_approvals })
224 : t('repo.protection.chip.noApprovals'),
225 policy.enable_push ? t('repo.protection.chip.restrictPush') : null,
226 policy.enable_force_push ? t('repo.protection.chip.noForcePush') : null,
227 policy.enable_push_whitelist ? t('repo.protection.chip.pushWhitelist') : null,
228 policy.enable_merge_whitelist ? t('repo.protection.chip.mergeWhitelist') : null,
229 policy.enable_approvals_whitelist ? t('repo.protection.chip.approvalWhitelist') : null,
230 policy.dismiss_stale_approvals ? t('repo.protection.chip.dismissStale') : null,
231 policy.block_on_rejected_reviews ? t('repo.protection.chip.blockRejected') : null,
232 policy.block_on_change_requested_reviews ? t('repo.protection.chip.blockChangeRequested') : null,
233 policy.block_on_outdated_branch ? t('repo.protection.chip.blockOutdated') : null,
234 policy.require_signed_commits ? t('repo.protection.chip.signedCommits') : null,
235 policy.protected_file_patterns
236 ? t('repo.protection.chip.protectedFiles', { patterns: policy.protected_file_patterns })
237 : null,
238 policy.unprotected_file_patterns
239 ? t('repo.protection.chip.unprotectedFiles', { patterns: policy.unprotected_file_patterns })
240 : null,
241 ].filter(Boolean) as string[]
242
243 return (
244 <div className="rounded-xl border border-shell-hairline bg-shell-field p-4">
245 <div className="flex flex-wrap items-center gap-2">
246 <code className="rounded bg-shell-active/70 px-2 py-0.5 font-mono text-[12.5px] font-semibold">
247 {policy.branch_pattern}
248 </code>
249 <span className="ml-auto flex items-center gap-1.5">
250 <button
251 type="button"
252 onClick={onEdit}
253 title={t('repo.protection.edit')}
254 className="p-1 text-shell-ink-muted hover:text-shell-ink"
255 >
256 <Pencil className="h-3.5 w-3.5" />
257 </button>
258 <button
259 type="button"
260 onClick={onDelete}
261 title={t('repo.protection.deleteTitle')}
262 className="p-1 text-shell-ink-muted hover:text-[#b42318]"
263 >
264 <Trash2 className="h-3.5 w-3.5" />
265 </button>
266 </span>
267 </div>
268 <div className="mt-2 flex flex-wrap gap-1.5">
269 {chips.map((chip) => (
270 <span key={chip} className="rounded-full bg-shell-surface px-2 py-0.5 text-[11.5px] text-shell-ink-muted">
271 {chip}
272 </span>
273 ))}
274 </div>
275 <button
276 type="button"
277 onClick={() => setOpen((v) => !v)}
278 className="mt-2 text-[12px] text-shell-ink-muted underline underline-offset-2 hover:text-shell-ink"
279 >
280 {open ? t('repo.protection.hideWhitelist') : t('repo.protection.manageWhitelist')}
281 </button>
282 {open ? <WhitelistManager space={space} repo={repo} policy={policy} /> : null}
283 </div>
284 )
285}
286
287/* ------------------------------------------------------------- whitelists */
288
289function WhitelistManager({ space, repo, policy }: { space: string; repo: string; policy: BranchPolicy }) {
290 const { t } = useT()
291 const [tick, setTick] = useState(0)
292 const [kind, setKind] = useState<WhitelistKind>('Push')
293 const [userId, setUserId] = useState(0)
294 const [error, setError] = useState<string | null>(null)
295 const lists = useAsync(() => listBranchWhitelists(space, repo, policy.id), [space, repo, policy.id, tick])
296 const users = useAsync(() => listUserOptions(space), [space])
297 const rows = lists.data ?? []
298 const options = users.data ?? []
299
300 async function add() {
301 if (!userId) return
302 setError(null)
303 try {
304 await addBranchWhitelist(space, repo, policy.id, { kind, user_id: userId })
305 setUserId(0)
306 setTick((n) => n + 1)
307 } catch (f) {
308 setError(f instanceof Error ? f.message : t('repo.protection.addFailed'))
309 }
310 }
311
312 async function remove(row: BranchWhitelistRow) {
313 try {
314 await removeBranchWhitelist(space, repo, policy.id, row.id)
315 setTick((n) => n + 1)
316 } catch (f) {
317 setError(f instanceof Error ? f.message : t('repo.protection.removeFailed'))
318 }
319 }
320
321 return (
322 <div className="mt-3 rounded-lg border border-shell-hairline bg-shell-surface p-3">
323 {error ? <p className="mb-2 text-[12px] text-[#b42318]">{error}</p> : null}
324 <div className="mb-2 flex flex-wrap items-center gap-2 text-[12px]">
325 {(Object.keys(KIND_LABEL) as WhitelistKind[]).map((value) => (
326 <label
327 key={value}
328 className={`flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 ${kind === value ? 'border-shell-ink font-medium' : 'border-shell-hairline text-shell-ink-muted'}`}
329 >
330 <input
331 type="radio"
332 name={`kind-${policy.id}`}
333 className="sr-only"
334 checked={kind === value}
335 onChange={() => setKind(value)}
336 />
337 {KIND_LABEL[value]}
338 </label>
339 ))}
340 <select
341 value={userId}
342 onChange={(event) => setUserId(Number(event.target.value))}
343 className={`h-8 flex-1 min-w-[140px] ${modalInputClass}`}
344 >
345 <option value="">{t('repo.protection.selectUser')}</option>
346 {options.map((user) => (
347 <option key={user.user_id} value={user.user_id}>
348 {t('repo.protection.userOption', { name: user.display_name || user.username, username: user.username })}
349 </option>
350 ))}
351 </select>
352 <Button className="h-8 text-[12.5px]" disabled={!userId} onClick={() => void add()}>
353 {t('repo.action.add')}
354 </Button>
355 </div>
356 {rows.length ? (
357 <ul className="space-y-1">
358 {rows.map((row) => (
359 <li key={row.id} className="flex items-center gap-2 rounded bg-shell-field px-2.5 py-1.5 text-[12.5px]">
360 <span className="rounded bg-shell-active/70 px-1.5 py-0.5 text-[11px]">
361 {KIND_LABEL[row.kind] ?? row.kind}
362 </span>
363 <span className="min-w-0 flex-1 truncate">
364 {row.user?.display_name || row.user?.username || row.user_id || t('repo.protection.groupLabel')}
365 </span>
366 <button
367 type="button"
368 aria-label={t('common.action.remove')}
369 onClick={() => void remove(row)}
370 className="text-shell-ink-muted hover:text-[#b42318]"
371 >
372 ×
373 </button>
374 </li>
375 ))}
376 </ul>
377 ) : (
378 <p className="text-[12px] text-shell-ink-muted">{t('repo.protection.whitelistEmpty')}</p>
379 )}
380 {policy.enable_push_whitelist || policy.enable_merge_whitelist || policy.enable_approvals_whitelist ? null : (
381 <p className="mt-1.5 text-[11.5px] text-[#8a6100]">{t('repo.protection.whitelistDisabledNote')}</p>
382 )}
383 </div>
384 )
385}
386
387/* --------------------------------------------------------- tag whitelists */
388
389function TagProtectionPanel({ space, repo }: { space: string; repo: string }) {
390 const { t } = useT()
391 const [policyId, setPolicyId] = useState('')
392 const [tick, setTick] = useState(0)
393 const [userId, setUserId] = useState(0)
394 const [error, setError] = useState<string | null>(null)
395 const rowsState = useAsync(
396 () => (policyId ? listTagWhitelists(space, repo, policyId) : Promise.resolve([] as TagWhitelistRow[])),
397 [space, repo, policyId, tick],
398 )
399 const users = useAsync(() => listUserOptions(space), [space])
400 const rows = rowsState.data ?? []
401
402 async function add() {
403 if (!userId || !policyId) return
404 setError(null)
405 try {
406 await addTagWhitelist(space, repo, policyId, userId)
407 setUserId(0)
408 setTick((n) => n + 1)
409 } catch (f) {
410 setError(f instanceof Error ? f.message : t('repo.protection.addFailed'))
411 }
412 }
413
414 return (
415 <div className="mt-6 rounded-xl border border-shell-hairline bg-shell-field p-4">
416 <h4 className="text-[13.5px] font-semibold">{t('repo.protection.tagWhitelistTitle')}</h4>
417 <p className="mt-1 text-[12px] leading-5 text-[#8a6100]">{t('repo.protection.tagWhitelistNote')}</p>
418 {error ? <p className="mt-2 text-[12px] text-[#b42318]">{error}</p> : null}
419 <input
420 className={`${modalInputClass} mt-2 h-8`}
421 placeholder={t('repo.protection.tagPolicyIdPlaceholder')}
422 value={policyId}
423 onChange={(event) => setPolicyId(event.target.value.trim())}
424 />
425 {policyId ? (
426 <div className="mt-2">
427 <div className="flex flex-wrap items-center gap-2 text-[12px]">
428 <select
429 value={userId}
430 onChange={(event) => setUserId(Number(event.target.value))}
431 className={`h-8 flex-1 min-w-[140px] ${modalInputClass}`}
432 >
433 <option value="">{t('repo.protection.selectUser')}</option>
434 {(users.data ?? []).map((user) => (
435 <option key={user.user_id} value={user.user_id}>
436 {t('repo.protection.userOption', {
437 name: user.display_name || user.username,
438 username: user.username,
439 })}
440 </option>
441 ))}
442 </select>
443 <Button className="h-8 text-[12.5px]" disabled={!userId} onClick={() => void add()}>
444 {t('repo.action.add')}
445 </Button>
446 </div>
447 <ul className="mt-2 space-y-1">
448 {rows.map((row) => (
449 <li
450 key={row.user_id}
451 className="flex items-center gap-2 rounded bg-shell-surface px-2.5 py-1.5 text-[12.5px]"
452 >
453 <span className="min-w-0 flex-1 truncate">
454 {row.user?.display_name || row.user?.username || row.user_id}
455 </span>
456 <button
457 type="button"
458 aria-label={t('common.action.remove')}
459 onClick={() =>
460 void removeTagWhitelist(space, repo, policyId, row.user_id)
461 .then(() => setTick((n) => n + 1))
462 .catch(() => undefined)
463 }
464 className="text-shell-ink-muted hover:text-[#b42318]"
465 >
466 ×
467 </button>
468 </li>
469 ))}
470 </ul>
471 </div>
472 ) : null}
473 </div>
474 )
475}
476
477/* ----------------------------------------------------------- form dialog */
478
479function PolicyFormDialog({
480 space,
481 repo,
482 policy,
483 onClose,
484 onSaved,
485}: {
486 space: string
487 repo: string
488 policy: BranchPolicy | null
489 onClose: () => void
490 onSaved: (message: string) => void
491}) {
492 const { t } = useT()
493 const [form, setForm] = useState<CreatePolicyInput>(() =>
494 policy
495 ? {
496 branch_pattern: policy.branch_pattern,
497 required_approvals: policy.required_approvals,
498 enable_push: policy.enable_push,
499 enable_force_push: policy.enable_force_push,
500 enable_push_whitelist: policy.enable_push_whitelist,
501 enable_merge_whitelist: policy.enable_merge_whitelist,
502 enable_approvals_whitelist: policy.enable_approvals_whitelist,
503 block_on_rejected_reviews: policy.block_on_rejected_reviews,
504 block_on_change_requested_reviews: policy.block_on_change_requested_reviews,
505 block_on_outdated_branch: policy.block_on_outdated_branch,
506 dismiss_stale_approvals: policy.dismiss_stale_approvals,
507 require_signed_commits: policy.require_signed_commits,
508 protected_file_patterns: policy.protected_file_patterns,
509 unprotected_file_patterns: policy.unprotected_file_patterns,
510 }
511 : { branch_pattern: 'main', required_approvals: 1 },
512 )
513 const [busy, setBusy] = useState(false)
514 const [error, setError] = useState<string | null>(null)
515
516 const boolValue = (key: keyof CreatePolicyInput) => Boolean(form[key as never] as boolean | null | undefined)
517
518 async function save() {
519 if (!form.branch_pattern.trim()) {
520 setError(t('repo.protection.branchRequired'))
521 return
522 }
523 setBusy(true)
524 setError(null)
525 try {
526 if (policy) {
527 await updatePolicy(space, repo, policy.id, form)
528 onSaved(t('repo.protection.updated'))
529 } else {
530 await createPolicy(space, repo, form)
531 onSaved(t('repo.protection.created'))
532 }
533 } catch (f) {
534 setError(f instanceof Error ? f.message : t('repo.error.saveFailed'))
535 setBusy(false)
536 }
537 }
538
539 return (
540 <Modal
541 title={policy ? t('repo.protection.editTitle') : t('repo.protection.create')}
542 description={t('repo.protection.formDesc')}
543 open
544 onClose={onClose}
545 width="max-w-[640px]"
546 footer={
547 <>
548 <Button variant="ghost" onClick={onClose}>
549 {t('common.action.cancel')}
550 </Button>
551 <Button disabled={busy} onClick={() => void save()}>
552 {busy ? t('repo.action.saving') : t('common.action.save')}
553 </Button>
554 </>
555 }
556 >
557 {error ? <p className="text-[12.5px] text-[#b42318]">{error}</p> : null}
558 <div className="grid gap-4 sm:grid-cols-2">
559 <label>
560 <span className={modalLabelClass}>{t('repo.protection.branchPattern')}</span>
561 <input
562 className={inputCls}
563 value={form.branch_pattern}
564 onChange={(event) => setForm((prev) => ({ ...prev, branch_pattern: event.target.value }))}
565 />
566 </label>
567 <label>
568 <span className={modalLabelClass}>{t('repo.protection.requiredApprovals')}</span>
569 <input
570 className={inputCls}
571 type="number"
572 min={0}
573 max={10}
574 value={form.required_approvals ?? 0}
575 onChange={(event) => setForm((prev) => ({ ...prev, required_approvals: Number(event.target.value) }))}
576 />
577 </label>
578 </div>
579 <div className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
580 {BOOL_FIELDS.map((field) => (
581 <label key={String(field.key)} className="flex items-center gap-2 text-[13px]">
582 <input
583 type="checkbox"
584 checked={boolValue(field.key)}
585 onChange={(event) => setForm((prev) => ({ ...prev, [field.key]: event.target.checked }))}
586 />
587 {field.label}
588 </label>
589 ))}
590 </div>
591 <Field label={t('repo.protection.protectedPatterns')}>
592 <textarea
593 className={areaCls}
594 rows={2}
595 value={form.protected_file_patterns ?? ''}
596 onChange={(event) => setForm((prev) => ({ ...prev, protected_file_patterns: event.target.value }))}
597 />
598 </Field>
599 <Field label={t('repo.protection.unprotectedPatterns')}>
600 <textarea
601 className={areaCls}
602 rows={2}
603 value={form.unprotected_file_patterns ?? ''}
604 onChange={(event) => setForm((prev) => ({ ...prev, unprotected_file_patterns: event.target.value }))}
605 />
606 </Field>
607 </Modal>
608 )
609}
Added view/app/src/domains/repo/settings/ui/sections/remotes-settings.tsx View view/app/src/domains/repo/settings/ui/sections/remotes-settings.tsx
@@ -0,0 +1,202 @@
1import {
2 type FetchResult,
3 fetchRemote,
4 type ImportResult,
5 importRemote,
6 listRemotes,
7 type PushResult,
8 pushRemote,
9 removeRemote,
10 upsertRemote,
11} from '@app/domains/repo/model'
12import { useT } from '@app/i18n'
13import { repoKey, useAsync } from '@app/lib'
14import { Button } from '@app/ui'
15import { useState } from 'react'
16import { Field, inputCls, ListRow, Notice, SectionHead } from '../parts'
17import { useAction } from '../use-action'
18
19/**
20 * Git remotes: `GET|PUT|DELETE /remotes*` plus fetch / push / mirror import.
21 * The server validates the URL scheme (https, ssh, git) and refuses localhost
22 * and private address ranges (SSRF guard), so the form says so up front.
23 */
24export function RemotesSettings({ space, repo }: { space: string; repo: string }) {
25 const { t } = useT()
26 const [tick, setTick] = useState(0)
27 const remotes = useAsync(() => listRemotes(space, repo), [space, repo, tick], {
28 cacheKey: repoKey(space, repo, 'remotes'),
29 })
30 const { busy, notice, error, run } = useAction()
31 const [name, setName] = useState('')
32 const [url, setUrl] = useState('')
33 const [token, setToken] = useState('')
34 const [refspec, setRefspec] = useState('')
35 const [pendingDelete, setPendingDelete] = useState<string | null>(null)
36 const [result, setResult] = useState<string | null>(null)
37
38 const rows = remotes.data ?? []
39 const refresh = () => setTick((n) => n + 1)
40
41 async function act(action: () => Promise<unknown>, done: string) {
42 setResult(null)
43 const ok = await run(action, done)
44 if (ok) refresh()
45 return ok
46 }
47
48 return (
49 <div className="max-w-[720px]">
50 <SectionHead title={t('repo.settings.nav.remotes')} sub={t('repo.remotes.sub')} />
51 <Notice notice={notice} error={error} />
52 {result ? (
53 <p className="mb-4 rounded-md border-2 border-shell-hairline bg-shell-field px-3 py-2 font-mono text-[12px]">
54 {result}
55 </p>
56 ) : null}
57
58 <ul className="mb-6 space-y-2">
59 {remotes.loading && !remotes.data ? (
60 <p className="text-[13px] text-shell-ink-muted">{t('repo.remotes.loading')}</p>
61 ) : null}
62 {!remotes.loading && rows.length === 0 ? (
63 <p className="text-[13px] text-shell-ink-muted">{t('repo.remotes.empty')}</p>
64 ) : null}
65 {rows.map((remote) => (
66 <ListRow key={remote.name}>
67 <span className="min-w-[90px] font-mono text-[13px] font-semibold">{remote.name}</span>
68 <span
69 className="min-w-0 flex-1 truncate font-mono text-[12px] text-shell-ink-muted"
70 title={remote.fetch_url ?? ''}
71 >
72 {remote.fetch_url ?? '—'}
73 {remote.push_url && remote.push_url !== remote.fetch_url ? ` → ${remote.push_url}` : ''}
74 </span>
75 <span className="ml-auto flex shrink-0 flex-wrap gap-1.5">
76 <Button
77 variant="secondary"
78 className="h-7 text-[12px]"
79 disabled={busy}
80 onClick={() =>
81 void act(
82 async () => {
83 const out = await fetchRemote(space, repo, remote.name, token || undefined)
84 setResult(
85 t('repo.remotes.fetchResult', {
86 added: (out as FetchResult).new?.length ?? 0,
87 updated: (out as FetchResult).updated?.length ?? 0,
88 }),
89 )
90 },
91 t('repo.remotes.fetched', { name: remote.name }),
92 )
93 }
94 >
95 {t('repo.remotes.fetch')}
96 </Button>
97 <Button
98 variant="secondary"
99 className="h-7 text-[12px]"
100 disabled={busy}
101 onClick={() =>
102 void act(
103 async () => {
104 const out = await pushRemote(space, repo, remote.name, refspec || undefined, token || undefined)
105 setResult(
106 t('repo.remotes.pushResult', {
107 pushed: (out as PushResult).pushed?.length ?? 0,
108 rejected: (out as PushResult).rejected?.length ?? 0,
109 }),
110 )
111 },
112 t('repo.remotes.pushed', { name: remote.name }),
113 )
114 }
115 >
116 {t('repo.remotes.push')}
117 </Button>
118 <Button
119 variant="secondary"
120 className="h-7 text-[12px]"
121 disabled={busy}
122 onClick={() => {
123 if (pendingDelete !== remote.name) {
124 setPendingDelete(remote.name)
125 return
126 }
127 setPendingDelete(null)
128 void act(
129 () => removeRemote(space, repo, remote.name),
130 t('repo.remotes.deleted', { name: remote.name }),
131 )
132 }}
133 >
134 {pendingDelete === remote.name ? t('repo.action.confirmDelete') : t('common.action.delete')}
135 </Button>
136 </span>
137 </ListRow>
138 ))}
139 </ul>
140
141 <h4 className="mb-3 text-[13px] font-bold">{t('repo.remotes.upsertTitle')}</h4>
142 <Field label={t('repo.remotes.name')} hint={t('repo.remotes.nameHint')}>
143 <input className={inputCls} placeholder="upstream" value={name} onChange={(e) => setName(e.target.value)} />
144 </Field>
145 <Field label={t('repo.remotes.url')}>
146 <input
147 className={inputCls}
148 placeholder="https://github.com/owner/repo.git"
149 value={url}
150 onChange={(e) => setUrl(e.target.value)}
151 />
152 </Field>
153 <div className="mb-5 flex gap-2">
154 <input
155 className={`${inputCls} max-w-[220px]`}
156 placeholder={t('repo.remotes.tokenPlaceholder')}
157 type="password"
158 value={token}
159 onChange={(e) => setToken(e.target.value)}
160 />
161 <input
162 className={`${inputCls} max-w-[220px]`}
163 placeholder={t('repo.remotes.refspecPlaceholder')}
164 value={refspec}
165 onChange={(e) => setRefspec(e.target.value)}
166 />
167 </div>
168 <div className="flex flex-wrap gap-2">
169 <Button
170 disabled={busy || !name.trim() || !url.trim()}
171 onClick={() =>
172 void act(
173 () => upsertRemote(space, repo, name.trim(), url.trim()),
174 t('repo.remotes.saved', { name: name.trim() }),
175 )
176 }
177 >
178 {t('repo.remotes.save')}
179 </Button>
180 <Button
181 variant="secondary"
182 disabled={busy || !url.trim()}
183 onClick={() =>
184 void act(async () => {
185 const out = await importRemote(space, repo, url.trim(), token || undefined)
186 setResult(
187 t('repo.remotes.mirrorResult', {
188 commits: (out as ImportResult).commit_count,
189 branch: (out as ImportResult).default_branch,
190 }),
191 )
192 }, t('repo.remotes.mirrorDone'))
193 }
194 title={t('repo.remotes.mirrorHint')}
195 >
196 {t('repo.remotes.mirror')}
197 </Button>
198 </div>
199 <p className="mt-3 text-[11.5px] text-shell-ink-muted">{t('repo.remotes.note')}</p>
200 </div>
201 )
202}
Added view/app/src/domains/repo/settings/ui/sections/topics-settings.tsx View view/app/src/domains/repo/settings/ui/sections/topics-settings.tsx
@@ -0,0 +1,94 @@
1import { addTopic, listTopics, removeTopic } from '@app/domains/repo/model'
2import { useT } from '@app/i18n'
3import { repoKey, useAsync } from '@app/lib'
4import { Button } from '@app/ui'
5import { X } from 'lucide-react'
6import { useState } from 'react'
7import { inputCls, ListRow, Notice, SectionHead } from '../parts'
8import { useAction } from '../use-action'
9
10/** GitHub-style topics: `PUT|DELETE /repos/{s}/{r}/topics/{name}`. */
11export function TopicsSettings({ space, repo }: { space: string; repo: string }) {
12 const { t } = useT()
13 const [tick, setTick] = useState(0)
14 const topics = useAsync(() => listTopics(space, repo), [space, repo, tick], {
15 cacheKey: repoKey(space, repo, 'topics'),
16 })
17 const { busy, notice, error, run } = useAction()
18 const [draft, setDraft] = useState('')
19
20 const names = topics.data ?? []
21 const normalized = draft
22 .trim()
23 .toLowerCase()
24 .replace(/[^a-z0-9-]+/g, '-')
25 .replace(/^-+|-+$/g, '')
26 .slice(0, 50)
27 const exists = names.some((topic) => topic.name === normalized)
28 const canAdd = Boolean(normalized) && !exists && !busy
29
30 async function submit() {
31 if (!canAdd) return
32 const ok = await run(() => addTopic(space, repo, normalized), t('repo.settings.topicAdded', { name: normalized }))
33 if (ok) {
34 setDraft('')
35 setTick((n) => n + 1)
36 }
37 }
38
39 async function remove(name: string) {
40 const ok = await run(() => removeTopic(space, repo, name), t('repo.settings.topicRemoved', { name }))
41 if (ok) setTick((n) => n + 1)
42 }
43
44 return (
45 <div className="max-w-[640px]">
46 <SectionHead title={t('repo.settings.nav.topics')} sub={t('repo.settings.topicsSub')} />
47 <Notice notice={notice} error={error} />
48 <div className="mb-5 flex gap-2">
49 <input
50 className={inputCls}
51 placeholder={t('repo.settings.topicPlaceholder')}
52 value={draft}
53 onChange={(e) => setDraft(e.target.value)}
54 onKeyDown={(e) => {
55 if (e.key === 'Enter') {
56 e.preventDefault()
57 void submit()
58 }
59 }}
60 />
61 <Button disabled={!canAdd} onClick={() => void submit()}>
62 {t('repo.action.add')}
63 </Button>
64 </div>
65 {normalized && normalized !== draft.trim() ? (
66 <p className="-mt-3 mb-4 text-[11.5px] text-shell-ink-muted">
67 {t('repo.settings.topicWillSavePrefix')} <code className="font-mono">{normalized}</code>
68 </p>
69 ) : null}
70
71 {topics.loading && !topics.data ? (
72 <p className="text-[13px] text-shell-ink-muted">{t('repo.settings.topicsLoading')}</p>
73 ) : names.length === 0 ? (
74 <p className="text-[13px] text-shell-ink-muted">{t('repo.settings.topicsEmpty')}</p>
75 ) : (
76 <ul className="space-y-2">
77 {names.map((topic) => (
78 <ListRow key={topic.id}>
79 <span className="font-medium">{topic.name}</span>
80 <Button
81 variant="secondary"
82 className="ml-auto h-7 gap-1 text-[12px]"
83 disabled={busy}
84 onClick={() => void remove(topic.name)}
85 >
86 <X className="h-3 w-3" aria-hidden /> {t('common.action.remove')}
87 </Button>
88 </ListRow>
89 ))}
90 </ul>
91 )}
92 </div>
93 )
94}
Added view/app/src/domains/repo/settings/ui/parts.tsx View view/app/src/domains/repo/settings/ui/parts.tsx
@@ -0,0 +1,57 @@
1import { Alert } from '@app/components'
2import type { ReactNode } from 'react'
3
4/** Shared primitives for the repo settings sections. */
5
6export const fieldLabel = 'mb-2 block text-[13px] font-medium text-shell-ink'
7export const inputCls =
8 'repo-settings-input h-11 w-full max-w-[520px] rounded-lg border border-shell-hairline bg-shell-field px-3 text-[13.5px] outline-none transition-colors focus:border-shell-tile'
9export const areaCls =
10 'repo-settings-input w-full max-w-[520px] resize-y rounded-lg border border-shell-hairline bg-shell-field px-3 py-3 text-[13.5px] leading-6 outline-none transition-colors focus:border-shell-tile'
11
12export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
13 return (
14 <label className="mb-5 block">
15 <span className={fieldLabel}>{label}</span>
16 {children}
17 {hint ? <span className="mt-2 block text-[12px] leading-5 text-shell-ink-muted">{hint}</span> : null}
18 </label>
19 )
20}
21
22export function SectionHead({ title, sub }: { title: string; sub?: string }) {
23 return (
24 <div className="mb-6 border-b border-shell-hairline pb-5">
25 <h3 className="text-[18px] font-semibold">{title}</h3>
26 {sub ? <p className="mt-2 text-[13px] leading-6 text-shell-ink-muted">{sub}</p> : null}
27 </div>
28 )
29}
30
31export function Fact({ label, children }: { label: string; children: ReactNode }) {
32 return (
33 <div className="repo-setting-fact flex gap-3 py-1">
34 <dt className="w-28 shrink-0">{label}</dt>
35 <dd className="min-w-0 break-all text-shell-ink">{children}</dd>
36 </div>
37 )
38}
39
40/** One bordered list row (remotes, topics, locks, contributors…). */
41export function ListRow({ children }: { children: ReactNode }) {
42 return (
43 <li className="flex flex-wrap items-center gap-3 rounded-xl border border-shell-hairline bg-shell-field px-4 py-3">
44 {children}
45 </li>
46 )
47}
48
49export function Notice({ notice, error }: { notice: string | null; error: string | null }) {
50 if (!notice && !error) return null
51 return (
52 <div className="mb-4 flex flex-wrap items-center gap-2">
53 {notice ? <Alert tone="success">{notice}</Alert> : null}
54 {error ? <Alert tone="error">{error}</Alert> : null}
55 </div>
56 )
57}
Added view/app/src/domains/repo/settings/ui/settings-tab.tsx View view/app/src/domains/repo/settings/ui/settings-tab.tsx
@@ -0,0 +1,74 @@
1import type { RepoDetail } from '@app/domains/repo/model'
2import { useT } from '@app/i18n'
3import { REPO_SETTINGS_SECTIONS, type RepoSettingsSection } from '../model/sections'
4import { ArchivesSettings } from './sections/archives-settings'
5import { FeaturesSettings } from './sections/features-settings'
6import { GeneralSettings } from './sections/general-settings'
7import { InsightsSettings } from './sections/insights-settings'
8import { LocksSettings } from './sections/locks-settings'
9import { ProtectionSettings } from './sections/protection-settings'
10import { RemotesSettings } from './sections/remotes-settings'
11import { TopicsSettings } from './sections/topics-settings'
12
13export interface SettingsTabProps {
14 space: string
15 repo: string
16 repoDto: RepoDetail
17 rev: string
18 /** Active second-level section, owned by the route. */
19 section: RepoSettingsSection
20 onSectionChange: (section: RepoSettingsSection) => void
21 onSaved: () => void
22}
23
24/**
25 * Repository settings, split into second-level sections. Every section is wired
26 * to the real endpoint group it owns: PATCH repo, topics, remotes (incl. fetch /
27 * push / mirror import), archives & bundles, analysis (rescan / contributors /
28 * activity) and LFS locks.
29 */
30export function SettingsTab({ space, repo, repoDto, rev, section, onSectionChange, onSaved }: SettingsTabProps) {
31 const { t } = useT()
32 return (
33 <div className="repo-settings min-h-0">
34 <nav
35 aria-label={t('repo.settings.navLabel')}
36 className="repo-settings-nav mb-5 flex flex-wrap gap-1 rounded-xl border border-shell-hairline bg-shell-surface p-1.5"
37 >
38 {REPO_SETTINGS_SECTIONS.map((item) => (
39 <button
40 key={item.id}
41 type="button"
42 aria-current={section === item.id ? 'page' : undefined}
43 onClick={() => onSectionChange(item.id)}
44 className={`rounded-lg px-3.5 py-2 text-[13px] font-medium transition-colors ${
45 section === item.id
46 ? 'bg-shell-active text-shell-ink'
47 : 'text-shell-ink-muted hover:bg-shell-field hover:text-shell-ink'
48 }`}
49 >
50 {item.label}
51 </button>
52 ))}
53 </nav>
54 <div className="repo-settings-content rounded-xl border border-shell-hairline bg-shell-surface p-6">
55 {section === 'general' ? (
56 <GeneralSettings space={space} repo={repo} repoDto={repoDto} onChanged={onSaved} />
57 ) : null}
58 {section === 'features' ? (
59 <FeaturesSettings space={space} repo={repo} repoDto={repoDto} onChanged={onSaved} />
60 ) : null}
61 {section === 'topics' ? <TopicsSettings space={space} repo={repo} /> : null}
62 {section === 'protection' ? <ProtectionSettings space={space} repo={repo} /> : null}
63 {section === 'remotes' ? <RemotesSettings space={space} repo={repo} /> : null}
64 {section === 'archives' ? (
65 <ArchivesSettings space={space} repo={repo} repoDto={repoDto} onChanged={onSaved} />
66 ) : null}
67 </div>
68 {section === 'insights' ? <InsightsSettings space={space} repo={repo} rev={rev} /> : null}
69 {section === 'locks' ? (
70 <LocksSettings space={space} repo={repo} rev={rev} enableLfs={repoDto.enable_lfs} onRepoChanged={onSaved} />
71 ) : null}
72 </div>
73 )
74}
Added view/app/src/domains/repo/settings/ui/use-action.ts View view/app/src/domains/repo/settings/ui/use-action.ts
@@ -0,0 +1,38 @@
1import { t } from '@app/i18n'
2import { ApiFailure } from '@app/lib'
3import { useState } from 'react'
4
5export function messageOf(err: unknown): string {
6 if (err instanceof ApiFailure) return err.message
7 const text = (err as Error | undefined)?.message
8 return text && text !== 'request failed' ? text : t('repo.error.actionFailed')
9}
10
11/**
12 * Busy / notice / error triad for a section's mutating actions. `run` resolves
13 * to whether the action succeeded so callers can chain refreshes.
14 */
15export function useAction() {
16 const [busy, setBusy] = useState(false)
17 const [notice, setNotice] = useState<string | null>(null)
18 const [error, setError] = useState<string | null>(null)
19
20 async function run(action: () => Promise<unknown>, done?: string): Promise<boolean> {
21 if (busy) return false
22 setBusy(true)
23 setNotice(null)
24 setError(null)
25 try {
26 await action()
27 if (done) setNotice(done)
28 return true
29 } catch (err) {
30 setError(messageOf(err))
31 return false
32 } finally {
33 setBusy(false)
34 }
35 }
36
37 return { busy, notice, error, setNotice, setError, run }
38}
Added view/app/src/domains/repo/settings/index.ts View view/app/src/domains/repo/settings/index.ts
@@ -0,0 +1,6 @@
1// Public API of features/repo-settings — the repo settings area and its six
2// sections. Consumers import from '@app/domains/repo/settings', never from
3// deeper files.
4
5export { REPO_SETTINGS_SECTIONS, type RepoSettingsSection } from './model/sections'
6export { SettingsTab, type SettingsTabProps } from './ui/settings-tab'
Added view/app/src/domains/repo/embed.css View view/app/src/domains/repo/embed.css
@@ -0,0 +1,429 @@
1/*
2 * Unifies server-rendered HTML fragments (`[data-component="git-*"]`) with
3 * the app-shell design language. Colors come from the same `--shell-*`
4 * tokens the React components use; the README sandbox iframe receives a copy
5 * of those variables injected at runtime, so fragments and app chrome share
6 * one palette (light and dark).
7 */
8.repo-embed {
9 font-size: 13px;
10 line-height: 1.55;
11 color: var(--shell-ink, #1c1e22);
12 word-break: break-word;
13}
14/*
15 * Server fragments are laid out against the column they are mounted in, so
16 * that column is the query container; it also owns the horizontal pan when a
17 * fragment cannot fit at all.
18 */
19.repo-embed-scroll {
20 container-type: inline-size;
21 overflow-x: auto;
22}
23
24/* ---------- file tree -------------------------------------------------- */
25.repo-embed [data-component="git-tree"] {
26 border: 2px solid var(--shell-hairline, #dde0e6);
27 border-radius: 10px;
28 background: var(--shell-surface, #fff);
29 overflow: hidden;
30 /* Fixed layout keeps the three lanes from trading places; the width itself
31 comes from the container query below so a squeezed desktop column and a
32 phone get the same readable result. */
33 table-layout: fixed;
34}
35.repo-embed [data-component="git-tree"] thead {
36 background: var(--shell-field, #f2f3f6);
37}
38.repo-embed [data-component="git-tree"] th {
39 text-align: left;
40 font-family: ui-monospace, monospace;
41 font-size: 10px;
42 font-weight: 700;
43 text-transform: uppercase;
44 letter-spacing: 1.2px;
45 color: var(--shell-ink-muted, #8b8f99);
46 padding: 8px 12px;
47 white-space: nowrap;
48}
49/* Name keeps a share of the row, the commit column takes the rest and the
50 date stays a fixed lane so the three never trade places while scrolling. */
51.repo-embed [data-component="git-tree"] th:nth-child(1) {
52 width: 34%;
53}
54.repo-embed [data-component="git-tree"] th:nth-child(3) {
55 width: 88px;
56}
57.repo-embed [data-component="git-tree"] td:nth-child(2) a {
58 display: flex;
59 min-width: 0;
60 align-items: center;
61 gap: 8px;
62}
63.repo-embed [data-component="git-tree"] td:nth-child(2) a > span:last-child {
64 min-width: 0;
65 max-width: 300px;
66 overflow: hidden;
67 text-overflow: ellipsis;
68 white-space: nowrap;
69}
70/* The short sha is a fixed chip: it must never be squeezed into a vertical stack. */
71.repo-embed [data-component="git-tree"] td:nth-child(2) code {
72 flex: none;
73 white-space: nowrap;
74}
75.repo-embed [data-component="git-tree"] td:nth-child(1) a {
76 display: flex;
77 min-width: 0;
78 align-items: center;
79 gap: 7px;
80}
81.repo-tree-icon {
82 display: inline-flex;
83 width: 16px;
84 height: 16px;
85 flex: none;
86 color: var(--shell-ink-muted, #8b8f99);
87}
88.repo-tree-icon svg,
89.repo-tree-icon img {
90 display: block;
91 width: 16px;
92 height: 16px;
93}
94.repo-tree-name {
95 min-width: 0;
96 overflow: hidden;
97 text-overflow: ellipsis;
98 white-space: nowrap;
99}
100.repo-embed [data-component="git-tree"] th:last-child {
101 text-align: right;
102}
103.repo-embed [data-component="git-tree"] td {
104 padding: 7px 12px;
105 border-top: 1px solid var(--shell-hairline, #dde0e6);
106 vertical-align: middle;
107}
108.repo-embed [data-component="git-tree"] tbody tr:hover > td {
109 background: color-mix(in srgb, var(--shell-active, #dee1e8) 45%, transparent);
110}
111.repo-embed [data-component="git-tree"] td:last-child {
112 text-align: right;
113 font-size: 12px;
114 color: var(--shell-ink-muted, #8b8f99);
115 white-space: nowrap;
116}
117
118/* ---------- links, chips, code (shared) --------------------------------- */
119.repo-embed a {
120 color: inherit;
121 text-decoration: none;
122}
123.repo-embed a:hover {
124 text-decoration: underline;
125}
126.repo-embed [data-component="git-tree"] code,
127.repo-embed .markdown-body code {
128 background: var(--shell-active, #dee1e8);
129 border-radius: 4px;
130 padding: 1px 6px;
131 font-family: var(--font-mono, ui-monospace, monospace);
132 font-size: 11px;
133 text-decoration: none;
134}
135.repo-embed [data-component="git-tree"] td:first-child a {
136 font-weight: 600;
137}
138
139/* ---------- blob viewer -------------------------------------------------- */
140.repo-embed [data-component="git-blob-content"] {
141 border: 2px solid var(--shell-hairline, #dde0e6);
142 border-radius: 10px;
143 background: var(--shell-surface, #fff);
144 overflow: hidden;
145}
146.repo-embed [data-component="git-blob-content"] table {
147 width: 100%;
148 border-collapse: collapse;
149 font-family: ui-monospace, monospace;
150 font-size: 12px;
151}
152.repo-embed [data-component="git-blob-content"] tr[id^="L"] > td:first-child {
153 width: 3.5rem;
154 min-width: 3.5rem;
155 text-align: right;
156 padding: 1px 10px;
157 color: var(--shell-ink-muted, #8b8f99);
158 background: var(--shell-field, #f2f3f6);
159 border-right: 1px solid var(--shell-hairline, #dde0e6);
160 user-select: none;
161}
162.repo-embed [data-component="git-blob-content"] tr[id^="L"] > td:last-child {
163 white-space: pre;
164 padding: 1px 14px;
165}
166.repo-embed [data-component="git-blob-content"] tr[id^="L"]:hover > td {
167 background: color-mix(in srgb, var(--shell-active, #dee1e8) 40%, transparent);
168}
169.repo-embed [data-component="git-blob-content"] tr[id^="L"]:hover > td:first-child {
170 color: var(--shell-ink, #1c1e22);
171}
172
173/* ---------- language card (already framed by the aside card) ------------- */
174.repo-embed [data-component="git-languages"] {
175 border: 0;
176 border-radius: 0;
177 background: transparent;
178 padding: 0;
179 font-size: 12px;
180}
181.repo-embed [data-component="git-languages"] > div:first-of-type {
182 display: none; /* the aside card has its own heading */
183}
184/*
185 * Geometry is spelled out here rather than inherited from the app's Tailwind
186 * build: fragment utility classes only work when we happen to use the same
187 * class in our own components, which is not a contract worth relying on.
188 */
189.repo-embed [data-component="git-languages"] > div:nth-of-type(2) {
190 display: flex;
191 flex-wrap: nowrap;
192 height: 8px;
193 width: 100%;
194 overflow: hidden;
195 border-radius: 9999px;
196 background: var(--shell-field, #f2f3f6);
197}
198.repo-embed [data-component="git-languages"] > div:nth-of-type(2) > div {
199 height: 100%;
200}
201/* Legend: one language per row, name left / share right. */
202.repo-embed [data-component="git-languages"] > div:last-of-type {
203 display: flex;
204 flex-direction: column;
205 flex-wrap: nowrap;
206 align-items: stretch;
207 gap: 3px;
208 padding-top: 6px;
209}
210.repo-embed [data-component="git-languages"] > div:last-of-type > div {
211 display: flex;
212 align-items: center;
213 gap: 6px;
214 min-width: 0;
215}
216.repo-embed [data-component="git-languages"] > div:last-of-type span:first-child {
217 width: 8px;
218 height: 8px;
219 flex: none;
220 border-radius: 9999px;
221}
222.repo-embed [data-component="git-languages"] > div:last-of-type span:nth-child(2) {
223 min-width: 0;
224 overflow: hidden;
225 text-overflow: ellipsis;
226 white-space: nowrap;
227 color: var(--shell-ink, #1c1e22);
228 font-weight: 500;
229}
230.repo-embed [data-component="git-languages"] > div:last-of-type span:last-child {
231 margin-left: auto;
232 padding-left: 8px;
233 flex: none;
234 color: var(--shell-ink-muted, #8b8f99);
235 font-variant-numeric: tabular-nums;
236}
237
238/* ---------- last-commit strip / error placeholders ----------------------- */
239.repo-embed [data-component="git-last-commit"] {
240 border: 0;
241 border-radius: 0;
242 padding: 2px 0;
243 background: transparent;
244}
245.repo-embed [data-component="git-err"] {
246 color: var(--shell-ink-muted, #8b8f99);
247 font-style: italic;
248}
249
250/* ---------- readme typography ------------------------------------------- */
251.repo-embed .markdown-body,
252.repo-embed [data-component="git-readme"] {
253 font-family: var(
254 --font-sans,
255 BlinkMacSystemFont,
256 "Segoe UI",
257 system-ui,
258 Roboto,
259 "Noto Sans",
260 Ubuntu,
261 Cantarell,
262 "Helvetica Neue",
263 Arial,
264 "PingFang SC",
265 "Source Han Sans SC",
266 "Microsoft YaHei UI",
267 "Microsoft YaHei",
268 "Noto Sans CJK SC",
269 sans-serif
270 );
271}
272.repo-embed .markdown-body h1,
273.repo-embed [data-component="git-readme"] h1 {
274 font-family: inherit;
275 font-size: 24px;
276 font-weight: 700;
277 letter-spacing: -0.01em;
278 border-bottom: 1px solid var(--shell-hairline, #dde0e6);
279 padding-bottom: 0.3em;
280 margin: 0.6em 0 0.5em;
281}
282.repo-embed .markdown-body h2,
283.repo-embed [data-component="git-readme"] h2 {
284 font-family: inherit;
285 font-size: 17.5px;
286 font-weight: 700;
287 letter-spacing: -0.01em;
288 border-bottom: 1px solid var(--shell-hairline, #dde0e6);
289 padding-bottom: 0.25em;
290 margin: 1.4em 0 0.5em;
291}
292.repo-embed .markdown-body h3,
293.repo-embed [data-component="git-readme"] h3 {
294 font-family: inherit;
295 font-size: 15px;
296 font-weight: 600;
297 margin: 1.3em 0 0.4em;
298}
299.repo-embed .markdown-body h4,
300.repo-embed [data-component="git-readme"] h4 {
301 font-family: inherit;
302 font-size: 13.5px;
303 font-weight: 600;
304 margin: 1.2em 0 0.35em;
305}
306.repo-embed .markdown-body p,
307.repo-embed [data-component="git-readme"] p {
308 margin: 0.55em 0;
309}
310.repo-embed .markdown-body a,
311.repo-embed [data-component="git-readme"] a {
312 color: var(--shell-ink, #23272b);
313}
314.repo-embed .markdown-body ul,
315.repo-embed .markdown-body ol,
316.repo-embed [data-component="git-readme"] ul,
317.repo-embed [data-component="git-readme"] ol {
318 padding-left: 1.6em;
319 margin: 0.55em 0;
320}
321.repo-embed .markdown-body blockquote,
322.repo-embed [data-component="git-readme"] blockquote {
323 margin: 0.7em 0;
324 padding: 0.15em 1em;
325 border-left: 3px solid var(--shell-active, #dee1e8);
326 color: var(--shell-ink-muted, #8b8f99);
327}
328.repo-embed .markdown-body pre,
329.repo-embed [data-component="git-readme"] pre {
330 background: var(--shell-field, #f2f3f6);
331 border: 1px solid var(--shell-hairline, #dde0e6);
332 border-radius: 8px;
333 padding: 12px;
334 overflow-x: auto;
335 font-family: var(--font-mono, ui-monospace, monospace);
336 font-size: 12.5px;
337 line-height: 1.65;
338 white-space: pre;
339}
340.repo-embed .markdown-body pre code,
341.repo-embed [data-component="git-readme"] pre code {
342 background: transparent;
343 padding: 0;
344 font-size: inherit;
345}
346.repo-embed .markdown-body table,
347.repo-embed [data-component="git-readme"] table {
348 border-collapse: collapse;
349 margin: 0.7em 0;
350}
351.repo-embed .markdown-body th,
352.repo-embed .markdown-body td,
353.repo-embed [data-component="git-readme"] th,
354.repo-embed [data-component="git-readme"] td {
355 border: 1px solid var(--shell-hairline, #dde0e6);
356 padding: 6px 10px;
357}
358.repo-embed .markdown-body th,
359.repo-embed [data-component="git-readme"] th {
360 background: var(--shell-field, #f2f3f6);
361}
362.repo-embed .markdown-body img,
363.repo-embed [data-component="git-readme"] img {
364 max-width: 100%;
365}
366.repo-embed .markdown-body hr,
367.repo-embed [data-component="git-readme"] hr {
368 border: none;
369 border-top: 1px solid var(--shell-hairline, #dde0e6);
370 margin: 1.2em 0;
371}
372
373/* ---------- generic fallbacks for unnamed fragment markup ---------------- */
374.repo-embed table {
375 width: 100%;
376 border-collapse: collapse;
377}
378.repo-embed th {
379 text-align: left;
380 font-size: 10px;
381 text-transform: uppercase;
382 letter-spacing: 1.2px;
383 color: var(--shell-ink-muted, #8b8f99);
384 padding: 8px 12px;
385}
386.repo-embed td {
387 padding: 7px 12px;
388 border-top: 1px solid var(--shell-hairline, #dde0e6);
389 vertical-align: top;
390}
391.repo-embed pre {
392 overflow-x: auto;
393 font-family: ui-monospace, monospace;
394 font-size: 12px;
395}
396.repo-embed img {
397 max-width: 100%;
398}
399
400/* The tree adapts to the column it actually got, not the viewport: a desktop
401 window with the sidebar and info aside open can be narrower than a tablet
402 in full screen. Below the threshold the date lane is dropped so the file
403 list itself never needs a horizontal pan. */
404@container (max-width: 560px) {
405 .repo-embed [data-component="git-tree"] th:nth-child(3),
406 .repo-embed [data-component="git-tree"] td:nth-child(3) {
407 display: none;
408 }
409 .repo-embed [data-component="git-tree"] th:nth-child(1) {
410 width: 44%;
411 }
412}
413
414/* ---------- media safety nets ---------------------------------------------
415 * Server markup sizes icons with Tailwind utilities (`h-4 w-4`) that are not
416 * shipped for fragments; an unsized inline <svg> would otherwise fall back to
417 * the 300x150 replaced-element default and blow out the layout. */
418.repo-embed img {
419 height: auto;
420}
421.repo-embed video,
422.repo-embed canvas {
423 max-width: 100%;
424}
425.repo-embed svg:not([width]):not([height]) {
426 width: 1em;
427 height: 1em;
428 flex: none;
429}