@dimina-kit/devtools 0.4.0-dev.20260707070110 → 0.4.0-dev.20260710085051

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.
Files changed (49) hide show
  1. package/dist/main/app/app.js +1 -1
  2. package/dist/main/index.bundle.js +51 -17
  3. package/dist/main/ipc/bridge-router.d.ts +1 -1
  4. package/dist/main/ipc/bridge-router.js +14 -11
  5. package/dist/main/ipc/project-fs.d.ts +32 -3
  6. package/dist/main/ipc/project-fs.js +33 -5
  7. package/dist/main/runtime/miniapp-runtime.d.ts +4 -3
  8. package/dist/main/services/render-inspect/index.d.ts +1 -1
  9. package/dist/main/services/render-inspect/index.js +1 -1
  10. package/dist/main/services/service-host-pool/pool.d.ts +4 -4
  11. package/dist/main/services/service-host-pool/pool.js +5 -5
  12. package/dist/main/services/settings/index.d.ts +7 -1
  13. package/dist/main/services/settings/index.js +22 -2
  14. package/dist/main/services/simulator-temp-files/disk.d.ts +1 -1
  15. package/dist/main/services/simulator-temp-files/disk.js +1 -1
  16. package/dist/main/services/simulator-temp-files/fs-channels.d.ts +1 -1
  17. package/dist/main/services/simulator-temp-files/fs-channels.js +1 -1
  18. package/dist/main/services/simulator-temp-files/request-handler.d.ts +1 -1
  19. package/dist/main/services/simulator-temp-files/request-handler.js +1 -1
  20. package/dist/main/services/workbench-coi-server.js +9 -2
  21. package/dist/main/services/workspace/workspace-service.js +7 -7
  22. package/dist/preload/windows/host-toolbar-runtime.cjs +1 -1
  23. package/dist/preload/windows/host-toolbar-runtime.cjs.map +2 -2
  24. package/dist/preload/windows/main.cjs +27 -2
  25. package/dist/preload/windows/main.cjs.map +2 -2
  26. package/dist/renderer/assets/workbenchSettings-C6w5S2ZY.js +8 -0
  27. package/dist/renderer/entries/workbench-settings/index.html +1 -1
  28. package/dist/shared/ipc-schemas.d.ts +4 -1
  29. package/dist/shared/ipc-schemas.js +4 -1
  30. package/dist/shared/vpath.d.ts +1 -1
  31. package/dist/shared/vpath.js +1 -1
  32. package/dist/simulator/assets/simulator-A1vGZVBM.js +10 -0
  33. package/dist/simulator/simulator.html +1 -1
  34. package/dist/vscode-workbench/assets/__vite-browser-external-B1y6A_ue.js +1 -0
  35. package/dist/vscode-workbench/assets/{dist-wJUFfrkb.js → dist-UYlh9rWP.js} +3 -3
  36. package/dist/vscode-workbench/assets/fs-core-worker-urls-BGhWkdqX.js +1 -0
  37. package/dist/vscode-workbench/assets/fs-core.worker-Bs1XABE2.js +1 -0
  38. package/dist/vscode-workbench/assets/fs-query.worker-cZ-7Fjch.js +2 -0
  39. package/dist/vscode-workbench/assets/{iconv-lite-umd-O-GPtKy4.js → iconv-lite-umd-P85lbjJb.js} +1 -1
  40. package/dist/vscode-workbench/assets/{index-DyxjKeLl.js → index-nrKFi46w.js} +450 -450
  41. package/dist/vscode-workbench/assets/{jschardet-BorbxV40.js → jschardet-CLQONAKz.js} +1 -1
  42. package/dist/vscode-workbench/index.html +1 -1
  43. package/package.json +4 -4
  44. package/dist/renderer/assets/workbenchSettings-Cr4EHo5w.js +0 -8
  45. package/dist/simulator/assets/simulator-DU3-fS3v.js +0 -10
  46. package/dist/vscode-workbench/assets/__vite-browser-external-CjtF3dcZ.js +0 -1
  47. package/dist/vscode-workbench/assets/fs-core-worker-urls-CLvv1Q_0.js +0 -1
  48. package/dist/vscode-workbench/assets/fs-core.worker-VoMLk-PY.js +0 -1
  49. package/dist/vscode-workbench/assets/fs-query.worker-fXLkQK9l.js +0 -2
@@ -356,7 +356,7 @@ export async function createDevtoolsRuntime(config = {}) {
356
356
  const context = createContext(config, mainWindow, rendererDir);
357
357
  // Anchor the main window's renderer as the first Connection. Resources
358
358
  // scoped to the main webContents (acquired by later wiring) tear down with
359
- // it; see packages/electron-deck/docs/foundation.md §4.
359
+ // it; see packages/electron-deck/docs/foundation.md (teardown paths).
360
360
  context.connections.acquire(mainWindow.webContents);
361
361
  context.registry.add(registerAppIpc(context));
362
362
  // Sandboxed project file-system IPC (the renderer-side project:fs:* surface).
@@ -27,7 +27,10 @@ var DEFAULTS = {
27
27
  port: 7789
28
28
  },
29
29
  compile: {
30
- watch: true
30
+ autoBuild: true
31
+ },
32
+ preview: {
33
+ autoReload: true
31
34
  },
32
35
  theme: "system",
33
36
  lastCreateBaseDir: null
@@ -35,6 +38,9 @@ var DEFAULTS = {
35
38
  function getSettingsFile() {
36
39
  return path.join(app.getPath("userData"), "dimina-workbench-settings.json");
37
40
  }
41
+ function pickBool(value, fallback) {
42
+ return typeof value === "boolean" ? value : fallback;
43
+ }
38
44
  function loadWorkbenchSettings() {
39
45
  try {
40
46
  const data = JSON.parse(fs.readFileSync(getSettingsFile(), "utf-8"));
@@ -49,7 +55,16 @@ function loadWorkbenchSettings() {
49
55
  port: data.mcp?.port ?? DEFAULTS.mcp.port
50
56
  },
51
57
  compile: {
52
- watch: data.compile?.watch ?? DEFAULTS.compile.watch
58
+ // Legacy files persisted a single `compile.watch`; the new schema
59
+ // splits it into `compile.autoBuild` (recompile on save) and
60
+ // `preview.autoReload` (reload the simulator afterwards). Prefer the new
61
+ // key, fall back to the legacy one so an old settings file keeps its
62
+ // "auto-compile" choice; `preview.autoReload` defaults to true (the old
63
+ // always-reload behavior) when the file predates the split.
64
+ autoBuild: pickBool(data.compile?.autoBuild, pickBool(data.compile?.watch, DEFAULTS.compile.autoBuild))
65
+ },
66
+ preview: {
67
+ autoReload: pickBool(data.preview?.autoReload, DEFAULTS.preview.autoReload)
53
68
  },
54
69
  theme,
55
70
  lastCreateBaseDir: typeof data.lastCreateBaseDir === "string" ? data.lastCreateBaseDir : DEFAULTS.lastCreateBaseDir
@@ -59,7 +74,8 @@ function loadWorkbenchSettings() {
59
74
  ...DEFAULTS,
60
75
  cdp: { ...DEFAULTS.cdp },
61
76
  mcp: { ...DEFAULTS.mcp },
62
- compile: { ...DEFAULTS.compile }
77
+ compile: { ...DEFAULTS.compile },
78
+ preview: { ...DEFAULTS.preview }
63
79
  };
64
80
  }
65
81
  }
@@ -502,12 +518,10 @@ var UpdateChannel = {
502
518
  };
503
519
 
504
520
  // src/main/ipc/project-fs.ts
521
+ import { WATCH_IGNORE_DIRS } from "@dimina-kit/devkit/watch-ignore";
505
522
  var LIST_FILE_LIMIT = 5e3;
506
523
  var SKIP_DIRS = /* @__PURE__ */ new Set([
507
- "node_modules",
508
- ".git",
509
- ".svn",
510
- ".hg",
524
+ ...WATCH_IGNORE_DIRS,
511
525
  "dist",
512
526
  "build",
513
527
  ".next",
@@ -665,7 +679,18 @@ async function statWithin(root, rel) {
665
679
  }
666
680
  async function readdirWithin(root, rel) {
667
681
  const safe = await resolveWithinProjectRoot(joinRel(root, rel), root);
668
- return fs2.readdir(safe, { withFileTypes: true });
682
+ const dirents = await fs2.readdir(safe, { withFileTypes: true });
683
+ return Promise.all(
684
+ dirents.map(async (d) => {
685
+ if (d.isDirectory()) return { name: d.name, isDirectory: true };
686
+ try {
687
+ const st = await fs2.stat(path2.join(safe, d.name));
688
+ return { name: d.name, isDirectory: false, size: st.size, mtimeMs: st.mtimeMs };
689
+ } catch {
690
+ return { name: d.name, isDirectory: false };
691
+ }
692
+ })
693
+ );
669
694
  }
670
695
  async function readFileBufferWithin(root, rel) {
671
696
  return readFileBuffer(root, joinRel(root, rel));
@@ -4095,16 +4120,18 @@ function createWorkspaceService(ctx) {
4095
4120
  }
4096
4121
  }
4097
4122
  async function runCompile(projectPath, sessionGeneration) {
4098
- const { compile } = loadWorkbenchSettings();
4123
+ const { compile, preview } = loadWorkbenchSettings();
4099
4124
  try {
4100
4125
  const session5 = await ctx.adapter.openProject({
4101
4126
  projectPath,
4102
4127
  sourcemap: true,
4103
4128
  fileTypes: ctx.fileTypes,
4104
- watch: compile.watch,
4129
+ // Two independent gates: autoBuild = recompile on save; autoReload = refresh the simulator afterwards (off ⇒ page/form state survives; hotReload below stays false so the native simulator, which has no SSE reload, isn't refreshed).
4130
+ watch: compile.autoBuild,
4131
+ autoReload: preview.autoReload,
4105
4132
  onRebuild: () => {
4106
4133
  const { pages } = getProjectPages(projectPath);
4107
- sendStatus("ready", "\u7F16\u8BD1\u5B8C\u6210\uFF0C\u5DF2\u91CD\u542F", true, pages.length ? pages : void 0);
4134
+ sendStatus("ready", preview.autoReload ? "\u7F16\u8BD1\u5B8C\u6210\uFF0C\u5DF2\u91CD\u542F" : "\u7F16\u8BD1\u5B8C\u6210", preview.autoReload, pages.length ? pages : void 0);
4108
4135
  },
4109
4136
  onBuildError: (err2) => sendStatus("error", String(err2)),
4110
4137
  // Watcher died mid-session (EMFILE, permission loss, …): non-fatal, so 'ready' stays but `watcher: 'dead'` flags that saves no longer auto-rebuild.
@@ -4631,7 +4658,10 @@ var WorkbenchSettingsSaveSchema = z3.tuple([
4631
4658
  port: z3.number().int().min(0).max(65535)
4632
4659
  }),
4633
4660
  compile: z3.looseObject({
4634
- watch: z3.boolean()
4661
+ autoBuild: z3.boolean()
4662
+ }),
4663
+ preview: z3.looseObject({
4664
+ autoReload: z3.boolean()
4635
4665
  }),
4636
4666
  theme: z3.enum(["system", "light", "dark"]),
4637
4667
  lastCreateBaseDir: z3.union([z3.string(), z3.null()])
@@ -5298,7 +5328,7 @@ var ServiceHostPool = class {
5298
5328
  /**
5299
5329
  * Hand a ready window matching `spec` to the caller. If none is ready, fall
5300
5330
  * back to synchronously constructing a fresh window — never blocking on warm
5301
- * (doc §3.1 "绝不阻塞 acquire"). Fallback windows carry `entryId === null` and
5331
+ * (see prewarm-webview.md's invariants section). Fallback windows carry `entryId === null` and
5302
5332
  * are destroyed (not pooled) on release.
5303
5333
  *
5304
5334
  * A spec change (different `preloadPath`) tears down all *pooled* entries and
@@ -5374,7 +5404,7 @@ var ServiceHostPool = class {
5374
5404
  }
5375
5405
  /**
5376
5406
  * Re-target the pool. `target` is clamped to `[0, maxPoolSize]`. Pooled
5377
- * entries beyond the target are disposed OLDEST-FIRST (doc §3.3/§3.6); in-use
5407
+ * entries beyond the target are disposed OLDEST-FIRST (see prewarm-webview.md); in-use
5378
5408
  * entries are untouched. Does not warm new entries up (use `init` / refill).
5379
5409
  */
5380
5410
  resize(target) {
@@ -5503,7 +5533,7 @@ var ServiceHostPool = class {
5503
5533
  }
5504
5534
  /**
5505
5535
  * Reset a released window back to a clean, blank state. Order matters
5506
- * (doc §3.5.3 / §3.4「等待时序」): navigate to blank FIRST so the old document
5536
+ * (see prewarm-webview.md's state-machine and reset-ordering sections): navigate to blank FIRST so the old document
5507
5537
  * stops running and in-flight requests are aborted, THEN clear storage — so a
5508
5538
  * still-live old page cannot re-populate storage after the clear.
5509
5539
  */
@@ -11044,8 +11074,12 @@ async function fsStat(ctx) {
11044
11074
  }
11045
11075
  async function fsReaddir(ctx) {
11046
11076
  const entries = await readdirWithin(ctx.projectRoot, ctx.rel);
11047
- const visible = entries.filter((e) => !(e.isDirectory() && SKIP_DIRS.has(e.name)));
11048
- jsonRes(ctx.res, 200, visible.map((e) => [e.name, e.isDirectory() ? 2 : 1]));
11077
+ const visible = entries.filter((e) => !(e.isDirectory && SKIP_DIRS.has(e.name)));
11078
+ jsonRes(
11079
+ ctx.res,
11080
+ 200,
11081
+ visible.map((e) => e.isDirectory ? [e.name, 2] : [e.name, 1, e.size, e.mtimeMs])
11082
+ );
11049
11083
  }
11050
11084
  async function fsRead(ctx) {
11051
11085
  const buf = await readFileBufferWithin(ctx.projectRoot, ctx.rel);
@@ -108,7 +108,7 @@ export interface BridgeRouterHandle {
108
108
  */
109
109
  census?(): BridgeResourceCensus;
110
110
  /**
111
- * The flag-gated debugTap (§7) over the bridge message stream. Exposed so a
111
+ * The flag-gated debugTap (see foundation.md) over the bridge message stream. Exposed so a
112
112
  * hidden devtools panel / automation can read `.entries()` when
113
113
  * `DIMINA_DEBUG_TAP=1`; a no-op snapshot otherwise. Optional so the many
114
114
  * partial `BridgeRouterHandle` test mocks don't each have to stub it.
@@ -63,10 +63,11 @@ const STACK_ID = 'stack_0';
63
63
  * constant instead of a magic number.
64
64
  */
65
65
  export const LAUNCH_TIMEOUT_MS = 20_000;
66
- /** Hard ceiling on the pre-warm pool, mirroring ServiceHostPool/doc §3.3. */
66
+ /** Hard ceiling on the pre-warm pool, mirroring ServiceHostPool/prewarm-webview.md. */
67
67
  const PREWARM_MAX_POOL_SIZE = 4;
68
68
  /**
69
- * Resolve the pre-warm pool size from env (doc §6). Returns 0 (OFF) unless
69
+ * Resolve the pre-warm pool size from env (see prewarm-webview.md's config
70
+ * section). Returns 0 (OFF) unless
70
71
  * `DIMINA_PREWARM_POOL_SIZE` is a positive integer and `DIMINA_PREWARM_DISABLE`
71
72
  * is not set. Default OFF — pooling is opt-in so the spawn path is unchanged
72
73
  * unless explicitly enabled.
@@ -82,7 +83,7 @@ function resolvePrewarmPoolSize() {
82
83
  return 0;
83
84
  return Math.min(n, PREWARM_MAX_POOL_SIZE);
84
85
  }
85
- /** debugTap (§7) is opt-in via `DIMINA_DEBUG_TAP=1` — off everywhere else. */
86
+ /** debugTap (see foundation.md) is opt-in via `DIMINA_DEBUG_TAP=1` — off everywhere else. */
86
87
  function resolveDebugTapEnabled() {
87
88
  return process.env.DIMINA_DEBUG_TAP === '1';
88
89
  }
@@ -181,8 +182,8 @@ export function installBridgeRouter(ctx) {
181
182
  if (prewarmPoolSize > 0) {
182
183
  const pool = new ServiceHostPool();
183
184
  state.pool = pool;
184
- // Defer warm-up off the cold-start critical path (doc §3.3: app.ready +
185
- // idle). Warming eagerly here races main-window startup — it creates an
185
+ // Defer warm-up off the cold-start critical path (see prewarm-webview.md:
186
+ // app.ready + idle). Warming eagerly here races main-window startup — it creates an
186
187
  // extra hidden BrowserWindow before the workbench renderer has settled,
187
188
  // which made the e2e's first-window/preload-readiness flaky. The timer is
188
189
  // cancelled on teardown so it can't warm a disposed pool.
@@ -451,7 +452,7 @@ export function installBridgeRouter(ctx) {
451
452
  };
452
453
  ipcMain.on(C.DISPOSE, onDispose);
453
454
  ctx.registry.add(() => { ipcMain.removeListener(C.DISPOSE, onDispose); });
454
- // debugTap (§7) ingress recorder — near-free no-op unless DIMINA_DEBUG_TAP=1.
455
+ // debugTap (see foundation.md) ingress recorder — near-free no-op unless DIMINA_DEBUG_TAP=1.
455
456
  // Hung on the bridge dispatch chokepoint so the cross-wc message flow is
456
457
  // inspectable (the first real consumer of the workbench debugTap primitive).
457
458
  const tapIn = (channel, sender, payload) => {
@@ -753,7 +754,8 @@ async function handleSpawn(state, ctx, event, opts) {
753
754
  appSession.pages.set(bridgeId, rootPage);
754
755
  bindWc(state.serviceWcIdToAppSessionId, serviceWindow.webContents, appSessionId);
755
756
  bindSimulatorWc(state.simulatorWcIdToAppSessionIds, simulatorWc, appSessionId);
756
- // Track the service-host webContents as a Connection (foundation.md §4.3) and
757
+ // Track the service-host webContents as a Connection (the soft-reuse
758
+ // teardown path documented in foundation.md) and
757
759
  // own this session's serviceWc→appSessionId binding on its CURRENT lifetime
758
760
  // segment. On a pooled-window REUSE, `disposeAppSession` calls
759
761
  // `connections.reset(serviceWc.id)` → this owned cleanup fires + a fresh
@@ -761,7 +763,8 @@ async function handleSpawn(state, ctx, event, opts) {
761
763
  // real destroy the connection closes and fires it too. The cleanup is guarded
762
764
  // by `appSessionId` so it only clears a binding that is still THIS session's.
763
765
  // (The simulatorWc binding stays manual in `disposeAppSession`: the simulator
764
- // wc outlives individual app sessions — cross-connection state, §4.4 — so it
766
+ // wc outlives individual app sessions — cross-connection state documented in
767
+ // foundation.md — so it
765
768
  // cannot hang off any single session's segment.)
766
769
  state.connections.acquire(serviceWindow.webContents).own(() => {
767
770
  if (state.serviceWcIdToAppSessionId.get(appSession.serviceWc.id) === appSessionId) {
@@ -1732,8 +1735,8 @@ function ensureRenderBound(state, sender, bridgeId) {
1732
1735
  page.renderWc = sender;
1733
1736
  state.wcIdToBridgeId.set(sender.id, bridgeId);
1734
1737
  // Consolidate the render-guest bookkeeping teardown onto the connection
1735
- // layer (foundation.md §4 / P2): the render guest is its own webContents =
1736
- // its own connection (§4.4); own() ties this cleanup to its lifetime so it
1738
+ // layer (see foundation.md): the render guest is its own webContents =
1739
+ // its own connection; own() ties this cleanup to its lifetime so it
1737
1740
  // fires on destroy, replacing the bespoke `once('destroyed')`. acquire() is
1738
1741
  // idempotent — re-binding the same sender re-uses its connection.
1739
1742
  state.connections.acquire(sender).own(() => {
@@ -1874,7 +1877,7 @@ function closeSessionPages(state, ap) {
1874
1877
  // the unpooled path.
1875
1878
  async function releaseServiceWindow(state, ap, opts) {
1876
1879
  if (ap.poolEntryId !== null && state.pool && !opts.serviceAlreadyClosed) {
1877
- // Soft reuse (foundation.md §4.3): the pooled service-host webContents keeps
1880
+ // Soft reuse (see foundation.md): the pooled service-host webContents keeps
1878
1881
  // its wc.id but is about to run a NEW app session. Reset its Connection
1879
1882
  // BEFORE returning it to the pool — dispose this session's owned segment
1880
1883
  // (the serviceWc binding cleanup) and swap a fresh one — so an old in-flight
@@ -2,7 +2,13 @@ import type { WorkbenchContext } from '../services/workbench-context.js';
2
2
  import type { Disposable } from '@dimina-kit/electron-deck/main';
3
3
  /** Dependency/VCS/build directories invisible to every project-tree consumer:
4
4
  * IPC `listFiles`, the `/__fs/readdir` bridge (so the workbench mirror and the
5
- * WAL ledger seed never walk into them), and the `/__fs/watch` change stream. */
5
+ * WAL ledger seed never walk into them), and the `/__fs/watch` change stream.
6
+ * Derived from devkit's `WATCH_IGNORE_DIRS` (the shared never-source core:
7
+ * node_modules + VCS) so the `node_modules` omission that once wedged
8
+ * `watcher.close()` can never drift back on this side, PLUS the editor mirror's
9
+ * own build-output names (`dist`/`build`/tool caches) — which the devkit
10
+ * recompile watcher deliberately does NOT ignore, since app.json may declare
11
+ * pages under such paths. */
6
12
  export declare const SKIP_DIRS: ReadonlySet<string>;
7
13
  /**
8
14
  * Synchronous, string-level guard: reject empty root (ENOACTIVE), NUL bytes
@@ -79,8 +85,31 @@ export declare function writeFile(root: string, p: string, content: string | Buf
79
85
  * out-of-sandbox metadata. `rel` is a project-root-relative bridge path.
80
86
  */
81
87
  export declare function statWithin(root: string, rel: string): Promise<import('node:fs').Stats>;
82
- /** Realpath-contained `fs.readdir` twin (see {@link statWithin}). */
83
- export declare function readdirWithin(root: string, rel: string): Promise<import('node:fs').Dirent[]>;
88
+ /**
89
+ * One `readdirWithin` result entry. Directories carry no stat (the caller —
90
+ * the `/__fs/readdir` bridge handler — never needs it: a directory recurses
91
+ * instead of being compared). `size`/`mtimeMs` are omitted (not `null`) when
92
+ * the entry raced a concurrent delete between `readdir` and `stat` — the
93
+ * bridge wire format then drops them too, which the sync engine's watch
94
+ * expansion (dimina-kit workbench's wal-audit-watch-expand.ts) treats as
95
+ * "always changed" rather than risking a false stat match.
96
+ */
97
+ export interface ReaddirStatEntry {
98
+ name: string;
99
+ isDirectory: boolean;
100
+ size?: number;
101
+ mtimeMs?: number;
102
+ }
103
+ /**
104
+ * Realpath-contained `fs.readdir` twin (see {@link statWithin}) that also
105
+ * `fs.stat`s every FILE entry (size + mtimeMs) — the disk-side half of the
106
+ * sync engine's stat-diffing (see wal-audit-watch-expand.ts's module doc):
107
+ * a watch-reported directory is only worth a full content re-read when a
108
+ * file's stat inside it actually moved, and the `/__fs/readdir` bridge is
109
+ * the one HTTP round trip cheap enough to do that check on every watch
110
+ * event without itself re-reading file bytes.
111
+ */
112
+ export declare function readdirWithin(root: string, rel: string): Promise<ReaddirStatEntry[]>;
84
113
  /**
85
114
  * Raw-byte read twin of {@link readFileBuffer} taking a project-root-relative
86
115
  * bridge path (the COI `/__fs/read` bridge), with the same realpath +
@@ -47,14 +47,21 @@ import { z } from 'zod';
47
47
  import { IpcRegistry } from '../utils/ipc-registry.js';
48
48
  import { validate } from '../utils/ipc-schema.js';
49
49
  import { ProjectFsChannel } from '../../shared/ipc-channels.js';
50
+ import { WATCH_IGNORE_DIRS } from '@dimina-kit/devkit/watch-ignore';
50
51
  /** Cap for `listFiles` — huge monorepos would otherwise stall the renderer. */
51
52
  const LIST_FILE_LIMIT = 5000;
52
53
  /** Dependency/VCS/build directories invisible to every project-tree consumer:
53
54
  * IPC `listFiles`, the `/__fs/readdir` bridge (so the workbench mirror and the
54
- * WAL ledger seed never walk into them), and the `/__fs/watch` change stream. */
55
+ * WAL ledger seed never walk into them), and the `/__fs/watch` change stream.
56
+ * Derived from devkit's `WATCH_IGNORE_DIRS` (the shared never-source core:
57
+ * node_modules + VCS) so the `node_modules` omission that once wedged
58
+ * `watcher.close()` can never drift back on this side, PLUS the editor mirror's
59
+ * own build-output names (`dist`/`build`/tool caches) — which the devkit
60
+ * recompile watcher deliberately does NOT ignore, since app.json may declare
61
+ * pages under such paths. */
55
62
  export const SKIP_DIRS = new Set([
56
- 'node_modules', '.git', '.svn', '.hg', 'dist', 'build',
57
- '.next', '.cache', '.turbo', '.parcel-cache', '.nuxt', '.output', '.vite',
63
+ ...WATCH_IGNORE_DIRS,
64
+ 'dist', 'build', '.next', '.cache', '.turbo', '.parcel-cache', '.nuxt', '.output', '.vite',
58
65
  ]);
59
66
  const PathArg = z.string().min(1);
60
67
  const ReadFileSchema = z.tuple([PathArg]);
@@ -319,10 +326,31 @@ export async function statWithin(root, rel) {
319
326
  const safe = await resolveWithinProjectRoot(joinRel(root, rel), root);
320
327
  return fs.stat(safe);
321
328
  }
322
- /** Realpath-contained `fs.readdir` twin (see {@link statWithin}). */
329
+ /**
330
+ * Realpath-contained `fs.readdir` twin (see {@link statWithin}) that also
331
+ * `fs.stat`s every FILE entry (size + mtimeMs) — the disk-side half of the
332
+ * sync engine's stat-diffing (see wal-audit-watch-expand.ts's module doc):
333
+ * a watch-reported directory is only worth a full content re-read when a
334
+ * file's stat inside it actually moved, and the `/__fs/readdir` bridge is
335
+ * the one HTTP round trip cheap enough to do that check on every watch
336
+ * event without itself re-reading file bytes.
337
+ */
323
338
  export async function readdirWithin(root, rel) {
324
339
  const safe = await resolveWithinProjectRoot(joinRel(root, rel), root);
325
- return fs.readdir(safe, { withFileTypes: true });
340
+ const dirents = await fs.readdir(safe, { withFileTypes: true });
341
+ return Promise.all(dirents.map(async (d) => {
342
+ if (d.isDirectory())
343
+ return { name: d.name, isDirectory: true };
344
+ try {
345
+ const st = await fs.stat(path.join(safe, d.name));
346
+ return { name: d.name, isDirectory: false, size: st.size, mtimeMs: st.mtimeMs };
347
+ }
348
+ catch {
349
+ // Entry vanished (or became unreadable) between readdir and stat —
350
+ // report it stat-less; see this function's doc for why that is safe.
351
+ return { name: d.name, isDirectory: false };
352
+ }
353
+ }));
326
354
  }
327
355
  /**
328
356
  * Raw-byte read twin of {@link readFileBuffer} taking a project-root-relative
@@ -1,6 +1,6 @@
1
1
  /**
2
- * MiniappRuntime — the stable, host-facing contract surface (foundation.md §3
3
- * "MiniappRuntime 契约" layer).
2
+ * MiniappRuntime — the stable, host-facing contract surface documented in
3
+ * host-migration.md's public-contract section.
4
4
  *
5
5
  * HAND-WRITTEN, not derived from `WorkbenchContext`. A `Pick`-style projection
6
6
  * would drag every nested internal service type (ViewManager,
@@ -18,7 +18,8 @@
18
18
  * - `workspace.openProject` stays writable (no `readonly`) for backward
19
19
  * compat: hosts MAY still gate permissions by reassigning it. The preferred
20
20
  * path is now the declarative `WorkbenchAppConfig.onBeforeOpenProject` hook
21
- * (runs before any side effect, throw to veto) — see host-migration.md §7.
21
+ * (runs before any side effect, throw to veto) — see host-migration.md's
22
+ * `onBeforeOpenProject` hook section.
22
23
  *
23
24
  * `asMiniappRuntime(ctx)` is an identity return — the contract is a typed
24
25
  * VIEW onto the live context, not a snapshot/projection. That is what makes
@@ -38,7 +38,7 @@ export interface RenderInspectorOptions {
38
38
  /** Override the injected IIFE source (default: read the built bundle). Test seam. */
39
39
  loadSource?: () => string;
40
40
  /**
41
- * Connection registry (foundation.md §4 / P2). When provided, the per-wc
41
+ * Connection registry (see foundation.md's teardown-paths section). When provided, the per-wc
42
42
  * `injected` bookkeeping is torn down via the wc's connection (deterministic
43
43
  * with the rest of that webContents's resources) instead of a bespoke
44
44
  * `once('destroyed')`. Optional so focused unit tests can omit it (they fall
@@ -71,7 +71,7 @@ export function createRenderInspector(options = {}) {
71
71
  }
72
72
  injected.add(wc.id);
73
73
  // Consolidate the per-wc bookkeeping teardown onto the connection layer
74
- // (foundation.md §4 / P2) when a registry is available; fall back to the
74
+ // (see foundation.md) when a registry is available; fall back to the
75
75
  // bespoke `once('destroyed')` only when omitted (focused unit tests).
76
76
  const forget = () => {
77
77
  injected.delete(wc.id);
@@ -94,7 +94,7 @@ export interface ServiceHostPoolInitOptions {
94
94
  defaultPoolSize: number;
95
95
  /** Spec used to warm the initial entries. */
96
96
  defaultSpec: ServiceHostSpec;
97
- /** Hard ceiling on pooled entries. Clamped to ≤ 4 (doc §3.3). Default 3. */
97
+ /** Hard ceiling on pooled entries. Clamped to ≤ 4 (see prewarm-webview.md). Default 3. */
98
98
  maxPoolSize?: number;
99
99
  }
100
100
  export declare class ServiceHostPool {
@@ -116,7 +116,7 @@ export declare class ServiceHostPool {
116
116
  /**
117
117
  * Hand a ready window matching `spec` to the caller. If none is ready, fall
118
118
  * back to synchronously constructing a fresh window — never blocking on warm
119
- * (doc §3.1 "绝不阻塞 acquire"). Fallback windows carry `entryId === null` and
119
+ * (see prewarm-webview.md's invariants section). Fallback windows carry `entryId === null` and
120
120
  * are destroyed (not pooled) on release.
121
121
  *
122
122
  * A spec change (different `preloadPath`) tears down all *pooled* entries and
@@ -144,7 +144,7 @@ export declare class ServiceHostPool {
144
144
  releaseDestroyed(entryId: string | null): void;
145
145
  /**
146
146
  * Re-target the pool. `target` is clamped to `[0, maxPoolSize]`. Pooled
147
- * entries beyond the target are disposed OLDEST-FIRST (doc §3.3/§3.6); in-use
147
+ * entries beyond the target are disposed OLDEST-FIRST (see prewarm-webview.md); in-use
148
148
  * entries are untouched. Does not warm new entries up (use `init` / refill).
149
149
  */
150
150
  resize(target: number): void;
@@ -170,7 +170,7 @@ export declare class ServiceHostPool {
170
170
  private warmOne;
171
171
  /**
172
172
  * Reset a released window back to a clean, blank state. Order matters
173
- * (doc §3.5.3 / §3.4「等待时序」): navigate to blank FIRST so the old document
173
+ * (see prewarm-webview.md's state-machine and reset-ordering sections): navigate to blank FIRST so the old document
174
174
  * stops running and in-flight requests are aborted, THEN clear storage — so a
175
175
  * still-live old page cannot re-populate storage after the clear.
176
176
  */
@@ -1,9 +1,9 @@
1
1
  import { BrowserWindow } from 'electron';
2
2
  /** Blank placeholder URL loaded while warming / after reset. */
3
3
  const BLANK_URL = 'about:blank';
4
- /** Hard ceiling on pool size regardless of requested size (doc §3.3). */
4
+ /** Hard ceiling on pool size regardless of requested size (see prewarm-webview.md). */
5
5
  const HARD_MAX_POOL_SIZE = 4;
6
- /** Storage buckets cleared on every release (doc §3.4 "必须" rows). */
6
+ /** Storage buckets cleared on every release (see prewarm-webview.md's reset checklist). */
7
7
  const RESET_STORAGES = [
8
8
  'cookies',
9
9
  'localstorage',
@@ -37,7 +37,7 @@ export class ServiceHostPool {
37
37
  /**
38
38
  * Hand a ready window matching `spec` to the caller. If none is ready, fall
39
39
  * back to synchronously constructing a fresh window — never blocking on warm
40
- * (doc §3.1 "绝不阻塞 acquire"). Fallback windows carry `entryId === null` and
40
+ * (see prewarm-webview.md's invariants section). Fallback windows carry `entryId === null` and
41
41
  * are destroyed (not pooled) on release.
42
42
  *
43
43
  * A spec change (different `preloadPath`) tears down all *pooled* entries and
@@ -129,7 +129,7 @@ export class ServiceHostPool {
129
129
  }
130
130
  /**
131
131
  * Re-target the pool. `target` is clamped to `[0, maxPoolSize]`. Pooled
132
- * entries beyond the target are disposed OLDEST-FIRST (doc §3.3/§3.6); in-use
132
+ * entries beyond the target are disposed OLDEST-FIRST (see prewarm-webview.md); in-use
133
133
  * entries are untouched. Does not warm new entries up (use `init` / refill).
134
134
  */
135
135
  resize(target) {
@@ -275,7 +275,7 @@ export class ServiceHostPool {
275
275
  }
276
276
  /**
277
277
  * Reset a released window back to a clean, blank state. Order matters
278
- * (doc §3.5.3 / §3.4「等待时序」): navigate to blank FIRST so the old document
278
+ * (see prewarm-webview.md's state-machine and reset-ordering sections): navigate to blank FIRST so the old document
279
279
  * stops running and in-flight requests are aborted, THEN clear storage — so a
280
280
  * still-live old page cannot re-populate storage after the clear.
281
281
  */
@@ -10,7 +10,13 @@ export interface WorkbenchSettings {
10
10
  };
11
11
  compile: {
12
12
  /** Watch project files and auto-recompile on change. */
13
- watch: boolean;
13
+ autoBuild: boolean;
14
+ };
15
+ preview: {
16
+ /** Reload the simulator once a watcher-triggered rebuild lands. Independent
17
+ * of `compile.autoBuild`: "auto-compile on save, reload manually" is valid
18
+ * and preserves the running page stack / form state across a save. */
19
+ autoReload: boolean;
14
20
  };
15
21
  theme: ThemeSource;
16
22
  /**
@@ -12,7 +12,10 @@ const DEFAULTS = {
12
12
  port: 7789,
13
13
  },
14
14
  compile: {
15
- watch: true,
15
+ autoBuild: true,
16
+ },
17
+ preview: {
18
+ autoReload: true,
16
19
  },
17
20
  theme: 'system',
18
21
  lastCreateBaseDir: null,
@@ -20,6 +23,13 @@ const DEFAULTS = {
20
23
  function getSettingsFile() {
21
24
  return path.join(app.getPath('userData'), 'dimina-workbench-settings.json');
22
25
  }
26
+ /** Accept `value` only when it is a real boolean, else `fallback`. A bare `??`
27
+ * chain guards only null/undefined, so a hand-edited or corrupt config with a
28
+ * stringy `"false"` would pass through and read as truthy downstream — silently
29
+ * keeping a toggle ON when the user meant OFF. */
30
+ function pickBool(value, fallback) {
31
+ return typeof value === 'boolean' ? value : fallback;
32
+ }
23
33
  export function loadWorkbenchSettings() {
24
34
  try {
25
35
  const data = JSON.parse(fs.readFileSync(getSettingsFile(), 'utf-8'));
@@ -36,7 +46,16 @@ export function loadWorkbenchSettings() {
36
46
  port: data.mcp?.port ?? DEFAULTS.mcp.port,
37
47
  },
38
48
  compile: {
39
- watch: data.compile?.watch ?? DEFAULTS.compile.watch,
49
+ // Legacy files persisted a single `compile.watch`; the new schema
50
+ // splits it into `compile.autoBuild` (recompile on save) and
51
+ // `preview.autoReload` (reload the simulator afterwards). Prefer the new
52
+ // key, fall back to the legacy one so an old settings file keeps its
53
+ // "auto-compile" choice; `preview.autoReload` defaults to true (the old
54
+ // always-reload behavior) when the file predates the split.
55
+ autoBuild: pickBool(data.compile?.autoBuild, pickBool(data.compile?.watch, DEFAULTS.compile.autoBuild)),
56
+ },
57
+ preview: {
58
+ autoReload: pickBool(data.preview?.autoReload, DEFAULTS.preview.autoReload),
40
59
  },
41
60
  theme,
42
61
  lastCreateBaseDir: typeof data.lastCreateBaseDir === 'string'
@@ -50,6 +69,7 @@ export function loadWorkbenchSettings() {
50
69
  cdp: { ...DEFAULTS.cdp },
51
70
  mcp: { ...DEFAULTS.mcp },
52
71
  compile: { ...DEFAULTS.compile },
72
+ preview: { ...DEFAULTS.preview },
53
73
  };
54
74
  }
55
75
  }
@@ -2,7 +2,7 @@
2
2
  * Main-process disk reader/writer backing `difile://_store/*` and
3
3
  * `difile://<usr-rel>` URLs.
4
4
  *
5
- * Spec: `packages/devtools/docs/file-system.md` §6 P1-1 / P1-3.
5
+ * Spec: `packages/devtools/docs/file-system.md`.
6
6
  *
7
7
  * - `readDiskFile(realPath, opts?)`: read a (possibly ranged) slice of a
8
8
  * file inside the USER_DATA_PATH sandbox. Returns `bytes` plus the
@@ -2,7 +2,7 @@
2
2
  * Main-process disk reader/writer backing `difile://_store/*` and
3
3
  * `difile://<usr-rel>` URLs.
4
4
  *
5
- * Spec: `packages/devtools/docs/file-system.md` §6 P1-1 / P1-3.
5
+ * Spec: `packages/devtools/docs/file-system.md`.
6
6
  *
7
7
  * - `readDiskFile(realPath, opts?)`: read a (possibly ranged) slice of a
8
8
  * file inside the USER_DATA_PATH sandbox. Returns `bytes` plus the
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Main-process `simulator:fs:*` IPC channel handlers.
3
3
  *
4
- * Spec: `packages/devtools/docs/file-system.md` §4.4, §6 P1-7.
4
+ * Spec: `packages/devtools/docs/file-system.md`.
5
5
  *
6
6
  * Each handler is the pure function that backs one IPC channel:
7
7
  *
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Main-process `simulator:fs:*` IPC channel handlers.
3
3
  *
4
- * Spec: `packages/devtools/docs/file-system.md` §4.4, §6 P1-7.
4
+ * Spec: `packages/devtools/docs/file-system.md`.
5
5
  *
6
6
  * Each handler is the pure function that backs one IPC channel:
7
7
  *
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Pure dispatcher for `difile://` URL requests.
3
3
  *
4
- * Spec: `packages/devtools/docs/file-system.md` §4.3.
4
+ * Spec: `packages/devtools/docs/file-system.md`.
5
5
  *
6
6
  * The shipping implementation in `index.ts` registers a thin
7
7
  * `simSession.protocol.handle('difile')` wrapper that delegates here; the race
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Pure dispatcher for `difile://` URL requests.
3
3
  *
4
- * Spec: `packages/devtools/docs/file-system.md` §4.3.
4
+ * Spec: `packages/devtools/docs/file-system.md`.
5
5
  *
6
6
  * The shipping implementation in `index.ts` registers a thin
7
7
  * `simSession.protocol.handle('difile')` wrapper that delegates here; the race
@@ -206,8 +206,15 @@ async function fsReaddir(ctx) {
206
206
  // every level: the workbench mirror and the WAL ledger seed walk whatever
207
207
  // this returns, so listing them would pull entire dependency trees into the
208
208
  // editor memfs and the OPFS ledger. Same-named FILES stay visible.
209
- const visible = entries.filter((e) => !(e.isDirectory() && SKIP_DIRS.has(e.name)));
210
- jsonRes(ctx.res, 200, visible.map((e) => [e.name, e.isDirectory() ? 2 : 1]));
209
+ const visible = entries.filter((e) => !(e.isDirectory && SKIP_DIRS.has(e.name)));
210
+ // Wire shape: `[name, type]` for a directory, `[name, type, size, mtimeMs]`
211
+ // for a file — the sync engine's watch-batch stat-diffing (workbench's
212
+ // wal-audit-watch-expand.ts) needs size+mtimeMs to tell a stat-unchanged
213
+ // survivor from an actually-modified file without re-reading its content.
214
+ // `size`/`mtimeMs` are simply absent (not `null`) for a stat-less entry
215
+ // (readdirWithin's readdir/stat race) — the client side then always
216
+ // treats it as changed.
217
+ jsonRes(ctx.res, 200, visible.map((e) => (e.isDirectory ? [e.name, 2] : [e.name, 1, e.size, e.mtimeMs])));
211
218
  }
212
219
  async function fsRead(ctx) {
213
220
  const buf = await readFileBufferWithin(ctx.projectRoot, ctx.rel);