@ban-sky/dsh-github 0.0.0-stage → 0.4.2

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/client.js ADDED
@@ -0,0 +1,1689 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-github',
3
+
4
+ factory(require) {
5
+ const React = require('react')
6
+ const { useCallback, useEffect, useRef, useState } = React
7
+ const h = React.createElement
8
+
9
+ /** Locale namespace, matching the `meta` dictionaries in locale/*.json. */
10
+ const NS = 'dsh-github'
11
+ /** Authenticated Host route registered by the Host half. */
12
+ const ROUTE = 'api/github'
13
+ /** Settings section that renders this page. */
14
+ const SECTION_SLOT = 'settings.section'
15
+ const DOCK_SLOT = 'conversation.composer.dock'
16
+ /** How often the pending sign-in is polled. */
17
+ const POLL_MS = 2500
18
+
19
+ const zh = {
20
+ nav: 'GitHub',
21
+ intro:
22
+ '使用 GitHub 官方的设备码(Device Flow)登录。访问令牌只写入 DSH 凭据库,不会出现在设置文件、git remote 地址或命令参数里。',
23
+ loading: '加载中…',
24
+ signIn: '在 GitHub 上登录',
25
+ signingIn: '正在向 GitHub 申请设备码…',
26
+ enterCode: '在浏览器中打开 GitHub 并输入此代码:',
27
+ openDevicePage: '打开验证页面',
28
+ copyCode: '复制代码',
29
+ copied: '已复制',
30
+ waiting: '等待浏览器中完成授权,本页会自动更新。',
31
+ cancel: '取消',
32
+ cancelSignIn: '取消登录',
33
+ scopesValue: '授权范围:{scopes}',
34
+ obtainedAtValue: '授权时间:{when}',
35
+ expired: '令牌已失效,请重新登录。',
36
+ unverified: '暂时无法验证令牌,请检查网络。',
37
+ signOut: '退出登录',
38
+ signOutConfirm: '确认退出登录?',
39
+ confirm: '确认',
40
+ back: '返回',
41
+ signedOut: '已退出登录;本地仓库不受影响。',
42
+ signedInNotice: '登录成功。',
43
+ settingsTitle: '连接设置',
44
+ clientIdLabel: 'OAuth App Client ID',
45
+ clientIdHint:
46
+ '在 GitHub → Settings → Developer settings → OAuth Apps 新建应用并启用 Device Flow;只填写 Client ID,绝不要填写 Client Secret。',
47
+ cloneRootLabel: '本地仓库目录',
48
+ cloneRootHint: '克隆、拉取、提交和推送都被限制在该目录之内;目录外的路径会被拒绝。',
49
+ agentToolsLabel: '启用 Agent 工具 github',
50
+ agentToolsHint: '让模型使用相同的仓库操作;需要重新加载插件后生效。',
51
+ save: '保存',
52
+ saved: '设置已保存。',
53
+ restartRequired: '设置已保存;Agent 工具变更将在重新加载插件后生效。',
54
+ configFileValue: '设置文件:{path}',
55
+ reposTitle: '我的仓库',
56
+ loadRepos: '加载仓库',
57
+ reload: '刷新',
58
+ reposEmpty: '没有可显示的仓库。',
59
+ reposTruncated: '只显示最近更新的 100 个仓库。',
60
+ clone: '克隆',
61
+ cloning: '克隆中…',
62
+ cloned: '已克隆到 {path}',
63
+ createTitle: '新建仓库',
64
+ repoNameLabel: '仓库名称',
65
+ repoNamePlaceholder: 'my-repo',
66
+ repoDescriptionLabel: '描述',
67
+ repoDescriptionPlaceholder: '可选',
68
+ createPrivateLabel: '私有仓库',
69
+ createAutoInitLabel: '用 README 初始化',
70
+ create: '创建',
71
+ creating: '创建中…',
72
+ created: '已创建 {fullName}。',
73
+ private: '私有',
74
+ public: '公开',
75
+ localTitle: '本地仓库',
76
+ localEmpty: '目录里还没有仓库,可以在上方克隆仓库。',
77
+ localTruncated: '只显示前 {count} 个本地仓库。',
78
+ localMissing: '目录不存在:{root}',
79
+ localLoading: '正在读取本地仓库…',
80
+ pull: '拉取',
81
+ pulling: '拉取中…',
82
+ pulled: '已拉取到 {branch}。',
83
+ push: '推送',
84
+ pushing: '推送中…',
85
+ pushed: '已推送 {branch}。',
86
+ commit: '提交',
87
+ committing: '提交中…',
88
+ committed: '已提交 {short}。',
89
+ committedPushed: '已提交 {short} 并推送 {branch}。',
90
+ commitMessageLabel: '提交说明',
91
+ commitMessagePlaceholder: '说明这次改动',
92
+ commitPushLabel: '提交后推送',
93
+ dirty: '{count} 项改动',
94
+ clean: '工作区干净',
95
+ ahead: '领先 {count}',
96
+ behind: '落后 {count}',
97
+ noUpstream: '未设置上游分支',
98
+ detachedHead: '游离 HEAD',
99
+ noCommit: '没有提交记录',
100
+ statusError: '无法读取状态({code})',
101
+ dockTitle: 'GitHub:会话目录',
102
+ dockHint: '在当前会话目录里直接克隆、拉取、更新和推送。',
103
+ sessionPathLabel: '会话目录',
104
+ sessionNotRepo: '该目录还不是 Git 仓库。可以克隆一个仓库到这里,或把当前目录创建为 GitHub 仓库。',
105
+ sessionCloneTitle: '克隆仓库到此目录',
106
+ sessionCloneHint: '将克隆到 {path}',
107
+ sessionFullNameLabel: '仓库(owner/name)',
108
+ sessionFullNamePlaceholder: 'octocat/Hello-World',
109
+ sessionPublishTitle: '创建仓库并推送此目录',
110
+ sessionPublishHint:
111
+ '在你的 GitHub 账号下创建仓库、初始化此目录并推送首个提交;仅当会话目录本身就是仓库根目录时执行。',
112
+ sessionPublishMessageLabel: '首次提交说明',
113
+ sessionPublishButton: '创建并推送',
114
+ sessionPublishing: '创建并推送中…',
115
+ sessionPublished: '已创建并推送 {fullName}。',
116
+ sessionRefresh: '刷新',
117
+ sessionFetch: '更新',
118
+ sessionFetching: '更新中…',
119
+ sessionFetched: '已更新远端信息。',
120
+ sessionClose: '关闭',
121
+ sessionSignInHint: '请先在「设置 → GitHub」中登录。',
122
+ 'error.sessionNotFound': '找不到该会话,请重新打开会话后再试。',
123
+ 'error.repoElsewhere': '会话目录位于另一个 Git 仓库内部,请改为打开那个仓库。',
124
+ 'error.remoteExists': '该目录已存在不同的 origin 远端,请先移除。',
125
+ 'error.targetExists': '目标位置已存在同名目录。',
126
+ 'error.repoNested': '会话目录里已经有一个仓库,请直接使用该仓库。',
127
+ 'error.generic': '操作失败。',
128
+ 'error.detail': '操作失败:{message}',
129
+ 'error.http': '请求失败(HTTP {status})。',
130
+ 'error.unauthorized': '会话已失效,请刷新页面后重试。',
131
+ 'error.connection': '无法连接 DSH 主机。',
132
+ 'error.missingClientId': '请先填写 OAuth App Client ID 并保存。',
133
+ 'error.invalidClientId': 'Client ID 格式无效。',
134
+ 'error.deviceFlowDisabled': '该 OAuth App 未启用 Device Flow,请在 GitHub 应用设置中开启。',
135
+ 'error.loginDenied': 'GitHub 拒绝了授权。',
136
+ 'error.loginExpired': '设备码已过期,请重试。',
137
+ 'error.tokenInvalid': '访问令牌无效,请重新登录。',
138
+ 'error.rateLimited': 'GitHub API 请求过于频繁,请稍后再试。',
139
+ 'error.unsafePath': '该路径不在本地仓库目录内,已拒绝。',
140
+ 'error.notFound': '找不到该仓库或路径。',
141
+ 'error.invalidRepoName': '仓库名称无效。',
142
+ 'error.invalidOwner': '仓库所有者无效。',
143
+ 'error.invalidCloneRoot': '本地仓库目录无效(必须是绝对路径)。',
144
+ 'error.invalidMessage': '提交说明不能为空。',
145
+ 'error.invalidFiles': '文件列表无效。',
146
+ 'error.invalidPath': '仓库路径无效。',
147
+ 'error.cloneRootMissing': '本地仓库目录不存在。',
148
+ 'error.notARepo': '该目录不是 git 仓库。',
149
+ 'error.gitMissing': '找不到 git 命令。',
150
+ 'error.gitFailed': 'git 操作失败。',
151
+ 'error.noRemote': '该仓库没有 origin 远端。',
152
+ 'error.unsupportedRemote': '只允许 https://github.com 远端,已阻止操作。',
153
+ 'error.remoteHasCredentials': '远端地址内嵌了凭据,请先清理。',
154
+ 'error.dirtyWorktree': '工作区有未提交改动,无法拉取。',
155
+ 'error.notFastForward': '无法快进合并,请先手动处理分支。',
156
+ 'error.noUpstream': '该分支没有上游分支。',
157
+ 'error.detachedHead': '当前是游离 HEAD,无法推送。',
158
+ 'error.nothingToCommit': '没有可提交的改动。',
159
+ 'error.identityMissing': 'git 用户身份未配置(user.name / user.email)。',
160
+ 'error.pushRejected': '推送被远端拒绝。',
161
+ 'error.bodyTooLarge': '请求内容过大。',
162
+ 'error.logoutFailed': '退出登录失败。',
163
+ 'error.notSignedIn': '请先登录 GitHub。',
164
+ 'error.workflowScope': '授权缺少 workflow 权限,GitHub 拒绝推送工作流文件。请点“重新授权”后再试。',
165
+ scopeMissingHint:
166
+ '当前授权缺少 workflow 权限,无法推送 .github/workflows 下的工作流文件。重新授权即可补上;账号与本地仓库都不受影响。',
167
+ reauth: '重新授权',
168
+ scopeMissingShort: '授权缺少 workflow 权限,推送工作流文件会被拒;请在「设置 → GitHub」里重新授权。',
169
+ }
170
+
171
+ const en = {
172
+ nav: 'GitHub',
173
+ intro:
174
+ 'Sign in through GitHub’s official device flow. The access token is written only to the DSH credential store — never to the settings file, a git remote URL, or a command argument.',
175
+ loading: 'Loading…',
176
+ signIn: 'Sign in with GitHub',
177
+ signingIn: 'Requesting a device code from GitHub…',
178
+ enterCode: 'Open GitHub in your browser and enter this code:',
179
+ openDevicePage: 'Open verification page',
180
+ copyCode: 'Copy code',
181
+ copied: 'Copied',
182
+ waiting: 'Waiting for the browser to finish. This page updates on its own.',
183
+ cancel: 'Cancel',
184
+ cancelSignIn: 'Cancel sign-in',
185
+ scopesValue: 'Scopes: {scopes}',
186
+ obtainedAtValue: 'Authorized: {when}',
187
+ expired: 'The token is no longer valid. Please sign in again.',
188
+ unverified: 'The token could not be verified right now. Check the network.',
189
+ signOut: 'Sign out',
190
+ signOutConfirm: 'Sign out of GitHub?',
191
+ confirm: 'Confirm',
192
+ back: 'Back',
193
+ signedOut: 'Signed out. Local repositories are untouched.',
194
+ signedInNotice: 'Signed in.',
195
+ settingsTitle: 'Connection settings',
196
+ clientIdLabel: 'OAuth App Client ID',
197
+ clientIdHint:
198
+ 'Create an OAuth App under GitHub → Settings → Developer settings → OAuth Apps and enable Device Flow. Enter only the Client ID — never the Client Secret.',
199
+ cloneRootLabel: 'Local repository root',
200
+ cloneRootHint: 'Clone, pull, commit and push are confined to this directory; paths outside it are rejected.',
201
+ agentToolsLabel: 'Enable the github Agent tool',
202
+ agentToolsHint: 'Lets the model run the same repository operations. Takes effect after the plugin reloads.',
203
+ save: 'Save',
204
+ saved: 'Settings saved.',
205
+ restartRequired: 'Settings saved; the Agent-tool change applies after the plugin reloads.',
206
+ configFileValue: 'Settings file: {path}',
207
+ reposTitle: 'My repositories',
208
+ loadRepos: 'Load repositories',
209
+ reload: 'Refresh',
210
+ reposEmpty: 'No repositories to show.',
211
+ reposTruncated: 'Showing the 100 most recently updated repositories.',
212
+ clone: 'Clone',
213
+ cloning: 'Cloning…',
214
+ cloned: 'Cloned to {path}',
215
+ createTitle: 'Create repository',
216
+ repoNameLabel: 'Repository name',
217
+ repoNamePlaceholder: 'my-repo',
218
+ repoDescriptionLabel: 'Description',
219
+ repoDescriptionPlaceholder: 'Optional',
220
+ createPrivateLabel: 'Private repository',
221
+ createAutoInitLabel: 'Initialize with a README',
222
+ create: 'Create',
223
+ creating: 'Creating…',
224
+ created: 'Created {fullName}.',
225
+ private: 'Private',
226
+ public: 'Public',
227
+ localTitle: 'Local repositories',
228
+ localEmpty: 'No repositories here yet. Clone one above.',
229
+ localTruncated: 'Showing the first {count} local repositories.',
230
+ localMissing: 'Directory does not exist: {root}',
231
+ localLoading: 'Reading local repositories…',
232
+ pull: 'Pull',
233
+ pulling: 'Pulling…',
234
+ pulled: 'Pulled to {branch}.',
235
+ push: 'Push',
236
+ pushing: 'Pushing…',
237
+ pushed: 'Pushed {branch}.',
238
+ commit: 'Commit',
239
+ committing: 'Committing…',
240
+ committed: 'Committed {short}.',
241
+ committedPushed: 'Committed {short} and pushed {branch}.',
242
+ commitMessageLabel: 'Commit message',
243
+ commitMessagePlaceholder: 'Describe this change',
244
+ commitPushLabel: 'Push after committing',
245
+ dirty: '{count} change(s)',
246
+ clean: 'Working tree clean',
247
+ ahead: '{count} ahead',
248
+ behind: '{count} behind',
249
+ noUpstream: 'No upstream branch',
250
+ detachedHead: 'Detached HEAD',
251
+ noCommit: 'No commits',
252
+ statusError: 'Status unavailable ({code})',
253
+ dockTitle: 'GitHub: session folder',
254
+ dockHint: 'Clone, pull, update and push inside the current session folder.',
255
+ sessionPathLabel: 'Session folder',
256
+ sessionNotRepo:
257
+ 'This folder is not a git repository yet. Clone a repository into it, or publish it as a new GitHub repository.',
258
+ sessionCloneTitle: 'Clone a repository into this folder',
259
+ sessionCloneHint: 'Cloned into {path}',
260
+ sessionFullNameLabel: 'Repository (owner/name)',
261
+ sessionFullNamePlaceholder: 'octocat/Hello-World',
262
+ sessionPublishTitle: 'Create a repository and push this folder',
263
+ sessionPublishHint:
264
+ 'Creates the repository in your GitHub account, initialises this folder and pushes the first commit. Only runs when the session folder is itself the repository root.',
265
+ sessionPublishMessageLabel: 'First commit message',
266
+ sessionPublishButton: 'Create and push',
267
+ sessionPublishing: 'Creating and pushing…',
268
+ sessionPublished: 'Created and pushed {fullName}.',
269
+ sessionRefresh: 'Refresh',
270
+ sessionFetch: 'Update',
271
+ sessionFetching: 'Updating…',
272
+ sessionFetched: 'Fetched from origin.',
273
+ sessionClose: 'Close',
274
+ sessionSignInHint: 'Sign in through Settings → GitHub first.',
275
+ 'error.sessionNotFound': 'That session is not active any more. Reopen it and try again.',
276
+ 'error.repoElsewhere': 'The session folder is inside another git repository; open that repository instead.',
277
+ 'error.remoteExists': 'This folder already has a different origin remote. Remove it first.',
278
+ 'error.targetExists': 'A directory with that name already exists at the target location.',
279
+ 'error.repoNested': 'The session folder already contains a repository; use that one instead.',
280
+ 'error.generic': 'The operation failed.',
281
+ 'error.detail': 'The operation failed: {message}',
282
+ 'error.http': 'The request failed (HTTP {status}).',
283
+ 'error.unauthorized': 'The session expired. Refresh the page and try again.',
284
+ 'error.connection': 'Could not reach the DSH host.',
285
+ 'error.missingClientId': 'Enter and save the OAuth App Client ID first.',
286
+ 'error.invalidClientId': 'That Client ID is not valid.',
287
+ 'error.deviceFlowDisabled': 'The OAuth App does not have Device Flow enabled.',
288
+ 'error.loginDenied': 'GitHub denied the authorization.',
289
+ 'error.loginExpired': 'The device code expired. Try again.',
290
+ 'error.tokenInvalid': 'The access token is no longer valid. Sign in again.',
291
+ 'error.rateLimited': 'GitHub API rate limit reached. Try again later.',
292
+ 'error.unsafePath': 'That path is outside the local repository root and was rejected.',
293
+ 'error.notFound': 'That repository or path does not exist.',
294
+ 'error.invalidRepoName': 'That repository name is not valid.',
295
+ 'error.invalidOwner': 'That repository owner is not valid.',
296
+ 'error.invalidCloneRoot': 'The local repository root must be an absolute path.',
297
+ 'error.invalidMessage': 'A commit message is required.',
298
+ 'error.invalidFiles': 'That file list is not valid.',
299
+ 'error.invalidPath': 'That repository path is not valid.',
300
+ 'error.cloneRootMissing': 'The local repository root does not exist.',
301
+ 'error.notARepo': 'That directory is not a git repository.',
302
+ 'error.gitMissing': 'The git executable was not found.',
303
+ 'error.gitFailed': 'A git operation failed.',
304
+ 'error.noRemote': 'The repository has no origin remote.',
305
+ 'error.unsupportedRemote': 'Only https://github.com remotes are allowed; the operation was blocked.',
306
+ 'error.remoteHasCredentials': 'The remote URL embeds credentials; clean it up first.',
307
+ 'error.dirtyWorktree': 'The working tree has uncommitted changes, so the pull was refused.',
308
+ 'error.notFastForward': 'The branch cannot be fast-forwarded; resolve it by hand.',
309
+ 'error.noUpstream': 'The branch has no upstream.',
310
+ 'error.detachedHead': 'HEAD is detached, so there is nothing to push.',
311
+ 'error.nothingToCommit': 'There is nothing to commit.',
312
+ 'error.identityMissing': 'git user.name / user.email are not configured.',
313
+ 'error.pushRejected': 'The remote rejected the push.',
314
+ 'error.bodyTooLarge': 'The request body was too large.',
315
+ 'error.logoutFailed': 'Signing out failed.',
316
+ 'error.notSignedIn': 'Sign in with GitHub first.',
317
+ 'error.workflowScope':
318
+ 'The token has no workflow scope, so GitHub refused to push workflow files. Re-authorize, then push again.',
319
+ scopeMissingHint:
320
+ 'This authorization lacks the workflow scope, so pushes that touch .github/workflows are refused. Re-authorizing adds it; the account and local repositories are untouched.',
321
+ reauth: 'Re-authorize',
322
+ scopeMissingShort:
323
+ 'The token lacks the workflow scope, so pushing workflow files will fail. Re-authorize under Settings → GitHub.',
324
+ }
325
+
326
+ /** Host error code → locale key. Unknown codes fall back to the message. */
327
+ const CODE_KEYS = {
328
+ unauthorized: 'error.unauthorized',
329
+ connection: 'error.connection',
330
+ http: 'error.http',
331
+ 'missing-client-id': 'error.missingClientId',
332
+ 'invalid-client-id': 'error.invalidClientId',
333
+ 'device-flow-disabled': 'error.deviceFlowDisabled',
334
+ 'login-denied': 'error.loginDenied',
335
+ 'login-expired': 'error.loginExpired',
336
+ 'token-invalid': 'error.tokenInvalid',
337
+ 'rate-limited': 'error.rateLimited',
338
+ 'unsafe-path': 'error.unsafePath',
339
+ 'not-found': 'error.notFound',
340
+ 'invalid-repo-name': 'error.invalidRepoName',
341
+ 'invalid-owner': 'error.invalidOwner',
342
+ 'invalid-clone-root': 'error.invalidCloneRoot',
343
+ 'invalid-message': 'error.invalidMessage',
344
+ 'invalid-files': 'error.invalidFiles',
345
+ 'invalid-path': 'error.invalidPath',
346
+ 'clone-root-missing': 'error.cloneRootMissing',
347
+ 'not-a-repo': 'error.notARepo',
348
+ 'git-missing': 'error.gitMissing',
349
+ 'git-failed': 'error.gitFailed',
350
+ 'no-remote': 'error.noRemote',
351
+ 'unsupported-remote': 'error.unsupportedRemote',
352
+ 'remote-has-credentials': 'error.remoteHasCredentials',
353
+ 'dirty-worktree': 'error.dirtyWorktree',
354
+ 'not-fast-forward': 'error.notFastForward',
355
+ 'no-upstream': 'error.noUpstream',
356
+ 'detached-head': 'error.detachedHead',
357
+ 'nothing-to-commit': 'error.nothingToCommit',
358
+ 'identity-missing': 'error.identityMissing',
359
+ 'push-rejected': 'error.pushRejected',
360
+ 'workflow-scope-missing': 'error.workflowScope',
361
+ 'body-too-large': 'error.bodyTooLarge',
362
+ 'logout-failed': 'error.logoutFailed',
363
+ 'not-signed-in': 'error.notSignedIn',
364
+ 'session-not-found': 'error.sessionNotFound',
365
+ 'repo-elsewhere': 'error.repoElsewhere',
366
+ 'remote-exists': 'error.remoteExists',
367
+ 'target-exists': 'error.targetExists',
368
+ 'repo-nested': 'error.repoNested',
369
+ }
370
+
371
+ const CSS = `
372
+ .gh-section{display:flex;flex-direction:column;gap:16px;padding:8px 0}
373
+ .gh-header{display:flex;flex-direction:column;gap:4px}
374
+ .gh-heading{margin:0;font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary,inherit)}
375
+ .gh-hint{margin:0;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary,inherit)}
376
+ .gh-hint-small{font-size:11px}
377
+ .gh-banner{margin:0;padding:8px 12px;border-radius:var(--dsw-radius-md,8px);font-size:12px;line-height:1.5;word-break:break-word}
378
+ .gh-banner-error{background:var(--dsw-alias-bg-danger,rgba(229,72,77,.12));color:var(--dsw-alias-label-danger,#b42318)}
379
+ .gh-banner-ok{background:var(--dsw-alias-bg-success,rgba(46,160,67,.12));color:var(--dsw-alias-label-success,#17663a)}
380
+ .gh-banner-warn{background:var(--dsw-alias-bg-warning,rgba(212,167,44,.16));color:var(--dsw-alias-label-warning,#8a5d00)}
381
+ .gh-card{display:flex;flex-direction:column;gap:12px;padding:16px;border:1px solid var(--dsw-alias-settings-card-stroke,var(--dsw-alias-border-l1,rgba(0,0,0,.1)));border-radius:var(--dsw-radius-xl,12px);background:var(--dsw-alias-settings-card-fill,transparent)}
382
+ .gh-card-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
383
+ .gh-card-title{margin:0;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,inherit)}
384
+ .gh-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
385
+ .gh-btn{appearance:none;border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.14));background:transparent;color:var(--dsw-alias-label-primary,inherit);border-radius:var(--dsw-radius-md,8px);padding:6px 12px;font:inherit;font-size:12px;line-height:1.4;cursor:pointer}
386
+ .gh-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05))}
387
+ .gh-btn:disabled{opacity:.55;cursor:default}
388
+ .gh-btn-primary{border-color:transparent;background:var(--dsw-alias-button-primary-fill,#24292f);color:var(--dsw-alias-button-primary-label,#fff)}
389
+ .gh-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover-fill,var(--dsw-alias-button-primary-fill,#24292f));opacity:.92}
390
+ .gh-btn-danger{border-color:transparent;background:var(--dsw-alias-interactive-bg-hover-danger,rgba(229,72,77,.12));color:var(--dsw-alias-label-danger,#b42318)}
391
+ .gh-btn:focus-visible,.gh-input:focus-visible,.gh-check input:focus-visible,.gh-code:focus-visible{outline:2px solid var(--dsw-alias-focus-ring,var(--dsw-alias-button-primary-fill,#0969da));outline-offset:2px}
392
+ .gh-code{font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:20px;letter-spacing:.18em;padding:8px 12px;border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.05));color:var(--dsw-alias-label-primary,inherit);align-self:flex-start}
393
+ .gh-identity{display:flex;align-items:center;gap:10px}
394
+ .gh-avatar{width:32px;height:32px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:14px;font-weight:600;background:var(--dsw-alias-bg-layer-2,#24292f);color:var(--dsw-alias-label-primary,#fff);flex:none}
395
+ .gh-identity-text{display:flex;flex-direction:column;gap:2px;font-size:12px;color:var(--dsw-alias-label-primary,inherit)}
396
+ .gh-muted{font-size:12px;color:var(--dsw-alias-label-secondary,inherit);word-break:break-all}
397
+ .gh-field{display:flex;flex-direction:column;gap:4px}
398
+ .gh-label{font-size:12px;font-weight:500;color:var(--dsw-alias-label-primary,inherit)}
399
+ .gh-input{box-sizing:border-box;width:100%;padding:6px 10px;font:inherit;font-size:12px;color:var(--dsw-alias-label-primary,inherit);background:transparent;border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.14));border-radius:var(--dsw-radius-md,8px)}
400
+ .gh-check{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-primary,inherit)}
401
+ .gh-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}
402
+ .gh-item{display:flex;flex-direction:column;gap:6px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1));border-radius:var(--dsw-radius-md,8px)}
403
+ .gh-item-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
404
+ .gh-item-name{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary,inherit);word-break:break-all}
405
+ .gh-item-meta{display:flex;flex-wrap:wrap;gap:8px;font-size:11px;color:var(--dsw-alias-label-secondary,inherit)}
406
+ .gh-badge{font-size:10px;padding:1px 6px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.14));color:var(--dsw-alias-label-secondary,inherit)}
407
+ .gh-commit{margin-top:4px;padding-top:10px;border-top:1px dashed var(--dsw-alias-border-l1,rgba(0,0,0,.14));display:flex;flex-direction:column;gap:8px}
408
+ .gh-dock-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;appearance:none;border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.14));background:transparent;color:var(--dsw-alias-label-secondary,inherit);border-radius:999px;padding:3px 10px;font:inherit;font-size:11px;line-height:1.5;cursor:pointer;flex:none;white-space:nowrap;min-width:0}
409
+ .gh-dock-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05));color:var(--dsw-alias-label-primary,inherit)}
410
+ .gh-dock-btn:focus-visible{outline:2px solid var(--dsw-alias-focus-ring,var(--dsw-alias-button-primary-fill,#0969da));outline-offset:2px}
411
+ .gh-dock-dot{width:6px;height:6px;border-radius:50%;background:currentColor;opacity:.6;flex:none}
412
+ .gh-dock-icon{display:none;width:14px;height:14px;flex:none}
413
+ .gh-dock-label{white-space:nowrap}
414
+ /* Narrow screens (phone web UI): collapse the pill to a square icon so the
415
+ label can never stack into a vertical column. The label stays in the DOM,
416
+ visually hidden, so the button keeps its accessible name. */
417
+ @media (max-width:640px){
418
+ .gh-dock-btn{position:relative;box-sizing:border-box;gap:0;padding:5px;width:26px;height:26px}
419
+ .gh-dock-dot{display:none}
420
+ .gh-dock-icon{display:block}
421
+ .gh-dock-label{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}
422
+ .gh-overlay{padding:10px}
423
+ .gh-dialog{padding:12px;gap:10px;max-height:min(86vh,720px)}
424
+ }
425
+ .gh-overlay{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;padding:16px;background:rgba(0,0,0,.35)}
426
+ .gh-dialog{box-sizing:border-box;width:min(560px,100%);max-height:min(80vh,720px);overflow:auto;display:flex;flex-direction:column;gap:12px;padding:16px;border-radius:var(--dsw-radius-xl,12px);background:var(--dsw-alias-bg-overlay,var(--dsw-alias-bg-layer-1,#fff));color:var(--dsw-alias-label-primary,inherit);box-shadow:0 12px 40px rgba(0,0,0,.28)}
427
+ .gh-dialog-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
428
+ .gh-dialog-title{margin:0;font-size:14px;font-weight:600}
429
+ .gh-dialog-foot{display:flex;justify-content:flex-end;gap:8px}
430
+ .gh-path{font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:11px;color:var(--dsw-alias-label-secondary,inherit);word-break:break-all}
431
+ .gh-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-secondary,inherit)}
432
+ .gh-stack{display:flex;flex-direction:column;gap:10px;padding:12px;border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1));border-radius:var(--dsw-radius-md,8px)}
433
+ `
434
+
435
+ /**
436
+ * Call the Host route.
437
+ *
438
+ * Treats a non-2xx response or an `ok:false` envelope as a failure and
439
+ * carries the Host error code on the thrown error.
440
+ *
441
+ * @param {string} action - Host action name.
442
+ * @param {Record<string, unknown>} [payload] - Action payload.
443
+ * @returns {Promise<any>}
444
+ */
445
+ async function call(action, payload) {
446
+ /** @type {Response} */
447
+ let response
448
+ try {
449
+ response = await fetch(ROUTE, {
450
+ method: 'POST',
451
+ credentials: 'same-origin',
452
+ headers: { 'content-type': 'application/json' },
453
+ body: JSON.stringify({ ...(payload ?? {}), action }),
454
+ })
455
+ } catch {
456
+ throw fail('connection', 'request failed')
457
+ }
458
+ if (response.status === 401 || response.status === 403) throw fail('unauthorized', 'not authorized')
459
+ /** @type {any} */
460
+ let body = null
461
+ try {
462
+ body = await response.json()
463
+ } catch {
464
+ // A non-JSON body is handled by the status check below.
465
+ }
466
+ if (response.ok && body !== null && body.ok === true) return body.value
467
+ const error = body !== null && typeof body === 'object' ? body.error ?? {} : {}
468
+ const thrown = fail(typeof error.code === 'string' ? error.code : 'http', typeof error.message === 'string' ? error.message : 'request failed')
469
+ thrown.status = response.status
470
+ throw thrown
471
+ }
472
+
473
+ /**
474
+ * @param {string} code - Host error code.
475
+ * @param {string} message - Redacted Host message.
476
+ * @returns {Error & {code: string, status?: number}}
477
+ */
478
+ function fail(code, message) {
479
+ const error = new Error(message)
480
+ error.code = code
481
+ return error
482
+ }
483
+
484
+ /**
485
+ * Localize one thrown error, preferring the code and falling back to the
486
+ * (already redacted) Host message.
487
+ *
488
+ * @param {(key: string, params?: Record<string, unknown>) => string} t - Translator.
489
+ * @param {unknown} error - Thrown value.
490
+ * @returns {string}
491
+ */
492
+ function describeError(t, error) {
493
+ const code = error !== null && typeof error === 'object' && typeof error.code === 'string' ? error.code : ''
494
+ if (code === 'http') {
495
+ const status = error !== null && typeof error === 'object' ? error.status : undefined
496
+ return t('error.http', { status: status ?? '?' })
497
+ }
498
+ const key = CODE_KEYS[code]
499
+ if (key !== undefined) return t(key)
500
+ const message = error instanceof Error ? error.message : ''
501
+ if (message !== '' && message !== 'request failed') return t('error.detail', { message })
502
+ return t('error.generic')
503
+ }
504
+
505
+ /**
506
+ * Register the Client half.
507
+ *
508
+ * @param {any} ctx - Client root context.
509
+ */
510
+ function apply(ctx) {
511
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-github: dictionaries')
512
+
513
+ ctx.effect(() => {
514
+ const style = document.createElement('style')
515
+ style.dataset.dshPlugin = NS
516
+ style.textContent = CSS
517
+ document.head.appendChild(style)
518
+ return () => style.remove()
519
+ }, 'dsh-github: styles')
520
+
521
+ const boundT = ctx.locale.bind(NS)
522
+
523
+ /**
524
+ * The GitHub settings page.
525
+ *
526
+ * Everything it shows is returned by the Host: settings, sign-in state,
527
+ * repository lists. The access token itself is never part of a payload.
528
+ *
529
+ * @param {{t?: Function}} props - Slot props.
530
+ */
531
+ function GithubSection({ t }) {
532
+ const tr = typeof t === 'function' ? t : boundT
533
+ const [state, setState] = useState(null)
534
+ const [settingsForm, setSettingsForm] = useState(null)
535
+ const [remote, setRemote] = useState(null)
536
+ const [local, setLocal] = useState(null)
537
+ const [busy, setBusy] = useState('')
538
+ const [error, setError] = useState('')
539
+ const [notice, setNotice] = useState('')
540
+ const [signOutOpen, setSignOutOpen] = useState(false)
541
+ const [createForm, setCreateForm] = useState({ name: '', description: '', private: true, autoInit: true })
542
+ const [commitPath, setCommitPath] = useState('')
543
+ const [commitMessage, setCommitMessage] = useState('')
544
+ const [commitPush, setCommitPush] = useState(false)
545
+ const alive = useRef(true)
546
+
547
+ useEffect(() => {
548
+ alive.current = true
549
+ return () => {
550
+ alive.current = false
551
+ }
552
+ }, [])
553
+
554
+ const applyState = useCallback((value) => {
555
+ if (!alive.current) return
556
+ setState(value)
557
+ setSettingsForm((current) =>
558
+ current === null
559
+ ? {
560
+ clientId: value.settings.clientId,
561
+ cloneRoot: value.settings.cloneRoot,
562
+ agentTools: value.settings.agentTools,
563
+ }
564
+ : current,
565
+ )
566
+ }, [])
567
+
568
+ const loadRepos = useCallback(async () => {
569
+ setBusy('repos')
570
+ setError('')
571
+ try {
572
+ const value = await call('list_repos')
573
+ if (alive.current) setRemote(value)
574
+ } catch (failure) {
575
+ if (alive.current) setError(describeError(tr, failure))
576
+ } finally {
577
+ if (alive.current) setBusy('')
578
+ }
579
+ }, [tr])
580
+
581
+ /** Run one Host action with shared busy/error handling. */
582
+ const perform = useCallback(
583
+ async (label, work) => {
584
+ setBusy(label)
585
+ setError('')
586
+ setNotice('')
587
+ try {
588
+ await work()
589
+ } catch (failure) {
590
+ if (alive.current) setError(describeError(tr, failure))
591
+ } finally {
592
+ if (alive.current) setBusy('')
593
+ }
594
+ },
595
+ [tr],
596
+ )
597
+
598
+ const replaceLocal = useCallback((repo) => {
599
+ if (!alive.current) return
600
+ setLocal((current) =>
601
+ current === null
602
+ ? current
603
+ : {
604
+ ...current,
605
+ repos: current.repos.map((entry) => (entry.path === repo.path ? repo : entry)),
606
+ },
607
+ )
608
+ }, [])
609
+
610
+ const reloadLocal = useCallback(async () => {
611
+ try {
612
+ const value = await call('local_list')
613
+ if (alive.current) setLocal(value)
614
+ } catch {
615
+ // A missing root is reported by the list itself; nothing to say here.
616
+ }
617
+ }, [])
618
+
619
+ // Initial read of the Host state.
620
+ useEffect(() => {
621
+ let cancelled = false
622
+ ;(async () => {
623
+ try {
624
+ const value = await call('state')
625
+ if (!cancelled) applyState(value)
626
+ } catch (failure) {
627
+ if (!cancelled) setError(describeError(tr, failure))
628
+ }
629
+ })()
630
+ return () => {
631
+ cancelled = true
632
+ }
633
+ }, [applyState, tr])
634
+
635
+ const loginStatus = state !== null ? state.login.status : 'idle'
636
+ const signedIn = state !== null && state.auth.signedIn === true
637
+ const signedKey = signedIn ? state.auth.login : ''
638
+
639
+ // Poll while a device flow is pending.
640
+ useEffect(() => {
641
+ if (loginStatus !== 'pending' && loginStatus !== 'starting') return undefined
642
+ const timer = setInterval(async () => {
643
+ try {
644
+ const value = await call('login_status')
645
+ if (alive.current) applyState(value)
646
+ } catch (failure) {
647
+ if (alive.current) setError(describeError(tr, failure))
648
+ }
649
+ }, POLL_MS)
650
+ return () => clearInterval(timer)
651
+ }, [loginStatus, applyState, tr])
652
+
653
+ // Announce the settled sign-in state once per transition.
654
+ useEffect(() => {
655
+ if (loginStatus === 'authorized') setNotice(tr('signedInNotice'))
656
+ else if (loginStatus === 'cancelled') setNotice('')
657
+ }, [loginStatus, tr])
658
+
659
+ // Load repositories once the account is known, and reload when it changes.
660
+ useEffect(() => {
661
+ if (signedKey === '') {
662
+ setRemote(null)
663
+ return
664
+ }
665
+ if (remote === null) void loadRepos()
666
+ }, [signedKey, remote, loadRepos])
667
+
668
+ // Read local repositories whenever the clone root is known or changes.
669
+ const cloneRoot = state !== null ? state.settings.cloneRoot : ''
670
+ useEffect(() => {
671
+ if (cloneRoot === '') return undefined
672
+ let cancelled = false
673
+ ;(async () => {
674
+ try {
675
+ const value = await call('local_list')
676
+ if (!cancelled && alive.current) setLocal(value)
677
+ } catch {
678
+ // Reported through the list's own `exists` flag.
679
+ }
680
+ })()
681
+ return () => {
682
+ cancelled = true
683
+ }
684
+ }, [cloneRoot])
685
+
686
+ const signIn = () =>
687
+ perform('login', async () => {
688
+ const value = await call('login_start')
689
+ applyState(value)
690
+ })
691
+
692
+ const cancelSignIn = () =>
693
+ perform('cancel', async () => {
694
+ const value = await call('login_cancel')
695
+ applyState(value)
696
+ })
697
+
698
+ const signOut = () =>
699
+ perform('logout', async () => {
700
+ const value = await call('logout')
701
+ applyState(value)
702
+ setSignOutOpen(false)
703
+ setRemote(null)
704
+ setNotice(tr('signedOut'))
705
+ })
706
+
707
+ const saveSettingsForm = () =>
708
+ perform('save', async () => {
709
+ const value = await call('settings_save', settingsForm)
710
+ if (!alive.current) return
711
+ setState((current) => (current === null ? current : { ...current, settings: value.settings }))
712
+ setSettingsForm({
713
+ clientId: value.settings.clientId,
714
+ cloneRoot: value.settings.cloneRoot,
715
+ agentTools: value.settings.agentTools,
716
+ })
717
+ setNotice(value.toolsRestartRequired ? tr('restartRequired') : tr('saved'))
718
+ })
719
+
720
+ const cloneOne = (repo) =>
721
+ perform(`clone:${repo.fullName}`, async () => {
722
+ const value = await call('repo_clone', { fullName: repo.fullName })
723
+ if (alive.current) setNotice(tr('cloned', { path: value.path }))
724
+ await reloadLocal()
725
+ })
726
+
727
+ const createRepo = () =>
728
+ perform('create', async () => {
729
+ const name = createForm.name.trim()
730
+ if (name === '') throw fail('invalid-repo-name', 'name required')
731
+ const value = await call('repo_create', {
732
+ name,
733
+ description: createForm.description.trim(),
734
+ private: createForm.private,
735
+ autoInit: createForm.autoInit,
736
+ })
737
+ if (!alive.current) return
738
+ setNotice(tr('created', { fullName: value.repo.fullName }))
739
+ setCreateForm((current) => ({ ...current, name: '', description: '' }))
740
+ if (remote !== null) await loadRepos()
741
+ })
742
+
743
+ const pullOne = (repo) =>
744
+ perform(`pull:${repo.path}`, async () => {
745
+ const value = await call('repo_pull', { path: repo.path })
746
+ replaceLocal(value.repo)
747
+ setNotice(tr('pulled', { branch: value.repo.branch }))
748
+ })
749
+
750
+ const pushOne = (repo) =>
751
+ perform(`push:${repo.path}`, async () => {
752
+ const value = await call('repo_push', { path: repo.path })
753
+ replaceLocal(value.repo)
754
+ setNotice(tr('pushed', { branch: value.repo.branch }))
755
+ })
756
+
757
+ const commitOne = (repo) =>
758
+ perform(`commit:${repo.path}`, async () => {
759
+ const message = commitMessage.trim()
760
+ if (message === '') throw fail('invalid-message', 'message required')
761
+ const value = await call('repo_commit', { path: repo.path, message, push: commitPush })
762
+ replaceLocal(value.repo)
763
+ if (!alive.current) return
764
+ setCommitPath('')
765
+ setCommitMessage('')
766
+ setCommitPush(false)
767
+ setNotice(
768
+ value.pushed !== null && value.pushed !== undefined
769
+ ? tr('committedPushed', { short: value.commit.shortHash, branch: value.pushed.branch })
770
+ : tr('committed', { short: value.commit.shortHash }),
771
+ )
772
+ })
773
+
774
+ const initial = (login) => (typeof login === 'string' && login !== '' ? login.slice(0, 1).toUpperCase() : '?')
775
+
776
+ const relativePath = (path) =>
777
+ local !== null && path.startsWith(`${local.root}/`) ? path.slice(local.root.length + 1) : path
778
+
779
+ const button = (key, label, onClick, options = {}) =>
780
+ h(
781
+ 'button',
782
+ {
783
+ key,
784
+ type: 'button',
785
+ className: options.primary ? 'gh-btn gh-btn-primary' : options.danger ? 'gh-btn gh-btn-danger' : 'gh-btn',
786
+ disabled: Boolean(options.disabled) || busy !== '',
787
+ onClick,
788
+ },
789
+ label,
790
+ )
791
+
792
+ const banner = (key, text, kind) =>
793
+ h('p', { key, className: `gh-banner gh-banner-${kind}`, role: kind === 'error' ? 'alert' : 'status' }, text)
794
+
795
+ const renderAuth = () => {
796
+ const auth = state.auth
797
+ const login = state.login
798
+ const children = []
799
+
800
+ if (auth.signedIn) {
801
+ children.push(
802
+ h(
803
+ 'div',
804
+ { className: 'gh-identity', key: 'identity' },
805
+ h('span', { className: 'gh-avatar', 'aria-hidden': true }, initial(auth.login)),
806
+ h(
807
+ 'div',
808
+ { className: 'gh-identity-text' },
809
+ h('strong', null, `@${auth.login !== '' ? auth.login : '?'}`),
810
+ auth.name !== '' ? h('span', { className: 'gh-muted' }, auth.name) : null,
811
+ auth.scopes !== '' ? h('span', { className: 'gh-muted' }, tr('scopesValue', { scopes: auth.scopes })) : null,
812
+ auth.obtainedAt !== ''
813
+ ? h('span', { className: 'gh-muted' }, tr('obtainedAtValue', { when: auth.obtainedAt }))
814
+ : null,
815
+ ),
816
+ ),
817
+ )
818
+ if (auth.expired === true) children.push(banner('expired', tr('expired'), 'error'))
819
+ else if (auth.unverified === true) children.push(banner('unverified', tr('unverified'), 'error'))
820
+ const missing = Array.isArray(auth.missingScopes) ? auth.missingScopes : []
821
+ if (missing.length > 0 && auth.expired !== true) {
822
+ children.push(banner('scope-missing', tr('scopeMissingHint'), 'warn'))
823
+ }
824
+ children.push(
825
+ h(
826
+ 'div',
827
+ { className: 'gh-actions', key: 'actions' },
828
+ signOutOpen
829
+ ? h(
830
+ React.Fragment,
831
+ null,
832
+ button('confirm-out', busy === 'logout' ? tr('signOut') : tr('confirm'), signOut, { danger: true }),
833
+ button('back', tr('back'), () => setSignOutOpen(false), { disabled: busy === 'logout' }),
834
+ )
835
+ : h(
836
+ React.Fragment,
837
+ null,
838
+ button('sign-out', tr('signOut'), () => setSignOutOpen(true)),
839
+ auth.expired === true
840
+ ? button('again', tr('signIn'), signIn, { primary: true })
841
+ : missing.length > 0
842
+ ? button('reauth', tr('reauth'), signIn, { primary: true })
843
+ : null,
844
+ ),
845
+ ),
846
+ )
847
+ if (signOutOpen) children.push(h('p', { className: 'gh-hint gh-hint-small', key: 'confirm-hint' }, tr('signOutConfirm')))
848
+ } else if (login.status === 'pending') {
849
+ children.push(
850
+ h('p', { className: 'gh-hint', key: 'code-label' }, tr('enterCode')),
851
+ h('code', { className: 'gh-code', tabIndex: 0, key: 'code' }, login.userCode ?? ''),
852
+ h(
853
+ 'div',
854
+ { className: 'gh-actions', key: 'actions' },
855
+ isGithubUrl(login.verificationUri)
856
+ ? h(
857
+ 'a',
858
+ {
859
+ className: 'gh-btn gh-btn-primary',
860
+ href: login.verificationUri,
861
+ target: '_blank',
862
+ rel: 'noreferrer noopener',
863
+ key: 'open',
864
+ },
865
+ tr('openDevicePage'),
866
+ )
867
+ : null,
868
+ button(
869
+ 'copy',
870
+ tr('copyCode'),
871
+ () => {
872
+ copyText(login.userCode)
873
+ setNotice(tr('copied'))
874
+ },
875
+ { disabled: busy !== '' },
876
+ ),
877
+ button('cancel', tr('cancelSignIn'), cancelSignIn, { danger: true }),
878
+ ),
879
+ h('p', { className: 'gh-hint gh-hint-small', key: 'wait' }, tr('waiting')),
880
+ )
881
+ } else if (login.status === 'starting') {
882
+ children.push(h('p', { className: 'gh-hint', key: 'starting' }, tr('signingIn')))
883
+ } else {
884
+ if (login.status === 'failed' && typeof login.message === 'string') {
885
+ children.push(banner('login-failed', describeError(tr, fail(login.code ?? 'generic', login.message)), 'error'))
886
+ }
887
+ children.push(
888
+ h(
889
+ 'div',
890
+ { className: 'gh-actions', key: 'actions' },
891
+ button('sign-in', busy === 'login' ? tr('signingIn') : tr('signIn'), signIn, {
892
+ primary: true,
893
+ disabled: busy !== '',
894
+ }),
895
+ ),
896
+ )
897
+ if (state.settings.clientId === '') children.push(h('p', { className: 'gh-hint gh-hint-small', key: 'need-id' }, tr('clientIdHint')))
898
+ }
899
+
900
+ return h(
901
+ 'div',
902
+ { className: 'gh-card', key: 'auth' },
903
+ h('div', { className: 'gh-card-head' }, h('h3', { className: 'gh-card-title' }, tr('nav'))),
904
+ ...children,
905
+ )
906
+ }
907
+
908
+ const renderSettings = () => {
909
+ const form = settingsForm
910
+ if (form === null) return null
911
+ return h(
912
+ 'div',
913
+ { className: 'gh-card', key: 'settings' },
914
+ h('div', { className: 'gh-card-head' }, h('h3', { className: 'gh-card-title' }, tr('settingsTitle'))),
915
+ h(
916
+ 'label',
917
+ { className: 'gh-field' },
918
+ h('span', { className: 'gh-label' }, tr('clientIdLabel')),
919
+ h('input', {
920
+ type: 'text',
921
+ className: 'gh-input',
922
+ value: form.clientId,
923
+ spellCheck: false,
924
+ autoComplete: 'off',
925
+ placeholder: 'Iv1.0123456789abcdef',
926
+ onChange: (event) => {
927
+ const value = event.currentTarget.value
928
+ setSettingsForm((current) => (current === null ? current : { ...current, clientId: value }))
929
+ },
930
+ }),
931
+ h('span', { className: 'gh-hint gh-hint-small' }, tr('clientIdHint')),
932
+ ),
933
+ h(
934
+ 'label',
935
+ { className: 'gh-field' },
936
+ h('span', { className: 'gh-label' }, tr('cloneRootLabel')),
937
+ h('input', {
938
+ type: 'text',
939
+ className: 'gh-input',
940
+ value: form.cloneRoot,
941
+ spellCheck: false,
942
+ autoComplete: 'off',
943
+ onChange: (event) => {
944
+ const value = event.currentTarget.value
945
+ setSettingsForm((current) => (current === null ? current : { ...current, cloneRoot: value }))
946
+ },
947
+ }),
948
+ h('span', { className: 'gh-hint gh-hint-small' }, tr('cloneRootHint')),
949
+ ),
950
+ h(
951
+ 'label',
952
+ { className: 'gh-check' },
953
+ h('input', {
954
+ type: 'checkbox',
955
+ checked: form.agentTools,
956
+ onChange: (event) => {
957
+ const value = event.currentTarget.checked
958
+ setSettingsForm((current) => (current === null ? current : { ...current, agentTools: value }))
959
+ },
960
+ }),
961
+ h('span', null, tr('agentToolsLabel')),
962
+ ),
963
+ h('p', { className: 'gh-hint gh-hint-small' }, tr('agentToolsHint')),
964
+ h(
965
+ 'div',
966
+ { className: 'gh-actions' },
967
+ button('save', busy === 'save' ? tr('save') : tr('save'), saveSettingsForm, { primary: true }),
968
+ ),
969
+ h('p', { className: 'gh-hint gh-hint-small' }, tr('configFileValue', { path: state.settings.configFile })),
970
+ )
971
+ }
972
+
973
+ const renderRemote = () => {
974
+ if (!signedIn) return null
975
+ const children = [
976
+ h(
977
+ 'div',
978
+ { className: 'gh-card-head', key: 'head' },
979
+ h('h3', { className: 'gh-card-title' }, tr('reposTitle')),
980
+ h(
981
+ 'div',
982
+ { className: 'gh-actions' },
983
+ remote === null
984
+ ? button('load', busy === 'repos' ? tr('loading') : tr('loadRepos'), loadRepos)
985
+ : button('reload', tr('reload'), loadRepos),
986
+ ),
987
+ ),
988
+ ]
989
+ if (remote !== null) {
990
+ if (remote.repos.length === 0) children.push(h('p', { className: 'gh-hint', key: 'empty' }, tr('reposEmpty')))
991
+ else {
992
+ children.push(
993
+ h(
994
+ 'ul',
995
+ { className: 'gh-list', key: 'list' },
996
+ remote.repos.map((repo) =>
997
+ h(
998
+ 'li',
999
+ { className: 'gh-item', key: repo.fullName },
1000
+ h(
1001
+ 'div',
1002
+ { className: 'gh-item-head' },
1003
+ h('span', { className: 'gh-item-name' }, repo.fullName),
1004
+ button(
1005
+ `clone:${repo.fullName}`,
1006
+ busy === `clone:${repo.fullName}` ? tr('cloning') : tr('clone'),
1007
+ () => cloneOne(repo),
1008
+ { disabled: busy !== '' },
1009
+ ),
1010
+ ),
1011
+ h(
1012
+ 'div',
1013
+ { className: 'gh-item-meta' },
1014
+ h('span', { className: 'gh-badge' }, repo.private ? tr('private') : tr('public')),
1015
+ repo.description !== '' ? h('span', null, repo.description) : null,
1016
+ repo.updatedAt !== '' ? h('span', null, repo.updatedAt) : null,
1017
+ ),
1018
+ ),
1019
+ ),
1020
+ ),
1021
+ )
1022
+ if (remote.truncated) children.push(h('p', { className: 'gh-hint gh-hint-small', key: 'trunc' }, tr('reposTruncated')))
1023
+ }
1024
+ }
1025
+ children.push(
1026
+ h('div', { className: 'gh-field', key: 'create' },
1027
+ h('h4', { className: 'gh-card-title' }, tr('createTitle')),
1028
+ h('label', { className: 'gh-field' },
1029
+ h('span', { className: 'gh-label' }, tr('repoNameLabel')),
1030
+ h('input', {
1031
+ type: 'text',
1032
+ className: 'gh-input',
1033
+ value: createForm.name,
1034
+ placeholder: tr('repoNamePlaceholder'),
1035
+ spellCheck: false,
1036
+ autoComplete: 'off',
1037
+ onChange: (event) => {
1038
+ const value = event.currentTarget.value
1039
+ setCreateForm((current) => ({ ...current, name: value }))
1040
+ },
1041
+ }),
1042
+ ),
1043
+ h('label', { className: 'gh-field' },
1044
+ h('span', { className: 'gh-label' }, tr('repoDescriptionLabel')),
1045
+ h('input', {
1046
+ type: 'text',
1047
+ className: 'gh-input',
1048
+ value: createForm.description,
1049
+ placeholder: tr('repoDescriptionPlaceholder'),
1050
+ autoComplete: 'off',
1051
+ onChange: (event) => {
1052
+ const value = event.currentTarget.value
1053
+ setCreateForm((current) => ({ ...current, description: value }))
1054
+ },
1055
+ }),
1056
+ ),
1057
+ h('label', { className: 'gh-check' },
1058
+ h('input', {
1059
+ type: 'checkbox',
1060
+ checked: createForm.private,
1061
+ onChange: (event) => {
1062
+ const value = event.currentTarget.checked
1063
+ setCreateForm((current) => ({ ...current, private: value }))
1064
+ },
1065
+ }),
1066
+ h('span', null, tr('createPrivateLabel')),
1067
+ ),
1068
+ h('label', { className: 'gh-check' },
1069
+ h('input', {
1070
+ type: 'checkbox',
1071
+ checked: createForm.autoInit,
1072
+ onChange: (event) => {
1073
+ const value = event.currentTarget.checked
1074
+ setCreateForm((current) => ({ ...current, autoInit: value }))
1075
+ },
1076
+ }),
1077
+ h('span', null, tr('createAutoInitLabel')),
1078
+ ),
1079
+ h('div', { className: 'gh-actions' },
1080
+ button('create', busy === 'create' ? tr('creating') : tr('create'), createRepo, { primary: true }),
1081
+ ),
1082
+ ),
1083
+ )
1084
+ return h('div', { className: 'gh-card', key: 'remote' }, ...children)
1085
+ }
1086
+
1087
+ const renderLocal = () => {
1088
+ const children = [
1089
+ h(
1090
+ 'div',
1091
+ { className: 'gh-card-head', key: 'head' },
1092
+ h('h3', { className: 'gh-card-title' }, tr('localTitle')),
1093
+ h('div', { className: 'gh-actions' }, button('reload-local', tr('reload'), reloadLocal)),
1094
+ ),
1095
+ ]
1096
+ if (local === null) children.push(h('p', { className: 'gh-hint', key: 'loading' }, tr('localLoading')))
1097
+ else if (local.exists !== true) children.push(h('p', { className: 'gh-hint', key: 'missing' }, tr('localMissing', { root: local.root })))
1098
+ else if (local.repos.length === 0) children.push(h('p', { className: 'gh-hint', key: 'empty' }, tr('localEmpty')))
1099
+ else {
1100
+ children.push(
1101
+ h(
1102
+ 'ul',
1103
+ { className: 'gh-list', key: 'list' },
1104
+ local.repos.map((repo) => {
1105
+ const dir = relativePath(repo.path)
1106
+ const meta = []
1107
+ if (repo.error !== undefined) {
1108
+ meta.push(h('span', { className: 'gh-badge', key: 'err' }, tr('statusError', { code: repo.error })))
1109
+ } else {
1110
+ meta.push(h('span', { className: 'gh-badge', key: 'branch' }, repo.detached ? tr('detachedHead') : repo.branch || '—'))
1111
+ meta.push(
1112
+ h(
1113
+ 'span',
1114
+ { className: 'gh-badge', key: 'changes' },
1115
+ repo.clean ? tr('clean') : tr('dirty', { count: repo.changeCount }),
1116
+ ),
1117
+ )
1118
+ if (repo.upstream) meta.push(h('span', { className: 'gh-badge', key: 'up' }, repo.upstream))
1119
+ else meta.push(h('span', { className: 'gh-badge', key: 'noup' }, tr('noUpstream')))
1120
+ if (repo.ahead > 0) meta.push(h('span', { className: 'gh-badge', key: 'ahead' }, tr('ahead', { count: repo.ahead })))
1121
+ if (repo.behind > 0) meta.push(h('span', { className: 'gh-badge', key: 'behind' }, tr('behind', { count: repo.behind })))
1122
+ }
1123
+ return h(
1124
+ 'li',
1125
+ { className: 'gh-item', key: repo.path },
1126
+ h(
1127
+ 'div',
1128
+ { className: 'gh-item-head' },
1129
+ h('span', { className: 'gh-item-name' }, dir),
1130
+ h(
1131
+ 'div',
1132
+ { className: 'gh-actions' },
1133
+ button(`pull:${repo.path}`, busy === `pull:${repo.path}` ? tr('pulling') : tr('pull'), () => pullOne(repo), {
1134
+ disabled: busy !== '',
1135
+ }),
1136
+ button(`push:${repo.path}`, busy === `push:${repo.path}` ? tr('pushing') : tr('push'), () => pushOne(repo), {
1137
+ disabled: busy !== '',
1138
+ }),
1139
+ button(
1140
+ `commit-open:${repo.path}`,
1141
+ tr('commit'),
1142
+ () => {
1143
+ setCommitPath(commitPath === repo.path ? '' : repo.path)
1144
+ setCommitMessage('')
1145
+ },
1146
+ { disabled: busy !== '' },
1147
+ ),
1148
+ ),
1149
+ ),
1150
+ h('div', { className: 'gh-item-meta' }, ...meta),
1151
+ repo.lastCommit !== null && repo.lastCommit !== undefined
1152
+ ? h(
1153
+ 'span',
1154
+ { className: 'gh-muted' },
1155
+ `${repo.lastCommit.shortHash} ${repo.lastCommit.subject} — ${repo.lastCommit.author}`,
1156
+ )
1157
+ : h('span', { className: 'gh-muted' }, tr('noCommit')),
1158
+ commitPath === repo.path
1159
+ ? h(
1160
+ 'div',
1161
+ { className: 'gh-commit' },
1162
+ h('label', { className: 'gh-field' },
1163
+ h('span', { className: 'gh-label' }, tr('commitMessageLabel')),
1164
+ h('input', {
1165
+ type: 'text',
1166
+ className: 'gh-input',
1167
+ value: commitMessage,
1168
+ placeholder: tr('commitMessagePlaceholder'),
1169
+ autoComplete: 'off',
1170
+ onChange: (event) => setCommitMessage(event.currentTarget.value),
1171
+ }),
1172
+ ),
1173
+ h('label', { className: 'gh-check' },
1174
+ h('input', {
1175
+ type: 'checkbox',
1176
+ checked: commitPush,
1177
+ onChange: (event) => setCommitPush(event.currentTarget.checked),
1178
+ }),
1179
+ h('span', null, tr('commitPushLabel')),
1180
+ ),
1181
+ h('div', { className: 'gh-actions' },
1182
+ button(
1183
+ `commit:${repo.path}`,
1184
+ busy === `commit:${repo.path}` ? tr('committing') : tr('commit'),
1185
+ () => commitOne(repo),
1186
+ { primary: true, disabled: busy !== '' },
1187
+ ),
1188
+ button('commit-cancel', tr('cancel'), () => setCommitPath(''), { disabled: busy !== '' }),
1189
+ ),
1190
+ )
1191
+ : null,
1192
+ )
1193
+ }),
1194
+ ),
1195
+ )
1196
+ if (local.truncated === true) {
1197
+ children.push(h('p', { className: 'gh-hint gh-hint-small', key: 'trunc' }, tr('localTruncated', { count: local.repos.length })))
1198
+ }
1199
+ }
1200
+ return h('div', { className: 'gh-card', key: 'local' }, ...children)
1201
+ }
1202
+
1203
+ const body = []
1204
+ if (state !== null) {
1205
+ body.push(renderAuth(), renderSettings(), renderRemote(), renderLocal())
1206
+ }
1207
+
1208
+ return h(
1209
+ 'section',
1210
+ { className: 'gh-section', 'aria-label': tr('nav') },
1211
+ h(
1212
+ 'div',
1213
+ { className: 'gh-header' },
1214
+ h('h2', { className: 'gh-heading' }, tr('nav')),
1215
+ h('p', { className: 'gh-hint' }, tr('intro')),
1216
+ ),
1217
+ error !== '' ? banner('error', error, 'error') : null,
1218
+ notice !== '' ? banner('notice', notice, 'ok') : null,
1219
+ state === null ? h('p', { className: 'gh-hint' }, tr('loading')) : body,
1220
+ )
1221
+ }
1222
+
1223
+ /**
1224
+ * Composer-dock entry: git actions for the current session folder.
1225
+ *
1226
+ * The Client never sends a path: it sends `sessionId`, and the Host
1227
+ * resolves the committed working directory itself.
1228
+ *
1229
+ * @param {{sessionId?: string, t?: Function}} props - Dock slot props.
1230
+ */
1231
+ function GithubDock({ sessionId, t }) {
1232
+ const tr = typeof t === 'function' ? t : boundT
1233
+ const [open, setOpen] = useState(false)
1234
+ const [snapshot, setSnapshot] = useState(null)
1235
+ const [auth, setAuth] = useState(null)
1236
+ const [busy, setBusy] = useState('')
1237
+ const [error, setError] = useState('')
1238
+ const [notice, setNotice] = useState('')
1239
+ const [cloneName, setCloneName] = useState('')
1240
+ const [publishName, setPublishName] = useState('')
1241
+ const [publishMessage, setPublishMessage] = useState('Initial commit')
1242
+ const [publishPrivate, setPublishPrivate] = useState(true)
1243
+ const [commitMessage, setCommitMessage] = useState('')
1244
+ const [commitPush, setCommitPush] = useState(true)
1245
+ const [showCommit, setShowCommit] = useState(false)
1246
+ const alive = useRef(true)
1247
+ const dialog = useRef(null)
1248
+ const banner = (key, text, kind) =>
1249
+ h('p', { key, className: `gh-banner gh-banner-${kind}`, role: kind === 'error' ? 'alert' : 'status' }, text)
1250
+
1251
+ useEffect(() => {
1252
+ alive.current = true
1253
+ return () => {
1254
+ alive.current = false
1255
+ }
1256
+ }, [])
1257
+
1258
+ useEffect(() => {
1259
+ if (open) {
1260
+ if (typeof dialog.current?.focus === 'function') {
1261
+ try {
1262
+ dialog.current.focus()
1263
+ } catch {
1264
+ // Focusing is a convenience only.
1265
+ }
1266
+ }
1267
+ }
1268
+ }, [open])
1269
+
1270
+ const applyStatus = useCallback((value) => {
1271
+ if (!alive.current || value === null || typeof value !== 'object') return
1272
+ setSnapshot(value)
1273
+ }, [])
1274
+
1275
+ /** Run one Host action with shared busy/error handling. */
1276
+ const run = useCallback(
1277
+ async (label, action, payload) => {
1278
+ setBusy(label)
1279
+ setError('')
1280
+ setNotice('')
1281
+ try {
1282
+ return await call(action, payload)
1283
+ } catch (failure) {
1284
+ if (alive.current) setError(describeError(tr, failure))
1285
+ return null
1286
+ } finally {
1287
+ if (alive.current) setBusy('')
1288
+ }
1289
+ },
1290
+ [tr],
1291
+ )
1292
+
1293
+ const refresh = useCallback(async () => {
1294
+ const status = await run('refresh', 'session_status', { sessionId })
1295
+ if (status !== null) applyStatus(status)
1296
+ const state = await run('auth', 'state')
1297
+ if (state !== null && alive.current) setAuth(state.auth)
1298
+ }, [applyStatus, run, sessionId])
1299
+
1300
+ useEffect(() => {
1301
+ if (!open) return undefined
1302
+ refresh()
1303
+ return undefined
1304
+ }, [open, refresh])
1305
+
1306
+ if (typeof sessionId !== 'string' || sessionId === '') return null
1307
+
1308
+ // An expired token is still "signed in" on the Host, but every remote
1309
+ // action would fail with token-invalid; treat it as signed out here so
1310
+ // the panel offers the sign-in hint instead of broken buttons.
1311
+ const signedIn = auth !== null && auth.signedIn === true && auth.expired !== true
1312
+ const repo = snapshot !== null ? snapshot.repo : null
1313
+ const busyLabel = busy
1314
+ const disabled = busy !== ''
1315
+
1316
+ const doClone = async () => {
1317
+ const value = await run('clone', 'session_clone', { sessionId, fullName: cloneName })
1318
+ if (value === null) return
1319
+ applyStatus({ path: value.path, repoRoot: value.path, repo: value.repo })
1320
+ setCloneName('')
1321
+ setNotice(tr('cloned', { path: value.path }))
1322
+ }
1323
+
1324
+ const doPublish = async () => {
1325
+ const value = await run('publish', 'session_publish', {
1326
+ sessionId,
1327
+ name: publishName,
1328
+ message: publishMessage,
1329
+ private: publishPrivate,
1330
+ })
1331
+ if (value === null) return
1332
+ applyStatus({ path: value.path, repoRoot: value.path, repo: value.repo })
1333
+ setPublishName('')
1334
+ setNotice(tr('sessionPublished', { fullName: value.remote.fullName }))
1335
+ }
1336
+
1337
+ const doPull = async () => {
1338
+ const value = await run('pull', 'session_pull', { sessionId })
1339
+ if (value === null) return
1340
+ applyStatus({ path: value.path, repoRoot: snapshot !== null ? snapshot.repoRoot : value.path, repo: value.repo })
1341
+ setNotice(tr('pulled', { branch: value.repo.branch }))
1342
+ }
1343
+
1344
+ const doFetch = async () => {
1345
+ const value = await run('fetch', 'session_fetch', { sessionId })
1346
+ if (value === null) return
1347
+ applyStatus({ path: value.path, repoRoot: snapshot !== null ? snapshot.repoRoot : value.path, repo: value.repo })
1348
+ setNotice(tr('sessionFetched'))
1349
+ }
1350
+
1351
+ const doCommit = async () => {
1352
+ const value = await run('commit', 'session_commit', {
1353
+ sessionId,
1354
+ message: commitMessage,
1355
+ push: commitPush,
1356
+ })
1357
+ if (value === null) return
1358
+ applyStatus({ path: value.path, repoRoot: snapshot !== null ? snapshot.repoRoot : value.path, repo: value.repo })
1359
+ setShowCommit(false)
1360
+ setCommitMessage('')
1361
+ if (value.pushed !== null) {
1362
+ setNotice(tr('committedPushed', { short: value.commit !== null ? value.commit.shortHash : '', branch: value.pushed.branch }))
1363
+ } else {
1364
+ setNotice(tr('committed', { short: value.commit !== null ? value.commit.shortHash : '' }))
1365
+ }
1366
+ }
1367
+
1368
+ const doPush = async () => {
1369
+ const value = await run('push', 'session_push', { sessionId })
1370
+ if (value === null) return
1371
+ applyStatus({ path: value.path, repoRoot: snapshot !== null ? snapshot.repoRoot : value.path, repo: value.repo })
1372
+ setNotice(tr('pushed', { branch: value.pushed.branch }))
1373
+ }
1374
+
1375
+ const pill = h(
1376
+ 'button',
1377
+ {
1378
+ type: 'button',
1379
+ className: 'gh-dock-btn',
1380
+ title: tr('dockTitle'),
1381
+ 'aria-label': tr('dockTitle'),
1382
+ 'aria-haspopup': 'dialog',
1383
+ 'aria-expanded': open ? 'true' : 'false',
1384
+ onClick: () => setOpen(true),
1385
+ },
1386
+ h('span', { className: 'gh-dock-dot', 'aria-hidden': true }),
1387
+ h(
1388
+ 'svg',
1389
+ {
1390
+ className: 'gh-dock-icon',
1391
+ viewBox: '0 0 16 16',
1392
+ width: 14,
1393
+ height: 14,
1394
+ 'aria-hidden': true,
1395
+ focusable: 'false',
1396
+ },
1397
+ h('path', {
1398
+ d: 'M5 4.6v6.8',
1399
+ fill: 'none',
1400
+ stroke: 'currentColor',
1401
+ strokeWidth: 1.4,
1402
+ strokeLinecap: 'round',
1403
+ }),
1404
+ h('path', {
1405
+ d: 'M5 7.2h2.5a2.4 2.4 0 0 1 2.4 2.4v.6',
1406
+ fill: 'none',
1407
+ stroke: 'currentColor',
1408
+ strokeWidth: 1.4,
1409
+ strokeLinecap: 'round',
1410
+ }),
1411
+ h('circle', { cx: 5, cy: 3, r: 1.5, fill: 'none', stroke: 'currentColor', strokeWidth: 1.4 }),
1412
+ h('circle', { cx: 10, cy: 11.6, r: 1.5, fill: 'none', stroke: 'currentColor', strokeWidth: 1.4 }),
1413
+ ),
1414
+ h('span', { className: 'gh-dock-label' }, 'GitHub'),
1415
+ )
1416
+
1417
+ if (!open) return pill
1418
+
1419
+ const body = []
1420
+ if (snapshot !== null) {
1421
+ body.push(
1422
+ h(
1423
+ 'div',
1424
+ { className: 'gh-row', key: 'path' },
1425
+ h('span', null, `${tr('sessionPathLabel')}: `),
1426
+ h('span', { className: 'gh-path' }, snapshot.path),
1427
+ ),
1428
+ )
1429
+ if (repo !== null) {
1430
+ const parts = []
1431
+ parts.push(h('span', { className: 'gh-badge', key: 'branch' }, repo.detached === true ? tr('detachedHead') : repo.branch))
1432
+ parts.push(
1433
+ h('span', { key: 'clean' }, repo.clean === true ? tr('clean') : tr('dirty', { count: repo.changeCount })),
1434
+ )
1435
+ if (repo.ahead > 0) parts.push(h('span', { key: 'ahead' }, tr('ahead', { count: repo.ahead })))
1436
+ if (repo.behind > 0) parts.push(h('span', { key: 'behind' }, tr('behind', { count: repo.behind })))
1437
+ if (repo.remote !== null) parts.push(h('span', { className: 'gh-path', key: 'remote' }, repo.remote.url))
1438
+ body.push(h('div', { className: 'gh-row', key: 'status' }, ...parts))
1439
+ if (repo.lastCommit !== null) {
1440
+ body.push(
1441
+ h(
1442
+ 'div',
1443
+ { className: 'gh-row', key: 'last' },
1444
+ h('span', { className: 'gh-path' }, `${repo.lastCommit.shortHash} ${repo.lastCommit.subject}`),
1445
+ ),
1446
+ )
1447
+ }
1448
+ }
1449
+ } else {
1450
+ body.push(h('p', { className: 'gh-hint', key: 'loading' }, tr('loading')))
1451
+ }
1452
+
1453
+ if (signedIn !== true) body.push(h('p', { className: 'gh-hint', key: 'signin' }, tr('sessionSignInHint')))
1454
+
1455
+ const scopes = auth !== null && Array.isArray(auth.missingScopes) ? auth.missingScopes : []
1456
+ if (signedIn === true && scopes.length > 0) {
1457
+ body.push(h('p', { className: 'gh-banner gh-banner-warn', key: 'scope' }, tr('scopeMissingShort')))
1458
+ }
1459
+
1460
+ if (repo !== null && signedIn) {
1461
+ body.push(
1462
+ h(
1463
+ 'div',
1464
+ { className: 'gh-actions', key: 'actions' },
1465
+ h('button', { type: 'button', className: 'gh-btn', disabled, onClick: refresh }, tr('sessionRefresh')),
1466
+ h('button', { type: 'button', className: 'gh-btn', disabled, onClick: doFetch }, busy === 'fetch' ? tr('sessionFetching') : tr('sessionFetch')),
1467
+ h('button', { type: 'button', className: 'gh-btn', disabled, onClick: doPull }, busy === 'pull' ? tr('pulling') : tr('pull')),
1468
+ h('button', { type: 'button', className: 'gh-btn', disabled, onClick: () => setShowCommit((value) => !value) }, tr('commit')),
1469
+ h('button', { type: 'button', className: 'gh-btn', disabled, onClick: doPush }, busy === 'push' ? tr('pushing') : tr('push')),
1470
+ ),
1471
+ )
1472
+ if (showCommit) {
1473
+ body.push(
1474
+ h(
1475
+ 'div',
1476
+ { className: 'gh-stack', key: 'commit-form' },
1477
+ h(
1478
+ 'label',
1479
+ { className: 'gh-field' },
1480
+ h('span', { className: 'gh-label' }, tr('commitMessageLabel')),
1481
+ h('input', {
1482
+ className: 'gh-input',
1483
+ value: commitMessage,
1484
+ placeholder: tr('commitMessagePlaceholder'),
1485
+ onChange: (event) => setCommitMessage(event.target.value),
1486
+ }),
1487
+ ),
1488
+ h(
1489
+ 'label',
1490
+ { className: 'gh-check' },
1491
+ h('input', { type: 'checkbox', checked: commitPush, onChange: (event) => setCommitPush(event.target.checked) }),
1492
+ h('span', null, tr('commitPushLabel')),
1493
+ ),
1494
+ h(
1495
+ 'div',
1496
+ { className: 'gh-actions' },
1497
+ h(
1498
+ 'button',
1499
+ { type: 'button', className: 'gh-btn gh-btn-primary', disabled: disabled || commitMessage === '', onClick: doCommit },
1500
+ busy === 'commit' ? tr('committing') : tr('commit'),
1501
+ ),
1502
+ ),
1503
+ ),
1504
+ )
1505
+ }
1506
+ }
1507
+
1508
+ if (repo === null && snapshot !== null && signedIn) {
1509
+ const preview = cloneName.includes('/') ? cloneName.split('/').pop() : cloneName
1510
+ body.push(
1511
+ h(
1512
+ 'div',
1513
+ { className: 'gh-stack', key: 'clone' },
1514
+ h('h3', { className: 'gh-dialog-title' }, tr('sessionCloneTitle')),
1515
+ h(
1516
+ 'label',
1517
+ { className: 'gh-field' },
1518
+ h('span', { className: 'gh-label' }, tr('sessionFullNameLabel')),
1519
+ h('input', {
1520
+ className: 'gh-input',
1521
+ value: cloneName,
1522
+ placeholder: tr('sessionFullNamePlaceholder'),
1523
+ onChange: (event) => setCloneName(event.target.value),
1524
+ }),
1525
+ ),
1526
+ preview !== '' ? h('p', { className: 'gh-hint gh-hint-small' }, tr('sessionCloneHint', { path: `${snapshot.path}/${preview}` })) : null,
1527
+ h('p', { className: 'gh-hint gh-hint-small' }, tr('sessionNotRepo')),
1528
+ h(
1529
+ 'div',
1530
+ { className: 'gh-actions' },
1531
+ h(
1532
+ 'button',
1533
+ { type: 'button', className: 'gh-btn gh-btn-primary', disabled: disabled || cloneName === '', onClick: doClone },
1534
+ busy === 'clone' ? tr('cloning') : tr('clone'),
1535
+ ),
1536
+ ),
1537
+ ),
1538
+ )
1539
+ }
1540
+
1541
+ if (repo === null && snapshot !== null && signedIn) {
1542
+ body.push(
1543
+ h(
1544
+ 'div',
1545
+ { className: 'gh-stack', key: 'publish' },
1546
+ h('h3', { className: 'gh-dialog-title' }, tr('sessionPublishTitle')),
1547
+ h('p', { className: 'gh-hint gh-hint-small' }, tr('sessionPublishHint')),
1548
+ h(
1549
+ 'label',
1550
+ { className: 'gh-field' },
1551
+ h('span', { className: 'gh-label' }, tr('repoNameLabel')),
1552
+ h('input', {
1553
+ className: 'gh-input',
1554
+ value: publishName,
1555
+ placeholder: tr('repoNamePlaceholder'),
1556
+ onChange: (event) => setPublishName(event.target.value),
1557
+ }),
1558
+ ),
1559
+ h(
1560
+ 'label',
1561
+ { className: 'gh-field' },
1562
+ h('span', { className: 'gh-label' }, tr('sessionPublishMessageLabel')),
1563
+ h('input', {
1564
+ className: 'gh-input',
1565
+ value: publishMessage,
1566
+ onChange: (event) => setPublishMessage(event.target.value),
1567
+ }),
1568
+ ),
1569
+ h(
1570
+ 'label',
1571
+ { className: 'gh-check' },
1572
+ h('input', { type: 'checkbox', checked: publishPrivate, onChange: (event) => setPublishPrivate(event.target.checked) }),
1573
+ h('span', null, tr('createPrivateLabel')),
1574
+ ),
1575
+ h(
1576
+ 'div',
1577
+ { className: 'gh-actions' },
1578
+ h(
1579
+ 'button',
1580
+ { type: 'button', className: 'gh-btn gh-btn-primary', disabled: disabled || publishName === '', onClick: doPublish },
1581
+ busy === 'publish' ? tr('sessionPublishing') : tr('sessionPublishButton'),
1582
+ ),
1583
+ ),
1584
+ ),
1585
+ )
1586
+ }
1587
+
1588
+ return h(
1589
+ 'div',
1590
+ null,
1591
+ pill,
1592
+ h(
1593
+ 'div',
1594
+ {
1595
+ className: 'gh-overlay',
1596
+ onClick: (event) => {
1597
+ if (event.target === event.currentTarget) setOpen(false)
1598
+ },
1599
+ },
1600
+ h(
1601
+ 'div',
1602
+ {
1603
+ className: 'gh-dialog',
1604
+ role: 'dialog',
1605
+ 'aria-modal': 'true',
1606
+ 'aria-label': tr('dockTitle'),
1607
+ tabIndex: -1,
1608
+ ref: dialog,
1609
+ onKeyDown: (event) => {
1610
+ if (event.key === 'Escape') setOpen(false)
1611
+ },
1612
+ },
1613
+ h(
1614
+ 'div',
1615
+ { className: 'gh-dialog-head' },
1616
+ h('h2', { className: 'gh-dialog-title' }, tr('dockTitle')),
1617
+ h(
1618
+ 'button',
1619
+ { type: 'button', className: 'gh-btn', onClick: () => setOpen(false) },
1620
+ tr('sessionClose'),
1621
+ ),
1622
+ ),
1623
+ h('p', { className: 'gh-hint gh-hint-small' }, tr('dockHint')),
1624
+ error !== '' ? banner('error', error, 'error') : null,
1625
+ notice !== '' ? banner('notice', notice, 'ok') : null,
1626
+ ...body,
1627
+ h(
1628
+ 'div',
1629
+ { className: 'gh-dialog-foot' },
1630
+ h('button', { type: 'button', className: 'gh-btn', onClick: () => setOpen(false) }, tr('sessionClose')),
1631
+ ),
1632
+ ),
1633
+ ),
1634
+ )
1635
+ }
1636
+
1637
+ ctx.slots.inject(SECTION_SLOT, () =>
1638
+ ctx.slots.register(
1639
+ {
1640
+ name: SECTION_SLOT,
1641
+ id: NS,
1642
+ order: 60,
1643
+ label: () => boundT('nav'),
1644
+ locale: NS,
1645
+ },
1646
+ GithubSection,
1647
+ ),
1648
+ )
1649
+
1650
+ ctx.slots.inject(DOCK_SLOT, () =>
1651
+ ctx.slots.register(
1652
+ {
1653
+ name: DOCK_SLOT,
1654
+ id: NS,
1655
+ order: 70,
1656
+ locale: NS,
1657
+ },
1658
+ GithubDock,
1659
+ ),
1660
+ )
1661
+ }
1662
+
1663
+ /**
1664
+ * Only GitHub's own https page may become a link in the UI.
1665
+ *
1666
+ * @param {unknown} value - URL from a Host payload.
1667
+ * @returns {boolean}
1668
+ */
1669
+ function isGithubUrl(value) {
1670
+ return typeof value === 'string' && /^https:\/\/github\.com\/login\/device\/?$/.test(value)
1671
+ }
1672
+
1673
+ /**
1674
+ * Copy the device code, tolerating a missing clipboard API.
1675
+ *
1676
+ * @param {unknown} value - Text to copy.
1677
+ */
1678
+ function copyText(value) {
1679
+ if (typeof value !== 'string' || value === '') return
1680
+ const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined
1681
+ if (clipboard === undefined || typeof clipboard.writeText !== 'function') return
1682
+ clipboard.writeText(value).catch(() => {
1683
+ // Copying is a convenience, never a requirement.
1684
+ })
1685
+ }
1686
+
1687
+ return { inject: ['slots', 'locale'], apply }
1688
+ },
1689
+ })