@aliou/pi-utils-settings 0.2.1 → 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 +1 -1
- package/index.ts +8 -0
- package/package.json +1 -1
- 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
|
+
}
|
|
@@ -354,7 +354,7 @@ export class SectionedSettings implements Component {
|
|
|
354
354
|
// Only some items matched: include header + matched items
|
|
355
355
|
filtered.push(currentSection);
|
|
356
356
|
for (const item of sectionItems) {
|
|
357
|
-
if (item.item
|
|
357
|
+
if (item.item?.label.toLowerCase().includes(q)) {
|
|
358
358
|
filtered.push(item);
|
|
359
359
|
}
|
|
360
360
|
}
|
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