@windypro-rourou/dsh-code-studio 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.
- package/README.md +42 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +688 -0
- package/lib/index.js +350 -0
- package/package.json +56 -0
- package/scripts/install.ps1 +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Code Studio 鈥?DSH Web GUI 鐨?VS Code + Cline 娣峰悎鎻掍欢
|
|
2
|
+
|
|
3
|
+
鍦?DeepSeek Harness 鐨?Web 鐣岄潰锛坵ebui锛夐噷鐩存帴**娴忚銆佺紪杈戜唬鐮?*锛屽苟鍦?**Agent 淇敼鏂囦欢鏃跺儚 Cline 涓€鏍烽€愯鏄剧ず Diff**锛氭敼鍔ㄥ尯琛屽彿鍓嶆爣娉?`+`锛堟柊澧?缁胯壊锛夈€乣-`锛堝垹闄?绾㈣壊锛夈€乣~`锛堜慨鏀?榛勮壊锛夈€?
|
|
4
|
+
## 鍔熻兘
|
|
5
|
+
|
|
6
|
+
- 鏂囦欢鏍戯細宸︿晶鎳掑姞杞界洰褰曟爲锛岀偣鍑绘枃浠舵墦寮€锛堟敮鎸佹墦寮€宸ヤ綔鍖轰换鎰忚矾寰勶級銆?- 缂栬緫鍣細甯﹁鍙风殑浠g爜缂栬緫瑙嗗浘锛汣trl+S 淇濆瓨锛孋trl+D 鍦?缂栬緫/Diff 闂村垏鎹€?- Cline 寮?Diff锛欴iff 瑙嗗浘鎸夎鏄剧ず before/after锛涜鍙峰墠鏂规湁绗﹀彿鍒楋紙+ - ~锛夛紝鏈敼鍔ㄥぇ娈佃嚜鍔ㄦ姌鍙犱负銆屸嫰 N 琛屾湭鏀瑰姩銆嶏紝鍙偣鍑诲睍寮€鍏ㄩ儴銆?- Agent 鍙樻洿璺熻釜锛欻ost 渚ч€掑綊鐩戝惉宸ヤ綔鍖猴紙fs.watch + mtime 杞鍏滃簳锛夛紝Agent 鍐欏叆鏂囦欢鍚庢祻瑙堝櫒瀹炴椂鏀跺埌浜嬩欢锛涙枃浠舵爲椤堕儴銆孉gent 鍙樻洿銆嶅垪琛ㄦ樉绀烘湁鍙樻洿鐨勬枃浠讹紙缁?鏂板缓銆侀粍=淇敼銆佺孩=鍒犻櫎锛夛紝鐐瑰嚮鍗崇湅 Diff銆?- 鍙樻洿鍘嗗彶锛氭瘡涓枃浠朵繚鐣欐渶杩?30 鏉?before/after 璁板綍锛?api/code-studio/history锛夈€?- 鍙岀鎻掍欢锛欻ost锛圢ode锛夋彁渚?/api/code-studio/* REST + SSE锛汣lient锛堟祻瑙堝櫒 bundle锛夋彁渚?UI銆?
|
|
7
|
+
## 瀹夎锛堝凡瑁呭ソ锛?
|
|
8
|
+
鎻掍欢宸插畨瑁呭埌 web profile锛?DSH_HOME/profiles/web锛夛細
|
|
9
|
+
- 鍖呬綅浜?profiles/web/node_modules/@windypro-rourou/dsh-code-studio
|
|
10
|
+
- cordis.patch.yml 宸叉彃鍏?code-studio 琛?- package.json 宸插姞鍏?link:F:/CycleMaster/dsh-code-studio 渚濊禆
|
|
11
|
+
|
|
12
|
+
**鐢熸晥鏃舵満**锛欴SH 鐨勫鎴风鎻掍欢鍚嶅崟锛坆oot manifest锛夊湪鍚姩鏃跺浐鍖栵紝鏂版彃浠堕渶瑕?*閲嶅惎 dsh web**锛堟垨涓嬫鍚姩 harness锛夊悗鍑虹幇鍦ㄤ晶杈规爮銆傚綋鍓嶆鍦ㄨ繍琛岀殑 webui 鏃犳硶鐑姞杞芥柊鎻掍欢锛堣繖鏄钩鍙版満鍒讹紝闈炴彃浠剁己闄凤級銆?
|
|
13
|
+
### 鎵嬪姩瀹夎锛堝叾浠栨満鍣級
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
# 1. 鐢ㄥ畼鏂瑰懡浠ら摼鎺ユ彃浠讹紙浼氳嚜鍔ㄦ妸瀹冨姞鍏?profile bundles 灞傦級
|
|
17
|
+
dsh plugin --profile web add link:F:/CycleMaster/dsh-code-studio
|
|
18
|
+
|
|
19
|
+
# 2. 鎴栬€呭湪 profiles/web/cordis.patch.yml 杩藉姞锛?# - insert:
|
|
20
|
+
# - id: code-studio
|
|
21
|
+
# name: '@windypro-rourou/dsh-code-studio'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### 鐜板湪灏辨兂棰勮锛?
|
|
25
|
+
```powershell
|
|
26
|
+
dsh web --port 3081 # 浼氱湅鍒颁晶杈规爮鍑虹幇 "Code Studio" 鍏ュ彛
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 浣跨敤
|
|
30
|
+
|
|
31
|
+
1. 鍒锋柊椤甸潰鍚庯紝宸︿晶杈规爮鍑虹幇 **Code Studio** 鍏ュ彛锛堜唬鐮佸浘鏍囷級銆?2. 鐐瑰嚮鎵撳紑鍏ㄥ睆闈㈡澘锛氬乏渚ф枃浠舵爲锛屼腑闂存爣绛鹃〉缂栬緫鍣ㄣ€?3. 璁?Agent 淇敼浠g爜锛堝啓鏂囦欢/缂栬緫锛夛紝闈㈡澘椤堕儴銆孉gent 鍙樻洿銆嶅疄鏃跺垪鍑哄彉鍖栨枃浠躲€?4. 鐐瑰嚮鍙樻洿鏂囦欢 鈫?榛樿杩涘叆 Diff 瑙嗗浘锛氳鍙峰墠绗﹀彿 + 棰滆壊鑳屾櫙锛屼竴鐩簡鐒躲€?
|
|
32
|
+
## 蹇嵎閿?
|
|
33
|
+
| 鎸夐敭 | 鍔熻兘 |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| Ctrl+S | 淇濆瓨褰撳墠鏂囦欢 |
|
|
36
|
+
| Ctrl+D | 鍒囨崲 缂栬緫 / Diff 瑙嗗浘 |
|
|
37
|
+
|
|
38
|
+
## 鎶€鏈鏄?
|
|
39
|
+
- Host锛歭ib/index.js 鈥?webServer.register 娉ㄥ唽 6 涓矾鐢憋紱fs.watch(recursive) + 1500ms mtime 杞锛涘姣忎釜鏂囦欢缁存姢鍩虹嚎蹇収锛屼粠鑰屼骇鍑虹簿纭殑 before/after銆?- Client锛歭ib/client.js 鈥?绾祻瑙堝櫒 bundle锛坵indow.__ModuleLoader__.load锛夛紝浠呬緷璧?react/react-dom锛涜嚜甯?LCS 琛岀骇 diff 寮曟搸涓庢牱寮忋€?- 璺緞瀹夊叏锛欰PI 浠呮帴鍙?loopback + 鍚屾簮鏍囪璇锋眰銆?
|
|
40
|
+
## 宸茬煡闄愬埗
|
|
41
|
+
|
|
42
|
+
- 鍗曟枃浠?> 512KB 涓嶈鍙栧唴瀹癸紙闃叉祻瑙堝櫒鍗℃锛夛紝浣嗕細鏍囪銆屾枃浠惰繃澶с€嶃€?- Diff 瑙嗗浘閽堝銆屾墦寮€鍚?涓婃璇诲彇鍚庛€嶇殑鍙樻洿鍋氬熀绾垮姣旓紝绗﹀悎 Cline 鐨勪細璇濊涔夈€?
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# dsh-code-studio bundle patch: inserts the Code Studio plugin row into the
|
|
2
|
+
# web profile roster. Applied as a profile bundle layer (the dsh.bundle.patch
|
|
3
|
+
# manifest field) over dsh-base; activate with
|
|
4
|
+
# dsh plugin --profile web add @windypro-rourou/dsh-code-studio
|
|
5
|
+
- insert:
|
|
6
|
+
- id: code-studio
|
|
7
|
+
name: '@windypro-rourou/dsh-code-studio'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@windypro-rourou/dsh-code-studio",
|
|
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 el = (type, props, ...children) => React.createElement(type, props ?? null, ...children);
|
|
10
|
+
const inject = ["slots"];
|
|
11
|
+
|
|
12
|
+
// ============================ API ============================
|
|
13
|
+
const API = "/api/code-studio";
|
|
14
|
+
async function api(path, init) {
|
|
15
|
+
const res = await fetch(API + path, init);
|
|
16
|
+
const body = await res.json().catch(() => ({}));
|
|
17
|
+
if (!res.ok) throw new Error(body.error ?? ("request failed: " + res.status));
|
|
18
|
+
return body;
|
|
19
|
+
}
|
|
20
|
+
const enc = encodeURIComponent;
|
|
21
|
+
const listDir = (path) => api("/tree?path=" + enc(path));
|
|
22
|
+
const readFile = (path) => api("/read?path=" + enc(path));
|
|
23
|
+
const historyApi = (path) => api("/history?path=" + enc(path));
|
|
24
|
+
const writeFile = (path, content) => api("/write", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "content-type": "application/json" },
|
|
27
|
+
body: JSON.stringify({ path, content })
|
|
28
|
+
});
|
|
29
|
+
const getRoot = () => api("/root");
|
|
30
|
+
|
|
31
|
+
// ============================ diff engine ============================
|
|
32
|
+
function lineDiff(before, after) {
|
|
33
|
+
const a = before == null ? [] : String(before).split("\n");
|
|
34
|
+
const b = after == null ? [] : String(after).split("\n");
|
|
35
|
+
const n = a.length, m = b.length;
|
|
36
|
+
if (n === 0) return b.map((text, j) => ({ type: "add", oldNo: null, newNo: j + 1, text }));
|
|
37
|
+
if (m === 0) return a.map((text, i) => ({ type: "del", oldNo: i + 1, newNo: null, text }));
|
|
38
|
+
if (n * m > 2500000) return greedyDiff(a, b);
|
|
39
|
+
const dp = new Uint32Array((n + 1) * (m + 1));
|
|
40
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
41
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
42
|
+
dp[i * (m + 1) + j] = a[i] === b[j]
|
|
43
|
+
? dp[(i + 1) * (m + 1) + j + 1] + 1
|
|
44
|
+
: Math.max(dp[(i + 1) * (m + 1) + j], dp[i * (m + 1) + j + 1]);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const ops = [];
|
|
48
|
+
let i = 0, j = 0;
|
|
49
|
+
while (i < n && j < m) {
|
|
50
|
+
if (a[i] === b[j]) { ops.push({ type: "same", oldNo: i + 1, newNo: j + 1, text: a[i] }); i++; j++; }
|
|
51
|
+
else if (dp[(i + 1) * (m + 1) + j] >= dp[i * (m + 1) + j + 1]) { ops.push({ type: "del", oldNo: i + 1, newNo: null, text: a[i] }); i++; }
|
|
52
|
+
else { ops.push({ type: "add", oldNo: null, newNo: j + 1, text: b[j] }); j++; }
|
|
53
|
+
}
|
|
54
|
+
while (i < n) { ops.push({ type: "del", oldNo: i + 1, newNo: null, text: a[i] }); i++; }
|
|
55
|
+
while (j < m) { ops.push({ type: "add", oldNo: null, newNo: j + 1, text: b[j] }); j++; }
|
|
56
|
+
return ops;
|
|
57
|
+
}
|
|
58
|
+
function greedyDiff(a, b) {
|
|
59
|
+
let s = 0;
|
|
60
|
+
while (s < a.length && s < b.length && a[s] === b[s]) s++;
|
|
61
|
+
let e = 0;
|
|
62
|
+
while (e < a.length - s && e < b.length - s && a[a.length - 1 - e] === b[b.length - 1 - e]) e++;
|
|
63
|
+
const ops = [];
|
|
64
|
+
for (let k = 0; k < s; k++) ops.push({ type: "same", oldNo: k + 1, newNo: k + 1, text: a[k] });
|
|
65
|
+
for (let k = s; k < a.length - e; k++) ops.push({ type: "del", oldNo: k + 1, newNo: null, text: a[k] });
|
|
66
|
+
for (let k = s; k < b.length - e; k++) ops.push({ type: "add", oldNo: null, newNo: k + 1, text: b[k] });
|
|
67
|
+
for (let k = a.length - e; k < a.length; k++) ops.push({ type: "same", oldNo: k + 1, newNo: b.length - (a.length - k) + 1, text: a[k] });
|
|
68
|
+
return ops;
|
|
69
|
+
}
|
|
70
|
+
function mergeModified(ops) {
|
|
71
|
+
const out = [];
|
|
72
|
+
let i = 0;
|
|
73
|
+
while (i < ops.length) {
|
|
74
|
+
if (ops[i].type === "del") {
|
|
75
|
+
let di = i;
|
|
76
|
+
while (di < ops.length && ops[di].type === "del") di++;
|
|
77
|
+
let ai = di;
|
|
78
|
+
while (ai < ops.length && ops[ai].type === "add") ai++;
|
|
79
|
+
if (di - i === ai - di) {
|
|
80
|
+
for (let k = 0; k < di - i; k++) {
|
|
81
|
+
const oldNo = ops[i + k].oldNo, newNo = ops[di + k].newNo;
|
|
82
|
+
out.push({ type: "mod", oldNo, newNo, text: ops[i + k].text, side: "old" });
|
|
83
|
+
out.push({ type: "mod", oldNo, newNo, text: ops[di + k].text, side: "new" });
|
|
84
|
+
}
|
|
85
|
+
i = ai;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
out.push(ops[i]);
|
|
90
|
+
i++;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
function buildBlocks(ops, gapThreshold = 5) {
|
|
95
|
+
const blocks = [];
|
|
96
|
+
let same = [];
|
|
97
|
+
const flushSame = (keepTail) => {
|
|
98
|
+
if (same.length === 0) return;
|
|
99
|
+
if (same.length <= gapThreshold) { blocks.push({ kind: "lines", ops: same }); same = []; return; }
|
|
100
|
+
const head = same.slice(0, 2);
|
|
101
|
+
const tail = keepTail ? same.slice(-2) : [];
|
|
102
|
+
blocks.push({ kind: "lines", ops: head });
|
|
103
|
+
blocks.push({ kind: "gap", count: same.length - head.length - tail.length });
|
|
104
|
+
if (tail.length) blocks.push({ kind: "lines", ops: tail });
|
|
105
|
+
same = [];
|
|
106
|
+
};
|
|
107
|
+
for (const op of ops) {
|
|
108
|
+
if (op.type === "same") { same.push(op); continue; }
|
|
109
|
+
flushSame(true);
|
|
110
|
+
blocks.push({ kind: "lines", ops: [op] });
|
|
111
|
+
}
|
|
112
|
+
flushSame(false);
|
|
113
|
+
return blocks;
|
|
114
|
+
}
|
|
115
|
+
function fileStats(before, after) {
|
|
116
|
+
const diff = lineDiff(before, after);
|
|
117
|
+
let adds = 0, dels = 0;
|
|
118
|
+
for (const op of diff) { if (op.type === "add") adds++; else if (op.type === "del") dels++; }
|
|
119
|
+
return { adds, dels };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ============================ syntax highlighting ============================
|
|
123
|
+
const KEYWORDS = new Set(["if","else","for","while","do","return","function","const","let","var","class","extends","new","import","export","from","default","async","await","try","catch","finally","throw","switch","case","break","continue","typeof","instanceof","in","of","this","super","static","get","set","public","private","protected","interface","implements","enum","package","void","yield","delegate","is","as","def","elif","lambda","pass","and","or","not","with","raise","global","nonlocal","assert","del","fun","val","data","object","when","override","open","internal","abstract","companion","lateinit","by","init","constructor","suspend","sealed","expect","actual","typealias","annotation","select","where","join","group","order","limit","offset","having","create","insert","update","delete","table","values","primary","foreign","key","index","unique","not","null","like","between","left","right","inner","outer","on","using","distinct","exists","union","all","grant","revoke","begin","commit","rollback","pragma","default","references"]);
|
|
124
|
+
const TYPES = new Set(["int","float","double","long","short","byte","boolean","char","String","List","Map","Set","Object","Array","Number","Boolean","Date","Promise","any","unknown","never","number","string","boolean","object","symbol","bigint","uint8","int8","int16","uint16","int32","uint32","int64","uint64","void","Unit","Any","Nothing","Byte","Short","Long","Char","Float","Double","Boolean","ArrayList","HashMap","HashSet","Integer","Runnable","Throwable","Exception","Error","Context","Activity","View","ViewGroup","Fragment","Intent","Bundle","Bitmap","RecyclerView","Adapter","ViewHolder","Handler","Thread","Task","TaskInfo"]);
|
|
125
|
+
const CONSTS = new Set(["true","false","null","undefined","NaN","Infinity","self","cls","it"]);
|
|
126
|
+
const TOKEN_RE = /(\/\/[^\n]*|\/\*[\s\S]*?\*\/|#[^\n]*|<!--[\s\S]*?-->|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\b\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?\b|#[0-9a-fA-F]{3,8}\b|@[A-Za-z_][\w]*|\b[A-Za-z_$][\w$]*(?=\s*\()|\b[A-Za-z_$][\w$]*\b)/g;
|
|
127
|
+
function tokenizeLine(line) {
|
|
128
|
+
const out = [];
|
|
129
|
+
let last = 0;
|
|
130
|
+
let m;
|
|
131
|
+
TOKEN_RE.lastIndex = 0;
|
|
132
|
+
while ((m = TOKEN_RE.exec(line)) !== null) {
|
|
133
|
+
if (m.index > last) out.push({ text: line.slice(last, m.index), cls: "" });
|
|
134
|
+
const tok = m[0];
|
|
135
|
+
let cls = "";
|
|
136
|
+
if (tok.startsWith("//") || tok.startsWith("/*") || tok.startsWith("#") || tok.startsWith("<!--")) cls = "cs-tok-cmt";
|
|
137
|
+
else if (tok[0] === "\"" || tok[0] === "'") cls = "cs-tok-str";
|
|
138
|
+
else if (/^[\d#@]/.test(tok)) cls = tok[0] === "@" ? "cs-tok-ann" : "cs-tok-num";
|
|
139
|
+
else if (tok.endsWith("(")) { const name = tok.slice(0, -1).trim(); cls = "cs-tok-fn"; }
|
|
140
|
+
else if (KEYWORDS.has(tok)) cls = "cs-tok-kw";
|
|
141
|
+
else if (TYPES.has(tok)) cls = "cs-tok-ty";
|
|
142
|
+
else if (CONSTS.has(tok)) cls = "cs-tok-cn";
|
|
143
|
+
out.push({ text: tok, cls });
|
|
144
|
+
last = m.index + tok.length;
|
|
145
|
+
}
|
|
146
|
+
if (last < line.length) out.push({ text: line.slice(last), cls: "" });
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
function extOf(path) {
|
|
150
|
+
const name = path.split(/[\\/]/).pop() || "";
|
|
151
|
+
const i = name.lastIndexOf(".");
|
|
152
|
+
return i >= 0 ? name.slice(i + 1).toLowerCase() : "";
|
|
153
|
+
}
|
|
154
|
+
function Highlighted({ text, path }) {
|
|
155
|
+
const toks = React.useMemo(() => tokenizeLine(text), [text]);
|
|
156
|
+
return el("span", null,
|
|
157
|
+
toks.map((t, i) => t.cls ? el("span", { className: t.cls, key: i }, t.text) : t.text)
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ============================ panel controller ============================
|
|
162
|
+
const panel = { open: false, listeners: new Set() };
|
|
163
|
+
function setPanelOpen(v) {
|
|
164
|
+
panel.open = !!v;
|
|
165
|
+
for (const fn of panel.listeners) fn(panel.open);
|
|
166
|
+
}
|
|
167
|
+
function subscribePanel(fn) { panel.listeners.add(fn); return () => panel.listeners.delete(fn); }
|
|
168
|
+
|
|
169
|
+
// ============================ CSS ============================
|
|
170
|
+
const CSS = [
|
|
171
|
+
".cs-root{position:absolute;top:0;right:0;bottom:0;display:flex;flex-direction:column;background:var(--dsw-alias-bg-layer-1,#151922);color:var(--dsw-alias-label-primary,#d7dde6);border-left:1px solid var(--dsw-alias-border-l1,#262a33);font:13px/1.55 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,sans-serif;z-index:30;box-shadow:-12px 0 32px rgba(0,0,0,.18);animation:csSlideIn .18s ease-out;--cs-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,\"Liberation Mono\",monospace}",
|
|
172
|
+
"@keyframes csSlideIn{from{transform:translateX(24px);opacity:.4}to{transform:none;opacity:1}}",
|
|
173
|
+
".cs-root *{box-sizing:border-box}",
|
|
174
|
+
".cs-resize{position:absolute;left:-3px;top:0;bottom:0;width:7px;cursor:col-resize;z-index:6}",
|
|
175
|
+
".cs-resize:hover,.cs-resize.cs-drag{background:var(--dsw-alias-state-business-primary,#2f6fed);opacity:.35}",
|
|
176
|
+
".cs-head{display:flex;align-items:center;gap:8px;padding:0 10px 0 14px;height:42px;flex:none;border-bottom:1px solid var(--dsw-alias-border-l1,#262a33);background:var(--dsw-alias-bg-layer-2,#1a1f2b)}",
|
|
177
|
+
".cs-title{font-weight:600;font-size:13px;display:flex;align-items:center;gap:7px;white-space:nowrap;min-width:0}",
|
|
178
|
+
".cs-title .cs-dot{width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-state-business-primary,#2f6fed);flex:none}",
|
|
179
|
+
".cs-count{margin-left:auto;font-size:11px;font-family:var(--cs-mono);color:var(--dsw-alias-label-tertiary,#8b93a3);background:var(--dsw-alias-bg-mask-1,rgba(255,255,255,.05));border:1px solid var(--dsw-alias-border-l1,#262a33);border-radius:10px;padding:1px 8px;white-space:nowrap}",
|
|
180
|
+
".cs-close{flex:none;width:26px;height:26px;border-radius:7px;border:1px solid transparent;background:transparent;color:var(--dsw-alias-label-tertiary,#8b93a3);cursor:pointer;font-size:13px;line-height:1}",
|
|
181
|
+
".cs-close:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08));color:var(--dsw-alias-label-primary,#d7dde6)}",
|
|
182
|
+
".cs-tabs{display:flex;gap:2px;padding:6px 10px 0;flex:none;border-bottom:1px solid var(--dsw-alias-border-l1,#262a33);background:var(--dsw-alias-bg-layer-2,#1a1f2b)}",
|
|
183
|
+
".cs-tab{flex:none;padding:5px 12px;border-radius:7px 7px 0 0;font-size:12px;color:var(--dsw-alias-label-tertiary,#8b93a3);cursor:pointer;border:1px solid transparent;border-bottom:none}",
|
|
184
|
+
".cs-tab:hover{color:var(--dsw-alias-label-primary,#d7dde6)}",
|
|
185
|
+
".cs-tab.cs-on{color:var(--dsw-alias-label-primary,#d7dde6);background:var(--dsw-alias-bg-layer-1,#151922);border-color:var(--dsw-alias-border-l1,#262a33)}",
|
|
186
|
+
".cs-body{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column}",
|
|
187
|
+
".cs-chg-list{flex:1;overflow:auto;padding:6px}",
|
|
188
|
+
".cs-chg{flex:none;border:1px solid var(--dsw-alias-border-l1,#262a33);border-radius:9px;margin:0 2px 6px;overflow:hidden;background:var(--dsw-alias-bg-base,#0f1115)}",
|
|
189
|
+
".cs-chg-row{display:flex;align-items:center;gap:6px;padding:6px 8px;cursor:pointer;font-family:var(--cs-mono);font-size:11.5px}",
|
|
190
|
+
".cs-chg-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.05))}",
|
|
191
|
+
".cs-caret{width:12px;flex:none;color:var(--dsw-alias-label-quaternary,#565e6e);font-size:9px;text-align:center}",
|
|
192
|
+
".cs-chg-path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary,#b6bdc9)}",
|
|
193
|
+
".cs-chg-path .cs-bad{font-size:9.5px;border-radius:4px;padding:0 4px;margin-left:6px;flex:none}",
|
|
194
|
+
".cs-bad-add{background:var(--dsw-alias-state-success-secondary,rgba(34,197,94,.16));color:var(--dsw-alias-state-success-primary,#22c55e)}",
|
|
195
|
+
".cs-bad-del{background:var(--dsw-alias-state-error-secondary,rgba(239,68,68,.14));color:var(--dsw-alias-state-error-primary,#ef4444)}",
|
|
196
|
+
".cs-plus{color:var(--dsw-alias-state-success-primary,#22c55e);flex:none;font-size:11px}",
|
|
197
|
+
".cs-minus{color:var(--dsw-alias-state-error-primary,#ef4444);flex:none;font-size:11px}",
|
|
198
|
+
".cs-chg-body{border-top:1px solid var(--dsw-alias-border-l1,#262a33)}",
|
|
199
|
+
".cs-dline{display:flex;font-family:var(--cs-mono);font-size:11.5px;line-height:1.5;min-width:max-content}",
|
|
200
|
+
".cs-dg{flex:none;display:flex;user-select:none}",
|
|
201
|
+
".cs-dsym{width:16px;flex:none;text-align:center;font-weight:700}",
|
|
202
|
+
".cs-dnum{min-width:34px;flex:none;text-align:right;padding-right:8px;color:var(--dsw-alias-label-quaternary,#565e6e);font-size:10.5px}",
|
|
203
|
+
".cs-dtxt{flex:1;white-space:pre;padding-right:10px;color:var(--dsw-alias-label-secondary,#b6bdc9)}",
|
|
204
|
+
".cs-dline.cs-add{background:var(--dsw-alias-state-success-secondary,rgba(34,197,94,.14))}.cs-dline.cs-add .cs-dsym{color:var(--dsw-alias-state-success-primary,#22c55e)}",
|
|
205
|
+
".cs-dline.cs-del{background:var(--dsw-alias-state-error-secondary,rgba(239,68,68,.13))}.cs-dline.cs-del .cs-dsym{color:var(--dsw-alias-state-error-primary,#ef4444)}.cs-dline.cs-del .cs-dtxt{text-decoration:line-through;text-decoration-color:rgba(239,68,68,.45)}",
|
|
206
|
+
".cs-dline.cs-mod{background:var(--dsw-alias-state-warn-secondary,rgba(234,179,8,.12))}.cs-dline.cs-mod .cs-dsym{color:var(--dsw-alias-state-warn-primary,#eab308)}",
|
|
207
|
+
".cs-dgap{display:flex;align-items:center;gap:8px;padding:0 10px;height:22px;cursor:pointer;color:var(--dsw-alias-label-tertiary,#8b93a3);font-size:10.5px;user-select:none}",
|
|
208
|
+
".cs-dgap:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.05))}",
|
|
209
|
+
".cs-dgap .cs-dgbar{flex:1;height:1px;background:var(--dsw-alias-border-l1,#262a33)}",
|
|
210
|
+
".cs-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;color:var(--dsw-alias-label-tertiary,#8b93a3);font-size:12px;padding:24px}",
|
|
211
|
+
".cs-empty .cs-hint{font-size:11px;color:var(--dsw-alias-label-quaternary,#565e6e);max-width:280px;text-align:center;line-height:1.6}",
|
|
212
|
+
".cs-files{flex:1;display:flex;flex-direction:column;min-height:0}",
|
|
213
|
+
".cs-tree{flex:none;max-height:34%;overflow:auto;border-bottom:1px solid var(--dsw-alias-border-l1,#262a33);padding:4px 0;font-size:11.5px;background:var(--dsw-alias-bg-base,#0f1115)}",
|
|
214
|
+
".cs-node{display:flex;align-items:center;gap:5px;padding:2px 8px;cursor:pointer;white-space:nowrap;color:var(--dsw-alias-label-secondary,#b6bdc9);border-radius:5px}",
|
|
215
|
+
".cs-node:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.06))}",
|
|
216
|
+
".cs-node.cs-sel{background:var(--dsw-alias-interactive-bg-active,rgba(47,111,237,.16));color:var(--dsw-alias-label-primary,#d7dde6)}",
|
|
217
|
+
".cs-node .cs-caret{width:11px;flex:none;color:var(--dsw-alias-label-quaternary,#565e6e);font-size:9px;text-align:center}",
|
|
218
|
+
".cs-node .cs-nm{overflow:hidden;text-overflow:ellipsis}",
|
|
219
|
+
".cs-node .cs-ico{flex:none;font-size:11px;opacity:.75}",
|
|
220
|
+
".cs-node .cs-badge{margin-left:auto;font-size:9.5px;color:var(--dsw-alias-state-warn-primary,#eab308);flex:none}",
|
|
221
|
+
".cs-editor{flex:1;display:flex;flex-direction:column;min-height:0}",
|
|
222
|
+
".cs-filebar{display:flex;align-items:center;gap:8px;padding:5px 10px;border-bottom:1px solid var(--dsw-alias-border-l1,#262a33);flex:none;font-family:var(--cs-mono);font-size:11px;color:var(--dsw-alias-label-tertiary,#8b93a3);background:var(--dsw-alias-bg-layer-2,#1a1f2b)}",
|
|
223
|
+
".cs-filebar .cs-fp{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
224
|
+
".cs-btn{flex:none;background:transparent;border:1px solid var(--dsw-alias-border-l1,#262a33);color:var(--dsw-alias-label-secondary,#b6bdc9);border-radius:6px;padding:3px 8px;font-size:11px;cursor:pointer}",
|
|
225
|
+
".cs-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08))}",
|
|
226
|
+
".cs-btn.cs-pri{background:var(--dsw-alias-button-primary-fill,#2f6fed);border-color:var(--dsw-alias-button-primary-fill,#2f6fed);color:#fff}",
|
|
227
|
+
".cs-btn:disabled{opacity:.45;cursor:default}",
|
|
228
|
+
".cs-edit-wrap{flex:1;display:flex;min-height:0}",
|
|
229
|
+
".cs-edit-g{flex:none;overflow:hidden;text-align:right;padding-left:10px;user-select:none}",
|
|
230
|
+
".cs-edit-g .cs-gn{min-width:30px;padding:0 8px 0 0;color:var(--dsw-alias-label-quaternary,#565e6e);font-family:var(--cs-mono);font-size:12px;line-height:1.55;text-align:right}",
|
|
231
|
+
".cs-edit-overlay{position:relative;flex:1;min-width:0;overflow:hidden;background:var(--dsw-alias-markdown-code-block,transparent)}",
|
|
232
|
+
".cs-edit-overlay .cs-hl{position:absolute;top:0;left:0;right:0;bottom:0;height:100%;margin:0;padding:0 10px 0 8px;white-space:pre;overflow:hidden;pointer-events:none;font-family:var(--cs-mono);font-size:12px;line-height:1.55;color:var(--dsw-alias-label-primary,#d7dde6)}",
|
|
233
|
+
".cs-edit-overlay .cs-ta{position:absolute;inset:0;width:100%;height:100%;background:transparent;border:none;outline:none;color:transparent;caret-color:var(--dsw-alias-label-primary,#d7dde6);font-family:var(--cs-mono);font-size:12px;line-height:1.55;padding:0 10px 0 8px;resize:none;white-space:pre;overflow:auto;tab-size:2}",
|
|
234
|
+
".cs-edit-overlay .cs-ta::selection{background:rgba(47,111,237,.35)}",
|
|
235
|
+
".cs-tok-kw{color:#c678dd}.cs-tok-str{color:#98c379}.cs-tok-cmt{color:#7f848e;font-style:italic}.cs-tok-num{color:#d19a66}.cs-tok-fn{color:#61afef}.cs-tok-ty{color:#e5c07b}.cs-tok-cn{color:#56b6c2}.cs-tok-ann{color:#61afef;font-style:italic}",
|
|
236
|
+
".cs-status{display:flex;align-items:center;gap:10px;padding:4px 12px;border-top:1px solid var(--dsw-alias-border-l1,#262a33);flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary,#8b93a3);font-family:var(--cs-mono);background:var(--dsw-alias-bg-layer-2,#1a1f2b)}",
|
|
237
|
+
".cs-status .cs-sdot{width:7px;height:7px;border-radius:50%;flex:none}",
|
|
238
|
+
".cs-status .cs-ok{background:var(--dsw-alias-state-success-primary,#22c55e)}",
|
|
239
|
+
".cs-status .cs-busy{background:var(--dsw-alias-state-warn-primary,#eab308)}",
|
|
240
|
+
".cs-status .cs-err{background:var(--dsw-alias-state-error-primary,#ef4444)}",
|
|
241
|
+
".cs-badge-f{position:fixed;right:16px;bottom:16px;z-index:40;display:flex;align-items:center;gap:8px;padding:8px 14px;border-radius:10px;background:var(--dsw-alias-bg-overlay,#1b2130);color:var(--dsw-alias-label-primary,#d7dde6);border:1px solid var(--dsw-alias-border-l1,#262a33);font-size:12px;cursor:pointer;box-shadow:0 8px 24px rgba(0,0,0,.3);animation:csSlideIn .2s ease-out}",
|
|
242
|
+
".cs-badge-f:hover{background:var(--dsw-alias-bg-layer-3,#222836)}",
|
|
243
|
+
".cs-badge-f .cs-bd{width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-state-business-primary,#2f6fed);flex:none}"
|
|
244
|
+
].join("\n");
|
|
245
|
+
|
|
246
|
+
// ============================ diff view ============================
|
|
247
|
+
function DiffRows({ before, after, path, expanded }) {
|
|
248
|
+
const ops = React.useMemo(() => mergeModified(lineDiff(before, after)), [before, after]);
|
|
249
|
+
const [open, setOpen] = React.useState(false);
|
|
250
|
+
const showAll = expanded === true || open;
|
|
251
|
+
const blocks = React.useMemo(() => buildBlocks(ops), [ops]);
|
|
252
|
+
const rows = [];
|
|
253
|
+
const rowOf = (op, key) => {
|
|
254
|
+
const type = op.type;
|
|
255
|
+
const sym = type === "add" ? "+" : type === "del" ? "−" : type === "mod" ? "~" : " ";
|
|
256
|
+
const num = type === "del" ? op.oldNo : op.newNo;
|
|
257
|
+
return el("div", { className: "cs-dline cs-" + type, key },
|
|
258
|
+
el("span", { className: "cs-dsym" }, sym),
|
|
259
|
+
el("span", { className: "cs-dnum" }, num ?? ""),
|
|
260
|
+
el("span", { className: "cs-dtxt" }, el(Highlighted, { text: op.text, path }))
|
|
261
|
+
);
|
|
262
|
+
};
|
|
263
|
+
for (const block of blocks) {
|
|
264
|
+
if (block.kind === "gap") {
|
|
265
|
+
rows.push(el("div", { className: "cs-dgap", key: "g" + rows.length, onClick: () => setOpen(true) },
|
|
266
|
+
el("span", null, "⋯ " + block.count + " 行未改动"),
|
|
267
|
+
el("span", { className: "cs-dgbar" })
|
|
268
|
+
));
|
|
269
|
+
} else {
|
|
270
|
+
for (const op of block.ops) rows.push(rowOf(op, "r" + rows.length));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (showAll) {
|
|
274
|
+
const all = ops.map((op, i) => rowOf(op, "e" + i));
|
|
275
|
+
return el("div", null, all);
|
|
276
|
+
}
|
|
277
|
+
return el("div", null, rows);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ============================ file tree ============================
|
|
281
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "build", "dist", ".gradle", ".idea", ".kotlin", "out", "target", "__pycache__", ".venv", "venv", ".cxx", ".gitlab"]);
|
|
282
|
+
function FileTree({ root, selected, changed, onOpen }) {
|
|
283
|
+
const [dirs, setDirs] = React.useState({});
|
|
284
|
+
const [expanded, setExpanded] = React.useState({});
|
|
285
|
+
React.useEffect(() => {
|
|
286
|
+
setDirs({}); setExpanded({ [root]: true });
|
|
287
|
+
// auto-load the root so it is immediately usable (no "click to expand" stall)
|
|
288
|
+
if (root != null) {
|
|
289
|
+
setDirs((prev) => ({ ...prev, [root]: null }));
|
|
290
|
+
listDir(root).then((data) => setDirs((prev) => ({ ...prev, [root]: data.entries })))
|
|
291
|
+
.catch(() => setDirs((prev) => ({ ...prev, [root]: [] })));
|
|
292
|
+
}
|
|
293
|
+
}, [root]);
|
|
294
|
+
const loadDir = (path) => {
|
|
295
|
+
setDirs((prev) => (path in prev ? prev : { ...prev, [path]: null }));
|
|
296
|
+
listDir(path).then((data) => setDirs((prev) => ({ ...prev, [path]: data.entries })))
|
|
297
|
+
.catch(() => setDirs((prev) => ({ ...prev, [path]: [] })));
|
|
298
|
+
};
|
|
299
|
+
const toggle = (path, isDir) => {
|
|
300
|
+
if (!isDir) { onOpen(path); return; }
|
|
301
|
+
const nextOpen = !expanded[path];
|
|
302
|
+
setExpanded((prev) => ({ ...prev, [path]: nextOpen }));
|
|
303
|
+
if (nextOpen && !(path in dirs)) loadDir(path);
|
|
304
|
+
};
|
|
305
|
+
const renderDir = (path, depth) => {
|
|
306
|
+
if (path == null) return null;
|
|
307
|
+
const base = String(path);
|
|
308
|
+
const isOpen = !!expanded[path];
|
|
309
|
+
const entries = dirs[path];
|
|
310
|
+
const name = path === root ? (base.split(/[\\/]/).pop() || base) : base.split(/[\\/]/).pop();
|
|
311
|
+
const rows = [];
|
|
312
|
+
rows.push(el("div", {
|
|
313
|
+
key: "d" + path, className: "cs-node" + (path === selected ? " cs-sel" : ""),
|
|
314
|
+
style: { paddingLeft: 8 + depth * 13 },
|
|
315
|
+
onClick: () => toggle(path, true)
|
|
316
|
+
},
|
|
317
|
+
el("span", { className: "cs-caret" }, isOpen ? "▾" : "▸"),
|
|
318
|
+
el("span", { className: "cs-ico" }, "📁"),
|
|
319
|
+
el("span", { className: "cs-nm" }, name)
|
|
320
|
+
));
|
|
321
|
+
if (isOpen) {
|
|
322
|
+
if (entries === null) rows.push(el("div", { key: "l" + path, className: "cs-node", style: { paddingLeft: 8 + (depth + 1) * 13, color: "var(--dsw-alias-label-quaternary)" } }, "加载中…"));
|
|
323
|
+
else if (entries === void 0) rows.push(el("div", { key: "l" + path, className: "cs-node", style: { paddingLeft: 8 + (depth + 1) * 13, color: "var(--dsw-alias-label-quaternary)" } }, "点击展开"));
|
|
324
|
+
else for (const entry of entries) {
|
|
325
|
+
if (entry.type === "dir" && SKIP_DIRS.has(entry.name)) continue;
|
|
326
|
+
if (entry.type === "dir") rows.push(el(React.Fragment, { key: entry.path }, renderDir(entry.path, depth + 1)));
|
|
327
|
+
else rows.push(el("div", {
|
|
328
|
+
key: entry.path, className: "cs-node cs-file" + (entry.path === selected ? " cs-sel" : ""),
|
|
329
|
+
style: { paddingLeft: 8 + (depth + 1) * 13 },
|
|
330
|
+
onClick: () => onOpen(entry.path)
|
|
331
|
+
},
|
|
332
|
+
el("span", { className: "cs-caret" }, ""),
|
|
333
|
+
el("span", { className: "cs-ico" }, "•"),
|
|
334
|
+
el("span", { className: "cs-nm" }, entry.name),
|
|
335
|
+
changed.has(entry.path) ? el("span", { className: "cs-badge" }, "●") : null
|
|
336
|
+
));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return rows;
|
|
340
|
+
};
|
|
341
|
+
return el("div", { className: "cs-tree" }, renderDir(root, 0));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ============================ editor (syntax highlighted overlay) ============================
|
|
345
|
+
function Editor({ file, onEdit, onSave, onDiff }) {
|
|
346
|
+
const gutterRef = React.useRef(null);
|
|
347
|
+
const hlRef = React.useRef(null);
|
|
348
|
+
const taRef = React.useRef(null);
|
|
349
|
+
const sync = (e) => {
|
|
350
|
+
const el2 = e ? e.currentTarget : taRef.current;
|
|
351
|
+
if (gutterRef.current) gutterRef.current.scrollTop = el2.scrollTop;
|
|
352
|
+
if (hlRef.current) hlRef.current.scrollTop = el2.scrollTop;
|
|
353
|
+
};
|
|
354
|
+
React.useEffect(() => { sync(null); }, [file.content]);
|
|
355
|
+
const lineCount = file.content == null ? 1 : file.content.split("\n").length;
|
|
356
|
+
const gutter = [];
|
|
357
|
+
for (let i = 1; i <= lineCount; i++) gutter.push(el("div", { className: "cs-gn", key: i }, String(i)));
|
|
358
|
+
const stats = file.baseline != null && file.content != null ? fileStats(file.baseline, file.content) : null;
|
|
359
|
+
const lines = (file.content ?? "").split("\n").map((line, i) =>
|
|
360
|
+
el("div", { key: i, style: { minHeight: "1.55em" } }, el(Highlighted, { text: line, path: file.path }))
|
|
361
|
+
);
|
|
362
|
+
return el("div", { className: "cs-editor" },
|
|
363
|
+
el("div", { className: "cs-filebar" },
|
|
364
|
+
el("span", { className: "cs-fp" }, file.path),
|
|
365
|
+
stats && (stats.adds > 0 || stats.dels > 0) ? el("span", null,
|
|
366
|
+
el("b", { style: { color: "var(--dsw-alias-state-success-primary)" } }, "+" + stats.adds),
|
|
367
|
+
" ",
|
|
368
|
+
el("b", { style: { color: "var(--dsw-alias-state-error-primary)" } }, "−" + stats.dels)
|
|
369
|
+
) : null,
|
|
370
|
+
el("button", { className: "cs-btn", onClick: onDiff, title: "查看该文件的 Diff" }, "Diff"),
|
|
371
|
+
el("button", { className: "cs-btn cs-pri", onClick: onSave, disabled: !file.dirty || file.content == null }, "保存")
|
|
372
|
+
),
|
|
373
|
+
el("div", { className: "cs-edit-wrap" },
|
|
374
|
+
el("div", { className: "cs-edit-g", ref: gutterRef }, gutter),
|
|
375
|
+
el("div", { className: "cs-edit-overlay" },
|
|
376
|
+
el("pre", { className: "cs-hl", ref: hlRef, "aria-hidden": "true" }, lines),
|
|
377
|
+
el("textarea", {
|
|
378
|
+
ref: taRef, className: "cs-ta",
|
|
379
|
+
value: file.content ?? "",
|
|
380
|
+
onChange: (e) => onEdit(e.target.value),
|
|
381
|
+
onScroll: sync,
|
|
382
|
+
spellCheck: false,
|
|
383
|
+
autoCapitalize: "off",
|
|
384
|
+
autoCorrect: "off",
|
|
385
|
+
placeholder: ""
|
|
386
|
+
})
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ============================ panel ============================
|
|
393
|
+
function CodeStudioPanel() {
|
|
394
|
+
const [open, setOpen] = React.useState(panel.open);
|
|
395
|
+
const [tab, setTab] = React.useState("changes");
|
|
396
|
+
const [changes, setChanges] = React.useState([]);
|
|
397
|
+
const [expandedPath, setExpandedPath] = React.useState(null);
|
|
398
|
+
const [root, setRoot] = React.useState(null);
|
|
399
|
+
const [editor, setEditor] = React.useState(null);
|
|
400
|
+
const [conn, setConn] = React.useState("connecting");
|
|
401
|
+
const [connText, setConnText] = React.useState("连接中…");
|
|
402
|
+
const [width, setWidth] = React.useState(() => {
|
|
403
|
+
try {
|
|
404
|
+
const saved = Number(globalThis.localStorage?.getItem("cs.panelWidth"));
|
|
405
|
+
if (saved >= 280 && saved <= 900) return saved;
|
|
406
|
+
} catch { /* ignore */ }
|
|
407
|
+
return 400;
|
|
408
|
+
});
|
|
409
|
+
const dragRef = React.useRef(null);
|
|
410
|
+
const changedSet = React.useMemo(() => new Set(changes.map((c) => c.path)), [changes]);
|
|
411
|
+
|
|
412
|
+
React.useEffect(() => subscribePanel(setOpen), []);
|
|
413
|
+
React.useEffect(() => {
|
|
414
|
+
const tagId = "@windypro-rourou/dsh-code-studio/styles";
|
|
415
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=\"" + tagId + "\"]") === null) {
|
|
416
|
+
const tag = document.createElement("style");
|
|
417
|
+
tag.dataset.plugin = "@windypro-rourou/dsh-code-studio";
|
|
418
|
+
tag.dataset.pluginCss = tagId;
|
|
419
|
+
tag.textContent = CSS;
|
|
420
|
+
document.head.appendChild(tag);
|
|
421
|
+
}
|
|
422
|
+
}, []);
|
|
423
|
+
React.useEffect(() => {
|
|
424
|
+
getRoot().then((d) => setRoot(d.root)).catch((e) => { setConn("err"); setConnText(String(e.message)); });
|
|
425
|
+
}, []);
|
|
426
|
+
|
|
427
|
+
// SSE change feed + auto-appear
|
|
428
|
+
React.useEffect(() => {
|
|
429
|
+
let source;
|
|
430
|
+
const openSse = () => {
|
|
431
|
+
source = new EventSource(API + "/events");
|
|
432
|
+
source.onopen = () => { setConn("ok"); setConnText("已连接"); };
|
|
433
|
+
source.onerror = () => { setConn("err"); setConnText("事件流断开,重连中…"); };
|
|
434
|
+
source.onmessage = (msg) => {
|
|
435
|
+
try {
|
|
436
|
+
const ev = JSON.parse(msg.data);
|
|
437
|
+
if (!ev || typeof ev.path !== "string") return;
|
|
438
|
+
setConn("ok"); setConnText("已连接");
|
|
439
|
+
const name = ev.path.split(/[\\/]/).pop() || ev.path;
|
|
440
|
+
setChanges((prev) => {
|
|
441
|
+
const next = prev.filter((c) => c.path !== ev.path);
|
|
442
|
+
next.unshift({ path: ev.path, name, before: ev.before, after: ev.after, deleted: !!ev.deleted, ts: ev.ts });
|
|
443
|
+
return next.slice(0, 50);
|
|
444
|
+
});
|
|
445
|
+
setPanelOpen(true);
|
|
446
|
+
setExpandedPath(ev.path);
|
|
447
|
+
} catch { /* ignore */ }
|
|
448
|
+
};
|
|
449
|
+
};
|
|
450
|
+
openSse();
|
|
451
|
+
return () => { if (source) source.close(); };
|
|
452
|
+
}, []);
|
|
453
|
+
|
|
454
|
+
// Ctrl+S / Ctrl+D
|
|
455
|
+
React.useEffect(() => {
|
|
456
|
+
const onKey = (e) => {
|
|
457
|
+
if (!editor) return;
|
|
458
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); void saveCurrent(); }
|
|
459
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "d") { e.preventDefault(); viewDiff(editor.path); }
|
|
460
|
+
};
|
|
461
|
+
window.addEventListener("keydown", onKey);
|
|
462
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const openFile = (path) => {
|
|
466
|
+
setEditor({ path, content: null, baseline: null, dirty: false, loading: true });
|
|
467
|
+
setTab("files");
|
|
468
|
+
readFile(path).then((data) => {
|
|
469
|
+
setEditor((prev) => prev && prev.path === path ? { path, content: data.content, baseline: data.content, dirty: false, loading: false } : prev);
|
|
470
|
+
}).catch((e) => {
|
|
471
|
+
setEditor((prev) => prev && prev.path === path ? { path, content: null, baseline: null, dirty: false, loading: false, error: String(e.message) } : prev);
|
|
472
|
+
});
|
|
473
|
+
};
|
|
474
|
+
const editContent = (content) => setEditor((prev) => prev ? { ...prev, content, dirty: true } : prev);
|
|
475
|
+
const saveCurrent = async () => {
|
|
476
|
+
if (!editor || editor.content == null) return;
|
|
477
|
+
setConn("busy"); setConnText("保存中…");
|
|
478
|
+
try {
|
|
479
|
+
await writeFile(editor.path, editor.content);
|
|
480
|
+
setEditor((prev) => prev ? { ...prev, baseline: prev.content, dirty: false } : prev);
|
|
481
|
+
setConn("ok"); setConnText("已保存 " + new Date().toLocaleTimeString());
|
|
482
|
+
} catch (e) {
|
|
483
|
+
setConn("err"); setConnText("保存失败: " + e.message);
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
const viewDiff = (path) => {
|
|
487
|
+
setTab("changes");
|
|
488
|
+
setExpandedPath(path);
|
|
489
|
+
historyApi(path).then((data) => {
|
|
490
|
+
const last = data.events && data.events.length > 0 ? data.events[data.events.length - 1] : null;
|
|
491
|
+
if (!last) return;
|
|
492
|
+
const name = path.split(/[\\/]/).pop() || path;
|
|
493
|
+
setChanges((prev) => {
|
|
494
|
+
if (prev.some((c) => c.path === path)) return prev;
|
|
495
|
+
return [{ path, name, before: last.before, after: last.after, deleted: !!last.deleted, ts: last.ts }, ...prev];
|
|
496
|
+
});
|
|
497
|
+
}).catch(() => {});
|
|
498
|
+
};
|
|
499
|
+
const clearChanges = () => setChanges([]);
|
|
500
|
+
|
|
501
|
+
// drag to resize
|
|
502
|
+
const onResizeDown = (e) => {
|
|
503
|
+
dragRef.current = { x: e.clientX, w: width };
|
|
504
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
505
|
+
e.currentTarget.classList.add("cs-drag");
|
|
506
|
+
};
|
|
507
|
+
const onResizeMove = (e) => {
|
|
508
|
+
if (!dragRef.current) return;
|
|
509
|
+
const next = Math.min(900, Math.max(280, dragRef.current.w + (dragRef.current.x - e.clientX)));
|
|
510
|
+
setWidth(next);
|
|
511
|
+
};
|
|
512
|
+
const onResizeUp = (e) => {
|
|
513
|
+
if (!dragRef.current) return;
|
|
514
|
+
dragRef.current = null;
|
|
515
|
+
e.currentTarget.classList.remove("cs-drag");
|
|
516
|
+
try { globalThis.localStorage?.setItem("cs.panelWidth", String(width)); } catch { /* ignore */ }
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
if (!open) {
|
|
520
|
+
if (changes.length > 0) {
|
|
521
|
+
return el("div", { className: "cs-badge-f", onClick: () => setPanelOpen(true) },
|
|
522
|
+
el("span", { className: "cs-bd" }),
|
|
523
|
+
el("span", null, changes.length + " 个文件已变更"),
|
|
524
|
+
el("span", { style: { color: "var(--dsw-alias-label-tertiary)", fontSize: 11 } }, "查看 →")
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return el("div", { className: "cs-root", style: { width } },
|
|
531
|
+
el("div", {
|
|
532
|
+
className: "cs-resize", onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp,
|
|
533
|
+
title: "拖拽调整宽度"
|
|
534
|
+
}),
|
|
535
|
+
el("div", { className: "cs-head" },
|
|
536
|
+
el("span", { className: "cs-title" },
|
|
537
|
+
el("span", { className: "cs-dot" }),
|
|
538
|
+
"Code Studio"
|
|
539
|
+
),
|
|
540
|
+
el("span", { className: "cs-count" }, changes.length > 0 ? changes.length + " 变更" : "就绪"),
|
|
541
|
+
el("button", { className: "cs-close", onClick: () => setPanelOpen(false), title: "关闭" }, "✕")
|
|
542
|
+
),
|
|
543
|
+
el("div", { className: "cs-tabs" },
|
|
544
|
+
el("div", { className: "cs-tab" + (tab === "changes" ? " cs-on" : ""), onClick: () => setTab("changes") }, "变更"),
|
|
545
|
+
el("div", { className: "cs-tab" + (tab === "files" ? " cs-on" : ""), onClick: () => setTab("files") }, "文件")
|
|
546
|
+
),
|
|
547
|
+
el("div", { className: "cs-body" },
|
|
548
|
+
tab === "changes" ? (
|
|
549
|
+
changes.length === 0
|
|
550
|
+
? el("div", { className: "cs-empty" },
|
|
551
|
+
el("div", null, "暂无文件变更"),
|
|
552
|
+
el("span", { className: "cs-hint" }, "Agent 修改文件时,这里会自动浮现逐行 Diff(+ 新增 − 删除 ~ 修改)")
|
|
553
|
+
)
|
|
554
|
+
: el("div", { className: "cs-chg-list" },
|
|
555
|
+
changes.map((c) => {
|
|
556
|
+
const stats = c.deleted ? { adds: 0, dels: 0 } : fileStats(c.before, c.after);
|
|
557
|
+
const isOpen = expandedPath === c.path;
|
|
558
|
+
return el("div", { className: "cs-chg", key: c.path },
|
|
559
|
+
el("div", { className: "cs-chg-row", onClick: () => setExpandedPath(isOpen ? null : c.path) },
|
|
560
|
+
el("span", { className: "cs-caret" }, isOpen ? "▾" : "▸"),
|
|
561
|
+
el("span", { className: "cs-chg-path" }, c.name,
|
|
562
|
+
c.deleted ? el("span", { className: "cs-bad cs-bad-del" }, "删除")
|
|
563
|
+
: c.before == null ? el("span", { className: "cs-bad cs-bad-add" }, "新建")
|
|
564
|
+
: null
|
|
565
|
+
),
|
|
566
|
+
el("span", { className: "cs-plus" }, "+" + stats.adds),
|
|
567
|
+
el("span", { className: "cs-minus" }, "−" + stats.dels)
|
|
568
|
+
),
|
|
569
|
+
isOpen ? el("div", { className: "cs-chg-body" },
|
|
570
|
+
c.deleted
|
|
571
|
+
? el("div", { className: "cs-empty" }, "文件已删除")
|
|
572
|
+
: el(DiffRows, { before: c.before, after: c.after, path: c.path })
|
|
573
|
+
) : null
|
|
574
|
+
);
|
|
575
|
+
}),
|
|
576
|
+
el("div", { style: { display: "flex", gap: 6, padding: "4px 2px" } },
|
|
577
|
+
el("button", { className: "cs-btn", onClick: clearChanges }, "清空列表")
|
|
578
|
+
)
|
|
579
|
+
)
|
|
580
|
+
) : (
|
|
581
|
+
el("div", { className: "cs-files" },
|
|
582
|
+
root ? el(FileTree, { root, selected: editor?.path, changed: changedSet, onOpen: openFile })
|
|
583
|
+
: el("div", { className: "cs-empty" }, "加载工作区…"),
|
|
584
|
+
editor ? (
|
|
585
|
+
editor.loading ? el("div", { className: "cs-empty" }, "加载中…")
|
|
586
|
+
: editor.error ? el("div", { className: "cs-empty" }, editor.error)
|
|
587
|
+
: el(Editor, { file: editor, onEdit: editContent, onSave: () => void saveCurrent(), onDiff: () => viewDiff(editor.path) })
|
|
588
|
+
) : el("div", { className: "cs-empty" },
|
|
589
|
+
el("div", null, "点击上方文件开始编辑"),
|
|
590
|
+
el("span", { className: "cs-hint" }, "打开文件后可编辑,Ctrl+S 保存,Ctrl+D 查看 Diff")
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
)
|
|
594
|
+
),
|
|
595
|
+
el("div", { className: "cs-status" },
|
|
596
|
+
el("span", { className: "cs-sdot cs-" + conn }),
|
|
597
|
+
el("span", null, connText),
|
|
598
|
+
el("span", { style: { marginLeft: "auto" } }, "变更 " + changes.length + " 文件"),
|
|
599
|
+
el("span", null, editor && editor.dirty ? "未保存" : "就绪")
|
|
600
|
+
)
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ============================ sidebar entry ============================
|
|
605
|
+
const ENTRY_SELECTOR = "[data-cs-entry]";
|
|
606
|
+
const ENTRY_VERSION = "4";
|
|
607
|
+
const ENTRY_ICON = "<svg viewBox=\"0 0 16 16\" width=\"15\" height=\"15\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M4 1.5h6l2.5 2.5v10.5a.5.5 0 0 1-.5.5H4a.5.5 0 0 1-.5-.5V2a.5.5 0 0 1 .5-.5z\"/><path d=\"M4 6h8M4 9h8M4 12h5\"/></svg>";
|
|
608
|
+
function sidebarRoot() {
|
|
609
|
+
const column = document.querySelector("[data-pane=\"sidebar\"], [class*=\"sidebarCol\"]");
|
|
610
|
+
if (column === null) return void 0;
|
|
611
|
+
return column.querySelector("[class*=\"logoRow\"]")?.parentElement ?? column.firstElementChild;
|
|
612
|
+
}
|
|
613
|
+
function newSessionButton(root) {
|
|
614
|
+
const nested = root.querySelector("button[class*=\"newSession\"]");
|
|
615
|
+
if (nested !== null) return nested;
|
|
616
|
+
for (const child of root.children) if (child.tagName === "BUTTON") return child;
|
|
617
|
+
}
|
|
618
|
+
function mountSidebarEntry() {
|
|
619
|
+
if (typeof document === "undefined") return () => {};
|
|
620
|
+
const existing = document.querySelector(ENTRY_SELECTOR);
|
|
621
|
+
if (existing !== null) {
|
|
622
|
+
if (existing.getAttribute("data-cs-ver") === ENTRY_VERSION) return () => {};
|
|
623
|
+
existing.remove();
|
|
624
|
+
}
|
|
625
|
+
const entry = document.createElement("button");
|
|
626
|
+
entry.type = "button";
|
|
627
|
+
entry.setAttribute("data-cs-entry", "");
|
|
628
|
+
entry.setAttribute("data-cs-ver", ENTRY_VERSION);
|
|
629
|
+
entry.setAttribute("data-dsh-plugin", "code-studio");
|
|
630
|
+
entry.setAttribute("data-dsh-part", "sidebar-entry");
|
|
631
|
+
entry.title = "Code Studio — 代码编辑与 Diff(拖拽面板左缘可调宽)";
|
|
632
|
+
entry.innerHTML = "<span>" + ENTRY_ICON + "</span><span>Code Studio</span>";
|
|
633
|
+
entry.style.cssText = "display:flex;align-items:center;gap:8px;width:100%;padding:8px 12px;background:transparent;border:none;color:inherit;cursor:pointer;font-size:13px;border-radius:8px;";
|
|
634
|
+
entry.addEventListener("click", () => setPanelOpen(!panel.open));
|
|
635
|
+
let root;
|
|
636
|
+
let placed = false;
|
|
637
|
+
const place = () => {
|
|
638
|
+
if (root !== void 0 && !root.isConnected) { root = void 0; placed = false; }
|
|
639
|
+
if (placed && document.body.contains(entry)) return;
|
|
640
|
+
root ??= sidebarRoot();
|
|
641
|
+
if (root === void 0) return;
|
|
642
|
+
const button = newSessionButton(root);
|
|
643
|
+
if (button === void 0) return;
|
|
644
|
+
root.insertBefore(entry, button.nextElementSibling);
|
|
645
|
+
placed = true;
|
|
646
|
+
};
|
|
647
|
+
const observer = new MutationObserver(place);
|
|
648
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
649
|
+
place();
|
|
650
|
+
return () => { observer.disconnect(); entry.remove(); };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ============================ apply ============================
|
|
654
|
+
let mounted = false;
|
|
655
|
+
function apply(ctx) {
|
|
656
|
+
if (mounted) return;
|
|
657
|
+
mounted = true;
|
|
658
|
+
ctx.effect(() => {
|
|
659
|
+
const disposers = [];
|
|
660
|
+
try {
|
|
661
|
+
disposers.push(mountSidebarEntry());
|
|
662
|
+
} catch (error) {
|
|
663
|
+
console.error("[code-studio] sidebar entry failed", error);
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
const slots = ctx.get("slots");
|
|
667
|
+
if (slots !== void 0) {
|
|
668
|
+
disposers.push(slots.inject("shell.overlay", () => slots.register(
|
|
669
|
+
{ name: "shell.overlay", id: "code-studio", order: 100 },
|
|
670
|
+
CodeStudioPanel
|
|
671
|
+
)));
|
|
672
|
+
} else {
|
|
673
|
+
console.error("[code-studio] slots service unavailable");
|
|
674
|
+
}
|
|
675
|
+
} catch (error) {
|
|
676
|
+
console.error("[code-studio] overlay slot failed", error);
|
|
677
|
+
}
|
|
678
|
+
return () => {
|
|
679
|
+
for (const dispose of disposers.splice(0)) dispose();
|
|
680
|
+
};
|
|
681
|
+
}, "code-studio: browser UI");
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
exports.apply = apply;
|
|
685
|
+
exports.inject = inject;
|
|
686
|
+
return module.exports;
|
|
687
|
+
}
|
|
688
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { watch as fsWatch, statSync, realpathSync } from "node:fs";
|
|
3
|
+
import { join, dirname, basename, resolve, isAbsolute } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @local/dsh-code-studio - host half.
|
|
7
|
+
* Serves the /api/code-studio/* REST + SSE surface: file tree/read/write,
|
|
8
|
+
* a recursive workspace watcher, and per-file change history (before/after
|
|
9
|
+
* content pairs) so the browser half can render Cline-style line diffs.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const name = "code-studio";
|
|
13
|
+
const inject = ["webServer"];
|
|
14
|
+
const PREFIX = "/api/code-studio";
|
|
15
|
+
const HEARTBEAT_MS = 25000;
|
|
16
|
+
const DEBOUNCE_MS = 120;
|
|
17
|
+
const MAX_READ_BYTES = 512 * 1024; // protect the browser from huge files
|
|
18
|
+
const HISTORY_LIMIT = 30;
|
|
19
|
+
|
|
20
|
+
/* ---------- helpers ---------- */
|
|
21
|
+
|
|
22
|
+
function json(res, status, body) {
|
|
23
|
+
const text = JSON.stringify(body);
|
|
24
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
25
|
+
res.end(text);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readBody(req, limit = 4 * 1024 * 1024) {
|
|
29
|
+
return new Promise((resolvePromise, reject) => {
|
|
30
|
+
const chunks = [];
|
|
31
|
+
let size = 0;
|
|
32
|
+
req.on("data", (chunk) => {
|
|
33
|
+
size += chunk.length;
|
|
34
|
+
if (size > limit) { reject(new Error("body-too-large")); req.destroy(); return; }
|
|
35
|
+
chunks.push(chunk);
|
|
36
|
+
});
|
|
37
|
+
req.on("end", () => {
|
|
38
|
+
try { resolvePromise(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
|
|
39
|
+
catch (error) { reject(new Error("invalid-json")); }
|
|
40
|
+
});
|
|
41
|
+
req.on("error", reject);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Loopback + same-origin tripwire; this is a localhost dev surface. */
|
|
46
|
+
function isTrusted(req) {
|
|
47
|
+
const addr = req.socket?.remoteAddress ?? "";
|
|
48
|
+
if (addr !== "127.0.0.1" && addr !== "::1" && addr !== "::ffff:127.0.0.1") return false;
|
|
49
|
+
const sec = req.headers["sec-fetch-site"];
|
|
50
|
+
if (typeof sec === "string" && sec !== "same-origin" && sec !== "none") return false;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function safePath(raw) {
|
|
55
|
+
if (typeof raw !== "string" || raw === "") return void 0;
|
|
56
|
+
const path = resolve(raw);
|
|
57
|
+
return path;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* ---------- watcher / history ---------- */
|
|
61
|
+
|
|
62
|
+
class FileLedger {
|
|
63
|
+
constructor(root) {
|
|
64
|
+
this.root = root;
|
|
65
|
+
/** baseline cache: absolute path -> { content, mtimeMs, size } */
|
|
66
|
+
this.baseline = new Map();
|
|
67
|
+
/** change history: absolute path -> [{ ts, before, after, deleted }] */
|
|
68
|
+
this.history = new Map();
|
|
69
|
+
/** SSE subscribers */
|
|
70
|
+
this.subscribers = new Set();
|
|
71
|
+
this.debounce = new Map();
|
|
72
|
+
this.watcher = void 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
snapshot(path) {
|
|
76
|
+
return this.baseline.get(path);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
recordBaseline(path, content, mtimeMs, size) {
|
|
80
|
+
this.baseline.set(path, { content, mtimeMs, size });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async readCurrent(path) {
|
|
84
|
+
const st = await stat(path);
|
|
85
|
+
let content = null;
|
|
86
|
+
let tooLarge = false;
|
|
87
|
+
if (st.size <= MAX_READ_BYTES) {
|
|
88
|
+
content = await readFile(path, "utf8");
|
|
89
|
+
} else {
|
|
90
|
+
tooLarge = true;
|
|
91
|
+
}
|
|
92
|
+
return { content, mtimeMs: st.mtimeMs, size: st.size, tooLarge };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Directory names whose contents are not interesting for the change feed. */
|
|
96
|
+
static IGNORED = new Set(["node_modules", ".git", ".idea", "build", "dist", ".gradle", ".kotlin", ".cxx", "out", "target", "__pycache__", ".venv", "venv", ".gitlab", ".gitlab-ci", ".DS_Store", ".cache", ".cs-"]);
|
|
97
|
+
/** Whether a path lives under one of the ignored directories (any segment). */
|
|
98
|
+
static isNoise(path) {
|
|
99
|
+
const parts = String(path).split(/[\\/]/);
|
|
100
|
+
for (const part of parts) if (FileLedger.IGNORED.has(part)) return true;
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Called when a file may have changed on disk. */
|
|
105
|
+
async handleChange(path) {
|
|
106
|
+
if (FileLedger.isNoise(path)) return;
|
|
107
|
+
let current;
|
|
108
|
+
try {
|
|
109
|
+
current = await this.readCurrent(path);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error.code === "ENOENT") {
|
|
112
|
+
const before = this.baseline.get(path);
|
|
113
|
+
this.baseline.delete(path);
|
|
114
|
+
this.push({ path, ts: Date.now(), before: before?.content ?? null, after: null, deleted: true });
|
|
115
|
+
this.addHistory(path, { ts: Date.now(), before: before?.content ?? null, after: null, deleted: true });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const prior = this.baseline.get(path);
|
|
121
|
+
const before = prior && !prior.tooLarge ? prior.content : null;
|
|
122
|
+
this.baseline.set(path, { ...current });
|
|
123
|
+
if (prior && prior.mtimeMs === current.mtimeMs && prior.size === current.size && prior.content === current.content) return;
|
|
124
|
+
const event = { path, ts: Date.now(), before, after: current.tooLarge ? null : current.content, deleted: false, tooLarge: current.tooLarge };
|
|
125
|
+
this.push(event);
|
|
126
|
+
this.addHistory(path, event);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
addHistory(path, event) {
|
|
130
|
+
const list = this.history.get(path) ?? [];
|
|
131
|
+
list.push(event);
|
|
132
|
+
if (list.length > HISTORY_LIMIT) list.shift();
|
|
133
|
+
this.history.set(path, list);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
push(event) {
|
|
137
|
+
for (const res of this.subscribers) {
|
|
138
|
+
try { res.write(`data: ${JSON.stringify(event)}\n\n`); } catch { /* ignore */ }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
schedule(path) {
|
|
143
|
+
if (FileLedger.isNoise(path)) return;
|
|
144
|
+
const timer = this.debounce.get(path);
|
|
145
|
+
if (timer) clearTimeout(timer);
|
|
146
|
+
this.debounce.set(path, setTimeout(() => {
|
|
147
|
+
this.debounce.delete(path);
|
|
148
|
+
void this.handleChange(path);
|
|
149
|
+
}, DEBOUNCE_MS));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Cheap mtime poll of everything we know about; catches missed watch events. */
|
|
153
|
+
poll() {
|
|
154
|
+
for (const [path, prior] of this.baseline) {
|
|
155
|
+
try {
|
|
156
|
+
const st = statSync(path);
|
|
157
|
+
if (st.mtimeMs === prior.mtimeMs && st.size === prior.size) continue;
|
|
158
|
+
this.schedule(path);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error.code === "ENOENT") this.schedule(path);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
start() {
|
|
166
|
+
try {
|
|
167
|
+
this.watcher = fsWatch(this.root, { recursive: true }, (_eventType, filename) => {
|
|
168
|
+
if (typeof filename !== "string") return;
|
|
169
|
+
this.schedule(join(this.root, filename));
|
|
170
|
+
});
|
|
171
|
+
this.watcher.on?.("error", () => { /* recursive watch may fail on some mounts; poll covers us */ });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
this.watcher = void 0;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
stop() {
|
|
178
|
+
for (const timer of this.debounce.values()) clearTimeout(timer);
|
|
179
|
+
this.debounce.clear();
|
|
180
|
+
if (this.watcher) { try { this.watcher.close(); } catch { /* ignore */ } }
|
|
181
|
+
for (const res of this.subscribers) { try { res.destroy(); } catch { /* ignore */ } }
|
|
182
|
+
this.subscribers.clear();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ---------- routes ---------- */
|
|
187
|
+
|
|
188
|
+
function makeRoutes(ledger, root, cwd) {
|
|
189
|
+
const guard = (req, res) => {
|
|
190
|
+
if (isTrusted(req)) return true;
|
|
191
|
+
json(res, 403, { ok: false, error: "forbidden" });
|
|
192
|
+
return false;
|
|
193
|
+
};
|
|
194
|
+
const queryPath = (req) => {
|
|
195
|
+
const path = new URL(req.url ?? "/", "http://x").searchParams.get("path");
|
|
196
|
+
return safePath(path);
|
|
197
|
+
};
|
|
198
|
+
return [
|
|
199
|
+
{
|
|
200
|
+
kind: "exact",
|
|
201
|
+
path: `${PREFIX}/root`,
|
|
202
|
+
handler: (req, res) => {
|
|
203
|
+
if (req.method !== "GET") return json(res, 405, { ok: false, error: "method-not-allowed" });
|
|
204
|
+
if (!guard(req, res)) return;
|
|
205
|
+
json(res, 200, { ok: true, root, cwd, platform: process.platform });
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
kind: "exact",
|
|
210
|
+
path: `${PREFIX}/tree`,
|
|
211
|
+
handler: async (req, res) => {
|
|
212
|
+
if (req.method !== "GET") return json(res, 405, { ok: false, error: "method-not-allowed" });
|
|
213
|
+
if (!guard(req, res)) return;
|
|
214
|
+
const path = queryPath(req) ?? root;
|
|
215
|
+
let entries;
|
|
216
|
+
try {
|
|
217
|
+
const names = await readdir(path, { withFileTypes: true });
|
|
218
|
+
entries = await Promise.all(names.map(async (entry) => {
|
|
219
|
+
const full = join(path, entry.name);
|
|
220
|
+
let size = 0, mtimeMs = 0;
|
|
221
|
+
try {
|
|
222
|
+
const st = await stat(full);
|
|
223
|
+
size = st.size;
|
|
224
|
+
mtimeMs = st.mtimeMs;
|
|
225
|
+
} catch { /* broken link etc. */ }
|
|
226
|
+
return { name: entry.name, path: full, type: entry.isDirectory() ? "dir" : "file", size, mtimeMs };
|
|
227
|
+
}));
|
|
228
|
+
} catch (error) {
|
|
229
|
+
return json(res, 400, { ok: false, error: error.code === "ENOENT" ? "not-found" : String(error.message ?? error) });
|
|
230
|
+
}
|
|
231
|
+
entries.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
|
|
232
|
+
json(res, 200, { ok: true, path, entries });
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
kind: "exact",
|
|
237
|
+
path: `${PREFIX}/read`,
|
|
238
|
+
handler: async (req, res) => {
|
|
239
|
+
if (req.method !== "GET") return json(res, 405, { ok: false, error: "method-not-allowed" });
|
|
240
|
+
if (!guard(req, res)) return;
|
|
241
|
+
const path = queryPath(req);
|
|
242
|
+
if (path === void 0) return json(res, 400, { ok: false, error: "path-required" });
|
|
243
|
+
let current;
|
|
244
|
+
try {
|
|
245
|
+
current = await ledger.readCurrent(path);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return json(res, 400, { ok: false, error: error.code === "ENOENT" ? "not-found" : String(error.message ?? error) });
|
|
248
|
+
}
|
|
249
|
+
if (current.tooLarge) return json(res, 413, { ok: false, error: "file-too-large" });
|
|
250
|
+
// First time we see this file: seed the baseline so a later agent edit
|
|
251
|
+
// produces a real before/after diff.
|
|
252
|
+
if (!ledger.snapshot(path)) ledger.recordBaseline(path, current.content, current.mtimeMs, current.size);
|
|
253
|
+
json(res, 200, { ok: true, path, content: current.content, mtimeMs: current.mtimeMs, size: current.size });
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
kind: "exact",
|
|
258
|
+
path: `${PREFIX}/write`,
|
|
259
|
+
handler: async (req, res) => {
|
|
260
|
+
if (req.method !== "POST") return json(res, 405, { ok: false, error: "method-not-allowed" });
|
|
261
|
+
if (!guard(req, res)) return;
|
|
262
|
+
let body;
|
|
263
|
+
try { body = await readBody(req); }
|
|
264
|
+
catch { return json(res, 400, { ok: false, error: "invalid-body" }); }
|
|
265
|
+
const path = safePath(body?.path);
|
|
266
|
+
if (path === void 0 || typeof body?.content !== "string") return json(res, 400, { ok: false, error: "path-and-content-required" });
|
|
267
|
+
try {
|
|
268
|
+
await writeFile(path, body.content, "utf8");
|
|
269
|
+
} catch (error) {
|
|
270
|
+
return json(res, 400, { ok: false, error: String(error.message ?? error) });
|
|
271
|
+
}
|
|
272
|
+
const st = await stat(path).catch(() => void 0);
|
|
273
|
+
const mtimeMs = st?.mtimeMs ?? Date.now();
|
|
274
|
+
const event = {
|
|
275
|
+
path,
|
|
276
|
+
ts: Date.now(),
|
|
277
|
+
before: ledger.snapshot(path)?.content ?? null,
|
|
278
|
+
after: body.content,
|
|
279
|
+
deleted: false,
|
|
280
|
+
source: "user"
|
|
281
|
+
};
|
|
282
|
+
ledger.recordBaseline(path, body.content, mtimeMs, body.content.length);
|
|
283
|
+
ledger.push(event);
|
|
284
|
+
ledger.addHistory(path, event);
|
|
285
|
+
json(res, 200, { ok: true, path, mtimeMs });
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
kind: "exact",
|
|
290
|
+
path: `${PREFIX}/history`,
|
|
291
|
+
handler: (req, res) => {
|
|
292
|
+
if (req.method !== "GET") return json(res, 405, { ok: false, error: "method-not-allowed" });
|
|
293
|
+
if (!guard(req, res)) return;
|
|
294
|
+
const path = queryPath(req);
|
|
295
|
+
json(res, 200, { ok: true, path, events: path === void 0 ? [] : (ledger.history.get(path) ?? []) });
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
kind: "exact",
|
|
300
|
+
path: `${PREFIX}/events`,
|
|
301
|
+
handler: (req, res) => {
|
|
302
|
+
if (req.method !== "GET" && req.method !== "HEAD") { res.writeHead(405); res.end(); return; }
|
|
303
|
+
if (!guard(req, res)) return;
|
|
304
|
+
res.writeHead(200, {
|
|
305
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
306
|
+
"cache-control": "no-cache",
|
|
307
|
+
connection: "keep-alive"
|
|
308
|
+
});
|
|
309
|
+
res.write(": connected\n\n");
|
|
310
|
+
ledger.subscribers.add(res);
|
|
311
|
+
const heartbeat = setInterval(() => { try { res.write(": ping\n\n"); } catch { /* ignore */ } }, HEARTBEAT_MS);
|
|
312
|
+
const close = () => {
|
|
313
|
+
clearInterval(heartbeat);
|
|
314
|
+
ledger.subscribers.delete(res);
|
|
315
|
+
};
|
|
316
|
+
req.once("close", close);
|
|
317
|
+
res.once("close", close);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* ---------- plugin ---------- */
|
|
324
|
+
|
|
325
|
+
function apply(ctx, config) {
|
|
326
|
+
const root = realpathSync((config?.root && config.root !== "" ? config.root : process.env.DSH_WORKSPACE ?? process.cwd()));
|
|
327
|
+
const cwd = process.cwd();
|
|
328
|
+
const ledger = new FileLedger(root);
|
|
329
|
+
const pollIntervalMs = config?.pollIntervalMs ?? 1500;
|
|
330
|
+
ctx.effect(() => {
|
|
331
|
+
const disposers = [];
|
|
332
|
+
try {
|
|
333
|
+
for (const route of makeRoutes(ledger, root, cwd)) disposers.push(ctx.webServer.register(route));
|
|
334
|
+
ledger.start();
|
|
335
|
+
const timer = setInterval(() => ledger.poll(), pollIntervalMs);
|
|
336
|
+
timer.unref();
|
|
337
|
+
disposers.push(() => clearInterval(timer));
|
|
338
|
+
} catch (error) {
|
|
339
|
+
for (const dispose of disposers) dispose();
|
|
340
|
+
ledger.stop();
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
return () => {
|
|
344
|
+
for (const dispose of disposers.splice(0)) dispose();
|
|
345
|
+
ledger.stop();
|
|
346
|
+
};
|
|
347
|
+
}, "code-studio: routes and watcher");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export { apply, inject, name };
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@windypro-rourou/dsh-code-studio",
|
|
3
|
+
"description": "Code Studio for DSH Web GUI — a VS Code + Cline hybrid: file tree, syntax-highlighted editor, and Cline-style line-by-line diffs that auto-appear when the agent edits files.",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/types/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/types/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"types": "./lib/types/client/index.d.ts",
|
|
15
|
+
"default": "./lib/client.js"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"dsh": {
|
|
20
|
+
"bundle": {
|
|
21
|
+
"patch": "./cordis.patch.yml"
|
|
22
|
+
},
|
|
23
|
+
"client": {
|
|
24
|
+
"inject": [
|
|
25
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
26
|
+
],
|
|
27
|
+
"platform": "web"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"dsh-plugin",
|
|
32
|
+
"dsh",
|
|
33
|
+
"deepseek-harness",
|
|
34
|
+
"code-studio",
|
|
35
|
+
"cline",
|
|
36
|
+
"diff",
|
|
37
|
+
"editor",
|
|
38
|
+
"web-ui"
|
|
39
|
+
],
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/windypro-rourou/dsh-code-studio.git"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
46
|
+
"react": "^18.2.0"
|
|
47
|
+
},
|
|
48
|
+
"files": [
|
|
49
|
+
"lib/**/*.js",
|
|
50
|
+
"lib/**/*.d.ts",
|
|
51
|
+
"cordis.patch.yml",
|
|
52
|
+
"README.md",
|
|
53
|
+
"scripts"
|
|
54
|
+
],
|
|
55
|
+
"license": "MIT"
|
|
56
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Install / refresh dsh-code-studio into the web profile.
|
|
2
|
+
# Idempotent: safe to re-run after editing the plugin sources.
|
|
3
|
+
$ErrorActionPreference = "Stop"
|
|
4
|
+
$src = "F:\CycleMaster\dsh-code-studio"
|
|
5
|
+
$profile = Join-Path $env:USERPROFILE ".dsh\profiles\web"
|
|
6
|
+
|
|
7
|
+
Write-Host "==> Copying plugin package into profile node_modules..."
|
|
8
|
+
$dest = Join-Path $profile "node_modules\@local\dsh-code-studio"
|
|
9
|
+
New-Item -ItemType Directory -Force -Path (Split-Path $dest) | Out-Null
|
|
10
|
+
Copy-Item -Recurse -Force $src $dest
|
|
11
|
+
|
|
12
|
+
Write-Host "==> Ensuring package.json dependency..."
|
|
13
|
+
$pkgPath = Join-Path $profile "package.json"
|
|
14
|
+
$pkg = Get-Content $pkgPath -Raw | ConvertFrom-Json
|
|
15
|
+
if (-not $pkg.dependencies."@local/dsh-code-studio") {
|
|
16
|
+
$pkg.dependencies | Add-Member -NotePropertyName "@local/dsh-code-studio" -NotePropertyValue "link:F:/CycleMaster/dsh-code-studio" -Force
|
|
17
|
+
($pkg | ConvertTo-Json -Depth 10) | Set-Content $pkgPath -Encoding UTF8
|
|
18
|
+
Write-Host " added dependency."
|
|
19
|
+
} else {
|
|
20
|
+
Write-Host " dependency already present."
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
Write-Host "==> Ensuring cordis.patch.yml insert row..."
|
|
24
|
+
$patchPath = Join-Path $profile "cordis.patch.yml"
|
|
25
|
+
$patch = Get-Content $patchPath -Raw
|
|
26
|
+
if ($patch -notmatch "code-studio") {
|
|
27
|
+
$row = "- insert:" + [char]10 + " - id: code-studio" + [char]10 + " name: '@local/dsh-code-studio'" + [char]10
|
|
28
|
+
Add-Content $patchPath $row
|
|
29
|
+
Write-Host " insert row added."
|
|
30
|
+
} else {
|
|
31
|
+
Write-Host " insert row already present."
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
Write-Host ""
|
|
35
|
+
Write-Host "Done. The plugin activates on the next 'dsh web' start (client plugin roster is fixed at boot)."
|
|
36
|
+
Write-Host "Preview now on another port: dsh web --port 3081"
|