@tensor.chat/companion 0.4.1 → 0.4.3
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/README.md +10 -5
- package/bin/tensor-companion.js +25 -2
- package/package.json +1 -1
- package/src/daemon.js +44 -11
- package/src/setup/i18n.js +157 -0
- package/src/setup/layout.js +49 -0
- package/src/setup/places.js +113 -0
- package/src/setup/term.js +94 -0
- package/src/setup/tree.js +174 -0
- package/src/setup/wizard.js +457 -0
- package/src/tools/outside.js +44 -15
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The folder tree of the setup's explorer, and the one rule its ticks follow: a
|
|
3
|
+
// ticked folder covers everything inside it, so the list keeps the outermost ones.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const fsp = fs.promises;
|
|
9
|
+
const WIN_HIDDEN = new Set(['appdata', 'system volume information', 'recovery', 'config.msi', 'perflogs', 'msocache']);
|
|
10
|
+
const GIT_MARK_LIMIT = 400;
|
|
11
|
+
const DRIVE_WAIT_MS = 1500;
|
|
12
|
+
|
|
13
|
+
function samePath(platform) {
|
|
14
|
+
const norm = (p) => (platform === 'win32' ? String(p).toLowerCase() : String(p));
|
|
15
|
+
return {
|
|
16
|
+
equal: (a, b) => norm(a) === norm(b),
|
|
17
|
+
// b lies inside a (not equal)
|
|
18
|
+
inside: (a, b) => {
|
|
19
|
+
const rel = path.relative(norm(a), norm(b));
|
|
20
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 'on' (ticked), 'covered' (inside a ticked one), 'partial' (holds ticked ones), 'off'
|
|
26
|
+
function tickState(p, selected, platform) {
|
|
27
|
+
const s = samePath(platform);
|
|
28
|
+
if (selected.some((x) => s.equal(x, p))) return 'on';
|
|
29
|
+
const parent = selected.find((x) => s.inside(x, p));
|
|
30
|
+
if (parent) return 'covered';
|
|
31
|
+
if (selected.some((x) => s.inside(p, x))) return 'partial';
|
|
32
|
+
return 'off';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The new list, or { blockedBy } when a ticked folder around it already covers it
|
|
36
|
+
function toggleTick(p, selected, platform) {
|
|
37
|
+
const s = samePath(platform);
|
|
38
|
+
if (selected.some((x) => s.equal(x, p))) return { list: selected.filter((x) => !s.equal(x, p)) };
|
|
39
|
+
const parent = selected.find((x) => s.inside(x, p));
|
|
40
|
+
if (parent) return { blockedBy: parent };
|
|
41
|
+
return { list: [...selected.filter((x) => !s.inside(p, x)), p] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hiddenName(name, platform) {
|
|
45
|
+
if (name.startsWith('.')) return true;
|
|
46
|
+
return platform === 'win32' && (name.startsWith('$') || WIN_HIDDEN.has(name.toLowerCase()));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function withTimeout(promise, ms, fallback) {
|
|
50
|
+
let timer;
|
|
51
|
+
const late = new Promise((resolve) => { timer = setTimeout(() => resolve(fallback), ms); });
|
|
52
|
+
try { return await Promise.race([promise, late]); } finally { clearTimeout(timer); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Drive letters that answer within a moment; a network drive that hangs is left out
|
|
56
|
+
async function windowsDrives() {
|
|
57
|
+
const letters = 'CDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
|
58
|
+
const found = await Promise.all(letters.map((l) => withTimeout(fsp.stat(`${l}:\\`).then((s) => s.isDirectory(), () => false), DRIVE_WAIT_MS, false)));
|
|
59
|
+
return letters.filter((_, i) => found[i]).map((l) => `${l}:\\`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function listFolders(dir, platform) {
|
|
63
|
+
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const e of entries) {
|
|
66
|
+
const full = path.join(dir, e.name);
|
|
67
|
+
let link = false;
|
|
68
|
+
if (e.isSymbolicLink()) {
|
|
69
|
+
try { if (!(await fsp.stat(full)).isDirectory()) continue; } catch (_) { continue; }
|
|
70
|
+
link = true;
|
|
71
|
+
} else if (!e.isDirectory()) continue;
|
|
72
|
+
out.push({ path: full, name: e.name, link, hidden: hiddenName(e.name, platform) });
|
|
73
|
+
}
|
|
74
|
+
out.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
|
|
75
|
+
if (out.length <= GIT_MARK_LIMIT) {
|
|
76
|
+
await Promise.all(out.map(async (f) => { try { f.git = (await fsp.stat(path.join(f.path, '.git'))).isDirectory(); } catch (_) { f.git = false; } }));
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function expandHome(p, home) {
|
|
82
|
+
if (p === '~') return home;
|
|
83
|
+
return p.startsWith('~/') || p.startsWith('~\\') ? path.join(home, p.slice(2)) : p;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Tab on a typed path: the one folder it can only mean, or as far as all matches agree
|
|
87
|
+
// and the matches to choose from. null when no folder fits.
|
|
88
|
+
function completeFolder(typed, { home, platform }) {
|
|
89
|
+
const raw = expandHome(typed, home);
|
|
90
|
+
const endsInSep = /[\\/]$/.test(raw);
|
|
91
|
+
const dir = endsInSep ? raw : path.dirname(raw);
|
|
92
|
+
const prefix = endsInSep ? '' : path.basename(raw);
|
|
93
|
+
let names = [];
|
|
94
|
+
try { names = fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() || d.isSymbolicLink()).map((d) => d.name); } catch (_) { names = []; }
|
|
95
|
+
const fold = (s) => (platform === 'win32' ? s.toLowerCase() : s);
|
|
96
|
+
const hits = names.filter((n) => fold(n).startsWith(fold(prefix)) && (prefix.startsWith('.') || !n.startsWith('.'))).sort();
|
|
97
|
+
if (!hits.length) return null;
|
|
98
|
+
let common = hits[0];
|
|
99
|
+
for (const h of hits) while (!fold(h).startsWith(fold(common))) common = common.slice(0, -1);
|
|
100
|
+
const typedDir = endsInSep ? typed : typed.slice(0, typed.length - prefix.length);
|
|
101
|
+
if (hits.length === 1) return { typed: typedDir + hits[0] + path.sep, hints: [] };
|
|
102
|
+
return { typed: typedDir + (common.length > prefix.length ? common : prefix), hints: hits };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
class FolderTree {
|
|
106
|
+
// base: the folder whose content is the top level; null on Windows = the drives
|
|
107
|
+
constructor({ base, platform = process.platform, onChange = () => {} }) {
|
|
108
|
+
this.platform = platform;
|
|
109
|
+
this.onChange = onChange;
|
|
110
|
+
this.showHidden = false;
|
|
111
|
+
this.ready = this.setBase(base);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setBase(base) {
|
|
115
|
+
this.base = base;
|
|
116
|
+
this.root = { path: base, name: base, open: true, children: null, loading: false, error: null, depth: -1 };
|
|
117
|
+
return this.load(this.root);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async load(node) {
|
|
121
|
+
if (node.children || node.loading) return;
|
|
122
|
+
node.loading = true;
|
|
123
|
+
this.onChange();
|
|
124
|
+
try {
|
|
125
|
+
const list = node.path === null ? (await windowsDrives()).map((d) => ({ path: d, name: d, link: false, hidden: false, git: false })) : await listFolders(node.path, this.platform);
|
|
126
|
+
node.children = list.map((f) => ({ ...f, open: false, children: null, loading: false, error: null, depth: node.depth + 1 }));
|
|
127
|
+
} catch (e) {
|
|
128
|
+
node.children = [];
|
|
129
|
+
node.error = e.code === 'EACCES' || e.code === 'EPERM' ? 'noAccess' : (e.code || e.message);
|
|
130
|
+
}
|
|
131
|
+
node.loading = false;
|
|
132
|
+
this.onChange();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The visible lines: { node, parent }
|
|
136
|
+
rows() {
|
|
137
|
+
const out = [];
|
|
138
|
+
const walk = (node) => {
|
|
139
|
+
for (const child of node.children || []) {
|
|
140
|
+
if (child.hidden && !this.showHidden) continue;
|
|
141
|
+
out.push({ node: child, parent: node });
|
|
142
|
+
if (child.open) walk(child);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
walk(this.root);
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
open(node) {
|
|
150
|
+
node.open = true;
|
|
151
|
+
return this.load(node);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
close(node) { node.open = false; }
|
|
155
|
+
|
|
156
|
+
// One folder up: the old top level stays open inside the new one
|
|
157
|
+
async up() {
|
|
158
|
+
if (this.base === null) return null;
|
|
159
|
+
const parent = path.dirname(this.base);
|
|
160
|
+
const from = this.base;
|
|
161
|
+
if (parent === this.base) {
|
|
162
|
+
if (this.platform !== 'win32') return null;
|
|
163
|
+
await this.setBase(null);
|
|
164
|
+
} else await this.setBase(parent);
|
|
165
|
+
const again = (this.root.children || []).find((c) => samePath(this.platform).equal(c.path, from));
|
|
166
|
+
if (again) {
|
|
167
|
+
if (again.hidden) this.showHidden = true;
|
|
168
|
+
await this.open(again);
|
|
169
|
+
}
|
|
170
|
+
return again || null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { FolderTree, tickState, toggleTick, samePath, listFolders, windowsDrives, hiddenName, expandHome, completeFolder };
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The guided setup: what this computer lets the AI use. It edits only the local
|
|
3
|
+
// approvals in ~/.tensor-companion/config.json - the website cannot change them.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { styles, width, pad, openScreen } = require('./term');
|
|
9
|
+
const layout = require('./layout');
|
|
10
|
+
const { translator, pickLanguage } = require('./i18n');
|
|
11
|
+
const { suggestPlaces } = require('./places');
|
|
12
|
+
const { FolderTree, tickState, toggleTick, samePath, windowsDrives, expandHome, completeFolder } = require('./tree');
|
|
13
|
+
|
|
14
|
+
const CONTENT_MAX = 92;
|
|
15
|
+
const LABEL_MAX = 44;
|
|
16
|
+
const STEP = { start: 0, places: 1, browse: 1, type: 1, everything: 1, shell: 2, review: 3 };
|
|
17
|
+
|
|
18
|
+
const { wrap, cell, spread } = layout;
|
|
19
|
+
|
|
20
|
+
function shellUntilOf(cfg, now) {
|
|
21
|
+
const until = Date.parse(String((cfg && cfg.shellApprovedUntil) || ''));
|
|
22
|
+
return Number.isFinite(until) && until > now ? new Date(until).toISOString() : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Resolves to { saved, roots, shellUntil, everything } once the user saved or left
|
|
26
|
+
function runSetup({
|
|
27
|
+
input = process.stdin, output = process.stdout, env = process.env, store = require('../config'),
|
|
28
|
+
platform = process.platform, home = os.homedir(), user = '', device = '', isRoot = false, lang, now = Date.now, system,
|
|
29
|
+
} = {}) {
|
|
30
|
+
const t = translator(pickLanguage(env, lang));
|
|
31
|
+
const S = styles(!('NO_COLOR' in env) && env.TERM !== 'dumb');
|
|
32
|
+
const same = samePath(platform);
|
|
33
|
+
const screen = openScreen({ input, output });
|
|
34
|
+
const show = (p) => {
|
|
35
|
+
if (platform === 'win32' || !home) return p;
|
|
36
|
+
if (same.equal(p, home)) return '~';
|
|
37
|
+
return same.inside(home, p) ? `~/${path.relative(home, p)}` : p;
|
|
38
|
+
};
|
|
39
|
+
const initial = store.approvedRoots().map(String);
|
|
40
|
+
const shellUntil = shellUntilOf(store.load(), now());
|
|
41
|
+
const posixEverything = platform !== 'win32' && initial.some((r) => r === '/');
|
|
42
|
+
const st = {
|
|
43
|
+
screen: 'start', cursor: { start: posixEverything ? 2 : 0, places: 0, browse: 0, everything: 0, shell: 0, review: 0 },
|
|
44
|
+
selected: [...initial], scope: posixEverything ? 'everything' : 'folders', places: null, tree: null,
|
|
45
|
+
typed: '', typeError: null, typeHints: [], shell: shellUntil ? 'keep' : 'no', message: null, shellFrom: 'places',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
let done = false;
|
|
50
|
+
const restore = () => screen.stop();
|
|
51
|
+
process.once('exit', restore);
|
|
52
|
+
const finish = (result) => {
|
|
53
|
+
if (done) return;
|
|
54
|
+
done = true;
|
|
55
|
+
process.removeListener('exit', restore);
|
|
56
|
+
screen.stop();
|
|
57
|
+
resolve(result);
|
|
58
|
+
};
|
|
59
|
+
const draw = () => { if (!done) screen.draw(render()); };
|
|
60
|
+
|
|
61
|
+
suggestPlaces({ platform, home, env, system }).then((list) => { st.places = list; draw(); }, () => { st.places = []; draw(); });
|
|
62
|
+
|
|
63
|
+
// ---- drawing ----
|
|
64
|
+
const K = t('keys');
|
|
65
|
+
const N = t('keyNames');
|
|
66
|
+
const contentWidth = () => Math.min(screen.size().cols - 4, CONTENT_MAX);
|
|
67
|
+
const keyLines = (keys, w) => layout.keyLines(S, keys, w);
|
|
68
|
+
const panel = (lines, tone) => layout.panel(S, lines, tone, Math.min(contentWidth(), 80));
|
|
69
|
+
function frame({ title, right = '', body, focus = 0, help = '', keys }) {
|
|
70
|
+
const { rows } = screen.size();
|
|
71
|
+
const w = contentWidth();
|
|
72
|
+
const step = STEP[st.screen];
|
|
73
|
+
const steps = t('steps').map((name, i) => (i < step ? S.green(`✔ ${name}`) : i === step ? S.bold(S.cyan(`● ${name}`)) : S.dim(`○ ${name}`))).join(S.dim(' ─ '));
|
|
74
|
+
const top = [
|
|
75
|
+
'',
|
|
76
|
+
' ' + spread(`${S.cyan('◆')} ${S.bold('Tensor.Chat Companion')} ${S.dim(t('setup'))}`, S.dim([device, user].filter(Boolean).join(' · ')), w),
|
|
77
|
+
' ' + steps,
|
|
78
|
+
'',
|
|
79
|
+
' ' + spread(S.bold(title), right, w),
|
|
80
|
+
'',
|
|
81
|
+
];
|
|
82
|
+
// the status, or the help for the line under the cursor; two lines at most
|
|
83
|
+
const note = st.message ? wrap(st.message.text, w).slice(0, 2).map((l) => ' ' + S[st.message.tone](l)) : wrap(help, w).slice(0, 2).map((l) => ' ' + S.dim(l));
|
|
84
|
+
const bottom = [
|
|
85
|
+
...(note.length ? note : ['']),
|
|
86
|
+
' ' + S.dim('─'.repeat(w)),
|
|
87
|
+
...keyLines(keys, w),
|
|
88
|
+
];
|
|
89
|
+
const space = Math.max(3, rows - top.length - bottom.length - 1);
|
|
90
|
+
let start = 0;
|
|
91
|
+
if (body.length > space) start = Math.min(Math.max(0, focus - Math.floor(space / 2)), body.length - space);
|
|
92
|
+
const shown = body.slice(start, start + space);
|
|
93
|
+
if (start > 0) shown[0] = ' ' + S.dim('↑');
|
|
94
|
+
if (start + space < body.length) shown[shown.length - 1] = ' ' + S.dim('↓');
|
|
95
|
+
while (shown.length < space) shown.push('');
|
|
96
|
+
return [...top, ...shown, '', ...bottom];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const pointer = (on) => (on ? S.cyan('❯') : ' ');
|
|
100
|
+
function radio(options, cursor) {
|
|
101
|
+
const col = Math.min(LABEL_MAX, Math.max(...options.map((o) => width(o.label))) + 3);
|
|
102
|
+
return options.map((o, i) => {
|
|
103
|
+
const on = i === cursor;
|
|
104
|
+
const note = o.note ? (o.tone ? S[o.tone](o.note) : S.dim(o.note)) : '';
|
|
105
|
+
return ` ${pointer(on)} ${on ? S.cyan('●') : S.dim('○')} ${cell(on ? S.bold(o.label) : o.label, col)}${note}`;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function box(state) {
|
|
110
|
+
if (state === 'on') return S.green('◼');
|
|
111
|
+
if (state === 'covered') return S.dim(S.green('◼'));
|
|
112
|
+
return S.dim('◻');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function insideCount(p) { return st.selected.filter((x) => same.inside(p, x)).length; }
|
|
116
|
+
|
|
117
|
+
function stateNote(p, state) {
|
|
118
|
+
if (state === 'covered') return S.dim(t('note.inside', { path: show(st.selected.find((x) => same.inside(x, p))) }));
|
|
119
|
+
if (state === 'partial') return S.cyan(t('selected', { n: insideCount(p) }));
|
|
120
|
+
return '';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- the screens ----
|
|
124
|
+
const START = ['folders', 'nothing', 'everything'];
|
|
125
|
+
function startView() {
|
|
126
|
+
const opts = [
|
|
127
|
+
{ label: t('start.folders'), note: t('start.foldersNote'), tone: 'green' },
|
|
128
|
+
{ label: t('start.nothing'), note: t('start.nothingNote') },
|
|
129
|
+
{ label: t('start.everything'), note: t('start.everythingNote'), tone: 'yellow' },
|
|
130
|
+
];
|
|
131
|
+
const { cols } = screen.size();
|
|
132
|
+
const body = [...radio(opts, st.cursor.start), '', ...wrap(t('start.help'), Math.min(cols - 6, CONTENT_MAX)).map((l) => ' ' + S.dim(l))];
|
|
133
|
+
if (initial.length) body.push('', ' ' + S.dim(t('start.now')), ...initial.slice(0, 4).map((r) => ` ${S.green('◼')} ${show(r)}`), ...(initial.length > 4 ? [' …'] : []));
|
|
134
|
+
body.push('', ' ' + S.dim(t('start.later')) + ' ' + S.cyan('npx @tensor.chat/companion setup'));
|
|
135
|
+
return frame({ title: t('start.title'), body, focus: st.cursor.start, keys: [['↑↓', K.move], [N.enter, K.select], [N.esc, K.quit]] });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function placeRows() {
|
|
139
|
+
const rows = [];
|
|
140
|
+
for (const x of st.selected) {
|
|
141
|
+
if (!(st.places || []).some((p) => same.equal(p.path, x))) rows.push({ type: 'place', path: x, kind: initial.some((i) => same.equal(i, x)) ? 'earlier' : 'added' });
|
|
142
|
+
}
|
|
143
|
+
for (const p of st.places || []) rows.push({ type: 'place', path: p.path, kind: p.kind, git: p.git, readOnly: p.readOnly });
|
|
144
|
+
rows.push({ type: 'browse' }, { type: 'type' }, { type: 'continue' });
|
|
145
|
+
return rows;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function placeNote(r, state) {
|
|
149
|
+
const parts = [];
|
|
150
|
+
const extra = stateNote(r.path, state);
|
|
151
|
+
if (r.kind === 'code' && r.git) parts.push(S.dim(r.git === 1 ? t('note.git1') : t('note.git', { n: r.git })));
|
|
152
|
+
else if (r.kind === 'project') parts.push(S.dim(t('note.project')));
|
|
153
|
+
else if (['web', 'srv', 'opt', 'log', 'earlier', 'added'].includes(r.kind)) parts.push(S.dim(t(`note.${r.kind}`)));
|
|
154
|
+
else if (r.kind === 'etc' || r.kind === 'home') parts.push(S.yellow(t(`note.${r.kind}`)));
|
|
155
|
+
if (r.readOnly) parts.push(S.dim(t('note.readOnly')));
|
|
156
|
+
if (extra) parts.push(extra);
|
|
157
|
+
return parts.join(S.dim(' · '));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function placeHelp(r) {
|
|
161
|
+
if (!r) return '';
|
|
162
|
+
if (r.type !== 'place') return t(`help.${r.type}`);
|
|
163
|
+
if (r.readOnly) return t('help.readOnly');
|
|
164
|
+
const kind = r.kind === 'project' ? 'code' : r.kind;
|
|
165
|
+
return ['home', 'etc', 'log', 'web', 'srv', 'opt', 'code'].includes(kind) ? t(`help.${kind}`, { home }) : '';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function placesView() {
|
|
169
|
+
const rows = placeRows();
|
|
170
|
+
const cur = Math.min(st.cursor.places, rows.length - 1);
|
|
171
|
+
const labels = rows.filter((r) => r.type === 'place').map((r) => width(show(r.path)));
|
|
172
|
+
const col = Math.min(LABEL_MAX, Math.max(12, ...labels) + 3);
|
|
173
|
+
const body = [];
|
|
174
|
+
let focus = 0;
|
|
175
|
+
if (!st.places) body.push(' ' + S.dim(t('places.loading')));
|
|
176
|
+
else if (!st.places.length && !st.selected.length) body.push(' ' + S.dim(t('places.none')));
|
|
177
|
+
rows.forEach((r, i) => {
|
|
178
|
+
const on = i === cur;
|
|
179
|
+
if (r.type !== 'place' && rows[i - 1] && rows[i - 1].type === 'place') body.push('');
|
|
180
|
+
if (on) focus = body.length;
|
|
181
|
+
if (r.type === 'place') {
|
|
182
|
+
const state = tickState(r.path, st.selected, platform);
|
|
183
|
+
const label = on ? S.bold(show(r.path)) : show(r.path);
|
|
184
|
+
body.push(` ${pointer(on)} ${box(state)} ${cell(label, col)}${placeNote(r, state)}`);
|
|
185
|
+
} else if (r.type === 'continue') {
|
|
186
|
+
body.push(` ${pointer(on)} ${on ? S.bold(S.cyan(t('places.continue') + ' →')) : S.cyan(t('places.continue') + ' →')}`);
|
|
187
|
+
} else {
|
|
188
|
+
const label = t(r.type === 'browse' ? 'places.browse' : 'places.type');
|
|
189
|
+
body.push(` ${pointer(on)} ${S.cyan('+')} ${on ? S.bold(label) : label}`);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
return frame({ title: t('places.title'), right: S.cyan(t('selected', { n: st.selected.length })), body, focus, help: placeHelp(rows[cur]), keys: [['↑↓', K.move], [`${N.space}/${N.enter}`, K.toggle], [N.esc, K.back]] });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function treeLines() {
|
|
196
|
+
const lines = [];
|
|
197
|
+
const items = [];
|
|
198
|
+
const tree = st.tree;
|
|
199
|
+
if (tree.root.loading) lines.push(' ' + S.dim(t('browse.loading')));
|
|
200
|
+
else if (tree.root.error) lines.push(' ' + S.red(t('browse.noAccess')));
|
|
201
|
+
else if (!tree.rows().length) lines.push(' ' + S.dim(t('browse.empty')));
|
|
202
|
+
for (const row of tree.rows()) {
|
|
203
|
+
const n = row.node;
|
|
204
|
+
const on = items.length === st.cursor.browse;
|
|
205
|
+
const indent = ' '.repeat(n.depth);
|
|
206
|
+
const state = tickState(n.path, st.selected, platform);
|
|
207
|
+
const tags = [];
|
|
208
|
+
if (n.git) tags.push(S.cyan('git'));
|
|
209
|
+
if (n.link) tags.push(S.dim(t('browse.link')));
|
|
210
|
+
const extra = stateNote(n.path, state);
|
|
211
|
+
if (extra) tags.push(extra);
|
|
212
|
+
const name = on ? S.bold(n.name) : (n.hidden ? S.dim(n.name) : n.name);
|
|
213
|
+
items.push({ node: n, line: lines.length });
|
|
214
|
+
lines.push(` ${pointer(on)} ${indent}${S.dim(n.open ? '▾' : '▸')} ${box(state)} ${name}${tags.length ? ' ' + tags.join(S.dim(' · ')) : ''}`);
|
|
215
|
+
if (n.open && n.loading) lines.push(` ${indent}${S.dim(t('browse.loading'))}`);
|
|
216
|
+
else if (n.open && n.children && !n.children.filter((c) => tree.showHidden || !c.hidden).length) lines.push(` ${indent}${S.dim(n.error === 'noAccess' ? t('browse.noAccess') : t('browse.empty'))}`);
|
|
217
|
+
}
|
|
218
|
+
return { lines, items };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function browseView() {
|
|
222
|
+
const { lines, items } = treeLines();
|
|
223
|
+
const at = items[Math.min(st.cursor.browse, items.length - 1)];
|
|
224
|
+
const where = st.tree.base === null ? t('browse.thisPc') : show(st.tree.base);
|
|
225
|
+
return frame({
|
|
226
|
+
title: `${t('browse.title')} ${S.dim(where)}`, right: S.cyan(t('selected', { n: st.selected.length })), body: lines, focus: at ? at.line : 0,
|
|
227
|
+
keys: [['↑↓', K.move], [N.space, K.toggle], ['→ ←', K.openClose], [N.backspace, K.up], ['.', K.hidden], [N.esc, K.done]],
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function typeView() {
|
|
232
|
+
const body = [` ${S.dim(t('type.label'))} ${S.cyan('›')} ${st.typed}${S.inverse(' ')}`];
|
|
233
|
+
if (st.typeError) body.push('', ' ' + S.red(st.typeError));
|
|
234
|
+
if (st.typeHints.length) body.push('', ...st.typeHints.slice(0, 8).map((h) => ' ' + S.dim(h)));
|
|
235
|
+
return frame({ title: t('type.title'), body, help: t('help.type'), keys: [[N.tab, K.tab], [N.enter, K.add], [N.esc, K.back]] });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function everythingView() {
|
|
239
|
+
const text = [t('everything.body', { user: user || '?' })];
|
|
240
|
+
if (isRoot) text.push(S.red(t('everything.root')));
|
|
241
|
+
text.push('', S.yellow(t('everything.free')));
|
|
242
|
+
const body = [...panel(text, 'yellow'), ''];
|
|
243
|
+
body.push(...radio([{ label: t('everything.no') }, { label: t('everything.yes'), tone: 'yellow' }], st.cursor.everything));
|
|
244
|
+
return frame({ title: `${S.yellow('▲')} ${t('everything.title')}`, body, focus: body.length - 1, keys: [['↑↓', K.move], [N.enter, K.select], [N.esc, K.back]] });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function shellOptions() {
|
|
248
|
+
const quiet = t('shell.noNote');
|
|
249
|
+
const list = shellUntil
|
|
250
|
+
? [{ id: 'keep', label: t('shell.keep'), note: t('shell.keepNote', { date: t.date(shellUntil) }) }, { id: 'off', label: t('shell.off'), note: quiet }]
|
|
251
|
+
: [{ id: 'no', label: t('shell.no'), note: quiet }];
|
|
252
|
+
return [...list, { id: 7, label: t('shell.d7') }, { id: 30, label: t('shell.d30') }, { id: 365, label: t('shell.d365'), tone: 'yellow' }];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function shellView() {
|
|
256
|
+
const body = [...panel([t('shell.body', { user: user || '?' })]), '', ...radio(shellOptions(), st.cursor.shell)];
|
|
257
|
+
return frame({ title: t('shell.title'), body, focus: body.length - 1, keys: [['↑↓', K.move], [N.enter, K.select], [N.esc, K.back]] });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function changes() {
|
|
261
|
+
return {
|
|
262
|
+
removed: initial.filter((x) => !st.selected.some((y) => same.equal(x, y))),
|
|
263
|
+
added: st.selected.filter((x) => !initial.some((y) => same.equal(x, y))),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function reviewView() {
|
|
268
|
+
const { removed } = changes();
|
|
269
|
+
const label = (s) => pad(S.dim(s), 12);
|
|
270
|
+
const body = [];
|
|
271
|
+
if (st.scope === 'everything') body.push(`${label(t('review.folders'))}${S.yellow('▲ ' + t('everything.summary'))}`);
|
|
272
|
+
else if (!st.selected.length) body.push(`${label(t('review.folders'))}${S.dim(t('review.noFolders'))}`);
|
|
273
|
+
else st.selected.forEach((x, i) => body.push(`${label(i ? '' : t('review.folders'))}${S.green('◼')} ${show(x)}`));
|
|
274
|
+
if (st.scope !== 'everything') for (const x of removed) body.push(`${label('')}${S.red('−')} ${S.dim(show(x))} ${S.red(t('review.removed'))}`);
|
|
275
|
+
const shellLine = st.shell === 'keep' ? t('review.cmdUntil', { date: t.date(shellUntil) })
|
|
276
|
+
: typeof st.shell === 'number' ? t('review.cmdUntil', { date: t.date(new Date(now() + st.shell * 864e5).toISOString()) }) : t('review.cmdNo');
|
|
277
|
+
body.push('', `${label(t('review.commands'))}${typeof st.shell === 'number' || st.shell === 'keep' ? S.yellow(shellLine) : shellLine}`);
|
|
278
|
+
const lines = [...panel(body), ''];
|
|
279
|
+
const opts = [t('review.save'), t('review.back'), t('review.quit')];
|
|
280
|
+
opts.forEach((o, i) => lines.push(` ${pointer(i === st.cursor.review)} ${i === st.cursor.review ? S.bold(i === 0 ? S.green(o) : o) : o}`));
|
|
281
|
+
return frame({ title: t('review.title'), body: lines, focus: lines.length - 1, help: t('review.where', { file: show(store.configPath()) }), keys: [['↑↓', K.move], [N.enter, K.select], [N.esc, K.back]] });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function render() {
|
|
285
|
+
return ({ start: startView, places: placesView, browse: browseView, type: typeView, everything: everythingView, shell: shellView, review: reviewView })[st.screen]();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---- keys ----
|
|
289
|
+
const go = (name) => { st.screen = name; st.message = null; draw(); };
|
|
290
|
+
const moveIn = (name, n, key) => {
|
|
291
|
+
const c = st.cursor[name];
|
|
292
|
+
if (key.name === 'up') st.cursor[name] = (c - 1 + n) % n;
|
|
293
|
+
else if (key.name === 'down') st.cursor[name] = (c + 1) % n;
|
|
294
|
+
else if (key.name === 'pageup') st.cursor[name] = Math.max(0, c - 10);
|
|
295
|
+
else if (key.name === 'pagedown') st.cursor[name] = Math.min(n - 1, c + 10);
|
|
296
|
+
else if (key.name === 'home') st.cursor[name] = 0;
|
|
297
|
+
else if (key.name === 'end') st.cursor[name] = n - 1;
|
|
298
|
+
else return false;
|
|
299
|
+
return true;
|
|
300
|
+
};
|
|
301
|
+
const tick = (p) => {
|
|
302
|
+
const r = toggleTick(p, st.selected, platform);
|
|
303
|
+
if (r.blockedBy) st.message = { tone: 'yellow', text: t('browse.blocked', { path: show(p), parent: show(r.blockedBy) }) };
|
|
304
|
+
else { st.selected = r.list; st.message = null; }
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const keys = {
|
|
308
|
+
start(str, key) {
|
|
309
|
+
if (moveIn('start', START.length, key)) return draw();
|
|
310
|
+
if (key.name === 'escape' || str === 'q') return finish({ saved: false });
|
|
311
|
+
if (key.name !== 'return') return;
|
|
312
|
+
const choice = START[st.cursor.start];
|
|
313
|
+
if (choice === 'folders') {
|
|
314
|
+
if (st.scope === 'everything') st.selected = st.selected.filter((x) => path.parse(x).root !== x);
|
|
315
|
+
st.scope = 'folders';
|
|
316
|
+
st.shellFrom = 'places';
|
|
317
|
+
return go('places');
|
|
318
|
+
}
|
|
319
|
+
if (choice === 'nothing') { st.scope = 'nothing'; st.selected = []; st.shellFrom = 'start'; return go('shell'); }
|
|
320
|
+
st.cursor.everything = 0;
|
|
321
|
+
return go('everything');
|
|
322
|
+
},
|
|
323
|
+
places(str, key) {
|
|
324
|
+
const rows = placeRows();
|
|
325
|
+
if (moveIn('places', rows.length, key)) { st.message = null; return draw(); }
|
|
326
|
+
if (key.name === 'escape') return go('start');
|
|
327
|
+
const r = rows[Math.min(st.cursor.places, rows.length - 1)];
|
|
328
|
+
if (key.name !== 'return' && key.name !== 'space') return;
|
|
329
|
+
if (r.type === 'place') { tick(r.path); return draw(); }
|
|
330
|
+
if (r.type === 'continue') return go('shell');
|
|
331
|
+
if (r.type === 'type') { st.typed = platform === 'win32' ? home + path.sep : '~/'; st.typeError = null; st.typeHints = []; return go('type'); }
|
|
332
|
+
if (!st.tree) {
|
|
333
|
+
st.tree = new FolderTree({ base: home, platform, onChange: draw });
|
|
334
|
+
st.cursor.browse = 0;
|
|
335
|
+
}
|
|
336
|
+
return go('browse');
|
|
337
|
+
},
|
|
338
|
+
async browse(str, key) {
|
|
339
|
+
const tree = st.tree;
|
|
340
|
+
const items = tree.rows();
|
|
341
|
+
if (moveIn('browse', Math.max(1, items.length), key)) { st.message = null; return draw(); }
|
|
342
|
+
if (key.name === 'escape') return go('places');
|
|
343
|
+
if (str === '.') {
|
|
344
|
+
const at = items[st.cursor.browse];
|
|
345
|
+
tree.showHidden = !tree.showHidden;
|
|
346
|
+
st.message = { tone: 'dim', text: t(tree.showHidden ? 'browse.hidden' : 'browse.hiddenOff') };
|
|
347
|
+
// the cursor stays on its folder; one that is hidden now gives way to the first
|
|
348
|
+
const again = tree.rows().findIndex((r) => at && r.node === at.node);
|
|
349
|
+
st.cursor.browse = Math.max(0, again);
|
|
350
|
+
return draw();
|
|
351
|
+
}
|
|
352
|
+
if (key.name === 'backspace') {
|
|
353
|
+
const from = await tree.up();
|
|
354
|
+
if (from) st.cursor.browse = Math.max(0, tree.rows().findIndex((r) => r.node === from));
|
|
355
|
+
return draw();
|
|
356
|
+
}
|
|
357
|
+
const row = items[Math.min(st.cursor.browse, items.length - 1)];
|
|
358
|
+
if (!row) return;
|
|
359
|
+
const n = row.node;
|
|
360
|
+
if (key.name === 'space') { tick(n.path); return draw(); }
|
|
361
|
+
if (key.name === 'right' || key.name === 'return') {
|
|
362
|
+
if (!n.open) { await tree.open(n); return draw(); }
|
|
363
|
+
if ((n.children || []).some((c) => tree.showHidden || !c.hidden)) st.cursor.browse++;
|
|
364
|
+
return draw();
|
|
365
|
+
}
|
|
366
|
+
if (key.name === 'left') {
|
|
367
|
+
if (n.open) tree.close(n);
|
|
368
|
+
else if (row.parent !== tree.root) st.cursor.browse = Math.max(0, tree.rows().findIndex((r) => r.node === row.parent));
|
|
369
|
+
return draw();
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
type(str, key) {
|
|
373
|
+
if (key.name === 'escape') return go('places');
|
|
374
|
+
if (key.name === 'tab') { complete(); return draw(); }
|
|
375
|
+
if (key.name === 'return') return addTyped();
|
|
376
|
+
if (key.name === 'backspace') st.typed = st.typed.slice(0, -1);
|
|
377
|
+
else if (key.ctrl && key.name === 'u') st.typed = '';
|
|
378
|
+
else if (str && !key.ctrl && !key.meta && str >= ' ' && str !== '\x7f') st.typed += str;
|
|
379
|
+
st.typeError = null;
|
|
380
|
+
st.typeHints = [];
|
|
381
|
+
return draw();
|
|
382
|
+
},
|
|
383
|
+
async everything(str, key) {
|
|
384
|
+
if (moveIn('everything', 2, key)) return draw();
|
|
385
|
+
if (key.name === 'escape') return go('start');
|
|
386
|
+
if (key.name !== 'return') return;
|
|
387
|
+
if (st.cursor.everything === 0) return go('start');
|
|
388
|
+
st.selected = platform === 'win32' ? await windowsDrives() : ['/'];
|
|
389
|
+
st.scope = 'everything';
|
|
390
|
+
st.shellFrom = 'start';
|
|
391
|
+
return go('shell');
|
|
392
|
+
},
|
|
393
|
+
shell(str, key) {
|
|
394
|
+
const opts = shellOptions();
|
|
395
|
+
if (moveIn('shell', opts.length, key)) return draw();
|
|
396
|
+
if (key.name === 'escape') return go(st.shellFrom);
|
|
397
|
+
if (key.name !== 'return') return;
|
|
398
|
+
st.shell = opts[st.cursor.shell].id;
|
|
399
|
+
st.cursor.review = 0;
|
|
400
|
+
return go('review');
|
|
401
|
+
},
|
|
402
|
+
review(str, key) {
|
|
403
|
+
if (moveIn('review', 3, key)) return draw();
|
|
404
|
+
if (key.name === 'escape') return go('shell');
|
|
405
|
+
if (key.name !== 'return') return;
|
|
406
|
+
if (st.cursor.review === 1) return go('shell');
|
|
407
|
+
if (st.cursor.review === 2) return finish({ saved: false });
|
|
408
|
+
try {
|
|
409
|
+
save();
|
|
410
|
+
} catch (e) {
|
|
411
|
+
st.message = { tone: 'red', text: e.message };
|
|
412
|
+
return draw();
|
|
413
|
+
}
|
|
414
|
+
const cfg = store.load();
|
|
415
|
+
return finish({ saved: true, roots: store.approvedRoots(), shellUntil: shellUntilOf(cfg, now()), everything: st.scope === 'everything' });
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
function complete() {
|
|
420
|
+
const r = completeFolder(st.typed, { home, platform });
|
|
421
|
+
if (r) { st.typed = r.typed; st.typeHints = r.hints; }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function addTyped() {
|
|
425
|
+
const raw = expandHome(st.typed.trim(), home);
|
|
426
|
+
let real = null;
|
|
427
|
+
try { real = fs.realpathSync.native(path.resolve(raw)); if (!fs.statSync(real).isDirectory()) real = null; } catch (_) { real = null; }
|
|
428
|
+
if (!raw || !real) { st.typeError = t('type.notFound', { path: st.typed.trim() || '?' }); return draw(); }
|
|
429
|
+
if (tickState(real, st.selected, platform) !== 'on') tick(real);
|
|
430
|
+
const message = st.message;
|
|
431
|
+
go('places');
|
|
432
|
+
st.message = message;
|
|
433
|
+
const at = placeRows().findIndex((r) => r.path && same.equal(r.path, real));
|
|
434
|
+
if (at >= 0) st.cursor.places = at;
|
|
435
|
+
return draw();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function save() {
|
|
439
|
+
const { removed, added } = changes();
|
|
440
|
+
for (const r of removed) store.revokeRoot(r);
|
|
441
|
+
for (const a of added) store.approveRoot(a);
|
|
442
|
+
if (st.shell === 'off') store.revokeShell();
|
|
443
|
+
else if (typeof st.shell === 'number') store.approveShell(st.shell);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
screen.onKey((str, key) => {
|
|
447
|
+
if (done) return;
|
|
448
|
+
if (key.ctrl && key.name === 'c') return finish({ saved: false });
|
|
449
|
+
Promise.resolve(keys[st.screen](str, key)).catch((e) => { st.message = { tone: 'red', text: e.message }; draw(); });
|
|
450
|
+
});
|
|
451
|
+
screen.onResize(draw);
|
|
452
|
+
screen.start();
|
|
453
|
+
draw();
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
module.exports = { runSetup };
|