@effect-x/envault 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.
@@ -0,0 +1,1591 @@
1
+ /** @jsxImportSource @opentui/react */
2
+ import { RegistryProvider, useAtomSet, useAtomValue } from "@effect/atom-react";
3
+ import { TextAttributes } from "@opentui/core";
4
+ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui";
5
+ import { KeymapProvider, useBindings } from "@opentui/keymap/react";
6
+ import { useRenderer, useTerminalDimensions } from "@opentui/react";
7
+ import { Effect } from "effect";
8
+ import { useCallback, useEffect, useMemo } from "react";
9
+ import type { EnvVariable } from "../domain.ts";
10
+ import {
11
+ busyAtom,
12
+ envFilesAtom,
13
+ envTableRowsAtom,
14
+ focusAtom,
15
+ messageAtom,
16
+ modalAtom,
17
+ previousFocusAtom,
18
+ queryAtom,
19
+ rootAtom,
20
+ decryptAtom,
21
+ selectedEnvironmentAtom,
22
+ selectedFileAtom,
23
+ selectedGroupAtom,
24
+ selectedPathAtom,
25
+ selectedVariableAtom,
26
+ selectedVariableKeyAtom,
27
+ statsAtom,
28
+ visibleEnvFileGroupsAtom,
29
+ workspaceAtom,
30
+ } from "./atoms.ts";
31
+ import { colors } from "./colors.ts";
32
+ import { Modal } from "./modal.ts";
33
+ import {
34
+ EmptyState,
35
+ HintRow,
36
+ PlainLine,
37
+ ScrollPanel,
38
+ SectionTitle,
39
+ TextLine,
40
+ } from "./primitives.tsx";
41
+ import type { AppCommand, AppProps, EditDraft, EnvTableCell, FocusTarget } from "./types.ts";
42
+
43
+ // ─── Utilities ───────────────────────────────────────────────────────────────
44
+
45
+ const focusLabel = (focus: FocusTarget): string => {
46
+ switch (focus) {
47
+ case "files":
48
+ return "projects";
49
+ case "variables":
50
+ return "keys";
51
+ case "filter":
52
+ return "search";
53
+ case "edit-key":
54
+ return "edit key";
55
+ case "edit-value":
56
+ return "edit value";
57
+ case "file-path":
58
+ return "new file";
59
+ }
60
+ };
61
+
62
+ const maskedValue = (variable: EnvVariable): string => {
63
+ if (!variable.encrypted) return variable.value;
64
+ if (variable.value === "***") return "•••••• encrypted";
65
+ return variable.value;
66
+ };
67
+
68
+ const commandMatches = (command: AppCommand, query: string): boolean => {
69
+ const normalized = query.trim().toLowerCase();
70
+ if (normalized.length === 0) return true;
71
+ return [command.id, command.title, command.shortcut ?? ""]
72
+ .join(" ")
73
+ .toLowerCase()
74
+ .includes(normalized);
75
+ };
76
+
77
+ const ellipsize = (value: string, width: number): string => {
78
+ if (value.length <= width) return value.padEnd(width);
79
+ if (width <= 1) return value.slice(0, width);
80
+ return `${value.slice(0, width - 1)}…`;
81
+ };
82
+
83
+ const cellValue = (cell: EnvTableCell, width: number): string => {
84
+ if (cell.variable === undefined) return "—".padEnd(width);
85
+ return ellipsize(maskedValue(cell.variable), width);
86
+ };
87
+
88
+ const selectedCellDetail = (cell: EnvTableCell): string => {
89
+ if (cell.variable === undefined) return "missing";
90
+ return maskedValue(cell.variable);
91
+ };
92
+
93
+ const clamp = (value: number, min: number, max: number): number =>
94
+ Math.min(Math.max(value, min), max);
95
+
96
+ // ─── Layout hook ─────────────────────────────────────────────────────────────
97
+
98
+ const useLayout = () => {
99
+ const { width, height } = useTerminalDimensions();
100
+ return {
101
+ width,
102
+ height,
103
+ isWide: width >= 120,
104
+ isNarrow: width < 80,
105
+ bodyHeight: height - 4, // header + tabs + footer + padding
106
+ };
107
+ };
108
+
109
+ // ─── Header ──────────────────────────────────────────────────────────────────
110
+
111
+ function Header() {
112
+ const decrypt = useAtomValue(decryptAtom);
113
+ const stats = useAtomValue(statsAtom);
114
+
115
+ return (
116
+ <box height={1} paddingLeft={1} paddingRight={1} flexDirection="row">
117
+ <box height={1}>
118
+ <TextLine>
119
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>
120
+ ENV VAULT
121
+ </span>
122
+ <span fg={colors.dim}> │ </span>
123
+ <span fg={colors.text}>{`${stats.files} files`}</span>
124
+ <span fg={colors.dim}> · </span>
125
+ <span fg={colors.text}>{`${stats.variables} vars`}</span>
126
+ {stats.encryptedFiles > 0 ? (
127
+ <span>
128
+ <span fg={colors.dim}> · </span>
129
+ <span fg={colors.encrypted}>{`${stats.encryptedFiles} enc`}</span>
130
+ </span>
131
+ ) : null}
132
+ <span fg={colors.dim}> │ </span>
133
+ <span fg={decrypt ? colors.success : colors.muted}>{decrypt ? "decrypt" : "locked"}</span>
134
+ </TextLine>
135
+ </box>
136
+ </box>
137
+ );
138
+ }
139
+
140
+ // ─── Project Tabs ────────────────────────────────────────────────────────────
141
+
142
+ function ProjectTabs() {
143
+ const groups = useAtomValue(visibleEnvFileGroupsAtom);
144
+ const selectedGroup = useAtomValue(selectedGroupAtom);
145
+ const focus = useAtomValue(focusAtom);
146
+ const layout = useLayout();
147
+
148
+ return (
149
+ <box flexDirection="column" height={2} backgroundColor={colors.panelDeep}>
150
+ <box
151
+ height={1}
152
+ backgroundColor={focus === "files" ? colors.panelAlt : colors.panelDeep}
153
+ paddingLeft={1}
154
+ paddingRight={1}
155
+ flexDirection="row"
156
+ >
157
+ <TextLine>
158
+ {groups.length === 0 ? <span fg={colors.muted}>no env groups found</span> : null}
159
+ {groups.map((group, index) => {
160
+ const active = group.key === selectedGroup?.key;
161
+ const labelWidth = layout.width < 100 ? 14 : 20;
162
+ return (
163
+ <span key={group.key}>
164
+ {index > 0 ? <span fg={colors.dim}> </span> : null}
165
+ <span
166
+ fg={active ? colors.panelDeep : colors.text}
167
+ bg={active ? colors.accent : colors.panel}
168
+ attributes={active ? TextAttributes.BOLD : 0}
169
+ >
170
+ {` ${ellipsize(group.label, labelWidth)} `}
171
+ </span>
172
+ <span
173
+ fg={active ? colors.panelDeep : colors.muted}
174
+ bg={active ? colors.accent : colors.panel}
175
+ >
176
+ {`${group.environments.length}e `}
177
+ </span>
178
+ </span>
179
+ );
180
+ })}
181
+ </TextLine>
182
+ </box>
183
+ <box height={1} backgroundColor={colors.line} />
184
+ </box>
185
+ );
186
+ }
187
+
188
+ // ─── Env Matrix ──────────────────────────────────────────────────────────────
189
+
190
+ type EnvMatrixProps = {
191
+ readonly availableWidth: number;
192
+ };
193
+
194
+ function EnvMatrix(props: EnvMatrixProps) {
195
+ const rows = useAtomValue(envTableRowsAtom);
196
+ const group = useAtomValue(selectedGroupAtom);
197
+ const selectedEnvironment = useAtomValue(selectedEnvironmentAtom);
198
+ const selectedVariableKey = useAtomValue(selectedVariableKeyAtom);
199
+ const focus = useAtomValue(focusAtom);
200
+
201
+ if (group === undefined) {
202
+ return <EmptyState message="select a project" hint="use h/l to navigate projects" />;
203
+ }
204
+
205
+ if (rows.length === 0) {
206
+ return <EmptyState message="no matching keys" hint="try adjusting your search filter" />;
207
+ }
208
+
209
+ const keyWidth = clamp(Math.floor(props.availableWidth * 0.28), 22, 34);
210
+ const countWidth = 5;
211
+ const chromeWidth = 1 + keyWidth + 3 + countWidth;
212
+ const environmentAreaWidth = Math.max(12, props.availableWidth - chromeWidth - 4);
213
+ const minimumEnvironmentWidth = props.availableWidth < 110 ? 16 : 20;
214
+ const selectedEnvironmentIndex = Math.max(
215
+ 0,
216
+ group.environments.findIndex((environment) => environment.path === selectedEnvironment?.path),
217
+ );
218
+ const visibleEnvironmentCount = clamp(
219
+ Math.floor(environmentAreaWidth / minimumEnvironmentWidth),
220
+ 1,
221
+ group.environments.length,
222
+ );
223
+ const environmentWidth = Math.max(
224
+ minimumEnvironmentWidth,
225
+ Math.floor(environmentAreaWidth / visibleEnvironmentCount),
226
+ );
227
+ const visibleEnvironmentStart = Math.min(
228
+ Math.max(0, selectedEnvironmentIndex - Math.floor(visibleEnvironmentCount / 2)),
229
+ Math.max(0, group.environments.length - visibleEnvironmentCount),
230
+ );
231
+ const visibleEnvironments = group.environments.slice(
232
+ visibleEnvironmentStart,
233
+ visibleEnvironmentStart + visibleEnvironmentCount,
234
+ );
235
+ const hiddenLeft = visibleEnvironmentStart;
236
+ const hiddenRight =
237
+ group.environments.length - visibleEnvironmentStart - visibleEnvironments.length;
238
+ const envRange =
239
+ group.environments.length > visibleEnvironments.length
240
+ ? ` · ${visibleEnvironmentStart + 1}-${visibleEnvironmentStart + visibleEnvironments.length}/${group.environments.length} envs`
241
+ : ` · ${group.environments.length} env${group.environments.length === 1 ? "" : "s"}`;
242
+
243
+ return (
244
+ <ScrollPanel
245
+ title={`keys ${rows.length}${envRange}`}
246
+ focused={focus === "variables"}
247
+ width="100%"
248
+ height="100%"
249
+ >
250
+ <TextLine backgroundColor={colors.panelDeep}>
251
+ <span fg={colors.dim}> {ellipsize("key", keyWidth)}</span>
252
+ <span fg={colors.dim}> │ </span>
253
+ {visibleEnvironments.map((environment) => {
254
+ const active = environment.path === selectedEnvironment?.path;
255
+ return (
256
+ <span
257
+ key={environment.path}
258
+ fg={active ? colors.accent : colors.info}
259
+ attributes={active ? TextAttributes.BOLD : 0}
260
+ >
261
+ {ellipsize(environment.label, environmentWidth)}
262
+ </span>
263
+ );
264
+ })}
265
+ <span fg={colors.dim}>{ellipsize("", countWidth)}</span>
266
+ </TextLine>
267
+ {hiddenLeft > 0 || hiddenRight > 0 ? (
268
+ <TextLine>
269
+ <span fg={colors.dim}> {ellipsize("", keyWidth)}</span>
270
+ <span fg={colors.dim}> │ </span>
271
+ <span fg={colors.muted}>
272
+ {ellipsize(
273
+ `${hiddenLeft > 0 ? `← ${hiddenLeft} hidden` : ""}${hiddenLeft > 0 && hiddenRight > 0 ? " · " : ""}${hiddenRight > 0 ? `${hiddenRight} hidden →` : ""}`,
274
+ environmentWidth * visibleEnvironmentCount,
275
+ )}
276
+ </span>
277
+ </TextLine>
278
+ ) : null}
279
+ {rows.map((row) => {
280
+ const active = row.key === selectedVariableKey;
281
+ const visibleCells = visibleEnvironments.map((environment) =>
282
+ row.cells.find((cell) => cell.environment.path === environment.path),
283
+ );
284
+ return (
285
+ <TextLine key={row.key} {...(active ? { backgroundColor: colors.selectedBg } : {})}>
286
+ <span fg={active ? colors.accent : colors.dim}>{active ? "▸" : " "}</span>
287
+ <span
288
+ fg={active ? colors.selectedText : colors.text}
289
+ attributes={active ? TextAttributes.BOLD : 0}
290
+ >
291
+ {ellipsize(row.key, keyWidth)}
292
+ </span>
293
+ <span fg={colors.dim}> │ </span>
294
+ {visibleCells.map((cell) => {
295
+ if (cell === undefined) return null;
296
+ const selectedCell = cell.environment.path === selectedEnvironment?.path;
297
+ return (
298
+ <span
299
+ key={cell.environment.path}
300
+ fg={
301
+ cell.variable === undefined
302
+ ? colors.dim
303
+ : cell.variable.encrypted
304
+ ? colors.encrypted
305
+ : selectedCell
306
+ ? colors.selectedText
307
+ : colors.text
308
+ }
309
+ attributes={selectedCell && active ? TextAttributes.BOLD : 0}
310
+ >
311
+ {cellValue(cell, environmentWidth)}
312
+ </span>
313
+ );
314
+ })}
315
+ <span fg={colors.dim}>
316
+ {ellipsize(`${row.presentCount}/${row.cells.length}`, countWidth)}
317
+ </span>
318
+ </TextLine>
319
+ );
320
+ })}
321
+ </ScrollPanel>
322
+ );
323
+ }
324
+
325
+ // ─── Detail Panel ─────────────────────────────────────────────────────────────
326
+
327
+ function DetailPanel(props: { readonly compact?: boolean | undefined }) {
328
+ const selectedVariable = useAtomValue(selectedVariableAtom);
329
+ const selectedEnvironment = useAtomValue(selectedEnvironmentAtom);
330
+ const selectedVariableKey = useAtomValue(selectedVariableKeyAtom);
331
+ const rows = useAtomValue(envTableRowsAtom);
332
+
333
+ if (selectedVariableKey === undefined) {
334
+ return (
335
+ <ScrollPanel title="detail" width="100%" height="100%">
336
+ <EmptyState message="no key selected" />
337
+ </ScrollPanel>
338
+ );
339
+ }
340
+
341
+ const row = rows.find((row) => row.key === selectedVariableKey);
342
+ const title = ellipsize(selectedVariableKey, 72);
343
+
344
+ return (
345
+ <ScrollPanel title={title} width="100%" height="100%">
346
+ <TextLine>
347
+ <span fg={colors.dim}>key </span>
348
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>
349
+ {selectedVariableKey}
350
+ </span>
351
+ </TextLine>
352
+ <TextLine>
353
+ <span fg={colors.dim}>env </span>
354
+ <span fg={colors.info}>{selectedEnvironment?.label ?? "—"}</span>
355
+ <span fg={colors.dim}> · </span>
356
+ <span fg={selectedVariable?.encrypted ? colors.warning : colors.muted}>
357
+ {selectedVariable?.encrypted
358
+ ? "encrypted"
359
+ : selectedVariable === undefined
360
+ ? "missing"
361
+ : "plain"}
362
+ </span>
363
+ </TextLine>
364
+ <TextLine>
365
+ <span fg={colors.dim}>value </span>
366
+ <span
367
+ fg={
368
+ selectedVariable === undefined
369
+ ? colors.dim
370
+ : selectedVariable.encrypted
371
+ ? colors.encrypted
372
+ : colors.text
373
+ }
374
+ >
375
+ {selectedVariable === undefined ? "missing" : maskedValue(selectedVariable)}
376
+ </span>
377
+ </TextLine>
378
+ {row !== undefined && row.cells.length > 1 ? (
379
+ <box flexDirection="column" marginTop={1}>
380
+ {row.cells.map((cell) => (
381
+ <TextLine key={cell.environment.path}>
382
+ <span
383
+ fg={
384
+ cell.environment.path === selectedEnvironment?.path ? colors.accent : colors.muted
385
+ }
386
+ >
387
+ {ellipsize(cell.environment.label, props.compact ? 10 : 14)}
388
+ </span>
389
+ <span fg={colors.dim}> </span>
390
+ <span
391
+ fg={
392
+ cell.variable === undefined
393
+ ? colors.dim
394
+ : cell.variable.encrypted
395
+ ? colors.encrypted
396
+ : colors.text
397
+ }
398
+ >
399
+ {ellipsize(selectedCellDetail(cell), props.compact ? 40 : 72)}
400
+ </span>
401
+ </TextLine>
402
+ ))}
403
+ </box>
404
+ ) : null}
405
+ </ScrollPanel>
406
+ );
407
+ }
408
+
409
+ // ─── Search Footer ───────────────────────────────────────────────────────────
410
+
411
+ function SearchFooter() {
412
+ const query = useAtomValue(queryAtom);
413
+ const setQuery = useAtomSet(queryAtom);
414
+
415
+ return (
416
+ <box
417
+ height={3}
418
+ borderStyle="single"
419
+ borderColor={colors.accent}
420
+ backgroundColor={colors.panel}
421
+ paddingLeft={1}
422
+ paddingRight={1}
423
+ flexDirection="column"
424
+ >
425
+ <input
426
+ value={query}
427
+ onInput={(value) => setQuery(value)}
428
+ onSubmit={() => setQuery(query)}
429
+ focused={true}
430
+ placeholder="search projects + keys"
431
+ />
432
+ </box>
433
+ );
434
+ }
435
+
436
+ // ─── Footer ──────────────────────────────────────────────────────────────────
437
+
438
+ const footerHints = (
439
+ focus: FocusTarget,
440
+ isWide: boolean,
441
+ ): ReadonlyArray<{ readonly key: string; readonly label: string }> => {
442
+ if (!isWide) {
443
+ // Compact hints for narrow terminals
444
+ switch (focus) {
445
+ case "files":
446
+ return [
447
+ { key: "h/l", label: "project" },
448
+ { key: "↵", label: "keys" },
449
+ { key: "/", label: "search" },
450
+ { key: ":", label: "cmd" },
451
+ ];
452
+ case "variables":
453
+ return [
454
+ { key: "j/k", label: "nav" },
455
+ { key: "e", label: "edit" },
456
+ { key: "n", label: "add" },
457
+ { key: "/", label: "search" },
458
+ ];
459
+ default:
460
+ return [
461
+ { key: "esc", label: "close" },
462
+ { key: "↵", label: "confirm" },
463
+ ];
464
+ }
465
+ }
466
+ switch (focus) {
467
+ case "files":
468
+ return [
469
+ { key: "h/l", label: "project" },
470
+ { key: "enter/tab", label: "keys" },
471
+ { key: "/", label: "search" },
472
+ { key: "N", label: "new file" },
473
+ { key: "r", label: "refresh" },
474
+ { key: ":", label: "commands" },
475
+ { key: "?", label: "help" },
476
+ ];
477
+ case "variables":
478
+ return [
479
+ { key: "j/k", label: "key" },
480
+ { key: "h/l", label: "env" },
481
+ { key: "e", label: "edit" },
482
+ { key: "n", label: "add" },
483
+ { key: "d", label: "delete" },
484
+ { key: "/", label: "search" },
485
+ { key: ":", label: "commands" },
486
+ ];
487
+ case "edit-key":
488
+ case "edit-value":
489
+ return [
490
+ { key: "tab", label: "field" },
491
+ { key: "Y", label: "encrypt" },
492
+ { key: "enter", label: "save" },
493
+ { key: "esc", label: "cancel" },
494
+ ];
495
+ case "file-path":
496
+ return [
497
+ { key: "enter", label: "create" },
498
+ { key: "esc", label: "cancel" },
499
+ ];
500
+ case "filter":
501
+ return [
502
+ { key: "enter/esc", label: "close" },
503
+ { key: "ctrl+u", label: "clear" },
504
+ ];
505
+ }
506
+ };
507
+
508
+ function Footer() {
509
+ const focus = useAtomValue(focusAtom);
510
+ const message = useAtomValue(messageAtom);
511
+ const query = useAtomValue(queryAtom);
512
+ const root = useAtomValue(rootAtom);
513
+ const busy = useAtomValue(busyAtom);
514
+ const layout = useLayout();
515
+
516
+ return (
517
+ <box
518
+ height={1}
519
+ backgroundColor={colors.panel}
520
+ paddingLeft={1}
521
+ paddingRight={1}
522
+ flexDirection="row"
523
+ >
524
+ <box height={1} flexGrow={1}>
525
+ <HintRow items={footerHints(focus, layout.isWide)} compact={!layout.isWide} />
526
+ </box>
527
+ <box height={1} justifyContent="flex-end">
528
+ <TextLine>
529
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>
530
+ {focusLabel(focus)}
531
+ </span>
532
+ {query.trim().length > 0 ? (
533
+ <span>
534
+ <span fg={colors.dim}> /</span>
535
+ <span fg={colors.info}>{query}</span>
536
+ </span>
537
+ ) : null}
538
+ <span fg={colors.dim}> │ </span>
539
+ <span fg={busy ? colors.warning : colors.success}>{busy ? "◐ syncing" : "● ready"}</span>
540
+ {message !== undefined ? (
541
+ <span>
542
+ <span fg={colors.dim}> │ </span>
543
+ <span fg={message.startsWith("error") ? colors.red : colors.muted}>{message}</span>
544
+ </span>
545
+ ) : null}
546
+ <span fg={colors.dim}> │ </span>
547
+ <span fg={colors.dim}>
548
+ {ellipsize(root, Math.max(12, Math.floor(layout.width * 0.3)))}
549
+ </span>
550
+ </TextLine>
551
+ </box>
552
+ </box>
553
+ );
554
+ }
555
+
556
+ // ─── Command Palette Modal ───────────────────────────────────────────────────
557
+
558
+ function CommandPalette(props: { readonly commands: ReadonlyArray<AppCommand> }) {
559
+ const modal = useAtomValue(modalAtom);
560
+ const setModal = useAtomSet(modalAtom);
561
+
562
+ if (modal._tag !== "CommandPalette") return null;
563
+
564
+ const commands = props.commands.filter((command) => commandMatches(command, modal.query));
565
+ const selectedIndex = commands.length === 0 ? 0 : Math.min(modal.index, commands.length - 1);
566
+
567
+ return (
568
+ <box
569
+ position="absolute"
570
+ left={6}
571
+ top={4}
572
+ width="82%"
573
+ height={18}
574
+ borderStyle="double"
575
+ borderColor={colors.accent}
576
+ backgroundColor={colors.panelAlt}
577
+ padding={1}
578
+ flexDirection="column"
579
+ gap={1}
580
+ >
581
+ <SectionTitle title="COMMANDS" subtitle="type to filter" />
582
+ <input
583
+ value={modal.query}
584
+ onInput={(value) => setModal(Modal.CommandPalette({ query: value, index: modal.index }))}
585
+ focused={true}
586
+ placeholder="type an action…"
587
+ />
588
+ {commands.map((command, commandIndex) => {
589
+ const active = commandIndex === selectedIndex;
590
+ return (
591
+ <TextLine key={command.id} {...(active ? { backgroundColor: colors.selectedBg } : {})}>
592
+ <span fg={active ? colors.accent : colors.dim}>{active ? "▸" : " "}</span>
593
+ <span fg={colors.dim}> </span>
594
+ {command.section !== undefined ? (
595
+ <span fg={colors.muted}>{`${command.section} › `}</span>
596
+ ) : null}
597
+ <span
598
+ fg={command.disabledReason ? colors.dim : active ? colors.selectedText : colors.text}
599
+ attributes={active ? TextAttributes.BOLD : 0}
600
+ >
601
+ {command.title}
602
+ </span>
603
+ {command.shortcut !== undefined ? (
604
+ <span fg={colors.dim}>{` ${command.shortcut}`}</span>
605
+ ) : null}
606
+ {command.disabledReason !== undefined ? (
607
+ <span fg={colors.red}>{` ${command.disabledReason}`}</span>
608
+ ) : null}
609
+ </TextLine>
610
+ );
611
+ })}
612
+ <HintRow
613
+ items={[
614
+ { key: "j/k", label: "navigate" },
615
+ { key: "enter", label: "run" },
616
+ { key: "esc", label: "close" },
617
+ ]}
618
+ />
619
+ </box>
620
+ );
621
+ }
622
+
623
+ // ─── Edit Modal ──────────────────────────────────────────────────────────────
624
+
625
+ function EditModal() {
626
+ const focus = useAtomValue(focusAtom);
627
+ const modal = useAtomValue(modalAtom);
628
+ const setModal = useAtomSet(modalAtom);
629
+
630
+ if (modal._tag !== "Edit") return null;
631
+ const { draft } = modal;
632
+
633
+ const updateDraft = (patch: Partial<EditDraft>) =>
634
+ setModal(Modal.Edit({ draft: { ...draft, ...patch } }));
635
+
636
+ return (
637
+ <box
638
+ position="absolute"
639
+ left={8}
640
+ top={6}
641
+ width="78%"
642
+ height={13}
643
+ borderStyle="double"
644
+ borderColor={colors.info}
645
+ backgroundColor={colors.panelAlt}
646
+ padding={1}
647
+ flexDirection="column"
648
+ gap={1}
649
+ >
650
+ <SectionTitle
651
+ title={draft.mode === "add" ? "ADD SECRET" : "EDIT SECRET"}
652
+ subtitle="current env"
653
+ fg={colors.info}
654
+ />
655
+ <PlainLine text="key" fg={focus === "edit-key" ? colors.accent : colors.muted} />
656
+ <input
657
+ value={draft.key}
658
+ focused={focus === "edit-key"}
659
+ onInput={(value) => updateDraft({ key: value })}
660
+ placeholder="API_TOKEN"
661
+ />
662
+ <PlainLine text="value" fg={focus === "edit-value" ? colors.accent : colors.muted} />
663
+ <input
664
+ value={draft.value}
665
+ focused={focus === "edit-value"}
666
+ onInput={(value) => updateDraft({ value })}
667
+ placeholder="secret value"
668
+ />
669
+ <TextLine>
670
+ <span fg={draft.encrypt ? colors.warning : colors.muted}>
671
+ {draft.encrypt ? "● encrypt:on" : "○ encrypt:off"}
672
+ </span>
673
+ <span fg={colors.dim}> press Y to toggle</span>
674
+ </TextLine>
675
+ <HintRow
676
+ items={[
677
+ { key: "tab", label: "field" },
678
+ { key: "Y", label: "encrypt" },
679
+ { key: "enter", label: "save" },
680
+ { key: "esc", label: "cancel" },
681
+ ]}
682
+ />
683
+ </box>
684
+ );
685
+ }
686
+
687
+ // ─── File Create Modal ───────────────────────────────────────────────────────
688
+
689
+ function FileModal() {
690
+ const modal = useAtomValue(modalAtom);
691
+ const setModal = useAtomSet(modalAtom);
692
+ const focus = useAtomValue(focusAtom);
693
+
694
+ if (modal._tag !== "CreateFile") return null;
695
+
696
+ return (
697
+ <box
698
+ position="absolute"
699
+ left={8}
700
+ top={7}
701
+ width="78%"
702
+ height={8}
703
+ borderStyle="double"
704
+ borderColor={colors.success}
705
+ backgroundColor={colors.panelAlt}
706
+ padding={1}
707
+ flexDirection="column"
708
+ gap={1}
709
+ >
710
+ <SectionTitle title="CREATE ENV FILE" fg={colors.success} />
711
+ <PlainLine text="relative path" fg={focus === "file-path" ? colors.accent : colors.muted} />
712
+ <input
713
+ value={modal.path}
714
+ focused={focus === "file-path"}
715
+ onInput={(value) => setModal(Modal.CreateFile({ path: value }))}
716
+ placeholder="apps/web/.env.local"
717
+ />
718
+ <HintRow
719
+ items={[
720
+ { key: "enter", label: "create" },
721
+ { key: "esc", label: "cancel" },
722
+ ]}
723
+ />
724
+ </box>
725
+ );
726
+ }
727
+
728
+ // ─── Confirm Delete Modal ────────────────────────────────────────────────────
729
+
730
+ function ConfirmDeleteModal() {
731
+ const modal = useAtomValue(modalAtom);
732
+
733
+ if (modal._tag !== "ConfirmDelete") return null;
734
+
735
+ return (
736
+ <box
737
+ position="absolute"
738
+ left={8}
739
+ top={8}
740
+ width="78%"
741
+ height={8}
742
+ borderStyle="double"
743
+ borderColor={colors.red}
744
+ backgroundColor={colors.panelAlt}
745
+ padding={1}
746
+ flexDirection="column"
747
+ gap={1}
748
+ >
749
+ <SectionTitle title="CONFIRM DELETE" fg={colors.red} />
750
+ <PlainLine text={`delete ${modal.key} from ${modal.path}?`} fg={colors.text} />
751
+ <PlainLine
752
+ text="this removes the key line while preserving the rest of the file"
753
+ fg={colors.muted}
754
+ />
755
+ <HintRow
756
+ items={[
757
+ { key: "y", label: "delete" },
758
+ { key: "n/esc", label: "cancel" },
759
+ ]}
760
+ />
761
+ </box>
762
+ );
763
+ }
764
+
765
+ // ─── Help Modal ──────────────────────────────────────────────────────────────
766
+
767
+ function HelpModal() {
768
+ const modal = useAtomValue(modalAtom);
769
+
770
+ if (modal._tag !== "Help") return null;
771
+
772
+ const groups = [
773
+ [
774
+ "NAVIGATION",
775
+ "tab cycle projects/keys",
776
+ "h/l ←/→ select project or env tab",
777
+ "j/k ↑/↓ move through keys",
778
+ "g/G jump to top/bottom",
779
+ ],
780
+ [
781
+ "ACTIONS",
782
+ "n add variable to active env",
783
+ "N create new env file",
784
+ "e edit selected cell",
785
+ "d/del delete from active env",
786
+ "r refresh vault",
787
+ ],
788
+ [
789
+ "SEARCH",
790
+ "/ focus search input",
791
+ " space-separated tokens",
792
+ " field filters: key: env: value: path: state:",
793
+ " state supports missing/encrypted/plain",
794
+ "ctrl+u clear search",
795
+ "x clear all filters",
796
+ ],
797
+ [
798
+ "SAFETY",
799
+ "writes preserve comments/order/quotes",
800
+ "delete requires y/n confirmation",
801
+ "encrypted write-back uses dotenvx + .env.keys",
802
+ ],
803
+ ] as const;
804
+
805
+ return (
806
+ <box
807
+ position="absolute"
808
+ left={4}
809
+ top={3}
810
+ width="88%"
811
+ height={24}
812
+ borderStyle="double"
813
+ borderColor={colors.accent}
814
+ backgroundColor={colors.panelAlt}
815
+ padding={1}
816
+ flexDirection="column"
817
+ gap={1}
818
+ >
819
+ <SectionTitle title="ENV VAULT HELP" subtitle="keyboard reference" />
820
+ {groups.map(([title, ...items]) => (
821
+ <box key={title} flexDirection="column">
822
+ <TextLine>
823
+ <span fg={colors.info} attributes={TextAttributes.BOLD}>
824
+ {title}
825
+ </span>
826
+ </TextLine>
827
+ {items.map((item) => (
828
+ <PlainLine key={item} text={` ${item}`} fg={colors.text} />
829
+ ))}
830
+ </box>
831
+ ))}
832
+ <HintRow items={[{ key: "?/esc", label: "close" }]} />
833
+ </box>
834
+ );
835
+ }
836
+
837
+ // ─── Dashboard (main orchestrator) ──────────────────────────────────────────
838
+
839
+ function Dashboard(props: AppProps) {
840
+ const renderer = useRenderer();
841
+ const layout = useLayout();
842
+ const setRoot = useAtomSet(rootAtom);
843
+ const setDecrypt = useAtomSet(decryptAtom);
844
+ const setWorkspace = useAtomSet(workspaceAtom);
845
+ const setEnvFiles = useAtomSet(envFilesAtom);
846
+ const setSelectedPath = useAtomSet(selectedPathAtom);
847
+ const setSelectedVariableKey = useAtomSet(selectedVariableKeyAtom);
848
+ const setFocus = useAtomSet(focusAtom);
849
+ const setPreviousFocus = useAtomSet(previousFocusAtom);
850
+ const setQuery = useAtomSet(queryAtom);
851
+ const setBusy = useAtomSet(busyAtom);
852
+ const setMessage = useAtomSet(messageAtom);
853
+ const setModal = useAtomSet(modalAtom);
854
+ const groups = useAtomValue(visibleEnvFileGroupsAtom);
855
+ const rows = useAtomValue(envTableRowsAtom);
856
+ const selectedFile = useAtomValue(selectedFileAtom);
857
+ const selectedGroup = useAtomValue(selectedGroupAtom);
858
+ const selectedEnvironment = useAtomValue(selectedEnvironmentAtom);
859
+ const selectedVariable = useAtomValue(selectedVariableAtom);
860
+ const selectedVariableKey = useAtomValue(selectedVariableKeyAtom);
861
+ const focus = useAtomValue(focusAtom);
862
+ const previousFocus = useAtomValue(previousFocusAtom);
863
+ const modal = useAtomValue(modalAtom);
864
+
865
+ // ─── Actions ─────────────────────────────────────────────────────────────
866
+
867
+ const refresh = useCallback(
868
+ (message?: string | undefined) => {
869
+ setBusy(true);
870
+ void Effect.runPromise(
871
+ Effect.all({ workspace: props.loadWorkspace(), envFiles: props.loadEnvFiles() }).pipe(
872
+ Effect.tap(({ workspace, envFiles }) =>
873
+ Effect.sync(() => {
874
+ setWorkspace(workspace);
875
+ setEnvFiles(envFiles);
876
+ setSelectedPath((current) =>
877
+ current !== undefined && envFiles.some((file) => file.path === current)
878
+ ? current
879
+ : envFiles[0]?.path,
880
+ );
881
+ setBusy(false);
882
+ setMessage(message);
883
+ }),
884
+ ),
885
+ Effect.catchTag("EnvFileError", (cause) =>
886
+ Effect.sync(() => {
887
+ setBusy(false);
888
+ setMessage(`error: ${cause.message}`);
889
+ }),
890
+ ),
891
+ ),
892
+ );
893
+ },
894
+ [props.loadWorkspace, props.loadEnvFiles],
895
+ );
896
+
897
+ useEffect(() => {
898
+ setRoot(props.root);
899
+ setDecrypt(props.decrypt);
900
+ refresh();
901
+ }, [props.root, props.decrypt]);
902
+
903
+ useEffect(() => {
904
+ if (selectedFile !== undefined) setSelectedPath(selectedFile.path);
905
+ }, [selectedFile, setSelectedPath]);
906
+
907
+ useEffect(() => {
908
+ if (rows.length === 0) {
909
+ setSelectedVariableKey(undefined);
910
+ return;
911
+ }
912
+ if (selectedVariableKey === undefined || !rows.some((row) => row.key === selectedVariableKey)) {
913
+ setSelectedVariableKey(rows[0]?.key);
914
+ }
915
+ }, [rows, selectedVariableKey, setSelectedVariableKey]);
916
+
917
+ const openCommands = () => {
918
+ setPreviousFocus(focus);
919
+ setModal(Modal.CommandPalette({ query: "", index: 0 }));
920
+ };
921
+
922
+ const closeModal = () => {
923
+ setModal(Modal.None());
924
+ setFocus(
925
+ previousFocus === "edit-key" ||
926
+ previousFocus === "edit-value" ||
927
+ previousFocus === "file-path"
928
+ ? "variables"
929
+ : previousFocus,
930
+ );
931
+ };
932
+
933
+ const openEdit = (mode: "add" | "update") => {
934
+ if (selectedFile === undefined) {
935
+ setMessage("select an env tab first");
936
+ return;
937
+ }
938
+ setPreviousFocus(focus);
939
+ setModal(
940
+ Modal.Edit({
941
+ draft: {
942
+ mode,
943
+ key: mode === "update" ? (selectedVariableKey ?? selectedVariable?.key ?? "") : "",
944
+ value:
945
+ mode === "update" ? (selectedVariable?.rawValue ?? selectedVariable?.value ?? "") : "",
946
+ encrypt: mode === "update" ? selectedVariable?.encrypted === true : false,
947
+ },
948
+ }),
949
+ );
950
+ setFocus("edit-key");
951
+ };
952
+
953
+ const openFileCreate = () => {
954
+ setPreviousFocus(focus);
955
+ setModal(Modal.CreateFile({ path: ".env.local" }));
956
+ setFocus("file-path");
957
+ };
958
+
959
+ const submitFileCreate = () => {
960
+ if (modal._tag !== "CreateFile") return;
961
+ const nextPath = modal.path.trim();
962
+ if (nextPath.length === 0) {
963
+ setMessage("enter an env file path");
964
+ return;
965
+ }
966
+ setBusy(true);
967
+ void Effect.runPromise(
968
+ props.createFile({ path: nextPath }).pipe(
969
+ Effect.tap((file) =>
970
+ Effect.sync(() => {
971
+ setEnvFiles((current) =>
972
+ [...current.filter((item) => item.path !== file.path), file].sort((left, right) =>
973
+ left.path.localeCompare(right.path),
974
+ ),
975
+ );
976
+ setSelectedPath(file.path);
977
+ setSelectedVariableKey(undefined);
978
+ setModal(Modal.None());
979
+ setFocus("files");
980
+ setBusy(false);
981
+ setMessage(`created ${file.path}`);
982
+ }),
983
+ ),
984
+ Effect.catchTag("EnvFileError", (cause) =>
985
+ Effect.sync(() => {
986
+ setBusy(false);
987
+ setMessage(`error: ${cause.message}`);
988
+ }),
989
+ ),
990
+ ),
991
+ );
992
+ };
993
+
994
+ const submitEdit = () => {
995
+ if (selectedFile === undefined || modal._tag !== "Edit") return;
996
+ const { draft } = modal;
997
+ setBusy(true);
998
+ void Effect.runPromise(
999
+ props
1000
+ .setVariable({
1001
+ path: selectedFile.path,
1002
+ key: draft.key.trim(),
1003
+ value: draft.value,
1004
+ encrypt: draft.encrypt,
1005
+ })
1006
+ .pipe(
1007
+ Effect.tap((file) =>
1008
+ Effect.sync(() => {
1009
+ setEnvFiles((current) =>
1010
+ current.map((item) => (item.path === file.path ? file : item)),
1011
+ );
1012
+ setSelectedVariableKey(draft.key.trim());
1013
+ setModal(Modal.None());
1014
+ setFocus("variables");
1015
+ setBusy(false);
1016
+ setMessage(`${draft.mode === "add" ? "added" : "updated"} ${draft.key.trim()}`);
1017
+ }),
1018
+ ),
1019
+ Effect.catchTag("EnvFileError", (cause) =>
1020
+ Effect.sync(() => {
1021
+ setBusy(false);
1022
+ setMessage(`error: ${cause.message}`);
1023
+ }),
1024
+ ),
1025
+ ),
1026
+ );
1027
+ };
1028
+
1029
+ const deleteSelected = () => {
1030
+ if (selectedFile === undefined || selectedVariableKey === undefined) {
1031
+ setMessage("select a key first");
1032
+ return;
1033
+ }
1034
+ if (selectedVariable === undefined) {
1035
+ setMessage(
1036
+ `${selectedVariableKey} is missing in ${selectedEnvironment?.label ?? selectedFile.path}`,
1037
+ );
1038
+ return;
1039
+ }
1040
+ setPreviousFocus(focus);
1041
+ setModal(Modal.ConfirmDelete({ path: selectedFile.path, key: selectedVariableKey }));
1042
+ };
1043
+
1044
+ const confirmDeleteSelected = () => {
1045
+ if (modal._tag !== "ConfirmDelete") return;
1046
+ setBusy(true);
1047
+ void Effect.runPromise(
1048
+ props.deleteVariable({ path: modal.path, key: modal.key }).pipe(
1049
+ Effect.tap((file) =>
1050
+ Effect.sync(() => {
1051
+ setEnvFiles((current) =>
1052
+ current.map((item) => (item.path === file.path ? file : item)),
1053
+ );
1054
+ setSelectedVariableKey(file.variables[0]?.key);
1055
+ setModal(Modal.None());
1056
+ setFocus("variables");
1057
+ setBusy(false);
1058
+ setMessage(`deleted ${modal.key}`);
1059
+ }),
1060
+ ),
1061
+ Effect.catchTag("EnvFileError", (cause) =>
1062
+ Effect.sync(() => {
1063
+ setModal(Modal.None());
1064
+ setBusy(false);
1065
+ setMessage(`error: ${cause.message}`);
1066
+ }),
1067
+ ),
1068
+ ),
1069
+ );
1070
+ };
1071
+
1072
+ const moveGroups = (direction: -1 | 1) => {
1073
+ if (groups.length === 0) return;
1074
+ const index = groups.findIndex((group) => group.key === selectedGroup?.key);
1075
+ const next = Math.min(Math.max(0, groups.length - 1), (index < 0 ? 0 : index) + direction);
1076
+ setSelectedPath(groups[next]?.environments[0]?.path);
1077
+ setFocus((current) => (current === "variables" ? "variables" : "files"));
1078
+ };
1079
+
1080
+ const moveEnvironment = (direction: -1 | 1) => {
1081
+ if (selectedGroup === undefined || selectedGroup.environments.length === 0) return;
1082
+ const index = selectedGroup.environments.findIndex(
1083
+ (environment) => environment.path === selectedEnvironment?.path,
1084
+ );
1085
+ const next = Math.min(
1086
+ Math.max(0, selectedGroup.environments.length - 1),
1087
+ (index < 0 ? 0 : index) + direction,
1088
+ );
1089
+ setSelectedPath(selectedGroup.environments[next]?.path);
1090
+ setFocus("variables");
1091
+ };
1092
+
1093
+ const moveVariables = (direction: -1 | 1) => {
1094
+ if (rows.length === 0) return;
1095
+ const index = rows.findIndex((row) => row.key === selectedVariableKey);
1096
+ const next = Math.min(Math.max(0, rows.length - 1), (index < 0 ? 0 : index) + direction);
1097
+ setSelectedVariableKey(rows[next]?.key);
1098
+ setFocus("variables");
1099
+ };
1100
+
1101
+ const jumpVariables = (position: "first" | "last") => {
1102
+ if (rows.length === 0) return;
1103
+ setSelectedVariableKey(position === "first" ? rows[0]?.key : rows[rows.length - 1]?.key);
1104
+ setFocus("variables");
1105
+ };
1106
+
1107
+ // ─── Commands ──────────────────────────────────────────────────────────────
1108
+
1109
+ const commands = useMemo<ReadonlyArray<AppCommand>>(
1110
+ () => [
1111
+ {
1112
+ id: "refresh",
1113
+ title: "Refresh vault",
1114
+ shortcut: "r",
1115
+ section: "APP",
1116
+ run: () => refresh(),
1117
+ },
1118
+ {
1119
+ id: "add",
1120
+ title: "Add variable",
1121
+ shortcut: "n",
1122
+ section: "EDIT",
1123
+ run: () => openEdit("add"),
1124
+ },
1125
+ {
1126
+ id: "file-create",
1127
+ title: "Create env file",
1128
+ shortcut: "N",
1129
+ section: "EDIT",
1130
+ run: openFileCreate,
1131
+ },
1132
+ {
1133
+ id: "help",
1134
+ title: "Show keyboard help",
1135
+ shortcut: "?",
1136
+ section: "APP",
1137
+ run: () => setModal(Modal.Help()),
1138
+ },
1139
+ {
1140
+ id: "edit",
1141
+ title: "Edit selected cell",
1142
+ shortcut: "e",
1143
+ section: "EDIT",
1144
+ ...(selectedVariableKey === undefined ? { disabledReason: "no key" } : {}),
1145
+ run: () => openEdit("update"),
1146
+ },
1147
+ {
1148
+ id: "delete",
1149
+ title: "Delete selected cell",
1150
+ shortcut: "d",
1151
+ section: "EDIT",
1152
+ ...(selectedVariable === undefined ? { disabledReason: "missing in env" } : {}),
1153
+ run: deleteSelected,
1154
+ },
1155
+ {
1156
+ id: "clear",
1157
+ title: "Clear filter",
1158
+ shortcut: "x",
1159
+ section: "SEARCH",
1160
+ run: () => {
1161
+ setQuery("");
1162
+ setMessage("filter cleared");
1163
+ },
1164
+ },
1165
+ { id: "quit", title: "Quit", shortcut: "q", section: "APP", run: () => renderer.destroy() },
1166
+ ],
1167
+ [selectedVariable, selectedVariableKey, selectedFile, selectedEnvironment, focus, modal],
1168
+ );
1169
+
1170
+ // ─── Keymap bindings ───────────────────────────────────────────────────────
1171
+
1172
+ useBindings(
1173
+ () => ({
1174
+ commands: [
1175
+ // Global
1176
+ {
1177
+ name: "app.quit",
1178
+ run: () => {
1179
+ renderer.destroy();
1180
+ },
1181
+ },
1182
+ {
1183
+ name: "app.forceQuit",
1184
+ run: () => {
1185
+ renderer.destroy();
1186
+ },
1187
+ },
1188
+ {
1189
+ name: "app.commands",
1190
+ run: () => {
1191
+ openCommands();
1192
+ },
1193
+ },
1194
+ {
1195
+ name: "app.help",
1196
+ run: () => {
1197
+ setModal(Modal.Help());
1198
+ },
1199
+ },
1200
+ {
1201
+ name: "app.search",
1202
+ run: () => {
1203
+ setPreviousFocus(focus);
1204
+ setFocus("filter");
1205
+ },
1206
+ },
1207
+ {
1208
+ name: "app.refresh",
1209
+ run: () => {
1210
+ refresh();
1211
+ },
1212
+ },
1213
+ {
1214
+ name: "app.clearFilter",
1215
+ run: () => {
1216
+ setQuery("");
1217
+ setMessage("filter cleared");
1218
+ },
1219
+ },
1220
+ // Navigation
1221
+ {
1222
+ name: "nav.tab",
1223
+ run: () => {
1224
+ setFocus((current: FocusTarget) => (current === "files" ? "variables" : "files"));
1225
+ },
1226
+ },
1227
+ {
1228
+ name: "nav.enter",
1229
+ run: () => {
1230
+ if (focus === "files") setFocus("variables");
1231
+ },
1232
+ },
1233
+ {
1234
+ name: "nav.groupLeft",
1235
+ run: () => {
1236
+ moveGroups(-1);
1237
+ },
1238
+ },
1239
+ {
1240
+ name: "nav.groupRight",
1241
+ run: () => {
1242
+ moveGroups(1);
1243
+ },
1244
+ },
1245
+ {
1246
+ name: "nav.envLeft",
1247
+ run: () => {
1248
+ moveEnvironment(-1);
1249
+ },
1250
+ },
1251
+ {
1252
+ name: "nav.envRight",
1253
+ run: () => {
1254
+ moveEnvironment(1);
1255
+ },
1256
+ },
1257
+ {
1258
+ name: "nav.up",
1259
+ run: () => {
1260
+ moveVariables(-1);
1261
+ },
1262
+ },
1263
+ {
1264
+ name: "nav.down",
1265
+ run: () => {
1266
+ moveVariables(1);
1267
+ },
1268
+ },
1269
+ {
1270
+ name: "nav.top",
1271
+ run: () => {
1272
+ jumpVariables("first");
1273
+ },
1274
+ },
1275
+ {
1276
+ name: "nav.bottom",
1277
+ run: () => {
1278
+ jumpVariables("last");
1279
+ },
1280
+ },
1281
+ // Edit actions
1282
+ {
1283
+ name: "edit.add",
1284
+ run: () => {
1285
+ openEdit("add");
1286
+ },
1287
+ },
1288
+ {
1289
+ name: "edit.update",
1290
+ run: () => {
1291
+ openEdit("update");
1292
+ },
1293
+ },
1294
+ {
1295
+ name: "edit.delete",
1296
+ run: () => {
1297
+ deleteSelected();
1298
+ },
1299
+ },
1300
+ {
1301
+ name: "edit.createFile",
1302
+ run: () => {
1303
+ openFileCreate();
1304
+ },
1305
+ },
1306
+ // Modal actions
1307
+ {
1308
+ name: "modal.close",
1309
+ run: () => {
1310
+ closeModal();
1311
+ },
1312
+ },
1313
+ {
1314
+ name: "modal.cmdUp",
1315
+ run: () => {
1316
+ if (modal._tag === "CommandPalette")
1317
+ setModal(
1318
+ Modal.CommandPalette({ query: modal.query, index: Math.max(0, modal.index - 1) }),
1319
+ );
1320
+ },
1321
+ },
1322
+ {
1323
+ name: "modal.cmdDown",
1324
+ run: () => {
1325
+ if (modal._tag === "CommandPalette") {
1326
+ const filtered = commands.filter((c) => commandMatches(c, modal.query));
1327
+ setModal(
1328
+ Modal.CommandPalette({
1329
+ query: modal.query,
1330
+ index: Math.min(filtered.length - 1, modal.index + 1),
1331
+ }),
1332
+ );
1333
+ }
1334
+ },
1335
+ },
1336
+ {
1337
+ name: "modal.cmdRun",
1338
+ run: () => {
1339
+ if (modal._tag === "CommandPalette") {
1340
+ const filtered = commands.filter((c) => commandMatches(c, modal.query));
1341
+ const cmd = filtered[Math.min(modal.index, filtered.length - 1)];
1342
+ if (cmd?.disabledReason) setMessage(cmd.disabledReason);
1343
+ else cmd?.run();
1344
+ setModal(Modal.None());
1345
+ setFocus(
1346
+ previousFocus === "edit-key" || previousFocus === "edit-value"
1347
+ ? "variables"
1348
+ : previousFocus,
1349
+ );
1350
+ }
1351
+ },
1352
+ },
1353
+ // Edit modal
1354
+ {
1355
+ name: "edit.tabField",
1356
+ run: () => {
1357
+ setFocus(focus === "edit-key" ? "edit-value" : "edit-key");
1358
+ },
1359
+ },
1360
+ {
1361
+ name: "edit.toggleEncrypt",
1362
+ run: () => {
1363
+ if (modal._tag === "Edit")
1364
+ setModal(Modal.Edit({ draft: { ...modal.draft, encrypt: !modal.draft.encrypt } }));
1365
+ },
1366
+ },
1367
+ {
1368
+ name: "edit.submit",
1369
+ run: () => {
1370
+ if (focus === "edit-key") setFocus("edit-value");
1371
+ else submitEdit();
1372
+ },
1373
+ },
1374
+ // File modal
1375
+ {
1376
+ name: "file.submit",
1377
+ run: () => {
1378
+ submitFileCreate();
1379
+ },
1380
+ },
1381
+ // Confirm delete
1382
+ {
1383
+ name: "confirm.yes",
1384
+ run: () => {
1385
+ confirmDeleteSelected();
1386
+ },
1387
+ },
1388
+ {
1389
+ name: "confirm.no",
1390
+ run: () => {
1391
+ closeModal();
1392
+ },
1393
+ },
1394
+ // Search
1395
+ {
1396
+ name: "search.close",
1397
+ run: () => {
1398
+ setFocus(previousFocus === "filter" ? "variables" : previousFocus);
1399
+ },
1400
+ },
1401
+ {
1402
+ name: "search.clear",
1403
+ run: () => {
1404
+ setQuery("");
1405
+ },
1406
+ },
1407
+ // Help
1408
+ {
1409
+ name: "help.close",
1410
+ run: () => {
1411
+ setModal(Modal.None());
1412
+ },
1413
+ },
1414
+ ],
1415
+ bindings: [
1416
+ // Global bindings (when no modal is open)
1417
+ ...(modal._tag === "None" && focus !== "filter"
1418
+ ? ([
1419
+ { key: "q", cmd: "app.quit" },
1420
+ { key: "escape", cmd: "app.quit" },
1421
+ { key: ":", cmd: "app.commands" },
1422
+ { key: "?", cmd: "app.help" },
1423
+ { key: "/", cmd: "app.search" },
1424
+ { key: "r", cmd: "app.refresh" },
1425
+ { key: "x", cmd: "app.clearFilter" },
1426
+ { key: "tab", cmd: "nav.tab" },
1427
+ { key: "enter", cmd: "nav.enter" },
1428
+ // Files focus
1429
+ ...(focus === "files"
1430
+ ? [
1431
+ { key: "h", cmd: "nav.groupLeft" },
1432
+ { key: "left", cmd: "nav.groupLeft" },
1433
+ { key: "l", cmd: "nav.groupRight" },
1434
+ { key: "right", cmd: "nav.groupRight" },
1435
+ ]
1436
+ : []),
1437
+ // Variables focus
1438
+ ...(focus === "variables"
1439
+ ? [
1440
+ { key: "h", cmd: "nav.envLeft" },
1441
+ { key: "left", cmd: "nav.envLeft" },
1442
+ { key: "l", cmd: "nav.envRight" },
1443
+ { key: "right", cmd: "nav.envRight" },
1444
+ { key: "k", cmd: "nav.up" },
1445
+ { key: "up", cmd: "nav.up" },
1446
+ { key: "j", cmd: "nav.down" },
1447
+ { key: "down", cmd: "nav.down" },
1448
+ { key: "g", cmd: "nav.top" },
1449
+ { key: "G", cmd: "nav.bottom" },
1450
+ ]
1451
+ : []),
1452
+ // Edit actions
1453
+ { key: "n", cmd: "edit.add" },
1454
+ { key: "N", cmd: "edit.createFile" },
1455
+ { key: "e", cmd: "edit.update" },
1456
+ { key: "d", cmd: "edit.delete" },
1457
+ { key: "delete", cmd: "edit.delete" },
1458
+ ] as const)
1459
+ : []),
1460
+ // Command palette bindings
1461
+ ...(modal._tag === "CommandPalette"
1462
+ ? ([
1463
+ { key: "escape", cmd: "modal.close" },
1464
+ { key: "k", cmd: "modal.cmdUp" },
1465
+ { key: "up", cmd: "modal.cmdUp" },
1466
+ { key: "j", cmd: "modal.cmdDown" },
1467
+ { key: "down", cmd: "modal.cmdDown" },
1468
+ { key: "enter", cmd: "modal.cmdRun" },
1469
+ ] as const)
1470
+ : []),
1471
+ // Edit modal bindings
1472
+ ...(modal._tag === "Edit"
1473
+ ? ([
1474
+ { key: "escape", cmd: "modal.close" },
1475
+ { key: "tab", cmd: "edit.tabField" },
1476
+ { key: "Y", cmd: "edit.toggleEncrypt" },
1477
+ { key: "enter", cmd: "edit.submit" },
1478
+ ] as const)
1479
+ : []),
1480
+ // File create modal bindings
1481
+ ...(modal._tag === "CreateFile"
1482
+ ? ([
1483
+ { key: "escape", cmd: "modal.close" },
1484
+ { key: "enter", cmd: "file.submit" },
1485
+ ] as const)
1486
+ : []),
1487
+ // Confirm delete bindings
1488
+ ...(modal._tag === "ConfirmDelete"
1489
+ ? ([
1490
+ { key: "y", cmd: "confirm.yes" },
1491
+ { key: "enter", cmd: "confirm.yes" },
1492
+ { key: "n", cmd: "confirm.no" },
1493
+ { key: "escape", cmd: "confirm.no" },
1494
+ ] as const)
1495
+ : []),
1496
+ // Help bindings
1497
+ ...(modal._tag === "Help"
1498
+ ? ([
1499
+ { key: "?", cmd: "help.close" },
1500
+ { key: "escape", cmd: "help.close" },
1501
+ ] as const)
1502
+ : []),
1503
+ // Search bindings
1504
+ ...(focus === "filter"
1505
+ ? ([
1506
+ { key: "escape", cmd: "search.close" },
1507
+ { key: "enter", cmd: "search.close" },
1508
+ { key: "ctrl+u", cmd: "search.clear" },
1509
+ ] as const)
1510
+ : []),
1511
+ // Ctrl+C always quits
1512
+ { key: "ctrl+c", cmd: "app.forceQuit" },
1513
+ ],
1514
+ }),
1515
+ [
1516
+ modal,
1517
+ focus,
1518
+ previousFocus,
1519
+ groups,
1520
+ rows,
1521
+ selectedGroup,
1522
+ selectedEnvironment,
1523
+ selectedVariableKey,
1524
+ selectedVariable,
1525
+ selectedFile,
1526
+ commands,
1527
+ ],
1528
+ );
1529
+
1530
+ // ─── Render ────────────────────────────────────────────────────────────────
1531
+
1532
+ const detailBesideMatrix = layout.width >= 120;
1533
+ const topHeight = 2;
1534
+ const appPadding = 2;
1535
+ const footerHeight = focus === "filter" ? 3 : 1;
1536
+ const bodyHeight = Math.max(1, layout.height - topHeight - footerHeight - appPadding);
1537
+ const detailHeight = Math.min(12, Math.max(7, Math.floor(bodyHeight * 0.34)));
1538
+ const matrixHeight = Math.max(8, bodyHeight - detailHeight);
1539
+ const contentWidth = Math.max(1, layout.width - 2);
1540
+
1541
+ return (
1542
+ <box
1543
+ width="100%"
1544
+ height="100%"
1545
+ position="relative"
1546
+ flexDirection="column"
1547
+ backgroundColor={colors.background}
1548
+ >
1549
+ {layout.isNarrow || layout.height < 24 ? (
1550
+ <box borderStyle="double" borderColor={colors.red} padding={1} flexDirection="column">
1551
+ <PlainLine text="terminal too small for Envault" fg={colors.red} bold />
1552
+ <PlainLine
1553
+ text={`current ${layout.width}×${layout.height} — need at least 80×24`}
1554
+ fg={colors.muted}
1555
+ />
1556
+ </box>
1557
+ ) : null}
1558
+ <Header />
1559
+ <ProjectTabs />
1560
+ <box flexDirection="column" gap={0} flexGrow={1}>
1561
+ <box width="100%" height={matrixHeight}>
1562
+ <EnvMatrix availableWidth={contentWidth} />
1563
+ </box>
1564
+ <box width="100%" height={detailHeight}>
1565
+ <DetailPanel compact={!detailBesideMatrix} />
1566
+ </box>
1567
+ </box>
1568
+ {focus === "filter" ? <SearchFooter /> : <Footer />}
1569
+ <CommandPalette commands={commands} />
1570
+ <EditModal />
1571
+ <FileModal />
1572
+ <ConfirmDeleteModal />
1573
+ <HelpModal />
1574
+ </box>
1575
+ );
1576
+ }
1577
+
1578
+ // ─── App Root ────────────────────────────────────────────────────────────────
1579
+
1580
+ export function App(props: AppProps) {
1581
+ const renderer = useRenderer();
1582
+ const keymap = useMemo(() => createDefaultOpenTuiKeymap(renderer), [renderer]);
1583
+
1584
+ return (
1585
+ <RegistryProvider>
1586
+ <KeymapProvider keymap={keymap}>
1587
+ <Dashboard {...props} />
1588
+ </KeymapProvider>
1589
+ </RegistryProvider>
1590
+ );
1591
+ }