@vscode/component-explorer-cli 0.1.1-8 → 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 (133) 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 +13 -5
  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 +36 -11
  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 +49 -31
  50. package/dist/componentExplorer.js.map +1 -1
  51. package/dist/daemon/DaemonContext.d.ts +4 -0
  52. package/dist/daemon/DaemonContext.d.ts.map +1 -0
  53. package/dist/daemon/DaemonService.d.ts +92 -23
  54. package/dist/daemon/DaemonService.d.ts.map +1 -1
  55. package/dist/daemon/DaemonService.js +473 -118
  56. package/dist/daemon/DaemonService.js.map +1 -1
  57. package/dist/daemon/dynamicSessions.test.d.ts +2 -0
  58. package/dist/daemon/dynamicSessions.test.d.ts.map +1 -0
  59. package/dist/daemon/lifecycle.d.ts +8 -3
  60. package/dist/daemon/lifecycle.d.ts.map +1 -1
  61. package/dist/daemon/lifecycle.js +28 -24
  62. package/dist/daemon/lifecycle.js.map +1 -1
  63. package/dist/daemon/pipeClient.d.ts +6 -1
  64. package/dist/daemon/pipeClient.d.ts.map +1 -1
  65. package/dist/daemon/pipeClient.js +97 -5
  66. package/dist/daemon/pipeClient.js.map +1 -1
  67. package/dist/daemon/pipeServer.d.ts +2 -1
  68. package/dist/daemon/pipeServer.d.ts.map +1 -1
  69. package/dist/daemon/pipeServer.js +62 -3
  70. package/dist/daemon/pipeServer.js.map +1 -1
  71. package/dist/daemon/version.d.ts +10 -0
  72. package/dist/daemon/version.d.ts.map +1 -0
  73. package/dist/daemon/version.js +17 -0
  74. package/dist/daemon/version.js.map +1 -0
  75. package/dist/dependencyInstaller.d.ts +2 -2
  76. package/dist/dependencyInstaller.d.ts.map +1 -1
  77. package/dist/dependencyInstaller.js.map +1 -1
  78. package/dist/formatValue.d.ts +2 -0
  79. package/dist/formatValue.d.ts.map +1 -0
  80. package/dist/formatValue.js +96 -0
  81. package/dist/formatValue.js.map +1 -0
  82. package/dist/formatValue.test.d.ts +2 -0
  83. package/dist/formatValue.test.d.ts.map +1 -0
  84. package/dist/git/gitIndexResolver.d.ts +25 -0
  85. package/dist/git/gitIndexResolver.d.ts.map +1 -0
  86. package/dist/git/gitIndexResolver.js +91 -0
  87. package/dist/git/gitIndexResolver.js.map +1 -0
  88. package/dist/git/gitIndexResolver.test.d.ts +2 -0
  89. package/dist/git/gitIndexResolver.test.d.ts.map +1 -0
  90. package/dist/git/gitService.d.ts +2 -0
  91. package/dist/git/gitService.d.ts.map +1 -1
  92. package/dist/git/gitService.js +6 -0
  93. package/dist/git/gitService.js.map +1 -1
  94. package/dist/git/gitWorktreeManager.d.ts +6 -0
  95. package/dist/git/gitWorktreeManager.d.ts.map +1 -1
  96. package/dist/git/gitWorktreeManager.js +42 -13
  97. package/dist/git/gitWorktreeManager.js.map +1 -1
  98. package/dist/git/gitWorktreeManager.test.d.ts +2 -0
  99. package/dist/git/gitWorktreeManager.test.d.ts.map +1 -0
  100. package/dist/git/testUtils.d.ts +13 -0
  101. package/dist/git/testUtils.d.ts.map +1 -0
  102. package/dist/httpServer.d.ts +6 -1
  103. package/dist/httpServer.d.ts.map +1 -1
  104. package/dist/httpServer.js +17 -3
  105. package/dist/httpServer.js.map +1 -1
  106. package/dist/index.js +11 -2
  107. package/dist/index.js.map +1 -1
  108. package/dist/logger.d.ts +1 -0
  109. package/dist/logger.d.ts.map +1 -1
  110. package/dist/logger.js +7 -1
  111. package/dist/logger.js.map +1 -1
  112. package/dist/mcp/McpServer.d.ts +19 -5
  113. package/dist/mcp/McpServer.d.ts.map +1 -1
  114. package/dist/mcp/McpServer.js +447 -97
  115. package/dist/mcp/McpServer.js.map +1 -1
  116. package/dist/mcp/TaskManager.d.ts +28 -0
  117. package/dist/mcp/TaskManager.d.ts.map +1 -0
  118. package/dist/mcp/TaskManager.js +54 -0
  119. package/dist/mcp/TaskManager.js.map +1 -0
  120. package/dist/packages/simple-api/dist/{chunk-Q24JOMNK.js → chunk-TAEFVNPN.js} +1 -1
  121. package/dist/packages/simple-api/dist/chunk-TAEFVNPN.js.map +1 -0
  122. package/dist/packages/simple-api/dist/express.js +11 -3
  123. package/dist/packages/simple-api/dist/express.js.map +1 -1
  124. package/dist/utils.d.ts +7 -0
  125. package/dist/utils.d.ts.map +1 -1
  126. package/dist/utils.js +6 -7
  127. package/dist/utils.js.map +1 -1
  128. package/dist/watchConfig.d.ts +19 -12
  129. package/dist/watchConfig.d.ts.map +1 -1
  130. package/dist/watchConfig.js +43 -48
  131. package/dist/watchConfig.js.map +1 -1
  132. package/package.json +21 -4
  133. 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({
@@ -203,6 +217,8 @@ class DaemonService {
203
217
  const session = this.getSession(args.sessionName);
204
218
  this.assertSourceTreeId(args.sessionName, args.sourceTreeId);
205
219
  const includeImages = args.includeImages ?? false;
220
+ this._logger.trace(`takeBatch: ${args.fixtureIds.length} fixtures, session=${args.sessionName}`);
221
+ const startTime = Date.now();
206
222
  const screenshots = [];
207
223
  for (const fixtureId of args.fixtureIds) {
208
224
  const result = await session.explorer.screenshotFixture(fixtureId);
@@ -213,9 +229,13 @@ class DaemonService {
213
229
  image: includeImages
214
230
  ? Buffer.from(result.image).toString('base64')
215
231
  : undefined,
216
- 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,
217
236
  });
218
237
  }
238
+ this._logger.trace(`takeBatch: done in ${Date.now() - startTime}ms`);
219
239
  return { sourceTreeId: args.sourceTreeId, screenshots };
220
240
  }),
221
241
  compare: createMethod({
@@ -233,13 +253,23 @@ class DaemonService {
233
253
  const currentSession = this.getSession(args.currentSessionName);
234
254
  this.assertSourceTreeId(args.baselineSessionName, args.baselineSourceTreeId);
235
255
  this.assertSourceTreeId(args.currentSessionName, args.currentSourceTreeId);
236
- 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
+ ]);
237
260
  this.assertSourceTreeId(args.baselineSessionName, args.baselineSourceTreeId);
238
- 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
+ ]);
239
265
  this.assertSourceTreeId(args.currentSessionName, args.currentSourceTreeId);
240
266
  const baselineHash = contentHash(baselineResult.image);
241
267
  const currentHash = contentHash(currentResult.image);
242
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 ?? []);
243
273
  return {
244
274
  match: baselineHash === currentHash,
245
275
  baselineHash,
@@ -248,8 +278,16 @@ class DaemonService {
248
278
  ? Buffer.from(baselineResult.image).toString('base64') : undefined,
249
279
  currentImage: includeImages
250
280
  ? Buffer.from(currentResult.image).toString('base64') : undefined,
251
- baselineErrors: baselineResult.errors.length > 0 ? baselineResult.errors : undefined,
252
- 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,
253
291
  approval: (baselineHash !== currentHash)
254
292
  ? this.approvals.lookup({
255
293
  fixtureId: args.fixtureId,
@@ -317,11 +355,55 @@ class DaemonService {
317
355
  events: createMethod({ args: {} }, async () => {
318
356
  return AsyncStream.fromIterable(this.eventStream());
319
357
  }),
320
- sessions: createMethod({ args: {} }, async () => {
358
+ sessions: createMethod({ args: {} }, async (_args, ctx) => {
321
359
  this._activityTracker.reportActivity();
360
+ this._logger.trace(`API: sessions (client=${ctx.clientName}, eventListeners=${this._eventListeners.size})`);
322
361
  return this.getSessionInfos();
323
362
  }),
324
- shutdown: createMethod({ args: {} }, async () => {
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
+ }),
405
+ shutdown: createMethod({ args: {} }, async (_args, ctx) => {
406
+ this._logger.debug(`Shutdown requested via API (client=${ctx.clientName})`);
325
407
  this.requestShutdown();
326
408
  }),
327
409
  });
@@ -335,18 +417,56 @@ class DaemonService {
335
417
  return session;
336
418
  }
337
419
  getSessionInfos(reader) {
338
- 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);
339
425
  const session = this._sessions.get(sc.name);
426
+ const meta = this._dynamicSessionMeta.get(sc.name);
340
427
  if (!session) {
341
- return { name: sc.name, sourceKind: sc.source.kind, isLoading: true };
428
+ infos.push({ name: sc.name, sourceKind: sc.source.kind, isLoading: true });
342
429
  }
343
- return {
344
- name: sc.name,
345
- sourceKind: sc.source.kind,
346
- serverUrl: session.serverUrl,
347
- sourceTreeId: reader ? session.sourceTreeId.read(reader).value : session.sourceTreeId.get().value,
348
- };
349
- });
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;
350
470
  }
351
471
  waitForSession(sessionName) {
352
472
  const sessionObs = derived(this, reader => {
@@ -374,6 +494,9 @@ class DaemonService {
374
494
  let resolve;
375
495
  let done = false;
376
496
  const listener = (event) => {
497
+ if (event.type !== 'log') {
498
+ self._logger.trace(`Event stream: ${event.type}`);
499
+ }
377
500
  if (resolve) {
378
501
  const r = resolve;
379
502
  resolve = undefined;
@@ -386,8 +509,10 @@ class DaemonService {
386
509
  self._eventListeners.add(listener);
387
510
  self._eventListenerCount.set(self._eventListeners.size, undefined);
388
511
  self._activityTracker.setActive(true);
512
+ self._logger.debug(`Event stream opened (listeners: ${self._eventListeners.size})`);
389
513
  const onShutdown = () => {
390
514
  done = true;
515
+ cleanup();
391
516
  if (resolve) {
392
517
  const r = resolve;
393
518
  resolve = undefined;
@@ -399,6 +524,7 @@ class DaemonService {
399
524
  self._eventListeners.delete(listener);
400
525
  self._eventListenerCount.set(self._eventListeners.size, undefined);
401
526
  self._activityTracker.setActive(false);
527
+ self._logger.debug(`Event stream closed (listeners: ${self._eventListeners.size})`);
402
528
  };
403
529
  return {
404
530
  next() {
@@ -413,6 +539,11 @@ class DaemonService {
413
539
  return() {
414
540
  done = true;
415
541
  cleanup();
542
+ if (resolve) {
543
+ const r = resolve;
544
+ resolve = undefined;
545
+ r({ value: undefined, done: true });
546
+ }
416
547
  return Promise.resolve({ value: undefined, done: true });
417
548
  },
418
549
  };
@@ -434,64 +565,57 @@ class DaemonService {
434
565
  _sourceChangeDisposables = [];
435
566
  _startSourceChangeWatchers() {
436
567
  for (const [name, session] of this._sessions) {
437
- let previousValue = session.sourceTreeId.get().value;
438
- const disposable = autorun(reader => {
439
- const current = session.sourceTreeId.read(reader);
440
- if (current.value !== previousValue) {
441
- previousValue = current.value;
442
- this._emit({ type: 'source-change', sessionName: name, sourceTreeId: current.value });
443
- }
444
- });
445
- this._sourceChangeDisposables.push(disposable);
446
- }
447
- // Watch for ref changes (worktree sessions)
448
- for (const [name, resolver] of this._resolvers) {
449
- let previousCommit = resolver.resolvedCommit.get();
450
- const disposable = autorun(reader => {
451
- const commit = resolver.resolvedCommit.read(reader);
452
- if (!previousCommit.equals(commit)) {
453
- previousCommit = commit;
454
- this._handleRefChange(name, resolver.ref, commit);
455
- }
456
- });
457
- this._sourceChangeDisposables.push(disposable);
568
+ this._addSourceChangeWatcher(name, session);
458
569
  }
459
570
  }
460
- async _handleRefChange(sessionName, ref, newCommit) {
461
- this._logger.log(`Ref ${ref} moved to ${newCommit.toShort()}`);
462
- const git = this._config.repo;
463
- const sessionConfig = this._sessionConfigs.get(sessionName);
464
- 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) {
465
588
  return;
466
589
  }
467
- const wt = sessionConfig.source.worktree;
468
- const wtInfo = await git.worktrees.info(wt.worktreePath);
590
+ const git = this._config.repo;
591
+ const wtInfo = await git.worktrees.info(meta.worktreePath);
469
592
  if (wtInfo && wtInfo.isDirty) {
470
593
  this._logger.log(`Worktree is dirty, skipping update to ${newCommit.toShort()}`);
471
594
  return;
472
595
  }
473
- // Dispose old session
474
- const oldSession = this._sessions.get(sessionName);
475
- await oldSession?.dispose();
476
- // Checkout + reinstall
477
596
  if (wtInfo) {
478
- await git.worktrees.checkout(wt.worktreePath, newCommit);
597
+ await git.worktrees.checkout(meta.worktreePath, newCommit);
479
598
  }
480
599
  else {
481
- await git.worktrees.create(wt.worktreePath, newCommit);
600
+ await git.worktrees.create(meta.worktreePath, newCommit);
482
601
  }
483
- await installDependencies(wt.worktreePath, wt.install, this._logger);
484
- // Recreate session
485
- const currentSession = this._config.sessions.find(s => s.source.kind === 'current');
486
- const resolveViteFrom = currentSession?.viteProject.cwd;
487
- const newSession = await ExplorerSession.create(sessionConfig.name, sessionConfig.viteProject, this._serverFactory, this._browserFactory, {
488
- logger: this._logger,
489
- resolveViteFrom,
490
- hmrAllowedPaths: this._config.viteHmr?.allowedPaths,
491
- });
492
- 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);
493
607
  this._emit({ type: 'ref-change', sessionName, newCommit: newCommit.toShort() });
494
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
+ }
495
619
  // -- Shutdown ------------------------------------------------------------
496
620
  requestShutdown() {
497
621
  this._shutdownRequested = true;
@@ -507,6 +631,10 @@ class DaemonService {
507
631
  d.dispose();
508
632
  }
509
633
  this._sourceChangeDisposables = [];
634
+ for (const d of this._dynamicRefWatchers.values()) {
635
+ d.dispose();
636
+ }
637
+ this._dynamicRefWatchers.clear();
510
638
  for (const session of this._sessions.values()) {
511
639
  await session.dispose();
512
640
  }
@@ -517,76 +645,303 @@ class DaemonService {
517
645
  await this._browserFactory.dispose();
518
646
  }
519
647
  // -- Private helpers -----------------------------------------------------
520
- /** @internal — also called by EventStreamLogger */
521
648
  _emit(event) {
522
649
  for (const listener of this._eventListeners) {
523
650
  listener(event);
524
651
  }
525
652
  }
526
- 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) {
710
+ this._logger.debug(`Setting up session: ${sessionConfig.name} (${sessionConfig.source.kind})`);
711
+ this._logger.log(`Starting server: ${sessionConfig.name}`);
527
712
  if (sessionConfig.source.kind === 'worktree') {
528
- const wt = sessionConfig.source.worktree;
529
- const resolver = await git.createCommitResolver(wt.ref);
530
- 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);
531
779
  const resolvedCommit = resolver.resolvedCommit.get();
532
- const wtInfo = await git.worktrees.info(wt.worktreePath);
780
+ const wtInfo = await git.worktrees.info(slot.worktreePath);
533
781
  if (!wtInfo) {
534
- this._logger.log(`Creating worktree at ${wt.worktreePath} (${wt.ref} @ ${resolvedCommit.toShort()})`);
535
- await git.worktrees.create(wt.worktreePath, resolvedCommit);
536
- 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);
537
784
  }
538
785
  else if (!wtInfo.checkedOutCommit.equals(resolvedCommit)) {
539
786
  if (wtInfo.isDirty) {
540
- this._logger.log(`Worktree is dirty, using existing checkout at ${wtInfo.checkedOutCommit.toShort()}`);
541
- }
542
- else {
543
- this._logger.log(`Updating worktree to ${resolvedCommit.toShort()}`);
544
- await git.worktrees.checkout(wt.worktreePath, resolvedCommit);
545
- 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
+ };
546
794
  }
795
+ this._logger.log(`Updating worktree to ${resolvedCommit.toShort()}`);
796
+ await git.worktrees.checkout(slot.worktreePath, resolvedCommit);
547
797
  }
548
798
  else {
549
799
  this._logger.log(`Worktree already at ${resolvedCommit.toShort()}`);
550
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}` };
551
817
  }
552
- this._logger.log(`Starting server: ${sessionConfig.name}`);
553
- // Set daemon config env var so the Vite plugin enables the daemon proxy
554
- process.env.COMPONENT_EXPLORER_DAEMON_CONFIG = JSON.stringify({
555
- pipeName: this._pipeName,
556
- sessionName: sessionConfig.name,
557
- });
558
- const session = await ExplorerSession.create(sessionConfig.name, sessionConfig.viteProject, this._serverFactory, this._browserFactory, {
559
- logger: this._logger,
560
- resolveViteFrom: sessionConfig.source.kind === 'worktree' ? resolveViteFrom : undefined,
561
- hmrAllowedPaths: this._config.viteHmr?.allowedPaths,
562
- });
563
- this._sessions.set(sessionConfig.name, session);
564
- this._logger.log(`Server ready: ${sessionConfig.name} (${session.serverUrl})`);
565
818
  }
566
- }
567
- // ---------------------------------------------------------------------------
568
- // EventStreamLogger — wraps a base logger and also emits log events
569
- // ---------------------------------------------------------------------------
570
- class EventStreamLogger {
571
- _base;
572
- _emitter;
573
- constructor(_base) {
574
- 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() };
575
850
  }
576
- log(message) { this.info(message); }
577
- info(message) {
578
- this._base.info(message);
579
- this._emitter?.({ type: 'log', level: 'info', message });
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
+ }
919
+ }
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);
580
932
  }
581
- debug(message) {
582
- this._base.debug(message);
583
- this._emitter?.({ type: 'log', level: 'debug', message });
933
+ }
934
+ function arraysEqual(a, b) {
935
+ if (a.length !== b.length) {
936
+ return false;
584
937
  }
585
- trace(message) {
586
- this._base.trace(message);
587
- 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
+ }
588
942
  }
943
+ return true;
589
944
  }
590
945
 
591
- export { ActivityTracker, DaemonService, SourceTreeChangedError };
946
+ export { ActivityTracker, DaemonService, SourceTreeChangedError, daemonApiVersionText, pluginProtocolVersionText };
592
947
  //# sourceMappingURL=DaemonService.js.map