@goodandready/dsh-github-ops 0.1.2 → 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/lib/client.js CHANGED
@@ -16,6 +16,13 @@ window.__ModuleLoader__.load({
16
16
  const React = require('react')
17
17
 
18
18
  const NS = 'dsh-github-ops'
19
+ const STATUS_PATH = '/dsh-github-ops/status'
20
+ const SCM_PATH = '/dsh-github-ops/scm'
21
+ const PANEL_PATH = '/dsh-github-ops/panel'
22
+
23
+ // The repository a github.com link in the conversation pointed at, so the tab and the
24
+ // panel can follow the human instead of asking again.
25
+ const linkState = { repo: '', enabled: false }
19
26
  const ROW_ID = 'dsh-github-ops'
20
27
  const PKG = '@goodandready/dsh-github-ops'
21
28
  const ROW_CONFIG_KEY = PKG + '#' + ROW_ID
@@ -47,6 +54,52 @@ window.__ModuleLoader__.load({
47
54
  savedPartial: 'Saved, except: ',
48
55
  noChanges: 'Nothing to change.',
49
56
  invalidNumber: 'Enter a positive number.',
57
+ statusTitle: 'Access',
58
+ statusLoading: 'Checking access…',
59
+ statusNotConfigured: 'No access yet',
60
+ statusError: 'Access check failed',
61
+ statusSource: 'Source',
62
+ statusAccount: 'Account',
63
+ statusScopes: 'Scopes',
64
+ statusRate: 'Rate limit',
65
+ statusCache: 'Read cache',
66
+ statusRefresh: 'Check again',
67
+ scmTitle: 'Source control',
68
+ scmRefresh: 'Refresh',
69
+ scmLoading: 'Reading the working copy…',
70
+ scmNotRepo: 'This workspace is not a git repository, so there is nothing to show here.',
71
+ scmError: 'Could not read the working copy',
72
+ scmStaged: 'Staged',
73
+ scmChanges: 'Changes',
74
+ scmUntracked: 'Untracked',
75
+ scmConflicts: 'Conflicts',
76
+ scmNothing: 'Nothing to commit: the working copy is clean.',
77
+ scmDiffEmpty: 'No diff for this file (it may be binary or untracked).',
78
+ scmBranches: 'Branches',
79
+ scmTags: 'Tags',
80
+ scmStashes: 'Stashes',
81
+ scmInProgress: 'In progress',
82
+ scmNoUpstream: 'no upstream',
83
+ prBarBranch: 'to',
84
+ prBarCommits: 'commit(s) ahead',
85
+ panelTitle: 'GitHub',
86
+ panelRepo: 'Repository',
87
+ panelSearch: 'Search public repositories…',
88
+ panelChoose: 'Choose a repository to browse.',
89
+ panelBack: 'Up',
90
+ panelIssues: 'Issues',
91
+ panelPulls: 'Pull requests',
92
+ panelRuns: 'Actions',
93
+ panelInbox: 'Inbox',
94
+ panelOpen: 'Open in browser',
95
+ panelEmpty: 'Nothing here yet.',
96
+ panelLoading: 'Loading…',
97
+ panelNoAccess: 'No GitHub access yet: configure a credential, sign in, or use the gh CLI.',
98
+ prBarCreate: 'Create PR',
99
+ prBarReview: 'Review',
100
+ prBarMerge: 'Merge',
101
+ prBarCopied: 'Instruction copied: paste it into the composer and send.',
102
+ prBarCopyFailed: 'Could not copy; the instruction is in the title.',
50
103
  }
51
104
 
52
105
  const zh = {
@@ -76,6 +129,52 @@ window.__ModuleLoader__.load({
76
129
  savedPartial: '已保存,除了:',
77
130
  noChanges: '没有需要修改的内容。',
78
131
  invalidNumber: '请输入正数。',
132
+ statusTitle: '访问状态',
133
+ statusLoading: '正在检查访问…',
134
+ statusNotConfigured: '尚未配置访问',
135
+ statusError: '访问检查失败',
136
+ statusSource: '来源',
137
+ statusAccount: '账号',
138
+ statusScopes: '权限范围',
139
+ statusRate: '速率限制',
140
+ statusCache: '读取缓存',
141
+ statusRefresh: '重新检查',
142
+ scmTitle: '源代码管理',
143
+ scmRefresh: '刷新',
144
+ scmLoading: '正在读取工作副本…',
145
+ scmNotRepo: '当前工作区不是 git 仓库,这里没有可显示的内容。',
146
+ scmError: '无法读取工作副本',
147
+ scmStaged: '已暂存',
148
+ scmChanges: '更改',
149
+ scmUntracked: '未跟踪',
150
+ scmConflicts: '冲突',
151
+ scmNothing: '没有待提交内容:工作副本是干净的。',
152
+ scmDiffEmpty: '该文件没有差异(可能是二进制或未跟踪)。',
153
+ scmBranches: '分支',
154
+ scmTags: '标签',
155
+ scmStashes: '暂存',
156
+ scmInProgress: '进行中',
157
+ scmNoUpstream: '无上游',
158
+ prBarBranch: '→',
159
+ prBarCommits: '个提交领先',
160
+ panelTitle: 'GitHub',
161
+ panelRepo: '仓库',
162
+ panelSearch: '搜索公开仓库…',
163
+ panelChoose: '请选择要浏览的仓库。',
164
+ panelBack: '上一级',
165
+ panelIssues: 'Issue',
166
+ panelPulls: 'Pull request',
167
+ panelRuns: 'Actions',
168
+ panelInbox: '收件箱',
169
+ panelOpen: '在浏览器中打开',
170
+ panelEmpty: '这里还没有内容。',
171
+ panelLoading: '加载中…',
172
+ panelNoAccess: '尚未配置 GitHub 访问:请设置凭据、登录或使用 gh CLI。',
173
+ prBarCreate: '创建 PR',
174
+ prBarReview: '评审',
175
+ prBarMerge: '合并',
176
+ prBarCopied: '指令已复制:粘贴到输入框并发送。',
177
+ prBarCopyFailed: '复制失败;指令在提示中。',
79
178
  }
80
179
 
81
180
  let ChevronIcon = null
@@ -92,6 +191,348 @@ window.__ModuleLoader__.load({
92
191
  React.createElement('path', { d: 'M3.5 5.25L7 8.75L10.5 5.25' }),
93
192
  )
94
193
 
194
+
195
+ /**
196
+ * Source Control tab: what the working copy looks like right now.
197
+ *
198
+ * Reads only. Data comes from the host route on the session workspace; nothing here writes
199
+ * to the repository, so a change always starts with an explicit human action.
200
+ */
201
+ function SourceControlView(props) {
202
+ ensureCss()
203
+ const t = props.t || ((key) => key)
204
+ const [state, setState] = React.useState({ phase: 'loading' })
205
+ const [selected, setSelected] = React.useState(null)
206
+ const [diff, setDiff] = React.useState({ phase: 'idle', text: '' })
207
+
208
+ const load = React.useCallback(() => {
209
+ setState({ phase: 'loading' })
210
+ if (typeof fetch !== 'function') {
211
+ setState({ phase: 'error', message: 'fetch is unavailable in this client' })
212
+ return
213
+ }
214
+ fetch(SCM_PATH, { headers: { accept: 'application/json' } })
215
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
216
+ .then((payload) => setState({ phase: 'ready', payload }))
217
+ .catch((err) => setState({ phase: 'error', message: String((err && err.message) || err) }))
218
+ }, [])
219
+
220
+ React.useEffect(() => { load() }, [load])
221
+
222
+ const openDiff = (file, staged) => {
223
+ setSelected({ path: file.path, staged })
224
+ setDiff({ phase: 'loading', text: '' })
225
+ const query = `?file=${encodeURIComponent(file.path)}${staged ? '&staged=1' : ''}`
226
+ fetch(SCM_PATH + query, { headers: { accept: 'application/json' } })
227
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
228
+ .then((payload) => setDiff({ phase: 'ready', text: payload.diff || '' }))
229
+ .catch((err) => setDiff({ phase: 'error', text: String((err && err.message) || err) }))
230
+ }
231
+
232
+ const fileRow = (file, staged) => React.createElement('div', {
233
+ key: `${staged ? 's' : 'w'}-${file.path}`,
234
+ className: 'gho-scm-file',
235
+ role: 'button',
236
+ tabIndex: 0,
237
+ 'aria-selected': selected && selected.path === file.path && selected.staged === staged,
238
+ onClick: () => openDiff(file, staged),
239
+ onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') openDiff(file, staged) },
240
+ },
241
+ React.createElement('span', { className: 'gho-scm-badge' }, file.status),
242
+ React.createElement('span', { className: 'gho-scm-path' }, file.renamedFrom ? `${file.renamedFrom} → ${file.path}` : file.path),
243
+ )
244
+
245
+ const group = (title, files, staged) => (files && files.length
246
+ ? React.createElement('div', { className: 'gho-scm-group', key: title },
247
+ React.createElement('div', { className: 'gho-scm-group-head' }, `${title} (${files.length})`),
248
+ files.map((file) => fileRow(file, staged)),
249
+ )
250
+ : null)
251
+
252
+ if (state.phase === 'loading') return React.createElement('div', { className: 'gho-scm' }, t('scmLoading'))
253
+ if (state.phase === 'error') {
254
+ return React.createElement('div', { className: 'gho-scm' },
255
+ React.createElement('div', { className: 'gho-scm-group-head' }, t('scmError')),
256
+ React.createElement('div', { className: 'gho-scm-meta' }, state.message),
257
+ React.createElement('button', { type: 'button', className: 'gho-link', onClick: load }, t('scmRefresh')),
258
+ )
259
+ }
260
+ const payload = state.payload || {}
261
+ if (!payload.isRepository) {
262
+ return React.createElement('div', { className: 'gho-scm' },
263
+ React.createElement('div', { className: 'gho-scm-group-head' }, t('scmNotRepo')),
264
+ payload.error ? React.createElement('div', { className: 'gho-scm-meta' }, payload.error) : null,
265
+ React.createElement('button', { type: 'button', className: 'gho-link', onClick: load }, t('scmRefresh')),
266
+ )
267
+ }
268
+ const files = payload.files || { staged: [], changes: [], untracked: [], unmerged: [] }
269
+ const clean = !files.total
270
+
271
+ return React.createElement('div', { className: 'gho-scm' },
272
+ React.createElement('div', { className: 'gho-scm-head' },
273
+ React.createElement('span', { className: 'gho-scm-repo' }, payload.cwd || ''),
274
+ React.createElement('span', { className: 'gho-scm-branch' }, payload.branch),
275
+ payload.upstream
276
+ ? React.createElement('span', { className: 'gho-scm-counts' }, `↑${payload.ahead ?? 0} ↓${payload.behind ?? 0}`)
277
+ : React.createElement('span', { className: 'gho-scm-counts' }, t('scmNoUpstream')),
278
+ React.createElement('button', { type: 'button', className: 'gho-link', onClick: load }, t('scmRefresh')),
279
+ ),
280
+ payload.mergeState && payload.mergeState.inProgress
281
+ ? React.createElement('div', { className: 'gho-scm-meta' },
282
+ React.createElement('span', { className: 'gho-scm-chip' }, `${t('scmInProgress')}: ${payload.mergeState.kind}`))
283
+ : null,
284
+ clean ? React.createElement('div', { className: 'gho-scm-meta' }, t('scmNothing')) : null,
285
+ group(t('scmConflicts'), files.unmerged, false),
286
+ group(t('scmStaged'), files.staged, true),
287
+ group(t('scmChanges'), files.changes, false),
288
+ group(t('scmUntracked'), files.untracked, false),
289
+ selected
290
+ ? React.createElement('pre', { className: 'gho-scm-diff' },
291
+ diff.phase === 'loading' ? t('scmLoading') : (diff.text || t('scmDiffEmpty')))
292
+ : null,
293
+ React.createElement('div', { className: 'gho-scm-meta' },
294
+ React.createElement('span', null, `${t('scmBranches')}: ${(payload.branches || []).map((b) => (b.current ? `*${b.name}` : b.name)).join(', ') || '—'}`),
295
+ React.createElement('span', null, `${t('scmTags')}: ${(payload.tags || []).join(', ') || '—'}`),
296
+ React.createElement('span', null, `${t('scmStashes')}: ${(payload.stashes || []).length}`),
297
+ ),
298
+ )
299
+ }
300
+
301
+
302
+ /**
303
+ * The pull request bar above the composer.
304
+ *
305
+ * It appears when the branch has commits the upstream does not, and it never writes: the
306
+ * buttons put an instruction on the clipboard for the human to send, so the call still goes
307
+ * through the normal approval contour.
308
+ */
309
+ function PullRequestBar(props) {
310
+ ensureCss()
311
+ const t = props.t || ((key) => key)
312
+ const [state, setState] = React.useState({ phase: 'idle' })
313
+ const [note, setNote] = React.useState('')
314
+
315
+ const load = React.useCallback(() => {
316
+ if (typeof fetch !== 'function') return
317
+ fetch(SCM_PATH, { headers: { accept: 'application/json' } })
318
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
319
+ .then((payload) => setState({ phase: 'ready', payload }))
320
+ .catch(() => setState({ phase: 'idle' }))
321
+ }, [])
322
+
323
+ React.useEffect(() => { load() }, [load])
324
+
325
+ const payload = state.payload
326
+ const ahead = payload && payload.isRepository && payload.upstream ? (payload.ahead || 0) : 0
327
+ if (!payload || !payload.isRepository || ahead <= 0) return null
328
+
329
+ const instruction = (kind) => {
330
+ const repo = payload.cwd ? ` (workspace ${payload.cwd})` : ''
331
+ if (kind === 'create') return `Create a GitHub pull request from ${payload.branch}${payload.upstream ? ` into ${payload.upstream.replace(/^[^/]+\//, '')}` : ''}: call pr_create with head=${payload.branch}${repo}.`
332
+ if (kind === 'review') return `Review the open pull request of ${payload.branch}: call gh_review and then ci_run, and summarise the findings.`
333
+ return `Merge the pull request of ${payload.branch} after the review: call pr_merge.`
334
+ }
335
+
336
+ const copy = (kind) => {
337
+ const text = instruction(kind)
338
+ try {
339
+ if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
340
+ navigator.clipboard.writeText(text)
341
+ setNote(t('prBarCopied'))
342
+ return
343
+ }
344
+ throw new Error('clipboard unavailable')
345
+ } catch {
346
+ setNote(`${t('prBarCopyFailed')} ${text}`)
347
+ }
348
+ }
349
+
350
+ const base = payload.upstream ? payload.upstream.replace(/^[^/]+\//, '') : ''
351
+ return React.createElement('div', { className: 'gho-prbar' },
352
+ React.createElement('span', { className: 'gho-prbar-branch' }, `${payload.branch} ${base ? `→ ${base}` : ''}`.trim()),
353
+ React.createElement('span', null, `↑${ahead} ${t('prBarCommits')}`),
354
+ note ? React.createElement('span', { className: 'gho-prbar-note' }, note) : null,
355
+ React.createElement('span', { className: 'gho-prbar-actions' },
356
+ React.createElement('button', { type: 'button', className: 'gho-prbar-btn', onClick: () => copy('create') }, t('prBarCreate')),
357
+ React.createElement('button', { type: 'button', className: 'gho-prbar-btn', onClick: () => copy('review') }, t('prBarReview')),
358
+ React.createElement('button', { type: 'button', className: 'gho-prbar-btn', onClick: () => copy('merge') }, t('prBarMerge')),
359
+ ),
360
+ )
361
+ }
362
+
363
+
364
+ /** Remember which repository a github.com link pointed at, when the user opted in. */
365
+ function installLinkWatcher() {
366
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return
367
+ document.addEventListener('click', (event) => {
368
+ if (!linkState.enabled) return
369
+ const anchor = event.target && event.target.closest ? event.target.closest('a[href]') : null
370
+ if (!anchor) return
371
+ const match = String(anchor.getAttribute('href') || '').match(/^https?:\/\/github\.com\/([^/]+)\/([^/?#]+)/)
372
+ if (!match) return
373
+ // the browser still opens the link: this only records where the human is looking
374
+ linkState.repo = `${match[1]}/${match[2].replace(/\.git$/, '')}`
375
+ }, true)
376
+ }
377
+
378
+
379
+ /**
380
+ * The GitHub panel: browse a repository, its issues, pull requests, runs and the attention
381
+ * inbox from the sidebar.
382
+ *
383
+ * Reads only, and through the host route: the panel never holds a token and never writes.
384
+ */
385
+ function GitHubPanel(props) {
386
+ ensureCss()
387
+ const t = props.t || ((key) => key)
388
+ const [repo, setRepo] = React.useState('')
389
+ const [repos, setRepos] = React.useState([])
390
+ const [search, setSearch] = React.useState('')
391
+ const [tab, setTab] = React.useState('tree')
392
+ const [tree, setTree] = React.useState({ phase: 'idle', path: '', entries: [] })
393
+ const [file, setFile] = React.useState(null)
394
+ const [items, setItems] = React.useState({ phase: 'idle', list: [] })
395
+ const [error, setError] = React.useState('')
396
+ const [loading, setLoading] = React.useState(false)
397
+
398
+ const call = React.useCallback((params) => {
399
+ if (typeof fetch !== 'function') return Promise.reject(new Error('fetch is unavailable in this client'))
400
+ const query = new URLSearchParams(params).toString()
401
+ return fetch(`${PANEL_PATH}?${query}`, { headers: { accept: 'application/json' } })
402
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
403
+ }, [])
404
+
405
+ const loadRepos = React.useCallback((q) => {
406
+ setLoading(true)
407
+ setError('')
408
+ call(q ? { what: 'repos', q } : { what: 'repos' })
409
+ .then((payload) => {
410
+ if (payload.configured === false) { setError(payload.guidance || t('panelNoAccess')); setRepos([]); return }
411
+ setRepos(payload.repos || [])
412
+ })
413
+ .catch((err) => setError(String((err && err.message) || err)))
414
+ .finally(() => setLoading(false))
415
+ }, [call, t])
416
+
417
+ React.useEffect(() => { loadRepos('') }, [loadRepos])
418
+
419
+ const loadTree = React.useCallback((path) => {
420
+ if (!repo) return
421
+ setFile(null)
422
+ setTree({ phase: 'loading', path, entries: [] })
423
+ call({ what: 'tree', repo, path })
424
+ .then((payload) => setTree({ phase: 'ready', path: payload.path || path, entries: payload.entries || [], truncated: payload.truncated }))
425
+ .catch((err) => { setTree({ phase: 'error', path, entries: [] }); setError(String((err && err.message) || err)) })
426
+ }, [call, repo])
427
+
428
+ const loadList = React.useCallback((which) => {
429
+ if (!repo) return
430
+ setItems({ phase: 'loading', list: [] })
431
+ call({ what: which, repo })
432
+ .then((payload) => setItems({ phase: 'ready', list: payload.items || [] }))
433
+ .catch((err) => { setItems({ phase: 'error', list: [] }); setError(String((err && err.message) || err)) })
434
+ }, [call, repo])
435
+
436
+ const loadInbox = React.useCallback(() => {
437
+ setItems({ phase: 'loading', list: [] })
438
+ call({ what: 'inbox' })
439
+ .then((payload) => setItems({ phase: 'ready', list: payload.notifications || [] }))
440
+ .catch((err) => { setItems({ phase: 'error', list: [] }); setError(String((err && err.message) || err)) })
441
+ }, [call])
442
+
443
+ React.useEffect(() => {
444
+ if (!repo) return
445
+ if (tab === 'tree') loadTree('')
446
+ else if (tab === 'inbox') loadInbox()
447
+ else loadList(tab)
448
+ }, [repo, tab, loadTree, loadList, loadInbox])
449
+
450
+ const openFile = (entry) => {
451
+ if (entry.type === 'tree') { loadTree(entry.path); return }
452
+ call({ what: 'blob', repo, path: entry.path })
453
+ .then((payload) => setFile(payload))
454
+ .catch((err) => setError(String((err && err.message) || err)))
455
+ }
456
+
457
+ const tabButton = (id, label) => React.createElement('button', {
458
+ key: id, type: 'button', className: 'gho-panel-tab', 'aria-pressed': tab === id, onClick: () => setTab(id),
459
+ }, label)
460
+
461
+ const header = React.createElement('div', { className: 'gho-panel-row' },
462
+ React.createElement('span', { className: 'gho-panel-muted' }, t('panelRepo')),
463
+ React.createElement('span', null, repo || '—'),
464
+ repo ? React.createElement('a', { className: 'gho-link', href: `https://github.com/${repo}`, target: '_blank', rel: 'noreferrer' }, t('panelOpen')) : null,
465
+ )
466
+
467
+ const switcher = React.createElement('div', { className: 'gho-panel' },
468
+ React.createElement('div', { className: 'gho-panel-row' },
469
+ React.createElement('input', {
470
+ className: 'gho-panel-input',
471
+ placeholder: t('panelSearch'),
472
+ value: search,
473
+ onChange: (event) => setSearch(event.target.value),
474
+ onKeyDown: (event) => { if (event.key === 'Enter') loadRepos(search) },
475
+ }),
476
+ ),
477
+ repos.length === 0
478
+ ? React.createElement('div', { className: 'gho-panel-muted' }, loading ? t('panelLoading') : t('panelChoose'))
479
+ : repos.slice(0, 30).map((entry) => React.createElement('div', {
480
+ key: entry.fullName, className: 'gho-panel-item', role: 'button', tabIndex: 0,
481
+ onClick: () => setRepo(entry.fullName),
482
+ onKeyDown: (event) => { if (event.key === 'Enter') setRepo(entry.fullName) },
483
+ },
484
+ React.createElement('span', { className: 'gho-panel-file' }, entry.fullName),
485
+ React.createElement('span', { className: 'gho-panel-muted' }, entry.private ? 'private' : 'public'),
486
+ entry.description ? React.createElement('span', { className: 'gho-panel-muted' }, entry.description.slice(0, 40)) : null,
487
+ )),
488
+ )
489
+
490
+ if (!repo) return React.createElement('div', null, header, switcher)
491
+
492
+ const list = (() => {
493
+ if (items.phase === 'loading') return React.createElement('div', { className: 'gho-panel-muted' }, t('panelLoading'))
494
+ if (!items.list.length) return React.createElement('div', { className: 'gho-panel-muted' }, t('panelEmpty'))
495
+ return items.list.slice(0, 40).map((item, index) => React.createElement('div', { className: 'gho-panel-item', key: `${item.number || item.id || index}` },
496
+ React.createElement('span', { className: 'gho-panel-file' }, item.number ? `#${item.number}` : (item.name || '')),
497
+ React.createElement('span', null, item.title || item.displayTitle || item.reason || item.name || ''),
498
+ item.url || item.html_url ? React.createElement('a', { className: 'gho-link', href: item.url || item.html_url, target: '_blank', rel: 'noreferrer' }, '↗') : null,
499
+ ))
500
+ })()
501
+
502
+ const treeView = React.createElement('div', null,
503
+ tree.path ? React.createElement('button', { type: 'button', className: 'gho-link', onClick: () => loadTree(tree.path.split('/').slice(0, -1).join('/')) }, t('panelBack')) : null,
504
+ tree.phase === 'loading' ? React.createElement('div', { className: 'gho-panel-muted' }, t('panelLoading')) : null,
505
+ tree.phase === 'ready' && tree.entries.length === 0 ? React.createElement('div', { className: 'gho-panel-muted' }, t('panelEmpty')) : null,
506
+ (tree.entries || []).map((entry) => React.createElement('div', {
507
+ key: entry.path, className: 'gho-panel-item', role: 'button', tabIndex: 0,
508
+ onClick: () => openFile(entry),
509
+ onKeyDown: (event) => { if (event.key === 'Enter') openFile(entry) },
510
+ },
511
+ React.createElement('span', { className: 'gho-panel-file' }, entry.type === 'tree' ? '▸' : '·'),
512
+ React.createElement('span', { className: 'gho-panel-file' }, entry.name),
513
+ entry.type === 'blob' && entry.size !== undefined ? React.createElement('span', { className: 'gho-panel-muted' }, `${entry.size} B`) : null,
514
+ )),
515
+ file ? React.createElement('pre', { className: 'gho-panel-pre' }, file.truncated ? `${file.text}\n…` : (file.text || t('panelEmpty'))) : null,
516
+ )
517
+
518
+ return React.createElement('div', { className: 'gho-panel' },
519
+ header,
520
+ React.createElement('div', { className: 'gho-panel-tabs' },
521
+ tabButton('tree', 'Code'),
522
+ tabButton('issues', t('panelIssues')),
523
+ tabButton('pulls', t('panelPulls')),
524
+ tabButton('runs', t('panelRuns')),
525
+ tabButton('inbox', t('panelInbox')),
526
+ React.createElement('button', { type: 'button', className: 'gho-panel-tab', onClick: () => { setRepo(''); setFile(null) } }, t('panelRepo')),
527
+ ),
528
+ error ? React.createElement('div', { className: 'gho-panel-muted' }, error) : null,
529
+ tab === 'tree' ? treeView : list,
530
+ )
531
+ }
532
+
533
+ // This file is the whole client half on purpose: DSH loads it as a single bundle through
534
+ // window.__ModuleLoader__, so splitting the card, the tab, the panel and the pull request
535
+ // bar into several files would need a build step the plugin deliberately does not have.
95
536
  let cssInstalled = false
96
537
  function ensureCss() {
97
538
  if (cssInstalled || typeof document === 'undefined') return
@@ -108,6 +549,43 @@ window.__ModuleLoader__.load({
108
549
  '.gho-field{display:flex;flex-direction:column;gap:6px;padding:12px 0}',
109
550
  '.gho-label{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}',
110
551
  '.gho-hint{font-size:12px;color:var(--dsw-alias-label-secondary)}',
552
+ '.gho-status{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:10px 12px;margin:12px 0 0;display:flex;flex-direction:column;gap:6px}',
553
+ '.gho-status-row{display:flex;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
554
+ '.gho-status-key{min-width:88px;color:var(--dsw-alias-label-tertiary)}',
555
+ '.gho-status-value{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}',
556
+ '.gho-status-head{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}',
557
+ '.gho-status-bad{color:var(--dsw-alias-label-error,#c0392b)}',
558
+ '.gho-link{appearance:none;border:0;background:0 0;font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary);text-decoration:underline;cursor:pointer;padding:0;margin-left:auto}',
559
+ '.gho-scm{display:flex;flex-direction:column;gap:12px;padding:4px 2px;font-size:13px;color:var(--dsw-alias-label-primary)}',
560
+ '.gho-scm-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
561
+ '.gho-scm-repo{font-weight:600}',
562
+ '.gho-scm-branch{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-secondary)}',
563
+ '.gho-scm-counts{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}',
564
+ '.gho-scm-group{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;overflow:hidden}',
565
+ '.gho-scm-group-head{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--dsw-alias-bg-layer-3);font-size:12px;font-weight:600}',
566
+ '.gho-scm-file{display:flex;align-items:center;gap:8px;padding:6px 12px;cursor:pointer;border-top:1px solid var(--dsw-alias-border-l2)}',
567
+ '.gho-scm-file[aria-selected="true"]{background:var(--dsw-alias-bg-layer-3)}',
568
+ '.gho-scm-badge{min-width:18px;text-align:center;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-secondary)}',
569
+ '.gho-scm-path{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
570
+ '.gho-scm-diff{margin:0;padding:10px 12px;max-height:420px;overflow:auto;background:var(--dsw-alias-bg-layer-3);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.5;white-space:pre}',
571
+ '.gho-scm-meta{display:flex;gap:12px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-secondary)}',
572
+ '.gho-scm-chip{border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:2px 8px}',
573
+ '.gho-prbar{display:flex;align-items:center;gap:10px;padding:6px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;font-size:12px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-3)}',
574
+ '.gho-prbar-branch{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-primary)}',
575
+ '.gho-prbar-actions{display:flex;gap:6px;margin-left:auto}',
576
+ '.gho-prbar-btn{appearance:none;font:inherit;font-size:12px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);background:0 0;color:var(--dsw-alias-label-primary);border-radius:8px;padding:3px 8px}',
577
+ '.gho-prbar-note{color:var(--dsw-alias-label-tertiary)}',
578
+ '.gho-panel{display:flex;flex-direction:column;gap:10px;font-size:12px;color:var(--dsw-alias-label-primary);padding:4px 2px}',
579
+ '.gho-panel-row{display:flex;align-items:center;gap:6px}',
580
+ '.gho-panel-input{flex:1;height:28px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);padding:0 8px;font:inherit}',
581
+ '.gho-panel-tabs{display:flex;gap:4px;flex-wrap:wrap}',
582
+ '.gho-panel-tab{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);background:0 0;color:var(--dsw-alias-label-secondary);border-radius:999px;padding:2px 10px}',
583
+ '.gho-panel-tab[aria-pressed="true"]{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary)}',
584
+ '.gho-panel-item{display:flex;gap:8px;align-items:baseline;padding:5px 2px;border-top:1px solid var(--dsw-alias-border-l2);cursor:pointer}',
585
+ '.gho-panel-item:hover{background:var(--dsw-alias-bg-layer-3)}',
586
+ '.gho-panel-muted{color:var(--dsw-alias-label-tertiary)}',
587
+ '.gho-panel-file{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}',
588
+ '.gho-panel-pre{margin:0;max-height:340px;overflow:auto;background:var(--dsw-alias-bg-layer-3);border-radius:8px;padding:8px;white-space:pre;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}',
111
589
  '.gho-input-area{height:auto;min-height:64px;padding:8px 12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;resize:vertical}',
112
590
  '.gho-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px}',
113
591
  '.gho-foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px}',
@@ -140,6 +618,26 @@ window.__ModuleLoader__.load({
140
618
  const [draft, setDraft] = React.useState(null)
141
619
  const [saving, setSaving] = React.useState(false)
142
620
  const [message, setMessage] = React.useState('')
621
+ const [accessStatus, setAccessStatus] = React.useState(null)
622
+ const [statusError, setStatusError] = React.useState('')
623
+
624
+ const loadStatus = React.useCallback(() => {
625
+ setAccessStatus('loading')
626
+ setStatusError('')
627
+ if (typeof fetch !== 'function') {
628
+ setAccessStatus(null)
629
+ setStatusError('fetch is unavailable in this client')
630
+ return
631
+ }
632
+ fetch(STATUS_PATH, { headers: { accept: 'application/json' } })
633
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
634
+ .then((payload) => setAccessStatus(payload))
635
+ .catch((err) => { setAccessStatus(null); setStatusError(String((err && err.message) || err)) })
636
+ }, [])
637
+
638
+ React.useEffect(() => {
639
+ if (open && accessStatus === null && !statusError) loadStatus()
640
+ }, [open, accessStatus, statusError, loadStatus])
143
641
 
144
642
  let scope = null
145
643
  try {
@@ -238,6 +736,44 @@ window.__ModuleLoader__.load({
238
736
  field('timeoutMs', 'timeoutMs', 'timeoutMsHint', 'number'),
239
737
  field('maxRetries', 'maxRetries', 'maxRetriesHint', 'number'),
240
738
  field('reviewRulesJson', 'reviewRules', 'reviewRulesHint', 'json'),
739
+ React.createElement('div', { className: 'gho-status' },
740
+ React.createElement('div', { className: 'gho-status-head' },
741
+ t('statusTitle'),
742
+ React.createElement('button', { type: 'button', className: 'gho-link', onClick: loadStatus }, t('statusRefresh')),
743
+ ),
744
+ accessStatus === 'loading' ? React.createElement('div', { className: 'gho-status-row' }, t('statusLoading')) : null,
745
+ statusError ? React.createElement('div', { className: 'gho-status-row gho-status-bad' }, `${t('statusError')}: ${statusError}`) : null,
746
+ accessStatus && accessStatus !== 'loading' && !accessStatus.configured
747
+ ? React.createElement('div', { className: 'gho-status-row' }, t('statusNotConfigured'))
748
+ : null,
749
+ accessStatus && accessStatus !== 'loading' && accessStatus.guidance
750
+ ? React.createElement('div', { className: 'gho-status-row' }, accessStatus.guidance)
751
+ : null,
752
+ accessStatus && accessStatus !== 'loading' && accessStatus.configured
753
+ ? React.createElement('div', null,
754
+ React.createElement('div', { className: 'gho-status-row' },
755
+ React.createElement('span', { className: 'gho-status-key' }, t('statusSource')),
756
+ React.createElement('span', { className: 'gho-status-value' }, accessStatus.source || '—'),
757
+ ),
758
+ React.createElement('div', { className: 'gho-status-row' },
759
+ React.createElement('span', { className: 'gho-status-key' }, t('statusAccount')),
760
+ React.createElement('span', { className: 'gho-status-value' }, accessStatus.login || '—'),
761
+ ),
762
+ accessStatus.scopes ? React.createElement('div', { className: 'gho-status-row' },
763
+ React.createElement('span', { className: 'gho-status-key' }, t('statusScopes')),
764
+ React.createElement('span', { className: 'gho-status-value' }, accessStatus.scopes),
765
+ ) : null,
766
+ accessStatus.rateLimit ? React.createElement('div', { className: 'gho-status-row' },
767
+ React.createElement('span', { className: 'gho-status-key' }, t('statusRate')),
768
+ React.createElement('span', { className: 'gho-status-value' }, `${accessStatus.rateLimit.remaining ?? '?'}/${accessStatus.rateLimit.limit ?? '?'}${accessStatus.rateLimit.resetAt ? ` · ${accessStatus.rateLimit.resetAt}` : ''}`),
769
+ ) : null,
770
+ accessStatus.cache ? React.createElement('div', { className: 'gho-status-row' },
771
+ React.createElement('span', { className: 'gho-status-key' }, t('statusCache')),
772
+ React.createElement('span', { className: 'gho-status-value' }, `${accessStatus.cache.size} · ${accessStatus.cache.ttlMs} ms`),
773
+ ) : null,
774
+ )
775
+ : null,
776
+ ),
241
777
  React.createElement('div', { className: 'gho-foot' },
242
778
  React.createElement('span', { className: 'gho-note' }, message),
243
779
  React.createElement('button', {
@@ -296,9 +832,76 @@ window.__ModuleLoader__.load({
296
832
  }
297
833
  }
298
834
 
835
+ // The panel goes into the sidebar: better-sidebar first (its contract is explicit), and
836
+ // the built-in right sidebar through its tab registry. Both are feature-detected, so a
837
+ // composition without either still loads the plugin.
838
+ try {
839
+ ctx.inject(['betterSidebar'], (sctx) => {
840
+ try {
841
+ sctx.effect(() => sctx.betterSidebar.registerTab({
842
+ id: 'github-ops:panel',
843
+ title: 'GitHub',
844
+ component: (viewProps) => React.createElement(GitHubPanel, { t: ctx.locale.bind(NS), scope: viewProps && viewProps.scope }),
845
+ }), 'dsh-github-ops: better-sidebar panel')
846
+ } catch {
847
+ /* an older better-sidebar without registerTab */
848
+ }
849
+ })
850
+ } catch {
851
+ /* no better-sidebar in this composition */
852
+ }
853
+ try {
854
+ ctx.inject(['sidebarRightTabs'], (sctx) => {
855
+ try {
856
+ sctx.effect(() => sctx.sidebarRightTabs.register({
857
+ id: 'github-ops:panel',
858
+ kind: 'github',
859
+ patterns: ['dsh-resource://github/**'],
860
+ priority: 'builtin',
861
+ canOpen: (address) => String(address || '').startsWith('dsh-resource://github/'),
862
+ title: () => 'GitHub',
863
+ }), 'dsh-github-ops: sidebar tab')
864
+ } catch {
865
+ /* a core without the right-sidebar tab registry */
866
+ }
867
+ })
868
+ } catch {
869
+ /* no built-in right sidebar in this composition */
870
+ }
871
+ // The pull request bar above the composer: a suggestion, never a write.
872
+ try {
873
+ ctx.slots.inject('conversation.composer.bar', () => ctx.slots.register(
874
+ { name: 'conversation.composer.bar', locale: NS, inject: () => ({ t: ctx.locale.bind(NS) }) },
875
+ PullRequestBar,
876
+ ))
877
+ } catch {
878
+ /* a core without the composer bar: the rest of the plugin still works */
879
+ }
880
+ installLinkWatcher()
881
+ // The Source Control tab lives in the session view ring, the same seat the Memory tab
882
+ // of dsh-memory-meter uses: a session-scoped view, not a navigation of our own.
883
+ try {
884
+ ctx.slots.inject('conversation.view', () => ctx.slots.register(
885
+ {
886
+ name: 'conversation.view',
887
+ id: 'github-scm',
888
+ order: 40,
889
+ label: () => 'Source control',
890
+ locale: NS,
891
+ inject: () => ({ t: ctx.locale.bind(NS) }),
892
+ },
893
+ SourceControlView,
894
+ ))
895
+ } catch {
896
+ /* a core without the session view ring: the rest of the plugin still works */
897
+ }
898
+
299
899
  exports.inject = ['slots', 'locale', 'settingsScope']
300
900
  exports.apply = apply
301
901
  exports.GitHubOpsCard = GitHubOpsCard
902
+ exports.SourceControlView = SourceControlView
903
+ exports.PullRequestBar = PullRequestBar
904
+ exports.GitHubPanel = GitHubPanel
302
905
  return module.exports
303
906
  },
304
907
  })