tridactyl/src/commandline_frame.ts
2020-02-28 21:10:04 +00:00

389 lines
14 KiB
TypeScript

/** # Command line functions
*
* This file contains functions to interact with the command line.
*
* If you want to bind them to keyboard shortcuts, be sure to prefix them with "ex.". For example, if you want to bind control-p to `prev_completion`, use:
*
* ```
* bind --mode=ex <C-p> ex.prev_completion
* ```
*
* Note that you can also bind Tridactyl's [editor functions](/static/docs/modules/_src_lib_editor_.html) in the command line.
*
* Contrary to the main tridactyl help page, this one doesn't tell you whether a specific function is bound to something. For now, you'll have to make do with `:bind` and `:viewconfig`.
*
*/
/** ignore this line */
/** Script used in the commandline iframe. Communicates with background. */
import * as perf from "@src/perf"
import "@src/lib/number.clamp"
import "@src/lib/html-tagged-template"
import { TabAllCompletionSource } from "@src/completions/TabAll"
import { BindingsCompletionSource } from "@src/completions/Bindings"
import { BufferCompletionSource } from "@src/completions/Tab"
import { BmarkCompletionSource } from "@src/completions/Bmark"
import { ExcmdCompletionSource } from "@src/completions/Excmd"
import { CompositeCompletionSource } from "@src/completions/Composite"
import { FileSystemCompletionSource } from "@src/completions/FileSystem"
import { GuisetCompletionSource } from "@src/completions/Guiset"
import { HelpCompletionSource } from "@src/completions/Help"
import { AproposCompletionSource } from "@src/completions/Apropos"
import { HistoryCompletionSource } from "@src/completions/History"
import { PreferenceCompletionSource } from "@src/completions/Preferences"
import { RssCompletionSource } from "@src/completions/Rss"
import { SessionsCompletionSource } from "@src/completions/Sessions"
import { SettingsCompletionSource } from "@src/completions/Settings"
import { WindowCompletionSource } from "@src/completions/Window"
import { ExtensionsCompletionSource } from "@src/completions/Extensions"
import * as Messaging from "@src/lib/messaging"
import "@src/lib/number.clamp"
import state from "@src/state"
import Logger from "@src/lib/logging"
import { theme } from "@src/content/styling"
import * as genericParser from "@src/parsers/genericmode"
import * as tri_editor from "@src/lib/editor"
/** @hidden **/
const logger = new Logger("cmdline")
/** @hidden **/
const commandline_state = {
activeCompletions: undefined,
clInput: (window.document.getElementById("tridactyl-input") as HTMLInputElement),
clear,
cmdline_history_position: 0,
completionsDiv: window.document.getElementById("completions"),
fns: undefined,
getCompletion,
history,
/** @hidden
* This is to handle Escape key which, while the cmdline is focused,
* ends up firing both keydown and input listeners. In the worst case
* hides the cmdline, shows and refocuses it and replaces its text
* which could be the prefix to generate a completion.
* tl;dr TODO: delete this and better resolve race condition
*/
isVisible: false,
keyEvents: new Array<KeyEventLike>(),
refresh_completions,
state,
}
// first theming of commandline iframe
theme(document.querySelector(":root"))
/** @hidden **/
function resizeArea() {
if (commandline_state.isVisible) {
Messaging.messageOwnTab("commandline_content", "show")
Messaging.messageOwnTab("commandline_content", "focus")
focus()
}
}
/** @hidden
* This is a bit loosely defined at the moment.
* Should work so long as there's only one completion source per prefix.
*/
function getCompletion() {
if (!commandline_state.activeCompletions) return undefined
for (const comp of commandline_state.activeCompletions) {
if (comp.state === "normal" && comp.completion !== undefined) {
return comp.completion
}
}
}
commandline_state.getCompletion = getCompletion
/** @hidden **/
export function enableCompletions() {
if (!commandline_state.activeCompletions) {
commandline_state.activeCompletions = [
// FindCompletionSource,
BindingsCompletionSource,
BmarkCompletionSource,
TabAllCompletionSource,
BufferCompletionSource,
ExcmdCompletionSource,
CompositeCompletionSource,
FileSystemCompletionSource,
GuisetCompletionSource,
HelpCompletionSource,
AproposCompletionSource,
HistoryCompletionSource,
PreferenceCompletionSource,
RssCompletionSource,
SessionsCompletionSource,
SettingsCompletionSource,
WindowCompletionSource,
ExtensionsCompletionSource,
]
.map(constructorr => {
try {
return new constructorr(commandline_state.completionsDiv)
} catch (e) {}
})
.filter(c => c)
const fragment = document.createDocumentFragment()
commandline_state.activeCompletions.forEach(comp => fragment.appendChild(comp.node))
commandline_state.completionsDiv.appendChild(fragment)
logger.debug(commandline_state.activeCompletions)
}
}
/* document.addEventListener("DOMContentLoaded", enableCompletions) */
/** @hidden **/
const noblur = e => setTimeout(() => commandline_state.clInput.focus(), 0)
/** @hidden **/
export function focus() {
commandline_state.clInput.focus()
commandline_state.clInput.removeEventListener("blur", noblur)
commandline_state.clInput.addEventListener("blur", noblur)
}
/** @hidden **/
let HISTORY_SEARCH_STRING: string
/** @hidden
* Command line keybindings
**/
const keyParser = keys => genericParser.parser("exmaps", keys)
/** @hidden **/
let history_called = false
/** @hidden **/
let prev_cmd_called_history = false
/** @hidden **/
commandline_state.clInput.addEventListener(
"keydown",
function(keyevent: KeyboardEvent) {
if (!keyevent.isTrusted) return
commandline_state.keyEvents.push(keyevent)
const response = keyParser(commandline_state.keyEvents)
if (response.isMatch) {
keyevent.preventDefault()
keyevent.stopImmediatePropagation()
} else {
// Ideally, all keys that aren't explicitly bound to an ex command
// should be bound to a "self-insert" command that would input the
// key itself. Because it's not possible to generate events as if
// they originated from the user, we can't do this, but we still
// need to simulate it, in order to have history() work.
prev_cmd_called_history = false
}
if (response.value) {
commandline_state.keyEvents = []
history_called = false
// If excmds start with 'ex.' they're coming back to us anyway, so skip that.
// This is definitely a hack. Should expand aliases with exmode, etc.
// but this whole thing should be scrapped soon, so whatever.
if (response.value.startsWith("ex.")) {
const funcname = response.value.slice(3)
commandline_state.fns[funcname]()
prev_cmd_called_history = history_called
} else {
// Send excmds directly to our own tab, which fixes the
// old bug where a command would be issued in one tab but
// land in another because the active tab had
// changed. Background-mode excmds will be received by the
// own tab's content script and then bounced through a
// shim to the background, but the latency increase should
// be acceptable becuase the background-mode excmds tend
// to be a touch less latency-sensitive.
Messaging.messageOwnTab("controller_content", "acceptExCmd", [
response.value,
]).then(_ => (prev_cmd_called_history = history_called))
}
} else {
commandline_state.keyEvents = response.keys
}
},
true,
)
export function refresh_completions(exstr) {
if (!commandline_state.activeCompletions) enableCompletions()
return Promise.all(
commandline_state.activeCompletions.map(comp =>
comp.filter(exstr).then(() => {
if (comp.shouldRefresh()) {
return resizeArea()
}
}),
),
).catch(err => {
console.error(err)
return []
}) // We can't use the regular logging mechanism because the user is using the command line.
}
/** @hidden **/
let onInputPromise: Promise<any> = Promise.resolve()
/** @hidden **/
commandline_state.clInput.addEventListener("input", () => {
const exstr = commandline_state.clInput.value
// Schedule completion computation. We do not start computing immediately because this would incur a slow down on quickly repeated input events (e.g. maintaining <Backspace> pressed)
setTimeout(async () => {
// Make sure the previous computation has ended
await onInputPromise
// If we're not the current completion computation anymore, stop
if (exstr !== commandline_state.clInput.value) return
onInputPromise = refresh_completions(exstr)
}, 100)
})
/** @hidden **/
let cmdline_history_current = ""
/** @hidden
* Clears the command line.
* If you intend to close the command line after this, set evlistener to true in order to enable losing focus.
* Otherwise, no need to pass an argument.
*/
export function clear(evlistener = false) {
if (evlistener) commandline_state.clInput.removeEventListener("blur", noblur)
commandline_state.clInput.value = ""
commandline_state.cmdline_history_position = 0
cmdline_history_current = ""
}
commandline_state.clear = clear
/** @hidden **/
function history(n) {
history_called = true
if (!prev_cmd_called_history) {
HISTORY_SEARCH_STRING = commandline_state.clInput.value
}
const matches = state.cmdHistory.filter(key =>
key.startsWith(HISTORY_SEARCH_STRING),
)
if (commandline_state.cmdline_history_position === 0) {
cmdline_history_current = commandline_state.clInput.value
}
let clamped_ind = matches.length + n - commandline_state.cmdline_history_position
clamped_ind = clamped_ind.clamp(0, matches.length)
const pot_history = matches[clamped_ind]
commandline_state.clInput.value =
pot_history === undefined ? cmdline_history_current : pot_history
// if there was no clampage, update history position
// there's a more sensible way of doing this but that would require more programmer time
if (clamped_ind === matches.length + n - commandline_state.cmdline_history_position)
commandline_state.cmdline_history_position = commandline_state.cmdline_history_position - n
}
commandline_state.history = history
/** @hidden **/
export function fillcmdline(
newcommand?: string,
trailspace = true,
ffocus = true,
) {
if (trailspace) commandline_state.clInput.value = newcommand + " "
else commandline_state.clInput.value = newcommand
commandline_state.isVisible = true
let result = Promise.resolve([])
// Focus is lost for some reason.
if (ffocus) {
focus()
result = refresh_completions(commandline_state.clInput.value)
}
return result
}
/** @hidden
* Create a temporary textarea and give it to fn. Remove the textarea afterwards
*
* Useful for document.execCommand
**/
function applyWithTmpTextArea(fn) {
let textarea
try {
textarea = document.createElement("textarea")
// Scratchpad must be `display`ed, but can be tiny and invisible.
// Being tiny and invisible means it won't make the parent page move.
textarea.style.cssText =
"visible: invisible; width: 0; height: 0; position: fixed"
textarea.contentEditable = "true"
document.documentElement.appendChild(textarea)
return fn(textarea)
} finally {
document.documentElement.removeChild(textarea)
}
}
/** @hidden **/
export async function setClipboard(content: string) {
await Messaging.messageOwnTab("commandline_content", "focus")
applyWithTmpTextArea(scratchpad => {
scratchpad.value = content
scratchpad.select()
if (document.execCommand("Copy")) {
// // todo: Maybe we can consider to using some logger and show it with status bar in the future
logger.info("set clipboard:", scratchpad.value)
} else throw "Failed to copy!"
})
// Return focus to the document
await Messaging.messageOwnTab("commandline_content", "hide")
return Messaging.messageOwnTab("commandline_content", "blur")
}
/** @hidden **/
export async function getClipboard() {
await Messaging.messageOwnTab("commandline_content", "focus")
const result = applyWithTmpTextArea(scratchpad => {
scratchpad.focus()
document.execCommand("Paste")
return scratchpad.textContent
})
// Return focus to the document
await Messaging.messageOwnTab("commandline_content", "hide")
await Messaging.messageOwnTab("commandline_content", "blur")
return result
}
/** @hidden **/
export function getContent() {
return commandline_state.clInput.value
}
/** @hidden **/
export function editor_function(fn_name, ...args) {
let result = Promise.resolve([])
if (tri_editor[fn_name]) {
tri_editor[fn_name](commandline_state.clInput, ...args)
result = refresh_completions(commandline_state.clInput.value)
} else {
// The user is using the command line so we can't log message there
// logger.error(`No editor function named ${fn_name}!`)
console.error(`No editor function named ${fn_name}!`)
}
return result
}
import * as SELF from "@src/commandline_frame"
Messaging.addListener("commandline_frame", Messaging.attributeCaller(SELF))
import { getCommandlineFns } from "@src/lib/commandline_cmds"
import { KeyEventLike } from "./lib/keyseq"
commandline_state.fns = getCommandlineFns(commandline_state)
Messaging.addListener("commandline_cmd", Messaging.attributeCaller(commandline_state.fns))
// Listen for statistics from the commandline iframe and send them to
// the background for collection. Attach the observer to the window
// object since there's apparently a bug that causes performance
// observers to be GC'd even if they're still the target of a
// callback.
; (window as any).tri = Object.assign(window.tri || {}, {
perfObserver: perf.listenForCounters(),
})