about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--Makefile8
-rw-r--r--adapter/format/uri2html3
-rw-r--r--doc/config.md28
-rw-r--r--res/config.toml7
-rw-r--r--res/mailcap1
-rw-r--r--res/mime.types1
-rw-r--r--src/config/config.nim4
-rw-r--r--src/config/history.nim127
-rw-r--r--src/local/container.nim3
-rw-r--r--src/local/lineedit.nim44
-rw-r--r--src/local/pager.nim69
-rw-r--r--src/main.nim6
-rw-r--r--src/utils/twtstr.nim4
-rw-r--r--todo5
14 files changed, 260 insertions, 50 deletions
diff --git a/Makefile b/Makefile
index 29df1f15..51d41295 100644
--- a/Makefile
+++ b/Makefile
@@ -58,7 +58,7 @@ all: $(OUTDIR_BIN)/cha $(OUTDIR_BIN)/mancha $(OUTDIR_CGI_BIN)/http \
 	$(OUTDIR_CGI_BIN)/gopher $(OUTDIR_LIBEXEC)/gopher2html \
 	$(OUTDIR_CGI_BIN)/finger $(OUTDIR_CGI_BIN)/about \
 	$(OUTDIR_CGI_BIN)/file $(OUTDIR_CGI_BIN)/ftp $(OUTDIR_CGI_BIN)/sftp \
-	$(OUTDIR_LIBEXEC)/dirlist2html \
+	$(OUTDIR_LIBEXEC)/dirlist2html $(OUTDIR_LIBEXEC)/uri2html \
 	$(OUTDIR_CGI_BIN)/man $(OUTDIR_CGI_BIN)/spartan \
 	$(OUTDIR_CGI_BIN)/stbi $(OUTDIR_CGI_BIN)/jebp $(OUTDIR_CGI_BIN)/canvas \
 	$(OUTDIR_CGI_BIN)/nanosvg $(OUTDIR_CGI_BIN)/sixel $(OUTDIR_CGI_BIN)/resize \
@@ -142,6 +142,10 @@ $(OUTDIR_CGI_BIN)/%: adapter/protocol/%
 	@mkdir -p "$(OUTDIR_CGI_BIN)"
 	install -m755 $< "$(OUTDIR_CGI_BIN)"
 
+$(OUTDIR_LIBEXEC)/%: adapter/format/%
+	@mkdir -p "$(OUTDIR_LIBEXEC)"
+	install -m755 $< "$(OUTDIR_LIBEXEC)"
+
 $(OUTDIR_CGI_BIN)/%: adapter/img/%.nim
 	@mkdir -p "$(OUTDIR_CGI_BIN)"
 	$(NIMC) $(FLAGS) --nimcache:"$(OBJDIR)/$(TARGET)/$(subst $(OUTDIR_CGI_BIN)/,,$@)" \
@@ -188,7 +192,7 @@ manpage: $(manpages:%=doc/%)
 
 protocols = http about file ftp sftp gopher gemini finger man spartan stbi \
 	jebp sixel canvas resize chabookmark nanosvg
-converters = gopher2html md2html ansi2html gmi2html dirlist2html
+converters = gopher2html md2html ansi2html gmi2html dirlist2html uri2html
 tools = urlenc nc
 
 .PHONY: install
diff --git a/adapter/format/uri2html b/adapter/format/uri2html
new file mode 100644
index 00000000..20ae5670
--- /dev/null
+++ b/adapter/format/uri2html
@@ -0,0 +1,3 @@
+#!/bin/sh
+echo "<!DOCTYPE html>${1+"<h1>$1</h1><hr>"}<ol>"
+sed -e '/^#/d' -e 's@.*@<li><a href="&">&</a>@'
diff --git a/doc/config.md b/doc/config.md
index c501c934..3c337194 100644
--- a/doc/config.md
+++ b/doc/config.md
@@ -195,6 +195,14 @@ Defaults to "ask".
 </td>
 </tr>
 
+<tr>
+<td>history</td>
+<td>true / false</td>
+<td>Whether or not browsing history should be saved to the disk.<br>
+Defaults to true.
+</td>
+</tr>
+
 </table>
 
 ## Search
@@ -377,6 +385,18 @@ defaults to `xsel -bo`.</td>
 .md extension, so that its type can be correctly deduced.)</td>
 </tr>
 
+<tr>
+<td>history-file</td>
+<td>path</td>
+<td>Path to the history file. Defaults to "history.uri".</td>
+</tr>
+
+<tr>
+<td>history-size</td>
+<td>number</td>
+<td>Maximum length of the history file. Defaults to 100.</td>
+</tr>
+
 </table>
 
 ## Input
@@ -896,6 +916,14 @@ Overrides `buffer.meta-refresh`.
 </td>
 </tr>
 
+<tr>
+<td>history</td>
+<td>true / false</td>
+<td>Whether or not browsing history should be saved to the disk.<br>
+Overrides `buffer.history`.
+</td>
+</tr>
+
 </table>
 
 ## Stylesheets
diff --git a/res/config.toml b/res/config.toml
index 3dca5fc3..fa38d6c6 100644
--- a/res/config.toml
+++ b/res/config.toml
@@ -20,6 +20,7 @@ scripting = false
 referer-from = false
 cookie = false
 meta-refresh = "ask"
+history = true
 
 [search]
 wrap = true
@@ -50,6 +51,8 @@ urimethodmap = [
 	"/usr/local/etc/w3m/urimethodmap"
 ]
 bookmark = "bookmark.md"
+history-file = "history.uri"
+history-size = 100
 tmpdir = "${TMPDIR:-/tmp}/cha-tmp-$LOGNAME"
 sockdir = "${TMPDIR:-/tmp}/cha-sock-$LOGNAME"
 editor = "${EDITOR:-vi}"
@@ -211,6 +214,7 @@ C-l = 'cmd.pager.load'
 C-k = 'cmd.pager.webSearch'
 M-a = 'cmd.pager.addBookmark'
 M-b = 'cmd.pager.openBookmarks'
+C-h = 'cmd.pager.openHistory'
 M-u = 'cmd.pager.dupeBuffer'
 U = 'cmd.pager.reloadBuffer'
 C-g = 'cmd.pager.lineInfo'
@@ -323,7 +327,8 @@ dupeBuffer = '() => pager.dupeBuffer()'
 load = '() => pager.load()'
 webSearch = '() => pager.load("go:")'
 addBookmark = '() => pager.gotoURL(`cgi-bin:chabookmark?url=${encodeURIComponent(pager.url)}&title=${encodeURIComponent(pager.title)}`)'
-openBookmarks = '() => pager.gotoURL(`cgi-bin:chabookmark?action=view`)'
+openBookmarks = '() => pager.gotoURL(`cgi-bin:chabookmark?action=view`, {history: false})'
+openHistory = '() => pager.gotoURL(pager.getHistoryURL(), {contentType: `text/uri-list;title="History page"`, history: false})'
 reloadBuffer = '() => pager.reload()'
 lineInfo = '() => pager.lineInfo()'
 toggleSource = '() => pager.toggleSource()'
diff --git a/res/mailcap b/res/mailcap
index ba4989ec..e0e2b503 100644
--- a/res/mailcap
+++ b/res/mailcap
@@ -5,3 +5,4 @@ text/gemini;	"$CHA_LIBEXEC_DIR"/gmi2html; x-htmloutput; x-needsstyle
 text/markdown;	"$CHA_LIBEXEC_DIR"/md2html; x-htmloutput
 text/x-ansi;	"$CHA_LIBEXEC_DIR"/ansi2html -st '%{title}'; x-htmloutput; x-needsstyle
 text/x-dirlist;	"$CHA_LIBEXEC_DIR"/dirlist2html -t '%{title}'; x-htmloutput
+text/uri-list;	"$CHA_LIBEXEC_DIR"/uri2html '%{title}'; x-htmloutput
diff --git a/res/mime.types b/res/mime.types
index 9feed14e..533b2f01 100644
--- a/res/mime.types
+++ b/res/mime.types
@@ -16,3 +16,4 @@ image/svg+xml		svg
 text/markdown		md
 text/gemini		gmi
 text/x-ansi		ans	asc
+text/uri-list		uri
diff --git a/src/config/config.nim b/src/config/config.nim
index 2e0eaacf..a55c807f 100644
--- a/src/config/config.nim
+++ b/src/config/config.nim
@@ -73,6 +73,7 @@ type
     insecure_ssl_no_verify*: Option[bool]
     autofocus*: Option[bool]
     meta_refresh*: Option[MetaRefresh]
+    history*: Option[bool]
 
   OmniRule* = ref object
     match*: Regex
@@ -110,6 +111,8 @@ type
     cgi_dir* {.jsgetset.}: seq[ChaPathResolved]
     urimethodmap*: URIMethodMap
     bookmark* {.jsgetset.}: ChaPathResolved
+    history_file*: ChaPathResolved
+    history_size* {.jsgetset.}: int32
     download_dir* {.jsgetset.}: ChaPathResolved
     w3m_cgi_compat* {.jsgetset.}: bool
     copy_cmd* {.jsgetset.}: string
@@ -162,6 +165,7 @@ type
     referer_from* {.jsgetset.}: bool
     autofocus* {.jsgetset.}: bool
     meta_refresh* {.jsgetset.}: MetaRefresh
+    history* {.jsgetset.}: bool
 
   Config* = ref object
     jsctx*: JSContext
diff --git a/src/config/history.nim b/src/config/history.nim
new file mode 100644
index 00000000..879f0b77
--- /dev/null
+++ b/src/config/history.nim
@@ -0,0 +1,127 @@
+# Generic object for line editing and browsing hist.
+import std/posix
+import std/tables
+
+import io/dynstream
+import utils/twtstr
+
+type
+  History* = ref object
+    first*: HistoryEntry
+    last*: HistoryEntry
+    mtime*: int64
+    map: Table[string, HistoryEntry]
+    len: int
+    maxLen: int
+
+  HistoryEntry* = ref object
+    s*: string
+    prev* {.cursor.}: HistoryEntry
+    next*: HistoryEntry
+
+func newHistoryEntry(s: string): HistoryEntry =
+  return HistoryEntry(s: s)
+
+proc add(hist: History; entry: HistoryEntry) =
+  let old = hist.map.getOrDefault(entry.s)
+  if old != nil:
+    if hist.first == old:
+      hist.first = old.next
+    if hist.last == old:
+      hist.last = old.prev
+    let prev = old.prev
+    if prev != nil:
+      prev.next = old.next
+    if old.next != nil:
+      old.next.prev = prev
+    dec hist.len
+  if hist.first == nil:
+    hist.first = entry
+  else:
+    entry.prev = hist.last
+    hist.last.next = entry
+  hist.map[entry.s] = entry
+  hist.last = entry
+  inc hist.len
+  if hist.len > hist.maxLen:
+    hist.first = hist.first.next
+    if hist.first == nil:
+      hist.last = nil
+    dec hist.len
+
+func newHistory*(maxLen: int; mtime = 0i64): History =
+  return History(maxLen: maxLen, mtime: mtime)
+
+proc add*(hist: History; s: string) =
+  hist.add(newHistoryEntry(s))
+
+proc parse(hist: History; iq: openArray[char]) =
+  var i = 0
+  while i < iq.len:
+    if iq[i] == '#': # text/uri-list :P
+      while i < iq.len and iq[i] != '\n':
+        inc i
+    else:
+      let entry = newHistoryEntry(iq.until('\n', i))
+      hist.add(entry)
+      i += entry.s.len
+    inc i
+
+# Consumes `ps'.
+proc parse*(hist: History; ps: PosixStream; mtime: int64): bool =
+  try:
+    let src = ps.recvAllOrMmap()
+    hist.parse(src.toOpenArray())
+    hist.mtime = mtime
+    deallocMem(src)
+  except IOError:
+    return false
+  finally:
+    ps.sclose()
+  return true
+
+proc c_rename(oldname, newname: cstring): cint {.importc: "rename",
+  header: "<stdio.h>".}
+
+# Consumes `ps'.
+proc write*(hist: History; ps: PosixStream; reverse = false): bool =
+  try:
+    var buf = ""
+    var entry = if reverse: hist.last else: hist.first
+    while entry != nil:
+      buf &= entry.s
+      buf &= '\n'
+      if buf.len >= 4096:
+        ps.sendDataLoop(buf)
+        buf = ""
+      if reverse:
+        entry = entry.prev
+      else:
+        entry = entry.next
+    if buf.len > 0:
+      ps.sendDataLoop(buf)
+  except IOError:
+    return false
+  finally:
+    ps.sclose()
+  return true
+
+proc write*(hist: History; file: string): bool =
+  let ps = newPosixStream(file)
+  if ps != nil:
+    var stats: Stat
+    if fstat(ps.fd, stats) != -1 and S_ISREG(stats.st_mode):
+      let mtime = int64(stats.st_mtime)
+      if mtime > hist.mtime:
+        if not hist.parse(ps, mtime):
+          return false
+  if hist.first == nil:
+    return true
+  block write:
+    # Can't just use getTempFile, because the temp directory may be in
+    # another filesystem.
+    let tmp = file & '~'
+    let ps = newPosixStream(tmp, O_WRONLY or O_CREAT, 0o600)
+    if ps != nil and hist.write(ps):
+      return c_rename(cstring(tmp), file) == 0
+  return false
diff --git a/src/local/container.nim b/src/local/container.nim
index ab69a0ed..7e43f296 100644
--- a/src/local/container.nim
+++ b/src/local/container.nim
@@ -94,7 +94,8 @@ type
     lsLoading, lsCanceled, lsLoaded
 
   ContainerFlag* = enum
-    cfCloned, cfUserRequested, cfHasStart, cfCanReinterpret, cfSave, cfIsHTML
+    cfCloned, cfUserRequested, cfHasStart, cfCanReinterpret, cfSave, cfIsHTML,
+    cfHistory
 
   CachedImageState* = enum
     cisLoading, cisCanceled, cisLoaded
diff --git a/src/local/lineedit.nim b/src/local/lineedit.nim
index 3ae3f4ef..30c672d9 100644
--- a/src/local/lineedit.nim
+++ b/src/local/lineedit.nim
@@ -2,6 +2,7 @@ import std/strutils
 
 import chagashi/charset
 import chagashi/decoder
+import config/history
 import monoucha/javascript
 import monoucha/quickjs
 import types/cell
@@ -16,9 +17,6 @@ type
   LineEditState* = enum
     lesEdit, lesFinish, lesCancel
 
-  LineHistory* = ref object
-    lines*: seq[string]
-
   LineEdit* = ref object
     news*: string
     prompt: string
@@ -33,17 +31,14 @@ type
     maxwidth: int
     disallowed: set[char]
     hide: bool
-    hist: LineHistory
-    histindex: int
+    hist: History
+    currHist: HistoryEntry
     histtmp: string
     luctx: LUContext
     redraw*: bool
 
 jsDestructor(LineEdit)
 
-func newLineHistory*(): LineHistory =
-  return LineHistory()
-
 # Note: capped at edit.maxwidth.
 func getDisplayWidth(edit: LineEdit): int =
   var dispw = 0
@@ -133,8 +128,8 @@ proc cancel(edit: LineEdit) {.jsfunc.} =
   edit.state = lesCancel
 
 proc submit(edit: LineEdit) {.jsfunc.} =
-  if edit.hist.lines.len == 0 or edit.news != edit.hist.lines[^1]:
-    edit.hist.lines.add(edit.news)
+  if edit.hist.mtime == 0:
+    edit.hist.add(edit.news)
   edit.state = lesFinish
 
 proc backspace(edit: LineEdit) {.jsfunc.} =
@@ -274,11 +269,14 @@ proc `end`(edit: LineEdit) {.jsfunc.} =
       edit.redraw = true
 
 proc prevHist(edit: LineEdit) {.jsfunc.} =
-  if edit.histindex > 0:
-    if edit.news.len > 0:
+  if edit.currHist == nil:
+    if edit.hist.last != nil and edit.news.len > 0:
       edit.histtmp = $edit.news
-    dec edit.histindex
-    edit.news = edit.hist.lines[edit.histindex]
+    edit.currHist = edit.hist.last
+  elif edit.currHist.prev != nil:
+    edit.currHist = edit.currHist.prev
+  if edit.currHist != nil:
+    edit.news = edit.currHist.s
     # The begin call is needed so the cursor doesn't get lost outside
     # the string.
     edit.begin()
@@ -286,25 +284,25 @@ proc prevHist(edit: LineEdit) {.jsfunc.} =
     edit.redraw = true
 
 proc nextHist(edit: LineEdit) {.jsfunc.} =
-  if edit.histindex + 1 < edit.hist.lines.len:
-    inc edit.histindex
-    edit.news = edit.hist.lines[edit.histindex]
+  if edit.currHist != nil and edit.currHist != edit.hist.last:
+    edit.currHist = edit.currHist.next
+    edit.news = edit.currHist.s
     edit.begin()
     edit.end()
     edit.redraw = true
-  elif edit.histindex < edit.hist.lines.len:
-    inc edit.histindex
-    edit.news = edit.histtmp
+  elif edit.currHist == edit.hist.last:
+    edit.currHist = edit.currHist.next
+    edit.news = move(edit.histtmp)
+    edit.histtmp = ""
     edit.begin()
     edit.end()
-    edit.histtmp = ""
     edit.redraw = true
 
 proc windowChange*(edit: LineEdit; attrs: WindowAttributes) =
   edit.maxwidth = attrs.width - edit.promptw - 1
 
 proc readLine*(prompt, current: string; termwidth: int; disallowed: set[char];
-    hide: bool; hist: LineHistory; luctx: LUContext): LineEdit =
+    hide: bool; hist: History; luctx: LUContext): LineEdit =
   let promptw = prompt.width()
   return LineEdit(
     prompt: prompt,
@@ -318,7 +316,7 @@ proc readLine*(prompt, current: string; termwidth: int; disallowed: set[char];
     # - 1, so that the cursor always has place
     maxwidth: termwidth - promptw - 1,
     hist: hist,
-    histindex: hist.lines.len,
+    currHist: nil,
     luctx: luctx
   )
 
diff --git a/src/local/pager.nim b/src/local/pager.nim
index 72945745..dfd73b05 100644
--- a/src/local/pager.nim
+++ b/src/local/pager.nim
@@ -7,11 +7,13 @@ import std/posix
 import std/sets
 import std/strutils
 import std/tables
+import std/times
 
 import chagashi/charset
 import chagashi/decoder
 import config/chapath
 import config/config
+import config/history
 import config/mailcap
 import config/mimetypes
 import css/render
@@ -156,7 +158,7 @@ type
     jsrt: JSRuntime
     lastAlert: string # last alert seen by the user
     lineData: LineData
-    lineHist: array[LineMode, LineHistory]
+    lineHist: array[LineMode, History]
     lineedit*: LineEdit
     linemode: LineMode
     loader*: FileLoader
@@ -210,13 +212,13 @@ proc dumpBuffers(pager: Pager)
 proc evalJS(pager: Pager; src, filename: string; module = false): JSValue
 proc fulfillAsk(pager: Pager; y: bool)
 proc fulfillCharAsk(pager: Pager; s: string)
-proc getLineHist(pager: Pager; mode: LineMode): LineHistory
+proc getHist(pager: Pager; mode: LineMode): History
 proc handleEvents(pager: Pager)
 proc handleRead(pager: Pager; fd: int)
 proc headlessLoop(pager: Pager)
 proc inputLoop(pager: Pager)
 proc loadURL(pager: Pager; url: string; ctype = none(string);
-  cs = CHARSET_UNKNOWN)
+  cs = CHARSET_UNKNOWN; history = true)
 proc openMenu(pager: Pager; x = -1; y = -1)
 proc readPipe(pager: Pager; contentType: string; cs: Charset; ps: PosixStream;
   title: string)
@@ -343,14 +345,14 @@ proc searchPrev(pager: Pager; n = 1) {.jsfunc.} =
   else:
     pager.alert("No previous regular expression")
 
-proc getLineHist(pager: Pager; mode: LineMode): LineHistory =
+proc getHist(pager: Pager; mode: LineMode): History =
   if pager.lineHist[mode] == nil:
-    pager.lineHist[mode] = newLineHistory()
+    pager.lineHist[mode] = newHistory(100)
   return pager.lineHist[mode]
 
 proc setLineEdit(pager: Pager; mode: LineMode; current = ""; hide = false;
     extraPrompt = "") =
-  let hist = pager.getLineHist(mode)
+  let hist = pager.getHist(mode)
   if pager.term.isatty() and pager.config.input.use_mouse:
     pager.term.disableMouse()
   pager.lineedit = readLine($mode & extraPrompt, current, pager.attrs.width,
@@ -485,12 +487,22 @@ proc newPager*(config: Config; forkserver: ForkServer; ctx: JSContext;
     proxy: pager.config.network.proxy,
     filter: newURLFilter(default = true),
   ))
+  let hist = newHistory(pager.config.external.history_size, getTime().toUnix())
+  let ps = newPosixStream(pager.config.external.history_file)
+  if ps != nil:
+    var stat: Stat
+    if fstat(ps.fd, stat) != -1:
+      discard hist.parse(ps, int64(stat.st_mtime))
+  pager.lineHist[lmLocation] = hist
   return pager
 
 proc cleanup(pager: Pager) =
   if pager.alive:
     pager.alive = false
     pager.term.quit()
+    let hist = pager.lineHist[lmLocation]
+    if not hist.write(pager.config.external.history_file):
+      pager.alert("failed to save history")
     for msg in pager.alerts:
       stderr.write("cha: " & msg & '\n')
     for val in pager.config.cmd.map.values:
@@ -809,7 +821,7 @@ proc addLoaderClient(pager: Pager; pid: int; config: LoaderClientConfig;
   return key
 
 proc run*(pager: Pager; pages: openArray[string]; contentType: Option[string];
-    cs: Charset; dump: bool) =
+    cs: Charset; dump, history: bool) =
   var istream: PosixStream = nil
   var dump = dump
   if not dump:
@@ -847,8 +859,9 @@ proc run*(pager: Pager; pages: openArray[string]; contentType: Option[string];
     let contentType = contentType.get("text/x-ansi")
     let ps = newPosixStream(STDIN_FILENO)
     pager.readPipe(contentType, cs, ps, "*stdin*")
+  let history = not dump and history # we don't want history for dump either
   for page in pages:
-    pager.loadURL(page, ctype = contentType, cs = cs)
+    pager.loadURL(page, ctype = contentType, cs = cs, history = history)
   pager.showAlerts()
   pager.acceptBuffers()
   if not dump:
@@ -912,11 +925,8 @@ proc refreshStatusMsg(pager: Pager) =
     discard pager.writeStatusMessage(pager.alerts[0])
     # save to alert history
     if pager.lastAlert != "":
-      let hist = pager.getLineHist(lmAlert)
-      if hist.lines.len == 0 or hist.lines[^1] != pager.lastAlert:
-        if hist.lines.len > 19:
-          hist.lines.delete(0)
-        hist.lines.add(move(pager.lastAlert))
+      let hist = pager.getHist(lmAlert)
+      hist.add(move(pager.lastAlert))
     pager.lastAlert = move(pager.alerts[0])
     pager.alerts.delete(0)
   else:
@@ -1878,7 +1888,8 @@ proc applySiteconf(pager: Pager; url: URL; charsetOverride: Charset;
 proc gotoURL(pager: Pager; request: Request; prevurl = none(URL);
     contentType = none(string); cs = CHARSET_UNKNOWN; replace: Container = nil;
     replaceBackup: Container = nil; redirectDepth = 0;
-    referrer: Container = nil; save = false; url: URL = nil): Container =
+    referrer: Container = nil; save = false; history = true;
+    url: URL = nil): Container =
   pager.navDirection = ndNext
   if referrer != nil and referrer.config.refererFrom:
     request.referrer = referrer.url
@@ -1904,6 +1915,8 @@ proc gotoURL(pager: Pager; request: Request; prevurl = none(URL);
     var flags = {cfCanReinterpret, cfUserRequested}
     if save:
       flags.incl(cfSave)
+    if history:
+      flags.incl(cfHistory)
     let container = pager.newContainer(
       bufferConfig,
       loaderConfig,
@@ -1963,7 +1976,7 @@ proc omniRewrite(pager: Pager; s: string): string =
 # * https://<url>
 # So we attempt to load both, and see what works.
 proc loadURL(pager: Pager; url: string; ctype = none(string);
-    cs = CHARSET_UNKNOWN) =
+    cs = CHARSET_UNKNOWN; history = true) =
   let url0 = pager.omniRewrite(url)
   let url = expandPath(url0)
   if url.len == 0:
@@ -1974,7 +1987,8 @@ proc loadURL(pager: Pager; url: string; ctype = none(string);
       some(pager.container.url)
     else:
       none(URL)
-    discard pager.gotoURL(newRequest(firstparse.get), prev, ctype, cs)
+    discard pager.gotoURL(newRequest(firstparse.get), prev, ctype, cs,
+      history = history)
     return
   var urls: seq[URL] = @[]
   if pager.config.network.prepend_https and
@@ -1992,7 +2006,7 @@ proc loadURL(pager: Pager; url: string; ctype = none(string);
     pager.alert("Invalid URL " & url)
   else:
     let container = pager.gotoURL(newRequest(urls.pop()), contentType = ctype,
-      cs = cs)
+      cs = cs, history = history)
     if container != nil:
       container.retry = urls
 
@@ -2029,6 +2043,18 @@ proc readPipe(pager: Pager; contentType: string; cs: Charset; ps: PosixStream;
   inc pager.numload
   pager.addContainer(container)
 
+proc getHistoryURL(pager: Pager): URL {.jsfunc.} =
+  let (pins, pouts) = pager.createPipe()
+  if pins == nil:
+    return nil
+  let url = newURL("stream:history").get
+  pager.loader.passFd(url.pathname, pins.fd)
+  pins.sclose()
+  let hist = pager.lineHist[lmLocation]
+  if not hist.write(pouts, reverse = true):
+    pager.alert("failed to write history")
+  return url
+
 const ConsoleTitle = "Browser Console"
 
 proc showConsole(pager: Pager) =
@@ -2256,6 +2282,7 @@ type GotoURLDict = object of JSDict
   contentType {.jsdefault.}: Option[string]
   replace {.jsdefault.}: Container
   save {.jsdefault.}: bool
+  history {.jsdefault.}: bool
 
 proc jsGotoURL(pager: Pager; v: JSValue; t = GotoURLDict()): JSResult[void]
     {.jsfunc: "gotoURL".} =
@@ -2271,7 +2298,7 @@ proc jsGotoURL(pager: Pager; v: JSValue; t = GotoURLDict()): JSResult[void]
       url = ?newURL(s)
     request = newRequest(url)
   discard pager.gotoURL(request, contentType = t.contentType,
-    replace = t.replace, save = t.save)
+    replace = t.replace, save = t.save, history = t.history)
   return ok()
 
 # Reload the page in a new buffer, then kill the previous buffer.
@@ -2533,7 +2560,8 @@ proc redirectTo(pager: Pager; container: Container; request: Request) =
     container.find(ndAny)
   let nc = pager.gotoURL(request, some(container.url), replace = container,
     replaceBackup = replaceBackup, redirectDepth = container.redirectDepth + 1,
-    referrer = container)
+    referrer = container, save = cfSave in container.flags,
+    history = cfHistory in container.flags)
   nc.loadinfo = "Redirecting to " & $request.url
   pager.onSetLoadInfo(nc)
   dec pager.numload
@@ -2743,6 +2771,8 @@ proc connected(pager: Pager; container: Container; response: Response) =
   # variable.)
   if cfUserRequested in container.flags:
     pager.hasload = true
+  if cfHistory in container.flags:
+    pager.lineHist[lmLocation].add($container.url)
   var contentType = if "Content-Type" in response.headers:
     response.headers["Content-Type"]
   else:
@@ -2868,6 +2898,7 @@ const MenuMap = [
   ("─────────────────────────", ""),
   ("Bookmark page       (M-a)", "cmd.pager.addBookmark(1)"),
   ("Open bookmarks      (M-b)", "cmd.pager.openBookmarks(1)"),
+  ("Open history        (C-h)", "cmd.pager.openHistory(1)"),
 ]
 
 proc menuFinish(opaque: RootRef; select: Select; sr: SubmitResult) =
diff --git a/src/main.nim b/src/main.nim
index 1a9a583e..ec0f4f8a 100644
--- a/src/main.nim
+++ b/src/main.nim
@@ -250,13 +250,17 @@ proc main() =
   if (let res = ctx.initConfig(config, warnings); res.isNone):
     stderr.writeLine(res.error)
     quit(1)
+  var history = true
   if ctx.pages.len == 0 and stdin.isatty():
     if ctx.visual:
       ctx.pages.add(config.start.visual_home)
+      history = false
     elif (let httpHome = getEnv("HTTP_HOME"); httpHome != ""):
       ctx.pages.add(httpHome)
+      history = false
     elif (let wwwHome = getEnv("WWW_HOME"); wwwHome != ""):
       ctx.pages.add(wwwHome)
+      history = false
   if ctx.pages.len == 0 and not config.start.headless:
     if stdin.isatty():
       help(1)
@@ -269,7 +273,7 @@ proc main() =
   let client = newClient(config, forkserver, loaderPid, jsctx, warnings,
     urandom)
   try:
-    client.pager.run(ctx.pages, ctx.contentType, ctx.charset, ctx.dump)
+    client.pager.run(ctx.pages, ctx.contentType, ctx.charset, ctx.dump, history)
   except CatchableError:
     client.flushConsole()
     raise
diff --git a/src/utils/twtstr.nim b/src/utils/twtstr.nim
index 0759cbae..18f7dd13 100644
--- a/src/utils/twtstr.nim
+++ b/src/utils/twtstr.nim
@@ -270,10 +270,10 @@ func untilLower*(s: openArray[char]; c: set[char]; starti = 0): string =
       break
     result.add(s[i].toLowerAscii())
 
-func until*(s: string; c: char; starti = 0): string =
+func until*(s: openArray[char]; c: char; starti = 0): string =
   return s.until({c}, starti)
 
-func untilLower*(s: string; c: char; starti = 0): string =
+func untilLower*(s: openArray[char]; c: char; starti = 0): string =
   return s.untilLower({c}, starti)
 
 func after*(s: string; c: set[char]): string =
diff --git a/todo b/todo
index 0a6cfbc5..fb434b27 100644
--- a/todo
+++ b/todo
@@ -38,6 +38,10 @@ buffer:
 	  buffer
 	* this also includes not crashing when the buffer dies while
 	  container is reading...
+- color visited links
+	* needs some sort of conditional formatting in pager, e.g. give
+	  all Formats an id in buffer and send a list of "if URL
+	  visited, change Format" commands
 - configurable/better url filtering in loader
 - when the log buffer crashes, print its contents to stderr
 	* easiest way seems to be to just dump its cache file
@@ -46,7 +50,6 @@ buffer:
 pager:
 - better horizontal line handling: allow viewing content positioned before page
   start, handle long lines, etc
-- history (what format?)
 - save/edit buffer output
 - alert on external command failure
 network: