@weibaohui/dsh-git-server 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -2
- package/client/bundle.js +657 -75
- package/client/index.js +657 -75
- package/package.json +1 -1
- package/server/dist/api/v1.js +71 -3
- package/server/dist/dshapi.js +558 -66
- package/server/dist/gitx/git.js +39 -3
- package/server/dist/gitx/service.js +4 -3
- package/server/dist/hook.js +31 -2
- package/server/dist/notify.js +55 -0
- package/src/index.js +12 -1
package/client/index.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* @weibaohui/dsh-git-server — Browser half(原生 dsh 插件 UI,无 iframe/桥接)。
|
|
3
3
|
*
|
|
4
4
|
* 两个面:
|
|
5
|
-
* 1. sidebar.footer.action「Git」→
|
|
6
|
-
* 仓库浏览:文件树/文件内容/提交/分支/工单[列表/新建/评论/开关])。
|
|
5
|
+
* 1. sidebar.footer.action「Git」→ 中栏接管管理页(taskboard 同款,盖会话列不盖侧栏;
|
|
6
|
+
* 仓库列表/建仓/删除 + 仓库浏览:文件树/文件内容/提交/分支/工单[列表/新建/评论/开关])。
|
|
7
7
|
* 2. settings.section「Git 服务器」→ 服务配置(启停/端口/数据目录/兜底密码)。
|
|
8
8
|
*
|
|
9
9
|
* 数据通道:宿主同源路由 /dsh-git-server/api/*(dsh 登录门禁保护,
|
|
@@ -27,6 +27,12 @@ const { createElement: h, useState, useEffect, useCallback } = __React
|
|
|
27
27
|
// ── locale ────────────────────────────────────────────────────────────────
|
|
28
28
|
|
|
29
29
|
const NS = 'dshGitServer'
|
|
30
|
+
// 中栏接管(taskboard 同款)开合属性 + 互斥协议
|
|
31
|
+
const DGS_ACTIVE_ATTR = 'data-dsh-git-active'
|
|
32
|
+
const DGS_PANEL_NAME = 'dsh-git'
|
|
33
|
+
const DGS_ACTIVATE_EVENT = 'dsh-panel-activate'
|
|
34
|
+
const DGS_OTHER_ACTIVE_ATTRS = ['data-dsh-atb-active', 'data-dsh-taskboard-active', 'data-dsh-ssh-active', 'data-dsh-prc-active', 'data-dsh-kb-active']
|
|
35
|
+
const DGS_SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="newSession"]'
|
|
30
36
|
const ZH = {
|
|
31
37
|
nav: 'Git', title: '代码仓库',
|
|
32
38
|
myRepos: '我的仓库', newRepo: '新建仓库', repoName: '仓库名', private: '私有',
|
|
@@ -84,7 +90,16 @@ function ensureStyles() {
|
|
|
84
90
|
holder.id = 'dgs-styles'
|
|
85
91
|
holder.style.display = 'none'
|
|
86
92
|
holder.innerHTML = `<style>
|
|
87
|
-
|
|
93
|
+
/* 中栏接管视图(taskboard 同款):容器挂进会话列末尾,html 属性驱动开合,只隐藏列内兄弟。
|
|
94
|
+
* 三代壳层选择器:dev shell data-pane / 官方 CSS-Module centerCol / Desktop 扩展框。 */
|
|
95
|
+
.dsh-git-view{display:none}
|
|
96
|
+
html[data-dsh-git-active] [data-pane="conversation"] > *:not([data-dsh-git-view]),
|
|
97
|
+
html[data-dsh-git-active] [class*="centerCol"] > *:not([data-dsh-git-view]),
|
|
98
|
+
html[data-dsh-git-active] .dshDesktopConversationSurface > *:not([data-dsh-git-view]){display:none !important}
|
|
99
|
+
html[data-dsh-git-active] .dsh-git-view{display:flex;flex-direction:column;height:100%;overflow:hidden}
|
|
100
|
+
/* 壳层退化(会话列缺席):容器兜底挂 body 时退回全屏浮层,保证入口点击永远有响应 */
|
|
101
|
+
.dsh-git-view[data-dsh-git-fallback]{position:fixed;inset:0;z-index:2147483000;display:flex}
|
|
102
|
+
.dgs-page{position:relative;flex:1;min-height:0;display:flex;flex-direction:column;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);font-size:14px}
|
|
88
103
|
.dgs-head{display:flex;align-items:center;gap:12px;padding:12px 28px;border-bottom:1px solid var(--dsw-alias-border-l2);flex:none}
|
|
89
104
|
.dgs-h1{font-size:16px;font-weight:700;margin:0}
|
|
90
105
|
.dgs-close{margin-left:auto;cursor:pointer;border:none;background:transparent;color:var(--dsw-alias-label-secondary);font-size:18px;padding:4px 8px;border-radius:8px}
|
|
@@ -193,6 +208,24 @@ textarea.dgs-input,.dgs-card>.dgs-input{width:100%;box-sizing:border-box;flex:no
|
|
|
193
208
|
.dgs-md blockquote{border-left:3px solid var(--dsw-alias-border-l2);margin:8px 0;padding:2px 12px;color:var(--dsw-alias-label-secondary)}
|
|
194
209
|
.dgs-md table{border-collapse:collapse}
|
|
195
210
|
.dgs-md td,.dgs-md th{border:1px solid var(--dsw-alias-border-l2);padding:4px 10px}
|
|
211
|
+
/* 语法高亮(亮暗双主题可读配色) */
|
|
212
|
+
.tok-k{color:#cf222e;font-weight:500}
|
|
213
|
+
.tok-s{color:#1a7f37}
|
|
214
|
+
.tok-c{color:#6e7781;font-style:italic}
|
|
215
|
+
.tok-n{color:#0550ae}
|
|
216
|
+
.tok-b{color:#953800}
|
|
217
|
+
@media (prefers-color-scheme: dark){
|
|
218
|
+
.tok-k{color:#ff7b72}
|
|
219
|
+
.tok-s{color:#7ee787}
|
|
220
|
+
.tok-c{color:#8b949e}
|
|
221
|
+
.tok-n{color:#79c0ff}
|
|
222
|
+
.tok-b{color:#ffa657}
|
|
223
|
+
}
|
|
224
|
+
.dgs-hit{padding:4px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);cursor:pointer}
|
|
225
|
+
.dgs-hit:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
226
|
+
.dgs-hit-code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-all}
|
|
227
|
+
.dgs-notif-dot{display:inline-block;min-width:16px;height:16px;line-height:16px;text-align:center;border-radius:999px;background:var(--dsw-alias-state-error-primary);color:#fff;font-size:10px;font-weight:700;padding:0 4px;margin-left:4px}
|
|
228
|
+
.dgs-guide{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;background:var(--dsw-alias-markdown-code-block);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px 14px;margin:6px 0;white-space:pre-wrap;word-break:break-all}
|
|
196
229
|
</style>`
|
|
197
230
|
document.head.appendChild(holder)
|
|
198
231
|
}
|
|
@@ -222,25 +255,133 @@ function useHashRoute() {
|
|
|
222
255
|
}
|
|
223
256
|
const nav = (hash) => { location.hash = hash }
|
|
224
257
|
|
|
258
|
+
// ── 语法高亮(零依赖:无 bundler,客户端不能 import,内置一个轻量 tokenizer) ──
|
|
259
|
+
|
|
260
|
+
function escapeHtml(s) {
|
|
261
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const HL = (() => {
|
|
265
|
+
const KW = {
|
|
266
|
+
clike: 'abstract assert boolean break byte case catch char class const continue default do double else enum extends final finally float for goto if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while true false null',
|
|
267
|
+
js: 'const let var function return if else for while do switch case break continue class extends new delete typeof instanceof in of async await yield import export from default try catch finally throw this super static get set null undefined true false',
|
|
268
|
+
py: 'def class return if elif else for while import from as with try except finally raise lambda pass break continue global nonlocal yield assert async await in is not and or del print None True False self cls',
|
|
269
|
+
go: 'func return if else for range switch case break continue go defer select chan map struct interface package import const var type fallthrough default nil true false iota',
|
|
270
|
+
rs: 'fn let mut pub struct enum impl trait for while loop if else match return use mod crate self Self super const static type where async await move ref break continue in as dyn unsafe extern true false None Some Ok Err',
|
|
271
|
+
sh: 'if then else elif fi for while until do done case esac function return local export readonly declare shift eval exec source alias unalias set unset trap exit break continue in true false echo printf cd test',
|
|
272
|
+
sql: 'select from where insert into values update set delete join inner left right outer on group by order having limit offset as asc desc distinct union all create table alter drop index primary key foreign references not null default and or like in is between exists case when then else end',
|
|
273
|
+
}
|
|
274
|
+
const C = { line: '//[^\\n]*', block: '\\/\\*[\\s\\S]*?\\*\\/' }
|
|
275
|
+
const HASH = { line: '#[^\\n]*' }
|
|
276
|
+
const STR = ['"(?:\\\\.|[^"\\\\\\n])*"', "'(?:\\\\.|[^'\\\\\\n])*'", '`(?:\\\\.|[^`\\\\])*`']
|
|
277
|
+
const NUM = '\\b\\d[\\d_]*(?:\\.[\\d_]+)?(?:[eE][+-]?\\d+)?\\b'
|
|
278
|
+
const langs = {
|
|
279
|
+
js: { kw: KW.js, c: C }, mjs: { kw: KW.js, c: C }, cjs: { kw: KW.js, c: C }, jsx: { kw: KW.js, c: C },
|
|
280
|
+
ts: { kw: KW.js + ' interface type namespace declare readonly keyof infer never unknown any string number boolean', c: C },
|
|
281
|
+
tsx: { kw: KW.js + ' interface type namespace declare readonly keyof infer never unknown any string number boolean', c: C },
|
|
282
|
+
json: { kw: 'true false null', c: null },
|
|
283
|
+
py: { kw: KW.py, c: HASH }, rb: { kw: KW.py, c: HASH }, pl: { kw: KW.py, c: HASH },
|
|
284
|
+
go: { kw: KW.go, c: C },
|
|
285
|
+
rs: { kw: KW.rs, c: C },
|
|
286
|
+
java: { kw: KW.clike, c: C }, kt: { kw: KW.clike, c: C }, scala: { kw: KW.clike, c: C },
|
|
287
|
+
c: { kw: KW.clike, c: C }, h: { kw: KW.clike, c: C }, cpp: { kw: KW.clike, c: C }, cc: { kw: KW.clike, c: C }, cxx: { kw: KW.clike, c: C }, cs: { kw: KW.clike, c: C }, mm: { kw: KW.clike, c: C }, m: { kw: KW.clike, c: C },
|
|
288
|
+
sh: { kw: KW.sh, c: HASH }, bash: { kw: KW.sh, c: HASH }, zsh: { kw: KW.sh, c: HASH },
|
|
289
|
+
yaml: { kw: 'true false null yes no on off', c: HASH }, yml: { kw: 'true false null yes no on off', c: HASH },
|
|
290
|
+
toml: { kw: 'true false', c: HASH }, ini: { kw: 'true false', c: HASH }, cfg: { kw: 'true false', c: HASH },
|
|
291
|
+
sql: { kw: KW.sql, c: { line: '--[^\\n]*', block: C.block } },
|
|
292
|
+
css: { kw: '', c: { block: C.block } }, scss: { kw: '', c: C }, less: { kw: '', c: C },
|
|
293
|
+
dockerfile: { kw: 'FROM RUN CMD ENTRYPOINT COPY ADD WORKDIR EXPOSE ENV ARG VOLUME USER LABEL HEALTHCHECK ONBUILD SHELL STOPSIGNAL', c: HASH },
|
|
294
|
+
makefile: { kw: '', c: HASH },
|
|
295
|
+
vue: { kw: KW.js, c: C }, svelte: { kw: KW.js, c: C },
|
|
296
|
+
}
|
|
297
|
+
const byName = { dockerfile: 'dockerfile', makefile: 'makefile', 'cmakelists.txt': 'makefile' }
|
|
298
|
+
const cache = new Map()
|
|
299
|
+
function langFor(name) {
|
|
300
|
+
const lower = (name || '').toLowerCase()
|
|
301
|
+
const ext = byName[lower] || lower.split('.').pop() || ''
|
|
302
|
+
return langs[ext] || null
|
|
303
|
+
}
|
|
304
|
+
function regexFor(cfg) {
|
|
305
|
+
if (cache.has(cfg)) return cache.get(cfg)
|
|
306
|
+
const parts = []
|
|
307
|
+
if (cfg.c && cfg.c.block) parts.push(cfg.c.block)
|
|
308
|
+
if (cfg.c && cfg.c.line) parts.push(cfg.c.line)
|
|
309
|
+
parts.push(...STR, NUM)
|
|
310
|
+
if (cfg.kw) parts.push('\\b(?:' + cfg.kw.split(' ').join('|') + ')\\b')
|
|
311
|
+
const rx = new RegExp(parts.join('|'), 'g')
|
|
312
|
+
cache.set(cfg, rx)
|
|
313
|
+
return rx
|
|
314
|
+
}
|
|
315
|
+
return { langFor, regexFor }
|
|
316
|
+
})()
|
|
317
|
+
|
|
318
|
+
/** 源码 → 带 token 着色的 HTML(输入已转义)。 */
|
|
319
|
+
function highlightCode(text, filename) {
|
|
320
|
+
const cfg = HL.langFor(filename)
|
|
321
|
+
if (!cfg) return escapeHtml(text)
|
|
322
|
+
const rx = HL.regexFor(cfg)
|
|
323
|
+
let out = ''
|
|
324
|
+
let last = 0
|
|
325
|
+
for (const m of text.matchAll(rx)) {
|
|
326
|
+
const i = m.index
|
|
327
|
+
if (i > last) out += escapeHtml(text.slice(last, i))
|
|
328
|
+
const tok = m[0]
|
|
329
|
+
let cls = 'tok-n'
|
|
330
|
+
if (/^\/\/|^\/\*|^#|^--/.test(tok)) cls = 'tok-c'
|
|
331
|
+
else if (/^["'`]/.test(tok)) cls = 'tok-s'
|
|
332
|
+
else if (/^\d/.test(tok)) cls = 'tok-n'
|
|
333
|
+
else cls = 'tok-k'
|
|
334
|
+
out += '<span class="' + cls + '">' + escapeHtml(tok) + '</span>'
|
|
335
|
+
last = i + tok.length
|
|
336
|
+
}
|
|
337
|
+
if (last < text.length) out += escapeHtml(text.slice(last))
|
|
338
|
+
return out
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 提交/评论文本里的 #123 → 点击跳工单(返回 React 子节点数组)。 */
|
|
342
|
+
function linkifyIssues(text, repo) {
|
|
343
|
+
const parts = String(text || '').split(/(#\d+)/g)
|
|
344
|
+
if (parts.length === 1) return text
|
|
345
|
+
return parts.map((p, i) => {
|
|
346
|
+
const m = /^#(\d+)$/.exec(p)
|
|
347
|
+
if (!m) return p
|
|
348
|
+
return h('a', {
|
|
349
|
+
key: i, style: { color: 'var(--dsw-alias-state-business-primary)', cursor: 'pointer' },
|
|
350
|
+
onClick: (e) => { e.stopPropagation(); nav('/r/' + repo.owner + '/' + repo.name + '/issues/' + m[1]) },
|
|
351
|
+
}, p)
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function dirnameOf(p) {
|
|
356
|
+
const i = (p || '').lastIndexOf('/')
|
|
357
|
+
return i < 0 ? '' : p.slice(0, i)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
225
361
|
// ── 全屏管理页 ────────────────────────────────────────────────────────────
|
|
226
362
|
|
|
227
|
-
function RepoBrowser({ repo, t, onBack }) {
|
|
228
|
-
const [tab, setTab] = useState(
|
|
363
|
+
function RepoBrowser({ repo, t, onBack, deep }) {
|
|
364
|
+
const [tab, setTab] = useState(() => {
|
|
365
|
+
const k = deep && deep.tab
|
|
366
|
+
return k && ['files', 'issues', 'pulls', 'wiki', 'releases', 'settings', 'commits', 'branches', 'tags'].includes(k) ? k : 'files'
|
|
367
|
+
})
|
|
229
368
|
const [prPreset, setPrPreset] = useState(null)
|
|
230
369
|
const [issuesSub, setIssuesSub] = useState(null)
|
|
231
370
|
const [ov, setOv] = useState(null)
|
|
232
|
-
const [rev, setRev] = useState('')
|
|
371
|
+
const [rev, setRev] = useState(deep && deep.tab === 'src' ? deep.ref || '' : '')
|
|
233
372
|
const [cloneUrl, setCloneUrl] = useState('')
|
|
234
373
|
const [copied, setCopied] = useState(false)
|
|
235
374
|
const [star, setStar] = useState(null)
|
|
236
375
|
const [watch, setWatch] = useState(null)
|
|
237
376
|
const [forkBusy, setForkBusy] = useState(false)
|
|
377
|
+
const [pop, setPop] = useState(null) // 'star' | 'watch' | 'fork' | null
|
|
378
|
+
const [forkList, setForkList] = useState(null)
|
|
238
379
|
const [msg, setMsg] = useState('')
|
|
239
380
|
useEffect(() => {
|
|
240
381
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/overview`).then((d) => {
|
|
241
382
|
if (!d.defaultBranch) return
|
|
242
383
|
setOv(d)
|
|
243
|
-
setRev(d.defaultBranch)
|
|
384
|
+
setRev((cur) => cur || d.defaultBranch)
|
|
244
385
|
})
|
|
245
386
|
fetch(API + '/status').then((r) => r.json()).then((st) => {
|
|
246
387
|
if (st && st.running) setCloneUrl('git clone http://' + location.hostname + ':' + (st.port || 3400) + '/' + repo.owner + '/' + repo.name + '.git')
|
|
@@ -271,15 +412,37 @@ function RepoBrowser({ repo, t, onBack }) {
|
|
|
271
412
|
h('span', { className: 'dgs-h1' }, repo.owner + '/' + repo.name),
|
|
272
413
|
repo.private || (ov && ov.private) ? h('span', { className: 'dgs-badge pri' }, t('private')) : null,
|
|
273
414
|
h('span', { style: { flex: 1 } }),
|
|
415
|
+
h('div', { style: { position: 'relative', display: 'flex', alignItems: 'center', gap: 10 } },
|
|
274
416
|
star ? h('span', { className: 'dgs-labeled' },
|
|
275
417
|
h('button', { className: 'dgs-btn ghost', onClick: () => toggle('star') }, star.on ? '★ Unstar' : '☆ Star'),
|
|
276
|
-
h('span', { className: 'dgs-labeled-count' }, star.count)) : null,
|
|
418
|
+
h('span', { className: 'dgs-labeled-count', style: { cursor: 'pointer' }, title: '谁点了星标', onClick: () => setPop(pop === 'star' ? null : 'star') }, star.count)) : null,
|
|
277
419
|
watch ? h('span', { className: 'dgs-labeled' },
|
|
278
420
|
h('button', { className: 'dgs-btn ghost', onClick: () => toggle('watch') }, watch.on ? '👁 Unwatch' : '👁 Watch'),
|
|
279
|
-
h('span', { className: 'dgs-labeled-count' }, watch.count)) : null,
|
|
280
|
-
h('
|
|
281
|
-
h('
|
|
282
|
-
|
|
421
|
+
h('span', { className: 'dgs-labeled-count', style: { cursor: 'pointer' }, title: '谁在关注', onClick: () => setPop(pop === 'watch' ? null : 'watch') }, watch.count)) : null,
|
|
422
|
+
h('span', { className: 'dgs-labeled' },
|
|
423
|
+
h('button', { className: 'dgs-btn ghost', disabled: forkBusy, onClick: fork, style: { display: 'inline-flex', alignItems: 'center', gap: 6 } },
|
|
424
|
+
h('span', { style: { display: 'inline-flex', lineHeight: 0 }, dangerouslySetInnerHTML: { __html: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="4" cy="3" r="1.7" fill="currentColor" stroke="none"/><circle cx="12" cy="3" r="1.7" fill="currentColor" stroke="none"/><circle cx="8" cy="13" r="1.7" fill="currentColor" stroke="none"/><path d="M4 4.7v2.8c0 .6.4 1 1 1h2.2M12 4.7v2.8c0 .6-.4 1-1 1H8.8M8 8.5v2.8"/></svg>' } }),
|
|
425
|
+
'Fork'),
|
|
426
|
+
h('span', { className: 'dgs-labeled-count', style: { cursor: 'pointer' }, title: '复刻列表', onClick: () => {
|
|
427
|
+
if (pop === 'fork') { setPop(null); return }
|
|
428
|
+
setPop('fork')
|
|
429
|
+
if (forkList === null) api('GET', `/dsh/repos/${repo.owner}/${repo.name}/forks`).then((d) => setForkList(Array.isArray(d) ? d : []))
|
|
430
|
+
} }, ov ? ov.numForks || 0 : '')),
|
|
431
|
+
pop ? h('div', { className: 'dgs-card', style: { position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 50, minWidth: 230, maxHeight: 280, overflowY: 'auto', margin: 0, padding: '10px 12px' } },
|
|
432
|
+
h('div', { style: { fontWeight: 700, fontSize: 12.5, marginBottom: 6 } },
|
|
433
|
+
pop === 'star' ? '星标用户' : pop === 'watch' ? '关注者' : '复刻(Fork)'),
|
|
434
|
+
pop === 'fork'
|
|
435
|
+
? (forkList === null ? h('div', { className: 'dgs-sub' }, t('loading'))
|
|
436
|
+
: forkList.length === 0 ? h('div', { className: 'dgs-sub' }, '—')
|
|
437
|
+
: forkList.map((f) => h('div', { key: f.owner + '/' + f.name, style: { padding: '3px 0' } },
|
|
438
|
+
h('a', { className: 'dgs-sub', style: { color: 'var(--dsw-alias-state-business-primary)', cursor: 'pointer' }, onClick: () => { setPop(null); nav('/r/' + f.owner + '/' + f.name) } },
|
|
439
|
+
f.owner + '/' + f.name + (f.stars ? ' ★' + f.stars : '')))))
|
|
440
|
+
: ((pop === 'star' ? (star && star.users) : (watch && watch.users)) || []).length === 0
|
|
441
|
+
? h('div', { className: 'dgs-sub' }, '—')
|
|
442
|
+
: ((pop === 'star' ? star.users : watch.users) || []).map((u) =>
|
|
443
|
+
h('div', { key: u, style: { padding: '3px 0' } },
|
|
444
|
+
h('a', { className: 'dgs-sub', style: { color: 'var(--dsw-alias-state-business-primary)', cursor: 'pointer' }, onClick: () => { setPop(null); nav('/u/' + u) } }, '@' + u))))
|
|
445
|
+
: null),
|
|
283
446
|
),
|
|
284
447
|
msg ? h('div', { className: 'dgs-err' }, msg) : null,
|
|
285
448
|
h('div', { className: 'dgs-tabs' },
|
|
@@ -310,19 +473,19 @@ function RepoBrowser({ repo, t, onBack }) {
|
|
|
310
473
|
h('a', { className: 'dgs-btn ghost', style: { borderRadius: '0 8px 8px 0', borderLeft: 'none' }, href: `/dsh-git-server/api/dsh/repos/${repo.owner}/${repo.name}/archive/${encodeURIComponent(rev || 'master')}.zip`, download: `${repo.name}-${rev}.zip`, title: '下载' }, '⭳'))
|
|
311
474
|
: null))
|
|
312
475
|
: null,
|
|
313
|
-
tab === 'files' && h(FileTree, { repo, rev: rev || 'master', t, overview: ov, onOverview: setOv }),
|
|
476
|
+
tab === 'files' && h(FileTree, { repo, rev: rev || 'master', t, overview: ov, onOverview: setOv, cloneUrl, initialPath: deep && deep.tab === 'src' ? deep.filePath : '' }),
|
|
314
477
|
tab === 'commits' && h(Commits, { repo, rev: rev || 'master', t }),
|
|
315
478
|
tab === 'branches' && h(Branches, { repo, t, onNewPR: (head, base) => { setPrPreset({ head, base }); setTab('pulls') } }),
|
|
316
|
-
tab === 'issues' && h(IssuesArea, { repo, t, initialSub: issuesSub || undefined }),
|
|
317
|
-
tab === 'pulls' && h(Pulls, { repo, t, preset: prPreset, onPresetDone: () => setPrPreset(null), onGoIssuesSub: (k) => { setIssuesSub(k); setTab('issues') } }),
|
|
479
|
+
tab === 'issues' && h(IssuesArea, { repo, t, initialSub: issuesSub || undefined, initialIdx: deep && deep.tab === 'issues' && deep.arg ? Number(deep.arg) : undefined }),
|
|
480
|
+
tab === 'pulls' && h(Pulls, { repo, t, preset: prPreset, onPresetDone: () => setPrPreset(null), onGoIssuesSub: (k) => { setIssuesSub(k); setTab('issues') }, initialIdx: deep && deep.tab === 'pulls' && deep.arg ? Number(deep.arg) : undefined }),
|
|
318
481
|
tab === 'wiki' && h(WikiView, { repo, t }),
|
|
319
482
|
tab === 'releases' && h(Releases, { repo, t }),
|
|
320
483
|
tab === 'settings' && h(RepoSettings, { repo, t }),
|
|
321
484
|
)
|
|
322
485
|
}
|
|
323
486
|
|
|
324
|
-
function FileTree({ repo, rev, t, overview, onOverview }) {
|
|
325
|
-
const [path, setPath] = useState('')
|
|
487
|
+
function FileTree({ repo, rev, t, overview, onOverview, initialPath, cloneUrl }) {
|
|
488
|
+
const [path, setPath] = useState(() => dirnameOf(initialPath || ''))
|
|
326
489
|
const [entries, setEntries] = useState(null)
|
|
327
490
|
const [file, setFile] = useState(null)
|
|
328
491
|
const [err, setErr] = useState('')
|
|
@@ -336,6 +499,9 @@ function FileTree({ repo, rev, t, overview, onOverview }) {
|
|
|
336
499
|
const [editText, setEditText] = useState('')
|
|
337
500
|
const [editMsg, setEditMsg] = useState('')
|
|
338
501
|
const [histMode, setHistMode] = useState(null)
|
|
502
|
+
const [searchQ, setSearchQ] = useState('')
|
|
503
|
+
const [hits, setHits] = useState(null) // null=未搜索;[]=无结果
|
|
504
|
+
const [deepDone, setDeepDone] = useState(false)
|
|
339
505
|
const load = useCallback((p) => {
|
|
340
506
|
setFile(null); setEntries(null); setErr(''); setBlameOn(false); setEditing(false); setHistMode(null)
|
|
341
507
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/tree?ref=${encodeURIComponent(rev)}&path=${encodeURIComponent(p)}`)
|
|
@@ -351,20 +517,38 @@ function FileTree({ repo, rev, t, overview, onOverview }) {
|
|
|
351
517
|
if (p !== '') setReadme(null)
|
|
352
518
|
}, [repo.owner, repo.name, rev])
|
|
353
519
|
useEffect(() => { load(path) }, [load, path])
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
if (e.type === 'dir' || e.type === 'tree') { setPath(p); return }
|
|
357
|
-
api('GET', `/repos/${repo.owner}/${repo.name}/raw?ref=${encodeURIComponent(rev)}&path=${encodeURIComponent(p)}`)
|
|
520
|
+
const openFile = (fp, name, size) => {
|
|
521
|
+
api('GET', `/repos/${repo.owner}/${repo.name}/raw?ref=${encodeURIComponent(rev)}&path=${encodeURIComponent(fp)}`)
|
|
358
522
|
.then(async (d) => {
|
|
359
|
-
|
|
523
|
+
if (!d.ok) { setPath(fp); setFile(null); return } // 目录型永久链接:落到目录
|
|
524
|
+
const isMd = /\.md$/i.test(name)
|
|
360
525
|
let html = ''
|
|
361
|
-
if (isMd
|
|
526
|
+
if (isMd) {
|
|
362
527
|
const r = await api('POST', '/dsh/markdown', { text: d.text })
|
|
363
528
|
html = r.html || ''
|
|
364
529
|
}
|
|
365
|
-
setFile({ name:
|
|
530
|
+
setFile({ name, path: fp, text: d.text, size: size || 0, isMd, html })
|
|
366
531
|
})
|
|
367
532
|
}
|
|
533
|
+
const openEntry = (e) => {
|
|
534
|
+
if (e.type === 'dir' || e.type === 'tree') { setPath(e.path); return }
|
|
535
|
+
openFile(e.path, e.name, e.size)
|
|
536
|
+
}
|
|
537
|
+
// 深链直达:#/r/:o/:r/src/:ref/:path —— 打开指定文件(rev 由父组件同步)
|
|
538
|
+
useEffect(() => {
|
|
539
|
+
if (deepDone || !initialPath) return
|
|
540
|
+
setDeepDone(true)
|
|
541
|
+
openFile(initialPath, initialPath.split('/').pop(), 0)
|
|
542
|
+
}, [initialPath])
|
|
543
|
+
const doSearch = () => {
|
|
544
|
+
const q = searchQ.trim()
|
|
545
|
+
if (!q) return
|
|
546
|
+
setHits(null); setFile(null)
|
|
547
|
+
setMsg('')
|
|
548
|
+
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/search?q=${encodeURIComponent(q)}&ref=${encodeURIComponent(rev)}`)
|
|
549
|
+
.then((d) => { setHits(Array.isArray(d.matches) ? d.matches : []); if (d.error) setMsg(d.error) })
|
|
550
|
+
.catch(() => setMsg(t('loadFailed')))
|
|
551
|
+
}
|
|
368
552
|
const startCreate = (mode) => {
|
|
369
553
|
setNewFile(mode); setMsg('')
|
|
370
554
|
setNf({ path: '', content: '', message: '' })
|
|
@@ -395,6 +579,11 @@ function FileTree({ repo, rev, t, overview, onOverview }) {
|
|
|
395
579
|
h('span', { className: 'dgs-crumb' + (i === arr.length - 1 ? ' cur' : ''), onClick: () => setPath(arr.slice(0, i + 1).join('/')) }, seg),
|
|
396
580
|
i < arr.length - 1 ? h('span', { className: 'dgs-sub' }, '/') : null))),
|
|
397
581
|
h('span', { style: { flex: 1 } }),
|
|
582
|
+
h('input', {
|
|
583
|
+
className: 'dgs-input', style: { maxWidth: 180, flex: 'none' }, placeholder: '搜索代码…',
|
|
584
|
+
value: searchQ, onChange: (e) => setSearchQ(e.target.value),
|
|
585
|
+
onKeyDown: (e) => { if (e.key === 'Enter') doSearch() },
|
|
586
|
+
}),
|
|
398
587
|
h('button', { className: 'dgs-btn', onClick: () => startCreate('create') }, '新的文件'),
|
|
399
588
|
h('button', { className: 'dgs-btn ghost', onClick: () => startCreate('upload') }, '上传文件')),
|
|
400
589
|
msg ? h('div', { className: 'dgs-err' }, msg) : null,
|
|
@@ -433,15 +622,17 @@ function FileTree({ repo, rev, t, overview, onOverview }) {
|
|
|
433
622
|
h('span', { style: { fontWeight: 600, fontSize: 12.5 } }, '📄 ' + file.name + ' ' + (file.size ? fmtSize(file.size) : '')),
|
|
434
623
|
h('span', { style: { flex: 1 } }),
|
|
435
624
|
file.text !== null ? h('a', { className: 'dgs-sub', style: { cursor: 'pointer', marginRight: 12 }, onClick: () => {
|
|
436
|
-
|
|
625
|
+
// 永久链接走面板的 hash 路由(旧网页 UI 已裁撤,裸路径链接是死的)
|
|
626
|
+
const link = location.origin + location.pathname + '#/r/' + repo.owner + '/' + repo.name + '/src/' + encodeURIComponent(rev) + '/' + (file.path || '').split('/').map(encodeURIComponent).join('/')
|
|
627
|
+
try { navigator.clipboard.writeText(link) } catch {}
|
|
437
628
|
setMsg('✓ 已复制永久链接'); setTimeout(() => setMsg(''), 1500)
|
|
438
629
|
} }, '永久链接') : null,
|
|
439
630
|
file.text !== null ? h('a', { className: 'dgs-sub', style: { cursor: 'pointer', marginRight: 12 }, onClick: () => setHistMode(histMode === 'history' ? null : 'history') }, '文件历史') : null,
|
|
440
|
-
file.text !== null ? h('a', { className: 'dgs-sub', style: { marginRight: 12 }, href: `/dsh-git-server/api/repos/${repo.owner}/${repo.name}/raw?ref=${encodeURIComponent(rev)}&path=${encodeURIComponent(path)}`, target: '_blank', rel: 'noreferrer' }, '原始文件') : null,
|
|
631
|
+
file.text !== null ? h('a', { className: 'dgs-sub', style: { marginRight: 12 }, href: `/dsh-git-server/api/repos/${repo.owner}/${repo.name}/raw?ref=${encodeURIComponent(rev)}&path=${encodeURIComponent(file.path || path)}`, target: '_blank', rel: 'noreferrer' }, '原始文件') : null,
|
|
441
632
|
file.text !== null ? h('button', { className: 'dgs-btn ghost', style: { padding: '2px 8px' }, onClick: () => { setEditing(true); setEditText(file.text || '') } }, '✏️') : null,
|
|
442
633
|
file.text !== null ? h('button', { className: 'dgs-btn danger', style: { padding: '2px 8px' }, onClick: async () => {
|
|
443
634
|
if (!confirm('删除文件 ' + file.name + ' ?')) return
|
|
444
|
-
const d = await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/files`, { path, branch: rev })
|
|
635
|
+
const d = await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/files`, { path: file.path || path, branch: rev })
|
|
445
636
|
if (d && d.ok) { setFile(null); load(path) } else setMsg((d && d.error) || '删除失败')
|
|
446
637
|
} }, '🗑️') : null,
|
|
447
638
|
file.text !== null ? h('button', { className: 'dgs-btn ghost', style: { padding: '2px 8px' }, onClick: () => setBlameOn(!blameOn) }, 'Blame') : null),
|
|
@@ -453,20 +644,37 @@ function FileTree({ repo, rev, t, overview, onOverview }) {
|
|
|
453
644
|
h('button', { className: 'dgs-btn ghost', onClick: () => setEditing(false) }, '取消'),
|
|
454
645
|
h('button', { className: 'dgs-btn', disabled: busy, onClick: async () => {
|
|
455
646
|
setBusy(true); setMsg('')
|
|
456
|
-
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/files`, { path, branch: rev, content: editText, message: editMsg, overwrite: true })
|
|
647
|
+
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/files`, { path: file.path || path, branch: rev, content: editText, message: editMsg, overwrite: true })
|
|
457
648
|
setBusy(false)
|
|
458
|
-
if (d && d.ok) { setEditing(false); load(path) } else setMsg((d && d.error) || '保存失败')
|
|
649
|
+
if (d && d.ok) { setEditing(false); setFile(null); load(path) } else setMsg((d && d.error) || '保存失败')
|
|
459
650
|
} }, '提交修改')))
|
|
460
|
-
: histMode === 'history' ? h(FileHistory, { repo, rev, path })
|
|
651
|
+
: histMode === 'history' ? h(FileHistory, { repo, rev, path: file.path || path })
|
|
461
652
|
: blameOn && file.text !== null
|
|
462
|
-
? h(BlameView, { repo, rev, path, text: file.text })
|
|
653
|
+
? h(BlameView, { repo, rev, path: file.path || path, text: file.text })
|
|
463
654
|
: file.isMd
|
|
464
655
|
? h('div', { className: 'dgs-md', style: { padding: '14px 16px' }, dangerouslySetInnerHTML: { __html: file.html || '' } })
|
|
465
|
-
:
|
|
656
|
+
: file.text === null
|
|
657
|
+
? h('pre', { className: 'dgs-file', style: { border: 'none', borderRadius: 0 } }, t('emptyFile'))
|
|
658
|
+
: h('pre', { className: 'dgs-file', style: { border: 'none', borderRadius: 0 }, dangerouslySetInnerHTML: { __html: highlightCode(file.text, file.name) } })))
|
|
659
|
+
: hits !== null ? renderHits()
|
|
466
660
|
: entries === null ? h('div', { className: 'dgs-empty' }, t('loading'))
|
|
467
661
|
: renderDir(),
|
|
468
662
|
)
|
|
469
663
|
|
|
664
|
+
function renderHits() {
|
|
665
|
+
return h('div', null,
|
|
666
|
+
h('div', { className: 'dgs-row', style: { marginBottom: 8 } },
|
|
667
|
+
h('button', { className: 'dgs-btn ghost', onClick: () => setHits(null) }, t('back')),
|
|
668
|
+
h('span', { className: 'dgs-sub' }, '在 ' + rev + ' 中搜索「' + searchQ.trim() + '」')),
|
|
669
|
+
hits.length === 0 ? h('div', { className: 'dgs-empty' }, '—')
|
|
670
|
+
: hits.map((hit, i) =>
|
|
671
|
+
h('div', { key: i, className: 'dgs-hit', onClick: () => { setHits(null); setPath(dirnameOf(hit.path)); openFile(hit.path, hit.path.split('/').pop(), 0) } },
|
|
672
|
+
h('div', null,
|
|
673
|
+
h('span', { style: { color: 'var(--dsw-alias-state-business-primary)', fontWeight: 500, fontSize: 12.5 } }, hit.path),
|
|
674
|
+
h('span', { className: 'dgs-sub' }, ':' + hit.line)),
|
|
675
|
+
h('div', { className: 'dgs-hit-code' }, hit.text))))
|
|
676
|
+
}
|
|
677
|
+
|
|
470
678
|
function renderDir() {
|
|
471
679
|
const head = entries.find((e) => e.last)
|
|
472
680
|
const rows = entries.map((e) =>
|
|
@@ -483,7 +691,9 @@ function FileTree({ repo, rev, t, overview, onOverview }) {
|
|
|
483
691
|
h('span', { style: { flex: 1 } }),
|
|
484
692
|
h('span', { className: 'dgs-sub' }, timeAgo(head.last.date))) : null
|
|
485
693
|
const table = entries.length === 0
|
|
486
|
-
?
|
|
694
|
+
? (path === '' && overview && overview.numCommits === 0
|
|
695
|
+
? renderEmptyRepoGuide()
|
|
696
|
+
: h('div', { className: 'dgs-empty' }, t('emptyDir')))
|
|
487
697
|
: h('div', null, headRow, h('table', { className: 'dgs-table' }, h('tbody', null, rows)))
|
|
488
698
|
const readmeEl = (path === '' && overview && overview.readmeHtml)
|
|
489
699
|
? h('div', { className: 'dgs-card', style: { marginTop: 12 } },
|
|
@@ -492,9 +702,26 @@ function FileTree({ repo, rev, t, overview, onOverview }) {
|
|
|
492
702
|
: null
|
|
493
703
|
return h('div', null, table, readmeEl)
|
|
494
704
|
}
|
|
705
|
+
|
|
706
|
+
function renderEmptyRepoGuide() {
|
|
707
|
+
const remote = (cloneUrl || '').replace(/^git clone\s+/, '') || ('http://<服务器>:3400/' + repo.owner + '/' + repo.name + '.git')
|
|
708
|
+
const branch = (overview && overview.defaultBranch) || 'master'
|
|
709
|
+
const copyBtn = (text) => h('button', { className: 'dgs-btn ghost', style: { padding: '2px 10px', fontSize: 12 }, onClick: () => { try { navigator.clipboard.writeText(text) } catch {} } }, t('copy'))
|
|
710
|
+
return h('div', { className: 'dgs-card', style: { maxWidth: 660, margin: '24px auto' } },
|
|
711
|
+
h('div', { style: { fontWeight: 700, fontSize: 15, marginBottom: 6 } }, '这个仓库还是空的'),
|
|
712
|
+
h('div', { className: 'dgs-sub', style: { marginBottom: 12 } }, '克隆到本地开始开发,或把已有的本地仓库推送上来。git 凭据使用 dsh(user-management)的用户名和密码。'),
|
|
713
|
+
h('div', { className: 'dgs-row', style: { marginBottom: 4 } },
|
|
714
|
+
h('span', { style: { fontWeight: 600, fontSize: 12.5, flex: 1 } }, '克隆仓库'),
|
|
715
|
+
copyBtn('git clone ' + remote)),
|
|
716
|
+
h('div', { className: 'dgs-guide' }, 'git clone ' + remote),
|
|
717
|
+
h('div', { className: 'dgs-row', style: { margin: '12px 0 4px' } },
|
|
718
|
+
h('span', { style: { fontWeight: 600, fontSize: 12.5, flex: 1 } }, '推送已有仓库'),
|
|
719
|
+
copyBtn('git remote add origin ' + remote + '\ngit push -u origin ' + branch)),
|
|
720
|
+
h('div', { className: 'dgs-guide' }, 'git remote add origin ' + remote + '\ngit push -u origin ' + branch))
|
|
721
|
+
}
|
|
495
722
|
}
|
|
496
723
|
|
|
497
|
-
function IssuesArea({ repo, t, initialSub }) {
|
|
724
|
+
function IssuesArea({ repo, t, initialSub, initialIdx }) {
|
|
498
725
|
const [sub, setSub] = useState(initialSub || 'list')
|
|
499
726
|
const [labelJump, setLabelJump] = useState(null)
|
|
500
727
|
const [msJump, setMsJump] = useState(null)
|
|
@@ -510,12 +737,12 @@ function IssuesArea({ repo, t, initialSub }) {
|
|
|
510
737
|
h('span', { style: { flex: 1 } }),
|
|
511
738
|
sub === 'list' ? h('button', { className: 'dgs-btn', onClick: () => setCreateSignal((n) => n + 1) }, t('issueNew')) : null),
|
|
512
739
|
sub === 'list' && h(Issues, { repo, t, presetLabel: labelJump, presetMilestone: msJump,
|
|
513
|
-
onPresetDone: () => { setLabelJump(null); setMsJump(null) }, createSignal }),
|
|
740
|
+
onPresetDone: () => { setLabelJump(null); setMsJump(null) }, createSignal, initialIdx }),
|
|
514
741
|
sub === 'labels' && h(LabelsManage, { repo, t, onFilterLabel: (lid) => { setLabelJump(lid); setSub('list') } }),
|
|
515
742
|
sub === 'milestones' && h(MilestonesManage, { repo, t, onOpenMilestone: (m) => { setMsJump(m.id); setSub('list') } }))
|
|
516
743
|
}
|
|
517
744
|
|
|
518
|
-
function Issues({ repo, t, presetLabel, presetMilestone, onPresetDone, createSignal }) {
|
|
745
|
+
function Issues({ repo, t, presetLabel, presetMilestone, onPresetDone, createSignal, initialIdx }) {
|
|
519
746
|
const [state, setState] = useState('open')
|
|
520
747
|
const [flt, setFlt] = useState({ label: '', milestone: '', assignee: '' })
|
|
521
748
|
useEffect(() => {
|
|
@@ -530,7 +757,7 @@ function Issues({ repo, t, presetLabel, presetMilestone, onPresetDone, createSig
|
|
|
530
757
|
const [meta, setMeta] = useState({ labels: [], milestones: [], collabs: [] })
|
|
531
758
|
const [creating, setCreating] = useState(false)
|
|
532
759
|
useEffect(() => { if (createSignal) setCreating(true) }, [createSignal])
|
|
533
|
-
const [openIdx, setOpenIdx] = useState(null)
|
|
760
|
+
const [openIdx, setOpenIdx] = useState(initialIdx ?? null)
|
|
534
761
|
const reload = useCallback(() => {
|
|
535
762
|
setList(null)
|
|
536
763
|
const q = new URLSearchParams({ state, sort })
|
|
@@ -675,6 +902,8 @@ function IssueDetail({ repo, idx, t, onBack }) {
|
|
|
675
902
|
setComments(null)
|
|
676
903
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/issues/${idx}`).then((d) => setIssue(d && d.number ? d : null))
|
|
677
904
|
api('GET', `/repos/${repo.owner}/${repo.name}/issues/${idx}/comments`).then((d) => setComments(d.comments || []))
|
|
905
|
+
// 打开详情即视为已读(通知中心据此消未读)
|
|
906
|
+
api('POST', `/dsh/repos/${repo.owner}/${repo.name}/issues/${idx}/read`).catch(() => {})
|
|
678
907
|
}, [repo.owner, repo.name, idx])
|
|
679
908
|
useEffect(reload, [reload])
|
|
680
909
|
useEffect(() => {
|
|
@@ -704,7 +933,13 @@ function IssueDetail({ repo, idx, t, onBack }) {
|
|
|
704
933
|
comments === null ? h('div', { className: 'dgs-empty' }, t('loading'))
|
|
705
934
|
: comments.length === 0 ? h('div', { className: 'dgs-empty' }, t('noIssues'))
|
|
706
935
|
: comments.map((c) =>
|
|
707
|
-
|
|
936
|
+
c.type === 2 // 提交关单事件(push 时 fixes #N 触发)
|
|
937
|
+
? h('div', { key: c.id || ('ev' + c.created), className: 'dgs-row', style: { padding: '5px 4px', gap: 6 } },
|
|
938
|
+
h('span', null, '🔒'),
|
|
939
|
+
h('span', { className: 'dgs-sub' }, (c.user || '') + ' 通过提交 '),
|
|
940
|
+
h('code', { className: 'dgs-badge' }, (c.commit_sha || '').slice(0, 10) || '?'),
|
|
941
|
+
h('span', { className: 'dgs-sub' }, ' 关闭了此工单 · ' + fmtDate(c.created)))
|
|
942
|
+
: h('div', { key: c.id, className: 'dgs-comment' },
|
|
708
943
|
h('div', { className: 'dgs-row', style: { marginBottom: 4 } },
|
|
709
944
|
h('span', { className: 'dgs-sub' }, `${c.user || ''} · ${fmtDate(c.created)}`),
|
|
710
945
|
h('span', { style: { flex: 1 } }),
|
|
@@ -721,7 +956,7 @@ function IssueDetail({ repo, idx, t, onBack }) {
|
|
|
721
956
|
await api('PATCH', `/repos/${repo.owner}/${repo.name}/issues/comments/${c.id}`, { body: editC.text })
|
|
722
957
|
setEditC(null); reload()
|
|
723
958
|
} }, t('save'))))
|
|
724
|
-
: h('div', { style: { whiteSpace: 'pre-wrap' } }, c.body))),
|
|
959
|
+
: h('div', { style: { whiteSpace: 'pre-wrap' } }, linkifyIssues(c.body, repo)))),
|
|
725
960
|
h('div', { className: 'dgs-card', style: { margin: '12px 0' } },
|
|
726
961
|
h('textarea', { className: 'dgs-input', placeholder: t('commentPlaceholder'), value: text, onChange: (e) => setText(e.target.value) }),
|
|
727
962
|
h('div', { className: 'dgs-row', style: { marginTop: 8 } },
|
|
@@ -804,7 +1039,7 @@ function Commits({ repo, rev, t }) {
|
|
|
804
1039
|
h('table', { className: 'dgs-table' },
|
|
805
1040
|
h('tbody', null, shown.map((c, i) =>
|
|
806
1041
|
h('tr', { key: i, style: { cursor: 'pointer' }, onClick: () => setSel(c.sha) },
|
|
807
|
-
h('td', null, h('div', { style: { fontWeight: 500 } }, (c.message || '').split('\n')[0]),
|
|
1042
|
+
h('td', null, h('div', { style: { fontWeight: 500 } }, linkifyIssues((c.message || '').split('\n')[0], repo)),
|
|
808
1043
|
h('div', { className: 'dgs-sub' }, c.author)),
|
|
809
1044
|
h('td', { className: 'dgs-sub', style: { textAlign: 'right', whiteSpace: 'nowrap' } }, (c.sha || '').slice(0, 10)),
|
|
810
1045
|
h('td', { className: 'dgs-sub', style: { textAlign: 'right', whiteSpace: 'nowrap', width: 100 } }, timeAgo(c.date)))))),
|
|
@@ -824,7 +1059,7 @@ function CommitDetail({ repo, sha, t, onBack }) {
|
|
|
824
1059
|
return h('div', null,
|
|
825
1060
|
h('div', { className: 'dgs-row', style: { marginBottom: 10 } },
|
|
826
1061
|
h('button', { className: 'dgs-btn ghost', onClick: onBack }, t('back')),
|
|
827
|
-
h('span', { style: { fontWeight: 600 } }, (d.message || '').split('\n')[0]),
|
|
1062
|
+
h('span', { style: { fontWeight: 600 } }, linkifyIssues((d.message || '').split('\n')[0], repo)),
|
|
828
1063
|
h('span', { className: 'dgs-sub' }, (d.sha || '').slice(0, 10))),
|
|
829
1064
|
h('div', { className: 'dgs-sub', style: { marginBottom: 8 } },
|
|
830
1065
|
`${(d.author && d.author.name) || ''} · ${fmtDate(when)}`),
|
|
@@ -918,8 +1153,9 @@ function Branches({ repo, t, onNewPR }) {
|
|
|
918
1153
|
b.name !== def ? h('button', { className: 'dgs-btn ghost', style: { marginLeft: 8, padding: '2px 8px' }, onClick: () => onNewPR && onNewPR(b.name, def) }, '+ PR') : null,
|
|
919
1154
|
b.name !== def ? h('button', { className: 'dgs-btn danger', style: { marginLeft: 4, padding: '2px 8px' }, onClick: async () => {
|
|
920
1155
|
if (!confirm('删除分支 ' + b.name + ' ?')) return
|
|
921
|
-
await api('DELETE', `/repos/${repo.owner}/${repo.name}/branches/${encodeURIComponent(b.name)}`)
|
|
922
|
-
reload()
|
|
1156
|
+
const d = await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/branches/${encodeURIComponent(b.name)}`)
|
|
1157
|
+
if (d && d.ok) reload()
|
|
1158
|
+
else alert((d && d.error) || '删除失败')
|
|
923
1159
|
} }, '删') : null))))),
|
|
924
1160
|
h('div', { style: { fontWeight: 700, margin: '18px 0 8px' } }, '标签'),
|
|
925
1161
|
tags === null ? h('div', { className: 'dgs-empty' }, t('loading'))
|
|
@@ -1174,19 +1410,33 @@ function RepoSettings({ repo, t }) {
|
|
|
1174
1410
|
const [form, setForm] = useState({ description: '', private: false, website: '' })
|
|
1175
1411
|
const [collabs, setCollabs] = useState([])
|
|
1176
1412
|
const [addName, setAddName] = useState('')
|
|
1413
|
+
const [addMode, setAddMode] = useState('write')
|
|
1177
1414
|
const [hooks, setHooks] = useState(null)
|
|
1178
1415
|
const [hookUrl, setHookUrl] = useState('')
|
|
1416
|
+
const [hookDeliveries, setHookDeliveries] = useState({}) // id → rows | 'loading'
|
|
1417
|
+
const [prots, setProts] = useState(null)
|
|
1418
|
+
const [protBranch, setProtBranch] = useState('')
|
|
1419
|
+
const [branchList, setBranchList] = useState([])
|
|
1420
|
+
const [mirrorInfo, setMirrorInfo] = useState(null)
|
|
1421
|
+
const [syncBusy, setSyncBusy] = useState(false)
|
|
1179
1422
|
const [msg, setMsg] = useState('')
|
|
1180
1423
|
const [busy, setBusy] = useState(false)
|
|
1181
1424
|
const loadCollabs = () => api('GET', `/dsh/repos/${repo.owner}/${repo.name}/collaborators`).then((d) => setCollabs(Array.isArray(d) ? d : []))
|
|
1182
1425
|
const loadHooks = () => api('GET', `/dsh/repos/${repo.owner}/${repo.name}/hooks`).then((d) => setHooks(Array.isArray(d) ? d : []))
|
|
1426
|
+
const loadProts = () => api('GET', `/dsh/repos/${repo.owner}/${repo.name}/protections`).then((d) => setProts(Array.isArray(d) ? d : []))
|
|
1183
1427
|
useEffect(() => {
|
|
1184
1428
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}`).then((d) => {
|
|
1185
|
-
if (d && d.name) {
|
|
1429
|
+
if (d && d.name) {
|
|
1430
|
+
setInfo(d)
|
|
1431
|
+
setForm({ description: d.description || '', private: !!d.private, website: d.website || '' })
|
|
1432
|
+
if (d.isMirror) setMirrorInfo(d.mirror || { address: '', updatedAt: 0, nextAt: 0 })
|
|
1433
|
+
}
|
|
1186
1434
|
else setMsg((d && d.error) || t('loadFailed'))
|
|
1187
1435
|
})
|
|
1188
1436
|
loadCollabs()
|
|
1189
1437
|
loadHooks()
|
|
1438
|
+
loadProts()
|
|
1439
|
+
api('GET', `/repos/${repo.owner}/${repo.name}/branches`).then((d) => setBranchList((d && d.branches) || []))
|
|
1190
1440
|
}, [repo.owner, repo.name])
|
|
1191
1441
|
if (info === null && !msg) return h('div', { className: 'dgs-empty' }, t('loading'))
|
|
1192
1442
|
const navItem = (k, label) => h('span', { key: k, className: 'item' + (sub === k ? ' active' : ''), onClick: () => setSub(k) }, label)
|
|
@@ -1211,19 +1461,33 @@ function RepoSettings({ repo, t }) {
|
|
|
1211
1461
|
setBusy(false)
|
|
1212
1462
|
d && d.ok ? setMsg('✓ 已保存') : setMsg((d && d.error) || 'failed')
|
|
1213
1463
|
} }, '更新设置')))
|
|
1464
|
+
const collabModeName = { 1: '只读', 2: '可写', 3: '管理' }
|
|
1214
1465
|
const collab = h('div', { className: 'dgs-card' },
|
|
1215
1466
|
h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '协作者'),
|
|
1216
1467
|
h('div', { className: 'dgs-row' },
|
|
1217
1468
|
h('input', { className: 'dgs-input', style: { maxWidth: 200 }, placeholder: '用户名', value: addName, onChange: (e) => setAddName(e.target.value) }),
|
|
1469
|
+
h('select', { className: 'dgs-input', style: { maxWidth: 110, flex: 'none', width: 'auto' }, value: addMode, onChange: (e) => setAddMode(e.target.value) },
|
|
1470
|
+
h('option', { value: 'read' }, '只读'),
|
|
1471
|
+
h('option', { value: 'write' }, '可写'),
|
|
1472
|
+
h('option', { value: 'admin' }, '管理')),
|
|
1218
1473
|
h('button', { className: 'dgs-btn', disabled: !addName.trim(),
|
|
1219
1474
|
onClick: async () => {
|
|
1220
|
-
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/collaborators/${encodeURIComponent(addName.trim())}
|
|
1475
|
+
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/collaborators/${encodeURIComponent(addName.trim())}`, { mode: addMode })
|
|
1221
1476
|
if (d && d.ok !== false && !d.error) { setAddName(''); setMsg(''); loadCollabs() } else setMsg((d && d.error) || 'failed')
|
|
1222
1477
|
} }, '+ 添加')),
|
|
1223
1478
|
collabs.length === 0 ? h('div', { className: 'dgs-sub', style: { marginTop: 8 } }, '—') : null,
|
|
1224
1479
|
collabs.map((u) => h('div', { key: u.name, className: 'dgs-row', style: { marginTop: 6 } },
|
|
1225
1480
|
h('a', { className: 'dgs-name', href: '#/u/' + u.name }, u.name),
|
|
1226
1481
|
h('span', { style: { flex: 1 } }),
|
|
1482
|
+
h('select', { className: 'dgs-input', style: { maxWidth: 96, flex: 'none', width: 'auto', padding: '3px 6px', fontSize: 12 }, value: String(u.mode || 2),
|
|
1483
|
+
onChange: async (e) => {
|
|
1484
|
+
const mode = { 1: 'read', 2: 'write', 3: 'admin' }[e.target.value] || 'write'
|
|
1485
|
+
await api('PATCH', `/dsh/repos/${repo.owner}/${repo.name}/collaborators/${encodeURIComponent(u.name)}`, { mode })
|
|
1486
|
+
loadCollabs()
|
|
1487
|
+
} },
|
|
1488
|
+
h('option', { value: '1' }, '只读'),
|
|
1489
|
+
h('option', { value: '2' }, '可写'),
|
|
1490
|
+
h('option', { value: '3' }, '管理')),
|
|
1227
1491
|
h('button', { className: 'dgs-btn danger', onClick: async () => {
|
|
1228
1492
|
await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/collaborators/${encodeURIComponent(u.name)}`)
|
|
1229
1493
|
loadCollabs()
|
|
@@ -1236,16 +1500,77 @@ function RepoSettings({ repo, t }) {
|
|
|
1236
1500
|
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/hooks`, { url: hookUrl.trim() })
|
|
1237
1501
|
if (d && !d.error) { setHookUrl(''); loadHooks() } else setMsg((d && d.error) || 'failed')
|
|
1238
1502
|
} }, '+ 添加')),
|
|
1239
|
-
(hooks || []).map((hk) => h('div', { key: hk.id,
|
|
1240
|
-
h('
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1503
|
+
(hooks || []).map((hk) => h('div', { key: hk.id, style: { marginTop: 6 } },
|
|
1504
|
+
h('div', { className: 'dgs-row' },
|
|
1505
|
+
h('span', { className: 'dgs-badge' + (hk.is_active ? ' ok' : ' closed') }, hk.is_active ? '启用' : '停用'),
|
|
1506
|
+
hk.last_status === 1 ? h('span', { className: 'dgs-badge ok', title: '最近一次投递成功' }, '✓ 投递成功')
|
|
1507
|
+
: hk.last_status === 2 ? h('span', { className: 'dgs-badge closed', title: '最近一次投递失败' }, '✗ 投递失败') : null,
|
|
1508
|
+
h('span', { className: 'dgs-sub', style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' } }, hk.url),
|
|
1509
|
+
h('button', { className: 'dgs-btn ghost', onClick: async () => {
|
|
1510
|
+
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/hooks/${hk.id}/test`)
|
|
1511
|
+
if (d && d.ok) { setMsg('✓ 已发送测试事件(push)'); setTimeout(() => setMsg(''), 1600); setTimeout(loadHooks, 1200) }
|
|
1512
|
+
else setMsg((d && d.error) || 'failed')
|
|
1513
|
+
} }, '测试'),
|
|
1514
|
+
h('button', { className: 'dgs-btn ghost', onClick: async () => {
|
|
1515
|
+
if (hookDeliveries[hk.id]) { setHookDeliveries({ ...hookDeliveries, [hk.id]: undefined }); return }
|
|
1516
|
+
setHookDeliveries({ ...hookDeliveries, [hk.id]: 'loading' })
|
|
1517
|
+
const d = await api('GET', `/dsh/repos/${repo.owner}/${repo.name}/hooks/${hk.id}/deliveries`)
|
|
1518
|
+
setHookDeliveries((m) => ({ ...m, [hk.id]: Array.isArray(d) ? d : [] }))
|
|
1519
|
+
} }, '记录'),
|
|
1520
|
+
h('button', { className: 'dgs-btn ghost', onClick: async () => {
|
|
1521
|
+
await api('PATCH', `/dsh/repos/${repo.owner}/${repo.name}/hooks/${hk.id}`, { active: !hk.is_active }); loadHooks()
|
|
1522
|
+
} }, hk.is_active ? '停用' : '启用'),
|
|
1523
|
+
h('button', { className: 'dgs-btn danger', onClick: async () => {
|
|
1524
|
+
await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/hooks/${hk.id}`); loadHooks()
|
|
1525
|
+
} }, t('delete'))),
|
|
1526
|
+
Array.isArray(hookDeliveries[hk.id]) ? h('div', { style: { margin: '4px 0 4px 24px' } },
|
|
1527
|
+
hookDeliveries[hk.id].length === 0 ? h('div', { className: 'dgs-sub' }, '暂无投递记录')
|
|
1528
|
+
: hookDeliveries[hk.id].map((dv) =>
|
|
1529
|
+
h('div', { key: dv.id, className: 'dgs-row', style: { padding: '2px 0', gap: 8 } },
|
|
1530
|
+
h('span', { className: 'dgs-badge' + (dv.ok ? ' ok' : ' closed') }, dv.ok ? '成功' : '失败'),
|
|
1531
|
+
h('span', { className: 'dgs-sub' }, '#' + dv.id + ' · ' + dv.event + (dv.status ? ' · HTTP ' + dv.status : '')),
|
|
1532
|
+
dv.err ? h('span', { className: 'dgs-sub', style: { color: 'var(--dsw-alias-state-error-primary)' } }, dv.err) : null)))
|
|
1533
|
+
: hookDeliveries[hk.id] === 'loading' ? h('div', { className: 'dgs-sub', style: { margin: '4px 0 4px 24px' } }, t('loading')) : null)),
|
|
1248
1534
|
hooks !== null && hooks.length === 0 ? h('div', { className: 'dgs-sub', style: { marginTop: 6 } }, '—') : null)
|
|
1535
|
+
const protsCard = h('div', { className: 'dgs-card' },
|
|
1536
|
+
h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '分支保护'),
|
|
1537
|
+
h('div', { className: 'dgs-sub', style: { marginBottom: 10 } }, '受保护的分支不可删除,也不接受强推(非快进推送会被拒绝)。合并 PR、网页编辑等正常快进操作不受影响。'),
|
|
1538
|
+
h('div', { className: 'dgs-row' },
|
|
1539
|
+
h('select', { className: 'dgs-input', style: { maxWidth: 220, flex: 'none', width: 'auto' }, value: protBranch, onChange: (e) => setProtBranch(e.target.value) },
|
|
1540
|
+
h('option', { value: '' }, '选择分支…'),
|
|
1541
|
+
branchList.map((b) => h('option', { key: b.name, value: b.name }, b.name + (info && b.name === info.defaultBranch ? '(默认)' : '')))),
|
|
1542
|
+
h('button', { className: 'dgs-btn', disabled: !protBranch, onClick: async () => {
|
|
1543
|
+
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/protections`, { branch: protBranch, protected: true })
|
|
1544
|
+
if (d && d.ok) { setProtBranch(''); loadProts() } else setMsg((d && d.error) || 'failed')
|
|
1545
|
+
} }, '+ 保护')),
|
|
1546
|
+
prots === null ? h('div', { className: 'dgs-sub', style: { marginTop: 8 } }, t('loading'))
|
|
1547
|
+
: prots.length === 0 ? h('div', { className: 'dgs-sub', style: { marginTop: 8 } }, '—')
|
|
1548
|
+
: prots.map((p) => h('div', { key: p.branch, className: 'dgs-row', style: { marginTop: 6 } },
|
|
1549
|
+
h('span', { className: 'dgs-badge ok' }, '🔒 ' + p.branch),
|
|
1550
|
+
h('span', { style: { flex: 1 } }),
|
|
1551
|
+
h('button', { className: 'dgs-btn danger', onClick: async () => {
|
|
1552
|
+
await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/protections`, { branch: p.branch, protected: false })
|
|
1553
|
+
loadProts()
|
|
1554
|
+
} }, '解除保护'))))
|
|
1555
|
+
const mirrorCard = mirrorInfo ? h('div', { className: 'dgs-card' },
|
|
1556
|
+
h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '镜像同步'),
|
|
1557
|
+
h('div', { className: 'dgs-row' },
|
|
1558
|
+
h('span', { className: 'dgs-sub', style: { minWidth: 70 } }, '上游地址'),
|
|
1559
|
+
h('span', { style: { fontFamily: 'ui-monospace,monospace', fontSize: 12.5, wordBreak: 'break-all' } }, mirrorInfo.address || '—')),
|
|
1560
|
+
h('div', { className: 'dgs-row' },
|
|
1561
|
+
h('span', { className: 'dgs-sub', style: { minWidth: 70 } }, '上次同步'),
|
|
1562
|
+
h('span', { className: 'dgs-sub' }, mirrorInfo.updatedAt ? timeAgo(mirrorInfo.updatedAt) : '从未'),
|
|
1563
|
+
h('span', { className: 'dgs-sub' }, mirrorInfo.nextAt ? '· 下次 ' + timeAgo(mirrorInfo.nextAt).replace('之前', '后') : '')),
|
|
1564
|
+
h('div', { className: 'dgs-row', style: { marginTop: 8 } },
|
|
1565
|
+
h('button', { className: 'dgs-btn', disabled: syncBusy, onClick: async () => {
|
|
1566
|
+
setSyncBusy(true); setMsg('')
|
|
1567
|
+
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/mirror-sync`)
|
|
1568
|
+
setSyncBusy(false)
|
|
1569
|
+
if (d && d.ok) {
|
|
1570
|
+
setMirrorInfo({ ...mirrorInfo, updatedAt: d.updatedAt, nextAt: d.nextAt })
|
|
1571
|
+
setMsg('✓ 同步完成')
|
|
1572
|
+
} else setMsg((d && d.error) || '同步失败')
|
|
1573
|
+
} }, syncBusy ? '同步中…(拉取上游可能需要数十秒)' : '立即同步'))) : null
|
|
1249
1574
|
const danger = h('div', { className: 'dgs-card', style: { marginTop: 12, borderColor: 'var(--dsw-alias-state-error-primary)55' } },
|
|
1250
1575
|
h('div', { style: { fontWeight: 700, marginBottom: 8, color: 'var(--dsw-alias-state-error-primary)' } }, '危险区域'),
|
|
1251
1576
|
h('div', { className: 'dgs-row' },
|
|
@@ -1277,17 +1602,19 @@ function RepoSettings({ repo, t }) {
|
|
|
1277
1602
|
h('div', { className: 'dgs-settings-nav' },
|
|
1278
1603
|
navItem('basic', '基本设置'),
|
|
1279
1604
|
navItem('collab', '管理协作者'),
|
|
1605
|
+
navItem('prot', '分支保护'),
|
|
1280
1606
|
navItem('hooks', '管理 Web 钩子'),
|
|
1607
|
+
mirrorInfo ? navItem('mirror', '镜像同步') : null,
|
|
1281
1608
|
navItem('danger', '危险区域')),
|
|
1282
1609
|
h('div', { style: { flex: 1, minWidth: 0 } },
|
|
1283
|
-
sub === 'basic' ? basic : sub === 'collab' ? collab : sub === 'hooks' ? hooksCard : danger)))
|
|
1610
|
+
sub === 'basic' ? basic : sub === 'collab' ? collab : sub === 'prot' ? protsCard : sub === 'hooks' ? hooksCard : sub === 'mirror' ? mirrorCard : danger)))
|
|
1284
1611
|
}
|
|
1285
1612
|
|
|
1286
1613
|
// ── PR(列表 + 详情 + 合并 + 评论复用 issue 通道) ────────────────────────
|
|
1287
1614
|
|
|
1288
|
-
function Pulls({ repo, t, preset, onPresetDone, onGoIssuesSub }) {
|
|
1615
|
+
function Pulls({ repo, t, preset, onPresetDone, onGoIssuesSub, initialIdx }) {
|
|
1289
1616
|
const [list, setList] = useState(null)
|
|
1290
|
-
const [openIdx, setOpenIdx] = useState(null)
|
|
1617
|
+
const [openIdx, setOpenIdx] = useState(initialIdx ?? null)
|
|
1291
1618
|
const [creating, setCreating] = useState(false)
|
|
1292
1619
|
const [state, setState] = useState('open')
|
|
1293
1620
|
const [flt, setFlt] = useState({ label: '', milestone: '', assignee: '' })
|
|
@@ -1458,6 +1785,11 @@ function PullDetail({ repo, idx, t, onBack }) {
|
|
|
1458
1785
|
const [allMs, setAllMs] = useState([])
|
|
1459
1786
|
const [collabs, setCollabs] = useState([])
|
|
1460
1787
|
const [mergeMsg, setMergeMsg] = useState('')
|
|
1788
|
+
const [reviews, setReviews] = useState([])
|
|
1789
|
+
const [reviewText, setReviewText] = useState('')
|
|
1790
|
+
const loadReviews = useCallback(() => {
|
|
1791
|
+
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/pulls/${idx}/reviews`).then((d) => setReviews(Array.isArray(d) ? d : []))
|
|
1792
|
+
}, [repo.owner, repo.name, idx])
|
|
1461
1793
|
useEffect(() => {
|
|
1462
1794
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/pulls`).then((d) => {
|
|
1463
1795
|
const all = Array.isArray(d) ? d : (d.data || d.pulls || [])
|
|
@@ -1467,6 +1799,8 @@ function PullDetail({ repo, idx, t, onBack }) {
|
|
|
1467
1799
|
})
|
|
1468
1800
|
api('GET', `/repos/${repo.owner}/${repo.name}/issues/${idx}/comments`).then((d) => setComments(d.comments || []))
|
|
1469
1801
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/issues/${idx}`).then((d) => setIssue(d && d.number ? d : null))
|
|
1802
|
+
api('POST', `/dsh/repos/${repo.owner}/${repo.name}/issues/${idx}/read`).catch(() => {})
|
|
1803
|
+
loadReviews()
|
|
1470
1804
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/labels`).then((d) => setAllLabels(Array.isArray(d) ? d : []))
|
|
1471
1805
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/milestones`).then((d) => setAllMs(Array.isArray(d) ? d : []))
|
|
1472
1806
|
api('GET', `/dsh/repos/${repo.owner}/${repo.name}/collaborators`).then((d) => setCollabs(Array.isArray(d) ? d : []))
|
|
@@ -1507,13 +1841,37 @@ function PullDetail({ repo, idx, t, onBack }) {
|
|
|
1507
1841
|
onChange: (e) => patchIssue({ assignee: e.target.value }) },
|
|
1508
1842
|
h('option', { value: '' }, '未指派成员'),
|
|
1509
1843
|
collabs.map((u) => h('option', { key: u.name, value: u.name }, u.name)))),
|
|
1844
|
+
h('div', { className: 'dgs-side-block' },
|
|
1845
|
+
h('div', { className: 'dgs-side-title' }, '评审'),
|
|
1846
|
+
reviews.length === 0 ? h('div', { className: 'dgs-sub' }, '暂无评审') : null,
|
|
1847
|
+
reviews.map((rv) => h('div', { key: rv.user, className: 'dgs-row', style: { padding: '3px 0', gap: 6 } },
|
|
1848
|
+
h('span', { style: { color: rv.approved ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontWeight: 600, fontSize: 12.5 } }, rv.approved ? '✓' : '✗'),
|
|
1849
|
+
h('span', { style: { fontSize: 12.5 } }, rv.user),
|
|
1850
|
+
h('span', { className: 'dgs-sub' }, rv.approved ? '通过' : '请求修改')))),
|
|
1510
1851
|
h('div', { className: 'dgs-side-block' },
|
|
1511
1852
|
h('div', { className: 'dgs-side-title' }, (issue && issue.participants || 1) + ' 名参与者'),
|
|
1512
1853
|
h('div', { className: 'dgs-sub' }, pr.author || '')))
|
|
1854
|
+
const approvals = reviews.filter((rv) => rv.approved).length
|
|
1855
|
+
const submitReview = async (approved) => {
|
|
1856
|
+
setBusy(true)
|
|
1857
|
+
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/pulls/${idx}/reviews`, { approved, content: reviewText.trim() })
|
|
1858
|
+
setBusy(false)
|
|
1859
|
+
if (d && d.ok) {
|
|
1860
|
+
setReviewText('')
|
|
1861
|
+
loadReviews()
|
|
1862
|
+
if (reviewText.trim()) api('GET', `/repos/${repo.owner}/${repo.name}/issues/${idx}/comments`).then((d2) => setComments(d2.comments || []))
|
|
1863
|
+
} else setMsg((d && d.error) || 'failed')
|
|
1864
|
+
}
|
|
1513
1865
|
const mergeBox = pr.state === 'open' ? h('div', { className: 'dgs-card', style: { margin: '10px 0', borderColor: 'var(--dsw-alias-state-success-primary)55' } },
|
|
1514
1866
|
h('div', { className: 'dgs-row', style: { gap: 8 } },
|
|
1515
1867
|
h('span', { style: { color: 'var(--dsw-alias-state-success-primary)', fontSize: 16 } }, '⑂'),
|
|
1516
|
-
h('span', { style: { color: 'var(--dsw-alias-state-success-primary)', fontSize: 13 } }, '该合并请求可以进行自动合并操作。')
|
|
1868
|
+
h('span', { style: { color: 'var(--dsw-alias-state-success-primary)', fontSize: 13 } }, '该合并请求可以进行自动合并操作。'),
|
|
1869
|
+
approvals > 0 ? h('span', { className: 'dgs-badge ok' }, approvals + ' 个评审通过') : null),
|
|
1870
|
+
h('div', { style: { margin: '10px 0', borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: 10 } },
|
|
1871
|
+
h('textarea', { className: 'dgs-input', style: { minHeight: 48 }, placeholder: '评审意见(可选,会同时出现在对话中)', value: reviewText, onChange: (e) => setReviewText(e.target.value) }),
|
|
1872
|
+
h('div', { className: 'dgs-row', style: { marginTop: 6 } },
|
|
1873
|
+
h('button', { className: 'dgs-btn ghost', disabled: busy, onClick: () => submitReview(true) }, '✓ 通过评审'),
|
|
1874
|
+
h('button', { className: 'dgs-btn ghost', disabled: busy, onClick: () => submitReview(false) }, '✗ 请求修改'))),
|
|
1517
1875
|
h('div', { style: { margin: '10px 0' } },
|
|
1518
1876
|
h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 6, alignItems: 'center', margin: '4px 0' } },
|
|
1519
1877
|
h('input', { type: 'radio', name: 'mergeStyle', checked: mergeStyle === 'merge', onChange: () => setMergeStyle('merge') }), '创建一个新的合并提交'),
|
|
@@ -1545,7 +1903,7 @@ function PullDetail({ repo, idx, t, onBack }) {
|
|
|
1545
1903
|
(comments || []).map((c) =>
|
|
1546
1904
|
h('div', { key: c.id, className: 'dgs-comment' },
|
|
1547
1905
|
h('div', { className: 'dgs-sub', style: { marginBottom: 4 } }, `${c.user || ''} 评论于 ${fmtDate(c.created)}`),
|
|
1548
|
-
h('div', { style: { whiteSpace: 'pre-wrap' } }, c.body
|
|
1906
|
+
h('div', { style: { whiteSpace: 'pre-wrap' } }, c.body ? linkifyIssues(c.body, repo) : '这个人很懒,什么都没留下。'))),
|
|
1549
1907
|
h('div', { className: 'dgs-card', style: { margin: '10px 0' } },
|
|
1550
1908
|
h('textarea', { className: 'dgs-input', placeholder: t('commentPlaceholder'), value: text, onChange: (e) => setText(e.target.value) }),
|
|
1551
1909
|
h('div', { className: 'dgs-row', style: { marginTop: 8 } },
|
|
@@ -1563,7 +1921,7 @@ function PullDetail({ repo, idx, t, onBack }) {
|
|
|
1563
1921
|
: h('table', { className: 'dgs-table' },
|
|
1564
1922
|
h('tbody', null, cmp.commits.map((c) =>
|
|
1565
1923
|
h('tr', { key: c.sha, style: { cursor: 'pointer' }, onClick: () => setSelCommit(c.sha) },
|
|
1566
|
-
h('td', null, h('div', { style: { fontWeight: 500 } }, (c.message || '').split('\n')[0]),
|
|
1924
|
+
h('td', null, h('div', { style: { fontWeight: 500 } }, linkifyIssues((c.message || '').split('\n')[0], repo)),
|
|
1567
1925
|
h('div', { className: 'dgs-sub' }, c.author)),
|
|
1568
1926
|
h('td', { className: 'dgs-sub', style: { textAlign: 'right' } },
|
|
1569
1927
|
h('a', { style: { color: 'var(--dsw-alias-state-business-primary)', cursor: 'pointer' } }, (c.sha || '').slice(0, 10)))))))
|
|
@@ -1784,16 +2142,46 @@ function Releases({ repo, t }) {
|
|
|
1784
2142
|
r.prerelease ? h('span', { className: 'dgs-badge pri' }, '预发布') : null,
|
|
1785
2143
|
h('span', { style: { fontWeight: 600 } }, r.title),
|
|
1786
2144
|
h('span', { style: { flex: 1 } }),
|
|
1787
|
-
h('a', { className: 'dgs-sub', style: { cursor: 'pointer' }, onClick: () => { setEditId(r.id); setEditForm({ title: r.title || '', note: r.noteRaw || '' }) } }, '(编辑)')
|
|
2145
|
+
h('a', { className: 'dgs-sub', style: { cursor: 'pointer' }, onClick: () => { setEditId(r.id); setEditForm({ title: r.title || '', note: r.noteRaw || '' }) } }, '(编辑)'),
|
|
2146
|
+
h('a', { className: 'dgs-sub', style: { cursor: 'pointer', color: 'var(--dsw-alias-state-error-primary)' }, onClick: async () => {
|
|
2147
|
+
if (!confirm('删除发版 ' + r.tag + '(git 标签保留)?')) return
|
|
2148
|
+
const d = await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/releases/${r.id}`)
|
|
2149
|
+
if (d && d.ok) reload(); else setMsg((d && d.error) || '删除失败')
|
|
2150
|
+
} }, '(删除)')),
|
|
1788
2151
|
h('div', { className: 'dgs-sub', style: { marginTop: 4 } },
|
|
1789
2152
|
(r.author || '') + ' · ' + timeAgo((r.createdAt || 0) * 1000) + ' 发布 · ' +
|
|
1790
2153
|
(r.behind ? '在该版本发布之后已有 ' + r.behind + ' 次代码提交到 ' + (r.target || '默认') + ' 分支' : '暂无后续提交')),
|
|
1791
2154
|
r.noteHtml ? h('div', { className: 'dgs-md', style: { marginTop: 8 }, dangerouslySetInnerHTML: { __html: r.noteHtml } }) : null,
|
|
1792
2155
|
h('div', { style: { marginTop: 10 } },
|
|
1793
|
-
h('div', {
|
|
2156
|
+
h('div', { className: 'dgs-row', style: { marginBottom: 4 } },
|
|
2157
|
+
h('div', { style: { fontWeight: 600, fontSize: 13 } }, '下载附件'),
|
|
2158
|
+
h('span', { style: { flex: 1 } }),
|
|
2159
|
+
h('label', { className: 'dgs-sub', style: { cursor: 'pointer' } }, '⭱ 上传附件',
|
|
2160
|
+
h('input', { type: 'file', style: { display: 'none' }, onChange: async (e) => {
|
|
2161
|
+
const f = e.target.files && e.target.files[0]
|
|
2162
|
+
e.target.value = ''
|
|
2163
|
+
if (!f) return
|
|
2164
|
+
if (f.size > 25 * 1024 * 1024) { setMsg('附件超过 25MB 上限'); return }
|
|
2165
|
+
const bytes = new Uint8Array(await f.arrayBuffer())
|
|
2166
|
+
let bin = ''
|
|
2167
|
+
for (let i = 0; i < bytes.length; i += 32768) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 32768))
|
|
2168
|
+
setMsg('')
|
|
2169
|
+
const d = await api('POST', `/dsh/repos/${repo.owner}/${repo.name}/releases/${r.id}/assets`, { name: f.name, contentBase64: btoa(bin) })
|
|
2170
|
+
if (d && d.ok) reload(); else setMsg((d && d.error) || '上传失败')
|
|
2171
|
+
} }))),
|
|
1794
2172
|
h('div', { className: 'dgs-row', style: { gap: 8 } },
|
|
1795
2173
|
h('a', { className: 'dgs-sub', href: `/dsh-git-server/api/dsh/repos/${repo.owner}/${repo.name}/archive/${encodeURIComponent(r.tag)}.zip`, download: `${repo.name}-${r.tag}.zip` }, '源代码 (ZIP)'),
|
|
1796
|
-
h('a', { className: 'dgs-sub', href: `/dsh-git-server/api/dsh/repos/${repo.owner}/${repo.name}/archive/${encodeURIComponent(r.tag)}.tar.gz`, download: `${repo.name}-${r.tag}.tar.gz` }, '源代码 (TAR.GZ)'))
|
|
2174
|
+
h('a', { className: 'dgs-sub', href: `/dsh-git-server/api/dsh/repos/${repo.owner}/${repo.name}/archive/${encodeURIComponent(r.tag)}.tar.gz`, download: `${repo.name}-${r.tag}.tar.gz` }, '源代码 (TAR.GZ)')),
|
|
2175
|
+
(r.assets || []).length === 0 ? null
|
|
2176
|
+
: h('div', { style: { marginTop: 6 } }, r.assets.map((a) =>
|
|
2177
|
+
h('div', { key: a.id, className: 'dgs-row', style: { gap: 8, padding: '2px 0' } },
|
|
2178
|
+
h('a', { className: 'dgs-sub', style: { color: 'var(--dsw-alias-state-business-primary)' }, href: `/dsh-git-server/api/dsh/repos/${repo.owner}/${repo.name}/attachments/${a.id}`, download: a.name }, '📦 ' + a.name),
|
|
2179
|
+
h('span', { className: 'dgs-sub' }, fmtSize(a.size)),
|
|
2180
|
+
h('a', { className: 'dgs-sub', style: { cursor: 'pointer', color: 'var(--dsw-alias-state-error-primary)' }, onClick: async () => {
|
|
2181
|
+
if (!confirm('删除附件 ' + a.name + ' ?')) return
|
|
2182
|
+
await api('DELETE', `/dsh/repos/${repo.owner}/${repo.name}/attachments/${a.id}`)
|
|
2183
|
+
reload()
|
|
2184
|
+
} }, '删除')))))))),
|
|
1797
2185
|
(plainTags && plainTags.length > 0) ? h('div', { style: { marginTop: 16 } },
|
|
1798
2186
|
h('div', { style: { fontWeight: 700, margin: '4px 0 8px' } }, '未发版的标签'),
|
|
1799
2187
|
plainTags.map((tg) => h('div', { key: tg, className: 'dgs-issue' },
|
|
@@ -1925,20 +2313,44 @@ function OrgView({ name, t }) {
|
|
|
1925
2313
|
|
|
1926
2314
|
function UserProfile({ name, t }) {
|
|
1927
2315
|
const [profile, setProfile] = useState(null)
|
|
1928
|
-
|
|
2316
|
+
const [showList, setShowList] = useState(null) // 'followers' | 'following' | null
|
|
2317
|
+
const [followBusy, setFollowBusy] = useState(false)
|
|
2318
|
+
const reload = useCallback(() => {
|
|
1929
2319
|
api('GET', '/dsh/users/' + encodeURIComponent(name)).then((d) => setProfile(d.data || d))
|
|
1930
2320
|
}, [name])
|
|
2321
|
+
useEffect(() => { reload() }, [reload])
|
|
1931
2322
|
if (profile === null) return h('div', { className: 'dgs-empty' }, t('loading'))
|
|
2323
|
+
const followBtn = profile.isSelf === false ? h('button', {
|
|
2324
|
+
className: 'dgs-btn' + (profile.isFollowing ? ' ghost' : ''), style: { marginTop: 10, width: '100%' }, disabled: followBusy,
|
|
2325
|
+
onClick: async () => {
|
|
2326
|
+
setFollowBusy(true)
|
|
2327
|
+
const d = await api('POST', '/dsh/users/' + encodeURIComponent(name) + '/follow')
|
|
2328
|
+
setFollowBusy(false)
|
|
2329
|
+
if (d && typeof d.on === 'boolean') reload()
|
|
2330
|
+
},
|
|
2331
|
+
}, profile.isFollowing ? '取消关注' : '+ 关注') : null
|
|
2332
|
+
const countBtn = (k, n, label) => h('a', {
|
|
2333
|
+
className: 'dgs-sub', style: { cursor: 'pointer', color: showList === k ? 'var(--dsw-alias-state-business-primary)' : undefined },
|
|
2334
|
+
onClick: () => setShowList(showList === k ? null : k),
|
|
2335
|
+
}, `${n} ${label}`)
|
|
2336
|
+
const listNames = showList === 'followers' ? profile.followers : showList === 'following' ? profile.following : []
|
|
1932
2337
|
return h('div', { className: 'dgs-settings-layout' },
|
|
1933
2338
|
h('div', { className: 'dgs-settings-nav', style: { width: 230 } },
|
|
1934
2339
|
h('div', { style: { width: 64, height: 64, borderRadius: 10, background: 'var(--dsw-alias-bg-layer-2)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 26, fontWeight: 700, color: 'var(--dsw-alias-label-secondary)', marginBottom: 10 } },
|
|
1935
2340
|
(name || '?').slice(0, 1).toUpperCase()),
|
|
1936
2341
|
h('div', { className: 'dgs-h1', style: { fontSize: 17 } }, name),
|
|
1937
2342
|
profile.isAdmin ? h('span', { className: 'dgs-badge ok', style: { marginTop: 4 } }, 'admin') : null,
|
|
2343
|
+
followBtn,
|
|
1938
2344
|
h('div', { className: 'dgs-sub', style: { marginTop: 10 } }, profile.email ? '✉ ' + profile.email : ''),
|
|
1939
2345
|
h('div', { className: 'dgs-sub', style: { marginTop: 4 } }, '🕐 加入于 ' + (profile.created ? new Date(profile.created * 1000).toLocaleDateString() : '')),
|
|
1940
2346
|
h('div', { className: 'dgs-sub', style: { marginTop: 10 } },
|
|
1941
|
-
|
|
2347
|
+
countBtn('followers', profile.followers.length, '关注者'),
|
|
2348
|
+
' - ',
|
|
2349
|
+
countBtn('following', profile.following.length, '关注中')),
|
|
2350
|
+
showList ? h('div', { style: { marginTop: 8, borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: 8 } },
|
|
2351
|
+
listNames.length === 0 ? h('div', { className: 'dgs-sub' }, '—')
|
|
2352
|
+
: listNames.map((u) => h('div', { key: u, style: { padding: '3px 0' } },
|
|
2353
|
+
h('a', { className: 'dgs-sub', style: { color: 'var(--dsw-alias-state-business-primary)', cursor: 'pointer' }, onClick: () => { setProfile(null); setShowList(null); nav('/u/' + u) } }, '@' + u)))) : null),
|
|
1942
2354
|
h('div', { style: { flex: 1, minWidth: 0 } },
|
|
1943
2355
|
h('div', { style: { fontWeight: 700, margin: '4px 0 10px' } }, '仓库'),
|
|
1944
2356
|
profile.repos.length === 0 ? h('div', { className: 'dgs-empty' }, '—') : null,
|
|
@@ -2038,15 +2450,80 @@ function Admin({ t }) {
|
|
|
2038
2450
|
sub === 'panel' ? panel : sub === 'users' ? usersTab : sub === 'repos' ? reposTab : orgsTab))
|
|
2039
2451
|
}
|
|
2040
2452
|
|
|
2453
|
+
// ── 通知中心 ───────────────────────────────────────────────────────────────
|
|
2454
|
+
|
|
2455
|
+
function Notifications({ t }) {
|
|
2456
|
+
const [data, setData] = useState(null)
|
|
2457
|
+
const reload = useCallback(() => {
|
|
2458
|
+
api('GET', '/dsh/notifications').then((d) => setData(d && Array.isArray(d.items) ? d : { items: [], unread: 0 }))
|
|
2459
|
+
}, [])
|
|
2460
|
+
useEffect(() => { reload() }, [reload])
|
|
2461
|
+
const reasonLabel = { mentioned: '@ 提及了你', assigned: '指派给你', poster: '你发起的', comment: '有新评论' }
|
|
2462
|
+
const open = (n) => {
|
|
2463
|
+
api('POST', `/dsh/repos/${n.repoOwner}/${n.repoName}/issues/${n.index}/read`).catch(() => {})
|
|
2464
|
+
nav('/r/' + n.repoOwner + '/' + n.repoName + '/' + (n.isPull ? 'pulls' : 'issues') + '/' + n.index)
|
|
2465
|
+
}
|
|
2466
|
+
return h('div', null,
|
|
2467
|
+
h('div', { className: 'dgs-row', style: { margin: '8px 0 12px' } },
|
|
2468
|
+
h('span', { style: { fontWeight: 700 } }, '未读通知' + (data && data.unread ? '(' + data.unread + ')' : '')),
|
|
2469
|
+
h('span', { style: { flex: 1 } }),
|
|
2470
|
+
h('button', { className: 'dgs-btn ghost', disabled: !data || !data.unread, onClick: async () => { await api('POST', '/dsh/notifications/read-all'); reload() } }, '全部标记已读')),
|
|
2471
|
+
data === null ? h('div', { className: 'dgs-empty' }, t('loading'))
|
|
2472
|
+
: data.items.length === 0 ? h('div', { className: 'dgs-empty' }, '没有未读通知 🎉')
|
|
2473
|
+
: data.items.map((n) =>
|
|
2474
|
+
h('div', { key: n.repoOwner + '/' + n.repoName + '#' + n.index, className: 'dgs-issue', style: { cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }, onClick: () => open(n) },
|
|
2475
|
+
h('span', { className: 'dgs-issue-num' + (n.isClosed ? ' closed' : '') }, '#' + n.index),
|
|
2476
|
+
h('div', { style: { flex: 1, minWidth: 0 } },
|
|
2477
|
+
h('div', null, h('span', { className: 'dgs-issue-title' }, n.title), n.isPull ? h('span', { className: 'dgs-badge', style: { marginLeft: 6 } }, 'PR') : null),
|
|
2478
|
+
h('div', { className: 'dgs-sub', style: { marginTop: 2 } }, n.repoOwner + '/' + n.repoName + ' · ' + (reasonLabel[n.reason] || '有更新') + ' · ' + timeAgo(n.updatedAt))),
|
|
2479
|
+
h('span', { className: 'dgs-sub', style: { flex: 'none' } }, '›'))))
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
// ── 我的工单 / 我的 PR(跨仓库聚合) ────────────────────────────────────────
|
|
2483
|
+
|
|
2484
|
+
function MyIssues({ t, isPull }) {
|
|
2485
|
+
const [filter, setFilter] = useState('all')
|
|
2486
|
+
const [state, setState] = useState('open')
|
|
2487
|
+
const [list, setList] = useState(null)
|
|
2488
|
+
useEffect(() => {
|
|
2489
|
+
setList(null)
|
|
2490
|
+
api('GET', `/dsh/my/issues?type=${isPull ? 'pulls' : 'issues'}&state=${state}&filter=${filter}`)
|
|
2491
|
+
.then((d) => setList(Array.isArray(d) ? d : []))
|
|
2492
|
+
}, [isPull, state, filter])
|
|
2493
|
+
const fBtn = (k, label) => h('button', { key: k, className: 'dgs-subtab' + (filter === k ? ' active' : ''), onClick: () => setFilter(k) }, label)
|
|
2494
|
+
return h('div', null,
|
|
2495
|
+
h('div', { className: 'dgs-row', style: { margin: '8px 0 12px' } },
|
|
2496
|
+
h('button', { className: 'dgs-pill' + (state === 'open' ? ' active' : ''), onClick: () => setState('open') }, '⊘ ' + t('openState')),
|
|
2497
|
+
h('button', { className: 'dgs-pill closed' + (state === 'closed' ? ' active' : ''), onClick: () => setState('closed') }, '✓ ' + t('closedState')),
|
|
2498
|
+
h('span', { style: { flex: 1 } }),
|
|
2499
|
+
h('div', { className: 'dgs-subtabs', style: { margin: 0 } },
|
|
2500
|
+
fBtn('all', '与我相关'), fBtn('created', '我创建的'), fBtn('assigned', '指派给我的'))),
|
|
2501
|
+
list === null ? h('div', { className: 'dgs-empty' }, t('loading'))
|
|
2502
|
+
: list.length === 0 ? h('div', { className: 'dgs-empty' }, '—')
|
|
2503
|
+
: list.map((i) =>
|
|
2504
|
+
h('div', {
|
|
2505
|
+
key: i.repoOwner + '/' + i.repoName + '#' + i.index, className: 'dgs-issue', style: { cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 },
|
|
2506
|
+
onClick: () => nav('/r/' + i.repoOwner + '/' + i.repoName + '/' + (isPull ? 'pulls' : 'issues') + '/' + i.index),
|
|
2507
|
+
},
|
|
2508
|
+
h('span', { className: 'dgs-issue-num' + (i.state === 'open' ? '' : ' closed') }, '#' + i.index),
|
|
2509
|
+
h('div', { style: { flex: 1, minWidth: 0 } },
|
|
2510
|
+
h('div', { className: 'dgs-issue-title' }, i.title),
|
|
2511
|
+
h('div', { className: 'dgs-sub', style: { marginTop: 2 } },
|
|
2512
|
+
i.repoOwner + '/' + i.repoName + ' · ' + (i.author || '') + ' · ' + timeAgo(i.updatedAt) + (i.assignee ? ' · @' + i.assignee : ''))),
|
|
2513
|
+
h('span', { className: 'dgs-sub', style: { flex: 'none' } }, '💬 ' + (i.comments || 0)))))
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2041
2516
|
// ── 主页(仓库列表 + 建仓) ────────────────────────────────────────────────
|
|
2042
2517
|
|
|
2043
|
-
function GitPage({ onClose, t }) {
|
|
2518
|
+
function GitPage({ onClose, t, standalone }) {
|
|
2044
2519
|
useEffect(ensureStyles, [])
|
|
2045
2520
|
const route = useHashRoute()
|
|
2046
2521
|
const [me, setMe] = useState(null)
|
|
2047
2522
|
const [err, setErr] = useState('')
|
|
2048
2523
|
const [name, setName] = useState('')
|
|
2049
2524
|
const [isPrivate, setIsPrivate] = useState(false)
|
|
2525
|
+
const [createMode, setCreateMode] = useState('new') // new | import
|
|
2526
|
+
const [importForm, setImportForm] = useState({ url: '', mirror: false })
|
|
2050
2527
|
const [busy, setBusy] = useState(false)
|
|
2051
2528
|
const [toast, setToast] = useState('')
|
|
2052
2529
|
const reload = useCallback(() => {
|
|
@@ -2059,29 +2536,98 @@ function GitPage({ onClose, t }) {
|
|
|
2059
2536
|
window.addEventListener('keydown', onKey)
|
|
2060
2537
|
return () => window.removeEventListener('keydown', onKey)
|
|
2061
2538
|
}, [onClose])
|
|
2539
|
+
// 中栏接管开合:挂载即打开 → html[data-dsh-git-active] + 广播互斥事件;卸载(关闭)时清理。
|
|
2540
|
+
// standalone(__boot 开发挂载)不动全局。
|
|
2541
|
+
useEffect(() => {
|
|
2542
|
+
if (standalone) return undefined
|
|
2543
|
+
for (const attr of DGS_OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
|
|
2544
|
+
document.documentElement.setAttribute(DGS_ACTIVE_ATTR, '')
|
|
2545
|
+
document.dispatchEvent(new CustomEvent(DGS_ACTIVATE_EVENT, { detail: DGS_PANEL_NAME }))
|
|
2546
|
+
return () => { document.documentElement.removeAttribute(DGS_ACTIVE_ATTR) }
|
|
2547
|
+
}, [standalone])
|
|
2548
|
+
useEffect(() => {
|
|
2549
|
+
if (standalone) return undefined
|
|
2550
|
+
const onOther = (e) => { if (e && e.detail !== DGS_PANEL_NAME) onClose() }
|
|
2551
|
+
document.addEventListener(DGS_ACTIVATE_EVENT, onOther)
|
|
2552
|
+
return () => document.removeEventListener(DGS_ACTIVATE_EVENT, onOther)
|
|
2553
|
+
}, [standalone, onClose])
|
|
2554
|
+
// 点侧栏会话行自动关面板(自家入口子树豁免)
|
|
2555
|
+
useEffect(() => {
|
|
2556
|
+
if (standalone) return undefined
|
|
2557
|
+
const onClickRow = (e) => {
|
|
2558
|
+
const target = e.target
|
|
2559
|
+
if (!(target instanceof Element)) return
|
|
2560
|
+
if (target.closest('[' + GIT_ENTRY_ATTR + ']')) return
|
|
2561
|
+
if (target.closest(DGS_SIDEBAR_ROW_SELECTOR)) onClose()
|
|
2562
|
+
}
|
|
2563
|
+
document.addEventListener('click', onClickRow, true)
|
|
2564
|
+
return () => document.removeEventListener('click', onClickRow, true)
|
|
2565
|
+
}, [standalone, onClose])
|
|
2062
2566
|
const notify = (m) => { setToast(m); setTimeout(() => setToast(''), 1600) }
|
|
2567
|
+
const [unread, setUnread] = useState(0)
|
|
2568
|
+
useEffect(() => {
|
|
2569
|
+
api('GET', '/dsh/notifications/count').then((d) => { if (d && typeof d.unread === 'number') setUnread(d.unread) }).catch(() => {})
|
|
2570
|
+
}, [route])
|
|
2063
2571
|
const seg = route.split('/').filter(Boolean)
|
|
2064
2572
|
const view = seg[0] || 'repos'
|
|
2065
|
-
const
|
|
2573
|
+
const deepTab = seg[3] ? decodeURIComponent(seg[3]) : ''
|
|
2574
|
+
// 深链:#/r/:o/:r 之外支持 /issues/:idx /pulls/:idx /src/:ref/:path...
|
|
2575
|
+
const repoRoute = view === 'r' && seg[1] && seg[2] ? {
|
|
2576
|
+
owner: seg[1], name: seg[2],
|
|
2577
|
+
deep: {
|
|
2578
|
+
tab: deepTab,
|
|
2579
|
+
ref: deepTab === 'src' ? decodeURIComponent(seg[4] || '') : '',
|
|
2580
|
+
arg: deepTab && deepTab !== 'src' ? decodeURIComponent(seg[4] || '') : '',
|
|
2581
|
+
filePath: deepTab === 'src' && seg.length > 5 ? seg.slice(5).map((s) => decodeURIComponent(s)).join('/') : '',
|
|
2582
|
+
},
|
|
2583
|
+
} : null
|
|
2066
2584
|
const body = err ? h('div', { className: 'dgs-card dgs-err' }, err,
|
|
2067
2585
|
h('button', { className: 'dgs-btn ghost', style: { marginLeft: 12 }, onClick: reload }, t('retry')))
|
|
2068
|
-
: repoRoute ? h(RepoBrowser, { repo: repoRoute, t, onBack: () => nav('/repos') })
|
|
2586
|
+
: repoRoute ? h(RepoBrowser, { key: route, repo: repoRoute, t, deep: repoRoute.deep, onBack: () => nav('/repos') })
|
|
2069
2587
|
: view === 'explore' ? h(Explore, { t })
|
|
2070
2588
|
: view === 'orgs' ? h(Orgs, { t })
|
|
2071
2589
|
: view === 'org' && seg[1] ? h(OrgView, { name: seg[1], t })
|
|
2072
2590
|
: view === 'u' && seg[1] ? h(UserProfile, { name: decodeURIComponent(seg[1]), t })
|
|
2073
2591
|
: view === 'admin' ? h(Admin, { t })
|
|
2592
|
+
: view === 'notifications' ? h(Notifications, { t })
|
|
2593
|
+
: view === 'myissues' ? h(MyIssues, { t, isPull: false })
|
|
2594
|
+
: view === 'mypulls' ? h(MyIssues, { t, isPull: true })
|
|
2074
2595
|
: me === null ? h('div', { className: 'dgs-empty' }, t('loading'))
|
|
2075
2596
|
: h('div', null,
|
|
2076
2597
|
h('div', { className: 'dgs-card' },
|
|
2077
2598
|
h('div', { className: 'dgs-row' },
|
|
2078
2599
|
h('span', { style: { fontWeight: 700 } }, t('newRepo')),
|
|
2600
|
+
h('div', { className: 'dgs-subtabs', style: { margin: 0 } },
|
|
2601
|
+
h('button', { className: 'dgs-subtab' + (createMode === 'new' ? ' active' : ''), onClick: () => setCreateMode('new') }, '新建'),
|
|
2602
|
+
h('button', { className: 'dgs-subtab' + (createMode === 'import' ? ' active' : ''), onClick: () => setCreateMode('import') }, '导入'))),
|
|
2603
|
+
createMode === 'new' ? h('div', { className: 'dgs-row', style: { marginTop: 8 } },
|
|
2079
2604
|
h('input', { className: 'dgs-input', placeholder: t('repoName'), value: name,
|
|
2080
2605
|
onChange: (e) => setName(e.target.value),
|
|
2081
2606
|
onKeyDown: (e) => { if (e.key === 'Enter' && name.trim()) create() } }),
|
|
2082
2607
|
h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 4, alignItems: 'center' } },
|
|
2083
2608
|
h('input', { type: 'checkbox', checked: isPrivate, onChange: (e) => setIsPrivate(e.target.checked) }), t('private')),
|
|
2084
|
-
h('button', { className: 'dgs-btn', disabled: busy || !name.trim(), onClick: create }, t('create')))
|
|
2609
|
+
h('button', { className: 'dgs-btn', disabled: busy || !name.trim(), onClick: create }, t('create')))
|
|
2610
|
+
: h('div', null,
|
|
2611
|
+
h('div', { className: 'dgs-row', style: { marginTop: 8 } },
|
|
2612
|
+
h('input', {
|
|
2613
|
+
className: 'dgs-input', placeholder: 'https://github.com/owner/repo.git', value: importForm.url,
|
|
2614
|
+
onChange: (e) => {
|
|
2615
|
+
const url = e.target.value
|
|
2616
|
+
setImportForm({ ...importForm, url })
|
|
2617
|
+
if (!name) {
|
|
2618
|
+
const m = /\/([^/]+?)(?:\.git)?\/?$/.exec(url.trim())
|
|
2619
|
+
if (m) setName(m[1])
|
|
2620
|
+
}
|
|
2621
|
+
},
|
|
2622
|
+
})),
|
|
2623
|
+
h('div', { className: 'dgs-row', style: { marginTop: 8 } },
|
|
2624
|
+
h('input', { className: 'dgs-input', placeholder: t('repoName'), value: name, onChange: (e) => setName(e.target.value) }),
|
|
2625
|
+
h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 4, alignItems: 'center' } },
|
|
2626
|
+
h('input', { type: 'checkbox', checked: importForm.mirror, onChange: (e) => setImportForm({ ...importForm, mirror: e.target.checked }) }), '镜像同步'),
|
|
2627
|
+
h('label', { className: 'dgs-sub', style: { display: 'flex', gap: 4, alignItems: 'center' } },
|
|
2628
|
+
h('input', { type: 'checkbox', checked: isPrivate, onChange: (e) => setIsPrivate(e.target.checked) }), t('private')),
|
|
2629
|
+
h('button', { className: 'dgs-btn', disabled: busy || !name.trim() || !importForm.url.trim(), onClick: importRepo }, busy ? '导入中…' : '导入')),
|
|
2630
|
+
h('div', { className: 'dgs-sub', style: { marginTop: 6 } }, '私有仓库可在地址里带凭据:https://user:token@host/owner/repo.git;勾选「镜像同步」后每小时自动拉取上游更新。'))),
|
|
2085
2631
|
h('div', { style: { height: 10 } }),
|
|
2086
2632
|
h('div', { style: { fontWeight: 700, margin: '4px 0 10px' } }, `${t('myRepos')}(${me.repos.length})`),
|
|
2087
2633
|
me.repos.length === 0 ? h('div', { className: 'dgs-empty' }, t('emptyRepo'))
|
|
@@ -2102,7 +2648,7 @@ function GitPage({ onClose, t }) {
|
|
|
2102
2648
|
h('div', { className: 'dgs-row' }, title, badge, h('span', { style: { flex: 1 } }), stats, del), desc)
|
|
2103
2649
|
}
|
|
2104
2650
|
|
|
2105
|
-
const navItems = [['repos', t('myRepos')], ['explore', '探索'], ['orgs', '组织'], ['admin', '管理']]
|
|
2651
|
+
const navItems = [['repos', t('myRepos')], ['myissues', '工单'], ['mypulls', 'PR'], ['notifications', '通知'], ['explore', '探索'], ['orgs', '组织'], ['admin', '管理']]
|
|
2106
2652
|
return h('div', { className: 'dgs-page' },
|
|
2107
2653
|
h('div', { className: 'dgs-head' },
|
|
2108
2654
|
h('span', { className: 'dgs-h1' }, t('title')),
|
|
@@ -2110,7 +2656,9 @@ function GitPage({ onClose, t }) {
|
|
|
2110
2656
|
navItems.map(([k, label]) =>
|
|
2111
2657
|
h('button', { key: k, className: 'dgs-btn ghost',
|
|
2112
2658
|
style: view === k ? { color: 'var(--dsw-alias-state-business-primary)', borderColor: 'var(--dsw-alias-state-business-primary)55' } : undefined,
|
|
2113
|
-
onClick: () => nav('/' + k) },
|
|
2659
|
+
onClick: () => nav('/' + k) },
|
|
2660
|
+
label,
|
|
2661
|
+
k === 'notifications' && unread > 0 ? h('span', { className: 'dgs-notif-dot' }, unread > 99 ? '99+' : unread) : null))),
|
|
2114
2662
|
h('button', { className: 'dgs-close', onClick: onClose, title: 'Esc' }, '✕')),
|
|
2115
2663
|
h('div', { className: 'dgs-body' }, body),
|
|
2116
2664
|
toast ? h('div', { className: 'dgs-toast' }, toast) : null)
|
|
@@ -2121,6 +2669,20 @@ function GitPage({ onClose, t }) {
|
|
|
2121
2669
|
setBusy(false)
|
|
2122
2670
|
if (d.ok) { setName(''); notify('✓ ' + name); reload() } else notify(d.error || 'failed')
|
|
2123
2671
|
}
|
|
2672
|
+
|
|
2673
|
+
async function importRepo() {
|
|
2674
|
+
setBusy(true)
|
|
2675
|
+
const d = await api('POST', '/dsh/migrate', {
|
|
2676
|
+
cloneAddr: importForm.url.trim(), name: name.trim(), private: isPrivate, mirror: importForm.mirror,
|
|
2677
|
+
})
|
|
2678
|
+
setBusy(false)
|
|
2679
|
+
if (d && d.ok) {
|
|
2680
|
+
const repoName = name
|
|
2681
|
+
setName(''); setImportForm({ url: '', mirror: false })
|
|
2682
|
+
notify('✓ ' + repoName)
|
|
2683
|
+
nav('/r/' + d.owner + '/' + d.name)
|
|
2684
|
+
} else notify((d && d.error) || 'failed')
|
|
2685
|
+
}
|
|
2124
2686
|
}
|
|
2125
2687
|
|
|
2126
2688
|
// ── 设置节(服务配置,无 iframe) ──────────────────────────────────────────
|
|
@@ -2204,10 +2766,21 @@ function closeGitPage() {
|
|
|
2204
2766
|
try { gitPageHost.el.remove() } catch {}
|
|
2205
2767
|
gitPageHost = null
|
|
2206
2768
|
}
|
|
2769
|
+
function gitConversationColumn() {
|
|
2770
|
+
return document.querySelector('[data-pane="conversation"], [class*="centerCol"], .dshDesktopConversationSurface')
|
|
2771
|
+
}
|
|
2207
2772
|
function openGitPage(t) {
|
|
2208
2773
|
if (gitPageHost) { closeGitPage(); return }
|
|
2209
2774
|
const el = document.createElement('div')
|
|
2210
|
-
|
|
2775
|
+
el.setAttribute('data-dsh-git-view', '')
|
|
2776
|
+
el.className = 'dsh-git-view'
|
|
2777
|
+
const column = gitConversationColumn()
|
|
2778
|
+
if (column) {
|
|
2779
|
+
column.appendChild(el)
|
|
2780
|
+
} else {
|
|
2781
|
+
el.setAttribute('data-dsh-git-fallback', '')
|
|
2782
|
+
document.body.appendChild(el)
|
|
2783
|
+
}
|
|
2211
2784
|
const root = require('react-dom/client').createRoot(el)
|
|
2212
2785
|
gitPageHost = { el, root }
|
|
2213
2786
|
root.render(h(GitPage, { onClose: closeGitPage, t }))
|
|
@@ -2259,6 +2832,7 @@ function mountGitSidebarEntry(t) {
|
|
|
2259
2832
|
.dsh-git-entry .dsh-git-entry-stats{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:11px;color:var(--dsw-alias-label-secondary,var(--dsw-text-secondary,gray));font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
2260
2833
|
.dsh-git-entry .dsh-git-entry-dot{width:6px;height:6px;border-radius:6px;background:var(--dsw-alias-state-success-primary);display:inline-block}
|
|
2261
2834
|
[data-sidebar-collapsed] .dsh-git-entry,[class*="_collapsed"] .dsh-git-entry{width:36px;height:36px;min-width:36px;margin:0 0 12px;padding:0;justify-content:center;gap:0;text-align:center}
|
|
2835
|
+
html[data-dsh-git-active] .dsh-git-entry{background:var(--dsw-active,rgba(128,128,128,.18));color:var(--dsw-text-primary,inherit);font-weight:500}
|
|
2262
2836
|
[data-sidebar-collapsed] .dsh-git-entry .dsh-git-entry-label,[data-sidebar-collapsed] .dsh-git-entry .dsh-git-entry-stats,[class*="_collapsed"] .dsh-git-entry .dsh-git-entry-label,[class*="_collapsed"] .dsh-git-entry .dsh-git-entry-stats{display:none}
|
|
2263
2837
|
`
|
|
2264
2838
|
document.head.appendChild(style)
|
|
@@ -2272,9 +2846,17 @@ function mountGitSidebarEntry(t) {
|
|
|
2272
2846
|
entry.addEventListener('click', () => openGitPage(t))
|
|
2273
2847
|
const stats = entry.querySelector('.dsh-git-entry-stats')
|
|
2274
2848
|
const refreshStats = () => {
|
|
2849
|
+
let repos = ''
|
|
2275
2850
|
fetch(API + '/me').then((r) => r.json()).then((d) => {
|
|
2276
|
-
if (
|
|
2277
|
-
}).catch(() => {})
|
|
2851
|
+
if (d && Array.isArray(d.repos)) repos = String(d.repos.length)
|
|
2852
|
+
}).catch(() => {}).then(() =>
|
|
2853
|
+
fetch(API + '/dsh/notifications/count').then((r) => r.json()).then((d) => {
|
|
2854
|
+
if (!stats) return
|
|
2855
|
+
const unread = d && typeof d.unread === 'number' ? d.unread : 0
|
|
2856
|
+
stats.textContent = repos + (unread > 0 ? ' · ' + unread + ' 未读' : '')
|
|
2857
|
+
if (unread > 0) stats.style.color = 'var(--dsw-alias-state-error-primary)'
|
|
2858
|
+
else stats.style.color = ''
|
|
2859
|
+
}).catch(() => { if (stats) stats.textContent = repos }))
|
|
2278
2860
|
}
|
|
2279
2861
|
refreshStats()
|
|
2280
2862
|
const poll = setInterval(refreshStats, 30000)
|
|
@@ -2358,7 +2940,7 @@ module.exports = {
|
|
|
2358
2940
|
ensureStyles()
|
|
2359
2941
|
let t = opts.t || ((key) => ZH[key] ?? EN[key] ?? key)
|
|
2360
2942
|
const root = require('react-dom/client').createRoot(container)
|
|
2361
|
-
root.render(h(GitPage, { onClose: () => {}, t }))
|
|
2943
|
+
root.render(h(GitPage, { onClose: () => {}, t, standalone: true }))
|
|
2362
2944
|
return root
|
|
2363
2945
|
},
|
|
2364
2946
|
apply(ctx) {
|