claudeup 4.27.0 → 4.28.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/package.json +4 -4
- package/src/__tests__/plugin-releases.test.ts +98 -0
- package/src/__tests__/version-check-refresh.test.ts +45 -0
- package/src/services/local-marketplace.ts +5 -0
- package/src/services/marketplace-fetcher.ts +6 -0
- package/src/services/plugin-manager.ts +7 -0
- package/src/services/plugin-releases.ts +47 -0
- package/src/services/version-check.ts +11 -5
- package/src/types/index.ts +17 -0
- package/src/ui/App.tsx +12 -1
- package/src/ui/renderers/pluginRenderers.tsx +71 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.28.0",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"typescript": "^5.6.3"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"claudeup-darwin-arm64": "4.
|
|
68
|
-
"claudeup-darwin-x64": "4.
|
|
69
|
-
"claudeup-linux-x64": "4.
|
|
67
|
+
"claudeup-darwin-arm64": "4.28.0",
|
|
68
|
+
"claudeup-darwin-x64": "4.28.0",
|
|
69
|
+
"claudeup-linux-x64": "4.28.0"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
MAX_RELEASES,
|
|
4
|
+
normalizeReleases,
|
|
5
|
+
} from "../services/plugin-releases.js";
|
|
6
|
+
import type { PluginRelease } from "../types/index.js";
|
|
7
|
+
|
|
8
|
+
/** Narrow to a populated result, failing the test rather than the assertion's type. */
|
|
9
|
+
function expectEntries(input: unknown): PluginRelease[] {
|
|
10
|
+
const out = normalizeReleases(input);
|
|
11
|
+
if (!out) throw new Error("expected releases, got undefined");
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("normalizeReleases", () => {
|
|
16
|
+
test("passes through a well-formed entry", () => {
|
|
17
|
+
expect(
|
|
18
|
+
normalizeReleases([
|
|
19
|
+
{
|
|
20
|
+
version: "3.3.0",
|
|
21
|
+
date: "2026-07-29",
|
|
22
|
+
kinds: ["Changed", "Fixed"],
|
|
23
|
+
summary: "Model resolution is now live",
|
|
24
|
+
},
|
|
25
|
+
]),
|
|
26
|
+
).toEqual([
|
|
27
|
+
{
|
|
28
|
+
version: "3.3.0",
|
|
29
|
+
date: "2026-07-29",
|
|
30
|
+
kinds: ["Changed", "Fixed"],
|
|
31
|
+
summary: "Model resolution is now live",
|
|
32
|
+
},
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("omits optional fields rather than emitting undefined", () => {
|
|
37
|
+
const [entry] = expectEntries([
|
|
38
|
+
{ version: "1.0.0", summary: "First release" },
|
|
39
|
+
]);
|
|
40
|
+
expect(entry).toEqual({ version: "1.0.0", summary: "First release" });
|
|
41
|
+
expect("date" in entry).toBe(false);
|
|
42
|
+
expect("kinds" in entry).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("returns undefined for anything that is not an array", () => {
|
|
46
|
+
for (const input of [undefined, null, "3.3.0", 7, {}, true]) {
|
|
47
|
+
expect(normalizeReleases(input)).toBeUndefined();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// The manifest is fetched over the network, so no field's type can be assumed.
|
|
52
|
+
test("drops entries missing or mistyping the required fields", () => {
|
|
53
|
+
expect(
|
|
54
|
+
normalizeReleases([
|
|
55
|
+
{ version: "1.0.0", summary: "kept" },
|
|
56
|
+
{ version: "1.0.1" }, // no summary
|
|
57
|
+
{ summary: "no version" },
|
|
58
|
+
{ version: 2, summary: "version is a number" },
|
|
59
|
+
{ version: "1.0.2", summary: 42 },
|
|
60
|
+
{ version: " ", summary: "blank version" },
|
|
61
|
+
{ version: "1.0.3", summary: " " },
|
|
62
|
+
null,
|
|
63
|
+
"not an object",
|
|
64
|
+
]),
|
|
65
|
+
).toEqual([{ version: "1.0.0", summary: "kept" }]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("returns undefined when every entry is unusable", () => {
|
|
69
|
+
expect(normalizeReleases([{ nope: true }, null])).toBeUndefined();
|
|
70
|
+
expect(normalizeReleases([])).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("caps the list so one manifest cannot flood the panel", () => {
|
|
74
|
+
const many = Array.from({ length: 500 }, (_, i) => ({
|
|
75
|
+
version: `1.0.${i}`,
|
|
76
|
+
summary: `entry ${i}`,
|
|
77
|
+
}));
|
|
78
|
+
const out = expectEntries(many);
|
|
79
|
+
expect(out).toHaveLength(MAX_RELEASES);
|
|
80
|
+
// Newest-first ordering is preserved — the cap takes the head, not the tail.
|
|
81
|
+
expect(out[0].version).toBe("1.0.0");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("keeps only string members of kinds", () => {
|
|
85
|
+
const [entry] = expectEntries([
|
|
86
|
+
{ version: "1.0.0", summary: "x", kinds: ["Added", 3, null, "Fixed"] },
|
|
87
|
+
]);
|
|
88
|
+
expect(entry.kinds).toEqual(["Added", "Fixed"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("trims surrounding whitespace", () => {
|
|
92
|
+
expect(
|
|
93
|
+
normalizeReleases([
|
|
94
|
+
{ version: " 1.0.0 ", date: " 2026-07-29 ", summary: " padded " },
|
|
95
|
+
]),
|
|
96
|
+
).toEqual([{ version: "1.0.0", date: "2026-07-29", summary: "padded" }]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards the update-check refresh fix. checkForUpdates caches within a session
|
|
3
|
+
* (so we don't hammer the registry), but `force` bypasses that cache so a
|
|
4
|
+
* release landing mid-session is actually seen — the 4.25-vs-4.26 banner
|
|
5
|
+
* staleness. (The fetch itself also sets `cache: "no-store"` so a CDN-stale
|
|
6
|
+
* response can't mask a fresh release.)
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
9
|
+
import { checkForUpdates } from "../services/version-check.js";
|
|
10
|
+
|
|
11
|
+
describe("checkForUpdates — session cache vs forced re-check", () => {
|
|
12
|
+
const realFetch = globalThis.fetch;
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
globalThis.fetch = realFetch;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("serves the session cache by default and re-fetches only when forced", async () => {
|
|
18
|
+
let calls = 0;
|
|
19
|
+
let latest = "9.9.9";
|
|
20
|
+
globalThis.fetch = (async () => {
|
|
21
|
+
calls++;
|
|
22
|
+
return new Response(JSON.stringify({ version: latest }), {
|
|
23
|
+
status: 200,
|
|
24
|
+
headers: { "content-type": "application/json" },
|
|
25
|
+
});
|
|
26
|
+
}) as typeof fetch;
|
|
27
|
+
|
|
28
|
+
// Force the first call so the test is independent of any cache another
|
|
29
|
+
// test in the same process may have primed.
|
|
30
|
+
const first = await checkForUpdates(true);
|
|
31
|
+
expect(calls).toBe(1);
|
|
32
|
+
expect(first.latestVersion).toBe("9.9.9");
|
|
33
|
+
|
|
34
|
+
// Default call is served from the session cache — no new fetch.
|
|
35
|
+
const cached = await checkForUpdates();
|
|
36
|
+
expect(calls).toBe(1);
|
|
37
|
+
expect(cached.latestVersion).toBe("9.9.9");
|
|
38
|
+
|
|
39
|
+
// A release lands; a forced re-check bypasses the cache and sees it.
|
|
40
|
+
latest = "9.9.10";
|
|
41
|
+
const forced = await checkForUpdates(true);
|
|
42
|
+
expect(calls).toBe(2);
|
|
43
|
+
expect(forced.latestVersion).toBe("9.9.10");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -2,6 +2,8 @@ import fs from "fs-extra";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { execSync } from "node:child_process";
|
|
5
|
+
import type { PluginRelease } from "../types/index.js";
|
|
6
|
+
import { normalizeReleases } from "./plugin-releases.js";
|
|
5
7
|
|
|
6
8
|
export interface LocalMarketplacePlugin {
|
|
7
9
|
name: string;
|
|
@@ -10,6 +12,8 @@ export interface LocalMarketplacePlugin {
|
|
|
10
12
|
source?: string;
|
|
11
13
|
category?: string;
|
|
12
14
|
author?: { name: string; email?: string };
|
|
15
|
+
/** Recent release history, as published in the marketplace manifest. */
|
|
16
|
+
releases?: PluginRelease[];
|
|
13
17
|
// Extended info from plugin.json or marketplace.json
|
|
14
18
|
strict?: boolean;
|
|
15
19
|
lspServers?: Record<string, unknown>;
|
|
@@ -185,6 +189,7 @@ async function scanSingleMarketplace(
|
|
|
185
189
|
source: plugin.source,
|
|
186
190
|
category: plugin.category,
|
|
187
191
|
author: plugin.author,
|
|
192
|
+
releases: normalizeReleases(plugin.releases),
|
|
188
193
|
strict: plugin.strict,
|
|
189
194
|
lspServers: plugin.lspServers,
|
|
190
195
|
agents,
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* imported via plugin-manager.ts).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { PluginRelease } from "../types/index.js";
|
|
10
11
|
import { isValidGitHubRepo } from "../utils/string-utils.js";
|
|
11
12
|
import type { LocalMarketplace } from "./local-marketplace.js";
|
|
13
|
+
import { normalizeReleases } from "./plugin-releases.js";
|
|
12
14
|
|
|
13
15
|
export interface MarketplacePlugin {
|
|
14
16
|
name: string;
|
|
@@ -18,6 +20,7 @@ export interface MarketplacePlugin {
|
|
|
18
20
|
author?: { name: string; email?: string };
|
|
19
21
|
homepage?: string;
|
|
20
22
|
tags?: string[];
|
|
23
|
+
releases?: PluginRelease[];
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
// Session-level cache for fetched marketplace data (no TTL - persists until explicit refresh)
|
|
@@ -92,6 +95,7 @@ export async function fetchMarketplacePlugins(
|
|
|
92
95
|
author?: { name: string; email?: string };
|
|
93
96
|
homepage?: string;
|
|
94
97
|
tags?: string[];
|
|
98
|
+
releases?: unknown;
|
|
95
99
|
}
|
|
96
100
|
const data = (await response.json()) as {
|
|
97
101
|
plugins?: RawPlugin[];
|
|
@@ -116,6 +120,7 @@ export async function fetchMarketplacePlugins(
|
|
|
116
120
|
author: plugin.author,
|
|
117
121
|
homepage: plugin.homepage,
|
|
118
122
|
tags: plugin.tags,
|
|
123
|
+
releases: normalizeReleases(plugin.releases),
|
|
119
124
|
});
|
|
120
125
|
}
|
|
121
126
|
}
|
|
@@ -161,5 +166,6 @@ export async function resolveMarketplacePlugins(
|
|
|
161
166
|
description: p.description,
|
|
162
167
|
category: p.category,
|
|
163
168
|
author: p.author,
|
|
169
|
+
releases: p.releases,
|
|
164
170
|
}));
|
|
165
171
|
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
removeFromInstalledPluginsRegistry,
|
|
17
17
|
} from "./claude-settings.js";
|
|
18
18
|
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
19
|
+
import type { PluginRelease } from "../types/index.js";
|
|
19
20
|
import {
|
|
20
21
|
scanLocalMarketplaces,
|
|
21
22
|
repairAllMarketplaces,
|
|
@@ -66,6 +67,8 @@ export interface PluginInfo {
|
|
|
66
67
|
author?: { name: string; email?: string };
|
|
67
68
|
homepage?: string;
|
|
68
69
|
tags?: string[];
|
|
70
|
+
/** Recent release history, newest first. Absent when the manifest publishes none. */
|
|
71
|
+
releases?: PluginRelease[];
|
|
69
72
|
agents?: string[];
|
|
70
73
|
commands?: string[];
|
|
71
74
|
skills?: string[];
|
|
@@ -215,6 +218,7 @@ export async function getAvailablePlugins(
|
|
|
215
218
|
author: plugin.author,
|
|
216
219
|
homepage: plugin.homepage,
|
|
217
220
|
tags: plugin.tags,
|
|
221
|
+
releases: plugin.releases,
|
|
218
222
|
});
|
|
219
223
|
}
|
|
220
224
|
}
|
|
@@ -253,6 +257,7 @@ export async function getAvailablePlugins(
|
|
|
253
257
|
skills: localPlugin.skills,
|
|
254
258
|
mcpServers: localPlugin.mcpServers,
|
|
255
259
|
lspServers: localPlugin.lspServers,
|
|
260
|
+
releases: localPlugin.releases,
|
|
256
261
|
});
|
|
257
262
|
}
|
|
258
263
|
}
|
|
@@ -405,6 +410,7 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
|
|
|
405
410
|
author: plugin.author,
|
|
406
411
|
homepage: plugin.homepage,
|
|
407
412
|
tags: plugin.tags,
|
|
413
|
+
releases: plugin.releases,
|
|
408
414
|
});
|
|
409
415
|
}
|
|
410
416
|
}
|
|
@@ -443,6 +449,7 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
|
|
|
443
449
|
skills: localPlugin.skills,
|
|
444
450
|
mcpServers: localPlugin.mcpServers,
|
|
445
451
|
lspServers: localPlugin.lspServers,
|
|
452
|
+
releases: localPlugin.releases,
|
|
446
453
|
});
|
|
447
454
|
}
|
|
448
455
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalisation for the `releases` array published in marketplace manifests.
|
|
3
|
+
*
|
|
4
|
+
* Lives apart from both readers because both need it: `marketplace-fetcher.ts`
|
|
5
|
+
* (manifest over the network) and `local-marketplace.ts` (same manifest from the
|
|
6
|
+
* on-disk cache). Putting it in either would make one import the other, and
|
|
7
|
+
* marketplace-fetcher was split out precisely to avoid importing local-marketplace,
|
|
8
|
+
* whose module-level `os.homedir()` constants contaminate the test suite.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PluginRelease } from "../types/index.js";
|
|
12
|
+
|
|
13
|
+
/** Most release entries the panel will show, however many the manifest declares. */
|
|
14
|
+
export const MAX_RELEASES = 5;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Coerce a manifest `releases` value into renderable shape, or undefined.
|
|
18
|
+
*
|
|
19
|
+
* The remote manifest is fetched over the network from a repo whose contents we do
|
|
20
|
+
* not control at read time, so nothing about its shape may be assumed — a number
|
|
21
|
+
* where a string belongs, or a ten-thousand-entry array, would otherwise reach the
|
|
22
|
+
* renderer. Entries missing a version or summary are dropped rather than rendered
|
|
23
|
+
* blank, and the array is capped so one manifest cannot flood the detail panel.
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeReleases(input: unknown): PluginRelease[] | undefined {
|
|
26
|
+
if (!Array.isArray(input)) return undefined;
|
|
27
|
+
|
|
28
|
+
const out: PluginRelease[] = [];
|
|
29
|
+
for (const raw of input.slice(0, MAX_RELEASES)) {
|
|
30
|
+
if (!raw || typeof raw !== "object") continue;
|
|
31
|
+
const r = raw as Record<string, unknown>;
|
|
32
|
+
if (typeof r.version !== "string" || typeof r.summary !== "string")
|
|
33
|
+
continue;
|
|
34
|
+
if (!r.version.trim() || !r.summary.trim()) continue;
|
|
35
|
+
out.push({
|
|
36
|
+
version: r.version.trim(),
|
|
37
|
+
...(typeof r.date === "string" && r.date.trim()
|
|
38
|
+
? { date: r.date.trim() }
|
|
39
|
+
: {}),
|
|
40
|
+
...(Array.isArray(r.kinds)
|
|
41
|
+
? { kinds: r.kinds.filter((k): k is string => typeof k === "string") }
|
|
42
|
+
: {}),
|
|
43
|
+
summary: r.summary.trim(),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return out.length > 0 ? out : undefined;
|
|
47
|
+
}
|
|
@@ -33,6 +33,9 @@ async function fetchLatestVersion(): Promise<string | null> {
|
|
|
33
33
|
signal: controller.signal,
|
|
34
34
|
headers: {
|
|
35
35
|
Accept: "application/json",
|
|
36
|
+
// Always revalidate — a CDN/HTTP-cached response serves a stale
|
|
37
|
+
// "latest" right after a release (the 4.25-vs-4.26 flicker).
|
|
38
|
+
"Cache-Control": "no-cache",
|
|
36
39
|
},
|
|
37
40
|
});
|
|
38
41
|
|
|
@@ -70,12 +73,15 @@ function getUpdateType(
|
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
/**
|
|
73
|
-
* Check if a new version is available
|
|
74
|
-
* Returns cached result
|
|
76
|
+
* Check if a new version is available.
|
|
77
|
+
* Returns the session-cached result unless `force` requests a fresh check.
|
|
75
78
|
*/
|
|
76
|
-
export async function checkForUpdates(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
export async function checkForUpdates(
|
|
80
|
+
force = false,
|
|
81
|
+
): Promise<VersionCheckResult> {
|
|
82
|
+
// Return the session-cached result unless a fresh check is forced — a periodic
|
|
83
|
+
// re-check passes force:true so a release landing mid-session updates the banner.
|
|
84
|
+
if (!force && cachedResult) {
|
|
79
85
|
return cachedResult;
|
|
80
86
|
}
|
|
81
87
|
|
package/src/types/index.ts
CHANGED
|
@@ -51,6 +51,23 @@ export interface DiscoveredMarketplace {
|
|
|
51
51
|
config?: MarketplaceSource;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* One entry of a plugin's recent release history, as published in marketplace.json.
|
|
56
|
+
*
|
|
57
|
+
* Kept separate from `description` on purpose: `description` says what a plugin *is*
|
|
58
|
+
* and changes rarely, while this says what *changed* and turns over every release.
|
|
59
|
+
* Folding the second into the first is what left plugin panels showing a changelog
|
|
60
|
+
* line where the plugin's purpose should be.
|
|
61
|
+
*/
|
|
62
|
+
export interface PluginRelease {
|
|
63
|
+
version: string;
|
|
64
|
+
/** ISO date (YYYY-MM-DD). Absent on entries whose heading carried no date. */
|
|
65
|
+
date?: string;
|
|
66
|
+
/** Change categories in the entry — "Added", "Changed", "Fixed", … */
|
|
67
|
+
kinds?: string[];
|
|
68
|
+
summary: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
54
71
|
export interface Plugin {
|
|
55
72
|
name: string;
|
|
56
73
|
version: string;
|
package/src/ui/App.tsx
CHANGED
|
@@ -348,11 +348,22 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
348
348
|
} | null>(null);
|
|
349
349
|
const gitignoreModal = useGitignoreModal();
|
|
350
350
|
|
|
351
|
-
// Check for updates on startup (
|
|
351
|
+
// Check for updates on startup, then re-check periodically (force-bypassing
|
|
352
|
+
// the session cache) so the banner reflects a release that lands while
|
|
353
|
+
// claudeup is left open — otherwise it shows the stale startup value.
|
|
352
354
|
useEffect(() => {
|
|
353
355
|
checkForUpdates()
|
|
354
356
|
.then(setUpdateInfo)
|
|
355
357
|
.catch(() => {});
|
|
358
|
+
const id = setInterval(
|
|
359
|
+
() => {
|
|
360
|
+
checkForUpdates(true)
|
|
361
|
+
.then(setUpdateInfo)
|
|
362
|
+
.catch(() => {});
|
|
363
|
+
},
|
|
364
|
+
15 * 60 * 1000, // 15 min
|
|
365
|
+
);
|
|
366
|
+
return () => clearInterval(id);
|
|
356
367
|
}, []);
|
|
357
368
|
|
|
358
369
|
// Auto-dismiss recovery banner after 5 seconds
|
|
@@ -18,6 +18,7 @@ import { highlightMatches } from "../../utils/fuzzy-search.js";
|
|
|
18
18
|
import { getMarketplaceVersion } from "../../services/marketplace-fetcher.js";
|
|
19
19
|
import { isEnabledButNotInstalled } from "../../services/plugin-manager.js";
|
|
20
20
|
import { isKnownVersion } from "../../services/version-snapshot.js";
|
|
21
|
+
import type { PluginRelease } from "../../types/index.js";
|
|
21
22
|
|
|
22
23
|
// ─── Category renderers ───────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -358,6 +359,9 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
|
|
|
358
359
|
</box>
|
|
359
360
|
</DetailSection>
|
|
360
361
|
|
|
362
|
+
{/* Recent releases */}
|
|
363
|
+
<ReleaseHistory releases={plugin.releases} installedVersion={plugin.installedVersion} />
|
|
364
|
+
|
|
361
365
|
{/* Update action */}
|
|
362
366
|
{isInstalled && plugin.hasUpdate ? (
|
|
363
367
|
<ActionHints
|
|
@@ -368,6 +372,73 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
|
|
|
368
372
|
);
|
|
369
373
|
}
|
|
370
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Recent release history, newest first.
|
|
377
|
+
*
|
|
378
|
+
* Releases the user does not have yet are marked and coloured; the installed one is
|
|
379
|
+
* flagged; older ones are dimmed. The list is already ordered newest-first, so the
|
|
380
|
+
* installed entry's index is all that's needed to tell those three groups apart —
|
|
381
|
+
* no version comparison, and no way for it to disagree with the published order.
|
|
382
|
+
* When the installed version isn't in the window (unknown "0.0.0", or older than the
|
|
383
|
+
* releases published), everything renders neutrally rather than guessing.
|
|
384
|
+
*/
|
|
385
|
+
function ReleaseHistory({
|
|
386
|
+
releases,
|
|
387
|
+
installedVersion,
|
|
388
|
+
}: {
|
|
389
|
+
releases?: PluginRelease[];
|
|
390
|
+
installedVersion?: string;
|
|
391
|
+
}): React.ReactNode {
|
|
392
|
+
if (!releases?.length) return null;
|
|
393
|
+
|
|
394
|
+
const installedAt = installedVersion
|
|
395
|
+
? releases.findIndex((r) => r.version === installedVersion)
|
|
396
|
+
: -1;
|
|
397
|
+
|
|
398
|
+
return (
|
|
399
|
+
<DetailSection>
|
|
400
|
+
<text>{"─".repeat(24)}</text>
|
|
401
|
+
<text>
|
|
402
|
+
<strong>Recent releases:</strong>
|
|
403
|
+
</text>
|
|
404
|
+
<box marginTop={1} flexDirection="column">
|
|
405
|
+
{releases.map((release, i) => {
|
|
406
|
+
const isNewer = installedAt > 0 && i < installedAt;
|
|
407
|
+
const isInstalled = installedAt >= 0 && i === installedAt;
|
|
408
|
+
const versionColor = isNewer
|
|
409
|
+
? theme.colors.warning
|
|
410
|
+
: isInstalled
|
|
411
|
+
? theme.colors.success
|
|
412
|
+
: theme.colors.link;
|
|
413
|
+
|
|
414
|
+
return (
|
|
415
|
+
<box key={release.version} flexDirection="column" marginBottom={1}>
|
|
416
|
+
<text>
|
|
417
|
+
<span fg={versionColor}>
|
|
418
|
+
{isNewer ? "▲ " : isInstalled ? "● " : " "}v{release.version}
|
|
419
|
+
</span>
|
|
420
|
+
{release.date ? (
|
|
421
|
+
<span fg={theme.colors.muted}> {release.date}</span>
|
|
422
|
+
) : null}
|
|
423
|
+
{release.kinds?.length ? (
|
|
424
|
+
<span fg={theme.colors.dim}> {release.kinds.join(" · ")}</span>
|
|
425
|
+
) : null}
|
|
426
|
+
{isInstalled ? (
|
|
427
|
+
<span fg={theme.colors.success}> installed</span>
|
|
428
|
+
) : null}
|
|
429
|
+
</text>
|
|
430
|
+
<text fg={isNewer ? theme.colors.text : theme.colors.muted}>
|
|
431
|
+
{" "}
|
|
432
|
+
{release.summary}
|
|
433
|
+
</text>
|
|
434
|
+
</box>
|
|
435
|
+
);
|
|
436
|
+
})}
|
|
437
|
+
</box>
|
|
438
|
+
</DetailSection>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
371
442
|
// ─── Public dispatch functions ────────────────────────────────────────────────
|
|
372
443
|
|
|
373
444
|
/**
|