@aliou/pi-utils-settings 0.2.0 → 0.3.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/README.md +2 -0
- package/components/fuzzy-selector.ts +176 -0
- package/components/path-array-editor.ts +447 -0
- package/components/sectioned-settings.ts +32 -9
- package/index.ts +8 -0
- package/package.json +2 -2
- package/settings-command.ts +1 -1
package/README.md
CHANGED
|
@@ -134,6 +134,7 @@ interface ConfigStore<TConfig, TResolved> {
|
|
|
134
134
|
|
|
135
135
|
- **SectionedSettings**: Grouped settings list with search filtering and cursor preservation on update.
|
|
136
136
|
- **ArrayEditor**: String array editor with add/remove/reorder.
|
|
137
|
+
- **PathArrayEditor**: Path-focused array editor with Tab completion in add/edit mode.
|
|
137
138
|
|
|
138
139
|
### Helpers
|
|
139
140
|
|
|
@@ -148,5 +149,6 @@ export { ConfigLoader, type ConfigStore, type Migration } from "./config-loader"
|
|
|
148
149
|
export { registerSettingsCommand, type SettingsCommandOptions } from "./settings-command";
|
|
149
150
|
export { SectionedSettings, type SectionedSettingsOptions, type SettingsSection } from "./components/sectioned-settings";
|
|
150
151
|
export { ArrayEditor, type ArrayEditorOptions } from "./components/array-editor";
|
|
152
|
+
export { PathArrayEditor, type PathArrayEditorOptions } from "./components/path-array-editor";
|
|
151
153
|
export { setNestedValue, getNestedValue, displayToStorageValue } from "./helpers";
|
|
152
154
|
```
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { Component, SettingsListTheme } from "@mariozechner/pi-tui";
|
|
2
|
+
import {
|
|
3
|
+
fuzzyFilter,
|
|
4
|
+
Input,
|
|
5
|
+
Key,
|
|
6
|
+
matchesKey,
|
|
7
|
+
truncateToWidth,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
} from "@mariozechner/pi-tui";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A submenu component for selecting one item from a large list using fuzzy search.
|
|
13
|
+
*
|
|
14
|
+
* Features:
|
|
15
|
+
* - Type to filter items via fuzzy search
|
|
16
|
+
* - Navigate with up/down arrows
|
|
17
|
+
* - Enter to select
|
|
18
|
+
* - Esc to cancel
|
|
19
|
+
* - Shows highlighted item clearly
|
|
20
|
+
* - Scrolls when items exceed maxVisible
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface FuzzySelectorOptions {
|
|
24
|
+
label: string;
|
|
25
|
+
items: string[];
|
|
26
|
+
currentValue?: string; // pre-select this item if present
|
|
27
|
+
theme: SettingsListTheme;
|
|
28
|
+
onSelect: (value: string) => void;
|
|
29
|
+
onDone: () => void;
|
|
30
|
+
maxVisible?: number; // default 10
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class FuzzySelector implements Component {
|
|
34
|
+
private allItems: string[];
|
|
35
|
+
private filteredItems: string[];
|
|
36
|
+
private label: string;
|
|
37
|
+
private theme: SettingsListTheme;
|
|
38
|
+
private onSelect: (value: string) => void;
|
|
39
|
+
private onDone: () => void;
|
|
40
|
+
private selectedIndex = 0;
|
|
41
|
+
private maxVisible: number;
|
|
42
|
+
private input: Input;
|
|
43
|
+
private query = "";
|
|
44
|
+
|
|
45
|
+
constructor(options: FuzzySelectorOptions) {
|
|
46
|
+
this.allItems = [...options.items];
|
|
47
|
+
this.filteredItems = [...this.allItems];
|
|
48
|
+
this.label = options.label;
|
|
49
|
+
this.theme = options.theme;
|
|
50
|
+
this.onSelect = options.onSelect;
|
|
51
|
+
this.onDone = options.onDone;
|
|
52
|
+
this.maxVisible = options.maxVisible ?? 10;
|
|
53
|
+
this.input = new Input();
|
|
54
|
+
|
|
55
|
+
// Pre-select currentValue if provided and exists in the list
|
|
56
|
+
if (options.currentValue) {
|
|
57
|
+
const index = this.allItems.indexOf(options.currentValue);
|
|
58
|
+
if (index !== -1) {
|
|
59
|
+
this.selectedIndex = index;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.input.onSubmit = () => {
|
|
64
|
+
this.selectCurrent();
|
|
65
|
+
};
|
|
66
|
+
this.input.onEscape = () => {
|
|
67
|
+
this.onDone();
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private selectCurrent() {
|
|
72
|
+
if (this.filteredItems.length === 0) return;
|
|
73
|
+
const selected = this.filteredItems[this.selectedIndex];
|
|
74
|
+
if (selected) {
|
|
75
|
+
this.onSelect(selected);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private updateFilter() {
|
|
80
|
+
this.query = this.input.getValue();
|
|
81
|
+
if (this.query.trim() === "") {
|
|
82
|
+
this.filteredItems = [...this.allItems];
|
|
83
|
+
} else {
|
|
84
|
+
this.filteredItems = fuzzyFilter(
|
|
85
|
+
this.allItems,
|
|
86
|
+
this.query,
|
|
87
|
+
(item) => item,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
// Reset cursor to 0 when filtering
|
|
91
|
+
this.selectedIndex = 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
invalidate() {}
|
|
95
|
+
|
|
96
|
+
render(width: number): string[] {
|
|
97
|
+
const lines: string[] = [];
|
|
98
|
+
|
|
99
|
+
// Header
|
|
100
|
+
lines.push(this.theme.label(` ${this.label}`, true));
|
|
101
|
+
lines.push("");
|
|
102
|
+
|
|
103
|
+
// Input field
|
|
104
|
+
lines.push(this.theme.hint(" Search:"));
|
|
105
|
+
lines.push(` ${this.input.render(width - 4).join("")}`);
|
|
106
|
+
lines.push("");
|
|
107
|
+
|
|
108
|
+
// List of filtered items
|
|
109
|
+
if (this.filteredItems.length === 0) {
|
|
110
|
+
lines.push(this.theme.hint(" (no matches)"));
|
|
111
|
+
} else {
|
|
112
|
+
const startIndex = Math.max(
|
|
113
|
+
0,
|
|
114
|
+
Math.min(
|
|
115
|
+
this.selectedIndex - Math.floor(this.maxVisible / 2),
|
|
116
|
+
this.filteredItems.length - this.maxVisible,
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
const endIndex = Math.min(
|
|
120
|
+
startIndex + this.maxVisible,
|
|
121
|
+
this.filteredItems.length,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
125
|
+
const item = this.filteredItems[i];
|
|
126
|
+
if (!item) continue;
|
|
127
|
+
const isSelected = i === this.selectedIndex;
|
|
128
|
+
const prefix = isSelected ? this.theme.cursor : " ";
|
|
129
|
+
const prefixWidth = visibleWidth(prefix);
|
|
130
|
+
const maxItemWidth = width - prefixWidth - 2;
|
|
131
|
+
const text = this.theme.value(
|
|
132
|
+
truncateToWidth(item, maxItemWidth, ""),
|
|
133
|
+
isSelected,
|
|
134
|
+
);
|
|
135
|
+
lines.push(prefix + text);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Show count indicator when scrolling
|
|
139
|
+
if (startIndex > 0 || endIndex < this.filteredItems.length) {
|
|
140
|
+
lines.push(
|
|
141
|
+
this.theme.hint(
|
|
142
|
+
` (${this.selectedIndex + 1}/${this.filteredItems.length})`,
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
lines.push("");
|
|
149
|
+
lines.push(this.theme.hint(" Type to search · Enter: select · Esc: back"));
|
|
150
|
+
|
|
151
|
+
return lines;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
handleInput(data: string) {
|
|
155
|
+
// Navigation and selection
|
|
156
|
+
if (matchesKey(data, Key.up)) {
|
|
157
|
+
if (this.filteredItems.length === 0) return;
|
|
158
|
+
this.selectedIndex =
|
|
159
|
+
this.selectedIndex === 0
|
|
160
|
+
? this.filteredItems.length - 1
|
|
161
|
+
: this.selectedIndex - 1;
|
|
162
|
+
} else if (matchesKey(data, Key.down)) {
|
|
163
|
+
if (this.filteredItems.length === 0) return;
|
|
164
|
+
this.selectedIndex =
|
|
165
|
+
this.selectedIndex === this.filteredItems.length - 1
|
|
166
|
+
? 0
|
|
167
|
+
: this.selectedIndex + 1;
|
|
168
|
+
} else if (matchesKey(data, Key.escape)) {
|
|
169
|
+
this.onDone();
|
|
170
|
+
} else {
|
|
171
|
+
// Delegate to input handler
|
|
172
|
+
this.input.handleInput(data);
|
|
173
|
+
this.updateFilter();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { Component, SettingsListTheme } from "@mariozechner/pi-tui";
|
|
5
|
+
import {
|
|
6
|
+
Input,
|
|
7
|
+
Key,
|
|
8
|
+
matchesKey,
|
|
9
|
+
truncateToWidth,
|
|
10
|
+
visibleWidth,
|
|
11
|
+
} from "@mariozechner/pi-tui";
|
|
12
|
+
|
|
13
|
+
export interface PathArrayEditorOptions {
|
|
14
|
+
label: string;
|
|
15
|
+
items: string[];
|
|
16
|
+
theme: SettingsListTheme;
|
|
17
|
+
onSave: (items: string[]) => void;
|
|
18
|
+
onDone: () => void;
|
|
19
|
+
/** Max visible items before scrolling */
|
|
20
|
+
maxVisible?: number;
|
|
21
|
+
/** Base directory for resolving relative paths. Default: process.cwd() */
|
|
22
|
+
baseDir?: string;
|
|
23
|
+
/** Optional validation hook. Return error message to reject submit. */
|
|
24
|
+
validatePath?: (value: string) => string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Array editor specialized for filesystem paths.
|
|
29
|
+
*
|
|
30
|
+
* Same UX as ArrayEditor, plus Tab completion in add/edit mode.
|
|
31
|
+
*/
|
|
32
|
+
export class PathArrayEditor implements Component {
|
|
33
|
+
private items: string[];
|
|
34
|
+
private label: string;
|
|
35
|
+
private theme: SettingsListTheme;
|
|
36
|
+
private onSave: (items: string[]) => void;
|
|
37
|
+
private onDone: () => void;
|
|
38
|
+
private selectedIndex = 0;
|
|
39
|
+
private maxVisible: number;
|
|
40
|
+
private mode: "list" | "add" | "edit" = "list";
|
|
41
|
+
private input: Input;
|
|
42
|
+
private editIndex = -1;
|
|
43
|
+
private baseDir: string;
|
|
44
|
+
private completions: string[] = [];
|
|
45
|
+
private completionIndex = 0;
|
|
46
|
+
private readonly validatePath?: (value: string) => string | null;
|
|
47
|
+
private inputError: string | null = null;
|
|
48
|
+
|
|
49
|
+
constructor(options: PathArrayEditorOptions) {
|
|
50
|
+
this.items = [...options.items];
|
|
51
|
+
this.label = options.label;
|
|
52
|
+
this.theme = options.theme;
|
|
53
|
+
this.onSave = options.onSave;
|
|
54
|
+
this.onDone = options.onDone;
|
|
55
|
+
this.maxVisible = options.maxVisible ?? 10;
|
|
56
|
+
this.baseDir = options.baseDir ?? process.cwd();
|
|
57
|
+
this.validatePath = options.validatePath;
|
|
58
|
+
this.input = new Input();
|
|
59
|
+
this.input.onSubmit = (value: string) => {
|
|
60
|
+
if (this.mode === "edit") {
|
|
61
|
+
this.submitEdit(value);
|
|
62
|
+
} else {
|
|
63
|
+
this.submitAdd(value);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
this.input.onEscape = () => {
|
|
67
|
+
this.mode = "list";
|
|
68
|
+
this.editIndex = -1;
|
|
69
|
+
this.completions = [];
|
|
70
|
+
this.completionIndex = 0;
|
|
71
|
+
this.inputError = null;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private submitAdd(value: string) {
|
|
76
|
+
const trimmed = value.trim();
|
|
77
|
+
if (!trimmed) {
|
|
78
|
+
this.mode = "list";
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const validationError = this.validatePath?.(trimmed) ?? null;
|
|
83
|
+
if (validationError) {
|
|
84
|
+
this.inputError = validationError;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.items.push(trimmed);
|
|
89
|
+
this.selectedIndex = this.items.length - 1;
|
|
90
|
+
this.save();
|
|
91
|
+
this.mode = "list";
|
|
92
|
+
this.input.setValue("");
|
|
93
|
+
this.completions = [];
|
|
94
|
+
this.completionIndex = 0;
|
|
95
|
+
this.inputError = null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private submitEdit(value: string) {
|
|
99
|
+
const trimmed = value.trim();
|
|
100
|
+
if (!trimmed) {
|
|
101
|
+
this.mode = "list";
|
|
102
|
+
this.editIndex = -1;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const validationError = this.validatePath?.(trimmed) ?? null;
|
|
107
|
+
if (validationError) {
|
|
108
|
+
this.inputError = validationError;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.items[this.editIndex] = trimmed;
|
|
113
|
+
this.save();
|
|
114
|
+
this.mode = "list";
|
|
115
|
+
this.editIndex = -1;
|
|
116
|
+
this.input.setValue("");
|
|
117
|
+
this.completions = [];
|
|
118
|
+
this.completionIndex = 0;
|
|
119
|
+
this.inputError = null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private deleteSelected() {
|
|
123
|
+
if (this.items.length === 0) return;
|
|
124
|
+
this.items.splice(this.selectedIndex, 1);
|
|
125
|
+
if (this.selectedIndex >= this.items.length) {
|
|
126
|
+
this.selectedIndex = Math.max(0, this.items.length - 1);
|
|
127
|
+
}
|
|
128
|
+
this.save();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private startEdit() {
|
|
132
|
+
if (this.items.length === 0) return;
|
|
133
|
+
this.editIndex = this.selectedIndex;
|
|
134
|
+
this.mode = "edit";
|
|
135
|
+
this.setInputValueAtEnd(this.items[this.selectedIndex] as string);
|
|
136
|
+
this.completions = [];
|
|
137
|
+
this.completionIndex = 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private save() {
|
|
141
|
+
this.onSave([...this.items]);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
invalidate() {}
|
|
145
|
+
|
|
146
|
+
render(width: number): string[] {
|
|
147
|
+
const lines: string[] = [];
|
|
148
|
+
|
|
149
|
+
lines.push(this.theme.label(` ${this.label}`, true));
|
|
150
|
+
lines.push("");
|
|
151
|
+
|
|
152
|
+
if (this.mode === "add" || this.mode === "edit") {
|
|
153
|
+
return [...lines, ...this.renderInputMode(width)];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return [...lines, ...this.renderListMode(width)];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private renderListMode(width: number): string[] {
|
|
160
|
+
const lines: string[] = [];
|
|
161
|
+
|
|
162
|
+
if (this.items.length === 0) {
|
|
163
|
+
lines.push(this.theme.hint(" (empty)"));
|
|
164
|
+
} else {
|
|
165
|
+
const startIndex = Math.max(
|
|
166
|
+
0,
|
|
167
|
+
Math.min(
|
|
168
|
+
this.selectedIndex - Math.floor(this.maxVisible / 2),
|
|
169
|
+
this.items.length - this.maxVisible,
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
const endIndex = Math.min(
|
|
173
|
+
startIndex + this.maxVisible,
|
|
174
|
+
this.items.length,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
178
|
+
const item = this.items[i];
|
|
179
|
+
if (!item) continue;
|
|
180
|
+
const isSelected = i === this.selectedIndex;
|
|
181
|
+
const prefix = isSelected ? this.theme.cursor : " ";
|
|
182
|
+
const prefixWidth = visibleWidth(prefix);
|
|
183
|
+
const maxItemWidth = width - prefixWidth - 2;
|
|
184
|
+
const text = this.theme.value(
|
|
185
|
+
truncateToWidth(item, maxItemWidth, ""),
|
|
186
|
+
isSelected,
|
|
187
|
+
);
|
|
188
|
+
lines.push(prefix + text);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (startIndex > 0 || endIndex < this.items.length) {
|
|
192
|
+
lines.push(
|
|
193
|
+
this.theme.hint(` (${this.selectedIndex + 1}/${this.items.length})`),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
lines.push("");
|
|
199
|
+
lines.push(
|
|
200
|
+
this.theme.hint(" a: add · e/Enter: edit · d: delete · Esc: back"),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
return lines;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private renderInputMode(width: number): string[] {
|
|
207
|
+
const lines: string[] = [];
|
|
208
|
+
const label = this.mode === "edit" ? " Edit path:" : " New path:";
|
|
209
|
+
lines.push(this.theme.hint(label));
|
|
210
|
+
lines.push(` ${this.input.render(width - 4).join("")}`);
|
|
211
|
+
|
|
212
|
+
if (this.completions.length > 0) {
|
|
213
|
+
lines.push("");
|
|
214
|
+
lines.push(this.theme.hint(" Suggestions:"));
|
|
215
|
+
const start = Math.max(
|
|
216
|
+
0,
|
|
217
|
+
Math.min(this.completionIndex - 2, this.completions.length - 5),
|
|
218
|
+
);
|
|
219
|
+
const end = Math.min(this.completions.length, start + 5);
|
|
220
|
+
for (let i = start; i < end; i++) {
|
|
221
|
+
const completion = this.completions[i];
|
|
222
|
+
if (!completion) continue;
|
|
223
|
+
const isSelected = i === this.completionIndex;
|
|
224
|
+
const prefix = isSelected ? this.theme.cursor : " ";
|
|
225
|
+
const text = isSelected
|
|
226
|
+
? this.theme.value(completion, true)
|
|
227
|
+
: this.theme.hint(completion);
|
|
228
|
+
lines.push(`${prefix}${text}`);
|
|
229
|
+
}
|
|
230
|
+
if (this.completions.length > 5) {
|
|
231
|
+
lines.push(
|
|
232
|
+
this.theme.hint(
|
|
233
|
+
` (${this.completionIndex + 1}/${this.completions.length})`,
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (this.inputError) {
|
|
240
|
+
lines.push("");
|
|
241
|
+
lines.push(this.theme.value(` ${this.inputError}`, true));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
lines.push("");
|
|
245
|
+
lines.push(
|
|
246
|
+
this.theme.hint(
|
|
247
|
+
" Tab: complete/apply · ↑/↓: select suggestion · Enter: confirm · Esc: cancel",
|
|
248
|
+
),
|
|
249
|
+
);
|
|
250
|
+
return lines;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
handleInput(data: string) {
|
|
254
|
+
if (this.mode === "add" || this.mode === "edit") {
|
|
255
|
+
if (matchesKey(data, Key.up) && this.completions.length > 0) {
|
|
256
|
+
this.completionIndex =
|
|
257
|
+
this.completionIndex === 0
|
|
258
|
+
? this.completions.length - 1
|
|
259
|
+
: this.completionIndex - 1;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (matchesKey(data, Key.down) && this.completions.length > 0) {
|
|
263
|
+
this.completionIndex =
|
|
264
|
+
this.completionIndex === this.completions.length - 1
|
|
265
|
+
? 0
|
|
266
|
+
: this.completionIndex + 1;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (matchesKey(data, Key.tab)) {
|
|
270
|
+
if (this.completions.length > 0) {
|
|
271
|
+
this.applySelectedCompletion();
|
|
272
|
+
} else {
|
|
273
|
+
this.completeInputPath();
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (matchesKey(data, Key.enter)) {
|
|
278
|
+
// Keep Enter for submit/confirm. Use Tab to apply suggestions.
|
|
279
|
+
this.input.handleInput(data);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
this.input.handleInput(data);
|
|
283
|
+
this.completions = [];
|
|
284
|
+
this.completionIndex = 0;
|
|
285
|
+
this.inputError = null;
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
290
|
+
if (this.items.length === 0) return;
|
|
291
|
+
this.selectedIndex =
|
|
292
|
+
this.selectedIndex === 0
|
|
293
|
+
? this.items.length - 1
|
|
294
|
+
: this.selectedIndex - 1;
|
|
295
|
+
} else if (matchesKey(data, Key.down) || data === "j") {
|
|
296
|
+
if (this.items.length === 0) return;
|
|
297
|
+
this.selectedIndex =
|
|
298
|
+
this.selectedIndex === this.items.length - 1
|
|
299
|
+
? 0
|
|
300
|
+
: this.selectedIndex + 1;
|
|
301
|
+
} else if (data === "a" || data === "A") {
|
|
302
|
+
this.mode = "add";
|
|
303
|
+
this.input.setValue("");
|
|
304
|
+
this.completions = [];
|
|
305
|
+
this.completionIndex = 0;
|
|
306
|
+
} else if (data === "e" || data === "E" || matchesKey(data, Key.enter)) {
|
|
307
|
+
this.startEdit();
|
|
308
|
+
} else if (data === "d" || data === "D") {
|
|
309
|
+
this.deleteSelected();
|
|
310
|
+
} else if (matchesKey(data, Key.escape)) {
|
|
311
|
+
this.onDone();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private completeInputPath(): void {
|
|
316
|
+
const raw = this.input.getValue();
|
|
317
|
+
const completed = this.getCompletion(raw);
|
|
318
|
+
if (!completed) return;
|
|
319
|
+
this.setInputValueAtEnd(completed.nextValue);
|
|
320
|
+
this.completions = completed.suggestions;
|
|
321
|
+
this.completionIndex = 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private applySelectedCompletion(): void {
|
|
325
|
+
const selected = this.completions[this.completionIndex];
|
|
326
|
+
if (!selected) return;
|
|
327
|
+
|
|
328
|
+
const raw = this.input.getValue().trim();
|
|
329
|
+
const rawEndsWithSep = raw.endsWith("/") || raw.endsWith(path.sep);
|
|
330
|
+
const rawDirPart = rawEndsWithSep ? raw : path.dirname(raw);
|
|
331
|
+
const separator = rawDirPart === "." ? "" : rawDirPart;
|
|
332
|
+
const joiner = separator && !separator.endsWith("/") ? "/" : "";
|
|
333
|
+
|
|
334
|
+
const nextValue = `${separator}${joiner}${selected}`;
|
|
335
|
+
|
|
336
|
+
// Prevent duplicate appends when user tabs repeatedly on same completion.
|
|
337
|
+
if (nextValue === raw || raw.endsWith(selected)) {
|
|
338
|
+
this.completions = [];
|
|
339
|
+
this.completionIndex = 0;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
this.setInputValueAtEnd(nextValue);
|
|
344
|
+
this.completions = [];
|
|
345
|
+
this.completionIndex = 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private setInputValueAtEnd(value: string): void {
|
|
349
|
+
this.input.setValue(value);
|
|
350
|
+
// Input has no public setCursor API. Emulate End (Ctrl+E) so follow-up
|
|
351
|
+
// typing/tab completion continues from end of line.
|
|
352
|
+
this.input.handleInput("\u0005");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private getCompletion(inputValue: string): {
|
|
356
|
+
nextValue: string;
|
|
357
|
+
suggestions: string[];
|
|
358
|
+
} | null {
|
|
359
|
+
const raw = inputValue.trim();
|
|
360
|
+
if (!raw) return null;
|
|
361
|
+
|
|
362
|
+
const rawEndsWithSep = raw.endsWith("/") || raw.endsWith(path.sep);
|
|
363
|
+
const rawDirPart = rawEndsWithSep ? raw : path.dirname(raw);
|
|
364
|
+
const rawPrefix = rawEndsWithSep ? "" : path.basename(raw);
|
|
365
|
+
|
|
366
|
+
const absDir = this.resolveAbsolutePath(
|
|
367
|
+
rawDirPart === "." ? "" : rawDirPart,
|
|
368
|
+
this.baseDir,
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
let names: string[];
|
|
372
|
+
try {
|
|
373
|
+
names = fs.readdirSync(absDir);
|
|
374
|
+
} catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const matched = names
|
|
379
|
+
.filter((name) => name.startsWith(rawPrefix))
|
|
380
|
+
.sort((a, b) => a.localeCompare(b));
|
|
381
|
+
|
|
382
|
+
if (matched.length === 0) return null;
|
|
383
|
+
|
|
384
|
+
const suggestions = matched.map((name) => {
|
|
385
|
+
const candidateAbs = path.join(absDir, name);
|
|
386
|
+
const isDir = this.isDirectory(candidateAbs);
|
|
387
|
+
return `${name}${isDir ? "/" : ""}`;
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const nextName =
|
|
391
|
+
matched.length === 1
|
|
392
|
+
? matched[0]
|
|
393
|
+
: this.commonPrefix(matched) || rawPrefix;
|
|
394
|
+
|
|
395
|
+
if (!nextName || nextName === rawPrefix) {
|
|
396
|
+
return { nextValue: raw, suggestions };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const nextAbs = path.join(absDir, nextName);
|
|
400
|
+
const nextIsDir = this.isDirectory(nextAbs);
|
|
401
|
+
const separator = rawDirPart === "." ? "" : rawDirPart;
|
|
402
|
+
const joiner = separator && !separator.endsWith("/") ? "/" : "";
|
|
403
|
+
const nextValue = `${separator}${joiner}${nextName}${nextIsDir ? "/" : ""}`;
|
|
404
|
+
|
|
405
|
+
return { nextValue, suggestions };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private resolveAbsolutePath(rawPath: string, baseDir: string): string {
|
|
409
|
+
if (rawPath.startsWith("~/")) {
|
|
410
|
+
return path.resolve(os.homedir(), rawPath.slice(2));
|
|
411
|
+
}
|
|
412
|
+
if (rawPath === "~") {
|
|
413
|
+
return os.homedir();
|
|
414
|
+
}
|
|
415
|
+
if (path.isAbsolute(rawPath)) {
|
|
416
|
+
return path.normalize(rawPath);
|
|
417
|
+
}
|
|
418
|
+
return path.resolve(baseDir, rawPath || ".");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private isDirectory(filePath: string): boolean {
|
|
422
|
+
try {
|
|
423
|
+
return fs.statSync(filePath).isDirectory();
|
|
424
|
+
} catch {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private commonPrefix(values: string[]): string {
|
|
430
|
+
if (values.length === 0) return "";
|
|
431
|
+
let prefix = values[0] as string;
|
|
432
|
+
for (let i = 1; i < values.length; i++) {
|
|
433
|
+
const current = values[i] as string;
|
|
434
|
+
let j = 0;
|
|
435
|
+
while (
|
|
436
|
+
j < prefix.length &&
|
|
437
|
+
j < current.length &&
|
|
438
|
+
prefix[j] === current[j]
|
|
439
|
+
) {
|
|
440
|
+
j++;
|
|
441
|
+
}
|
|
442
|
+
prefix = prefix.slice(0, j);
|
|
443
|
+
if (!prefix) break;
|
|
444
|
+
}
|
|
445
|
+
return prefix;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
@@ -328,6 +328,9 @@ export class SectionedSettings implements Component {
|
|
|
328
328
|
/**
|
|
329
329
|
* Apply search filter to entries without resetting the cursor.
|
|
330
330
|
* Used by updateSections() to preserve selection.
|
|
331
|
+
*
|
|
332
|
+
* Matches on both item labels and section labels. When a section label
|
|
333
|
+
* matches, all items in that section are included.
|
|
331
334
|
*/
|
|
332
335
|
private filterEntries(query: string): void {
|
|
333
336
|
if (!query) {
|
|
@@ -335,29 +338,49 @@ export class SectionedSettings implements Component {
|
|
|
335
338
|
return;
|
|
336
339
|
}
|
|
337
340
|
|
|
341
|
+
const q = query.toLowerCase();
|
|
338
342
|
const filtered: FlatEntry[] = [];
|
|
339
343
|
let currentSection: FlatEntry | null = null;
|
|
344
|
+
let sectionLabelMatches = false;
|
|
340
345
|
let sectionHasMatch = false;
|
|
346
|
+
let sectionItems: FlatEntry[] = [];
|
|
347
|
+
|
|
348
|
+
const flushSection = () => {
|
|
349
|
+
if (sectionLabelMatches && currentSection) {
|
|
350
|
+
// Section label matched: include header + all items
|
|
351
|
+
filtered.push(currentSection);
|
|
352
|
+
filtered.push(...sectionItems);
|
|
353
|
+
} else if (sectionHasMatch && currentSection) {
|
|
354
|
+
// Only some items matched: include header + matched items
|
|
355
|
+
filtered.push(currentSection);
|
|
356
|
+
for (const item of sectionItems) {
|
|
357
|
+
if (item.item?.label.toLowerCase().includes(q)) {
|
|
358
|
+
filtered.push(item);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
};
|
|
341
363
|
|
|
342
364
|
for (const entry of this.flatEntries) {
|
|
343
365
|
if (entry.type === "section") {
|
|
366
|
+
flushSection();
|
|
344
367
|
currentSection = entry;
|
|
345
|
-
|
|
368
|
+
sectionLabelMatches = (entry.sectionLabel ?? "")
|
|
369
|
+
.toLowerCase()
|
|
370
|
+
.includes(q);
|
|
371
|
+
sectionHasMatch = sectionLabelMatches;
|
|
372
|
+
sectionItems = [];
|
|
346
373
|
continue;
|
|
347
374
|
}
|
|
348
375
|
|
|
349
376
|
if (entry.item) {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (currentSection && !sectionHasMatch) {
|
|
354
|
-
filtered.push(currentSection);
|
|
355
|
-
sectionHasMatch = true;
|
|
356
|
-
}
|
|
357
|
-
filtered.push(entry);
|
|
377
|
+
sectionItems.push(entry);
|
|
378
|
+
if (entry.item.label.toLowerCase().includes(q)) {
|
|
379
|
+
sectionHasMatch = true;
|
|
358
380
|
}
|
|
359
381
|
}
|
|
360
382
|
}
|
|
383
|
+
flushSection();
|
|
361
384
|
|
|
362
385
|
this.filteredEntries = filtered;
|
|
363
386
|
}
|
package/index.ts
CHANGED
|
@@ -13,6 +13,14 @@ export {
|
|
|
13
13
|
ArrayEditor,
|
|
14
14
|
type ArrayEditorOptions,
|
|
15
15
|
} from "./components/array-editor";
|
|
16
|
+
export {
|
|
17
|
+
FuzzySelector,
|
|
18
|
+
type FuzzySelectorOptions,
|
|
19
|
+
} from "./components/fuzzy-selector";
|
|
20
|
+
export {
|
|
21
|
+
PathArrayEditor,
|
|
22
|
+
type PathArrayEditorOptions,
|
|
23
|
+
} from "./components/path-array-editor";
|
|
16
24
|
export {
|
|
17
25
|
SectionedSettings,
|
|
18
26
|
type SectionedSettingsOptions,
|
package/package.json
CHANGED