@ibobbyts/dsh-git-commit 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +67 -2
- package/THIRD_PARTY.md +14 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +544 -0
- package/lib/core/git.js +161 -0
- package/lib/core/message.js +193 -0
- package/lib/core/metrics.js +39 -0
- package/lib/core/service.js +155 -0
- package/lib/core/store.js +113 -0
- package/lib/core/watcher.js +57 -0
- package/lib/index.d.ts +78 -0
- package/lib/index.js +80 -0
- package/package.json +73 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/** Browser loader bundle (no JSX compilation required). DSH also loads this in Desktop. */
|
|
2
|
+
window.__ModuleLoader__.load({
|
|
3
|
+
id: 'dsh-git-commit',
|
|
4
|
+
factory: (require) => {
|
|
5
|
+
const React = require('react');
|
|
6
|
+
const h = React.createElement;
|
|
7
|
+
const TAB_ID = 'dsh-git-commit/settings';
|
|
8
|
+
const TAB_KIND = 'gitCommitSettings';
|
|
9
|
+
const CHANNEL = '/api';
|
|
10
|
+
const ENDPOINT = 'git-commit';
|
|
11
|
+
const CSS = `
|
|
12
|
+
.dgc-root{position:absolute;z-index:24;pointer-events:none;color:var(--dsw-alias-label-primary);font-size:12px}
|
|
13
|
+
.dgc-card{pointer-events:auto;width:max-content;min-width:102px;max-width:216px;background:var(--dsw-alias-bg-overlay);border:1px solid var(--dsw-alias-border-l1);border-radius:12px;box-shadow:0 8px 26px #0004;padding:10px;box-sizing:border-box;display:flex;flex-direction:column;gap:8px}
|
|
14
|
+
.dgc-line{display:flex;align-items:center;gap:8px;min-width:0}.dgc-branch{font-weight:600;font-family:ui-monospace,Menlo,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;max-width:128px}
|
|
15
|
+
.dgc-grip{cursor:grab;touch-action:none;user-select:none;color:var(--dsw-alias-label-secondary)}.dgc-grip:active{cursor:grabbing}
|
|
16
|
+
.dgc-numbers{font:600 13px ui-monospace,Menlo,monospace;display:flex;gap:15px;white-space:nowrap;min-width:0;max-width:100%;overflow:hidden}.dgc-add{color:var(--dsw-alias-state-success-primary)}.dgc-del{color:var(--dsw-alias-state-error-primary)}
|
|
17
|
+
.dgc-button{font:inherit;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;padding:6px 9px;cursor:pointer;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}
|
|
18
|
+
.dgc-button:hover:not(:disabled){border-color:var(--dsw-alias-border-l2)}
|
|
19
|
+
.dgc-button:disabled{opacity:.55;cursor:default}
|
|
20
|
+
.dgc-button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
|
|
21
|
+
.dgc-primary{background:var(--dsw-alias-brand-primary);color:white;border-color:transparent;flex:1;font-weight:600}
|
|
22
|
+
.dgc-actions{gap:8px;justify-content:flex-start}
|
|
23
|
+
.dgc-icon-button{display:inline-flex;align-items:center;justify-content:center;height:36px;min-width:44px;padding:0;line-height:1}
|
|
24
|
+
.dgc-icon-button svg{width:19px;height:19px;display:block;flex-shrink:0}
|
|
25
|
+
.dgc-actions .dgc-primary,.dgc-actions .dgc-secondary{width:36px;height:36px;min-width:36px;flex:0 0 36px;padding:0}
|
|
26
|
+
.dgc-fold{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border:0;background:transparent;color:var(--dsw-alias-label-secondary);border-radius:6px;cursor:pointer;flex-shrink:0;padding:0}
|
|
27
|
+
.dgc-fold:hover{background:rgba(128,136,150,.15);color:var(--dsw-alias-label-primary)}
|
|
28
|
+
.dgc-fold svg{width:16px;height:16px}
|
|
29
|
+
.dgc-fold:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
|
|
30
|
+
.dgc-card-collapsed{width:44px;height:44px;min-width:44px;max-width:44px;padding:0;gap:0}
|
|
31
|
+
.dgc-collapsed-trigger{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;border:0;background:transparent;color:var(--dsw-alias-label-primary);cursor:grab;touch-action:none;user-select:none;border-radius:11px}
|
|
32
|
+
.dgc-collapsed-trigger:hover{background:rgba(128,136,150,.14)}.dgc-collapsed-trigger:active{cursor:grabbing}
|
|
33
|
+
.dgc-collapsed-trigger svg{width:22px;height:22px}
|
|
34
|
+
.dgc-collapsed-trigger:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-4px}
|
|
35
|
+
.dgc-card-no-repo{width:max-content;min-width:0;max-width:216px;padding:10px 12px}
|
|
36
|
+
.dgc-no-repo{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;color:var(--dsw-alias-label-primary);cursor:grab;touch-action:none;user-select:none}.dgc-no-repo:active{cursor:grabbing}
|
|
37
|
+
.dgc-git-cross{position:absolute;top:5px;right:5px;width:14px;height:14px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:var(--dsw-alias-bg-layer-1);color:#ef4444;pointer-events:none}
|
|
38
|
+
.dgc-git-cross svg{width:12px;height:12px;stroke-width:2.8}
|
|
39
|
+
/* DSH expresses the resolved theme on body, not [data-theme] or the OS media query. */
|
|
40
|
+
body[data-ds-dark-theme] .dgc-card{background:#2b3038;border-color:#48505b;box-shadow:0 12px 32px rgba(0,0,0,.48),0 2px 8px rgba(0,0,0,.23)}
|
|
41
|
+
body[data-ds-dark-theme] .dgc-root{color:#f4f6f9}
|
|
42
|
+
body[data-ds-dark-theme] .dgc-grip{color:#b6bdc8}
|
|
43
|
+
body[data-ds-dark-theme] .dgc-primary{background:#101216;color:#f5f7fa;border-color:#414852}
|
|
44
|
+
body[data-ds-dark-theme] .dgc-primary:hover:not(:disabled){background:#191d24;border-color:#66707e}
|
|
45
|
+
body[data-ds-dark-theme] .dgc-secondary{background:#383e47;color:#f3f5f8;border-color:#4c5560}
|
|
46
|
+
body[data-ds-dark-theme] .dgc-secondary:hover:not(:disabled){background:#454c56;border-color:#758090}
|
|
47
|
+
body[data-ds-dark-theme] .dgc-icon-button:focus-visible{outline-color:#a7b4cc}
|
|
48
|
+
body[data-ds-dark-theme] .dgc-no-repo{color:#f4f6f9}
|
|
49
|
+
.dgc-notice{font-size:11px;line-height:1.45;overflow-wrap:anywhere;color:var(--dsw-alias-label-secondary)}.dgc-error{color:var(--dsw-alias-state-error-primary)}
|
|
50
|
+
.dgc-settings{padding:16px;height:100%;box-sizing:border-box;overflow:auto;display:flex;flex-direction:column;gap:13px;color:var(--dsw-alias-label-primary)}.dgc-settings h3{font-size:15px;margin:0 0 4px}
|
|
51
|
+
.dgc-field{display:flex;flex-direction:column;gap:6px;font-size:12px}.dgc-field textarea,.dgc-field select{width:100%;box-sizing:border-box;min-height:34px;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:7px;color:var(--dsw-alias-label-primary);padding:8px;font:inherit}.dgc-field textarea{min-height:92px;resize:vertical}
|
|
52
|
+
.dgc-check{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:12px}.dgc-check input{accent-color:var(--dsw-alias-brand-primary)}.dgc-subtle{color:var(--dsw-alias-label-secondary);font-size:11px;overflow-wrap:anywhere}
|
|
53
|
+
.dgc-settings-divider{width:100%;border:0;border-top:1px solid var(--dsw-alias-border-l2);margin:2px 0}
|
|
54
|
+
.dgc-repo-path{display:block;font:11px ui-monospace,Menlo,monospace;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere;word-break:break-all}
|
|
55
|
+
.dgc-save-error{color:var(--dsw-alias-state-error-primary)}
|
|
56
|
+
.dgc-reset-position{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:34px;width:100%;font:inherit;cursor:pointer;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}
|
|
57
|
+
.dgc-reset-position:hover{background:var(--dsw-alias-bg-layer-2)}
|
|
58
|
+
.dgc-reset-position:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
|
|
59
|
+
.dgc-countdown-color{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px}
|
|
60
|
+
.dgc-countdown-color input{width:42px;height:30px;padding:2px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:var(--dsw-alias-bg-base);cursor:pointer}
|
|
61
|
+
.dgc-countdown-color input:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
|
|
62
|
+
.dgc-feedback{display:flex;flex-direction:column;gap:4px;min-width:0;width:100%;box-sizing:border-box}
|
|
63
|
+
.dgc-result-head{font:600 10.5px/1.4 ui-monospace,Menlo,monospace;letter-spacing:-.15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
64
|
+
.dgc-result-subject{font:12px/1.4 var(--dsh-font-family-text,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
65
|
+
.dgc-result-metrics{font:10.5px/1.5 ui-monospace,Menlo,monospace;overflow-wrap:anywhere;white-space:normal}
|
|
66
|
+
.dgc-countdown-track{width:100%;height:3px;overflow:hidden;border-radius:99px;background:rgba(128,136,150,.18);margin-top:3px}
|
|
67
|
+
.dgc-countdown-fill{width:100%;height:100%;background-color:#f59e0b;transform-origin:left center;animation:dgc-countdown 10s linear forwards}
|
|
68
|
+
@keyframes dgc-countdown{from{transform:scaleX(1)}to{transform:scaleX(0)}}
|
|
69
|
+
`;
|
|
70
|
+
/** All icons inherit theme text color, with accessible text on their buttons. */
|
|
71
|
+
function actionIcon(kind) {
|
|
72
|
+
const props = { width:19, height:19, viewBox:'0 0 24 24', fill:'none', stroke:'currentColor', strokeWidth:1.9, strokeLinecap:'round', strokeLinejoin:'round', 'aria-hidden':true, focusable:'false' };
|
|
73
|
+
if (kind === 'commit') return h('svg',props,
|
|
74
|
+
h('path',{d:'M2.5 12h5'}),h('circle',{cx:12,cy:12,r:4.5}),h('path',{d:'M16.5 12h5'}));
|
|
75
|
+
if (kind === 'settings') return h('svg',props,
|
|
76
|
+
h('path',{d:'M9.8 3h4.4l.5 2.05 1.33.55 1.82-1.09 3.11 3.11-1.09 1.82.55 1.33 2.05.5v4.4l-2.05.5-.55 1.33 1.09 1.82-3.11 3.11-1.82-1.09-1.33.55-.5 2.05H9.8l-.5-2.05-1.33-.55-1.82 1.09-3.11-3.11 1.09-1.82-.55-1.33-2.05-.5v-4.4l2.05-.5.55-1.33-1.09-1.82 3.11-3.11 1.82 1.09 1.33-.55.5-2.05Z'}),
|
|
77
|
+
h('circle',{cx:12,cy:13.5,r:3}));
|
|
78
|
+
if (kind === 'collapse') return h('svg',props,h('path',{d:'M6 10l6 6 6-6'}));
|
|
79
|
+
if (kind === 'cross') return h('svg',props,h('path',{d:'M5 5l14 14M19 5L5 19'}));
|
|
80
|
+
return h('svg',props,
|
|
81
|
+
h('circle',{cx:6,cy:4,r:2}),h('path',{d:'M6 6v8a5 5 0 0 0 5 5h7'}),h('circle',{cx:18,cy:19,r:2}));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Merge changed settings only; serialize requests so older saves cannot overwrite newer edits. */
|
|
85
|
+
function createAutoSaver(send,onError) {
|
|
86
|
+
let pending = {};
|
|
87
|
+
let timer = null;
|
|
88
|
+
let active = null;
|
|
89
|
+
const flush = () => {
|
|
90
|
+
if (timer !== null) { clearTimeout(timer); timer = null; }
|
|
91
|
+
if (active) return active;
|
|
92
|
+
active = (async () => {
|
|
93
|
+
while (Object.keys(pending).length) {
|
|
94
|
+
const patch = pending;
|
|
95
|
+
pending = {};
|
|
96
|
+
try { await send(patch); onError(''); }
|
|
97
|
+
catch (error) {
|
|
98
|
+
// Keep unsaved edits, preserving newer values on conflicting fields.
|
|
99
|
+
pending = { ...patch, ...pending };
|
|
100
|
+
onError('Failed to save settings: ' + (error?.message ?? String(error)));
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
})().finally(() => { active = null; });
|
|
105
|
+
return active;
|
|
106
|
+
};
|
|
107
|
+
const update = (key,value,immediate=false) => {
|
|
108
|
+
pending[key] = value;
|
|
109
|
+
if (timer !== null) clearTimeout(timer);
|
|
110
|
+
if (immediate) { void flush(); }
|
|
111
|
+
else timer = setTimeout(() => { timer=null; void flush(); }, 400);
|
|
112
|
+
};
|
|
113
|
+
return { update, flush, close: () => { void flush(); } };
|
|
114
|
+
}
|
|
115
|
+
const colorOrDefault = value => typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value)
|
|
116
|
+
? value.toLowerCase() : '#f59e0b';
|
|
117
|
+
const countText = value => Number.isFinite(value) && value >= 0
|
|
118
|
+
? Math.round(value).toLocaleString('en-US') : '—';
|
|
119
|
+
const secondsText = value => Number.isFinite(value) && value >= 0
|
|
120
|
+
? (value / 1000).toFixed(2) : '—';
|
|
121
|
+
const speedText = value => Number.isFinite(value) && value >= 0
|
|
122
|
+
? value.toFixed(1) : '—';
|
|
123
|
+
function tokenLine(llm) {
|
|
124
|
+
return `↓ ${countText(llm?.inputTokens)} · ↑ ${countText(llm?.outputTokens)} | ${secondsText(llm?.latencyMs)}s · ${speedText(llm?.outputSpeedTps)}t/s`;
|
|
125
|
+
}
|
|
126
|
+
const workspace = { cwd: '', listeners: new Set(), getSnapshot() { return workspace.cwd; }, subscribe(fn) { workspace.listeners.add(fn); return () => workspace.listeners.delete(fn); }, set(cwd) { if (cwd !== workspace.cwd) { workspace.cwd = cwd; workspace.listeners.forEach(fn => fn()); } } };
|
|
127
|
+
// The setting is Host-owned, but the two Client panels share its current value.
|
|
128
|
+
const visibility = {
|
|
129
|
+
value: { hide: false, showTokenDetails: false, countdownColor: '#f59e0b', ready: false },
|
|
130
|
+
version: 0, listeners: new Set(),
|
|
131
|
+
getSnapshot() { return visibility.value; },
|
|
132
|
+
subscribe(fn) { visibility.listeners.add(fn); return () => visibility.listeners.delete(fn); },
|
|
133
|
+
/** Partial global setting patch, synchronized immediately between both Client surfaces. */
|
|
134
|
+
set(patch) {
|
|
135
|
+
const current = visibility.value;
|
|
136
|
+
const next = {
|
|
137
|
+
hide: Object.hasOwn(patch, 'autoHideNonGit') ? patch.autoHideNonGit === true : current.hide,
|
|
138
|
+
showTokenDetails: Object.hasOwn(patch, 'showTokenDetails') ? patch.showTokenDetails === true : current.showTokenDetails,
|
|
139
|
+
countdownColor: Object.hasOwn(patch, 'countdownColor') ? colorOrDefault(patch.countdownColor) : current.countdownColor,
|
|
140
|
+
ready: true,
|
|
141
|
+
};
|
|
142
|
+
if (!current.ready || current.hide !== next.hide || current.showTokenDetails !== next.showTokenDetails
|
|
143
|
+
|| current.countdownColor !== next.countdownColor) {
|
|
144
|
+
visibility.value = next;
|
|
145
|
+
visibility.version++;
|
|
146
|
+
for (const fn of visibility.listeners) fn();
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
/** The default is lower-right anchored. Explicit moves are top/right anchored. */
|
|
151
|
+
const DEFAULT_POSITION = Object.freeze({ right: 24, bottom: 98, top: null });
|
|
152
|
+
const POSITION_MARGIN = 6;
|
|
153
|
+
const isUsablePosition = pos => pos && Number.isFinite(pos.right) && pos.right >= 0
|
|
154
|
+
&& Number.isFinite(pos.top) && pos.top >= 0;
|
|
155
|
+
const samePosition = (a,b) => a.right === b.right && a.top === b.top;
|
|
156
|
+
/**
|
|
157
|
+
* Shared between settings and overlay; one global saved position per Desktop profile.
|
|
158
|
+
* A late settings response must never undo a local drag or reset.
|
|
159
|
+
*/
|
|
160
|
+
const floatingPosition = {
|
|
161
|
+
value: DEFAULT_POSITION, loaded: false, localEdit: false, listeners: new Set(), saveQueue: Promise.resolve(),
|
|
162
|
+
subscribe(fn) { floatingPosition.listeners.add(fn); return () => floatingPosition.listeners.delete(fn); },
|
|
163
|
+
getSnapshot() { return floatingPosition.value; },
|
|
164
|
+
set(next) {
|
|
165
|
+
floatingPosition.localEdit = true;
|
|
166
|
+
if (!samePosition(floatingPosition.value,next)) {
|
|
167
|
+
floatingPosition.value = next;
|
|
168
|
+
for (const fn of floatingPosition.listeners) fn();
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
hydrate(saved) {
|
|
172
|
+
if (floatingPosition.loaded) return;
|
|
173
|
+
floatingPosition.loaded = true;
|
|
174
|
+
if (floatingPosition.localEdit) return;
|
|
175
|
+
const next = isUsablePosition(saved) ? { right: saved.right, top: saved.top } : DEFAULT_POSITION;
|
|
176
|
+
if (!samePosition(floatingPosition.value,next)) {
|
|
177
|
+
floatingPosition.value = next;
|
|
178
|
+
for (const fn of floatingPosition.listeners) fn();
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
const bounded = (number, min, max) => Math.max(min, Math.min(Math.max(min,max),number));
|
|
183
|
+
/** Clamp to the visible intersection of the overlay's positioning container and viewport.
|
|
184
|
+
* Use measured actual panel dimensions rather than arbitrary pixel limits.
|
|
185
|
+
*/
|
|
186
|
+
function inBounds(position, root) {
|
|
187
|
+
if (!root || !Number.isFinite(root.offsetWidth) || !Number.isFinite(root.offsetHeight)) return position;
|
|
188
|
+
const parent = root.offsetParent ?? root.parentElement;
|
|
189
|
+
const width = parent?.clientWidth ?? window.innerWidth;
|
|
190
|
+
const height = parent?.clientHeight ?? window.innerHeight;
|
|
191
|
+
if (!(width > 0 && height > 0)) return position;
|
|
192
|
+
const rect = parent?.getBoundingClientRect?.() ?? {left:0,top:0};
|
|
193
|
+
const viewW = Number.isFinite(window.innerWidth) ? window.innerWidth : width;
|
|
194
|
+
const viewH = Number.isFinite(window.innerHeight) ? window.innerHeight : height;
|
|
195
|
+
const visibleLeft = Math.max(0,-rect.left);
|
|
196
|
+
const visibleRight = Math.min(width,viewW-rect.left);
|
|
197
|
+
const visibleTop = Math.max(0,-rect.top);
|
|
198
|
+
const visibleBottom = Math.min(height,viewH-rect.top);
|
|
199
|
+
if (visibleRight <= visibleLeft || visibleBottom <= visibleTop) return position;
|
|
200
|
+
const left = bounded(width-position.right-root.offsetWidth,
|
|
201
|
+
visibleLeft+POSITION_MARGIN,visibleRight-root.offsetWidth-POSITION_MARGIN);
|
|
202
|
+
const top = bounded(position.top == null ? root.offsetTop : position.top,
|
|
203
|
+
visibleTop+POSITION_MARGIN,visibleBottom-root.offsetHeight-POSITION_MARGIN);
|
|
204
|
+
const right = width-left-root.offsetWidth;
|
|
205
|
+
if (position.top == null && Math.abs(right-position.right) < 0.5
|
|
206
|
+
&& Math.abs(top-root.offsetTop) < 0.5) return position;
|
|
207
|
+
return { right, top };
|
|
208
|
+
}
|
|
209
|
+
function failureText(result) { return result?.error?.message ?? result?.error ?? 'Request failed'; }
|
|
210
|
+
function apply(ctx) {
|
|
211
|
+
const rpc = async (method, payload = {}, signal) => {
|
|
212
|
+
const result = await ctx.connection.rpc.call(CHANNEL, ENDPOINT, { method, payload }, signal);
|
|
213
|
+
if (!result.ok) throw new Error(failureText(result));
|
|
214
|
+
return result.value;
|
|
215
|
+
};
|
|
216
|
+
/** Serialize position saves from dragging, resize correction, and the reset button. */
|
|
217
|
+
const persistPosition = (position,cwd,onError=()=>{}) => {
|
|
218
|
+
const value = position.top == null ? null : {right:position.right,top:position.top};
|
|
219
|
+
floatingPosition.saveQueue = floatingPosition.saveQueue.catch(()=>{}).then(() =>
|
|
220
|
+
rpc('saveSettings',{cwd,patch:{floatingPosition:value}})).catch(error=>{
|
|
221
|
+
onError('Failed to save floating window position: ' + (error?.message ?? String(error)));
|
|
222
|
+
});
|
|
223
|
+
return floatingPosition.saveQueue;
|
|
224
|
+
};
|
|
225
|
+
ctx.effect(() => {
|
|
226
|
+
const tag = document.createElement('style');
|
|
227
|
+
tag.dataset.dshGitCommit = 'true';
|
|
228
|
+
tag.textContent = CSS;
|
|
229
|
+
document.head.appendChild(tag);
|
|
230
|
+
return () => tag.remove();
|
|
231
|
+
}, 'dsh-git-commit: styles');
|
|
232
|
+
|
|
233
|
+
function CommitPill(props) {
|
|
234
|
+
const sessionId = React.useSyncExternalStore(
|
|
235
|
+
ctx.sidebarRight.mounted.subscribe,
|
|
236
|
+
ctx.sidebarRight.mounted.getSnapshot,
|
|
237
|
+
ctx.sidebarRight.mounted.getSnapshot,
|
|
238
|
+
);
|
|
239
|
+
const cwd = props.useSessions(s => sessionId ? s.byId[sessionId]?.cwd : undefined);
|
|
240
|
+
const floatingPreference = React.useSyncExternalStore(visibility.subscribe, visibility.getSnapshot, visibility.getSnapshot);
|
|
241
|
+
const [statusEntry, setStatusEntry] = React.useState(null);
|
|
242
|
+
// Index status by cwd to avoid showing the previous Session's repository while switching.
|
|
243
|
+
const data = statusEntry != null && statusEntry.cwd === cwd ? statusEntry.data : null;
|
|
244
|
+
const [problem, setProblem] = React.useState('');
|
|
245
|
+
const [working, setWorking] = React.useState(false);
|
|
246
|
+
const [notice, setNotice] = React.useState('');
|
|
247
|
+
const [feedback, setFeedback] = React.useState(null);
|
|
248
|
+
const feedbackSequence = React.useRef(0);
|
|
249
|
+
const [collapsed, setCollapsed] = React.useState(false);
|
|
250
|
+
const position = React.useSyncExternalStore(floatingPosition.subscribe, floatingPosition.getSnapshot, floatingPosition.getSnapshot);
|
|
251
|
+
const rootRef = React.useRef(null);
|
|
252
|
+
const drag = React.useRef(null);
|
|
253
|
+
const suppressClick = React.useRef(false);
|
|
254
|
+
React.useEffect(() => { workspace.set(typeof cwd === 'string' ? cwd : ''); }, [cwd]);
|
|
255
|
+
React.useEffect(() => {
|
|
256
|
+
if (!cwd) return;
|
|
257
|
+
let valid = true;
|
|
258
|
+
const revision = visibility.version;
|
|
259
|
+
void rpc('settings', { cwd }).then(settings => {
|
|
260
|
+
if (valid) {
|
|
261
|
+
floatingPosition.hydrate(settings.floatingPosition);
|
|
262
|
+
if (visibility.version === revision) visibility.set(settings);
|
|
263
|
+
}
|
|
264
|
+
}, () => { if (valid && visibility.version === revision) visibility.set({ autoHideNonGit: false }); });
|
|
265
|
+
return () => { valid = false; };
|
|
266
|
+
}, [cwd]);
|
|
267
|
+
React.useEffect(() => {
|
|
268
|
+
if (!cwd) { setStatusEntry(null); setProblem(''); return; }
|
|
269
|
+
let cancelled = false, previousToken = undefined, lastFull = 0, reading = false, notRepo = false;
|
|
270
|
+
const refresh = async () => {
|
|
271
|
+
if (reading) return;
|
|
272
|
+
reading = true;
|
|
273
|
+
try {
|
|
274
|
+
const status = await rpc('status', { cwd });
|
|
275
|
+
if (!cancelled) {
|
|
276
|
+
notRepo = status.isRepository === false;
|
|
277
|
+
setStatusEntry({ cwd, data: status });
|
|
278
|
+
setProblem('');
|
|
279
|
+
}
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (!cancelled) { setStatusEntry(null); setProblem(error.message); }
|
|
282
|
+
} finally { reading = false; lastFull = Date.now(); }
|
|
283
|
+
};
|
|
284
|
+
const tick = async () => {
|
|
285
|
+
if (cancelled) return;
|
|
286
|
+
// Without a Git repository no need to launch Git every 900ms.
|
|
287
|
+
// A 30s probe also detects a later `git init` in the same directory.
|
|
288
|
+
if (notRepo) { if (Date.now() - lastFull >= 30000) await refresh(); return; }
|
|
289
|
+
try {
|
|
290
|
+
const { token } = await rpc('changeToken', { cwd });
|
|
291
|
+
if (previousToken !== token || Date.now() - lastFull > 30000) {
|
|
292
|
+
previousToken = token;
|
|
293
|
+
await refresh();
|
|
294
|
+
}
|
|
295
|
+
} catch { if (Date.now() - lastFull > 5000) await refresh(); }
|
|
296
|
+
};
|
|
297
|
+
setStatusEntry(null); setNotice(''); setFeedback(null); setProblem('');
|
|
298
|
+
void tick();
|
|
299
|
+
const interval = setInterval(() => { void tick(); }, 900);
|
|
300
|
+
return () => { cancelled = true; clearInterval(interval); };
|
|
301
|
+
}, [cwd]);
|
|
302
|
+
React.useEffect(() => {
|
|
303
|
+
if (!feedback) return;
|
|
304
|
+
// One timer per completed commit: cleanup prevents stale results from
|
|
305
|
+
// removing a newer notification or firing after the Session closes.
|
|
306
|
+
const remaining = Math.max(0, feedback.expiresAt - Date.now());
|
|
307
|
+
const timer = setTimeout(() => setFeedback(current => current === feedback ? null : current), remaining);
|
|
308
|
+
return () => clearTimeout(timer);
|
|
309
|
+
}, [feedback]);
|
|
310
|
+
const isNonGit = data?.isRepository === false;
|
|
311
|
+
const hiddenNonGit = isNonGit && (!floatingPreference.ready || floatingPreference.hide);
|
|
312
|
+
// Watch both the pane and the panel: window resizing, folding, or a new
|
|
313
|
+
// commit feedback line may reduce available space or increase card height.
|
|
314
|
+
React.useEffect(() => {
|
|
315
|
+
const root = rootRef.current;
|
|
316
|
+
if (!root || !sessionId || !cwd || hiddenNonGit || !floatingPosition.loaded) return;
|
|
317
|
+
const check = () => {
|
|
318
|
+
if (drag.current) return;
|
|
319
|
+
const checked = inBounds(floatingPosition.value,root);
|
|
320
|
+
if (!samePosition(checked,floatingPosition.value)) {
|
|
321
|
+
floatingPosition.set(checked);
|
|
322
|
+
void persistPosition(checked,cwd,setNotice);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
check();
|
|
326
|
+
const ResizeObserverClass = window.ResizeObserver;
|
|
327
|
+
const observer = typeof ResizeObserverClass === 'function' ? new ResizeObserverClass(check) : null;
|
|
328
|
+
if (observer) { observer.observe(root); if(root.offsetParent) observer.observe(root.offsetParent); }
|
|
329
|
+
window.addEventListener?.('resize',check);
|
|
330
|
+
return () => { observer?.disconnect(); window.removeEventListener?.('resize',check); };
|
|
331
|
+
}, [cwd,sessionId,collapsed,hiddenNonGit,!!feedback,floatingPosition.loaded,position.top == null]);
|
|
332
|
+
if (!sessionId || !cwd) return null;
|
|
333
|
+
const location = { right: position.right, ...(position.top == null ? { bottom: position.bottom } : { top: position.top }) };
|
|
334
|
+
// Do not flash a non-Git warning before persisted global visibility is known.
|
|
335
|
+
if (hiddenNonGit) return null;
|
|
336
|
+
// The first collapse turns bottom-right placement into top-right placement.
|
|
337
|
+
// Both compact and expanded cards thereafter share exactly the same top/right.
|
|
338
|
+
const pinUpperRight = event => {
|
|
339
|
+
if (floatingPosition.value.top != null) return;
|
|
340
|
+
const root = event.currentTarget.closest?.('.dgc-root');
|
|
341
|
+
if (root && Number.isFinite(root.offsetTop)) {
|
|
342
|
+
const next = inBounds({ right:floatingPosition.value.right, top:root.offsetTop }, root);
|
|
343
|
+
floatingPosition.set(next);
|
|
344
|
+
void persistPosition(next,cwd,setNotice);
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
const onDragStart = event => {
|
|
348
|
+
if (event.button !== 0) return;
|
|
349
|
+
const root = event.currentTarget.closest?.('.dgc-root');
|
|
350
|
+
const top = floatingPosition.value.top ?? root?.offsetTop ?? 0;
|
|
351
|
+
drag.current = { id: event.pointerId, x: event.clientX, y: event.clientY,
|
|
352
|
+
right: floatingPosition.value.right, top, moved: false, root };
|
|
353
|
+
suppressClick.current = false;
|
|
354
|
+
try { event.currentTarget.setPointerCapture(event.pointerId); } catch {}
|
|
355
|
+
};
|
|
356
|
+
const onDragMove = event => {
|
|
357
|
+
const d = drag.current;
|
|
358
|
+
if (!d || d.id !== event.pointerId) return;
|
|
359
|
+
const dx = event.clientX - d.x, dy = event.clientY - d.y;
|
|
360
|
+
if (!d.moved && dx * dx + dy * dy < 16) return;
|
|
361
|
+
d.moved = true;
|
|
362
|
+
floatingPosition.set(inBounds({right:d.right-dx,top:d.top+dy},d.root));
|
|
363
|
+
};
|
|
364
|
+
const onDragEnd = event => {
|
|
365
|
+
if (drag.current?.id !== event.pointerId) return;
|
|
366
|
+
if (drag.current.moved) {
|
|
367
|
+
suppressClick.current = true;
|
|
368
|
+
void persistPosition(floatingPosition.value,cwd,setNotice);
|
|
369
|
+
}
|
|
370
|
+
drag.current = null;
|
|
371
|
+
try { event.currentTarget.releasePointerCapture(event.pointerId); } catch {}
|
|
372
|
+
};
|
|
373
|
+
const onDragCancel = () => { if (drag.current?.moved) void persistPosition(floatingPosition.value,cwd,setNotice); drag.current = null; suppressClick.current = false; };
|
|
374
|
+
const dragHandlers = { onPointerDown: onDragStart, onPointerMove: onDragMove,
|
|
375
|
+
onPointerUp: onDragEnd, onPointerCancel: onDragCancel };
|
|
376
|
+
const fold = event => { pinUpperRight(event); setCollapsed(true); };
|
|
377
|
+
if (collapsed) return h('div',{className:'dgc-root',style:location,ref:rootRef},
|
|
378
|
+
h('div',{className:'dgc-card dgc-card-collapsed'},
|
|
379
|
+
h('button',{type:'button',className:'dgc-collapsed-trigger',title:'Git commit','aria-label':'Git commit',
|
|
380
|
+
...dragHandlers,
|
|
381
|
+
onClick:()=>{ if (suppressClick.current) { suppressClick.current = false; return; } setCollapsed(false); }},
|
|
382
|
+
actionIcon('git'),
|
|
383
|
+
isNonGit ? h('span',{className:'dgc-git-cross','aria-hidden':true},actionIcon('cross')) : null)));
|
|
384
|
+
if (isNonGit) return h('div',{className:'dgc-root',style:location,ref:rootRef},
|
|
385
|
+
h('div',{className:'dgc-card dgc-card-no-repo',role:'status'},
|
|
386
|
+
h('div',{className:'dgc-line'},
|
|
387
|
+
h('span',{className:'dgc-no-repo',title:'Drag Git commit window',...dragHandlers},'Not a git repository'),
|
|
388
|
+
h('button',{type:'button',className:'dgc-fold',title:'Collapse Git commit',
|
|
389
|
+
'aria-label':'Collapse Git commit',onClick:fold},actionIcon('collapse')))));
|
|
390
|
+
const hasChanges = data && (data.additions || data.deletions || data.untracked || data.binary || data.stagedFiles);
|
|
391
|
+
const onCommit = async () => {
|
|
392
|
+
if (working || !hasChanges || data?.isRepository === false) return;
|
|
393
|
+
setWorking(true); setNotice(''); setFeedback(null);
|
|
394
|
+
try {
|
|
395
|
+
const result = await rpc('commit', { cwd });
|
|
396
|
+
if (result.success) {
|
|
397
|
+
setFeedback({
|
|
398
|
+
id: ++feedbackSequence.current,
|
|
399
|
+
heading: `Committed ${result.hash} · +${result.additions} −${result.deletions}`,
|
|
400
|
+
subject: String(result.message ?? '').split(/\r?\n/, 1)[0].trim(),
|
|
401
|
+
llm: result.llm ?? null,
|
|
402
|
+
expiresAt: Date.now() + 10000,
|
|
403
|
+
});
|
|
404
|
+
} else setNotice('Commit failed: ' + result.error);
|
|
405
|
+
setStatusEntry({cwd, data: await rpc('status', { cwd })});
|
|
406
|
+
} catch (error) { setNotice('Commit failed: ' + error.message); }
|
|
407
|
+
finally { setWorking(false); }
|
|
408
|
+
};
|
|
409
|
+
const grip = h('span', {
|
|
410
|
+
className:'dgc-grip', title:'Drag Git commit window', ...dragHandlers,
|
|
411
|
+
},'⠿');
|
|
412
|
+
return h('div',{className:'dgc-root',style:location,ref:rootRef},
|
|
413
|
+
h('div',{className:'dgc-card'},
|
|
414
|
+
h('div',{className:'dgc-line'},grip,
|
|
415
|
+
h('span',{className:'dgc-branch',title:data?.branch??'Git…'},data?.branch??'Git…'),
|
|
416
|
+
h('button',{type:'button',className:'dgc-fold',title:'Collapse Git commit','aria-label':'Collapse Git commit',
|
|
417
|
+
onClick:fold},actionIcon('collapse'))),
|
|
418
|
+
h('div',{className:'dgc-numbers'},h('span',{className:'dgc-add'},`+${data?.additions??0}`),h('span',{className:'dgc-del'},`−${data?.deletions??0}`)),
|
|
419
|
+
h('div',{className:'dgc-line dgc-actions'},
|
|
420
|
+
h('button',{type:'button',className:'dgc-button dgc-icon-button dgc-primary',disabled:working||!hasChanges,onClick:onCommit,
|
|
421
|
+
title:working?'Committing changes…':data?.stagedFiles?'Commit staged changes only':'Stage and commit all changes',
|
|
422
|
+
'aria-label':working?'Committing changes':data?.stagedFiles?'Commit staged changes only':'Stage and commit all changes'},actionIcon('commit')),
|
|
423
|
+
h('button',{type:'button',className:'dgc-button dgc-icon-button dgc-secondary',
|
|
424
|
+
title:'Commit settings','aria-label':'Commit settings',
|
|
425
|
+
onClick:()=>{if(data?.isRepository!==true)return;try{ctx.sidebarRight.openTab(TAB_KIND);}catch(error){setNotice(error.message);}}},actionIcon('settings'))),
|
|
426
|
+
problem?h('div',{className:'dgc-notice dgc-error'},problem):null,
|
|
427
|
+
notice?h('div',{className:'dgc-notice dgc-error',role:'alert',style:{whiteSpace:'pre-wrap'}},notice):null,
|
|
428
|
+
feedback?h('div',{key:feedback.id,className:'dgc-feedback',role:'status','aria-label':'Commit completed'},
|
|
429
|
+
h('div',{className:'dgc-result-head',title:feedback.heading},feedback.heading),
|
|
430
|
+
h('div',{className:'dgc-result-subject',title:feedback.subject},feedback.subject),
|
|
431
|
+
floatingPreference.showTokenDetails
|
|
432
|
+
? h('div',{className:'dgc-result-metrics',title:tokenLine(feedback.llm)},tokenLine(feedback.llm)) : null,
|
|
433
|
+
h('div',{className:'dgc-countdown-track',role:'progressbar','aria-label':'Commit notification countdown',
|
|
434
|
+
'aria-valuemin':0,'aria-valuemax':10},
|
|
435
|
+
h('div',{className:'dgc-countdown-fill',style:{
|
|
436
|
+
backgroundColor:floatingPreference.countdownColor,
|
|
437
|
+
animationDelay:`-${Math.min(10000,Math.max(0,10000 - (feedback.expiresAt - Date.now())))}ms`,
|
|
438
|
+
}}))) : null
|
|
439
|
+
)
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function CommitSettings() {
|
|
444
|
+
const cwd = React.useSyncExternalStore(workspace.subscribe, workspace.getSnapshot, workspace.getSnapshot);
|
|
445
|
+
const [form, setForm] = React.useState(null);
|
|
446
|
+
const [models, setModels] = React.useState([]);
|
|
447
|
+
const [defaultModel, setDefaultModel] = React.useState(null);
|
|
448
|
+
const [loadError, setLoadError] = React.useState('');
|
|
449
|
+
const [saveError, setSaveError] = React.useState('');
|
|
450
|
+
const saverRef = React.useRef(null);
|
|
451
|
+
React.useEffect(()=>{
|
|
452
|
+
let active = true;
|
|
453
|
+
setForm(null); setLoadError(''); setSaveError('');
|
|
454
|
+
// Global settings remain readable even when cwd is outside a repository.
|
|
455
|
+
void rpc('settings',{cwd}).then(value=>{if(active){setForm(value);visibility.set(value);floatingPosition.hydrate(value.floatingPosition);}},error=>{if(active)setLoadError(error.message);});
|
|
456
|
+
void rpc('models').then(catalog=>{
|
|
457
|
+
if (!active)return;
|
|
458
|
+
setModels(catalog.models??[]);setDefaultModel(catalog.defaultModel??null);
|
|
459
|
+
},error=>{if(active)setLoadError(error.message);});
|
|
460
|
+
return ()=>{active=false;};
|
|
461
|
+
},[cwd]);
|
|
462
|
+
React.useEffect(()=>{
|
|
463
|
+
let active = true;
|
|
464
|
+
const saver = createAutoSaver(patch=>rpc('saveSettings',{cwd,patch}),error=>{
|
|
465
|
+
if(active)setSaveError(error);
|
|
466
|
+
});
|
|
467
|
+
saverRef.current=saver;
|
|
468
|
+
return ()=>{
|
|
469
|
+
active=false;
|
|
470
|
+
if(saverRef.current===saver)saverRef.current=null;
|
|
471
|
+
saver.close(); // Flush the last unsubmitted keystrokes on closing the sidebar.
|
|
472
|
+
};
|
|
473
|
+
},[cwd]);
|
|
474
|
+
const update=(key,value,immediate=false)=>{
|
|
475
|
+
if (key === 'autoHideNonGit' || key === 'showTokenDetails' || key === 'countdownColor') {
|
|
476
|
+
visibility.set({ [key]: value });
|
|
477
|
+
}
|
|
478
|
+
setForm(current=>({...current,[key]:value}));
|
|
479
|
+
setSaveError('');
|
|
480
|
+
saverRef.current?.update(key,value,immediate);
|
|
481
|
+
};
|
|
482
|
+
const choice=form?.model?`${form.model.provider}\t${form.model.model}`:'';
|
|
483
|
+
const repository=form?.isRepository===true;
|
|
484
|
+
return h('div',{className:'dgc-settings'},
|
|
485
|
+
h('h3',null,'Git commit settings'),
|
|
486
|
+
!form? h('div',{className:'dgc-subtle'},loadError||'Loading…') : [
|
|
487
|
+
h('label',{className:'dgc-field',key:'global'},'Global commit instruction',
|
|
488
|
+
h('textarea',{value:form.globalInstruction,onChange:e=>update('globalInstruction',e.target.value),
|
|
489
|
+
onBlur:()=>{void saverRef.current?.flush();},placeholder:'Shared conventions for every repository'})),
|
|
490
|
+
h('label',{className:'dgc-field',key:'model'},'Model',h('select',{value:choice,onChange:e=>{
|
|
491
|
+
const value=e.target.value;
|
|
492
|
+
const item=models.find(m=>`${m.provider}\t${m.model}`===value);
|
|
493
|
+
update('model',item?{provider:item.provider,model:item.model}:null,true);
|
|
494
|
+
}},
|
|
495
|
+
h('option',{value:''},defaultModel ? `DSH default (${defaultModel.provider}/${defaultModel.model})` : 'DSH default'),
|
|
496
|
+
...models.map(model=>h('option',{key:`${model.provider}/${model.model}`,value:`${model.provider}\t${model.model}`},`${model.providerName} / ${model.name}`))
|
|
497
|
+
)),
|
|
498
|
+
h('hr',{key:'divider',className:'dgc-settings-divider'}),
|
|
499
|
+
repository ? [
|
|
500
|
+
h('label',{className:'dgc-check',key:'inherit'},
|
|
501
|
+
h('input',{type:'checkbox',checked:form.inheritGlobal,onChange:e=>update('inheritGlobal',e.target.checked,true)}),
|
|
502
|
+
'Inherit global instruction'),
|
|
503
|
+
h('label',{className:'dgc-field',key:'repo'},'Repository commit instruction',
|
|
504
|
+
h('textarea',{value:form.repositoryInstruction,onChange:e=>update('repositoryInstruction',e.target.value),
|
|
505
|
+
onBlur:()=>{void saverRef.current?.flush();},placeholder:'Repository-specific commit conventions'})),
|
|
506
|
+
h('code',{className:'dgc-repo-path',key:'path'},form.root),
|
|
507
|
+
] : h('div',{className:'dgc-subtle',key:'no-repo'},'Not a git repository'),
|
|
508
|
+
h('hr',{key:'visibilityDivider',className:'dgc-settings-divider'}),
|
|
509
|
+
h('label',{className:'dgc-check',key:'autoHideNonGit'},
|
|
510
|
+
h('input',{type:'checkbox',checked:form.autoHideNonGit===true,
|
|
511
|
+
onChange:e=>update('autoHideNonGit',e.target.checked,true)}),
|
|
512
|
+
'Automatically hide floating window in non-git projects'),
|
|
513
|
+
h('label',{className:'dgc-check',key:'showTokenDetails'},
|
|
514
|
+
h('input',{type:'checkbox',checked:form.showTokenDetails===true,
|
|
515
|
+
onChange:e=>update('showTokenDetails',e.target.checked,true)}),
|
|
516
|
+
'Show token details'),
|
|
517
|
+
h('label',{className:'dgc-countdown-color',key:'countdownColor'},
|
|
518
|
+
h('span',null,'Countdown color'),
|
|
519
|
+
h('input',{type:'color',value:colorOrDefault(form.countdownColor),
|
|
520
|
+
'aria-label':'Countdown color',title:'Countdown color',
|
|
521
|
+
onChange:e=>update('countdownColor',e.target.value,true)})),
|
|
522
|
+
h('hr',{key:'positionDivider',className:'dgc-settings-divider'}),
|
|
523
|
+
h('button',{type:'button',key:'resetPosition',className:'dgc-reset-position',
|
|
524
|
+
'aria-label':'Reset floating window position',title:'Reset floating window position',
|
|
525
|
+
onClick:()=>{
|
|
526
|
+
floatingPosition.set(DEFAULT_POSITION);
|
|
527
|
+
setSaveError('');
|
|
528
|
+
void persistPosition(DEFAULT_POSITION,cwd,setSaveError);
|
|
529
|
+
}},'Reset floating window position'),
|
|
530
|
+
],
|
|
531
|
+
saveError ? h('div',{className:'dgc-notice dgc-save-error',role:'alert'},saveError) : null,
|
|
532
|
+
form && loadError ? h('div',{className:'dgc-notice dgc-error',role:'alert'},loadError) : null
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
ctx.effect(() => ctx.sidebarRightTabs.register({ id:TAB_ID, kind:TAB_KIND, title:()=> 'Git commit settings', priority:'extension' }), 'dsh-git-commit: settings tab');
|
|
537
|
+
ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () =>
|
|
538
|
+
ctx.slots.register({name:'sidebar.right.pane.tab',key:TAB_ID},CommitSettings)), 'dsh-git-commit: settings body');
|
|
539
|
+
ctx.effect(() => ctx.slots.inject('shell.overlay', () =>
|
|
540
|
+
ctx.slots.register({name:'shell.overlay',id:'dsh-git-commit',order:45,label:'Git commit'},CommitPill)), 'dsh-git-commit: floating pill');
|
|
541
|
+
}
|
|
542
|
+
return { apply, inject: ['slots','connection','sidebarRight','sidebarRightTabs'] };
|
|
543
|
+
},
|
|
544
|
+
});
|