@vscode/component-explorer-cli 0.1.1-2 → 0.1.1-20

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 (169) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -0
  3. package/SECURITY.md +14 -0
  4. package/dist/WorktreePool.d.ts +22 -0
  5. package/dist/WorktreePool.d.ts.map +1 -0
  6. package/dist/WorktreePool.js +58 -0
  7. package/dist/WorktreePool.js.map +1 -0
  8. package/dist/WorktreePool.test.d.ts +2 -0
  9. package/dist/WorktreePool.test.d.ts.map +1 -0
  10. package/dist/_virtual/_build-info.js +4 -0
  11. package/dist/_virtual/_build-info.js.map +1 -0
  12. package/dist/browserPage.d.ts +5 -0
  13. package/dist/browserPage.d.ts.map +1 -1
  14. package/dist/browserPage.js +28 -2
  15. package/dist/browserPage.js.map +1 -1
  16. package/dist/commands/acceptCommand.d.ts +2 -1
  17. package/dist/commands/acceptCommand.d.ts.map +1 -1
  18. package/dist/commands/acceptCommand.js +15 -8
  19. package/dist/commands/acceptCommand.js.map +1 -1
  20. package/dist/commands/checkStabilityCommand.d.ts +12 -0
  21. package/dist/commands/checkStabilityCommand.d.ts.map +1 -0
  22. package/dist/commands/checkStabilityCommand.js +84 -0
  23. package/dist/commands/checkStabilityCommand.js.map +1 -0
  24. package/dist/commands/compareCommand.d.ts +5 -1
  25. package/dist/commands/compareCommand.d.ts.map +1 -1
  26. package/dist/commands/compareCommand.js +56 -71
  27. package/dist/commands/compareCommand.js.map +1 -1
  28. package/dist/commands/mcpCommand.d.ts +4 -1
  29. package/dist/commands/mcpCommand.d.ts.map +1 -1
  30. package/dist/commands/mcpCommand.js +51 -8
  31. package/dist/commands/mcpCommand.js.map +1 -1
  32. package/dist/commands/screenshotCommand.d.ts +9 -2
  33. package/dist/commands/screenshotCommand.d.ts.map +1 -1
  34. package/dist/commands/screenshotCommand.js +73 -15
  35. package/dist/commands/screenshotCommand.js.map +1 -1
  36. package/dist/commands/serveCommand.d.ts +6 -1
  37. package/dist/commands/serveCommand.d.ts.map +1 -1
  38. package/dist/commands/serveCommand.js +104 -23
  39. package/dist/commands/serveCommand.js.map +1 -1
  40. package/dist/commands/watchCommand.d.ts +3 -2
  41. package/dist/commands/watchCommand.d.ts.map +1 -1
  42. package/dist/commands/watchCommand.js +28 -80
  43. package/dist/commands/watchCommand.js.map +1 -1
  44. package/dist/comparison.d.ts +70 -0
  45. package/dist/comparison.d.ts.map +1 -0
  46. package/dist/comparison.js +264 -0
  47. package/dist/comparison.js.map +1 -0
  48. package/dist/component-explorer-config.schema.json +222 -0
  49. package/dist/componentExplorer.d.ts +40 -2
  50. package/dist/componentExplorer.d.ts.map +1 -1
  51. package/dist/componentExplorer.js +118 -24
  52. package/dist/componentExplorer.js.map +1 -1
  53. package/dist/daemon/DaemonContext.d.ts +4 -0
  54. package/dist/daemon/DaemonContext.d.ts.map +1 -0
  55. package/dist/daemon/DaemonService.d.ts +146 -21
  56. package/dist/daemon/DaemonService.d.ts.map +1 -1
  57. package/dist/daemon/DaemonService.js +620 -123
  58. package/dist/daemon/DaemonService.js.map +1 -1
  59. package/dist/daemon/dynamicSessions.test.d.ts +2 -0
  60. package/dist/daemon/dynamicSessions.test.d.ts.map +1 -0
  61. package/dist/daemon/lifecycle.d.ts +8 -3
  62. package/dist/daemon/lifecycle.d.ts.map +1 -1
  63. package/dist/daemon/lifecycle.js +27 -10
  64. package/dist/daemon/lifecycle.js.map +1 -1
  65. package/dist/daemon/pipeClient.d.ts +6 -1
  66. package/dist/daemon/pipeClient.d.ts.map +1 -1
  67. package/dist/daemon/pipeClient.js +101 -8
  68. package/dist/daemon/pipeClient.js.map +1 -1
  69. package/dist/daemon/pipeServer.d.ts +2 -1
  70. package/dist/daemon/pipeServer.d.ts.map +1 -1
  71. package/dist/daemon/pipeServer.js +56 -6
  72. package/dist/daemon/pipeServer.js.map +1 -1
  73. package/dist/daemon/types.d.ts +12 -0
  74. package/dist/daemon/types.d.ts.map +1 -0
  75. package/dist/daemon/version.d.ts +10 -0
  76. package/dist/daemon/version.d.ts.map +1 -0
  77. package/dist/daemon/version.js +17 -0
  78. package/dist/daemon/version.js.map +1 -0
  79. package/dist/dependencyInstaller.d.ts +2 -2
  80. package/dist/dependencyInstaller.d.ts.map +1 -1
  81. package/dist/dependencyInstaller.js +1 -1
  82. package/dist/dependencyInstaller.js.map +1 -1
  83. package/dist/external/vscode-observables/observables/dist/disposables.js +24 -1
  84. package/dist/external/vscode-observables/observables/dist/disposables.js.map +1 -1
  85. package/dist/external/vscode-observables/observables/dist/observableInternal/commonFacade/deps.js +1 -4
  86. package/dist/external/vscode-observables/observables/dist/observableInternal/commonFacade/deps.js.map +1 -1
  87. package/dist/external/vscode-observables/observables/dist/observableInternal/index.js +2 -5
  88. package/dist/external/vscode-observables/observables/dist/observableInternal/index.js.map +1 -1
  89. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/consoleObservableLogger.js +30 -6
  90. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/consoleObservableLogger.js.map +1 -1
  91. package/dist/external/vscode-observables/observables/dist/observableInternal/observables/baseObservable.js +1 -1
  92. package/dist/external/vscode-observables/observables/dist/observableInternal/observables/baseObservable.js.map +1 -1
  93. package/dist/external/vscode-observables/observables/dist/observableInternal/observables/derived.js +12 -1
  94. package/dist/external/vscode-observables/observables/dist/observableInternal/observables/derived.js.map +1 -1
  95. package/dist/external/vscode-observables/observables/dist/observableInternal/utils/utilsCancellation.js +55 -0
  96. package/dist/external/vscode-observables/observables/dist/observableInternal/utils/utilsCancellation.js.map +1 -0
  97. package/dist/formatValue.d.ts +2 -0
  98. package/dist/formatValue.d.ts.map +1 -0
  99. package/dist/formatValue.js +96 -0
  100. package/dist/formatValue.js.map +1 -0
  101. package/dist/formatValue.test.d.ts +2 -0
  102. package/dist/formatValue.test.d.ts.map +1 -0
  103. package/dist/git/gitIndexResolver.d.ts +25 -0
  104. package/dist/git/gitIndexResolver.d.ts.map +1 -0
  105. package/dist/git/gitIndexResolver.js +91 -0
  106. package/dist/git/gitIndexResolver.js.map +1 -0
  107. package/dist/git/gitIndexResolver.test.d.ts +2 -0
  108. package/dist/git/gitIndexResolver.test.d.ts.map +1 -0
  109. package/dist/git/gitService.d.ts +2 -0
  110. package/dist/git/gitService.d.ts.map +1 -1
  111. package/dist/git/gitService.js +6 -0
  112. package/dist/git/gitService.js.map +1 -1
  113. package/dist/git/gitUtils.js +1 -1
  114. package/dist/git/gitUtils.js.map +1 -1
  115. package/dist/git/gitWorktreeManager.d.ts +6 -0
  116. package/dist/git/gitWorktreeManager.d.ts.map +1 -1
  117. package/dist/git/gitWorktreeManager.js +42 -13
  118. package/dist/git/gitWorktreeManager.js.map +1 -1
  119. package/dist/git/gitWorktreeManager.test.d.ts +2 -0
  120. package/dist/git/gitWorktreeManager.test.d.ts.map +1 -0
  121. package/dist/git/testUtils.d.ts +13 -0
  122. package/dist/git/testUtils.d.ts.map +1 -0
  123. package/dist/httpServer.d.ts +6 -1
  124. package/dist/httpServer.d.ts.map +1 -1
  125. package/dist/httpServer.js +30 -10
  126. package/dist/httpServer.js.map +1 -1
  127. package/dist/index.js +11 -2
  128. package/dist/index.js.map +1 -1
  129. package/dist/logger.d.ts +1 -0
  130. package/dist/logger.d.ts.map +1 -1
  131. package/dist/logger.js +7 -1
  132. package/dist/logger.js.map +1 -1
  133. package/dist/mcp/McpServer.d.ts +47 -4
  134. package/dist/mcp/McpServer.d.ts.map +1 -1
  135. package/dist/mcp/McpServer.js +913 -155
  136. package/dist/mcp/McpServer.js.map +1 -1
  137. package/dist/mcp/TaskManager.d.ts +28 -0
  138. package/dist/mcp/TaskManager.d.ts.map +1 -0
  139. package/dist/mcp/TaskManager.js +54 -0
  140. package/dist/mcp/TaskManager.js.map +1 -0
  141. package/dist/packages/simple-api/dist/chunk-3R7GHWBM.js +137 -0
  142. package/dist/packages/simple-api/dist/chunk-3R7GHWBM.js.map +1 -0
  143. package/dist/packages/simple-api/dist/chunk-SGBCNXYH.js +24 -0
  144. package/dist/packages/simple-api/dist/chunk-SGBCNXYH.js.map +1 -0
  145. package/dist/packages/simple-api/dist/chunk-TAEFVNPN.js +27 -0
  146. package/dist/packages/simple-api/dist/chunk-TAEFVNPN.js.map +1 -0
  147. package/dist/packages/simple-api/dist/express.js +104 -0
  148. package/dist/packages/simple-api/dist/express.js.map +1 -0
  149. package/dist/resolveProject.d.ts +21 -0
  150. package/dist/resolveProject.d.ts.map +1 -0
  151. package/dist/resolveProject.js +39 -0
  152. package/dist/resolveProject.js.map +1 -0
  153. package/dist/utils.d.ts +31 -0
  154. package/dist/utils.d.ts.map +1 -0
  155. package/dist/utils.js +49 -0
  156. package/dist/utils.js.map +1 -0
  157. package/dist/watchConfig.d.ts +52 -9
  158. package/dist/watchConfig.d.ts.map +1 -1
  159. package/dist/watchConfig.js +67 -62
  160. package/dist/watchConfig.js.map +1 -1
  161. package/package.json +31 -8
  162. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/debuggerRpc.js +0 -72
  163. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/debuggerRpc.js.map +0 -1
  164. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/devToolsLogger.js +0 -447
  165. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/devToolsLogger.js.map +0 -1
  166. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/rpc.js +0 -64
  167. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/rpc.js.map +0 -1
  168. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/utils.js +0 -52
  169. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/utils.js.map +0 -1
@@ -1,19 +1,86 @@
1
1
  import { z } from 'zod';
2
+ import * as path from 'node:path';
2
3
  import '../external/vscode-observables/observables/dist/observableInternal/index.js';
4
+ import { observableValue } from '../external/vscode-observables/observables/dist/observableInternal/observables/observableValue.js';
3
5
  import '../external/vscode-observables/observables/dist/observableInternal/debugLocation.js';
4
6
  import { autorun } from '../external/vscode-observables/observables/dist/observableInternal/reactions/autorun.js';
5
- import '../external/vscode-observables/observables/dist/observableInternal/observables/derived.js';
7
+ import { derived } from '../external/vscode-observables/observables/dist/observableInternal/observables/derived.js';
8
+ import { waitForState } from '../external/vscode-observables/observables/dist/observableInternal/utils/utilsCancellation.js';
6
9
  import '../external/vscode-observables/observables/dist/observableInternal/utils/utils.js';
7
10
  import '../external/vscode-observables/observables/dist/observableInternal/observables/observableFromEvent.js';
8
- import { createApiFactory } from '@hediet/simple-api/server';
9
- import { AsyncStream } from '@hediet/simple-api';
11
+ import { createApiFactory } from '../packages/simple-api/dist/chunk-3R7GHWBM.js';
12
+ import { AsyncStream } from '../packages/simple-api/dist/chunk-SGBCNXYH.js';
10
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';
11
17
  import { PlaywrightBrowserPageFactory } from '../browserPage.js';
12
18
  import { DefaultComponentExplorerHttpServerFactory } from '../httpServer.js';
13
19
  import { installDependencies } from '../dependencyInstaller.js';
14
20
  import { FileApprovalStore } from './approvalStore.js';
15
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';
16
26
 
27
+ // ---------------------------------------------------------------------------
28
+ // ActivityTracker — monitors idle time and triggers shutdown
29
+ // ---------------------------------------------------------------------------
30
+ class ActivityTracker {
31
+ _idleTimeoutMs;
32
+ _onIdle;
33
+ _logger;
34
+ _lastActivityTime = Date.now();
35
+ _idleCheckInterval;
36
+ _autorunDisposable;
37
+ _disposed = false;
38
+ _started = false;
39
+ _activeCount = 0;
40
+ constructor(_idleTimeoutMs, _onIdle, _logger) {
41
+ this._idleTimeoutMs = _idleTimeoutMs;
42
+ this._onIdle = _onIdle;
43
+ this._logger = _logger;
44
+ }
45
+ setActive(value) {
46
+ if (value) {
47
+ this._activeCount++;
48
+ this._lastActivityTime = Date.now();
49
+ }
50
+ else {
51
+ this._activeCount = Math.max(0, this._activeCount - 1);
52
+ }
53
+ }
54
+ reportActivity() {
55
+ this._lastActivityTime = Date.now();
56
+ }
57
+ start() {
58
+ if (this._started || this._disposed || this._idleTimeoutMs <= 0) {
59
+ return;
60
+ }
61
+ this._started = true;
62
+ const checkIntervalMs = Math.min(30_000, this._idleTimeoutMs / 2);
63
+ this._idleCheckInterval = setInterval(() => {
64
+ if (this._activeCount > 0) {
65
+ this._lastActivityTime = Date.now();
66
+ return;
67
+ }
68
+ const idleMs = Date.now() - this._lastActivityTime;
69
+ if (idleMs >= this._idleTimeoutMs) {
70
+ this._logger.log(`Idle for ${Math.round(idleMs / 1000)}s, shutting down`);
71
+ this._onIdle();
72
+ }
73
+ }, checkIntervalMs);
74
+ }
75
+ dispose() {
76
+ this._disposed = true;
77
+ this._autorunDisposable?.dispose();
78
+ if (this._idleCheckInterval) {
79
+ clearInterval(this._idleCheckInterval);
80
+ this._idleCheckInterval = undefined;
81
+ }
82
+ }
83
+ }
17
84
  class SourceTreeChangedError extends Error {
18
85
  constructor(sessionName) {
19
86
  super(`Source tree changed for session "${sessionName}" — retry with latest sourceTreeId`);
@@ -25,38 +92,44 @@ class SourceTreeChangedError extends Error {
25
92
  // ---------------------------------------------------------------------------
26
93
  class DaemonService {
27
94
  _config;
95
+ _pipeName;
28
96
  _logger;
29
97
  _browserFactory;
30
98
  _serverFactory;
31
99
  _sessions = new Map();
32
100
  _sessionConfigs = new Map();
101
+ _dynamicSessionMeta = new Map();
33
102
  _resolvers = new Map();
103
+ _eventListenerCount = observableValue(this, 0);
34
104
  _eventListeners = new Set();
35
105
  _shutdownRequested = false;
36
106
  _shutdownResolvers = [];
107
+ _activityTracker;
108
+ _worktreePool;
37
109
  approvals;
38
110
  api;
39
- constructor(_config, _logger, _browserFactory, _serverFactory, approvalStorePath) {
111
+ constructor(_config, _pipeName, _logger, _browserFactory, _serverFactory, approvalStorePath, idleTimeoutMs) {
40
112
  this._config = _config;
113
+ this._pipeName = _pipeName;
41
114
  this._logger = _logger;
42
115
  this._browserFactory = _browserFactory;
43
116
  this._serverFactory = _serverFactory;
44
117
  this.approvals = new FileApprovalStore(approvalStorePath);
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
+ }
45
122
  this.api = this._buildApi();
46
123
  }
47
- static async create(config, logger) {
48
- const browserFactory = new PlaywrightBrowserPageFactory();
124
+ static async create(config, logger, pipeName, options) {
125
+ const browserFactory = new PlaywrightBrowserPageFactory(false, 30_000, logger);
49
126
  const serverFactory = new DefaultComponentExplorerHttpServerFactory();
50
127
  const approvalStorePath = `${config.screenshotDir}/approvals.json`;
51
- // Wrap the logger so messages also appear on the event stream
52
- const eventStreamLogger = new EventStreamLogger(logger);
53
- const svc = new DaemonService(config, eventStreamLogger, browserFactory, serverFactory, approvalStorePath);
54
- eventStreamLogger._emitter = (event) => svc._emit(event);
55
- const currentSession = config.sessions.find(s => s.source.kind === 'current');
56
- const resolveViteFrom = currentSession?.viteProject.cwd;
128
+ const idleTimeoutMs = options?.idleTimeoutMs ?? 5 * 60 * 1000; // 5 minutes default
129
+ const svc = new DaemonService(config, pipeName ?? '', logger, browserFactory, serverFactory, approvalStorePath, idleTimeoutMs);
57
130
  for (const sessionConfig of config.sessions) {
58
131
  svc._sessionConfigs.set(sessionConfig.name, sessionConfig);
59
- await svc._setupSession(sessionConfig, config.repo, resolveViteFrom);
132
+ await svc._setupSession(sessionConfig);
60
133
  }
61
134
  return svc;
62
135
  }
@@ -71,6 +144,7 @@ class DaemonService {
71
144
  sourceTreeId: z.string(),
72
145
  },
73
146
  }, async (args) => {
147
+ this._activityTracker.reportActivity();
74
148
  this.assertSourceTreeId(args.sessionName, args.sourceTreeId);
75
149
  const session = this.getSession(args.sessionName);
76
150
  const result = await session.explorer.listFixtures();
@@ -85,18 +159,50 @@ class DaemonService {
85
159
  sourceTreeId: z.string(),
86
160
  sessionName: z.string(),
87
161
  includeImage: z.boolean().optional(),
162
+ stabilityCheck: z.boolean().optional(),
88
163
  },
89
164
  }, async (args) => {
165
+ this._activityTracker.reportActivity();
90
166
  const session = this.getSession(args.sessionName);
91
167
  this.assertSourceTreeId(args.sessionName, args.sourceTreeId);
92
- const png = await session.explorer.screenshotFixture(args.fixtureId);
168
+ if (args.stabilityCheck) {
169
+ const result = await session.explorer.screenshotFixtureStabilityCheck(args.fixtureId);
170
+ this.assertSourceTreeId(args.sessionName, args.sourceTreeId);
171
+ const hashes = result.screenshots.map(s => contentHash(s.image));
172
+ const isStable = hashes[0] === hashes[1] && hashes[1] === hashes[2];
173
+ const stabilityScreenshots = result.screenshots.map((s, i) => ({
174
+ hash: hashes[i],
175
+ delayMs: s.delayMs,
176
+ image: (args.includeImage ?? true)
177
+ ? Buffer.from(s.image).toString('base64')
178
+ : undefined,
179
+ }));
180
+ return {
181
+ hash: hashes[hashes.length - 1],
182
+ sourceTreeId: args.sourceTreeId,
183
+ image: (args.includeImage ?? true)
184
+ ? Buffer.from(result.screenshots[result.screenshots.length - 1].image).toString('base64')
185
+ : undefined,
186
+ hasError: result.hasError,
187
+ error: result.error,
188
+ events: result.events.length > 0 ? result.events : undefined,
189
+ resultData: result.resultData,
190
+ isStable,
191
+ stabilityScreenshots,
192
+ };
193
+ }
194
+ const result = await session.explorer.screenshotFixture(args.fixtureId);
93
195
  this.assertSourceTreeId(args.sessionName, args.sourceTreeId);
94
196
  return {
95
- hash: contentHash(png),
197
+ hash: contentHash(result.image),
96
198
  sourceTreeId: args.sourceTreeId,
97
199
  image: (args.includeImage ?? true)
98
- ? Buffer.from(png).toString('base64')
200
+ ? Buffer.from(result.image).toString('base64')
99
201
  : undefined,
202
+ hasError: result.hasError,
203
+ error: result.error,
204
+ events: result.events?.length > 0 ? result.events : undefined,
205
+ resultData: result.resultData,
100
206
  };
101
207
  }),
102
208
  takeBatch: createMethod({
@@ -107,21 +213,29 @@ class DaemonService {
107
213
  includeImages: z.boolean().optional(),
108
214
  },
109
215
  }, async (args) => {
216
+ this._activityTracker.reportActivity();
110
217
  const session = this.getSession(args.sessionName);
111
218
  this.assertSourceTreeId(args.sessionName, args.sourceTreeId);
112
219
  const includeImages = args.includeImages ?? false;
220
+ this._logger.trace(`takeBatch: ${args.fixtureIds.length} fixtures, session=${args.sessionName}`);
221
+ const startTime = Date.now();
113
222
  const screenshots = [];
114
223
  for (const fixtureId of args.fixtureIds) {
115
- const png = await session.explorer.screenshotFixture(fixtureId);
224
+ const result = await session.explorer.screenshotFixture(fixtureId);
116
225
  this.assertSourceTreeId(args.sessionName, args.sourceTreeId);
117
226
  screenshots.push({
118
227
  fixtureId,
119
- hash: contentHash(png),
228
+ hash: contentHash(result.image),
120
229
  image: includeImages
121
- ? Buffer.from(png).toString('base64')
230
+ ? Buffer.from(result.image).toString('base64')
122
231
  : undefined,
232
+ hasError: result.hasError,
233
+ error: result.error,
234
+ events: result.events.length > 0 ? result.events : undefined,
235
+ resultData: result.resultData,
123
236
  });
124
237
  }
238
+ this._logger.trace(`takeBatch: done in ${Date.now() - startTime}ms`);
125
239
  return { sourceTreeId: args.sourceTreeId, screenshots };
126
240
  }),
127
241
  compare: createMethod({
@@ -134,25 +248,46 @@ class DaemonService {
134
248
  includeImages: z.boolean().optional(),
135
249
  },
136
250
  }, async (args) => {
251
+ this._activityTracker.reportActivity();
137
252
  const baselineSession = this.getSession(args.baselineSessionName);
138
253
  const currentSession = this.getSession(args.currentSessionName);
139
254
  this.assertSourceTreeId(args.baselineSessionName, args.baselineSourceTreeId);
140
255
  this.assertSourceTreeId(args.currentSessionName, args.currentSourceTreeId);
141
- const baselinePng = await baselineSession.explorer.screenshotFixture(args.fixtureId);
256
+ const [baselineResult, baselineFixtures] = await Promise.all([
257
+ baselineSession.explorer.screenshotFixture(args.fixtureId),
258
+ baselineSession.explorer.listFixtures(),
259
+ ]);
142
260
  this.assertSourceTreeId(args.baselineSessionName, args.baselineSourceTreeId);
143
- const currentPng = await currentSession.explorer.screenshotFixture(args.fixtureId);
261
+ const [currentResult, currentFixtures] = await Promise.all([
262
+ currentSession.explorer.screenshotFixture(args.fixtureId),
263
+ currentSession.explorer.listFixtures(),
264
+ ]);
144
265
  this.assertSourceTreeId(args.currentSessionName, args.currentSourceTreeId);
145
- const baselineHash = contentHash(baselinePng);
146
- const currentHash = contentHash(currentPng);
266
+ const baselineHash = contentHash(baselineResult.image);
267
+ const currentHash = contentHash(currentResult.image);
147
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 ?? []);
148
273
  return {
149
274
  match: baselineHash === currentHash,
150
275
  baselineHash,
151
276
  currentHash,
152
277
  baselineImage: includeImages
153
- ? Buffer.from(baselinePng).toString('base64') : undefined,
278
+ ? Buffer.from(baselineResult.image).toString('base64') : undefined,
154
279
  currentImage: includeImages
155
- ? Buffer.from(currentPng).toString('base64') : undefined,
280
+ ? Buffer.from(currentResult.image).toString('base64') : 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,
156
291
  approval: (baselineHash !== currentHash)
157
292
  ? this.approvals.lookup({
158
293
  fixtureId: args.fixtureId,
@@ -172,6 +307,7 @@ class DaemonService {
172
307
  comment: z.string(),
173
308
  },
174
309
  }, async (args) => {
310
+ this._activityTracker.reportActivity();
175
311
  this.approvals.approve(args);
176
312
  }),
177
313
  revoke: createMethod({
@@ -181,11 +317,13 @@ class DaemonService {
181
317
  modifiedHash: z.string(),
182
318
  },
183
319
  }, async (args) => {
320
+ this._activityTracker.reportActivity();
184
321
  this.approvals.revoke(args);
185
322
  }),
186
323
  list: createMethod({
187
324
  args: { fixtureId: z.string().optional() },
188
325
  }, async (args) => {
326
+ this._activityTracker.reportActivity();
189
327
  return this.approvals.list(args.fixtureId);
190
328
  }),
191
329
  lookup: createMethod({
@@ -195,16 +333,77 @@ class DaemonService {
195
333
  modifiedHash: z.string(),
196
334
  },
197
335
  }, async (args) => {
336
+ this._activityTracker.reportActivity();
198
337
  return this.approvals.lookup(args) ?? null;
199
338
  }),
200
339
  }),
340
+ evaluate: createMethod({
341
+ args: {
342
+ sessionName: z.string(),
343
+ sourceTreeId: z.string(),
344
+ expression: z.string(),
345
+ fixtureId: z.string().optional(),
346
+ },
347
+ }, async (args) => {
348
+ this._activityTracker.reportActivity();
349
+ const session = this.getSession(args.sessionName);
350
+ this.assertSourceTreeId(args.sessionName, args.sourceTreeId);
351
+ const result = await session.explorer.evaluateJs(args.expression, args.fixtureId);
352
+ this.assertSourceTreeId(args.sessionName, args.sourceTreeId);
353
+ return { result };
354
+ }),
201
355
  events: createMethod({ args: {} }, async () => {
202
356
  return AsyncStream.fromIterable(this.eventStream());
203
357
  }),
204
- sessions: createMethod({ args: {} }, async () => {
358
+ sessions: createMethod({ args: {} }, async (_args, ctx) => {
359
+ this._activityTracker.reportActivity();
360
+ this._logger.trace(`API: sessions (client=${ctx.clientName}, eventListeners=${this._eventListeners.size})`);
361
+ return this.getSessionInfos();
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);
205
376
  return this.getSessionInfos();
206
377
  }),
207
- shutdown: createMethod({ args: {} }, async () => {
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})`);
208
407
  this.requestShutdown();
209
408
  }),
210
409
  });
@@ -217,16 +416,65 @@ class DaemonService {
217
416
  }
218
417
  return session;
219
418
  }
220
- getSessionInfos() {
221
- return this._config.sessions.map(sc => {
419
+ getSessionInfos(reader) {
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);
222
425
  const session = this._sessions.get(sc.name);
223
- return {
224
- name: sc.name,
225
- sourceKind: sc.source.kind,
226
- serverUrl: session.serverUrl,
227
- sourceTreeId: session.sourceTreeId.get().value,
228
- };
426
+ const meta = this._dynamicSessionMeta.get(sc.name);
427
+ if (!session) {
428
+ infos.push({ name: sc.name, sourceKind: sc.source.kind, isLoading: true });
429
+ }
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;
470
+ }
471
+ waitForSession(sessionName) {
472
+ const sessionObs = derived(this, reader => {
473
+ const infos = this.getSessionInfos(reader);
474
+ const session = sessionName ? infos.find(s => s.name === sessionName) : infos[0];
475
+ return session && !session.isLoading ? session : undefined;
229
476
  });
477
+ return waitForState(sessionObs);
230
478
  }
231
479
  getSourceTreeId(sessionName) {
232
480
  return this.getSession(sessionName).sourceTreeId.get().value;
@@ -246,6 +494,9 @@ class DaemonService {
246
494
  let resolve;
247
495
  let done = false;
248
496
  const listener = (event) => {
497
+ if (event.type !== 'log') {
498
+ self._logger.trace(`Event stream: ${event.type}`);
499
+ }
249
500
  if (resolve) {
250
501
  const r = resolve;
251
502
  resolve = undefined;
@@ -256,8 +507,12 @@ class DaemonService {
256
507
  }
257
508
  };
258
509
  self._eventListeners.add(listener);
510
+ self._eventListenerCount.set(self._eventListeners.size, undefined);
511
+ self._activityTracker.setActive(true);
512
+ self._logger.debug(`Event stream opened (listeners: ${self._eventListeners.size})`);
259
513
  const onShutdown = () => {
260
514
  done = true;
515
+ cleanup();
261
516
  if (resolve) {
262
517
  const r = resolve;
263
518
  resolve = undefined;
@@ -265,6 +520,12 @@ class DaemonService {
265
520
  }
266
521
  };
267
522
  self._shutdownResolvers.push(onShutdown);
523
+ const cleanup = () => {
524
+ self._eventListeners.delete(listener);
525
+ self._eventListenerCount.set(self._eventListeners.size, undefined);
526
+ self._activityTracker.setActive(false);
527
+ self._logger.debug(`Event stream closed (listeners: ${self._eventListeners.size})`);
528
+ };
268
529
  return {
269
530
  next() {
270
531
  if (done) {
@@ -277,7 +538,12 @@ class DaemonService {
277
538
  },
278
539
  return() {
279
540
  done = true;
280
- self._eventListeners.delete(listener);
541
+ cleanup();
542
+ if (resolve) {
543
+ const r = resolve;
544
+ resolve = undefined;
545
+ r({ value: undefined, done: true });
546
+ }
281
547
  return Promise.resolve({ value: undefined, done: true });
282
548
  },
283
549
  };
@@ -287,6 +553,7 @@ class DaemonService {
287
553
  // -- Event loop ----------------------------------------------------------
288
554
  async runEventLoop() {
289
555
  this._startSourceChangeWatchers();
556
+ this._activityTracker.start();
290
557
  await new Promise(resolve => {
291
558
  if (this._shutdownRequested) {
292
559
  resolve();
@@ -298,64 +565,57 @@ class DaemonService {
298
565
  _sourceChangeDisposables = [];
299
566
  _startSourceChangeWatchers() {
300
567
  for (const [name, session] of this._sessions) {
301
- let previousValue = session.sourceTreeId.get().value;
302
- const disposable = autorun(reader => {
303
- const current = session.sourceTreeId.read(reader);
304
- if (current.value !== previousValue) {
305
- previousValue = current.value;
306
- this._emit({ type: 'source-change', sessionName: name, sourceTreeId: current.value });
307
- }
308
- });
309
- this._sourceChangeDisposables.push(disposable);
310
- }
311
- // Watch for ref changes (worktree sessions)
312
- for (const [name, resolver] of this._resolvers) {
313
- let previousCommit = resolver.resolvedCommit.get();
314
- const disposable = autorun(reader => {
315
- const commit = resolver.resolvedCommit.read(reader);
316
- if (!previousCommit.equals(commit)) {
317
- previousCommit = commit;
318
- this._handleRefChange(name, resolver.ref, commit);
319
- }
320
- });
321
- this._sourceChangeDisposables.push(disposable);
568
+ this._addSourceChangeWatcher(name, session);
322
569
  }
323
570
  }
324
- async _handleRefChange(sessionName, ref, newCommit) {
325
- this._logger.log(`Ref ${ref} moved to ${newCommit.toShort()}`);
326
- const git = this._config.repo;
327
- const sessionConfig = this._sessionConfigs.get(sessionName);
328
- 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) {
329
588
  return;
330
589
  }
331
- const wt = sessionConfig.source.worktree;
332
- const wtInfo = await git.worktrees.info(wt.worktreePath);
590
+ const git = this._config.repo;
591
+ const wtInfo = await git.worktrees.info(meta.worktreePath);
333
592
  if (wtInfo && wtInfo.isDirty) {
334
593
  this._logger.log(`Worktree is dirty, skipping update to ${newCommit.toShort()}`);
335
594
  return;
336
595
  }
337
- // Dispose old session
338
- const oldSession = this._sessions.get(sessionName);
339
- await oldSession?.dispose();
340
- // Checkout + reinstall
341
596
  if (wtInfo) {
342
- await git.worktrees.checkout(wt.worktreePath, newCommit);
597
+ await git.worktrees.checkout(meta.worktreePath, newCommit);
343
598
  }
344
599
  else {
345
- await git.worktrees.create(wt.worktreePath, newCommit);
600
+ await git.worktrees.create(meta.worktreePath, newCommit);
346
601
  }
347
- await installDependencies(wt.worktreePath, wt.install, this._logger);
348
- // Recreate session
349
- const currentSession = this._config.sessions.find(s => s.source.kind === 'current');
350
- const resolveViteFrom = currentSession?.viteProject.cwd;
351
- const newSession = await ExplorerSession.create(sessionConfig.name, sessionConfig.viteProject, this._serverFactory, this._browserFactory, {
352
- logger: this._logger,
353
- resolveViteFrom,
354
- hmrAllowedPaths: this._config.viteHmr?.allowedPaths,
355
- });
356
- 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);
357
607
  this._emit({ type: 'ref-change', sessionName, newCommit: newCommit.toShort() });
358
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
+ }
359
619
  // -- Shutdown ------------------------------------------------------------
360
620
  requestShutdown() {
361
621
  this._shutdownRequested = true;
@@ -366,10 +626,15 @@ class DaemonService {
366
626
  }
367
627
  async dispose() {
368
628
  this.requestShutdown();
629
+ this._activityTracker.dispose();
369
630
  for (const d of this._sourceChangeDisposables) {
370
631
  d.dispose();
371
632
  }
372
633
  this._sourceChangeDisposables = [];
634
+ for (const d of this._dynamicRefWatchers.values()) {
635
+ d.dispose();
636
+ }
637
+ this._dynamicRefWatchers.clear();
373
638
  for (const session of this._sessions.values()) {
374
639
  await session.dispose();
375
640
  }
@@ -380,71 +645,303 @@ class DaemonService {
380
645
  await this._browserFactory.dispose();
381
646
  }
382
647
  // -- Private helpers -----------------------------------------------------
383
- /** @internal — also called by EventStreamLogger */
384
648
  _emit(event) {
385
649
  for (const listener of this._eventListeners) {
386
650
  listener(event);
387
651
  }
388
652
  }
389
- 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}`);
390
712
  if (sessionConfig.source.kind === 'worktree') {
391
- const wt = sessionConfig.source.worktree;
392
- const resolver = await git.createCommitResolver(wt.ref);
393
- 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);
394
779
  const resolvedCommit = resolver.resolvedCommit.get();
395
- const wtInfo = await git.worktrees.info(wt.worktreePath);
780
+ const wtInfo = await git.worktrees.info(slot.worktreePath);
396
781
  if (!wtInfo) {
397
- this._logger.log(`Creating worktree at ${wt.worktreePath} (${wt.ref} @ ${resolvedCommit.toShort()})`);
398
- await git.worktrees.create(wt.worktreePath, resolvedCommit);
399
- 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);
400
784
  }
401
785
  else if (!wtInfo.checkedOutCommit.equals(resolvedCommit)) {
402
786
  if (wtInfo.isDirty) {
403
- this._logger.log(`Worktree is dirty, using existing checkout at ${wtInfo.checkedOutCommit.toShort()}`);
404
- }
405
- else {
406
- this._logger.log(`Updating worktree to ${resolvedCommit.toShort()}`);
407
- await git.worktrees.checkout(wt.worktreePath, resolvedCommit);
408
- 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
+ };
409
794
  }
795
+ this._logger.log(`Updating worktree to ${resolvedCommit.toShort()}`);
796
+ await git.worktrees.checkout(slot.worktreePath, resolvedCommit);
410
797
  }
411
798
  else {
412
799
  this._logger.log(`Worktree already at ${resolvedCommit.toShort()}`);
413
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}` };
414
817
  }
415
- this._logger.log(`Starting server: ${sessionConfig.name}`);
416
- const session = await ExplorerSession.create(sessionConfig.name, sessionConfig.viteProject, this._serverFactory, this._browserFactory, {
417
- logger: this._logger,
418
- resolveViteFrom: sessionConfig.source.kind === 'worktree' ? resolveViteFrom : undefined,
419
- hmrAllowedPaths: this._config.viteHmr?.allowedPaths,
420
- });
421
- this._sessions.set(sessionConfig.name, session);
422
- this._logger.log(`Server ready: ${sessionConfig.name} (${session.serverUrl})`);
423
818
  }
424
- }
425
- // ---------------------------------------------------------------------------
426
- // EventStreamLogger — wraps a base logger and also emits log events
427
- // ---------------------------------------------------------------------------
428
- class EventStreamLogger {
429
- _base;
430
- _emitter;
431
- constructor(_base) {
432
- 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() };
433
850
  }
434
- log(message) { this.info(message); }
435
- info(message) {
436
- this._base.info(message);
437
- 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
+ }
438
919
  }
439
- debug(message) {
440
- this._base.debug(message);
441
- this._emitter?.({ type: 'log', level: 'debug', 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);
932
+ }
933
+ }
934
+ function arraysEqual(a, b) {
935
+ if (a.length !== b.length) {
936
+ return false;
442
937
  }
443
- trace(message) {
444
- this._base.trace(message);
445
- 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
+ }
446
942
  }
943
+ return true;
447
944
  }
448
945
 
449
- export { DaemonService, SourceTreeChangedError };
946
+ export { ActivityTracker, DaemonService, SourceTreeChangedError, daemonApiVersionText, pluginProtocolVersionText };
450
947
  //# sourceMappingURL=DaemonService.js.map