dsh-design-studio 0.4.1 → 0.6.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/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
- if (!d.isFile() || !HTML_EXT.test(d.name) || d.name.startsWith('.')) continue
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, '&amp;').replace(/"/g, '&quot;')
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 自己、预览服务器)
@@ -344,7 +519,7 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
344
519
 
345
520
  function persist(id, st) {
346
521
  if (!store) return
347
- if (st.target) saved[id] = { target: st.target, source: st.source, design: st.design, kind: st.kind, size: st.size, view: st.view, routes: st.routes, order: st.target.kind === 'file' ? st.screens : undefined, at: now() }
522
+ if (st.target) saved[id] = { target: st.target, source: st.source, design: st.design, kind: st.kind, size: st.size, view: st.view, routes: st.routes, order: st.target.kind === 'file' ? st.screens : undefined, cwd: cwds.get(id) || (saved[id] && saved[id].cwd) || undefined, at: now() }
348
523
  else delete saved[id]
349
524
  // 只留最近 200 个会话
350
525
  const ids = Object.keys(saved)
@@ -398,18 +573,25 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
398
573
  }
399
574
 
400
575
  function cwdOf(id) {
401
- return cwds.get(id) || os.homedir()
576
+ // DSH 重启后还没跑过工具时内存里没有,用缓存里记的(右侧栏「转成代码」要靠它找项目)
577
+ return cwds.get(id) || (saved[id] && saved[id].cwd) || os.homedir()
402
578
  }
403
579
 
404
- /** 新的一屏排到最后;「再出两版」的 login-b.html / login-c.html 紧跟在 login.html 后面,方便挑 */
580
+ /**
581
+ * 新的一屏排到最后;同一屏的几个版本挨在一起、按字母排,方便挑:
582
+ * 「再出两版」的 login-b.html / login-c.html 紧跟在 login.html 后面;开局的三个方向 home-a / home-b / home-c 哪个先写完都排成 a、b、c
583
+ */
405
584
  function placeScreen(list, k) {
406
- const VAR = /^(.+)-[b-z]\.html?$/
407
- const stem = (x) => x.toLowerCase().replace(/\.html?$/, '')
408
- const m = VAR.exec(k.toLowerCase())
409
- if (m && list.some((x) => stem(x) === m[1])) {
410
- let at = -1
411
- list.forEach((x, i) => { if (stem(x) === m[1] || (VAR.exec(x.toLowerCase()) || [])[1] === m[1]) at = i })
412
- list.splice(at + 1, 0, k)
585
+ const VAR = /^(.+)-([a-z])\.html?$/
586
+ const part = (x) => {
587
+ const v = VAR.exec(x.toLowerCase())
588
+ return v ? { stem: v[1], letter: v[2] } : { stem: x.toLowerCase().replace(/\.html?$/, ''), letter: '' }
589
+ }
590
+ const me = part(k)
591
+ const group = me.letter ? list.map((x, i) => ({ i, ...part(x) })).filter((x) => x.stem === me.stem) : []
592
+ if (group.length) {
593
+ const after = group.find((x) => x.letter > me.letter)
594
+ list.splice(after ? after.i : group[group.length - 1].i + 1, 0, k)
413
595
  return
414
596
  }
415
597
  list.push(k)
@@ -504,12 +686,18 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
504
686
  persist(id, st)
505
687
  }
506
688
 
507
- /** 预览服务器报告某个目录里有文件改了:用到这个目录的会话都 rev+1 */
508
- function changed(root) {
689
+ /** 预览服务器报告某个目录里有文件改了:用到这个目录的会话都 rev+1(files:改了哪些,远程看的右侧栏按它决定哪几屏重新打包) */
690
+ function changed(root, files) {
509
691
  for (const [id, st] of states) {
510
692
  if (st.target && st.target.kind === 'file' && st.target.root === root) {
511
693
  st.rev++
512
694
  st.changedAt = now()
695
+ // 文件太多(预览服务器那边已经截断了)就不列:空的 = 不知道改了啥,右侧栏整屏刷新
696
+ const list = Array.isArray(files) ? files.filter((f) => typeof f === 'string') : []
697
+ st.changedFiles = list.length > 50 ? [] : list
698
+ // 最近几批改动(递增序号):远程看的右侧栏两次轮询之间可能来了好几批,按序号取并集,别只看最后一批
699
+ st.changeN = (st.changeN || 0) + 1
700
+ st.changes = [...(st.changes || []), { n: st.changeN, files: st.changedFiles }].slice(-CHANGE_LOG)
513
701
  const before = st.screens.join('\n')
514
702
  refreshScreens(st)
515
703
  if (st.screens.join('\n') !== before) persist(id, st)
@@ -543,7 +731,11 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
543
731
  case 'turn/end':
544
732
  st.turn = false
545
733
  st.writing = ''
546
- if (live) keepVersion(st, st.ask)
734
+ st.asking = false
735
+ if (live) {
736
+ keepVersion(st, st.ask)
737
+ st.turns = (st.turns || 0) + 1 // 右侧栏靠它知道「又干完一轮」(很快的一轮轮询看不到 turn 变 true)
738
+ }
547
739
  st.ask = '' // 用过就清:下一轮不是用户发起的(没有新的一句话)就不借这一句当名字
548
740
  break
549
741
  case 'tool/call': {
@@ -551,6 +743,7 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
551
743
  if (d.name === 'skill' && argsOf(d.arguments).name === DESIGN_SKILL) st.design = true
552
744
  if (d.name === 'preview_page') st.design = true
553
745
  if (!live) break
746
+ if (d.name === ASK_TOOL) st.asking = true
554
747
  if (d.callId) {
555
748
  st.calls.set(d.callId, { name: d.name, args: argsOf(d.arguments) })
556
749
  if (st.calls.size > 64) st.calls.delete(st.calls.keys().next().value)
@@ -567,7 +760,10 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
567
760
  break
568
761
  }
569
762
  case 'tool/result': {
570
- if (d.meta && typeof d.meta.cwd === 'string' && d.meta.cwd) cwds.set(sessionId, d.meta.cwd)
763
+ if (d.meta && typeof d.meta.cwd === 'string' && d.meta.cwd) {
764
+ cwds.set(sessionId, d.meta.cwd)
765
+ if (st.target && saved[sessionId] && saved[sessionId].cwd !== d.meta.cwd) persist(sessionId, st)
766
+ }
571
767
  st.writing = ''
572
768
  if (!live) break
573
769
  const pending = st.pendingHtml
@@ -575,6 +771,7 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
575
771
  const callId = (d.message && (d.message.toolCallId || (d.message.source && d.message.source.callId))) || d.callId || ''
576
772
  const call = callId ? st.calls.get(callId) : null
577
773
  if (callId) st.calls.delete(callId)
774
+ if (!call || call.name === ASK_TOOL) st.asking = false
578
775
  if (pending && fs.existsSync(pending) && (!st.target || st.source === 'auto') && !(st.target && st.target.file === pending)) {
579
776
  // 只有在做设计(加载了设计技能、或页面写了设计类型标记)才自动打开;别的任务写的网页只记下来,不弹
580
777
  // 用户关了「自动打开预览」:也只记下来,右侧栏给个按钮
@@ -623,16 +820,29 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
623
820
  return v
624
821
  }
625
822
 
823
+ /** 预览里直接改字:在这一屏(key = 文件名)的 HTML 里把第 nth 处 before 换成 after,改前改后各存一版 */
824
+ function editText(id, { key, before, after, nth = 0 } = {}) {
825
+ const short = String(after).replace(/\s+/g, ' ').trim()
826
+ return rewrite(id, key, (html) => replaceText(html, before, after, Math.max(0, Math.floor(Number(nth) || 0))),
827
+ `改字「${short.length > 16 ? short.slice(0, 16) + '…' : short}」`)
828
+ }
829
+
830
+ /** 预览里调样式:把 css 写进这一屏(key)里那个元素(loc,见 findTag)的 style 属性,改前改后各存一版 */
831
+ function editStyle(id, { key, loc, css, name } = {}) {
832
+ const short = String(name || (loc && loc.tag) || '').trim()
833
+ return rewrite(id, key, (html) => setStyle(html, loc || {}, css), `调样式「${short.length > 24 ? short.slice(0, 24) + '…' : short}」`)
834
+ }
835
+
626
836
  /**
627
- * 预览里直接改字:在这一屏(key = 文件名)的 HTML 里把第 nth 处 before 换成 after,改前改后各存一版。
837
+ * 改这一屏(key = 文件名)的 HTML:fn(原文) → { ok, html }。
628
838
  * 开发服务器的页面源码在项目里、不一定是这份 HTML,不在这里改(右侧栏改成让模型去改)。
629
- * 模型正在干活时不改:它可能正整份重写这个文件,改了也会被盖掉(右侧栏改成让模型去改)。
839
+ * 模型正在干活时不改:它可能正整份重写这个文件,改了也会被盖掉。
630
840
  */
631
- function editText(id, { key, before, after, nth = 0 } = {}) {
841
+ function rewrite(id, key, fn, label) {
632
842
  const st = states.get(id)
633
843
  const t = st && st.target
634
844
  if (!t || t.kind !== 'file') return { ok: false, reason: 'not-file' }
635
- if (st.turn) return { ok: false, reason: 'busy' }
845
+ if (busyRoot(t.root)) return { ok: false, reason: 'busy' }
636
846
  const name = String(key || path.basename(t.file))
637
847
  const file = path.join(t.root, name)
638
848
  if (path.dirname(file) !== t.root || !HTML_EXT.test(name) || !fs.existsSync(file)) return { ok: false, reason: 'no-screen' }
@@ -640,15 +850,39 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
640
850
  let real = ''
641
851
  try { real = fs.realpathSync(file) } catch { real = '' }
642
852
  if (!real.startsWith(fs.realpathSync(t.root) + path.sep)) return { ok: false, reason: 'no-screen' }
643
- const r = replaceText(fs.readFileSync(file, 'utf8'), before, after, Math.max(0, Math.floor(Number(nth) || 0)))
853
+ const r = fn(fs.readFileSync(file, 'utf8'))
644
854
  if (!r.ok) return r
645
855
  keepVersion(st, '')
646
856
  fs.writeFileSync(file, r.html)
647
- const short = String(after).replace(/\s+/g, ' ').trim()
648
- keepVersion(st, `改字「${short.length > 16 ? short.slice(0, 16) + '…' : short}」`)
857
+ keepVersion(st, label)
649
858
  return { ok: true, file: name }
650
859
  }
651
860
 
861
+ /** 这个目录有没有会话的模型正在干活(它可能正整份重写文件,这时改了会被盖掉);经软链接打开的同一个目录也算 */
862
+ function busyRoot(root) {
863
+ const realOf = (p) => { try { return fs.realpathSync(p) } catch { return p } }
864
+ const mine = realOf(root)
865
+ for (const o of states.values()) if (o.turn && o.target && o.target.kind === 'file' && (o.target.root === root || realOf(o.target.root) === mine)) return true
866
+ return false
867
+ }
868
+
869
+ /**
870
+ * 改设计目录里的几个文件(调节滑块写回:tokens.css、每屏 <html> 上的属性、tweaks.json):fn(目录) → { ok, files, … };
871
+ * 改前改后各存一版。开发服务器的页面、模型正在干活时不改;给了 expectRoot 而现在看的不是它也不改
872
+ */
873
+ function editDir(id, fn, label, expectRoot) {
874
+ const st = states.get(id)
875
+ const t = st && st.target
876
+ if (!t || t.kind !== 'file') return { ok: false, reason: 'not-file' }
877
+ // 右侧栏发请求时看的是哪套设计:这期间换了设计就别写,免得写进另一套
878
+ if (expectRoot !== undefined && t.root !== expectRoot) return { ok: false, reason: 'moved' }
879
+ if (busyRoot(t.root)) return { ok: false, reason: 'busy' }
880
+ keepVersion(st, '')
881
+ const r = fn(t.root)
882
+ if (r && r.ok && r.files && r.files.length) keepVersion(st, typeof label === 'function' ? label(r) : label)
883
+ return r
884
+ }
885
+
652
886
  /** 版本列表(新的在前);开发服务器没有版本(项目自己有 git) */
653
887
  function versions(id) {
654
888
  const st = states.get(id)
@@ -669,19 +903,23 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
669
903
  const st = states.get(id)
670
904
  if (!(st && st.target)) restore(id)
671
905
  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) }
906
+ 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
907
  const { target, url, label, source, rev, openRev, turn, writing, changedAt, kind, size, view, origin } = st
674
908
  const file = target.kind === 'file'
675
909
  return {
676
- target: file ? { kind: 'file', file: target.file } : { kind: 'proxy', port: target.port, path: target.path },
910
+ target: file ? { kind: 'file', file: target.file, root: target.root } : { kind: 'proxy', port: target.port, path: target.path },
677
911
  url,
678
912
  label,
679
913
  source,
680
914
  rev,
681
915
  openRev,
682
916
  turn,
917
+ turns: st.turns || 0,
918
+ asking: !!st.asking,
683
919
  writing,
684
920
  changedAt,
921
+ changedFiles: st.changedFiles || [],
922
+ changes: st.changes || [],
685
923
  kind,
686
924
  size,
687
925
  view,
@@ -696,5 +934,5 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
696
934
  }
697
935
  }
698
936
 
699
- return { observe, setTarget, setView, clear, changed, snapshot, cwdOf, stateOf, versions, restoreVersion, editText, keepVersion: (id, label) => keepVersion(stateOf(id), label) }
937
+ return { observe, setTarget, setView, clear, changed, snapshot, cwdOf, stateOf, versions, restoreVersion, editText, editStyle, editDir, keepVersion: (id, label) => keepVersion(stateOf(id), label) }
700
938
  }