All files / src/ui CommandPalette.ts

29.28% Statements 41/140
74.07% Branches 20/27
75% Functions 3/4
29.28% Lines 41/140

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219                              2x 2x 2x                                                             2x 9x 9x 7x 7x 7x 9x     1x                         7x 7x 7x 7x 7x 7x     7x 7x 7x 6x 6x 7x 6x 7x 5x 5x 5x 7x 7x 2x 2x 2x 7x     5x 3x 5x 5x 6x 6x   2x                                                                                                                                                                                                                                    
/**
 * The command palette (⌘P / Ctrl+P).
 *
 * Matching takes a subsequence and not only a substring: `wm` finds "whole
 * metagenome" and `ctb` finds "contigs table". That is what makes a palette
 * faster than the menu it replaces — you type the shape of the command, not its
 * spelling — and a whole word still wins over a shape, because a reader who
 * types a word they can see on screen is naming a command rather than sketching
 * one ({@link scoreCommand}).
 *
 * Disabled commands stay listed and unselectable. A palette whose contents
 * change as you navigate cannot be learned, and "why is this greyed out" is a
 * better question for a user to have than "where did it go".
 */
 
import { Modal } from './Modal'
import { el } from './dom'
import { groupRank, keyCap, stateChip, type Command } from './commands'
 
/**
 * Score a command against a query. `null` means no match; lower is better.
 *
 * The score counts the *gaps* the match had to jump, so `contigs` beats `cs` on
 * "Contigs table" even though both span the same six characters — the first is
 * the word, the second is two letters that happen to sit at its ends. A prefix
 * wins outright and a whole word inside the title comes next, because a reader
 * who types a word they can see on screen is not typing the shape of a
 * command: they are naming one, and the one they named has to be first.
 *
 * ## The title, and the group as a leading word
 *
 * The haystack used to be `${group} ${title}`, which let the group's letters
 * take part in any match: `fold` reached *File · Copy this plot to the
 * clipboard* through the F of FILE, and a row with no visible relation to the
 * word typed reads as a bug about the rows beside it. So the title is what is
 * matched, and the group is a **leading word** only — `file copy` still finds
 * it, and typing a group's name or the start of one still lists that group.
 *
 * ## Every anchor, not the first
 *
 * The subsequence used to start at the *first* occurrence of the needle's first
 * letter, so a first letter that happens to appear early — in another word, or
 * in the group — stretched the span across the whole title: `folder` anchored on
 * the F of FILE and scored 25 against the command whose name it is, where *Fold
 * or show the General panel* scored 16 and won the list. The match is tried from
 * every occurrence and the tightest span kept, which is what the gap count was
 * always meant to measure.
 */
export function scoreCommand(command: Command, query: string): number | null {
  const needle = query.trim().toLowerCase()
  if (needle === '') return 0
  const title = command.title.toLowerCase()
  const direct = scoreTitle(title, needle)
  const rest = afterGroupWord(needle, command.group.toLowerCase())
  if (rest === null) return direct
  // The group named and nothing after it: every command in it matches, above
  // the scattered-letter matches and below a command the letters actually name.
  if (rest === '') return direct === null ? 0 : Math.min(direct, 0)
  const scored = scoreTitle(title, rest)
  if (direct === null) return scored
  if (scored === null) return direct
  return Math.min(direct, scored)
}
 
/**
 * The needle with its leading group word taken off, or `null` where it has none.
 *
 * `''` where the group is the whole of it (`help`, or `f` for File), which is a
 * match on every command in that group rather than on a title.
 */
function afterGroupWord(needle: string, group: string): string | null {
  const space = needle.indexOf(' ')
  const head = space < 0 ? needle : needle.slice(0, space)
  if (head === '' || !group.startsWith(head)) return null
  return space < 0 ? '' : needle.slice(space + 1).trim()
}
 
/** A prefix, then a whole substring, then the tightest subsequence. */
function scoreTitle(title: string, needle: string): number | null {
  if (title.startsWith(needle)) return -1
  if (title.includes(needle)) return -0.5
  const chars = [...needle]
  const first = chars[0]
  if (first === undefined) return 0
  let best: number | null = null
  for (let start = title.indexOf(first); start >= 0; start = title.indexOf(first, start + 1)) {
    let at = start
    let ok = true
    for (const char of chars.slice(1)) {
      at = title.indexOf(char, at + 1)
      if (at < 0) {
        ok = false
        break
      }
    }
    // A later anchor can only push every following letter later, so a match
    // that fails from here fails from every anchor after it.
    if (!ok) break
    const span = at - start - (chars.length - 1)
    if (best === null || span < best) best = span
  }
  return best
}
 
export function openCommandPalette(commands: Command[], onClose?: () => void): Modal {
  let selection = 0
  let visible: Command[] = []
  // "Search commands" is the command that opened this, and a palette that
  // offers to open the palette is the one row in it that cannot mean anything.
  const offered = commands.filter((command) => command.id !== 'palette')
 
  // The words the header's own field and the command that opens this both use.
  // The box said *Type a command…* under a title reading *Commands*, opened
  // from a field reading *Search commands…*: three names for one box, two of
  // them on screen at once.
  const input = el('input', {
    class: 'input',
    type: 'text',
    placeholder: 'Search commands…',
    attrs: { 'aria-label': 'search commands' },
  })
  const list = el('div', { class: 'palette-list', attrs: { role: 'listbox' } })
 
  const modal = new Modal({
    title: 'Search commands',
    variant: 'modal-palette',
    ...(onClose === undefined ? {} : { onClose }),
    body: (close) => {
      const run = (command: Command): void => {
        if (command.enabled === false) return
        close()
        command.run()
      }
 
      const render = (): void => {
        const query = input.value
        visible = offered
          .map((command) => ({ command, score: scoreCommand(command, query) }))
          .filter((entry): entry is { command: Command; score: number } => entry.score !== null)
          // With nothing typed every score is 0, and `sort` on equal keys left
          // the list in registry order — App's two commands, then File's nine,
          // then whatever the session had appended since. Unqueried, the list is
          // the reference table and is printed in the reference order; once
          // something is typed, the match is what the reader is looking at and
          // the group is only the tie-break.
          .sort((a, b) => a.score - b.score || groupRank(a.command.group) - groupRank(b.command.group))
          .map((entry) => entry.command)
        if (selection >= visible.length) selection = Math.max(0, visible.length - 1)
 
        list.replaceChildren(
          ...(visible.length === 0
            ? [el('p', { class: 'hint', text: 'No command matches that.' })]
            : visible.map((command, index) => {
                const row = el(
                  'div',
                  {
                    class: `palette-row${index === selection ? ' selected' : ''}${
                      command.enabled === false ? ' disabled' : ''
                    }`,
                    attrs: { role: 'option', 'aria-selected': String(index === selection) },
                  },
                  el(
                    'div',
                    { class: 'palette-text' },
                    el('span', { class: 'palette-group', text: command.group }),
                    el('span', { class: 'palette-title', text: command.title }),
                    ...(command.hint === undefined
                      ? []
                      : [el('span', { class: 'palette-hint', text: command.hint })]),
                  ),
                  ...stateChip(command),
                  ...(command.keys === undefined
                    ? []
                    : [el('kbd', { class: 'kbd' }, ...keyCap(command.keys))]),
                )
                row.addEventListener('mousemove', () => {
                  if (selection === index) return
                  selection = index
                  render()
                })
                row.addEventListener('click', () => run(command))
                return row
              })),
        )
        list.querySelector('.palette-row.selected')?.scrollIntoView({ block: 'nearest' })
      }
 
      input.addEventListener('input', () => {
        selection = 0
        render()
      })
      input.addEventListener('keydown', (event) => {
        switch (event.key) {
          case 'ArrowDown':
            event.preventDefault()
            selection = Math.min(selection + 1, visible.length - 1)
            render()
            return
          case 'ArrowUp':
            event.preventDefault()
            selection = Math.max(selection - 1, 0)
            render()
            return
          case 'Enter': {
            event.preventDefault()
            const chosen = visible[selection]
            if (chosen !== undefined) run(chosen)
          }
        }
      })
 
      render()
      return el('div', { class: 'palette-body' }, input, list)
    },
  })
 
  return modal
}