@leavepulse/ui 0.79.0 → 0.80.1
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/dist/assets/{LpCommandPalette-DDJKICKh.css → LpCommandPalette-C1LRFzbN.css} +19 -19
- package/dist/components/LpCommandPalette.vue.d.ts +22 -1
- package/dist/components/LpCommandPalette.vue.js +34 -14
- package/dist/components/commandPalette.d.ts +14 -0
- package/dist/components/commandPalette.js +42 -0
- package/dist/components/relativeTime.js +6 -1
- package/dist/composables/useAsyncTask.d.ts +6 -1
- package/dist/composables/useAsyncTask.js +4 -1
- package/package.json +1 -1
- package/src/components/LpCommandPalette.vue +64 -14
- package/src/components/commandPalette.ts +62 -0
- package/src/components/relativeTime.ts +13 -1
- package/src/composables/useAsyncTask.ts +10 -2
|
@@ -5,34 +5,34 @@
|
|
|
5
5
|
* pane from its right edge as it grows. Animating the columns themselves made
|
|
6
6
|
* every line of the list and the pane re-wrap on every frame.
|
|
7
7
|
*/
|
|
8
|
-
.lp-cmd-dialog[data-v-
|
|
8
|
+
.lp-cmd-dialog[data-v-63099a19] {
|
|
9
9
|
--lp-cmd-list: min(92vw, 40rem);
|
|
10
10
|
--lp-cmd-pane: max(0px, min(26rem, 96vw - var(--lp-cmd-list)));
|
|
11
11
|
width: var(--lp-cmd-list);
|
|
12
12
|
}
|
|
13
|
-
.lp-cmd-split[data-v-
|
|
13
|
+
.lp-cmd-split[data-v-63099a19] {
|
|
14
14
|
transition:
|
|
15
15
|
width 300ms var(--ease-emphasized, ease),
|
|
16
16
|
min-height 300ms var(--ease-emphasized, ease);
|
|
17
17
|
}
|
|
18
18
|
@media (min-width: 768px) {
|
|
19
|
-
.lp-cmd-split .lp-cmd-list[data-v-
|
|
19
|
+
.lp-cmd-split .lp-cmd-list[data-v-63099a19] {
|
|
20
20
|
flex: 0 0 auto;
|
|
21
21
|
/* the dialog's 1px border on each side */
|
|
22
22
|
width: calc(var(--lp-cmd-list) - 2px);
|
|
23
23
|
}
|
|
24
|
-
.lp-cmd-dialog[data-pane="open"][data-v-
|
|
24
|
+
.lp-cmd-dialog[data-pane="open"][data-v-63099a19] {
|
|
25
25
|
width: calc(var(--lp-cmd-list) + var(--lp-cmd-pane));
|
|
26
26
|
min-height: min(76vh, 36rem);
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
-
.lp-cmd-pane[data-v-
|
|
29
|
+
.lp-cmd-pane[data-v-63099a19] {
|
|
30
30
|
width: var(--lp-cmd-pane);
|
|
31
31
|
}
|
|
32
|
-
.lp-cmd-dialog[data-editing] .lp-cmd-pane[data-v-
|
|
32
|
+
.lp-cmd-dialog[data-editing] .lp-cmd-pane[data-v-63099a19] {
|
|
33
33
|
width: 100%;
|
|
34
34
|
}
|
|
35
|
-
.lp-cmd-dialog[data-editing][data-v-
|
|
35
|
+
.lp-cmd-dialog[data-editing][data-v-63099a19] {
|
|
36
36
|
width: var(--lp-cmd-list);
|
|
37
37
|
min-height: 0;
|
|
38
38
|
}
|
|
@@ -40,43 +40,43 @@
|
|
|
40
40
|
* The pane rides in from the right edge while the dialog opens up to it, and
|
|
41
41
|
* leaves the same way, for as long as the dialog takes to close over it.
|
|
42
42
|
*/
|
|
43
|
-
.lp-cmd-pane-enter-active[data-v-
|
|
44
|
-
.lp-cmd-pane-leave-active[data-v-
|
|
43
|
+
.lp-cmd-pane-enter-active[data-v-63099a19],
|
|
44
|
+
.lp-cmd-pane-leave-active[data-v-63099a19] {
|
|
45
45
|
transition:
|
|
46
46
|
transform 300ms var(--ease-emphasized, ease),
|
|
47
47
|
opacity 300ms ease;
|
|
48
48
|
}
|
|
49
|
-
.lp-cmd-pane-enter-from[data-v-
|
|
50
|
-
.lp-cmd-pane-leave-to[data-v-
|
|
49
|
+
.lp-cmd-pane-enter-from[data-v-63099a19],
|
|
50
|
+
.lp-cmd-pane-leave-to[data-v-63099a19] {
|
|
51
51
|
opacity: 0;
|
|
52
52
|
transform: translateX(40%);
|
|
53
53
|
}
|
|
54
|
-
.lp-cmd-para[data-v-
|
|
54
|
+
.lp-cmd-para[data-v-63099a19] {
|
|
55
55
|
--rise: 4px;
|
|
56
56
|
animation: rise-in 260ms ease-out both;
|
|
57
57
|
}
|
|
58
58
|
@media (prefers-reduced-motion: reduce) {
|
|
59
|
-
.lp-cmd-split[data-v-
|
|
60
|
-
.lp-cmd-pane-enter-active[data-v-
|
|
61
|
-
.lp-cmd-pane-leave-active[data-v-
|
|
59
|
+
.lp-cmd-split[data-v-63099a19],
|
|
60
|
+
.lp-cmd-pane-enter-active[data-v-63099a19],
|
|
61
|
+
.lp-cmd-pane-leave-active[data-v-63099a19] {
|
|
62
62
|
transition: none;
|
|
63
63
|
}
|
|
64
|
-
.lp-cmd-para[data-v-
|
|
64
|
+
.lp-cmd-para[data-v-63099a19] {
|
|
65
65
|
animation: none;
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
-
.lp-cmd-excerpt[data-v-
|
|
68
|
+
.lp-cmd-excerpt[data-v-63099a19] {
|
|
69
69
|
display: -webkit-box;
|
|
70
70
|
-webkit-box-orient: vertical;
|
|
71
71
|
overflow: hidden;
|
|
72
72
|
}
|
|
73
73
|
/* Each result eases in; honours reduced motion. */
|
|
74
|
-
.lp-cmd-item[data-v-
|
|
74
|
+
.lp-cmd-item[data-v-63099a19] {
|
|
75
75
|
--rise: 3px;
|
|
76
76
|
animation: rise-in 140ms ease-out both;
|
|
77
77
|
}
|
|
78
78
|
@media (prefers-reduced-motion: reduce) {
|
|
79
|
-
.lp-cmd-item[data-v-
|
|
79
|
+
.lp-cmd-item[data-v-63099a19] {
|
|
80
80
|
animation: none;
|
|
81
81
|
}
|
|
82
82
|
}
|
|
@@ -188,8 +188,27 @@ export interface CommandPrompt {
|
|
|
188
188
|
* after the palette closed, is discarded and never reaches the list, so a
|
|
189
189
|
* fetcher does not need to check the signal itself. Checking it before an
|
|
190
190
|
* expensive step is only a saving.
|
|
191
|
+
*
|
|
192
|
+
* A fetcher that gathers from sources of different speeds (a local index, a
|
|
193
|
+
* database, a slow remote) can return an async iterable instead — an async
|
|
194
|
+
* generator is the easy way — and yield as it goes, so the quick sources show
|
|
195
|
+
* without waiting for the slow one. Each batch is the whole list so far and
|
|
196
|
+
* replaces the one before; the last is the answer. The search keeps its
|
|
197
|
+
* loading bar until the iterable ends, the highlight stays on the row it was
|
|
198
|
+
* on as rows are added, and when the query moves on the signal aborts and the
|
|
199
|
+
* iterable is closed (`return()`) — a generator parked in an `await` only
|
|
200
|
+
* closes at its next `yield`, running its `finally` there, so pass the signal
|
|
201
|
+
* on to what it awaits. A throw after some batches keeps them on screen, with
|
|
202
|
+
* a line saying the rest could not be fetched:
|
|
203
|
+
*
|
|
204
|
+
* async function* search(q, signal) {
|
|
205
|
+
* const local = index.find(q)
|
|
206
|
+
* yield local
|
|
207
|
+
* const remote = await api.search(q, { signal })
|
|
208
|
+
* yield [...local, ...remote]
|
|
209
|
+
* }
|
|
191
210
|
*/
|
|
192
|
-
export type CommandFetcher = (query: string, signal: AbortSignal, parsed: PaletteQuery) => Command[] | Promise<Command[]>;
|
|
211
|
+
export type CommandFetcher = (query: string, signal: AbortSignal, parsed: PaletteQuery) => Command[] | Promise<Command[]> | AsyncIterable<Command[]>;
|
|
193
212
|
/** Footer hints and the mode chip, spelled out so an app can translate them. */
|
|
194
213
|
export type PaletteLabels = Partial<Record<PaletteMode, string>> & {
|
|
195
214
|
navigate: string;
|
|
@@ -209,6 +228,8 @@ export type PaletteLabels = Partial<Record<PaletteMode, string>> & {
|
|
|
209
228
|
searching: string;
|
|
210
229
|
/** Shown when `search` (or a fetching mode) threw. */
|
|
211
230
|
searchFailed: string;
|
|
231
|
+
/** Shown under streamed results when the fetcher threw after some of them; defaults to "Some results could not be loaded." */
|
|
232
|
+
searchIncomplete?: string;
|
|
212
233
|
retry: string;
|
|
213
234
|
/** The row that unfolds a group cut by `groupLimit`; `{count}` is how many are hidden. */
|
|
214
235
|
showMore: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineComponent, computed, h, shallowRef, ref, watch, useId, useSlots, onBeforeUnmount, nextTick, openBlock, createBlock, unref, withCtx, createVNode, normalizeStyle, normalizeClass, withModifiers, createTextVNode, toDisplayString, createElementVNode, createElementBlock, Fragment, renderList,
|
|
1
|
+
import { defineComponent, computed, h, shallowRef, ref, watch, useId, useSlots, onBeforeUnmount, nextTick, openBlock, createBlock, unref, withCtx, createVNode, normalizeStyle, normalizeClass, withModifiers, createTextVNode, toDisplayString, createElementVNode, createCommentVNode, createElementBlock, Fragment, renderList, withDirectives, withKeys, vModelText, resolveDynamicComponent, vShow, Transition, renderSlot } from "vue";
|
|
2
2
|
import { useModelOverlayHistory } from "../composables/useOverlayHistory.js";
|
|
3
3
|
import { nodeWindow } from "../composables/realm.js";
|
|
4
4
|
import { clamp } from "../composables/clamp.js";
|
|
@@ -23,10 +23,10 @@ import { runningHintSystem, HINT_SIGNALS } from "../composables/useHintSystem.js
|
|
|
23
23
|
import { useModalLayer } from "./modalDepth.js";
|
|
24
24
|
import { useLabels } from "../composables/useLpConfig.js";
|
|
25
25
|
import { parsePaletteQuery, parseCommandQuery, searchCommandItems, applyQueryFilters, highlightParts } from "./commandSearch.js";
|
|
26
|
-
import { PALETTE_MODES, modeChipLabel, fallbackRows, readRecent, resolveRecentItems, recentVisible, commandHasPreview, matchModePrefix, isSafeHref, absoluteHref, commandKey, recentKey, pushRecent, moreRowId, toRecentItem } from "./commandPalette.js";
|
|
26
|
+
import { PALETTE_MODES, modeChipLabel, settleFetched, fallbackRows, readRecent, resolveRecentItems, recentVisible, commandHasPreview, matchModePrefix, isSafeHref, absoluteHref, commandKey, recentKey, pushRecent, moreRowId, toRecentItem } from "./commandPalette.js";
|
|
27
27
|
import { useView, usePortalTarget, useOutsideGuard, viewWindow } from "../composables/useView.js";
|
|
28
28
|
import { _ as _export_sfc } from "../chunks/_plugin-vue_export-helper-1tPrXgE0.js";
|
|
29
|
-
import '../assets/LpCommandPalette-
|
|
29
|
+
import '../assets/LpCommandPalette-C1LRFzbN.css';const _hoisted_1 = { class: "relative flex shrink-0 items-center gap-2.5 border-b border-line px-4" };
|
|
30
30
|
const _hoisted_2 = ["title", "onClick"];
|
|
31
31
|
const _hoisted_3 = ["readonly", "aria-label", "aria-expanded", "aria-controls", "aria-activedescendant", "placeholder", "onKeydown"];
|
|
32
32
|
const _hoisted_4 = { class: "shrink-0 rounded-small border border-line px-1.5 py-0.5 text-[10px] text-muted" };
|
|
@@ -190,6 +190,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
190
190
|
back: "back",
|
|
191
191
|
searching: "Searching…",
|
|
192
192
|
searchFailed: "Search is unavailable right now.",
|
|
193
|
+
searchIncomplete: "Some results could not be loaded.",
|
|
193
194
|
retry: "Retry",
|
|
194
195
|
showMore: "Show {count} more",
|
|
195
196
|
actions: "actions",
|
|
@@ -342,24 +343,34 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
342
343
|
task.cancel();
|
|
343
344
|
}
|
|
344
345
|
const failed = ref(false);
|
|
346
|
+
const incomplete = ref(false);
|
|
345
347
|
function refresh(keepRows = false) {
|
|
346
348
|
cancel();
|
|
347
349
|
if (!keepRows) found.value = [];
|
|
348
350
|
failed.value = false;
|
|
351
|
+
incomplete.value = false;
|
|
349
352
|
const run2 = fetcher();
|
|
350
353
|
if (!run2) return;
|
|
351
354
|
const parsed = parsePaletteQuery(query.value.trim());
|
|
352
355
|
const q = parsed.text;
|
|
353
356
|
const inMode = mode.value !== null;
|
|
354
357
|
if (!inMode && q.length < Math.max(1, props.minChars)) return;
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
358
|
+
let streamed = false;
|
|
359
|
+
task.run(
|
|
360
|
+
(signal, emit2) => settleFetched(run2(q, signal, parsed), signal, (rows) => {
|
|
361
|
+
streamed = true;
|
|
362
|
+
emit2(rows);
|
|
363
|
+
}),
|
|
364
|
+
{
|
|
365
|
+
delay: props.debounce,
|
|
366
|
+
apply: (hits) => found.value = hits,
|
|
367
|
+
fail: () => {
|
|
368
|
+
if (!streamed) found.value = [];
|
|
369
|
+
incomplete.value = streamed;
|
|
370
|
+
failed.value = true;
|
|
371
|
+
}
|
|
361
372
|
}
|
|
362
|
-
|
|
373
|
+
);
|
|
363
374
|
}
|
|
364
375
|
const source = computed(() => mode.value ? modeItems.value : rootCommands.value);
|
|
365
376
|
const local = computed(() => {
|
|
@@ -851,6 +862,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
851
862
|
modeItems.value = [];
|
|
852
863
|
found.value = [];
|
|
853
864
|
failed.value = false;
|
|
865
|
+
incomplete.value = false;
|
|
854
866
|
if (o) {
|
|
855
867
|
await nextTick();
|
|
856
868
|
if (props.open) inputRef.value?.focus();
|
|
@@ -917,7 +929,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
917
929
|
_: 1
|
|
918
930
|
}),
|
|
919
931
|
createElementVNode("div", _hoisted_1, [
|
|
920
|
-
!
|
|
932
|
+
!executing.value && !unref(validating) ? (openBlock(), createBlock(_sfc_main$1, {
|
|
921
933
|
key: 0,
|
|
922
934
|
name: "lucide:search",
|
|
923
935
|
size: 18,
|
|
@@ -926,9 +938,17 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
926
938
|
key: 1,
|
|
927
939
|
size: "inline",
|
|
928
940
|
delay: 0,
|
|
929
|
-
labels: { loading:
|
|
941
|
+
labels: { loading: unref(text).running },
|
|
930
942
|
class: "shrink-0 text-[18px] text-muted"
|
|
931
943
|
}, null, 8, ["labels"])),
|
|
944
|
+
unref(searching) ? (openBlock(), createBlock(_sfc_main$2, {
|
|
945
|
+
key: 2,
|
|
946
|
+
variant: "bar",
|
|
947
|
+
size: "sm",
|
|
948
|
+
tone: "brand",
|
|
949
|
+
labels: { loading: unref(text).searching },
|
|
950
|
+
class: "pointer-events-none absolute inset-x-0 -bottom-px"
|
|
951
|
+
}, null, 8, ["labels"])) : createCommentVNode("", true),
|
|
932
952
|
(openBlock(true), createElementBlock(Fragment, null, renderList(trail.value, (step, i) => {
|
|
933
953
|
return openBlock(), createElementBlock("button", {
|
|
934
954
|
key: step.id,
|
|
@@ -1164,7 +1184,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
1164
1184
|
size: 15,
|
|
1165
1185
|
class: "shrink-0"
|
|
1166
1186
|
}),
|
|
1167
|
-
createElementVNode("span", null, toDisplayString(unref(text).searchFailed), 1),
|
|
1187
|
+
createElementVNode("span", null, toDisplayString(incomplete.value ? unref(text).searchIncomplete : unref(text).searchFailed), 1),
|
|
1168
1188
|
createElementVNode("button", {
|
|
1169
1189
|
type: "button",
|
|
1170
1190
|
class: "rounded-inner px-1.5 py-0.5 font-medium text-brand outline-none transition-colors hover:bg-brand-soft focus-visible:ring-2 focus-visible:ring-ring",
|
|
@@ -1343,7 +1363,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
1343
1363
|
};
|
|
1344
1364
|
}
|
|
1345
1365
|
});
|
|
1346
|
-
const LpCommandPalette = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-
|
|
1366
|
+
const LpCommandPalette = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-63099a19"]]);
|
|
1347
1367
|
export {
|
|
1348
1368
|
LpCommandPalette as default
|
|
1349
1369
|
};
|
|
@@ -202,3 +202,17 @@ export declare function modeChipLabel(command: {
|
|
|
202
202
|
label: string;
|
|
203
203
|
modeLabel?: string;
|
|
204
204
|
}): string;
|
|
205
|
+
/** What a fetcher may hand back: the rows, a promise of them, or batches of them. */
|
|
206
|
+
export type FetchedRows<T> = T[] | Promise<T[]> | AsyncIterable<T[]>;
|
|
207
|
+
/**
|
|
208
|
+
* Settles a fetcher's answer into its final rows. A plain list or a promise is
|
|
209
|
+
* passed through; batches are walked one by one, each handed to `onBatch` as
|
|
210
|
+
* it lands (each is the whole list so far, replacing the one before), and the
|
|
211
|
+
* last is the result — no batches at all is an empty list.
|
|
212
|
+
*
|
|
213
|
+
* An abort stops the walk at once rather than at the next batch: the iterator
|
|
214
|
+
* is closed (`return()`; a generator parked in an `await` closes at its next
|
|
215
|
+
* `yield`, running its `finally` there) and the wait for the pending batch is
|
|
216
|
+
* dropped. A batch that lands afterwards is not handed on.
|
|
217
|
+
*/
|
|
218
|
+
export declare function settleFetched<T>(result: FetchedRows<T>, signal: AbortSignal, onBatch: (rows: T[]) => void): Promise<T[]>;
|
|
@@ -138,6 +138,47 @@ function modeChipLabel(command) {
|
|
|
138
138
|
if (!command.label.includes("{query}")) return command.label;
|
|
139
139
|
return command.label.replace(/\s*["'“”«»]?\{query\}["'“”«»]?/g, "").replace(/\s+(for|in|on|:)$/i, "").trim();
|
|
140
140
|
}
|
|
141
|
+
function isAsyncIterable(value) {
|
|
142
|
+
return value != null && typeof value[Symbol.asyncIterator] === "function";
|
|
143
|
+
}
|
|
144
|
+
async function settleFetched(result, signal, onBatch) {
|
|
145
|
+
if (!isAsyncIterable(result)) return result;
|
|
146
|
+
const iterator = result[Symbol.asyncIterator]();
|
|
147
|
+
let closed = false;
|
|
148
|
+
const close = () => {
|
|
149
|
+
if (closed) return;
|
|
150
|
+
closed = true;
|
|
151
|
+
void Promise.resolve(iterator.return?.()).catch(() => {
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
let stop;
|
|
155
|
+
const aborted = new Promise((resolve) => {
|
|
156
|
+
stop = () => resolve({ done: true, value: void 0 });
|
|
157
|
+
if (signal.aborted) stop();
|
|
158
|
+
else signal.addEventListener("abort", stop, { once: true });
|
|
159
|
+
});
|
|
160
|
+
let rows = [];
|
|
161
|
+
try {
|
|
162
|
+
for (; ; ) {
|
|
163
|
+
const step = await Promise.race([iterator.next(), aborted]);
|
|
164
|
+
if (signal.aborted) {
|
|
165
|
+
close();
|
|
166
|
+
return rows;
|
|
167
|
+
}
|
|
168
|
+
if (step.done) {
|
|
169
|
+
closed = true;
|
|
170
|
+
return rows;
|
|
171
|
+
}
|
|
172
|
+
rows = step.value;
|
|
173
|
+
onBatch(rows);
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
closed = true;
|
|
177
|
+
throw error;
|
|
178
|
+
} finally {
|
|
179
|
+
if (stop) signal.removeEventListener("abort", stop);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
141
182
|
export {
|
|
142
183
|
PALETTE_MODES,
|
|
143
184
|
absoluteHref,
|
|
@@ -154,5 +195,6 @@ export {
|
|
|
154
195
|
recentKey,
|
|
155
196
|
recentVisible,
|
|
156
197
|
resolveRecentItems,
|
|
198
|
+
settleFetched,
|
|
157
199
|
toRecentItem
|
|
158
200
|
};
|
|
@@ -26,6 +26,11 @@ function formatterFor(locale, style) {
|
|
|
26
26
|
}
|
|
27
27
|
return f;
|
|
28
28
|
}
|
|
29
|
+
const SIGNED = /^[-+\u2212]/;
|
|
30
|
+
function say(fmt, locale, style, value, unit) {
|
|
31
|
+
const text = fmt.format(value, unit);
|
|
32
|
+
return style === "narrow" && SIGNED.test(text) ? formatterFor(locale, "short").format(value, unit) : text;
|
|
33
|
+
}
|
|
29
34
|
const MAX_REFRESH = HOUR;
|
|
30
35
|
const MIN_REFRESH = SECOND;
|
|
31
36
|
function formatRelativeTime(date, now = Date.now(), options = {}) {
|
|
@@ -47,7 +52,7 @@ function formatRelativeTime(date, now = Date.now(), options = {}) {
|
|
|
47
52
|
const boundary = diff < 0 ? (Math.floor(units + 0.5) + 0.5) * step.ms : (Math.ceil(units - 0.5) - 0.5) * step.ms;
|
|
48
53
|
const unitChange = diff < 0 ? step.until - abs : index > 0 ? abs - SCALE[index - 1].until : Infinity;
|
|
49
54
|
const refreshIn = Math.min(diff < 0 ? boundary - abs : abs - boundary, unitChange);
|
|
50
|
-
return { text: fmt
|
|
55
|
+
return { text: say(fmt, locale, style, value, step.unit), unit: step.unit, value, refreshIn: clampRefresh(refreshIn) };
|
|
51
56
|
}
|
|
52
57
|
function clampRefresh(ms) {
|
|
53
58
|
return clamp(Math.ceil(ms), MIN_REFRESH, MAX_REFRESH);
|
|
@@ -12,10 +12,15 @@
|
|
|
12
12
|
* and an error from a superseded run is dropped unseen. It is also one slot:
|
|
13
13
|
* a new `run` replaces the last, and `pending` speaks for the latest only, so
|
|
14
14
|
* independent requests each need their own `useAsyncTask`.
|
|
15
|
+
*
|
|
16
|
+
* A task that has something to show before it is done (the command palette's
|
|
17
|
+
* streamed results) calls `emit` with it: that reaches `apply` at once, for
|
|
18
|
+
* the latest run only, while `pending` stays true until the task settles. The
|
|
19
|
+
* value the task finally resolves to is applied as well, after the last emit.
|
|
15
20
|
*/
|
|
16
21
|
export declare function useAsyncTask<T>(): {
|
|
17
22
|
pending: import("vue").Ref<boolean, boolean>;
|
|
18
|
-
run: (task: (signal: AbortSignal) => T | Promise<T>, options: {
|
|
23
|
+
run: (task: (signal: AbortSignal, emit: (value: T) => void) => T | Promise<T>, options: {
|
|
19
24
|
delay?: number;
|
|
20
25
|
apply: (value: T) => void;
|
|
21
26
|
fail?: (error: unknown) => void;
|
|
@@ -19,7 +19,10 @@ function useAsyncTask() {
|
|
|
19
19
|
timer = setTimeout(async () => {
|
|
20
20
|
controller = new AbortController();
|
|
21
21
|
try {
|
|
22
|
-
const
|
|
22
|
+
const emit = (value2) => {
|
|
23
|
+
if (mine === token) options.apply(value2);
|
|
24
|
+
};
|
|
25
|
+
const value = await task(controller.signal, emit);
|
|
23
26
|
if (mine === token) options.apply(value);
|
|
24
27
|
} catch (error) {
|
|
25
28
|
if (mine === token) options.fail?.(error);
|
package/package.json
CHANGED
|
@@ -87,6 +87,7 @@ import {
|
|
|
87
87
|
toRecentItem,
|
|
88
88
|
type RecentEntry,
|
|
89
89
|
type RecentItem,
|
|
90
|
+
settleFetched,
|
|
90
91
|
} from "./commandPalette"
|
|
91
92
|
import { useOutsideGuard, usePortalTarget, useView, viewWindow } from "../composables/useView"
|
|
92
93
|
|
|
@@ -279,12 +280,31 @@ export interface CommandPrompt {
|
|
|
279
280
|
* after the palette closed, is discarded and never reaches the list, so a
|
|
280
281
|
* fetcher does not need to check the signal itself. Checking it before an
|
|
281
282
|
* expensive step is only a saving.
|
|
283
|
+
*
|
|
284
|
+
* A fetcher that gathers from sources of different speeds (a local index, a
|
|
285
|
+
* database, a slow remote) can return an async iterable instead — an async
|
|
286
|
+
* generator is the easy way — and yield as it goes, so the quick sources show
|
|
287
|
+
* without waiting for the slow one. Each batch is the whole list so far and
|
|
288
|
+
* replaces the one before; the last is the answer. The search keeps its
|
|
289
|
+
* loading bar until the iterable ends, the highlight stays on the row it was
|
|
290
|
+
* on as rows are added, and when the query moves on the signal aborts and the
|
|
291
|
+
* iterable is closed (`return()`) — a generator parked in an `await` only
|
|
292
|
+
* closes at its next `yield`, running its `finally` there, so pass the signal
|
|
293
|
+
* on to what it awaits. A throw after some batches keeps them on screen, with
|
|
294
|
+
* a line saying the rest could not be fetched:
|
|
295
|
+
*
|
|
296
|
+
* async function* search(q, signal) {
|
|
297
|
+
* const local = index.find(q)
|
|
298
|
+
* yield local
|
|
299
|
+
* const remote = await api.search(q, { signal })
|
|
300
|
+
* yield [...local, ...remote]
|
|
301
|
+
* }
|
|
282
302
|
*/
|
|
283
303
|
export type CommandFetcher = (
|
|
284
304
|
query: string,
|
|
285
305
|
signal: AbortSignal,
|
|
286
306
|
parsed: PaletteQuery,
|
|
287
|
-
) => Command[] | Promise<Command[]>
|
|
307
|
+
) => Command[] | Promise<Command[]> | AsyncIterable<Command[]>
|
|
288
308
|
|
|
289
309
|
/** Footer hints and the mode chip, spelled out so an app can translate them. */
|
|
290
310
|
export type PaletteLabels = Partial<Record<PaletteMode, string>> & {
|
|
@@ -305,6 +325,8 @@ export type PaletteLabels = Partial<Record<PaletteMode, string>> & {
|
|
|
305
325
|
searching: string
|
|
306
326
|
/** Shown when `search` (or a fetching mode) threw. */
|
|
307
327
|
searchFailed: string
|
|
328
|
+
/** Shown under streamed results when the fetcher threw after some of them; defaults to "Some results could not be loaded." */
|
|
329
|
+
searchIncomplete?: string
|
|
308
330
|
retry: string
|
|
309
331
|
/** The row that unfolds a group cut by `groupLimit`; `{count}` is how many are hidden. */
|
|
310
332
|
showMore: string
|
|
@@ -508,6 +530,7 @@ const DEFAULT_LABELS: Required<PaletteLabels> = {
|
|
|
508
530
|
back: "back",
|
|
509
531
|
searching: "Searching…",
|
|
510
532
|
searchFailed: "Search is unavailable right now.",
|
|
533
|
+
searchIncomplete: "Some results could not be loaded.",
|
|
511
534
|
retry: "Retry",
|
|
512
535
|
showMore: "Show {count} more",
|
|
513
536
|
actions: "actions",
|
|
@@ -762,11 +785,14 @@ function cancel() {
|
|
|
762
785
|
* drops a superseded request before `fail` runs.
|
|
763
786
|
*/
|
|
764
787
|
const failed = ref(false)
|
|
788
|
+
/** The failure came after some batches had shown: those stay, the line says the rest is missing. */
|
|
789
|
+
const incomplete = ref(false)
|
|
765
790
|
|
|
766
791
|
function refresh(keepRows = false) {
|
|
767
792
|
cancel()
|
|
768
793
|
if (!keepRows) found.value = []
|
|
769
794
|
failed.value = false
|
|
795
|
+
incomplete.value = false
|
|
770
796
|
const run = fetcher()
|
|
771
797
|
if (!run) return
|
|
772
798
|
const parsed = parsePaletteQuery(query.value.trim())
|
|
@@ -778,14 +804,25 @@ function refresh(keepRows = false) {
|
|
|
778
804
|
if (!inMode && q.length < Math.max(1, props.minChars)) return
|
|
779
805
|
// A failing provider: the static commands still stand, with a line saying
|
|
780
806
|
// that the rest could not be fetched.
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
807
|
+
// Streamed batches go through `emit`, which drops them once this request
|
|
808
|
+
// is superseded; `streamed` tells a failure after them from one before.
|
|
809
|
+
let streamed = false
|
|
810
|
+
task.run(
|
|
811
|
+
(signal, emit) =>
|
|
812
|
+
settleFetched(run(q, signal, parsed), signal, (rows) => {
|
|
813
|
+
streamed = true
|
|
814
|
+
emit(rows)
|
|
815
|
+
}),
|
|
816
|
+
{
|
|
817
|
+
delay: props.debounce,
|
|
818
|
+
apply: (hits) => (found.value = hits),
|
|
819
|
+
fail: () => {
|
|
820
|
+
if (!streamed) found.value = []
|
|
821
|
+
incomplete.value = streamed
|
|
822
|
+
failed.value = true
|
|
823
|
+
},
|
|
787
824
|
},
|
|
788
|
-
|
|
825
|
+
)
|
|
789
826
|
}
|
|
790
827
|
|
|
791
828
|
// ── filtering + scoring ──────────────────────────────────────
|
|
@@ -1483,6 +1520,7 @@ watch(
|
|
|
1483
1520
|
modeItems.value = []
|
|
1484
1521
|
found.value = []
|
|
1485
1522
|
failed.value = false
|
|
1523
|
+
incomplete.value = false
|
|
1486
1524
|
if (o) {
|
|
1487
1525
|
await nextTick()
|
|
1488
1526
|
// Closed again meanwhile: the box belongs to a dialog on its way out.
|
|
@@ -1547,22 +1585,34 @@ const guardOutside = useOutsideGuard()
|
|
|
1547
1585
|
<!-- Filter. The trail sits inside the box as chips, the way a scoped
|
|
1548
1586
|
search reads everywhere else: what you type is filtered by what is
|
|
1549
1587
|
to its left. -->
|
|
1550
|
-
<div class="flex shrink-0 items-center gap-2.5 border-b border-line px-4">
|
|
1588
|
+
<div class="relative flex shrink-0 items-center gap-2.5 border-b border-line px-4">
|
|
1551
1589
|
<LpIcon
|
|
1552
|
-
v-if="!
|
|
1590
|
+
v-if="!executing && !validating"
|
|
1553
1591
|
name="lucide:search"
|
|
1554
1592
|
:size="18"
|
|
1555
1593
|
class="shrink-0 text-muted"
|
|
1556
1594
|
/>
|
|
1557
|
-
<!-- At once, since it takes the search icon's place.
|
|
1558
|
-
thing that tells a screen reader a search went out, so it speaks. -->
|
|
1595
|
+
<!-- At once, since it takes the search icon's place. -->
|
|
1559
1596
|
<LpLoader
|
|
1560
1597
|
v-else
|
|
1561
1598
|
size="inline"
|
|
1562
1599
|
:delay="0"
|
|
1563
|
-
:labels="{ loading:
|
|
1600
|
+
:labels="{ loading: text.running }"
|
|
1564
1601
|
class="shrink-0 text-[18px] text-muted"
|
|
1565
1602
|
/>
|
|
1603
|
+
<!-- A search under way runs a bar along the box's lower edge, over
|
|
1604
|
+
the border, for as long as it lasts: results that stream in
|
|
1605
|
+
batches are already on screen by then, and the bar is what says
|
|
1606
|
+
more may come. It is also what tells a screen reader a search
|
|
1607
|
+
went out, so it speaks. -->
|
|
1608
|
+
<LpLoader
|
|
1609
|
+
v-if="searching"
|
|
1610
|
+
variant="bar"
|
|
1611
|
+
size="sm"
|
|
1612
|
+
tone="brand"
|
|
1613
|
+
:labels="{ loading: text.searching }"
|
|
1614
|
+
class="pointer-events-none absolute inset-x-0 -bottom-px"
|
|
1615
|
+
/>
|
|
1566
1616
|
|
|
1567
1617
|
<button
|
|
1568
1618
|
v-for="(step, i) in trail"
|
|
@@ -1733,7 +1783,7 @@ const guardOutside = useOutsideGuard()
|
|
|
1733
1783
|
:class="flat.length ? 'pb-1 pt-3' : 'py-10'"
|
|
1734
1784
|
>
|
|
1735
1785
|
<LpIcon name="lucide:circle-alert" :size="15" class="shrink-0" />
|
|
1736
|
-
<span>{{ text.searchFailed }}</span>
|
|
1786
|
+
<span>{{ incomplete ? text.searchIncomplete : text.searchFailed }}</span>
|
|
1737
1787
|
<button
|
|
1738
1788
|
type="button"
|
|
1739
1789
|
class="rounded-inner px-1.5 py-0.5 font-medium text-brand outline-none transition-colors hover:bg-brand-soft focus-visible:ring-2 focus-visible:ring-ring"
|
|
@@ -344,3 +344,65 @@ export function modeChipLabel(command: { label: string; modeLabel?: string }): s
|
|
|
344
344
|
.replace(/\s+(for|in|on|:)$/i, "")
|
|
345
345
|
.trim()
|
|
346
346
|
}
|
|
347
|
+
|
|
348
|
+
/** What a fetcher may hand back: the rows, a promise of them, or batches of them. */
|
|
349
|
+
export type FetchedRows<T> = T[] | Promise<T[]> | AsyncIterable<T[]>
|
|
350
|
+
|
|
351
|
+
function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
|
|
352
|
+
return value != null && typeof (value as AsyncIterable<T>)[Symbol.asyncIterator] === "function"
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Settles a fetcher's answer into its final rows. A plain list or a promise is
|
|
357
|
+
* passed through; batches are walked one by one, each handed to `onBatch` as
|
|
358
|
+
* it lands (each is the whole list so far, replacing the one before), and the
|
|
359
|
+
* last is the result — no batches at all is an empty list.
|
|
360
|
+
*
|
|
361
|
+
* An abort stops the walk at once rather than at the next batch: the iterator
|
|
362
|
+
* is closed (`return()`; a generator parked in an `await` closes at its next
|
|
363
|
+
* `yield`, running its `finally` there) and the wait for the pending batch is
|
|
364
|
+
* dropped. A batch that lands afterwards is not handed on.
|
|
365
|
+
*/
|
|
366
|
+
export async function settleFetched<T>(
|
|
367
|
+
result: FetchedRows<T>,
|
|
368
|
+
signal: AbortSignal,
|
|
369
|
+
onBatch: (rows: T[]) => void,
|
|
370
|
+
): Promise<T[]> {
|
|
371
|
+
if (!isAsyncIterable<T[]>(result)) return result
|
|
372
|
+
const iterator = result[Symbol.asyncIterator]()
|
|
373
|
+
let closed = false
|
|
374
|
+
const close = () => {
|
|
375
|
+
if (closed) return
|
|
376
|
+
closed = true
|
|
377
|
+
// A generator still awaiting inside rejects its queued return with the
|
|
378
|
+
// error it later throws; that is not ours to report.
|
|
379
|
+
void Promise.resolve(iterator.return?.()).catch(() => {})
|
|
380
|
+
}
|
|
381
|
+
let stop: (() => void) | undefined
|
|
382
|
+
const aborted = new Promise<IteratorResult<T[]>>((resolve) => {
|
|
383
|
+
stop = () => resolve({ done: true, value: undefined })
|
|
384
|
+
if (signal.aborted) stop()
|
|
385
|
+
else signal.addEventListener("abort", stop, { once: true })
|
|
386
|
+
})
|
|
387
|
+
let rows: T[] = []
|
|
388
|
+
try {
|
|
389
|
+
for (;;) {
|
|
390
|
+
const step = await Promise.race([iterator.next(), aborted])
|
|
391
|
+
if (signal.aborted) {
|
|
392
|
+
close()
|
|
393
|
+
return rows
|
|
394
|
+
}
|
|
395
|
+
if (step.done) {
|
|
396
|
+
closed = true
|
|
397
|
+
return rows
|
|
398
|
+
}
|
|
399
|
+
rows = step.value
|
|
400
|
+
onBatch(rows)
|
|
401
|
+
}
|
|
402
|
+
} catch (error) {
|
|
403
|
+
closed = true
|
|
404
|
+
throw error
|
|
405
|
+
} finally {
|
|
406
|
+
if (stop) signal.removeEventListener("abort", stop)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
@@ -68,6 +68,18 @@ function formatterFor(locale: string | undefined, style: Intl.RelativeTimeFormat
|
|
|
68
68
|
return f
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/*
|
|
72
|
+
* Some locales' narrow form drops the words for past and future and keeps a
|
|
73
|
+
* bare sign: ru narrow is "-4 мин" and "+3 ч", where short says "4 мин. назад"
|
|
74
|
+
* and "через 3 ч". A sign reads as arithmetic, not as "ago", so such a text
|
|
75
|
+
* is said in the short form instead.
|
|
76
|
+
*/
|
|
77
|
+
const SIGNED = /^[-+\u2212]/
|
|
78
|
+
function say(fmt: Intl.RelativeTimeFormat, locale: string | undefined, style: Intl.RelativeTimeFormatStyle, value: number, unit: Intl.RelativeTimeFormatUnit): string {
|
|
79
|
+
const text = fmt.format(value, unit)
|
|
80
|
+
return style === "narrow" && SIGNED.test(text) ? formatterFor(locale, "short").format(value, unit) : text
|
|
81
|
+
}
|
|
82
|
+
|
|
71
83
|
/** Never sleep longer than this: a laptop waking from sleep should not show "2 h ago" for an hour. */
|
|
72
84
|
const MAX_REFRESH = HOUR
|
|
73
85
|
const MIN_REFRESH = SECOND
|
|
@@ -111,7 +123,7 @@ export function formatRelativeTime(
|
|
|
111
123
|
const unitChange = diff < 0 ? step.until - abs : index > 0 ? abs - SCALE[index - 1].until : Infinity
|
|
112
124
|
const refreshIn = Math.min(diff < 0 ? boundary - abs : abs - boundary, unitChange)
|
|
113
125
|
|
|
114
|
-
return { text: fmt
|
|
126
|
+
return { text: say(fmt, locale, style, value, step.unit), unit: step.unit, value, refreshIn: clampRefresh(refreshIn) }
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
function clampRefresh(ms: number): number {
|
|
@@ -14,6 +14,11 @@ import { getCurrentScope, onScopeDispose, ref } from "vue"
|
|
|
14
14
|
* and an error from a superseded run is dropped unseen. It is also one slot:
|
|
15
15
|
* a new `run` replaces the last, and `pending` speaks for the latest only, so
|
|
16
16
|
* independent requests each need their own `useAsyncTask`.
|
|
17
|
+
*
|
|
18
|
+
* A task that has something to show before it is done (the command palette's
|
|
19
|
+
* streamed results) calls `emit` with it: that reaches `apply` at once, for
|
|
20
|
+
* the latest run only, while `pending` stays true until the task settles. The
|
|
21
|
+
* value the task finally resolves to is applied as well, after the last emit.
|
|
17
22
|
*/
|
|
18
23
|
export function useAsyncTask<T>() {
|
|
19
24
|
const pending = ref(false)
|
|
@@ -31,7 +36,7 @@ export function useAsyncTask<T>() {
|
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
function run(
|
|
34
|
-
task: (signal: AbortSignal) => T | Promise<T>,
|
|
39
|
+
task: (signal: AbortSignal, emit: (value: T) => void) => T | Promise<T>,
|
|
35
40
|
options: { delay?: number; apply: (value: T) => void; fail?: (error: unknown) => void },
|
|
36
41
|
) {
|
|
37
42
|
cancel()
|
|
@@ -40,7 +45,10 @@ export function useAsyncTask<T>() {
|
|
|
40
45
|
timer = setTimeout(async () => {
|
|
41
46
|
controller = new AbortController()
|
|
42
47
|
try {
|
|
43
|
-
const
|
|
48
|
+
const emit = (value: T) => {
|
|
49
|
+
if (mine === token) options.apply(value)
|
|
50
|
+
}
|
|
51
|
+
const value = await task(controller.signal, emit)
|
|
44
52
|
if (mine === token) options.apply(value)
|
|
45
53
|
} catch (error) {
|
|
46
54
|
if (mine === token) options.fail?.(error)
|