stashes 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -39,6 +39,10 @@ var STASHES_PORT = 4000;
39
39
  var DEFAULT_STASH_COUNT = 3;
40
40
  var APP_PROXY_PORT = STASHES_PORT + 1;
41
41
  var STASH_PORT_START = 4010;
42
+ var STASH_PORT_END = 4030;
43
+ var MAX_PREVIEW_SERVERS = 5;
44
+ var PREVIEW_TTL_MS = 300000;
45
+ var PREVIEW_REAPER_INTERVAL = 30000;
42
46
  var DEFAULT_DIRECTIVES = [
43
47
  "Minimal and clean \u2014 reduce visual noise, emphasize whitespace, limit to 2-3 colors, typography-driven hierarchy",
44
48
  "Bold and expressive \u2014 strong visual identity, use motion/animation, rich interactions, distinctive layout",
@@ -296,6 +300,34 @@ class WorktreeManager {
296
300
  getPreviewPort() {
297
301
  return PREVIEW_PORT;
298
302
  }
303
+ async createPreviewForPool(stashId) {
304
+ const previewPath = join3(this.projectPath, ".stashes", "previews", stashId);
305
+ const branch = `stashes/${stashId}`;
306
+ if (existsSync3(previewPath)) {
307
+ try {
308
+ await this.git.raw(["worktree", "remove", "--force", previewPath]);
309
+ } catch {
310
+ rmSync(previewPath, { recursive: true, force: true });
311
+ }
312
+ await this.git.raw(["worktree", "prune"]);
313
+ }
314
+ logger.info("worktree", `creating pool preview: ${stashId}`, { branch, path: previewPath });
315
+ await this.git.raw(["worktree", "add", previewPath, branch]);
316
+ this.symlinkDeps(previewPath);
317
+ logger.info("worktree", `pool preview created: ${stashId}`);
318
+ return previewPath;
319
+ }
320
+ async removePreviewForPool(stashId) {
321
+ const previewPath = join3(this.projectPath, ".stashes", "previews", stashId);
322
+ logger.info("worktree", `removing pool preview: ${stashId}`);
323
+ try {
324
+ await this.git.raw(["worktree", "remove", "--force", previewPath]);
325
+ } catch {
326
+ if (existsSync3(previewPath)) {
327
+ rmSync(previewPath, { recursive: true, force: true });
328
+ }
329
+ }
330
+ }
299
331
  async apply(stashId) {
300
332
  const branch = `stashes/${stashId}`;
301
333
  logger.info("worktree", `merging: ${stashId}`, { branch });
@@ -336,6 +368,10 @@ class WorktreeManager {
336
368
  if (existsSync3(previewDir)) {
337
369
  rmSync(previewDir, { recursive: true, force: true });
338
370
  }
371
+ const previewsDir = join3(this.projectPath, ".stashes", "previews");
372
+ if (existsSync3(previewsDir)) {
373
+ rmSync(previewsDir, { recursive: true, force: true });
374
+ }
339
375
  logger.info("worktree", `cleanup complete`);
340
376
  }
341
377
  symlinkDeps(worktreePath) {
@@ -919,19 +955,201 @@ async function cleanup(projectPath) {
919
955
  // ../server/dist/services/stash-service.js
920
956
  import { readFileSync as readFileSync4, existsSync as existsSync7 } from "fs";
921
957
  import { join as join7 } from "path";
958
+
959
+ // ../server/dist/services/preview-pool.js
960
+ class PreviewPool {
961
+ entries = new Map;
962
+ usedPorts = new Set;
963
+ maxSize;
964
+ ttlMs;
965
+ worktreeManager;
966
+ broadcast;
967
+ reaperInterval;
968
+ constructor(worktreeManager, broadcast, maxSize = MAX_PREVIEW_SERVERS, ttlMs = PREVIEW_TTL_MS) {
969
+ this.worktreeManager = worktreeManager;
970
+ this.broadcast = broadcast;
971
+ this.maxSize = maxSize;
972
+ this.ttlMs = ttlMs;
973
+ this.reaperInterval = setInterval(() => this.reap(), PREVIEW_REAPER_INTERVAL);
974
+ }
975
+ async getOrStart(stashId) {
976
+ const existing = this.entries.get(stashId);
977
+ if (existing) {
978
+ existing.lastHeartbeat = Date.now();
979
+ logger.info("pool", `warm hit: ${stashId} on port ${existing.port}`);
980
+ return existing.port;
981
+ }
982
+ if (this.entries.size >= this.maxSize) {
983
+ this.evictOldest();
984
+ }
985
+ const port = this.allocatePort();
986
+ const worktreePath = await this.worktreeManager.createPreviewForPool(stashId);
987
+ const process2 = Bun.spawn({
988
+ cmd: ["npm", "run", "dev"],
989
+ cwd: worktreePath,
990
+ stdin: "ignore",
991
+ stdout: "pipe",
992
+ stderr: "pipe",
993
+ env: { ...Bun.env, PORT: String(port), BROWSER: "none" }
994
+ });
995
+ const entry = {
996
+ stashId,
997
+ port,
998
+ process: process2,
999
+ worktreePath,
1000
+ lastHeartbeat: Date.now()
1001
+ };
1002
+ this.entries.set(stashId, entry);
1003
+ this.usedPorts.add(port);
1004
+ logger.info("pool", `cold start: ${stashId} on port ${port}`, { poolSize: this.entries.size });
1005
+ await this.waitForPort(port, 60000);
1006
+ return port;
1007
+ }
1008
+ heartbeat(stashId) {
1009
+ const entry = this.entries.get(stashId);
1010
+ if (entry) {
1011
+ entry.lastHeartbeat = Date.now();
1012
+ }
1013
+ }
1014
+ isWarm(stashId) {
1015
+ return this.entries.has(stashId);
1016
+ }
1017
+ getPort(stashId) {
1018
+ return this.entries.get(stashId)?.port ?? null;
1019
+ }
1020
+ async stop(stashId) {
1021
+ const entry = this.entries.get(stashId);
1022
+ if (!entry)
1023
+ return;
1024
+ logger.info("pool", `stopping: ${stashId} on port ${entry.port}`);
1025
+ this.killEntry(entry);
1026
+ this.entries.delete(stashId);
1027
+ this.usedPorts.delete(entry.port);
1028
+ try {
1029
+ await this.worktreeManager.removePreviewForPool(stashId);
1030
+ } catch (err) {
1031
+ logger.warn("pool", `worktree removal failed for ${stashId}`, {
1032
+ error: err instanceof Error ? err.message : String(err)
1033
+ });
1034
+ }
1035
+ }
1036
+ prefetchNeighbors(currentStashId, sortedStashIds) {
1037
+ const currentIndex = sortedStashIds.indexOf(currentStashId);
1038
+ if (currentIndex === -1 || sortedStashIds.length < 2)
1039
+ return;
1040
+ const neighbors = [];
1041
+ const prevIndex = (currentIndex - 1 + sortedStashIds.length) % sortedStashIds.length;
1042
+ const nextIndex = (currentIndex + 1) % sortedStashIds.length;
1043
+ if (!this.entries.has(sortedStashIds[prevIndex])) {
1044
+ neighbors.push(sortedStashIds[prevIndex]);
1045
+ }
1046
+ if (!this.entries.has(sortedStashIds[nextIndex])) {
1047
+ neighbors.push(sortedStashIds[nextIndex]);
1048
+ }
1049
+ for (const stashId of neighbors) {
1050
+ if (this.entries.size >= this.maxSize)
1051
+ break;
1052
+ logger.info("pool", `prefetching neighbor: ${stashId}`);
1053
+ this.getOrStart(stashId).then((port) => {
1054
+ this.broadcast({ type: "stash:port", stashId, port });
1055
+ }).catch((err) => {
1056
+ logger.warn("pool", `prefetch failed for ${stashId}`, {
1057
+ error: err instanceof Error ? err.message : String(err)
1058
+ });
1059
+ });
1060
+ }
1061
+ }
1062
+ async shutdown() {
1063
+ logger.info("pool", `shutting down all`, { poolSize: this.entries.size });
1064
+ clearInterval(this.reaperInterval);
1065
+ const stashIds = [...this.entries.keys()];
1066
+ for (const stashId of stashIds) {
1067
+ await this.stop(stashId);
1068
+ }
1069
+ }
1070
+ reap() {
1071
+ const now = Date.now();
1072
+ const expired = [];
1073
+ for (const [stashId, entry] of this.entries) {
1074
+ if (now - entry.lastHeartbeat > this.ttlMs) {
1075
+ expired.push(stashId);
1076
+ }
1077
+ }
1078
+ for (const stashId of expired) {
1079
+ logger.info("pool", `reaping inactive: ${stashId}`);
1080
+ const entry = this.entries.get(stashId);
1081
+ this.killEntry(entry);
1082
+ this.entries.delete(stashId);
1083
+ this.usedPorts.delete(entry.port);
1084
+ this.worktreeManager.removePreviewForPool(stashId).catch((err) => {
1085
+ logger.warn("pool", `reap worktree cleanup failed for ${stashId}`, {
1086
+ error: err instanceof Error ? err.message : String(err)
1087
+ });
1088
+ });
1089
+ this.broadcast({ type: "stash:preview_stopped", stashId });
1090
+ }
1091
+ }
1092
+ evictOldest() {
1093
+ let oldest = null;
1094
+ for (const entry of this.entries.values()) {
1095
+ if (!oldest || entry.lastHeartbeat < oldest.lastHeartbeat) {
1096
+ oldest = entry;
1097
+ }
1098
+ }
1099
+ if (oldest) {
1100
+ logger.info("pool", `evicting: ${oldest.stashId} (oldest heartbeat)`);
1101
+ this.killEntry(oldest);
1102
+ this.entries.delete(oldest.stashId);
1103
+ this.usedPorts.delete(oldest.port);
1104
+ this.worktreeManager.removePreviewForPool(oldest.stashId).catch((err) => {
1105
+ logger.warn("pool", `evict worktree cleanup failed for ${oldest.stashId}`, {
1106
+ error: err instanceof Error ? err.message : String(err)
1107
+ });
1108
+ });
1109
+ this.broadcast({ type: "stash:preview_stopped", stashId: oldest.stashId });
1110
+ }
1111
+ }
1112
+ allocatePort() {
1113
+ for (let port = STASH_PORT_START;port <= STASH_PORT_END; port++) {
1114
+ if (!this.usedPorts.has(port)) {
1115
+ return port;
1116
+ }
1117
+ }
1118
+ throw new Error(`No available ports in range ${STASH_PORT_START}-${STASH_PORT_END}`);
1119
+ }
1120
+ killEntry(entry) {
1121
+ try {
1122
+ entry.process.kill();
1123
+ } catch {}
1124
+ }
1125
+ async waitForPort(port, timeout) {
1126
+ const start = Date.now();
1127
+ while (Date.now() - start < timeout) {
1128
+ try {
1129
+ const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
1130
+ if (res.ok || res.status < 500)
1131
+ return;
1132
+ } catch {}
1133
+ await new Promise((r) => setTimeout(r, 1000));
1134
+ }
1135
+ throw new Error(`Port ${port} not ready within ${timeout}ms`);
1136
+ }
1137
+ }
1138
+
1139
+ // ../server/dist/services/stash-service.js
922
1140
  class StashService {
923
1141
  projectPath;
924
1142
  worktreeManager;
925
1143
  persistence;
926
1144
  broadcast;
1145
+ previewPool;
927
1146
  selectedComponent = null;
928
- previewServer = null;
929
- activePreviewStashId = null;
930
1147
  constructor(projectPath, worktreeManager, persistence, broadcast) {
931
1148
  this.projectPath = projectPath;
932
1149
  this.worktreeManager = worktreeManager;
933
1150
  this.persistence = persistence;
934
1151
  this.broadcast = broadcast;
1152
+ this.previewPool = new PreviewPool(worktreeManager, broadcast);
935
1153
  }
936
1154
  setSelectedComponent(component) {
937
1155
  this.selectedComponent = component;
@@ -1090,94 +1308,25 @@ ${refDescriptions.join(`
1090
1308
  onProgress: (event) => this.progressToBroadcast(event)
1091
1309
  });
1092
1310
  }
1093
- async switchPreview(stashId) {
1094
- const previewPort = this.worktreeManager.getPreviewPort();
1095
- let previewPath = this.worktreeManager.getPreviewPath();
1096
- if (!previewPath) {
1097
- previewPath = await this.worktreeManager.createPreview();
1098
- }
1099
- await this.ensurePreviewServer(previewPath);
1100
- if (this.activePreviewStashId === stashId) {
1101
- this.broadcast({ type: "stash:port", stashId, port: previewPort });
1102
- return;
1311
+ async switchPreview(stashId, sortedStashIds) {
1312
+ const port = await this.previewPool.getOrStart(stashId);
1313
+ this.broadcast({ type: "stash:port", stashId, port });
1314
+ if (sortedStashIds && sortedStashIds.length > 1) {
1315
+ this.previewPool.prefetchNeighbors(stashId, sortedStashIds);
1103
1316
  }
1104
- await this.worktreeManager.switchPreviewTo(stashId);
1105
- this.activePreviewStashId = stashId;
1106
- await this.waitForRecompile(previewPort, 15000);
1107
- this.broadcast({ type: "stash:port", stashId, port: previewPort });
1317
+ }
1318
+ previewHeartbeat(stashId) {
1319
+ this.previewPool.heartbeat(stashId);
1108
1320
  }
1109
1321
  async applyStash(stashId) {
1110
- this.stopPreviewServer();
1322
+ await this.previewPool.shutdown();
1111
1323
  await apply({ projectPath: this.projectPath, stashId });
1112
- this.activePreviewStashId = null;
1113
1324
  this.broadcast({ type: "stash:applied", stashId });
1114
1325
  }
1115
1326
  async deleteStash(stashId) {
1116
- if (this.activePreviewStashId === stashId) {
1117
- this.activePreviewStashId = null;
1118
- }
1327
+ await this.previewPool.stop(stashId);
1119
1328
  await remove(this.projectPath, stashId);
1120
1329
  }
1121
- async ensurePreviewServer(previewPath) {
1122
- if (this.previewServer) {
1123
- const port = this.worktreeManager.getPreviewPort();
1124
- try {
1125
- const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
1126
- if (res.ok || res.status < 500)
1127
- return;
1128
- } catch {
1129
- this.previewServer = null;
1130
- }
1131
- }
1132
- await this.startPreviewServer(previewPath);
1133
- }
1134
- async startPreviewServer(previewPath) {
1135
- const port = this.worktreeManager.getPreviewPort();
1136
- this.previewServer = Bun.spawn({
1137
- cmd: ["npm", "run", "dev"],
1138
- cwd: previewPath,
1139
- stdin: "ignore",
1140
- stdout: "pipe",
1141
- stderr: "pipe",
1142
- env: { ...process.env, PORT: String(port), BROWSER: "none" }
1143
- });
1144
- await this.waitForPort(port, 60000);
1145
- }
1146
- stopPreviewServer() {
1147
- if (this.previewServer) {
1148
- this.previewServer.kill();
1149
- this.previewServer = null;
1150
- }
1151
- }
1152
- async waitForRecompile(port, timeout) {
1153
- const start = Date.now();
1154
- await new Promise((r) => setTimeout(r, 500));
1155
- while (Date.now() - start < timeout) {
1156
- try {
1157
- const res = await fetch(`http://localhost:${port}`, {
1158
- signal: AbortSignal.timeout(3000),
1159
- headers: { "cache-control": "no-cache" }
1160
- });
1161
- if (res.ok) {
1162
- await new Promise((r) => setTimeout(r, 1000));
1163
- return;
1164
- }
1165
- } catch {}
1166
- await new Promise((r) => setTimeout(r, 500));
1167
- }
1168
- }
1169
- async waitForPort(port, timeout) {
1170
- const start = Date.now();
1171
- while (Date.now() - start < timeout) {
1172
- try {
1173
- const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
1174
- if (res.ok || res.status < 500)
1175
- return;
1176
- } catch {}
1177
- await new Promise((r) => setTimeout(r, 1000));
1178
- }
1179
- throw new Error(`Port ${port} not ready within ${timeout}ms`);
1180
- }
1181
1330
  }
1182
1331
 
1183
1332
  // ../server/dist/services/websocket.js
@@ -1245,7 +1394,10 @@ function createWebSocketHandler(projectPath, userDevPort, appProxyPort) {
1245
1394
  await stashService.vary(event.sourceStashId, event.prompt);
1246
1395
  break;
1247
1396
  case "interact":
1248
- await stashService.switchPreview(event.stashId);
1397
+ await stashService.switchPreview(event.stashId, event.sortedStashIds);
1398
+ break;
1399
+ case "preview_heartbeat":
1400
+ stashService.previewHeartbeat(event.stashId);
1249
1401
  break;
1250
1402
  case "apply_stash":
1251
1403
  await stashService.applyStash(event.stashId);