@young1lin/dsh-ui-gitworkbench 0.1.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.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Browser half of @young1lin/dsh-ui-gitworkbench.
3
+ *
4
+ * Contributes one entry to the `conversation.session.header.actions` list slot:
5
+ * a compact git-workbench chip. Clicking it opens a right-side drawer with the
6
+ * file list and per-file diff. The data is fetched from the host's
7
+ * `gitWorkbench/stats` Remote method through the generic connection RPC channel;
8
+ * `gitWorkbench/sessionWorktree` reports the session's worktree binding so the
9
+ * chip can badge it and the drawer can switch between bound/main sources;
10
+ * `gitWorkbench/styleGet` and `styleSet` carry the per-project and global drawer
11
+ * styling, which lives on the host rather than in this browser.
12
+ *
13
+ * The fetch callback is handed to the component through the slot registration's
14
+ * `inject` face (the dsh pattern: business callbacks cross from apply-scope to
15
+ * component via the inject factory, never through a global ctx).
16
+ */
17
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
18
+ import type {} from '@deepseek-ai/dsh-client-runtime' // informational inject edge (loading order)
19
+ import type {} from '@deepseek-ai/dsh-client-ui-slots' // SlotMap is reused, not extended
20
+ import {
21
+ GitWorkbenchPanel,
22
+ type GitCommit, type GitOpName, type GitOpPayload, type GitOpResult,
23
+ type WorkbenchStats, type SyncStatus, type WorktreeStatus,
24
+ } from './GitWorkbenchPanel.tsx'
25
+ import type { StyleEntry, StyleScope, StyleSettings } from './themes.ts'
26
+ import { en, zh } from './locales.ts'
27
+
28
+ /**
29
+ * This plugin's dictionary namespace. It is outside dsh's `LocaleNamespaceMap`
30
+ * merge table (out-of-tree plugin), so registration uses the single-locale
31
+ * untyped `register` overload provided for that case.
32
+ */
33
+ const NS = 'gitworkbench'
34
+
35
+ /** Required client services: sessions store + slot registry + connection RPC + locale. */
36
+ export const inject = ['sessions', 'slots', 'connection', 'locale']
37
+
38
+ export function apply(ctx: ClientContext): void {
39
+ // `connection` is a root service; reach it off the root context the apply runs under.
40
+ const connection = (ctx as unknown as { connection: { rpc: { call: (channel: string, endpoint: string, payload: unknown, signal: AbortSignal) => Promise<unknown> } } }).connection
41
+ const locale = (ctx as unknown as {
42
+ locale: { register: (ns: string, id: string, dict: Record<string, string>) => () => void }
43
+ }).locale
44
+
45
+ // One effect per locale: each register call returns its own disposer, and the
46
+ // runtime rejects re-registering a locale a namespace already has (so a reload
47
+ // that failed to dispose fails loudly rather than silently keeping stale copy).
48
+ ctx.effect(() => locale.register(NS, 'zh', zh), 'dsh-ui-gitworkbench: zh dictionary')
49
+ ctx.effect(() => locale.register(NS, 'en', en), 'dsh-ui-gitworkbench: en dictionary')
50
+
51
+ ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register(
52
+ {
53
+ name: 'conversation.session.header.actions',
54
+ id: 'git-workbench',
55
+ // After the job-list action (order 20); a low-key stats chip.
56
+ order: 30,
57
+ // Declaring the namespace is what puts the framework's `t` in the
58
+ // component's props — it follows the user's language preference.
59
+ locale: NS,
60
+ inject: () => ({
61
+ // Bound RPC caller the component invokes on mount + poll. Plain args under SRC.
62
+ fetchStats: async (worktreePath: string | undefined, signal: AbortSignal): Promise<WorkbenchStats | null> => {
63
+ const result = await connection.rpc.call(
64
+ '/api',
65
+ 'gitWorkbench/stats',
66
+ worktreePath === undefined ? { args: {} } : { args: { worktreePath } },
67
+ signal,
68
+ ) as { ok: true; value: WorkbenchStats } | { ok: false; error: { message?: string } }
69
+ return result.ok ? result.value : null
70
+ },
71
+ // On-demand single-file diff. With `commit` it is that commit's change to the
72
+ // file; without it, the working tree (tracked: git diff HEAD --; untracked: synthesized).
73
+ fetchFileDiff: async (worktreePath: string | undefined, path: string, commit: string | undefined, signal: AbortSignal): Promise<string> => {
74
+ const result = await connection.rpc.call(
75
+ '/api',
76
+ 'gitWorkbench/fileDiff',
77
+ { args: { worktreePath: worktreePath ?? '', path, ...commit === undefined ? {} : { commit } } },
78
+ signal,
79
+ ) as { ok: true; value: { diff: string } } | { ok: false; error: { message?: string } }
80
+ return result.ok ? result.value.diff : ''
81
+ },
82
+ // One commit's change set, in the same shape as `stats` — the drawer's tree and
83
+ // diff panes render it through the same code path as the working tree.
84
+ fetchCommitStats: async (worktreePath: string | undefined, hash: string, signal: AbortSignal): Promise<WorkbenchStats | null> => {
85
+ const result = await connection.rpc.call(
86
+ '/api',
87
+ 'gitWorkbench/commitStats',
88
+ { args: { worktreePath: worktreePath ?? '', hash } },
89
+ signal,
90
+ ) as { ok: true; value: WorkbenchStats } | { ok: false; error: { message?: string } }
91
+ return result.ok ? result.value : null
92
+ },
93
+ // One page of the commit log past what `stats` bundles, so the history
94
+ // list can grow instead of stopping at the first page.
95
+ fetchCommits: async (worktreePath: string | undefined, ref: string, skip: number, limit: number, signal: AbortSignal): Promise<{ commits: GitCommit[]; hasMore: boolean } | null> => {
96
+ const result = await connection.rpc.call(
97
+ '/api',
98
+ 'gitWorkbench/commits',
99
+ { args: { worktreePath: worktreePath ?? '', ref, skip, limit } },
100
+ signal,
101
+ ) as { ok: true; value: { commits: GitCommit[]; hasMore: boolean } } | { ok: false; error: { message?: string } }
102
+ return result.ok ? result.value : null
103
+ },
104
+ // Two refs compared as `base...head`, in the same shape as every other
105
+ // view, so the drawer's tree and diff panes render it unchanged.
106
+ fetchCompare: async (worktreePath: string | undefined, base: string, head: string, signal: AbortSignal): Promise<WorkbenchStats | null> => {
107
+ const result = await connection.rpc.call(
108
+ '/api',
109
+ 'gitWorkbench/compareRefs',
110
+ { args: { worktreePath: worktreePath ?? '', base, head } },
111
+ signal,
112
+ ) as { ok: true; value: WorkbenchStats } | { ok: false; error: { message?: string } }
113
+ return result.ok ? result.value : null
114
+ },
115
+ // The session's binding plus EVERY worktree of the surrounding repository.
116
+ // Polled in step with the stats, so the chip tracks the agent's enter/exit
117
+ // calls and the drawer's source list tracks worktrees added outside dsh.
118
+ fetchWorktreeStatus: async (sessionId: string, repoPath: string | undefined, signal: AbortSignal): Promise<WorktreeStatus | null> => {
119
+ const result = await connection.rpc.call(
120
+ '/api',
121
+ 'gitWorkbench/worktreeStatus',
122
+ { args: { sessionId, repoPath: repoPath ?? '' } },
123
+ signal,
124
+ ) as { ok: true; value: WorktreeStatus } | { ok: false; error: { message?: string } }
125
+ return result.ok ? result.value : null
126
+ },
127
+ // The session's binding and nothing else. This one reads the bindings
128
+ // JSON and spawns no git, which is what lets the chip keep watching for
129
+ // an agent's `worktree_enter` with the drawer shut — where the full
130
+ // status call (rev-parse + worktree list + branch list) would be a git
131
+ // spawn per session header per tick. A disagreement here is what
132
+ // triggers one `worktreeStatus` to repaint everything.
133
+ fetchSessionBinding: async (sessionId: string, signal: AbortSignal): Promise<{ worktreePath: string | null; name: string | null } | null> => {
134
+ const result = await connection.rpc.call(
135
+ '/api',
136
+ 'gitWorkbench/sessionWorktree',
137
+ { args: { sessionId } },
138
+ signal,
139
+ ) as { ok: true; value: { worktreePath: string | null; name: string | null } } | { ok: false; error: { message?: string } }
140
+ return result.ok ? result.value : null
141
+ },
142
+ // Drawer styling for this directory: the project's scope and the global
143
+ // one, unresolved — the menu edits each separately, so it needs both.
144
+ fetchStyle: async (worktreePath: string | undefined, signal: AbortSignal): Promise<StyleSettings | null> => {
145
+ const result = await connection.rpc.call(
146
+ '/api',
147
+ 'gitWorkbench/styleGet',
148
+ { args: { worktreePath: worktreePath ?? '' } },
149
+ signal,
150
+ ) as { ok: true; value: StyleSettings } | { ok: false; error: { message?: string } }
151
+ return result.ok ? result.value : null
152
+ },
153
+ // Replace one scope's styling. The host validates and clamps everything
154
+ // in the entry, so a rejected value comes back as ok:false with a reason.
155
+ saveStyle: async (worktreePath: string | undefined, scope: StyleScope, entry: StyleEntry, signal: AbortSignal): Promise<{ ok: boolean; error?: string }> => {
156
+ const result = await connection.rpc.call(
157
+ '/api',
158
+ 'gitWorkbench/styleSet',
159
+ { args: { worktreePath: worktreePath ?? '', scope, entry } },
160
+ signal,
161
+ // Flat rather than a discriminated union: this package compiles with
162
+ // `strictNullChecks` off, which cannot narrow one, and this is the only
163
+ // caller that reads the transport's own error rather than ignoring it.
164
+ ) as { ok: boolean; value?: { ok: boolean; error?: string }; error?: { message?: string } }
165
+ if (result.ok && result.value !== undefined) return result.value
166
+ return { ok: false, error: result.error?.message ?? 'rpc failed' }
167
+ },
168
+
169
+ // ---- write operations ----
170
+ //
171
+ // Each returns the host's own GitOpResult. A transport failure is folded
172
+ // into the same shape rather than thrown: the drawer shows one banner for
173
+ // "the operation failed", and it should not matter to that banner whether
174
+ // git refused or the socket did.
175
+ fetchSync: async (worktreePath: string | undefined, signal: AbortSignal): Promise<SyncStatus | null> => {
176
+ const result = await connection.rpc.call(
177
+ '/api',
178
+ 'gitWorkbench/syncStatus',
179
+ { args: { worktreePath: worktreePath ?? '' } },
180
+ signal,
181
+ ) as { ok: true; value: SyncStatus } | { ok: false; error: { message?: string } }
182
+ return result.ok ? result.value : null
183
+ },
184
+ runGitOp: async (op: GitOpName, worktreePath: string | undefined, payload: GitOpPayload, signal: AbortSignal): Promise<GitOpResult> => {
185
+ const result = await connection.rpc.call(
186
+ '/api',
187
+ `gitWorkbench/${op}`,
188
+ { args: { worktreePath: worktreePath ?? '', ...payload } },
189
+ signal,
190
+ ) as { ok: boolean; value?: GitOpResult; error?: { message?: string } }
191
+ if (result.ok && result.value !== undefined) return result.value
192
+ return { ok: false, failure: 'unknown', error: result.error?.message ?? 'rpc failed' }
193
+ },
194
+ }),
195
+ },
196
+ GitWorkbenchPanel,
197
+ ))
198
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Dictionaries for this plugin's UI copy, registered into the app's locale
3
+ * runtime under the `gitworkbench` namespace so the panel follows the user's
4
+ * language preference instead of shipping one hardcoded language.
5
+ *
6
+ * The namespace lives outside dsh's `LocaleNamespaceMap` merge table (this is an
7
+ * out-of-tree plugin), so registration uses `ctx.locale.register(ns, locale, dict)`
8
+ * — the single-locale untyped form the runtime provides for exactly that case.
9
+ * Keys ARE compile-time checked — both dictionaries are `Record<WorkbenchKey, string>`,
10
+ * so tsc keeps them in step — but a lookup miss still renders the key itself
11
+ * rather than blank.
12
+ *
13
+ * Values are templates with `{name}` placeholders.
14
+ *
15
+ * English wording follows git's own vocabulary rather than translating the
16
+ * Chinese literally: a change set is `added / modified / deleted`, matching
17
+ * `git status` and the file badges (A / M / D) the tree already renders.
18
+ */
19
+
20
+ /** Every key this plugin looks up — the two dictionaries below must both cover it. */
21
+ export type WorkbenchKey =
22
+ | 'aheadTitle' | 'behindTitle' | 'files'
23
+ | 'drawerLabel' | 'totalsDim' | 'refresh' | 'close'
24
+ | 'tabsLabel' | 'tabChanges' | 'tabHistory' | 'tabCompare'
25
+ | 'sourceLabel' | 'workingTree'
26
+ | 'loadingCommit' | 'renamedFrom' | 'binaryFile' | 'loadingDiff' | 'noTextDiff'
27
+ | 'noCommits' | 'historyLabel' | 'historyEnd' | 'loading' | 'maximize' | 'restore'
28
+ | 'compareBase' | 'compareHead' | 'comparePick' | 'compareCommits' | 'loadingCompare' | 'noBranches'
29
+ | 'refSearch' | 'refNone' | 'refCount' | 'refTruncated' | 'refWorktrees' | 'refBranches' | 'historyRefLabel'
30
+ | 'settings' | 'themeMode' | 'themePalette' | 'themeScope' | 'themeBackground' | 'themeCss'
31
+ | 'modeSystem' | 'modeLight' | 'modeDark'
32
+ | 'scopeProject' | 'scopeGlobal' | 'scopeGlobalHint' | 'scopeNoRepo'
33
+ | 'bgNone' | 'bgChoose' | 'bgClear' | 'bgBlur' | 'bgVeil' | 'bgWorking' | 'bgFailed' | 'bgTooBig'
34
+ | 'cssPlaceholder' | 'cssImport' | 'cssApply' | 'cssUnapplied' | 'styleFailed'
35
+ | 'resizeLabel' | 'resizeCommits' | 'resizeTree'
36
+ | 'expandAll' | 'collapseAll' | 'noBranch'
37
+ | 'copyCommit' | 'copiedCommit'
38
+ // write operations
39
+ | 'syncLabel' | 'noUpstream' | 'noUpstreamHint' | 'upToDate' | 'opRunning'
40
+ | 'fetch' | 'pull' | 'push' | 'publish' | 'pushSetUpstream'
41
+ | 'pullModeLabel' | 'pullFf' | 'pullRebase' | 'pullMerge'
42
+ | 'stage' | 'unstage' | 'stageAll' | 'unstageAll' | 'stagedCount'
43
+ | 'commit' | 'amend' | 'commitPlaceholder' | 'commitNeedMessage' | 'commitLead'
44
+ | 'op.ok.stage' | 'op.ok.unstage' | 'op.ok.commit' | 'op.ok.fetch' | 'op.ok.pull' | 'op.ok.push'
45
+ | 'op.fail.auth' | 'op.fail.network' | 'op.fail.no-upstream' | 'op.fail.diverged' | 'op.fail.conflict'
46
+ | 'op.fail.nothing-to-commit' | 'op.fail.dirty' | 'op.fail.unknown'
47
+
48
+ export const zh: Record<WorkbenchKey, string> = {
49
+ aheadTitle: '领先上游 {count} 个提交',
50
+ behindTitle: '落后上游 {count} 个提交',
51
+ files: '{count} 文件',
52
+ drawerLabel: 'git 变更',
53
+ totalsDim: '{added} 新 · {modified} 改 · {deleted} 删',
54
+ refresh: '刷新',
55
+ close: '关闭',
56
+ tabsLabel: '变更视图',
57
+ tabChanges: '变更',
58
+ tabHistory: '历史',
59
+ tabCompare: '对比',
60
+ historyEnd: '已到最早的提交',
61
+ loading: '加载中…',
62
+ maximize: '最大化',
63
+ restore: '还原',
64
+ compareBase: '基准',
65
+ compareHead: '对比',
66
+ comparePick: '选择两个不同的分支进行对比',
67
+ compareCommits: '{count} 个提交(自共同祖先起)',
68
+ loadingCompare: '加载对比…',
69
+ noBranches: '没有可对比的分支',
70
+ refSearch: '筛选分支…',
71
+ refNone: '没有匹配的分支',
72
+ refCount: '{shown} / {total}',
73
+ refTruncated: '仅显示最近的若干分支',
74
+ refWorktrees: '有工作树的分支',
75
+ refBranches: '其他分支',
76
+ historyRefLabel: '分支',
77
+ settings: '设置',
78
+ themeMode: '明暗',
79
+ themePalette: '配色',
80
+ themeScope: '作用范围',
81
+ themeBackground: '背景图',
82
+ themeCss: '自定义 CSS',
83
+ modeSystem: '跟随',
84
+ modeLight: '亮色',
85
+ modeDark: '暗色',
86
+ scopeProject: '本项目',
87
+ scopeGlobal: '全局',
88
+ scopeGlobalHint: '所有项目;本项目单独设置时以本项目为准',
89
+ scopeNoRepo: '当前目录不是 git 仓库,只能设置全局',
90
+ bgNone: '未设置背景图',
91
+ bgChoose: '选择图片…',
92
+ bgClear: '清除',
93
+ bgBlur: '虚化',
94
+ bgVeil: '遮罩',
95
+ bgWorking: '处理中…',
96
+ bgFailed: '这个文件无法作为图片读取',
97
+ bgTooBig: '图片过大,请换一张',
98
+ cssPlaceholder: '例如 [data-gs-part="card"] { --gs-accent: #ff0066; }',
99
+ cssImport: '导入…',
100
+ cssApply: '应用',
101
+ cssUnapplied: '有未应用的修改',
102
+ styleFailed: '保存失败',
103
+ resizeLabel: '拖动调整抽屉宽度',
104
+ resizeCommits: '拖动调整提交列表宽度',
105
+ resizeTree: '拖动调整文件树宽度',
106
+ sourceLabel: '统计来源切换',
107
+ workingTree: '工作区',
108
+ loadingCommit: '加载提交…',
109
+ renamedFrom: '重命名自',
110
+ binaryFile: '二进制文件 —— 不显示文本 diff',
111
+ loadingDiff: '加载 diff…',
112
+ noTextDiff: '无文本差异',
113
+ noCommits: '无提交历史',
114
+ historyLabel: '提交历史',
115
+ expandAll: '展开全部',
116
+ collapseAll: '收起全部',
117
+ noBranch: '(无分支)',
118
+ copyCommit: '复制提交说明',
119
+ copiedCommit: '已复制',
120
+ syncLabel: '与远端同步',
121
+ noUpstream: '未跟踪远端分支',
122
+ noUpstreamHint: '当前分支没有上游分支,先推送一次即可建立',
123
+ upToDate: '已同步',
124
+ opRunning: '执行中…',
125
+ fetch: '拉取远端信息',
126
+ pull: '拉取',
127
+ push: '推送',
128
+ publish: '发布分支',
129
+ pushSetUpstream: '首次推送,将建立 origin 上的同名分支',
130
+ pullModeLabel: '拉取方式',
131
+ pullFf: '仅快进',
132
+ pullRebase: '变基',
133
+ pullMerge: '合并',
134
+ stage: '暂存',
135
+ unstage: '取消暂存',
136
+ stageAll: '勾选全部',
137
+ unstageAll: '取消全部勾选',
138
+ stagedCount: '已勾选 {count} 个',
139
+ commit: '提交',
140
+ amend: '修改上一条提交',
141
+ commitPlaceholder: '提交说明(Ctrl+Enter 提交)',
142
+ commitNeedMessage: '请先填写提交说明',
143
+ commitLead: '先勾选文件暂存,再提交',
144
+ 'op.ok.stage': '已暂存',
145
+ 'op.ok.unstage': '已取消暂存',
146
+ 'op.ok.commit': '提交成功',
147
+ 'op.ok.fetch': '已获取远端信息',
148
+ 'op.ok.pull': '拉取完成',
149
+ 'op.ok.push': '推送成功',
150
+ 'op.fail.auth': '认证失败。凭据提示已被禁用,请先在终端里配置好凭据再重试。',
151
+ 'op.fail.network': '网络不可达:主机名解析失败或连接不上。检查网络与远程地址后重试。',
152
+ 'op.fail.no-upstream': '当前分支没有上游分支。',
153
+ 'op.fail.diverged': '远端有本地没有的提交。先拉取再推送——这里不会强制覆盖。',
154
+ 'op.fail.conflict': '出现冲突,工作区已被改动。请在编辑器里解决冲突后继续。',
155
+ 'op.fail.nothing-to-commit': '暂存区是空的,没有可提交的内容。',
156
+ 'op.fail.dirty': '本地改动会被覆盖。先提交或暂存它们。',
157
+ 'op.fail.unknown': '操作失败。',
158
+ }
159
+
160
+ export const en: Record<WorkbenchKey, string> = {
161
+ aheadTitle: '{count} commits ahead of upstream',
162
+ behindTitle: '{count} commits behind upstream',
163
+ files: '{count} files',
164
+ drawerLabel: 'git changes',
165
+ totalsDim: '{added} added · {modified} modified · {deleted} deleted',
166
+ refresh: 'Refresh',
167
+ close: 'Close',
168
+ tabsLabel: 'Change views',
169
+ tabChanges: 'Changes',
170
+ tabHistory: 'History',
171
+ tabCompare: 'Compare',
172
+ historyEnd: 'Start of history',
173
+ loading: 'Loading…',
174
+ maximize: 'Maximize',
175
+ restore: 'Restore',
176
+ compareBase: 'Base',
177
+ compareHead: 'Compare',
178
+ comparePick: 'Pick two different branches to compare',
179
+ compareCommits: '{count} commits since they diverged',
180
+ loadingCompare: 'Loading comparison…',
181
+ noBranches: 'No branches to compare',
182
+ refSearch: 'Filter branches…',
183
+ refNone: 'No matching branch',
184
+ refCount: '{shown} / {total}',
185
+ refTruncated: 'showing the most recent only',
186
+ refWorktrees: 'With a worktree',
187
+ refBranches: 'Other branches',
188
+ historyRefLabel: 'Branch',
189
+ settings: 'Settings',
190
+ themeMode: 'Mode',
191
+ themePalette: 'Palette',
192
+ themeScope: 'Applies to',
193
+ themeBackground: 'Background',
194
+ themeCss: 'Custom CSS',
195
+ modeSystem: 'Match app',
196
+ modeLight: 'Light',
197
+ modeDark: 'Dark',
198
+ scopeProject: 'This project',
199
+ scopeGlobal: 'Global',
200
+ scopeGlobalHint: 'Every project; a project setting wins over this one',
201
+ scopeNoRepo: 'Not a git repository — global only',
202
+ bgNone: 'No background image',
203
+ bgChoose: 'Choose image…',
204
+ bgClear: 'Clear',
205
+ bgBlur: 'Blur',
206
+ bgVeil: 'Veil',
207
+ bgWorking: 'Working…',
208
+ bgFailed: 'That file could not be read as an image',
209
+ bgTooBig: 'Image too large — pick another',
210
+ cssPlaceholder: 'e.g. [data-gs-part="card"] { --gs-accent: #ff0066; }',
211
+ cssImport: 'Import…',
212
+ cssApply: 'Apply',
213
+ cssUnapplied: 'Unapplied changes',
214
+ styleFailed: 'Could not save',
215
+ resizeLabel: 'Drag to resize the drawer',
216
+ resizeCommits: 'Drag to resize the commit list',
217
+ resizeTree: 'Drag to resize the file tree',
218
+ sourceLabel: 'Switch stats source',
219
+ workingTree: 'Working tree',
220
+ loadingCommit: 'Loading commit…',
221
+ renamedFrom: 'Renamed from',
222
+ binaryFile: 'Binary file — no text diff',
223
+ loadingDiff: 'Loading diff…',
224
+ noTextDiff: 'No text changes',
225
+ noCommits: 'No commit history',
226
+ historyLabel: 'Commit history',
227
+ expandAll: 'Expand all',
228
+ collapseAll: 'Collapse all',
229
+ noBranch: '(no branch)',
230
+ copyCommit: 'Copy message',
231
+ copiedCommit: 'Copied',
232
+ syncLabel: 'Sync with remote',
233
+ noUpstream: 'No upstream branch',
234
+ noUpstreamHint: 'This branch tracks nothing yet; one push establishes it',
235
+ upToDate: 'Up to date',
236
+ opRunning: 'Working…',
237
+ fetch: 'Fetch',
238
+ pull: 'Pull',
239
+ push: 'Push',
240
+ publish: 'Publish branch',
241
+ pushSetUpstream: 'First push — creates the branch on origin',
242
+ pullModeLabel: 'Pull strategy',
243
+ pullFf: 'Fast-forward only',
244
+ pullRebase: 'Rebase',
245
+ pullMerge: 'Merge',
246
+ stage: 'Stage',
247
+ unstage: 'Unstage',
248
+ stageAll: 'Tick all',
249
+ unstageAll: 'Untick all',
250
+ stagedCount: '{count} ticked',
251
+ commit: 'Commit',
252
+ amend: 'Amend last commit',
253
+ commitPlaceholder: 'Commit message (Ctrl+Enter to commit)',
254
+ commitNeedMessage: 'Write a commit message first',
255
+ commitLead: 'Tick to stage, then commit',
256
+ 'op.ok.stage': 'Staged',
257
+ 'op.ok.unstage': 'Unstaged',
258
+ 'op.ok.commit': 'Committed',
259
+ 'op.ok.fetch': 'Fetched',
260
+ 'op.ok.pull': 'Pulled',
261
+ 'op.ok.push': 'Pushed',
262
+ 'op.fail.auth': 'Authentication failed. Credential prompts are disabled here — set your credentials up in a terminal first.',
263
+ 'op.fail.network': 'The network was unreachable — the host could not be resolved or the connection failed. Check connectivity and the remote URL, then retry.',
264
+ 'op.fail.no-upstream': 'This branch has no upstream.',
265
+ 'op.fail.diverged': 'The remote has commits this branch does not. Pull first — nothing here force-pushes.',
266
+ 'op.fail.conflict': 'Conflicts. The working tree has been changed; resolve them in the editor before continuing.',
267
+ 'op.fail.nothing-to-commit': 'Nothing staged to commit.',
268
+ 'op.fail.dirty': 'Local changes would be overwritten. Commit or stash them first.',
269
+ 'op.fail.unknown': 'The operation failed.',
270
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pacing for the drawer's "an operation is running" appearance.
3
+ *
4
+ * `busy` in GitWorkbenchPanel names two different facts. The first is a guard: an
5
+ * operation is in flight, so every control refuses to start another. That has
6
+ * to be true on the same tick as the click, or a double click fires two git
7
+ * calls. The second is an announcement — the drawer dims what it has disabled,
8
+ * so the user can see why a click did nothing.
9
+ *
10
+ * Only the announcement is worth pacing. A tick IS a git call (`add` on the way
11
+ * in, `restore --staged` on the way out) and it completes in about 150ms; that
12
+ * was long enough to fade the whole sync row to `opacity: .45` and back on
13
+ * every single click, which reads as a flicker rather than as feedback. Nobody
14
+ * can act on a message that brief — its only effect was the blink.
15
+ *
16
+ * So the appearance waits, and once it arrives it stays. A threshold on its own
17
+ * would just move the boundary: an operation finishing a little past it would
18
+ * paint the dim for a few milliseconds, which is the same blink one notch
19
+ * slower. The hold is what makes the dim always mean something.
20
+ *
21
+ * These live outside the panel so they can be tested — importing
22
+ * GitWorkbenchPanel.tsx pulls a CSS module and React, which a node test environment
23
+ * cannot load.
24
+ */
25
+
26
+ /**
27
+ * How long an operation must run before the drawer dims what it disabled.
28
+ *
29
+ * Above the ~150ms a stage/unstage round trip measured on the live drawer, so
30
+ * the operation behind every tick never paints at all.
31
+ */
32
+ export const BUSY_DELAY_MS = 220
33
+
34
+ /** Once the dim is up, the shortest time it stays — long enough to read. */
35
+ export const BUSY_HOLD_MS = 320
36
+
37
+ /**
38
+ * Whether a control is disabled ONLY by an operation too young to report.
39
+ *
40
+ * Such a control keeps its full strength: it is already refusing clicks, and
41
+ * saying so costs more than it gives when the answer arrives in 150ms.
42
+ *
43
+ * @param running - whether any git operation is in flight.
44
+ * @param sustained - whether that operation has lasted long enough to report.
45
+ * @param steady - whether this control is disabled for a reason of its own,
46
+ * like Pull with no upstream. Those keep their dim: it states an
47
+ * unavailable action, and dropping it mid-operation would be a new flicker.
48
+ */
49
+ export function quietlyDisabled(running: boolean, sustained: boolean, steady: boolean): boolean {
50
+ return running && !sustained && !steady
51
+ }
52
+
53
+ /**
54
+ * Milliseconds the dim must stay up before it may be taken down.
55
+ *
56
+ * @param shownAt - when the dim appeared, in `Date.now()` terms.
57
+ * @param now - the current time.
58
+ * @param hold - the minimum visible duration.
59
+ * @returns the remaining time, never negative — a negative delay would
60
+ * schedule a timer into the past and make the dim vanish on the same frame
61
+ * it was granted.
62
+ */
63
+ export function holdRemaining(shownAt: number, now: number, hold: number = BUSY_HOLD_MS): number {
64
+ return Math.max(0, hold - (now - shownAt))
65
+ }