about summary refs log tree commit diff stats
path: root/src/local/pager.nim
diff options
context:
space:
mode:
Diffstat (limited to 'src/local/pager.nim')
-rw-r--r--src/local/pager.nim1189
1 files changed, 1189 insertions, 0 deletions
diff --git a/src/local/pager.nim b/src/local/pager.nim
new file mode 100644
index 00000000..8bb1172f
--- /dev/null
+++ b/src/local/pager.nim
@@ -0,0 +1,1189 @@
+import deques
+import net
+import options
+import os
+import osproc
+import streams
+import tables
+import unicode
+
+when defined(posix):
+  import posix
+
+import buffer/cell
+import config/config
+import config/mailcap
+import config/mimetypes
+import display/term
+import extern/editor
+import extern/runproc
+import io/connecterror
+import io/lineedit
+import io/loader
+import io/promise
+import io/request
+import io/tempfile
+import io/window
+import ips/forkserver
+import ips/socketstream
+import js/dict
+import js/javascript
+import js/regex
+import js/tojs
+import local/container
+import local/select
+import types/buffersource
+import types/color
+import types/cookie
+import types/url
+import utils/opt
+import utils/twtstr
+
+import chakasu/charset
+
+type
+  LineMode* = enum
+    NO_LINEMODE, LOCATION, USERNAME, PASSWORD, COMMAND, BUFFER, SEARCH_F,
+    SEARCH_B, ISEARCH_F, ISEARCH_B, GOTO_LINE
+
+  Pager* = ref object
+    alerton: bool
+    alerts: seq[string]
+    askcursor: int
+    askpromise*: Promise[bool]
+    askprompt: string
+    commandMode* {.jsget.}: bool
+    config: Config
+    container*: Container
+    cookiejars: Table[string, CookieJar]
+    display: FixedGrid
+    forkserver: ForkServer
+    iregex: Result[Regex, string]
+    isearchpromise: EmptyPromise
+    lineedit*: Option[LineEdit]
+    linehist: array[LineMode, LineHistory]
+    linemode*: LineMode
+    mailcap: Mailcap
+    mainproc: Pid
+    mimeTypes: MimeTypes
+    numload*: int
+    omnirules: seq[OmniRule]
+    procmap*: Table[Pid, Container]
+    proxy: URL
+    redraw*: bool
+    regex: Opt[Regex]
+    reverseSearch: bool
+    scommand*: string
+    siteconf: seq[SiteConfig]
+    statusgrid*: FixedGrid
+    term*: Terminal
+    tty: File
+    unreg*: seq[(Pid, SocketStream)]
+    username: string
+
+jsDestructor(Pager)
+
+func attrs(pager: Pager): WindowAttributes = pager.term.attrs
+
+func getRoot(container: Container): Container =
+  var c = container
+  while c.parent != nil: c = c.parent
+  return c
+
+iterator all_children(parent: Container): Container {.inline.} =
+  var stack = newSeqOfCap[Container](parent.children.len)
+  for i in countdown(parent.children.high, 0):
+    stack.add(parent.children[i])
+  while stack.len > 0:
+    let c = stack.pop()
+    yield c
+    for i in countdown(c.children.high, 0):
+      stack.add(c.children[i])
+
+iterator containers*(pager: Pager): Container {.inline.} =
+  if pager.container != nil:
+    let root = getRoot(pager.container)
+    yield root
+    for c in root.all_children:
+      yield c
+
+proc setContainer*(pager: Pager, c: Container) {.jsfunc.} =
+  pager.container = c
+  pager.redraw = true
+  if c != nil:
+    pager.term.setTitle(c.getTitle())
+
+proc hasprop(ctx: JSContext, pager: Pager, s: string): bool {.jshasprop.} =
+  if pager.container != nil:
+    let cval = toJS(ctx, pager.container)
+    let val = JS_GetPropertyStr(ctx, cval, s)
+    if val != JS_UNDEFINED:
+      result = true
+    JS_FreeValue(ctx, val)
+
+proc reflect(ctx: JSContext, this_val: JSValue, argc: cint, argv: ptr JSValue,
+             magic: cint, func_data: ptr JSValue): JSValue {.cdecl.} =
+  let fun = cast[ptr JSValue](cast[int](func_data) + sizeof(JSValue))[]
+  return JS_Call(ctx, fun, func_data[], argc, argv)
+
+proc getter(ctx: JSContext, pager: Pager, s: string): Option[JSValue]
+    {.jsgetprop.} =
+  if pager.container != nil:
+    let cval = toJS(ctx, pager.container)
+    let val = JS_GetPropertyStr(ctx, cval, s)
+    if val != JS_UNDEFINED:
+      if JS_IsFunction(ctx, val):
+        var func_data = @[cval, val]
+        let fun = JS_NewCFunctionData(ctx, reflect, 1, 0, 2, addr func_data[0])
+        return some(fun)
+      return some(val)
+
+proc searchNext(pager: Pager) {.jsfunc.} =
+  if pager.regex.isSome:
+    let wrap = pager.config.search.wrap
+    if not pager.reverseSearch:
+      pager.container.cursorNextMatch(pager.regex.get, wrap, true)
+    else:
+      pager.container.cursorPrevMatch(pager.regex.get, wrap, true)
+
+proc searchPrev(pager: Pager) {.jsfunc.} =
+  if pager.regex.isSome:
+    let wrap = pager.config.search.wrap
+    if not pager.reverseSearch:
+      pager.container.cursorPrevMatch(pager.regex.get, wrap, true)
+    else:
+      pager.container.cursorNextMatch(pager.regex.get, wrap, true)
+
+proc getLineHist(pager: Pager, mode: LineMode): LineHistory =
+  if pager.linehist[mode] == nil:
+    pager.linehist[mode] = newLineHistory()
+  return pager.linehist[mode]
+
+proc setLineEdit(pager: Pager, prompt: string, mode: LineMode, current = "", hide = false) =
+  pager.lineedit = some(readLine(prompt, pager.attrs.width, current = current, term = pager.term, hide = hide, hist = pager.getLineHist(mode)))
+  pager.linemode = mode
+
+proc clearLineEdit(pager: Pager) =
+  pager.lineedit = none(LineEdit)
+
+proc searchForward(pager: Pager) {.jsfunc.} =
+  pager.setLineEdit("/", SEARCH_F)
+
+proc searchBackward(pager: Pager) {.jsfunc.} =
+  pager.setLineEdit("?", SEARCH_B)
+
+proc isearchForward(pager: Pager) {.jsfunc.} =
+  pager.container.pushCursorPos()
+  pager.isearchpromise = newResolvedPromise()
+  pager.setLineEdit("/", ISEARCH_F)
+
+proc isearchBackward(pager: Pager) {.jsfunc.} =
+  pager.container.pushCursorPos()
+  pager.isearchpromise = newResolvedPromise()
+  pager.setLineEdit("?", ISEARCH_B)
+
+proc gotoLine[T: string|int](pager: Pager, s: T = "") {.jsfunc.} =
+  when s is string:
+    if s == "":
+      pager.setLineEdit("Goto line: ", GOTO_LINE)
+      return
+  pager.container.gotoLine(s)
+
+proc alert*(pager: Pager, msg: string)
+
+proc newPager*(config: Config, attrs: WindowAttributes,
+    forkserver: ForkServer, mainproc: Pid, ctx: JSContext): Pager =
+  let pager = Pager(
+    config: config,
+    display: newFixedGrid(attrs.width, attrs.height - 1),
+    forkserver: forkserver,
+    mainproc: mainproc,
+    omnirules: config.getOmniRules(ctx),
+    proxy: config.getProxy(),
+    siteconf: config.getSiteConfig(ctx),
+    statusgrid: newFixedGrid(attrs.width),
+    term: newTerminal(stdout, config, attrs),
+    mimeTypes: config.getMimeTypes()
+  )
+  let (mcap, errs) = config.getMailcap()
+  pager.mailcap = mcap
+  for err in errs:
+    pager.alert("Error reading mailcap: " & err)
+  return pager
+
+proc launchPager*(pager: Pager, tty: File) =
+  pager.tty = tty
+  pager.term.start(tty)
+
+proc dumpAlerts*(pager: Pager) =
+  for msg in pager.alerts:
+    stderr.write("cha: " & msg & '\n')
+
+proc quit*(pager: Pager, code = 0) =
+  pager.term.quit()
+  pager.dumpAlerts()
+
+proc clearDisplay(pager: Pager) =
+  pager.display = newFixedGrid(pager.display.width, pager.display.height)
+
+proc buffer(pager: Pager): Container {.jsfget, inline.} = pager.container
+
+proc refreshDisplay(pager: Pager, container = pager.container) =
+  pager.clearDisplay()
+  container.drawLines(pager.display,
+    cellColor(pager.config.display.highlight_color))
+
+# Note: this function doesn't work if start < i of last written char
+proc writeStatusMessage(pager: Pager, str: string,
+                        format: Format = newFormat(), start = 0,
+                        maxwidth = -1, clip = '$'): int {.discardable.} =
+  var maxwidth = maxwidth
+  if maxwidth == -1:
+    maxwidth = pager.statusgrid.len
+  var i = start
+  let e = min(start + maxwidth, pager.statusgrid.width)
+  if i >= e:
+    return i
+  for r in str.runes:
+    let pi = i
+    i += r.twidth(i)
+    if i >= e:
+      if i >= pager.statusgrid.width:
+        i = pi
+      pager.statusgrid[i].format = format
+      pager.statusgrid[i].str = $clip
+      inc i
+      break
+    if r.isControlChar():
+      pager.statusgrid[pi].str = "^" & getControlLetter(char(r))
+    else:
+      pager.statusgrid[pi].str = $r
+    pager.statusgrid[pi].format = format
+  result = i
+  var def = newFormat()
+  while i < e:
+    pager.statusgrid[i].str = ""
+    pager.statusgrid[i].format = def
+    inc i
+
+# Note: should only be called directly after user interaction.
+proc refreshStatusMsg*(pager: Pager) =
+  let container = pager.container
+  if container == nil: return
+  if pager.tty == nil: return
+  if pager.askpromise != nil: return
+  if container.loadinfo != "":
+    pager.alerton = false
+    pager.writeStatusMessage(container.loadinfo)
+  elif pager.alerts.len > 0:
+    pager.alerton = true
+    pager.writeStatusMessage(pager.alerts[0])
+    pager.alerts.delete(0)
+  else:
+    var format = newFormat()
+    format.reverse = true
+    pager.alerton = false
+    container.clearHover()
+    var msg = $(container.cursory + 1) & "/" & $container.numLines & " (" &
+              $container.atPercentOf() & "%)"
+    let mw = pager.writeStatusMessage(msg, format)
+    let title = " <" & container.getTitle() & ">"
+    let hover = container.getHoverText()
+    if hover.len == 0:
+      pager.writeStatusMessage(title, format, mw)
+    else:
+      let hover2 = " " & hover
+      let maxwidth = pager.statusgrid.width - hover2.width() - mw
+      let tw = pager.writeStatusMessage(title, format, mw, maxwidth, '>')
+      pager.writeStatusMessage(hover2, format, tw)
+
+# Call refreshStatusMsg if no alert is being displayed on the screen.
+proc showAlerts*(pager: Pager) =
+  if not pager.alerton:
+    pager.refreshStatusMsg()
+
+proc drawBuffer*(pager: Pager, container: Container, ostream: Stream) =
+  var format = newFormat()
+  container.readLines(proc(line: SimpleFlexibleLine) =
+    if line.formats.len == 0:
+      ostream.write(line.str & "\n")
+    else:
+      var x = 0
+      var w = 0
+      var i = 0
+      var s = ""
+      for f in line.formats:
+        var outstr = ""
+        while x < f.pos:
+          var r: Rune
+          fastRuneAt(line.str, i, r)
+          outstr &= r
+          x += r.width()
+        s &= pager.term.processOutputString(outstr, w)
+        s &= pager.term.processFormat(format, f.format)
+      if i < line.str.len:
+        s &= pager.term.processOutputString(line.str.substr(i), w)
+      s &= pager.term.processFormat(format, newFormat()) & "\n"
+      ostream.write(s))
+  ostream.flush()
+
+proc redraw(pager: Pager) {.jsfunc.} =
+  pager.redraw = true
+  pager.term.clearCanvas()
+
+proc draw*(pager: Pager) =
+  let container = pager.container
+  if container == nil: return
+  pager.term.hideCursor()
+  if pager.redraw:
+    pager.refreshDisplay()
+    pager.term.writeGrid(pager.display)
+  if container.select.open and container.select.redraw:
+    container.select.drawSelect(pager.display)
+    pager.term.writeGrid(pager.display)
+  if pager.askpromise != nil:
+    discard
+  elif pager.lineedit.isSome:
+    if pager.lineedit.get.isnew:
+      #TODO hack
+      # make term notice that it must redraw when status is restored
+      let x = newFixedGrid(pager.attrs.width)
+      pager.term.writeGrid(x, 0, pager.attrs.height - 1)
+  else:
+    pager.term.writeGrid(pager.statusgrid, 0, pager.attrs.height - 1)
+  pager.term.outputGrid()
+  if pager.askpromise != nil:
+    pager.term.setCursor(pager.askcursor, pager.attrs.height - 1)
+  elif pager.lineedit.isSome:
+    if pager.lineedit.get.isnew:
+      #TODO hack
+      pager.term.setCursor(0, pager.attrs.height - 1)
+      pager.lineedit.get.drawPrompt()
+      pager.term.setCursor(pager.lineedit.get.getCursorX(), pager.attrs.height - 1)
+      pager.lineedit.get.fullRedraw()
+      pager.lineedit.get.isnew = false
+    pager.term.setCursor(pager.lineedit.get.getCursorX(), pager.attrs.height - 1)
+  elif container.select.open:
+    pager.term.setCursor(container.select.getCursorX(),
+      container.select.getCursorY())
+  else:
+    pager.term.setCursor(pager.container.acursorx, pager.container.acursory)
+  pager.term.showCursor()
+  pager.term.flush()
+  pager.redraw = false
+
+proc writeAskPrompt(pager: Pager) =
+  let yn = " (y/n)"
+  let maxwidth = pager.statusgrid.width - yn.len
+  let i = pager.writeStatusMessage(pager.askprompt, maxwidth = maxwidth)
+  pager.askcursor = pager.writeStatusMessage(yn, start = i)
+  pager.term.writeGrid(pager.statusgrid, 0, pager.attrs.height - 1)
+
+proc ask(pager: Pager, prompt: string): Promise[bool] {.jsfunc.} =
+  pager.askprompt = prompt
+  pager.writeAskPrompt()
+  pager.askpromise = Promise[bool]()
+  return pager.askpromise
+
+proc fulfillAsk*(pager: Pager, y: bool) =
+  pager.askpromise.resolve(y)
+  pager.askpromise = nil
+  pager.askprompt = ""
+
+proc registerContainer*(pager: Pager, container: Container) =
+  pager.procmap[container.process] = container
+
+proc addContainer*(pager: Pager, container: Container) =
+  container.parent = pager.container
+  if pager.container != nil:
+    pager.container.children.insert(container, 0)
+  pager.registerContainer(container)
+  pager.setContainer(container)
+
+proc newBuffer(pager: Pager, bufferConfig: BufferConfig, source: BufferSource,
+    title = "", redirectdepth = 0): Container =
+  return newBuffer(
+    pager.forkserver,
+    pager.mainproc,
+    bufferConfig,
+    source,
+    title,
+    redirectdepth
+  )
+
+proc dupeBuffer(pager: Pager, container: Container, location: URL,
+    contentType = ""): Container =
+  let contentType = if contentType != "":
+    some(contentType)
+  else:
+    container.contenttype
+  let location = if location != nil:
+    location
+  else:
+    container.source.location
+  let source = BufferSource(
+    t: CLONE,
+    location: location,
+    contenttype: contentType,
+    clonepid: container.process,
+  )
+  let pipeTo = pager.newBuffer(container.config, source, container.title)
+  container.pipeBuffer(pipeTo)
+  return pipeTo
+
+proc dupeBuffer(pager: Pager, location: URL = nil) {.jsfunc.} =
+  pager.addContainer(pager.dupeBuffer(pager.container, location))
+
+# The prevBuffer and nextBuffer procedures emulate w3m's PREV and NEXT
+# commands by traversing the container tree in a depth-first order.
+proc prevBuffer(pager: Pager): bool {.jsfunc.} =
+  if pager.container == nil:
+    return false
+  if pager.container.parent == nil:
+    return false
+  let n = pager.container.parent.children.find(pager.container)
+  assert n != -1, "Container not a child of its parent"
+  if n > 0:
+    var container = pager.container.parent.children[n - 1]
+    while container.children.len > 0:
+      container = container.children[^1]
+    pager.setContainer(container)
+  else:
+    pager.setContainer(pager.container.parent)
+  return true
+
+proc nextBuffer(pager: Pager): bool {.jsfunc.} =
+  if pager.container == nil:
+    return false
+  if pager.container.children.len > 0:
+    pager.setContainer(pager.container.children[0])
+    return true
+  var container = pager.container
+  while container.parent != nil:
+    let n = container.parent.children.find(container)
+    assert n != -1, "Container not a child of its parent"
+    if n < container.parent.children.high:
+      pager.setContainer(container.parent.children[n + 1])
+      return true
+    container = container.parent
+  return false
+
+proc parentBuffer(pager: Pager): bool {.jsfunc.} =
+  if pager.container == nil:
+    return false
+  if pager.container.parent == nil:
+    return false
+  pager.setContainer(pager.container.parent)
+  return true
+
+proc prevSiblingBuffer(pager: Pager): bool {.jsfunc.} =
+  if pager.container == nil:
+    return false
+  if pager.container.parent == nil:
+    return false
+  var n = pager.container.parent.children.find(pager.container)
+  assert n != -1, "Container not a child of its parent"
+  if n == 0:
+    n = pager.container.parent.children.len
+  pager.setContainer(pager.container.parent.children[n - 1])
+  return true
+
+proc nextSiblingBuffer(pager: Pager): bool {.jsfunc.} =
+  if pager.container == nil:
+    return false
+  if pager.container.parent == nil:
+    return false
+  var n = pager.container.parent.children.find(pager.container)
+  assert n != -1, "Container not a child of its parent"
+  if n == pager.container.parent.children.high:
+    n = -1
+  pager.setContainer(pager.container.parent.children[n + 1])
+  return true
+
+proc alert*(pager: Pager, msg: string) {.jsfunc.} =
+  pager.alerts.add(msg)
+
+proc deleteContainer(pager: Pager, container: Container) =
+  container.cancel()
+  if container.sourcepair != nil:
+    container.sourcepair.sourcepair = nil
+    container.sourcepair = nil
+  if container.parent != nil:
+    let parent = container.parent
+    let n = parent.children.find(container)
+    assert n != -1, "Container not a child of its parent"
+    for i in countdown(container.children.high, 0):
+      let child = container.children[i]
+      child.parent = container.parent
+      parent.children.insert(child, n + 1)
+    parent.children.delete(n)
+    if container == pager.container:
+      if n == 0:
+        pager.setContainer(parent)
+      else:
+        pager.setContainer(parent.children[n - 1])
+  elif container.children.len > 0:
+    let parent = container.children[0]
+    parent.parent = nil
+    for i in 1..container.children.high:
+      container.children[i].parent = parent
+      parent.children.add(container.children[i])
+    if container == pager.container:
+      pager.setContainer(parent)
+  else:
+    for child in container.children:
+      child.parent = nil
+    if container == pager.container:
+      pager.setContainer(nil)
+  container.parent = nil
+  container.children.setLen(0)
+  pager.unreg.add((container.process, SocketStream(container.iface.stream)))
+  pager.forkserver.removeChild(container.process)
+
+proc discardBuffer(pager: Pager, container = none(Container)) {.jsfunc.} =
+  let c = container.get(pager.container)
+  if c == nil or c.parent == nil and c.children.len == 0:
+    pager.alert("Cannot discard last buffer!")
+  else:
+    pager.deleteContainer(c)
+
+proc discardTree(pager: Pager, container = none(Container)) {.jsfunc.} =
+  let container = container.get(pager.container)
+  if container != nil:
+    for c in container.all_children:
+      pager.deleteContainer(c)
+  else:
+    pager.alert("Buffer has no children!")
+
+proc toggleSource(pager: Pager) {.jsfunc.} =
+  if pager.container.sourcepair != nil:
+    pager.setContainer(pager.container.sourcepair)
+  else:
+    let contenttype = if pager.container.contentType.get("") == "text/html":
+      "text/plain"
+    else:
+      "text/html"
+    let container = pager.dupeBuffer(pager.container, nil, contenttype)
+    container.sourcepair = pager.container
+    pager.container.sourcepair = container
+    pager.addContainer(container)
+
+proc windowChange*(pager: Pager, attrs: WindowAttributes) =
+  pager.term.windowChange(attrs)
+  pager.display = newFixedGrid(attrs.width, attrs.height - 1)
+  pager.statusgrid = newFixedGrid(attrs.width)
+  for container in pager.containers:
+    container.windowChange(attrs)
+  if pager.askprompt != "":
+    pager.writeAskPrompt()
+  pager.showAlerts()
+
+# Apply siteconf settings to a request.
+# Note that this may modify the URL passed.
+proc applySiteconf(pager: Pager, url: var URL): BufferConfig =
+  let host = url.host
+  var referer_from: bool
+  var cookiejar: CookieJar
+  var headers = pager.config.getDefaultHeaders()
+  var scripting: bool
+  var images: bool
+  var charsets = pager.config.encoding.document_charset
+  var userstyle = pager.config.css.stylesheet
+  var proxy = pager.proxy
+  let mimeTypes = pager.mimeTypes
+  for sc in pager.siteconf:
+    if sc.url.isSome and not sc.url.get.match($url):
+      continue
+    elif sc.host.isSome and not sc.host.get.match(host):
+      continue
+    if sc.rewrite_url != nil:
+      let s = sc.rewrite_url(url)
+      if s.isSome and s.get != nil:
+        url = s.get
+    if sc.cookie.isSome:
+      if sc.cookie.get:
+        # host/url might have changed by now
+        let jarid = sc.share_cookiejar.get(url.host)
+        if jarid notin pager.cookiejars:
+          pager.cookiejars[jarid] = newCookieJar(url,
+            sc.third_party_cookie)
+        cookiejar = pager.cookiejars[jarid]
+      else:
+        cookiejar = nil # override
+    if sc.scripting.isSome:
+      scripting = sc.scripting.get
+    if sc.referer_from.isSome:
+      referer_from = sc.referer_from.get
+    if sc.document_charset.len > 0:
+      charsets = sc.document_charset
+    if sc.images.isSome:
+      images = sc.images.get
+    if sc.stylesheet.isSome:
+      userstyle &= "\n"
+      userstyle &= sc.stylesheet.get
+    if sc.proxy.isSome:
+      proxy = sc.proxy.get
+  return pager.config.getBufferConfig(url, cookiejar, headers, referer_from,
+    scripting, charsets, images, userstyle, proxy, mimeTypes)
+
+# Load request in a new buffer.
+proc gotoURL(pager: Pager, request: Request, prevurl = none(URL),
+    ctype = none(string), cs = CHARSET_UNKNOWN, replace: Container = nil,
+    redirectdepth = 0, referrer: Container = nil) =
+  if referrer != nil and referrer.config.referer_from:
+    request.referer = referrer.source.location
+  var bufferconfig = pager.applySiteconf(request.url)
+  if prevurl.isnone or not prevurl.get.equals(request.url, true) or
+      request.url.hash == "" or request.httpmethod != HTTP_GET:
+    # Basically, we want to reload the page *only* when
+    # a) we force a reload (by setting prevurl to none)
+    # b) or the new URL isn't just the old URL + an anchor
+    # I think this makes navigation pretty natural, or at least very close to
+    # what other browsers do. Still, it would be nice if we got some visual
+    # feedback on what is actually going to happen when typing a URL; TODO.
+    let source = BufferSource(
+      t: LOAD_REQUEST,
+      request: request,
+      contenttype: ctype,
+      charset: cs,
+      location: request.url
+    )
+    if referrer != nil:
+      bufferconfig.referrerpolicy = referrer.config.referrerpolicy
+    let container = pager.newBuffer(
+      bufferconfig,
+      source,
+      redirectdepth = redirectdepth
+    )
+    if replace != nil:
+      container.replace = replace
+      container.copyCursorPos(container.replace)
+    pager.addContainer(container)
+    inc pager.numload
+  else:
+    pager.container.findAnchor(request.url.anchor)
+
+proc omniRewrite(pager: Pager, s: string): string =
+  for rule in pager.omnirules:
+    if rule.match.match(s):
+      let sub = rule.substitute_url(s)
+      if sub.isSome:
+        return sub.get
+      else:
+        pager.alert("Error in substitution of rule " & rule.match.buf & " for " & s)
+  return s
+
+# When the user has passed a partial URL as an argument, they might've meant
+# either:
+# * file://$PWD/<file>
+# * https://<url>
+# So we attempt to load both, and see what works.
+proc loadURL*(pager: Pager, url: string, ctype = none(string),
+    cs = CHARSET_UNKNOWN) =
+  let url0 = pager.omniRewrite(url)
+  let url = if url[0] == '~': expandPath(url0) else: url0
+  let firstparse = parseURL(url)
+  if firstparse.issome:
+    let prev = if pager.container != nil:
+      some(pager.container.source.location)
+    else:
+      none(URL)
+    pager.gotoURL(newRequest(firstparse.get), prev, ctype, cs)
+    return
+  var urls: seq[URL]
+  if pager.config.network.prepend_https and url[0] != '/':
+    let pageurl = parseURL("https://" & url)
+    if pageurl.isSome: # attempt to load remote page
+      urls.add(pageurl.get)
+  let cdir = parseURL("file://" & percentEncode(getCurrentDir(), LocalPathPercentEncodeSet) & DirSep)
+  let localurl = percentEncode(url, LocalPathPercentEncodeSet)
+  let newurl = parseURL(localurl, cdir)
+  if newurl.isSome:
+    urls.add(newurl.get) # attempt to load local file
+  if urls.len == 0:
+    pager.alert("Invalid URL " & url)
+  else:
+    let prevc = pager.container
+    pager.gotoURL(newRequest(urls.pop()), ctype = ctype, cs = cs)
+    if pager.container != prevc:
+      pager.container.retry = urls
+
+proc readPipe0*(pager: Pager, ctype: Option[string], cs: Charset,
+    fd: FileHandle, location: Option[URL], title: string): Container =
+  var location = location.get(newURL("file://-").get)
+  let bufferconfig = pager.applySiteconf(location)
+  let source = BufferSource(
+    t: LOAD_PIPE,
+    fd: fd,
+    contenttype: some(ctype.get("text/plain")),
+    charset: cs,
+    location: location
+  )
+  return pager.newBuffer(bufferconfig, source, title = title)
+
+proc readPipe*(pager: Pager, ctype: Option[string], cs: Charset,
+    fd: FileHandle) =
+  let container = pager.readPipe0(ctype, cs, fd, none(URL), "*pipe*")
+  pager.addContainer(container)
+
+proc command(pager: Pager) {.jsfunc.} =
+  pager.setLineEdit("COMMAND: ", COMMAND)
+
+proc commandMode(pager: Pager, val: bool) {.jsfset.} =
+  pager.commandMode = val
+  if val:
+    pager.command()
+
+proc checkRegex(pager: Pager, regex: Result[Regex, string]): Opt[Regex] =
+  if regex.isErr:
+    pager.alert("Invalid regex: " & regex.error)
+    return err()
+  return ok(regex.get)
+
+proc updateReadLineISearch(pager: Pager, linemode: LineMode) =
+  let lineedit = pager.lineedit.get
+  pager.isearchpromise = pager.isearchpromise.then(proc(): EmptyPromise =
+    case lineedit.state
+    of CANCEL:
+      pager.iregex.err()
+      pager.container.popCursorPos()
+      pager.container.clearSearchHighlights()
+      pager.redraw = true
+      pager.isearchpromise = nil
+    of EDIT:
+      let x = $lineedit.news
+      if x != "": pager.iregex = compileSearchRegex(x)
+      pager.container.popCursorPos(true)
+      pager.container.pushCursorPos()
+      if pager.iregex.isSome:
+        pager.container.hlon = true
+        let wrap = pager.config.search.wrap
+        return if linemode == ISEARCH_F:
+          pager.container.cursorNextMatch(pager.iregex.get, wrap, false)
+        else:
+          pager.container.cursorPrevMatch(pager.iregex.get, wrap, false)
+    of FINISH:
+      pager.regex = pager.checkRegex(pager.iregex)
+      pager.reverseSearch = linemode == ISEARCH_B
+      pager.container.clearSearchHighlights()
+      pager.container.sendCursorPosition()
+      pager.redraw = true
+      pager.isearchpromise = nil
+  )
+
+proc updateReadLine*(pager: Pager) =
+  let lineedit = pager.lineedit.get
+  template s: string = $lineedit.news
+  if pager.linemode in {ISEARCH_F, ISEARCH_B}:
+    pager.updateReadLineISearch(pager.linemode)
+  else:
+    case lineedit.state
+    of EDIT: return
+    of FINISH:
+      case pager.linemode
+      of LOCATION: pager.loadURL(s)
+      of USERNAME:
+        pager.username = s
+        pager.setLineEdit("Password: ", PASSWORD, hide = true)
+      of PASSWORD:
+        let url = newURL(pager.container.source.location)
+        url.username = pager.username
+        url.password = s
+        pager.username = ""
+        pager.gotoURL(newRequest(url), some(pager.container.source.location), replace = pager.container, referrer = pager.container)
+      of COMMAND:
+        pager.scommand = s
+        if pager.commandMode:
+          pager.command()
+      of BUFFER: pager.container.readSuccess(s)
+      of SEARCH_F, SEARCH_B:
+        let x = s
+        if x != "":
+          pager.regex = pager.checkRegex(compileSearchRegex(x))
+        pager.reverseSearch = pager.linemode == SEARCH_B
+        pager.searchNext()
+      of GOTO_LINE:
+        pager.container.gotoLine(s)
+      else: discard
+    of CANCEL:
+      case pager.linemode
+      of USERNAME: pager.discardBuffer()
+      of PASSWORD:
+        pager.username = ""
+        pager.discardBuffer()
+      of BUFFER: pager.container.readCanceled()
+      of COMMAND: pager.commandMode = false
+      else: discard
+  if lineedit.state in {CANCEL, FINISH}:
+    if pager.lineedit.get == lineedit:
+      pager.clearLineEdit()
+
+# Open a URL prompt and visit the specified URL.
+proc load(pager: Pager, s = "") {.jsfunc.} =
+  if s.len > 0 and s[^1] == '\n':
+    pager.loadURL(s[0..^2])
+  else:
+    var url = s
+    if url == "":
+      url = pager.container.source.location.serialize()
+    pager.setLineEdit("URL: ", LOCATION, url)
+
+# Reload the page in a new buffer, then kill the previous buffer.
+proc reload(pager: Pager) {.jsfunc.} =
+  pager.gotoURL(newRequest(pager.container.source.location), none(URL),
+    pager.container.contenttype, replace = pager.container)
+
+proc setEnvVars(pager: Pager) {.jsfunc.} =
+  try:
+    putEnv("CHA_URL", $pager.container.location)
+    putEnv("CHA_CHARSET", $pager.container.charset)
+  except OSError:
+    pager.alert("Warning: failed to set some environment variables")
+
+#TODO use default values instead...
+type ExternDict = object of JSDict
+  setenv: Opt[bool]
+  suspend: Opt[bool]
+  wait: bool
+
+#TODO this could be handled much better.
+# * suspend, setenv, wait as dict flags
+# * retval as int?
+proc extern(pager: Pager, cmd: string, t = ExternDict()): bool {.jsfunc.} =
+  if t.setenv.get(true):
+    pager.setEnvVars()
+  if t.suspend.get(true):
+    return runProcess(pager.term, cmd, t.wait)
+  else:
+    return runProcess(cmd)
+
+proc authorize(pager: Pager) =
+  pager.setLineEdit("Username: ", USERNAME)
+
+# Pipe input into the mailcap command, then read its output into a buffer.
+# needsterminal is ignored.
+proc runMailcapReadPipe(pager: Pager, container: Container,
+    entry: MailcapEntry, cmd: string): (EmptyPromise, bool) =
+  var pipefd_in: array[2, cint]
+  if pipe(pipefd_in) == -1:
+    raise newException(Defect, "Failed to open pipe.")
+  var pipefd_out: array[2, cint]
+  if pipe(pipefd_out) == -1:
+    raise newException(Defect, "Failed to open pipe.")
+  let pid = fork()
+  if pid == -1:
+    return (nil, false)
+  elif pid == 0:
+    # child process
+    discard close(pipefd_in[1])
+    discard close(pipefd_out[0])
+    stdout.flushFile()
+    discard dup2(pipefd_in[0], stdin.getFileHandle())
+    discard dup2(pipefd_out[1], stdout.getFileHandle())
+    let devnull = open("/dev/null", O_WRONLY)
+    discard dup2(devnull, stderr.getFileHandle())
+    discard close(devnull)
+    discard close(pipefd_in[0])
+    discard close(pipefd_out[1])
+    discard execCmd(cmd)
+    discard close(stdin.getFileHandle())
+    discard close(stdout.getFileHandle())
+    quit(0)
+  # parent
+  discard close(pipefd_in[0])
+  discard close(pipefd_out[1])
+  let fdin = pipefd_in[1]
+  let fdout = pipefd_out[0]
+  let p = container.redirectToFd(fdin, wait = false)
+  let p2 = p.then(proc(): auto =
+    discard close(fdin)
+    let ishtml = HTMLOUTPUT in entry.flags
+    if ishtml:
+      #TODO this is a hack for dupe buffer and should be reconsidered.
+      container.source.contenttype = some("text/html")
+    return container.readFromFd(fdout, ishtml)
+  ).then(proc() =
+    discard close(fdout)
+  )
+  return (p2, true)
+
+# Pipe input into the mailcap command, and discard its output.
+# If needsterminal, leave stderr and stdout open and wait for the process.
+proc runMailcapWritePipe(pager: Pager, container: Container,
+    entry: MailcapEntry, cmd: string): (EmptyPromise, bool) =
+  let needsterminal = NEEDSTERMINAL in entry.flags
+  var pipefd: array[2, cint]
+  if pipe(pipefd) == -1:
+    raise newException(Defect, "Failed to open pipe.")
+  if needsterminal:
+    pager.term.quit()
+  let pid = fork()
+  if pid == -1:
+    return (nil, false)
+  elif pid == 0:
+    # child process
+    discard close(pipefd[1])
+    discard dup2(pipefd[0], stdin.getFileHandle())
+    if not needsterminal:
+      let devnull = open("/dev/null", O_WRONLY)
+      discard dup2(devnull, stdout.getFileHandle())
+      discard dup2(devnull, stderr.getFileHandle())
+      discard close(devnull)
+    discard close(pipefd[0])
+    discard execCmd(cmd)
+    discard close(stdin.getFileHandle())
+    quit(0)
+  else:
+    # parent
+    discard close(pipefd[0])
+    let fd = pipefd[1]
+    let p = container.redirectToFd(fd, wait = false)
+    discard close(fd)
+    if needsterminal:
+      var x: cint
+      discard waitpid(pid, x, 0)
+      pager.term.restart()
+    return (p, false)
+
+# Save input in a file, run the command, and redirect its output to a
+# new buffer.
+# needsterminal is ignored.
+proc runMailcapReadFile(pager: Pager, container: Container,
+    entry: MailcapEntry, cmd, outpath: string): (EmptyPromise, bool) =
+  let fd = open(outpath, O_WRONLY or O_CREAT, 0o666)
+  if fd == -1:
+    return (nil, false)
+  let p = container.redirectToFd(fd, wait = true).then(proc(): auto =
+    var pipefd: array[2, cint] # redirect stdout here
+    if pipe(pipefd) == -1:
+      raise newException(Defect, "Failed to open pipe.")
+    let pid = fork()
+    if pid == 0:
+      # child process
+      discard close(pipefd[0])
+      discard dup2(pipefd[1], stdout.getFileHandle())
+      discard close(pipefd[1])
+      let devnull = open("/dev/null", O_WRONLY)
+      discard dup2(devnull, stderr.getFileHandle())
+      discard close(devnull)
+      discard execCmd(cmd)
+      discard tryRemoveFile(outpath)
+      quit(0)
+    # parent
+    discard close(pipefd[1])
+    let fdout = pipefd[0]
+    let ishtml = HTMLOUTPUT in entry.flags
+    if ishtml:
+      #TODO this is a hack for dupe buffer and should be reconsidered.
+      container.source.contenttype = some("text/html")
+    return container.readFromFd(fdout, ishtml).then(proc() =
+      discard close(fdout)
+    )
+  )
+  return (p, true)
+
+# Save input in a file, run the command, and discard its output.
+# If needsterminal, leave stderr and stdout open and wait for the process.
+proc runMailcapWriteFile(pager: Pager, container: Container,
+    entry: MailcapEntry, cmd, outpath: string): (EmptyPromise, bool) =
+  let needsterminal = NEEDSTERMINAL in entry.flags
+  let fd = open(outpath, O_WRONLY or O_CREAT, 0o666)
+  if fd == -1:
+    return (nil, false)
+  let p = container.redirectToFd(fd, wait = true).then(proc() =
+    if needsterminal:
+      pager.term.quit()
+      discard execCmd(cmd)
+      discard tryRemoveFile(outpath)
+      pager.term.restart()
+    else:
+      # don't block
+      let pid = fork()
+      if pid == 0:
+        # child process
+        let devnull = open("/dev/null", O_WRONLY)
+        discard dup2(devnull, stdin.getFileHandle())
+        discard dup2(devnull, stdout.getFileHandle())
+        discard dup2(devnull, stderr.getFileHandle())
+        discard close(devnull)
+        discard execCmd(cmd)
+        discard tryRemoveFile(outpath)
+        quit(0)
+  )
+  return (p, false)
+
+# Search for a mailcap entry, and if found, execute the specified command
+# and pipeline the input and output appropriately.
+# There is four possible outcomes:
+# * pipe stdin, discard stdout
+# * pipe stdin, read stdout
+# * write to file, run, discard stdout
+# * write to file, run, read stdout
+# If needsterminal is specified, and stdout is not being read, then the
+# pager is suspended until the command exits.
+#TODO add support for edit/compose, better error handling (use Promise[bool]
+# instead of tuple[EmptyPromise, bool])
+proc checkMailcap(pager: Pager, container: Container): (EmptyPromise, bool) =
+  if container.contenttype.isNone:
+    return (nil, true)
+  if container.source.t == CLONE:
+    return (nil, true) # clone cannot use mailcap
+  let contentType = container.contenttype.get
+  if contentType == "text/html":
+    # We support HTML natively, so it would make little sense to execute
+    # mailcap filters for it.
+    return (nil, true)
+  elif contentType == "text/plain":
+    # This could potentially be useful. Unfortunately, many mailcaps include
+    # a text/plain entry with less by default, so it's probably better to
+    # ignore this.
+    return (nil, true)
+  #TODO callback for outpath or something
+  let url = container.location
+  let cs = container.source.charset
+  let entry = pager.mailcap.getMailcapEntry(contentType, "", url, cs)
+  if entry != nil:
+    let tmpdir = pager.config.external.tmpdir
+    let ext = container.location.pathname.afterLast('.')
+    let tempfile = getTempfile(tmpdir, ext)
+    let outpath = if entry.nametemplate != "":
+      unquoteCommand(entry.nametemplate, contentType, tempfile, url, cs)
+    else:
+      tempfile
+    var canpipe = true
+    let cmd = unquoteCommand(entry.cmd, contentType, outpath, url, cs, canpipe)
+    if {COPIOUSOUTPUT, HTMLOUTPUT} * entry.flags == {}:
+      # no output.
+      if canpipe:
+        return pager.runMailcapWritePipe(container, entry[], cmd)
+      else:
+        return pager.runMailcapWriteFile(container, entry[], cmd, outpath)
+    else:
+      if canpipe:
+        return pager.runMailcapReadPipe(container, entry[], cmd)
+      else:
+        return pager.runMailcapReadFile(container, entry[], cmd, outpath)
+  return (nil, true)
+
+proc handleEvent0(pager: Pager, container: Container, event: ContainerEvent): bool =
+  case event.t
+  of FAIL:
+    dec pager.numload
+    pager.deleteContainer(container)
+    if container.retry.len > 0:
+      pager.gotoURL(newRequest(container.retry.pop()), ctype = container.contenttype)
+    else:
+      let errorMessage = getLoaderErrorMessage(container.code)
+      pager.alert("Can't load " & $container.source.location & " (" &
+        errorMessage & ")")
+    return false
+  of SUCCESS:
+    if container.replace != nil:
+      let n = container.replace.children.find(container)
+      if n != -1:
+        container.replace.children.delete(n)
+        container.parent = nil
+      let n2 = container.children.find(container.replace)
+      if n2 != -1:
+        container.children.delete(n2)
+        container.replace.parent = nil
+      container.children.add(container.replace.children)
+      for child in container.children:
+        child.parent = container
+      container.replace.children.setLen(0)
+      if container.replace.parent != nil:
+        container.parent = container.replace.parent
+        let n = container.replace.parent.children.find(container.replace)
+        assert n != -1, "Container not a child of its parent"
+        container.parent.children[n] = container
+        container.replace.parent = nil
+      if pager.container == container.replace:
+        pager.setContainer(container)
+      pager.deleteContainer(container.replace)
+      container.replace = nil
+  of LOADED:
+    dec pager.numload
+  of NEEDS_AUTH:
+    if pager.container == container:
+      pager.authorize()
+  of REDIRECT:
+    if container.redirectdepth < pager.config.network.max_redirect:
+      pager.alert("Redirecting to " & $event.request.url)
+      pager.gotoURL(event.request, some(container.source.location),
+        replace = container, redirectdepth = container.redirectdepth + 1,
+        referrer = pager.container)
+    else:
+      pager.alert("Error: maximum redirection depth reached")
+      pager.deleteContainer(container)
+      return false
+  of ANCHOR:
+    var url2 = newURL(container.source.location)
+    url2.setHash(event.anchor)
+    pager.addContainer(pager.dupeBuffer(container, url2))
+  of NO_ANCHOR:
+    pager.alert("Couldn't find anchor " & event.anchor)
+  of UPDATE:
+    if container == pager.container:
+      pager.redraw = true
+      if event.force: pager.term.clearCanvas()
+  of READ_LINE:
+    if container == pager.container:
+      pager.setLineEdit("(BUFFER) " & event.prompt, BUFFER, event.value, hide = event.password)
+  of READ_AREA:
+    if container == pager.container:
+      var s = event.tvalue
+      if openInEditor(pager.term, pager.config, s):
+        pager.container.readSuccess(s)
+      else:
+        pager.container.readCanceled()
+      pager.redraw = true
+  of OPEN:
+    if pager.container == nil or not pager.container.isHoverURL(event.request.url):
+      pager.ask("Open pop-up? " & $event.request.url).then(proc(x: bool) =
+        if x:
+          pager.gotoURL(event.request, some(container.source.location), referrer = pager.container))
+    else:
+      pager.gotoURL(event.request, some(container.source.location), referrer = pager.container)
+  of INVALID_COMMAND: discard
+  of STATUS:
+    if pager.container == container:
+      pager.showAlerts()
+  of TITLE:
+    if pager.container == container:
+      pager.showAlerts()
+      pager.term.setTitle(container.getTitle())
+  of ALERT:
+    if pager.container == container:
+      pager.alert(event.msg)
+  of CHECK_MAILCAP:
+    var (cm, connect) = pager.checkMailcap(container)
+    if cm == nil:
+      cm = container.connect2()
+    if connect:
+      cm.then(proc() =
+        container.startload())
+    else:
+      cm.then(proc(): auto =
+        container.quit())
+  of QUIT:
+    dec pager.numload
+    pager.deleteContainer(container)
+    return false
+  of NO_EVENT: discard
+  return true
+
+proc handleEvents*(pager: Pager, container: Container) =
+  while container.events.len > 0:
+    let event = container.events.popFirst()
+    if not pager.handleEvent0(container, event):
+      break
+
+proc handleEvent*(pager: Pager, container: Container) =
+  try:
+    container.handleEvent()
+    pager.handleEvents(container)
+  except IOError:
+    discard
+
+proc addPagerModule*(ctx: JSContext) =
+  ctx.registerType(Pager)