dsh-design-studio 0.4.1 → 0.5.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.en.md +13 -7
- package/README.md +33 -11
- package/cordis.patch.yml +1 -1
- package/lib/browser.js +251 -0
- package/lib/bundle.js +228 -0
- package/lib/client.js +1067 -46
- package/lib/core.js +233 -21
- package/lib/doc.js +356 -0
- package/lib/index.js +428 -21
- package/lib/look.js +550 -0
- package/lib/picker.js +1118 -24
- package/lib/serve.js +23 -7
- package/lib/tool.js +6 -3
- package/locale/tool/en.json +2 -2
- package/locale/tool/zh.json +2 -2
- package/package.json +1 -1
- package/skills/ui-design/SKILL.md +40 -2
- package/skills/ui-design/references/app.md +1 -1
package/lib/core.js
CHANGED
|
@@ -16,7 +16,9 @@ const LOCAL_URL = /^(?:https?:\/\/)?(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):
|
|
|
16
16
|
// 命令输出里的本机地址(开发服务器启动时会打印)
|
|
17
17
|
const URL_IN_TEXT = /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):(\d{2,5})(\/[^\s"'`<>)\]]*)?/gi
|
|
18
18
|
const LIVE_MS = 120000 // 事件时间离现在超过这么久就当是重放历史
|
|
19
|
+
const CHANGE_LOG = 20 // 记最近几批文件改动(远程看按它补齐两次轮询之间的改动)
|
|
19
20
|
const DESIGN_SKILL = 'ui-design'
|
|
21
|
+
const ASK_TOOL = 'ask_user_question' // DSH 的提问卡:模型问了、在等用户回答
|
|
20
22
|
const DESIGN_META = /<meta[^>]+name=["']?dsh-design\b/i
|
|
21
23
|
// 只查看、不启动服务的命令(curl 一下别的项目的端口,不该把那个端口当成在做的设计)
|
|
22
24
|
const PROBE_CMD = /\b(curl|wget|httpie|xh|lsof|netstat|ss|nc|telnet|kill|pkill|open|ps)\b/i
|
|
@@ -64,7 +66,8 @@ export function listScreens(root) {
|
|
|
64
66
|
try { dirents = fs.readdirSync(root, { withFileTypes: true }) } catch { return [] }
|
|
65
67
|
const out = []
|
|
66
68
|
for (const d of dirents) {
|
|
67
|
-
|
|
69
|
+
// . 开头是隐藏文件,_ 开头当草稿 / 模型的临时探针(_probe.html、_layout.html),都不算一屏
|
|
70
|
+
if (!d.isFile() || !HTML_EXT.test(d.name) || d.name.startsWith('.') || d.name.startsWith('_')) continue
|
|
68
71
|
let born = 0
|
|
69
72
|
try {
|
|
70
73
|
const st = fs.statSync(path.join(root, d.name))
|
|
@@ -267,6 +270,176 @@ export function replaceText(html, before, after, nth = 0) {
|
|
|
267
270
|
return { ok: false, reason: 'not-found' }
|
|
268
271
|
}
|
|
269
272
|
|
|
273
|
+
// ── 预览里调样式:在 HTML 源码里找到那个元素的开头标签,把改的样式并进它的 style 属性 ─────────
|
|
274
|
+
|
|
275
|
+
/** 开头标签(属性值里可以有 >);注释整块跳过,脚本 / 样式 / 模板这类连内容一起跳过(模板里的元素不在页面上;<SCRIPT> 大写也算) */
|
|
276
|
+
const TAG_SCAN = /<!--[\s\S]*?-->|<(script|style|textarea|title|template)\b(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/\1\s*>|<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/gi
|
|
277
|
+
const ATTR = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g
|
|
278
|
+
/** 能调的几项;值只许数字、单位、颜色、var(--x) 这类,引号、分号、尖括号一律不收 */
|
|
279
|
+
export const STYLE_PROPS = ['font-size', 'font-weight', 'color', 'background', 'border-radius', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'gap']
|
|
280
|
+
const STYLE_VALUE = /^[\w\s#%.,()+-]{1,80}$/
|
|
281
|
+
|
|
282
|
+
function attrsOf(s) {
|
|
283
|
+
const out = {}
|
|
284
|
+
ATTR.lastIndex = 0
|
|
285
|
+
for (let m = ATTR.exec(s); m; m = ATTR.exec(s)) {
|
|
286
|
+
const k = m[1].toLowerCase()
|
|
287
|
+
if (!(k in out)) out[k] = { v: m[2] ?? m[3] ?? m[4] ?? '', at: m.index, len: m[0].length }
|
|
288
|
+
}
|
|
289
|
+
return out
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 页面上那个元素在源码里是哪个开头标签。loc:{ tag, n(页面上第几个这种标签), total(页面上一共几个), id, cls(页面上的类) }。
|
|
294
|
+
* 页面上和源码里这种标签一样多:按第几个找,再核对 id 和类(页面脚本可能加了类,源码里的类都在就行);
|
|
295
|
+
* 对不上(页面脚本加过元素):只认 id 和类完全一样、而且只有一个的。都不行就算找不到,宁可交给模型也别改错地方。
|
|
296
|
+
*/
|
|
297
|
+
export function findTag(html, { tag, n = 0, total = -1, id = '', cls = [] } = {}) {
|
|
298
|
+
const name = String(tag || '').toLowerCase()
|
|
299
|
+
if (!/^[a-z][\w-]*$/.test(name)) return null
|
|
300
|
+
const list = []
|
|
301
|
+
TAG_SCAN.lastIndex = 0
|
|
302
|
+
for (let m = TAG_SCAN.exec(html); m; m = TAG_SCAN.exec(html)) {
|
|
303
|
+
if (m[1] || !m[2] || m[2].toLowerCase() !== name) continue
|
|
304
|
+
list.push({ start: m.index, end: m.index + m[0].length, name: m[2], attrs: m[3] || '' })
|
|
305
|
+
}
|
|
306
|
+
const want = new Set((Array.isArray(cls) ? cls : []).map(String))
|
|
307
|
+
const classesOf = (x) => { const a = attrsOf(x.attrs); return { id: a.id ? a.id.v : '', cls: a.class ? a.class.v.split(/\s+/).filter(Boolean) : [] } }
|
|
308
|
+
const same = (x) => { const a = classesOf(x); return a.id === String(id || '') && a.cls.length === want.size && a.cls.every((k) => want.has(k)) }
|
|
309
|
+
const x = list[n]
|
|
310
|
+
if (x && total === list.length) {
|
|
311
|
+
const a = classesOf(x)
|
|
312
|
+
if (a.id === String(id || '') && a.cls.every((k) => want.has(k))) return x
|
|
313
|
+
}
|
|
314
|
+
const hits = list.filter(same)
|
|
315
|
+
return hits.length === 1 ? hits[0] : null
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** style 属性按 ; 拆成一条条(括号、引号里的不拆,比如 url(data:…;base64,…)) */
|
|
319
|
+
function splitDecls(s) {
|
|
320
|
+
const out = []
|
|
321
|
+
let depth = 0
|
|
322
|
+
let q = ''
|
|
323
|
+
let cur = ''
|
|
324
|
+
for (const ch of s) {
|
|
325
|
+
if (q) { if (ch === q) q = ''; cur += ch; continue }
|
|
326
|
+
if (ch === '"' || ch === "'") q = ch
|
|
327
|
+
else if (ch === '(') depth++
|
|
328
|
+
else if (ch === ')') depth = Math.max(0, depth - 1)
|
|
329
|
+
else if (ch === ';' && !depth) { out.push(cur); cur = ''; continue }
|
|
330
|
+
cur += ch
|
|
331
|
+
}
|
|
332
|
+
out.push(cur)
|
|
333
|
+
return out.map((d) => d.trim()).filter(Boolean)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 把 css(属性 → 值,值为空 = 去掉这一条)并进原来的 style(已解开实体的文字):改过的原地换(同名的几条只留一条),新的放最后;
|
|
338
|
+
* 写 background 时去掉 background-color / -image(不然前后打架)。自定义属性(--x)区分大小写,原样留着。
|
|
339
|
+
*/
|
|
340
|
+
export function mergeStyle(style, css) {
|
|
341
|
+
const decls = splitDecls(String(style || '')).map((d) => {
|
|
342
|
+
const i = d.indexOf(':')
|
|
343
|
+
if (i <= 0) return null
|
|
344
|
+
const p = d.slice(0, i).trim()
|
|
345
|
+
return [p.startsWith('--') ? p : p.toLowerCase(), d.slice(i + 1).trim()]
|
|
346
|
+
}).filter(Boolean)
|
|
347
|
+
for (const [k, v] of Object.entries(css || {})) {
|
|
348
|
+
const prop = k.toLowerCase()
|
|
349
|
+
if (prop === 'background') for (let i = decls.length - 1; i >= 0; i--) if (/^background-(color|image)$/.test(decls[i][0])) decls.splice(i, 1)
|
|
350
|
+
const i = decls.findIndex(([p]) => p === prop)
|
|
351
|
+
for (let j = decls.length - 1; j > i && i >= 0; j--) if (decls[j][0] === prop) decls.splice(j, 1)
|
|
352
|
+
if (!v) { if (i >= 0) decls.splice(i, 1); continue }
|
|
353
|
+
if (i >= 0) decls[i][1] = v
|
|
354
|
+
else decls.push([prop, v])
|
|
355
|
+
}
|
|
356
|
+
return decls.map(([p, v]) => `${p}: ${v}`).join('; ')
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** 属性值里的实体解回来(style 里用得到的:引号、&、尖括号) */
|
|
360
|
+
const unAttr = (s) => String(s)
|
|
361
|
+
.replace(/&(quot|#0*34|#x0*22);/gi, '"').replace(/&(apos|#0*39|#x0*27);/gi, "'")
|
|
362
|
+
.replace(/&(lt|#0*60|#x0*3c);/gi, '<').replace(/&(gt|#0*62|#x0*3e);/gi, '>').replace(/&(amp|#0*38|#x0*26);/gi, '&')
|
|
363
|
+
|
|
364
|
+
/** 只留能调的那几项、值合规的;一项都不剩返回 null */
|
|
365
|
+
export function cleanCss(css) {
|
|
366
|
+
const out = {}
|
|
367
|
+
for (const [k, v] of Object.entries(css && typeof css === 'object' ? css : {})) {
|
|
368
|
+
const prop = String(k).toLowerCase()
|
|
369
|
+
const val = String(v ?? '').trim()
|
|
370
|
+
if (!STYLE_PROPS.includes(prop)) continue
|
|
371
|
+
if (val && (!STYLE_VALUE.test(val) || /url\s*\(|expression/i.test(val))) continue
|
|
372
|
+
out[prop] = val
|
|
373
|
+
}
|
|
374
|
+
return Object.keys(out).length ? out : null
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** 选择器写法统一:去引号、:: → :、空白压成一个、组合符和括号两边不留空格 */
|
|
378
|
+
const normSel = (s) => String(s || '').replace(/["']/g, '').replace(/::/g, ':').replace(/\s+/g, ' ').replace(/ ?([>+~,()]) ?/g, '$1').trim()
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* 一条样式规则在源码(.css,或 HTML 里的 <style>)的第几行。
|
|
382
|
+
* sel 是浏览器给的(规范化过:组合符两边加空格、属性值加双引号),源码里可能写得紧凑、换了引号、多了换行:都认(大小写照原样比)。
|
|
383
|
+
* 不用拼正则:逐个字符找规则开头的「{」,把它前面那段选择器统一写法后比较,一遍扫完。
|
|
384
|
+
* n:同样选择器的第几条(源码顺序,@media 里的也算)。找不到给 0
|
|
385
|
+
*/
|
|
386
|
+
export function findRule(text, sel, n = 0) {
|
|
387
|
+
const want = normSel(sel)
|
|
388
|
+
if (!want || want.length > 400) return 0
|
|
389
|
+
const src = String(text || '').replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')) // 注释换成空格(位置、行号不变)
|
|
390
|
+
// HTML 只看 <style> 里面(脚本、正文里像 CSS 的字不算)
|
|
391
|
+
const regions = []
|
|
392
|
+
if (/<style\b/i.test(src)) {
|
|
393
|
+
const re = /<style\b[^>]*>/gi
|
|
394
|
+
let m
|
|
395
|
+
while ((m = re.exec(src))) {
|
|
396
|
+
const from = m.index + m[0].length
|
|
397
|
+
const end = src.slice(from).search(/<\/style\s*>/i)
|
|
398
|
+
regions.push([from, end < 0 ? src.length : from + end])
|
|
399
|
+
re.lastIndex = end < 0 ? src.length : from + end
|
|
400
|
+
}
|
|
401
|
+
} else regions.push([0, src.length])
|
|
402
|
+
let k = 0
|
|
403
|
+
for (const [from, to] of regions) {
|
|
404
|
+
let start = from
|
|
405
|
+
for (let i = from; i < to; i++) {
|
|
406
|
+
const ch = src[i]
|
|
407
|
+
if (ch === '"' || ch === "'") { // 字符串里的 { } ; 不算
|
|
408
|
+
let j = i + 1
|
|
409
|
+
while (j < to && src[j] !== ch && src[j] !== '\n') j += src[j] === '\\' ? 2 : 1
|
|
410
|
+
i = j
|
|
411
|
+
} else if (ch === '{') {
|
|
412
|
+
const pre = src.slice(start, i)
|
|
413
|
+
if (normSel(pre) === want && k++ === n) {
|
|
414
|
+
const at = start + (pre.length - pre.trimStart().length)
|
|
415
|
+
return src.slice(0, at).split('\n').length
|
|
416
|
+
}
|
|
417
|
+
start = i + 1
|
|
418
|
+
} else if (ch === '}' || ch === ';') start = i + 1
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return 0
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* 把 css 写进页面上那个元素(loc,见 findTag)在源码里的 style 属性。
|
|
426
|
+
* @returns {{ ok: true, html } | { ok: false, reason: 'empty' | 'not-found' }}
|
|
427
|
+
*/
|
|
428
|
+
export function setStyle(html, loc, css) {
|
|
429
|
+
const clean = cleanCss(css)
|
|
430
|
+
if (!clean) return { ok: false, reason: 'empty' }
|
|
431
|
+
const x = findTag(html, loc)
|
|
432
|
+
if (!x) return { ok: false, reason: 'not-found' }
|
|
433
|
+
const a = attrsOf(x.attrs).style
|
|
434
|
+
const next = mergeStyle(a ? unAttr(a.v) : '', clean).replace(/&/g, '&').replace(/"/g, '"')
|
|
435
|
+
let attrs
|
|
436
|
+
if (a) attrs = x.attrs.slice(0, a.at) + (next ? `style="${next}"` : '') + x.attrs.slice(a.at + a.len)
|
|
437
|
+
// 自闭合的 / 前面得是空白或引号(src=a.png/ 里的 / 是属性值的一部分)
|
|
438
|
+
else if (next) attrs = /(^|["'\s])\/\s*$/.test(x.attrs) ? x.attrs.replace(/\s*\/\s*$/, ` style="${next}" /`) : x.attrs + ` style="${next}"`
|
|
439
|
+
else attrs = x.attrs
|
|
440
|
+
return { ok: true, html: html.slice(0, x.start) + `<${x.name}${attrs}>` + html.slice(x.end) }
|
|
441
|
+
}
|
|
442
|
+
|
|
270
443
|
export function serverUrlIn(text, skipPorts = new Set()) {
|
|
271
444
|
let found = null
|
|
272
445
|
URL_IN_TEXT.lastIndex = 0
|
|
@@ -291,6 +464,8 @@ export function serverUrlIn(text, skipPorts = new Set()) {
|
|
|
291
464
|
* openRev 每次要右侧栏自动弹出来就 +1(客户端看到变大就打开标签页)
|
|
292
465
|
* rev 页面内容变了就 +1(文件改动)
|
|
293
466
|
* turn 这个会话正在跑一轮(预览上显示「正在生成」)
|
|
467
|
+
* turns 这次运行里干完了几轮(右侧栏看它变了就自动体检)
|
|
468
|
+
* asking 模型用提问卡问了用户、在等回答(右侧栏不转圈,提示去对话里回答)
|
|
294
469
|
*
|
|
295
470
|
* @param opts.open (target) => Promise<{ origin }> 开预览服务器
|
|
296
471
|
* @param opts.skipPorts () => Set<number> 不当开发服务器的端口(DSH 自己、预览服务器)
|
|
@@ -401,15 +576,21 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
|
|
|
401
576
|
return cwds.get(id) || os.homedir()
|
|
402
577
|
}
|
|
403
578
|
|
|
404
|
-
/**
|
|
579
|
+
/**
|
|
580
|
+
* 新的一屏排到最后;同一屏的几个版本挨在一起、按字母排,方便挑:
|
|
581
|
+
* 「再出两版」的 login-b.html / login-c.html 紧跟在 login.html 后面;开局的三个方向 home-a / home-b / home-c 哪个先写完都排成 a、b、c
|
|
582
|
+
*/
|
|
405
583
|
function placeScreen(list, k) {
|
|
406
|
-
const VAR = /^(.+)-[
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
584
|
+
const VAR = /^(.+)-([a-z])\.html?$/
|
|
585
|
+
const part = (x) => {
|
|
586
|
+
const v = VAR.exec(x.toLowerCase())
|
|
587
|
+
return v ? { stem: v[1], letter: v[2] } : { stem: x.toLowerCase().replace(/\.html?$/, ''), letter: '' }
|
|
588
|
+
}
|
|
589
|
+
const me = part(k)
|
|
590
|
+
const group = me.letter ? list.map((x, i) => ({ i, ...part(x) })).filter((x) => x.stem === me.stem) : []
|
|
591
|
+
if (group.length) {
|
|
592
|
+
const after = group.find((x) => x.letter > me.letter)
|
|
593
|
+
list.splice(after ? after.i : group[group.length - 1].i + 1, 0, k)
|
|
413
594
|
return
|
|
414
595
|
}
|
|
415
596
|
list.push(k)
|
|
@@ -504,12 +685,18 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
|
|
|
504
685
|
persist(id, st)
|
|
505
686
|
}
|
|
506
687
|
|
|
507
|
-
/** 预览服务器报告某个目录里有文件改了:用到这个目录的会话都 rev+1 */
|
|
508
|
-
function changed(root) {
|
|
688
|
+
/** 预览服务器报告某个目录里有文件改了:用到这个目录的会话都 rev+1(files:改了哪些,远程看的右侧栏按它决定哪几屏重新打包) */
|
|
689
|
+
function changed(root, files) {
|
|
509
690
|
for (const [id, st] of states) {
|
|
510
691
|
if (st.target && st.target.kind === 'file' && st.target.root === root) {
|
|
511
692
|
st.rev++
|
|
512
693
|
st.changedAt = now()
|
|
694
|
+
// 文件太多(预览服务器那边已经截断了)就不列:空的 = 不知道改了啥,右侧栏整屏刷新
|
|
695
|
+
const list = Array.isArray(files) ? files.filter((f) => typeof f === 'string') : []
|
|
696
|
+
st.changedFiles = list.length > 50 ? [] : list
|
|
697
|
+
// 最近几批改动(递增序号):远程看的右侧栏两次轮询之间可能来了好几批,按序号取并集,别只看最后一批
|
|
698
|
+
st.changeN = (st.changeN || 0) + 1
|
|
699
|
+
st.changes = [...(st.changes || []), { n: st.changeN, files: st.changedFiles }].slice(-CHANGE_LOG)
|
|
513
700
|
const before = st.screens.join('\n')
|
|
514
701
|
refreshScreens(st)
|
|
515
702
|
if (st.screens.join('\n') !== before) persist(id, st)
|
|
@@ -543,7 +730,11 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
|
|
|
543
730
|
case 'turn/end':
|
|
544
731
|
st.turn = false
|
|
545
732
|
st.writing = ''
|
|
546
|
-
|
|
733
|
+
st.asking = false
|
|
734
|
+
if (live) {
|
|
735
|
+
keepVersion(st, st.ask)
|
|
736
|
+
st.turns = (st.turns || 0) + 1 // 右侧栏靠它知道「又干完一轮」(很快的一轮轮询看不到 turn 变 true)
|
|
737
|
+
}
|
|
547
738
|
st.ask = '' // 用过就清:下一轮不是用户发起的(没有新的一句话)就不借这一句当名字
|
|
548
739
|
break
|
|
549
740
|
case 'tool/call': {
|
|
@@ -551,6 +742,7 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
|
|
|
551
742
|
if (d.name === 'skill' && argsOf(d.arguments).name === DESIGN_SKILL) st.design = true
|
|
552
743
|
if (d.name === 'preview_page') st.design = true
|
|
553
744
|
if (!live) break
|
|
745
|
+
if (d.name === ASK_TOOL) st.asking = true
|
|
554
746
|
if (d.callId) {
|
|
555
747
|
st.calls.set(d.callId, { name: d.name, args: argsOf(d.arguments) })
|
|
556
748
|
if (st.calls.size > 64) st.calls.delete(st.calls.keys().next().value)
|
|
@@ -575,6 +767,7 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
|
|
|
575
767
|
const callId = (d.message && (d.message.toolCallId || (d.message.source && d.message.source.callId))) || d.callId || ''
|
|
576
768
|
const call = callId ? st.calls.get(callId) : null
|
|
577
769
|
if (callId) st.calls.delete(callId)
|
|
770
|
+
if (!call || call.name === ASK_TOOL) st.asking = false
|
|
578
771
|
if (pending && fs.existsSync(pending) && (!st.target || st.source === 'auto') && !(st.target && st.target.file === pending)) {
|
|
579
772
|
// 只有在做设计(加载了设计技能、或页面写了设计类型标记)才自动打开;别的任务写的网页只记下来,不弹
|
|
580
773
|
// 用户关了「自动打开预览」:也只记下来,右侧栏给个按钮
|
|
@@ -623,16 +816,32 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
|
|
|
623
816
|
return v
|
|
624
817
|
}
|
|
625
818
|
|
|
819
|
+
/** 预览里直接改字:在这一屏(key = 文件名)的 HTML 里把第 nth 处 before 换成 after,改前改后各存一版 */
|
|
820
|
+
function editText(id, { key, before, after, nth = 0 } = {}) {
|
|
821
|
+
const short = String(after).replace(/\s+/g, ' ').trim()
|
|
822
|
+
return rewrite(id, key, (html) => replaceText(html, before, after, Math.max(0, Math.floor(Number(nth) || 0))),
|
|
823
|
+
`改字「${short.length > 16 ? short.slice(0, 16) + '…' : short}」`)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** 预览里调样式:把 css 写进这一屏(key)里那个元素(loc,见 findTag)的 style 属性,改前改后各存一版 */
|
|
827
|
+
function editStyle(id, { key, loc, css, name } = {}) {
|
|
828
|
+
const short = String(name || (loc && loc.tag) || '').trim()
|
|
829
|
+
return rewrite(id, key, (html) => setStyle(html, loc || {}, css), `调样式「${short.length > 24 ? short.slice(0, 24) + '…' : short}」`)
|
|
830
|
+
}
|
|
831
|
+
|
|
626
832
|
/**
|
|
627
|
-
*
|
|
833
|
+
* 改这一屏(key = 文件名)的 HTML:fn(原文) → { ok, html }。
|
|
628
834
|
* 开发服务器的页面源码在项目里、不一定是这份 HTML,不在这里改(右侧栏改成让模型去改)。
|
|
629
|
-
*
|
|
835
|
+
* 模型正在干活时不改:它可能正整份重写这个文件,改了也会被盖掉。
|
|
630
836
|
*/
|
|
631
|
-
function
|
|
837
|
+
function rewrite(id, key, fn, label) {
|
|
632
838
|
const st = states.get(id)
|
|
633
839
|
const t = st && st.target
|
|
634
840
|
if (!t || t.kind !== 'file') return { ok: false, reason: 'not-file' }
|
|
635
|
-
|
|
841
|
+
// 同一个目录别的会话的模型正在干活也不改(它可能正整份重写这个文件);经软链接打开的同一个目录也算
|
|
842
|
+
const realOf = (p) => { try { return fs.realpathSync(p) } catch { return p } }
|
|
843
|
+
const mine = realOf(t.root)
|
|
844
|
+
for (const o of states.values()) if (o.turn && o.target && o.target.kind === 'file' && (o.target.root === t.root || realOf(o.target.root) === mine)) return { ok: false, reason: 'busy' }
|
|
636
845
|
const name = String(key || path.basename(t.file))
|
|
637
846
|
const file = path.join(t.root, name)
|
|
638
847
|
if (path.dirname(file) !== t.root || !HTML_EXT.test(name) || !fs.existsSync(file)) return { ok: false, reason: 'no-screen' }
|
|
@@ -640,12 +849,11 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
|
|
|
640
849
|
let real = ''
|
|
641
850
|
try { real = fs.realpathSync(file) } catch { real = '' }
|
|
642
851
|
if (!real.startsWith(fs.realpathSync(t.root) + path.sep)) return { ok: false, reason: 'no-screen' }
|
|
643
|
-
const r =
|
|
852
|
+
const r = fn(fs.readFileSync(file, 'utf8'))
|
|
644
853
|
if (!r.ok) return r
|
|
645
854
|
keepVersion(st, '')
|
|
646
855
|
fs.writeFileSync(file, r.html)
|
|
647
|
-
|
|
648
|
-
keepVersion(st, `改字「${short.length > 16 ? short.slice(0, 16) + '…' : short}」`)
|
|
856
|
+
keepVersion(st, label)
|
|
649
857
|
return { ok: true, file: name }
|
|
650
858
|
}
|
|
651
859
|
|
|
@@ -669,7 +877,7 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
|
|
|
669
877
|
const st = states.get(id)
|
|
670
878
|
if (!(st && st.target)) restore(id)
|
|
671
879
|
else if (st.origin && !alive(st.origin)) revive(id, st)
|
|
672
|
-
if (!st || !st.target) return { target: null, rev: st ? st.rev : 0, openRev: st ? st.openRev : 0, turn: st ? st.turn : false, writing: '', hint: st && st.hint && fs.existsSync(st.hint) ? st.hint : '', loading: restoring.has(id) }
|
|
880
|
+
if (!st || !st.target) return { target: null, rev: st ? st.rev : 0, openRev: st ? st.openRev : 0, turn: st ? st.turn : false, turns: st ? st.turns || 0 : 0, asking: !!(st && st.asking), writing: '', hint: st && st.hint && fs.existsSync(st.hint) ? st.hint : '', loading: restoring.has(id) }
|
|
673
881
|
const { target, url, label, source, rev, openRev, turn, writing, changedAt, kind, size, view, origin } = st
|
|
674
882
|
const file = target.kind === 'file'
|
|
675
883
|
return {
|
|
@@ -680,8 +888,12 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
|
|
|
680
888
|
rev,
|
|
681
889
|
openRev,
|
|
682
890
|
turn,
|
|
891
|
+
turns: st.turns || 0,
|
|
892
|
+
asking: !!st.asking,
|
|
683
893
|
writing,
|
|
684
894
|
changedAt,
|
|
895
|
+
changedFiles: st.changedFiles || [],
|
|
896
|
+
changes: st.changes || [],
|
|
685
897
|
kind,
|
|
686
898
|
size,
|
|
687
899
|
view,
|
|
@@ -696,5 +908,5 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
|
|
|
696
908
|
}
|
|
697
909
|
}
|
|
698
910
|
|
|
699
|
-
return { observe, setTarget, setView, clear, changed, snapshot, cwdOf, stateOf, versions, restoreVersion, editText, keepVersion: (id, label) => keepVersion(stateOf(id), label) }
|
|
911
|
+
return { observe, setTarget, setView, clear, changed, snapshot, cwdOf, stateOf, versions, restoreVersion, editText, editStyle, keepVersion: (id, label) => keepVersion(stateOf(id), label) }
|
|
700
912
|
}
|