2017-11-15 13:41:04 -08:00
|
|
|
/*
|
|
|
|
|
|
|
|
Have an array of all completion sources. Completion sources display nothing if the filter doesn't match for them.
|
|
|
|
|
|
|
|
On each input event, call updateCompletions on the array. That will mutate the array and update the display as required.
|
|
|
|
|
|
|
|
How to handle cached e.g. buffer information going out of date?
|
|
|
|
|
2018-06-19 07:59:39 +02:00
|
|
|
Concrete completion classes have been moved to src/completions/.
|
|
|
|
|
2017-11-15 13:41:04 -08:00
|
|
|
*/
|
|
|
|
|
2018-04-13 19:28:03 +01:00
|
|
|
import * as Fuse from "fuse.js"
|
|
|
|
import { enumerate } from "./itertools"
|
|
|
|
import { toNumber } from "./convert"
|
2018-09-29 15:57:09 -07:00
|
|
|
import * as config from "@src/lib/config"
|
2018-09-29 15:32:13 -07:00
|
|
|
import * as aliases from "@src/lib/aliases"
|
2017-11-15 13:41:04 -08:00
|
|
|
|
2018-06-19 07:59:39 +02:00
|
|
|
export const DEFAULT_FAVICON = browser.extension.getURL(
|
|
|
|
"static/defaultFavicon.svg",
|
|
|
|
)
|
2017-11-15 13:41:04 -08:00
|
|
|
|
|
|
|
// {{{ INTERFACES
|
|
|
|
|
2018-04-13 19:28:03 +01:00
|
|
|
type OptionState = "focused" | "hidden" | "normal"
|
2017-11-15 13:41:04 -08:00
|
|
|
|
2018-06-19 07:59:39 +02:00
|
|
|
export abstract class CompletionOption {
|
2017-11-22 18:05:54 +00:00
|
|
|
/** What to fill into cmdline */
|
|
|
|
value: string
|
|
|
|
/** Control presentation of the option */
|
|
|
|
state: OptionState
|
2017-11-15 13:41:04 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
export abstract class CompletionSource {
|
2017-11-22 18:05:54 +00:00
|
|
|
readonly options: CompletionOption[]
|
|
|
|
node: HTMLElement
|
|
|
|
public completion: string
|
2018-08-17 22:56:12 +02:00
|
|
|
protected prefixes: string[] = []
|
|
|
|
|
|
|
|
constructor(prefixes) {
|
2018-08-18 07:15:14 +02:00
|
|
|
let commands = aliases.getCmdAliasMapping()
|
2018-08-17 22:56:12 +02:00
|
|
|
|
|
|
|
// Now, for each prefix given as argument, add it to the completionsource's prefix list and also add any alias it has
|
|
|
|
prefixes.map(p => p.trim()).forEach(p => {
|
|
|
|
this.prefixes.push(p)
|
|
|
|
if (commands[p]) this.prefixes = this.prefixes.concat(commands[p])
|
|
|
|
})
|
|
|
|
|
|
|
|
// Not sure this is necessary but every completion source has it
|
|
|
|
this.prefixes = this.prefixes.map(p => p + " ")
|
|
|
|
}
|
2017-11-22 18:05:54 +00:00
|
|
|
|
|
|
|
/** Update [[node]] to display completions relevant to exstr */
|
|
|
|
public abstract filter(exstr: string): Promise<void>
|
|
|
|
|
|
|
|
private _state: OptionState
|
|
|
|
|
|
|
|
/** Control presentation of Source */
|
|
|
|
set state(newstate: OptionState) {
|
|
|
|
switch (newstate) {
|
2018-04-13 19:28:03 +01:00
|
|
|
case "normal":
|
|
|
|
this.node.classList.remove("hidden")
|
2017-11-22 18:05:54 +00:00
|
|
|
this.completion = undefined
|
|
|
|
break
|
2018-04-13 19:28:03 +01:00
|
|
|
case "hidden":
|
|
|
|
this.node.classList.add("hidden")
|
|
|
|
break
|
2017-11-22 18:05:54 +00:00
|
|
|
}
|
|
|
|
this._state = newstate
|
|
|
|
}
|
2017-11-15 13:41:04 -08:00
|
|
|
|
2017-11-22 18:05:54 +00:00
|
|
|
get state() {
|
|
|
|
return this._state
|
|
|
|
}
|
2017-11-23 15:44:07 +00:00
|
|
|
|
2017-11-29 23:47:40 +00:00
|
|
|
abstract next(inc?: number): boolean
|
2017-11-23 15:44:07 +00:00
|
|
|
|
2017-11-24 19:00:26 +00:00
|
|
|
prev(inc = 1): boolean {
|
2018-04-13 19:28:03 +01:00
|
|
|
return this.next(-1 * inc)
|
2017-11-24 19:00:26 +00:00
|
|
|
}
|
2017-11-22 18:05:54 +00:00
|
|
|
}
|
2017-11-15 13:41:04 -08:00
|
|
|
|
2017-11-22 18:05:54 +00:00
|
|
|
// Default classes
|
|
|
|
|
2018-06-19 07:59:39 +02:00
|
|
|
export abstract class CompletionOptionHTML extends CompletionOption {
|
2017-11-22 18:05:54 +00:00
|
|
|
public html: HTMLElement
|
|
|
|
public value
|
|
|
|
|
2018-04-13 19:28:03 +01:00
|
|
|
private _state: OptionState = "hidden"
|
2017-11-22 18:05:54 +00:00
|
|
|
|
|
|
|
/** Control presentation of element */
|
|
|
|
set state(newstate: OptionState) {
|
2017-11-24 18:46:49 +00:00
|
|
|
// console.log("state from to", this._state, newstate)
|
2017-11-22 18:05:54 +00:00
|
|
|
switch (newstate) {
|
2018-04-13 19:28:03 +01:00
|
|
|
case "focused":
|
|
|
|
this.html.classList.add("focused")
|
2018-05-01 11:44:35 +02:00
|
|
|
this.html.scrollIntoView()
|
2018-04-13 19:28:03 +01:00
|
|
|
this.html.classList.remove("hidden")
|
2017-11-22 18:05:54 +00:00
|
|
|
break
|
2018-04-13 19:28:03 +01:00
|
|
|
case "normal":
|
|
|
|
this.html.classList.remove("focused")
|
|
|
|
this.html.classList.remove("hidden")
|
|
|
|
break
|
|
|
|
case "hidden":
|
|
|
|
this.html.classList.remove("focused")
|
|
|
|
this.html.classList.add("hidden")
|
2017-11-22 18:05:54 +00:00
|
|
|
break
|
|
|
|
}
|
2017-11-24 18:46:49 +00:00
|
|
|
this._state = newstate
|
|
|
|
}
|
|
|
|
|
|
|
|
get state() {
|
|
|
|
return this._state
|
2017-11-22 18:05:54 +00:00
|
|
|
}
|
|
|
|
}
|
2017-11-15 13:41:04 -08:00
|
|
|
|
2018-06-19 07:59:39 +02:00
|
|
|
export interface CompletionOptionFuse extends CompletionOptionHTML {
|
2017-11-22 18:05:54 +00:00
|
|
|
// For fuzzy matching
|
|
|
|
fuseKeys: any[]
|
|
|
|
}
|
2017-11-15 13:41:04 -08:00
|
|
|
|
2018-06-19 07:59:39 +02:00
|
|
|
export type ScoredOption = {
|
2018-04-13 19:28:03 +01:00
|
|
|
index: number
|
|
|
|
option: CompletionOptionFuse
|
2017-11-22 18:05:54 +00:00
|
|
|
score: number
|
|
|
|
}
|
|
|
|
|
2018-06-19 07:59:39 +02:00
|
|
|
export abstract class CompletionSourceFuse extends CompletionSource {
|
2017-11-22 18:05:54 +00:00
|
|
|
public node
|
|
|
|
public options: CompletionOptionFuse[]
|
2017-11-22 18:13:31 +00:00
|
|
|
protected lastExstr: string
|
2017-11-24 18:46:49 +00:00
|
|
|
protected lastFocused: CompletionOption
|
2017-11-22 18:05:54 +00:00
|
|
|
|
2017-11-23 01:09:10 +00:00
|
|
|
protected optionContainer = html`<table class="optionContainer">`
|
2017-11-22 18:05:54 +00:00
|
|
|
|
2018-08-17 22:56:12 +02:00
|
|
|
constructor(prefixes, className: string, title?: string) {
|
|
|
|
super(prefixes)
|
2018-04-13 19:28:03 +01:00
|
|
|
this.node = html`<div class="${className} hidden">
|
2017-11-22 18:05:54 +00:00
|
|
|
<div class="sectionHeader">${title || className}</div>
|
|
|
|
</div>`
|
|
|
|
this.node.appendChild(this.optionContainer)
|
2018-04-13 19:28:03 +01:00
|
|
|
this.state = "hidden"
|
2017-11-22 18:05:54 +00:00
|
|
|
}
|
|
|
|
|
2017-11-22 18:13:31 +00:00
|
|
|
/* abstract onUpdate(query: string, prefix: string, options: CompletionOptionFuse[]) */
|
|
|
|
abstract onInput(exstr: string)
|
2017-11-22 18:05:54 +00:00
|
|
|
|
2017-11-22 18:13:31 +00:00
|
|
|
// Helpful default implementations
|
|
|
|
|
|
|
|
public async filter(exstr: string) {
|
|
|
|
this.lastExstr = exstr
|
2018-06-19 08:29:24 +02:00
|
|
|
await this.onInput(exstr)
|
|
|
|
await this.updateChain()
|
2017-11-22 18:13:31 +00:00
|
|
|
}
|
2018-04-13 19:28:03 +01:00
|
|
|
|
2017-11-22 18:13:31 +00:00
|
|
|
updateChain(exstr = this.lastExstr, options = this.options) {
|
|
|
|
if (options === undefined) {
|
2018-04-13 19:28:03 +01:00
|
|
|
this.state = "hidden"
|
2017-11-22 18:13:31 +00:00
|
|
|
return
|
2017-11-22 18:05:54 +00:00
|
|
|
}
|
|
|
|
|
2017-11-22 18:13:31 +00:00
|
|
|
const [prefix, query] = this.splitOnPrefix(exstr)
|
|
|
|
|
2017-11-24 18:46:49 +00:00
|
|
|
// console.log(prefix, query, options)
|
2017-11-22 18:13:31 +00:00
|
|
|
|
|
|
|
// Hide self and stop if prefixes don't match
|
2017-11-22 18:05:54 +00:00
|
|
|
if (prefix) {
|
2017-11-22 18:13:31 +00:00
|
|
|
// Show self if prefix and currently hidden
|
2018-04-13 19:28:03 +01:00
|
|
|
if (this.state === "hidden") {
|
|
|
|
this.state = "normal"
|
2017-11-22 18:13:31 +00:00
|
|
|
}
|
2017-11-22 18:05:54 +00:00
|
|
|
} else {
|
2018-04-13 19:28:03 +01:00
|
|
|
this.state = "hidden"
|
2017-11-22 18:05:54 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2017-11-22 18:13:31 +00:00
|
|
|
// Filter by query if query is not empty
|
|
|
|
if (query) {
|
|
|
|
this.setStateFromScore(this.scoredOptions(query))
|
2018-04-13 19:28:03 +01:00
|
|
|
// Else show all options
|
2017-11-22 18:13:31 +00:00
|
|
|
} else {
|
2018-04-13 19:28:03 +01:00
|
|
|
options.forEach(option => (option.state = "normal"))
|
2017-11-22 18:13:31 +00:00
|
|
|
}
|
|
|
|
|
2017-11-22 18:05:54 +00:00
|
|
|
// Call concrete class
|
2017-11-22 18:13:31 +00:00
|
|
|
this.updateDisplay()
|
|
|
|
}
|
|
|
|
|
|
|
|
select(option: CompletionOption) {
|
|
|
|
if (this.lastExstr !== undefined && option !== undefined) {
|
|
|
|
const [prefix, _] = this.splitOnPrefix(this.lastExstr)
|
|
|
|
this.completion = prefix + option.value
|
2018-04-13 19:28:03 +01:00
|
|
|
option.state = "focused"
|
2017-11-24 18:46:49 +00:00
|
|
|
this.lastFocused = option
|
2017-11-22 18:13:31 +00:00
|
|
|
} else {
|
|
|
|
throw new Error("lastExstr and option must be defined!")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
deselect() {
|
|
|
|
this.completion = undefined
|
2017-11-24 18:46:49 +00:00
|
|
|
if (this.lastFocused != undefined) this.lastFocused.state = "normal"
|
2017-11-22 18:13:31 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
splitOnPrefix(exstr: string) {
|
|
|
|
for (const prefix of this.prefixes) {
|
|
|
|
if (exstr.startsWith(prefix)) {
|
2018-04-13 19:28:03 +01:00
|
|
|
const query = exstr.replace(prefix, "")
|
2017-11-22 18:13:31 +00:00
|
|
|
return [prefix, query]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return [undefined, undefined]
|
2017-11-22 18:05:54 +00:00
|
|
|
}
|
2017-11-24 18:46:49 +00:00
|
|
|
|
|
|
|
fuseOptions = {
|
|
|
|
keys: ["fuseKeys"],
|
|
|
|
shouldSort: true,
|
|
|
|
id: "index",
|
|
|
|
includeScore: true,
|
|
|
|
}
|
|
|
|
|
|
|
|
// PERF: Could be expensive not to cache Fuse()
|
|
|
|
// yeah, it was.
|
|
|
|
fuse = undefined
|
2017-11-22 18:05:54 +00:00
|
|
|
|
|
|
|
/** Rtn sorted array of {option, score} */
|
2017-11-22 18:13:31 +00:00
|
|
|
scoredOptions(query: string, options = this.options): ScoredOption[] {
|
2018-07-07 20:10:42 +02:00
|
|
|
let searchThis = this.options.map((elem, index) => {
|
|
|
|
return { index, fuseKeys: elem.fuseKeys }
|
|
|
|
})
|
|
|
|
this.fuse = new Fuse(searchThis, this.fuseOptions)
|
|
|
|
return this.fuse.search(query).map(res => {
|
|
|
|
let result = res as any
|
|
|
|
// console.log(result, result.item, query)
|
|
|
|
let index = toNumber(result.item)
|
|
|
|
return {
|
|
|
|
index,
|
|
|
|
option: this.options[index],
|
|
|
|
score: result.score as number,
|
2017-11-24 18:46:49 +00:00
|
|
|
}
|
2018-07-07 20:10:42 +02:00
|
|
|
})
|
2017-11-22 18:05:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/** Set option state by score
|
|
|
|
|
|
|
|
For now just displays all scored elements (see threshold in fuse) and
|
|
|
|
focus the best match.
|
|
|
|
*/
|
2017-11-24 18:46:49 +00:00
|
|
|
setStateFromScore(scoredOpts: ScoredOption[], autoselect = false) {
|
2017-11-22 18:05:54 +00:00
|
|
|
let matches = scoredOpts.map(res => res.index)
|
|
|
|
|
|
|
|
for (const [index, option] of enumerate(this.options)) {
|
2018-04-13 19:28:03 +01:00
|
|
|
if (matches.includes(index)) option.state = "normal"
|
|
|
|
else option.state = "hidden"
|
2017-11-22 18:05:54 +00:00
|
|
|
}
|
|
|
|
|
2017-11-24 18:46:49 +00:00
|
|
|
// ideally, this would not deselect anything unless it fell off the list of matches
|
|
|
|
if (matches.length && autoselect) {
|
2017-11-22 18:13:31 +00:00
|
|
|
this.select(this.options[matches[0]])
|
|
|
|
} else {
|
|
|
|
this.deselect()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Call to replace the current display */
|
|
|
|
// TODO: optionContainer.replaceWith and optionContainer.remove don't work.
|
|
|
|
// I don't know why, but it means we can't replace the div in one go. Maybe
|
|
|
|
// an iframe thing.
|
|
|
|
updateDisplay() {
|
|
|
|
/* const newContainer = html`<div>` */
|
|
|
|
|
|
|
|
while (this.optionContainer.hasChildNodes()) {
|
|
|
|
this.optionContainer.removeChild(this.optionContainer.lastChild)
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const option of this.options) {
|
|
|
|
/* newContainer.appendChild(option.html) */
|
2018-04-13 19:28:03 +01:00
|
|
|
if (option.state != "hidden")
|
|
|
|
this.optionContainer.appendChild(option.html)
|
2017-11-22 18:05:54 +00:00
|
|
|
}
|
2017-11-22 18:13:31 +00:00
|
|
|
|
|
|
|
/* console.log('updateDisplay', this.optionContainer, newContainer) */
|
|
|
|
|
|
|
|
/* let result1 = this.optionContainer.remove() */
|
|
|
|
/* let res2 = this.node.appendChild(newContainer) */
|
|
|
|
/* console.log('results', result1, res2) */
|
2017-11-22 18:05:54 +00:00
|
|
|
}
|
2017-11-23 15:44:07 +00:00
|
|
|
|
2018-04-13 19:28:03 +01:00
|
|
|
next(inc = 1) {
|
|
|
|
if (this.state != "hidden") {
|
|
|
|
let visopts = this.options.filter(o => o.state != "hidden")
|
|
|
|
let currind = visopts.findIndex(o => o.state == "focused")
|
2017-11-24 18:46:49 +00:00
|
|
|
this.deselect()
|
2018-03-15 18:49:35 +01:00
|
|
|
// visopts.length + 1 because we want an empty completion at the end
|
|
|
|
let max = visopts.length + 1
|
|
|
|
let opt = visopts[(currind + inc + max) % max]
|
2018-05-01 11:44:35 +02:00
|
|
|
if (opt) this.select(opt)
|
2017-11-24 18:46:49 +00:00
|
|
|
return true
|
|
|
|
} else return false
|
2017-11-23 15:44:07 +00:00
|
|
|
}
|
2017-11-15 13:41:04 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
// }}}
|
|
|
|
|
|
|
|
// {{{ UNUSED: MANAGING ASYNC CHANGES
|
|
|
|
|
2017-11-22 18:05:54 +00:00
|
|
|
/** If first to modify epoch, commit change. May want to change epoch after commiting. */
|
2018-04-13 19:28:03 +01:00
|
|
|
async function commitIfCurrent(
|
|
|
|
epochref: any,
|
|
|
|
asyncFunc: Function,
|
|
|
|
commitFunc: Function,
|
|
|
|
...args: any[]
|
|
|
|
): Promise<any> {
|
2017-11-15 13:41:04 -08:00
|
|
|
// I *think* sync stuff in here is guaranteed to happen immediately after
|
|
|
|
// being called, up to the first await, despite this being an async
|
|
|
|
// function. But I don't know. Should check.
|
|
|
|
const epoch = epochref
|
|
|
|
const res = await asyncFunc(...args)
|
|
|
|
if (epoch === epochref) return commitFunc(res)
|
|
|
|
else console.error(new Error("Update failed: epoch out of date!"))
|
|
|
|
}
|
|
|
|
|
2017-11-22 18:05:54 +00:00
|
|
|
/** Indicate changes to completions we would like.
|
|
|
|
|
|
|
|
This will probably never be used for original designed purpose.
|
|
|
|
*/
|
2017-11-15 13:41:04 -08:00
|
|
|
function updateCompletions(filter: string, sources: CompletionSource[]) {
|
|
|
|
for (let [index, source] of enumerate(sources)) {
|
|
|
|
// Tell each compOpt to filter, and if they finish fast enough they:
|
|
|
|
// 0. Leave a note for any siblings that they got here first
|
|
|
|
// 1. Take over their parent's slot in compOpts
|
|
|
|
// 2. Update their display
|
|
|
|
commitIfCurrent(
|
2018-04-13 19:28:03 +01:00
|
|
|
source.obsolete, // Flag/epoch
|
|
|
|
source.filter, // asyncFunc
|
|
|
|
childSource => {
|
|
|
|
// commitFunc
|
2017-11-15 13:41:04 -08:00
|
|
|
source.obsolete = true
|
|
|
|
sources[index] = childSource
|
|
|
|
childSource.activate()
|
|
|
|
},
|
2018-04-13 19:28:03 +01:00
|
|
|
filter, // argument to asyncFunc
|
2017-11-15 13:41:04 -08:00
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// }}}
|