about summary refs log tree commit diff
diff options
context:
space:
mode:
authorSören Tempel <soeren+git@soeren-tempel.net>2021-05-24 23:05:18 +0200
committerSören Tempel <soeren+git@soeren-tempel.net>2021-05-24 23:05:18 +0200
commitab07740928db691ce612b1138a21ed7a21506e71 (patch)
tree50d2d3accfa74c00db11d1dc4bff790305505e43
parenta7dceab464dbac7fa51e932373170ed8847a5be5 (diff)
parent226c4a7cef27041923347235b0b031cc14fb24c8 (diff)
Merge branch 'history'
-rw-r--r--saneterm/history.py88
-rw-r--r--saneterm/keys.py3
-rw-r--r--saneterm/proc.py14
-rw-r--r--saneterm/terminal.py33
4 files changed, 138 insertions, 0 deletions
diff --git a/saneterm/history.py b/saneterm/history.py
new file mode 100644
index 0000000..038a4f9
--- /dev/null
+++ b/saneterm/history.py
@@ -0,0 +1,88 @@
+import os
+import sqlite3
+
+from . import proc
+
+# TODO: The SQL queries in this file may have some room for improvement.
+
+DEFSIZ = 1000
+SIZE_ENV = "HISTSIZE"
+HISTORY_FN = "history.db"
+
+class History():
+    """History provides a single database for storing line-based editing
+       histories on a per-executable basis. The current executable is
+       determined using tcgetpgrp(3) on a given file descriptior."""
+
+    __schema = """
+        CREATE TABLE IF NOT EXISTS history (exe TEXT, entry TEXT)
+    """
+
+    def __init__(self):
+        # See https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
+        # Explicitly not using HISTFILE to avoid overwriting the shell's history.
+        if "XDG_DATA_DIR" in os.environ:
+            data_dir = os.environ["XDG_DATA_DIR"]
+        else:
+            data_dir = os.path.join(os.path.expanduser('~'), '.local', 'share')
+
+        data_dir = os.path.join(data_dir, "saneterm")
+        os.makedirs(data_dir, exist_ok=True)
+
+        histfile = os.path.join(data_dir, HISTORY_FN)
+        self.histsize = int(os.environ[SIZE_ENV]) if SIZE_ENV in os.environ else DEFSIZ
+
+        self.__con = sqlite3.connect(histfile)
+        self.__cur = self.__con.cursor()
+
+        self.__cur.execute(self.__schema)
+
+    def close(self):
+        self.__con.close()
+
+    def add_entry(self, fd, entry):
+        entry = entry.rstrip('\n')
+        if len(entry) == 0:
+            return
+        exe = self.__get_exec(fd)
+
+        # Insert new entry into table and make sure the **total** amount
+        # of entries in the entire table does not exceed self.histsize.
+        # If this value is exceeded, remove the first (i.e. oldest) entries.
+        self.__cur.execute("INSERT INTO history VALUES (?, ?)", (exe, entry))
+        self.__cur.execute("""
+                DELETE FROM history WHERE ( SELECT count(*) FROM history ) > :max
+                    AND rowid IN (
+                        SELECT rowid FROM history ORDER BY rowid ASC LIMIT
+                            (( SELECT count(*) FROM history ) - :max )
+                    );
+                """, {"max": self.histsize});
+
+        self.__con.commit()
+
+    def get_entry(self, fd, offset):
+        '''Select an entry by the given offset. The offset is
+           interpreted relative to the latest entry. That is,
+           an offset of zero would return the current entry and
+           an offset of one would return the previous entry. None
+           is returned if no entry with the given offset exists.'''
+        exe = self.__get_exec(fd)
+
+        # Select an entry by the given offset. If the offset exceeds the
+        # amount of available entries, select nothing and return None.
+        self.__cur.execute("""
+                SELECT entry FROM history WHERE exe=:exe AND
+                    ( SELECT count(*) FROM history WHERE exe=:exe ) >= :offset
+                    LIMIT 1 OFFSET
+                    (( SELECT count(*) FROM history WHERE exe=:exe ) - :offset);
+                """, {"exe": exe, "offset": offset})
+
+        res = self.__cur.fetchone()
+        if res is None:
+            return None
+
+        return res[0]
+
+    def __get_exec(self, fd):
+        pid = os.tcgetpgrp(fd);
+        return proc.executable(pid)
diff --git a/saneterm/keys.py b/saneterm/keys.py
index 47058f6..5572f71 100644
--- a/saneterm/keys.py
+++ b/saneterm/keys.py
@@ -24,6 +24,9 @@ class Bindings():
             bind "<ctrl>w" { "delete-from-cursor" (word-ends, -1) };
             bind "<ctrl>h" { "backspace" () };
 
+            bind "Up" { "history-entry" (1) };
+            bind "Down" { "history-entry" (-1) };
+
             /* Since <ctrl>c is used for VINTR, unbind <ctrl>v */
             unbind "<ctrl>v";
         }
diff --git a/saneterm/proc.py b/saneterm/proc.py
new file mode 100644
index 0000000..7fd5b72
--- /dev/null
+++ b/saneterm/proc.py
@@ -0,0 +1,14 @@
+import os
+
+# This code is highly linux-specific and requires proc to be mounted in
+# order to retrieve information about the current foreground process.
+
+def __proc_dir(pid):
+    return os.path.join("/proc", str(pid))
+
+def executable(pid):
+    exec = os.path.join(__proc_dir(pid), "exe")
+    dest = os.readlink(exec)
+
+    # XXX: Does this work with busybox symlinks i.e. /bin/sh → /bin/busybox?
+    return os.path.abspath(dest)
diff --git a/saneterm/terminal.py b/saneterm/terminal.py
index ebae1db..8bd3120 100644
--- a/saneterm/terminal.py
+++ b/saneterm/terminal.py
@@ -7,6 +7,7 @@ import fcntl
 import struct
 
 from . import keys
+from .history import History
 from .termview import *
 
 import gi
@@ -63,6 +64,9 @@ class Terminal(Gtk.Window):
         Gtk.Window.__init__(self, title=NAME)
         self.set_name(NAME)
 
+        self.hist = History()
+        self.reset_history_index()
+
         self.pty = PtySource(cmd)
         self.pty.set_callback(self.handle_pty)
         self.pty.attach(None)
@@ -77,7 +81,9 @@ class Terminal(Gtk.Window):
         self.termview.connect("termios-ctrlkey", self.termios_ctrl)
         self.termview.connect("size-allocate", self.autoscroll)
         self.termview.connect("populate-popup", self.populate_popup)
+
         self.connect("configure-event", self.update_size)
+        self.connect("destroy", self.destroy)
 
         bindings = keys.Bindings(self.termview)
         for key, idx in keys.CTRL.items():
@@ -89,6 +95,14 @@ class Terminal(Gtk.Window):
         self.scroll.add(self.termview)
         self.add(self.scroll)
 
+        GObject.signal_new("history-entry", self.termview,
+                GObject.SIGNAL_ACTION, GObject.TYPE_NONE,
+                (GObject.TYPE_LONG,))
+        self.termview.connect("history-entry", self.history)
+
+    def destroy(self, widget):
+        self.hist.close()
+
     def update_wrapmode(self):
         mode = Gtk.WrapMode.WORD_CHAR if self.config['wordwrap'] else Gtk.WrapMode.NONE
         self.termview.set_wrap_mode(mode)
@@ -129,6 +143,22 @@ class Terminal(Gtk.Window):
         self.termview.insert_data(self.decoder.decode(data))
         return GLib.SOURCE_CONTINUE
 
+    def reset_history_index(self):
+        self.hist_index = 0
+
+    def history(self, termview, idx):
+        # Backup index and restore it if no entry with new index exists.
+        backup_index = self.hist_index
+
+        self.hist_index += idx
+        entry = self.hist.get_entry(self.pty.master, self.hist_index)
+
+        if entry is None:
+            self.hist_index = backup_index
+        else:
+            self.termview.emit("kill-after-output")
+            self.termview.emit("insert-at-cursor", entry)
+
     def autoscroll(self, widget, rect):
         if not self.config['autoscroll']:
             return
@@ -155,6 +185,9 @@ class Terminal(Gtk.Window):
         popup.show_all()
 
     def user_input(self, termview, line):
+        self.hist.add_entry(self.pty.master, line)
+        self.reset_history_index()
+
         os.write(self.pty.master, line.encode("UTF-8"))
 
     def termios_ctrl(self, termview, cidx):