@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.
@@ -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-04518689] {
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-04518689] {
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-04518689] {
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-04518689] {
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-04518689] {
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-04518689] {
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-04518689] {
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-04518689],
44
- .lp-cmd-pane-leave-active[data-v-04518689] {
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-04518689],
50
- .lp-cmd-pane-leave-to[data-v-04518689] {
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-04518689] {
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-04518689],
60
- .lp-cmd-pane-enter-active[data-v-04518689],
61
- .lp-cmd-pane-leave-active[data-v-04518689] {
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-04518689] {
64
+ .lp-cmd-para[data-v-63099a19] {
65
65
  animation: none;
66
66
  }
67
67
  }
68
- .lp-cmd-excerpt[data-v-04518689] {
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-04518689] {
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-04518689] {
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, createCommentVNode, withDirectives, withKeys, vModelText, resolveDynamicComponent, vShow, Transition, renderSlot } from "vue";
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-DDJKICKh.css';const _hoisted_1 = { class: "flex shrink-0 items-center gap-2.5 border-b border-line px-4" };
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
- task.run((signal) => run2(q, signal, parsed), {
356
- delay: props.debounce,
357
- apply: (hits) => found.value = hits,
358
- fail: () => {
359
- found.value = [];
360
- failed.value = true;
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
- !unref(searching) && !executing.value && !unref(validating) ? (openBlock(), createBlock(_sfc_main$1, {
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: executing.value || unref(validating) ? unref(text).running : unref(text).searching },
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-04518689"]]);
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.format(value, step.unit), unit: step.unit, value, refreshIn: clampRefresh(refreshIn) };
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 value = await task(controller.signal);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.79.0",
3
+ "version": "0.80.1",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -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
- task.run((signal) => run(q, signal, parsed), {
782
- delay: props.debounce,
783
- apply: (hits) => (found.value = hits),
784
- fail: () => {
785
- found.value = []
786
- failed.value = true
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="!searching && !executing && !validating"
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. It is the only
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: executing || validating ? text.running : text.searching }"
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.format(value, step.unit), unit: step.unit, value, refreshIn: clampRefresh(refreshIn) }
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 value = await task(controller.signal)
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)