rolldown-pnpm-config 0.2.2 → 0.4.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.
package/cli/interop.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Effect } from "effect";
2
- import { Range, SemVer } from "semver-effect";
2
+ import { Range, SemVer } from "@effected/semver";
3
3
 
4
4
  //#region src/cli/interop.ts
5
5
  /** Maximum number of concurrent peerDependencies fetches inside runInterop. @internal */
@@ -34,7 +34,7 @@ function deriveFloors(resolved, fetchPeer) {
34
34
  const valid = (yield* Effect.forEach(declared, (f) => SemVer.parse(f).pipe(Effect.map((sv) => ({
35
35
  f,
36
36
  sv
37
- })), Effect.catchAll(() => Effect.succeed(null))))).filter((x) => x !== null);
37
+ })), Effect.catch(() => Effect.succeed(null))))).filter((x) => x !== null);
38
38
  valid.sort((a, b) => a.sv.compare(b.sv));
39
39
  out.set(pkg, `^${valid[0]?.f ?? version}`);
40
40
  } else out.set(pkg, `^${version}`);
@@ -45,9 +45,9 @@ function deriveFloors(resolved, fetchPeer) {
45
45
  /** Does `version` satisfy `range`? Unparseable input is treated as not-satisfied. */
46
46
  function satisfies(version, range) {
47
47
  return Effect.gen(function* () {
48
- const r = yield* Range.parse(range).pipe(Effect.catchAll(() => Effect.succeed(null)));
48
+ const r = yield* Range.parse(range).pipe(Effect.catch(() => Effect.succeed(null)));
49
49
  if (!r) return false;
50
- const v = yield* SemVer.parse(version).pipe(Effect.catchAll(() => Effect.succeed(null)));
50
+ const v = yield* SemVer.parse(version).pipe(Effect.catch(() => Effect.succeed(null)));
51
51
  return v ? r.test(v) : false;
52
52
  });
53
53
  }
@@ -81,8 +81,8 @@ function resolveGroup(members, fetchPeer) {
81
81
  const resolved = new Map(members.map((m) => [m.pkg, m.ceiling]));
82
82
  const ceilingOf = new Map(members.map((m) => [m.pkg, m.ceiling]));
83
83
  const leq = (a, b) => Effect.gen(function* () {
84
- const av = yield* SemVer.parse(a).pipe(Effect.catchAll(() => Effect.succeed(null)));
85
- const bv = yield* SemVer.parse(b).pipe(Effect.catchAll(() => Effect.succeed(null)));
84
+ const av = yield* SemVer.parse(a).pipe(Effect.catch(() => Effect.succeed(null)));
85
+ const bv = yield* SemVer.parse(b).pipe(Effect.catch(() => Effect.succeed(null)));
86
86
  return av && bv ? av.compare(bv) <= 0 : false;
87
87
  });
88
88
  const maxIter = members.reduce((n, m) => n + m.candidates.length, 0) + members.length + 1;
@@ -130,7 +130,7 @@ function sortDesc(versions) {
130
130
  const parsed = yield* Effect.forEach(versions, (v) => SemVer.parse(v).pipe(Effect.map((sv) => ({
131
131
  v,
132
132
  sv
133
- })), Effect.catchAll(() => Effect.succeed({
133
+ })), Effect.catch(() => Effect.succeed({
134
134
  v,
135
135
  sv: null
136
136
  }))));
@@ -165,7 +165,7 @@ function runInterop(members, resolver, cache = /* @__PURE__ */ new Map()) {
165
165
  const k = key(pkg, v);
166
166
  const cached = cache.get(k);
167
167
  if (cached !== void 0) return Effect.succeed(cached);
168
- return resolver.peerDependencies(pkg, v).pipe(Effect.catchAll(() => Effect.succeed({}))).pipe(Effect.map((deps) => {
168
+ return resolver.peerDependencies(pkg, v).pipe(Effect.catch(() => Effect.succeed({}))).pipe(Effect.map((deps) => {
169
169
  cache.set(k, deps);
170
170
  return deps;
171
171
  }));
@@ -270,11 +270,11 @@ function buildInteropEdits(entries, result) {
270
270
  */
271
271
  function capVersions(list, max) {
272
272
  return Effect.gen(function* () {
273
- const mv = yield* SemVer.parse(max).pipe(Effect.catchAll(() => Effect.succeed(null)));
273
+ const mv = yield* SemVer.parse(max).pipe(Effect.catch(() => Effect.succeed(null)));
274
274
  if (!mv) return [...list];
275
275
  const out = [];
276
276
  for (const v of list) {
277
- const sv = yield* SemVer.parse(v).pipe(Effect.catchAll(() => Effect.succeed(null)));
277
+ const sv = yield* SemVer.parse(v).pipe(Effect.catch(() => Effect.succeed(null)));
278
278
  if (sv && sv.compare(mv) <= 0) out.push(v);
279
279
  }
280
280
  return out;
package/cli/peer-range.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Data, Effect } from "effect";
2
- import { SemVer } from "semver-effect";
2
+ import { SemVer } from "@effected/semver";
3
3
 
4
4
  //#region src/cli/peer-range.ts
5
5
  /**
@@ -15,8 +15,20 @@ const PREFIX_RE = /^(\^|~|)(\d.*)$/;
15
15
  * "lock" pins to the exact version; "lock-minor" floors the patch to .0.
16
16
  * The operator (^/~/exact) is preserved.
17
17
  *
18
- * Note: expects a release (non-prerelease) version string; a prerelease tag
19
- * would be dropped by the major.minor.patch reconstruction.
18
+ * "lock" reuses the version text verbatim rather than rebuilding it from
19
+ * major.minor.patch, so prerelease and build identifiers survive intact
20
+ * (rebuilding would silently drop them and derive an unpublished range).
21
+ *
22
+ * "lock-minor" floors a stable version's patch to .0, which intentionally
23
+ * drops any build metadata: `^6.5.1+build.7` derives to `^6.5.0`, not
24
+ * `^6.5.0+build.7`. Build metadata identifies a specific build of 6.5.1, not
25
+ * of the floored 6.5.0, and semver ignores build metadata when matching
26
+ * ranges anyway, so carrying it forward would be misleading.
27
+ *
28
+ * "lock-minor" is not meaningful on a prerelease — flooring `3.0.0-next.8` to
29
+ * `^3.0.0` yields a range that does not match `3.0.0-next.8` at all, excluding
30
+ * the very version being catalogued. It therefore degrades to "lock" behavior
31
+ * and reports a warning rather than emitting an unsatisfiable range.
20
32
  *
21
33
  * @internal
22
34
  */
@@ -26,7 +38,21 @@ function derivePeerRange(range, strategy) {
26
38
  if (!match) return yield* Effect.fail(new PeerRangeError({ message: `Cannot derive peer range from "${range}"` }));
27
39
  const [, prefix, version] = match;
28
40
  const parsed = yield* SemVer.parse(version).pipe(Effect.mapError(() => new PeerRangeError({ message: `Invalid version in range "${range}"` })));
29
- return strategy === "lock" ? `${prefix}${parsed.major}.${parsed.minor}.${parsed.patch}` : `${prefix}${parsed.major}.${parsed.minor}.0`;
41
+ if (strategy === "lock") return {
42
+ range: `${prefix}${version}`,
43
+ warning: null
44
+ };
45
+ if (parsed.prerelease.length > 0) return {
46
+ range: `${prefix}${version}`,
47
+ warning: {
48
+ kind: "lock-minor-prerelease",
49
+ message: `lock-minor cannot floor the prerelease "${version}" — pinned to the exact version instead`
50
+ }
51
+ };
52
+ return {
53
+ range: `${prefix}${parsed.major}.${parsed.minor}.0`,
54
+ warning: null
55
+ };
30
56
  });
31
57
  }
32
58
 
package/cli/plan.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { derivePeerRange } from "./peer-range.js";
2
2
  import { Effect } from "effect";
3
- import { Range, SemVer } from "semver-effect";
3
+ import { Range, SemVer } from "@effected/semver";
4
4
 
5
5
  //#region src/cli/plan.ts
6
6
  /** Parse a version, returning null instead of failing (filters junk tags). */
7
- const parseOrNull = (v) => SemVer.parse(v).pipe(Effect.catchAll(() => Effect.succeed(null)));
7
+ const parseOrNull = (v) => SemVer.parse(v).pipe(Effect.catch(() => Effect.succeed(null)));
8
8
  /**
9
9
  * Compute the candidate versions for one catalog entry against the list of
10
10
  * published versions. Order: latest in-range (when newer than current), latest
@@ -16,20 +16,22 @@ const parseOrNull = (v) => SemVer.parse(v).pipe(Effect.catchAll(() => Effect.suc
16
16
  */
17
17
  function planEntry(entry, versions) {
18
18
  return Effect.gen(function* () {
19
- const range = yield* Range.parse(entry.currentRange).pipe(Effect.catchAll(() => Effect.succeed(null)));
19
+ const range = yield* Range.parse(entry.currentRange).pipe(Effect.catch(() => Effect.succeed(null)));
20
+ const currentStripped = entry.currentRange.replace(/^[\^~]/, "");
21
+ const current = yield* parseOrNull(currentStripped);
22
+ const currentMajor = current?.major ?? 0;
23
+ const track = current && current.prerelease.length > 0 ? String(current.prerelease[0]) : null;
24
+ const onTrack = (v) => track !== null && v.prerelease.length > 0 && String(v.prerelease[0]) === track;
20
25
  const parsed = [];
21
26
  for (const v of versions) {
22
27
  const sv = yield* parseOrNull(v);
23
- if (sv?.isStable) parsed.push(sv);
28
+ if (sv && (sv.isStable || onTrack(sv))) parsed.push(sv);
24
29
  }
25
30
  parsed.sort((a, b) => a.compare(b));
26
31
  const maxOf = (list) => list.length ? list[list.length - 1] : null;
27
- const currentStripped = entry.currentRange.replace(/^[\^~]/, "");
28
- const current = yield* parseOrNull(currentStripped);
29
- const currentMajor = current?.major ?? 0;
30
32
  const inRangeMax = range ? maxOf(parsed.filter((v) => range.test(v))) : null;
31
33
  const overallMax = maxOf(parsed);
32
- const withPeer = (version) => entry.strategy && entry.strategy !== "interop" ? derivePeerRange(`${entry.operator}${version}`, entry.strategy) : Effect.succeed(void 0);
34
+ const withPeer = (version) => entry.strategy && entry.strategy !== "interop" ? derivePeerRange(`${entry.operator}${version}`, entry.strategy).pipe(Effect.map((d) => d.range)) : Effect.succeed(void 0);
33
35
  const candidates = [];
34
36
  if (inRangeMax && (current === null || inRangeMax.gt(current))) {
35
37
  const version = inRangeMax.toString();
package/cli/resolve.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Context, Data, Effect, Layer } from "effect";
2
- import { Command, CommandExecutor } from "@effect/platform";
2
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3
3
 
4
4
  //#region src/cli/resolve.ts
5
+ const Spawner = ChildProcessSpawner.ChildProcessSpawner;
5
6
  /**
6
7
  * Typed failure raised when a package's versions cannot be resolved.
7
8
  *
@@ -15,7 +16,7 @@ var ResolveError = class extends Data.TaggedError("ResolveError") {};
15
16
  *
16
17
  * @internal
17
18
  */
18
- var RegistryResolver = class extends Context.Tag("RegistryResolver")() {};
19
+ var RegistryResolver = class extends Context.Service()("RegistryResolver") {};
19
20
  /** Parse `pnpm view ... versions --json` stdout: a JSON array, or a single JSON string. */
20
21
  function parseVersions(pkg, stdout) {
21
22
  return Effect.try({
@@ -75,32 +76,51 @@ function parsePeerDeps(pkg, stdout) {
75
76
  * @internal
76
77
  */
77
78
  const RegistryResolverLive = Layer.effect(RegistryResolver, Effect.gen(function* () {
78
- const executor = yield* CommandExecutor.CommandExecutor;
79
+ const spawner = yield* Spawner;
79
80
  return {
80
81
  versions: (pkg) => Effect.gen(function* () {
81
- const cmd = Command.make("pnpm", "view", pkg, "versions", "--json");
82
- return yield* parseVersions(pkg, yield* executor.string(cmd).pipe(Effect.mapError((e) => new ResolveError({
82
+ const cmd = ChildProcess.make("pnpm", [
83
+ "view",
84
+ pkg,
85
+ "versions",
86
+ "--json"
87
+ ]);
88
+ return yield* parseVersions(pkg, yield* spawner.string(cmd).pipe(Effect.mapError((e) => new ResolveError({
83
89
  pkg,
84
90
  message: String(e)
85
91
  }))));
86
92
  }),
87
93
  times: (pkg) => Effect.gen(function* () {
88
- const cmd = Command.make("pnpm", "view", pkg, "time", "--json");
89
- return yield* parseTimes(pkg, yield* executor.string(cmd).pipe(Effect.mapError((e) => new ResolveError({
94
+ const cmd = ChildProcess.make("pnpm", [
95
+ "view",
96
+ pkg,
97
+ "time",
98
+ "--json"
99
+ ]);
100
+ return yield* parseTimes(pkg, yield* spawner.string(cmd).pipe(Effect.mapError((e) => new ResolveError({
90
101
  pkg,
91
102
  message: String(e)
92
103
  }))));
93
104
  }),
94
105
  peerDependencies: (pkg, version) => Effect.gen(function* () {
95
- const cmd = Command.make("pnpm", "view", `${pkg}@${version}`, "peerDependencies", "--json");
96
- return yield* parsePeerDeps(pkg, yield* executor.string(cmd).pipe(Effect.mapError((e) => new ResolveError({
106
+ const cmd = ChildProcess.make("pnpm", [
107
+ "view",
108
+ `${pkg}@${version}`,
109
+ "peerDependencies",
110
+ "--json"
111
+ ]);
112
+ return yield* parsePeerDeps(pkg, yield* spawner.string(cmd).pipe(Effect.mapError((e) => new ResolveError({
97
113
  pkg,
98
114
  message: String(e)
99
115
  }))));
100
116
  }),
101
117
  pnpmConfig: (key) => Effect.gen(function* () {
102
- const cmd = Command.make("pnpm", "config", "get", key);
103
- return yield* executor.string(cmd).pipe(Effect.map((s) => s.trim()), Effect.catchAll(() => Effect.succeed(null)));
118
+ const cmd = ChildProcess.make("pnpm", [
119
+ "config",
120
+ "get",
121
+ key
122
+ ]);
123
+ return yield* spawner.string(cmd).pipe(Effect.map((s) => s.trim()), Effect.catch(() => Effect.succeed(null)));
104
124
  })
105
125
  };
106
126
  }));
package/cli/summary.js CHANGED
@@ -1,89 +1,90 @@
1
1
  import { toAnsi } from "./ui/ansi.js";
2
+ import { displayCandidates, peerFor } from "./walk-reducer.js";
2
3
 
3
4
  //#region src/cli/summary.ts
5
+ /** Trailing annotation appended to a major candidate's cell, e.g. " ⚠ major". */
6
+ const MAJOR_SUFFIX = " ⚠ major";
7
+ /** Filled / hollow radio glyphs. MUST match `ui/Walk.ts` — the summary mirrors the table. */
8
+ const SELECTED = "●";
9
+ const UNSELECTED = "○";
10
+ /** "● " / "○ " glyph-plus-space prefix width, common to every cell. */
11
+ const BUBBLE_WIDTH = 2;
4
12
  /**
5
- * Build the pending-decisions summary as styled lines: one line per real
6
- * change, peer changes indented, a dim tally, then interop adjustments and
7
- * conflicts. Pure; color is applied by `renderSummary`/`toAnsi`.
13
+ * Build the pending-decisions summary as styled lines: one table row per
14
+ * decision mirroring the interactive selection table, catalog headers,
15
+ * chosen bubble filled then a dim tally, interop adjustments, conflicts,
16
+ * and any rejected edits. Pure; color is applied by `renderSummary`/`toAnsi`.
8
17
  *
9
18
  * @internal
10
19
  */
11
- function summaryLines(decisions, interop) {
20
+ function summaryLines(decisions, interop, rejected) {
12
21
  const lines = [];
13
22
  let toUpdate = 0;
14
23
  let major = 0;
15
24
  let resync = 0;
16
25
  let materialize = 0;
17
26
  let upToDate = 0;
27
+ const pkgWidth = decisions.length ? Math.max(...decisions.map((d) => d.item.entry.pkg.length)) : 0;
28
+ const cellWidth = decisions.length ? Math.max(...decisions.flatMap((d) => displayCandidates(d.item).map((c) => c.range.length + (c.isMajor ? 8 : 0)))) : 0;
29
+ const maxCells = decisions.length ? Math.max(...decisions.map((d) => displayCandidates(d.item).length)) : 0;
30
+ const blankCell = `${" ".repeat(BUBBLE_WIDTH + cellWidth)} `;
31
+ let lastCatalog = null;
18
32
  for (const { item, chosen } of decisions) {
19
33
  const { entry } = item;
20
- if (chosen.kind !== "keep") {
21
- toUpdate++;
22
- if (chosen.isMajor) major++;
34
+ if (entry.catalog !== lastCatalog) {
35
+ lastCatalog = entry.catalog;
23
36
  lines.push({
24
37
  indent: 0,
25
- gutter: "~",
38
+ gutter: " ",
26
39
  segments: [{
27
- text: `${entry.catalog} ${entry.pkg} ${entry.currentRange} → ${chosen.range}`,
28
- style: "changed"
40
+ text: `── catalog: ${entry.catalog} ──`,
41
+ style: "unchanged"
29
42
  }]
30
43
  });
31
- if (entry.peer && chosen.peerRange && chosen.peerRange !== entry.peer.value) lines.push({
32
- indent: 1,
33
- gutter: "~",
34
- segments: [{
35
- text: `↳ peer ${entry.peer.value} → ${chosen.peerRange}`,
36
- style: "changed"
37
- }]
38
- });
39
- else if (!entry.peer && entry.strategy && chosen.peerRange) {
40
- lines.push({
41
- indent: 1,
42
- gutter: "+",
43
- segments: [{
44
- text: `↳ peer (new) → ${chosen.peerRange}`,
45
- style: "added"
46
- }]
47
- });
48
- materialize++;
49
- }
50
- } else if (entry.peer && item.driftPeer) {
51
- resync++;
52
- lines.push({
53
- indent: 0,
54
- gutter: "~",
55
- segments: [{
56
- text: `${entry.catalog} › ${entry.pkg} (resync peer)`,
57
- style: "changed"
58
- }]
59
- });
60
- lines.push({
61
- indent: 1,
62
- gutter: "~",
63
- segments: [{
64
- text: `↳ peer ${entry.peer.value} → ${item.driftPeer}`,
65
- style: "changed"
66
- }]
67
- });
68
- } else if (!entry.peer && item.materializePeer) {
69
- materialize++;
70
- lines.push({
71
- indent: 0,
72
- gutter: "+",
73
- segments: [{
74
- text: `${entry.catalog} › ${entry.pkg} (materialize peer)`,
75
- style: "added"
76
- }]
77
- });
78
- lines.push({
79
- indent: 1,
80
- gutter: "+",
81
- segments: [{
82
- text: `↳ peer (new) → ${item.materializePeer}`,
83
- style: "added"
84
- }]
44
+ }
45
+ const cells = displayCandidates(item);
46
+ const segments = [{
47
+ text: entry.pkg.padEnd(pkgWidth + 2),
48
+ style: "plain"
49
+ }];
50
+ for (const c of cells) {
51
+ const selected = c.kind === chosen.kind;
52
+ const bubble = selected ? SELECTED : UNSELECTED;
53
+ const style = !selected ? "unchanged" : c.kind === "keep" ? "unchanged" : c.isMajor ? "changed" : "added";
54
+ const content = `${c.range}${c.isMajor ? MAJOR_SUFFIX : ""}`.padEnd(cellWidth);
55
+ segments.push({
56
+ text: `${bubble} ${content} `,
57
+ style
85
58
  });
86
- } else upToDate++;
59
+ }
60
+ for (let ci = cells.length; ci < maxCells; ci++) segments.push({
61
+ text: blankCell,
62
+ style: "plain"
63
+ });
64
+ segments.push({
65
+ text: `│ ${peerFor(item, chosen)}`,
66
+ style: "unchanged"
67
+ });
68
+ lines.push({
69
+ indent: 0,
70
+ gutter: chosen.kind === "keep" ? " " : "~",
71
+ segments
72
+ });
73
+ if (item.peerWarning) lines.push({
74
+ indent: 1,
75
+ gutter: "⚠",
76
+ segments: [{
77
+ text: item.peerWarning.message,
78
+ style: "warn"
79
+ }]
80
+ });
81
+ if (chosen.kind !== "keep") {
82
+ toUpdate++;
83
+ if (chosen.isMajor) major++;
84
+ if (!entry.peer && entry.strategy && chosen.peerRange) materialize++;
85
+ } else if (entry.peer && item.driftPeer) resync++;
86
+ else if (!entry.peer && item.materializePeer) materialize++;
87
+ else upToDate++;
87
88
  }
88
89
  lines.push({
89
90
  indent: 0,
@@ -121,6 +122,32 @@ function summaryLines(decisions, interop) {
121
122
  }]
122
123
  });
123
124
  }
125
+ if (rejected && rejected.length > 0) {
126
+ lines.push({
127
+ indent: 0,
128
+ gutter: " ",
129
+ segments: [{
130
+ text: "",
131
+ style: "plain"
132
+ }]
133
+ });
134
+ lines.push({
135
+ indent: 0,
136
+ gutter: "⚠",
137
+ segments: [{
138
+ text: "Rejected (no published version satisfies these):",
139
+ style: "warn"
140
+ }]
141
+ });
142
+ for (const r of rejected) lines.push({
143
+ indent: 1,
144
+ gutter: "⚠",
145
+ segments: [{
146
+ text: `${r.pkg} ${r.kind} ${r.value} — ${r.reason}`,
147
+ style: "warn"
148
+ }]
149
+ });
150
+ }
124
151
  return lines;
125
152
  }
126
153
  /**
@@ -129,8 +156,8 @@ function summaryLines(decisions, interop) {
129
156
  *
130
157
  * @internal
131
158
  */
132
- function renderSummary(decisions, interop, opts) {
133
- return toAnsi(summaryLines(decisions, interop), { color: opts?.color ?? false });
159
+ function renderSummary(decisions, interop, opts, rejected) {
160
+ return toAnsi(summaryLines(decisions, interop, rejected), { color: opts?.color ?? false });
134
161
  }
135
162
 
136
163
  //#endregion
package/cli/ui/Walk.js CHANGED
@@ -1,54 +1,80 @@
1
- import { initWalk, walkStep } from "../walk-reducer.js";
1
+ import { cellColor, displayCandidates, initTable, peerFor, tableDecisions, tableStep } from "../walk-reducer.js";
2
2
  import { Box, Text, useApp, useInput } from "ink";
3
3
  import { createElement, useEffect, useState } from "react";
4
4
 
5
5
  //#region src/cli/ui/Walk.ts
6
+ /** Rows visible at once before the viewport scrolls. */
7
+ const VIEWPORT = 20;
6
8
  /**
7
- * Interactive per-package upgrade selector rendered with Ink.
9
+ * Interactive upgrade table rendered with Ink. Every package is one row; each
10
+ * row is a radio group over its candidates with keep (index 0) preselected, so
11
+ * the default state of the table applies nothing.
8
12
  *
9
13
  * Written with React.createElement (no JSX) so the file can be plain .ts
10
14
  * without requiring TSX transform configuration.
11
15
  *
12
16
  * @internal
13
17
  */
14
- function Walk({ items, onDone }) {
18
+ function Walk({ items, onDone, dryRun = false, unresolved = [] }) {
15
19
  const app = useApp();
16
- const [state, setState] = useState(() => initWalk(items));
20
+ const [state, setState] = useState(() => initTable(items));
17
21
  useEffect(() => {
18
- if (state.done) {
19
- onDone(state.decisions);
22
+ if (items.length === 0) {
23
+ onDone([]);
20
24
  app.exit();
21
25
  }
22
26
  }, []);
23
27
  useInput((_input, key) => {
24
28
  if (state.done) return;
25
- const which = key.upArrow ? "up" : key.downArrow ? "down" : key.return ? "enter" : null;
29
+ const which = key.upArrow ? "up" : key.downArrow ? "down" : key.leftArrow ? "left" : key.rightArrow ? "right" : key.return ? "submit" : key.escape ? "cancel" : null;
26
30
  if (!which) return;
27
- const next = walkStep(state, items, which);
31
+ const next = tableStep(state, items, which);
28
32
  setState(next);
29
33
  if (next.done) {
30
- onDone(next.decisions);
34
+ onDone(tableDecisions(next, items));
31
35
  app.exit();
32
36
  }
33
37
  });
34
- if (state.done || state.index >= items.length) return createElement(Text, null, "Done.");
35
- const item = items[state.index];
36
- const e = item.entry;
37
- return createElement(Box, { flexDirection: "column" }, createElement(Text, null, [
38
- `${e.catalog} ${e.pkg} current ${e.currentRange}`,
39
- e.peer ? ` peer ${e.peer.value}` : "",
40
- e.strategy ? ` strategy: ${e.strategy}` : ""
41
- ].join("")), ...item.candidates.map((c, i) => {
42
- const selected = i === state.cursor;
43
- const cursor = selected ? "❯ " : " ";
44
- const base = c.kind === "keep" ? `keep ${c.range}` : `${c.range} ${c.kind}`;
45
- const colorProps = selected ? { color: "cyan" } : c.isMajor ? { color: "yellow" } : {};
46
- const text = c.kind === "keep" ? `${cursor}${base}` : `${cursor}${base}${c.isMajor ? " ⚠ major" : ""}`;
47
- return createElement(Text, {
48
- key: c.kind,
49
- ...colorProps
50
- }, text);
51
- }));
38
+ if (state.done || items.length === 0) return createElement(Text, null, "Done.");
39
+ const pkgWidth = Math.max(...items.map((i) => i.entry.pkg.length));
40
+ const MAJOR_SUFFIX = " ⚠ major";
41
+ const SELECTED = "";
42
+ const UNSELECTED = "○";
43
+ const cellWidth = Math.max(...items.flatMap((i) => displayCandidates(i).map((c) => c.range.length + (c.isMajor ? 8 : 0))));
44
+ const maxCells = Math.max(...items.map((i) => displayCandidates(i).length));
45
+ const blankCell = `${" ".repeat(2 + cellWidth)} `;
46
+ const start = Math.max(0, Math.min(state.cursor - Math.floor(VIEWPORT / 2), items.length - VIEWPORT));
47
+ const visible = items.slice(Math.max(0, start), Math.max(0, start) + VIEWPORT);
48
+ const rows = [];
49
+ let lastCatalog = null;
50
+ visible.forEach((item, offset) => {
51
+ const i = Math.max(0, start) + offset;
52
+ if (item.entry.catalog !== lastCatalog) {
53
+ lastCatalog = item.entry.catalog;
54
+ rows.push(createElement(Text, {
55
+ key: `cat-${item.entry.catalog}`,
56
+ dimColor: true
57
+ }, ` ── catalog: ${lastCatalog} ──`));
58
+ }
59
+ const onCursor = i === state.cursor;
60
+ const pick = state.picks[i] ?? 0;
61
+ const candidates = displayCandidates(item);
62
+ const cells = candidates.map((c, ci) => {
63
+ const selected = ci === pick;
64
+ const bubble = selected ? SELECTED : UNSELECTED;
65
+ const major = c.isMajor ? MAJOR_SUFFIX : "";
66
+ const content = `${c.range}${major}`.padEnd(cellWidth);
67
+ const color = cellColor(c, selected);
68
+ return createElement(Text, {
69
+ key: c.kind,
70
+ ...color ? { color } : {}
71
+ }, `${bubble} ${content} `);
72
+ });
73
+ for (let ci = candidates.length; ci < maxCells; ci++) cells.push(createElement(Text, { key: `blank-${ci}` }, blankCell));
74
+ const chosen = candidates[pick];
75
+ rows.push(createElement(Box, { key: `${item.entry.catalog}/${item.entry.pkg}` }, createElement(Text, { ...onCursor ? { color: "cyan" } : {} }, onCursor ? "❯ " : " "), createElement(Text, { bold: onCursor }, item.entry.pkg.padEnd(pkgWidth + 2)), ...cells, createElement(Text, { dimColor: true }, `│ ${chosen === void 0 ? "—" : peerFor(item, chosen)}`), item.peerWarning ? createElement(Text, { color: "red" }, ` ⚠ ${item.peerWarning.message}`) : null));
76
+ });
77
+ return createElement(Box, { flexDirection: "column" }, createElement(Text, { bold: true }, dryRun ? "Enter to preview • Esc to cancel" : "Enter to update • Esc to cancel"), dryRun ? createElement(Text, { color: "yellow" }, "DRY RUN — nothing will be written to the config") : null, unresolved.length > 0 ? createElement(Text, { color: "red" }, `⚠ Could not resolve from the registry — check for a typo: ${unresolved.join(", ")}`) : null, createElement(Box, { height: 1 }), ...rows);
52
78
  }
53
79
 
54
80
  //#endregion
@@ -11,7 +11,7 @@ import { createElement } from "react";
11
11
  * @internal
12
12
  */
13
13
  function runPreview(views) {
14
- return Effect.async((resume) => {
14
+ return Effect.callback((resume) => {
15
15
  render(createElement(Preview, {
16
16
  views,
17
17
  onExit: () => {}
@@ -5,17 +5,19 @@ import { createElement } from "react";
5
5
 
6
6
  //#region src/cli/ui/run-walk.ts
7
7
  /**
8
- * Render the interactive Walk inside an Effect, resolving with the collected
9
- * decisions once the user finishes (or immediately when nothing is actionable),
10
- * after Ink has fully exited.
8
+ * Render the interactive table inside an Effect, resolving with the collected
9
+ * decisions once the user submits (or with an empty list when they cancel with
10
+ * Esc, or when no rows are actionable), after Ink has fully exited.
11
11
  *
12
12
  * @internal
13
13
  */
14
- function runWalk(items) {
15
- return Effect.async((resume) => {
14
+ function runWalk(items, dryRun = false, unresolved = []) {
15
+ return Effect.callback((resume) => {
16
16
  let collected = [];
17
17
  render(createElement(Walk, {
18
18
  items,
19
+ dryRun,
20
+ unresolved,
19
21
  onDone: (d) => {
20
22
  collected = d;
21
23
  }