* Global capture-phase Tab interceptor for the CodeMirror-Vim ex prompt. * * Keystrokes on the prompt input bubble up like any DOM event, but CM-Vim * also registers its own keydown listener on the same input. When two * listeners share a target, they fire in registration order — meaning * CM-V
()
| 1210 | * first match, first-Shift-Tab on the last — matches vim's wildmenu. |
| 1211 | */ |
| 1212 | function installExTabCompletion(): void { |
| 1213 | if (exTabListenerInstalled) return |
| 1214 | exTabListenerInstalled = true |
| 1215 | if (typeof window === 'undefined') return |
| 1216 | |
| 1217 | window.addEventListener( |
| 1218 | 'keydown', |
| 1219 | (e) => { |
| 1220 | if (e.key !== 'Tab') { |
| 1221 | // Reset cycle state when the user types anything else — but only |
| 1222 | // if the event target IS the ex prompt input. Keys elsewhere in |
| 1223 | // the app shouldn't clobber our state (modifier keys fire even |
| 1224 | // when the input is focused too, so we ignore those explicitly |
| 1225 | // instead of resetting on them). |
| 1226 | if (e.key === 'Shift' || e.key === 'Control' || e.key === 'Alt' || e.key === 'Meta') { |
| 1227 | return |
| 1228 | } |
| 1229 | const target = e.target as HTMLElement | null |
| 1230 | if (target && target instanceof HTMLInputElement) { |
| 1231 | if (target.closest('.cm-vim-panel')) { |
| 1232 | // Mark the cycle as expired so the next Tab re-seeds from |
| 1233 | // the freshly-typed prefix. `null` means "no cycle in |
| 1234 | // progress" — an empty-string prefix would look identical |
| 1235 | // to the legitimate "Tab with no input" case. |
| 1236 | if (exCycle && exCycle.input === target) exCycle.basePrefix = null |
| 1237 | // Enter / Escape / navigation keys dismiss the popup. Plain |
| 1238 | // character input also hides it — we want a clean slate on |
| 1239 | // the next Tab so the popup reflects the new prefix. |
| 1240 | hideWildmenu() |
| 1241 | } |
| 1242 | } |
| 1243 | return |
| 1244 | } |
| 1245 | |
| 1246 | const target = e.target as HTMLElement | null |
| 1247 | if (!target || !(target instanceof HTMLInputElement)) return |
| 1248 | if (!target.closest('.cm-vim-panel')) return |
| 1249 | |
| 1250 | // This is our prompt — take over Tab handling entirely. |
| 1251 | e.preventDefault() |
| 1252 | e.stopPropagation() |
| 1253 | e.stopImmediatePropagation() |
| 1254 | |
| 1255 | const step = e.shiftKey ? -1 : 1 |
| 1256 | const fresh = !exCycle || exCycle.input !== target || exCycle.basePrefix === null |
| 1257 | |
| 1258 | if (fresh) { |
| 1259 | exCycle = { |
| 1260 | input: target, |
| 1261 | // Seed from whatever the user typed. `target.value` is the |
| 1262 | // current content of the prompt, which on a fresh cycle is |
| 1263 | // exactly the prefix we want to filter by. |
| 1264 | basePrefix: target.value, |
| 1265 | matches: computeExMatches(target.value), |
| 1266 | cycleIdx: step === 1 ? 0 : -1 // sentinel; normalized below |
| 1267 | } |
| 1268 | } else if (exCycle) { |
| 1269 | exCycle.cycleIdx += step |
no test coverage detected