Skip to content
Source
Sign inSign up

CommandPalette.logic.ts

912 lines31.1 KB

1import {
2 foldSearchQuery,
3 PALETTE_SECONDARY_LIMIT,
4 PALETTE_THREAD_LIMIT,
5 rankSearchGroups,
6 scoreSearchTerms,
7 scoreThreadSearch,
8 type SearchMatchHint,
9 type SearchRange,
10 type ThreadSearchSubject,
11} from "@t3tools/shared/paletteSearch";
12import { threadSearchSubject } from "@t3tools/client-runtime/state/thread-search";
13import type { CommandPaletteLinkedThreads } from "../commandPaletteBus";
14import {
15 addProjectRemoteSourceLabel,
16 addProjectRemoteSourcePathHint,
17 buildAddProjectRemoteSourceReadiness,
18 type AddProjectRemoteSource,
19 type AddProjectRemoteSourceReadiness,
20} from "@t3tools/client-runtime/operations/projects";
21import {
22 type ScopedThreadRef,
23 type EnvironmentId,
24 type ProjectId,
25 type EnvironmentMachineKind,
26 type FilesystemBrowseEntry,
27 type KeybindingCommand,
28 type SourceControlDiscoveryResult,
29 type SourceControlRepositoryInfo,
30 THREAD_JUMP_KEYBINDING_COMMANDS,
31} from "@t3tools/contracts";
32import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/filesystem";
33import { type ReactNode } from "react";
34import { formatRelativeTimeLabel } from "../timestampFormat";
35import { type Project, type SidebarThreadSummary, type Thread } from "../types";
36import { isSidebarHiddenThread } from "./Sidebar.logic";
37​
38export const RECENT_THREAD_LIMIT = 12;
39export const ITEM_ICON_CLASS = "size-4 text-icon-muted";
40export const ADDON_ICON_CLASS = "size-4";
41​
42export function buildLinkedThreadActionItems(
43 input: CommandPaletteLinkedThreads & {
44 query: string;
45 icon: ReactNode;
46 runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;
47 },
48): CommandPaletteActionItem[] {
49 return input.threads.map((thread) => ({
50 kind: "action",
51 value: `thread:${input.environmentId}:${thread.id}`,
52 threadRef: { environmentId: input.environmentId, threadId: thread.id },
53 title: thread.title || "Untitled thread",
54 description: thread.archivedAt === null ? "Linked thread" : "Archived thread",
55 searchTerms: [input.query, thread.title],
56 icon: input.icon,
57 run: () => input.runThread({ environmentId: input.environmentId, id: thread.id }),
58 }));
60​
61export function browseInputEndPaddingClass(input: {
62 readonly willCreateProjectPath: boolean;
63 readonly hasHighlightedBrowseItem: boolean;
64}): string {
65 if (input.willCreateProjectPath) {
66 return "*:data-[slot=autocomplete-input]:pe-38!";
67 }
68 if (input.hasHighlightedBrowseItem) {
69 return "*:data-[slot=autocomplete-input]:pe-30!";
70 }
71 return "*:data-[slot=autocomplete-input]:pe-24!";
73​
74export type SearchOverlayMode = "command" | "files" | "content";
75​
76export type CommandPaletteOpenIntent =
77 | { readonly kind: "add-project" | "new-thread-in" | "change-theme" }
78 | {
79 readonly kind: "search";
80 readonly query: string;
81 readonly linkedThreads?: CommandPaletteLinkedThreads;
82 };
83​
84export interface CommandPaletteUiState {
85 readonly open: boolean;
86 readonly mode: SearchOverlayMode;
87 /** What the current palette session opened to; read once when the palette view mounts. */
88 readonly openIntent: CommandPaletteOpenIntent | null;
89 /** Bumped by every intent so an open palette starts over from the new one. */
90 readonly session: number;
91 readonly parentSearch?: CommandPaletteReturnContext;
92 readonly restoredSearch?: CommandPaletteReturnContext;
94​
95export type CommandPaletteUiAction =
96 | { readonly _tag: "SetOpen"; readonly open: boolean }
97 | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode }
98 | {
99 readonly _tag: "OpenSearch";
100 readonly query: string;
101 readonly linkedThreads?: CommandPaletteLinkedThreads;
102 }
103 | { readonly _tag: "OpenAddProject" }
104 | { readonly _tag: "OpenNewThreadIn" }
105 | { readonly _tag: "OpenChangeTheme" }
106 | {
107 readonly _tag: "OpenChildSearch";
108 readonly mode: "files" | "content";
109 readonly context: CommandPaletteReturnContext;
110 }
111 | { readonly _tag: "BackToCommand" };
112​
113export function reduceCommandPaletteUiState(
114 state: CommandPaletteUiState,
115 action: CommandPaletteUiAction,
116): CommandPaletteUiState {
117 const { session } = state;
118 const openWithIntent = (openIntent: CommandPaletteOpenIntent): CommandPaletteUiState => ({
119 open: true,
120 mode: "command",
121 openIntent,
122 session: session + 1,
123 });
124 switch (action._tag) {
125 case "SetOpen":
126 return action.open
127 ? { open: true, mode: "command", openIntent: state.openIntent, session }
128 : { open: false, mode: state.mode, openIntent: null, session };
129 case "ToggleMode":
130 return state.open && state.mode === action.mode
131 ? { open: false, mode: state.mode, openIntent: null, session }
132 : { open: true, mode: action.mode, openIntent: null, session };
133 case "OpenChildSearch":
134 return {
135 open: true,
136 mode: action.mode,
137 openIntent: null,
138 session,
139 parentSearch: action.context,
140 };
141 case "BackToCommand":
142 return state.open && state.mode !== "command"
143 ? {
144 open: true,
145 mode: "command",
146 openIntent: null,
147 session,
148 ...(state.parentSearch ? { restoredSearch: state.parentSearch } : {}),
149 }
150 : state;
151 case "OpenSearch":
152 return openWithIntent({
153 kind: "search",
154 query: action.query,
155 ...(action.linkedThreads ? { linkedThreads: action.linkedThreads } : {}),
156 });
157 case "OpenAddProject":
158 return openWithIntent({ kind: "add-project" });
159 case "OpenNewThreadIn":
160 return openWithIntent({ kind: "new-thread-in" });
161 case "OpenChangeTheme":
162 return openWithIntent({ kind: "change-theme" });
163 }
165​
166/** Where "Add project" starts, given the environments that can take a new project. */
167export type AddProjectEntry =
168 | { readonly kind: "connect-environment" }
169 | { readonly kind: "choose-environment" }
170 | { readonly kind: "choose-source"; readonly environmentId: EnvironmentId };
171​
172export function resolveAddProjectEntry(
173 options: ReadonlyArray<Pick<AddProjectEnvironmentOption, "environmentId">>,
174 defaultEnvironmentId: EnvironmentId | null,
175): AddProjectEntry {
176 if (options.length === 0) return { kind: "connect-environment" };
177 if (options.length > 1 || defaultEnvironmentId === null) return { kind: "choose-environment" };
178 return { kind: "choose-source", environmentId: defaultEnvironmentId };
180​
181export interface CommandPaletteThreadContentMatch {
182 readonly source: "user" | "assistant";
183 readonly snippet: string;
184 readonly query: string;
186​
187export interface CommandPaletteItem {
188 readonly kind: "action" | "submenu";
189 readonly value: string;
190 readonly searchTerms: ReadonlyArray<string>;
191 readonly title: ReactNode;
192 /** The title as plain text, for searching, when `title` is styled. */
193 readonly searchTitle?: string;
194 readonly description?: ReactNode;
195 readonly threadContentMatch?: CommandPaletteThreadContentMatch;
196 readonly timestamp?: string;
197 /** Thread rows rank by this, with use history, instead of by `searchTerms`. */
198 readonly threadSearch?: ThreadSearchSubject;
199 readonly threadRef?: ScopedThreadRef;
200 /** Shown in place of the description when the match is not in the title. */
201 readonly matchHint?: SearchMatchHint;
202 readonly icon: ReactNode;
203 readonly disabled?: boolean;
204 readonly titleLeadingContent?: ReactNode;
205 readonly titleTrailingContent?: ReactNode;
206 readonly shortcutCommand?: KeybindingCommand;
207 readonly secondary?: boolean;
209​
210export interface CommandPaletteActionItem extends CommandPaletteItem {
211 readonly kind: "action";
212 readonly keepOpen?: boolean;
213 readonly run: () => Promise<void>;
214 /** A pin toggle on the row, also reached with the pin shortcut while the row is highlighted. */
215 readonly pin?: CommandPaletteItemPin;
216 /** Set on toggle rows; exposed to assistive technology as `aria-checked`. */
217 readonly checked?: boolean;
219​
220export interface CommandPaletteItemPin {
221 readonly name: string;
222 readonly pinned: boolean;
223 readonly toggle: () => void;
225​
226export interface CommandPaletteSubmenuItem extends CommandPaletteItem {
227 readonly kind: "submenu";
228 readonly addonIcon: ReactNode;
229 readonly groups: ReadonlyArray<CommandPaletteGroup>;
230 readonly initialQuery?: string;
232​
233export interface CommandPaletteGroup {
234 readonly value: string;
235 readonly label: string;
236 readonly items: ReadonlyArray<CommandPaletteActionItem | CommandPaletteSubmenuItem>;
238​
239export interface CommandPaletteView {
240 readonly addonIcon: ReactNode;
241 readonly groups: ReadonlyArray<CommandPaletteGroup>;
242 readonly initialQuery?: string;
244​
245// Child search unmounts the palette; retain search data without its view closures.
246export interface CommandPaletteReturnContext {
247 readonly query: string;
248 readonly linkedThreadSearch: Extract<CommandPaletteOpenIntent, { kind: "search" }> | null;
250​
251export interface CommandPaletteSearchState {
252 readonly query: string;
253 readonly views: ReadonlyArray<{
254 readonly view: CommandPaletteView;
255 readonly parentQuery: string;
256 }>;
258​
259export function createCommandPaletteSearchState(query = ""): CommandPaletteSearchState {
260 return { query, views: [] };
262​
263export function reduceCommandPaletteSearchState(
264 state: CommandPaletteSearchState,
265 action:
266 | { readonly type: "query"; readonly query: string }
267 | { readonly type: "push"; readonly view: CommandPaletteView }
268 | { readonly type: "back" }
269 | { readonly type: "reset"; readonly query?: string },
270): CommandPaletteSearchState {
271 switch (action.type) {
272 case "query":
273 return { ...state, query: action.query };
274 case "push":
275 return {
276 query: action.view.initialQuery ?? "",
277 views: [...state.views, { view: action.view, parentQuery: state.query }],
278 };
279 case "back": {
280 const parent = state.views.at(-1);
281 return parent ? { query: parent.parentQuery, views: state.views.slice(0, -1) } : state;
282 }
283 case "reset":
284 return createCommandPaletteSearchState(action.query);
285 }
287​
288export type CommandPaletteRow =
289 | {
290 readonly kind: "label";
291 readonly key: string;
292 readonly label: string;
293 readonly first: boolean;
294 }
295 | {
296 readonly kind: "item";
297 readonly key: string;
298 readonly item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
299 readonly itemIndex: number | null;
300 };
301​
302export function buildCommandPaletteRows(groups: ReadonlyArray<CommandPaletteGroup>) {
303 const rows: CommandPaletteRow[] = [];
304 const itemValues: string[] = [];
305 const rowIndexByItemIndex: number[] = [];
306 for (const group of groups) {
307 if (group.label) {
308 rows.push({
309 kind: "label",
310 key: `group:${group.value}`,
311 label: group.label,
312 first: rows.length === 0,
313 });
314 }
315 for (const item of group.items) {
316 const itemIndex = item.disabled ? null : itemValues.length;
317 if (itemIndex !== null) {
318 itemValues.push(item.value);
319 rowIndexByItemIndex.push(rows.length);
320 }
321 rows.push({ kind: "item", key: `${group.value}:${item.value}`, item, itemIndex });
322 }
323 }
324 return { rows, itemValues, rowIndexByItemIndex };
326​
327export function findHighlightedCommandPaletteItem(
328 groups: ReadonlyArray<CommandPaletteGroup>,
329 highlightedItemValue: string | null,
330): CommandPaletteActionItem | CommandPaletteSubmenuItem | null {
331 if (highlightedItemValue === null) return null;
332 for (const group of groups) {
333 for (const item of group.items) {
334 if (item.value === highlightedItemValue && !item.disabled) return item;
335 }
336 }
337 return null;
339​
340export function enumerateCommandPaletteItems(
341 items: ReadonlyArray<CommandPaletteActionItem>,
342): CommandPaletteActionItem[] {
343 return items.map((item, index) => {
344 const shortcutCommand = THREAD_JUMP_KEYBINDING_COMMANDS[index];
345 if (shortcutCommand) return { ...item, shortcutCommand };
346​
347 const { shortcutCommand: _shortcutCommand, ...itemWithoutShortcut } = item;
348 return itemWithoutShortcut;
349 });
351​
352export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse";
353​
354export type CommandPaletteProject = Project & { readonly displayName: string };
355​
356export function buildCommandPaletteProjectMetadata(input: {
357 readonly projects: ReadonlyArray<Pick<Project, "environmentId" | "title" | "workspaceRoot">>;
358 readonly locationByEnvironmentId: ReadonlyMap<EnvironmentId, { readonly label: string }>;
359}) {
360 const searchTerms: string[] = [];
361 const environmentLabels = new Set<string>();
362​
363 for (const project of input.projects) {
364 const label = input.locationByEnvironmentId.get(project.environmentId)?.label ?? "Remote";
365 searchTerms.push(project.title, project.workspaceRoot, label);
366 environmentLabels.add(label);
367 }
368​
369 return { searchTerms, environmentLabels: [...environmentLabels] };
371​
372export function buildProjectActionItems(input: {
373 projects: ReadonlyArray<CommandPaletteProject>;
374 valuePrefix: string;
375 icon: (project: CommandPaletteProject) => ReactNode;
376 runProject: (project: CommandPaletteProject) => Promise<void>;
377 searchTerms?: (project: CommandPaletteProject) => ReadonlyArray<string>;
378 renderDescription?: (project: CommandPaletteProject) => ReactNode;
379 shortcutCommand?: KeybindingCommand;
380 pin?: (project: CommandPaletteProject) => CommandPaletteItemPin | null;
381}): CommandPaletteActionItem[] {
382 return input.projects.map((project) => {
383 const pin = input.pin?.(project) ?? null;
384 return {
385 kind: "action",
386 value: `${input.valuePrefix}:${project.environmentId}:${project.id}`,
387 searchTerms: [
388 project.displayName,
389 project.title,
390 project.workspaceRoot,
391 ...(input.searchTerms?.(project) ?? []),
392 ],
393 title: project.displayName,
394 description: input.renderDescription?.(project) ?? project.workspaceRoot,
395 icon: input.icon(project),
396 ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}),
397 ...(pin ? { pin } : {}),
398 run: async () => {
399 await input.runProject(project);
400 },
401 };
402 });
404​
405export type BuildThreadActionItemsThread = Pick<
406 SidebarThreadSummary,
407 | "archivedAt"
408 | "branch"
409 | "createdAt"
410 | "environmentId"
411 | "id"
412 | "lineage"
413 | "modelSelection"
414 | "projectId"
415 | "runtime"
416 | "title"
417 | "worktreePath"
418> &
419 Partial<
420 Pick<
421 SidebarThreadSummary,
422 "pullRequests" | "linkedPullRequest" | "branchPullRequest" | "opens" | "latestUserMessageAt"
423 >
424 > & {
425 updatedAt: string;
426 };
427​
428export function buildThreadActionItems<TThread extends BuildThreadActionItemsThread>(input: {
429 threads: ReadonlyArray<TThread>;
430 activeThread?: Pick<Thread, "environmentId" | "id">;
431 projectByKey: ReadonlyMap<string, Pick<Project, "title">>;
432 icon: ReactNode;
433 renderLeadingContent?: (thread: TThread) => ReactNode;
434 renderTrailingContent?: (thread: TThread) => ReactNode;
435 renderDescription?: (thread: TThread, meta: { projectTitle: string | undefined }) => ReactNode;
436 runThread: (thread: TThread) => Promise<void>;
437 /** Makes the environment searchable; pass it only with two or more environments. */
438 environmentLabelOf?: (thread: TThread) => string | null;
439}): CommandPaletteActionItem[] {
440 // Callers order these: recents by last use, search results by rank.
441 const visibleThreads = input.threads.filter(
442 (thread) => thread.archivedAt === null && !isSidebarHiddenThread(thread),
443 );
444​
445 return visibleThreads.map((thread) => {
446 const projectTitle = input.projectByKey.get(
447 `${thread.environmentId}:${thread.projectId}`,
448 )?.title;
449 const descriptionParts: string[] = [];
450​
451 if (projectTitle) {
452 descriptionParts.push(projectTitle);
453 }
454 if (thread.branch) {
455 descriptionParts.push(`#${thread.branch}`);
456 }
457 const isCurrent =
458 thread.id === input.activeThread?.id &&
459 thread.environmentId === input.activeThread.environmentId;
460 if (isCurrent) {
461 descriptionParts.push("Current thread");
462 }
463​
464 const leadingContent = input.renderLeadingContent?.(thread);
465 const trailingContent = input.renderTrailingContent?.(thread);
466 const description = input.renderDescription
467 ? input.renderDescription(thread, { projectTitle })
468 : descriptionParts.join(` · `);
469​
470 return Object.assign(
471 {
472 kind: "action" as const,
473 value: `thread:${thread.environmentId}:${thread.id}`,
474 threadRef: { environmentId: thread.environmentId, threadId: thread.id },
475 searchTerms: [thread.title],
476 threadSearch: threadSearchSubject(
477 {
478 ...thread,
479 latestUserMessageAt: thread.latestUserMessageAt ?? null,
480 opens: thread.opens ?? null,
481 },
482 projectTitle,
483 input.environmentLabelOf?.(thread),
484 ),
485 title: thread.title,
486 description,
487 timestamp: formatRelativeTimeLabel(
488 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
489 ),
490 icon: input.icon,
491 },
492 leadingContent ? { titleLeadingContent: leadingContent } : {},
493 trailingContent ? { titleTrailingContent: trailingContent } : {},
494 // Last in its group: picking the thread already open does nothing.
495 isCurrent ? { secondary: true } : {},
496 {
497 run: async () => {
498 await input.runThread(thread);
499 },
500 },
501 );
502 });
504​
505type PaletteItem = CommandPaletteActionItem | CommandPaletteSubmenuItem;
506​
507function rankPaletteItem(
508 item: PaletteItem,
509 normalizedQuery: string,
510 now: number,
511 options: { readonly typos: boolean },
512): {
513 readonly score: number;
514 readonly quality: number;
515 readonly groupQuality?: number;
516 readonly strong: boolean;
517 readonly ranges: ReadonlyArray<SearchRange>;
518 readonly explain?: () => SearchMatchHint | undefined;
519 readonly pickedAt?: number;
520 readonly pickedExactly?: boolean;
521} | null {
522 const match = scorePaletteItem(item, normalizedQuery, now, options);
523 // A row listed last in its group (a keybinding setting, the open thread)
524 // does not decide where the group sits.
525 return match && item.secondary ? { ...match, groupQuality: 0 } : match;
527​
528function scorePaletteItem(
529 item: PaletteItem,
530 normalizedQuery: string,
531 now: number,
532 options: { readonly typos: boolean },
533) {
534 if (item.threadSearch) return scoreThreadSearch(normalizedQuery, item.threadSearch, now, options);
535 // The title names the item; search terms are keywords. Ranges index the
536 // first term, so they only highlight a title that is that term.
537 const name = typeof item.title === "string" ? item.title : item.searchTitle;
538 const terms =
539 name !== undefined && name !== item.searchTerms[0]
540 ? [name, ...item.searchTerms]
541 : item.searchTerms;
542 const match = scoreSearchTerms(normalizedQuery, terms, options);
543 return match && (item.title === terms[0] ? match : { ...match, ranges: [] });
545​
546/**
547 * Equal match classes put commands before threads, so "new" runs "New thread".
548 * Server message matches stay in a final group so late results cannot move earlier rows.
549 */
550export function filterCommandPaletteGroups(input: {
551 activeGroups: ReadonlyArray<CommandPaletteGroup>;
552 query: string;
553 isInSubmenu: boolean;
554 /** Submenu options join the root search, and a submenu with no match falls back to it. */
555 allOptions?: {
556 readonly rootGroups: ReadonlyArray<CommandPaletteGroup>;
557 readonly nestedItems: ReadonlyArray<CommandPaletteActionItem>;
558 };
559 projectSearchItems: ReadonlyArray<CommandPaletteActionItem>;
560 settingsSearchItems?: ReadonlyArray<CommandPaletteActionItem>;
561 threadSearchItems: ReadonlyArray<CommandPaletteActionItem>;
562 /** Threads whose messages match, best first; threads already listed are skipped. */
563 threadMessageItems?: ReadonlyArray<CommandPaletteActionItem>;
564 now?: number;
565}): CommandPaletteGroup[] {
566 if (input.allOptions && input.isInSubmenu) {
567 const { allOptions, ...submenuInput } = input;
568 const scoped = filterCommandPaletteGroups(submenuInput);
569 if (scoped.length > 0 || input.query.trim().length === 0) return scoped;
570 return filterCommandPaletteGroups({
571 ...input,
572 activeGroups: allOptions.rootGroups,
573 isInSubmenu: false,
574 });
575 }
576​
577 const isActionsFilter = input.query.startsWith(">");
578 const searchQuery = isActionsFilter ? input.query.slice(1) : input.query;
579 const normalizedQuery = foldSearchQuery(searchQuery);
580​
581 if (normalizedQuery.length === 0) {
582 if (isActionsFilter) {
583 return input.activeGroups.filter((group) => group.value === "actions");
584 }
585 return [...input.activeGroups];
586 }
587​
588 let baseGroups = [...input.activeGroups];
589 if (isActionsFilter) {
590 baseGroups = baseGroups.filter((group) => group.value === "actions");
591 } else if (!input.isInSubmenu) {
592 baseGroups = baseGroups.filter((group) => group.value !== "recent-threads");
593 }
594​
595 const searchableGroups = [...baseGroups];
596 const searchesEverything = !input.isInSubmenu && !isActionsFilter;
597 // On equal match classes, groups keep this order: threads, what people search
598 // for most, right after commands.
599 if (searchesEverything) {
600 if (input.allOptions && input.allOptions.nestedItems.length > 0) {
601 searchableGroups.push({
602 value: "nested-search",
603 label: "Suggestions",
604 items: input.allOptions.nestedItems,
605 });
606 }
607 if (input.threadSearchItems.length > 0) {
608 searchableGroups.push({
609 value: "threads-search",
610 label: "Threads",
611 items: input.threadSearchItems,
612 });
613 }
614 if (input.projectSearchItems.length > 0) {
615 searchableGroups.push({
616 value: "projects-search",
617 label: "Projects",
618 items: input.projectSearchItems,
619 });
620 }
621 if (input.settingsSearchItems && input.settingsSearchItems.length > 0) {
622 searchableGroups.push({
623 value: "settings-search",
624 label: "Settings",
625 items: input.settingsSearchItems,
626 });
627 }
628 }
629​
630 const now = input.now ?? Date.now();
631 const ranked = rankSearchGroups(
632 searchableGroups.map((group) => ({ group, items: group.items })),
633 (item, options) => rankPaletteItem(item, normalizedQuery, now, options),
634 );
635 const threadsMatch = ranked.some(({ group }) => group.value === "threads-search");
636 const groups = ranked.map(({ group, ranked }): CommandPaletteGroup => {
637 // Secondary items stay below the rest; the sort is stable, so score order holds.
638 const limit =
639 group.value === "threads-search"
640 ? PALETTE_THREAD_LIMIT
641 : group.value === "nested-search" || (searchesEverything && threadsMatch)
642 ? PALETTE_SECONDARY_LIMIT
643 : ranked.length;
644 // Hints are built only for the rows shown.
645 const items = ranked
646 .toSorted(
647 (left, right) =>
648 Number(left.item.secondary ?? false) - Number(right.item.secondary ?? false),
649 )
650 .slice(0, limit)
651 .map(({ item, match }) => {
652 if (match.ranges.length > 0) return item;
653 // Built only for the rows shown.
654 const matchHint = match.explain?.();
655 return matchHint ? { ...item, matchHint } : item;
656 });
657 return { value: group.value, label: group.label, items };
658 });
659​
660 if (!searchesEverything) return groups;
661 const listed = new Set(groups.flatMap((group) => group.items.map((item) => item.value)));
662 // The items arrive cut to what the palette shows; see `nextShownOrder`.
663 const messageItems = (input.threadMessageItems ?? []).filter((item) => !listed.has(item.value));
664 return messageItems.length === 0
665 ? groups
666 : [...groups, { value: "threads-messages", label: "In messages", items: messageItems }];
668​
669/** Every option inside a submenu as one row, e.g. "New thread in t3code" or "Change theme: Dracula". */
670export function buildNestedSearchItems(
671 items: ReadonlyArray<CommandPaletteActionItem | CommandPaletteSubmenuItem>,
672 query: string,
673): CommandPaletteActionItem[] {
674 const nameOf = (item: CommandPaletteItem) =>
675 typeof item.title === "string" ? item.title : item.searchTitle;
676 const topLevelNames = new Set(items.map(nameOf));
677 const foldedQuery = foldSearchQuery(query);
678 return items.flatMap((parent) => {
679 if (parent.kind !== "submenu" || typeof parent.title !== "string") return [];
680 const stem = parent.title.replace(/(\.\.\.|…)$/u, "");
681 // A query that only names the submenu ("new thread in") wants the submenu row itself.
682 if (foldSearchQuery(stem).startsWith(foldedQuery)) return [];
683 const prefix = stem === parent.title ? `${stem}: ` : `${stem.trimEnd()} `;
684 return parent.groups.flatMap((group) =>
685 group.items.flatMap((child) => {
686 const name = nameOf(child);
687 if (child.kind !== "action" || name === undefined) return [];
688 const title = `${prefix}${name}`;
689 if (topLevelNames.has(title)) return [];
690 const { searchTitle: _searchTitle, ...rest } = child;
691 // Only the combined title: child keywords like "theme" would list every theme.
692 return [{ ...rest, title, searchTerms: [title] }];
693 }),
694 );
695 });
697​
698export function buildBrowseGroups(input: {
699 browseEntries: ReadonlyArray<FilesystemBrowseEntry>;
700 browseQuery: string;
701 canBrowseUp: boolean;
702 upIcon: ReactNode;
703 directoryIcon: ReactNode;
704 browseUp: () => void | Promise<void>;
705 browseTo: (name: string) => void | Promise<void>;
706}): CommandPaletteGroup[] {
707 const items: CommandPaletteActionItem[] = [];
708​
709 if (input.canBrowseUp) {
710 items.push({
711 kind: "action",
712 value: "browse:up",
713 searchTerms: [input.browseQuery, ".."],
714 title: "..",
715 icon: input.upIcon,
716 keepOpen: true,
717 run: async () => {
718 await input.browseUp();
719 },
720 });
721 }
722​
723 for (const entry of input.browseEntries) {
724 items.push({
725 kind: "action",
726 value: `browse:${entry.fullPath}`,
727 searchTerms: [input.browseQuery, entry.fullPath, entry.name],
728 title: entry.name,
729 icon: input.directoryIcon,
730 keepOpen: true,
731 run: async () => {
732 await input.browseTo(entry.name);
733 },
734 });
735 }
736​
737 return [{ value: "directories", label: "Directories", items }];
739​
740export function filterPinnedBrowseEntries(input: {
741 browseEntries: ReadonlyArray<FilesystemBrowseEntry>;
742 filterQuery: string;
743 pinnedDirectoryName: string;
744 caseSensitive: boolean;
745}): ReturnType<typeof filterFilesystemBrowseEntries> {
746 const namesMatch = (left: string, right: string) =>
747 input.caseSensitive ? left === right : left.toLowerCase() === right.toLowerCase();
748 const visibleFilterQuery = namesMatch(input.filterQuery, input.pinnedDirectoryName)
749 ? ""
750 : input.filterQuery;
751 const { visibleEntries } = filterFilesystemBrowseEntries(input.browseEntries, visibleFilterQuery);
752 const exactEntry =
753 input.filterQuery.length > 0
754 ? (input.browseEntries.find((entry) => namesMatch(entry.name, input.filterQuery)) ?? null)
755 : null;
756 return { visibleEntries, exactEntry };
758​
759export function getCommandPaletteMode(input: {
760 currentView: CommandPaletteView | null;
761 isBrowsing: boolean;
762}): CommandPaletteMode {
763 if (input.currentView) {
764 return input.isBrowsing ? "submenu-browse" : "submenu";
765 }
766 return input.isBrowsing ? "root-browse" : "root";
768​
769export function buildRootGroups(input: {
770 actionItems: ReadonlyArray<CommandPaletteActionItem | CommandPaletteSubmenuItem>;
771 recentThreadItems: ReadonlyArray<CommandPaletteActionItem>;
772}): CommandPaletteGroup[] {
773 const groups: CommandPaletteGroup[] = [];
774 if (input.actionItems.length > 0) {
775 groups.push({ value: "actions", label: "Actions", items: input.actionItems });
776 }
777 if (input.recentThreadItems.length > 0) {
778 groups.push({
779 value: "recent-threads",
780 label: "Recent Threads",
781 items: input.recentThreadItems,
782 });
783 }
784 return groups;
786​
787export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): string {
788 switch (mode) {
789 case "root":
790 return "Search commands, projects, and threads...";
791 case "root-browse":
792 return "Enter project path (e.g. ~/projects/my-app)";
793 case "submenu":
794 return "Search...";
795 case "submenu-browse":
796 return "Enter path (e.g. ~/projects/my-app)";
797 }
799​
800export function getEnvironmentBrowsePlatform(os: string | null | undefined): string {
801 if (os === "windows") {
802 return "Win32";
803 }
804 if (os === "darwin") {
805 return "MacIntel";
806 }
807 if (os === "linux") {
808 return "Linux";
809 }
810 return typeof navigator === "undefined" ? "" : navigator.platform;
812​
813export interface AddProjectEnvironmentOption {
814 readonly environmentId: EnvironmentId;
815 readonly label: string;
816 readonly machine: EnvironmentMachineKind;
817 readonly isPrimary: boolean;
818 readonly isConnected: boolean;
819 readonly status: string;
821​
822export type AddProjectCloneFlow =
823 | {
824 readonly step: "repository";
825 readonly environmentId: EnvironmentId;
826 readonly source: AddProjectRemoteSource;
827 }
828 | {
829 readonly step: "confirm";
830 readonly environmentId: EnvironmentId;
831 readonly source: AddProjectRemoteSource;
832 readonly repositoryInput: string;
833 readonly repository: SourceControlRepositoryInfo | null;
834 readonly remoteUrl: string;
835 };
836​
837export function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string | null {
838 if (!flow) return null;
839 if (flow.step === "confirm") return null;
840 if (flow.source === "url") {
841 return "Enter Git clone URL";
842 }
843 return `Enter ${addProjectRemoteSourceLabel(flow.source)} repository (${addProjectRemoteSourcePathHint(flow.source)})`;
845​
846export function buildPaletteRemoteSourceReadiness(
847 discovery: SourceControlDiscoveryResult | null,
848): AddProjectRemoteSourceReadiness {
849 const readiness = buildAddProjectRemoteSourceReadiness(discovery);
850 return (source) => {
851 const entry = readiness(source);
852 return entry.hint === null
853 ? entry
854 : {
855 ...entry,
856 hint: entry.hint.replace(
857 "Open source control settings",
858 "Open Settings -> Source control",
859 ),
860 };
861 };
863​
864export function errorMessage(error: unknown): string {
865 if (error instanceof Error && error.message.trim().length > 0) {
866 return error.message;
867 }
868 return "An error occurred.";
870​
871const OVERLAY_MODE_BY_COMMAND = {
872 "commandPalette.toggle": "command",
873 "filePicker.toggle": "files",
874 "projectSearch.toggle": "content",
875} as const satisfies Partial<Record<string, SearchOverlayMode>>;
876​
877export function overlayModeForCommand(command: string | null): SearchOverlayMode | null {
878 if (command === null) return null;
879 return command in OVERLAY_MODE_BY_COMMAND
880 ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND]
881 : null;
883​
884export function resolveCommandPaletteProject<TProject>(
885 projects: ReadonlyMap<string, TProject>,
886 environmentId: EnvironmentId | null,
887 projectId: ProjectId | null,
888) {
889 return environmentId && projectId ? projects.get(`${environmentId}:${projectId}`) : undefined;
891​
892/**
893 * Where "No project" goes among the new-thread picker's real projects: at the
894 * scratch project's own place when it is pinned or current, else after the
895 * pinned projects and the first unpinned one.
896 */
897export function newThreadNoProjectIndex(
898 entries: ReadonlyArray<{
899 readonly isScratch: boolean;
900 readonly pinned: boolean;
901 readonly isPreferred: boolean;
902 }>,
903): number {
904 const projects = entries.filter((entry) => !entry.isScratch);
905 const scratchAt = entries.findIndex((entry) => entry.isScratch);
906 const scratch = entries[scratchAt];
907 if (scratch && (scratch.pinned || scratch.isPreferred)) {
908 return entries.slice(0, scratchAt).filter((entry) => !entry.isScratch).length;
909 }
910 const pinnedCount = projects.filter((entry) => entry.pinned).length;
911 return Math.min(pinnedCount + 1, projects.length);