dockza.app 0.2.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.
@@ -0,0 +1,314 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ContainersTab = void 0;
4
+ const containers_1 = require("../../docker/containers");
5
+ const status_1 = require("../../utils/status");
6
+ const external_terminal_1 = require("../../utils/external-terminal");
7
+ const container_list_1 = require("../containers/container-list");
8
+ const container_detail_1 = require("../containers/container-detail");
9
+ const confirm_dialog_1 = require("../containers/confirm-dialog");
10
+ const log_viewer_1 = require("../containers/log-viewer");
11
+ class ContainersTab {
12
+ screen;
13
+ containerList;
14
+ containerDetail;
15
+ confirmDialog;
16
+ logViewer;
17
+ containers = [];
18
+ selectedIndex = 0;
19
+ active = false;
20
+ statsCache = new Map();
21
+ errorHandlers = [];
22
+ containerSelectHandlers = [];
23
+ detailOpenHandlers = [];
24
+ logOpenHandlers = [];
25
+ logFollowChangeHandlers = [];
26
+ handleEnter = () => {
27
+ if (!this.active || this.isOverlayOpen())
28
+ return;
29
+ const c = this.containerList.getSelected();
30
+ if (!c)
31
+ return;
32
+ this.containerDetail.show(c, this.statsCache.get(c.id));
33
+ this.detailOpenHandlers.forEach((h) => h());
34
+ };
35
+ handleL = () => {
36
+ if (!this.active || this.isModalOpen())
37
+ return;
38
+ const c = this.getActionTarget();
39
+ if (!c)
40
+ return;
41
+ if (this.containerDetail.isVisible())
42
+ this.containerDetail.hide();
43
+ this.logViewer.show(c);
44
+ this.logOpenHandlers.forEach((h) => h());
45
+ };
46
+ confirmAndRun(title, message, danger, action) {
47
+ if (this.containerDetail.isVisible())
48
+ this.containerDetail.hide();
49
+ this.confirmDialog.show({
50
+ title,
51
+ message,
52
+ danger,
53
+ onConfirm: () => {
54
+ void action()
55
+ .then(() => this.refresh())
56
+ .catch((err) => this.emitError(err))
57
+ .finally(() => {
58
+ this.containerList.focus();
59
+ this.screen.render();
60
+ });
61
+ },
62
+ onCancel: () => {
63
+ this.containerList.focus();
64
+ this.screen.render();
65
+ },
66
+ });
67
+ }
68
+ handleS = () => {
69
+ if (!this.active || this.isModalOpen())
70
+ return;
71
+ const c = this.getActionTarget();
72
+ if (!c || !(0, status_1.isActive)(c.status))
73
+ return;
74
+ this.confirmAndRun('Stop container?', `${c.name} will be stopped.`, true, () => (0, containers_1.stopContainer)(c.id));
75
+ };
76
+ handleR = () => {
77
+ if (!this.active || this.isModalOpen())
78
+ return;
79
+ const c = this.getActionTarget();
80
+ if (!c || !(0, status_1.isActive)(c.status))
81
+ return;
82
+ this.confirmAndRun('Restart container?', `${c.name} will be restarted.`, false, () => (0, containers_1.restartContainer)(c.id));
83
+ };
84
+ handleK = () => {
85
+ if (!this.active || this.isModalOpen())
86
+ return;
87
+ const c = this.getActionTarget();
88
+ if (!c || !(0, status_1.isActive)(c.status))
89
+ return;
90
+ this.confirmAndRun('Kill container?', `${c.name} will be killed (SIGKILL).`, true, () => (0, containers_1.killContainer)(c.id));
91
+ };
92
+ handleShiftS = () => {
93
+ if (!this.active || this.isModalOpen())
94
+ return;
95
+ const c = this.getActionTarget();
96
+ if (!c) {
97
+ this.emitError('No container selected');
98
+ return;
99
+ }
100
+ if ((0, status_1.isActive)(c.status)) {
101
+ this.emitError(`${c.name} is already ${c.status}`);
102
+ return;
103
+ }
104
+ if (c.status !== 'exited' && c.status !== 'created') {
105
+ this.emitError(`Cannot start ${c.name}: status is ${c.status}`);
106
+ return;
107
+ }
108
+ if (this.containerDetail.isVisible())
109
+ this.containerDetail.hide();
110
+ void (0, containers_1.startContainer)(c.id)
111
+ .then(() => this.refresh())
112
+ .catch((err) => this.emitError(err))
113
+ .finally(() => {
114
+ this.containerList.focus();
115
+ this.screen.render();
116
+ });
117
+ };
118
+ handleD = () => {
119
+ if (!this.active || this.isModalOpen())
120
+ return;
121
+ const c = this.getActionTarget();
122
+ if (!c || (0, status_1.isActive)(c.status))
123
+ return;
124
+ this.confirmAndRun('Remove container?', `${c.name} will be permanently removed.`, true, () => (0, containers_1.removeContainer)(c.id));
125
+ };
126
+ handleX = () => {
127
+ if (!this.active || this.isModalOpen())
128
+ return;
129
+ const c = this.getActionTarget();
130
+ if (!c || c.status !== 'running') {
131
+ if (c)
132
+ this.emitError(`Cannot exec into ${c.name}: container is not running`);
133
+ return;
134
+ }
135
+ if (this.containerDetail.isVisible())
136
+ this.containerDetail.hide();
137
+ const result = (0, external_terminal_1.openExternalShell)(c.id);
138
+ if (!result.ok) {
139
+ this.emitError(result.error ?? `Failed to open external terminal for ${c.name}`);
140
+ }
141
+ this.screen.render();
142
+ };
143
+ constructor(screen, dims) {
144
+ this.screen = screen;
145
+ this.containerList = new container_list_1.ContainerList(screen, dims);
146
+ this.containerDetail = new container_detail_1.ContainerDetail(screen, dims);
147
+ this.confirmDialog = new confirm_dialog_1.ConfirmDialog(screen);
148
+ this.logViewer = new log_viewer_1.LogViewer(screen, dims);
149
+ this.containerList.on('select', (container) => {
150
+ const idx = this.containers.findIndex((c) => c.id === container.id);
151
+ if (idx >= 0)
152
+ this.selectedIndex = idx;
153
+ });
154
+ this.containerList.on('navigate', (container) => {
155
+ this.containerSelectHandlers.forEach((h) => h(container));
156
+ });
157
+ this.containerDetail.on('close', () => {
158
+ this.containerList.focus();
159
+ this.emitSelect();
160
+ this.screen.render();
161
+ });
162
+ this.logViewer.on('close', () => {
163
+ this.containerList.focus();
164
+ this.emitSelect();
165
+ this.screen.render();
166
+ });
167
+ this.logViewer.on('follow-change', (following) => {
168
+ this.logFollowChangeHandlers.forEach((h) => h(following));
169
+ });
170
+ this.containerList.hide();
171
+ }
172
+ on(event, handler) {
173
+ if (event === 'error')
174
+ this.errorHandlers.push(handler);
175
+ if (event === 'select')
176
+ this.containerSelectHandlers.push(handler);
177
+ if (event === 'detail-open')
178
+ this.detailOpenHandlers.push(handler);
179
+ if (event === 'log-open')
180
+ this.logOpenHandlers.push(handler);
181
+ if (event === 'log-follow-change') {
182
+ this.logFollowChangeHandlers.push(handler);
183
+ }
184
+ }
185
+ show() {
186
+ this.active = true;
187
+ this.containerList.show();
188
+ this.containerList.focus();
189
+ this.screen.key(['enter'], this.handleEnter);
190
+ this.screen.key(['l'], this.handleL);
191
+ this.screen.key(['s'], this.handleS);
192
+ this.screen.key(['r'], this.handleR);
193
+ this.screen.key(['k'], this.handleK);
194
+ this.screen.key(['S-s'], this.handleShiftS);
195
+ this.screen.key(['d'], this.handleD);
196
+ this.screen.key(['x'], this.handleX);
197
+ this.emitSelect();
198
+ }
199
+ hide() {
200
+ this.active = false;
201
+ this.containerList.hide();
202
+ if (this.containerDetail.isVisible())
203
+ this.containerDetail.hide();
204
+ if (this.logViewer.isVisible())
205
+ this.logViewer.hide();
206
+ if (this.confirmDialog.isVisible())
207
+ this.confirmDialog.hide();
208
+ this.screen.removeKey('enter', this.handleEnter);
209
+ this.screen.removeKey('l', this.handleL);
210
+ this.screen.removeKey('s', this.handleS);
211
+ this.screen.removeKey('r', this.handleR);
212
+ this.screen.removeKey('k', this.handleK);
213
+ this.screen.removeKey('S-s', this.handleShiftS);
214
+ this.screen.removeKey('d', this.handleD);
215
+ this.screen.removeKey('x', this.handleX);
216
+ this.containerSelectHandlers.forEach((h) => h(null));
217
+ }
218
+ showLoading() {
219
+ this.containerList.showLoading();
220
+ }
221
+ isOverlayOpen() {
222
+ return this.isModalOpen() || this.containerDetail.isVisible();
223
+ }
224
+ isConfirmOpen() {
225
+ return this.confirmDialog.isVisible();
226
+ }
227
+ getSelected() {
228
+ return this.containerList.getSelected();
229
+ }
230
+ cleanup() {
231
+ if (this.logViewer.isVisible())
232
+ this.logViewer.hide();
233
+ if (this.containerDetail.isVisible())
234
+ this.containerDetail.hide();
235
+ if (this.confirmDialog.isVisible())
236
+ this.confirmDialog.hide();
237
+ }
238
+ refreshListStats() {
239
+ const cursorPos = this.containerList.getSelectedIndex();
240
+ const listStats = new Map();
241
+ for (const c of this.containers) {
242
+ const s = this.statsCache.get(c.id);
243
+ if (s)
244
+ listStats.set(c.id, { cpuPercent: s.cpuPercent, memPercent: s.memPercent });
245
+ }
246
+ this.containerList.setData(this.containers, listStats);
247
+ this.containerList.list.select(Math.min(cursorPos, Math.max(0, this.containers.length - 1)));
248
+ }
249
+ setData(containers) {
250
+ const cursorId = this.containerList.getSelected()?.id ?? this.containers[this.selectedIndex]?.id;
251
+ this.containers = containers;
252
+ if (cursorId) {
253
+ const newIdx = containers.findIndex((c) => c.id === cursorId);
254
+ this.selectedIndex = newIdx >= 0 ? newIdx : 0;
255
+ }
256
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, containers.length - 1));
257
+ const listStats = new Map();
258
+ for (const c of containers) {
259
+ const s = this.statsCache.get(c.id);
260
+ if (s)
261
+ listStats.set(c.id, { cpuPercent: s.cpuPercent, memPercent: s.memPercent });
262
+ }
263
+ const targetIdx = this.selectedIndex;
264
+ this.containerList.setData(containers, listStats);
265
+ this.containerList.list.select(targetIdx);
266
+ if (this.containerDetail.isVisible()) {
267
+ const detailId = this.containerDetail.getContainerId();
268
+ if (detailId) {
269
+ const updated = containers.find((c) => c.id === detailId);
270
+ if (updated) {
271
+ this.containerDetail.update(updated, this.statsCache.get(detailId));
272
+ }
273
+ else {
274
+ this.containerDetail.hide();
275
+ this.containerList.focus();
276
+ }
277
+ }
278
+ }
279
+ this.emitSelect();
280
+ }
281
+ updateStats(id, stats) {
282
+ this.statsCache.set(id, stats);
283
+ if (this.containerDetail.isVisible() && this.containerDetail.getContainerId() === id) {
284
+ const container = this.containers.find((c) => c.id === id);
285
+ if (container)
286
+ this.containerDetail.update(container, stats);
287
+ }
288
+ }
289
+ async refresh() {
290
+ const containers = await (0, containers_1.listContainers)();
291
+ this.setData(containers);
292
+ }
293
+ isModalOpen() {
294
+ return this.confirmDialog.isVisible() || this.logViewer.isVisible();
295
+ }
296
+ /** Container targeted by list/detail actions (detail takes precedence when open). */
297
+ getActionTarget() {
298
+ if (this.containerDetail.isVisible()) {
299
+ const id = this.containerDetail.getContainerId();
300
+ if (id)
301
+ return this.containers.find((c) => c.id === id) ?? null;
302
+ }
303
+ return this.containerList.getSelected();
304
+ }
305
+ emitSelect() {
306
+ const container = this.containerList.getSelected();
307
+ this.containerSelectHandlers.forEach((h) => h(container));
308
+ }
309
+ emitError(err) {
310
+ const msg = err instanceof Error ? err.message : String(err);
311
+ this.errorHandlers.forEach((h) => h(msg));
312
+ }
313
+ }
314
+ exports.ContainersTab = ContainersTab;
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.LogViewer = void 0;
7
+ const neo_blessed_1 = __importDefault(require("neo-blessed"));
8
+ const containers_1 = require("../../docker/containers");
9
+ const _theme_1 = require("../../theme");
10
+ class LogViewer {
11
+ screen;
12
+ wrapper;
13
+ headerBox;
14
+ logBox;
15
+ visible = false;
16
+ following = true;
17
+ activeStream = null;
18
+ streamGen = 0;
19
+ pending = Buffer.alloc(0);
20
+ containerName = '';
21
+ closeHandlers = [];
22
+ followChangeHandlers = [];
23
+ handleF = () => {
24
+ if (!this.visible)
25
+ return;
26
+ this.following = !this.following;
27
+ this.followChangeHandlers.forEach((h) => h(this.following));
28
+ this.screen.render();
29
+ };
30
+ handleG = () => {
31
+ if (!this.visible)
32
+ return;
33
+ this.logBox.setScrollPerc(0);
34
+ this.screen.render();
35
+ };
36
+ handleShiftG = () => {
37
+ if (!this.visible)
38
+ return;
39
+ this.following = true;
40
+ this.logBox.setScrollPerc(100);
41
+ this.followChangeHandlers.forEach((h) => h(this.following));
42
+ this.screen.render();
43
+ };
44
+ handleUp = () => {
45
+ if (!this.visible)
46
+ return;
47
+ this.following = false;
48
+ this.logBox.scroll(-1);
49
+ this.followChangeHandlers.forEach((h) => h(this.following));
50
+ this.screen.render();
51
+ };
52
+ handleClose = () => {
53
+ if (!this.visible)
54
+ return;
55
+ this.hide();
56
+ this.closeHandlers.forEach((h) => h());
57
+ };
58
+ constructor(screen, dims) {
59
+ this.screen = screen;
60
+ this.wrapper = neo_blessed_1.default.box({
61
+ parent: screen,
62
+ top: dims.top,
63
+ left: dims.left,
64
+ width: dims.width,
65
+ height: dims.height,
66
+ style: { bg: _theme_1.C.bg },
67
+ hidden: true,
68
+ });
69
+ this.headerBox = neo_blessed_1.default.box({
70
+ parent: this.wrapper,
71
+ top: 0,
72
+ left: 0,
73
+ height: 1,
74
+ width: '100%',
75
+ tags: true,
76
+ style: { bg: _theme_1.C.bgSel },
77
+ });
78
+ this.logBox = neo_blessed_1.default.log({
79
+ parent: this.wrapper,
80
+ top: 1,
81
+ left: 0,
82
+ height: '100%-1',
83
+ width: '100%',
84
+ scrollable: true,
85
+ mouse: true,
86
+ keys: true,
87
+ vi: true,
88
+ tags: true,
89
+ alwaysScroll: true,
90
+ // Cap retained lines so a chatty container can't grow this without bound.
91
+ scrollback: 5000,
92
+ scrollbar: { ch: '│', style: { fg: _theme_1.C.comment } },
93
+ style: { fg: _theme_1.C.fg, bg: _theme_1.C.bg },
94
+ });
95
+ }
96
+ on(event, handler) {
97
+ if (event === 'close')
98
+ this.closeHandlers.push(handler);
99
+ if (event === 'follow-change')
100
+ this.followChangeHandlers.push(handler);
101
+ }
102
+ show(container) {
103
+ this.containerName = container.name;
104
+ this.following = true;
105
+ this.logBox.setContent('');
106
+ this.logBox.setScrollPerc(0);
107
+ this.visible = true;
108
+ this.wrapper.show();
109
+ this.logBox.focus();
110
+ this.updateHeader();
111
+ this.screen.key(['f'], this.handleF);
112
+ this.screen.key(['g'], this.handleG);
113
+ this.screen.key(['S-g'], this.handleShiftG);
114
+ this.screen.key(['up', 'k'], this.handleUp);
115
+ this.screen.key(['escape'], this.handleClose);
116
+ this.screen.render();
117
+ this.pending = Buffer.alloc(0);
118
+ const myGen = ++this.streamGen;
119
+ void (0, containers_1.streamLogs)(container.id, 200)
120
+ .then((stream) => {
121
+ if (myGen !== this.streamGen) {
122
+ stream.destroy?.();
123
+ return;
124
+ }
125
+ this.activeStream = stream;
126
+ stream.on('data', (chunk) => {
127
+ if (myGen !== this.streamGen)
128
+ return;
129
+ this.parseFrames(chunk);
130
+ if (this.following)
131
+ this.logBox.setScrollPerc(100);
132
+ this.screen.render();
133
+ });
134
+ stream.on('error', (err) => {
135
+ if (myGen !== this.streamGen)
136
+ return;
137
+ this.logBox.pushLine(_theme_1.t.red(`Stream error: ${err.message}`));
138
+ this.screen.render();
139
+ });
140
+ })
141
+ .catch((err) => {
142
+ if (myGen !== this.streamGen)
143
+ return;
144
+ this.logBox.pushLine(_theme_1.t.red(`Failed to open log stream: ${err instanceof Error ? err.message : String(err)}`));
145
+ this.screen.render();
146
+ });
147
+ }
148
+ hide() {
149
+ this.visible = false;
150
+ this.streamGen++;
151
+ this.pending = Buffer.alloc(0);
152
+ if (this.activeStream) {
153
+ this.activeStream.destroy?.();
154
+ this.activeStream = null;
155
+ }
156
+ this.wrapper.hide();
157
+ this.screen.removeKey('f', this.handleF);
158
+ this.screen.removeKey('g', this.handleG);
159
+ this.screen.removeKey('S-g', this.handleShiftG);
160
+ this.screen.removeKey('up', this.handleUp);
161
+ this.screen.removeKey('k', this.handleUp);
162
+ this.screen.removeKey('escape', this.handleClose);
163
+ this.screen.render();
164
+ }
165
+ isVisible() {
166
+ return this.visible;
167
+ }
168
+ updateHeader() {
169
+ this.headerBox.setContent(` ${_theme_1.t.purple('LOGS')} — ${_theme_1.t.fg(this.containerName)}`);
170
+ }
171
+ parseFrames(chunk) {
172
+ const buf = this.pending.length > 0 ? Buffer.concat([this.pending, chunk]) : chunk;
173
+ let offset = 0;
174
+ while (offset + 8 <= buf.length) {
175
+ const type = buf[offset];
176
+ const size = buf.readUInt32BE(offset + 4);
177
+ if (offset + 8 + size > buf.length)
178
+ break;
179
+ const message = buf.slice(offset + 8, offset + 8 + size).toString('utf8');
180
+ offset += 8 + size;
181
+ for (const line of message.split('\n')) {
182
+ if (!line)
183
+ continue;
184
+ this.logBox.pushLine(type === 2 ? _theme_1.t.orange(line) : line);
185
+ }
186
+ }
187
+ this.pending = offset < buf.length ? buf.slice(offset) : Buffer.alloc(0);
188
+ }
189
+ }
190
+ exports.LogViewer = LogViewer;
@@ -0,0 +1,193 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Footer = void 0;
7
+ const neo_blessed_1 = __importDefault(require("neo-blessed"));
8
+ const _theme_1 = require("../theme");
9
+ const format_1 = require("../utils/format");
10
+ const HINTS = {
11
+ global: [
12
+ { key: '↑↓', verb: 'nav' },
13
+ { key: '↵', verb: 'select' },
14
+ { key: '1-5', verb: 'view' },
15
+ { key: 'h', verb: 'help' },
16
+ { key: 'q', verb: 'quit' },
17
+ ],
18
+ 'stacks-tree-stack': [
19
+ { key: '↑↓', verb: 'nav' },
20
+ { key: '→←', verb: 'expand' },
21
+ { key: '↵', verb: 'toggle' },
22
+ { key: '/', verb: 'filter' },
23
+ { key: 'h', verb: 'help' },
24
+ { key: 'q', verb: 'quit' },
25
+ ],
26
+ 'stacks-tree-running': [
27
+ { key: '↑↓', verb: 'nav' },
28
+ { key: '↵', verb: 'detail' },
29
+ { key: 'l', verb: 'logs' },
30
+ { key: 'x', verb: 'shell' },
31
+ { key: 's', verb: 'stop' },
32
+ { key: 'r', verb: 'restart' },
33
+ { key: 'k', verb: 'kill' },
34
+ { key: '/', verb: 'filter' },
35
+ { key: 'h', verb: 'help' },
36
+ ],
37
+ 'stacks-tree-stopped': [
38
+ { key: '↑↓', verb: 'nav' },
39
+ { key: '↵', verb: 'detail' },
40
+ { key: 'l', verb: 'logs' },
41
+ { key: 'S', verb: 'start' },
42
+ { key: 'd', verb: 'remove' },
43
+ { key: '/', verb: 'filter' },
44
+ { key: 'h', verb: 'help' },
45
+ ],
46
+ 'containers-running': [
47
+ { key: '↑↓', verb: 'nav' },
48
+ { key: '↵', verb: 'detail' },
49
+ { key: 'l', verb: 'logs' },
50
+ { key: 'x', verb: 'shell' },
51
+ { key: 's', verb: 'stop' },
52
+ { key: 'r', verb: 'restart' },
53
+ { key: 'k', verb: 'kill' },
54
+ { key: 'h', verb: 'help' },
55
+ ],
56
+ 'containers-stopped': [
57
+ { key: '↑↓', verb: 'nav' },
58
+ { key: '↵', verb: 'detail' },
59
+ { key: 'l', verb: 'logs' },
60
+ { key: 'S', verb: 'start' },
61
+ { key: 'd', verb: 'remove' },
62
+ { key: 'h', verb: 'help' },
63
+ ],
64
+ 'containers-empty': [
65
+ { key: '↑↓', verb: 'nav' },
66
+ { key: '1-5', verb: 'view' },
67
+ { key: 'h', verb: 'help' },
68
+ { key: 'q', verb: 'quit' },
69
+ ],
70
+ images: [
71
+ { key: '↑↓', verb: 'nav' },
72
+ { key: 'd', verb: 'delete' },
73
+ { key: 'h', verb: 'help' },
74
+ { key: 'q', verb: 'quit' },
75
+ ],
76
+ volumes: [
77
+ { key: '↑↓', verb: 'nav' },
78
+ { key: 'd', verb: 'delete' },
79
+ { key: 'h', verb: 'help' },
80
+ { key: 'q', verb: 'quit' },
81
+ ],
82
+ networks: [
83
+ { key: '↑↓', verb: 'nav' },
84
+ { key: 'd', verb: 'delete' },
85
+ { key: 'h', verb: 'help' },
86
+ { key: 'q', verb: 'quit' },
87
+ ],
88
+ detail: [
89
+ { key: 'e', verb: 'env' },
90
+ { key: 'l', verb: 'logs' },
91
+ { key: 's/r/k', verb: 'ctrl' },
92
+ { key: '↑↓', verb: 'scroll' },
93
+ { key: 'Esc', verb: 'close' },
94
+ ],
95
+ log: [
96
+ { key: 'f', verb: 'follow' },
97
+ { key: 'g', verb: 'top' },
98
+ { key: 'G', verb: 'bottom' },
99
+ { key: '↑↓', verb: 'scroll' },
100
+ { key: 'Esc', verb: 'close' },
101
+ ],
102
+ };
103
+ class Footer {
104
+ box;
105
+ context = 'global';
106
+ message = null;
107
+ lastRefreshAt = null;
108
+ tickerHandle = null;
109
+ constructor(screen) {
110
+ this.box = neo_blessed_1.default.box({
111
+ parent: screen,
112
+ bottom: 0,
113
+ left: 0,
114
+ width: '100%',
115
+ height: 1,
116
+ tags: true,
117
+ style: { bg: _theme_1.C.bg, fg: _theme_1.C.fg },
118
+ });
119
+ }
120
+ setContext(c) {
121
+ this.context = c;
122
+ this.render();
123
+ }
124
+ setMessage(message) {
125
+ this.message = message;
126
+ this.render();
127
+ }
128
+ noteRefresh() {
129
+ this.lastRefreshAt = Date.now();
130
+ this.render();
131
+ }
132
+ startTicker(onTick) {
133
+ if (this.tickerHandle)
134
+ return;
135
+ this.tickerHandle = setInterval(() => {
136
+ this.render();
137
+ onTick();
138
+ }, 1000);
139
+ }
140
+ stopTicker() {
141
+ if (this.tickerHandle) {
142
+ clearInterval(this.tickerHandle);
143
+ this.tickerHandle = null;
144
+ }
145
+ }
146
+ render() {
147
+ const width = Number(this.box.width) || 80;
148
+ const right = this.buildRight();
149
+ const left = this.message ? this.buildMessage() : this.buildHints(width - (0, format_1.visualLength)(right) - 2);
150
+ const gap = Math.max(1, width - (0, format_1.visualLength)(left) - (0, format_1.visualLength)(right));
151
+ this.box.setContent(left + ' '.repeat(gap) + right);
152
+ }
153
+ buildMessage() {
154
+ if (!this.message)
155
+ return '';
156
+ const text = this.message.text;
157
+ if (this.message.color === 'red')
158
+ return ` ${_theme_1.t.red(text)}`;
159
+ if (this.message.color === 'green')
160
+ return ` ${_theme_1.t.green(text)}`;
161
+ return ` ${_theme_1.t.fg(text)}`;
162
+ }
163
+ buildHints(budget) {
164
+ const hints = HINTS[this.context];
165
+ const parts = [' '];
166
+ let visible = 1;
167
+ for (const h of hints) {
168
+ const piece = `${this.renderKey(h.key)} ${_theme_1.t.dim(h.verb)}`;
169
+ const pieceLen = (0, format_1.visualLength)(piece);
170
+ const separator = parts.length === 1 ? '' : ' ';
171
+ const sepLen = separator.length;
172
+ if (visible + pieceLen + sepLen > budget) {
173
+ if (parts.length > 1)
174
+ parts.push(_theme_1.t.faint('…'));
175
+ break;
176
+ }
177
+ parts.push(separator + piece);
178
+ visible += pieceLen + sepLen;
179
+ }
180
+ return parts.join('');
181
+ }
182
+ renderKey(key) {
183
+ return `{${_theme_1.C.rule2}-bg}{${_theme_1.C.fg}-fg} ${key} {/}`;
184
+ }
185
+ buildRight() {
186
+ if (!this.lastRefreshAt)
187
+ return `${_theme_1.t.faint('—')} `;
188
+ const ageSec = (Date.now() - this.lastRefreshAt) / 1000;
189
+ const label = ageSec < 1 ? `${ageSec.toFixed(1)}s` : `${Math.floor(ageSec)}s`;
190
+ return `${_theme_1.t.faint(`last refresh ${label} ago`)} `;
191
+ }
192
+ }
193
+ exports.Footer = Footer;