@uplift-beyond/upex 0.0.0-stage → 1.5.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/LICENSE +21 -0
- package/README.md +327 -2
- package/bin/upex.js +16 -0
- package/package.json +38 -6
- package/src/commands/add.js +87 -0
- package/src/commands/app.js +58 -0
- package/src/commands/check.js +70 -0
- package/src/commands/cmd.js +26 -0
- package/src/commands/completion.js +75 -0
- package/src/commands/copy.js +51 -0
- package/src/commands/doctor.js +130 -0
- package/src/commands/edit.js +85 -0
- package/src/commands/help.js +45 -0
- package/src/commands/last.js +23 -0
- package/src/commands/list.js +76 -0
- package/src/commands/open.js +195 -0
- package/src/commands/remove.js +35 -0
- package/src/commands/rename.js +47 -0
- package/src/commands/run.js +84 -0
- package/src/commands/setup.js +178 -0
- package/src/commands/tag.js +101 -0
- package/src/commands/transfer.js +75 -0
- package/src/commands/web.js +282 -0
- package/src/commands/workspace.js +135 -0
- package/src/fuzzy.js +77 -0
- package/src/git.js +23 -0
- package/src/index.js +120 -0
- package/src/interactive.js +582 -0
- package/src/launcher.js +508 -0
- package/src/store.js +249 -0
- package/src/ui.js +127 -0
- package/src/web/index.html +766 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* interactive.js -- Interactive shortcut selector for `upex` with no arguments.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import readline from 'node:readline';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import Fuse from 'fuse.js';
|
|
10
|
+
import { getInteractiveAppChoices, getAppMap, isTerminalApp } from './launcher.js';
|
|
11
|
+
import { loadLinks, frecency, removeLink, setLink, renameLink, linkExists, loadWorkspaces, removeWorkspace, nameError } from './store.js';
|
|
12
|
+
import { describeEntry } from './commands/workspace.js';
|
|
13
|
+
import * as ui from './ui.js';
|
|
14
|
+
|
|
15
|
+
const MAX_VISIBLE_CHOICES = 8;
|
|
16
|
+
const DEFAULT_APP_KEY = 'explorer';
|
|
17
|
+
|
|
18
|
+
function buildChoices() {
|
|
19
|
+
// Most-used / most-recent first, ties alphabetical.
|
|
20
|
+
return Object.entries(loadLinks())
|
|
21
|
+
.map(([name, entry]) => ({
|
|
22
|
+
name,
|
|
23
|
+
path: entry.path || entry.app || entry.url || '',
|
|
24
|
+
app: entry.app || null,
|
|
25
|
+
isApp: !!entry.app,
|
|
26
|
+
isCmd: !!(entry.app && entry.terminal),
|
|
27
|
+
isUrl: !!entry.url,
|
|
28
|
+
tags: entry.tags || [],
|
|
29
|
+
opener: entry.opener ? String(entry.opener).toLowerCase() : null,
|
|
30
|
+
exists: entry.path ? fs.existsSync(entry.path) : !!(entry.app || entry.url),
|
|
31
|
+
searchText: `${name} ${entry.path || ''} ${entry.app || ''} ${entry.url || ''} ${(entry.tags || []).join(' ')}`,
|
|
32
|
+
score: frecency(entry),
|
|
33
|
+
}))
|
|
34
|
+
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Folder shortcuts on the Shortcuts tab, app + URL shortcuts on the Apps tab.
|
|
38
|
+
export const inView = (view) => (c) => (view === 'apps') === !!(c.isApp || c.isUrl);
|
|
39
|
+
|
|
40
|
+
// Workspaces in the same { name, path, searchText } shape so filterChoices works on them.
|
|
41
|
+
function buildWorkspaceChoices() {
|
|
42
|
+
return Object.entries(loadWorkspaces()).map(([name, entries]) => {
|
|
43
|
+
const summary = (entries || []).map(describeEntry).join(' ');
|
|
44
|
+
return { name, path: summary, searchText: `${name} ${summary}` };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function filterChoices(choices, query) {
|
|
49
|
+
const trimmed = query.trim();
|
|
50
|
+
if (!trimmed) return choices;
|
|
51
|
+
|
|
52
|
+
const fuse = new Fuse(choices, {
|
|
53
|
+
keys: ['name', 'path', 'searchText'],
|
|
54
|
+
threshold: 0.35,
|
|
55
|
+
includeScore: true,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return fuse.search(trimmed).map(result => result.item);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatName(name, width) {
|
|
62
|
+
return chalk.bold(name.padEnd(width));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatAppChoices(appChoices, selectedAppIndex, cmdCount) {
|
|
66
|
+
return appChoices
|
|
67
|
+
.map((choice, index) => {
|
|
68
|
+
const suffix = isTerminalApp(choice.key) && cmdCount > 1 ? ` x${cmdCount}` : '';
|
|
69
|
+
const label = `${choice.label}${suffix}`;
|
|
70
|
+
return index === selectedAppIndex ? chalk.bold(`[${label}]`) : label;
|
|
71
|
+
})
|
|
72
|
+
.join(' ');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getVisibleWindow(choices, selectedIndex, maxVisible = MAX_VISIBLE_CHOICES) {
|
|
76
|
+
if (choices.length <= maxVisible) {
|
|
77
|
+
return { start: 0, end: choices.length };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const half = Math.floor(maxVisible / 2);
|
|
81
|
+
let start = Math.max(0, selectedIndex - half);
|
|
82
|
+
let end = start + maxVisible;
|
|
83
|
+
|
|
84
|
+
if (end > choices.length) {
|
|
85
|
+
end = choices.length;
|
|
86
|
+
start = end - maxVisible;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { start, end };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const PROMPT_LABEL = {
|
|
93
|
+
edit: name => `new path for "${name}" (blank = cwd):`,
|
|
94
|
+
rename: name => `rename "${name}" to:`,
|
|
95
|
+
add: () => 'new shortcut name (path = cwd):',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const VIEWS = { shortcuts: 'Shortcuts', apps: 'Apps', workspaces: 'Workspaces' };
|
|
99
|
+
const VIEW_KEYS = Object.keys(VIEWS);
|
|
100
|
+
const nextView = (view) => VIEW_KEYS[(VIEW_KEYS.indexOf(view) + 1) % VIEW_KEYS.length];
|
|
101
|
+
|
|
102
|
+
export function renderInteractiveView({
|
|
103
|
+
query,
|
|
104
|
+
choices,
|
|
105
|
+
selectedIndex,
|
|
106
|
+
appChoices,
|
|
107
|
+
selectedAppIndex,
|
|
108
|
+
cmdCount,
|
|
109
|
+
view = 'shortcuts',
|
|
110
|
+
workspaces = [],
|
|
111
|
+
wsIndex = 0,
|
|
112
|
+
mode = 'browse',
|
|
113
|
+
action = null,
|
|
114
|
+
inputBuffer = '',
|
|
115
|
+
flash = '',
|
|
116
|
+
}) {
|
|
117
|
+
const isWs = view === 'workspaces';
|
|
118
|
+
const list = isWs ? workspaces : choices;
|
|
119
|
+
const index = isWs ? wsIndex : selectedIndex;
|
|
120
|
+
const nameWidth = Math.max(
|
|
121
|
+
4,
|
|
122
|
+
...list.slice(0, MAX_VISIBLE_CHOICES).map(choice => choice.name.length),
|
|
123
|
+
12,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const currentChoice = list[index];
|
|
127
|
+
|
|
128
|
+
const lines = [];
|
|
129
|
+
lines.push('');
|
|
130
|
+
lines.push(chalk.dim(' ┌─────────────────────┐'));
|
|
131
|
+
lines.push(` ${chalk.dim('│')} ${chalk.bold('Shortcut Selector')} ${chalk.dim('│')}`);
|
|
132
|
+
lines.push(chalk.dim(' └─────────────────────┘'));
|
|
133
|
+
lines.push('');
|
|
134
|
+
const tabs = Object.entries(VIEWS).map(([key, label]) => (key === view ? chalk.bold(`[${label}]`) : label)).join(' ');
|
|
135
|
+
lines.push(` ${chalk.dim('view')} ${tabs}`);
|
|
136
|
+
lines.push(` ${chalk.dim('filter')} ${query || chalk.dim('(type to search)')}`);
|
|
137
|
+
|
|
138
|
+
const tabHint = `Tab=${VIEWS[nextView(view)].toLowerCase()}`;
|
|
139
|
+
if (isWs) {
|
|
140
|
+
lines.push(chalk.dim(` keys Enter=open all ${tabHint} Up/Down=navigate ^D=del Esc=cancel`));
|
|
141
|
+
} else if (view === 'apps') {
|
|
142
|
+
lines.push(` ${chalk.dim('open')} ${chalk.cyan(currentChoice?.isUrl ? 'open in browser' : 'launch app')}`);
|
|
143
|
+
lines.push(chalk.dim(` keys Enter=open ${tabHint} Up/Down=navigate ^R=rename ^D=del Esc=cancel`));
|
|
144
|
+
} else {
|
|
145
|
+
lines.push(` ${chalk.dim('open')} ${formatAppChoices(appChoices, selectedAppIndex, cmdCount)}`);
|
|
146
|
+
const countHint = isTerminalApp(appChoices[selectedAppIndex]?.key) ? ' ^Up/^Down=count' : '';
|
|
147
|
+
lines.push(chalk.dim(` keys Enter=open ${tabHint} Up/Down=navigate Left/Right=app${countHint} ^E=edit ^R=rename ^A=add ^D=del Esc=cancel`));
|
|
148
|
+
}
|
|
149
|
+
lines.push(chalk.dim(' ────────────────────────────────────────────────────────────────────────────'));
|
|
150
|
+
|
|
151
|
+
// Live-action status line (input prompt / delete confirm / error flash).
|
|
152
|
+
if (mode === 'input') {
|
|
153
|
+
const target = currentChoice?.name || '';
|
|
154
|
+
lines.push(` ${chalk.cyan('▸')} ${PROMPT_LABEL[action](target)} ${inputBuffer}${chalk.dim('▏')}`);
|
|
155
|
+
} else if (mode === 'confirm') {
|
|
156
|
+
lines.push(` ${chalk.yellow('?')} delete ${isWs ? 'workspace ' : ''}"${currentChoice?.name}"? ${chalk.dim('(y / n)')}`);
|
|
157
|
+
} else if (flash) {
|
|
158
|
+
lines.push(` ${chalk.yellow(flash)}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (list.length === 0) {
|
|
162
|
+
const empty = {
|
|
163
|
+
workspaces: 'No workspaces yet. Add one: upex ws add <name> <shortcut> [app], or use upex ui',
|
|
164
|
+
apps: 'No app or URL shortcuts yet. Add one: upex add <name> <exe> --app',
|
|
165
|
+
shortcuts: 'No folder shortcuts yet. Add one with ^A (uses the current folder)',
|
|
166
|
+
};
|
|
167
|
+
lines.push(query ? ` ${ui.tag.warn} No matches` : ` ${chalk.dim(empty[view])}`);
|
|
168
|
+
return lines.join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (isWs) {
|
|
172
|
+
const { start, end } = getVisibleWindow(list, index);
|
|
173
|
+
for (let i = start; i < end; i++) {
|
|
174
|
+
lines.push(` ${i === index ? '>' : ' '} ${formatName(list[i].name, nameWidth)} ${chalk.dim(list[i].path)}`);
|
|
175
|
+
}
|
|
176
|
+
if (list.length > MAX_VISIBLE_CHOICES) {
|
|
177
|
+
lines.push(chalk.dim(` showing ${start + 1}-${end} of ${list.length}`));
|
|
178
|
+
}
|
|
179
|
+
return lines.join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const { start, end } = getVisibleWindow(choices, selectedIndex);
|
|
183
|
+
const visibleChoices = choices.slice(start, end);
|
|
184
|
+
|
|
185
|
+
for (let i = 0; i < visibleChoices.length; i++) {
|
|
186
|
+
const choiceIndex = start + i;
|
|
187
|
+
const choice = visibleChoices[i];
|
|
188
|
+
const cursor = choiceIndex === selectedIndex ? '>' : ' ';
|
|
189
|
+
const status = choice.exists ? ui.tag.ok : ui.tag.fail;
|
|
190
|
+
const badge = choice.isCmd ? chalk.cyan('[*CMD]') : choice.isApp ? chalk.cyan('[*APP]') : choice.isUrl ? chalk.blue('[*URL]') : status;
|
|
191
|
+
const tags = choice.tags && choice.tags.length ? chalk.dim(` #${choice.tags.join(' #')}`) : '';
|
|
192
|
+
const label = `${formatName(choice.name, nameWidth)} ${badge} ${choice.path}${tags}`;
|
|
193
|
+
lines.push(` ${cursor} ${label}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (choices.length > MAX_VISIBLE_CHOICES) {
|
|
197
|
+
lines.push(chalk.dim(` showing ${start + 1}-${end} of ${choices.length}`));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return lines.join('\n');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function runInteractiveMode({ preferredApp, stdin = process.stdin, stdout = process.stdout } = {}) {
|
|
204
|
+
let allChoices = buildChoices();
|
|
205
|
+
const appChoices = getInteractiveAppChoices();
|
|
206
|
+
// A default opener outside the usual row (e.g. "open") still needs a slot to preselect.
|
|
207
|
+
const appMap = getAppMap();
|
|
208
|
+
for (const key of new Set(allChoices.map(c => c.opener))) {
|
|
209
|
+
if (key && Object.hasOwn(appMap, key) && !appChoices.some(c => c.key === key)) {
|
|
210
|
+
appChoices.push({ key, label: appMap[key].displayName });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (allChoices.length === 0) {
|
|
215
|
+
ui.blank();
|
|
216
|
+
ui.info('No shortcuts saved. Use: upex add <name> [path]');
|
|
217
|
+
ui.blank();
|
|
218
|
+
return { status: 'empty' };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!stdin.isTTY || !stdout.isTTY) {
|
|
222
|
+
return { status: 'noninteractive' };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return new Promise((resolve, reject) => {
|
|
226
|
+
let query = '';
|
|
227
|
+
let view = 'shortcuts'; // Tab cycles 'shortcuts' | 'apps' | 'workspaces'
|
|
228
|
+
let choices = allChoices.filter(inView(view));
|
|
229
|
+
let filteredChoices = choices;
|
|
230
|
+
let selectedIndex = 0;
|
|
231
|
+
let wsChoices = buildWorkspaceChoices();
|
|
232
|
+
let filteredWs = wsChoices;
|
|
233
|
+
let wsIndex = 0;
|
|
234
|
+
let cmdCount = 1;
|
|
235
|
+
let renderedLineCount = 0;
|
|
236
|
+
let mode = 'browse'; // 'browse' | 'input' | 'confirm'
|
|
237
|
+
let action = null; // 'edit' | 'rename' | 'add'
|
|
238
|
+
let inputBuffer = '';
|
|
239
|
+
let flash = '';
|
|
240
|
+
const appIndex = (key) => appChoices.findIndex(choice => choice.key === key);
|
|
241
|
+
let selectedAppIndex = Math.max(0, appIndex(preferredApp || DEFAULT_APP_KEY));
|
|
242
|
+
// Until Left/Right (or --app) picks one, the opener follows the shortcut's default opener.
|
|
243
|
+
let appPicked = !!preferredApp;
|
|
244
|
+
|
|
245
|
+
const clearRendered = () => {
|
|
246
|
+
if (renderedLineCount > 0) {
|
|
247
|
+
readline.moveCursor(stdout, 0, -renderedLineCount);
|
|
248
|
+
readline.clearScreenDown(stdout);
|
|
249
|
+
renderedLineCount = 0;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const redraw = () => {
|
|
254
|
+
if (!appPicked && view === 'shortcuts') {
|
|
255
|
+
const own = appIndex(filteredChoices[selectedIndex]?.opener);
|
|
256
|
+
selectedAppIndex = own >= 0 ? own : Math.max(0, appIndex(DEFAULT_APP_KEY));
|
|
257
|
+
}
|
|
258
|
+
const output = renderInteractiveView({
|
|
259
|
+
query,
|
|
260
|
+
choices: filteredChoices,
|
|
261
|
+
selectedIndex,
|
|
262
|
+
appChoices,
|
|
263
|
+
selectedAppIndex,
|
|
264
|
+
cmdCount,
|
|
265
|
+
view,
|
|
266
|
+
workspaces: filteredWs,
|
|
267
|
+
wsIndex,
|
|
268
|
+
mode,
|
|
269
|
+
action,
|
|
270
|
+
inputBuffer,
|
|
271
|
+
flash,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Clip to terminal width: a wrapped line occupies 2+ rows, which would
|
|
275
|
+
// desync renderedLineCount and leave stale rows behind on redraw.
|
|
276
|
+
const width = Math.max(1, (stdout.columns || 80) - 1);
|
|
277
|
+
const clipped = output.split('\n').map(line => ui.truncate(line, width));
|
|
278
|
+
|
|
279
|
+
clearRendered();
|
|
280
|
+
|
|
281
|
+
stdout.write(clipped.join('\n') + '\n');
|
|
282
|
+
renderedLineCount = clipped.length;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const updateFilter = () => {
|
|
286
|
+
filteredWs = filterChoices(wsChoices, query);
|
|
287
|
+
wsIndex = Math.max(0, Math.min(wsIndex, filteredWs.length - 1));
|
|
288
|
+
filteredChoices = filterChoices(choices, query);
|
|
289
|
+
if (filteredChoices.length === 0) {
|
|
290
|
+
selectedIndex = 0;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (selectedIndex >= filteredChoices.length) {
|
|
294
|
+
selectedIndex = filteredChoices.length - 1;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// Reload from disk after a mutation and try to keep `name` selected.
|
|
299
|
+
const rebuild = (name) => {
|
|
300
|
+
allChoices = buildChoices();
|
|
301
|
+
choices = allChoices.filter(inView(view));
|
|
302
|
+
wsChoices = buildWorkspaceChoices();
|
|
303
|
+
updateFilter();
|
|
304
|
+
const idx = name ? filteredChoices.findIndex(c => c.name === name) : -1;
|
|
305
|
+
selectedIndex = idx >= 0 ? idx : Math.min(selectedIndex, Math.max(0, filteredChoices.length - 1));
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const cleanup = () => {
|
|
309
|
+
stdin.removeListener('keypress', onKeypress);
|
|
310
|
+
if (stdin.isTTY) {
|
|
311
|
+
stdin.setRawMode(false);
|
|
312
|
+
}
|
|
313
|
+
stdin.pause();
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const finish = (result) => {
|
|
317
|
+
clearRendered();
|
|
318
|
+
cleanup();
|
|
319
|
+
resolve(result);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const fail = (error) => {
|
|
323
|
+
cleanup();
|
|
324
|
+
reject(error);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const startAction = (name) => {
|
|
328
|
+
action = name;
|
|
329
|
+
inputBuffer = '';
|
|
330
|
+
mode = 'input';
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// Commit the pending input action; returns false + sets flash on validation error.
|
|
334
|
+
const commitInput = (target) => {
|
|
335
|
+
const buf = inputBuffer.trim();
|
|
336
|
+
|
|
337
|
+
if (action === 'add') {
|
|
338
|
+
const bad = nameError(buf);
|
|
339
|
+
if (bad) { flash = bad.toLowerCase().replace(/\.$/, ''); return false; }
|
|
340
|
+
if (linkExists(buf)) { flash = `"${buf}" already exists`; return false; }
|
|
341
|
+
setLink(buf, { path: process.cwd() });
|
|
342
|
+
rebuild(buf);
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (action === 'edit') {
|
|
347
|
+
const resolved = buf ? path.resolve(buf) : process.cwd();
|
|
348
|
+
if (!fs.existsSync(resolved)) { flash = 'path not found'; return false; }
|
|
349
|
+
const links = loadLinks();
|
|
350
|
+
setLink(target, { ...(links[target] || {}), path: resolved });
|
|
351
|
+
rebuild(target);
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (action === 'rename') {
|
|
356
|
+
const bad = nameError(buf);
|
|
357
|
+
if (bad) { flash = bad.toLowerCase().replace(/\.$/, ''); return false; }
|
|
358
|
+
const result = renameLink(target, buf);
|
|
359
|
+
if (!result.ok) {
|
|
360
|
+
flash = result.reason === 'exists' ? `"${buf}" already exists` : 'not found';
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
rebuild(buf);
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return true;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const handleKey = (str, key = {}) => {
|
|
371
|
+
flash = '';
|
|
372
|
+
|
|
373
|
+
if (key.ctrl && key.name === 'c') {
|
|
374
|
+
stdout.write('\n');
|
|
375
|
+
finish({ status: 'cancelled' });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── Confirm-delete mode ──
|
|
380
|
+
if (mode === 'confirm') {
|
|
381
|
+
if (key.name === 'y' && view === 'workspaces') {
|
|
382
|
+
const victim = filteredWs[wsIndex];
|
|
383
|
+
if (victim) removeWorkspace(victim.name);
|
|
384
|
+
rebuild();
|
|
385
|
+
} else if (key.name === 'y') {
|
|
386
|
+
const victim = filteredChoices[selectedIndex];
|
|
387
|
+
if (victim) removeLink(victim.name);
|
|
388
|
+
rebuild();
|
|
389
|
+
}
|
|
390
|
+
mode = 'browse';
|
|
391
|
+
redraw();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── Inline input mode (edit / rename / add) ──
|
|
396
|
+
if (mode === 'input') {
|
|
397
|
+
const target = filteredChoices[selectedIndex]?.name;
|
|
398
|
+
|
|
399
|
+
if (key.name === 'escape') {
|
|
400
|
+
mode = 'browse';
|
|
401
|
+
redraw();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (key.name === 'return') {
|
|
405
|
+
if (commitInput(target)) mode = 'browse';
|
|
406
|
+
redraw();
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (key.name === 'backspace') {
|
|
410
|
+
inputBuffer = inputBuffer.slice(0, -1);
|
|
411
|
+
redraw();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (!key.ctrl && !key.meta && str && str >= ' ' && str.charCodeAt(0) !== 127) {
|
|
415
|
+
inputBuffer += str;
|
|
416
|
+
redraw();
|
|
417
|
+
}
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ── Browse mode ──
|
|
422
|
+
if (key.name === 'escape') {
|
|
423
|
+
stdout.write('\n');
|
|
424
|
+
finish({ status: 'cancelled' });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (key.name === 'tab') {
|
|
429
|
+
view = nextView(view);
|
|
430
|
+
query = '';
|
|
431
|
+
choices = allChoices.filter(inView(view));
|
|
432
|
+
selectedIndex = 0;
|
|
433
|
+
updateFilter();
|
|
434
|
+
redraw();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (view === 'workspaces') {
|
|
439
|
+
const n = filteredWs.length;
|
|
440
|
+
if (key.ctrl && key.name === 'd') {
|
|
441
|
+
if (n > 0) { mode = 'confirm'; redraw(); }
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (key.name === 'return') {
|
|
445
|
+
if (n > 0) finish({ status: 'workspace', name: filteredWs[wsIndex].name });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if ((key.name === 'up' || key.name === 'down') && n > 0) {
|
|
449
|
+
wsIndex = (wsIndex + (key.name === 'up' ? n - 1 : 1)) % n;
|
|
450
|
+
redraw();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
// Shortcut-only keys (^A/^E/^R, Left/Right) do nothing here.
|
|
454
|
+
if (key.ctrl || key.name === 'left' || key.name === 'right' || key.name === 'up' || key.name === 'down') return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Live actions (Ctrl+key so they never clash with search typing).
|
|
458
|
+
// ^A adds the cwd as a folder shortcut, so it belongs to the Shortcuts tab.
|
|
459
|
+
if (key.ctrl && key.name === 'a') {
|
|
460
|
+
if (view === 'shortcuts') { startAction('add'); redraw(); }
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (key.ctrl && key.name === 'd') {
|
|
464
|
+
if (filteredChoices.length > 0) { mode = 'confirm'; redraw(); }
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (key.ctrl && key.name === 'e') {
|
|
468
|
+
const target = filteredChoices[selectedIndex];
|
|
469
|
+
if (target && !target.isApp && !target.isUrl) { startAction('edit'); redraw(); }
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (key.ctrl && key.name === 'r') {
|
|
473
|
+
if (filteredChoices.length > 0) { startAction('rename'); redraw(); }
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (key.name === 'return') {
|
|
478
|
+
if (filteredChoices.length === 0) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const selected = filteredChoices[selectedIndex];
|
|
483
|
+
|
|
484
|
+
// App and URL shortcuts launch directly, ignore the opener selector
|
|
485
|
+
if (selected.isApp || selected.isUrl) {
|
|
486
|
+
finish({
|
|
487
|
+
status: 'selected',
|
|
488
|
+
name: selected.name,
|
|
489
|
+
app: null,
|
|
490
|
+
});
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const appKey = appChoices[selectedAppIndex]?.key || DEFAULT_APP_KEY;
|
|
495
|
+
finish({
|
|
496
|
+
status: 'selected',
|
|
497
|
+
name: selected.name,
|
|
498
|
+
app: appKey,
|
|
499
|
+
count: isTerminalApp(appKey) ? cmdCount : 1,
|
|
500
|
+
});
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Ctrl+Up/Down sets the terminal count; plain Up/Down navigates.
|
|
505
|
+
const countKey = key.ctrl && view === 'shortcuts' && isTerminalApp(appChoices[selectedAppIndex]?.key);
|
|
506
|
+
|
|
507
|
+
if (key.name === 'up') {
|
|
508
|
+
if (countKey) {
|
|
509
|
+
cmdCount = Math.min(9, cmdCount + 1);
|
|
510
|
+
redraw();
|
|
511
|
+
} else if (!key.ctrl && filteredChoices.length > 0) {
|
|
512
|
+
selectedIndex = selectedIndex === 0 ? filteredChoices.length - 1 : selectedIndex - 1;
|
|
513
|
+
redraw();
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (key.name === 'down') {
|
|
519
|
+
if (countKey) {
|
|
520
|
+
cmdCount = Math.max(1, cmdCount - 1);
|
|
521
|
+
redraw();
|
|
522
|
+
} else if (!key.ctrl && filteredChoices.length > 0) {
|
|
523
|
+
selectedIndex = selectedIndex === filteredChoices.length - 1 ? 0 : selectedIndex + 1;
|
|
524
|
+
redraw();
|
|
525
|
+
}
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Opener picking only exists on the Shortcuts tab.
|
|
530
|
+
if ((key.name === 'left' || key.name === 'right') && view !== 'shortcuts') return;
|
|
531
|
+
|
|
532
|
+
if (key.name === 'left') {
|
|
533
|
+
appPicked = true;
|
|
534
|
+
selectedAppIndex = selectedAppIndex === 0 ? appChoices.length - 1 : selectedAppIndex - 1;
|
|
535
|
+
redraw();
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (key.name === 'right') {
|
|
540
|
+
appPicked = true;
|
|
541
|
+
selectedAppIndex = selectedAppIndex === appChoices.length - 1 ? 0 : selectedAppIndex + 1;
|
|
542
|
+
redraw();
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (key.name === 'backspace') {
|
|
547
|
+
if (query.length > 0) {
|
|
548
|
+
query = query.slice(0, -1);
|
|
549
|
+
updateFilter();
|
|
550
|
+
redraw();
|
|
551
|
+
}
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (!key.ctrl && !key.meta && str && str >= ' ' && str.charCodeAt(0) !== 127) {
|
|
556
|
+
query += str;
|
|
557
|
+
updateFilter();
|
|
558
|
+
redraw();
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
// Store errors in a handler must reject (bin/upex.js prints them), not crash uncaught.
|
|
563
|
+
const onKeypress = (str, key) => {
|
|
564
|
+
try {
|
|
565
|
+
handleKey(str, key);
|
|
566
|
+
} catch (error) {
|
|
567
|
+
fail(error);
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
readline.emitKeypressEvents(stdin);
|
|
572
|
+
stdin.setRawMode(true);
|
|
573
|
+
stdin.resume();
|
|
574
|
+
stdin.on('keypress', onKeypress);
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
redraw();
|
|
578
|
+
} catch (error) {
|
|
579
|
+
fail(error);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
}
|