snip-manager 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ANALYSIS.md +113 -0
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/NPM_PUBLISH_GUIDE.md +75 -0
- package/README.md +76 -0
- package/STRATEGY.md +370 -0
- package/bin/snip +2 -0
- package/color_scheme.md +24 -0
- package/example_snippet.md +26 -0
- package/jest.config.js +5 -0
- package/jest.setup.js +17 -0
- package/landing.html +833 -0
- package/lib/cli.js +114 -0
- package/lib/clipboard.js +37 -0
- package/lib/commands/add.js +36 -0
- package/lib/commands/config.js +19 -0
- package/lib/commands/edit.js +26 -0
- package/lib/commands/export.js +23 -0
- package/lib/commands/import.js +18 -0
- package/lib/commands/list.js +81 -0
- package/lib/commands/rm.js +10 -0
- package/lib/commands/run.js +34 -0
- package/lib/commands/search.js +13 -0
- package/lib/commands/show.js +29 -0
- package/lib/commands/sync.js +27 -0
- package/lib/commands/ui.js +1137 -0
- package/lib/config.js +56 -0
- package/lib/exec.js +73 -0
- package/lib/migrate_to_sqlite.js +42 -0
- package/lib/safety.js +26 -0
- package/lib/search.js +21 -0
- package/lib/storage.js +372 -0
- package/lib/sync/gist.js +67 -0
- package/mvp.md +69 -0
- package/npm_publish_workflow.md +19 -0
- package/package.json +50 -0
- package/plan.md +99 -0
- package/prd.md +70 -0
- package/scripts/seed-examples.js +113 -0
- package/scripts/sqljs-smoke.js +48 -0
- package/tech_architecture.md +47 -0
- package/temp-sqljs.db +0 -0
- package/ui_ux.md +46 -0
- package/user_journey.md +29 -0
- package/value_prop.md +21 -0
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
const blessed = require('blessed');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
const storage = require('../storage');
|
|
7
|
+
const Fuse = require('fuse.js');
|
|
8
|
+
const config = require('../config');
|
|
9
|
+
const exec = require('../exec');
|
|
10
|
+
const safety = require('../safety');
|
|
11
|
+
const clipboard = require('../clipboard');
|
|
12
|
+
|
|
13
|
+
const LIST_KEYS = ['name', 'tags', 'content'];
|
|
14
|
+
const FUSE_OPTIONS = { keys: LIST_KEYS, threshold: 0.4, ignoreLocation: true };
|
|
15
|
+
const FAST_MOVE_STEP = 5;
|
|
16
|
+
const SPLIT_MIN_WIDTH = 80;
|
|
17
|
+
|
|
18
|
+
// ── Unicode glyphs ──────────────────────────────────────────────────
|
|
19
|
+
const G = {
|
|
20
|
+
dot: '●',
|
|
21
|
+
hollow: '○',
|
|
22
|
+
arrow: '›',
|
|
23
|
+
usage: '↻',
|
|
24
|
+
sort: '↕',
|
|
25
|
+
check: '✓',
|
|
26
|
+
cross: '✗',
|
|
27
|
+
warn: '⚠',
|
|
28
|
+
bar: '│',
|
|
29
|
+
dash: '─',
|
|
30
|
+
corner: '╭',
|
|
31
|
+
search: '⌕',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ── Catppuccin Mocha inspired palette ───────────────────────────────
|
|
35
|
+
// Designed for readability on both dark and light terminal backgrounds.
|
|
36
|
+
const THEME = {
|
|
37
|
+
border: { fg: '#585B70' },
|
|
38
|
+
borderFocus: { fg: '#89B4FA' },
|
|
39
|
+
selected: { bg: '#89B4FA', fg: '#1E1E2E', bold: true },
|
|
40
|
+
item: { fg: '#CDD6F4' },
|
|
41
|
+
muted: { fg: '#6C7086' },
|
|
42
|
+
accent: { fg: '#94E2D5' },
|
|
43
|
+
success: { fg: '#A6E3A1' },
|
|
44
|
+
warning: { fg: '#F9E2AF' },
|
|
45
|
+
error: { fg: '#F38BA8' },
|
|
46
|
+
headerBg: '#313244',
|
|
47
|
+
headerFg: '#CDD6F4',
|
|
48
|
+
footerBg: '#1E1E2E',
|
|
49
|
+
footerFg: '#6C7086',
|
|
50
|
+
labelBg: '#45475A',
|
|
51
|
+
labelFg: '#CDD6F4',
|
|
52
|
+
preview: { fg: '#BAC2DE' },
|
|
53
|
+
badge: { fg: '#F5C2E7' },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function getFilteredSnippets(all, tagFilter, searchQuery) {
|
|
57
|
+
let list = tagFilter
|
|
58
|
+
? all.filter(s => (s.tags || []).includes(tagFilter))
|
|
59
|
+
: all.slice();
|
|
60
|
+
if (searchQuery.trim()) {
|
|
61
|
+
const fuse = new Fuse(list.map(s => ({
|
|
62
|
+
...s,
|
|
63
|
+
content: (storage.readSnippetContent(s) || '').slice(0, 1000)
|
|
64
|
+
})), FUSE_OPTIONS);
|
|
65
|
+
const results = fuse.search(searchQuery, { limit: 500 });
|
|
66
|
+
list = results.map(r => r.item);
|
|
67
|
+
}
|
|
68
|
+
return list;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getUniqueTags(snippets) {
|
|
72
|
+
const set = new Set();
|
|
73
|
+
snippets.forEach(s => (s.tags || []).forEach(t => set.add(t)));
|
|
74
|
+
return Array.from(set).sort();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Helper: spawn an external command (editor, runner) while blessed is active.
|
|
78
|
+
// Blessed holds the alt-screen buffer + raw stdin — must release both first.
|
|
79
|
+
function spawnExternal(screen, cmd, args) {
|
|
80
|
+
try {
|
|
81
|
+
screen.program.normalBuffer();
|
|
82
|
+
screen.program.showCursor();
|
|
83
|
+
} catch (e) { /* older blessed versions */ }
|
|
84
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(false);
|
|
85
|
+
process.stdin.pause();
|
|
86
|
+
|
|
87
|
+
const result = spawnSync(cmd, args, { stdio: 'inherit' });
|
|
88
|
+
|
|
89
|
+
process.stdin.resume();
|
|
90
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(true);
|
|
91
|
+
try {
|
|
92
|
+
screen.program.alternateBuffer();
|
|
93
|
+
screen.program.hideCursor();
|
|
94
|
+
} catch (e) { /* older blessed versions */ }
|
|
95
|
+
if (screen.alloc) screen.alloc();
|
|
96
|
+
screen.render();
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function truncate(text, max) {
|
|
101
|
+
if (!text || max <= 0) return '';
|
|
102
|
+
if (text.length <= max) return text;
|
|
103
|
+
if (max <= 3) return '.'.repeat(max);
|
|
104
|
+
return text.slice(0, max - 3) + '...';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function clampIndex(index, length) {
|
|
108
|
+
if (!length) return 0;
|
|
109
|
+
return Math.max(0, Math.min(index, length - 1));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getModalSize(screen, minWidth, maxWidth, minHeight, maxHeight) {
|
|
113
|
+
const usableWidth = Math.max(16, screen.width - 2);
|
|
114
|
+
const usableHeight = Math.max(8, screen.height - 2);
|
|
115
|
+
const floorWidth = Math.min(minWidth, usableWidth);
|
|
116
|
+
const floorHeight = Math.min(minHeight, usableHeight);
|
|
117
|
+
const width = Math.max(floorWidth, Math.min(maxWidth, usableWidth));
|
|
118
|
+
const height = Math.max(floorHeight, Math.min(maxHeight, usableHeight));
|
|
119
|
+
return { width, height };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function formatListItems(filtered, listWidth) {
|
|
123
|
+
if (!filtered.length) return [];
|
|
124
|
+
const w = listWidth || 80;
|
|
125
|
+
return filtered.map(s => {
|
|
126
|
+
const nameMax = Math.min(28, Math.floor(w * 0.3));
|
|
127
|
+
const name = truncate(String(s.name || 'untitled'), nameMax).padEnd(nameMax + 2, ' ');
|
|
128
|
+
const language = truncate((s.language || '').trim(), 8);
|
|
129
|
+
const langToken = language ? `{${THEME.muted.fg}-fg}[${language}]{/}` : '';
|
|
130
|
+
const langPad = language ? 10 - language.length : 0;
|
|
131
|
+
const pad1 = ' '.repeat(Math.max(1, langPad));
|
|
132
|
+
const tagsMax = Math.max(12, Math.floor(w * 0.3));
|
|
133
|
+
const tags = truncate((s.tags || []).join(', '), tagsMax);
|
|
134
|
+
const tagsStr = tags ? `{${THEME.accent.fg}-fg}${tags}{/}` : `{${THEME.muted.fg}-fg}${G.dash}{/}`;
|
|
135
|
+
const usage = s.usageCount ? `{${THEME.badge.fg}-fg}${G.usage}${s.usageCount}{/}` : '';
|
|
136
|
+
return ` ${G.hollow} ${name}${langToken}${pad1}${tagsStr} ${usage}`;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function runSnippet(snippet, screen, cb) {
|
|
141
|
+
const content = storage.readSnippetContent(snippet);
|
|
142
|
+
const cfg = config.loadConfig();
|
|
143
|
+
const runner = exec.resolveRunner(snippet.language, cfg.defaultShell);
|
|
144
|
+
const isDangerous = safety.isDangerous(content);
|
|
145
|
+
const { width, height } = getModalSize(screen, 78, 118, 15, 36);
|
|
146
|
+
|
|
147
|
+
const modal = blessed.box({
|
|
148
|
+
parent: screen,
|
|
149
|
+
top: 'center',
|
|
150
|
+
left: 'center',
|
|
151
|
+
width,
|
|
152
|
+
height,
|
|
153
|
+
padding: { top: 1, right: 1, bottom: 1, left: 1 },
|
|
154
|
+
border: { type: 'line', fg: THEME.warning.fg },
|
|
155
|
+
style: { border: { fg: THEME.warning.fg }, bg: THEME.footerBg, fg: THEME.item.fg },
|
|
156
|
+
keys: true,
|
|
157
|
+
scrollable: true,
|
|
158
|
+
tags: true,
|
|
159
|
+
label: { text: ` ${G.dot} Run (${runner.command}) `, side: 'left', fg: THEME.labelFg, bg: THEME.labelBg }
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const preview = blessed.box({
|
|
163
|
+
parent: modal,
|
|
164
|
+
top: 0,
|
|
165
|
+
left: 0,
|
|
166
|
+
width: '100%-2',
|
|
167
|
+
height: '100%-4',
|
|
168
|
+
content: content + (isDangerous ? `\n\n${G.warn} Potentially dangerous. Press y to run anyway.` : ''),
|
|
169
|
+
tags: false,
|
|
170
|
+
scrollable: true,
|
|
171
|
+
alwaysScroll: true,
|
|
172
|
+
mouse: false,
|
|
173
|
+
keys: true,
|
|
174
|
+
vi: true,
|
|
175
|
+
scrollbar: { style: { bg: THEME.accent.fg } }
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
blessed.text({
|
|
179
|
+
parent: modal,
|
|
180
|
+
bottom: 0,
|
|
181
|
+
left: 0,
|
|
182
|
+
width: '100%',
|
|
183
|
+
content: isDangerous
|
|
184
|
+
? ` ${G.warn} j/k:scroll y:run anyway n/Esc:cancel `
|
|
185
|
+
: ` ${G.check} j/k:scroll y:run n/Esc:cancel `,
|
|
186
|
+
style: { fg: THEME.warning.fg, bg: THEME.footerBg }
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
function abort() {
|
|
190
|
+
modal.destroy();
|
|
191
|
+
screen.render();
|
|
192
|
+
cb(null);
|
|
193
|
+
}
|
|
194
|
+
function run() {
|
|
195
|
+
modal.destroy();
|
|
196
|
+
screen.render();
|
|
197
|
+
// Leave blessed screen for snippet execution
|
|
198
|
+
try {
|
|
199
|
+
screen.program.normalBuffer();
|
|
200
|
+
screen.program.showCursor();
|
|
201
|
+
} catch (e) { /* fallback */ }
|
|
202
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(false);
|
|
203
|
+
process.stdin.pause();
|
|
204
|
+
|
|
205
|
+
const status = exec.runSnippetContent(content, {
|
|
206
|
+
dryRun: false,
|
|
207
|
+
shell: cfg.defaultShell,
|
|
208
|
+
language: snippet.language
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Return to blessed screen
|
|
212
|
+
process.stdin.resume();
|
|
213
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(true);
|
|
214
|
+
try {
|
|
215
|
+
screen.program.alternateBuffer();
|
|
216
|
+
screen.program.hideCursor();
|
|
217
|
+
} catch (e) { /* fallback */ }
|
|
218
|
+
if (screen.alloc) screen.alloc();
|
|
219
|
+
screen.render();
|
|
220
|
+
|
|
221
|
+
if (status === 0) storage.touchUsage(snippet);
|
|
222
|
+
cb(status);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
modal.key(['escape', 'q', 'n'], abort);
|
|
226
|
+
preview.key(['escape', 'q', 'n'], abort);
|
|
227
|
+
modal.key('y', run);
|
|
228
|
+
preview.key('y', run);
|
|
229
|
+
preview.focus();
|
|
230
|
+
screen.render();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function showSnippet(snippet, screen, cb) {
|
|
234
|
+
const content = storage.readSnippetContent(snippet);
|
|
235
|
+
const { width, height } = getModalSize(screen, 78, 120, 14, 34);
|
|
236
|
+
const box = blessed.box({
|
|
237
|
+
parent: screen,
|
|
238
|
+
top: 'center',
|
|
239
|
+
left: 'center',
|
|
240
|
+
width,
|
|
241
|
+
height,
|
|
242
|
+
padding: { top: 1, right: 1, bottom: 1, left: 1 },
|
|
243
|
+
border: { type: 'line', fg: THEME.accent.fg },
|
|
244
|
+
style: { border: { fg: THEME.accent.fg }, bg: THEME.footerBg, fg: THEME.item.fg },
|
|
245
|
+
keys: true,
|
|
246
|
+
scrollable: true,
|
|
247
|
+
tags: true,
|
|
248
|
+
label: { text: ` ${G.dot} ${snippet.name} `, side: 'left', fg: THEME.labelFg, bg: THEME.accent.fg }
|
|
249
|
+
});
|
|
250
|
+
const text = blessed.box({
|
|
251
|
+
parent: box,
|
|
252
|
+
top: 0,
|
|
253
|
+
left: 0,
|
|
254
|
+
width: '100%-2',
|
|
255
|
+
height: '100%-3',
|
|
256
|
+
content,
|
|
257
|
+
tags: false,
|
|
258
|
+
scrollable: true,
|
|
259
|
+
alwaysScroll: true,
|
|
260
|
+
mouse: false,
|
|
261
|
+
keys: true,
|
|
262
|
+
vi: true,
|
|
263
|
+
scrollbar: { style: { bg: THEME.success.fg } }
|
|
264
|
+
});
|
|
265
|
+
const footerDefault = ` j/k:scroll c:copy p:pager q/Esc:close `;
|
|
266
|
+
const footer = blessed.text({
|
|
267
|
+
parent: box,
|
|
268
|
+
bottom: 0,
|
|
269
|
+
left: 0,
|
|
270
|
+
width: '100%',
|
|
271
|
+
content: footerDefault,
|
|
272
|
+
style: { fg: THEME.muted.fg, bg: 'black' }
|
|
273
|
+
});
|
|
274
|
+
let footerTimer = null;
|
|
275
|
+
function showFooterMessage(message, style, timeoutMs) {
|
|
276
|
+
if (footerTimer) clearTimeout(footerTimer);
|
|
277
|
+
footer.style.fg = style.fg;
|
|
278
|
+
footer.setContent(message);
|
|
279
|
+
screen.render();
|
|
280
|
+
if (timeoutMs > 0) {
|
|
281
|
+
footerTimer = setTimeout(() => {
|
|
282
|
+
footer.style.fg = THEME.muted.fg;
|
|
283
|
+
footer.setContent(footerDefault);
|
|
284
|
+
screen.render();
|
|
285
|
+
}, timeoutMs);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function copySnippetContent() {
|
|
289
|
+
const result = clipboard.copyText(content);
|
|
290
|
+
if (result.ok) {
|
|
291
|
+
showFooterMessage(` Copied snippet to clipboard (${result.command}) `, THEME.success, 1800);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
showFooterMessage(' Clipboard unavailable. Use terminal select + copy. ', THEME.warning, 2600);
|
|
295
|
+
}
|
|
296
|
+
function openInPager() {
|
|
297
|
+
const pagerRaw = process.env.PAGER || 'less -R';
|
|
298
|
+
const pager = pagerRaw.split(' ').filter(Boolean);
|
|
299
|
+
const tmpFile = path.join(os.tmpdir(), `snip-view-${snippet.id}.txt`);
|
|
300
|
+
const pagerEnv = { ...process.env, LESSOPEN: '', LESSCLOSE: '' };
|
|
301
|
+
try {
|
|
302
|
+
fs.writeFileSync(tmpFile, content, 'utf8');
|
|
303
|
+
if (screen.leave) screen.leave();
|
|
304
|
+
const res = spawnSync(pager[0], pager.slice(1).concat([tmpFile]), {
|
|
305
|
+
stdio: 'inherit',
|
|
306
|
+
env: pagerEnv
|
|
307
|
+
});
|
|
308
|
+
if (screen.enter) screen.enter();
|
|
309
|
+
if (res.error && res.error.code === 'ENOENT') {
|
|
310
|
+
showFooterMessage(` Pager not found: ${pager[0]} `, THEME.warning, 2600);
|
|
311
|
+
} else {
|
|
312
|
+
showFooterMessage(' Returned from pager. ', THEME.accent, 1200);
|
|
313
|
+
}
|
|
314
|
+
} finally {
|
|
315
|
+
try { fs.unlinkSync(tmpFile); } catch (e) { }
|
|
316
|
+
text.focus();
|
|
317
|
+
screen.render();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function close() {
|
|
321
|
+
if (footerTimer) clearTimeout(footerTimer);
|
|
322
|
+
box.destroy();
|
|
323
|
+
screen.render();
|
|
324
|
+
cb();
|
|
325
|
+
}
|
|
326
|
+
box.key(['c', 'y'], copySnippetContent);
|
|
327
|
+
text.key(['c', 'y'], copySnippetContent);
|
|
328
|
+
box.key(['p'], openInPager);
|
|
329
|
+
text.key(['p'], openInPager);
|
|
330
|
+
box.key(['escape', 'q'], () => {
|
|
331
|
+
close();
|
|
332
|
+
});
|
|
333
|
+
text.key(['escape', 'q'], () => {
|
|
334
|
+
close();
|
|
335
|
+
});
|
|
336
|
+
text.focus();
|
|
337
|
+
screen.render();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function showHelpOverlay(screen, helpBar, cb) {
|
|
341
|
+
const lines = [
|
|
342
|
+
` ${G.dot} SNIP ${G.dash} KEYBOARD SHORTCUTS`,
|
|
343
|
+
'',
|
|
344
|
+
` {${THEME.accent.fg}-fg}Navigation{/}`,
|
|
345
|
+
` j / ${G.arrow} Move down`,
|
|
346
|
+
` k / ${G.arrow} Move up`,
|
|
347
|
+
' Ctrl+d Jump down (+5)',
|
|
348
|
+
' Ctrl+u Jump up (-5)',
|
|
349
|
+
' g Go to first',
|
|
350
|
+
' G Go to last',
|
|
351
|
+
'',
|
|
352
|
+
` {${THEME.accent.fg}-fg}Actions{/}`,
|
|
353
|
+
` Enter Show snippet content`,
|
|
354
|
+
` c Copy selected snippet`,
|
|
355
|
+
` r Run snippet (preview + confirm)`,
|
|
356
|
+
` e Edit snippet in $EDITOR`,
|
|
357
|
+
` a Add new snippet`,
|
|
358
|
+
` d Delete snippet (with confirm)`,
|
|
359
|
+
` / Fuzzy search (type to filter)`,
|
|
360
|
+
` t Filter by tag`,
|
|
361
|
+
` s Cycle sort (name ${G.arrow} usage ${G.arrow} recent)`,
|
|
362
|
+
'',
|
|
363
|
+
` {${THEME.accent.fg}-fg}View Mode{/}`,
|
|
364
|
+
` c / y Copy snippet content`,
|
|
365
|
+
` p Open snippet in pager`,
|
|
366
|
+
'',
|
|
367
|
+
` {${THEME.accent.fg}-fg}General{/}`,
|
|
368
|
+
` ? This help`,
|
|
369
|
+
` q / Ctrl+C Quit`,
|
|
370
|
+
` Esc Cancel / clear filter`,
|
|
371
|
+
'',
|
|
372
|
+
` {${THEME.muted.fg}-fg}[Press any key to close]{/}`
|
|
373
|
+
];
|
|
374
|
+
const { width, height } = getModalSize(screen, 58, 66, 28, 34);
|
|
375
|
+
const box = blessed.box({
|
|
376
|
+
parent: screen,
|
|
377
|
+
top: 'center',
|
|
378
|
+
left: 'center',
|
|
379
|
+
width,
|
|
380
|
+
height,
|
|
381
|
+
padding: { top: 1, right: 2, bottom: 1, left: 2 },
|
|
382
|
+
border: { type: 'line', fg: THEME.accent.fg },
|
|
383
|
+
style: { border: { fg: THEME.accent.fg }, bg: THEME.footerBg, fg: THEME.item.fg },
|
|
384
|
+
keys: true,
|
|
385
|
+
tags: true,
|
|
386
|
+
content: lines.join('\n'),
|
|
387
|
+
label: { text: ` ${G.dot} Help `, side: 'left', fg: THEME.labelFg, bg: THEME.labelBg }
|
|
388
|
+
});
|
|
389
|
+
let isClosed = false;
|
|
390
|
+
function close() {
|
|
391
|
+
if (isClosed) return;
|
|
392
|
+
isClosed = true;
|
|
393
|
+
box.destroy();
|
|
394
|
+
screen.render();
|
|
395
|
+
cb();
|
|
396
|
+
}
|
|
397
|
+
box.key(['escape', 'q', 'enter', 'space'], close);
|
|
398
|
+
let ignoreFirstKeypress = true;
|
|
399
|
+
setTimeout(() => {
|
|
400
|
+
ignoreFirstKeypress = false;
|
|
401
|
+
}, 0);
|
|
402
|
+
box.on('keypress', () => {
|
|
403
|
+
if (ignoreFirstKeypress) return;
|
|
404
|
+
close();
|
|
405
|
+
});
|
|
406
|
+
box.focus();
|
|
407
|
+
screen.render();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const SORT_MODES = ['name', 'usage', 'recent'];
|
|
411
|
+
const SORT_LABELS = { name: 'A-Z', usage: 'Most used', recent: 'Recent' };
|
|
412
|
+
|
|
413
|
+
function buildHelpBar(mode) {
|
|
414
|
+
if (mode === 'tag') return ` ${G.sort} select tag Enter:apply Esc:cancel`;
|
|
415
|
+
if (mode === 'search') return ` ${G.search} Type to filter... Enter:apply Esc:cancel`;
|
|
416
|
+
return ` j/k:move ${G.sort}s:sort /:search t:tag Enter:open c:copy r:run a:add e:edit d:del ?:help q:quit`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function showRunFeedback(screen, helpBar, status, cb) {
|
|
420
|
+
const msg = status === 0 ? ` ${G.check} Done (exit 0) ` : ` ${G.cross} Failed (exit ${status}) `;
|
|
421
|
+
const style = status === 0 ? THEME.success : THEME.error;
|
|
422
|
+
helpBar.setContent(`{${style.fg}-fg}${msg}{/} ${buildHelpBar()}`);
|
|
423
|
+
helpBar.style.fg = style.fg;
|
|
424
|
+
screen.render();
|
|
425
|
+
setTimeout(() => {
|
|
426
|
+
helpBar.setContent(buildHelpBar());
|
|
427
|
+
helpBar.style.fg = THEME.footerFg;
|
|
428
|
+
screen.render();
|
|
429
|
+
if (cb) cb();
|
|
430
|
+
}, 2200);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function start() {
|
|
434
|
+
let all = storage.listSnippets();
|
|
435
|
+
let tagFilter = null;
|
|
436
|
+
let searchQuery = '';
|
|
437
|
+
let sortMode = 0; // index into SORT_MODES
|
|
438
|
+
let filtered = getFilteredSnippets(all, tagFilter, searchQuery);
|
|
439
|
+
let selectedIndex = 0;
|
|
440
|
+
let tagPickerActive = false;
|
|
441
|
+
let tagList = [];
|
|
442
|
+
let tagIndex = 0;
|
|
443
|
+
let helpTimer = null;
|
|
444
|
+
let deleteConfirmActive = false;
|
|
445
|
+
|
|
446
|
+
// Sort helper
|
|
447
|
+
const SORTERS = {
|
|
448
|
+
name: (a, b) => String(a.name || '').localeCompare(String(b.name || '')),
|
|
449
|
+
usage: (a, b) => {
|
|
450
|
+
const diff = (b.usageCount || 0) - (a.usageCount || 0);
|
|
451
|
+
return diff !== 0 ? diff : String(a.name || '').localeCompare(String(b.name || ''));
|
|
452
|
+
},
|
|
453
|
+
recent: (a, b) => {
|
|
454
|
+
const aTs = Date.parse(a.lastUsedAt || a.updatedAt || a.createdAt || 0) || 0;
|
|
455
|
+
const bTs = Date.parse(b.lastUsedAt || b.updatedAt || b.createdAt || 0) || 0;
|
|
456
|
+
return bTs !== aTs ? bTs - aTs : String(a.name || '').localeCompare(String(b.name || ''));
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const screen = blessed.screen({
|
|
461
|
+
smartCSR: true,
|
|
462
|
+
title: 'snip',
|
|
463
|
+
fullUnicode: true,
|
|
464
|
+
cursor: { artificial: 'line', shape: 'line', blink: true }
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const isSplit = () => screen.width >= SPLIT_MIN_WIDTH;
|
|
468
|
+
const calcListWidth = () => isSplit() ? Math.floor(screen.width * 0.42) : screen.width;
|
|
469
|
+
const calcPreviewWidth = () => isSplit() ? screen.width - calcListWidth() : 0;
|
|
470
|
+
|
|
471
|
+
// ── Header ──────────────────────────────────────────────────────────
|
|
472
|
+
const headerLeft = blessed.box({
|
|
473
|
+
parent: screen,
|
|
474
|
+
top: 0,
|
|
475
|
+
left: 0,
|
|
476
|
+
width: '60%',
|
|
477
|
+
height: 1,
|
|
478
|
+
content: ` ${G.dot} snip`,
|
|
479
|
+
style: { fg: THEME.headerFg, bg: THEME.headerBg, bold: true },
|
|
480
|
+
tags: true
|
|
481
|
+
});
|
|
482
|
+
const headerRight = blessed.box({
|
|
483
|
+
parent: screen,
|
|
484
|
+
top: 0,
|
|
485
|
+
right: 0,
|
|
486
|
+
width: '40%',
|
|
487
|
+
height: 1,
|
|
488
|
+
content: '',
|
|
489
|
+
align: 'right',
|
|
490
|
+
style: { fg: THEME.headerFg, bg: THEME.headerBg },
|
|
491
|
+
tags: true
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// ── Search ──────────────────────────────────────────────────────────
|
|
495
|
+
const searchBox = blessed.textbox({
|
|
496
|
+
parent: screen,
|
|
497
|
+
top: 1,
|
|
498
|
+
left: 0,
|
|
499
|
+
width: calcListWidth(),
|
|
500
|
+
height: 3,
|
|
501
|
+
border: { type: 'line', fg: THEME.accent.fg },
|
|
502
|
+
padding: { left: 1, right: 1 },
|
|
503
|
+
style: { fg: THEME.item.fg, border: THEME.borderFocus, bg: THEME.footerBg },
|
|
504
|
+
keys: true,
|
|
505
|
+
inputOnFocus: true,
|
|
506
|
+
tags: true,
|
|
507
|
+
label: { text: ` ${G.search} Search `, side: 'left', fg: THEME.labelFg, bg: THEME.labelBg }
|
|
508
|
+
});
|
|
509
|
+
searchBox.hide();
|
|
510
|
+
|
|
511
|
+
// ── List ────────────────────────────────────────────────────────────
|
|
512
|
+
const listBox = blessed.list({
|
|
513
|
+
parent: screen,
|
|
514
|
+
top: 1,
|
|
515
|
+
left: 0,
|
|
516
|
+
width: calcListWidth(),
|
|
517
|
+
height: '100%-2',
|
|
518
|
+
keys: true,
|
|
519
|
+
vi: false,
|
|
520
|
+
mouse: true,
|
|
521
|
+
padding: { left: 0, right: 0 },
|
|
522
|
+
style: {
|
|
523
|
+
selected: THEME.selected,
|
|
524
|
+
item: THEME.item,
|
|
525
|
+
border: THEME.border
|
|
526
|
+
},
|
|
527
|
+
border: { type: 'line', fg: THEME.border.fg },
|
|
528
|
+
tags: true,
|
|
529
|
+
items: [' Loading...']
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// ── Preview pane ────────────────────────────────────────────────────
|
|
533
|
+
const previewBox = blessed.box({
|
|
534
|
+
parent: screen,
|
|
535
|
+
top: 1,
|
|
536
|
+
right: 0,
|
|
537
|
+
width: calcPreviewWidth(),
|
|
538
|
+
height: '100%-2',
|
|
539
|
+
padding: { top: 0, right: 1, bottom: 0, left: 1 },
|
|
540
|
+
border: { type: 'line', fg: THEME.border.fg },
|
|
541
|
+
style: { fg: THEME.preview.fg, border: THEME.border },
|
|
542
|
+
tags: true,
|
|
543
|
+
scrollable: true,
|
|
544
|
+
alwaysScroll: true,
|
|
545
|
+
mouse: false,
|
|
546
|
+
keys: false,
|
|
547
|
+
vi: false,
|
|
548
|
+
scrollbar: { style: { bg: THEME.accent.fg } },
|
|
549
|
+
label: { text: ' Preview ', side: 'left', fg: THEME.labelFg, bg: THEME.labelBg },
|
|
550
|
+
content: ''
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// ── Footer ──────────────────────────────────────────────────────────
|
|
554
|
+
const helpBar = blessed.box({
|
|
555
|
+
parent: screen,
|
|
556
|
+
bottom: 0,
|
|
557
|
+
left: 0,
|
|
558
|
+
width: '100%',
|
|
559
|
+
height: 1,
|
|
560
|
+
style: { fg: THEME.footerFg, bg: THEME.footerBg },
|
|
561
|
+
content: buildHelpBar(),
|
|
562
|
+
tags: true
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// ── State helpers ───────────────────────────────────────────────────
|
|
566
|
+
function clearHelpTimer() {
|
|
567
|
+
if (helpTimer) {
|
|
568
|
+
clearTimeout(helpTimer);
|
|
569
|
+
helpTimer = null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function flashHelp(message, style = THEME.accent, timeoutMs = 1800) {
|
|
574
|
+
clearHelpTimer();
|
|
575
|
+
helpBar.style.fg = style.fg;
|
|
576
|
+
helpBar.setContent(message);
|
|
577
|
+
screen.render();
|
|
578
|
+
if (timeoutMs > 0) {
|
|
579
|
+
helpTimer = setTimeout(() => {
|
|
580
|
+
setHelpBarDefault();
|
|
581
|
+
screen.render();
|
|
582
|
+
}, timeoutMs);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function setHelpBarDefault() {
|
|
587
|
+
clearHelpTimer();
|
|
588
|
+
helpBar.setContent(buildHelpBar());
|
|
589
|
+
helpBar.style.fg = THEME.footerFg;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function updatePreview() {
|
|
593
|
+
if (!isSplit()) {
|
|
594
|
+
previewBox.hide();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
previewBox.show();
|
|
598
|
+
const snippet = filtered[selectedIndex];
|
|
599
|
+
if (!snippet) {
|
|
600
|
+
previewBox.setLabel({ text: ' Preview ', side: 'left', fg: THEME.labelFg, bg: THEME.labelBg });
|
|
601
|
+
previewBox.setContent(`{${THEME.muted.fg}-fg}No snippet selected{/}`);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
const content = storage.readSnippetContent(snippet);
|
|
605
|
+
const lang = snippet.language ? `[${snippet.language}]` : '';
|
|
606
|
+
previewBox.setLabel({ text: ` ${snippet.name} ${lang} `, side: 'left', fg: THEME.labelFg, bg: THEME.labelBg });
|
|
607
|
+
previewBox.setContent(content || `{${THEME.muted.fg}-fg}(empty){/}`);
|
|
608
|
+
previewBox.setScrollPerc(0);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function updateHeader() {
|
|
612
|
+
const parts = [];
|
|
613
|
+
if (tagFilter) parts.push(`{${THEME.accent.fg}-fg}${tagFilter}{/}`);
|
|
614
|
+
if (searchQuery.trim()) parts.push(`{${THEME.warning.fg}-fg}"${searchQuery}"{/}`);
|
|
615
|
+
const filterStr = parts.length ? ` ${G.arrow} ` + parts.join(` ${G.arrow} `) : '';
|
|
616
|
+
headerLeft.setContent(` {bold}${G.dot} snip{/bold}${filterStr}`);
|
|
617
|
+
const sortLabel = SORT_LABELS[SORT_MODES[sortMode]] || 'A-Z';
|
|
618
|
+
headerRight.setContent(`{${THEME.muted.fg}-fg}${G.sort} ${sortLabel}{/} {${THEME.accent.fg}-fg}${filtered.length}/${all.length}{/} snippets `);
|
|
619
|
+
screen.render();
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function refreshList() {
|
|
623
|
+
filtered = getFilteredSnippets(all, tagFilter, searchQuery);
|
|
624
|
+
// Apply sort
|
|
625
|
+
const key = SORT_MODES[sortMode];
|
|
626
|
+
if (SORTERS[key]) filtered.sort(SORTERS[key]);
|
|
627
|
+
selectedIndex = clampIndex(selectedIndex, filtered.length);
|
|
628
|
+
const lw = calcListWidth();
|
|
629
|
+
const items = filtered.length
|
|
630
|
+
? formatListItems(filtered, lw)
|
|
631
|
+
: [` {${THEME.muted.fg}-fg}No matches. Change search or tag filter.{/}`];
|
|
632
|
+
listBox.setItems(items);
|
|
633
|
+
if (filtered.length) {
|
|
634
|
+
listBox.select(selectedIndex);
|
|
635
|
+
} else {
|
|
636
|
+
listBox.select(0);
|
|
637
|
+
}
|
|
638
|
+
updateHeader();
|
|
639
|
+
updatePreview();
|
|
640
|
+
screen.render();
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function reloadAll() {
|
|
644
|
+
all = storage.listSnippets();
|
|
645
|
+
refreshList();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ── Search ──────────────────────────────────────────────────────────
|
|
649
|
+
function focusSearch() {
|
|
650
|
+
searchBox.show();
|
|
651
|
+
if (searchBox.setValue) searchBox.setValue(searchQuery);
|
|
652
|
+
else searchBox.setContent(searchQuery);
|
|
653
|
+
searchBox.focus();
|
|
654
|
+
listBox.hide();
|
|
655
|
+
previewBox.hide();
|
|
656
|
+
headerLeft.setContent(` {bold}${G.dot} snip{/bold} ${G.arrow} {${THEME.warning.fg}-fg}search{/}`);
|
|
657
|
+
headerRight.setContent(` Enter:apply Esc:cancel `);
|
|
658
|
+
helpBar.setContent(buildHelpBar('search'));
|
|
659
|
+
screen.render();
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ── Tag filter ──────────────────────────────────────────────────────
|
|
663
|
+
function toggleTagFilter() {
|
|
664
|
+
if (tagPickerActive) {
|
|
665
|
+
tagPickerActive = false;
|
|
666
|
+
listBox.focus();
|
|
667
|
+
refreshList();
|
|
668
|
+
setHelpBarDefault();
|
|
669
|
+
screen.render();
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const tags = getUniqueTags(all);
|
|
673
|
+
if (tags.length === 0) {
|
|
674
|
+
flashHelp(` ${G.warn} No tags found. Add tags with: snip add foo --tags a,b `, THEME.warning, 2200);
|
|
675
|
+
listBox.focus();
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
tagPickerActive = true;
|
|
679
|
+
tagList = tags;
|
|
680
|
+
tagIndex = tagFilter ? tagList.indexOf(tagFilter) : 0;
|
|
681
|
+
if (tagIndex < 0) tagIndex = 0;
|
|
682
|
+
helpBar.style.fg = THEME.accent.fg;
|
|
683
|
+
helpBar.setContent(` ${G.sort} j/k:select Enter:apply Esc:cancel ${G.arrow} {bold}${tagList[tagIndex]}{/bold}`);
|
|
684
|
+
screen.render();
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ── Navigation ──────────────────────────────────────────────────────
|
|
688
|
+
function selectRelative(delta) {
|
|
689
|
+
if (tagPickerActive) {
|
|
690
|
+
tagIndex = clampIndex(tagIndex + delta, tagList.length);
|
|
691
|
+
helpBar.setContent(` ${G.sort} j/k:select Enter:apply Esc:cancel ${G.arrow} {bold}${tagList[tagIndex]}{/bold}`);
|
|
692
|
+
screen.render();
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (!filtered.length) return;
|
|
696
|
+
selectedIndex = clampIndex(selectedIndex + delta, filtered.length);
|
|
697
|
+
listBox.select(selectedIndex);
|
|
698
|
+
updateHeader();
|
|
699
|
+
updatePreview();
|
|
700
|
+
screen.render();
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function selectAbsolute(index) {
|
|
704
|
+
if (tagPickerActive) {
|
|
705
|
+
tagIndex = clampIndex(index, tagList.length);
|
|
706
|
+
helpBar.setContent(` ${G.sort} j/k:select Enter:apply Esc:cancel ${G.arrow} {bold}${tagList[tagIndex]}{/bold}`);
|
|
707
|
+
screen.render();
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (!filtered.length) return;
|
|
711
|
+
selectedIndex = clampIndex(index, filtered.length);
|
|
712
|
+
listBox.select(selectedIndex);
|
|
713
|
+
updateHeader();
|
|
714
|
+
updatePreview();
|
|
715
|
+
screen.render();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ── Clipboard ───────────────────────────────────────────────────────
|
|
719
|
+
function copySelectedSnippet() {
|
|
720
|
+
if (tagPickerActive || deleteConfirmActive) return;
|
|
721
|
+
const snippet = filtered[selectedIndex];
|
|
722
|
+
if (!snippet) return;
|
|
723
|
+
const result = clipboard.copyText(storage.readSnippetContent(snippet));
|
|
724
|
+
if (result.ok) {
|
|
725
|
+
flashHelp(` ${G.check} Copied "${snippet.name}" to clipboard `, THEME.success, 1800);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
flashHelp(` ${G.cross} Clipboard unavailable. Press Enter to view and manually copy. `, THEME.warning, 2600);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ── Sort cycling ────────────────────────────────────────────────────
|
|
732
|
+
function cycleSort() {
|
|
733
|
+
if (tagPickerActive || deleteConfirmActive) return;
|
|
734
|
+
sortMode = (sortMode + 1) % SORT_MODES.length;
|
|
735
|
+
const label = SORT_LABELS[SORT_MODES[sortMode]];
|
|
736
|
+
flashHelp(` ${G.sort} Sort: ${label} `, THEME.accent, 1200);
|
|
737
|
+
refreshList();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ── Delete snippet ──────────────────────────────────────────────────
|
|
741
|
+
function deleteSelected() {
|
|
742
|
+
if (tagPickerActive || deleteConfirmActive) return;
|
|
743
|
+
const snippet = filtered[selectedIndex];
|
|
744
|
+
if (!snippet) return;
|
|
745
|
+
deleteConfirmActive = true;
|
|
746
|
+
helpBar.style.fg = THEME.error.fg;
|
|
747
|
+
helpBar.setContent(` ${G.warn} Delete "${snippet.name}"? y:confirm n/Esc:cancel`);
|
|
748
|
+
screen.render();
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function confirmDelete() {
|
|
752
|
+
if (!deleteConfirmActive) return;
|
|
753
|
+
const snippet = filtered[selectedIndex];
|
|
754
|
+
if (snippet) {
|
|
755
|
+
storage.deleteSnippetById(snippet.id);
|
|
756
|
+
flashHelp(` ${G.check} Deleted "${snippet.name}" `, THEME.success, 1800);
|
|
757
|
+
reloadAll();
|
|
758
|
+
}
|
|
759
|
+
deleteConfirmActive = false;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function cancelDelete() {
|
|
763
|
+
if (!deleteConfirmActive) return;
|
|
764
|
+
deleteConfirmActive = false;
|
|
765
|
+
setHelpBarDefault();
|
|
766
|
+
screen.render();
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ── Inline editor modal ──────────────────────────────────────────────
|
|
770
|
+
function showEditorModal(title, initialContent, onSave) {
|
|
771
|
+
const { width, height } = getModalSize(screen, 70, 120, 18, 36);
|
|
772
|
+
const modal = blessed.box({
|
|
773
|
+
parent: screen,
|
|
774
|
+
top: 'center',
|
|
775
|
+
left: 'center',
|
|
776
|
+
width,
|
|
777
|
+
height,
|
|
778
|
+
border: { type: 'line', fg: THEME.accent.fg },
|
|
779
|
+
style: { border: { fg: THEME.accent.fg }, bg: THEME.footerBg, fg: THEME.item.fg },
|
|
780
|
+
tags: true,
|
|
781
|
+
label: { text: ` ${G.dot} ${title} `, side: 'left', fg: THEME.labelFg, bg: THEME.accent.fg }
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
const editor = blessed.textarea({
|
|
785
|
+
parent: modal,
|
|
786
|
+
top: 0,
|
|
787
|
+
left: 0,
|
|
788
|
+
width: '100%-2',
|
|
789
|
+
height: '100%-3',
|
|
790
|
+
keys: true,
|
|
791
|
+
mouse: true,
|
|
792
|
+
inputOnFocus: true,
|
|
793
|
+
scrollable: true,
|
|
794
|
+
alwaysScroll: true,
|
|
795
|
+
style: { fg: THEME.item.fg, bg: THEME.footerBg },
|
|
796
|
+
scrollbar: { style: { bg: THEME.accent.fg } },
|
|
797
|
+
value: initialContent
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
const footer = blessed.text({
|
|
801
|
+
parent: modal,
|
|
802
|
+
bottom: 0,
|
|
803
|
+
left: 0,
|
|
804
|
+
width: '100%',
|
|
805
|
+
content: ` Ctrl+S:save Esc:cancel `,
|
|
806
|
+
style: { fg: THEME.footerFg, bg: THEME.headerBg }
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
let isClosed = false;
|
|
810
|
+
function close(saved) {
|
|
811
|
+
if (isClosed) return;
|
|
812
|
+
isClosed = true;
|
|
813
|
+
const content = (editor.getValue ? editor.getValue() : editor.value || '');
|
|
814
|
+
modal.destroy();
|
|
815
|
+
listBox.focus();
|
|
816
|
+
screen.render();
|
|
817
|
+
if (saved) onSave(content);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
editor.key(['C-s'], () => close(true));
|
|
821
|
+
editor.key(['escape'], () => close(false));
|
|
822
|
+
|
|
823
|
+
editor.focus();
|
|
824
|
+
screen.render();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ── Metadata prompt (tags + language) — sequential single-input ─────
|
|
828
|
+
function showMetaPrompt(defaultTags, defaultLang, onDone) {
|
|
829
|
+
// Step 1: Tags
|
|
830
|
+
promptSingleField('Tags (comma-separated)', defaultTags, (tags) => {
|
|
831
|
+
if (tags === null) { onDone(null, null); return; }
|
|
832
|
+
// Step 2: Language
|
|
833
|
+
promptSingleField('Language (sh, bash, python, node)', defaultLang || 'sh', (lang) => {
|
|
834
|
+
if (lang === null) {
|
|
835
|
+
// Esc on language — still save tags
|
|
836
|
+
const tagArr = tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [];
|
|
837
|
+
onDone(tagArr, defaultLang || 'sh');
|
|
838
|
+
} else {
|
|
839
|
+
const tagArr = tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [];
|
|
840
|
+
onDone(tagArr, lang || 'sh');
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function promptSingleField(label, defaultValue, cb) {
|
|
847
|
+
const input = blessed.textbox({
|
|
848
|
+
parent: screen,
|
|
849
|
+
top: 'center',
|
|
850
|
+
left: 'center',
|
|
851
|
+
width: 56,
|
|
852
|
+
height: 3,
|
|
853
|
+
border: { type: 'line', fg: THEME.accent.fg },
|
|
854
|
+
padding: { left: 1, right: 1 },
|
|
855
|
+
style: { fg: THEME.item.fg, border: { fg: THEME.accent.fg }, bg: THEME.footerBg },
|
|
856
|
+
keys: true,
|
|
857
|
+
inputOnFocus: true,
|
|
858
|
+
value: defaultValue || '',
|
|
859
|
+
label: { text: ` ${G.dot} ${label} `, side: 'left', fg: THEME.labelFg, bg: THEME.accent.fg }
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
input.on('submit', (val) => {
|
|
863
|
+
input.destroy();
|
|
864
|
+
listBox.focus();
|
|
865
|
+
screen.render();
|
|
866
|
+
cb((val || '').trim());
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
input.on('cancel', () => {
|
|
870
|
+
input.destroy();
|
|
871
|
+
listBox.focus();
|
|
872
|
+
screen.render();
|
|
873
|
+
cb(null);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
input.focus();
|
|
877
|
+
screen.render();
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ── Edit snippet ────────────────────────────────────────────────────
|
|
881
|
+
function editSelected() {
|
|
882
|
+
if (tagPickerActive || deleteConfirmActive) return;
|
|
883
|
+
const snippet = filtered[selectedIndex];
|
|
884
|
+
if (!snippet) return;
|
|
885
|
+
const content = storage.readSnippetContent(snippet);
|
|
886
|
+
showEditorModal(`Edit: ${snippet.name}`, content, (newContent) => {
|
|
887
|
+
const contentChanged = newContent.trim() && newContent !== content;
|
|
888
|
+
if (contentChanged) {
|
|
889
|
+
storage.updateSnippetContent(snippet.id, newContent);
|
|
890
|
+
}
|
|
891
|
+
// Always offer metadata editing
|
|
892
|
+
const currentTags = (snippet.tags || []).join(', ');
|
|
893
|
+
const currentLang = snippet.language || 'sh';
|
|
894
|
+
showMetaPrompt(currentTags, currentLang, (tags, lang) => {
|
|
895
|
+
if (tags !== null) {
|
|
896
|
+
storage.updateSnippetMeta(snippet.id, { tags, language: lang });
|
|
897
|
+
}
|
|
898
|
+
if (contentChanged || tags !== null) {
|
|
899
|
+
flashHelp(` ${G.check} Updated "${snippet.name}" `, THEME.success, 1800);
|
|
900
|
+
reloadAll();
|
|
901
|
+
} else {
|
|
902
|
+
flashHelp(` No changes. `, THEME.muted, 1200);
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// ── Add snippet from TUI ────────────────────────────────────────────
|
|
909
|
+
function addSnippet() {
|
|
910
|
+
if (tagPickerActive || deleteConfirmActive) return;
|
|
911
|
+
// Step 1: prompt for snippet name
|
|
912
|
+
const nameInput = blessed.textbox({
|
|
913
|
+
parent: screen,
|
|
914
|
+
top: 'center',
|
|
915
|
+
left: 'center',
|
|
916
|
+
width: 50,
|
|
917
|
+
height: 3,
|
|
918
|
+
border: { type: 'line', fg: THEME.accent.fg },
|
|
919
|
+
padding: { left: 1, right: 1 },
|
|
920
|
+
style: { fg: THEME.item.fg, border: { fg: THEME.accent.fg }, bg: THEME.footerBg },
|
|
921
|
+
keys: true,
|
|
922
|
+
inputOnFocus: true,
|
|
923
|
+
label: { text: ` ${G.dot} Snippet name `, side: 'left', fg: THEME.labelFg, bg: THEME.accent.fg }
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
nameInput.on('submit', (name) => {
|
|
927
|
+
nameInput.destroy();
|
|
928
|
+
name = (name || '').trim();
|
|
929
|
+
if (!name) {
|
|
930
|
+
flashHelp(` Cancelled — no name given. `, THEME.muted, 1200);
|
|
931
|
+
listBox.focus();
|
|
932
|
+
screen.render();
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
name = name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
|
|
936
|
+
// Step 2: editor (empty — no placeholder)
|
|
937
|
+
showEditorModal(`New: ${name}`, '', (content) => {
|
|
938
|
+
if (!content.trim()) {
|
|
939
|
+
flashHelp(` Cancelled — empty content. `, THEME.muted, 1200);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
// Step 3: tags + language
|
|
943
|
+
showMetaPrompt('', 'sh', (tags, lang) => {
|
|
944
|
+
const finalTags = tags || [];
|
|
945
|
+
const finalLang = lang || 'sh';
|
|
946
|
+
storage.addSnippet({ name, content, language: finalLang, tags: finalTags });
|
|
947
|
+
flashHelp(` ${G.check} Added "${name}" `, THEME.success, 2600);
|
|
948
|
+
reloadAll();
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
nameInput.on('cancel', () => {
|
|
954
|
+
nameInput.destroy();
|
|
955
|
+
listBox.focus();
|
|
956
|
+
screen.render();
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
nameInput.focus();
|
|
960
|
+
screen.render();
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// ── Event bindings ──────────────────────────────────────────────────
|
|
964
|
+
searchBox.on('submit', () => {
|
|
965
|
+
const raw = (searchBox.getValue ? searchBox.getValue() : searchBox.content || '').trim();
|
|
966
|
+
searchQuery = raw.startsWith('/') ? raw.slice(1).trim() : raw;
|
|
967
|
+
searchBox.hide();
|
|
968
|
+
if (searchBox.clearValue) searchBox.clearValue();
|
|
969
|
+
listBox.show();
|
|
970
|
+
listBox.focus();
|
|
971
|
+
refreshList();
|
|
972
|
+
setHelpBarDefault();
|
|
973
|
+
screen.render();
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
searchBox.on('cancel', () => {
|
|
977
|
+
searchBox.hide();
|
|
978
|
+
if (searchBox.clearValue) searchBox.clearValue();
|
|
979
|
+
listBox.show();
|
|
980
|
+
listBox.focus();
|
|
981
|
+
updateHeader();
|
|
982
|
+
setHelpBarDefault();
|
|
983
|
+
updatePreview();
|
|
984
|
+
screen.render();
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
listBox.on('select', (el, i) => {
|
|
988
|
+
selectedIndex = i;
|
|
989
|
+
updatePreview();
|
|
990
|
+
});
|
|
991
|
+
listBox.on('focus', () => {
|
|
992
|
+
listBox.style.border = THEME.borderFocus;
|
|
993
|
+
screen.render();
|
|
994
|
+
});
|
|
995
|
+
listBox.on('blur', () => {
|
|
996
|
+
listBox.style.border = THEME.border;
|
|
997
|
+
screen.render();
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
// Delete confirm/cancel intercept
|
|
1001
|
+
listBox.key('y', () => { if (deleteConfirmActive) confirmDelete(); });
|
|
1002
|
+
listBox.key(['n'], () => { if (deleteConfirmActive) { cancelDelete(); return; } });
|
|
1003
|
+
|
|
1004
|
+
listBox.key('r', () => {
|
|
1005
|
+
if (deleteConfirmActive) return;
|
|
1006
|
+
const snippet = filtered[selectedIndex];
|
|
1007
|
+
if (!snippet) return;
|
|
1008
|
+
runSnippet(snippet, screen, (status) => {
|
|
1009
|
+
listBox.focus();
|
|
1010
|
+
if (status !== null && status !== undefined) showRunFeedback(screen, helpBar, status);
|
|
1011
|
+
else screen.render();
|
|
1012
|
+
});
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
listBox.key('c', () => copySelectedSnippet());
|
|
1016
|
+
listBox.key('t', () => { if (!deleteConfirmActive) toggleTagFilter(); });
|
|
1017
|
+
listBox.key('/', () => { if (!deleteConfirmActive) focusSearch(); });
|
|
1018
|
+
listBox.key('s', () => cycleSort());
|
|
1019
|
+
listBox.key('d', () => deleteSelected());
|
|
1020
|
+
listBox.key('e', () => editSelected());
|
|
1021
|
+
listBox.key('a', () => addSnippet());
|
|
1022
|
+
|
|
1023
|
+
listBox.key('?', () => {
|
|
1024
|
+
if (deleteConfirmActive) return;
|
|
1025
|
+
showHelpOverlay(screen, helpBar, () => {
|
|
1026
|
+
listBox.focus();
|
|
1027
|
+
setHelpBarDefault();
|
|
1028
|
+
screen.render();
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
listBox.key(['j', 'down'], () => {
|
|
1033
|
+
if (deleteConfirmActive) return;
|
|
1034
|
+
selectRelative(1);
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
listBox.key(['k', 'up'], () => {
|
|
1038
|
+
if (deleteConfirmActive) return;
|
|
1039
|
+
selectRelative(-1);
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
listBox.key(['C-d', 'pagedown', 'J'], () => {
|
|
1043
|
+
if (deleteConfirmActive) return;
|
|
1044
|
+
selectRelative(FAST_MOVE_STEP);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
listBox.key(['C-u', 'pageup', 'K'], () => {
|
|
1048
|
+
if (deleteConfirmActive) return;
|
|
1049
|
+
selectRelative(-FAST_MOVE_STEP);
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
listBox.key('g', () => {
|
|
1053
|
+
if (deleteConfirmActive) return;
|
|
1054
|
+
selectAbsolute(0);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
listBox.key('G', () => {
|
|
1058
|
+
if (deleteConfirmActive) return;
|
|
1059
|
+
const index = tagPickerActive ? Math.max(0, tagList.length - 1) : Math.max(0, filtered.length - 1);
|
|
1060
|
+
selectAbsolute(index);
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
listBox.key('enter', () => {
|
|
1064
|
+
if (deleteConfirmActive) return;
|
|
1065
|
+
if (tagPickerActive) {
|
|
1066
|
+
tagFilter = tagList[tagIndex] || null;
|
|
1067
|
+
tagPickerActive = false;
|
|
1068
|
+
refreshList();
|
|
1069
|
+
setHelpBarDefault();
|
|
1070
|
+
} else {
|
|
1071
|
+
const snippet = filtered[selectedIndex];
|
|
1072
|
+
if (snippet) showSnippet(snippet, screen, () => { listBox.focus(); updatePreview(); screen.render(); });
|
|
1073
|
+
}
|
|
1074
|
+
screen.render();
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
function quit() {
|
|
1078
|
+
clearHelpTimer();
|
|
1079
|
+
screen.destroy();
|
|
1080
|
+
process.exit(0);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
listBox.key(['q'], () => { if (deleteConfirmActive) { cancelDelete(); return; } quit(); });
|
|
1084
|
+
screen.key(['C-c'], () => quit());
|
|
1085
|
+
|
|
1086
|
+
listBox.key(['escape'], () => {
|
|
1087
|
+
if (deleteConfirmActive) { cancelDelete(); return; }
|
|
1088
|
+
if (tagPickerActive) {
|
|
1089
|
+
tagPickerActive = false;
|
|
1090
|
+
setHelpBarDefault();
|
|
1091
|
+
}
|
|
1092
|
+
if (searchQuery) {
|
|
1093
|
+
searchQuery = '';
|
|
1094
|
+
refreshList();
|
|
1095
|
+
}
|
|
1096
|
+
screen.render();
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
// Handle resize to toggle split-pane
|
|
1100
|
+
function resizeLayout() {
|
|
1101
|
+
const lw = calcListWidth();
|
|
1102
|
+
const pw = calcPreviewWidth();
|
|
1103
|
+
searchBox.width = lw;
|
|
1104
|
+
listBox.width = lw;
|
|
1105
|
+
previewBox.width = pw;
|
|
1106
|
+
}
|
|
1107
|
+
screen.on('resize', () => {
|
|
1108
|
+
resizeLayout();
|
|
1109
|
+
refreshList();
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
listBox.focus();
|
|
1113
|
+
setHelpBarDefault();
|
|
1114
|
+
refreshList();
|
|
1115
|
+
screen.render();
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function ui() {
|
|
1119
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1120
|
+
console.error('snip ui requires an interactive terminal (TTY).');
|
|
1121
|
+
process.exitCode = 1;
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
// Avoid terminfo quirks (e.g. Ghostty's Setulc) by using a well-supported TERM
|
|
1125
|
+
const termOverride = process.env.TERM;
|
|
1126
|
+
if (!termOverride || /ghostty|wezterm|kitty/i.test(termOverride)) {
|
|
1127
|
+
process.env.TERM = 'xterm-256color';
|
|
1128
|
+
}
|
|
1129
|
+
try {
|
|
1130
|
+
start();
|
|
1131
|
+
} catch (e) {
|
|
1132
|
+
console.error('TUI failed:', e.message);
|
|
1133
|
+
process.exitCode = 1;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
module.exports = ui;
|