@pi-unipi/updater 2.6.1 → 2.6.2
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 +7 -3
- package/src/changelog.ts +1 -16
- package/src/index.ts +0 -5
- package/src/installer.ts +4 -26
- package/src/markdown.ts +6 -152
- package/src/settings.ts +0 -15
- package/src/tui/changelog-overlay.ts +42 -234
- package/src/tui/list-detail-overlay.ts +260 -0
- package/src/tui/readme-overlay.ts +53 -210
- package/src/tui/settings-overlay.ts +13 -26
- package/src/version.ts +2 -31
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/updater",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.2",
|
|
4
4
|
"description": "Auto-updater, changelog browser, and readme browser for Unipi — checks npm registry, renders CHANGELOG.md and README.md files in TUI overlays",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -45,8 +45,12 @@
|
|
|
45
45
|
"typescript": "^6.0.0"
|
|
46
46
|
},
|
|
47
47
|
"pi": {
|
|
48
|
-
"extensions": [
|
|
49
|
-
|
|
48
|
+
"extensions": [
|
|
49
|
+
"./index.ts"
|
|
50
|
+
],
|
|
51
|
+
"skills": [
|
|
52
|
+
"./skills"
|
|
53
|
+
],
|
|
50
54
|
"prompts": [],
|
|
51
55
|
"themes": []
|
|
52
56
|
}
|
package/src/changelog.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { existsSync, readFileSync } from "fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { findPackageRoot } from "@pi-unipi/core";
|
|
11
|
+
import { findPackageRoot, compareVersions } from "@pi-unipi/core";
|
|
12
12
|
import { isNewerVersion } from "./version.js";
|
|
13
13
|
import type { ChangelogEntry } from "../types.js";
|
|
14
14
|
|
|
@@ -155,18 +155,3 @@ export function getNewerVersions(
|
|
|
155
155
|
}
|
|
156
156
|
return result;
|
|
157
157
|
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Compare semver strings (simple lexicographic for x.y.z format).
|
|
161
|
-
* Returns positive if a > b, negative if a < b, 0 if equal.
|
|
162
|
-
*/
|
|
163
|
-
export function compareVersions(a: string, b: string): number {
|
|
164
|
-
const pa = a.split(".").map(Number);
|
|
165
|
-
const pb = b.split(".").map(Number);
|
|
166
|
-
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
167
|
-
const na = pa[i] ?? 0;
|
|
168
|
-
const nb = pb[i] ?? 0;
|
|
169
|
-
if (na !== nb) return na - nb;
|
|
170
|
-
}
|
|
171
|
-
return 0;
|
|
172
|
-
}
|
package/src/index.ts
CHANGED
package/src/installer.ts
CHANGED
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
* @pi-unipi/updater — Update installer
|
|
3
3
|
*
|
|
4
4
|
* Wraps child_process.exec for installing updates via pi CLI.
|
|
5
|
-
* Emits UPDATE_APPLIED or UPDATE_ERROR events.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
import { exec } from "child_process";
|
|
9
8
|
import { promisify } from "util";
|
|
10
|
-
import { getInstalledPackageVersion
|
|
9
|
+
import { getInstalledPackageVersion } from "@pi-unipi/core";
|
|
11
10
|
import type { InstallResult } from "../types.js";
|
|
12
11
|
|
|
13
12
|
const execAsync = promisify(exec);
|
|
@@ -20,14 +19,11 @@ const INSTALL_TIMEOUT_MS = 60000;
|
|
|
20
19
|
* Uses pi CLI: `pi install npm:@pi-unipi/unipi`
|
|
21
20
|
* Returns structured result with success/failure info.
|
|
22
21
|
*/
|
|
23
|
-
export async function installUpdate(
|
|
24
|
-
pi?: { events: { emit: (name: string, payload: unknown) => void } },
|
|
25
|
-
): Promise<InstallResult> {
|
|
22
|
+
export async function installUpdate(): Promise<InstallResult> {
|
|
26
23
|
const thisDir = new URL("..", import.meta.url).pathname;
|
|
27
|
-
const installedBefore = getInstalledPackageVersion(thisDir, "@pi-unipi/unipi");
|
|
28
24
|
|
|
29
25
|
try {
|
|
30
|
-
|
|
26
|
+
await execAsync(
|
|
31
27
|
"pi install npm:@pi-unipi/unipi",
|
|
32
28
|
{
|
|
33
29
|
timeout: INSTALL_TIMEOUT_MS,
|
|
@@ -38,34 +34,16 @@ export async function installUpdate(
|
|
|
38
34
|
// Get new version after install
|
|
39
35
|
const installedAfter = getInstalledPackageVersion(thisDir, "@pi-unipi/unipi");
|
|
40
36
|
|
|
41
|
-
|
|
37
|
+
return {
|
|
42
38
|
success: true,
|
|
43
39
|
version: installedAfter,
|
|
44
40
|
};
|
|
45
|
-
|
|
46
|
-
// Emit success event
|
|
47
|
-
if (pi) {
|
|
48
|
-
emitEvent(pi, UNIPI_EVENTS.UPDATE_APPLIED, {
|
|
49
|
-
previousVersion: installedBefore,
|
|
50
|
-
newVersion: installedAfter,
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return result;
|
|
55
41
|
} catch (err: unknown) {
|
|
56
42
|
const errorMessage = (err instanceof Error && 'stderr' in err ? String((err as Error & { stderr?: string }).stderr) : undefined)
|
|
57
43
|
|| (err instanceof Error ? err.message : undefined)
|
|
58
44
|
|| String(err)
|
|
59
45
|
|| "Unknown install error";
|
|
60
46
|
|
|
61
|
-
// Emit error event
|
|
62
|
-
if (pi) {
|
|
63
|
-
emitEvent(pi, UNIPI_EVENTS.UPDATE_ERROR, {
|
|
64
|
-
error: errorMessage,
|
|
65
|
-
phase: "install",
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
47
|
return {
|
|
70
48
|
success: false,
|
|
71
49
|
error: errorMessage,
|
package/src/markdown.ts
CHANGED
|
@@ -1,173 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @pi-unipi/updater — Markdown terminal renderer
|
|
3
3
|
*
|
|
4
|
-
* Renders markdown to terminal-formatted strings
|
|
5
|
-
*
|
|
6
|
-
* with theme-aware styling. Falls back to simple ANSI rendering otherwise.
|
|
4
|
+
* Renders markdown to terminal-formatted strings using the full
|
|
5
|
+
* Markdown component from pi-tui with theme-aware styling.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import { Markdown } from "@earendil-works/pi-tui";
|
|
10
|
-
import type { MarkdownTheme } from "@earendil-works/pi-tui";
|
|
11
9
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
12
10
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
13
11
|
|
|
14
12
|
/**
|
|
15
13
|
* Render markdown text to terminal-formatted lines.
|
|
16
14
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* Uses pi-tui's Markdown component with syntax highlighting,
|
|
16
|
+
* proper list nesting, tables, etc.
|
|
19
17
|
*
|
|
20
18
|
* @param text - Markdown text to render
|
|
21
19
|
* @param width - Available width for rendering
|
|
22
|
-
* @param theme -
|
|
20
|
+
* @param theme - Theme for styled rendering
|
|
23
21
|
* @returns Array of rendered terminal lines
|
|
24
22
|
*/
|
|
25
|
-
export function renderMarkdown(text: string, width: number, theme
|
|
26
|
-
if (theme) {
|
|
27
|
-
return renderWithTheme(text, width, theme);
|
|
28
|
-
}
|
|
29
|
-
return renderSimple(text, width);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Render using the full Markdown component with theme support.
|
|
34
|
-
*/
|
|
35
|
-
function renderWithTheme(text: string, width: number, theme: Theme): string[] {
|
|
23
|
+
export function renderMarkdown(text: string, width: number, theme: Theme): string[] {
|
|
36
24
|
const mdTheme = getMarkdownTheme();
|
|
37
25
|
const md = new Markdown(text, 1, 0, mdTheme);
|
|
38
26
|
return md.render(width);
|
|
39
27
|
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Simple fallback renderer using basic ANSI codes.
|
|
43
|
-
* Used when no Theme is available.
|
|
44
|
-
*/
|
|
45
|
-
|
|
46
|
-
/** ANSI escape codes */
|
|
47
|
-
const ESC = "\x1b";
|
|
48
|
-
const BOLD = `${ESC}[1m`;
|
|
49
|
-
const DIM = `${ESC}[2m`;
|
|
50
|
-
const UNDERLINE = `${ESC}[4m`;
|
|
51
|
-
const RESET = `${ESC}[0m`;
|
|
52
|
-
|
|
53
|
-
/** Wrap text in ANSI formatting */
|
|
54
|
-
function fmt(code: string, text: string): string {
|
|
55
|
-
return `${code}${text}${RESET}`;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Word-wrap a line to fit within width */
|
|
59
|
-
function wordWrap(line: string, width: number): string[] {
|
|
60
|
-
// Strip ANSI for length calculation but preserve in output
|
|
61
|
-
const stripped = line.replace(/\x1b\[[0-9;]*m/g, "");
|
|
62
|
-
if (stripped.length <= width) return [line];
|
|
63
|
-
|
|
64
|
-
const words = line.split(/(\s+)/);
|
|
65
|
-
const result: string[] = [];
|
|
66
|
-
let currentLine = "";
|
|
67
|
-
let currentWidth = 0;
|
|
68
|
-
|
|
69
|
-
for (const word of words) {
|
|
70
|
-
const wordWidth = word.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
71
|
-
if (currentWidth + wordWidth > width && currentLine) {
|
|
72
|
-
result.push(currentLine);
|
|
73
|
-
currentLine = word.trimStart();
|
|
74
|
-
currentWidth = currentLine.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
75
|
-
} else {
|
|
76
|
-
currentLine += word;
|
|
77
|
-
currentWidth += wordWidth;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
if (currentLine) result.push(currentLine);
|
|
81
|
-
return result.length > 0 ? result : [""];
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Apply inline formatting: bold, italic, code, links */
|
|
85
|
-
function formatInline(text: string): string {
|
|
86
|
-
// Inline code: `code`
|
|
87
|
-
text = text.replace(/`([^`]+)`/g, (_, code) => fmt(DIM, code));
|
|
88
|
-
|
|
89
|
-
// Bold: **text**
|
|
90
|
-
text = text.replace(/\*\*([^*]+)\*\*/g, (_, bold) => fmt(BOLD, bold));
|
|
91
|
-
|
|
92
|
-
// Italic: *text*
|
|
93
|
-
text = text.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, (_, italic) => fmt(UNDERLINE, italic));
|
|
94
|
-
|
|
95
|
-
// Links: [text](url) → underlined text
|
|
96
|
-
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, (_, linkText) => fmt(UNDERLINE, linkText));
|
|
97
|
-
|
|
98
|
-
return text;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Simple markdown renderer (fallback).
|
|
103
|
-
*/
|
|
104
|
-
function renderSimple(text: string, width: number): string[] {
|
|
105
|
-
const lines = text.split("\n");
|
|
106
|
-
const result: string[] = [];
|
|
107
|
-
let inCodeBlock = false;
|
|
108
|
-
|
|
109
|
-
for (const line of lines) {
|
|
110
|
-
const trimmed = line.trim();
|
|
111
|
-
|
|
112
|
-
// Code fence toggle
|
|
113
|
-
if (trimmed.startsWith("```")) {
|
|
114
|
-
inCodeBlock = !inCodeBlock;
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Inside code block — dim, no formatting
|
|
119
|
-
if (inCodeBlock) {
|
|
120
|
-
const formatted = fmt(DIM, line);
|
|
121
|
-
result.push(...wordWrap(formatted, width));
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Heading: #, ##, ###
|
|
126
|
-
if (trimmed.startsWith("### ")) {
|
|
127
|
-
const heading = trimmed.slice(4);
|
|
128
|
-
const formatted = fmt(BOLD + UNDERLINE, heading);
|
|
129
|
-
result.push(...wordWrap(formatted, width));
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
if (trimmed.startsWith("## ")) {
|
|
133
|
-
const heading = trimmed.slice(3);
|
|
134
|
-
const formatted = fmt(BOLD, heading);
|
|
135
|
-
result.push(...wordWrap(formatted, width));
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
if (trimmed.startsWith("# ")) {
|
|
139
|
-
const heading = trimmed.slice(2);
|
|
140
|
-
const formatted = fmt(BOLD, heading);
|
|
141
|
-
result.push(...wordWrap(formatted, width));
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Bullet list: - or *
|
|
146
|
-
if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
|
|
147
|
-
const content = trimmed.slice(2);
|
|
148
|
-
const formatted = " • " + formatInline(content);
|
|
149
|
-
result.push(...wordWrap(formatted, width));
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Numbered list: 1. 2. etc.
|
|
154
|
-
const numberMatch = trimmed.match(/^(\d+)\.\s+(.+)$/);
|
|
155
|
-
if (numberMatch) {
|
|
156
|
-
const formatted = ` ${numberMatch[1]}. ${formatInline(numberMatch[2])}`;
|
|
157
|
-
result.push(...wordWrap(formatted, width));
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Empty line — preserve spacing
|
|
162
|
-
if (!trimmed) {
|
|
163
|
-
result.push("");
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Regular paragraph — apply inline formatting
|
|
168
|
-
const formatted = formatInline(trimmed);
|
|
169
|
-
result.push(...wordWrap(formatted, width));
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return result;
|
|
173
|
-
}
|
package/src/settings.ts
CHANGED
|
@@ -56,21 +56,6 @@ export function saveConfig(config: UpdaterConfig): void {
|
|
|
56
56
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
/** Validate config, returning list of error messages */
|
|
60
|
-
export function validateConfig(config: UpdaterConfig): string[] {
|
|
61
|
-
const errors: string[] = [];
|
|
62
|
-
|
|
63
|
-
if (!VALID_MODES.includes(config.autoUpdate)) {
|
|
64
|
-
errors.push(`autoUpdate must be one of: ${VALID_MODES.join(", ")}`);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (config.checkIntervalMs < 60000) {
|
|
68
|
-
errors.push("checkIntervalMs must be at least 60000 (1 minute)");
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return errors;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
59
|
/** Get human-readable label for an interval */
|
|
75
60
|
export function getIntervalLabel(ms: number): string {
|
|
76
61
|
for (const [label, value] of Object.entries(VALID_INTERVALS)) {
|
|
@@ -1,257 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @pi-unipi/updater — Changelog TUI Overlay
|
|
3
3
|
*
|
|
4
|
-
* Version list with Current/New labels, Enter opens detail view
|
|
5
|
-
* Uses ctx.ui.custom() overlay API with component return pattern.
|
|
4
|
+
* Version list with Current/New labels, Enter opens detail view.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
import { existsSync } from "fs";
|
|
9
|
-
import { join } from "path";
|
|
10
|
-
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
11
8
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
12
9
|
import { parseChangelog, resolveChangelogPath } from "../changelog.js";
|
|
13
10
|
import { renderMarkdown } from "../markdown.js";
|
|
14
|
-
import { getInstalledPackageVersion
|
|
11
|
+
import { getInstalledPackageVersion } from "@pi-unipi/core";
|
|
12
|
+
import { createListDetailOverlay } from "./list-detail-overlay.js";
|
|
15
13
|
import type { ChangelogEntry } from "../../types.js";
|
|
16
14
|
|
|
17
|
-
type View = "list" | "detail";
|
|
18
|
-
|
|
19
|
-
interface ChangelogState {
|
|
20
|
-
view: View;
|
|
21
|
-
entries: ChangelogEntry[];
|
|
22
|
-
listIndex: number;
|
|
23
|
-
listScroll: number;
|
|
24
|
-
detailScroll: number;
|
|
25
|
-
installedVersion: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Pad content to exact visible width.
|
|
30
|
-
*/
|
|
31
|
-
function padVisible(content: string, targetWidth: number): string {
|
|
32
|
-
const vw = visibleWidth(content);
|
|
33
|
-
const pad = Math.max(0, targetWidth - vw);
|
|
34
|
-
return content + " ".repeat(pad);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
15
|
/**
|
|
38
16
|
* Render the changelog overlay.
|
|
39
17
|
*/
|
|
40
18
|
export function renderChangelogOverlay() {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
view: "list",
|
|
54
|
-
entries: [],
|
|
55
|
-
listIndex: 0,
|
|
56
|
-
listScroll: 0,
|
|
57
|
-
detailScroll: 0,
|
|
58
|
-
installedVersion,
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
// Load changelog
|
|
62
|
-
let loaded = false;
|
|
63
|
-
const ensureLoaded = () => {
|
|
64
|
-
if (loaded) return;
|
|
19
|
+
const installedVersion = getInstalledPackageVersion(
|
|
20
|
+
new URL("..", import.meta.url).pathname,
|
|
21
|
+
"@pi-unipi/unipi",
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
return createListDetailOverlay<ChangelogEntry>({
|
|
25
|
+
title: " 📋 Changelog ",
|
|
26
|
+
emptyMessage: "No changelog available.",
|
|
27
|
+
listFooter: " j/k navigate Enter view details q/Esc close",
|
|
28
|
+
detailFooter: " j/k scroll q/Esc back to list",
|
|
29
|
+
|
|
30
|
+
loadEntries: () => {
|
|
65
31
|
const changelogPath = resolveChangelogPath();
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const innerWidth = boxInnerWidth(width);
|
|
76
|
-
const lines: string[] = [];
|
|
77
|
-
|
|
78
|
-
// ── Header ──────────────────────────────────────────────────────
|
|
79
|
-
lines.push(theme.fg("accent", `╭${"─".repeat(innerWidth)}╮`));
|
|
80
|
-
lines.push(
|
|
81
|
-
theme.fg("accent", "│") +
|
|
82
|
-
padVisible(theme.fg("accent", theme.bold(" 📋 Changelog ")), innerWidth) +
|
|
83
|
-
theme.fg("accent", "│"),
|
|
84
|
-
);
|
|
85
|
-
lines.push(theme.fg("accent", `├${"─".repeat(innerWidth)}┤`));
|
|
86
|
-
|
|
87
|
-
// ── Content ─────────────────────────────────────────────────────
|
|
88
|
-
if (state.view === "list") {
|
|
89
|
-
renderListView(lines, innerWidth);
|
|
32
|
+
return existsSync(changelogPath) ? parseChangelog(changelogPath) : [];
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
renderItem: (entry, selected, theme) => {
|
|
36
|
+
let label: string;
|
|
37
|
+
if (entry.version === "Unreleased") {
|
|
38
|
+
label = theme.fg("muted", "Unreleased");
|
|
39
|
+
} else if (entry.version === installedVersion) {
|
|
40
|
+
label = theme.fg("success", "✓ Current");
|
|
90
41
|
} else {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
? ` ${theme.fg("accent", "j/k")} navigate ${theme.fg("success", "Enter")} view details ${theme.fg("error", "q/Esc")} close`
|
|
99
|
-
: ` ${theme.fg("accent", "j/k")} scroll ${theme.fg("error", "q/Esc")} back to list`;
|
|
100
|
-
lines.push(
|
|
101
|
-
theme.fg("accent", "│") +
|
|
102
|
-
padVisible(truncateToWidth(footer, innerWidth), innerWidth) +
|
|
103
|
-
theme.fg("accent", "│"),
|
|
104
|
-
);
|
|
105
|
-
lines.push(theme.fg("accent", `╰${"─".repeat(innerWidth)}╯`));
|
|
106
|
-
|
|
107
|
-
return lines;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
const renderListView = (lines: string[], innerWidth: number) => {
|
|
111
|
-
if (state.entries.length === 0) {
|
|
112
|
-
lines.push(
|
|
113
|
-
theme.fg("accent", "│") +
|
|
114
|
-
padVisible(theme.fg("muted", " No changelog available."), innerWidth) +
|
|
115
|
-
theme.fg("accent", "│"),
|
|
116
|
-
);
|
|
117
|
-
return;
|
|
42
|
+
const pa = entry.version.split(".").map(Number);
|
|
43
|
+
const pb = installedVersion.split(".").map(Number);
|
|
44
|
+
const isNewer =
|
|
45
|
+
pa[0]! > pb[0]! ||
|
|
46
|
+
(pa[0] === pb[0] && pa[1]! > pb[1]!) ||
|
|
47
|
+
(pa[0] === pb[0] && pa[1] === pb[1] && pa[2]! > pb[2]!);
|
|
48
|
+
label = isNewer ? theme.fg("warning", "↑ New") : "";
|
|
118
49
|
}
|
|
119
50
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (state.listIndex < state.listScroll) state.listScroll = state.listIndex;
|
|
126
|
-
if (state.listIndex >= state.listScroll + maxLines) {
|
|
127
|
-
state.listScroll = state.listIndex - maxLines + 1;
|
|
128
|
-
}
|
|
51
|
+
const version = selected ? theme.bold(entry.version) : theme.fg("text", entry.version);
|
|
52
|
+
const date = entry.date ? ` — ${theme.fg("muted", entry.date)}` : "";
|
|
53
|
+
const prefix = selected ? theme.fg("accent", "▸ ") : " ";
|
|
54
|
+
return ` ${prefix}${version}${date} ${label}`;
|
|
55
|
+
},
|
|
129
56
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
for (let i = 0; i < visible.length; i++) {
|
|
133
|
-
const entry = visible[i]!;
|
|
134
|
-
const globalIdx = state.listScroll + i;
|
|
135
|
-
const selected = globalIdx === state.listIndex;
|
|
136
|
-
const prefix = selected ? theme.fg("accent", "▸ ") : " ";
|
|
137
|
-
|
|
138
|
-
let label: string;
|
|
139
|
-
if (entry.version === "Unreleased") {
|
|
140
|
-
label = theme.fg("muted", "Unreleased");
|
|
141
|
-
} else if (entry.version === state.installedVersion) {
|
|
142
|
-
label = theme.fg("success", "✓ Current");
|
|
143
|
-
} else {
|
|
144
|
-
const pa = entry.version.split(".").map(Number);
|
|
145
|
-
const pb = state.installedVersion.split(".").map(Number);
|
|
146
|
-
const isNewer =
|
|
147
|
-
pa[0]! > pb[0]! ||
|
|
148
|
-
(pa[0] === pb[0] && pa[1]! > pb[1]!) ||
|
|
149
|
-
(pa[0] === pb[0] && pa[1] === pb[1] && pa[2]! > pb[2]!);
|
|
150
|
-
label = isNewer ? theme.fg("warning", "↑ New") : "";
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const version = selected ? theme.bold(entry.version) : theme.fg("text", entry.version);
|
|
154
|
-
const date = entry.date ? ` — ${theme.fg("muted", entry.date)}` : "";
|
|
155
|
-
const line = ` ${prefix}${version}${date} ${label}`;
|
|
156
|
-
lines.push(
|
|
157
|
-
theme.fg("accent", "│") +
|
|
158
|
-
padVisible(
|
|
159
|
-
selected ? theme.bg("selectedBg", truncateToWidth(line, innerWidth)) : truncateToWidth(line, innerWidth),
|
|
160
|
-
innerWidth,
|
|
161
|
-
) +
|
|
162
|
-
theme.fg("accent", "│"),
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
const renderDetailView = (lines: string[], innerWidth: number) => {
|
|
168
|
-
const entry = state.entries[state.listIndex];
|
|
169
|
-
if (!entry) {
|
|
170
|
-
lines.push(
|
|
171
|
-
theme.fg("accent", "│") +
|
|
172
|
-
padVisible(theme.fg("muted", " No entry selected."), innerWidth) +
|
|
173
|
-
theme.fg("accent", "│"),
|
|
174
|
-
);
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const title = entry.date
|
|
57
|
+
renderDetailTitle: (entry, theme) =>
|
|
58
|
+
entry.date
|
|
179
59
|
? `${theme.bold(entry.version)} — ${theme.fg("muted", entry.date)}`
|
|
180
|
-
: `${theme.bold(entry.version)} — ${theme.fg("muted", "Unreleased")}
|
|
181
|
-
lines.push(
|
|
182
|
-
theme.fg("accent", "│") +
|
|
183
|
-
padVisible(truncateToWidth(` ${title}`, innerWidth), innerWidth) +
|
|
184
|
-
theme.fg("accent", "│"),
|
|
185
|
-
);
|
|
186
|
-
lines.push(
|
|
187
|
-
theme.fg("accent", "│") +
|
|
188
|
-
padVisible("", innerWidth) +
|
|
189
|
-
theme.fg("accent", "│"),
|
|
190
|
-
);
|
|
191
|
-
|
|
192
|
-
const bodyLines = renderMarkdown(entry.body, innerWidth - 2, theme);
|
|
193
|
-
const maxScroll = Math.max(0, bodyLines.length - 15);
|
|
194
|
-
state.detailScroll = Math.min(state.detailScroll, maxScroll);
|
|
195
|
-
state.detailScroll = Math.max(0, state.detailScroll);
|
|
196
|
-
|
|
197
|
-
const visible = bodyLines.slice(state.detailScroll, state.detailScroll + 15);
|
|
198
|
-
for (const line of visible) {
|
|
199
|
-
lines.push(
|
|
200
|
-
theme.fg("accent", "│") +
|
|
201
|
-
padVisible(truncateToWidth(` ${line}`, innerWidth), innerWidth) +
|
|
202
|
-
theme.fg("accent", "│"),
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
const handleInput = (data: string) => {
|
|
208
|
-
ensureLoaded();
|
|
209
|
-
|
|
210
|
-
// Close from list view
|
|
211
|
-
if ((matchesKey(data, Key.escape) || data === "q") && state.view === "list") {
|
|
212
|
-
done({ viewed: true });
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Back from detail view
|
|
217
|
-
if ((matchesKey(data, Key.escape) || data === "q") && state.view === "detail") {
|
|
218
|
-
state.view = "list";
|
|
219
|
-
state.detailScroll = 0;
|
|
220
|
-
tui.requestRender();
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Navigation
|
|
225
|
-
if (state.view === "list") {
|
|
226
|
-
if (matchesKey(data, Key.down) || data === "j") {
|
|
227
|
-
state.listIndex = Math.min(state.listIndex + 1, state.entries.length - 1);
|
|
228
|
-
} else if (matchesKey(data, Key.up) || data === "k") {
|
|
229
|
-
state.listIndex = Math.max(state.listIndex - 1, 0);
|
|
230
|
-
} else if (matchesKey(data, Key.enter)) {
|
|
231
|
-
if (state.entries.length > 0) {
|
|
232
|
-
state.view = "detail";
|
|
233
|
-
state.detailScroll = 0;
|
|
234
|
-
}
|
|
235
|
-
} else if (data === "g") {
|
|
236
|
-
state.listIndex = 0;
|
|
237
|
-
} else if (data === "G") {
|
|
238
|
-
state.listIndex = state.entries.length - 1;
|
|
239
|
-
}
|
|
240
|
-
} else {
|
|
241
|
-
if (matchesKey(data, Key.down) || data === "j") {
|
|
242
|
-
state.detailScroll++;
|
|
243
|
-
} else if (matchesKey(data, Key.up) || data === "k") {
|
|
244
|
-
state.detailScroll = Math.max(0, state.detailScroll - 1);
|
|
245
|
-
} else if (data === "g") {
|
|
246
|
-
state.detailScroll = 0;
|
|
247
|
-
} else if (data === "G") {
|
|
248
|
-
state.detailScroll = 999999;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
tui.requestRender();
|
|
253
|
-
};
|
|
60
|
+
: `${theme.bold(entry.version)} — ${theme.fg("muted", "Unreleased")}`,
|
|
254
61
|
|
|
255
|
-
|
|
256
|
-
|
|
62
|
+
renderDetailBody: (entry, innerWidth, theme) =>
|
|
63
|
+
renderMarkdown(entry.body, innerWidth - 2, theme),
|
|
64
|
+
});
|
|
257
65
|
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/updater — Shared List+Detail TUI Overlay
|
|
3
|
+
*
|
|
4
|
+
* Parameterized overlay for browse-a-list-then-view-detail patterns.
|
|
5
|
+
* Used by both the changelog and readme browsers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { boxInnerWidth } from "@pi-unipi/core";
|
|
11
|
+
|
|
12
|
+
/** Pad content to exact visible width. */
|
|
13
|
+
function padVisible(content: string, targetWidth: number): string {
|
|
14
|
+
const vw = visibleWidth(content);
|
|
15
|
+
const pad = Math.max(0, targetWidth - vw);
|
|
16
|
+
return content + " ".repeat(pad);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Configuration for a list+detail overlay. */
|
|
20
|
+
export interface ListDetailConfig<T> {
|
|
21
|
+
/** Header title (e.g. " 📋 Changelog ") */
|
|
22
|
+
title: string;
|
|
23
|
+
/** Message when list is empty */
|
|
24
|
+
emptyMessage: string;
|
|
25
|
+
/** Footer text in list view */
|
|
26
|
+
listFooter: string;
|
|
27
|
+
/** Footer text in detail view */
|
|
28
|
+
detailFooter: string;
|
|
29
|
+
/** Load list entries lazily on first render */
|
|
30
|
+
loadEntries: () => T[];
|
|
31
|
+
/** Render a single list item line (without the │ borders) */
|
|
32
|
+
renderItem: (entry: T, selected: boolean, theme: Theme) => string;
|
|
33
|
+
/** Render the detail title line for an entry */
|
|
34
|
+
renderDetailTitle: (entry: T, theme: Theme) => string;
|
|
35
|
+
/** Render the detail body lines for an entry */
|
|
36
|
+
renderDetailBody: (entry: T, innerWidth: number, theme: Theme) => string[];
|
|
37
|
+
/** Optional: if set, Enter in list view calls this instead of default detail switch.
|
|
38
|
+
* Return true to switch to detail view, false to stay. */
|
|
39
|
+
onEnter?: (entry: T, tui: import("@earendil-works/pi-tui").TUI, theme: Theme) => boolean;
|
|
40
|
+
/** Optional: if set, Esc from detail view closes the overlay instead of returning to list */
|
|
41
|
+
closeOnDetailBack?: boolean;
|
|
42
|
+
/** Optional: open directly to a specific entry's detail view */
|
|
43
|
+
openDirectIndex?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type View = "list" | "detail";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create a list+detail overlay component.
|
|
50
|
+
* Returns the ctx.ui.custom() callback.
|
|
51
|
+
*/
|
|
52
|
+
export function createListDetailOverlay<T>(
|
|
53
|
+
config: ListDetailConfig<T>,
|
|
54
|
+
): (
|
|
55
|
+
tui: import("@earendil-works/pi-tui").TUI,
|
|
56
|
+
theme: Theme,
|
|
57
|
+
_kb: import("@earendil-works/pi-coding-agent").KeybindingsManager,
|
|
58
|
+
done: (result: { viewed: boolean } | null) => void,
|
|
59
|
+
) => { render: (width: number) => string[]; handleInput: (data: string) => void; invalidate: () => void; focused: boolean } {
|
|
60
|
+
return (tui, theme, _kb, done) => {
|
|
61
|
+
const state: {
|
|
62
|
+
view: View;
|
|
63
|
+
entries: T[];
|
|
64
|
+
listIndex: number;
|
|
65
|
+
listScroll: number;
|
|
66
|
+
detailScroll: number;
|
|
67
|
+
} = {
|
|
68
|
+
view: "list",
|
|
69
|
+
entries: [],
|
|
70
|
+
listIndex: 0,
|
|
71
|
+
listScroll: 0,
|
|
72
|
+
detailScroll: 0,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
let loaded = false;
|
|
76
|
+
const ensureLoaded = () => {
|
|
77
|
+
if (loaded) return;
|
|
78
|
+
state.entries = config.loadEntries();
|
|
79
|
+
if (config.openDirectIndex !== undefined && config.openDirectIndex >= 0 && config.openDirectIndex < state.entries.length) {
|
|
80
|
+
state.listIndex = config.openDirectIndex;
|
|
81
|
+
state.view = "detail";
|
|
82
|
+
state.detailScroll = 0;
|
|
83
|
+
}
|
|
84
|
+
loaded = true;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const render = (width: number): string[] => {
|
|
88
|
+
ensureLoaded();
|
|
89
|
+
|
|
90
|
+
const innerWidth = boxInnerWidth(width);
|
|
91
|
+
const lines: string[] = [];
|
|
92
|
+
|
|
93
|
+
// ── Header ──────────────────────────────────────────────────────
|
|
94
|
+
lines.push(theme.fg("accent", `╭${"─".repeat(innerWidth)}╮`));
|
|
95
|
+
lines.push(
|
|
96
|
+
theme.fg("accent", "│") +
|
|
97
|
+
padVisible(theme.fg("accent", theme.bold(config.title)), innerWidth) +
|
|
98
|
+
theme.fg("accent", "│"),
|
|
99
|
+
);
|
|
100
|
+
lines.push(theme.fg("accent", `├${"─".repeat(innerWidth)}┤`));
|
|
101
|
+
|
|
102
|
+
// ── Content ─────────────────────────────────────────────────────
|
|
103
|
+
if (state.view === "list") {
|
|
104
|
+
renderListView(lines, innerWidth);
|
|
105
|
+
} else {
|
|
106
|
+
renderDetailView(lines, innerWidth);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Footer ──────────────────────────────────────────────────────
|
|
110
|
+
lines.push(theme.fg("accent", `├${"─".repeat(innerWidth)}┤`));
|
|
111
|
+
const footer = state.view === "list" ? config.listFooter : config.detailFooter;
|
|
112
|
+
lines.push(
|
|
113
|
+
theme.fg("accent", "│") +
|
|
114
|
+
padVisible(truncateToWidth(footer, innerWidth), innerWidth) +
|
|
115
|
+
theme.fg("accent", "│"),
|
|
116
|
+
);
|
|
117
|
+
lines.push(theme.fg("accent", `╰${"─".repeat(innerWidth)}╯`));
|
|
118
|
+
|
|
119
|
+
return lines;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const renderListView = (lines: string[], innerWidth: number) => {
|
|
123
|
+
if (state.entries.length === 0) {
|
|
124
|
+
lines.push(
|
|
125
|
+
theme.fg("accent", "│") +
|
|
126
|
+
padVisible(theme.fg("muted", ` ${config.emptyMessage}`), innerWidth) +
|
|
127
|
+
theme.fg("accent", "│"),
|
|
128
|
+
);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
state.listIndex = Math.min(state.listIndex, state.entries.length - 1);
|
|
133
|
+
state.listIndex = Math.max(0, state.listIndex);
|
|
134
|
+
|
|
135
|
+
const maxLines = 20;
|
|
136
|
+
if (state.listIndex < state.listScroll) state.listScroll = state.listIndex;
|
|
137
|
+
if (state.listIndex >= state.listScroll + maxLines) {
|
|
138
|
+
state.listScroll = state.listIndex - maxLines + 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const visible = state.entries.slice(state.listScroll, state.listScroll + maxLines);
|
|
142
|
+
|
|
143
|
+
for (let i = 0; i < visible.length; i++) {
|
|
144
|
+
const entry = visible[i]!;
|
|
145
|
+
const globalIdx = state.listScroll + i;
|
|
146
|
+
const selected = globalIdx === state.listIndex;
|
|
147
|
+
const line = config.renderItem(entry, selected, theme);
|
|
148
|
+
lines.push(
|
|
149
|
+
theme.fg("accent", "│") +
|
|
150
|
+
padVisible(
|
|
151
|
+
selected ? theme.bg("selectedBg", truncateToWidth(line, innerWidth)) : truncateToWidth(line, innerWidth),
|
|
152
|
+
innerWidth,
|
|
153
|
+
) +
|
|
154
|
+
theme.fg("accent", "│"),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const renderDetailView = (lines: string[], innerWidth: number) => {
|
|
160
|
+
const entry = state.entries[state.listIndex];
|
|
161
|
+
if (!entry) {
|
|
162
|
+
lines.push(
|
|
163
|
+
theme.fg("accent", "│") +
|
|
164
|
+
padVisible(theme.fg("muted", " No entry selected."), innerWidth) +
|
|
165
|
+
theme.fg("accent", "│"),
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const title = config.renderDetailTitle(entry, theme);
|
|
171
|
+
lines.push(
|
|
172
|
+
theme.fg("accent", "│") +
|
|
173
|
+
padVisible(truncateToWidth(` ${title}`, innerWidth), innerWidth) +
|
|
174
|
+
theme.fg("accent", "│"),
|
|
175
|
+
);
|
|
176
|
+
lines.push(
|
|
177
|
+
theme.fg("accent", "│") +
|
|
178
|
+
padVisible("", innerWidth) +
|
|
179
|
+
theme.fg("accent", "│"),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const bodyLines = config.renderDetailBody(entry, innerWidth, theme);
|
|
183
|
+
const maxScroll = Math.max(0, bodyLines.length - 15);
|
|
184
|
+
state.detailScroll = Math.min(state.detailScroll, maxScroll);
|
|
185
|
+
state.detailScroll = Math.max(0, state.detailScroll);
|
|
186
|
+
|
|
187
|
+
const visible = bodyLines.slice(state.detailScroll, state.detailScroll + 15);
|
|
188
|
+
for (const line of visible) {
|
|
189
|
+
lines.push(
|
|
190
|
+
theme.fg("accent", "│") +
|
|
191
|
+
padVisible(truncateToWidth(` ${line}`, innerWidth), innerWidth) +
|
|
192
|
+
theme.fg("accent", "│"),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const handleInput = (data: string) => {
|
|
198
|
+
ensureLoaded();
|
|
199
|
+
|
|
200
|
+
// Close from list view
|
|
201
|
+
if ((matchesKey(data, Key.escape) || data === "q") && state.view === "list") {
|
|
202
|
+
done({ viewed: true });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Back from detail view
|
|
207
|
+
if ((matchesKey(data, Key.escape) || data === "q") && state.view === "detail") {
|
|
208
|
+
if (config.closeOnDetailBack) {
|
|
209
|
+
done({ viewed: true });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
state.view = "list";
|
|
213
|
+
state.detailScroll = 0;
|
|
214
|
+
tui.requestRender();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Navigation
|
|
219
|
+
if (state.view === "list") {
|
|
220
|
+
if (matchesKey(data, Key.down) || data === "j") {
|
|
221
|
+
state.listIndex = Math.min(state.listIndex + 1, state.entries.length - 1);
|
|
222
|
+
} else if (matchesKey(data, Key.up) || data === "k") {
|
|
223
|
+
state.listIndex = Math.max(state.listIndex - 1, 0);
|
|
224
|
+
} else if (matchesKey(data, Key.enter)) {
|
|
225
|
+
if (state.entries.length > 0) {
|
|
226
|
+
if (config.onEnter) {
|
|
227
|
+
const entry = state.entries[state.listIndex]!;
|
|
228
|
+
const shouldSwitch = config.onEnter(entry, tui, theme);
|
|
229
|
+
if (shouldSwitch) {
|
|
230
|
+
state.view = "detail";
|
|
231
|
+
state.detailScroll = 0;
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
state.view = "detail";
|
|
235
|
+
state.detailScroll = 0;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} else if (data === "g") {
|
|
239
|
+
state.listIndex = 0;
|
|
240
|
+
} else if (data === "G") {
|
|
241
|
+
state.listIndex = state.entries.length - 1;
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
if (matchesKey(data, Key.down) || data === "j") {
|
|
245
|
+
state.detailScroll++;
|
|
246
|
+
} else if (matchesKey(data, Key.up) || data === "k") {
|
|
247
|
+
state.detailScroll = Math.max(0, state.detailScroll - 1);
|
|
248
|
+
} else if (data === "g") {
|
|
249
|
+
state.detailScroll = 0;
|
|
250
|
+
} else if (data === "G") {
|
|
251
|
+
state.detailScroll = 999999;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
tui.requestRender();
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
return { render, handleInput, invalidate: () => {}, focused: true };
|
|
259
|
+
};
|
|
260
|
+
}
|
|
@@ -7,231 +7,74 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { readFileSync } from "fs";
|
|
10
|
-
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
11
10
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
12
11
|
import { discoverReadmes, resolveReadmePath } from "../readme.js";
|
|
13
12
|
import { renderMarkdown } from "../markdown.js";
|
|
13
|
+
import { createListDetailOverlay } from "./list-detail-overlay.js";
|
|
14
14
|
import type { ReadmeEntry } from "../../types.js";
|
|
15
|
-
import { boxInnerWidth } from "@pi-unipi/core";
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
interface ReadmeState {
|
|
20
|
-
view: View;
|
|
21
|
-
entries: ReadmeEntry[];
|
|
22
|
-
listIndex: number;
|
|
23
|
-
listScroll: number;
|
|
24
|
-
contentScroll: number;
|
|
25
|
-
contentLines: string[];
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Pad content to exact visible width.
|
|
30
|
-
*/
|
|
31
|
-
function padVisible(content: string, targetWidth: number): string {
|
|
32
|
-
const vw = visibleWidth(content);
|
|
33
|
-
const pad = Math.max(0, targetWidth - vw);
|
|
34
|
-
return content + " ".repeat(pad);
|
|
16
|
+
interface ReadmeParams {
|
|
17
|
+
openDirect?: string;
|
|
35
18
|
}
|
|
36
19
|
|
|
37
20
|
/**
|
|
38
21
|
* Render the readme overlay.
|
|
39
22
|
*/
|
|
40
|
-
export function renderReadmeOverlay(params?:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
contentLines: [],
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
let loaded = false;
|
|
57
|
-
const ensureLoaded = () => {
|
|
58
|
-
if (loaded) return;
|
|
59
|
-
state.entries = discoverReadmes();
|
|
60
|
-
|
|
23
|
+
export function renderReadmeOverlay(params?: ReadmeParams) {
|
|
24
|
+
// Cache for rendered markdown content (keyed by entry path)
|
|
25
|
+
const contentCache = new Map<string, string[]>();
|
|
26
|
+
let openDirectIndex: number | undefined;
|
|
27
|
+
|
|
28
|
+
return createListDetailOverlay<ReadmeEntry>({
|
|
29
|
+
title: " 📖 README Browser ",
|
|
30
|
+
emptyMessage: "No README files found.",
|
|
31
|
+
listFooter: " j/k navigate Enter read q/Esc close",
|
|
32
|
+
detailFooter: " j/k scroll q/Esc back to list",
|
|
33
|
+
|
|
34
|
+
loadEntries: () => {
|
|
35
|
+
const entries = discoverReadmes();
|
|
61
36
|
if (params?.openDirect) {
|
|
62
37
|
const readmePath = resolveReadmePath(params.openDirect);
|
|
63
38
|
if (readmePath) {
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
39
|
+
const idx = entries.findIndex((e) => e.path === readmePath);
|
|
40
|
+
if (idx >= 0) {
|
|
41
|
+
openDirectIndex = idx;
|
|
42
|
+
// Pre-load the content
|
|
43
|
+
const content = readFileSync(readmePath, "utf-8");
|
|
44
|
+
const lines = renderMarkdown(content, (process.stdout.columns ?? 80) - 4, undefined as any);
|
|
45
|
+
contentCache.set(readmePath, lines);
|
|
46
|
+
}
|
|
67
47
|
}
|
|
68
48
|
}
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
renderListView(lines, innerWidth);
|
|
90
|
-
} else {
|
|
91
|
-
renderContentView(lines, innerWidth);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// ── Footer ──────────────────────────────────────────────────────
|
|
95
|
-
lines.push(theme.fg("accent", `├${"─".repeat(innerWidth)}┤`));
|
|
96
|
-
const footer =
|
|
97
|
-
state.view === "list"
|
|
98
|
-
? ` ${theme.fg("accent", "j/k")} navigate ${theme.fg("success", "Enter")} read ${theme.fg("error", "q/Esc")} close`
|
|
99
|
-
: ` ${theme.fg("accent", "j/k")} scroll ${theme.fg("error", "q/Esc")} back to list`;
|
|
100
|
-
lines.push(
|
|
101
|
-
theme.fg("accent", "│") +
|
|
102
|
-
padVisible(truncateToWidth(footer, innerWidth), innerWidth) +
|
|
103
|
-
theme.fg("accent", "│"),
|
|
104
|
-
);
|
|
105
|
-
lines.push(theme.fg("accent", `╰${"─".repeat(innerWidth)}╯`));
|
|
106
|
-
|
|
107
|
-
return lines;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
const renderListView = (lines: string[], innerWidth: number) => {
|
|
111
|
-
if (state.entries.length === 0) {
|
|
112
|
-
lines.push(
|
|
113
|
-
theme.fg("accent", "│") +
|
|
114
|
-
padVisible(theme.fg("muted", " No README files found."), innerWidth) +
|
|
115
|
-
theme.fg("accent", "│"),
|
|
116
|
-
);
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
state.listIndex = Math.min(state.listIndex, state.entries.length - 1);
|
|
121
|
-
state.listIndex = Math.max(0, state.listIndex);
|
|
122
|
-
|
|
123
|
-
const maxLines = 20;
|
|
124
|
-
if (state.listIndex < state.listScroll) state.listScroll = state.listIndex;
|
|
125
|
-
if (state.listIndex >= state.listScroll + maxLines) {
|
|
126
|
-
state.listScroll = state.listIndex - maxLines + 1;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const visible = state.entries.slice(state.listScroll, state.listScroll + maxLines);
|
|
130
|
-
|
|
131
|
-
for (let i = 0; i < visible.length; i++) {
|
|
132
|
-
const entry = visible[i]!;
|
|
133
|
-
const globalIdx = state.listScroll + i;
|
|
134
|
-
const selected = globalIdx === state.listIndex;
|
|
135
|
-
const prefix = selected ? theme.fg("accent", "▸ ") : " ";
|
|
136
|
-
|
|
137
|
-
const name = selected ? theme.bold(entry.name) : theme.fg("text", entry.name);
|
|
138
|
-
const version = theme.fg("muted", `v${entry.version}`);
|
|
139
|
-
const line = ` ${prefix}${name} ${version}`;
|
|
140
|
-
lines.push(
|
|
141
|
-
theme.fg("accent", "│") +
|
|
142
|
-
padVisible(
|
|
143
|
-
selected ? theme.bg("selectedBg", truncateToWidth(line, innerWidth)) : truncateToWidth(line, innerWidth),
|
|
144
|
-
innerWidth,
|
|
145
|
-
) +
|
|
146
|
-
theme.fg("accent", "│"),
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
const renderContentView = (lines: string[], innerWidth: number) => {
|
|
152
|
-
if (state.contentLines.length === 0) {
|
|
153
|
-
lines.push(
|
|
154
|
-
theme.fg("accent", "│") +
|
|
155
|
-
padVisible(theme.fg("muted", " No content available."), innerWidth) +
|
|
156
|
-
theme.fg("accent", "│"),
|
|
157
|
-
);
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const maxLines = 20;
|
|
162
|
-
const maxScroll = Math.max(0, state.contentLines.length - maxLines);
|
|
163
|
-
state.contentScroll = Math.min(state.contentScroll, maxScroll);
|
|
164
|
-
state.contentScroll = Math.max(0, state.contentScroll);
|
|
165
|
-
|
|
166
|
-
const visible = state.contentLines.slice(state.contentScroll, state.contentScroll + maxLines);
|
|
167
|
-
for (const line of visible) {
|
|
168
|
-
lines.push(
|
|
169
|
-
theme.fg("accent", "│") +
|
|
170
|
-
padVisible(truncateToWidth(` ${line}`, innerWidth), innerWidth) +
|
|
171
|
-
theme.fg("accent", "│"),
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
const handleInput = (data: string) => {
|
|
177
|
-
ensureLoaded();
|
|
178
|
-
|
|
179
|
-
// Close from list view
|
|
180
|
-
if ((matchesKey(data, Key.escape) || data === "q") && state.view === "list") {
|
|
181
|
-
done({ viewed: true });
|
|
182
|
-
return;
|
|
49
|
+
return entries;
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
openDirectIndex,
|
|
53
|
+
closeOnDetailBack: !!params?.openDirect,
|
|
54
|
+
|
|
55
|
+
renderItem: (entry, selected, theme) => {
|
|
56
|
+
const name = selected ? theme.bold(entry.name) : theme.fg("text", entry.name);
|
|
57
|
+
const version = theme.fg("muted", `v${entry.version}`);
|
|
58
|
+
const prefix = selected ? theme.fg("accent", "▸ ") : " ";
|
|
59
|
+
return ` ${prefix}${name} ${version}`;
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
renderDetailTitle: (entry, theme) =>
|
|
63
|
+
`${theme.bold(entry.name)} — ${theme.fg("muted", `v${entry.version}`)}`,
|
|
64
|
+
|
|
65
|
+
renderDetailBody: (entry, _innerWidth, theme) => {
|
|
66
|
+
// Return cached content if available
|
|
67
|
+
if (contentCache.has(entry.path)) {
|
|
68
|
+
return contentCache.get(entry.path)!;
|
|
183
69
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
state.view = "list";
|
|
192
|
-
state.contentScroll = 0;
|
|
193
|
-
tui.requestRender();
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (state.view === "list") {
|
|
198
|
-
if (matchesKey(data, Key.down) || data === "j") {
|
|
199
|
-
state.listIndex = Math.min(state.listIndex + 1, state.entries.length - 1);
|
|
200
|
-
} else if (matchesKey(data, Key.up) || data === "k") {
|
|
201
|
-
state.listIndex = Math.max(state.listIndex - 1, 0);
|
|
202
|
-
} else if (matchesKey(data, Key.enter)) {
|
|
203
|
-
if (state.entries.length > 0) {
|
|
204
|
-
const entry = state.entries[state.listIndex]!;
|
|
205
|
-
try {
|
|
206
|
-
const content = readFileSync(entry.path, "utf-8");
|
|
207
|
-
state.contentLines = renderMarkdown(content, (tui.terminal?.columns ?? 80) - 4, theme);
|
|
208
|
-
state.contentScroll = 0;
|
|
209
|
-
state.view = "content";
|
|
210
|
-
} catch {
|
|
211
|
-
state.contentLines = [" Error reading README file."];
|
|
212
|
-
state.view = "content";
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
} else if (data === "g") {
|
|
216
|
-
state.listIndex = 0;
|
|
217
|
-
} else if (data === "G") {
|
|
218
|
-
state.listIndex = state.entries.length - 1;
|
|
219
|
-
}
|
|
220
|
-
} else {
|
|
221
|
-
if (matchesKey(data, Key.down) || data === "j") {
|
|
222
|
-
state.contentScroll++;
|
|
223
|
-
} else if (matchesKey(data, Key.up) || data === "k") {
|
|
224
|
-
state.contentScroll = Math.max(0, state.contentScroll - 1);
|
|
225
|
-
} else if (data === "g") {
|
|
226
|
-
state.contentScroll = 0;
|
|
227
|
-
} else if (data === "G") {
|
|
228
|
-
state.contentScroll = 999999;
|
|
229
|
-
}
|
|
70
|
+
try {
|
|
71
|
+
const content = readFileSync(entry.path, "utf-8");
|
|
72
|
+
const lines = renderMarkdown(content, (process.stdout.columns ?? 80) - 4, theme);
|
|
73
|
+
contentCache.set(entry.path, lines);
|
|
74
|
+
return lines;
|
|
75
|
+
} catch {
|
|
76
|
+
return [" Error reading README file."];
|
|
230
77
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
return { render, handleInput, invalidate: () => {}, focused: true };
|
|
236
|
-
};
|
|
78
|
+
},
|
|
79
|
+
});
|
|
237
80
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Space cycles options, Enter saves, Esc cancels.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
8
9
|
import {
|
|
9
10
|
loadConfig,
|
|
10
11
|
saveConfig,
|
|
@@ -22,21 +23,6 @@ const TEAL = `${ESC}[36m`;
|
|
|
22
23
|
const GREEN = `${ESC}[32m`;
|
|
23
24
|
const RESET = `${ESC}[0m`;
|
|
24
25
|
|
|
25
|
-
/** Truncate string to visible width */
|
|
26
|
-
function trunc(text: string, width: number): string {
|
|
27
|
-
let vw = 0;
|
|
28
|
-
let result = "";
|
|
29
|
-
let inEsc = false;
|
|
30
|
-
for (const ch of text) {
|
|
31
|
-
if (ch === "\x1b") { inEsc = true; result += ch; continue; }
|
|
32
|
-
if (inEsc) { result += ch; if (ch === "m") inEsc = false; continue; }
|
|
33
|
-
if (vw >= width) break;
|
|
34
|
-
result += ch;
|
|
35
|
-
vw++;
|
|
36
|
-
}
|
|
37
|
-
return result;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
26
|
/**
|
|
41
27
|
* Render the settings overlay.
|
|
42
28
|
*/
|
|
@@ -58,7 +44,7 @@ export function renderSettingsOverlay() {
|
|
|
58
44
|
const render = (width: number): string[] => {
|
|
59
45
|
const lines: string[] = [];
|
|
60
46
|
|
|
61
|
-
lines.push(
|
|
47
|
+
lines.push(truncateToWidth(` ${BOLD}⚙ Updater Settings${RESET}`, width));
|
|
62
48
|
lines.push("─".repeat(width));
|
|
63
49
|
lines.push("");
|
|
64
50
|
|
|
@@ -67,7 +53,7 @@ export function renderSettingsOverlay() {
|
|
|
67
53
|
const row0Selected = state.row === 0;
|
|
68
54
|
const row0Prefix = row0Selected ? `${TEAL}▸${RESET} ` : " ";
|
|
69
55
|
lines.push(
|
|
70
|
-
|
|
56
|
+
truncateToWidth(
|
|
71
57
|
` ${row0Prefix}${BOLD}Check Interval${RESET} ${DIM}${intervalLabel}${RESET}`,
|
|
72
58
|
width,
|
|
73
59
|
),
|
|
@@ -81,7 +67,7 @@ export function renderSettingsOverlay() {
|
|
|
81
67
|
: `${DIM}○ ${opt.label}${RESET}`;
|
|
82
68
|
})
|
|
83
69
|
.join(" ");
|
|
84
|
-
lines.push(
|
|
70
|
+
lines.push(truncateToWidth(` ${intervalLine}`, width));
|
|
85
71
|
lines.push("");
|
|
86
72
|
|
|
87
73
|
// Row 1: Auto Update
|
|
@@ -89,7 +75,7 @@ export function renderSettingsOverlay() {
|
|
|
89
75
|
const row1Selected = state.row === 1;
|
|
90
76
|
const row1Prefix = row1Selected ? `${TEAL}▸${RESET} ` : " ";
|
|
91
77
|
lines.push(
|
|
92
|
-
|
|
78
|
+
truncateToWidth(
|
|
93
79
|
` ${row1Prefix}${BOLD}Auto Update${RESET} ${DIM}${modeLabel}${RESET}`,
|
|
94
80
|
width,
|
|
95
81
|
),
|
|
@@ -103,12 +89,12 @@ export function renderSettingsOverlay() {
|
|
|
103
89
|
: `${DIM}○ ${mode}${RESET}`;
|
|
104
90
|
})
|
|
105
91
|
.join(" ");
|
|
106
|
-
lines.push(
|
|
92
|
+
lines.push(truncateToWidth(` ${modeLine}`, width));
|
|
107
93
|
lines.push("");
|
|
108
94
|
|
|
109
95
|
lines.push("─".repeat(width));
|
|
110
96
|
lines.push(
|
|
111
|
-
|
|
97
|
+
truncateToWidth(
|
|
112
98
|
` j/k: navigate Space: cycle ${GREEN}Enter: save${RESET} ${DIM}Esc: cancel${RESET}`,
|
|
113
99
|
width,
|
|
114
100
|
),
|
|
@@ -119,9 +105,10 @@ export function renderSettingsOverlay() {
|
|
|
119
105
|
|
|
120
106
|
const handleInput = (data: string) => {
|
|
121
107
|
const key = data.toLowerCase();
|
|
108
|
+
const keyRaw = data;
|
|
122
109
|
|
|
123
110
|
// Close without saving
|
|
124
|
-
if (
|
|
111
|
+
if (matchesKey(keyRaw, Key.escape)) {
|
|
125
112
|
done({ saved: false });
|
|
126
113
|
return;
|
|
127
114
|
}
|
|
@@ -134,9 +121,9 @@ export function renderSettingsOverlay() {
|
|
|
134
121
|
}
|
|
135
122
|
|
|
136
123
|
// Navigate rows
|
|
137
|
-
if (key === "j" ||
|
|
124
|
+
if (key === "j" || matchesKey(keyRaw, Key.down)) {
|
|
138
125
|
state.row = Math.min(state.row + 1, 1);
|
|
139
|
-
} else if (key === "k" ||
|
|
126
|
+
} else if (key === "k" || matchesKey(keyRaw, Key.up)) {
|
|
140
127
|
state.row = Math.max(state.row - 1, 0);
|
|
141
128
|
}
|
|
142
129
|
|
|
@@ -156,7 +143,7 @@ export function renderSettingsOverlay() {
|
|
|
156
143
|
}
|
|
157
144
|
|
|
158
145
|
// Cycle with left/right
|
|
159
|
-
if (key === "h" ||
|
|
146
|
+
if (key === "h" || matchesKey(keyRaw, Key.left)) {
|
|
160
147
|
if (state.row === 0) {
|
|
161
148
|
const currentIdx = intervalOptions.findIndex(
|
|
162
149
|
(opt) => opt.ms === state.config.checkIntervalMs,
|
|
@@ -169,7 +156,7 @@ export function renderSettingsOverlay() {
|
|
|
169
156
|
state.config.autoUpdate = modeOptions[prevIdx];
|
|
170
157
|
}
|
|
171
158
|
}
|
|
172
|
-
if (key === "l" ||
|
|
159
|
+
if (key === "l" || matchesKey(keyRaw, Key.right)) {
|
|
173
160
|
if (state.row === 0) {
|
|
174
161
|
const currentIdx = intervalOptions.findIndex(
|
|
175
162
|
(opt) => opt.ms === state.config.checkIntervalMs,
|
package/src/version.ts
CHANGED
|
@@ -1,36 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @pi-unipi/updater — version comparison helpers
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Compare two semver-ish version strings.
|
|
7
|
-
* Returns 1 when a > b, -1 when a < b, 0 when equal.
|
|
8
3
|
*
|
|
9
|
-
*
|
|
10
|
-
* without adding a runtime dependency. Non-numeric suffixes are ignored for
|
|
11
|
-
* ordering, so `2.0.5` and `v2.0.5` compare equal.
|
|
4
|
+
* Re-exported from @pi-unipi/core for backward compatibility.
|
|
12
5
|
*/
|
|
13
|
-
export function compareVersions(a: string, b: string): number {
|
|
14
|
-
const parse = (version: string): number[] => version
|
|
15
|
-
.replace(/^v/, "")
|
|
16
|
-
.split(/[.-]/)
|
|
17
|
-
.slice(0, 3)
|
|
18
|
-
.map((part) => {
|
|
19
|
-
const parsed = Number.parseInt(part, 10);
|
|
20
|
-
return Number.isNaN(parsed) ? 0 : parsed;
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
const left = parse(a);
|
|
24
|
-
const right = parse(b);
|
|
25
|
-
for (let i = 0; i < 3; i++) {
|
|
26
|
-
const diff = (left[i] ?? 0) - (right[i] ?? 0);
|
|
27
|
-
if (diff > 0) return 1;
|
|
28
|
-
if (diff < 0) return -1;
|
|
29
|
-
}
|
|
30
|
-
return 0;
|
|
31
|
-
}
|
|
32
6
|
|
|
33
|
-
|
|
34
|
-
export function isNewerVersion(latest: string, current: string): boolean {
|
|
35
|
-
return compareVersions(latest, current) > 0;
|
|
36
|
-
}
|
|
7
|
+
export { compareVersions, isNewerVersion } from "@pi-unipi/core";
|