1import { useTicket } from "@/composables/useTicket";
2import { router } from "@/router";
3import { capture } from "@/telemetry";
4import { __ } from "@/translation";
5import { useDebounceFn } from "@vueuse/core";
6import { createResource, toast } from "frappe-ui";
7import { computed, ref, shallowRef } from "vue";
8import {
9 buildFallbackCommands,
10 buildRootCommands,
11 recentTicketCommands,
12} from "./commands";
13import { scoreCommand } from "./fuzzyScore";
14import {
15 GROUP,
16 type Command,
17 type PaletteContext,
18 type SearchItem,
19} from "./paletteTypes";
20import { searchCommands } from "./searchCommands";
21import { ticketCommands } from "./ticketCommands";
22
23// Module scope: the sidebar button, Cmd+K and the dialog drive one instance.
24export const isOpen = ref(false);
25export const query = ref("");
26
27interface Level {
28 title: string;
29 commands: Command[];
30}
31
32export interface PaletteGroup {
33 title: string;
34 items: Command[];
35}
36
37const stack = shallowRef<Level[]>([]); // drill-down levels, empty = root
38const loadingChildren = ref(false);
39
40export const breadcrumb = computed(() =>
41 stack.value.map((level) => level.title)
42);
43export const depth = computed(() => stack.value.length);
44
45// --- context -------------------------------------------------------------
46
47const contextDismissed = ref(false);
48
49/** The open ticket, or null once dismissed. Route-derived so it can't go stale. */
50export const context = computed<PaletteContext | null>(() => {
51 if (contextDismissed.value) return null;
52 const route = router.currentRoute.value;
53 if (route.name !== "TicketAgent") return null;
54 const id = String(route.params.ticketId);
55 return {
56 id,
57 label: `#${id}`,
58 title: useTicket(id).ticket.doc?.subject ?? "",
59 };
60});
61
62/** Drops the chip and widens the list back out to every global command. */
63export function dismissContext(): void {
64 contextDismissed.value = true;
65 query.value = "";
66 clearSearch();
67}
68
69export function openPalette(): void {
70 isOpen.value = true;
71 // Every open path routes through here, Cmd+K included, so the count is honest.
72 capture("command_palette_opened", {
73 data: { context: String(router.currentRoute.value.name ?? "") },
74 });
75}
76
77export function closePalette(): void {
78 isOpen.value = false;
79}
80
81export function resetPalette(): void {
82 stack.value = [];
83 query.value = "";
84 contextDismissed.value = false;
85 clearSearch();
86}
87
88/** Pops one drill-down level. Backspace on an empty query calls this. */
89export function back(): void {
90 if (!stack.value.length) return;
91 stack.value = stack.value.slice(0, -1);
92 query.value = "";
93 clearSearch(); // else the pre-drill-down hits linger under an empty query
94}
95
96// A second Enter mid-await stacked a duplicate level or double-toggled a tag.
97let running = false;
98
99/** Drills in when the command has children, otherwise performs it and closes. */
100export async function run(command: Command): Promise<void> {
101 if (!command || running) return;
102 if (command.children) {
103 loadingChildren.value = true;
104 running = true;
105 try {
106 const commands = await command.children();
107 stack.value = [...stack.value, { title: command.title, commands }];
108 query.value = "";
109 clearSearch();
110 } catch {
111 toast.error(__("Could not load {0}", [command.title]));
112 } finally {
113 loadingChildren.value = false;
114 running = false;
115 }
116 return;
117 }
118 capture("command_palette_command_run", {
119 data: {
120 command_id: command.id,
121 query_length: query.value.length,
122 depth: depth.value,
123 },
124 });
125 if (command.keepOpen) {
126 // Optimistic: waiting for the round trip made the row look like it ignored Enter.
127 flipChecked(command);
128 running = true;
129 try {
130 await command.perform?.();
131 back(); // done toggling — return to the parent level
132 } catch {
133 flipChecked(command);
134 } finally {
135 running = false;
136 }
137 return;
138 }
139 closePalette();
140 command.perform?.();
141}
142
143/** Flips a row's tick in the current level without rebuilding it. */
144function flipChecked(target: Command): void {
145 const level = stack.value.at(-1);
146 if (!level) return;
147 const commands = level.commands.map((command) =>
148 command.id === target.id
149 ? { ...command, checked: !command.checked }
150 : command
151 );
152 stack.value = [...stack.value.slice(0, -1), { ...level, commands }];
153}
154
155// Frozen while closed: skips rebuilds for users who never open the palette, and
156// keeps the rows on screen through the close fade instead of "No commands found".
157let lastGroups: PaletteGroup[] = [];
158
159/** Visible rows for the current level, grouped, ordered by best score inside. */
160export const groups = computed<PaletteGroup[]>(() => {
161 if (!isOpen.value) return lastGroups;
162 return (lastGroups = buildGroups());
163});
164
165function buildGroups(): PaletteGroup[] {
166 const level = stack.value.at(-1);
167 if (level) {
168 // A single group's header only repeats the breadcrumb; Settings keeps its many.
169 const grouped = groupCommands(level.commands, query.value);
170 return grouped.length === 1
171 ? grouped.map((group) => ({ ...group, title: "" }))
172 : grouped;
173 }
174
175 // The chip already says what "Ticket" rows act on; named sections keep headers.
176 const scoped = context.value
177 ? groupCommands(ticketCommands(context.value.id), query.value).map(
178 (group) =>
179 group.title === __(GROUP.ticket) ? { ...group, title: "" } : group
180 )
181 : [];
182
183 const term = query.value.trim();
184 // Chip + empty query: ticket rows then recents. No chip: the global root.
185 if (!term && context.value)
186 return [...scoped, ...groupCommands(recentTicketCommands(), "")];
187 if (!term) return groupCommands(globalCommands(), "");
188
189 // Scope *ranks*, never *gates*: returning scoped-only made a ticket whose
190 // subject contains "open" unreachable behind "Set status: Open".
191 return [
192 ...scoped,
193 ...groupCommands(globalCommands(), term),
194 ...fallback(term),
195 ];
196}
197
198/** Pinned, not last-resort: almost any word fuzzy-matches *something*, so an
199 * only-when-empty fallback would hide exactly when needed. */
200function fallback(term: string): PaletteGroup[] {
201 if (term.length < MIN_QUERY_LENGTH) return [];
202 return [{ title: "", items: buildFallbackCommands(term) }];
203}
204
205function globalCommands(): Command[] {
206 return [
207 ...buildRootCommands(query.value),
208 ...searchCommands(searchResults.value),
209 ];
210}
211
212function groupCommands(source: Command[], term: string): PaletteGroup[] {
213 const typed = Boolean(term.trim());
214 const matched = source
215 .filter((command) => typed || !command.hideWhenEmpty)
216 .map((command, index) => ({
217 command,
218 score: scoreCommand(command, term),
219 index,
220 }))
221 .filter((entry) => entry.score >= 0)
222 .sort((a, b) => b.score - a.score || a.index - b.index);
223
224 const byGroup = new Map<string, Command[]>();
225 for (const { command } of matched) {
226 if (!byGroup.has(command.group)) byGroup.set(command.group, []);
227 byGroup.get(command.group)!.push(command);
228 }
229 return [...byGroup].map(([title, items]) => ({ title: __(title), items }));
230}
231
232export const flatItems = computed(() => groups.value.flatMap((g) => g.items));
233export const isLoading = computed(
234 () => loadingChildren.value || searchLoading.value
235);
236
237// --- ticket search -------------------------------------------------------
238
239export const searchResults = shallowRef<SearchItem[]>([]);
240const searchLoading = ref(false);
241let latestSearchToken = 0;
242
243const MIN_QUERY_LENGTH = 2;
244const SEARCH_RESULT_LIMIT = 6;
245
246const searchResource = createResource({ url: "opendesk.api.search.search" });
247
248const debouncedSearch = useDebounceFn(async (term: string, token: number) => {
249 try {
250 const response = await searchResource.submit({
251 query: term,
252 title_only: true,
253 });
254 if (token !== latestSearchToken) return; // a newer keystroke won
255 searchResults.value = (response?.results ?? []).slice(
256 0,
257 SEARCH_RESULT_LIMIT
258 );
259 } catch {
260 if (token === latestSearchToken) searchResults.value = [];
261 } finally {
262 if (token === latestSearchToken) searchLoading.value = false;
263 }
264}, 300);
265
266/** Token advances on every change, so a response can't land under a newer query. */
267export function onQueryChange(term: string): void {
268 query.value = term;
269 latestSearchToken += 1;
270 const trimmed = term.trim();
271 if (stack.value.length || trimmed.length < MIN_QUERY_LENGTH) {
272 searchResults.value = [];
273 searchLoading.value = false;
274 return;
275 }
276 // Old rows stay until new ones land; clearing here blanked the list per keystroke.
277 searchLoading.value = true;
278 debouncedSearch(trimmed, latestSearchToken);
279}
280
281/** Drops the current results *and* any response still in flight. */
282export function clearSearch(): void {
283 latestSearchToken += 1;
284 searchResults.value = [];
285 searchLoading.value = false;
286}
287