bitty-tui 0.0.21 → 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/dist/app.js +5 -0
- package/dist/cli.js +10 -1
- package/dist/clients/bw.js +63 -14
- package/dist/components/TextInput.js +17 -5
- package/dist/dashboard/DashboardView.js +106 -93
- package/dist/dashboard/KeybindingsView.d.ts +5 -0
- package/dist/dashboard/KeybindingsView.js +77 -0
- package/dist/dashboard/components/HelpBar.js +13 -10
- package/dist/dashboard/components/VaultList.js +8 -5
- package/dist/debug.d.ts +3 -0
- package/dist/debug.js +15 -0
- package/dist/hooks/keybindings.d.ts +34 -0
- package/dist/hooks/keybindings.js +202 -0
- package/dist/hooks/use-mouse.js +2 -0
- package/package.json +11 -11
- package/readme.md +6 -0
package/dist/app.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
+
import { useInput } from "ink";
|
|
3
4
|
import { LoginView } from "./login/LoginView.js";
|
|
4
5
|
import { DashboardView } from "./dashboard/DashboardView.js";
|
|
5
6
|
import { useRefreshResize } from "./hooks/refresh-resize.js";
|
|
6
7
|
export default function App() {
|
|
7
8
|
const [view, setView] = useState("login");
|
|
8
9
|
const r = useRefreshResize();
|
|
10
|
+
useInput((input, key) => {
|
|
11
|
+
if (input === "c" && key.ctrl)
|
|
12
|
+
process.exit(0);
|
|
13
|
+
});
|
|
9
14
|
return (_jsxs(_Fragment, { children: [view === "login" ? (_jsx(LoginView, { onLogin: () => setView("dashboard") })) : (_jsx(DashboardView, { onLogout: () => setView("login") })), r] }));
|
|
10
15
|
}
|
package/dist/cli.js
CHANGED
|
@@ -4,10 +4,12 @@ import { render } from "ink";
|
|
|
4
4
|
import App from "./app.js";
|
|
5
5
|
import { StatusMessageProvider } from "./hooks/status-message.js";
|
|
6
6
|
import { MouseProvider } from "./hooks/use-mouse.js";
|
|
7
|
+
import { KeybindingsProvider } from "./hooks/keybindings.js";
|
|
7
8
|
import { readPackageUpSync } from "read-package-up";
|
|
8
9
|
import { art } from "./theme/art.js";
|
|
9
10
|
import path from "node:path";
|
|
10
11
|
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { debugLogPath } from "./debug.js";
|
|
11
13
|
const args = process.argv.slice(2);
|
|
12
14
|
if (args.includes("--version") || args.includes("-v")) {
|
|
13
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -24,7 +26,14 @@ if (args.includes("--help") || args.includes("-h")) {
|
|
|
24
26
|
Options
|
|
25
27
|
--help Show help
|
|
26
28
|
--version Show version
|
|
29
|
+
--debug Write API debug logs to ${debugLogPath}
|
|
27
30
|
`);
|
|
28
31
|
process.exit(0);
|
|
29
32
|
}
|
|
30
|
-
render(_jsx(StatusMessageProvider, { children: _jsx(MouseProvider, { children: _jsx(App, {}) }) }))
|
|
33
|
+
render(_jsx(KeybindingsProvider, { children: _jsx(StatusMessageProvider, { children: _jsx(MouseProvider, { children: _jsx(App, {}) }) }) }), {
|
|
34
|
+
exitOnCtrlC: false,
|
|
35
|
+
kittyKeyboard: {
|
|
36
|
+
mode: "enabled",
|
|
37
|
+
flags: ["disambiguateEscapeCodes"],
|
|
38
|
+
},
|
|
39
|
+
});
|
package/dist/clients/bw.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import crypto from "node:crypto";
|
|
21
21
|
import * as argon2 from "argon2";
|
|
22
|
+
import { debugEnabled, debugLog } from "../debug.js";
|
|
22
23
|
export class FetchError extends Error {
|
|
23
24
|
status;
|
|
24
25
|
data;
|
|
@@ -32,7 +33,26 @@ export class FetchError extends Error {
|
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
35
|
const fetchApi = async (...args) => {
|
|
36
|
+
if (debugEnabled) {
|
|
37
|
+
const [input, init] = args;
|
|
38
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
39
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
40
|
+
const body = init?.body != null ? String(init.body) : "(none)";
|
|
41
|
+
const headers = new Headers(init?.headers);
|
|
42
|
+
const headersStr = [...headers.entries()]
|
|
43
|
+
.map(([k, v]) => ` ${k}: ${v}`)
|
|
44
|
+
.join("\n");
|
|
45
|
+
debugLog(`→ ${method} ${url}`);
|
|
46
|
+
debugLog(` headers:\n${headersStr || " (none)"}`);
|
|
47
|
+
debugLog(` body: ${body}`);
|
|
48
|
+
}
|
|
35
49
|
const response = await fetch(...args);
|
|
50
|
+
if (debugEnabled) {
|
|
51
|
+
const clone = response.clone();
|
|
52
|
+
const responseBody = await clone.text();
|
|
53
|
+
debugLog(`← ${response.status} ${response.statusText}`);
|
|
54
|
+
debugLog(` response: ${responseBody}`);
|
|
55
|
+
}
|
|
36
56
|
if (!response.ok) {
|
|
37
57
|
const data = await response.text();
|
|
38
58
|
throw new FetchError(response.status, data);
|
|
@@ -73,6 +93,17 @@ export var KdfType;
|
|
|
73
93
|
KdfType[KdfType["Argon2id"] = 1] = "Argon2id";
|
|
74
94
|
})(KdfType || (KdfType = {}));
|
|
75
95
|
const DEVICE_IDENTIFIER = "928f9664-5559-4a7b-9853-caf5bfa5dd57";
|
|
96
|
+
const DEVICE_TYPE = "9"; // ChromeBrowser → ClientName::Web in the SDK
|
|
97
|
+
const CLIENT_NAME = "web";
|
|
98
|
+
const CLIENT_VERSION = "2025.9.0";
|
|
99
|
+
const USER_AGENT = "Bitwarden_Bitty";
|
|
100
|
+
const defaultHeaders = () => ({
|
|
101
|
+
"Device-Identifier": DEVICE_IDENTIFIER,
|
|
102
|
+
"Bitwarden-Client-Name": CLIENT_NAME,
|
|
103
|
+
"Bitwarden-Client-Version": CLIENT_VERSION,
|
|
104
|
+
"Device-Type": DEVICE_TYPE,
|
|
105
|
+
"User-Agent": USER_AGENT,
|
|
106
|
+
});
|
|
76
107
|
class Bw {
|
|
77
108
|
/**
|
|
78
109
|
* Derives the master key and related keys from the user's email and password.
|
|
@@ -373,6 +404,8 @@ export class Client {
|
|
|
373
404
|
const prelogin = await fetchApi(`${this.identityUrl}/accounts/prelogin`, {
|
|
374
405
|
method: "POST",
|
|
375
406
|
headers: {
|
|
407
|
+
...defaultHeaders(),
|
|
408
|
+
Accept: "application/json",
|
|
376
409
|
"Content-Type": "application/json",
|
|
377
410
|
},
|
|
378
411
|
body: JSON.stringify({ email }),
|
|
@@ -381,24 +414,24 @@ export class Client {
|
|
|
381
414
|
this.keys = keys;
|
|
382
415
|
}
|
|
383
416
|
const bodyParams = new URLSearchParams();
|
|
384
|
-
bodyParams.append("
|
|
385
|
-
bodyParams.append("
|
|
386
|
-
bodyParams.append("grant_type", "password");
|
|
387
|
-
bodyParams.append("deviceName", "chrome");
|
|
417
|
+
bodyParams.append("scope", "api offline_access");
|
|
418
|
+
bodyParams.append("client_id", "web");
|
|
388
419
|
bodyParams.append("deviceType", "9");
|
|
389
420
|
bodyParams.append("deviceIdentifier", DEVICE_IDENTIFIER);
|
|
390
|
-
bodyParams.append("
|
|
391
|
-
bodyParams.append("
|
|
421
|
+
bodyParams.append("deviceName", "firefox");
|
|
422
|
+
bodyParams.append("grant_type", "password");
|
|
423
|
+
bodyParams.append("username", email);
|
|
424
|
+
bodyParams.append("password", keys.masterPasswordHash);
|
|
392
425
|
for (const [key, value] of Object.entries(opts || {})) {
|
|
393
426
|
bodyParams.append(key, value);
|
|
394
427
|
}
|
|
395
428
|
const identityReq = await fetchApi(`${this.identityUrl}/connect/token`, {
|
|
396
429
|
method: "POST",
|
|
397
430
|
headers: {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
"
|
|
401
|
-
|
|
431
|
+
...defaultHeaders(),
|
|
432
|
+
Accept: "application/json",
|
|
433
|
+
"Cache-Control": "no-store",
|
|
434
|
+
Pragma: "no-cache",
|
|
402
435
|
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
|
403
436
|
},
|
|
404
437
|
body: bodyParams.toString(),
|
|
@@ -421,6 +454,8 @@ export class Client {
|
|
|
421
454
|
return fetchApi(`${this.apiUrl}/two-factor/send-email-login`, {
|
|
422
455
|
method: "POST",
|
|
423
456
|
headers: {
|
|
457
|
+
...defaultHeaders(),
|
|
458
|
+
Accept: "application/json",
|
|
424
459
|
"Content-Type": "application/json",
|
|
425
460
|
},
|
|
426
461
|
body: JSON.stringify({
|
|
@@ -440,14 +475,17 @@ export class Client {
|
|
|
440
475
|
throw new Error("No refresh token available. Please login first.");
|
|
441
476
|
}
|
|
442
477
|
const bodyParams = new URLSearchParams();
|
|
443
|
-
bodyParams.append("refresh_token", this.refreshToken);
|
|
444
478
|
bodyParams.append("grant_type", "refresh_token");
|
|
479
|
+
bodyParams.append("refresh_token", this.refreshToken);
|
|
445
480
|
bodyParams.append("client_id", "web");
|
|
446
|
-
bodyParams.append("scope", "api offline_access");
|
|
447
481
|
const identityReq = await fetchApi(`${this.identityUrl}/connect/token`, {
|
|
448
482
|
method: "POST",
|
|
449
483
|
headers: {
|
|
450
|
-
|
|
484
|
+
...defaultHeaders(),
|
|
485
|
+
Accept: "application/json",
|
|
486
|
+
"Cache-Control": "no-store",
|
|
487
|
+
Pragma: "no-cache",
|
|
488
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
|
451
489
|
},
|
|
452
490
|
body: bodyParams.toString(),
|
|
453
491
|
}).then((r) => r.json());
|
|
@@ -466,8 +504,9 @@ export class Client {
|
|
|
466
504
|
this.syncCache = await fetchApi(`${this.apiUrl}/sync?excludeDomains=true`, {
|
|
467
505
|
method: "GET",
|
|
468
506
|
headers: {
|
|
507
|
+
...defaultHeaders(),
|
|
508
|
+
Accept: "application/json",
|
|
469
509
|
Authorization: `Bearer ${this.token}`,
|
|
470
|
-
"bitwarden-client-version": "2025.9.0",
|
|
471
510
|
},
|
|
472
511
|
}).then((r) => r.json());
|
|
473
512
|
this.decryptOrgKeys();
|
|
@@ -638,6 +677,8 @@ export class Client {
|
|
|
638
677
|
const s = await fetchApi(url, {
|
|
639
678
|
method: "POST",
|
|
640
679
|
headers: {
|
|
680
|
+
// ...defaultHeaders(),
|
|
681
|
+
// Accept: "application/json",
|
|
641
682
|
Authorization: `Bearer ${this.token}`,
|
|
642
683
|
"Content-Type": "application/json",
|
|
643
684
|
},
|
|
@@ -685,6 +726,8 @@ export class Client {
|
|
|
685
726
|
const s = await fetchApi(`${this.apiUrl}/ciphers/${id}`, {
|
|
686
727
|
method: "PUT",
|
|
687
728
|
headers: {
|
|
729
|
+
// ...defaultHeaders(),
|
|
730
|
+
// Accept: "application/json",
|
|
688
731
|
Authorization: `Bearer ${this.token}`,
|
|
689
732
|
"Content-Type": "application/json",
|
|
690
733
|
},
|
|
@@ -699,6 +742,8 @@ export class Client {
|
|
|
699
742
|
await fetchApi(`${this.apiUrl}/ciphers/${id}`, {
|
|
700
743
|
method: "DELETE",
|
|
701
744
|
headers: {
|
|
745
|
+
// ...defaultHeaders(),
|
|
746
|
+
// Accept: "application/json",
|
|
702
747
|
Authorization: `Bearer ${this.token}`,
|
|
703
748
|
},
|
|
704
749
|
});
|
|
@@ -722,6 +767,8 @@ export class Client {
|
|
|
722
767
|
const s = await fetchApi(`${this.apiUrl}/ciphers/${id}/share`, {
|
|
723
768
|
method: "PUT",
|
|
724
769
|
headers: {
|
|
770
|
+
// ...defaultHeaders(),
|
|
771
|
+
// Accept: "application/json",
|
|
725
772
|
Authorization: `Bearer ${this.token}`,
|
|
726
773
|
"Content-Type": "application/json",
|
|
727
774
|
},
|
|
@@ -739,6 +786,8 @@ export class Client {
|
|
|
739
786
|
const s = await fetchApi(`${this.apiUrl}/ciphers/${id}/collections_v2`, {
|
|
740
787
|
method: "PUT",
|
|
741
788
|
headers: {
|
|
789
|
+
// ...defaultHeaders(),
|
|
790
|
+
// Accept: "application/json",
|
|
742
791
|
Authorization: `Bearer ${this.token}`,
|
|
743
792
|
"Content-Type": "application/json",
|
|
744
793
|
},
|
|
@@ -6,6 +6,7 @@ import clipboard from "clipboardy";
|
|
|
6
6
|
import chalk from "chalk";
|
|
7
7
|
import { useStatusMessage } from "../hooks/status-message.js";
|
|
8
8
|
import { useMouseTarget } from "../hooks/use-mouse.js";
|
|
9
|
+
import { matchBinding, useKeybindings } from "../hooks/keybindings.js";
|
|
9
10
|
export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFocus, isActive, autoFocus, inline, multiline, maxLines = 1, onChange, onSubmit, onCopy, ...props }) => {
|
|
10
11
|
const [cursor, setCursor] = useState(onChange ? value.length : 0);
|
|
11
12
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
@@ -14,6 +15,7 @@ export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFo
|
|
|
14
15
|
const { isFocused } = useFocus({ id: effectiveId, isActive, autoFocus });
|
|
15
16
|
const { showStatusMessage } = useStatusMessage();
|
|
16
17
|
const { focusNext } = useFocusManager();
|
|
18
|
+
const { keybindings } = useKeybindings();
|
|
17
19
|
const boxRef = useRef(null);
|
|
18
20
|
useMouseTarget(effectiveId, boxRef);
|
|
19
21
|
const displayValue = useMemo(() => {
|
|
@@ -82,12 +84,12 @@ export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFo
|
|
|
82
84
|
}
|
|
83
85
|
}, [value, cursor, multiline, maxLines]);
|
|
84
86
|
useInput((input, key) => {
|
|
85
|
-
if (
|
|
87
|
+
if (matchBinding(input, key, keybindings.copyField)) {
|
|
86
88
|
if (onCopy) {
|
|
87
89
|
onCopy(value);
|
|
88
90
|
}
|
|
89
91
|
else {
|
|
90
|
-
clipboard.
|
|
92
|
+
clipboard.write(value);
|
|
91
93
|
showStatusMessage("📋 Copied to clipboard!", "success");
|
|
92
94
|
}
|
|
93
95
|
}
|
|
@@ -95,7 +97,7 @@ export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFo
|
|
|
95
97
|
onChange?.(value.slice(0, Math.max(0, cursor - 1)) + value.slice(cursor));
|
|
96
98
|
setCursor(cursor - 1);
|
|
97
99
|
}
|
|
98
|
-
else if (
|
|
100
|
+
else if (matchBinding(input, key, keybindings.cursorEnd)) {
|
|
99
101
|
if (multiline) {
|
|
100
102
|
const nextNewline = value.indexOf("\n", cursor);
|
|
101
103
|
if (nextNewline !== -1) {
|
|
@@ -105,7 +107,7 @@ export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFo
|
|
|
105
107
|
}
|
|
106
108
|
setCursor(value.length);
|
|
107
109
|
}
|
|
108
|
-
else if (
|
|
110
|
+
else if (matchBinding(input, key, keybindings.cursorStart)) {
|
|
109
111
|
if (multiline) {
|
|
110
112
|
const prevNewline = value.lastIndexOf("\n", Math.max(0, cursor - 1));
|
|
111
113
|
if (prevNewline !== -1) {
|
|
@@ -166,7 +168,17 @@ export const TextInput = ({ id, placeholder, value, isPassword, showPasswordOnFo
|
|
|
166
168
|
}
|
|
167
169
|
}
|
|
168
170
|
else if (key.return) {
|
|
169
|
-
if (multiline &&
|
|
171
|
+
if (multiline && (key.shift || key.meta)) {
|
|
172
|
+
const newValue = value.slice(0, cursor) + "\n" + value.slice(cursor);
|
|
173
|
+
const newCursor = cursor + 1;
|
|
174
|
+
const newCurrentLine = newValue.substring(0, newCursor).split("\n").length - 1;
|
|
175
|
+
if (newCurrentLine >= scrollOffset + maxLines) {
|
|
176
|
+
setScrollOffset(newCurrentLine - maxLines + 1);
|
|
177
|
+
}
|
|
178
|
+
onChange?.(newValue);
|
|
179
|
+
setCursor(newCursor);
|
|
180
|
+
}
|
|
181
|
+
else if (multiline && cursor > 0 && value[cursor - 1] === "\\") {
|
|
170
182
|
const newValue = value.slice(0, cursor - 1) + "\n" + value.slice(cursor);
|
|
171
183
|
const newCurrentLine = newValue.substring(0, cursor).split("\n").length - 1;
|
|
172
184
|
if (newCurrentLine >= scrollOffset + maxLines) {
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect, useMemo } from "react";
|
|
3
3
|
import { Box, Text, useFocusManager, useInput, useStdout } from "ink";
|
|
4
4
|
import { TextInput } from "../components/TextInput.js";
|
|
5
5
|
import { VaultList } from "./components/VaultList.js";
|
|
6
6
|
import { CipherDetail } from "./components/CipherDetail.js";
|
|
7
7
|
import { HelpBar } from "./components/HelpBar.js";
|
|
8
|
+
import { KeybindingsView } from "./KeybindingsView.js";
|
|
8
9
|
import { primary } from "../theme/style.js";
|
|
9
10
|
import { bwClient, clearConfig, createEmptyCipher, emptyCipher, useBwSync } from "../hooks/bw.js";
|
|
10
11
|
import { useStatusMessage } from "../hooks/status-message.js";
|
|
11
12
|
import { useMouseSubscribe } from "../hooks/use-mouse.js";
|
|
12
13
|
import { TabButton } from "../components/TabButton.js";
|
|
14
|
+
import { matchBinding, useKeybindings } from "../hooks/keybindings.js";
|
|
13
15
|
export function DashboardView({ onLogout }) {
|
|
14
16
|
const { sync, error, fetchSync } = useBwSync();
|
|
15
17
|
const [syncState, setSyncState] = useState(sync);
|
|
@@ -20,9 +22,11 @@ export function DashboardView({ onLogout }) {
|
|
|
20
22
|
const [detailMode, setDetailMode] = useState("view");
|
|
21
23
|
const [editedCipher, setEditedCipher] = useState(null);
|
|
22
24
|
const [activeTab, setActiveTab] = useState("main");
|
|
25
|
+
const [showKeybindings, setShowKeybindings] = useState(false);
|
|
23
26
|
const { focus, focusNext } = useFocusManager();
|
|
24
27
|
const { stdout } = useStdout();
|
|
25
28
|
const { statusMessage, statusMessageColor, showStatusMessage } = useStatusMessage();
|
|
29
|
+
const { keybindings } = useKeybindings();
|
|
26
30
|
const filteredCiphers = useMemo(() => {
|
|
27
31
|
return (syncState?.ciphers.filter((c) => {
|
|
28
32
|
if (c.deletedDate)
|
|
@@ -73,11 +77,20 @@ export function DashboardView({ onLogout }) {
|
|
|
73
77
|
showStatusMessage(error, "error");
|
|
74
78
|
}, [error]);
|
|
75
79
|
useInput(async (input, key) => {
|
|
76
|
-
if (
|
|
80
|
+
if (matchBinding(input, key, keybindings.logout)) {
|
|
77
81
|
await logout();
|
|
78
82
|
return;
|
|
79
83
|
}
|
|
80
|
-
if (key
|
|
84
|
+
if (matchBinding(input, key, keybindings.openKeybindings)) {
|
|
85
|
+
setShowKeybindings(true);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (matchBinding(input, key, keybindings.refresh)) {
|
|
89
|
+
await fetchSync();
|
|
90
|
+
showStatusMessage("Refreshed!", "success");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (matchBinding(input, key, keybindings.nextTab)) {
|
|
81
94
|
setActiveTab((prev) => {
|
|
82
95
|
if (prev === "main")
|
|
83
96
|
return "more";
|
|
@@ -87,7 +100,7 @@ export function DashboardView({ onLogout }) {
|
|
|
87
100
|
});
|
|
88
101
|
return;
|
|
89
102
|
}
|
|
90
|
-
if (key
|
|
103
|
+
if (matchBinding(input, key, keybindings.prevTab)) {
|
|
91
104
|
setActiveTab((prev) => {
|
|
92
105
|
if (prev === "main")
|
|
93
106
|
return syncState?.collections?.length ? "collections" : "more";
|
|
@@ -97,12 +110,12 @@ export function DashboardView({ onLogout }) {
|
|
|
97
110
|
});
|
|
98
111
|
return;
|
|
99
112
|
}
|
|
100
|
-
if (input
|
|
113
|
+
if (matchBinding(input, key, keybindings.focusSearch) && focusedComponent === "list") {
|
|
101
114
|
setFocusedComponent("search");
|
|
102
115
|
focus("search");
|
|
103
116
|
return;
|
|
104
117
|
}
|
|
105
|
-
if (
|
|
118
|
+
if (matchBinding(input, key, keybindings.newCipher)) {
|
|
106
119
|
setDetailMode("new");
|
|
107
120
|
setEditedCipher(emptyCipher);
|
|
108
121
|
setFocusedComponent("detail");
|
|
@@ -131,105 +144,105 @@ export function DashboardView({ onLogout }) {
|
|
|
131
144
|
setShowDetails(false);
|
|
132
145
|
}
|
|
133
146
|
}
|
|
134
|
-
});
|
|
147
|
+
}, { isActive: !showKeybindings });
|
|
135
148
|
useEffect(() => {
|
|
136
149
|
if (showDetails)
|
|
137
150
|
return;
|
|
138
151
|
setShowDetails(true);
|
|
139
152
|
setTimeout(focusNext, 50);
|
|
140
153
|
}, [showDetails]);
|
|
141
|
-
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: stdout.rows - 2, children: [_jsx(Box, { borderStyle: "double", borderColor: primary, paddingX: 2, justifyContent: "center", flexShrink: 0, children: _jsx(Text, { bold: true, color: primary, children: "BiTTY" }) }), _jsxs(Box, { children: [_jsx(Box, { width: "40%", children: _jsx(TextInput, { id: "search", placeholder: focusedComponent === "search" ? "" : "[/] Search in vault", value: searchQuery, isActive: false, onChange: setSearchQuery, onSubmit: () => {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
const updatedCiphers = syncState?.ciphers.map((c) => c.id === cipher.id ? cipher : c);
|
|
164
|
-
setSyncState((prev) => ({ ...prev, ciphers: updatedCiphers }));
|
|
165
|
-
}, onDelete: async (cipher) => {
|
|
166
|
-
showStatusMessage("Deleting...");
|
|
167
|
-
try {
|
|
168
|
-
await bwClient.deleteSecret(cipher.id);
|
|
169
|
-
fetchSync();
|
|
170
|
-
showStatusMessage("Deleted!", "success");
|
|
171
|
-
setFocusedComponent("list");
|
|
172
|
-
setActiveTab("main");
|
|
173
|
-
}
|
|
174
|
-
catch (e) {
|
|
175
|
-
showStatusMessage("Delete error", "error");
|
|
176
|
-
}
|
|
177
|
-
}, onSave: async (cipher) => {
|
|
178
|
-
showStatusMessage("Saving...");
|
|
179
|
-
if (detailMode === "new") {
|
|
180
|
-
try {
|
|
181
|
-
if (cipher.organizationId && !cipher.collectionIds?.length) {
|
|
182
|
-
showStatusMessage("Select at least one collection", "error");
|
|
154
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: stdout.rows - 2, children: [_jsx(Box, { borderStyle: "double", borderColor: primary, paddingX: 2, justifyContent: "center", flexShrink: 0, children: _jsx(Text, { bold: true, color: primary, children: "BiTTY" }) }), showKeybindings ? (_jsx(KeybindingsView, { onClose: () => setShowKeybindings(false) })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { flexShrink: 0, children: [_jsx(Box, { width: "40%", children: _jsx(TextInput, { id: "search", placeholder: focusedComponent === "search" ? "" : "[/] Search in vault", value: searchQuery, isActive: false, onChange: setSearchQuery, onSubmit: () => {
|
|
155
|
+
setFocusedComponent("list");
|
|
156
|
+
focusNext();
|
|
157
|
+
} }) }), _jsxs(Box, { width: "60%", paddingX: 1, justifyContent: "space-between", children: [statusMessage ? (_jsx(Box, { padding: 1, flexShrink: 1, children: _jsx(Text, { color: statusMessageColor, children: statusMessage }) })) : (_jsx(Box, {})), _jsxs(Box, { gap: 1, flexShrink: 0, children: [_jsx(TabButton, { active: false, onClick: () => setShowKeybindings(true), children: "\u2699" }), _jsx(TabButton, { active: false, onClick: async () => {
|
|
158
|
+
await fetchSync();
|
|
159
|
+
showStatusMessage("Refreshed!", "success");
|
|
160
|
+
}, children: "\u27F3" }), selectedCipher && (_jsxs(_Fragment, { children: [_jsx(TabButton, { active: activeTab === "main", onClick: () => setActiveTab("main"), children: "Main" }), _jsx(TabButton, { active: activeTab === "more", onClick: () => setActiveTab("more"), children: "More" }), !!syncState?.collections?.length && (_jsx(TabButton, { active: activeTab === "collections", onClick: () => setActiveTab("collections"), children: "Collections" }))] }))] })] })] }), _jsxs(Box, { minHeight: 20, flexGrow: 1, children: [_jsx(VaultList, { filteredCiphers: filteredCiphers, isFocused: ["list", "search"].includes(focusedComponent), selected: listIndex, onSelect: (index) => setListSelected(filteredCiphers[index] || null) }), _jsx(CipherDetail, { selectedCipher: showDetails ? selectedCipher : null, mode: detailMode, activeTab: activeTab, collections: writableCollections, organizations: organizations, isFocused: focusedComponent === "detail", onTypeChange: (type) => {
|
|
161
|
+
const fresh = createEmptyCipher(type);
|
|
162
|
+
fresh.name = editedCipher?.name ?? "";
|
|
163
|
+
fresh.notes = editedCipher?.notes ?? null;
|
|
164
|
+
fresh.collectionIds = editedCipher?.collectionIds ?? [];
|
|
165
|
+
fresh.organizationId = editedCipher?.organizationId ?? null;
|
|
166
|
+
setEditedCipher(fresh);
|
|
167
|
+
}, onReset: async () => {
|
|
168
|
+
bwClient.decryptedSyncCache = null;
|
|
169
|
+
await fetchSync(false);
|
|
170
|
+
showStatusMessage("Resetted!", "success");
|
|
171
|
+
}, onChange: (cipher) => {
|
|
172
|
+
if (detailMode === "new") {
|
|
173
|
+
setEditedCipher(cipher);
|
|
183
174
|
return;
|
|
184
175
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
catch (e) {
|
|
193
|
-
showStatusMessage("Synchronization error", "error");
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
else {
|
|
197
|
-
try {
|
|
198
|
-
const original = sync?.ciphers.find((c) => c.id === cipher.id);
|
|
199
|
-
const originalOrgId = original?.organizationId ?? null;
|
|
200
|
-
const originalCollections = original?.collectionIds ?? [];
|
|
201
|
-
const newCollections = [...cipher.collectionIds ?? []];
|
|
202
|
-
// Sharing: personal cipher being assigned to an org
|
|
203
|
-
if (!originalOrgId && cipher.organizationId) {
|
|
204
|
-
if (!newCollections.length) {
|
|
205
|
-
showStatusMessage("Select at least one collection to share", "error");
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
await bwClient.shareCipher(cipher.id, cipher, newCollections);
|
|
176
|
+
const updatedCiphers = syncState?.ciphers.map((c) => c.id === cipher.id ? cipher : c);
|
|
177
|
+
setSyncState((prev) => ({ ...prev, ciphers: updatedCiphers }));
|
|
178
|
+
}, onDelete: async (cipher) => {
|
|
179
|
+
showStatusMessage("Deleting...");
|
|
180
|
+
try {
|
|
181
|
+
await bwClient.deleteSecret(cipher.id);
|
|
209
182
|
fetchSync();
|
|
210
|
-
showStatusMessage("
|
|
183
|
+
showStatusMessage("Deleted!", "success");
|
|
211
184
|
setFocusedComponent("list");
|
|
212
185
|
setActiveTab("main");
|
|
213
|
-
return;
|
|
214
186
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
originalCollections.some((id) => !newCollections.includes(id));
|
|
218
|
-
if (collectionsChanged) {
|
|
219
|
-
await bwClient.updateCollections(cipher.id, newCollections);
|
|
187
|
+
catch (e) {
|
|
188
|
+
showStatusMessage("Delete error", "error");
|
|
220
189
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
190
|
+
}, onSave: async (cipher) => {
|
|
191
|
+
showStatusMessage("Saving...");
|
|
192
|
+
if (detailMode === "new") {
|
|
193
|
+
try {
|
|
194
|
+
if (cipher.organizationId && !cipher.collectionIds?.length) {
|
|
195
|
+
showStatusMessage("Select at least one collection", "error");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
await bwClient.createSecret(cipher);
|
|
199
|
+
fetchSync();
|
|
200
|
+
showStatusMessage("Saved!", "success");
|
|
201
|
+
setDetailMode("view");
|
|
202
|
+
setFocusedComponent("list");
|
|
203
|
+
setActiveTab("main");
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
showStatusMessage("Synchronization error", "error");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
try {
|
|
211
|
+
const original = sync?.ciphers.find((c) => c.id === cipher.id);
|
|
212
|
+
const originalOrgId = original?.organizationId ?? null;
|
|
213
|
+
const originalCollections = original?.collectionIds ?? [];
|
|
214
|
+
const newCollections = [...cipher.collectionIds ?? []];
|
|
215
|
+
// Sharing: personal cipher being assigned to an org
|
|
216
|
+
if (!originalOrgId && cipher.organizationId) {
|
|
217
|
+
if (!newCollections.length) {
|
|
218
|
+
showStatusMessage("Select at least one collection to share", "error");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
await bwClient.shareCipher(cipher.id, cipher, newCollections);
|
|
222
|
+
fetchSync();
|
|
223
|
+
showStatusMessage("Shared!", "success");
|
|
224
|
+
setFocusedComponent("list");
|
|
225
|
+
setActiveTab("main");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const updated = await bwClient.updateSecret(cipher.id, cipher);
|
|
229
|
+
const collectionsChanged = originalCollections.length !== newCollections.length ||
|
|
230
|
+
originalCollections.some((id) => !newCollections.includes(id));
|
|
231
|
+
if (collectionsChanged) {
|
|
232
|
+
await bwClient.updateCollections(cipher.id, newCollections);
|
|
233
|
+
}
|
|
234
|
+
if (!updated && !collectionsChanged) {
|
|
235
|
+
showStatusMessage("Nothing to save");
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
fetchSync();
|
|
239
|
+
showStatusMessage("Saved!", "success");
|
|
240
|
+
setFocusedComponent("list");
|
|
241
|
+
setActiveTab("main");
|
|
242
|
+
}
|
|
243
|
+
catch (e) {
|
|
244
|
+
showStatusMessage("Synchronization error", "error");
|
|
245
|
+
}
|
|
224
246
|
}
|
|
225
|
-
|
|
226
|
-
showStatusMessage("Saved!", "success");
|
|
227
|
-
setFocusedComponent("list");
|
|
228
|
-
setActiveTab("main");
|
|
229
|
-
}
|
|
230
|
-
catch (e) {
|
|
231
|
-
showStatusMessage("Synchronization error", "error");
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
} })] }), _jsx(HelpBar, { focus: focusedComponent, cipher: selectedCipher, mode: detailMode })] }));
|
|
247
|
+
} })] }), _jsx(HelpBar, { focus: focusedComponent, cipher: selectedCipher, mode: detailMode })] }))] }));
|
|
235
248
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { primary } from "../theme/style.js";
|
|
5
|
+
import { DEFAULT_KEYBINDINGS, KEYBINDING_LABELS, KEYBINDING_ORDER, captureBinding, displayBinding, useKeybindings, } from "../hooks/keybindings.js";
|
|
6
|
+
export function KeybindingsView({ onClose }) {
|
|
7
|
+
const { keybindings, updateKeybindings } = useKeybindings();
|
|
8
|
+
const { stdout } = useStdout();
|
|
9
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
10
|
+
const [capturing, setCapturing] = useState(false);
|
|
11
|
+
const [capturingId, setCapturingId] = useState(null);
|
|
12
|
+
const [justReset, setJustReset] = useState(false);
|
|
13
|
+
// Total items: all bindings + "Reset to defaults" row
|
|
14
|
+
const totalItems = KEYBINDING_ORDER.length + 1;
|
|
15
|
+
const isResetSelected = selectedIndex === KEYBINDING_ORDER.length;
|
|
16
|
+
// How many list rows can fit on screen (leave room for header, hints, reset row)
|
|
17
|
+
const maxVisible = Math.max(stdout.rows - 10, 5);
|
|
18
|
+
const [offset, setOffset] = useState(0);
|
|
19
|
+
useInput(async (input, key) => {
|
|
20
|
+
if (capturing) {
|
|
21
|
+
// Bare Escape cancels capture without saving
|
|
22
|
+
if (key.escape && !key.ctrl && !key.shift && !key.meta) {
|
|
23
|
+
setCapturing(false);
|
|
24
|
+
setCapturingId(null);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const binding = captureBinding(input, key);
|
|
28
|
+
if (binding && capturingId) {
|
|
29
|
+
await updateKeybindings({ ...keybindings, [capturingId]: binding });
|
|
30
|
+
setCapturing(false);
|
|
31
|
+
setCapturingId(null);
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (key.upArrow) {
|
|
36
|
+
const newIndex = Math.max(0, selectedIndex - 1);
|
|
37
|
+
setSelectedIndex(newIndex);
|
|
38
|
+
if (newIndex < offset)
|
|
39
|
+
setOffset(newIndex);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (key.downArrow) {
|
|
43
|
+
const newIndex = Math.min(totalItems - 1, selectedIndex + 1);
|
|
44
|
+
setSelectedIndex(newIndex);
|
|
45
|
+
if (newIndex >= offset + maxVisible)
|
|
46
|
+
setOffset(newIndex - maxVisible + 1);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (key.escape) {
|
|
50
|
+
onClose();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (key.return) {
|
|
54
|
+
if (isResetSelected) {
|
|
55
|
+
await updateKeybindings({ ...DEFAULT_KEYBINDINGS });
|
|
56
|
+
setJustReset(true);
|
|
57
|
+
setTimeout(() => setJustReset(false), 1500);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const id = KEYBINDING_ORDER[selectedIndex];
|
|
61
|
+
if (id) {
|
|
62
|
+
setCapturingId(id);
|
|
63
|
+
setCapturing(true);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
const visibleBindings = KEYBINDING_ORDER.slice(offset, offset + maxVisible);
|
|
68
|
+
return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "round", borderColor: primary, paddingX: 2, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: primary, children: "Keybindings" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "\u2191/\u2193" }), " Navigate ", _jsx(Text, { bold: true, children: "Enter" }), " Edit ", _jsx(Text, { bold: true, children: "Esc" }), " Close"] }) }), _jsxs(Box, { paddingX: 1, marginBottom: 1, children: [_jsx(Box, { width: 36, children: _jsx(Text, { color: "#9f9f9f", bold: true, children: "Action" }) }), _jsx(Text, { color: "#9f9f9f", bold: true, children: "Binding" })] }), visibleBindings.map((id, visibleIdx) => {
|
|
69
|
+
const actualIndex = visibleIdx + offset;
|
|
70
|
+
const isSelected = selectedIndex === actualIndex;
|
|
71
|
+
const isEditing = capturing && capturingId === id;
|
|
72
|
+
const binding = keybindings[id];
|
|
73
|
+
return (_jsxs(Box, { paddingX: 1, children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: isSelected ? primary : "#9f9f9f", children: isSelected ? "▶" : " " }) }), _jsx(Box, { width: 34, flexShrink: 0, children: _jsx(Text, { color: isSelected ? "white" : "default", wrap: "truncate", children: KEYBINDING_LABELS[id] }) }), _jsx(Text, { color: isEditing ? "yellow" : isSelected ? primary : "#9f9f9f", children: isEditing ? "Press new key..." : displayBinding(binding) })] }, id));
|
|
74
|
+
}), KEYBINDING_ORDER.length > maxVisible && (_jsx(Box, { paddingX: 3, children: _jsx(Text, { color: "#9f9f9f", children: offset + maxVisible < KEYBINDING_ORDER.length ? "▼ more" : "" }) })), selectedIndex >= offset + maxVisible - 1 ||
|
|
75
|
+
isResetSelected ||
|
|
76
|
+
KEYBINDING_ORDER.length <= maxVisible ? (_jsxs(Box, { paddingX: 1, marginTop: 1, children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: isResetSelected ? primary : "#9f9f9f", children: isResetSelected ? "▶" : " " }) }), _jsx(Text, { color: isResetSelected ? "white" : "#9f9f9f", children: justReset ? "Reset done!" : "Reset to defaults" })] })) : null] }));
|
|
77
|
+
}
|
|
@@ -1,34 +1,37 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import { CipherType } from "../../clients/bw.js";
|
|
4
|
+
import { displayBinding, useKeybindings } from "../../hooks/keybindings.js";
|
|
4
5
|
export function HelpBar({ focus, cipher, mode, }) {
|
|
5
|
-
|
|
6
|
+
const { keybindings } = useKeybindings();
|
|
7
|
+
const kb = keybindings;
|
|
8
|
+
return (_jsxs(Box, { borderStyle: "single", borderColor: "#9f9f9f", marginTop: 1, paddingX: 1, flexShrink: 0, justifyContent: "space-around", children: [_jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.focusSearch), " "] }), "Search"] }), focus === "list" ? (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "\u2191/\u2193 " }), "Navigate"] })) : focus === "detail" ? (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Tab/Enter " }), "Next Field"] })) : (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Esc " }), "Clear Search"] })), focus === "list" ? (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Tab/Enter " }), "Select"] })) : focus === "detail" ? (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Esc " }), "Focus List"] })) : (_jsxs(Text, { color: "#9f9f9f", children: [_jsx(Text, { bold: true, children: "Tab/Enter " }), "Focus List"] })), mode !== "new" && (_jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.newCipher), " "] }), "New"] })), ...copyButtons(focus, cipher, kb), _jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.logout), " "] }), "Logout"] })] }));
|
|
6
9
|
}
|
|
7
|
-
const copyButtons = (focus, cipher) => {
|
|
10
|
+
const copyButtons = (focus, cipher, kb) => {
|
|
8
11
|
if (focus === "detail") {
|
|
9
12
|
return [
|
|
10
|
-
_jsxs(Text, { color: "#9f9f9f", children: [
|
|
13
|
+
_jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.copyField), " "] }), "Copy Field"] }),
|
|
11
14
|
];
|
|
12
15
|
}
|
|
13
16
|
switch (cipher?.type) {
|
|
14
17
|
case CipherType.Login:
|
|
15
18
|
return [
|
|
16
|
-
_jsxs(Text, { color: "#9f9f9f", children: [
|
|
19
|
+
_jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.copyPrimary), " "] }), "Copy Password"] }, "copy-password"),
|
|
17
20
|
...(cipher.login?.totp
|
|
18
21
|
? [
|
|
19
|
-
_jsxs(Text, { color: "#9f9f9f", children: [
|
|
22
|
+
_jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.copyTotp), " "] }), "Copy TOTP"] }, "copy-totp"),
|
|
20
23
|
]
|
|
21
24
|
: []),
|
|
22
|
-
_jsxs(Text, { color: "#9f9f9f", children: [
|
|
25
|
+
_jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.copySecondary), " "] }), "Copy Username"] }, "copy-username"),
|
|
23
26
|
];
|
|
24
27
|
case CipherType.SecureNote:
|
|
25
28
|
return [
|
|
26
|
-
_jsxs(Text, { color: "#9f9f9f", children: [
|
|
29
|
+
_jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.copyPrimary), " "] }), "Copy Note"] }, "copy-note"),
|
|
27
30
|
];
|
|
28
31
|
case CipherType.SSHKey:
|
|
29
32
|
return [
|
|
30
|
-
_jsxs(Text, { color: "#9f9f9f", children: [
|
|
31
|
-
_jsxs(Text, { color: "#9f9f9f", children: [
|
|
33
|
+
_jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.copyPrimary), " "] }), "Copy Private Key"] }, "copy-private-key"),
|
|
34
|
+
_jsxs(Text, { color: "#9f9f9f", children: [_jsxs(Text, { bold: true, children: [displayBinding(kb.copySecondary), " "] }), "Copy Public Key"] }, "copy-public-key"),
|
|
32
35
|
];
|
|
33
36
|
default:
|
|
34
37
|
return [];
|
|
@@ -7,6 +7,7 @@ import clipboard from "clipboardy";
|
|
|
7
7
|
import { useStatusMessage } from "../../hooks/status-message.js";
|
|
8
8
|
import { useRef } from "react";
|
|
9
9
|
import { useMouseTarget } from "../../hooks/use-mouse.js";
|
|
10
|
+
import { matchBinding, useKeybindings } from "../../hooks/keybindings.js";
|
|
10
11
|
const getTypeIcon = (type) => {
|
|
11
12
|
switch (type) {
|
|
12
13
|
case CipherType.Login:
|
|
@@ -24,6 +25,7 @@ const getTypeIcon = (type) => {
|
|
|
24
25
|
export function VaultList({ filteredCiphers, isFocused, selected, onSelect, }) {
|
|
25
26
|
const { stdout } = useStdout();
|
|
26
27
|
const { showStatusMessage } = useStatusMessage();
|
|
28
|
+
const { keybindings } = useKeybindings();
|
|
27
29
|
const boxRef = useRef(null);
|
|
28
30
|
const scrollOffsetRef = useRef(0);
|
|
29
31
|
useMouseTarget("list", boxRef, {
|
|
@@ -38,8 +40,9 @@ export function VaultList({ filteredCiphers, isFocused, selected, onSelect, }) {
|
|
|
38
40
|
});
|
|
39
41
|
useInput((input, key) => {
|
|
40
42
|
const cipher = selected !== null ? filteredCiphers[selected] : null;
|
|
41
|
-
let field
|
|
42
|
-
|
|
43
|
+
let field;
|
|
44
|
+
let fldName;
|
|
45
|
+
if (matchBinding(input, key, keybindings.copyPrimary)) {
|
|
43
46
|
switch (cipher?.type) {
|
|
44
47
|
case CipherType.Login:
|
|
45
48
|
field = cipher.login?.password;
|
|
@@ -55,7 +58,7 @@ export function VaultList({ filteredCiphers, isFocused, selected, onSelect, }) {
|
|
|
55
58
|
break;
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
|
-
else if (
|
|
61
|
+
else if (matchBinding(input, key, keybindings.copySecondary)) {
|
|
59
62
|
switch (cipher?.type) {
|
|
60
63
|
case CipherType.Login:
|
|
61
64
|
field = cipher.login?.username;
|
|
@@ -67,14 +70,14 @@ export function VaultList({ filteredCiphers, isFocused, selected, onSelect, }) {
|
|
|
67
70
|
break;
|
|
68
71
|
}
|
|
69
72
|
}
|
|
70
|
-
else if (
|
|
73
|
+
else if (matchBinding(input, key, keybindings.copyTotp)) {
|
|
71
74
|
if (cipher?.type === CipherType.Login) {
|
|
72
75
|
field = cipher.login?.currentTotp;
|
|
73
76
|
fldName = "TOTP";
|
|
74
77
|
}
|
|
75
78
|
}
|
|
76
79
|
if (field) {
|
|
77
|
-
clipboard.
|
|
80
|
+
clipboard.write(field);
|
|
78
81
|
showStatusMessage(`📋 Copied ${fldName} to clipboard!`, "success");
|
|
79
82
|
}
|
|
80
83
|
}, { isActive: isFocused });
|
package/dist/debug.d.ts
ADDED
package/dist/debug.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
export const debugEnabled = process.argv.includes("--debug");
|
|
5
|
+
const logDir = path.join(os.homedir(), ".config", "bitty");
|
|
6
|
+
export const debugLogPath = path.join(logDir, "debug.log");
|
|
7
|
+
if (debugEnabled) {
|
|
8
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
9
|
+
fs.writeFileSync(debugLogPath, `--- debug session started ${new Date().toISOString()} ---\n`);
|
|
10
|
+
}
|
|
11
|
+
export function debugLog(message) {
|
|
12
|
+
if (!debugEnabled)
|
|
13
|
+
return;
|
|
14
|
+
fs.appendFileSync(debugLogPath, `[${new Date().toISOString()}] ${message}\n`);
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { Key } from "ink";
|
|
3
|
+
export type KeybindingId = "logout" | "nextTab" | "prevTab" | "focusSearch" | "newCipher" | "copyPrimary" | "copySecondary" | "copyTotp" | "copyField" | "cursorEnd" | "cursorStart" | "openKeybindings" | "refresh";
|
|
4
|
+
export interface KeyBinding {
|
|
5
|
+
ctrl?: boolean;
|
|
6
|
+
shift?: boolean;
|
|
7
|
+
meta?: boolean;
|
|
8
|
+
key: string;
|
|
9
|
+
}
|
|
10
|
+
export type KeybindingsMap = Record<KeybindingId, KeyBinding>;
|
|
11
|
+
export declare const KEYBINDING_LABELS: Record<KeybindingId, string>;
|
|
12
|
+
export declare const KEYBINDING_ORDER: KeybindingId[];
|
|
13
|
+
export declare const DEFAULT_KEYBINDINGS: KeybindingsMap;
|
|
14
|
+
export declare function loadKeybindings(): Promise<KeybindingsMap>;
|
|
15
|
+
export declare function saveKeybindings(keybindings: KeybindingsMap): Promise<void>;
|
|
16
|
+
export declare function displayBinding(binding: KeyBinding): string;
|
|
17
|
+
/**
|
|
18
|
+
* Capture a key combination from raw Ink input.
|
|
19
|
+
* Returns null if the combination should not be captured (Ctrl+C, Ctrl+Z, bare Escape).
|
|
20
|
+
*/
|
|
21
|
+
export declare function captureBinding(input: string, key: Key): KeyBinding | null;
|
|
22
|
+
/**
|
|
23
|
+
* Returns true if the given (input, key) pair matches the binding.
|
|
24
|
+
*/
|
|
25
|
+
export declare function matchBinding(input: string, key: Key, binding: KeyBinding): boolean;
|
|
26
|
+
interface KeybindingsContextValue {
|
|
27
|
+
keybindings: KeybindingsMap;
|
|
28
|
+
updateKeybindings: (newBindings: KeybindingsMap) => Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export declare function KeybindingsProvider({ children }: {
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
33
|
+
export declare function useKeybindings(): KeybindingsContextValue;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { createContext, useContext, useState, useEffect, } from "react";
|
|
6
|
+
export const KEYBINDING_LABELS = {
|
|
7
|
+
logout: "Logout",
|
|
8
|
+
nextTab: "Next Tab",
|
|
9
|
+
prevTab: "Previous Tab",
|
|
10
|
+
focusSearch: "Focus Search",
|
|
11
|
+
newCipher: "New Cipher",
|
|
12
|
+
copyPrimary: "Copy Primary (Password/Note/Key)",
|
|
13
|
+
copySecondary: "Copy Secondary (Username/Pub Key)",
|
|
14
|
+
copyTotp: "Copy TOTP",
|
|
15
|
+
copyField: "Copy Field (in editor)",
|
|
16
|
+
cursorEnd: "Cursor to End",
|
|
17
|
+
cursorStart: "Cursor to Start",
|
|
18
|
+
openKeybindings: "Open Keybindings",
|
|
19
|
+
refresh: "Refresh Vault",
|
|
20
|
+
};
|
|
21
|
+
export const KEYBINDING_ORDER = [
|
|
22
|
+
"logout",
|
|
23
|
+
"nextTab",
|
|
24
|
+
"prevTab",
|
|
25
|
+
"focusSearch",
|
|
26
|
+
"newCipher",
|
|
27
|
+
"copyPrimary",
|
|
28
|
+
"copySecondary",
|
|
29
|
+
"copyTotp",
|
|
30
|
+
"copyField",
|
|
31
|
+
"cursorEnd",
|
|
32
|
+
"cursorStart",
|
|
33
|
+
"openKeybindings",
|
|
34
|
+
"refresh",
|
|
35
|
+
];
|
|
36
|
+
export const DEFAULT_KEYBINDINGS = {
|
|
37
|
+
logout: { ctrl: true, key: "w" },
|
|
38
|
+
nextTab: { shift: true, key: "rightArrow" },
|
|
39
|
+
prevTab: { shift: true, key: "leftArrow" },
|
|
40
|
+
focusSearch: { key: "/" },
|
|
41
|
+
newCipher: { ctrl: true, key: "n" },
|
|
42
|
+
copyPrimary: { ctrl: true, key: "y" },
|
|
43
|
+
copySecondary: { ctrl: true, key: "u" },
|
|
44
|
+
copyTotp: { ctrl: true, key: "t" },
|
|
45
|
+
copyField: { ctrl: true, key: "y" },
|
|
46
|
+
cursorEnd: { ctrl: true, key: "e" },
|
|
47
|
+
cursorStart: { ctrl: true, key: "a" },
|
|
48
|
+
openKeybindings: { ctrl: true, key: "k" },
|
|
49
|
+
refresh: { ctrl: true, key: "r" },
|
|
50
|
+
};
|
|
51
|
+
const configDir = path.join(os.homedir(), ".config", "bitty");
|
|
52
|
+
const keybindsPath = path.join(configDir, "keybinds.json");
|
|
53
|
+
export async function loadKeybindings() {
|
|
54
|
+
try {
|
|
55
|
+
if (fs.existsSync(keybindsPath)) {
|
|
56
|
+
const content = await fs.promises.readFile(keybindsPath, "utf-8");
|
|
57
|
+
const overrides = JSON.parse(content);
|
|
58
|
+
return { ...DEFAULT_KEYBINDINGS, ...overrides };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch { }
|
|
62
|
+
return { ...DEFAULT_KEYBINDINGS };
|
|
63
|
+
}
|
|
64
|
+
export async function saveKeybindings(keybindings) {
|
|
65
|
+
const overrides = {};
|
|
66
|
+
for (const [id, binding] of Object.entries(keybindings)) {
|
|
67
|
+
const def = DEFAULT_KEYBINDINGS[id];
|
|
68
|
+
if (JSON.stringify(binding) !== JSON.stringify(def)) {
|
|
69
|
+
overrides[id] = binding;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
await fs.promises.mkdir(configDir, { recursive: true });
|
|
73
|
+
await fs.promises.writeFile(keybindsPath, JSON.stringify(overrides, null, 2));
|
|
74
|
+
}
|
|
75
|
+
const SPECIAL_KEY_DISPLAY = {
|
|
76
|
+
upArrow: "↑",
|
|
77
|
+
downArrow: "↓",
|
|
78
|
+
leftArrow: "←",
|
|
79
|
+
rightArrow: "→",
|
|
80
|
+
escape: "Esc",
|
|
81
|
+
return: "Enter",
|
|
82
|
+
tab: "Tab",
|
|
83
|
+
backspace: "Backspace",
|
|
84
|
+
delete: "Delete",
|
|
85
|
+
pageUp: "PgUp",
|
|
86
|
+
pageDown: "PgDn",
|
|
87
|
+
space: "Space",
|
|
88
|
+
};
|
|
89
|
+
export function displayBinding(binding) {
|
|
90
|
+
const parts = [];
|
|
91
|
+
if (binding.ctrl)
|
|
92
|
+
parts.push("Ctrl");
|
|
93
|
+
if (binding.shift)
|
|
94
|
+
parts.push("Shift");
|
|
95
|
+
if (binding.meta)
|
|
96
|
+
parts.push("Meta");
|
|
97
|
+
const keyDisplay = SPECIAL_KEY_DISPLAY[binding.key] ?? binding.key.toUpperCase();
|
|
98
|
+
parts.push(keyDisplay);
|
|
99
|
+
return parts.join("+");
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Capture a key combination from raw Ink input.
|
|
103
|
+
* Returns null if the combination should not be captured (Ctrl+C, Ctrl+Z, bare Escape).
|
|
104
|
+
*/
|
|
105
|
+
export function captureBinding(input, key) {
|
|
106
|
+
// Never capture Ctrl+C or Ctrl+Z (terminal signals)
|
|
107
|
+
if (key.ctrl && (input === "c" || input === "z"))
|
|
108
|
+
return null;
|
|
109
|
+
// Bare Escape is used to cancel capture mode
|
|
110
|
+
if (key.escape && !key.ctrl && !key.shift && !key.meta)
|
|
111
|
+
return null;
|
|
112
|
+
let keyName;
|
|
113
|
+
if (key.upArrow)
|
|
114
|
+
keyName = "upArrow";
|
|
115
|
+
else if (key.downArrow)
|
|
116
|
+
keyName = "downArrow";
|
|
117
|
+
else if (key.leftArrow)
|
|
118
|
+
keyName = "leftArrow";
|
|
119
|
+
else if (key.rightArrow)
|
|
120
|
+
keyName = "rightArrow";
|
|
121
|
+
else if (key.return)
|
|
122
|
+
keyName = "return";
|
|
123
|
+
else if (key.tab)
|
|
124
|
+
keyName = "tab";
|
|
125
|
+
else if (key.backspace)
|
|
126
|
+
keyName = "backspace";
|
|
127
|
+
else if (key.delete)
|
|
128
|
+
keyName = "delete";
|
|
129
|
+
else if (key.pageUp)
|
|
130
|
+
keyName = "pageUp";
|
|
131
|
+
else if (key.pageDown)
|
|
132
|
+
keyName = "pageDown";
|
|
133
|
+
else if (input === " ")
|
|
134
|
+
keyName = "space";
|
|
135
|
+
else if (input)
|
|
136
|
+
keyName = input.toLowerCase();
|
|
137
|
+
else
|
|
138
|
+
return null;
|
|
139
|
+
return {
|
|
140
|
+
ctrl: key.ctrl || undefined,
|
|
141
|
+
shift: key.shift || undefined,
|
|
142
|
+
meta: key.meta || undefined,
|
|
143
|
+
key: keyName,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Returns true if the given (input, key) pair matches the binding.
|
|
148
|
+
*/
|
|
149
|
+
export function matchBinding(input, key, binding) {
|
|
150
|
+
if (!!binding.ctrl !== !!key.ctrl)
|
|
151
|
+
return false;
|
|
152
|
+
if (!!binding.shift !== !!key.shift)
|
|
153
|
+
return false;
|
|
154
|
+
if (!!binding.meta !== !!key.meta)
|
|
155
|
+
return false;
|
|
156
|
+
switch (binding.key) {
|
|
157
|
+
case "upArrow":
|
|
158
|
+
return !!key.upArrow;
|
|
159
|
+
case "downArrow":
|
|
160
|
+
return !!key.downArrow;
|
|
161
|
+
case "leftArrow":
|
|
162
|
+
return !!key.leftArrow;
|
|
163
|
+
case "rightArrow":
|
|
164
|
+
return !!key.rightArrow;
|
|
165
|
+
case "escape":
|
|
166
|
+
return !!key.escape;
|
|
167
|
+
case "return":
|
|
168
|
+
return !!key.return;
|
|
169
|
+
case "tab":
|
|
170
|
+
return !!key.tab;
|
|
171
|
+
case "backspace":
|
|
172
|
+
return !!key.backspace;
|
|
173
|
+
case "delete":
|
|
174
|
+
return !!key.delete;
|
|
175
|
+
case "pageUp":
|
|
176
|
+
return !!key.pageUp;
|
|
177
|
+
case "pageDown":
|
|
178
|
+
return !!key.pageDown;
|
|
179
|
+
case "space":
|
|
180
|
+
return input === " ";
|
|
181
|
+
default:
|
|
182
|
+
return input === binding.key;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const KeybindingsContext = createContext({
|
|
186
|
+
keybindings: DEFAULT_KEYBINDINGS,
|
|
187
|
+
updateKeybindings: async () => { },
|
|
188
|
+
});
|
|
189
|
+
export function KeybindingsProvider({ children }) {
|
|
190
|
+
const [keybindings, setKeybindings] = useState(DEFAULT_KEYBINDINGS);
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
loadKeybindings().then(setKeybindings);
|
|
193
|
+
}, []);
|
|
194
|
+
const updateKeybindings = async (newBindings) => {
|
|
195
|
+
await saveKeybindings(newBindings);
|
|
196
|
+
setKeybindings(newBindings);
|
|
197
|
+
};
|
|
198
|
+
return (_jsx(KeybindingsContext.Provider, { value: { keybindings, updateKeybindings }, children: children }));
|
|
199
|
+
}
|
|
200
|
+
export function useKeybindings() {
|
|
201
|
+
return useContext(KeybindingsContext);
|
|
202
|
+
}
|
package/dist/hooks/use-mouse.js
CHANGED
|
@@ -21,6 +21,8 @@ function getAbsolutePosition(node) {
|
|
|
21
21
|
return { x, y };
|
|
22
22
|
}
|
|
23
23
|
export const MouseProvider = ({ children, }) => {
|
|
24
|
+
// ink 7 removed internal_eventEmitter from PublicProps type, but it is still
|
|
25
|
+
// present at runtime inside the StdinContext. Cast through unknown to access it.
|
|
24
26
|
const { internal_eventEmitter } = useStdin();
|
|
25
27
|
const { focus } = useFocusManager();
|
|
26
28
|
const targetsRef = useRef(new Map());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bitty-tui",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": "https://github.com/mceck/bitty",
|
|
6
6
|
"keywords": [
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
"type": "module",
|
|
19
19
|
"engines": {
|
|
20
|
-
"node": ">=
|
|
20
|
+
"node": ">=22"
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
23
|
"build": "tsc",
|
|
@@ -32,18 +32,18 @@
|
|
|
32
32
|
"argon2": "^0.44.0",
|
|
33
33
|
"chalk": "^5.6.2",
|
|
34
34
|
"clipboardy": "^5.3.1",
|
|
35
|
-
"ink": "^
|
|
36
|
-
"otplib": "^13.
|
|
37
|
-
"react": "^19.
|
|
35
|
+
"ink": "^7.0.1",
|
|
36
|
+
"otplib": "^13.4.0",
|
|
37
|
+
"react": "^19.2.5",
|
|
38
38
|
"read-package-up": "^12.0.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@sindresorhus/tsconfig": "^8.0
|
|
42
|
-
"@types/node": "^
|
|
43
|
-
"@types/react": "^19.
|
|
44
|
-
"eslint-plugin-react": "^7.
|
|
45
|
-
"eslint-plugin-react-hooks": "^
|
|
41
|
+
"@sindresorhus/tsconfig": "^8.1.0",
|
|
42
|
+
"@types/node": "^25.6.0",
|
|
43
|
+
"@types/react": "^19.2.14",
|
|
44
|
+
"eslint-plugin-react": "^7.37.5",
|
|
45
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
46
46
|
"ink-testing-library": "^4.0.0",
|
|
47
|
-
"typescript": "^
|
|
47
|
+
"typescript": "^6.0.3"
|
|
48
48
|
}
|
|
49
49
|
}
|
package/readme.md
CHANGED
|
@@ -23,6 +23,12 @@ Works also with Vaultwarden.
|
|
|
23
23
|
|
|
24
24
|
If you check "Remember me" during login, your vault encryption keys will be stored in plain text in your home folder (`$HOME/.config/bitty/config.json`). Use this option only if you are the only user of your machine.
|
|
25
25
|
|
|
26
|
+
## Custom keybindings
|
|
27
|
+
|
|
28
|
+
Click the **⚙** button in the toolbar, or press `Ctrl+K`, to open the keybindings editor. Use `↑`/`↓` to select an action, `Enter` to capture a new key combination, and `Esc` to close.
|
|
29
|
+
|
|
30
|
+
Overrides are saved to `~/.config/bitty/keybinds.json` and merged with the defaults on startup. Delete the file (or use "Reset to defaults" inside the editor) to go back to the defaults.
|
|
31
|
+
|
|
26
32
|
|
|
27
33
|
## Acknowledgments
|
|
28
34
|
- [Bitwarden whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper)
|