Skip to content
Clone

feat(repo): add per-repository feature toggles and tab order

A repository now decides whether pull requests can be created, and whether
the pipelines and AI summary tabs are public or members-only, plus the order
of its middle tabs. Settings, browse, branch, history and pipeline views pick
the new flags up.
ZhenYi committed
parent 3bda9d1 commit aaaaf4c Browse files

16 files changed

+715 −161

File tree 16
Modified lib/git/src/service/repos.rs View lib/git/src/service/repos.rs
@@ -25,6 +25,38 @@
2525
2626pub const DEFAULT_BRANCH: &str = "main";
2727
28/// 仓库页签中可由设置重排序的中间页签(代码固定最前、设置固定最后)。
29pub const REORDERABLE_REPO_TABS: [&str; 6] = [
30 "commits",
31 "pulls",
32 "branches",
33 "pipelines",
34 "releases",
35 "ai",
36];
37
38/// 归一化页签顺序:只保留已知中间页签、去重、限制长度;空 = 默认顺序。
39pub fn normalize_repo_tab_order(order: &[String]) -> Vec<String> {
40 let mut out: Vec<String> = Vec::new();
41 for id in order {
42 let id = id.trim();
43 if REORDERABLE_REPO_TABS.contains(&id) && !out.iter().any(|kept| kept == id) {
44 out.push(id.to_string());
45 }
46 if out.len() == REORDERABLE_REPO_TABS.len() {
47 break;
48 }
49 }
50 out
51}
52
53fn tab_order_from_json(value: Option<&serde_json::Value>) -> Vec<String> {
54 value
55 .and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok())
56 .map(|order| normalize_repo_tab_order(&order))
57 .unwrap_or_default()
58}
59
2860#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
2961pub struct RepoDto {
3062 pub id: Uuid,
@@ -55,6 +87,14 @@
5587 pub ai_review_on_push: bool,
5688 pub ai_summary_on_push: bool,
5789 pub ai_review_on_pull: bool,
90 /// 是否允许在本仓库创建合并请求(关闭后页签隐藏、接口拒绝)。
91 pub allow_pull_requests: bool,
92 /// 流水线页签是否公开展示(关闭后仅空间成员可见)。
93 pub pipelines_public: bool,
94 /// AI 总结页签是否公开展示(关闭后仅空间成员可见)。
95 pub ai_summary_public: bool,
96 /// 中间页签顺序;空数组 = 默认顺序。
97 pub tab_order: Vec<String>,
5898 /// 平台侧 AI 是否可用(总开关 + 模型端点已配置)。
5999 ///
60100 /// 随仓库详情一起下发,是因为设置页必须能区分「已关」与「开了也不生效」:
@@ -150,6 +190,10 @@
150190 ai_review_on_push: m.ai_review_on_push,
151191 ai_summary_on_push: m.ai_summary_on_push,
152192 ai_review_on_pull: m.ai_review_on_pull,
193 allow_pull_requests: m.allow_pull_requests,
194 pipelines_public: m.pipelines_public,
195 ai_summary_public: m.ai_summary_public,
196 tab_order: tab_order_from_json(m.tab_order.as_ref()),
153197 // 平台状态由服务层补齐(`From` 拿不到配置)。
154198 ai_available: false,
155199 created_at: m.created_at,
@@ -180,7 +224,7 @@
180224 pub init_message: Option<String>,
181225}
182226
183#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
227#[derive(Debug, Clone, Default, Deserialize, utoipa::ToSchema)]
184228pub struct UpdateRepoCmd {
185229 #[serde(default)]
186230 pub description: Option<String>,
@@ -198,6 +242,15 @@
198242 pub ai_summary_on_push: Option<bool>,
199243 #[serde(default)]
200244 pub ai_review_on_pull: Option<bool>,
245 #[serde(default)]
246 pub allow_pull_requests: Option<bool>,
247 #[serde(default)]
248 pub pipelines_public: Option<bool>,
249 #[serde(default)]
250 pub ai_summary_public: Option<bool>,
251 /// 中间页签顺序;传空数组恢复默认顺序。
252 #[serde(default)]
253 pub tab_order: Option<Vec<String>>,
201254}
202255
203256/// Strip a trailing `:<port>` from a URL authority (`localhost:3000` -> `localhost`).
@@ -411,6 +464,10 @@
411464 ai_review_on_push: Set(true),
412465 ai_summary_on_push: Set(false),
413466 ai_review_on_pull: Set(true),
467 allow_pull_requests: Set(true),
468 pipelines_public: Set(true),
469 ai_summary_public: Set(true),
470 tab_order: Set(None),
414471 };
415472 let repo = repo_row.insert(&txn).await?;
416473 txn.commit().await?;
@@ -636,6 +693,18 @@
636693 if let Some(value) = cmd.ai_review_on_pull {
637694 am.ai_review_on_pull = Set(value);
638695 }
696 if let Some(value) = cmd.allow_pull_requests {
697 am.allow_pull_requests = Set(value);
698 }
699 if let Some(value) = cmd.pipelines_public {
700 am.pipelines_public = Set(value);
701 }
702 if let Some(value) = cmd.ai_summary_public {
703 am.ai_summary_public = Set(value);
704 }
705 if let Some(order) = &cmd.tab_order {
706 am.tab_order = Set(Some(serde_json::json!(normalize_repo_tab_order(order))));
707 }
639708 am.updated_at = Set(now);
640709 let saved = am.update(self.db_write()).await?;
641710
Modified lib/models/src/repo/repos.rs View lib/models/src/repo/repos.rs
@@ -66,6 +66,19 @@
6666 /// 打开或更新 PR 时自动投递 AI 评审。
6767 #[sea_orm(default_value = "true")]
6868 pub ai_review_on_pull: bool,
69 /// 是否允许在本仓库创建合并请求;关闭后合并请求页签对所有人隐藏,
70 /// 创建接口(含 MCP 工具)拒绝执行。
71 #[sea_orm(default_value = "true")]
72 pub allow_pull_requests: bool,
73 /// 流水线页签是否公开展示;关闭后仅空间成员可见。
74 #[sea_orm(default_value = "true")]
75 pub pipelines_public: bool,
76 /// AI 总结页签是否公开展示;关闭后仅空间成员可见。
77 #[sea_orm(default_value = "true")]
78 pub ai_summary_public: bool,
79 /// 中间页签顺序(提交/合并请求/分支/流水线/发布/AI 总结);缺省 = 站点默认顺序。
80 #[sea_orm(column_type = "JsonBinary")]
81 pub tab_order: Option<sea_orm::JsonValue>,
6982 #[sea_orm(belongs_to, from = "dir_id", to = "id")]
7083 pub dir: BelongsTo<super::super::dir::dirs::Entity>,
7184 #[sea_orm(belongs_to, from = "license_name", to = "name")]
Modified web/src/entities/repo/model/repo.ts View web/src/entities/repo/model/repo.ts
@@ -7,6 +7,7 @@
77 AppResponseData_Page_RepoSummaryDto,
88 AppResponseData_RepoDto,
99 UpdateRepoCmd,
10 WatchMode,
1011} from '@/shared/api'
1112
1213export type RepoDetail = NonNullable<AppResponseData_RepoDto['data']>
@@ -61,6 +62,14 @@
6162 Apis.repos.unstar_repo({ pathParams: { space_name: space, repo_name: repo } }),
6263 )
6364
65export const setRepoWatch = (space: string, repo: string, mode: WatchMode) =>
66 unwrapApi<unknown>(
67 Apis.repos.upsert_watch({
68 pathParams: { space_name: space, repo_name: repo },
69 data: { mode },
70 }),
71 )
72
6473/* ---- clone urls ---------------------------------------------------------- */
6574
6675export interface CloneTarget {
Modified web/src/entities/repo/ui/embed.css View web/src/entities/repo/ui/embed.css
@@ -58,6 +58,7 @@
5858}
5959.repo-embed [data-component='git-tree'] td:nth-child(2) a > span:last-child {
6060 min-width: 0;
61 max-width: 300px;
6162 overflow: hidden;
6263 text-overflow: ellipsis;
6364 white-space: nowrap;
@@ -68,7 +69,25 @@
6869 white-space: nowrap;
6970}
7071.repo-embed [data-component='git-tree'] td:nth-child(1) a {
72 display: flex;
73 min-width: 0;
74 align-items: center;
75 gap: 7px;
76}
77.repo-embed .repo-tree-icon {
78 display: inline-flex;
79 width: 16px;
80 height: 16px;
81 flex: none;
82 color: var(--shell-ink-muted, #8b8f99);
83}
84.repo-embed .repo-tree-icon svg {
7185 display: block;
86 width: 16px;
87 height: 16px;
88}
89.repo-embed .repo-tree-name {
90 min-width: 0;
7291 overflow: hidden;
7392 text-overflow: ellipsis;
7493 white-space: nowrap;
@@ -104,7 +123,7 @@
104123 background: var(--shell-active, #dee1e8);
105124 border-radius: 4px;
106125 padding: 1px 6px;
107 font-family: ui-monospace, monospace;
126 font-family: var(--font-mono, ui-monospace, monospace);
108127 font-size: 11px;
109128 text-decoration: none;
110129}
@@ -222,24 +241,44 @@
222241}
223242
224243/* ---------- readme typography ------------------------------------------- */
244.repo-embed .markdown-body,
245.repo-embed [data-component='git-readme'] {
246 font-family: var(--font-sans, 'Geist', 'Inter', 'PingFang SC', 'Microsoft YaHei', ui-sans-serif, system-ui, sans-serif);
247}
225248.repo-embed .markdown-body h1,
226249.repo-embed [data-component='git-readme'] h1 {
227 font-size: 1.55em;
250 font-family: inherit;
251 font-size: 24px;
228252 font-weight: 700;
253 letter-spacing: -.01em;
229254 border-bottom: 1px solid var(--shell-hairline, #dde0e6);
230255 padding-bottom: 0.3em;
231 margin: 0.9em 0 0.45em;
256 margin: 0.6em 0 0.5em;
232257}
233258.repo-embed .markdown-body h2,
234259.repo-embed [data-component='git-readme'] h2 {
235 font-size: 1.3em;
260 font-family: inherit;
261 font-size: 17.5px;
236262 font-weight: 700;
263 letter-spacing: -.01em;
237264 border-bottom: 1px solid var(--shell-hairline, #dde0e6);
238265 padding-bottom: 0.25em;
239 margin: 1em 0 0.4em;
266 margin: 1.4em 0 0.5em;
240267}
241268.repo-embed .markdown-body h3,
242.repo-embed [data-component='git-readme'] h3 { font-size: 1.12em; font-weight: 700; margin: 1em 0 0.35em; }
269.repo-embed [data-component='git-readme'] h3 {
270 font-family: inherit;
271 font-size: 15px;
272 font-weight: 600;
273 margin: 1.3em 0 0.4em;
274}
275.repo-embed .markdown-body h4,
276.repo-embed [data-component='git-readme'] h4 {
277 font-family: inherit;
278 font-size: 13.5px;
279 font-weight: 600;
280 margin: 1.2em 0 0.35em;
281}
243282.repo-embed .markdown-body p,
244283.repo-embed [data-component='git-readme'] p { margin: 0.55em 0; }
245284.repo-embed .markdown-body a,
@@ -263,8 +302,9 @@
263302 border-radius: 8px;
264303 padding: 12px;
265304 overflow-x: auto;
266 font-family: ui-monospace, monospace;
267 font-size: 12px;
305 font-family: var(--font-mono, ui-monospace, monospace);
306 font-size: 12.5px;
307 line-height: 1.65;
268308 white-space: pre;
269309}
270310.repo-embed .markdown-body pre code,
Modified web/src/entities/repo/index.ts View web/src/entities/repo/index.ts
@@ -20,6 +20,7 @@
2020 unarchiveRepo,
2121 starRepo,
2222 unstarRepo,
23 setRepoWatch,
2324 cloneTargets,
2425 primaryCloneUrl,
2526 type CloneTarget,
Modified web/src/features/repo-branches/ui/branches-tab.tsx View web/src/features/repo-branches/ui/branches-tab.tsx
@@ -17,6 +17,8 @@
1717 /** Revision currently checked out in the browser. */
1818 rev: string
1919 defaultBranch: string
20 /** Repo write access (space owner / repo collaborator server-side). */
21 canWrite: boolean
2022 /** Point the browser at this branch without leaving the tab. */
2123 onCheckout: (name: string) => void
2224 /** Browse this branch in the code view. */
@@ -31,6 +33,7 @@
3133 repo,
3234 rev,
3335 defaultBranch,
36 canWrite,
3437 onCheckout,
3538 onBrowse,
3639 onChanged,
@@ -90,9 +93,11 @@
9093 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"
9194 />
9295 </label>
93 <Button className="ml-auto gap-1.5 text-[13px]" disabled={busy} onClick={() => setCreating(true)}>
94 <Plus className="h-4 w-4" aria-hidden /> {t('repo.branch.create')}
95 </Button>
96 {canWrite ? (
97 <Button className="ml-auto gap-1.5 text-[13px]" disabled={busy} onClick={() => setCreating(true)}>
98 <Plus className="h-4 w-4" aria-hidden /> {t('repo.branch.create')}
99 </Button>
100 ) : null}
96101 </div>
97102 {notice || actionError ? (
98103 <div className="mb-3">
@@ -141,50 +146,54 @@
141146 >
142147 <Eye className="h-3.5 w-3.5" aria-hidden /> {t('repo.action.browseCode')}
143148 </Button>
144 <Button
145 variant="secondary"
146 className="h-8 text-[12.5px]"
147 disabled={isDefault || busy}
148 title={isDefault ? t('repo.branch.alreadyDefault') : t('repo.branch.makeDefaultHint')}
149 onClick={() => void run(() => setDefaultBranch(space, repo, branch.name), t('repo.branch.defaultSet', { name: branch.name }))}
150 >
151 {t('repo.action.setDefault')}
152 </Button>
153 <Button
154 variant="secondary"
155 className="h-8 text-[12.5px]"
156 disabled={branch.is_protected || busy}
157 title={branch.is_protected ? t('repo.branch.protectedRenameBlocked') : undefined}
158 onClick={() => setRenaming(branch.name)}
159 >
160 {t('repo.action.rename')}
161 </Button>
162 <Button
163 variant="secondary"
164 className="repo-delete-button h-8 text-[12.5px]"
165 disabled={locked || busy}
166 title={locked ? t('repo.branch.deleteBlocked') : undefined}
167 onClick={() => {
168 if (pendingDelete !== branch.name) {
169 setPendingDelete(branch.name)
170 return
171 }
172 setPendingDelete(null)
173 void run(
174 async () => {
175 await deleteBranch(space, repo, branch.name)
176 if (isCurrent) onCheckout(defaultBranch)
177 },
178 t('repo.branch.deletedName', { name: branch.name }),
179 )
180 }}
181 >
182 {pendingDelete === branch.name ? t('repo.action.confirmDelete') : t('common.action.delete')}
183 </Button>
184 {pendingDelete === branch.name ? (
185 <Button variant="secondary" className="h-8 text-[12.5px]" onClick={() => setPendingDelete(null)}>
186 {t('common.action.cancel')}
187 </Button>
149 {canWrite ? (
150 <>
151 <Button
152 variant="secondary"
153 className="h-8 text-[12.5px]"
154 disabled={isDefault || busy}
155 title={isDefault ? t('repo.branch.alreadyDefault') : t('repo.branch.makeDefaultHint')}
156 onClick={() => void run(() => setDefaultBranch(space, repo, branch.name), t('repo.branch.defaultSet', { name: branch.name }))}
157 >
158 {t('repo.action.setDefault')}
159 </Button>
160 <Button
161 variant="secondary"
162 className="h-8 text-[12.5px]"
163 disabled={branch.is_protected || busy}
164 title={branch.is_protected ? t('repo.branch.protectedRenameBlocked') : undefined}
165 onClick={() => setRenaming(branch.name)}
166 >
167 {t('repo.action.rename')}
168 </Button>
169 <Button
170 variant="secondary"
171 className="repo-delete-button h-8 text-[12.5px]"
172 disabled={locked || busy}
173 title={locked ? t('repo.branch.deleteBlocked') : undefined}
174 onClick={() => {
175 if (pendingDelete !== branch.name) {
176 setPendingDelete(branch.name)
177 return
178 }
179 setPendingDelete(null)
180 void run(
181 async () => {
182 await deleteBranch(space, repo, branch.name)
183 if (isCurrent) onCheckout(defaultBranch)
184 },
185 t('repo.branch.deletedName', { name: branch.name }),
186 )
187 }}
188 >
189 {pendingDelete === branch.name ? t('repo.action.confirmDelete') : t('common.action.delete')}
190 </Button>
191 {pendingDelete === branch.name ? (
192 <Button variant="secondary" className="h-8 text-[12.5px]" onClick={() => setPendingDelete(null)}>
193 {t('common.action.cancel')}
194 </Button>
195 ) : null}
196 </>
188197 ) : null}
189198 </div>
190199 </li>
@@ -198,7 +207,7 @@
198207 </p>
199208 ) : null}
200209
201 <TagsSection space={space} repo={repo} rev={rev} onBrowse={onBrowse} onChanged={onChanged} />
210 <TagsSection space={space} repo={repo} rev={rev} canWrite={canWrite} onBrowse={onBrowse} onChanged={onChanged} />
202211 <SubmodulesSection space={space} repo={repo} rev={rev} />
203212
204213 <FormModal
Modified web/src/features/repo-branches/ui/tags-section.tsx View web/src/features/repo-branches/ui/tags-section.tsx
@@ -10,6 +10,8 @@
1010export interface TagsSectionProps {
1111 space: string
1212 repo: string
13 /** Repo write access (space owner / repo collaborator server-side). */
14 canWrite: boolean
1315 /** Offer the current revision as the default tag target. */
1416 rev: string
1517 onBrowse: (name: string) => void
@@ -17,7 +19,7 @@
1719}
1820
1921/** Tag management: list, inspect annotated payload, create, delete. */
20export function TagsSection({ space, repo, rev, onBrowse, onChanged }: TagsSectionProps) {
22export function TagsSection({ space, repo, rev, canWrite, onBrowse, onChanged }: TagsSectionProps) {
2123 const { t } = useT()
2224 const [tick, setTick] = useState(0)
2325 const [creating, setCreating] = useState(false)
@@ -58,9 +60,11 @@
5860 <h3 className="mr-2 text-[17px] font-semibold">
5961 {t('repo.tags.title')} <span className="text-[12.5px] font-normal text-shell-ink-muted">{t('repo.tags.total', { count: data.data?.total ?? 0 })}</span>
6062 </h3>
61 <Button className="ml-auto gap-1.5 text-[13px]" disabled={busy} onClick={() => setCreating(true)}>
62 <Plus className="h-4 w-4" aria-hidden /> {t('repo.tag.create')}
63 </Button>
63 {canWrite ? (
64 <Button className="ml-auto gap-1.5 text-[13px]" disabled={busy} onClick={() => setCreating(true)}>
65 <Plus className="h-4 w-4" aria-hidden /> {t('repo.tag.create')}
66 </Button>
67 ) : null}
6468 </div>
6569 {notice || error ? (
6670 <div className="mb-3">
@@ -104,25 +108,29 @@
104108 >
105109 <GitCommitHorizontal className="h-3.5 w-3.5" aria-hidden /> {t('repo.action.details')}
106110 </Button>
107 <Button
108 variant="secondary"
109 className="repo-delete-button h-8 text-[12.5px]"
110 disabled={busy}
111 onClick={() => {
112 if (pendingDelete !== tag.name) {
113 setPendingDelete(tag.name)
114 return
115 }
116 setPendingDelete(null)
117 void run(() => deleteTag(space, repo, tag.name), t('repo.tag.deletedName', { name: tag.name }))
118 }}
119 >
120 {pendingDelete === tag.name ? t('repo.action.confirmDelete') : t('common.action.delete')}
121 </Button>
122 {pendingDelete === tag.name ? (
123 <Button variant="secondary" className="h-8 text-[12.5px]" onClick={() => setPendingDelete(null)}>
124 {t('common.action.cancel')}
125 </Button>
111 {canWrite ? (
112 <>
113 <Button
114 variant="secondary"
115 className="repo-delete-button h-8 text-[12.5px]"
116 disabled={busy}
117 onClick={() => {
118 if (pendingDelete !== tag.name) {
119 setPendingDelete(tag.name)
120 return
121 }
122 setPendingDelete(null)
123 void run(() => deleteTag(space, repo, tag.name), t('repo.tag.deletedName', { name: tag.name }))
124 }}
125 >
126 {pendingDelete === tag.name ? t('repo.action.confirmDelete') : t('common.action.delete')}
127 </Button>
128 {pendingDelete === tag.name ? (
129 <Button variant="secondary" className="h-8 text-[12.5px]" onClick={() => setPendingDelete(null)}>
130 {t('common.action.cancel')}
131 </Button>
132 ) : null}
133 </>
126134 ) : null}
127135 </div>
128136 </li>
Added web/src/features/repo-browse/model/tree-html.ts View web/src/features/repo-browse/model/tree-html.ts
@@ -0,0 +1,66 @@
1/**
2 * Post-processes the server-rendered tree fragment before it is injected:
3 * every row gains a 16px outline icon (parent / folder / code / doc / config /
4 * file) and its name is wrapped in a span, so a long entry ellipsizes next to
5 * the icon instead of pushing the commit column around.
6 */
7
8type TreeIconKind = 'parent' | 'folder' | 'code' | 'doc' | 'config' | 'file'
9
10const SVG_ATTRS =
11 '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"'
12
13const ICONS: Record<TreeIconKind, string> = {
14 parent: `<svg ${SVG_ATTRS}><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>`,
15 folder: `<svg ${SVG_ATTRS}><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>`,
16 code: `<svg ${SVG_ATTRS}><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m10 13-2 2 2 2"/><path d="m14 17 2-2-2-2"/></svg>`,
17 doc: `<svg ${SVG_ATTRS}><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/></svg>`,
18 config: `<svg ${SVG_ATTRS}><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5c0 1.1.9 2 2 2a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg>`,
19 file: `<svg ${SVG_ATTRS}><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>`,
20}
21
22const CODE_EXT = new Set([
23 'rs', 'mjs', 'cjs', 'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'go', 'java', 'kt', 'kts',
24 'c', 'h', 'cc', 'cpp', 'cxx', 'hpp', 'cs', 'swift', 'php', 'sh', 'bash', 'zsh', 'fish',
25 'lua', 'pl', 'r', 'scala', 'ex', 'exs', 'erl', 'hs', 'ml', 'clj', 'dart', 'vue', 'svelte',
26 'sql', 'html', 'htm', 'css', 'scss', 'less', 'proto', 'graphql',
27])
28const DOC_EXT = new Set(['md', 'markdown', 'mdx', 'rst', 'adoc', 'txt'])
29const CONFIG_EXT = new Set([
30 'toml', 'yaml', 'yml', 'json', 'json5', 'ini', 'conf', 'cfg', 'env', 'lock',
31 'properties', 'editorconfig', 'gitignore', 'gitattributes', 'dockerignore',
32])
33
34function kindFor(name: string, mode: string): TreeIconKind {
35 if (name === '..') return 'parent'
36 if (mode === 'tree' || mode === 'submodule') return 'folder'
37 const dot = name.lastIndexOf('.')
38 const ext = dot === -1 ? '' : name.slice(dot + 1).toLowerCase()
39 if (CODE_EXT.has(ext)) return 'code'
40 if (DOC_EXT.has(ext)) return 'doc'
41 if (CONFIG_EXT.has(ext)) return 'config'
42 return 'file'
43}
44
45/** Returns the fragment's table with icons injected; falls back verbatim. */
46export function prepareTreeHtml(html: string): string {
47 if (!html) return html
48 const doc = new DOMParser().parseFromString(html, 'text/html')
49 const table = doc.querySelector("[data-component='git-tree']")
50 if (!table) return html
51 for (const row of Array.from(table.querySelectorAll('tbody tr'))) {
52 const anchor = row.querySelector('td:first-child a')
53 if (!anchor || anchor.querySelector('.repo-tree-icon')) continue
54 const name = row.getAttribute('data-name') ?? anchor.textContent?.trim() ?? ''
55 const icon = doc.createElement('span')
56 icon.className = 'repo-tree-icon'
57 icon.setAttribute('aria-hidden', 'true')
58 icon.innerHTML = ICONS[kindFor(name, (row.getAttribute('data-mode') ?? '').toLowerCase())]
59 const label = doc.createElement('span')
60 label.className = 'repo-tree-name'
61 while (anchor.firstChild) label.appendChild(anchor.firstChild)
62 anchor.appendChild(icon)
63 anchor.appendChild(label)
64 }
65 return doc.body.innerHTML
66}
No newline at end of file
Modified web/src/features/repo-browse/ui/code-tab.tsx View web/src/features/repo-browse/ui/code-tab.tsx
@@ -1,13 +1,14 @@
11import { useEffect, useMemo, useRef, useState } from 'react'
22import { ArrowLeft, ArrowUp, ChevronRight, Eye, File as FileIcon, FileCode, Folder, FolderGit2, History, Users } from 'lucide-react'
33import { useT } from '@/shared/i18n'
4import { formatBytes, repoKey, useAsync } from '@/shared/lib'
5import { ErrorPanel, PanelNote } from '@/shared/ui'
4import { fmtRelative, fmtTime, formatBytes, repoKey, shortSha, useAsync } from '@/shared/lib'
5import { ErrorPanel, InitialAvatar, PanelNote } from '@/shared/ui'
66import {
77 embedCssText,
88 fetchBlob,
99 fetchTreeJson,
1010 htmlFragmentUrl,
11 listCommitsPage,
1112 parentPath,
1213 primaryCloneUrl,
1314 useHtmlFragment,
@@ -16,6 +17,7 @@
1617} from '@/entities/repo'
1718import { isMarkdownPath, isPdfPath } from '../model/blob-kind'
1819import { prepareMarkdownHtml, prepareReadmeHtml, resolveRepoPath, statRepoPath } from '../model/readme'
20import { prepareTreeHtml } from '../model/tree-html'
1921import { useFragmentNavigation, type FragmentRoutes } from '../model/use-fragment-navigation'
2022import { BlameView } from './blame-view'
2123import { PdfViewer } from './pdf-viewer'
@@ -121,7 +123,6 @@
121123 mode: 'html' | 'json'
122124 routes: FragmentRoutes
123125}) {
124 const { t } = useT()
125126 return (
126127 <div className="repo-code-view">
127128 <div className="repo-tree-card">
@@ -129,14 +130,15 @@
129130 <div className="min-w-0 flex-1">
130131 <Breadcrumbs path={path} onNavigate={routes.onNavigate} />
131132 </div>
132 <button
133 type="button"
134 onClick={() => routes.onShowHistory(path)}
135 className="flex shrink-0 items-center gap-1.5 rounded-md bg-shell-field px-2.5 py-1 text-[12.5px] transition-colors hover:brightness-95"
136 >
137 <History className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden /> {t('repo.commit.history')}
138 </button>
139133 </div>
134 <DirectoryCommitBar
135 space={space}
136 repo={repo}
137 rev={rev}
138 path={path}
139 onOpenCommit={routes.onOpenCommit}
140 onShowHistory={routes.onShowHistory}
141 />
140142 {mode === 'html' ? (
141143 <HtmlTree space={space} repo={repo} rev={rev} path={path} routes={routes} />
142144 ) : (
@@ -220,6 +222,59 @@
220222 )
221223}
222224
225/** Latest commit for the directory being listed — the strip above the table. */
226function DirectoryCommitBar({
227 space,
228 repo,
229 rev,
230 path,
231 onOpenCommit,
232 onShowHistory,
233}: {
234 space: string
235 repo: string
236 rev: string
237 path: string
238 onOpenCommit: (sha: string) => void
239 onShowHistory: (path: string) => void
240}) {
241 const { t } = useT()
242 const state = useAsync(
243 () => listCommitsPage(space, repo, { revision: rev, skip: 0, limit: 1, path: path || undefined }),
244 [space, repo, rev, path],
245 { cacheKey: repoKey(space, repo, `latest-commit:${rev}:${path}`) },
246 )
247 const commit = state.data?.commits?.[0]
248 return (
249 <div className="repo-commit-bar flex items-center gap-2">
250 {commit ? (
251 <button
252 type="button"
253 onClick={() => onOpenCommit(commit.id)}
254 title={commit.message}
255 className="repo-commit-bar-main flex min-w-0 flex-1 items-center gap-2.5 text-left"
256 >
257 <InitialAvatar name={commit.author_name || '?'} size={22} />
258 <span className="shrink-0 text-[12.5px] font-medium">{commit.author_name || t('repo.commit.unknownAuthor')}</span>
259 <span className="repo-commit-subject min-w-0 truncate text-[12.5px] text-shell-ink-muted">{commit.message.split('\n')[0]}</span>
260 <code className="shrink-0 rounded bg-shell-field px-1.5 py-0.5 font-mono text-[11px] text-shell-ink-muted">{shortSha(commit.id)}</code>
261 <span className="shrink-0 text-[12px] text-shell-ink-muted" title={fmtTime(commit.author_time)}>{fmtRelative(commit.author_time)}</span>
262 </button>
263 ) : (
264 <span className="min-w-0 flex-1" />
265 )}
266 <button
267 type="button"
268 onClick={() => onShowHistory(path)}
269 className="repo-commit-history flex shrink-0 items-center gap-1.5"
270 >
271 <History className="h-3.5 w-3.5" aria-hidden />
272 {t('repo.commit.history')}
273 </button>
274 </div>
275 )
276}
277
223278function HtmlTree({
224279 space,
225280 repo,
@@ -239,6 +294,7 @@
239294 htmlFragmentUrl(space, repo, 'tree', { revision: rev, path, _r: retry || undefined }),
240295 )
241296 const onClick = useFragmentNavigation(routes)
297 const html = useMemo(() => (frag && frag.ok ? prepareTreeHtml(frag.html) : null), [frag])
242298
243299 if (!frag) return <PanelNote text={t('repo.tree.loading')} />
244300 if (!frag.ok) {
@@ -246,7 +302,7 @@
246302 <ErrorPanel status={frag.status} what={t('repo.tree.what')} onRetry={() => setRetry(r => r + 1)} />
247303 )
248304 }
249 return <div className="repo-embed repo-embed-scroll p-3" onClick={onClick} dangerouslySetInnerHTML={{ __html: frag.html }} />
305 return <div className="repo-embed repo-embed-scroll pt-3" onClick={onClick} dangerouslySetInnerHTML={{ __html: html ?? '' }} />
250306}
251307
252308function JsonTree({
@@ -351,12 +407,10 @@
351407const SANDBOX_CSS = `
352408*,*::before,*::after{box-sizing:border-box}
353409html,body{margin:0;padding:0;overflow:hidden}
354body{padding:24px 28px;background:var(--shell-surface,#fff);color:var(--shell-ink,#1c1e22);font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;font-size:14px;line-height:1.85}
410body{padding:24px 28px;background:var(--shell-surface,#fff);color:var(--shell-ink,#1c1e22);font-family:var(--font-sans,'Geist','Inter','PingFang SC','Microsoft YaHei',ui-sans-serif,system-ui,sans-serif);font-size:14px;line-height:1.85}
355411.repo-embed .markdown-body{max-width:900px;margin:0 auto}
356412.repo-embed .markdown-body a{color:var(--shell-ink);text-underline-offset:3px}
357.repo-embed .markdown-body h1{font-size:26px;letter-spacing:-.5px}
358.repo-embed .markdown-body h2{font-size:21px;margin-top:1.6em}
359@media(max-width:600px){body{padding:18px}.repo-embed .markdown-body h1{font-size:22px}}
413@media(max-width:600px){body{padding:18px}}
360414.repo-embed img,.repo-embed video,.repo-embed canvas,.repo-embed svg{max-width:100%}
361415.repo-embed img,.repo-embed video{height:auto}
362416.repo-embed svg:not([width]):not([height]){width:1em;height:1em;flex:none}
@@ -452,8 +506,13 @@
452506})()
453507`
454508
509/** The same Google-hosted webfonts the app shell loads, so README prose and
510 * code render in Geist / JetBrains Mono instead of the system fallback. */
511const README_FONTS_IMPORT =
512 '@import url("https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=JetBrains+Mono:wght@400;500;700;800&display=swap");'
513
455514function readmeDocument(bodyHtml: string): string {
456 return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>:root{${shellVarDeclarations()}}${embedCssText}${SANDBOX_CSS}</style></head><body class="repo-embed"><div class="markdown-body">${bodyHtml}</div><script>${SANDBOX_SCRIPT}</script></body></html>`
515 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>`
457516}
458517
459518/**
@@ -665,6 +724,8 @@
665724 '--shell-ink',
666725 '--shell-ink-muted',
667726 '--shell-hairline',
727 '--font-sans',
728 '--font-mono',
668729]
669730
670731function shellVarDeclarations(): string {
Modified web/src/features/repo-history/ui/commits-tab.tsx View web/src/features/repo-history/ui/commits-tab.tsx
@@ -1,11 +1,12 @@
1import { useState } from 'react'
2import { ChevronLeft, ChevronRight, GitCommitHorizontal, GitMerge } from 'lucide-react'
3import { Button, Checkbox, CopyChip, ErrorPanel, PanelNote } from '@/shared/ui'
1import { useMemo, useState } from 'react'
2import { ChevronLeft, ChevronRight, GitCommitHorizontal, GitMerge, Tag } from 'lucide-react'
3import { Button, Checkbox, CopyChip, ErrorPanel, InitialAvatar, PanelNote } from '@/shared/ui'
44import { useT } from '@/shared/i18n'
5import { fmtRelative, fmtTime, repoKey, shortSha, useAsync } from '@/shared/lib'
6import { listCommitsPage } from '@/entities/repo'
5import { fmtDay, fmtRelative, fmtTime, repoKey, shortSha, useAsync } from '@/shared/lib'
6import { listCommitsPage, listTags, type CommitRow } from '@/entities/repo'
77
88const PAGE_SIZE = 50
9const MAX_TAG_CHIPS = 2
910
1011export interface CommitsTabProps {
1112 space: string
@@ -22,10 +23,32 @@
2223 onFirstParentChange: (on: boolean) => void
2324}
2425
26interface CommitDay {
27 key: string
28 label: string
29 commits: CommitRow[]
30}
31
32/** One page of commits, folded under their calendar day (newest first). */
33function groupByDay(commits: CommitRow[]): CommitDay[] {
34 const days: CommitDay[] = []
35 for (const commit of commits) {
36 const date = new Date(commit.committer_time * 1000)
37 const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
38 let day = days[days.length - 1]
39 if (!day || day.key !== key) {
40 day = { key, label: fmtDay(commit.committer_time), commits: [] }
41 days.push(day)
42 }
43 day.commits.push(commit)
44 }
45 return days
46}
47
2548/** Commit history for the selected revision, straight from
2649 * `GET /repos/{s}/{r}/commits`. Mounted with `key={rev}` so switching revision
27 * restarts paging instead of showing page N of a new history. Clicking 查看改动
28 * opens that commit's patch on its own page (`CommitView`). */
50 * restarts paging instead of showing page N of a new history. Commits group by
51 * day with a card row each; clicking a subject or 查看改动 opens its patch. */
2952export function CommitsTab({
3053 space,
3154 repo,
@@ -48,14 +71,33 @@
4871 )
4972
5073 const page = state.data
74 const commits = page?.commits ?? []
5175 const total = page?.total ?? 0
5276 const pages = Math.max(1, Math.ceil(total / PAGE_SIZE))
5377 const current = Math.floor(skip / PAGE_SIZE) + 1
5478 const failure = state.error as { code?: number } | undefined
5579
80 // Tags ride along so a release marker shows on its own commit, the way the
81 // GitLab history does it. A repo without tags just yields nothing.
82 const tags = useAsync(
83 () => listTags(space, repo, 100).catch(() => null),
84 [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
5698 return (
5799 <div className="repo-history">
58 <div className="repo-section-toolbar mb-5 flex flex-wrap items-center gap-3">
100 <div className="repo-section-toolbar mb-4 flex flex-wrap items-center gap-3">
59101 <h3 className="min-w-0 break-all text-[17px] font-semibold">
60102 {t('repo.commit.history')} <span className="font-mono text-[12.5px] font-normal text-shell-ink-muted">· {rev}</span>
61103 {path ? (
@@ -92,59 +134,81 @@
92134 note={t('repo.commits.missing')}
93135 onRetry={() => setRetry(r => r + 1)}
94136 />
95 ) : page && page.commits.length === 0 ? (
137 ) : page && commits.length === 0 ? (
96138 <PanelNote text={total === 0 ? t('repo.commits.none') : t('repo.commits.pageEmpty')} />
97139 ) : (
98 <ul className="repo-commit-list divide-y divide-shell-hairline overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface">
99 {page?.commits.map((commit, index) => {
100 const [subject, ...rest] = commit.message.split('\n')
101 const body = rest.join(' ').replace(/\s+/g, ' ').trim()
102 const isMerge = commit.parents.length > 1
103 const isTip = skip === 0 && index === 0
104 return (
105 <li key={commit.id} className="repo-commit-row flex items-start gap-3 px-5 py-4">
106 {isMerge ? (
107 <GitMerge className="mt-0.5 h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />
108 ) : (
109 <GitCommitHorizontal className="mt-0.5 h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />
110 )}
111 <div className="min-w-0 flex-1">
112 <button
113 type="button"
114 onClick={() => onOpenCommit(commit.id)}
115 title={commit.message}
116 className="repo-commit-subject block max-w-full text-left text-[14px] font-medium leading-6 hover:underline hover:decoration-shell-hairline"
117 >
118 {subject || t('repo.commit.noMessage')}
119 </button>
120 {body ? (
121 <p className="mt-1 line-clamp-2 text-[12px] leading-5 text-shell-ink-muted" title={body}>
122 {body}
123 </p>
124 ) : null}
125 <p className="mt-1 flex flex-wrap items-center gap-2 text-[12px] text-shell-ink-muted">
126 <span className="font-medium text-shell-ink">{commit.author_name || t('repo.commit.unknownAuthor')}</span>
127 <span title={fmtTime(commit.author_time)}>{fmtRelative(commit.author_time)}</span>
128 {isMerge ? <span>· {tc('repo.commit.parents', commit.parents.length)}</span> : null}
129 {isTip ? (
130 <span className="rounded-full bg-shell-field px-2 py-0.5 text-[11px] text-shell-ink-muted">{t('repo.commit.latest')}</span>
131 ) : null}
132 </p>
133 </div>
134 <div className="repo-commit-actions flex shrink-0 items-center gap-2">
135 <CopyChip text={commit.id} label={shortSha(commit.id, 8)} />
136 <Button
137 variant="secondary"
138 className="h-8 text-[12.5px]"
139 onClick={() => onOpenCommit(commit.id)}
140 >
141 {t('repo.action.viewChanges')}
142 </Button>
143 </div>
144 </li>
145 )
146 })}
147 </ul>
140 <div className="flex flex-col">
141 {days.map((day, dayIndex) => (
142 <section key={day.key + '-' + dayIndex} className={dayIndex > 0 ? 'mt-5' : ''}>
143 <h4 className="repo-commit-day mb-2 flex items-center gap-2.5 px-1">
144 <GitCommitHorizontal className="h-3.5 w-3.5 shrink-0" aria-hidden />
145 <span>{day.label}</span>
146 <span className="repo-commit-day-rule" aria-hidden />
147 </h4>
148 <ul className="repo-commit-list">
149 {day.commits.map(commit => {
150 const [subject, ...rest] = commit.message.split('\n')
151 const body = rest.join(' ').replace(/\s+/g, ' ').trim()
152 const isMerge = commit.parents.length > 1
153 const isTip = skip === 0 && commit.id === commits[0]?.id
154 const commitTags = tagsBySha.get(commit.id) ?? []
155 return (
156 <li key={commit.id} className="repo-commit-row flex items-start gap-3 px-4 py-3">
157 <InitialAvatar name={commit.author_name || '?'} size={26} />
158 <div className="min-w-0 flex-1">
159 <button
160 type="button"
161 onClick={() => onOpenCommit(commit.id)}
162 title={commit.message}
163 className="repo-commit-subject block max-w-full text-left text-[13.5px] font-medium leading-6"
164 >
165 {subject || t('repo.commit.noMessage')}
166 </button>
167 {body ? (
168 <p className="mt-0.5 line-clamp-1 text-[12px] leading-5 text-shell-ink-muted" title={body}>
169 {body}
170 </p>
171 ) : null}
172 <p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px] text-shell-ink-muted">
173 <span className="font-medium text-shell-ink">{commit.author_name || t('repo.commit.unknownAuthor')}</span>
174 <span title={fmtTime(commit.committer_time)}>{fmtRelative(commit.committer_time)}</span>
175 {commitTags.slice(0, MAX_TAG_CHIPS).map(name => (
176 <span key={name} className="repo-commit-tag" title={name}>
177 <Tag className="h-3 w-3" aria-hidden />
178 {name}
179 </span>
180 ))}
181 {commitTags.length > MAX_TAG_CHIPS ? (
182 <span className="repo-commit-tag">+{commitTags.length - MAX_TAG_CHIPS}</span>
183 ) : null}
184 {isMerge ? (
185 <span className="inline-flex items-center gap-1">
186 <GitMerge className="h-3.5 w-3.5" aria-hidden />
187 {tc('repo.commit.parents', commit.parents.length)}
188 </span>
189 ) : null}
190 {isTip ? (
191 <span className="rounded-full bg-shell-field px-2 py-0.5 text-[11px]">{t('repo.commit.latest')}</span>
192 ) : null}
193 </p>
194 </div>
195 <div className="repo-commit-actions flex shrink-0 items-center gap-2 pt-0.5">
196 <CopyChip text={commit.id} label={shortSha(commit.id, 8)} />
197 <Button
198 variant="secondary"
199 className="h-7 px-2.5 text-[12px]"
200 onClick={() => onOpenCommit(commit.id)}
201 >
202 {t('repo.action.viewChanges')}
203 </Button>
204 </div>
205 </li>
206 )
207 })}
208 </ul>
209 </section>
210 ))}
211 </div>
148212 )}
149213
150214 {page && total > PAGE_SIZE ? (
@@ -172,4 +236,4 @@
172236 ) : null}
173237 </div>
174238 )
175}
239}
No newline at end of file
Modified web/src/features/repo-pipelines/ui/pipelines-view.tsx View web/src/features/repo-pipelines/ui/pipelines-view.tsx
@@ -104,12 +104,15 @@
104104 space,
105105 repo,
106106 defaultBranch,
107 canWrite,
107108 runId,
108109 onSelectRun,
109110}: {
110111 space: string
111112 repo: string
112113 defaultBranch?: string
114 /** Repo write access (space owner / repo collaborator server-side). */
115 canWrite: boolean
113116 runId: string
114117 onSelectRun: (runId: string) => void
115118}) {
@@ -129,6 +132,7 @@
129132 space={space}
130133 repo={repo}
131134 runId={runId}
135 canWrite={canWrite}
132136 onBack={() => onSelectRun('')}
133137 />
134138 )
@@ -147,9 +151,11 @@
147151 <Button size="default" variant="secondary" onClick={refresh}>
148152 <RefreshCw className="mr-1 h-3.5 w-3.5" /> {t('common.action.refresh')}
149153 </Button>
150 <Button size="default" onClick={() => setTriggering(true)}>
151 <Play className="mr-1 h-3.5 w-3.5" /> {t('repo.pipeline.trigger')}
152 </Button>
154 {canWrite ? (
155 <Button size="default" onClick={() => setTriggering(true)}>
156 <Play className="mr-1 h-3.5 w-3.5" /> {t('repo.pipeline.trigger')}
157 </Button>
158 ) : null}
153159 </div>
154160 </div>
155161
@@ -425,11 +431,13 @@
425431 space,
426432 repo,
427433 runId,
434 canWrite,
428435 onBack,
429436}: {
430437 space: string
431438 repo: string
432439 runId: string
440 canWrite: boolean
433441 onBack: () => void
434442}) {
435443 const { t } = useT()
@@ -503,12 +511,12 @@
503511 </>
504512 ) : null}
505513 <div className="ml-auto flex items-center gap-2">
506 {active ? (
514 {canWrite && active ? (
507515 <Button size="default" variant="secondary" onClick={() => void action(() => cancelRun(space, repo, runId))}>
508516 <XCircle className="mr-1 h-3.5 w-3.5" /> {t('common.action.cancel')}
509517 </Button>
510518 ) : null}
511 {finished ? (
519 {canWrite && finished ? (
512520 <Button size="default" variant="secondary" onClick={() => void action(() => rerun(space, repo, runId))}>
513521 <RotateCw className="mr-1 h-3.5 w-3.5" /> {t('repo.pipeline.rerun')}
514522 </Button>
Modified web/src/features/repo-settings/model/sections.ts View web/src/features/repo-settings/model/sections.ts
@@ -10,6 +10,7 @@
1010
1111export type RepoSettingsSection =
1212 | 'general'
13 | 'features'
1314 | 'topics'
1415 | 'remotes'
1516 | 'import'
@@ -22,6 +23,7 @@
2223
2324export const REPO_SETTINGS_SECTIONS: ReadonlyArray<{ id: RepoSettingsSection; label: string }> = [
2425 { id: 'general', get label() { return t('repo.settings.nav.general') } },
26 { id: 'features', get label() { return t('repo.settings.nav.features') } },
2527 { id: 'topics', get label() { return t('repo.settings.nav.topics') } },
2628 { id: 'remotes', get label() { return t('repo.settings.nav.remotes') } },
2729 { id: 'import', get label() { return t('repo.settings.nav.import') } },
Added web/src/features/repo-settings/ui/sections/features-settings.tsx View web/src/features/repo-settings/ui/sections/features-settings.tsx
@@ -0,0 +1,176 @@
1import { useState } from 'react'
2import { ArrowDown, ArrowUp } from 'lucide-react'
3import { Button } from '@/shared/ui'
4import { useT, type MessageKey } from '@/shared/i18n'
5import { updateRepo, type RepoDetail } from '@/entities/repo'
6import { Notice, SectionHead } from '../parts'
7
8/** 可由设置重排序的中间页签,顺序与站点默认一致。 */
9const REORDERABLE: string[] = ['commits', 'pulls', 'branches', 'pipelines', 'releases', 'ai']
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 pipelines: 'shell.repo.tabs.pipelines',
16 releases: 'shell.repo.tabs.releases',
17 ai: 'shell.repo.tabs.ai',
18}
19
20/** 只保留已知中间页签、去重,缺失项按默认顺序补齐。 */
21function normalize(order: readonly string[]): string[] {
22 const out: string[] = []
23 for (const id of order) if (REORDERABLE.includes(id) && !out.includes(id)) out.push(id)
24 for (const id of REORDERABLE) if (!out.includes(id)) out.push(id)
25 return out
26}
27
28/**
29 * 仓库页签配置:是否允许合并请求、流水线 / AI 总结是否公开展示,以及中间
30 * 页签的顺序。只有空间所有者能保存(服务端 PATCH repo 一律 owner-only)。
31 */
32export function FeaturesSettings({
33 space,
34 repo,
35 repoDto,
36 onChanged,
37}: {
38 space: string
39 repo: string
40 repoDto: RepoDetail
41 onChanged: () => void
42}) {
43 const { t } = useT()
44 const [draft, setDraft] = useState({
45 allow_pull_requests: repoDto.allow_pull_requests,
46 pipelines_public: repoDto.pipelines_public,
47 ai_summary_public: repoDto.ai_summary_public,
48 })
49 const [order, setOrder] = useState<string[]>(() => normalize(repoDto.tab_order))
50 const [busy, setBusy] = useState(false)
51 const [notice, setNotice] = useState<string | null>(null)
52 const [error, setError] = useState<string | null>(null)
53
54 const savedOrder = normalize(repoDto.tab_order)
55 const dirty =
56 draft.allow_pull_requests !== repoDto.allow_pull_requests ||
57 draft.pipelines_public !== repoDto.pipelines_public ||
58 draft.ai_summary_public !== repoDto.ai_summary_public ||
59 order.join(',') !== savedOrder.join(',')
60
61 const move = (index: number, delta: -1 | 1) => {
62 setOrder(prev => {
63 const target = index + delta
64 if (target < 0 || target >= prev.length) return prev
65 const next = [...prev]
66 ;[next[index], next[target]] = [next[target], next[index]]
67 return next
68 })
69 }
70
71 const save = async () => {
72 setBusy(true)
73 setNotice(null)
74 setError(null)
75 try {
76 await updateRepo(space, repo, { ...draft, tab_order: order })
77 setNotice(t('repo.featuresSettings.saved'))
78 onChanged()
79 } catch (err) {
80 setError(err instanceof Error ? err.message : t('repo.error.saveFailed'))
81 } finally {
82 setBusy(false)
83 }
84 }
85
86 const rows: Array<{ key: keyof typeof draft; label: string; hint: string }> = [
87 {
88 key: 'allow_pull_requests',
89 label: t('repo.featuresSettings.allowPr'),
90 hint: t('repo.featuresSettings.allowPrHint'),
91 },
92 {
93 key: 'pipelines_public',
94 label: t('repo.featuresSettings.pipelinesPublic'),
95 hint: t('repo.featuresSettings.pipelinesPublicHint'),
96 },
97 {
98 key: 'ai_summary_public',
99 label: t('repo.featuresSettings.aiPublic'),
100 hint: t('repo.featuresSettings.aiPublicHint'),
101 },
102 ]
103
104 return (
105 <div>
106 <SectionHead title={t('repo.settings.nav.features')} sub={t('repo.featuresSettings.sub')} />
107 <Notice notice={notice} error={error} />
108
109 <ul className="mb-6 space-y-3">
110 {rows.map(row => (
111 <li
112 key={row.key}
113 className="flex flex-wrap items-start gap-3 rounded-xl border border-shell-hairline bg-shell-field px-4 py-3"
114 >
115 <label className="flex flex-1 cursor-pointer items-start gap-3">
116 <input
117 type="checkbox"
118 className="mt-0.5 h-4 w-4 shrink-0 accent-[var(--shell-action)]"
119 checked={draft[row.key]}
120 onChange={event =>
121 setDraft(prev => ({ ...prev, [row.key]: event.target.checked }))
122 }
123 />
124 <span className="min-w-0">
125 <span className="block text-[13.5px] font-medium">{row.label}</span>
126 <span className="mt-1 block text-[12px] leading-5 text-shell-ink-muted">
127 {row.hint}
128 </span>
129 </span>
130 </label>
131 </li>
132 ))}
133 </ul>
134
135 <h4 className="mb-1 text-[14px] font-semibold">{t('repo.featuresSettings.orderTitle')}</h4>
136 <p className="mb-3 text-[12px] leading-5 text-shell-ink-muted">{t('repo.featuresSettings.orderHint')}</p>
137 <ul className="mb-6 space-y-2">
138 {order.map((id, index) => {
139 const label = t(TAB_KEYS[id])
140 return (
141 <li
142 key={id}
143 className="flex items-center gap-3 rounded-xl border border-shell-hairline bg-shell-field px-4 py-2.5"
144 >
145 <span className="min-w-0 flex-1 text-[13.5px] font-medium">{label}</span>
146 <button
147 type="button"
148 aria-label={t('repo.featuresSettings.moveUp', { name: label })}
149 title={t('repo.featuresSettings.moveUp', { name: label })}
150 disabled={index === 0}
151 onClick={() => move(index, -1)}
152 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"
153 >
154 <ArrowUp className="h-3.5 w-3.5" aria-hidden />
155 </button>
156 <button
157 type="button"
158 aria-label={t('repo.featuresSettings.moveDown', { name: label })}
159 title={t('repo.featuresSettings.moveDown', { name: label })}
160 disabled={index === order.length - 1}
161 onClick={() => move(index, 1)}
162 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"
163 >
164 <ArrowDown className="h-3.5 w-3.5" aria-hidden />
165 </button>
166 </li>
167 )
168 })}
169 </ul>
170
171 <Button disabled={busy || !dirty} onClick={() => void save()}>
172 {t('common.action.save')}
173 </Button>
174 </div>
175 )
176}
No newline at end of file
Modified web/src/features/repo-settings/ui/settings-tab.tsx View web/src/features/repo-settings/ui/settings-tab.tsx
@@ -4,6 +4,7 @@
44import { ArchivesSettings } from './sections/archives-settings'
55import { AiSettings } from './sections/ai-settings'
66import { CiSettings } from './sections/ci-settings'
7import { FeaturesSettings } from './sections/features-settings'
78import { GeneralSettings } from './sections/general-settings'
89import { ImportSettings } from './sections/import-settings'
910import { InsightsSettings } from './sections/insights-settings'
@@ -60,6 +61,7 @@
6061 </nav>
6162 <div className="repo-settings-content rounded-xl border border-shell-hairline bg-shell-surface p-6">
6263 {section === 'general' ? <GeneralSettings space={space} repo={repo} repoDto={repoDto} onChanged={onSaved} /> : null}
64 {section === 'features' ? <FeaturesSettings space={space} repo={repo} repoDto={repoDto} onChanged={onSaved} /> : null}
6365 {section === 'topics' ? <TopicsSettings space={space} repo={repo} /> : null}
6466 {section === 'protection' ? <ProtectionSettings space={space} repo={repo} /> : null}
6567 {section === 'ci' ? <CiSettings space={space} repo={repo} rev={rev} /> : null}
Modified web/src/shared/i18n/messages/en/repo.ts View web/src/shared/i18n/messages/en/repo.ts
@@ -440,6 +440,19 @@
440440 'repo.aside.languages': 'Languages',
441441 'repo.aside.primaryLanguage': 'Primary language: {language}',
442442 'repo.settings.nav.general': 'General',
443 'repo.settings.nav.features': 'Tabs',
444 'repo.featuresSettings.sub': 'Control which tabs the repository strip shows and in what order. Space owners only.',
445 'repo.featuresSettings.allowPr': 'Allow pull requests',
446 'repo.featuresSettings.allowPrHint': 'When off, the Pull requests tab is hidden for everyone and creating pull requests is rejected.',
447 'repo.featuresSettings.pipelinesPublic': 'Publicly show pipelines',
448 'repo.featuresSettings.pipelinesPublicHint': 'When off, the Pipelines tab is visible to space members only.',
449 'repo.featuresSettings.aiPublic': 'Publicly show AI summary',
450 'repo.featuresSettings.aiPublicHint': 'When off, the AI summary tab is visible to space members only.',
451 'repo.featuresSettings.orderTitle': 'Tab order',
452 'repo.featuresSettings.orderHint': 'Reorder Commits / Pull requests / Branches / Pipelines / Releases / AI summary. Code and Settings stay pinned.',
453 'repo.featuresSettings.moveUp': 'Move {name} up',
454 'repo.featuresSettings.moveDown': 'Move {name} down',
455 'repo.featuresSettings.saved': 'Tab settings saved.',
443456 'repo.settings.nav.topics': 'Topics',
444457 'repo.settings.nav.remotes': 'Remotes',
445458 'repo.settings.nav.import': 'Import',
Modified web/src/shared/i18n/messages/zh-CN/repo.ts View web/src/shared/i18n/messages/zh-CN/repo.ts
@@ -440,6 +440,19 @@
440440 'repo.aside.languages': '开发语言',
441441 'repo.aside.primaryLanguage': '主要语言:{language}',
442442 'repo.settings.nav.general': '常规',
443 'repo.settings.nav.features': '页签',
444 'repo.featuresSettings.sub': '控制仓库标签条里有哪些页签、以及它们的顺序。仅空间所有者可修改。',
445 'repo.featuresSettings.allowPr': '允许合并请求',
446 'repo.featuresSettings.allowPrHint': '关闭后「合并请求」页签对所有人隐藏,创建合并请求会被拒绝。',
447 'repo.featuresSettings.pipelinesPublic': '公开展示流水线',
448 'repo.featuresSettings.pipelinesPublicHint': '关闭后「流水线」页签仅空间成员可见。',
449 'repo.featuresSettings.aiPublic': '公开展示 AI 总结',
450 'repo.featuresSettings.aiPublicHint': '关闭后「AI 总结」页签仅空间成员可见。',
451 'repo.featuresSettings.orderTitle': '页签顺序',
452 'repo.featuresSettings.orderHint': '调整「提交 / 合并请求 / 分支 / 流水线 / 发布 / AI 总结」的顺序;代码与设置固定不动。',
453 'repo.featuresSettings.moveUp': '上移 {name}',
454 'repo.featuresSettings.moveDown': '下移 {name}',
455 'repo.featuresSettings.saved': '页签设置已保存。',
443456 'repo.settings.nav.topics': '主题',
444457 'repo.settings.nav.remotes': '远程',
445458 'repo.settings.nav.import': '导入',