herdr-remote 0.2.18 → 0.2.19
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/bin/herdr-remote.js +3 -225
- package/dist/agent-status.js +130 -0
- package/dist/binary-plist.js +157 -0
- package/dist/cli.js +255 -0
- package/dist/config.js +270 -0
- package/dist/connector/agent-watch.js +110 -0
- package/dist/connector/font-server.js +165 -0
- package/dist/connector/host-connector.js +430 -0
- package/dist/connector/lock.js +66 -0
- package/dist/connector/main.js +18 -0
- package/dist/connector/sessions.js +366 -0
- package/dist/connector/update-report.js +67 -0
- package/dist/connector/wire.js +42 -0
- package/{src → dist}/exit-codes.js +1 -4
- package/dist/font-subset.js +110 -0
- package/dist/herdr-api.js +246 -0
- package/dist/herdr-command.js +223 -0
- package/dist/herdr-plugin.js +116 -0
- package/dist/herdr-server.js +189 -0
- package/dist/i18n/en.js +223 -0
- package/dist/i18n/index.js +48 -0
- package/dist/i18n/zh.js +222 -0
- package/dist/keepalive/detached.js +78 -0
- package/dist/keepalive/environment.js +44 -0
- package/dist/keepalive/index.js +110 -0
- package/dist/keepalive/launchd.js +117 -0
- package/dist/keepalive/systemd.js +108 -0
- package/dist/keepalive/types.js +1 -0
- package/dist/lib/process.js +12 -0
- package/dist/lifecycle.js +59 -0
- package/dist/net-interfaces.js +73 -0
- package/dist/pasted-files.js +115 -0
- package/dist/paths.js +62 -0
- package/dist/pty-session.js +96 -0
- package/dist/relay-client.js +106 -0
- package/dist/relay-urls.js +99 -0
- package/dist/runtime.js +106 -0
- package/dist/service.js +260 -0
- package/dist/settings-model.js +298 -0
- package/dist/socket-discovery.js +48 -0
- package/dist/supervisor.js +319 -0
- package/dist/terminal-font/deps.js +73 -0
- package/dist/terminal-font/files.js +354 -0
- package/dist/terminal-font/formats.js +335 -0
- package/dist/terminal-font/index.js +187 -0
- package/dist/terminal-font/readers.js +316 -0
- package/dist/terminal-font/terminals.js +128 -0
- package/dist/terminal-palette.js +339 -0
- package/dist/tui/App.js +195 -0
- package/dist/tui/api.js +79 -0
- package/dist/tui/components/TextField.js +50 -0
- package/dist/tui/components/common.js +80 -0
- package/dist/tui/index.js +36 -0
- package/dist/tui/mouse/index.js +164 -0
- package/dist/tui/mouse/protocol.js +96 -0
- package/dist/tui/mouse/source.js +74 -0
- package/dist/tui/screens/About.js +166 -0
- package/dist/tui/screens/Herdr.js +74 -0
- package/dist/tui/screens/Keepalive.js +84 -0
- package/dist/tui/screens/Overview.js +36 -0
- package/dist/tui/screens/Pair.js +74 -0
- package/dist/tui/screens/Relay.js +242 -0
- package/dist/tui/screens/Services.js +63 -0
- package/dist/tui/screens/Wizard.js +235 -0
- package/dist/tui/terminal.js +39 -0
- package/dist/tui/theme.js +33 -0
- package/dist/updater.js +382 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +8 -8
- package/dist/tui.mjs +0 -5775
- package/src/agent-status.js +0 -139
- package/src/binary-plist.js +0 -146
- package/src/config.js +0 -454
- package/src/font-subset.js +0 -110
- package/src/herdr-api.js +0 -244
- package/src/herdr-command.js +0 -231
- package/src/herdr-plugin.js +0 -130
- package/src/herdr-server.js +0 -201
- package/src/host-connector.js +0 -1069
- package/src/i18n/en.js +0 -240
- package/src/i18n/index.js +0 -56
- package/src/i18n/zh.js +0 -239
- package/src/keepalive.js +0 -460
- package/src/lifecycle.js +0 -62
- package/src/net-interfaces.js +0 -76
- package/src/pasted-files.js +0 -176
- package/src/pty-session.js +0 -92
- package/src/service.js +0 -530
- package/src/settings-model.js +0 -323
- package/src/socket-discovery.js +0 -60
- package/src/state.js +0 -47
- package/src/supervisor.js +0 -250
- package/src/terminal-font.js +0 -1204
- package/src/terminal-palette.js +0 -325
- package/src/updater.js +0 -394
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef } from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { useMouseTarget } from '../mouse/index.js';
|
|
5
|
+
import { SELECTED_MARKER, STATUS_COLOR, STATUS_GLYPH, UNSELECTED_MARKER, theme, } from '../theme.js';
|
|
6
|
+
export function StatusDot({ level }) {
|
|
7
|
+
return _jsx(Text, { color: STATUS_COLOR[level], children: STATUS_GLYPH[level] });
|
|
8
|
+
}
|
|
9
|
+
// Wide enough for the longest label in either language plus a separating
|
|
10
|
+
// space. Too narrow and Ink wraps the label onto a second line or butts it
|
|
11
|
+
// straight against its value.
|
|
12
|
+
const LABEL_COLUMN_WIDTH = 20;
|
|
13
|
+
/** A `label value` row with the labels aligned into a column. */
|
|
14
|
+
export function Row({ label, labelWidth = LABEL_COLUMN_WIDTH, children, }) {
|
|
15
|
+
return (_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, flexShrink: 0, children: _jsx(Text, { color: theme.muted, wrap: "truncate-end", children: label }) }), _jsx(Box, { flexGrow: 1, children: children })] }));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* One row of a list that responds to both the keyboard and the mouse.
|
|
19
|
+
*
|
|
20
|
+
* Each row is its own component because the mouse hooks bind to a ref: mapping
|
|
21
|
+
* over items in the parent and calling hooks inline would make the hook order
|
|
22
|
+
* depend on the list length.
|
|
23
|
+
*
|
|
24
|
+
* The row stretches to the full width of its container so the whole line is a
|
|
25
|
+
* click target, not just the few columns the label happens to occupy.
|
|
26
|
+
*/
|
|
27
|
+
export function Selectable({ selected, disabled = false, onSelect, onHover, children, }) {
|
|
28
|
+
const ref = useRef(null);
|
|
29
|
+
const hovered = useMouseTarget(ref, { disabled, onClick: onSelect });
|
|
30
|
+
useFollowPointer(hovered, onHover);
|
|
31
|
+
const active = selected || hovered;
|
|
32
|
+
const color = disabled ? theme.muted : active ? theme.accent : undefined;
|
|
33
|
+
return (_jsxs(Box, { ref: ref, children: [_jsxs(Text, { color: active ? theme.accent : undefined, children: [active ? SELECTED_MARKER : UNSELECTED_MARKER, ' '] }), _jsx(Text, { color: color, bold: active, dimColor: disabled, children: children })] }));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Move the keyboard cursor to whatever the pointer is over.
|
|
37
|
+
*
|
|
38
|
+
* Without this the two run independently and a list shows two markers at once —
|
|
39
|
+
* one where the keyboard is, one under the mouse — leaving it ambiguous which
|
|
40
|
+
* one Enter would activate.
|
|
41
|
+
*/
|
|
42
|
+
function useFollowPointer(hovered, onHover) {
|
|
43
|
+
const handler = useRef(onHover);
|
|
44
|
+
handler.current = onHover;
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (hovered)
|
|
47
|
+
handler.current?.();
|
|
48
|
+
}, [hovered]);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Keyboard/mouse driven list. `onChange` moves the highlight, `onSelect`
|
|
52
|
+
* activates an entry.
|
|
53
|
+
*/
|
|
54
|
+
export function Menu({ items, selectedId, onChange, onSelect, }) {
|
|
55
|
+
return (_jsx(Box, { flexDirection: "column", children: items.map((item) => (_jsx(Selectable, { selected: item.id === selectedId, disabled: item.disabled, onSelect: () => onSelect(item.id), onHover: () => onChange(item.id), children: item.label }, item.id))) }));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A `label value` row that is clickable across its whole width.
|
|
59
|
+
*
|
|
60
|
+
* Used for settings: clicking anywhere on the line — label or value — starts
|
|
61
|
+
* editing it, which is what a pointer user expects.
|
|
62
|
+
*/
|
|
63
|
+
export function FieldRow({ label, selected, disabled = false, labelWidth = 22, onSelect, onHover, children, }) {
|
|
64
|
+
const ref = useRef(null);
|
|
65
|
+
const hovered = useMouseTarget(ref, { disabled, onClick: onSelect });
|
|
66
|
+
useFollowPointer(hovered, onHover);
|
|
67
|
+
const active = selected || hovered;
|
|
68
|
+
return (_jsxs(Box, { ref: ref, children: [_jsxs(Box, { width: labelWidth, flexShrink: 0, children: [_jsxs(Text, { color: active ? theme.accent : undefined, children: [active ? SELECTED_MARKER : UNSELECTED_MARKER, ' '] }), _jsx(Text, { color: active ? theme.accent : theme.muted, bold: active, wrap: "truncate-end", children: label })] }), _jsx(Box, { flexGrow: 1, children: children })] }));
|
|
69
|
+
}
|
|
70
|
+
/** A framed panel used for every screen body, so the layout stays predictable. */
|
|
71
|
+
export function Panel({ title, children }) {
|
|
72
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, flexGrow: 1, children: [title ? (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: title }) })) : null, children] }));
|
|
73
|
+
}
|
|
74
|
+
/** Feedback from the last action. Colour is the only signal, no icons. */
|
|
75
|
+
export function Message({ text, level, }) {
|
|
76
|
+
if (!text)
|
|
77
|
+
return null;
|
|
78
|
+
const color = level === 'error' ? theme.bad : level === 'success' ? theme.ok : theme.muted;
|
|
79
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: color, children: text }) }));
|
|
80
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from 'ink';
|
|
3
|
+
import { App } from './App.js';
|
|
4
|
+
import { MouseProvider, createMouseSource } from './mouse/index.js';
|
|
5
|
+
import { enterFullScreen, restoreTerminal } from './terminal.js';
|
|
6
|
+
import { configExists } from './api.js';
|
|
7
|
+
export { App } from './App.js';
|
|
8
|
+
export { Wizard } from './screens/Wizard.js';
|
|
9
|
+
export async function startTui({ language = null } = {}) {
|
|
10
|
+
const needsWizard = !configExists();
|
|
11
|
+
const mouse = createMouseSource(process.stdin, process.stdout);
|
|
12
|
+
// Before render: Ink draws from wherever the cursor is, and the mouse hit
|
|
13
|
+
// test assumes the app starts at row 1.
|
|
14
|
+
enterFullScreen();
|
|
15
|
+
const instance = render(_jsx(MouseProvider, { source: mouse, children: _jsx(App, { initialLanguage: language, needsWizard: needsWizard }) }), { stdin: mouse.stdin, exitOnCtrlC: false });
|
|
16
|
+
const cleanup = () => {
|
|
17
|
+
mouse.dispose();
|
|
18
|
+
restoreTerminal();
|
|
19
|
+
};
|
|
20
|
+
const onSignal = () => {
|
|
21
|
+
instance.unmount();
|
|
22
|
+
cleanup();
|
|
23
|
+
process.exit(0);
|
|
24
|
+
};
|
|
25
|
+
process.once('SIGINT', onSignal);
|
|
26
|
+
process.once('SIGTERM', onSignal);
|
|
27
|
+
process.once('exit', cleanup);
|
|
28
|
+
try {
|
|
29
|
+
await instance.waitUntilExit();
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
cleanup();
|
|
33
|
+
process.off('SIGINT', onSignal);
|
|
34
|
+
process.off('SIGTERM', onSignal);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
3
|
+
export { createMouseSource } from './source.js';
|
|
4
|
+
export { splitMouseInput, createMouseSplitter } from './protocol.js';
|
|
5
|
+
/**
|
|
6
|
+
* Absolute position of an element, in 1-based terminal coordinates.
|
|
7
|
+
*
|
|
8
|
+
* Yoga reports each node's offset relative to its parent, so the walk up the
|
|
9
|
+
* tree accumulates them. The +1 converts to terminal coordinates, which is only
|
|
10
|
+
* correct because the app owns the alternate screen and therefore starts at
|
|
11
|
+
* row 1 — rendering below a shell prompt would shift every row.
|
|
12
|
+
*/
|
|
13
|
+
export function measureElement(node) {
|
|
14
|
+
if (!node?.yogaNode)
|
|
15
|
+
return null;
|
|
16
|
+
const layout = node.yogaNode.getComputedLayout();
|
|
17
|
+
let left = 0;
|
|
18
|
+
let top = 0;
|
|
19
|
+
let current = node;
|
|
20
|
+
while (current?.yogaNode) {
|
|
21
|
+
const own = current.yogaNode.getComputedLayout();
|
|
22
|
+
left += own.left;
|
|
23
|
+
top += own.top;
|
|
24
|
+
current = current.parentNode ?? undefined;
|
|
25
|
+
}
|
|
26
|
+
return { left: left + 1, top: top + 1, width: layout.width, height: layout.height };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Half-open containment: a row-N item owns row N and nothing else.
|
|
30
|
+
*
|
|
31
|
+
* The inclusive test used by @ink-tools/ink-mouse (`y <= top + height`) makes
|
|
32
|
+
* every one-row item also claim the row below it, so two neighbours fire for a
|
|
33
|
+
* single click and the selection lands on the wrong one.
|
|
34
|
+
*/
|
|
35
|
+
export function rectContains(rect, x, y) {
|
|
36
|
+
return (x >= rect.left && x < rect.left + rect.width && y >= rect.top && y < rect.top + rect.height);
|
|
37
|
+
}
|
|
38
|
+
// Two contexts on purpose: the registry must stay referentially stable, or
|
|
39
|
+
// every hover would re-run the registration effect of every target on screen.
|
|
40
|
+
const MouseRegistryContext = createContext(null);
|
|
41
|
+
const MouseStateContext = createContext(null);
|
|
42
|
+
export function MouseProvider({ source, children, }) {
|
|
43
|
+
const targets = useRef(new Set());
|
|
44
|
+
const pressed = useRef(null);
|
|
45
|
+
const [hovered, setHovered] = useState(null);
|
|
46
|
+
const [enabled, setEnabled] = useState(false);
|
|
47
|
+
const register = useCallback((target) => {
|
|
48
|
+
targets.current.add(target);
|
|
49
|
+
return () => {
|
|
50
|
+
targets.current.delete(target);
|
|
51
|
+
if (pressed.current === target)
|
|
52
|
+
pressed.current = null;
|
|
53
|
+
setHovered((current) => (current === target ? null : current));
|
|
54
|
+
};
|
|
55
|
+
}, []);
|
|
56
|
+
/** The innermost registered element under the pointer. */
|
|
57
|
+
const hitTest = useCallback((x, y) => {
|
|
58
|
+
let best = null;
|
|
59
|
+
let bestArea = Number.POSITIVE_INFINITY;
|
|
60
|
+
for (const target of targets.current) {
|
|
61
|
+
const rect = measureElement(target.ref.current);
|
|
62
|
+
if (!rect || rect.width <= 0 || rect.height <= 0)
|
|
63
|
+
continue;
|
|
64
|
+
if (!rectContains(rect, x, y))
|
|
65
|
+
continue;
|
|
66
|
+
const area = rect.width * rect.height;
|
|
67
|
+
if (area < bestArea) {
|
|
68
|
+
best = target;
|
|
69
|
+
bestArea = area;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return best;
|
|
73
|
+
}, []);
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!source)
|
|
76
|
+
return undefined;
|
|
77
|
+
return source.subscribe((event) => {
|
|
78
|
+
if (event.type === 'wheel')
|
|
79
|
+
return;
|
|
80
|
+
const target = hitTest(event.x, event.y);
|
|
81
|
+
if (event.type === 'move') {
|
|
82
|
+
// Only re-render when the pointer actually crosses into a different
|
|
83
|
+
// element; any-motion tracking fires on every cell otherwise.
|
|
84
|
+
setHovered((current) => (current === target ? current : target));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (event.type === 'press') {
|
|
88
|
+
if (event.button !== 'left')
|
|
89
|
+
return;
|
|
90
|
+
pressed.current = target;
|
|
91
|
+
setHovered(target);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// Release: a click counts only when press and release share a target,
|
|
95
|
+
// so dragging off a row cancels it the way a button should.
|
|
96
|
+
if (pressed.current && pressed.current === target)
|
|
97
|
+
target?.onClick?.();
|
|
98
|
+
pressed.current = null;
|
|
99
|
+
});
|
|
100
|
+
}, [source, hitTest]);
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
const previous = hovered;
|
|
103
|
+
previous?.onHover?.(true);
|
|
104
|
+
return () => previous?.onHover?.(false);
|
|
105
|
+
}, [hovered]);
|
|
106
|
+
const enable = useCallback(() => {
|
|
107
|
+
if (!source)
|
|
108
|
+
return;
|
|
109
|
+
source.enable();
|
|
110
|
+
setEnabled(source.isEnabled());
|
|
111
|
+
}, [source]);
|
|
112
|
+
const disable = useCallback(() => {
|
|
113
|
+
if (!source)
|
|
114
|
+
return;
|
|
115
|
+
source.disable();
|
|
116
|
+
setEnabled(source.isEnabled());
|
|
117
|
+
}, [source]);
|
|
118
|
+
const registry = useMemo(() => ({ register }), [register]);
|
|
119
|
+
const state = useMemo(() => ({
|
|
120
|
+
supported: Boolean(source?.supported),
|
|
121
|
+
enabled,
|
|
122
|
+
enable,
|
|
123
|
+
disable,
|
|
124
|
+
}), [source, enabled, enable, disable]);
|
|
125
|
+
return (_jsx(MouseRegistryContext.Provider, { value: registry, children: _jsx(MouseStateContext.Provider, { value: state, children: children }) }));
|
|
126
|
+
}
|
|
127
|
+
const KEYBOARD_ONLY = {
|
|
128
|
+
supported: false,
|
|
129
|
+
enabled: false,
|
|
130
|
+
enable: () => { },
|
|
131
|
+
disable: () => { },
|
|
132
|
+
};
|
|
133
|
+
export function useMouse() {
|
|
134
|
+
// Tests mount screens without a provider; degrade to keyboard only rather
|
|
135
|
+
// than crash the interface.
|
|
136
|
+
return useContext(MouseStateContext) ?? KEYBOARD_ONLY;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Make an element clickable and hoverable.
|
|
140
|
+
*
|
|
141
|
+
* Returns whether the pointer is currently over it, so callers can highlight
|
|
142
|
+
* without tracking mouse state themselves.
|
|
143
|
+
*/
|
|
144
|
+
export function useMouseTarget(ref, { onClick, disabled = false } = {}) {
|
|
145
|
+
const context = useContext(MouseRegistryContext);
|
|
146
|
+
const [hovered, setHovered] = useState(false);
|
|
147
|
+
const handler = useRef(onClick);
|
|
148
|
+
handler.current = onClick;
|
|
149
|
+
const target = useMemo(() => ({
|
|
150
|
+
ref,
|
|
151
|
+
onClick: () => handler.current?.(),
|
|
152
|
+
onHover: setHovered,
|
|
153
|
+
}), [ref]);
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (!context || disabled)
|
|
156
|
+
return undefined;
|
|
157
|
+
return context.register(target);
|
|
158
|
+
}, [context, target, disabled]);
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (disabled)
|
|
161
|
+
setHovered(false);
|
|
162
|
+
}, [disabled]);
|
|
163
|
+
return hovered && !disabled;
|
|
164
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// SGR mouse reporting: parsing and the escape sequences that turn it on.
|
|
2
|
+
//
|
|
3
|
+
// Written here rather than taken from a library because the available ones get
|
|
4
|
+
// the hit test wrong: `@ink-tools/ink-mouse` compares a click against inclusive
|
|
5
|
+
// bounds (`y >= top && y <= bottom`) while its rectangles are half-open, so a
|
|
6
|
+
// one-row item at row N also swallows clicks on row N+1 and two neighbouring
|
|
7
|
+
// rows both fire. In a vertical menu that reads as "the mouse selects the wrong
|
|
8
|
+
// entry".
|
|
9
|
+
const ESC = String.fromCharCode(27);
|
|
10
|
+
/** Any-motion tracking (1003) so hover works without a button held, plus SGR
|
|
11
|
+
* coordinates (1006) so columns past 223 still report correctly. */
|
|
12
|
+
export const ENABLE_MOUSE = `${ESC}[?1000h${ESC}[?1002h${ESC}[?1003h${ESC}[?1006h`;
|
|
13
|
+
export const DISABLE_MOUSE = `${ESC}[?1006l${ESC}[?1003l${ESC}[?1002l${ESC}[?1000l`;
|
|
14
|
+
// ESC [ < button ; column ; row (M|m)
|
|
15
|
+
const SGR_PATTERN = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/;
|
|
16
|
+
// The longest incomplete prefix worth holding on to; beyond this the input is
|
|
17
|
+
// not a mouse report and must be passed through rather than swallowed.
|
|
18
|
+
const MAX_PENDING = 32;
|
|
19
|
+
function decodeButton(code, isRelease) {
|
|
20
|
+
if (code & 64)
|
|
21
|
+
return (code & 3) === 1 ? 'wheel-down' : 'wheel-up';
|
|
22
|
+
if (isRelease)
|
|
23
|
+
return 'none';
|
|
24
|
+
switch (code & 3) {
|
|
25
|
+
case 0:
|
|
26
|
+
return 'left';
|
|
27
|
+
case 1:
|
|
28
|
+
return 'middle';
|
|
29
|
+
case 2:
|
|
30
|
+
return 'right';
|
|
31
|
+
default:
|
|
32
|
+
return 'none';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function couldBeMousePrefix(value) {
|
|
36
|
+
// Partial forms of "\x1b[<…" that may complete on the next chunk.
|
|
37
|
+
return (`${ESC}[<`.startsWith(value.slice(0, 3)) &&
|
|
38
|
+
value.length <= MAX_PENDING &&
|
|
39
|
+
/^\x1b(\[(<[\d;]*)?)?$/.test(value));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Separate mouse reports from ordinary key input.
|
|
43
|
+
*
|
|
44
|
+
* Ink's key parser knows nothing about mouse reports, so an unfiltered click
|
|
45
|
+
* would reach `useInput` as a stray Escape and cancel whatever the user was
|
|
46
|
+
* editing. Everything that is not a mouse report is passed through untouched.
|
|
47
|
+
*/
|
|
48
|
+
export function splitMouseInput(buffer) {
|
|
49
|
+
const events = [];
|
|
50
|
+
let passthrough = '';
|
|
51
|
+
let index = 0;
|
|
52
|
+
while (index < buffer.length) {
|
|
53
|
+
const escapeAt = buffer.indexOf(ESC, index);
|
|
54
|
+
if (escapeAt === -1) {
|
|
55
|
+
passthrough += buffer.slice(index);
|
|
56
|
+
return { events, passthrough, pending: '' };
|
|
57
|
+
}
|
|
58
|
+
passthrough += buffer.slice(index, escapeAt);
|
|
59
|
+
const rest = buffer.slice(escapeAt);
|
|
60
|
+
const match = SGR_PATTERN.exec(rest);
|
|
61
|
+
if (match) {
|
|
62
|
+
const code = Number(match[1]);
|
|
63
|
+
const isRelease = match[4] === 'm';
|
|
64
|
+
const isMotion = (code & 32) !== 0;
|
|
65
|
+
const isWheel = (code & 64) !== 0;
|
|
66
|
+
events.push({
|
|
67
|
+
type: isWheel ? 'wheel' : isMotion ? 'move' : isRelease ? 'release' : 'press',
|
|
68
|
+
button: decodeButton(code, isRelease),
|
|
69
|
+
x: Number(match[2]),
|
|
70
|
+
y: Number(match[3]),
|
|
71
|
+
shift: (code & 4) !== 0,
|
|
72
|
+
alt: (code & 8) !== 0,
|
|
73
|
+
ctrl: (code & 16) !== 0,
|
|
74
|
+
});
|
|
75
|
+
index = escapeAt + match[0].length;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (couldBeMousePrefix(rest)) {
|
|
79
|
+
// Hold the fragment back; the rest of the report is in the next chunk.
|
|
80
|
+
return { events, passthrough, pending: rest };
|
|
81
|
+
}
|
|
82
|
+
// A real escape sequence (arrow key, etc.) — hand it to the key parser.
|
|
83
|
+
passthrough += ESC;
|
|
84
|
+
index = escapeAt + 1;
|
|
85
|
+
}
|
|
86
|
+
return { events, passthrough, pending: '' };
|
|
87
|
+
}
|
|
88
|
+
/** Stateful wrapper that carries a partial sequence across chunk boundaries. */
|
|
89
|
+
export function createMouseSplitter() {
|
|
90
|
+
let pending = '';
|
|
91
|
+
return (chunk) => {
|
|
92
|
+
const result = splitMouseInput(pending + chunk);
|
|
93
|
+
pending = result.pending;
|
|
94
|
+
return { events: result.events, passthrough: result.passthrough };
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { PassThrough } from 'node:stream';
|
|
2
|
+
import { createMouseSplitter, DISABLE_MOUSE, ENABLE_MOUSE } from './protocol.js';
|
|
3
|
+
/**
|
|
4
|
+
* Read the real stdin, peel off mouse reports, and forward the rest to Ink.
|
|
5
|
+
*
|
|
6
|
+
* Ink and the mouse layer cannot both own stdin: whoever reads it first
|
|
7
|
+
* consumes the bytes. So this reads it once, routes mouse reports to
|
|
8
|
+
* subscribers, and republishes the remaining keystrokes on a stream that looks
|
|
9
|
+
* enough like a TTY for Ink to drive.
|
|
10
|
+
*/
|
|
11
|
+
export function createMouseSource(input = process.stdin, output = process.stdout) {
|
|
12
|
+
const supported = Boolean(input.isTTY && output.isTTY);
|
|
13
|
+
const listeners = new Set();
|
|
14
|
+
const split = createMouseSplitter();
|
|
15
|
+
const forwarded = new PassThrough();
|
|
16
|
+
const onData = (chunk) => {
|
|
17
|
+
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
18
|
+
const { events, passthrough } = split(text);
|
|
19
|
+
for (const event of events) {
|
|
20
|
+
for (const listener of listeners)
|
|
21
|
+
listener(event);
|
|
22
|
+
}
|
|
23
|
+
if (passthrough.length > 0)
|
|
24
|
+
forwarded.write(passthrough);
|
|
25
|
+
};
|
|
26
|
+
if (supported)
|
|
27
|
+
input.on('data', onData);
|
|
28
|
+
// Ink expects a TTY: it checks isTTY and drives raw mode itself. Delegate
|
|
29
|
+
// those to the real stdin while it reads from our filtered copy.
|
|
30
|
+
const stdin = forwarded;
|
|
31
|
+
stdin.isTTY = input.isTTY;
|
|
32
|
+
stdin.setRawMode = ((mode) => {
|
|
33
|
+
input.setRawMode?.(mode);
|
|
34
|
+
return stdin;
|
|
35
|
+
});
|
|
36
|
+
stdin.ref = (() => {
|
|
37
|
+
input.ref?.();
|
|
38
|
+
return stdin;
|
|
39
|
+
});
|
|
40
|
+
stdin.unref = (() => {
|
|
41
|
+
input.unref?.();
|
|
42
|
+
return stdin;
|
|
43
|
+
});
|
|
44
|
+
let enabled = false;
|
|
45
|
+
const enable = () => {
|
|
46
|
+
if (!supported || enabled)
|
|
47
|
+
return;
|
|
48
|
+
output.write(ENABLE_MOUSE);
|
|
49
|
+
enabled = true;
|
|
50
|
+
};
|
|
51
|
+
const disable = () => {
|
|
52
|
+
if (!enabled)
|
|
53
|
+
return;
|
|
54
|
+
output.write(DISABLE_MOUSE);
|
|
55
|
+
enabled = false;
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
stdin,
|
|
59
|
+
supported,
|
|
60
|
+
isEnabled: () => enabled,
|
|
61
|
+
enable,
|
|
62
|
+
disable,
|
|
63
|
+
subscribe(listener) {
|
|
64
|
+
listeners.add(listener);
|
|
65
|
+
return () => listeners.delete(listener);
|
|
66
|
+
},
|
|
67
|
+
dispose() {
|
|
68
|
+
disable();
|
|
69
|
+
listeners.clear();
|
|
70
|
+
if (supported)
|
|
71
|
+
input.off('data', onData);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { theme } from '../theme.js';
|
|
5
|
+
import { Message, Panel, Row, Selectable } from '../components/common.js';
|
|
6
|
+
import { canSelfUpdate, checkForUpdate, configPath, currentVersion, detectLocale, installKind, performUpdate, saveDraft, setField, stateDir, } from '../api.js';
|
|
7
|
+
/** `https://registry.npmmirror.com` → `registry.npmmirror.com`, for one line of text. */
|
|
8
|
+
const registryHost = (registry) => registry.replace(/^https?:\/\//, '');
|
|
9
|
+
export function About({ ctx }) {
|
|
10
|
+
const { t, draft } = ctx;
|
|
11
|
+
const [selected, setSelected] = useState('auto');
|
|
12
|
+
const [update, setUpdate] = useState({ phase: 'idle' });
|
|
13
|
+
/** Why the install will come from somewhere other than npm's own registry. */
|
|
14
|
+
const [sourceNote, setSourceNote] = useState(null);
|
|
15
|
+
const checkRef = useState(() => ({ current: { registry: '', sources: [] } }))[0];
|
|
16
|
+
const detected = detectLocale({ preference: 'auto' });
|
|
17
|
+
const languageOptions = [
|
|
18
|
+
{ id: 'auto', label: t('about.languageAuto', { detected }) },
|
|
19
|
+
{ id: 'zh', label: t('about.languageZh') },
|
|
20
|
+
{ id: 'en', label: t('about.languageEn') },
|
|
21
|
+
];
|
|
22
|
+
// The update row is a selectable option alongside the languages, so one set
|
|
23
|
+
// of arrow keys drives the whole screen.
|
|
24
|
+
const updatable = canSelfUpdate();
|
|
25
|
+
const updateLabel = (() => {
|
|
26
|
+
switch (update.phase) {
|
|
27
|
+
case 'checking':
|
|
28
|
+
return t('update.checking');
|
|
29
|
+
case 'current':
|
|
30
|
+
return t('update.upToDate', { version: update.latest });
|
|
31
|
+
case 'available':
|
|
32
|
+
return t('update.available', { version: update.latest });
|
|
33
|
+
case 'updating':
|
|
34
|
+
return update.attempt > 1
|
|
35
|
+
? t('update.updatingRetry', { version: update.latest, attempt: update.attempt })
|
|
36
|
+
: t('update.updating', { version: update.latest });
|
|
37
|
+
case 'done':
|
|
38
|
+
return t('update.done', { version: update.latest });
|
|
39
|
+
case 'error':
|
|
40
|
+
return t(update.messageKey, update.params);
|
|
41
|
+
default:
|
|
42
|
+
return t('update.check');
|
|
43
|
+
}
|
|
44
|
+
})();
|
|
45
|
+
const options = [...languageOptions, { id: 'update', label: updateLabel }];
|
|
46
|
+
/** Show a check's answer, whether this screen asked or the TUI did on opening. */
|
|
47
|
+
const applyCheck = (result) => {
|
|
48
|
+
checkRef.current = { registry: result.registry ?? '', sources: result.sources ?? [] };
|
|
49
|
+
// A mirror that has not synced the release yet used to be the whole
|
|
50
|
+
// answer. It is still asked, and said to be behind.
|
|
51
|
+
const behind = result.behind ?? [];
|
|
52
|
+
setSourceNote(result.updateAvailable && behind.length > 0 && result.registry
|
|
53
|
+
? t('update.mirrorBehind', {
|
|
54
|
+
registries: behind
|
|
55
|
+
.map((entry) => `${registryHost(entry.registry)} ${entry.version ?? ''}`.trim())
|
|
56
|
+
.join(', '),
|
|
57
|
+
source: registryHost(result.registry),
|
|
58
|
+
})
|
|
59
|
+
: null);
|
|
60
|
+
setUpdate(result.updateAvailable
|
|
61
|
+
? { phase: 'available', latest: result.latest }
|
|
62
|
+
: { phase: 'current', latest: result.latest });
|
|
63
|
+
};
|
|
64
|
+
// The TUI already asked when it opened; start from that answer.
|
|
65
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: only a new answer from the opening check matters, not each render's applyCheck or phase
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (ctx.updateCheck?.ok && update.phase === 'idle')
|
|
68
|
+
applyCheck(ctx.updateCheck);
|
|
69
|
+
}, [ctx.updateCheck]);
|
|
70
|
+
const runCheck = async () => {
|
|
71
|
+
setUpdate({ phase: 'checking' });
|
|
72
|
+
setSourceNote(null);
|
|
73
|
+
const result = await checkForUpdate();
|
|
74
|
+
if (!result.ok) {
|
|
75
|
+
setUpdate({ phase: 'error', messageKey: result.errorKey ?? 'update.errorNetwork' });
|
|
76
|
+
// Which registries were tried, and what each of them said. Without this
|
|
77
|
+
// the row reads "could not reach npm registry" on a machine where
|
|
78
|
+
// `npm install` works perfectly, and there is nothing to act on.
|
|
79
|
+
if (result.message)
|
|
80
|
+
ctx.notify(t('update.errorNetworkDetail', { message: result.message }), 'error');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
ctx.setUpdateCheck(result);
|
|
84
|
+
applyCheck(result);
|
|
85
|
+
};
|
|
86
|
+
const runUpdate = async (latest) => {
|
|
87
|
+
setUpdate({ phase: 'updating', latest, attempt: 1 });
|
|
88
|
+
const result = await performUpdate({
|
|
89
|
+
registry: checkRef.current.registry,
|
|
90
|
+
sources: checkRef.current.sources,
|
|
91
|
+
version: latest,
|
|
92
|
+
onAttempt: ({ attempt }) => setUpdate({ phase: 'updating', latest, attempt }),
|
|
93
|
+
});
|
|
94
|
+
if (!result.ok) {
|
|
95
|
+
const params = { version: latest, installed: result.installed ?? '' };
|
|
96
|
+
setUpdate({ phase: 'error', messageKey: result.errorKey ?? 'update.errorFailed', params });
|
|
97
|
+
// npm's own words: "update failed" alone is what left this unfixable.
|
|
98
|
+
ctx.notify(result.summary
|
|
99
|
+
? t('update.errorFailedDetail', { message: result.summary })
|
|
100
|
+
: t(result.errorKey ?? 'update.errorFailed', params), 'error');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
setUpdate({ phase: 'done', latest });
|
|
104
|
+
setSourceNote(null);
|
|
105
|
+
// Installed: the line under the title has nothing left to announce.
|
|
106
|
+
ctx.setUpdateCheck(null);
|
|
107
|
+
ctx.notify(t('update.restartHint'), 'success');
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* One row, two meanings: check first, then install what the check found.
|
|
111
|
+
* Refusing to self-update is reported rather than hidden, so a checkout does
|
|
112
|
+
* not look like a silently broken button.
|
|
113
|
+
*/
|
|
114
|
+
const activateUpdate = () => {
|
|
115
|
+
if (update.phase === 'checking' || update.phase === 'updating')
|
|
116
|
+
return;
|
|
117
|
+
if (update.phase === 'available') {
|
|
118
|
+
if (!updatable) {
|
|
119
|
+
setUpdate({ phase: 'error', messageKey: `update.cannot.${installKind()}` });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
void runUpdate(update.latest);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
void runCheck();
|
|
126
|
+
};
|
|
127
|
+
const choose = (language) => {
|
|
128
|
+
const result = setField(draft, 'language', language);
|
|
129
|
+
if (result.errorKey) {
|
|
130
|
+
ctx.notify(t(result.errorKey), 'error');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
ctx.updateDraft(result.draft);
|
|
134
|
+
try {
|
|
135
|
+
saveDraft(result.draft);
|
|
136
|
+
ctx.reloadConfig();
|
|
137
|
+
ctx.notify(t('common.saved', { path: configPath() }), 'success');
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
ctx.notify(t('error.saveFailed', { message: error.message }), 'error');
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
const activate = (id) => {
|
|
144
|
+
if (id === 'update') {
|
|
145
|
+
activateUpdate();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
choose(id);
|
|
149
|
+
};
|
|
150
|
+
useInput((_input, key) => {
|
|
151
|
+
const index = options.findIndex((option) => option.id === selected);
|
|
152
|
+
if (key.upArrow)
|
|
153
|
+
setSelected(options[(index - 1 + options.length) % options.length].id);
|
|
154
|
+
else if (key.downArrow)
|
|
155
|
+
setSelected(options[(index + 1) % options.length].id);
|
|
156
|
+
else if (key.return)
|
|
157
|
+
activate(selected);
|
|
158
|
+
}, { isActive: ctx.editingId === null });
|
|
159
|
+
return (_jsxs(Panel, { title: t('about.title'), children: [_jsx(Text, { color: theme.muted, children: t('about.language') }), _jsx(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: languageOptions.map((option) => (_jsx(Selectable, { selected: selected === option.id, onSelect: () => {
|
|
160
|
+
setSelected(option.id);
|
|
161
|
+
choose(option.id);
|
|
162
|
+
}, onHover: () => setSelected(option.id), children: `${option.label}${draft.ui.language === option.id ? ' ✓' : ''}` }, option.id))) }), _jsx(Text, { color: theme.muted, children: t('update.title') }), _jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: [_jsx(Selectable, { selected: selected === 'update', onSelect: () => {
|
|
163
|
+
setSelected('update');
|
|
164
|
+
activateUpdate();
|
|
165
|
+
}, onHover: () => setSelected('update'), children: updateLabel }), sourceNote ? _jsx(Text, { color: theme.muted, children: ` ${sourceNote}` }) : null] }), _jsx(Row, { label: t('about.version'), children: _jsx(Text, { color: theme.muted, children: currentVersion() }) }), _jsx(Row, { label: t('about.configPath'), children: _jsx(Text, { color: theme.muted, children: configPath() }) }), _jsx(Row, { label: t('about.statePath'), children: _jsx(Text, { color: theme.muted, children: stateDir() }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('about.docs') }) }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
|
|
166
|
+
}
|