@jacebenson/jsn 3.1.0 → 3.1.1

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.
@@ -1,178 +1,210 @@
1
1
  /**
2
- * Paginated interactive search prompt with server-side pagination.
3
- *
4
- * Uses raw readline for terminal control — @inquirer/search doesn't support
5
- * scroll-based pagination (source only fires on keystrokes, not on scroll).
6
- *
7
- * Returns the selected choice ({name, value}), or null on cancel.
2
+ * Paginated search prompt built with @inquirer/core hooks.
8
3
  */
9
4
 
10
- import readline from 'node:readline';
11
-
12
- export async function paginatedSearch({ message, pageSize, totalCount, pageSize: pgSize, source }) {
13
- const PAGE = pageSize || pgSize || 10;
14
- let choices = [];
15
- let active = 0;
16
- let loaded = 0;
17
- let cancelled = false;
18
- let term = '';
19
- let outputLines = 0; // number of lines currently on screen
20
-
21
- // Write + track: returns number of newlines written (cursor advance)
22
- function writeLines(lines) {
23
- const out = lines.join('\n');
24
- process.stdout.write(out);
25
- return lines.length - 1; // N elements = N-1 newlines = cursor advances this many
26
- }
27
-
28
- function render() {
29
- if (outputLines > 0) {
30
- process.stdout.moveCursor(0, -outputLines);
31
- process.stdout.clearScreenDown();
32
- } else {
33
- // First render: ensure we start on a fresh line
34
- process.stdout.cursorTo(0);
35
- process.stdout.clearLine(0);
36
- }
37
-
38
- const buf = [];
39
-
40
- // Header: show message + count
41
- const countInfo = totalCount > 0 ? ` (${loaded} of ${totalCount} loaded)` : '';
42
- buf.push(`${message}${countInfo}`);
43
-
44
- // Visible window: center active item
45
- const start = Math.max(0, active - Math.floor(PAGE / 2));
46
- const end = Math.min(choices.length, start + PAGE);
47
- const visible = choices.slice(start, end);
48
-
49
- for (let i = 0; i < visible.length; i++) {
50
- const choice = visible[i];
51
- const isActive = (start + i) === active;
52
- const prefix = isActive ? '\x1b[7m' : '';
53
- const suffix = isActive ? '\x1b[0m' : '';
54
- const display = choice.name.length > 70 ? choice.name.slice(0, 67) + '...' : choice.name;
55
- buf.push(` ${prefix}${display}${suffix}`);
5
+ import { createPrompt, useState, useKeypress, usePagination, useEffect, useRef } from '@inquirer/core';
6
+ import { isEnterKey, isUpKey, isDownKey } from '@inquirer/core';
7
+ import { appendFileSync } from 'fs';
8
+
9
+ export const paginatedSearch = createPrompt((config, done) => {
10
+ const { message, pageSize = 10, totalCount = 0, source } = config;
11
+
12
+ // ── DEBUG ──
13
+ const DBG = '/home/jace/workspace/holly/sn-jsn-fork/debug.log';
14
+ const log = (msg) => appendFileSync(DBG, `${new Date().toISOString()} ${msg}\n`);
15
+ log(`START totalCount=${totalCount}`);
16
+
17
+ const [status, setStatus] = useState('loading');
18
+ const [searchTerm, setSearchTerm] = useState('');
19
+ const [choices, setChoices] = useState([]);
20
+ const [active, setActive] = useState(0);
21
+ const [loaded, setLoaded] = useState(0);
22
+ const [searchMode, setSearchMode] = useState(false);
23
+
24
+ // Refs for mutable state (no functional updaters — @inquirer/core doesn't support them)
25
+ const choicesRef = useRef([]);
26
+ const loadedRef = useRef(0);
27
+ const activeRef = useRef(0);
28
+ const totalCountRef = useRef(totalCount);
29
+ const searchModeRef = useRef(false);
30
+
31
+ const fullMessage = totalCount > 0
32
+ ? `${message} (${loaded} of ${totalCount} loaded)`
33
+ : message;
34
+
35
+ // Load initial page
36
+ useEffect(() => {
37
+ const controller = new AbortController();
38
+ setStatus('loading');
39
+ log(`EFFECT-INIT searchTerm="${searchTerm}"`);
40
+
41
+ const load = async () => {
42
+ try {
43
+ const items = await source(searchTerm || undefined, 0, { signal: controller.signal });
44
+ log(`INIT-LOAD got ${items.length} items`);
45
+ if (!controller.signal.aborted) {
46
+ choicesRef.current = items;
47
+ loadedRef.current = items.length;
48
+ activeRef.current = 0;
49
+ setChoices(items);
50
+ setLoaded(items.length);
51
+ setActive(0);
52
+ setStatus('done');
53
+ }
54
+ } catch (err) {
55
+ log(`INIT-LOAD error: ${err.message}`);
56
+ if (!controller.signal.aborted) {
57
+ setStatus('done');
58
+ setChoices([]);
59
+ setLoaded(0);
60
+ }
61
+ }
62
+ };
63
+ load();
64
+ return () => controller.abort();
65
+ }, []);
66
+
67
+ // Load more when scrolling past end
68
+ async function loadMore() {
69
+ log(`loadMore check: loaded=${loadedRef.current} total=${totalCountRef.current} searchMode=${searchModeRef.current}`);
70
+ if (loadedRef.current >= totalCountRef.current || searchModeRef.current) {
71
+ log(`loadMore SKIPPED`);
72
+ return;
56
73
  }
57
-
58
- // Pad to constant height
59
- while (buf.length < PAGE + 2) {
60
- buf.push('');
74
+ setStatus('loading');
75
+ log(`loadMore calling source offset=${loadedRef.current}`);
76
+ try {
77
+ const newItems = await source('', loadedRef.current, { signal: new AbortController().signal });
78
+ log(`loadMore got ${newItems.length} items`);
79
+ const updated = choicesRef.current.concat(newItems);
80
+ choicesRef.current = updated;
81
+ loadedRef.current = updated.length;
82
+ setChoices([...updated]);
83
+ setLoaded(updated.length);
84
+ } catch (err) {
85
+ console.error('Scroll load error:', err.message || err);
86
+ loadedRef.current = totalCountRef.current;
87
+ setLoaded(totalCountRef.current);
61
88
  }
62
-
63
- // Footer
64
- const more = loaded < totalCount;
65
- const footer = more
66
- ? `↑↓ navigate • ⏎ select • esc cancel • type to filter • ↓ at end loads more`
67
- : `↑↓ navigate • ⏎ select • esc cancel • type to filter`;
68
- buf.push('');
69
- buf.push(footer);
70
-
71
- // Search bar
72
- buf.push(`> ${term}`);
73
-
74
- outputLines = writeLines(buf);
89
+ setStatus('done');
75
90
  }
76
91
 
77
- function cleanup() {
78
- if (outputLines > 0) {
79
- process.stdout.moveCursor(0, -outputLines);
80
- process.stdout.clearScreenDown();
81
- outputLines = 0;
92
+ // Search when term changes
93
+ useEffect(() => {
94
+ if (!searchTerm) {
95
+ setSearchMode(false);
96
+ searchModeRef.current = false;
97
+ const controller = new AbortController();
98
+ setStatus('loading');
99
+ source('', 0, { signal: controller.signal }).then(items => {
100
+ if (!controller.signal.aborted) {
101
+ choicesRef.current = items;
102
+ loadedRef.current = Math.min(items.length, totalCount || Infinity);
103
+ activeRef.current = 0;
104
+ setChoices(items);
105
+ setLoaded(items.length);
106
+ setActive(0);
107
+ setStatus('done');
108
+ }
109
+ }).catch(() => {
110
+ if (!controller.signal.aborted) setStatus('done');
111
+ });
112
+ return () => controller.abort();
82
113
  }
83
- // Ensure raw mode is off
84
- try { process.stdin.setRawMode(false); } catch { /* ok */ }
85
- }
86
114
 
87
- return new Promise((resolve) => {
88
- const rl = readline.createInterface({
89
- input: process.stdin,
90
- output: process.stdout,
91
- terminal: true,
115
+ const controller = new AbortController();
116
+ setStatus('loading');
117
+ setSearchMode(true);
118
+ searchModeRef.current = true;
119
+
120
+ source(searchTerm, undefined, { signal: controller.signal }).then(items => {
121
+ if (!controller.signal.aborted) {
122
+ choicesRef.current = items;
123
+ loadedRef.current = items.length;
124
+ activeRef.current = 0;
125
+ setChoices(items);
126
+ setLoaded(items.length);
127
+ setActive(0);
128
+ setStatus('done');
129
+ }
130
+ }).catch(() => {
131
+ if (!controller.signal.aborted) {
132
+ setStatus('done');
133
+ setChoices([]);
134
+ }
92
135
  });
93
- try { process.stdin.setRawMode(true); } catch { /* non-TTY */ }
94
136
 
95
- let inputBuf = '';
137
+ return () => controller.abort();
138
+ }, [searchTerm]);
96
139
 
97
- async function loadPage() {
98
- if (loaded >= totalCount) return;
99
- const newItems = await source('', loaded);
100
- if (newItems.length > 0) {
101
- choices = choices.concat(newItems);
102
- loaded = Math.min(choices.length, totalCount);
140
+ // Key handling
141
+ useKeypress((key, rl) => {
142
+ if (status === 'loading') return;
143
+
144
+ if (isEnterKey(key)) {
145
+ if (choicesRef.current.length > 0 && activeRef.current >= 0 && activeRef.current < choicesRef.current.length) {
146
+ done(choicesRef.current[activeRef.current]);
103
147
  }
148
+ return;
104
149
  }
105
150
 
106
- // Initial load
107
- (async () => {
108
- const initial = await source('', 0);
109
- if (cancelled) return;
110
- choices = initial;
111
- loaded = Math.min(choices.length, totalCount || Infinity);
112
- active = 0;
113
- render();
114
- })();
115
-
116
- rl.input.on('keypress', async (_str, key) => {
117
- if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
118
- cancelled = true;
119
- rl.close();
120
- return;
121
- }
151
+ if (key.name === 'escape') {
152
+ done(undefined);
153
+ return;
154
+ }
122
155
 
123
- if (key.name === 'return' || key.name === 'enter') {
124
- if (choices.length > 0 && active >= 0 && active < choices.length) {
125
- rl.close();
126
- return;
127
- }
156
+ if (isUpKey(key)) {
157
+ if (activeRef.current > 0) {
158
+ activeRef.current--;
159
+ setActive(activeRef.current);
128
160
  }
161
+ return;
162
+ }
129
163
 
130
- if (key.name === 'up' && active > 0) {
131
- active--;
132
- render();
133
- return;
164
+ if (isDownKey(key)) {
165
+ if (activeRef.current < choicesRef.current.length - 1) {
166
+ activeRef.current++;
167
+ setActive(activeRef.current);
168
+ } else if (!searchModeRef.current && loadedRef.current < totalCountRef.current) {
169
+ log(`DOWN at end: active=${activeRef.current} len=${choicesRef.current.length} loaded=${loadedRef.current} total=${totalCountRef.current}`);
170
+ loadMore().then(() => {
171
+ activeRef.current++;
172
+ setActive(activeRef.current);
173
+ });
134
174
  }
175
+ return;
176
+ }
135
177
 
136
- if (key.name === 'down') {
137
- if (active < choices.length - 1) {
138
- active++;
139
- render();
140
- } else if (loaded < totalCount) {
141
- await loadPage();
142
- if (active < choices.length - 1) active++;
143
- render();
144
- }
145
- return;
146
- }
178
+ // Use readline's built-in line buffer for text input
179
+ const newTerm = rl.line;
180
+ if (newTerm !== searchTerm) {
181
+ setSearchTerm(newTerm);
182
+ }
183
+ });
147
184
 
148
- if (key.name === 'backspace') {
149
- if (inputBuf.length > 0) inputBuf = inputBuf.slice(0, -1);
150
- } else if (key.sequence && key.sequence.length === 1 && !key.meta && !key.ctrl) {
151
- inputBuf += key.sequence;
152
- } else {
153
- return;
154
- }
185
+ // Paginated rendering
186
+ const page = usePagination({
187
+ items: Array.isArray(choices) ? choices : [],
188
+ active,
189
+ pageSize,
190
+ renderItem: ({ item, isActive }) => {
191
+ const prefix = isActive ? '\x1b[7m' : ' ';
192
+ const suffix = isActive ? '\x1b[0m' : '';
193
+ const name = item.name || String(item.value || item);
194
+ const display = name.length > 70 ? name.slice(0, 67) + '...' : name;
195
+ return ` ${prefix}${display}${suffix}`;
196
+ },
197
+ loop: false,
198
+ });
155
199
 
156
- // Search
157
- term = inputBuf;
158
- if (term) {
159
- const results = await source(term, 0);
160
- choices = results;
161
- loaded = results.length;
162
- } else {
163
- // Reset to browse mode — reload first page
164
- const results = await source('', 0);
165
- choices = results;
166
- loaded = Math.min(results.length, totalCount || Infinity);
167
- }
168
- active = 0;
169
- render();
170
- });
200
+ const helpText = loaded < totalCount && !searchMode
201
+ ? '(scroll to load more, type to search)'
202
+ : '(type to search)';
171
203
 
172
- rl.on('close', () => {
173
- cleanup();
174
- const choice = (!cancelled && active >= 0 && active < choices.length) ? choices[active] : null;
175
- resolve(cancelled ? null : choice);
176
- });
177
- });
178
- }
204
+ return [
205
+ `${fullMessage} Search: ${searchTerm}${status === 'loading' ? ' ...' : ''}`,
206
+ `${page}\n\n${helpText}`,
207
+ ];
208
+ });
209
+
210
+ export default paginatedSearch;
package/src/sdk.js CHANGED
@@ -139,6 +139,10 @@ export class SDKClient {
139
139
  async list(table, params = {}) {
140
140
  const query = new URLSearchParams(params).toString();
141
141
  const endpoint = `${this.baseURL}/api/now/table/${table}${query ? '?' + query : ''}`;
142
+ // Debug: log paginated queries
143
+ if (params.sysparm_offset) {
144
+ process.stderr.write(`[SDK list] offset=${params.sysparm_offset} limit=${params.sysparm_limit} table=${table}\n`);
145
+ }
142
146
  const result = await this.request(endpoint, { method: 'GET' });
143
147
  return result?.result || [];
144
148
  }