@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,227 @@
1
+ import * as Atom from "effect/unstable/reactivity/Atom";
2
+ import path from "node:path";
3
+ import type { EnvFileInfo, EnvVariable, WorkspaceInfo } from "../domain.ts";
4
+ import { Modal } from "./modal.ts";
5
+ import type { EnvFileGroup, EnvTableRow, EnvVaultStats, FocusTarget } from "./types.ts";
6
+ import { matchesSearch, variableSearchText } from "./types.ts";
7
+
8
+ // ─── Core state ──────────────────────────────────────────────────────────────
9
+
10
+ export const rootAtom = Atom.make("");
11
+ export const decryptAtom = Atom.make(false);
12
+ export const workspaceAtom = Atom.make<WorkspaceInfo | undefined>(undefined);
13
+ export const envFilesAtom = Atom.make<ReadonlyArray<EnvFileInfo>>([]);
14
+ export const selectedPathAtom = Atom.make<string | undefined>(undefined);
15
+ export const selectedVariableKeyAtom = Atom.make<string | undefined>(undefined);
16
+ export const focusAtom = Atom.make<FocusTarget>("files");
17
+ export const previousFocusAtom = Atom.make<FocusTarget>("files");
18
+ export const queryAtom = Atom.make("");
19
+ export const busyAtom = Atom.make(false);
20
+ export const messageAtom = Atom.make<string | undefined>(undefined);
21
+
22
+ // ─── Modal state (tagged enum) ───────────────────────────────────────────────
23
+
24
+ export const modalAtom = Atom.make<Modal>(Modal.None());
25
+
26
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
27
+
28
+ const envFileNamePattern = /(^|\/)\.env(?<suffix>(?:\.[^/]+)*)$/;
29
+
30
+ const envLabel = (filePath: string): string => {
31
+ const name = path.basename(filePath);
32
+ if (name === ".env") return "base";
33
+ return name.slice(".env.".length).replaceAll(".", "/");
34
+ };
35
+
36
+ const groupLabel = (groupKey: string): string => (groupKey.length === 0 ? "root" : groupKey);
37
+
38
+ const groupKeyForFile = (filePath: string): string => {
39
+ const match = envFileNamePattern.exec(filePath);
40
+ if (match === null) return path.dirname(filePath) === "." ? "" : path.dirname(filePath);
41
+ const prefix = filePath.slice(0, match.index);
42
+ return prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
43
+ };
44
+
45
+ const variableState = (variable: EnvVariable | undefined): string => {
46
+ if (variable === undefined) return "missing";
47
+ return variable.encrypted ? "encrypted" : "plain";
48
+ };
49
+
50
+ // ─── Derived atoms ───────────────────────────────────────────────────────────
51
+
52
+ export const statsAtom = Atom.make((get): EnvVaultStats => {
53
+ const files = get(envFilesAtom);
54
+ return {
55
+ files: files.length,
56
+ variables: files.reduce((sum, file) => sum + file.variables.length, 0),
57
+ encryptedFiles: files.filter((file) => file.encrypted).length,
58
+ withKeys: files.filter((file) => file.hasKeys).length,
59
+ };
60
+ });
61
+
62
+ export const visibleEnvFilesAtom = Atom.make((get) => {
63
+ const query = get(queryAtom).trim().toLowerCase();
64
+ const files = get(envFilesAtom);
65
+ if (query.length === 0) return files;
66
+ return files.filter((file) => {
67
+ const text = [
68
+ file.path,
69
+ file.encrypted ? "encrypted" : "plain",
70
+ file.hasKeys ? "keys" : "no-keys",
71
+ file.variables.map(variableSearchText).join(" "),
72
+ ]
73
+ .join(" ")
74
+ .toLowerCase();
75
+ return matchesSearch(
76
+ { text, path: file.path, state: file.encrypted ? "encrypted" : "plain" },
77
+ query,
78
+ );
79
+ });
80
+ });
81
+
82
+ export const envFileGroupsAtom = Atom.make((get): ReadonlyArray<EnvFileGroup> => {
83
+ const files = get(envFilesAtom);
84
+ const groups = new Map<string, Array<EnvFileInfo>>();
85
+
86
+ for (const file of files) {
87
+ const key = groupKeyForFile(file.path);
88
+ groups.set(key, [...(groups.get(key) ?? []), file]);
89
+ }
90
+
91
+ return [...groups.entries()]
92
+ .map(([key, groupFiles]) => {
93
+ const sortedFiles = [...groupFiles].sort((left, right) =>
94
+ left.path.localeCompare(right.path),
95
+ );
96
+ return {
97
+ key,
98
+ label: groupLabel(key),
99
+ files: sortedFiles,
100
+ environments: sortedFiles.map((file) => ({
101
+ id: file.path,
102
+ label: envLabel(file.path),
103
+ path: file.path,
104
+ file,
105
+ })),
106
+ };
107
+ })
108
+ .sort((left, right) => left.label.localeCompare(right.label));
109
+ });
110
+
111
+ export const visibleEnvFileGroupsAtom = Atom.make((get) => {
112
+ const query = get(queryAtom);
113
+ const groups = get(envFileGroupsAtom);
114
+ if (query.trim().length === 0) return groups;
115
+
116
+ return groups.filter((group) =>
117
+ matchesSearch(
118
+ {
119
+ text: [
120
+ group.label,
121
+ group.files.map((file) => file.path).join(" "),
122
+ group.files.flatMap((file) => file.variables.map(variableSearchText)).join(" "),
123
+ ].join(" "),
124
+ path: group.files.map((file) => file.path).join(" "),
125
+ env: group.environments.map((environment) => environment.label).join(" "),
126
+ },
127
+ query,
128
+ ),
129
+ );
130
+ });
131
+
132
+ export const selectedFileAtom = Atom.make((get) => {
133
+ const selectedPath = get(selectedPathAtom);
134
+ const environment = get(selectedEnvironmentAtom);
135
+ const files = get(envFilesAtom);
136
+ return environment?.file ?? files.find((file) => file.path === selectedPath) ?? files[0];
137
+ });
138
+
139
+ export const selectedGroupAtom = Atom.make((get) => {
140
+ const selectedPath = get(selectedPathAtom);
141
+ const visibleGroups = get(visibleEnvFileGroupsAtom);
142
+ return (
143
+ visibleGroups.find((group) => group.files.some((file) => file.path === selectedPath)) ??
144
+ visibleGroups[0]
145
+ );
146
+ });
147
+
148
+ export const selectedEnvironmentAtom = Atom.make((get) => {
149
+ const selectedPath = get(selectedPathAtom);
150
+ const group = get(selectedGroupAtom);
151
+ return (
152
+ group?.environments.find((environment) => environment.path === selectedPath) ??
153
+ group?.environments[0]
154
+ );
155
+ });
156
+
157
+ export const envTableRowsAtom = Atom.make((get): ReadonlyArray<EnvTableRow> => {
158
+ const group = get(selectedGroupAtom);
159
+ const query = get(queryAtom);
160
+ if (group === undefined) return [];
161
+
162
+ const keys = [
163
+ ...new Set(group.files.flatMap((file) => file.variables.map((variable) => variable.key))),
164
+ ].sort((left, right) => left.localeCompare(right));
165
+
166
+ return keys
167
+ .map((key) => {
168
+ const cells = group.environments.map((environment) => ({
169
+ environment,
170
+ variable: environment.file.variables.find((variable) => variable.key === key),
171
+ }));
172
+ return {
173
+ key,
174
+ presentCount: cells.filter((cell) => cell.variable !== undefined).length,
175
+ cells,
176
+ };
177
+ })
178
+ .filter((row) =>
179
+ matchesSearch(
180
+ {
181
+ text: [
182
+ row.key,
183
+ row.cells.map((cell) => cell.environment.label).join(" "),
184
+ row.cells.map((cell) => cell.environment.path).join(" "),
185
+ row.cells
186
+ .map((cell) =>
187
+ cell.variable === undefined ? "missing" : variableSearchText(cell.variable),
188
+ )
189
+ .join(" "),
190
+ ].join(" "),
191
+ key: row.key,
192
+ value: row.cells
193
+ .map((cell) => cell.variable?.value ?? cell.variable?.rawValue ?? "")
194
+ .join(" "),
195
+ env: row.cells.map((cell) => cell.environment.label).join(" "),
196
+ path: row.cells.map((cell) => cell.environment.path).join(" "),
197
+ state: row.cells.map((cell) => variableState(cell.variable)).join(" "),
198
+ },
199
+ query,
200
+ ),
201
+ );
202
+ });
203
+
204
+ export const visibleVariablesAtom = Atom.make((get) => {
205
+ const file = get(selectedFileAtom);
206
+ const query = get(queryAtom).trim().toLowerCase();
207
+ const variables = file?.variables ?? [];
208
+ if (query.length === 0) return variables;
209
+ return variables.filter((variable) =>
210
+ matchesSearch(
211
+ {
212
+ text: variableSearchText(variable),
213
+ key: variable.key,
214
+ value: `${variable.value} ${variable.rawValue}`,
215
+ state: variable.encrypted ? "encrypted" : "plain",
216
+ },
217
+ query,
218
+ ),
219
+ );
220
+ });
221
+
222
+ export const selectedVariableAtom = Atom.make((get) => {
223
+ const selectedKey = get(selectedVariableKeyAtom);
224
+ const variables = get(visibleVariablesAtom);
225
+ if (selectedKey !== undefined) return variables.find((variable) => variable.key === selectedKey);
226
+ return variables[0];
227
+ });
@@ -0,0 +1,58 @@
1
+ /** Semantic color palette — warm minimalism with amber/gold accents on deep slate */
2
+ export const colors = {
3
+ // Surfaces
4
+ background: "#070a0d",
5
+ panel: "#0e151b",
6
+ panelAlt: "#111b23",
7
+ panelDeep: "#091015",
8
+
9
+ // Borders & separators
10
+ line: "#26333d",
11
+ lineHot: "#d8b45d",
12
+ separator: "#1e2a33",
13
+
14
+ // Text hierarchy
15
+ text: "#dce7ef",
16
+ muted: "#8a9aa8",
17
+ dim: "#53616d",
18
+
19
+ // Accent & interactive
20
+ accent: "#d8b45d",
21
+ gold: "#d8b45d",
22
+ link: "#66d9ef",
23
+
24
+ // Semantic status
25
+ info: "#66d9ef",
26
+ cyan: "#66d9ef",
27
+ success: "#9ad981",
28
+ green: "#9ad981",
29
+ danger: "#ff6f7d",
30
+ red: "#ff6f7d",
31
+ warning: "#ffb86c",
32
+ orange: "#ffb86c",
33
+
34
+ // Selection states
35
+ selectedBg: "#1b2a33",
36
+ selectedText: "#fff2c4",
37
+ hoverBg: "#141f27",
38
+
39
+ // Extras
40
+ count: "#d8b45d",
41
+ inlineCode: "#a6e3a1",
42
+ encrypted: "#ffb86c",
43
+ } as const;
44
+
45
+ /** Mix two hex colors at a given ratio (0–1, 0 = color1, 1 = color2) */
46
+ export const mixHex = (color1: string, color2: string, ratio: number): string => {
47
+ const parse = (hex: string) => {
48
+ const h = hex.replace("#", "");
49
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
50
+ };
51
+ const [r1, g1, b1] = parse(color1);
52
+ const [r2, g2, b2] = parse(color2);
53
+ const mix = (a: number, b: number) => Math.round(a + (b - a) * ratio);
54
+ const r = mix(r1!, r2!).toString(16).padStart(2, "0");
55
+ const g = mix(g1!, g2!).toString(16).padStart(2, "0");
56
+ const b = mix(b1!, b2!).toString(16).padStart(2, "0");
57
+ return `#${r}${g}${b}`;
58
+ };
@@ -0,0 +1,15 @@
1
+ import { Data } from "effect";
2
+ import type { EditDraft } from "./types.ts";
3
+
4
+ export type Modal = Data.TaggedEnum<{
5
+ None: {};
6
+ Edit: { draft: EditDraft };
7
+ CreateFile: { path: string };
8
+ ConfirmDelete: { path: string; key: string };
9
+ CommandPalette: { query: string; index: number };
10
+ Help: {};
11
+ }>;
12
+
13
+ export const Modal = Data.taggedEnum<Modal>();
14
+
15
+ export const isModalOpen = (modal: Modal): boolean => modal._tag !== "None";
@@ -0,0 +1,168 @@
1
+ /** @jsxImportSource @opentui/react */
2
+ import { TextAttributes } from "@opentui/core";
3
+ import type { ReactNode } from "react";
4
+ import { colors } from "./colors.ts";
5
+
6
+ export function TextLine(props: {
7
+ readonly children: ReactNode;
8
+ readonly backgroundColor?: string | undefined;
9
+ readonly width?: number | "auto" | `${number}%`;
10
+ }) {
11
+ return (
12
+ <box height={1} {...(props.width === undefined ? {} : { width: props.width })}>
13
+ <text
14
+ wrapMode="none"
15
+ truncate
16
+ fg={colors.text}
17
+ {...(props.backgroundColor === undefined ? {} : { bg: props.backgroundColor })}
18
+ >
19
+ {props.children}
20
+ </text>
21
+ </box>
22
+ );
23
+ }
24
+
25
+ export function PlainLine(props: {
26
+ readonly text: string;
27
+ readonly fg?: string | undefined;
28
+ readonly bold?: boolean | undefined;
29
+ readonly backgroundColor?: string | undefined;
30
+ }) {
31
+ return (
32
+ <TextLine
33
+ {...(props.backgroundColor === undefined ? {} : { backgroundColor: props.backgroundColor })}
34
+ >
35
+ <span fg={props.fg ?? colors.text} attributes={props.bold === true ? TextAttributes.BOLD : 0}>
36
+ {props.text}
37
+ </span>
38
+ </TextLine>
39
+ );
40
+ }
41
+
42
+ export function Badge(props: { readonly label: string; readonly fg?: string | undefined }) {
43
+ return (
44
+ <span fg={props.fg ?? colors.accent} attributes={TextAttributes.BOLD}>
45
+ {props.label}
46
+ </span>
47
+ );
48
+ }
49
+
50
+ export function HintRow(props: {
51
+ readonly items: ReadonlyArray<{ readonly key: string; readonly label: string }>;
52
+ readonly compact?: boolean | undefined;
53
+ }) {
54
+ return (
55
+ <TextLine>
56
+ {props.items.map((item, index) => (
57
+ <span key={`${item.key}-${item.label}`}>
58
+ {index > 0 ? <span fg={colors.dim}>{props.compact ? " " : " · "}</span> : null}
59
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>
60
+ {item.key}
61
+ </span>
62
+ <span fg={colors.muted}>{` ${item.label}`}</span>
63
+ </span>
64
+ ))}
65
+ </TextLine>
66
+ );
67
+ }
68
+
69
+ export function SectionTitle(props: {
70
+ readonly title: string;
71
+ readonly subtitle?: string | undefined;
72
+ readonly fg?: string | undefined;
73
+ }) {
74
+ return (
75
+ <TextLine>
76
+ <span fg={props.fg ?? colors.accent} attributes={TextAttributes.BOLD}>
77
+ {props.title}
78
+ </span>
79
+ {props.subtitle !== undefined ? <span fg={colors.dim}> {props.subtitle}</span> : null}
80
+ </TextLine>
81
+ );
82
+ }
83
+
84
+ export function StatusBadge(props: {
85
+ readonly label: string;
86
+ readonly active: boolean;
87
+ readonly activeFg?: string | undefined;
88
+ }) {
89
+ return (
90
+ <span fg={props.active ? (props.activeFg ?? colors.success) : colors.muted}>{props.label}</span>
91
+ );
92
+ }
93
+
94
+ export function Panel(props: {
95
+ readonly title: string;
96
+ readonly focused?: boolean | undefined;
97
+ readonly children: ReactNode;
98
+ readonly width?: number | `${number}%` | undefined;
99
+ readonly height?: number | `${number}%` | undefined;
100
+ }) {
101
+ const focused = props.focused === true;
102
+ return (
103
+ <box
104
+ borderStyle={focused ? "double" : "single"}
105
+ borderColor={focused ? colors.accent : colors.line}
106
+ backgroundColor={colors.panel}
107
+ title={focused ? `◆ ${props.title}` : props.title}
108
+ padding={1}
109
+ flexDirection="column"
110
+ gap={0}
111
+ {...(props.width === undefined ? {} : { width: props.width })}
112
+ {...(props.height === undefined ? {} : { height: props.height })}
113
+ >
114
+ {props.children}
115
+ </box>
116
+ );
117
+ }
118
+
119
+ export function ScrollPanel(props: {
120
+ readonly title: string;
121
+ readonly focused?: boolean | undefined;
122
+ readonly children: ReactNode;
123
+ readonly width?: number | `${number}%` | undefined;
124
+ readonly height?: number | `${number}%` | undefined;
125
+ }) {
126
+ const focused = props.focused === true;
127
+ return (
128
+ <box
129
+ borderStyle={focused ? "double" : "single"}
130
+ borderColor={focused ? colors.accent : colors.line}
131
+ backgroundColor={colors.panel}
132
+ title={focused ? `◆ ${props.title}` : props.title}
133
+ padding={1}
134
+ flexDirection="column"
135
+ {...(props.width === undefined ? {} : { width: props.width })}
136
+ {...(props.height === undefined ? {} : { height: props.height })}
137
+ >
138
+ <scrollbox height="100%" focused={focused} scrollY viewportCulling>
139
+ {props.children}
140
+ </scrollbox>
141
+ </box>
142
+ );
143
+ }
144
+
145
+ export function Spinner(props: { readonly label?: string | undefined }) {
146
+ return (
147
+ <TextLine>
148
+ <span fg={colors.warning}>◐</span>
149
+ <span fg={colors.muted}> {props.label ?? "loading…"}</span>
150
+ </TextLine>
151
+ );
152
+ }
153
+
154
+ export function EmptyState(props: {
155
+ readonly message: string;
156
+ readonly hint?: string | undefined;
157
+ }) {
158
+ return (
159
+ <box flexDirection="column" justifyContent="center" alignItems="center" height="100%">
160
+ <PlainLine text={props.message} fg={colors.muted} />
161
+ {props.hint !== undefined ? <PlainLine text={props.hint} fg={colors.dim} /> : null}
162
+ </box>
163
+ );
164
+ }
165
+
166
+ export function SeparatorLine() {
167
+ return <box height={1} backgroundColor={colors.separator} />;
168
+ }
@@ -0,0 +1,68 @@
1
+ /** @jsxImportSource @opentui/react */
2
+ import { CliRenderEvents, createCliRenderer } from "@opentui/core";
3
+ import { createRoot } from "@opentui/react";
4
+ import { Effect } from "effect";
5
+ import { EnvFileService } from "../env-file.ts";
6
+ import { detectWorkspace } from "../workspace.ts";
7
+ import { App } from "./App.tsx";
8
+
9
+ export type RunTuiOptions = {
10
+ readonly root: string;
11
+ readonly decrypt: boolean;
12
+ };
13
+
14
+ export const runTui = Effect.fn("tui.runTui")(function* ({ root, decrypt }: RunTuiOptions) {
15
+ const envFiles = yield* EnvFileService;
16
+ const renderer = yield* Effect.promise(() => createCliRenderer({ exitOnCtrlC: true }));
17
+ const reactRoot = createRoot(renderer);
18
+
19
+ const loadWorkspace = () => detectWorkspace(root);
20
+ const loadEnvFiles = () => envFiles.scan({ root, decrypt, maxDepth: 8 });
21
+ const setVariable = ({
22
+ path,
23
+ key,
24
+ value,
25
+ encrypt,
26
+ }: {
27
+ readonly path: string;
28
+ readonly key: string;
29
+ readonly value: string;
30
+ readonly encrypt: boolean;
31
+ }) => envFiles.setVariable({ root, path, key, value, decrypt, encrypt });
32
+ const createFile = ({ path }: { readonly path: string }) =>
33
+ envFiles.createFile({ root, path, decrypt });
34
+ const deleteVariable = ({ path, key }: { readonly path: string; readonly key: string }) =>
35
+ envFiles.deleteVariable({ root, path, key, decrypt });
36
+
37
+ yield* Effect.acquireUseRelease(
38
+ Effect.sync(() => {
39
+ reactRoot.render(
40
+ <App
41
+ root={root}
42
+ decrypt={decrypt}
43
+ loadWorkspace={loadWorkspace}
44
+ loadEnvFiles={loadEnvFiles}
45
+ setVariable={setVariable}
46
+ createFile={createFile}
47
+ deleteVariable={deleteVariable}
48
+ />,
49
+ );
50
+ return renderer;
51
+ }),
52
+ (liveRenderer) =>
53
+ Effect.callback<void>((resume) => {
54
+ if (liveRenderer.isDestroyed) {
55
+ resume(Effect.void);
56
+ return;
57
+ }
58
+ const onDestroy = () => resume(Effect.void);
59
+ liveRenderer.once(CliRenderEvents.DESTROY, onDestroy);
60
+ return Effect.sync(() => liveRenderer.off(CliRenderEvents.DESTROY, onDestroy));
61
+ }),
62
+ (liveRenderer) =>
63
+ Effect.sync(() => {
64
+ reactRoot.unmount();
65
+ if (!liveRenderer.isDestroyed) liveRenderer.destroy();
66
+ }),
67
+ );
68
+ });
@@ -0,0 +1,127 @@
1
+ import type { Effect } from "effect";
2
+ import type { EnvFileError } from "../env-file.ts";
3
+ import type { EnvFileInfo, EnvVariable, WorkspaceInfo } from "../domain.ts";
4
+
5
+ export type EnvEnvironment = {
6
+ readonly id: string;
7
+ readonly label: string;
8
+ readonly path: string;
9
+ readonly file: EnvFileInfo;
10
+ };
11
+
12
+ export type EnvFileGroup = {
13
+ readonly key: string;
14
+ readonly label: string;
15
+ readonly files: ReadonlyArray<EnvFileInfo>;
16
+ readonly environments: ReadonlyArray<EnvEnvironment>;
17
+ };
18
+
19
+ export type EnvTableCell = {
20
+ readonly environment: EnvEnvironment;
21
+ readonly variable: EnvVariable | undefined;
22
+ };
23
+
24
+ export type EnvTableRow = {
25
+ readonly key: string;
26
+ readonly presentCount: number;
27
+ readonly cells: ReadonlyArray<EnvTableCell>;
28
+ };
29
+
30
+ export type FocusTarget =
31
+ | "filter"
32
+ | "files"
33
+ | "variables"
34
+ | "edit-key"
35
+ | "edit-value"
36
+ | "file-path";
37
+
38
+ export type EditMode = "add" | "update";
39
+
40
+ export type EditDraft = {
41
+ readonly mode: EditMode;
42
+ readonly key: string;
43
+ readonly value: string;
44
+ readonly encrypt: boolean;
45
+ };
46
+
47
+ export type ConfirmDelete = {
48
+ readonly path: string;
49
+ readonly key: string;
50
+ };
51
+
52
+ export type AppCommand = {
53
+ readonly id: string;
54
+ readonly title: string;
55
+ readonly shortcut?: string | undefined;
56
+ readonly section?: string | undefined;
57
+ readonly disabledReason?: string | undefined;
58
+ readonly run: () => void;
59
+ };
60
+
61
+ export type AppProps = {
62
+ readonly root: string;
63
+ readonly decrypt: boolean;
64
+ readonly loadWorkspace: () => Effect.Effect<WorkspaceInfo, EnvFileError>;
65
+ readonly loadEnvFiles: () => Effect.Effect<ReadonlyArray<EnvFileInfo>, EnvFileError>;
66
+ readonly setVariable: (request: {
67
+ readonly path: string;
68
+ readonly key: string;
69
+ readonly value: string;
70
+ readonly encrypt: boolean;
71
+ }) => Effect.Effect<EnvFileInfo, EnvFileError>;
72
+ readonly createFile: (request: {
73
+ readonly path: string;
74
+ }) => Effect.Effect<EnvFileInfo, EnvFileError>;
75
+ readonly deleteVariable: (request: {
76
+ readonly path: string;
77
+ readonly key: string;
78
+ }) => Effect.Effect<EnvFileInfo, EnvFileError>;
79
+ };
80
+
81
+ export type EnvVaultStats = {
82
+ readonly files: number;
83
+ readonly variables: number;
84
+ readonly encryptedFiles: number;
85
+ readonly withKeys: number;
86
+ };
87
+
88
+ export const variableSearchText = (variable: EnvVariable): string =>
89
+ [variable.key, variable.value, variable.rawValue, variable.encrypted ? "encrypted" : "plain"]
90
+ .join(" ")
91
+ .toLowerCase();
92
+
93
+ type SearchFields = {
94
+ readonly text: string;
95
+ readonly path?: string | undefined;
96
+ readonly key?: string | undefined;
97
+ readonly value?: string | undefined;
98
+ readonly env?: string | undefined;
99
+ readonly state?: string | undefined;
100
+ };
101
+
102
+ const searchable = (value: string | undefined): string => value?.toLowerCase() ?? "";
103
+
104
+ export const searchTokens = (query: string): ReadonlyArray<string> =>
105
+ query
106
+ .trim()
107
+ .toLowerCase()
108
+ .split(/\s+/)
109
+ .filter((token) => token.length > 0);
110
+
111
+ export const matchesSearch = (fields: SearchFields, query: string): boolean => {
112
+ const tokens = searchTokens(query);
113
+ if (tokens.length === 0) return true;
114
+
115
+ const text = searchable(fields.text);
116
+ return tokens.every((token) => {
117
+ const separator = token.indexOf(":");
118
+ if (separator > 0) {
119
+ const field = token.slice(0, separator) as keyof Omit<SearchFields, "text">;
120
+ const value = token.slice(separator + 1);
121
+ if (["path", "key", "value", "env", "state"].includes(field)) {
122
+ return searchable(fields[field]).includes(value);
123
+ }
124
+ }
125
+ return text.includes(token);
126
+ });
127
+ };