@openspecui/server 2.1.3 → 2.1.7

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 (2) hide show
  1. package/dist/index.mjs +64 -25
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -139,6 +139,19 @@ var DashboardOverviewService = class {
139
139
  }
140
140
  };
141
141
 
142
+ //#endregion
143
+ //#region ../core/src/dashboard-display.ts
144
+ const DASHBOARD_RECENT_LIST_LIMIT = 10;
145
+ function compareDashboardItemsByUpdatedAt(left, right) {
146
+ return right.updatedAt - left.updatedAt || left.id.localeCompare(right.id);
147
+ }
148
+ function sortDashboardItemsByUpdatedAt(items) {
149
+ return [...items].sort(compareDashboardItemsByUpdatedAt);
150
+ }
151
+ function selectRecentDashboardItems(items, limit = DASHBOARD_RECENT_LIST_LIMIT) {
152
+ return sortDashboardItemsByUpdatedAt(items).slice(0, Math.max(0, Math.trunc(limit)));
153
+ }
154
+
142
155
  //#endregion
143
156
  //#region src/dashboard-git-snapshot.ts
144
157
  const execFileAsync$1 = promisify(execFile);
@@ -165,6 +178,14 @@ async function defaultRunGit(cwd, args) {
165
178
  };
166
179
  }
167
180
  }
181
+ async function defaultReadPathTimestampMs(absolutePath) {
182
+ try {
183
+ const stats = await stat(absolutePath);
184
+ return Number.isFinite(stats.mtimeMs) && stats.mtimeMs > 0 ? stats.mtimeMs : null;
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
168
189
  function parseShortStat(output) {
169
190
  const files = Number(/(\d+)\s+files? changed/.exec(output)?.[1] ?? 0);
170
191
  const insertions = Number(/(\d+)\s+insertions?\(\+\)/.exec(output)?.[1] ?? 0);
@@ -274,17 +295,17 @@ async function resolveDefaultBranch(projectDir, runGit) {
274
295
  return "main";
275
296
  }
276
297
  async function collectCommitEntries(options) {
277
- const { worktreePath, defaultBranch, maxCommitEntries, runGit } = options;
278
- const entries = [];
298
+ const { worktreePath, defaultBranch, maxCommitEntries, runGit, readPathTimestampMs } = options;
299
+ const commitEntries = [];
279
300
  const commits = await runGit(worktreePath, [
280
301
  "log",
281
- "--format=%H%x1f%s",
302
+ "--format=%H%x1f%ct%x1f%s",
282
303
  `-n${maxCommitEntries}`,
283
304
  `${defaultBranch}..HEAD`
284
305
  ]);
285
306
  if (commits.ok) for (const line of commits.stdout.split("\n")) {
286
307
  if (!line.trim()) continue;
287
- const [hash, title = ""] = line.split("");
308
+ const [hash, committedAtRaw = "0", title = ""] = line.split("");
288
309
  if (!hash) continue;
289
310
  const diffResult = await runGit(worktreePath, [
290
311
  "show",
@@ -298,10 +319,12 @@ async function collectCommitEntries(options) {
298
319
  "--format=",
299
320
  hash
300
321
  ])).stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
301
- entries.push({
322
+ const committedAt = Number(committedAtRaw) * 1e3;
323
+ commitEntries.push({
302
324
  type: "commit",
303
325
  hash,
304
326
  title: title.trim() || hash.slice(0, 7),
327
+ committedAt: Number.isFinite(committedAt) && committedAt > 0 ? committedAt : 0,
305
328
  relatedChanges: parseRelatedChanges(changedFiles),
306
329
  diff: diffResult.ok ? parseNumStat(diffResult.stdout) : EMPTY_DIFF
307
330
  });
@@ -325,20 +348,28 @@ async function collectCommitEntries(options) {
325
348
  const untrackedFiles = untrackedResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
326
349
  const allUncommittedFiles = new Set([...trackedFiles, ...untrackedFiles]);
327
350
  const trackedDiff = trackedResult.ok ? parseNumStat(trackedResult.stdout) : EMPTY_DIFF;
328
- entries.push({
351
+ const updatedAt = (await Promise.all([...allUncommittedFiles].map((path) => readPathTimestampMs(resolve(worktreePath, path))))).reduce((latest, current) => {
352
+ if (!current || !Number.isFinite(current) || current <= 0) return latest;
353
+ return latest === null || current > latest ? current : latest;
354
+ }, null) ?? null;
355
+ commitEntries.sort((left, right) => {
356
+ if (left.type !== "commit" || right.type !== "commit") return 0;
357
+ return right.committedAt - left.committedAt;
358
+ });
359
+ return [{
329
360
  type: "uncommitted",
330
361
  title: "Uncommitted",
362
+ updatedAt,
331
363
  relatedChanges: parseRelatedChanges([...allUncommittedFiles]),
332
364
  diff: {
333
365
  files: allUncommittedFiles.size,
334
366
  insertions: trackedDiff.insertions,
335
367
  deletions: trackedDiff.deletions
336
368
  }
337
- });
338
- return entries;
369
+ }, ...commitEntries];
339
370
  }
340
371
  async function collectWorktree(options) {
341
- const { projectDir, worktree, defaultBranch, runGit, maxCommitEntries } = options;
372
+ const { projectDir, worktree, defaultBranch, runGit, maxCommitEntries, readPathTimestampMs } = options;
342
373
  const worktreePath = resolve(worktree.path);
343
374
  const resolvedProjectDir = resolve(projectDir);
344
375
  const aheadBehindResult = await runGit(worktreePath, [
@@ -364,7 +395,8 @@ async function collectWorktree(options) {
364
395
  worktreePath,
365
396
  defaultBranch,
366
397
  maxCommitEntries,
367
- runGit
398
+ runGit,
399
+ readPathTimestampMs
368
400
  });
369
401
  return {
370
402
  path: worktreePath,
@@ -402,6 +434,7 @@ async function removeDetachedDashboardGitWorktree(options) {
402
434
  async function buildDashboardGitSnapshot(options) {
403
435
  const runGit = options.runGit ?? defaultRunGit;
404
436
  const maxCommitEntries = options.maxCommitEntries ?? 8;
437
+ const readPathTimestampMs = options.readPathTimestampMs ?? defaultReadPathTimestampMs;
405
438
  const resolvedProjectDir = resolve(options.projectDir);
406
439
  const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
407
440
  const worktreeResult = await runGit(resolvedProjectDir, [
@@ -420,7 +453,8 @@ async function buildDashboardGitSnapshot(options) {
420
453
  worktree,
421
454
  defaultBranch,
422
455
  runGit,
423
- maxCommitEntries
456
+ maxCommitEntries,
457
+ readPathTimestampMs
424
458
  })));
425
459
  worktrees.sort((a, b) => {
426
460
  if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
@@ -635,12 +669,13 @@ async function loadDashboardOverview(ctx, reason = "dashboard-refresh") {
635
669
  ctx.adapter.listChangesWithMeta(),
636
670
  ctx.adapter.listArchivedChangesWithMeta()
637
671
  ]);
638
- const activeChanges = changeMetas.map((changeMeta) => ({
672
+ const allActiveChanges = changeMetas.map((changeMeta) => ({
639
673
  id: changeMeta.id,
640
674
  name: changeMeta.name ?? changeMeta.id,
641
675
  progress: changeMeta.progress,
642
676
  updatedAt: changeMeta.updatedAt
643
- })).sort((a, b) => b.updatedAt - a.updatedAt);
677
+ }));
678
+ const activeChanges = selectRecentDashboardItems(allActiveChanges);
644
679
  const archivedChanges = (await Promise.all(archiveMetas.map(async (meta) => {
645
680
  const change = await ctx.adapter.readArchivedChange(meta.id);
646
681
  if (!change) return null;
@@ -651,7 +686,7 @@ async function loadDashboardOverview(ctx, reason = "dashboard-refresh") {
651
686
  tasksCompleted: change.tasks.filter((task) => task.completed).length
652
687
  };
653
688
  }))).filter((item) => item !== null);
654
- const specifications = (await Promise.all(specMetas.map(async (meta) => {
689
+ const allSpecifications = (await Promise.all(specMetas.map(async (meta) => {
655
690
  const spec = await ctx.adapter.readSpec(meta.id);
656
691
  if (!spec) return null;
657
692
  return {
@@ -660,13 +695,14 @@ async function loadDashboardOverview(ctx, reason = "dashboard-refresh") {
660
695
  requirements: spec.requirements.length,
661
696
  updatedAt: meta.updatedAt
662
697
  };
663
- }))).filter((item) => item !== null).sort((a, b) => b.requirements - a.requirements || b.updatedAt - a.updatedAt);
664
- const requirements = specifications.reduce((sum, spec) => sum + spec.requirements, 0);
665
- const tasksTotal = activeChanges.reduce((sum, change) => sum + change.progress.total, 0);
666
- const tasksCompleted = activeChanges.reduce((sum, change) => sum + change.progress.completed, 0);
698
+ }))).filter((item) => item !== null);
699
+ const specifications = selectRecentDashboardItems(allSpecifications);
700
+ const requirements = allSpecifications.reduce((sum, spec) => sum + spec.requirements, 0);
701
+ const tasksTotal = allActiveChanges.reduce((sum, change) => sum + change.progress.total, 0);
702
+ const tasksCompleted = allActiveChanges.reduce((sum, change) => sum + change.progress.completed, 0);
667
703
  const archivedTasksCompleted = archivedChanges.reduce((sum, change) => sum + change.tasksCompleted, 0);
668
704
  const taskCompletionPercent = tasksTotal > 0 ? Math.round(tasksCompleted / tasksTotal * 100) : null;
669
- const inProgressChanges = activeChanges.filter((change) => change.progress.total > 0 && change.progress.completed < change.progress.total).length;
705
+ const inProgressChanges = allActiveChanges.filter((change) => change.progress.total > 0 && change.progress.completed < change.progress.total).length;
670
706
  const specificationTrendEvents = specMetas.flatMap((spec) => {
671
707
  const ts = resolveTrendTimestamp(spec.createdAt, spec.updatedAt);
672
708
  return ts === null ? [] : [{
@@ -682,7 +718,7 @@ async function loadDashboardOverview(ctx, reason = "dashboard-refresh") {
682
718
  }];
683
719
  });
684
720
  const specMetaById = new Map(specMetas.map((meta) => [meta.id, meta]));
685
- const requirementTrendEvents = specifications.flatMap((spec) => {
721
+ const requirementTrendEvents = allSpecifications.flatMap((spec) => {
686
722
  const meta = specMetaById.get(spec.id);
687
723
  const ts = resolveTrendTimestamp(meta?.updatedAt, meta?.createdAt);
688
724
  return ts === null ? [] : [{
@@ -690,7 +726,7 @@ async function loadDashboardOverview(ctx, reason = "dashboard-refresh") {
690
726
  value: spec.requirements
691
727
  }];
692
728
  });
693
- const hasObjectiveSpecificationTrend = specificationTrendEvents.length > 0 || specifications.length === 0;
729
+ const hasObjectiveSpecificationTrend = specificationTrendEvents.length > 0 || allSpecifications.length === 0;
694
730
  const hasObjectiveRequirementTrend = requirementTrendEvents.length > 0 || requirements === 0;
695
731
  const hasObjectiveCompletedTrend = completedTrendEvents.length > 0 || archiveMetas.length === 0;
696
732
  const config = await ctx.configManager.readConfig();
@@ -764,9 +800,9 @@ async function loadDashboardOverview(ctx, reason = "dashboard-refresh") {
764
800
  });
765
801
  return {
766
802
  summary: {
767
- specifications: specifications.length,
803
+ specifications: allSpecifications.length,
768
804
  requirements,
769
- activeChanges: activeChanges.length,
805
+ activeChanges: allActiveChanges.length,
770
806
  inProgressChanges,
771
807
  completedChanges: archiveMetas.length,
772
808
  archivedTasksCompleted,
@@ -1583,6 +1619,9 @@ const changeRouter = router({
1583
1619
  if (!await ctx.adapter.toggleTask(input.changeId, input.taskIndex, input.completed)) throw new Error(`Failed to toggle task ${input.taskIndex} in change ${input.changeId}`);
1584
1620
  return { success: true };
1585
1621
  }),
1622
+ subscribe: publicProcedure.subscription(({ ctx }) => {
1623
+ return createReactiveSubscription(() => ctx.adapter.listChangesWithMeta());
1624
+ }),
1586
1625
  subscribeFiles: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
1587
1626
  return createReactiveSubscriptionWithInput((id) => ctx.adapter.readChangeFiles(id))(input.id);
1588
1627
  })
@@ -2247,8 +2286,8 @@ const dashboardRouter = router({
2247
2286
  }),
2248
2287
  refreshGitSnapshot: publicProcedure.input(z.object({ reason: z.string().optional() }).optional()).mutation(async ({ ctx, input }) => {
2249
2288
  const reason = input?.reason?.trim() || "manual-refresh";
2250
- await touchDashboardGitRefreshStamp(ctx.projectDir, reason);
2251
2289
  await ctx.dashboardOverviewService.refresh(reason);
2290
+ await touchDashboardGitRefreshStamp(ctx.projectDir, reason);
2252
2291
  return { success: true };
2253
2292
  }),
2254
2293
  removeDetachedWorktree: publicProcedure.input(z.object({ path: z.string().min(1) })).mutation(async ({ ctx, input }) => {
@@ -2257,8 +2296,8 @@ const dashboardRouter = router({
2257
2296
  projectDir: ctx.projectDir,
2258
2297
  targetPath: input.path
2259
2298
  });
2260
- await touchDashboardGitRefreshStamp(ctx.projectDir, "remove-detached-worktree");
2261
2299
  await ctx.dashboardOverviewService.refresh("remove-detached-worktree");
2300
+ await touchDashboardGitRefreshStamp(ctx.projectDir, "remove-detached-worktree");
2262
2301
  return { success: true };
2263
2302
  }),
2264
2303
  gitTaskStatus: publicProcedure.query(async ({ ctx }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openspecui/server",
3
- "version": "2.1.3",
3
+ "version": "2.1.7",
4
4
  "type": "module",
5
5
  "main": "dist/index.mjs",
6
6
  "exports": {