bitty-tui 0.0.16 → 0.0.18
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/dist/cli.js +2 -1
- package/dist/clients/bw.js +38 -71
- package/dist/components/Button.js +21 -15
- package/dist/components/Checkbox.js +9 -2
- package/dist/components/ScrollView.d.ts +2 -1
- package/dist/components/ScrollView.js +3 -1
- package/dist/components/TextInput.js +9 -4
- package/dist/dashboard/DashboardView.js +12 -4
- package/dist/dashboard/components/VaultList.js +15 -1
- package/dist/hooks/bw.d.ts +6 -0
- package/dist/hooks/bw.js +29 -2
- package/dist/hooks/use-mouse.d.ts +11 -0
- package/dist/hooks/use-mouse.js +124 -0
- package/dist/login/LoginView.js +13 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { render } from "ink";
|
|
4
4
|
import App from "./app.js";
|
|
5
5
|
import { StatusMessageProvider } from "./hooks/status-message.js";
|
|
6
|
+
import { MouseProvider } from "./hooks/use-mouse.js";
|
|
6
7
|
import { readPackageUpSync } from "read-package-up";
|
|
7
8
|
import { art } from "./theme/art.js";
|
|
8
9
|
import path from "node:path";
|
|
@@ -26,4 +27,4 @@ if (args.includes("--help") || args.includes("-h")) {
|
|
|
26
27
|
`);
|
|
27
28
|
process.exit(0);
|
|
28
29
|
}
|
|
29
|
-
render(_jsx(StatusMessageProvider, { children: _jsx(App, {}) }));
|
|
30
|
+
render(_jsx(StatusMessageProvider, { children: _jsx(MouseProvider, { children: _jsx(App, {}) }) }));
|
package/dist/clients/bw.js
CHANGED
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
* - Cipher-specific key if available
|
|
18
18
|
* 4. Decrypt cipher fields using the chosen key
|
|
19
19
|
*/
|
|
20
|
-
import https from "node:https";
|
|
21
20
|
import crypto from "node:crypto";
|
|
22
21
|
import * as argon2 from "argon2";
|
|
23
22
|
export class FetchError extends Error {
|
|
@@ -32,54 +31,14 @@ export class FetchError extends Error {
|
|
|
32
31
|
return JSON.parse(this.data);
|
|
33
32
|
}
|
|
34
33
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
};
|
|
44
|
-
const req = https.request(requestOptions, (res) => {
|
|
45
|
-
let data = "";
|
|
46
|
-
const onData = (chunk) => {
|
|
47
|
-
data += chunk;
|
|
48
|
-
};
|
|
49
|
-
const onEnd = () => {
|
|
50
|
-
cleanup();
|
|
51
|
-
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
|
|
52
|
-
reject(new FetchError(res.statusCode, data, `HTTP error: ${res.statusCode} ${res.statusMessage}`));
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
resolve({
|
|
56
|
-
status: res.statusCode,
|
|
57
|
-
json: () => Promise.resolve(JSON.parse(data)),
|
|
58
|
-
text: () => Promise.resolve(data),
|
|
59
|
-
});
|
|
60
|
-
};
|
|
61
|
-
const onError = (error) => {
|
|
62
|
-
cleanup();
|
|
63
|
-
reject(error);
|
|
64
|
-
};
|
|
65
|
-
const cleanup = () => {
|
|
66
|
-
res.removeListener("data", onData);
|
|
67
|
-
res.removeListener("end", onEnd);
|
|
68
|
-
res.removeListener("error", onError);
|
|
69
|
-
};
|
|
70
|
-
res.on("data", onData);
|
|
71
|
-
res.on("end", onEnd);
|
|
72
|
-
res.on("error", onError);
|
|
73
|
-
});
|
|
74
|
-
req.on("error", (error) => {
|
|
75
|
-
reject(error);
|
|
76
|
-
});
|
|
77
|
-
if (body) {
|
|
78
|
-
req.write(typeof body === "string" ? body : JSON.stringify(body));
|
|
79
|
-
}
|
|
80
|
-
req.end();
|
|
81
|
-
});
|
|
82
|
-
}
|
|
34
|
+
const fetchApi = async (...args) => {
|
|
35
|
+
const response = await fetch(...args);
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
const data = await response.text();
|
|
38
|
+
throw new FetchError(response.status, data);
|
|
39
|
+
}
|
|
40
|
+
return response;
|
|
41
|
+
};
|
|
83
42
|
export var CipherType;
|
|
84
43
|
(function (CipherType) {
|
|
85
44
|
CipherType[CipherType["Login"] = 1] = "Login";
|
|
@@ -409,13 +368,12 @@ export class Client {
|
|
|
409
368
|
async login(email, password, skipPrelogin = false, opts) {
|
|
410
369
|
let keys = this.keys;
|
|
411
370
|
if (!skipPrelogin) {
|
|
412
|
-
const prelogin = await
|
|
371
|
+
const prelogin = await fetchApi(`${this.identityUrl}/accounts/prelogin`, {
|
|
413
372
|
method: "POST",
|
|
414
373
|
headers: {
|
|
415
374
|
"Content-Type": "application/json",
|
|
416
375
|
},
|
|
417
|
-
|
|
418
|
-
email,
|
|
376
|
+
body: JSON.stringify({ email }),
|
|
419
377
|
}).then((r) => r.json());
|
|
420
378
|
keys = await mcbw.deriveMasterKey(email, password, prelogin);
|
|
421
379
|
this.keys = keys;
|
|
@@ -432,12 +390,17 @@ export class Client {
|
|
|
432
390
|
for (const [key, value] of Object.entries(opts || {})) {
|
|
433
391
|
bodyParams.append(key, value);
|
|
434
392
|
}
|
|
435
|
-
const identityReq = await
|
|
393
|
+
const identityReq = await fetchApi(`${this.identityUrl}/connect/token`, {
|
|
436
394
|
method: "POST",
|
|
437
395
|
headers: {
|
|
438
|
-
|
|
396
|
+
accept: "*/*",
|
|
397
|
+
"accept-language": "en-US",
|
|
398
|
+
"bitwarden-client-name": "web",
|
|
399
|
+
"bitwarden-client-version": "2025.9.0",
|
|
400
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
|
439
401
|
},
|
|
440
|
-
|
|
402
|
+
body: bodyParams.toString(),
|
|
403
|
+
}).then((r) => r.json());
|
|
441
404
|
this.token = identityReq.access_token;
|
|
442
405
|
this.refreshToken = identityReq.refresh_token;
|
|
443
406
|
this.tokenExpiration = Date.now() + identityReq.expires_in * 1000;
|
|
@@ -453,19 +416,20 @@ export class Client {
|
|
|
453
416
|
this.orgKeys = {};
|
|
454
417
|
}
|
|
455
418
|
async sendEmailMfaCode(email) {
|
|
456
|
-
|
|
419
|
+
fetchApi(`${this.apiUrl}/two-factor/send-email-login`, {
|
|
457
420
|
method: "POST",
|
|
458
421
|
headers: {
|
|
459
422
|
"Content-Type": "application/json",
|
|
460
423
|
},
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
424
|
+
body: JSON.stringify({
|
|
425
|
+
email: email,
|
|
426
|
+
masterPasswordHash: this.keys.masterPasswordHash,
|
|
427
|
+
ssoEmail2FaSessionToken: "",
|
|
428
|
+
deviceIdentifier: DEVICE_IDENTIFIER,
|
|
429
|
+
authRequestAccessCode: "",
|
|
430
|
+
authRequestId: "",
|
|
431
|
+
}),
|
|
432
|
+
});
|
|
469
433
|
}
|
|
470
434
|
// Check and refresh token if needed
|
|
471
435
|
async checkToken() {
|
|
@@ -473,12 +437,13 @@ export class Client {
|
|
|
473
437
|
if (!this.refreshToken) {
|
|
474
438
|
throw new Error("No refresh token available. Please login first.");
|
|
475
439
|
}
|
|
476
|
-
const identityReq = await
|
|
440
|
+
const identityReq = await fetchApi(`${this.identityUrl}/connect/token`, {
|
|
477
441
|
method: "POST",
|
|
478
442
|
headers: {
|
|
479
443
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
480
444
|
},
|
|
481
|
-
|
|
445
|
+
body: `refresh_token=${this.refreshToken}&grant_type=refresh_token&client_id=web&scope=api%20offline_access`,
|
|
446
|
+
}).then((r) => r.json());
|
|
482
447
|
this.token = identityReq.access_token;
|
|
483
448
|
this.refreshToken = identityReq.refresh_token;
|
|
484
449
|
this.tokenExpiration = Date.now() + identityReq.expires_in * 1000;
|
|
@@ -491,7 +456,7 @@ export class Client {
|
|
|
491
456
|
*/
|
|
492
457
|
async syncRefresh() {
|
|
493
458
|
await this.checkToken();
|
|
494
|
-
this.syncCache = await
|
|
459
|
+
this.syncCache = await fetchApi(`${this.apiUrl}/sync?excludeDomains=true`, {
|
|
495
460
|
method: "GET",
|
|
496
461
|
headers: {
|
|
497
462
|
Authorization: `Bearer ${this.token}`,
|
|
@@ -637,13 +602,14 @@ export class Client {
|
|
|
637
602
|
}
|
|
638
603
|
async createSecret(obj) {
|
|
639
604
|
const key = this.getDecryptionKey(obj);
|
|
640
|
-
const s = await
|
|
605
|
+
const s = await fetchApi(`${this.apiUrl}/ciphers`, {
|
|
641
606
|
method: "POST",
|
|
642
607
|
headers: {
|
|
643
608
|
Authorization: `Bearer ${this.token}`,
|
|
644
609
|
"Content-Type": "application/json",
|
|
645
610
|
},
|
|
646
|
-
|
|
611
|
+
body: JSON.stringify(this.encryptCipher(obj, key)),
|
|
612
|
+
});
|
|
647
613
|
return s.json();
|
|
648
614
|
}
|
|
649
615
|
objectDiff(obj1, obj2) {
|
|
@@ -683,13 +649,14 @@ export class Client {
|
|
|
683
649
|
const key = this.getDecryptionKey(patch);
|
|
684
650
|
const data = this.patchObject(original, this.encryptCipher(obj, key));
|
|
685
651
|
data.data = undefined;
|
|
686
|
-
const s = await
|
|
652
|
+
const s = await fetchApi(`${this.apiUrl}/ciphers/${id}`, {
|
|
687
653
|
method: "PUT",
|
|
688
654
|
headers: {
|
|
689
655
|
Authorization: `Bearer ${this.token}`,
|
|
690
656
|
"Content-Type": "application/json",
|
|
691
657
|
},
|
|
692
|
-
|
|
658
|
+
body: JSON.stringify(data),
|
|
659
|
+
});
|
|
693
660
|
this.decryptedSyncCache = null;
|
|
694
661
|
this.syncCache = null;
|
|
695
662
|
return s.json();
|
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Text, Box, useFocus, useInput } from "ink";
|
|
3
|
-
import { useRef, useState } from "react";
|
|
3
|
+
import { useId, useRef, useState } from "react";
|
|
4
4
|
import { primary } from "../theme/style.js";
|
|
5
|
+
import { useMouseTarget } from "../hooks/use-mouse.js";
|
|
5
6
|
export const Button = ({ isActive = true, doubleConfirm, onClick, children, autoFocus = false, ...props }) => {
|
|
6
|
-
const
|
|
7
|
+
const generatedId = useId();
|
|
8
|
+
const { isFocused } = useFocus({ id: generatedId, autoFocus: autoFocus });
|
|
7
9
|
const [askConfirm, setAskConfirm] = useState(false);
|
|
8
10
|
const timeoutRef = useRef(null);
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
if (askConfirm)
|
|
19
|
-
setAskConfirm(false);
|
|
20
|
-
onClick();
|
|
11
|
+
const boxRef = useRef(null);
|
|
12
|
+
const handlePress = () => {
|
|
13
|
+
if (timeoutRef.current)
|
|
14
|
+
clearTimeout(timeoutRef.current);
|
|
15
|
+
if (doubleConfirm && !askConfirm) {
|
|
16
|
+
setAskConfirm(true);
|
|
17
|
+
timeoutRef.current = setTimeout(() => setAskConfirm(false), 1000);
|
|
18
|
+
return;
|
|
21
19
|
}
|
|
20
|
+
if (askConfirm)
|
|
21
|
+
setAskConfirm(false);
|
|
22
|
+
onClick();
|
|
23
|
+
};
|
|
24
|
+
useMouseTarget(generatedId, boxRef, { onClick: handlePress });
|
|
25
|
+
useInput((input, key) => {
|
|
26
|
+
if (key.return)
|
|
27
|
+
handlePress();
|
|
22
28
|
}, { isActive: isFocused && isActive });
|
|
23
|
-
return (_jsx(Box, { borderStyle: "round", borderColor: isFocused && isActive ? primary : "gray", alignItems: "center", justifyContent: "center", ...props, children: _jsx(Text, { color: isFocused && isActive ? (askConfirm ? "yellow" : "white") : "gray", children: askConfirm ? "Confirm?" : children }) }));
|
|
29
|
+
return (_jsx(Box, { ref: boxRef, borderStyle: "round", borderColor: isFocused && isActive ? primary : "gray", alignItems: "center", justifyContent: "center", ...props, children: _jsx(Text, { color: isFocused && isActive ? (askConfirm ? "yellow" : "white") : "gray", children: askConfirm ? "Confirm?" : children }) }));
|
|
24
30
|
};
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Text, Box, useFocus, useInput } from "ink";
|
|
3
|
+
import { useId, useRef } from "react";
|
|
3
4
|
import { primary } from "../theme/style.js";
|
|
5
|
+
import { useMouseTarget } from "../hooks/use-mouse.js";
|
|
4
6
|
export const Checkbox = ({ isActive = true, value, label, onToggle, ...props }) => {
|
|
5
|
-
const
|
|
7
|
+
const generatedId = useId();
|
|
8
|
+
const { isFocused } = useFocus({ id: generatedId });
|
|
9
|
+
const boxRef = useRef(null);
|
|
10
|
+
useMouseTarget(generatedId, boxRef, {
|
|
11
|
+
onClick: () => onToggle(!value),
|
|
12
|
+
});
|
|
6
13
|
useInput((input, key) => {
|
|
7
14
|
if (input === " ") {
|
|
8
15
|
onToggle(!value);
|
|
9
16
|
}
|
|
10
17
|
}, { isActive: isFocused && isActive });
|
|
11
|
-
return (_jsxs(Box, { ...props, children: [_jsx(Box, { width: 5, height: 3, flexShrink: 0, borderStyle: "round", borderColor: isFocused && isActive ? primary : "gray", children: value && (_jsx(Box, { width: 1, height: 1, marginLeft: 1, children: _jsx(Text, { color: isFocused && isActive ? primary : "gray", children: "X" }) })) }), _jsx(Box, { marginTop: 1, marginLeft: 1, children: _jsx(Text, { children: label }) })] }));
|
|
18
|
+
return (_jsxs(Box, { ref: boxRef, ...props, children: [_jsx(Box, { width: 5, height: 3, flexShrink: 0, borderStyle: "round", borderColor: isFocused && isActive ? primary : "gray", children: value && (_jsx(Box, { width: 1, height: 1, marginLeft: 1, children: _jsx(Text, { color: isFocused && isActive ? primary : "gray", children: "X" }) })) }), _jsx(Box, { marginTop: 1, marginLeft: 1, children: _jsx(Text, { children: label }) })] }));
|
|
12
19
|
};
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { ReactNode } from "react";
|
|
2
|
-
export declare const ScrollView: <T>({ count, list, isActive, selectedIndex, onSelect, onSubmit, children, }: {
|
|
2
|
+
export declare const ScrollView: <T>({ count, list, isActive, selectedIndex, onSelect, onSubmit, offsetRef, children, }: {
|
|
3
3
|
count: number;
|
|
4
4
|
list: T[];
|
|
5
5
|
isActive: boolean;
|
|
6
6
|
selectedIndex: number;
|
|
7
7
|
onSelect?: (position: number) => void;
|
|
8
8
|
onSubmit?: (position: number) => void;
|
|
9
|
+
offsetRef?: React.MutableRefObject<number>;
|
|
9
10
|
children: (arg: {
|
|
10
11
|
el: T;
|
|
11
12
|
index: number;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, useInput } from "ink";
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
|
-
export const ScrollView = ({ count, list, isActive, selectedIndex, onSelect, onSubmit, children, }) => {
|
|
4
|
+
export const ScrollView = ({ count, list, isActive, selectedIndex, onSelect, onSubmit, offsetRef, children, }) => {
|
|
5
5
|
const [offset, setOffset] = useState(0);
|
|
6
|
+
if (offsetRef)
|
|
7
|
+
offsetRef.current = offset;
|
|
6
8
|
useInput((input, key) => {
|
|
7
9
|
if (key.upArrow) {
|
|
8
10
|
if (selectedIndex === offset && offset > 0) {
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { Text, Box, useFocus, useInput, useFocusManager } from "ink";
|
|
2
|
+
import { Text, Box, useFocus, useInput, useFocusManager, } from "ink";
|
|
3
3
|
import { primary } from "../theme/style.js";
|
|
4
|
-
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
5
5
|
import clipboard from "clipboardy";
|
|
6
6
|
import chalk from "chalk";
|
|
7
7
|
import { useStatusMessage } from "../hooks/status-message.js";
|
|
8
|
+
import { useMouseTarget } from "../hooks/use-mouse.js";
|
|
8
9
|
export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFocus, isActive, autoFocus, inline, multiline, maxLines = 1, onChange, onSubmit, onCopy, ...props }) => {
|
|
9
10
|
const [cursor, setCursor] = useState(onChange ? value.length : 0);
|
|
10
11
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
11
|
-
const
|
|
12
|
+
const generatedId = useId();
|
|
13
|
+
const effectiveId = id ?? generatedId;
|
|
14
|
+
const { isFocused } = useFocus({ id: effectiveId, isActive, autoFocus });
|
|
12
15
|
const { showStatusMessage } = useStatusMessage();
|
|
13
16
|
const { focusNext } = useFocusManager();
|
|
17
|
+
const boxRef = useRef(null);
|
|
18
|
+
useMouseTarget(effectiveId, boxRef);
|
|
14
19
|
const displayValue = useMemo(() => {
|
|
15
20
|
let displayValue = value;
|
|
16
21
|
if (isPassword && (showPasswordOnFocus ? !isFocused : true)) {
|
|
@@ -189,5 +194,5 @@ export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFo
|
|
|
189
194
|
}
|
|
190
195
|
}
|
|
191
196
|
}, { isActive: isFocused });
|
|
192
|
-
return (_jsx(Box, { borderStyle: "round", borderColor: isFocused ? primary : "gray", borderBottom: !inline, borderTop: !inline, borderLeft: !inline, borderRight: !inline, flexGrow: 1, flexShrink: 0, paddingX: inline ? 0 : 1, overflow: "hidden", minHeight: inline ? 1 : 3, ...props, children: _jsx(Text, { color: value ? "white" : "gray", children: displayValue }) }));
|
|
197
|
+
return (_jsx(Box, { ref: boxRef, borderStyle: "round", borderColor: isFocused ? primary : "gray", borderBottom: !inline, borderTop: !inline, borderLeft: !inline, borderRight: !inline, flexGrow: 1, flexShrink: 0, paddingX: inline ? 0 : 1, overflow: "hidden", minHeight: inline ? 1 : 3, ...props, children: _jsx(Text, { color: value ? "white" : "gray", children: displayValue }) }));
|
|
193
198
|
};
|
|
@@ -8,6 +8,7 @@ import { HelpBar } from "./components/HelpBar.js";
|
|
|
8
8
|
import { primary } from "../theme/style.js";
|
|
9
9
|
import { bwClient, clearConfig, emptyCipher, useBwSync } from "../hooks/bw.js";
|
|
10
10
|
import { useStatusMessage } from "../hooks/status-message.js";
|
|
11
|
+
import { useMouseSubscribe } from "../hooks/use-mouse.js";
|
|
11
12
|
export function DashboardView({ onLogout }) {
|
|
12
13
|
const { sync, error, fetchSync } = useBwSync();
|
|
13
14
|
const [syncState, setSyncState] = useState(sync);
|
|
@@ -46,13 +47,20 @@ export function DashboardView({ onLogout }) {
|
|
|
46
47
|
await clearConfig();
|
|
47
48
|
onLogout();
|
|
48
49
|
};
|
|
50
|
+
useMouseSubscribe((targetId) => {
|
|
51
|
+
if (targetId === "search") {
|
|
52
|
+
setFocusedComponent("search");
|
|
53
|
+
}
|
|
54
|
+
else if (targetId === "list") {
|
|
55
|
+
setFocusedComponent("list");
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
setFocusedComponent("detail");
|
|
59
|
+
}
|
|
60
|
+
});
|
|
49
61
|
useEffect(() => {
|
|
50
62
|
setSyncState(sync);
|
|
51
63
|
}, [sync]);
|
|
52
|
-
useEffect(() => {
|
|
53
|
-
if (focusedComponent === "detail")
|
|
54
|
-
focusNext();
|
|
55
|
-
}, [focusedComponent]);
|
|
56
64
|
useEffect(() => {
|
|
57
65
|
if (error)
|
|
58
66
|
showStatusMessage(error, "error");
|
|
@@ -5,6 +5,8 @@ import { CipherType } from "../../clients/bw.js";
|
|
|
5
5
|
import { ScrollView } from "../../components/ScrollView.js";
|
|
6
6
|
import clipboard from "clipboardy";
|
|
7
7
|
import { useStatusMessage } from "../../hooks/status-message.js";
|
|
8
|
+
import { useRef } from "react";
|
|
9
|
+
import { useMouseTarget } from "../../hooks/use-mouse.js";
|
|
8
10
|
const getTypeIcon = (type) => {
|
|
9
11
|
switch (type) {
|
|
10
12
|
case CipherType.Login:
|
|
@@ -22,6 +24,18 @@ const getTypeIcon = (type) => {
|
|
|
22
24
|
export function VaultList({ filteredCiphers, isFocused, selected, onSelect, }) {
|
|
23
25
|
const { stdout } = useStdout();
|
|
24
26
|
const { showStatusMessage } = useStatusMessage();
|
|
27
|
+
const boxRef = useRef(null);
|
|
28
|
+
const scrollOffsetRef = useRef(0);
|
|
29
|
+
useMouseTarget("list", boxRef, {
|
|
30
|
+
noFocus: true,
|
|
31
|
+
onClick: (_relX, relY) => {
|
|
32
|
+
const visibleIndex = relY - 1; // -1 for border
|
|
33
|
+
const actualIndex = scrollOffsetRef.current + visibleIndex;
|
|
34
|
+
if (actualIndex >= 0 && actualIndex < filteredCiphers.length) {
|
|
35
|
+
onSelect(actualIndex);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
});
|
|
25
39
|
useInput((input, key) => {
|
|
26
40
|
const cipher = selected !== null ? filteredCiphers[selected] : null;
|
|
27
41
|
let field, fldName;
|
|
@@ -64,5 +78,5 @@ export function VaultList({ filteredCiphers, isFocused, selected, onSelect, }) {
|
|
|
64
78
|
showStatusMessage(`📋 Copied ${fldName} to clipboard!`, "success");
|
|
65
79
|
}
|
|
66
80
|
}, { isActive: isFocused });
|
|
67
|
-
return (_jsx(Box, { flexDirection: "column", width: "40%", borderStyle: "round", borderColor: isFocused ? primaryLight : "gray", borderRightColor: "gray", paddingX: 1, overflow: "hidden", children: _jsx(ScrollView, { isActive: isFocused, count: Math.max(stdout.rows - 14, 20), list: filteredCiphers, selectedIndex: selected ?? 0, onSelect: onSelect, children: ({ el: cipher, selected }) => (_jsxs(Box, { justifyContent: "space-between", backgroundColor: selected ? (isFocused ? primary : primaryDark) : "", children: [_jsxs(Box, { children: [_jsxs(Text, { children: [getTypeIcon(cipher.type), " "] }), _jsx(Text, { color: selected && isFocused ? "white" : "default", wrap: "truncate", children: cipher.name })] }), cipher.favorite && _jsx(Text, { color: "yellow", children: "\u2605" })] }, cipher.id)) }) }));
|
|
81
|
+
return (_jsx(Box, { ref: boxRef, flexDirection: "column", width: "40%", borderStyle: "round", borderColor: isFocused ? primaryLight : "gray", borderRightColor: "gray", paddingX: 1, overflow: "hidden", children: _jsx(ScrollView, { isActive: isFocused, count: Math.max(stdout.rows - 14, 20), list: filteredCiphers, selectedIndex: selected ?? 0, onSelect: onSelect, offsetRef: scrollOffsetRef, children: ({ el: cipher, selected }) => (_jsxs(Box, { justifyContent: "space-between", backgroundColor: selected ? (isFocused ? primary : primaryDark) : "", children: [_jsxs(Box, { children: [_jsxs(Text, { children: [getTypeIcon(cipher.type), " "] }), _jsx(Text, { color: selected && isFocused ? "white" : "default", wrap: "truncate", children: cipher.name })] }), cipher.favorite && _jsx(Text, { color: "yellow", children: "\u2605" })] }, cipher.id)) }) }));
|
|
68
82
|
}
|
package/dist/hooks/bw.d.ts
CHANGED
|
@@ -5,6 +5,12 @@ interface BwConfig {
|
|
|
5
5
|
refreshToken: string;
|
|
6
6
|
}
|
|
7
7
|
export declare const bwClient: Client;
|
|
8
|
+
export interface LoginHints {
|
|
9
|
+
email?: string;
|
|
10
|
+
baseUrl?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function loadLoginHints(): Promise<LoginHints>;
|
|
13
|
+
export declare function saveLoginHints(hints: LoginHints): Promise<void>;
|
|
8
14
|
export declare function loadConfig(): Promise<boolean>;
|
|
9
15
|
export declare function saveConfig(config: BwConfig): Promise<void>;
|
|
10
16
|
export declare function clearConfig(): Promise<void>;
|
package/dist/hooks/bw.js
CHANGED
|
@@ -4,7 +4,34 @@ import path from "path";
|
|
|
4
4
|
import { CipherType, Client } from "../clients/bw.js";
|
|
5
5
|
import { useCallback, useEffect, useState } from "react";
|
|
6
6
|
export const bwClient = new Client();
|
|
7
|
-
const
|
|
7
|
+
const configDir = path.join(os.homedir(), ".config", "bitty");
|
|
8
|
+
const configPath = path.join(configDir, "config.json");
|
|
9
|
+
export async function loadLoginHints() {
|
|
10
|
+
try {
|
|
11
|
+
if (fs.existsSync(configPath)) {
|
|
12
|
+
const content = await fs.promises.readFile(configPath, "utf-8");
|
|
13
|
+
const config = JSON.parse(Buffer.from(content, "base64").toString("utf-8"));
|
|
14
|
+
return { email: config.email, baseUrl: config.baseUrl };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
catch { }
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
export async function saveLoginHints(hints) {
|
|
21
|
+
let config = {};
|
|
22
|
+
try {
|
|
23
|
+
if (fs.existsSync(configPath)) {
|
|
24
|
+
const content = await fs.promises.readFile(configPath, "utf-8");
|
|
25
|
+
config = JSON.parse(Buffer.from(content, "base64").toString("utf-8"));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch { }
|
|
29
|
+
config.email = hints.email;
|
|
30
|
+
config.baseUrl = hints.baseUrl;
|
|
31
|
+
const encoded = Buffer.from(JSON.stringify(config)).toString("base64");
|
|
32
|
+
await fs.promises.mkdir(configDir, { recursive: true });
|
|
33
|
+
await fs.promises.writeFile(configPath, encoded);
|
|
34
|
+
}
|
|
8
35
|
export async function loadConfig() {
|
|
9
36
|
try {
|
|
10
37
|
if (fs.existsSync(configPath)) {
|
|
@@ -69,7 +96,7 @@ export async function saveConfig(config) {
|
|
|
69
96
|
...config,
|
|
70
97
|
keys,
|
|
71
98
|
})).toString("base64");
|
|
72
|
-
await fs.promises.mkdir(
|
|
99
|
+
await fs.promises.mkdir(configDir, { recursive: true });
|
|
73
100
|
await fs.promises.writeFile(configPath, encConfig);
|
|
74
101
|
}
|
|
75
102
|
export async function clearConfig() {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type DOMElement } from "ink";
|
|
2
|
+
type TargetOptions = {
|
|
3
|
+
noFocus?: boolean;
|
|
4
|
+
onClick?: (relX: number, relY: number) => void;
|
|
5
|
+
};
|
|
6
|
+
export declare const MouseProvider: ({ children, }: {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
}) => import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export declare const useMouseTarget: (id: string, ref: React.RefObject<DOMElement | null>, options?: TargetOptions) => void;
|
|
10
|
+
export declare const useMouseSubscribe: (listener: (targetId: string) => void) => void;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useCallback, useEffect, useRef, } from "react";
|
|
3
|
+
import { useStdin, useFocusManager, measureElement, } from "ink";
|
|
4
|
+
const MouseContext = createContext({
|
|
5
|
+
registerTarget: () => { },
|
|
6
|
+
unregisterTarget: () => { },
|
|
7
|
+
subscribe: () => () => { },
|
|
8
|
+
});
|
|
9
|
+
const mouseSequenceRe = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
|
|
10
|
+
function getAbsolutePosition(node) {
|
|
11
|
+
let currentNode = node;
|
|
12
|
+
let x = 0;
|
|
13
|
+
let y = 0;
|
|
14
|
+
while (currentNode?.parentNode) {
|
|
15
|
+
if (!currentNode.yogaNode)
|
|
16
|
+
return undefined;
|
|
17
|
+
x += currentNode.yogaNode.getComputedLeft();
|
|
18
|
+
y += currentNode.yogaNode.getComputedTop();
|
|
19
|
+
currentNode = currentNode.parentNode;
|
|
20
|
+
}
|
|
21
|
+
return { x, y };
|
|
22
|
+
}
|
|
23
|
+
export const MouseProvider = ({ children, }) => {
|
|
24
|
+
const { internal_eventEmitter } = useStdin();
|
|
25
|
+
const { focus } = useFocusManager();
|
|
26
|
+
const targetsRef = useRef(new Map());
|
|
27
|
+
const listenersRef = useRef(new Set());
|
|
28
|
+
const focusRef = useRef(focus);
|
|
29
|
+
focusRef.current = focus;
|
|
30
|
+
const registerTarget = useCallback((id, ref, options) => {
|
|
31
|
+
targetsRef.current.set(id, { ref, ...options });
|
|
32
|
+
}, []);
|
|
33
|
+
const unregisterTarget = useCallback((id) => {
|
|
34
|
+
targetsRef.current.delete(id);
|
|
35
|
+
}, []);
|
|
36
|
+
const subscribe = useCallback((listener) => {
|
|
37
|
+
listenersRef.current.add(listener);
|
|
38
|
+
return () => {
|
|
39
|
+
listenersRef.current.delete(listener);
|
|
40
|
+
};
|
|
41
|
+
}, []);
|
|
42
|
+
const handleClick = useCallback((x, y) => {
|
|
43
|
+
for (const [id, target] of targetsRef.current) {
|
|
44
|
+
const node = target.ref.current;
|
|
45
|
+
if (!node)
|
|
46
|
+
continue;
|
|
47
|
+
const pos = getAbsolutePosition(node);
|
|
48
|
+
if (!pos)
|
|
49
|
+
continue;
|
|
50
|
+
const { width, height } = measureElement(node);
|
|
51
|
+
if (x >= pos.x &&
|
|
52
|
+
x < pos.x + width &&
|
|
53
|
+
y >= pos.y &&
|
|
54
|
+
y < pos.y + height) {
|
|
55
|
+
for (const listener of listenersRef.current) {
|
|
56
|
+
listener(id);
|
|
57
|
+
}
|
|
58
|
+
if (!target.noFocus) {
|
|
59
|
+
focusRef.current(id);
|
|
60
|
+
}
|
|
61
|
+
target.onClick?.(x - pos.x, y - pos.y);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}, []);
|
|
66
|
+
const handleClickRef = useRef(handleClick);
|
|
67
|
+
handleClickRef.current = handleClick;
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!internal_eventEmitter)
|
|
70
|
+
return;
|
|
71
|
+
// Enable SGR mouse tracking mode
|
|
72
|
+
process.stdout.write("\x1b[?1000h\x1b[?1006h");
|
|
73
|
+
// Patch event emitter to intercept mouse sequences
|
|
74
|
+
const originalEmit = internal_eventEmitter.emit.bind(internal_eventEmitter);
|
|
75
|
+
internal_eventEmitter.emit = (event, ...args) => {
|
|
76
|
+
if (event === "input") {
|
|
77
|
+
const data = args[0];
|
|
78
|
+
const match = mouseSequenceRe.exec(data);
|
|
79
|
+
if (match) {
|
|
80
|
+
const button = parseInt(match[1], 10);
|
|
81
|
+
const x = parseInt(match[2], 10) - 1;
|
|
82
|
+
const y = parseInt(match[3], 10) - 1;
|
|
83
|
+
const isPress = match[4] === "M";
|
|
84
|
+
if (isPress && (button & 3) === 0) {
|
|
85
|
+
handleClickRef.current(x, y);
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return originalEmit(event, ...args);
|
|
91
|
+
};
|
|
92
|
+
const disableMouse = () => {
|
|
93
|
+
process.stdout.write("\x1b[?1006l\x1b[?1000l");
|
|
94
|
+
};
|
|
95
|
+
process.on("exit", disableMouse);
|
|
96
|
+
return () => {
|
|
97
|
+
internal_eventEmitter.emit = originalEmit;
|
|
98
|
+
disableMouse();
|
|
99
|
+
process.removeListener("exit", disableMouse);
|
|
100
|
+
};
|
|
101
|
+
}, [internal_eventEmitter]);
|
|
102
|
+
return (_jsx(MouseContext.Provider, { value: { registerTarget, unregisterTarget, subscribe }, children: children }));
|
|
103
|
+
};
|
|
104
|
+
export const useMouseTarget = (id, ref, options) => {
|
|
105
|
+
const { registerTarget, unregisterTarget } = useContext(MouseContext);
|
|
106
|
+
const onClickRef = useRef(options?.onClick);
|
|
107
|
+
onClickRef.current = options?.onClick;
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
registerTarget(id, ref, {
|
|
110
|
+
noFocus: options?.noFocus,
|
|
111
|
+
onClick: (x, y) => onClickRef.current?.(x, y),
|
|
112
|
+
});
|
|
113
|
+
return () => unregisterTarget(id);
|
|
114
|
+
}, [id, ref, registerTarget, unregisterTarget, options?.noFocus]);
|
|
115
|
+
};
|
|
116
|
+
export const useMouseSubscribe = (listener) => {
|
|
117
|
+
const { subscribe } = useContext(MouseContext);
|
|
118
|
+
const listenerRef = useRef(listener);
|
|
119
|
+
listenerRef.current = listener;
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
const stableListener = (id) => listenerRef.current(id);
|
|
122
|
+
return subscribe(stableListener);
|
|
123
|
+
}, [subscribe]);
|
|
124
|
+
};
|
package/dist/login/LoginView.js
CHANGED
|
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
|
|
4
4
|
import { TextInput } from "../components/TextInput.js";
|
|
5
5
|
import { Button } from "../components/Button.js";
|
|
6
6
|
import { primary } from "../theme/style.js";
|
|
7
|
-
import { bwClient, loadConfig, saveConfig } from "../hooks/bw.js";
|
|
7
|
+
import { bwClient, loadConfig, loadLoginHints, saveConfig, saveLoginHints } from "../hooks/bw.js";
|
|
8
8
|
import { useStatusMessage } from "../hooks/status-message.js";
|
|
9
9
|
import { Checkbox } from "../components/Checkbox.js";
|
|
10
10
|
import { FetchError, TwoFactorProvider } from "../clients/bw.js";
|
|
@@ -94,6 +94,12 @@ export function LoginView({ onLogin }) {
|
|
|
94
94
|
refreshToken: bwClient.refreshToken,
|
|
95
95
|
});
|
|
96
96
|
}
|
|
97
|
+
else {
|
|
98
|
+
saveLoginHints({
|
|
99
|
+
email: email?.trim() || undefined,
|
|
100
|
+
baseUrl: url?.trim() || undefined,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
97
103
|
}
|
|
98
104
|
catch (e) {
|
|
99
105
|
showStatusMessage("Login failed, please check your credentials.", "error");
|
|
@@ -105,7 +111,13 @@ export function LoginView({ onLogin }) {
|
|
|
105
111
|
const loggedIn = await loadConfig();
|
|
106
112
|
if (loggedIn) {
|
|
107
113
|
onLogin();
|
|
114
|
+
return;
|
|
108
115
|
}
|
|
116
|
+
const hints = await loadLoginHints();
|
|
117
|
+
if (hints.baseUrl)
|
|
118
|
+
setUrl(hints.baseUrl);
|
|
119
|
+
if (hints.email)
|
|
120
|
+
setEmail(hints.email);
|
|
109
121
|
}
|
|
110
122
|
catch (e) {
|
|
111
123
|
showStatusMessage("Failed to load config file", "error");
|