dsh-git-sidebar 0.1.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/LICENSE +21 -0
- package/README.md +44 -0
- package/README.zh.md +44 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.js +565 -0
- package/lib/index.js +358 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 adamaik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# dsh-git-sidebar
|
|
2
|
+
|
|
3
|
+
A draggable right-side Git sidebar for DeepSeek Harness Web. It follows the active workspace, lists changed files, generates commit messages with the model you are already using, and runs commit / push / pull.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Follows the active workspace** — the panel switches to the workspace of the conversation you select, resolving the git root automatically (walking up from the workspace path, or one level down for nested checkouts).
|
|
8
|
+
- **Draggable right-edge entry** — a vertical tab on the right edge of the window; drag it anywhere along the edge, click to toggle the panel.
|
|
9
|
+
- **Change list** — staged / unstaged groups with status badges (M/A/D/R/?), click a file to stage or unstage, stage-all toggle.
|
|
10
|
+
- **Branch switching** — a branch dropdown listing local and remote branches; checkout with a dirty-workspace warning before switching.
|
|
11
|
+
- **AI commit message** — ✨ generates a Conventional Commits style message (Chinese subject) from `git status` + `git diff HEAD` using the current session's default model via the harness `llm` service, then fills the message box for editing.
|
|
12
|
+
- **Actions** — commit (optionally with all changes), push, pull, refresh.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
dsh plugin --profile web add dsh-git-sidebar
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or install from this repository:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
dsh plugin --profile web add "github:adamaik/dsh-git-sidebar#main"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
The Git tab appears on the right edge of the window. Click it to open the panel; drag it to reposition. Select a conversation in the left sidebar to point the panel at that workspace.
|
|
29
|
+
|
|
30
|
+
## How it works
|
|
31
|
+
|
|
32
|
+
- Host half (`lib/index.js`) registers JSON routes on the `webServer` service and runs git commands through the `shell` service; the AI commit message endpoint calls `llm.stream` with the current default model selection.
|
|
33
|
+
- Browser half (`lib/client.js`) is a `dsh.client` module registered into `shell.overlay`; it fetches the host routes and renders the React UI.
|
|
34
|
+
|
|
35
|
+
## Development
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
node --check lib/index.js
|
|
39
|
+
node --check lib/client.js
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
MIT
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# dsh-git-sidebar
|
|
2
|
+
|
|
3
|
+
用于 DeepSeek Harness Web 的可拖拽右侧 Git 侧栏。跟随当前工作区、列出变更文件、用你正在使用的模型生成提交信息,并支持提交 / 推送 / 拉取。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- **跟随当前工作区**——面板自动切换到你选中的会话所属项目,自动解析 Git 仓库根目录(从工作区向上查找,找不到再向下扫一级子目录)。
|
|
8
|
+
- **可拖拽的右侧入口**——窗口右边缘的竖条按钮,可沿边缘上下拖动位置,点击开合面板。
|
|
9
|
+
- **变更列表**——按已暂存 / 未暂存分组,带状态徽标(M/A/D/R/?),点击文件即可暂存或取消暂存,支持一键全部暂存。
|
|
10
|
+
- **分支切换**——分支下拉框列出本地与远程分支;切换前若工作区有未提交变更会弹出警告。
|
|
11
|
+
- **AI 生成提交信息**——✨ 按钮基于 `git status` + `git diff HEAD`,调用当前会话默认模型(走 harness `llm` 服务)生成 Conventional Commits 风格的中文提交信息,并填入注释框供你编辑。
|
|
12
|
+
- **操作按钮**——提交(可选包含全部更改)、推送、拉取、刷新。
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
dsh plugin --profile web add dsh-git-sidebar
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
或从本仓库安装:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
dsh plugin --profile web add "github:adamaik/dsh-git-sidebar#main"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 使用
|
|
27
|
+
|
|
28
|
+
窗口右边缘出现 Git 竖条按钮:点击打开面板,按住可上下拖动。在左侧栏选择某工作区的会话,面板即切换到该项目的 git 状态。
|
|
29
|
+
|
|
30
|
+
## 原理
|
|
31
|
+
|
|
32
|
+
- **宿主半**(`lib/index.js`):通过 `webServer` 服务注册 JSON 路由,用 `shell` 服务执行 git 命令;AI 生成提交信息的接口通过 `llm.stream` 调用当前默认模型。
|
|
33
|
+
- **浏览器半**(`lib/client.js`):一个注册到 `shell.overlay` 的 `dsh.client` 模块,通过 fetch 调用宿主路由并渲染 React UI。
|
|
34
|
+
|
|
35
|
+
## 开发
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
node --check lib/index.js
|
|
39
|
+
node --check lib/client.js
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# dsh-git-sidebar bundle patch: mount the draggable Git sidebar plugin.
|
|
2
|
+
#
|
|
3
|
+
# The host half (package root) runs git commands and registers the
|
|
4
|
+
# package-private RPC handlers; the browser half (dsh.client) is discovered
|
|
5
|
+
# automatically through the package's dsh.client declaration + exports["./client"].
|
|
6
|
+
|
|
7
|
+
- insert:
|
|
8
|
+
- id: git-sidebar
|
|
9
|
+
name: 'dsh-git-sidebar'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-git-sidebar",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
|
|
9
|
+
const API_BASE = "/git-sidebar";
|
|
10
|
+
|
|
11
|
+
async function api(path, body) {
|
|
12
|
+
const res = await fetch(`${API_BASE}/${path}`, {
|
|
13
|
+
method: "POST",
|
|
14
|
+
headers: { "content-type": "application/json" },
|
|
15
|
+
body: body === undefined ? "{}" : JSON.stringify(body),
|
|
16
|
+
});
|
|
17
|
+
return res.json();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 跨组件共享开合状态:按钮(写)与面板(读)同步。
|
|
21
|
+
const store = { open: false, listeners: new Set() };
|
|
22
|
+
function setOpen(v) {
|
|
23
|
+
store.open = v;
|
|
24
|
+
for (const fn of store.listeners) fn();
|
|
25
|
+
}
|
|
26
|
+
function useOpen() {
|
|
27
|
+
const [open, set] = react.useState(store.open);
|
|
28
|
+
react.useEffect(() => {
|
|
29
|
+
const fn = () => set(store.open);
|
|
30
|
+
store.listeners.add(fn);
|
|
31
|
+
return () => store.listeners.delete(fn);
|
|
32
|
+
}, []);
|
|
33
|
+
return open;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function GitIcon() {
|
|
37
|
+
return react.createElement(
|
|
38
|
+
"svg",
|
|
39
|
+
{ width: 15, height: 15, viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": true },
|
|
40
|
+
react.createElement("path", { d: "M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" })
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function FabButton() {
|
|
45
|
+
const open = useOpen();
|
|
46
|
+
const [top, setTop] = react.useState(null);
|
|
47
|
+
const [dragging, setDragging] = react.useState(false);
|
|
48
|
+
const dragRef = react.useRef(null);
|
|
49
|
+
|
|
50
|
+
const onMouseDown = react.useCallback((e) => {
|
|
51
|
+
if (e.button !== 0) return;
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
const startTop = top === null ? (window.innerHeight - 92) / 2 : top;
|
|
54
|
+
dragRef.current = { startY: e.clientY, startTop, moved: false };
|
|
55
|
+
const onMove = (ev) => {
|
|
56
|
+
const d = dragRef.current;
|
|
57
|
+
if (!d) return;
|
|
58
|
+
const dy = ev.clientY - d.startY;
|
|
59
|
+
if (Math.abs(dy) > 2) d.moved = true;
|
|
60
|
+
const maxTop = window.innerHeight - 92 - 8;
|
|
61
|
+
setTop(Math.max(4, Math.min(d.startTop + dy, maxTop)));
|
|
62
|
+
};
|
|
63
|
+
const onUp = () => {
|
|
64
|
+
dragRef.current = null;
|
|
65
|
+
setDragging(false);
|
|
66
|
+
window.removeEventListener("mousemove", onMove);
|
|
67
|
+
window.removeEventListener("mouseup", onUp);
|
|
68
|
+
};
|
|
69
|
+
setDragging(true);
|
|
70
|
+
window.addEventListener("mousemove", onMove);
|
|
71
|
+
window.addEventListener("mouseup", onUp);
|
|
72
|
+
}, [top]);
|
|
73
|
+
|
|
74
|
+
const onClick = () => {
|
|
75
|
+
const d = dragRef.current;
|
|
76
|
+
if (d && d.moved) return;
|
|
77
|
+
setOpen(!open);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return react.createElement(
|
|
81
|
+
"button",
|
|
82
|
+
{
|
|
83
|
+
className: "dshgit-fab" + (open ? " active" : "") + (dragging ? " dragging" : ""),
|
|
84
|
+
style: top === null ? undefined : { top: `${top}px` },
|
|
85
|
+
title: open ? "关闭 Git 面板" : "打开 Git 面板",
|
|
86
|
+
onMouseDown,
|
|
87
|
+
onClick,
|
|
88
|
+
"aria-label": "Git 面板",
|
|
89
|
+
},
|
|
90
|
+
react.createElement(GitIcon, null),
|
|
91
|
+
react.createElement("span", { className: "dshgit-fab-label" }, "Git")
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function GitPanel(props) {
|
|
96
|
+
const [status, setStatus] = react.useState(null);
|
|
97
|
+
const [loading, setLoading] = react.useState(false);
|
|
98
|
+
const [msg, setMsg] = react.useState("");
|
|
99
|
+
const [gen, setGen] = react.useState(false);
|
|
100
|
+
const [busy, setBusy] = react.useState("");
|
|
101
|
+
const [err, setErr] = react.useState("");
|
|
102
|
+
const [ok, setOk] = react.useState("");
|
|
103
|
+
const [all, setAll] = react.useState(true);
|
|
104
|
+
const [branchOpen, setBranchOpen] = react.useState(false);
|
|
105
|
+
const [branches, setBranches] = react.useState(null);
|
|
106
|
+
const [pendingCheckout, setPendingCheckout] = react.useState(null);
|
|
107
|
+
|
|
108
|
+
// 标准 props:useSessions selector hook(框架提供,非注入服务)。
|
|
109
|
+
// 会话变化会触发本组件重渲染,useSessions 返回最新 current/row。
|
|
110
|
+
const useSessions = props && typeof props.useSessions === "function" ? props.useSessions : undefined;
|
|
111
|
+
const sessionRow = useSessions
|
|
112
|
+
? useSessions((s) => (s && s.current && s.byId ? s.byId[s.current] : undefined))
|
|
113
|
+
: undefined;
|
|
114
|
+
const cwd = useSessions ? (sessionRow && sessionRow.cwd) || "" : "";
|
|
115
|
+
|
|
116
|
+
const refresh = react.useCallback(async () => {
|
|
117
|
+
setLoading(true);
|
|
118
|
+
setErr("");
|
|
119
|
+
setOk("");
|
|
120
|
+
try {
|
|
121
|
+
const r = await api("status", { cwd });
|
|
122
|
+
if (r && r.ok) setStatus(r);
|
|
123
|
+
else setErr((r && r.error) || "获取状态失败");
|
|
124
|
+
} catch (e) {
|
|
125
|
+
setErr(String((e && e.message) || e));
|
|
126
|
+
}
|
|
127
|
+
setLoading(false);
|
|
128
|
+
}, [cwd]);
|
|
129
|
+
|
|
130
|
+
// 会话/工作区切换后自动刷新到新目录
|
|
131
|
+
react.useEffect(() => {
|
|
132
|
+
refresh();
|
|
133
|
+
}, [refresh]);
|
|
134
|
+
|
|
135
|
+
function loadBranches() {
|
|
136
|
+
setBranches(null);
|
|
137
|
+
api("branches", { cwd: cwd })
|
|
138
|
+
.then((r) => {
|
|
139
|
+
if (r && r.ok) setBranches(r);
|
|
140
|
+
else if (r && !r.ok) setErr((r && r.error) || "获取分支失败");
|
|
141
|
+
})
|
|
142
|
+
.catch((e) => {
|
|
143
|
+
setBranches(null);
|
|
144
|
+
setErr(String((e && e.message) || e));
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function toggleBranch() {
|
|
149
|
+
if (branchOpen) {
|
|
150
|
+
setBranchOpen(false);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
setBranchOpen(true);
|
|
154
|
+
loadBranches();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function pickBranch(b) {
|
|
158
|
+
setBranchOpen(false);
|
|
159
|
+
if (status && b.name === status.branch) return;
|
|
160
|
+
const dirty = status && status.files && status.files.length > 0;
|
|
161
|
+
if (dirty) {
|
|
162
|
+
setPendingCheckout(b);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
doCheckout(b);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function doCheckout(b) {
|
|
169
|
+
setBusy("切换");
|
|
170
|
+
setErr("");
|
|
171
|
+
setOk("");
|
|
172
|
+
api("checkout", { name: b.name, isRemote: !!b.isRemote, cwd: cwd })
|
|
173
|
+
.then(async (r) => {
|
|
174
|
+
if (r && r.ok) {
|
|
175
|
+
if (r.notice) setOk(r.notice);
|
|
176
|
+
const [s, br] = await Promise.all([
|
|
177
|
+
api("status", { cwd: cwd }),
|
|
178
|
+
api("branches", { cwd: cwd }),
|
|
179
|
+
]);
|
|
180
|
+
if (s && s.ok) setStatus(s);
|
|
181
|
+
if (br && br.ok) setBranches(br);
|
|
182
|
+
setPendingCheckout(null);
|
|
183
|
+
} else {
|
|
184
|
+
setErr((r && r.error) || "切换分支失败");
|
|
185
|
+
setPendingCheckout(null);
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
.catch((e) => {
|
|
189
|
+
setErr(String((e && e.message) || e));
|
|
190
|
+
setPendingCheckout(null);
|
|
191
|
+
})
|
|
192
|
+
.finally(() => setBusy(""));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function doOp(label, fn) {
|
|
196
|
+
setBusy(label);
|
|
197
|
+
setErr("");
|
|
198
|
+
setOk("");
|
|
199
|
+
Promise.resolve(fn())
|
|
200
|
+
.then(async (r) => {
|
|
201
|
+
if (r && r.ok) {
|
|
202
|
+
if (r.notice) setOk(r.notice);
|
|
203
|
+
try {
|
|
204
|
+
const s = await api("status", { cwd: cwd });
|
|
205
|
+
if (s && s.ok) setStatus(s);
|
|
206
|
+
} catch (e) {}
|
|
207
|
+
} else {
|
|
208
|
+
setErr((r && r.error) || label + "失败");
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
212
|
+
.finally(() => setBusy(""));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function doGenerate() {
|
|
216
|
+
setGen(true);
|
|
217
|
+
setErr("");
|
|
218
|
+
setOk("");
|
|
219
|
+
api("generate", { cwd: cwd })
|
|
220
|
+
.then((r) => {
|
|
221
|
+
if (r && r.ok) {
|
|
222
|
+
setMsg(r.message);
|
|
223
|
+
setOk("已生成,可直接编辑后提交");
|
|
224
|
+
} else {
|
|
225
|
+
setErr((r && r.error) || "生成失败");
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
229
|
+
.finally(() => setGen(false));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function toggleFile(f) {
|
|
233
|
+
doOp(f.staged ? "取消暂存" : "暂存", () =>
|
|
234
|
+
api("stage", { paths: [f.path], staged: !f.staged, cwd: cwd }));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function commit() {
|
|
238
|
+
doOp("提交", () => api("commit", { message: msg, all, cwd: cwd }));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function badgeLetter(f) {
|
|
242
|
+
if (f.untracked) return "?";
|
|
243
|
+
if (f.staged && f.code[0] !== " ") return f.code[0];
|
|
244
|
+
if (f.code[1] !== " " && f.code[1] !== "?") return f.code[1];
|
|
245
|
+
return f.code[0];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function badgeCls(f) {
|
|
249
|
+
const L = badgeLetter(f);
|
|
250
|
+
const cls = { M: "M", A: "A", D: "D", R: "R", C: "C", U: "U" }[L];
|
|
251
|
+
return cls ? " dshgit-badge-" + cls : "";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function FileRow(f) {
|
|
255
|
+
return react.createElement(
|
|
256
|
+
"button",
|
|
257
|
+
{
|
|
258
|
+
key: f.path,
|
|
259
|
+
className: "dshgit-file" + (f.staged ? " staged" : ""),
|
|
260
|
+
onClick: () => toggleFile(f),
|
|
261
|
+
title: (f.staged ? "取消暂存:" : "暂存:") + f.path,
|
|
262
|
+
},
|
|
263
|
+
react.createElement("span", { className: "dshgit-badge" + badgeCls(f) }, badgeLetter(f)),
|
|
264
|
+
react.createElement("span", { className: "dshgit-filepath" }, f.path)
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const files = (status && status.files) || [];
|
|
269
|
+
const staged = files.filter((f) => f.staged);
|
|
270
|
+
const rest = files.filter((f) => !f.staged);
|
|
271
|
+
const dirtyCount = files.length;
|
|
272
|
+
|
|
273
|
+
const branchDesc =
|
|
274
|
+
!status
|
|
275
|
+
? "加载中…"
|
|
276
|
+
: status.upstream
|
|
277
|
+
? "→ " + status.upstream.split("/").slice(1).join("/") +
|
|
278
|
+
(status.ahead || status.behind
|
|
279
|
+
? " (" + (status.ahead ? "领先 " + status.ahead : "") +
|
|
280
|
+
(status.ahead && status.behind ? " / " : "") +
|
|
281
|
+
(status.behind ? "落后 " + status.behind : "") + ")"
|
|
282
|
+
: "")
|
|
283
|
+
: "(无上游分支)";
|
|
284
|
+
|
|
285
|
+
const branchItems = (branches && branches.branches) || null;
|
|
286
|
+
|
|
287
|
+
return react.createElement(
|
|
288
|
+
"div",
|
|
289
|
+
null,
|
|
290
|
+
react.createElement("div", { className: "dshgit-scrim", onClick: () => setOpen(false) }),
|
|
291
|
+
react.createElement(
|
|
292
|
+
"div",
|
|
293
|
+
{ className: "dshgit-panel" },
|
|
294
|
+
react.createElement(
|
|
295
|
+
"div",
|
|
296
|
+
{ className: "dshgit-head" },
|
|
297
|
+
react.createElement(
|
|
298
|
+
"div",
|
|
299
|
+
{ className: "dshgit-title" },
|
|
300
|
+
react.createElement(GitIcon, null),
|
|
301
|
+
react.createElement("span", null, "Git 面板")
|
|
302
|
+
),
|
|
303
|
+
react.createElement("button", { className: "dshgit-close", title: "关闭", onClick: () => setOpen(false) }, "✕")
|
|
304
|
+
),
|
|
305
|
+
react.createElement(
|
|
306
|
+
"div",
|
|
307
|
+
{ className: "dshgit-meta" },
|
|
308
|
+
react.createElement(
|
|
309
|
+
"div",
|
|
310
|
+
{ className: "dshgit-branch" },
|
|
311
|
+
react.createElement(
|
|
312
|
+
"button",
|
|
313
|
+
{ className: "dshgit-branch-chip", title: "切换分支", onClick: toggleBranch },
|
|
314
|
+
react.createElement("span", null, status ? status.branch : "…"),
|
|
315
|
+
react.createElement("span", { className: "dshgit-caret" }, "▼")
|
|
316
|
+
),
|
|
317
|
+
react.createElement("span", { className: "dshgit-branch-desc" }, branchDesc),
|
|
318
|
+
branchOpen && branchItems
|
|
319
|
+
? react.createElement(
|
|
320
|
+
"div",
|
|
321
|
+
{ className: "dshgit-dropdown" },
|
|
322
|
+
branchItems.map((b) =>
|
|
323
|
+
react.createElement(
|
|
324
|
+
"button",
|
|
325
|
+
{
|
|
326
|
+
key: (b.isRemote ? "r:" : "l:") + b.name,
|
|
327
|
+
className:
|
|
328
|
+
"dshgit-drop-item" +
|
|
329
|
+
(status && b.name === status.branch ? " active" : "") +
|
|
330
|
+
(b.isRemote ? " remote" : ""),
|
|
331
|
+
onClick: () => pickBranch(b),
|
|
332
|
+
},
|
|
333
|
+
react.createElement("span", null, b.name),
|
|
334
|
+
status && b.name === status.branch
|
|
335
|
+
? react.createElement("span", { className: "dshgit-drop-tag" }, "当前")
|
|
336
|
+
: null,
|
|
337
|
+
b.isRemote ? react.createElement("span", { className: "dshgit-drop-tag" }, "remote") : null
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
: null
|
|
342
|
+
),
|
|
343
|
+
pendingCheckout
|
|
344
|
+
? react.createElement(
|
|
345
|
+
"div",
|
|
346
|
+
{ className: "dshgit-warn" },
|
|
347
|
+
react.createElement("span", null, `工作区有 ${dirtyCount} 个未提交变更,切换分支可能丢失,确定继续?`),
|
|
348
|
+
react.createElement(
|
|
349
|
+
"span",
|
|
350
|
+
{ className: "dshgit-warnbtns" },
|
|
351
|
+
react.createElement("button", { className: "dshgit-btn dshgit-btn-danger", disabled: !!busy, onClick: () => doCheckout(pendingCheckout) }, "确认切换"),
|
|
352
|
+
react.createElement("button", { className: "dshgit-btn", disabled: !!busy, onClick: () => setPendingCheckout(null) }, "取消")
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
: null,
|
|
356
|
+
react.createElement("div", { className: "dshgit-rootpath", title: status ? status.root : "" }, status ? status.root : "")
|
|
357
|
+
),
|
|
358
|
+
react.createElement(
|
|
359
|
+
"div",
|
|
360
|
+
{ className: "dshgit-toolbar" },
|
|
361
|
+
react.createElement("button", { className: "dshgit-btn", disabled: !!busy, onClick: refresh }, "刷新"),
|
|
362
|
+
react.createElement(
|
|
363
|
+
"button",
|
|
364
|
+
{
|
|
365
|
+
className: "dshgit-btn",
|
|
366
|
+
disabled: !!busy || !files.length,
|
|
367
|
+
onClick: () =>
|
|
368
|
+
doOp("全部操作", () =>
|
|
369
|
+
api("stage", { paths: files.map((f) => f.path), staged: rest.length > 0, cwd: cwd })),
|
|
370
|
+
},
|
|
371
|
+
rest.length > 0 ? "全部暂存" : "全部取消"
|
|
372
|
+
)
|
|
373
|
+
),
|
|
374
|
+
react.createElement(
|
|
375
|
+
"div",
|
|
376
|
+
{ className: "dshgit-files" },
|
|
377
|
+
!status && loading
|
|
378
|
+
? react.createElement("div", { className: "dshgit-nofiles" }, "加载中…")
|
|
379
|
+
: status && files.length === 0
|
|
380
|
+
? react.createElement("div", { className: "dshgit-nofiles" }, "工作区干净,没有变更 ✓")
|
|
381
|
+
: react.createElement(
|
|
382
|
+
react.Fragment,
|
|
383
|
+
null,
|
|
384
|
+
staged.length
|
|
385
|
+
? react.createElement(
|
|
386
|
+
react.Fragment,
|
|
387
|
+
null,
|
|
388
|
+
react.createElement("div", { className: "dshgit-section-title" }, "已暂存 ( " + staged.length + " )"),
|
|
389
|
+
staged.map(FileRow)
|
|
390
|
+
)
|
|
391
|
+
: null,
|
|
392
|
+
rest.length
|
|
393
|
+
? react.createElement(
|
|
394
|
+
react.Fragment,
|
|
395
|
+
null,
|
|
396
|
+
react.createElement("div", { className: "dshgit-section-title" }, "未暂存 ( " + rest.length + " )"),
|
|
397
|
+
rest.map(FileRow)
|
|
398
|
+
)
|
|
399
|
+
: null
|
|
400
|
+
)
|
|
401
|
+
),
|
|
402
|
+
react.createElement(
|
|
403
|
+
"div",
|
|
404
|
+
{ className: "dshgit-commitbox" },
|
|
405
|
+
react.createElement("textarea", {
|
|
406
|
+
className: "dshgit-msg",
|
|
407
|
+
placeholder: "输入提交信息…(或点击 ✨ 自动生成)",
|
|
408
|
+
value: msg,
|
|
409
|
+
onChange: (e) => setMsg(e.target.value),
|
|
410
|
+
}),
|
|
411
|
+
react.createElement(
|
|
412
|
+
"div",
|
|
413
|
+
{ className: "dshgit-genrow" },
|
|
414
|
+
react.createElement(
|
|
415
|
+
"button",
|
|
416
|
+
{ className: "dshgit-btn dshgit-btn-gen", disabled: gen || !!busy || !status || status.clean, onClick: doGenerate },
|
|
417
|
+
gen ? "生成中…" : "✨ 自动生成"
|
|
418
|
+
),
|
|
419
|
+
react.createElement(
|
|
420
|
+
"label",
|
|
421
|
+
null,
|
|
422
|
+
react.createElement("input", { type: "checkbox", checked: all, onChange: (e) => setAll(e.target.checked) }),
|
|
423
|
+
"提交时包含全部更改"
|
|
424
|
+
)
|
|
425
|
+
),
|
|
426
|
+
react.createElement(
|
|
427
|
+
"div",
|
|
428
|
+
{ className: "dshgit-actions" },
|
|
429
|
+
react.createElement(
|
|
430
|
+
"button",
|
|
431
|
+
{ className: "dshgit-btn dshgit-btn-primary", disabled: busy === "提交" || !msg.trim(), onClick: commit },
|
|
432
|
+
busy === "提交" ? "提交中…" : "提交"
|
|
433
|
+
),
|
|
434
|
+
react.createElement(
|
|
435
|
+
"button",
|
|
436
|
+
{ className: "dshgit-btn", disabled: !!busy, onClick: () => doOp("推送", () => api("push", { cwd: cwd })) },
|
|
437
|
+
busy === "推送" ? "推送中…" : "推送"
|
|
438
|
+
),
|
|
439
|
+
react.createElement(
|
|
440
|
+
"button",
|
|
441
|
+
{ className: "dshgit-btn", disabled: !!busy, onClick: () => doOp("拉取", () => api("pull", { cwd: cwd })) },
|
|
442
|
+
busy === "拉取" ? "拉取中…" : "拉取"
|
|
443
|
+
)
|
|
444
|
+
),
|
|
445
|
+
ok ? react.createElement("div", { className: "dshgit-status ok" }, ok) : null,
|
|
446
|
+
err ? react.createElement("div", { className: "dshgit-status err" }, err) : null,
|
|
447
|
+
loading && status ? react.createElement("div", { className: "dshgit-status load" }, "刷新中…") : null
|
|
448
|
+
)
|
|
449
|
+
)
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function SidebarToggle() {
|
|
454
|
+
const open = useOpen();
|
|
455
|
+
return react.createElement(
|
|
456
|
+
"button",
|
|
457
|
+
{ className: "dshgit-toggle" + (open ? " active" : ""), title: "Git 面板", onClick: () => setOpen(true) },
|
|
458
|
+
react.createElement(GitIcon, null)
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const inject = ["slots"];
|
|
463
|
+
|
|
464
|
+
function apply(ctx) {
|
|
465
|
+
// 注入样式表(插件级样式,卸载自动移除)
|
|
466
|
+
const tag = document.createElement("style");
|
|
467
|
+
tag.dataset.plugin = "dsh-git-sidebar";
|
|
468
|
+
tag.textContent = `
|
|
469
|
+
.dshgit-layer{pointer-events:none}
|
|
470
|
+
.dshgit-fab{position:fixed;right:0;top:50%;pointer-events:auto;z-index:2147483000;width:32px;height:92px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;border:none;border-radius:9px 0 0 9px;border-right:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.1));background:var(--dsw-alias-bg-layer-1,#1e1f22);color:var(--dsw-alias-label-secondary,#9aa0a6);cursor:grab;box-shadow:-4px 0 14px rgba(0,0,0,.25);font-family:inherit;user-select:none;touch-action:none}
|
|
471
|
+
.dshgit-fab:active{cursor:grabbing}
|
|
472
|
+
.dshgit-fab:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-bg-overlay,#26272b)}
|
|
473
|
+
.dshgit-fab.active{color:var(--dsw-alias-brand-primary,#7aa5ff);background:rgba(76,141,255,.14);border-color:var(--dsw-alias-brand-primary,#4c8dff)}
|
|
474
|
+
.dshgit-fab-label{writing-mode:vertical-rl;font-size:10.5px;letter-spacing:2px;font-weight:600;pointer-events:none}
|
|
475
|
+
.dshgit-scrim{position:fixed;inset:0;pointer-events:auto;background:rgba(0,0,0,.35)}
|
|
476
|
+
.dshgit-panel{position:fixed;top:0;right:0;bottom:0;width:400px;max-width:88vw;pointer-events:auto;background:var(--dsw-alias-bg-layer-1,#1e1f22);border-left:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.1));display:flex;flex-direction:column;box-shadow:-8px 0 24px rgba(0,0,0,.25);animation:dshgitIn .18s ease-out;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif}
|
|
477
|
+
@keyframes dshgitIn{from{transform:translateX(24px);opacity:0}to{transform:none;opacity:1}}
|
|
478
|
+
.dshgit-head{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08))}
|
|
479
|
+
.dshgit-title{font-weight:600;font-size:13px;color:var(--dsw-alias-label-primary,#e6e8eb);flex:1;display:flex;align-items:center;gap:7px}
|
|
480
|
+
.dshgit-close{background:none;border:none;color:var(--dsw-alias-label-secondary,#9aa0a6);cursor:pointer;font-size:15px;line-height:1;padding:3px 8px;border-radius:4px}
|
|
481
|
+
.dshgit-close:hover{background:rgba(127,127,127,.15);color:var(--dsw-alias-label-primary,#e6e8eb)}
|
|
482
|
+
.dshgit-meta{padding:8px 14px 10px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));display:flex;flex-direction:column;gap:6px}
|
|
483
|
+
.dshgit-branch{display:flex;align-items:center;gap:8px;font-weight:600;font-size:12.5px;color:var(--dsw-alias-label-primary,#e6e8eb);position:relative;align-items:flex-start}
|
|
484
|
+
.dshgit-branch-chip{border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.16));border-radius:999px;padding:1px 9px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:500;display:inline-flex;align-items:center;gap:4px;cursor:pointer;background:none;color:var(--dsw-alias-label-primary,#e6e8eb);flex-shrink:0;white-space:nowrap;max-width:200px;overflow:hidden;text-overflow:ellipsis}
|
|
485
|
+
.dshgit-branch-chip:hover{border-color:var(--dsw-alias-brand-primary,#4c8dff);color:var(--dsw-alias-brand-primary,#7aa5ff)}
|
|
486
|
+
.dshgit-caret{font-size:9px;opacity:.75}
|
|
487
|
+
.dshgit-branch-desc{font-size:11px;color:var(--dsw-alias-label-secondary,#9aa0a6);word-break:break-all;line-height:1.5;min-width:0;flex:1}
|
|
488
|
+
.dshgit-dropdown{position:absolute;top:26px;left:0;z-index:10;min-width:220px;max-width:320px;max-height:260px;overflow-y:auto;background:var(--dsw-alias-bg-overlay,#26272b);border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.16));border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,.4);padding:4px}
|
|
489
|
+
.dshgit-drop-item{display:flex;align-items:center;gap:6px;width:100%;padding:5px 8px;border:none;border-radius:5px;background:none;cursor:pointer;color:var(--dsw-alias-label-primary,#e6e8eb);font-size:12px;text-align:left;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}
|
|
490
|
+
.dshgit-drop-item:hover{background:rgba(127,127,127,.15)}
|
|
491
|
+
.dshgit-drop-item.active{color:var(--dsw-alias-brand-primary,#7aa5ff);font-weight:600}
|
|
492
|
+
.dshgit-drop-item.remote{opacity:.8}
|
|
493
|
+
.dshgit-drop-tag{font-size:9px;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.2));border-radius:4px;padding:0 4px;color:var(--dsw-alias-label-secondary,#9aa0a6);margin-left:auto;flex-shrink:0}
|
|
494
|
+
.dshgit-rootpath{font-size:11px;color:var(--dsw-alias-label-secondary,#9aa0a6);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
495
|
+
.dshgit-warn{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:6px;padding:7px 9px;border:1px solid var(--dsw-alias-state-warn-primary,#e5a50a);border-radius:7px;background:rgba(229,165,10,.08);font-size:11.5px;color:var(--dsw-alias-state-warn-primary,#e5a50a)}
|
|
496
|
+
.dshgit-warnbtns{display:flex;gap:6px;margin-left:auto}
|
|
497
|
+
.dshgit-toolbar{display:flex;gap:6px;padding:8px 14px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));flex-wrap:wrap}
|
|
498
|
+
.dshgit-btn{background:var(--dsw-alias-bg-overlay,#26272b);border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.12));color:var(--dsw-alias-label-primary,#e6e8eb);border-radius:6px;padding:4px 11px;font-size:12px;cursor:pointer}
|
|
499
|
+
.dshgit-btn:hover:not(:disabled){background:rgba(127,127,127,.18)}
|
|
500
|
+
.dshgit-btn:disabled{opacity:.45;cursor:default}
|
|
501
|
+
.dshgit-btn-primary{background:var(--dsw-alias-brand-primary,#4c8dff);border-color:transparent;color:#fff;font-weight:600}
|
|
502
|
+
.dshgit-btn-danger{border-color:var(--dsw-alias-state-warn-primary,#e5a50a);color:var(--dsw-alias-state-warn-primary,#e5a50a)}
|
|
503
|
+
.dshgit-btn-gen{border-color:var(--dsw-alias-brand-primary,#4c8dff);color:var(--dsw-alias-brand-primary,#7aa5ff)}
|
|
504
|
+
.dshgit-files{flex:1;overflow-y:auto;min-height:0}
|
|
505
|
+
.dshgit-section-title{padding:6px 14px 2px;font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary,#9aa0a6)}
|
|
506
|
+
.dshgit-file{display:flex;align-items:center;gap:8px;width:100%;padding:4px 14px;background:none;border:none;cursor:pointer;color:var(--dsw-alias-label-primary,#e6e8eb);text-align:left}
|
|
507
|
+
.dshgit-file:hover{background:rgba(127,127,127,.12)}
|
|
508
|
+
.dshgit-file.staged{opacity:.82}
|
|
509
|
+
.dshgit-badge{display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:18px;border-radius:4px;font-size:10.5px;font-weight:700;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:rgba(127,127,127,.22);color:var(--dsw-alias-label-secondary,#c8ccd2)}
|
|
510
|
+
.dshgit-badge.M{color:var(--dsw-alias-state-warn-primary,#e5a50a)}
|
|
511
|
+
.dshgit-badge.A{color:var(--dsw-alias-state-success-primary,#3fb950)}
|
|
512
|
+
.dshgit-badge.D{color:var(--dsw-alias-state-error-primary,#f85149)}
|
|
513
|
+
.dshgit-badge.R,.dshgit-badge.C{color:var(--dsw-alias-brand-primary,#7aa5ff)}
|
|
514
|
+
.dshgit-badge.U{color:var(--dsw-alias-state-error-primary,#f85149)}
|
|
515
|
+
.dshgit-filepath{font-size:12.5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
|
516
|
+
.dshgit-nofiles{padding:18px 14px;font-size:12px;color:var(--dsw-alias-label-secondary,#9aa0a6)}
|
|
517
|
+
.dshgit-commitbox{border-top:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));padding:10px 14px;display:flex;flex-direction:column;gap:8px}
|
|
518
|
+
.dshgit-msg{width:100%;box-sizing:border-box;min-height:74px;resize:vertical;background:var(--dsw-alias-bg-base,#141518);border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.14));border-radius:8px;color:var(--dsw-alias-label-primary,#e6e8eb);font-size:13px;font-family:inherit;padding:8px 10px;outline:none}
|
|
519
|
+
.dshgit-msg:focus{border-color:var(--dsw-alias-brand-primary,#4c8dff)}
|
|
520
|
+
.dshgit-genrow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
521
|
+
.dshgit-genrow label{display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--dsw-alias-label-secondary,#9aa0a6);cursor:pointer}
|
|
522
|
+
.dshgit-actions{display:flex;gap:6px;align-items:center}
|
|
523
|
+
.dshgit-actions .dshgit-btn{flex:1;padding:6px 8px;font-size:12.5px}
|
|
524
|
+
.dshgit-status{font-size:11.5px;line-height:1.45;word-break:break-word;max-height:72px;overflow-y:auto}
|
|
525
|
+
.dshgit-status.err{color:var(--dsw-alias-state-error-primary,#f85149)}
|
|
526
|
+
.dshgit-status.ok{color:var(--dsw-alias-state-success-primary,#3fb950)}
|
|
527
|
+
.dshgit-status.load{color:var(--dsw-alias-label-secondary,#9aa0a6)}
|
|
528
|
+
.dshgit-toggle{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:none;color:var(--dsw-alias-label-secondary,#9aa0a6);cursor:pointer}
|
|
529
|
+
.dshgit-toggle:hover{background:rgba(127,127,127,.15);color:var(--dsw-alias-label-primary,#e6e8eb)}
|
|
530
|
+
.dshgit-toggle.active{color:var(--dsw-alias-brand-primary,#7aa5ff);background:rgba(76,141,255,.15)}
|
|
531
|
+
`;
|
|
532
|
+
ctx.effect(() => {
|
|
533
|
+
document.head.append(tag);
|
|
534
|
+
return () => tag.remove();
|
|
535
|
+
}, "dsh-git-sidebar: styles");
|
|
536
|
+
|
|
537
|
+
const slots = ctx.get("slots");
|
|
538
|
+
if (slots === undefined) return;
|
|
539
|
+
|
|
540
|
+
// 根层组件:按钮常驻,面板仅在 open 时挂载(避免 scrim/面板常驻全屏挡住交互)
|
|
541
|
+
function GitLayer(props) {
|
|
542
|
+
const open = useOpen();
|
|
543
|
+
return react.createElement(
|
|
544
|
+
"div",
|
|
545
|
+
{ className: "dshgit-layer" },
|
|
546
|
+
react.createElement(FabButton, null),
|
|
547
|
+
open ? react.createElement(GitPanel, props) : null
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
slots.inject(
|
|
552
|
+
"shell.overlay",
|
|
553
|
+
() =>
|
|
554
|
+
slots.register(
|
|
555
|
+
{ name: "shell.overlay", id: "dsh-git-sidebar", order: 300, label: "Git 面板" },
|
|
556
|
+
(props) => react.createElement(GitLayer, props)
|
|
557
|
+
)
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
exports.inject = inject;
|
|
562
|
+
exports.apply = apply;
|
|
563
|
+
return module.exports;
|
|
564
|
+
},
|
|
565
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-git-sidebar — draggable right-side Git sidebar (host half)
|
|
3
|
+
*
|
|
4
|
+
* Registers JSON endpoints served to the browser:
|
|
5
|
+
* /git-sidebar/status — branch, upstream, ahead/behind, changed files
|
|
6
|
+
* /git-sidebar/branches — local + remote branch list
|
|
7
|
+
* /git-sidebar/checkout — switch branch (refuses a dirty workspace)
|
|
8
|
+
* /git-sidebar/stage — stage / unstage files
|
|
9
|
+
* /git-sidebar/commit — stage-all + commit
|
|
10
|
+
* /git-sidebar/push — git push
|
|
11
|
+
* /git-sidebar/pull — git pull
|
|
12
|
+
* /git-sidebar/generate — AI commit message from git status + diff
|
|
13
|
+
*
|
|
14
|
+
* Transport: the webServer service registers the routes; the browser half
|
|
15
|
+
* fetches JSON. Only scalars/arrays are serialized — no live objects.
|
|
16
|
+
*
|
|
17
|
+
* The git root is resolved per request from the workspace cwd passed by the
|
|
18
|
+
* client, walking up to find .git and caching per workspace.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const name = 'dsh-git-sidebar'
|
|
22
|
+
// Dependencies must be declared via `inject` (ctx.get may return undefined for them)
|
|
23
|
+
export const inject = ['webServer', 'shell', 'sandboxPolicy']
|
|
24
|
+
|
|
25
|
+
const API_BASE = '/git-sidebar'
|
|
26
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024
|
|
27
|
+
const STATUS_CHARS = [' ', '?', 'A', 'M', 'D', 'R', 'C', 'T', 'U']
|
|
28
|
+
|
|
29
|
+
function dirname(p) {
|
|
30
|
+
const s = String(p).replace(/[\\/]+$/, '')
|
|
31
|
+
const i = s.lastIndexOf('/')
|
|
32
|
+
if (i <= 0) return i === 0 ? '/' : ''
|
|
33
|
+
return s.slice(0, i)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function shq(s) {
|
|
37
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isConflict(code) {
|
|
41
|
+
const x = code[0]
|
|
42
|
+
const y = code[1]
|
|
43
|
+
return x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function looksLikeStatus(rec) {
|
|
47
|
+
return (
|
|
48
|
+
rec.length >= 3 &&
|
|
49
|
+
STATUS_CHARS.indexOf(rec[0]) >= 0 &&
|
|
50
|
+
STATUS_CHARS.indexOf(rec[1]) >= 0 &&
|
|
51
|
+
rec[2] === ' '
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readJson(req) {
|
|
56
|
+
const chunks = []
|
|
57
|
+
let size = 0
|
|
58
|
+
for await (const chunk of req) {
|
|
59
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
60
|
+
size += buffer.length
|
|
61
|
+
if (size > MAX_BODY_BYTES) throw new Error('request body too large')
|
|
62
|
+
chunks.push(buffer)
|
|
63
|
+
}
|
|
64
|
+
if (chunks.length === 0) return null
|
|
65
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sendJson(res, status, body) {
|
|
69
|
+
res.writeHead(status, {
|
|
70
|
+
'content-type': 'application/json; charset=utf-8',
|
|
71
|
+
'cache-control': 'no-store',
|
|
72
|
+
'x-content-type-options': 'nosniff',
|
|
73
|
+
})
|
|
74
|
+
res.end(JSON.stringify(body))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function apply(ctx) {
|
|
78
|
+
const webServer = ctx.webServer
|
|
79
|
+
const shell = ctx.shell
|
|
80
|
+
const sp = ctx.get('sandboxPolicy')
|
|
81
|
+
const llm = ctx.get('llm')
|
|
82
|
+
const adm = ctx.get('agentDefaultModel')
|
|
83
|
+
if (webServer === undefined || shell === undefined) return
|
|
84
|
+
const defaultBase = sp && sp.workspaceRoot ? sp.workspaceRoot : undefined
|
|
85
|
+
const rootCache = new Map()
|
|
86
|
+
|
|
87
|
+
async function gitRun(cmd, opts) {
|
|
88
|
+
try {
|
|
89
|
+
const request = {
|
|
90
|
+
command: cmd,
|
|
91
|
+
workdir: (opts && opts.cwd) || defaultBase || undefined,
|
|
92
|
+
timeoutMs: (opts && opts.timeoutMs) || 60000,
|
|
93
|
+
stdoutMaxBytes: (opts && opts.cap) || 4 * 1024 * 1024,
|
|
94
|
+
}
|
|
95
|
+
const spec = shell.resolve(request)
|
|
96
|
+
const r = await shell.run(spec)
|
|
97
|
+
const stdout = (r.stdout && r.stdout.text) || ''
|
|
98
|
+
const stderr = (r.stderr && r.stderr.text) || ''
|
|
99
|
+
return { ok: r.exitCode === 0, exitCode: r.exitCode, stdout, stderr }
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return { ok: false, exitCode: null, stdout: '', stderr: '', error: String((err && err.message) || err) }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function findGitRoot(base) {
|
|
106
|
+
const start = base || defaultBase
|
|
107
|
+
if (start === undefined) return undefined
|
|
108
|
+
if (rootCache.has(start)) return rootCache.get(start)
|
|
109
|
+
let found
|
|
110
|
+
let dir = start
|
|
111
|
+
for (let i = 0; i < 8; i++) {
|
|
112
|
+
const r = await gitRun('git rev-parse --show-toplevel', { cwd: dir })
|
|
113
|
+
if (r.ok && r.stdout.trim()) {
|
|
114
|
+
found = r.stdout.trim().split('\n')[0]
|
|
115
|
+
break
|
|
116
|
+
}
|
|
117
|
+
const parent = dirname(dir)
|
|
118
|
+
if (!parent || parent === dir) break
|
|
119
|
+
dir = parent
|
|
120
|
+
}
|
|
121
|
+
if (!found) {
|
|
122
|
+
const f = await gitRun(`find ${shq(start)} -maxdepth 2 -name .git -type d 2>/dev/null | head -1`, { cwd: start, cap: 4096 })
|
|
123
|
+
if (f.ok && f.stdout.trim()) {
|
|
124
|
+
const parent = dirname(f.stdout.trim().split('\n')[0])
|
|
125
|
+
if (parent) {
|
|
126
|
+
const v = await gitRun('git rev-parse --show-toplevel', { cwd: parent })
|
|
127
|
+
if (v.ok && v.stdout.trim()) found = v.stdout.trim().split('\n')[0]
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
rootCache.set(start, found)
|
|
132
|
+
return found
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function readStatus(base) {
|
|
136
|
+
const start = base || defaultBase
|
|
137
|
+
const root = await findGitRoot(start)
|
|
138
|
+
if (!root) {
|
|
139
|
+
return { ok: false, error: `未找到 Git 仓库:${start || '当前工作区'} 及上级目录均无 .git` }
|
|
140
|
+
}
|
|
141
|
+
const r = await gitRun('git status --porcelain=v1 -z -b', { cwd: root, cap: 2 * 1024 * 1024 })
|
|
142
|
+
if (!r.ok) return { ok: false, error: (r.stderr || r.stdout || 'git status 失败').trim() }
|
|
143
|
+
const parts = r.stdout.split('\0')
|
|
144
|
+
const header = (parts[0] || '').replace(/\n$/, '')
|
|
145
|
+
let branch = '?', upstream = ''
|
|
146
|
+
let ahead = 0, behind = 0
|
|
147
|
+
const m = /^## (.+?)(?:\.\.\.(.+?))?(?: \[(.*)\])?$/.exec(header)
|
|
148
|
+
if (m) {
|
|
149
|
+
branch = m[1]
|
|
150
|
+
upstream = m[2] || ''
|
|
151
|
+
const info = m[3] || ''
|
|
152
|
+
const am = /ahead (\d+)/.exec(info)
|
|
153
|
+
const bm = /behind (\d+)/.exec(info)
|
|
154
|
+
ahead = am ? parseInt(am[1], 10) : 0
|
|
155
|
+
behind = bm ? parseInt(bm[1], 10) : 0
|
|
156
|
+
}
|
|
157
|
+
const files = []
|
|
158
|
+
for (let i = 1; i < parts.length; i++) {
|
|
159
|
+
const rec = parts[i] || ''
|
|
160
|
+
if (!rec) continue
|
|
161
|
+
if (!looksLikeStatus(rec)) continue
|
|
162
|
+
const x = rec[0]
|
|
163
|
+
const y = rec[1]
|
|
164
|
+
const path = rec.slice(3)
|
|
165
|
+
if (!path) continue
|
|
166
|
+
const untracked = x === '?' && y === '?'
|
|
167
|
+
files.push({
|
|
168
|
+
path,
|
|
169
|
+
code: untracked ? '??' : x + y,
|
|
170
|
+
staged: !untracked && x !== ' ',
|
|
171
|
+
unstaged: !untracked && y !== ' ' && y !== '?',
|
|
172
|
+
untracked,
|
|
173
|
+
conflict: untracked ? false : isConflict(x + y),
|
|
174
|
+
})
|
|
175
|
+
if (x === 'R' || x === 'C') i++
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
root,
|
|
180
|
+
branch,
|
|
181
|
+
upstream,
|
|
182
|
+
ahead,
|
|
183
|
+
behind,
|
|
184
|
+
files,
|
|
185
|
+
stagedCount: files.filter((f) => f.staged).length,
|
|
186
|
+
unstagedCount: files.filter((f) => !f.staged).length,
|
|
187
|
+
clean: files.length === 0,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function cwdOf(body) {
|
|
192
|
+
return body && body.cwd ? String(body.cwd) : defaultBase
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function route(path, handler) {
|
|
196
|
+
ctx.effect(() => webServer.register({
|
|
197
|
+
kind: 'exact',
|
|
198
|
+
path,
|
|
199
|
+
handler: async (req, res) => {
|
|
200
|
+
try {
|
|
201
|
+
const body = await readJson(req)
|
|
202
|
+
const result = await handler(body)
|
|
203
|
+
sendJson(res, 200, result)
|
|
204
|
+
} catch (err) {
|
|
205
|
+
sendJson(res, 400, { ok: false, error: String((err && err.message) || err) })
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
}), `dsh-git-sidebar: ${path}`)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
route(`${API_BASE}/status`, async (body) => readStatus(cwdOf(body)))
|
|
212
|
+
|
|
213
|
+
route(`${API_BASE}/branches`, async (body) => {
|
|
214
|
+
const root = await findGitRoot(cwdOf(body))
|
|
215
|
+
if (!root) return { ok: false, error: '未找到 Git 仓库' }
|
|
216
|
+
const [local, remote, cur] = await Promise.all([
|
|
217
|
+
gitRun(`git for-each-ref '--format=%(refname:short)' refs/heads`, { cwd: root }),
|
|
218
|
+
gitRun(`git for-each-ref '--format=%(refname:short)' refs/remotes`, { cwd: root }),
|
|
219
|
+
gitRun('git rev-parse --abbrev-ref HEAD', { cwd: root }),
|
|
220
|
+
])
|
|
221
|
+
if (!local.ok || !cur.ok) return { ok: false, error: (local.stderr || cur.stderr || '获取分支失败').trim() }
|
|
222
|
+
const branches = (local.stdout || '').split('\n').filter(Boolean).map((name) => ({ name, isRemote: false }))
|
|
223
|
+
if (remote.ok) {
|
|
224
|
+
;(remote.stdout || '').split('\n').filter(Boolean).forEach((name) => {
|
|
225
|
+
if (name === 'origin/HEAD' || /\/HEAD$/.test(name)) return
|
|
226
|
+
if (!branches.some((b) => b.name === name)) branches.push({ name, isRemote: true })
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
return { ok: true, current: (cur.stdout || '').trim(), branches }
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
route(`${API_BASE}/checkout`, async (body) => {
|
|
233
|
+
const root = await findGitRoot(cwdOf(body))
|
|
234
|
+
if (!root) return { ok: false, error: '未找到 Git 仓库' }
|
|
235
|
+
const name = body && body.name ? String(body.name) : ''
|
|
236
|
+
if (!name) return { ok: false, error: '未指定分支' }
|
|
237
|
+
const isRemote = !!(body && body.isRemote)
|
|
238
|
+
const st = await readStatus(cwdOf(body))
|
|
239
|
+
if (st.ok && !st.clean) {
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
code: 'dirty-workspace',
|
|
243
|
+
error: `工作区有 ${st.files.length} 个未提交变更,切换分支可能丢失这些更改`,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
let cmd
|
|
247
|
+
if (isRemote) {
|
|
248
|
+
const short = name.split('/').slice(1).join('/')
|
|
249
|
+
const exists = await gitRun(`git rev-parse --verify --quiet ${shq(short)}`, { cwd: root })
|
|
250
|
+
cmd = exists.ok ? `git checkout ${shq(short)}` : `git checkout -b ${shq(short)} --track ${shq(name)}`
|
|
251
|
+
} else {
|
|
252
|
+
cmd = `git checkout ${shq(name)}`
|
|
253
|
+
}
|
|
254
|
+
const r = await gitRun(cmd, { cwd: root, timeoutMs: 120000 })
|
|
255
|
+
if (!r.ok) return { ok: false, error: (r.stderr || r.stdout || '切换分支失败').trim() }
|
|
256
|
+
return { ok: true, notice: `已切换到 ${name}${isRemote ? '(新建跟踪分支)' : ''}` }
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
route(`${API_BASE}/stage`, async (body) => {
|
|
260
|
+
const root = await findGitRoot(cwdOf(body))
|
|
261
|
+
if (!root) return { ok: false, error: '未找到 Git 仓库' }
|
|
262
|
+
const paths = (body && Array.isArray(body.paths) && body.paths.map(String)) || []
|
|
263
|
+
if (!paths.length) return { ok: false, error: '未指定文件' }
|
|
264
|
+
const quoted = paths.map(shq).join(' ')
|
|
265
|
+
const r = body && body.staged
|
|
266
|
+
? await gitRun(`git add -- ${quoted}`, { cwd: root })
|
|
267
|
+
: await gitRun(`git reset -- ${quoted}`, { cwd: root })
|
|
268
|
+
if (!r.ok) return { ok: false, error: (r.stderr || r.stdout || '操作失败').trim() }
|
|
269
|
+
return { ok: true }
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
route(`${API_BASE}/commit`, async (body) => {
|
|
273
|
+
const root = await findGitRoot(cwdOf(body))
|
|
274
|
+
if (!root) return { ok: false, error: '未找到 Git 仓库' }
|
|
275
|
+
const msg = body && body.message ? String(body.message).trim() : ''
|
|
276
|
+
if (!msg) return { ok: false, error: '提交信息不能为空' }
|
|
277
|
+
if (body === null || body === undefined || body.all !== false) {
|
|
278
|
+
const add = await gitRun('git add -A', { cwd: root })
|
|
279
|
+
if (!add.ok) return { ok: false, error: (add.stderr || add.stdout || 'git add 失败').trim() }
|
|
280
|
+
}
|
|
281
|
+
const c = await gitRun(`git commit -m ${shq(msg)}`, { cwd: root })
|
|
282
|
+
if (!c.ok) return { ok: false, error: (c.stderr || c.stdout || 'git commit 失败').trim() }
|
|
283
|
+
return { ok: true, notice: (c.stdout || '').split('\n')[0] || '提交成功' }
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
route(`${API_BASE}/push`, async (body) => {
|
|
287
|
+
const root = await findGitRoot(cwdOf(body))
|
|
288
|
+
if (!root) return { ok: false, error: '未找到 Git 仓库' }
|
|
289
|
+
const r = await gitRun('git push', { cwd: root, timeoutMs: 300000 })
|
|
290
|
+
if (!r.ok) return { ok: false, error: (r.stderr || r.stdout || 'git push 失败').trim() }
|
|
291
|
+
return { ok: true, notice: (r.stdout || '推送成功').trim() || '推送成功' }
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
route(`${API_BASE}/pull`, async (body) => {
|
|
295
|
+
const root = await findGitRoot(cwdOf(body))
|
|
296
|
+
if (!root) return { ok: false, error: '未找到 Git 仓库' }
|
|
297
|
+
const r = await gitRun('git pull', { cwd: root, timeoutMs: 300000 })
|
|
298
|
+
if (!r.ok) return { ok: false, error: (r.stderr || r.stdout || 'git pull 失败').trim() }
|
|
299
|
+
return { ok: true, notice: (r.stdout || '拉取完成').trim() || '拉取完成' }
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
route(`${API_BASE}/generate`, async (body) => {
|
|
303
|
+
const st = await readStatus(cwdOf(body))
|
|
304
|
+
if (!st.ok) return st
|
|
305
|
+
if (st.clean) return { ok: false, error: '工作区没有变更' }
|
|
306
|
+
const root = st.root
|
|
307
|
+
const diffR = await gitRun('git diff HEAD', { cwd: root, cap: 20000 })
|
|
308
|
+
if (!diffR.ok && diffR.stderr) return { ok: false, error: diffR.stderr.trim() }
|
|
309
|
+
const diff = (diffR.stdout || '').slice(0, 19000)
|
|
310
|
+
if (!llm) return { ok: false, error: 'LLM 服务不可用' }
|
|
311
|
+
if (!adm) return { ok: false, error: '默认模型服务不可用' }
|
|
312
|
+
const sel = adm.currentSelection()
|
|
313
|
+
if (!sel || !sel.provider || !sel.model) return { ok: false, error: '未配置默认模型,无法生成提交信息' }
|
|
314
|
+
const fileLines = st.files.map((f) => ` ${f.code} ${f.path}`).join('\n')
|
|
315
|
+
const prompt = [
|
|
316
|
+
'你是一名资深开发者。请根据下面的 Git 变更生成一条提交信息。',
|
|
317
|
+
'要求:',
|
|
318
|
+
'1. 使用 Conventional Commits 风格:<type>(<scope>): <subject>,type 取 feat/fix/refactor/docs/style/chore/test/perf 之一;',
|
|
319
|
+
'2. subject 用简洁的中文描述,不超过 50 个字符;',
|
|
320
|
+
'3. 只输出提交信息本身,不要任何解释、前缀或引号。',
|
|
321
|
+
'',
|
|
322
|
+
`当前分支:${st.branch}`,
|
|
323
|
+
'变更文件:',
|
|
324
|
+
fileLines,
|
|
325
|
+
'',
|
|
326
|
+
'Diff:',
|
|
327
|
+
diff || '(无可展示的 diff,仅有未跟踪文件)',
|
|
328
|
+
].join('\n')
|
|
329
|
+
let text = ''
|
|
330
|
+
let failed = false
|
|
331
|
+
const stream = llm.stream({
|
|
332
|
+
provider: sel.provider,
|
|
333
|
+
model: sel.model,
|
|
334
|
+
temperature: 0.4,
|
|
335
|
+
maxTokens: 150,
|
|
336
|
+
messages: [
|
|
337
|
+
{
|
|
338
|
+
id: `dsh-git-gen-${Date.now()}`,
|
|
339
|
+
role: 'user',
|
|
340
|
+
content: [{ type: 'text', text: prompt }],
|
|
341
|
+
source: { kind: 'user' },
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
})
|
|
345
|
+
for await (const chunk of stream) {
|
|
346
|
+
if (!chunk) continue
|
|
347
|
+
if (chunk.type === 'text-delta') text += chunk.text || ''
|
|
348
|
+
else if (chunk.type === 'finish') {
|
|
349
|
+
if (chunk.reason === 'error' || chunk.reason === 'aborted') failed = true
|
|
350
|
+
break
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (failed) return { ok: false, error: '模型调用失败' }
|
|
354
|
+
const msg = text.trim()
|
|
355
|
+
if (!msg) return { ok: false, error: '模型未返回内容' }
|
|
356
|
+
return { ok: true, message: msg }
|
|
357
|
+
})
|
|
358
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-git-sidebar",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "A draggable right-side Git sidebar for DeepSeek Harness Web: follow the active workspace, list changed files, generate commit messages with the current model, and commit/push/pull",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"lib",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"README.md",
|
|
17
|
+
"README.zh.md",
|
|
18
|
+
"README.i18n.yaml",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"dsh",
|
|
23
|
+
"deepseek-harness",
|
|
24
|
+
"plugin",
|
|
25
|
+
"git",
|
|
26
|
+
"version-control",
|
|
27
|
+
"git-status",
|
|
28
|
+
"commit-message",
|
|
29
|
+
"web-ui"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"engines": {
|
|
33
|
+
"dsh": ">=0.0.1"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/adamaik/dsh-git-sidebar.git"
|
|
38
|
+
},
|
|
39
|
+
"dsh": {
|
|
40
|
+
"bundle": {
|
|
41
|
+
"patch": "./cordis.patch.yml"
|
|
42
|
+
},
|
|
43
|
+
"client": {
|
|
44
|
+
"platform": "web"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1"
|
|
49
|
+
}
|
|
50
|
+
}
|