@vscode/component-explorer-cli 0.1.1-9 → 0.2.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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/SECURITY.md +14 -0
  3. package/dist/WorktreePool.d.ts +22 -0
  4. package/dist/WorktreePool.d.ts.map +1 -0
  5. package/dist/WorktreePool.js +58 -0
  6. package/dist/WorktreePool.js.map +1 -0
  7. package/dist/WorktreePool.test.d.ts +2 -0
  8. package/dist/WorktreePool.test.d.ts.map +1 -0
  9. package/dist/_virtual/_build-info.js +4 -0
  10. package/dist/_virtual/_build-info.js.map +1 -0
  11. package/dist/browserPage.d.ts +5 -0
  12. package/dist/browserPage.d.ts.map +1 -1
  13. package/dist/browserPage.js +28 -2
  14. package/dist/browserPage.js.map +1 -1
  15. package/dist/commands/acceptCommand.d.ts.map +1 -1
  16. package/dist/commands/acceptCommand.js +3 -2
  17. package/dist/commands/acceptCommand.js.map +1 -1
  18. package/dist/commands/checkStabilityCommand.d.ts +12 -0
  19. package/dist/commands/checkStabilityCommand.d.ts.map +1 -0
  20. package/dist/commands/checkStabilityCommand.js +84 -0
  21. package/dist/commands/checkStabilityCommand.js.map +1 -0
  22. package/dist/commands/compareCommand.d.ts +1 -0
  23. package/dist/commands/compareCommand.d.ts.map +1 -1
  24. package/dist/commands/compareCommand.js +25 -4
  25. package/dist/commands/compareCommand.js.map +1 -1
  26. package/dist/commands/mcpCommand.d.ts +1 -0
  27. package/dist/commands/mcpCommand.d.ts.map +1 -1
  28. package/dist/commands/mcpCommand.js +11 -2
  29. package/dist/commands/mcpCommand.js.map +1 -1
  30. package/dist/commands/screenshotCommand.d.ts +2 -0
  31. package/dist/commands/screenshotCommand.d.ts.map +1 -1
  32. package/dist/commands/screenshotCommand.js +15 -4
  33. package/dist/commands/screenshotCommand.js.map +1 -1
  34. package/dist/commands/serveCommand.d.ts +2 -0
  35. package/dist/commands/serveCommand.d.ts.map +1 -1
  36. package/dist/commands/serveCommand.js +27 -3
  37. package/dist/commands/serveCommand.js.map +1 -1
  38. package/dist/commands/watchCommand.d.ts +2 -0
  39. package/dist/commands/watchCommand.d.ts.map +1 -1
  40. package/dist/commands/watchCommand.js +10 -63
  41. package/dist/commands/watchCommand.js.map +1 -1
  42. package/dist/comparison.d.ts +11 -1
  43. package/dist/comparison.d.ts.map +1 -1
  44. package/dist/comparison.js +25 -11
  45. package/dist/comparison.js.map +1 -1
  46. package/dist/component-explorer-config.schema.json +97 -58
  47. package/dist/componentExplorer.d.ts +13 -17
  48. package/dist/componentExplorer.d.ts.map +1 -1
  49. package/dist/componentExplorer.js +27 -15
  50. package/dist/componentExplorer.js.map +1 -1
  51. package/dist/daemon/DaemonService.d.ts +75 -7
  52. package/dist/daemon/DaemonService.d.ts.map +1 -1
  53. package/dist/daemon/DaemonService.js +462 -120
  54. package/dist/daemon/DaemonService.js.map +1 -1
  55. package/dist/daemon/dynamicSessions.test.d.ts +2 -0
  56. package/dist/daemon/dynamicSessions.test.d.ts.map +1 -0
  57. package/dist/daemon/lifecycle.d.ts.map +1 -1
  58. package/dist/daemon/lifecycle.js +11 -24
  59. package/dist/daemon/lifecycle.js.map +1 -1
  60. package/dist/daemon/pipeClient.d.ts.map +1 -1
  61. package/dist/daemon/pipeClient.js +81 -2
  62. package/dist/daemon/pipeClient.js.map +1 -1
  63. package/dist/daemon/pipeServer.d.ts +2 -1
  64. package/dist/daemon/pipeServer.d.ts.map +1 -1
  65. package/dist/daemon/pipeServer.js +59 -2
  66. package/dist/daemon/pipeServer.js.map +1 -1
  67. package/dist/daemon/version.d.ts +10 -0
  68. package/dist/daemon/version.d.ts.map +1 -0
  69. package/dist/daemon/version.js +17 -0
  70. package/dist/daemon/version.js.map +1 -0
  71. package/dist/dependencyInstaller.d.ts +2 -2
  72. package/dist/dependencyInstaller.d.ts.map +1 -1
  73. package/dist/dependencyInstaller.js.map +1 -1
  74. package/dist/git/gitIndexResolver.d.ts +25 -0
  75. package/dist/git/gitIndexResolver.d.ts.map +1 -0
  76. package/dist/git/gitIndexResolver.js +91 -0
  77. package/dist/git/gitIndexResolver.js.map +1 -0
  78. package/dist/git/gitIndexResolver.test.d.ts +2 -0
  79. package/dist/git/gitIndexResolver.test.d.ts.map +1 -0
  80. package/dist/git/gitService.d.ts +2 -0
  81. package/dist/git/gitService.d.ts.map +1 -1
  82. package/dist/git/gitService.js +6 -0
  83. package/dist/git/gitService.js.map +1 -1
  84. package/dist/git/gitWorktreeManager.d.ts +6 -0
  85. package/dist/git/gitWorktreeManager.d.ts.map +1 -1
  86. package/dist/git/gitWorktreeManager.js +42 -13
  87. package/dist/git/gitWorktreeManager.js.map +1 -1
  88. package/dist/git/gitWorktreeManager.test.d.ts +2 -0
  89. package/dist/git/gitWorktreeManager.test.d.ts.map +1 -0
  90. package/dist/git/testUtils.d.ts +13 -0
  91. package/dist/git/testUtils.d.ts.map +1 -0
  92. package/dist/httpServer.d.ts +6 -1
  93. package/dist/httpServer.d.ts.map +1 -1
  94. package/dist/httpServer.js +13 -0
  95. package/dist/httpServer.js.map +1 -1
  96. package/dist/index.js +11 -2
  97. package/dist/index.js.map +1 -1
  98. package/dist/logger.d.ts +1 -0
  99. package/dist/logger.d.ts.map +1 -1
  100. package/dist/logger.js +7 -1
  101. package/dist/logger.js.map +1 -1
  102. package/dist/mcp/McpServer.d.ts +13 -0
  103. package/dist/mcp/McpServer.d.ts.map +1 -1
  104. package/dist/mcp/McpServer.js +337 -13
  105. package/dist/mcp/McpServer.js.map +1 -1
  106. package/dist/mcp/TaskManager.d.ts +28 -0
  107. package/dist/mcp/TaskManager.d.ts.map +1 -0
  108. package/dist/mcp/TaskManager.js +54 -0
  109. package/dist/mcp/TaskManager.js.map +1 -0
  110. package/dist/packages/simple-api/dist/{chunk-Q24JOMNK.js → chunk-TAEFVNPN.js} +1 -1
  111. package/dist/packages/simple-api/dist/chunk-TAEFVNPN.js.map +1 -0
  112. package/dist/packages/simple-api/dist/express.js +11 -3
  113. package/dist/packages/simple-api/dist/express.js.map +1 -1
  114. package/dist/utils.d.ts +7 -0
  115. package/dist/utils.d.ts.map +1 -1
  116. package/dist/utils.js +6 -7
  117. package/dist/utils.js.map +1 -1
  118. package/dist/watchConfig.d.ts +19 -12
  119. package/dist/watchConfig.d.ts.map +1 -1
  120. package/dist/watchConfig.js +43 -48
  121. package/dist/watchConfig.js.map +1 -1
  122. package/package.json +21 -4
  123. package/dist/packages/simple-api/dist/chunk-Q24JOMNK.js.map +0 -1
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import * as path from 'node:path';
2
3
  import '../external/vscode-observables/observables/dist/observableInternal/index.js';
3
4
  import { observableValue } from '../external/vscode-observables/observables/dist/observableInternal/observables/observableValue.js';
4
5
  import '../external/vscode-observables/observables/dist/observableInternal/debugLocation.js';
@@ -10,11 +11,18 @@ import '../external/vscode-observables/observables/dist/observableInternal/obser
10
11
  import { createApiFactory } from '../packages/simple-api/dist/chunk-3R7GHWBM.js';
11
12
  import { AsyncStream } from '../packages/simple-api/dist/chunk-SGBCNXYH.js';
12
13
  import { ExplorerSession } from '../explorerSession.js';
14
+ import { GitIndexResolver } from '../git/gitIndexResolver.js';
15
+ import { DirtyWorktreeError } from '../git/gitWorktreeManager.js';
16
+ import { execGit } from '../git/gitUtils.js';
13
17
  import { PlaywrightBrowserPageFactory } from '../browserPage.js';
14
18
  import { DefaultComponentExplorerHttpServerFactory } from '../httpServer.js';
15
19
  import { installDependencies } from '../dependencyInstaller.js';
16
20
  import { FileApprovalStore } from './approvalStore.js';
17
21
  import { contentHash } from '../screenshotCache.js';
22
+ import { WorktreePool } from '../WorktreePool.js';
23
+ import { ViteProjectRef } from '../viteProjectRef.js';
24
+ import { pluginProtocolVersionText, daemonApiVersionText } from './version.js';
25
+ export { isCompatibleVersion, parseVersion } from './version.js';
18
26
 
19
27
  // ---------------------------------------------------------------------------
20
28
  // ActivityTracker — monitors idle time and triggers shutdown
@@ -90,12 +98,14 @@ class DaemonService {
90
98
  _serverFactory;
91
99
  _sessions = new Map();
92
100
  _sessionConfigs = new Map();
101
+ _dynamicSessionMeta = new Map();
93
102
  _resolvers = new Map();
94
103
  _eventListenerCount = observableValue(this, 0);
95
104
  _eventListeners = new Set();
96
105
  _shutdownRequested = false;
97
106
  _shutdownResolvers = [];
98
107
  _activityTracker;
108
+ _worktreePool;
99
109
  approvals;
100
110
  api;
101
111
  constructor(_config, _pipeName, _logger, _browserFactory, _serverFactory, approvalStorePath, idleTimeoutMs) {
@@ -106,22 +116,20 @@ class DaemonService {
106
116
  this._serverFactory = _serverFactory;
107
117
  this.approvals = new FileApprovalStore(approvalStorePath);
108
118
  this._activityTracker = new ActivityTracker(idleTimeoutMs, () => this.requestShutdown(), this._logger);
119
+ if (_config.worktreePool) {
120
+ this._worktreePool = new WorktreePool(_config.worktreePool.maxSlots, _config.repo.worktreeRootPath());
121
+ }
109
122
  this.api = this._buildApi();
110
123
  }
111
124
  static async create(config, logger, pipeName, options) {
112
- const browserFactory = new PlaywrightBrowserPageFactory();
125
+ const browserFactory = new PlaywrightBrowserPageFactory(false, 30_000, logger);
113
126
  const serverFactory = new DefaultComponentExplorerHttpServerFactory();
114
127
  const approvalStorePath = `${config.screenshotDir}/approvals.json`;
115
128
  const idleTimeoutMs = options?.idleTimeoutMs ?? 5 * 60 * 1000; // 5 minutes default
116
- // Wrap the logger so messages also appear on the event stream
117
- const eventStreamLogger = new EventStreamLogger(logger);
118
- const svc = new DaemonService(config, pipeName ?? '', eventStreamLogger, browserFactory, serverFactory, approvalStorePath, idleTimeoutMs);
119
- eventStreamLogger._emitter = (event) => svc._emit(event);
120
- const currentSession = config.sessions.find(s => s.source.kind === 'current');
121
- const resolveViteFrom = currentSession?.viteProject.cwd;
129
+ const svc = new DaemonService(config, pipeName ?? '', logger, browserFactory, serverFactory, approvalStorePath, idleTimeoutMs);
122
130
  for (const sessionConfig of config.sessions) {
123
131
  svc._sessionConfigs.set(sessionConfig.name, sessionConfig);
124
- await svc._setupSession(sessionConfig, config.repo, resolveViteFrom);
132
+ await svc._setupSession(sessionConfig);
125
133
  }
126
134
  return svc;
127
135
  }
@@ -175,7 +183,10 @@ class DaemonService {
175
183
  image: (args.includeImage ?? true)
176
184
  ? Buffer.from(result.screenshots[result.screenshots.length - 1].image).toString('base64')
177
185
  : undefined,
178
- errors: result.errors.length > 0 ? result.errors : undefined,
186
+ hasError: result.hasError,
187
+ error: result.error,
188
+ events: result.events.length > 0 ? result.events : undefined,
189
+ resultData: result.resultData,
179
190
  isStable,
180
191
  stabilityScreenshots,
181
192
  };
@@ -188,7 +199,10 @@ class DaemonService {
188
199
  image: (args.includeImage ?? true)
189
200
  ? Buffer.from(result.image).toString('base64')
190
201
  : undefined,
191
- errors: result.errors.length > 0 ? result.errors : undefined,
202
+ hasError: result.hasError,
203
+ error: result.error,
204
+ events: result.events?.length > 0 ? result.events : undefined,
205
+ resultData: result.resultData,
192
206
  };
193
207
  }),
194
208
  takeBatch: createMethod({
@@ -215,7 +229,10 @@ class DaemonService {
215
229
  image: includeImages
216
230
  ? Buffer.from(result.image).toString('base64')
217
231
  : undefined,
218
- errors: result.errors.length > 0 ? result.errors : undefined,
232
+ hasError: result.hasError,
233
+ error: result.error,
234
+ events: result.events.length > 0 ? result.events : undefined,
235
+ resultData: result.resultData,
219
236
  });
220
237
  }
221
238
  this._logger.trace(`takeBatch: done in ${Date.now() - startTime}ms`);
@@ -236,13 +253,23 @@ class DaemonService {
236
253
  const currentSession = this.getSession(args.currentSessionName);
237
254
  this.assertSourceTreeId(args.baselineSessionName, args.baselineSourceTreeId);
238
255
  this.assertSourceTreeId(args.currentSessionName, args.currentSourceTreeId);
239
- const baselineResult = await baselineSession.explorer.screenshotFixture(args.fixtureId);
256
+ const [baselineResult, baselineFixtures] = await Promise.all([
257
+ baselineSession.explorer.screenshotFixture(args.fixtureId),
258
+ baselineSession.explorer.listFixtures(),
259
+ ]);
240
260
  this.assertSourceTreeId(args.baselineSessionName, args.baselineSourceTreeId);
241
- const currentResult = await currentSession.explorer.screenshotFixture(args.fixtureId);
261
+ const [currentResult, currentFixtures] = await Promise.all([
262
+ currentSession.explorer.screenshotFixture(args.fixtureId),
263
+ currentSession.explorer.listFixtures(),
264
+ ]);
242
265
  this.assertSourceTreeId(args.currentSessionName, args.currentSourceTreeId);
243
266
  const baselineHash = contentHash(baselineResult.image);
244
267
  const currentHash = contentHash(currentResult.image);
245
268
  const includeImages = args.includeImages ?? false;
269
+ const currentFixture = currentFixtures.find(f => f.fixtureId === args.fixtureId);
270
+ const baselineFixture = baselineFixtures.find(f => f.fixtureId === args.fixtureId);
271
+ const labels = currentFixture?.labels;
272
+ const labelsChanged = baselineFixture && !arraysEqual(baselineFixture.labels, currentFixture?.labels ?? []);
246
273
  return {
247
274
  match: baselineHash === currentHash,
248
275
  baselineHash,
@@ -251,8 +278,16 @@ class DaemonService {
251
278
  ? Buffer.from(baselineResult.image).toString('base64') : undefined,
252
279
  currentImage: includeImages
253
280
  ? Buffer.from(currentResult.image).toString('base64') : undefined,
254
- baselineErrors: baselineResult.errors.length > 0 ? baselineResult.errors : undefined,
255
- currentErrors: currentResult.errors.length > 0 ? currentResult.errors : undefined,
281
+ baselineHasError: baselineResult.hasError,
282
+ baselineError: baselineResult.error,
283
+ baselineEvents: baselineResult.events.length > 0 ? baselineResult.events : undefined,
284
+ baselineResultData: baselineResult.resultData,
285
+ currentHasError: currentResult.hasError,
286
+ currentError: currentResult.error,
287
+ currentEvents: currentResult.events.length > 0 ? currentResult.events : undefined,
288
+ currentResultData: currentResult.resultData,
289
+ labels,
290
+ labelsBefore: labelsChanged ? baselineFixture.labels : undefined,
256
291
  approval: (baselineHash !== currentHash)
257
292
  ? this.approvals.lookup({
258
293
  fixtureId: args.fixtureId,
@@ -325,6 +360,48 @@ class DaemonService {
325
360
  this._logger.trace(`API: sessions (client=${ctx.clientName}, eventListeners=${this._eventListeners.size})`);
326
361
  return this.getSessionInfos();
327
362
  }),
363
+ version: createMethod({ args: {} }, async () => {
364
+ this._activityTracker.reportActivity();
365
+ return {
366
+ daemonApiVersion: daemonApiVersionText,
367
+ pluginProtocolVersion: pluginProtocolVersionText,
368
+ };
369
+ }),
370
+ restartSession: createMethod({
371
+ args: { sessionName: z.string() },
372
+ }, async (args, ctx) => {
373
+ this._activityTracker.reportActivity();
374
+ this._logger.debug(`Restart session "${args.sessionName}" requested (client=${ctx.clientName})`);
375
+ await this._restartSession(args.sessionName);
376
+ return this.getSessionInfos();
377
+ }),
378
+ openSession: createMethod({
379
+ args: {
380
+ name: z.string(),
381
+ ref: z.string(),
382
+ },
383
+ }, async (args, ctx) => {
384
+ this._activityTracker.reportActivity();
385
+ this._logger.log(`Open session "${args.name}" @ ${args.ref} requested (client=${ctx.clientName})`);
386
+ return this._openDynamicSession(args.name, args.ref);
387
+ }),
388
+ closeSession: createMethod({
389
+ args: { name: z.string() },
390
+ }, async (args, ctx) => {
391
+ this._activityTracker.reportActivity();
392
+ this._logger.log(`Close session "${args.name}" requested (client=${ctx.clientName})`);
393
+ return this._closeDynamicSession(args.name);
394
+ }),
395
+ updateSessionRef: createMethod({
396
+ args: {
397
+ name: z.string(),
398
+ ref: z.string(),
399
+ },
400
+ }, async (args, ctx) => {
401
+ this._activityTracker.reportActivity();
402
+ this._logger.log(`Update session ref "${args.name}" → ${args.ref} requested (client=${ctx.clientName})`);
403
+ return this._updateDynamicSessionRef(args.name, args.ref);
404
+ }),
328
405
  shutdown: createMethod({ args: {} }, async (_args, ctx) => {
329
406
  this._logger.debug(`Shutdown requested via API (client=${ctx.clientName})`);
330
407
  this.requestShutdown();
@@ -340,18 +417,56 @@ class DaemonService {
340
417
  return session;
341
418
  }
342
419
  getSessionInfos(reader) {
343
- return this._config.sessions.map(sc => {
420
+ const infos = [];
421
+ const reportedNames = new Set();
422
+ // Static sessions (from config)
423
+ for (const sc of this._config.sessions) {
424
+ reportedNames.add(sc.name);
344
425
  const session = this._sessions.get(sc.name);
426
+ const meta = this._dynamicSessionMeta.get(sc.name);
345
427
  if (!session) {
346
- return { name: sc.name, sourceKind: sc.source.kind, isLoading: true };
428
+ infos.push({ name: sc.name, sourceKind: sc.source.kind, isLoading: true });
347
429
  }
348
- return {
349
- name: sc.name,
350
- sourceKind: sc.source.kind,
351
- serverUrl: session.serverUrl,
352
- sourceTreeId: reader ? session.sourceTreeId.read(reader).value : session.sourceTreeId.get().value,
353
- };
354
- });
430
+ else if (meta) {
431
+ infos.push({
432
+ name: sc.name,
433
+ sourceKind: 'worktree',
434
+ serverUrl: session.serverUrl,
435
+ sourceTreeId: reader ? session.sourceTreeId.read(reader).value : session.sourceTreeId.get().value,
436
+ worktreePath: meta.worktreePath,
437
+ ref: meta.ref,
438
+ });
439
+ }
440
+ else {
441
+ infos.push({
442
+ name: sc.name,
443
+ sourceKind: sc.source.kind,
444
+ serverUrl: session.serverUrl,
445
+ sourceTreeId: reader ? session.sourceTreeId.read(reader).value : session.sourceTreeId.get().value,
446
+ });
447
+ }
448
+ }
449
+ // Dynamic worktree sessions (not already reported as static)
450
+ for (const [name, meta] of this._dynamicSessionMeta) {
451
+ if (reportedNames.has(name)) {
452
+ continue;
453
+ }
454
+ const session = this._sessions.get(name);
455
+ if (!session) {
456
+ infos.push({ name, sourceKind: 'worktree', isLoading: true });
457
+ }
458
+ else {
459
+ infos.push({
460
+ name,
461
+ sourceKind: 'worktree',
462
+ serverUrl: session.serverUrl,
463
+ sourceTreeId: reader ? session.sourceTreeId.read(reader).value : session.sourceTreeId.get().value,
464
+ worktreePath: meta.worktreePath,
465
+ ref: meta.ref,
466
+ });
467
+ }
468
+ }
469
+ return infos;
355
470
  }
356
471
  waitForSession(sessionName) {
357
472
  const sessionObs = derived(this, reader => {
@@ -394,8 +509,10 @@ class DaemonService {
394
509
  self._eventListeners.add(listener);
395
510
  self._eventListenerCount.set(self._eventListeners.size, undefined);
396
511
  self._activityTracker.setActive(true);
512
+ self._logger.debug(`Event stream opened (listeners: ${self._eventListeners.size})`);
397
513
  const onShutdown = () => {
398
514
  done = true;
515
+ cleanup();
399
516
  if (resolve) {
400
517
  const r = resolve;
401
518
  resolve = undefined;
@@ -407,6 +524,7 @@ class DaemonService {
407
524
  self._eventListeners.delete(listener);
408
525
  self._eventListenerCount.set(self._eventListeners.size, undefined);
409
526
  self._activityTracker.setActive(false);
527
+ self._logger.debug(`Event stream closed (listeners: ${self._eventListeners.size})`);
410
528
  };
411
529
  return {
412
530
  next() {
@@ -421,6 +539,11 @@ class DaemonService {
421
539
  return() {
422
540
  done = true;
423
541
  cleanup();
542
+ if (resolve) {
543
+ const r = resolve;
544
+ resolve = undefined;
545
+ r({ value: undefined, done: true });
546
+ }
424
547
  return Promise.resolve({ value: undefined, done: true });
425
548
  },
426
549
  };
@@ -442,68 +565,57 @@ class DaemonService {
442
565
  _sourceChangeDisposables = [];
443
566
  _startSourceChangeWatchers() {
444
567
  for (const [name, session] of this._sessions) {
445
- let previousValue = session.sourceTreeId.get().value;
446
- const disposable = autorun(reader => {
447
- const current = session.sourceTreeId.read(reader);
448
- if (current.value !== previousValue) {
449
- this._logger.debug(`Source tree changed: ${name} ${previousValue} → ${current.value}`);
450
- previousValue = current.value;
451
- this._emit({ type: 'source-change', sessionName: name, sourceTreeId: current.value });
452
- }
453
- });
454
- this._sourceChangeDisposables.push(disposable);
455
- }
456
- // Watch for ref changes (worktree sessions)
457
- for (const [name, resolver] of this._resolvers) {
458
- let previousCommit = resolver.resolvedCommit.get();
459
- const disposable = autorun(reader => {
460
- const commit = resolver.resolvedCommit.read(reader);
461
- if (!previousCommit.equals(commit)) {
462
- previousCommit = commit;
463
- this._handleRefChange(name, resolver.ref, commit);
464
- }
465
- });
466
- this._sourceChangeDisposables.push(disposable);
568
+ this._addSourceChangeWatcher(name, session);
467
569
  }
468
570
  }
469
- async _handleRefChange(sessionName, ref, newCommit) {
470
- this._logger.log(`Ref ${ref} moved to ${newCommit.toShort()}`);
471
- const git = this._config.repo;
472
- const sessionConfig = this._sessionConfigs.get(sessionName);
473
- if (!sessionConfig || sessionConfig.source.kind !== 'worktree') {
571
+ _addSourceChangeWatcher(name, session) {
572
+ let previousValue = session.sourceTreeId.get().value;
573
+ const disposable = autorun(reader => {
574
+ const current = session.sourceTreeId.read(reader);
575
+ if (current.value !== previousValue) {
576
+ this._logger.debug(`Source tree changed: ${name} ${previousValue} → ${current.value}`);
577
+ previousValue = current.value;
578
+ this._emit({ type: 'source-change', sessionName: name, sourceTreeId: current.value });
579
+ }
580
+ });
581
+ this._sourceChangeDisposables.push(disposable);
582
+ }
583
+ async _handleRefChange(sessionName, ref, previousCommit, newCommit) {
584
+ const changedFiles = await this._getChangedFiles(previousCommit, newCommit);
585
+ this._logger.log(`Ref ${ref} moved to ${newCommit.toShort()} (${changedFiles.length} file(s) changed${changedFiles.length > 0 ? ': ' + changedFiles.join(', ') : ''})`);
586
+ const meta = this._dynamicSessionMeta.get(sessionName);
587
+ if (!meta) {
474
588
  return;
475
589
  }
476
- const wt = sessionConfig.source.worktree;
477
- const wtInfo = await git.worktrees.info(wt.worktreePath);
590
+ const git = this._config.repo;
591
+ const wtInfo = await git.worktrees.info(meta.worktreePath);
478
592
  if (wtInfo && wtInfo.isDirty) {
479
593
  this._logger.log(`Worktree is dirty, skipping update to ${newCommit.toShort()}`);
480
594
  return;
481
595
  }
482
- // Dispose old session
483
- const oldSession = this._sessions.get(sessionName);
484
- if (oldSession) {
485
- this._logger.debug(`Disposing session: ${sessionName}`);
486
- await oldSession.dispose();
487
- }
488
- // Checkout + reinstall
489
596
  if (wtInfo) {
490
- await git.worktrees.checkout(wt.worktreePath, newCommit);
597
+ await git.worktrees.checkout(meta.worktreePath, newCommit);
491
598
  }
492
599
  else {
493
- await git.worktrees.create(wt.worktreePath, newCommit);
600
+ await git.worktrees.create(meta.worktreePath, newCommit);
494
601
  }
495
- await installDependencies(wt.worktreePath, wt.install, this._logger);
496
- // Recreate session
497
- const currentSession = this._config.sessions.find(s => s.source.kind === 'current');
498
- const resolveViteFrom = currentSession?.viteProject.cwd;
499
- const newSession = await ExplorerSession.create(sessionConfig.name, sessionConfig.viteProject, this._serverFactory, this._browserFactory, {
500
- logger: this._logger,
501
- resolveViteFrom,
502
- hmrAllowedPaths: this._config.viteHmr?.allowedPaths,
503
- });
504
- this._sessions.set(sessionName, newSession);
602
+ const sessionConfig = this._sessionConfigs.get(sessionName);
603
+ const installSetup = (sessionConfig?.source.kind === 'worktree' ? sessionConfig.source.install : undefined)
604
+ ?? this._config.worktreePool?.setup
605
+ ?? { kind: 'auto' };
606
+ await installDependencies(meta.worktreePath, installSetup, this._logger);
505
607
  this._emit({ type: 'ref-change', sessionName, newCommit: newCommit.toShort() });
506
608
  }
609
+ async _getChangedFiles(oldCommit, newCommit) {
610
+ try {
611
+ const output = await execGit(this._config.repo.gitRoot, ['diff', '--name-only', oldCommit.hash, newCommit.hash]);
612
+ return output.trim().split('\n').filter(f => f.length > 0);
613
+ }
614
+ catch (e) {
615
+ this._logger.log(`Failed to get changed files (${oldCommit.toShort()}..${newCommit.toShort()}): ${e instanceof Error ? e.message : e}`);
616
+ return [];
617
+ }
618
+ }
507
619
  // -- Shutdown ------------------------------------------------------------
508
620
  requestShutdown() {
509
621
  this._shutdownRequested = true;
@@ -519,6 +631,10 @@ class DaemonService {
519
631
  d.dispose();
520
632
  }
521
633
  this._sourceChangeDisposables = [];
634
+ for (const d of this._dynamicRefWatchers.values()) {
635
+ d.dispose();
636
+ }
637
+ this._dynamicRefWatchers.clear();
522
638
  for (const session of this._sessions.values()) {
523
639
  await session.dispose();
524
640
  }
@@ -529,77 +645,303 @@ class DaemonService {
529
645
  await this._browserFactory.dispose();
530
646
  }
531
647
  // -- Private helpers -----------------------------------------------------
532
- /** @internal — also called by EventStreamLogger */
533
648
  _emit(event) {
534
649
  for (const listener of this._eventListeners) {
535
650
  listener(event);
536
651
  }
537
652
  }
538
- async _setupSession(sessionConfig, git, resolveViteFrom) {
653
+ async _restartSession(sessionName) {
654
+ const config = this._sessionConfigs.get(sessionName);
655
+ const meta = this._dynamicSessionMeta.get(sessionName);
656
+ if (!config && !meta) {
657
+ throw new Error(`Unknown session: "${sessionName}"`);
658
+ }
659
+ const existing = this._sessions.get(sessionName);
660
+ if (existing) {
661
+ this._logger.debug(`Disposing session: ${sessionName}`);
662
+ await existing.dispose();
663
+ this._sessions.delete(sessionName);
664
+ }
665
+ this._logger.log(`Restarting server: ${sessionName}`);
666
+ if (meta) {
667
+ await this._createWorktreeExplorerSession(sessionName, meta.worktreePath);
668
+ }
669
+ else if (config) {
670
+ await this._createExplorerSession(config);
671
+ }
672
+ }
673
+ async _createExplorerSession(sessionConfig) {
674
+ const session = await ExplorerSession.create(sessionConfig.name, sessionConfig.viteProject, this._serverFactory, this._browserFactory, {
675
+ logger: this._logger,
676
+ hmrAllowedPaths: this._config.viteHmr?.allowedPaths,
677
+ daemonConfig: {
678
+ pipeName: this._pipeName,
679
+ sessionName: sessionConfig.name,
680
+ daemonApiVersion: daemonApiVersionText,
681
+ pluginProtocolVersion: pluginProtocolVersionText,
682
+ },
683
+ });
684
+ this._sessions.set(sessionConfig.name, session);
685
+ this._logger.debug(`Session ready: ${sessionConfig.name} (${session.serverUrl})`);
686
+ }
687
+ async _createWorktreeExplorerSession(sessionName, worktreePath) {
688
+ const configDirRelToGitRoot = path.relative(this._config.repo.gitRoot, this._config.configDir);
689
+ const worktreeConfigDir = path.resolve(worktreePath, configDirRelToGitRoot);
690
+ const viteConfigPath = path.resolve(worktreeConfigDir, this._config.defaultViteConfig);
691
+ const viteProject = ViteProjectRef.fromViteConfigPath(viteConfigPath);
692
+ const currentSession = this._config.sessions.find(s => s.source.kind === 'current');
693
+ const resolveViteFrom = currentSession?.viteProject.configFile;
694
+ this._logger.debug(`Worktree session "${sessionName}": resolveViteFrom=${resolveViteFrom}`);
695
+ const session = await ExplorerSession.create(sessionName, viteProject, this._serverFactory, this._browserFactory, {
696
+ logger: this._logger,
697
+ resolveViteFrom,
698
+ hmrAllowedPaths: this._config.viteHmr?.allowedPaths,
699
+ daemonConfig: {
700
+ pipeName: this._pipeName,
701
+ sessionName,
702
+ daemonApiVersion: daemonApiVersionText,
703
+ pluginProtocolVersion: pluginProtocolVersionText,
704
+ },
705
+ });
706
+ this._sessions.set(sessionName, session);
707
+ this._logger.debug(`Session ready: ${sessionName} (${session.serverUrl})`);
708
+ }
709
+ async _setupSession(sessionConfig) {
539
710
  this._logger.debug(`Setting up session: ${sessionConfig.name} (${sessionConfig.source.kind})`);
711
+ this._logger.log(`Starting server: ${sessionConfig.name}`);
540
712
  if (sessionConfig.source.kind === 'worktree') {
541
- const wt = sessionConfig.source.worktree;
542
- const resolver = await git.createCommitResolver(wt.ref);
543
- this._resolvers.set(sessionConfig.name, resolver);
713
+ await this._setupWorktreeSession(sessionConfig, sessionConfig.source);
714
+ }
715
+ else {
716
+ await this._createExplorerSession(sessionConfig);
717
+ }
718
+ }
719
+ async _setupWorktreeSession(sessionConfig, source) {
720
+ if (!this._worktreePool) {
721
+ throw new Error(`Session "${sessionConfig.name}" requires a worktree but no worktree pool is available`);
722
+ }
723
+ const slot = this._worktreePool.allocate(sessionConfig.name);
724
+ const git = this._config.repo;
725
+ const ref = source.ref;
726
+ const resolver = ref === GitIndexResolver.INDEX_REF
727
+ ? await git.createIndexResolver()
728
+ : await git.createCommitResolver(ref);
729
+ this._resolvers.set(sessionConfig.name, resolver);
730
+ const resolvedCommit = resolver.resolvedCommit.get();
731
+ const wtInfo = await git.worktrees.info(slot.worktreePath);
732
+ if (!wtInfo) {
733
+ this._logger.log(`Creating worktree at ${slot.worktreePath} (${ref} @ ${resolvedCommit.toShort()})`);
734
+ await git.worktrees.create(slot.worktreePath, resolvedCommit);
735
+ }
736
+ else if (!wtInfo.checkedOutCommit.equals(resolvedCommit)) {
737
+ if (wtInfo.isDirty) {
738
+ throw new Error(`Worktree slot ${slot.index} is dirty. Dirty files:\n` +
739
+ wtInfo.dirtyFiles.map(f => ` ${f}`).join('\n'));
740
+ }
741
+ this._logger.log(`Updating worktree to ${resolvedCommit.toShort()}`);
742
+ await git.worktrees.checkout(slot.worktreePath, resolvedCommit);
743
+ }
744
+ else {
745
+ this._logger.log(`Worktree already at ${resolvedCommit.toShort()}`);
746
+ }
747
+ const installSetup = source.install ?? this._config.worktreePool?.setup ?? { kind: 'auto' };
748
+ await installDependencies(slot.worktreePath, installSetup, this._logger);
749
+ this._dynamicSessionMeta.set(sessionConfig.name, { ref, worktreePath: slot.worktreePath });
750
+ await this._createWorktreeExplorerSession(sessionConfig.name, slot.worktreePath);
751
+ this._addDynamicRefWatcher(sessionConfig.name, resolver);
752
+ }
753
+ // -- Dynamic session management ------------------------------------------
754
+ async _openDynamicSession(name, ref) {
755
+ if (this._sessions.has(name) || this._sessionConfigs.has(name) || this._dynamicSessionMeta.has(name)) {
756
+ return { error: `Session "${name}" already exists` };
757
+ }
758
+ if (!this._worktreePool) {
759
+ return { error: 'No worktree pool configured. Add a "worktree" section to your component-explorer.json config.' };
760
+ }
761
+ let slot;
762
+ try {
763
+ slot = this._worktreePool.allocate(name);
764
+ }
765
+ catch (e) {
766
+ return { error: e instanceof Error ? e.message : String(e) };
767
+ }
768
+ const git = this._config.repo;
769
+ const isIndex = ref === GitIndexResolver.INDEX_REF;
770
+ try {
771
+ let resolver;
772
+ if (isIndex) {
773
+ resolver = await git.createIndexResolver();
774
+ }
775
+ else {
776
+ resolver = await git.createCommitResolver(ref);
777
+ }
778
+ this._resolvers.set(name, resolver);
544
779
  const resolvedCommit = resolver.resolvedCommit.get();
545
- const wtInfo = await git.worktrees.info(wt.worktreePath);
780
+ const wtInfo = await git.worktrees.info(slot.worktreePath);
546
781
  if (!wtInfo) {
547
- this._logger.log(`Creating worktree at ${wt.worktreePath} (${wt.ref} @ ${resolvedCommit.toShort()})`);
548
- await git.worktrees.create(wt.worktreePath, resolvedCommit);
549
- await installDependencies(wt.worktreePath, wt.install, this._logger);
782
+ this._logger.log(`Creating worktree at ${slot.worktreePath} (${ref} @ ${resolvedCommit.toShort()})`);
783
+ await git.worktrees.create(slot.worktreePath, resolvedCommit);
550
784
  }
551
785
  else if (!wtInfo.checkedOutCommit.equals(resolvedCommit)) {
552
786
  if (wtInfo.isDirty) {
553
- this._logger.log(`Worktree is dirty, using existing checkout at ${wtInfo.checkedOutCommit.toShort()}`);
554
- }
555
- else {
556
- this._logger.log(`Updating worktree to ${resolvedCommit.toShort()}`);
557
- await git.worktrees.checkout(wt.worktreePath, resolvedCommit);
558
- await installDependencies(wt.worktreePath, wt.install, this._logger);
787
+ this._worktreePool.release(name);
788
+ this._resolvers.get(name)?.dispose();
789
+ this._resolvers.delete(name);
790
+ return {
791
+ error: `Worktree slot ${slot.index} is dirty. Dirty files:\n` +
792
+ wtInfo.dirtyFiles.map(f => ` ${f}`).join('\n'),
793
+ };
559
794
  }
795
+ this._logger.log(`Updating worktree to ${resolvedCommit.toShort()}`);
796
+ await git.worktrees.checkout(slot.worktreePath, resolvedCommit);
560
797
  }
561
798
  else {
562
799
  this._logger.log(`Worktree already at ${resolvedCommit.toShort()}`);
563
800
  }
801
+ const poolConfig = this._config.worktreePool;
802
+ await installDependencies(slot.worktreePath, poolConfig.setup, this._logger);
803
+ this._dynamicSessionMeta.set(name, { ref, worktreePath: slot.worktreePath });
804
+ await this._createWorktreeExplorerSession(name, slot.worktreePath);
805
+ this._addDynamicRefWatcher(name, resolver);
806
+ this._emit({ type: 'session-change' });
807
+ return { sessions: this.getSessionInfos() };
808
+ }
809
+ catch (e) {
810
+ this._worktreePool.release(name);
811
+ this._resolvers.get(name)?.dispose();
812
+ this._resolvers.delete(name);
813
+ this._dynamicSessionMeta.delete(name);
814
+ const msg = e instanceof Error ? e.message : String(e);
815
+ this._logger.log(`Failed to open session "${name}": ${msg}`);
816
+ return { error: `Failed to open session: ${msg}` };
564
817
  }
565
- this._logger.log(`Starting server: ${sessionConfig.name}`);
566
- // Set daemon config env var so the Vite plugin enables the daemon proxy
567
- process.env.COMPONENT_EXPLORER_DAEMON_CONFIG = JSON.stringify({
568
- pipeName: this._pipeName,
569
- sessionName: sessionConfig.name,
570
- });
571
- const session = await ExplorerSession.create(sessionConfig.name, sessionConfig.viteProject, this._serverFactory, this._browserFactory, {
572
- logger: this._logger,
573
- resolveViteFrom: sessionConfig.source.kind === 'worktree' ? resolveViteFrom : undefined,
574
- hmrAllowedPaths: this._config.viteHmr?.allowedPaths,
575
- });
576
- this._sessions.set(sessionConfig.name, session);
577
- this._logger.debug(`Session ready: ${sessionConfig.name} (${session.serverUrl})`);
578
818
  }
579
- }
580
- // ---------------------------------------------------------------------------
581
- // EventStreamLogger — wraps a base logger and also emits log events
582
- // ---------------------------------------------------------------------------
583
- class EventStreamLogger {
584
- _base;
585
- _emitter;
586
- constructor(_base) {
587
- this._base = _base;
819
+ async _closeDynamicSession(name) {
820
+ if (!this._dynamicSessionMeta.has(name)) {
821
+ if (this._sessionConfigs.has(name)) {
822
+ return { error: `Session "${name}" is a static session and cannot be closed` };
823
+ }
824
+ return { error: `Session "${name}" does not exist` };
825
+ }
826
+ const session = this._sessions.get(name);
827
+ if (session) {
828
+ this._logger.debug(`Disposing session: ${name}`);
829
+ await session.dispose();
830
+ this._sessions.delete(name);
831
+ }
832
+ const resolver = this._resolvers.get(name);
833
+ if (resolver) {
834
+ resolver.dispose();
835
+ this._resolvers.delete(name);
836
+ }
837
+ // Remove any ref watcher disposable
838
+ const watcherDisposable = this._dynamicRefWatchers.get(name);
839
+ if (watcherDisposable) {
840
+ watcherDisposable.dispose();
841
+ this._dynamicRefWatchers.delete(name);
842
+ }
843
+ if (this._worktreePool) {
844
+ this._worktreePool.release(name);
845
+ }
846
+ this._dynamicSessionMeta.delete(name);
847
+ this._logger.log(`Session "${name}" closed`);
848
+ this._emit({ type: 'session-change' });
849
+ return { sessions: this.getSessionInfos() };
850
+ }
851
+ async _updateDynamicSessionRef(name, newRef) {
852
+ const meta = this._dynamicSessionMeta.get(name);
853
+ if (!meta) {
854
+ if (this._sessionConfigs.has(name)) {
855
+ return { error: `Session "${name}" is a static session — use restartSession instead` };
856
+ }
857
+ return { error: `Session "${name}" does not exist` };
858
+ }
859
+ const git = this._config.repo;
860
+ // Check dirty before doing anything
861
+ const wtInfo = await git.worktrees.info(meta.worktreePath);
862
+ if (wtInfo && wtInfo.isDirty) {
863
+ return {
864
+ error: `Worktree is dirty, cannot update ref. Dirty files:\n` +
865
+ wtInfo.dirtyFiles.map(f => ` ${f}`).join('\n'),
866
+ };
867
+ }
868
+ // Dispose old resolver
869
+ const oldResolver = this._resolvers.get(name);
870
+ if (oldResolver) {
871
+ oldResolver.dispose();
872
+ this._resolvers.delete(name);
873
+ }
874
+ const oldWatcher = this._dynamicRefWatchers.get(name);
875
+ if (oldWatcher) {
876
+ oldWatcher.dispose();
877
+ this._dynamicRefWatchers.delete(name);
878
+ }
879
+ try {
880
+ const isIndex = newRef === GitIndexResolver.INDEX_REF;
881
+ let resolver;
882
+ if (isIndex) {
883
+ resolver = await git.createIndexResolver();
884
+ }
885
+ else {
886
+ resolver = await git.createCommitResolver(newRef);
887
+ }
888
+ this._resolvers.set(name, resolver);
889
+ const resolvedCommit = resolver.resolvedCommit.get();
890
+ // Checkout in worktree — don't restart Vite, let HMR handle it
891
+ if (wtInfo) {
892
+ if (!wtInfo.checkedOutCommit.equals(resolvedCommit)) {
893
+ await git.worktrees.checkout(meta.worktreePath, resolvedCommit);
894
+ }
895
+ }
896
+ else {
897
+ await git.worktrees.create(meta.worktreePath, resolvedCommit);
898
+ }
899
+ const sessionConfig = this._sessionConfigs.get(name);
900
+ const installSetup = (sessionConfig?.source.kind === 'worktree' ? sessionConfig.source.install : undefined)
901
+ ?? this._config.worktreePool?.setup
902
+ ?? { kind: 'auto' };
903
+ await installDependencies(meta.worktreePath, installSetup, this._logger);
904
+ meta.ref = newRef;
905
+ this._addDynamicRefWatcher(name, resolver);
906
+ return { sessions: this.getSessionInfos() };
907
+ }
908
+ catch (e) {
909
+ if (e instanceof DirtyWorktreeError) {
910
+ return {
911
+ error: `Worktree is dirty, cannot update ref. Dirty files:\n` +
912
+ e.dirtyFiles.map(f => ` ${f}`).join('\n'),
913
+ };
914
+ }
915
+ const msg = e instanceof Error ? e.message : String(e);
916
+ this._logger.log(`Failed to update session ref "${name}": ${msg}`);
917
+ return { error: `Failed to update session ref: ${msg}` };
918
+ }
588
919
  }
589
- log(message) { this.info(message); }
590
- info(message) {
591
- this._base.info(message);
592
- this._emitter?.({ type: 'log', level: 'info', message });
920
+ _dynamicRefWatchers = new Map();
921
+ _addDynamicRefWatcher(sessionName, resolver) {
922
+ let previousCommit = resolver.resolvedCommit.get();
923
+ const disposable = autorun(reader => {
924
+ const commit = resolver.resolvedCommit.read(reader);
925
+ if (!previousCommit.equals(commit)) {
926
+ const prev = previousCommit;
927
+ previousCommit = commit;
928
+ this._handleRefChange(sessionName, resolver.ref, prev, commit);
929
+ }
930
+ });
931
+ this._dynamicRefWatchers.set(sessionName, disposable);
593
932
  }
594
- debug(message) {
595
- this._base.debug(message);
596
- this._emitter?.({ type: 'log', level: 'debug', message });
933
+ }
934
+ function arraysEqual(a, b) {
935
+ if (a.length !== b.length) {
936
+ return false;
597
937
  }
598
- trace(message) {
599
- this._base.trace(message);
600
- this._emitter?.({ type: 'log', level: 'trace', message });
938
+ for (let i = 0; i < a.length; i++) {
939
+ if (a[i] !== b[i]) {
940
+ return false;
941
+ }
601
942
  }
943
+ return true;
602
944
  }
603
945
 
604
- export { ActivityTracker, DaemonService, SourceTreeChangedError };
946
+ export { ActivityTracker, DaemonService, SourceTreeChangedError, daemonApiVersionText, pluginProtocolVersionText };
605
947
  //# sourceMappingURL=DaemonService.js.map