@tooee/choose 0.2.3 → 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 -2
- package/dist/{ChooseFilter.d.ts → choose-filter.d.ts} +2 -2
- package/dist/choose-filter.d.ts.map +1 -0
- package/dist/choose-filter.js +11 -0
- package/dist/choose-filter.js.map +1 -0
- package/dist/choose-highlighted-text.d.ts +9 -0
- package/dist/choose-highlighted-text.d.ts.map +1 -0
- package/dist/{ChooseHighlightedText.js → choose-highlighted-text.js} +10 -8
- package/dist/choose-highlighted-text.js.map +1 -0
- package/dist/{ChooseList.d.ts → choose-list.d.ts} +3 -3
- package/dist/choose-list.d.ts.map +1 -0
- package/dist/{ChooseList.js → choose-list.js} +20 -15
- package/dist/choose-list.js.map +1 -0
- package/dist/{ChooseOverlay.d.ts → choose-overlay.d.ts} +6 -6
- package/dist/choose-overlay.d.ts.map +1 -0
- package/dist/{ChooseOverlay.js → choose-overlay.js} +15 -13
- package/dist/choose-overlay.js.map +1 -0
- package/dist/{ChoosePanel.d.ts → choose-panel.d.ts} +5 -5
- package/dist/choose-panel.d.ts.map +1 -0
- package/dist/choose-panel.js +31 -0
- package/dist/choose-panel.js.map +1 -0
- package/dist/choose.d.ts +28 -0
- package/dist/choose.d.ts.map +1 -0
- package/dist/{Choose.js → choose.js} +25 -18
- package/dist/choose.js.map +1 -0
- package/dist/default-provider.d.ts +2 -2
- package/dist/default-provider.d.ts.map +1 -1
- package/dist/default-provider.js +4 -4
- package/dist/default-provider.js.map +1 -1
- package/dist/fuzzy.d.ts +1 -1
- package/dist/fuzzy.d.ts.map +1 -1
- package/dist/fuzzy.js +5 -5
- package/dist/fuzzy.js.map +1 -1
- package/dist/index.d.ts +12 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/launch.d.ts +1 -1
- package/dist/launch.d.ts.map +1 -1
- package/dist/launch.js +5 -4
- package/dist/launch.js.map +1 -1
- package/dist/source.d.ts +2 -2
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +8 -6
- package/dist/source.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/use-choose-dialog.d.ts +12 -10
- package/dist/use-choose-dialog.d.ts.map +1 -1
- package/dist/use-choose-dialog.js +98 -77
- package/dist/use-choose-dialog.js.map +1 -1
- package/dist/use-choose-dialog.typecheck.d.ts +1 -1
- package/dist/use-choose-dialog.typecheck.d.ts.map +1 -1
- package/dist/use-choose-dialog.typecheck.js +5 -5
- package/dist/use-choose-dialog.typecheck.js.map +1 -1
- package/dist/use-choose.d.ts +16 -16
- package/dist/use-choose.d.ts.map +1 -1
- package/dist/use-choose.js +176 -138
- package/dist/use-choose.js.map +1 -1
- package/package.json +7 -7
- package/src/{ChooseFilter.tsx → choose-filter.tsx} +18 -16
- package/src/choose-highlighted-text.tsx +49 -0
- package/src/{ChooseList.tsx → choose-list.tsx} +73 -60
- package/src/choose-overlay.tsx +96 -0
- package/src/{ChoosePanel.tsx → choose-panel.tsx} +41 -34
- package/src/choose.tsx +142 -0
- package/src/default-provider.ts +18 -15
- package/src/fuzzy.ts +16 -16
- package/src/index.ts +23 -23
- package/src/launch.tsx +15 -12
- package/src/source.ts +15 -9
- package/src/types.ts +12 -12
- package/src/use-choose-dialog.tsx +168 -139
- package/src/use-choose-dialog.typecheck.ts +19 -18
- package/src/use-choose.ts +349 -302
- package/dist/Choose.d.ts +0 -21
- package/dist/Choose.d.ts.map +0 -1
- package/dist/Choose.js.map +0 -1
- package/dist/ChooseFilter.d.ts.map +0 -1
- package/dist/ChooseFilter.js +0 -9
- package/dist/ChooseFilter.js.map +0 -1
- package/dist/ChooseHighlightedText.d.ts +0 -9
- package/dist/ChooseHighlightedText.d.ts.map +0 -1
- package/dist/ChooseHighlightedText.js.map +0 -1
- package/dist/ChooseList.d.ts.map +0 -1
- package/dist/ChooseList.js.map +0 -1
- package/dist/ChooseOverlay.d.ts.map +0 -1
- package/dist/ChooseOverlay.js.map +0 -1
- package/dist/ChoosePanel.d.ts.map +0 -1
- package/dist/ChoosePanel.js +0 -25
- package/dist/ChoosePanel.js.map +0 -1
- package/src/Choose.tsx +0 -126
- package/src/ChooseHighlightedText.tsx +0 -42
- package/src/ChooseOverlay.tsx +0 -88
package/src/use-choose.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
2
|
-
import type { InputRenderable, KeyEvent } from "@opentui/core"
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { InputRenderable, KeyEvent } from "@opentui/core";
|
|
3
3
|
import {
|
|
4
4
|
useActiveCommandSurface,
|
|
5
5
|
useActions,
|
|
@@ -8,155 +8,164 @@ import {
|
|
|
8
8
|
useMode,
|
|
9
9
|
useProvideCommandContext,
|
|
10
10
|
useSetMode,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
} from "
|
|
14
|
-
import {
|
|
15
|
-
import { chooseSourceError, loadChooseSource } from "./source.js"
|
|
16
|
-
import type { ChooseItem, ChooseResult, ChooseSource } from "./types.js"
|
|
11
|
+
} from "@tooee/commands";
|
|
12
|
+
import type { ActionDefinition, Mode } from "@tooee/commands";
|
|
13
|
+
import { fuzzyFilter } from "./fuzzy.js";
|
|
14
|
+
import type { FuzzyMatch } from "./fuzzy.js";
|
|
15
|
+
import { chooseSourceError, loadChooseSource } from "./source.js";
|
|
16
|
+
import type { ChooseItem, ChooseResult, ChooseSource } from "./types.js";
|
|
17
17
|
|
|
18
18
|
declare module "@tooee/commands" {
|
|
19
19
|
interface CommandContext {
|
|
20
20
|
/** Contributed by chooser primitives: current filtering and selection state. */
|
|
21
21
|
choose: {
|
|
22
|
-
activeItem: ChooseItem | undefined
|
|
23
|
-
selectedItems: ChooseItem[]
|
|
24
|
-
filterQuery: string
|
|
25
|
-
}
|
|
22
|
+
activeItem: ChooseItem | undefined;
|
|
23
|
+
selectedItems: ChooseItem[];
|
|
24
|
+
filterQuery: string;
|
|
25
|
+
};
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export type ChooseCommandGroup = "navigation" | "mode" | "submit" | "cancel" | "multi-select"
|
|
29
|
+
export type ChooseCommandGroup = "navigation" | "mode" | "submit" | "cancel" | "multi-select";
|
|
30
30
|
|
|
31
31
|
export interface UseChooseOptions {
|
|
32
|
-
source: ChooseSource
|
|
33
|
-
multi?: boolean
|
|
34
|
-
initialFilter?: string
|
|
35
|
-
onSubmit: (result: ChooseResult) => void | Promise<void
|
|
36
|
-
onCancel?: () => void
|
|
37
|
-
commands?: ActionDefinition[]
|
|
32
|
+
source: ChooseSource;
|
|
33
|
+
multi?: boolean;
|
|
34
|
+
initialFilter?: string;
|
|
35
|
+
onSubmit: (result: ChooseResult) => void | Promise<void>;
|
|
36
|
+
onCancel?: () => void;
|
|
37
|
+
commands?: ActionDefinition[];
|
|
38
38
|
/** Prefix for built-in command ids (default `choose`). */
|
|
39
|
-
commandScope?: string
|
|
39
|
+
commandScope?: string;
|
|
40
40
|
/** Disable a built-in command group when a host owns those keys. */
|
|
41
|
-
disable?: ChooseCommandGroup[]
|
|
41
|
+
disable?: ChooseCommandGroup[];
|
|
42
42
|
/** Explicitly blur/disable mouse views while a legacy covering surface is open. */
|
|
43
|
-
suspended?: boolean
|
|
43
|
+
suspended?: boolean;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export interface ChooseController {
|
|
47
|
-
getFilter()
|
|
48
|
-
setFilter(query: string)
|
|
49
|
-
clearFilter()
|
|
50
|
-
moveUp()
|
|
51
|
-
moveDown()
|
|
52
|
-
setActiveIndex(index: number)
|
|
53
|
-
getActiveItem()
|
|
54
|
-
getSelectedItems()
|
|
55
|
-
toggleActive()
|
|
56
|
-
submit()
|
|
57
|
-
cancel()
|
|
58
|
-
reload()
|
|
59
|
-
readonly mode: Mode
|
|
60
|
-
setMode(mode: Mode)
|
|
47
|
+
getFilter: () => string;
|
|
48
|
+
setFilter: (query: string) => void;
|
|
49
|
+
clearFilter: () => void;
|
|
50
|
+
moveUp: () => void;
|
|
51
|
+
moveDown: () => void;
|
|
52
|
+
setActiveIndex: (index: number) => void;
|
|
53
|
+
getActiveItem: () => ChooseItem | undefined;
|
|
54
|
+
getSelectedItems: () => ChooseItem[];
|
|
55
|
+
toggleActive: () => void;
|
|
56
|
+
submit: () => void;
|
|
57
|
+
cancel: () => void;
|
|
58
|
+
reload: () => void;
|
|
59
|
+
readonly mode: Mode;
|
|
60
|
+
setMode: (mode: Mode) => void;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export interface ChooseState {
|
|
64
|
-
items: ChooseItem[]
|
|
65
|
-
matches: FuzzyMatch[]
|
|
66
|
-
filterQuery: string
|
|
67
|
-
activeIndex: number
|
|
68
|
-
activeItem: ChooseItem | undefined
|
|
69
|
-
selectedOriginalIndices: ReadonlySet<number
|
|
70
|
-
selectedItems: ChooseItem[]
|
|
71
|
-
loading: boolean
|
|
72
|
-
error: string | null
|
|
73
|
-
multi: boolean
|
|
64
|
+
items: ChooseItem[];
|
|
65
|
+
matches: FuzzyMatch[];
|
|
66
|
+
filterQuery: string;
|
|
67
|
+
activeIndex: number;
|
|
68
|
+
activeItem: ChooseItem | undefined;
|
|
69
|
+
selectedOriginalIndices: ReadonlySet<number>;
|
|
70
|
+
selectedItems: ChooseItem[];
|
|
71
|
+
loading: boolean;
|
|
72
|
+
error: string | null;
|
|
73
|
+
multi: boolean;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
export interface ChooseViewModel {
|
|
77
|
-
filterRef: { current: InputRenderable | null }
|
|
78
|
-
mode: Mode
|
|
77
|
+
filterRef: { current: InputRenderable | null };
|
|
78
|
+
mode: Mode;
|
|
79
79
|
/** True while another modal surface (or an explicit host guard) owns interaction. */
|
|
80
|
-
suspended: boolean
|
|
81
|
-
filterFocused: boolean
|
|
82
|
-
onFilterInput: (query: string) => void
|
|
80
|
+
suspended: boolean;
|
|
81
|
+
filterFocused: boolean;
|
|
82
|
+
onFilterInput: (query: string) => void;
|
|
83
83
|
/** Input-level Tab bridge; OpenTUI consumes Tab before global dispatch. */
|
|
84
|
-
onFilterKeyDown: (event: KeyEvent) => void
|
|
84
|
+
onFilterKeyDown: (event: KeyEvent) => void;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
export interface UseChooseResult {
|
|
88
|
-
controller: ChooseController
|
|
89
|
-
state: ChooseState
|
|
90
|
-
view: ChooseViewModel
|
|
88
|
+
controller: ChooseController;
|
|
89
|
+
state: ChooseState;
|
|
90
|
+
view: ChooseViewModel;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
94
|
* Shared headless chooser: source lifecycle, fuzzy matches, selection,
|
|
95
95
|
* command context, controller, and all built-in keyboard commands.
|
|
96
96
|
*/
|
|
97
|
-
export function useChoose(options: UseChooseOptions): UseChooseResult {
|
|
98
|
-
const { source, multi = false, initialFilter = "", commandScope = "choose" } = options
|
|
99
|
-
const initialItems = Array.isArray(source) ? source : []
|
|
100
|
-
|
|
101
|
-
const [items, setItems] = useState<ChooseItem[]>(initialItems)
|
|
102
|
-
const [filterQuery, setFilterQuery] = useState(initialFilter)
|
|
103
|
-
const [activeIndex,
|
|
104
|
-
const [selectedOriginalIndices, setSelectedOriginalIndices] = useState<Set<number>>(new Set())
|
|
105
|
-
const [loading, setLoading] = useState(!Array.isArray(source))
|
|
106
|
-
const [error, setError] = useState<string | null>(null)
|
|
107
|
-
const [reloadRevision, setReloadRevision] = useState(0)
|
|
108
|
-
const filterRef = useRef<InputRenderable>(null)
|
|
109
|
-
const requestIdRef = useRef(0)
|
|
110
|
-
const initialArraySourceRef = useRef<ChooseItem[] | null>(Array.isArray(source) ? source : null)
|
|
111
|
-
const didHandleInitialSourceRef = useRef(false)
|
|
112
|
-
|
|
113
|
-
const matches = useMemo(() => fuzzyFilter(items, filterQuery), [items, filterQuery])
|
|
114
|
-
const activeItem = matches[activeIndex]?.item
|
|
97
|
+
export const useChoose = function useChoose(options: UseChooseOptions): UseChooseResult {
|
|
98
|
+
const { source, multi = false, initialFilter = "", commandScope = "choose" } = options;
|
|
99
|
+
const initialItems = Array.isArray(source) ? source : [];
|
|
100
|
+
|
|
101
|
+
const [items, setItems] = useState<ChooseItem[]>(initialItems);
|
|
102
|
+
const [filterQuery, setFilterQuery] = useState(initialFilter);
|
|
103
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
104
|
+
const [selectedOriginalIndices, setSelectedOriginalIndices] = useState<Set<number>>(new Set());
|
|
105
|
+
const [loading, setLoading] = useState(!Array.isArray(source));
|
|
106
|
+
const [error, setError] = useState<string | null>(null);
|
|
107
|
+
const [reloadRevision, setReloadRevision] = useState(0);
|
|
108
|
+
const filterRef = useRef<InputRenderable>(null);
|
|
109
|
+
const requestIdRef = useRef(0);
|
|
110
|
+
const initialArraySourceRef = useRef<ChooseItem[] | null>(Array.isArray(source) ? source : null);
|
|
111
|
+
const didHandleInitialSourceRef = useRef(false);
|
|
112
|
+
|
|
113
|
+
const matches = useMemo(() => fuzzyFilter(items, filterQuery), [items, filterQuery]);
|
|
114
|
+
const activeItem = matches[activeIndex]?.item;
|
|
115
115
|
const selectedItems = useMemo(() => {
|
|
116
|
-
if (!multi)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
116
|
+
if (!multi) {
|
|
117
|
+
return activeItem === undefined ? [] : [activeItem];
|
|
118
|
+
}
|
|
119
|
+
const selected = [...selectedOriginalIndices].flatMap((index) => {
|
|
120
|
+
const item = items[index];
|
|
121
|
+
return item === undefined ? [] : [item];
|
|
122
|
+
});
|
|
123
|
+
if (selected.length > 0) {
|
|
124
|
+
return selected;
|
|
125
|
+
}
|
|
126
|
+
return activeItem === undefined ? [] : [activeItem];
|
|
127
|
+
}, [multi, activeItem, selectedOriginalIndices, items]);
|
|
123
128
|
|
|
124
|
-
const mode = useMode()
|
|
125
|
-
const setMode = useSetMode()
|
|
129
|
+
const mode = useMode();
|
|
130
|
+
const setMode = useSetMode();
|
|
126
131
|
|
|
127
132
|
// Stable controller methods read live values through refs, including when
|
|
128
133
|
// multiple imperative operations happen before React renders again.
|
|
129
|
-
const optionsRef = useRef(options)
|
|
130
|
-
optionsRef.current = options
|
|
131
|
-
const itemsRef = useRef(items)
|
|
132
|
-
itemsRef.current = items
|
|
133
|
-
const matchesRef = useRef(matches)
|
|
134
|
-
matchesRef.current = matches
|
|
135
|
-
const filterQueryRef = useRef(filterQuery)
|
|
136
|
-
filterQueryRef.current = filterQuery
|
|
137
|
-
const activeIndexRef = useRef(activeIndex)
|
|
138
|
-
activeIndexRef.current = activeIndex
|
|
139
|
-
const selectedRef = useRef(selectedOriginalIndices)
|
|
140
|
-
selectedRef.current = selectedOriginalIndices
|
|
141
|
-
const multiRef = useRef(multi)
|
|
142
|
-
multiRef.current = multi
|
|
143
|
-
const modeRef = useRef(mode)
|
|
144
|
-
modeRef.current = mode
|
|
134
|
+
const optionsRef = useRef(options);
|
|
135
|
+
optionsRef.current = options;
|
|
136
|
+
const itemsRef = useRef(items);
|
|
137
|
+
itemsRef.current = items;
|
|
138
|
+
const matchesRef = useRef(matches);
|
|
139
|
+
matchesRef.current = matches;
|
|
140
|
+
const filterQueryRef = useRef(filterQuery);
|
|
141
|
+
filterQueryRef.current = filterQuery;
|
|
142
|
+
const activeIndexRef = useRef(activeIndex);
|
|
143
|
+
activeIndexRef.current = activeIndex;
|
|
144
|
+
const selectedRef = useRef(selectedOriginalIndices);
|
|
145
|
+
selectedRef.current = selectedOriginalIndices;
|
|
146
|
+
const multiRef = useRef(multi);
|
|
147
|
+
multiRef.current = multi;
|
|
148
|
+
const modeRef = useRef(mode);
|
|
149
|
+
modeRef.current = mode;
|
|
145
150
|
|
|
146
151
|
const replaceItems = useCallback((nextItems: ChooseItem[]) => {
|
|
147
|
-
itemsRef.current = nextItems
|
|
148
|
-
matchesRef.current = fuzzyFilter(nextItems, filterQueryRef.current)
|
|
149
|
-
activeIndexRef.current = 0
|
|
150
|
-
selectedRef.current = new Set()
|
|
151
|
-
setItems(nextItems)
|
|
152
|
-
|
|
153
|
-
setSelectedOriginalIndices(selectedRef.current)
|
|
154
|
-
}, [])
|
|
152
|
+
itemsRef.current = nextItems;
|
|
153
|
+
matchesRef.current = fuzzyFilter(nextItems, filterQueryRef.current);
|
|
154
|
+
activeIndexRef.current = 0;
|
|
155
|
+
selectedRef.current = new Set();
|
|
156
|
+
setItems(nextItems);
|
|
157
|
+
setActiveIndex(0);
|
|
158
|
+
setSelectedOriginalIndices(selectedRef.current);
|
|
159
|
+
}, []);
|
|
155
160
|
|
|
156
161
|
useEffect(() => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
let
|
|
162
|
+
requestIdRef.current += 1;
|
|
163
|
+
const requestId = requestIdRef.current;
|
|
164
|
+
let active = true;
|
|
165
|
+
const deactivate = () => {
|
|
166
|
+
active = false;
|
|
167
|
+
};
|
|
168
|
+
let result: ChooseItem[] | Promise<ChooseItem[]>;
|
|
160
169
|
|
|
161
170
|
// Direct arrays seed state during the first render. Avoid a redundant
|
|
162
171
|
// post-mount reset that could overwrite immediate controller operations.
|
|
@@ -165,253 +174,288 @@ export function useChoose(options: UseChooseOptions): UseChooseResult {
|
|
|
165
174
|
Array.isArray(source) &&
|
|
166
175
|
source === initialArraySourceRef.current
|
|
167
176
|
) {
|
|
168
|
-
didHandleInitialSourceRef.current = true
|
|
169
|
-
setLoading(false)
|
|
170
|
-
return
|
|
177
|
+
didHandleInitialSourceRef.current = true;
|
|
178
|
+
setLoading(false);
|
|
179
|
+
return deactivate;
|
|
171
180
|
}
|
|
172
|
-
didHandleInitialSourceRef.current = true
|
|
181
|
+
didHandleInitialSourceRef.current = true;
|
|
173
182
|
|
|
174
|
-
setError(null)
|
|
183
|
+
setError(null);
|
|
175
184
|
try {
|
|
176
|
-
result = loadChooseSource(source)
|
|
185
|
+
result = loadChooseSource(source);
|
|
177
186
|
} catch (loadError) {
|
|
178
|
-
if (requestId !== requestIdRef.current)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
187
|
+
if (requestId !== requestIdRef.current) {
|
|
188
|
+
return deactivate;
|
|
189
|
+
}
|
|
190
|
+
replaceItems([]);
|
|
191
|
+
setError(chooseSourceError(loadError));
|
|
192
|
+
setLoading(false);
|
|
193
|
+
return deactivate;
|
|
183
194
|
}
|
|
184
195
|
|
|
185
196
|
if (result instanceof Promise) {
|
|
186
|
-
setLoading(true)
|
|
187
|
-
void
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
197
|
+
setLoading(true);
|
|
198
|
+
void (async () => {
|
|
199
|
+
try {
|
|
200
|
+
const loaded = await result;
|
|
201
|
+
if (!active || requestId !== requestIdRef.current) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
replaceItems(loaded);
|
|
205
|
+
setError(null);
|
|
206
|
+
setLoading(false);
|
|
207
|
+
} catch (loadError) {
|
|
208
|
+
if (!active || requestId !== requestIdRef.current) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
replaceItems([]);
|
|
212
|
+
setError(chooseSourceError(loadError));
|
|
213
|
+
setLoading(false);
|
|
214
|
+
}
|
|
215
|
+
})();
|
|
216
|
+
return deactivate;
|
|
204
217
|
}
|
|
205
218
|
|
|
206
|
-
replaceItems(result)
|
|
207
|
-
setLoading(false)
|
|
208
|
-
|
|
219
|
+
replaceItems(result);
|
|
220
|
+
setLoading(false);
|
|
221
|
+
return deactivate;
|
|
222
|
+
}, [source, reloadRevision, replaceItems]);
|
|
209
223
|
|
|
210
224
|
const setFilter = useCallback((query: string) => {
|
|
211
225
|
// Controlled OpenTUI inputs may echo a programmatic `value` update through
|
|
212
226
|
// onInput. Only a real query change resets navigation.
|
|
213
227
|
if (query === filterQueryRef.current) {
|
|
214
|
-
setFilterQuery(query)
|
|
215
|
-
return
|
|
228
|
+
setFilterQuery(query);
|
|
229
|
+
return;
|
|
216
230
|
}
|
|
217
|
-
filterQueryRef.current = query
|
|
218
|
-
matchesRef.current = fuzzyFilter(itemsRef.current, query)
|
|
219
|
-
activeIndexRef.current = 0
|
|
220
|
-
setFilterQuery(query)
|
|
221
|
-
|
|
222
|
-
}, [])
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
const lastIndex = Math.max(0, matchesRef.current.length - 1)
|
|
226
|
-
const next = Math.min(lastIndex, Math.max(0, index))
|
|
227
|
-
activeIndexRef.current = next
|
|
228
|
-
|
|
229
|
-
}, [])
|
|
230
|
-
|
|
231
|
-
const moveUp = useCallback(() =>
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const
|
|
231
|
+
filterQueryRef.current = query;
|
|
232
|
+
matchesRef.current = fuzzyFilter(itemsRef.current, query);
|
|
233
|
+
activeIndexRef.current = 0;
|
|
234
|
+
setFilterQuery(query);
|
|
235
|
+
setActiveIndex(0);
|
|
236
|
+
}, []);
|
|
237
|
+
|
|
238
|
+
const updateActiveIndex = useCallback((index: number) => {
|
|
239
|
+
const lastIndex = Math.max(0, matchesRef.current.length - 1);
|
|
240
|
+
const next = Math.min(lastIndex, Math.max(0, index));
|
|
241
|
+
activeIndexRef.current = next;
|
|
242
|
+
setActiveIndex(next);
|
|
243
|
+
}, []);
|
|
244
|
+
|
|
245
|
+
const moveUp = useCallback(() => {
|
|
246
|
+
updateActiveIndex(activeIndexRef.current - 1);
|
|
247
|
+
}, [updateActiveIndex]);
|
|
248
|
+
const moveDown = useCallback(() => {
|
|
249
|
+
updateActiveIndex(activeIndexRef.current + 1);
|
|
250
|
+
}, [updateActiveIndex]);
|
|
251
|
+
|
|
252
|
+
const getActiveItem = useCallback(() => matchesRef.current[activeIndexRef.current]?.item, []);
|
|
235
253
|
|
|
236
254
|
const getSelectedItems = useCallback((): ChooseItem[] => {
|
|
237
|
-
const active = getActiveItem()
|
|
238
|
-
if (!multiRef.current)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
255
|
+
const active = getActiveItem();
|
|
256
|
+
if (!multiRef.current) {
|
|
257
|
+
return active === undefined ? [] : [active];
|
|
258
|
+
}
|
|
259
|
+
const selected = [...selectedRef.current].flatMap((index) => {
|
|
260
|
+
const item = itemsRef.current[index];
|
|
261
|
+
return item === undefined ? [] : [item];
|
|
262
|
+
});
|
|
263
|
+
if (selected.length > 0) {
|
|
264
|
+
return selected;
|
|
265
|
+
}
|
|
266
|
+
return active === undefined ? [] : [active];
|
|
267
|
+
}, [getActiveItem]);
|
|
245
268
|
|
|
246
269
|
const toggleActive = useCallback(() => {
|
|
247
|
-
if (!multiRef.current)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
270
|
+
if (!multiRef.current) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const originalIndex = matchesRef.current[activeIndexRef.current]?.originalIndex;
|
|
274
|
+
if (originalIndex === undefined) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const next = new Set(selectedRef.current);
|
|
278
|
+
if (next.has(originalIndex)) {
|
|
279
|
+
next.delete(originalIndex);
|
|
280
|
+
} else {
|
|
281
|
+
next.add(originalIndex);
|
|
282
|
+
}
|
|
283
|
+
selectedRef.current = next;
|
|
284
|
+
setSelectedOriginalIndices(next);
|
|
285
|
+
}, []);
|
|
256
286
|
|
|
257
287
|
const submit = useCallback(() => {
|
|
258
|
-
void optionsRef.current.onSubmit({ items: getSelectedItems() })
|
|
259
|
-
}, [getSelectedItems])
|
|
260
|
-
|
|
261
|
-
const cancel = useCallback(() => optionsRef.current.onCancel?.(), [])
|
|
262
|
-
const reload = useCallback(() =>
|
|
263
|
-
|
|
288
|
+
void optionsRef.current.onSubmit({ items: getSelectedItems() });
|
|
289
|
+
}, [getSelectedItems]);
|
|
290
|
+
|
|
291
|
+
const cancel = useCallback(() => optionsRef.current.onCancel?.(), []);
|
|
292
|
+
const reload = useCallback(() => {
|
|
293
|
+
setReloadRevision((revision) => revision + 1);
|
|
294
|
+
}, []);
|
|
295
|
+
const setModeExternal = useCallback(
|
|
296
|
+
(nextMode: Mode) => {
|
|
297
|
+
setMode(nextMode);
|
|
298
|
+
},
|
|
299
|
+
[setMode],
|
|
300
|
+
);
|
|
264
301
|
|
|
265
302
|
useProvideCommandContext(() => ({
|
|
266
303
|
choose: {
|
|
267
304
|
activeItem: getActiveItem(),
|
|
268
|
-
selectedItems: getSelectedItems(),
|
|
269
305
|
filterQuery: filterQueryRef.current,
|
|
306
|
+
selectedItems: getSelectedItems(),
|
|
270
307
|
},
|
|
271
308
|
...(optionsRef.current.onCancel ? { exit: cancel } : {}),
|
|
272
|
-
}))
|
|
309
|
+
}));
|
|
273
310
|
|
|
274
|
-
useActions(options.commands)
|
|
311
|
+
useActions(options.commands);
|
|
275
312
|
|
|
276
313
|
const enabled = useCallback(
|
|
277
314
|
(group: ChooseCommandGroup) => !(optionsRef.current.disable?.includes(group) ?? false),
|
|
278
315
|
[],
|
|
279
|
-
)
|
|
316
|
+
);
|
|
280
317
|
|
|
281
318
|
useCommand({
|
|
282
|
-
|
|
283
|
-
|
|
319
|
+
handler: () => {
|
|
320
|
+
if (modeRef.current === "insert") {
|
|
321
|
+
setMode("cursor");
|
|
322
|
+
} else {
|
|
323
|
+
cancel();
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
hidden: true,
|
|
284
327
|
hotkey: "Escape",
|
|
328
|
+
id: `${commandScope}:escape`,
|
|
285
329
|
modes: ["insert", "cursor"],
|
|
286
|
-
|
|
330
|
+
title: "Back / cancel",
|
|
287
331
|
when: () =>
|
|
288
332
|
modeRef.current === "insert"
|
|
289
333
|
? enabled("mode")
|
|
290
334
|
: enabled("cancel") && optionsRef.current.onCancel !== undefined,
|
|
291
|
-
|
|
292
|
-
if (modeRef.current === "insert") setMode("cursor")
|
|
293
|
-
else cancel()
|
|
294
|
-
},
|
|
295
|
-
})
|
|
335
|
+
});
|
|
296
336
|
useCommand({
|
|
297
|
-
|
|
298
|
-
|
|
337
|
+
handler: cancel,
|
|
338
|
+
hidden: true,
|
|
299
339
|
hotkey: "q",
|
|
340
|
+
id: `${commandScope}:cancel`,
|
|
300
341
|
modes: ["cursor"],
|
|
301
|
-
|
|
342
|
+
title: "Cancel",
|
|
302
343
|
when: () => enabled("cancel") && optionsRef.current.onCancel !== undefined,
|
|
303
|
-
|
|
304
|
-
})
|
|
344
|
+
});
|
|
305
345
|
useCommand({
|
|
306
|
-
|
|
307
|
-
|
|
346
|
+
handler: () => {
|
|
347
|
+
setMode("insert");
|
|
348
|
+
},
|
|
349
|
+
hidden: true,
|
|
308
350
|
hotkey: "i",
|
|
351
|
+
id: `${commandScope}:insert-mode-i`,
|
|
309
352
|
modes: ["cursor"],
|
|
310
|
-
|
|
353
|
+
title: "Insert mode",
|
|
311
354
|
when: () => enabled("mode"),
|
|
312
|
-
|
|
313
|
-
})
|
|
355
|
+
});
|
|
314
356
|
useCommand({
|
|
315
|
-
|
|
316
|
-
|
|
357
|
+
handler: () => {
|
|
358
|
+
setMode("insert");
|
|
359
|
+
},
|
|
360
|
+
hidden: true,
|
|
317
361
|
hotkey: "a",
|
|
362
|
+
id: `${commandScope}:insert-mode-a`,
|
|
318
363
|
modes: ["cursor"],
|
|
319
|
-
|
|
364
|
+
title: "Insert mode",
|
|
320
365
|
when: () => enabled("mode"),
|
|
321
|
-
|
|
322
|
-
})
|
|
366
|
+
});
|
|
323
367
|
useCommand({
|
|
324
|
-
|
|
325
|
-
|
|
368
|
+
handler: moveDown,
|
|
369
|
+
hidden: true,
|
|
326
370
|
hotkey: "j",
|
|
371
|
+
id: `${commandScope}:move-down-vim`,
|
|
327
372
|
modes: ["cursor"],
|
|
328
|
-
|
|
373
|
+
title: "Move down",
|
|
329
374
|
when: () => enabled("navigation"),
|
|
330
|
-
|
|
331
|
-
})
|
|
375
|
+
});
|
|
332
376
|
useCommand({
|
|
333
|
-
|
|
334
|
-
|
|
377
|
+
handler: moveUp,
|
|
378
|
+
hidden: true,
|
|
335
379
|
hotkey: "k",
|
|
380
|
+
id: `${commandScope}:move-up-vim`,
|
|
336
381
|
modes: ["cursor"],
|
|
337
|
-
|
|
382
|
+
title: "Move up",
|
|
338
383
|
when: () => enabled("navigation"),
|
|
339
|
-
|
|
340
|
-
})
|
|
384
|
+
});
|
|
341
385
|
useCommand({
|
|
342
|
-
|
|
343
|
-
|
|
386
|
+
handler: submit,
|
|
387
|
+
hidden: true,
|
|
344
388
|
hotkey: "Enter",
|
|
389
|
+
id: `${commandScope}:confirm`,
|
|
345
390
|
modes: ["insert", "cursor"],
|
|
346
|
-
|
|
391
|
+
title: "Confirm",
|
|
347
392
|
when: () => enabled("submit"),
|
|
348
|
-
|
|
349
|
-
})
|
|
393
|
+
});
|
|
350
394
|
useCommand({
|
|
351
|
-
|
|
352
|
-
|
|
395
|
+
handler: moveUp,
|
|
396
|
+
hidden: true,
|
|
353
397
|
hotkey: "up",
|
|
398
|
+
id: `${commandScope}:move-up`,
|
|
354
399
|
modes: ["insert", "cursor"],
|
|
355
|
-
|
|
400
|
+
title: "Move up",
|
|
356
401
|
when: () => enabled("navigation"),
|
|
357
|
-
|
|
358
|
-
})
|
|
402
|
+
});
|
|
359
403
|
useCommand({
|
|
360
|
-
|
|
361
|
-
|
|
404
|
+
handler: moveUp,
|
|
405
|
+
hidden: true,
|
|
362
406
|
hotkey: "ctrl+p",
|
|
407
|
+
id: `${commandScope}:move-up-ctrl-p`,
|
|
363
408
|
modes: ["insert", "cursor"],
|
|
364
|
-
|
|
409
|
+
title: "Move up",
|
|
365
410
|
when: () => enabled("navigation"),
|
|
366
|
-
|
|
367
|
-
})
|
|
411
|
+
});
|
|
368
412
|
useCommand({
|
|
369
|
-
|
|
370
|
-
|
|
413
|
+
handler: moveDown,
|
|
414
|
+
hidden: true,
|
|
371
415
|
hotkey: "down",
|
|
416
|
+
id: `${commandScope}:move-down`,
|
|
372
417
|
modes: ["insert", "cursor"],
|
|
373
|
-
|
|
418
|
+
title: "Move down",
|
|
374
419
|
when: () => enabled("navigation"),
|
|
375
|
-
|
|
376
|
-
})
|
|
420
|
+
});
|
|
377
421
|
useCommand({
|
|
378
|
-
|
|
379
|
-
|
|
422
|
+
handler: moveDown,
|
|
423
|
+
hidden: true,
|
|
380
424
|
hotkey: "ctrl+n",
|
|
425
|
+
id: `${commandScope}:move-down-ctrl-n`,
|
|
381
426
|
modes: ["insert", "cursor"],
|
|
382
|
-
|
|
427
|
+
title: "Move down",
|
|
383
428
|
when: () => enabled("navigation"),
|
|
384
|
-
|
|
385
|
-
})
|
|
429
|
+
});
|
|
386
430
|
useCommand({
|
|
387
|
-
|
|
388
|
-
|
|
431
|
+
handler: () => {
|
|
432
|
+
toggleActive();
|
|
433
|
+
moveDown();
|
|
434
|
+
},
|
|
435
|
+
hidden: true,
|
|
389
436
|
hotkey: "Tab",
|
|
437
|
+
id: `${commandScope}:toggle-next`,
|
|
390
438
|
modes: ["insert", "cursor"],
|
|
391
|
-
|
|
439
|
+
title: "Toggle selection and move down",
|
|
392
440
|
when: () => enabled("multi-select") && multiRef.current,
|
|
441
|
+
});
|
|
442
|
+
useCommand({
|
|
393
443
|
handler: () => {
|
|
394
|
-
toggleActive()
|
|
395
|
-
|
|
444
|
+
toggleActive();
|
|
445
|
+
moveUp();
|
|
396
446
|
},
|
|
397
|
-
|
|
398
|
-
useCommand({
|
|
399
|
-
id: `${commandScope}:toggle-previous`,
|
|
400
|
-
title: "Toggle selection and move up",
|
|
447
|
+
hidden: true,
|
|
401
448
|
hotkey: "shift+Tab",
|
|
449
|
+
id: `${commandScope}:toggle-previous`,
|
|
402
450
|
modes: ["insert", "cursor"],
|
|
403
|
-
|
|
451
|
+
title: "Toggle selection and move up",
|
|
404
452
|
when: () => enabled("multi-select") && multiRef.current,
|
|
405
|
-
|
|
406
|
-
toggleActive()
|
|
407
|
-
moveUp()
|
|
408
|
-
},
|
|
409
|
-
})
|
|
453
|
+
});
|
|
410
454
|
|
|
411
|
-
const surfaceId = useCommandSurfaceId()
|
|
412
|
-
const activeSurface = useActiveCommandSurface()
|
|
455
|
+
const surfaceId = useCommandSurfaceId();
|
|
456
|
+
const activeSurface = useActiveCommandSurface();
|
|
413
457
|
const suspended =
|
|
414
|
-
(options.suspended ?? false) || (activeSurface !== null && activeSurface.id !== surfaceId)
|
|
458
|
+
(options.suspended ?? false) || (activeSurface !== null && activeSurface.id !== surfaceId);
|
|
415
459
|
|
|
416
460
|
const onFilterKeyDown = useCallback(
|
|
417
461
|
(event: KeyEvent) => {
|
|
@@ -421,59 +465,62 @@ export function useChoose(options: UseChooseOptions): UseChooseResult {
|
|
|
421
465
|
!enabled("multi-select") ||
|
|
422
466
|
modeRef.current !== "insert"
|
|
423
467
|
) {
|
|
424
|
-
return
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
event.preventDefault();
|
|
471
|
+
toggleActive();
|
|
472
|
+
if (event.shift) {
|
|
473
|
+
moveUp();
|
|
474
|
+
} else {
|
|
475
|
+
moveDown();
|
|
425
476
|
}
|
|
426
|
-
event.preventDefault()
|
|
427
|
-
toggleActive()
|
|
428
|
-
if (event.shift) moveUp()
|
|
429
|
-
else moveDown()
|
|
430
477
|
},
|
|
431
478
|
[enabled, moveDown, moveUp, toggleActive],
|
|
432
|
-
)
|
|
433
|
-
|
|
434
|
-
const controllerRef = useRef<ChooseController | null>(null)
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
setFilter
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
}
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const controllerRef = useRef<ChooseController | null>(null);
|
|
482
|
+
controllerRef.current ??= {
|
|
483
|
+
cancel,
|
|
484
|
+
clearFilter: () => {
|
|
485
|
+
setFilter("");
|
|
486
|
+
},
|
|
487
|
+
getActiveItem,
|
|
488
|
+
getFilter: () => filterQueryRef.current,
|
|
489
|
+
getSelectedItems,
|
|
490
|
+
get mode() {
|
|
491
|
+
return modeRef.current;
|
|
492
|
+
},
|
|
493
|
+
moveDown,
|
|
494
|
+
moveUp,
|
|
495
|
+
reload,
|
|
496
|
+
setActiveIndex: updateActiveIndex,
|
|
497
|
+
setFilter,
|
|
498
|
+
setMode: setModeExternal,
|
|
499
|
+
submit,
|
|
500
|
+
toggleActive,
|
|
501
|
+
};
|
|
455
502
|
|
|
456
503
|
return {
|
|
457
504
|
controller: controllerRef.current,
|
|
458
505
|
state: {
|
|
459
|
-
items,
|
|
460
|
-
matches,
|
|
461
|
-
filterQuery,
|
|
462
506
|
activeIndex,
|
|
463
507
|
activeItem,
|
|
464
|
-
selectedOriginalIndices,
|
|
465
|
-
selectedItems,
|
|
466
|
-
loading,
|
|
467
508
|
error,
|
|
509
|
+
filterQuery,
|
|
510
|
+
items,
|
|
511
|
+
loading,
|
|
512
|
+
matches,
|
|
468
513
|
multi,
|
|
514
|
+
selectedItems,
|
|
515
|
+
selectedOriginalIndices,
|
|
469
516
|
},
|
|
470
517
|
view: {
|
|
518
|
+
filterFocused: mode === "insert" && !suspended,
|
|
471
519
|
filterRef,
|
|
472
520
|
mode,
|
|
473
|
-
suspended,
|
|
474
|
-
filterFocused: mode === "insert" && !suspended,
|
|
475
521
|
onFilterInput: setFilter,
|
|
476
522
|
onFilterKeyDown,
|
|
523
|
+
suspended,
|
|
477
524
|
},
|
|
478
|
-
}
|
|
479
|
-
}
|
|
525
|
+
};
|
|
526
|
+
};
|