Recently Written · git

sbm

dmenu bookmarks. Instantly fuzzy search thousands of bookmarks and plumb them. LOOKING FOR SUCKLESS SOFTWARE EDITION? GO TO sbm-suckless INSTEAD

git clone https://github.com/equwal/sbm

Log | Files | Refs


commit ea222432154f28d265007c72ad7c02c40558d9fb
Spenser Truex <truex@equwal.com>
2026-09-22 21:34:15 -0700

Live preview in more places: terminals on a desktop, the app menu, bm-html

bm now uses fzf when it runs in a terminal, also when a display is
there. The fzf preview shows the page of the bookmark under the cursor,
and dmenu cannot show a preview. From a key of the window manager, with
no terminal, bm keeps dmenu. SBM_MENU=dmenu keeps dmenu in a terminal.

make install adds "sbm" to the app menu of a Linux desktop
(contrib/linux/sbm.desktop). It opens bm in a terminal, so with fzf and
its live preview, as the Windows and macOS apps do. The rpm and FreeBSD
file lists include it.

bm-html pages get the live preview of sbm Sync: in a wide window the
page of the highlighted bookmark shows on the right. The arrow keys and
the mouse move the highlight, and Enter opens it. A page loads only
when the user types or points, 300 ms after the highlight stops, in a
sandboxed frame without a referrer. On an https page an http bookmark
loads as https. A page in the frame cannot keep the focus. Without
script the page is the plain list, as before.

 Makefile                                  |  4 +++
 README                                    | 27 ++++++++++-------
 bm                                        |  8 +++--
 bm-html                                   | 49 +++++++++++++++++++++++++++----
 contrib/linux/sbm.desktop                 |  9 ++++++
 packaging/README.md                       |  4 +++
 packaging/freebsd/deskutils/sbm/pkg-plist |  1 +
 packaging/rpm/sbm.spec                    |  1 +
 test/run.sh                               | 30 ++++++++++++++++++-
 9 files changed, 114 insertions(+), 19 deletions(-)
diff --git a/Makefile b/Makefile
index d1d4764..4fffffe 100755
--- a/Makefile
+++ b/Makefile
@@ -30,9 +30,13 @@ install:
 		cp $$tool ${DESTDIR}${PREFIX}/bin/ && \
 		chmod 755 ${DESTDIR}${PREFIX}/bin/$$tool || exit 1; \
 	done
+	@mkdir -p ${DESTDIR}${PREFIX}/share/applications
+	@cp contrib/linux/sbm.desktop ${DESTDIR}${PREFIX}/share/applications/
+	@chmod 644 ${DESTDIR}${PREFIX}/share/applications/sbm.desktop
 
 uninstall:
 	@echo removing scripts
 	@for tool in ${SCRIPTS}; do rm -f ${DESTDIR}${PREFIX}/bin/$$tool; done
+	@rm -f ${DESTDIR}${PREFIX}/share/applications/sbm.desktop
 
 .PHONY: all check dist install uninstall
diff --git a/README b/README
index 4dd5366..2fb3302 100755
--- a/README
+++ b/README
@@ -98,11 +98,12 @@ to do:
 Closing the second menu goes back to the list, closing the list leaves.
 [add] at the top of the list adds a bookmark.
 
-With fzf (no display, or SBM_MENU=fzf) it is one screen: the bookmark under
-the cursor is previewed live, and keys do the rest. With bm-page installed,
-the preview also shows the text of the page: its title, then its main part.
-bm-page fetches the page with curl (5 second limit, at most 512k; SBM_FETCH=0
-turns it off).
+With fzf (in a terminal, with no display, or SBM_MENU=fzf) it is one screen:
+the bookmark under the cursor is previewed live, and keys do the rest. With
+bm-page installed, the preview also shows the text of the page: its title,
+then its main part. bm-page fetches the page with curl (5 second limit, at
+most 512k; SBM_FETCH=0 turns it off). make install also adds "sbm" to the
+app menu of a Linux desktop: it opens bm with fzf in a terminal.
 
     ENTER   open            ctrl-a  add            ctrl-s  next sort order
     ctrl-y  copy            ctrl-d  delete (asks)
@@ -151,9 +152,13 @@ bookmark file itself stays a plain list.
 
 One self-contained file: no server side, nothing loaded from elsewhere.
 Bookmarks are listed under each of their tags, with the tags linked at the
-top. A dozen lines of inline script add a filter box; without script the page
-is still the complete list, and Ctrl-F works. page.html?q=words starts out
-filtered, so the page can be a search keyword in your browser, or in bm:
+top. Inline script adds a filter box and, in a wide window, a live preview:
+the page of the highlighted bookmark shows on the right. The arrow keys and
+the mouse move the highlight, and Enter opens it. The preview loads a page
+only when you type or point, in a sandboxed frame without a referrer; some
+sites do not let other pages show them. Without script the page is still the
+complete list, and Ctrl-F works. page.html?q=words starts out filtered, so
+the page can be a search keyword in your browser, or in bm:
 
     b | https://example.org/bookmarks.html?q=%s
 
@@ -249,8 +254,10 @@ All through the environment, all optional.
     SBM_OPEN      command that opens the URL given as its argument
     SBM_LOCK_WAIT seconds that bm and bm-sync wait for the lock (default 10)
 
-With a display bm uses dmenu; fzf is only used when dmenu is missing or
-there is no X11 or Wayland display. A custom SBM_MENU is called with the
+With a display bm uses dmenu, unless bm runs in a terminal: there it uses
+fzf, whose preview shows the page. fzf is also used when dmenu is missing or
+there is no X11 or Wayland display. SBM_MENU=dmenu keeps dmenu in a
+terminal. A custom SBM_MENU is called with the
 choices on stdin as one of:
 
     <command> pick  <prompt>     print the chosen line, or the typed text
diff --git a/bm b/bm
index 873d658..5b5f090 100755
--- a/bm
+++ b/bm
@@ -63,11 +63,13 @@ EOF
 
 # ---- external programs: menu, clipboard, opener ----
 
-# dmenu whenever there is a display to draw it on. fzf stands in on a bare
-# terminal, or when dmenu is not installed.
+# dmenu when there is a display to draw it on and bm does not run in a
+# terminal, for example from a key of the window manager. In a terminal fzf
+# is the menu, because its preview shows the page of the bookmark under the
+# cursor. fzf also stands in when dmenu is not installed.
 pickmenu () {
     [ -n "$SBM_MENU" ] && return
-    if [ -n "$DISPLAY$WAYLAND_DISPLAY" ]; then
+    if [ -n "$DISPLAY$WAYLAND_DISPLAY" ] && ! { [ -t 0 ] && [ -t 2 ]; }; then
         order='dmenu fzf'
     else
         order='fzf dmenu'
diff --git a/bm-html b/bm-html
index 4031c5f..73887d2 100755
--- a/bm-html
+++ b/bm-html
@@ -8,10 +8,11 @@
 # Without a file the default bookmark file is read; "-" is stdin.
 #
 # The page lists the bookmarks under each of their tags, with links to the
-# tags at the top. A few lines of script add a filter box; without script the
-# page is still the complete list. page.html?q=words starts out filtered, so
-# the page can serve as a search engine keyword in a browser, or in bm's own
-# engines file:    b | https://example.org/bookmarks.html?q=%s
+# tags at the top. A few lines of script add a filter box and, in a wide
+# window, a live preview: the page of the highlighted bookmark. Without
+# script the page is still the complete list. page.html?q=words starts out
+# filtered, so the page can serve as a search engine keyword in a browser, or
+# in bm's own engines file:    b | https://example.org/bookmarks.html?q=%s
 
 usage () {
     printf 'usage: bm-html [-t title] [file | -] ...\n'
@@ -79,9 +80,18 @@ END {
     print "body{max-width:50em;margin:1em auto;padding:0 1em;font:1em/1.5 sans-serif}"
     print "#q{width:100%;box-sizing:border-box;font:inherit;padding:.3em}"
     print "ul{padding-left:1.2em}small,.t{color:#777;font-size:.85em}.t{margin-left:.3em}"
+    print "li.sel{background:#e3eef3;box-shadow:0 0 0 .2em #e3eef3}"
+    print "#preview{display:none}#preview p{margin:0 0 .3em;color:#777;font-size:.85em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}"
+    print "#preview iframe{flex:1;width:100%;box-sizing:border-box;border:1px solid #ccc;background:#fff}"
+    print "@media(min-width:80em){body:has(#preview:not([hidden])){margin-left:1em}"
+    print "#preview:not([hidden]){display:flex;flex-direction:column;position:fixed;top:1em;bottom:1em;left:54em;right:1em}}"
     print "</style>"
     print "<h1>" t "</h1>"
     print "<input id=q type=search placeholder=\"filter " total " bookmarks\" hidden>"
+    # The live preview: the page of the highlighted bookmark, on the right
+    # of a wide window. The script shows it.
+    print "<aside id=preview hidden><p>Type in the filter, or point at a bookmark, to see its page here.</p>"
+    print "<iframe title=\"Preview of the page\" sandbox=\"allow-scripts allow-same-origin\" referrerpolicy=no-referrer></iframe></aside>"
     printf "<p>"
     for (i = 1; i <= count; i++)
         if (names[i] != "")
@@ -100,6 +110,35 @@ END {
     print "var t=(l.textContent+\" \"+l.firstChild.href).toLowerCase();"
     print "var ok=w.every(function(x){return t.indexOf(x)>=0});l.hidden=!ok;if(ok)n++});"
     print "s.hidden=!n})}"
-    print "q.oninput=f;q.value=new URLSearchParams(location.search).get(\"q\")||\"\";f();"
+    # The live preview, as on sbm Sync: the arrow keys and the mouse move the
+    # highlight, Enter opens it, and the page loads when the highlight stays
+    # on it for 300 ms. On an https page an http bookmark loads as https,
+    # because a secure page cannot show an insecure frame. A page in the
+    # frame cannot keep the focus: the typing stays in the filter.
+    print "var pane=document.getElementById(\"preview\"),frame=pane.querySelector(\"iframe\"),cap=pane.querySelector(\"p\");"
+    print "var wide=matchMedia(\"(min-width:80em)\"),sel=0,started=false,timer=0,framed=null,pointer=\"\";"
+    print "function rows(){return Array.prototype.filter.call(document.querySelectorAll(\"li\"),function(l){return !l.hidden})}"
+    print "function address(l){var u;try{u=new URL(l.firstChild.href)}catch(e){return null}"
+    print "if(u.protocol!==\"http:\"&&u.protocol!==\"https:\")return null;"
+    print "if(location.protocol===\"https:\")u.protocol=\"https:\";return u.href}"
+    print "function load(to){if(to===framed)return;framed=to;frame.src=to||\"about:blank\"}"
+    print "function show(l){clearTimeout(timer);if(!wide.matches)return;var to=l?address(l):null;"
+    print "cap.textContent=!l?\"No bookmark to preview.\":to||\"No preview: this bookmark is not a web page.\";"
+    print "if(to)timer=setTimeout(function(){load(to)},300);else load(null)}"
+    print "function light(i){var r=rows();sel=Math.max(0,Math.min(r.length-1,i));"
+    print "document.querySelectorAll(\"li.sel\").forEach(function(l){l.className=\"\"});"
+    print "if(r[sel])r[sel].className=\"sel\";if(started)show(r[sel])}"
+    print "function point(e){var l=e.target.closest&&e.target.closest(\"li\");if(!l)return;started=true;light(rows().indexOf(l))}"
+    print "q.oninput=function(){started=true;f();light(0)};"
+    print "q.onkeydown=function(e){var r=rows();"
+    print "if(e.key===\"ArrowDown\"||e.key===\"ArrowUp\"){e.preventDefault();started=true;"
+    print "light(sel+(e.key===\"ArrowDown\"?1:-1));if(rows()[sel])rows()[sel].scrollIntoView({block:\"nearest\"})}"
+    print "else if(e.key===\"Enter\"&&r[sel]){if(e.ctrlKey||e.metaKey)open(r[sel].firstChild.href,\"_blank\",\"noopener\");"
+    print "else location.assign(r[sel].firstChild.href)}};"
+    print "document.addEventListener(\"mousemove\",function(e){var at=e.screenX+\",\"+e.screenY;if(at===pointer)return;pointer=at;point(e)});"
+    print "document.addEventListener(\"focusin\",point);"
+    print "wide.addEventListener(\"change\",function(){if(wide.matches)light(sel);else load(null)});"
+    print "addEventListener(\"blur\",function(){setTimeout(function(){if(document.activeElement===frame)q.focus({preventScroll:true})})});"
+    print "q.value=new URLSearchParams(location.search).get(\"q\")||\"\";started=q.value!==\"\";f();pane.hidden=false;light(0);"
     print "</script>"
 }'
diff --git a/contrib/linux/sbm.desktop b/contrib/linux/sbm.desktop
new file mode 100644
index 0000000..6c820fc
--- /dev/null
+++ b/contrib/linux/sbm.desktop
@@ -0,0 +1,9 @@
+[Desktop Entry]
+Type=Application
+Name=sbm
+GenericName=Bookmarks
+Comment=Search your bookmarks, with a live preview of the page
+Exec=env SBM_FZF_OPTS=--no-height bm
+Terminal=true
+Categories=Utility;
+Keywords=bookmarks;search;fzf;
diff --git a/packaging/README.md b/packaging/README.md
index 653f57c..aaf0d9a 100755
--- a/packaging/README.md
+++ b/packaging/README.md
@@ -26,6 +26,10 @@ mentions a sync client.
 
     bm  bm-migrate  bm-import  bm-check  bm-html  bm-title  bm-page  bm-commit  bm-watch
 
+It also copies `contrib/linux/sbm.desktop` to
+`${DESTDIR}${PREFIX}/share/applications`: the app-menu entry "sbm", which
+opens bm with fzf and its live preview in a terminal.
+
 Nothing is compiled. There are no man pages. Every package calls
 `make DESTDIR=... PREFIX=... install` rather than copying the scripts itself,
 except the Windows packages, which unpack the release archive and generate
diff --git a/packaging/freebsd/deskutils/sbm/pkg-plist b/packaging/freebsd/deskutils/sbm/pkg-plist
index ccd8d22..f26ddd0 100755
--- a/packaging/freebsd/deskutils/sbm/pkg-plist
+++ b/packaging/freebsd/deskutils/sbm/pkg-plist
@@ -7,6 +7,7 @@ bin/bm-migrate
 bin/bm-title
 bin/bm-page
 bin/bm-watch
+share/applications/sbm.desktop
 %%PORTDOCS%%%%DOCSDIR%%/README
 %%PORTDOCS%%%%DOCSDIR%%/TODO
 %%PORTEXAMPLES%%%%EXAMPLESDIR%%/engines
diff --git a/packaging/rpm/sbm.spec b/packaging/rpm/sbm.spec
index 2bd2059..f8de39d 100755
--- a/packaging/rpm/sbm.spec
+++ b/packaging/rpm/sbm.spec
@@ -83,6 +83,7 @@ make check
 %{_bindir}/bm-title
 %{_bindir}/bm-page
 %{_bindir}/bm-watch
+%{_datadir}/applications/sbm.desktop
 
 %changelog
 * Sun Sep 20 2026 Spenser Truex <truex@equwal.com> - 0.3-1
diff --git a/test/run.sh b/test/run.sh
index 8ea58e0..9fa6b15 100755
--- a/test/run.sh
+++ b/test/run.sh
@@ -865,7 +865,7 @@ both="$work/bin:$work/fzfbin"
 : > "$SBM_TEST_LOG"
 answers 'Zed' 'copy'
 ( unset SBM_MENU WAYLAND_DISPLAY; PATH="$both:$PATH" DISPLAY=:0 $BM </dev/null )
-eq 'a display means dmenu even when fzf exists' \
+eq 'a display without a terminal means dmenu even when fzf exists' \
     "$(cut -d' ' -f1 "$SBM_TEST_LOG" | sort -u)" 'dmenu'
 
 : > "$SBM_TEST_LOG"
@@ -878,6 +878,25 @@ answers 'enter:Zed'
 ( unset SBM_MENU DISPLAY WAYLAND_DISPLAY; PATH="$both:$PATH" $BM </dev/null )
 eq 'no display falls back to fzf' "$(cut -d' ' -f1 "$SBM_TEST_LOG" | sort -u)" 'fzf'
 
+# In a terminal bm picks fzf even with a display, because the fzf preview
+# shows the page. script (util-linux) gives bm a terminal; without it the
+# test cannot run.
+if SHELL=/bin/sh script -eqc 'test -t 0' /dev/null </dev/null >/dev/null 2>&1; then
+    : > "$SBM_TEST_LOG"
+    answers 'enter:Zed'
+    ( unset SBM_MENU WAYLAND_DISPLAY; export PATH="$both:$PATH" DISPLAY=:0
+      SHELL=/bin/sh script -eqc "$BM" /dev/null </dev/null >/dev/null 2>&1 )
+    eq 'a display with a terminal means fzf, whose preview shows the page' \
+        "$(cut -d' ' -f1 "$SBM_TEST_LOG" | sort -u)" 'fzf'
+    : > "$SBM_TEST_LOG"
+    answers 'Zed' 'copy'
+    ( unset WAYLAND_DISPLAY; export PATH="$both:$PATH" DISPLAY=:0 SBM_MENU=dmenu
+      SHELL=/bin/sh script -eqc "$BM" /dev/null </dev/null >/dev/null 2>&1 )
+    eq 'SBM_MENU=dmenu keeps dmenu in a terminal' "$(cut -d' ' -f1 "$SBM_TEST_LOG" | sort -u)" 'dmenu'
+else
+    printf 'skip the menu in a terminal: util-linux script is not installed\n'
+fi
+
 unset SBM_TEST_LOG
 
 # ---- one-screen browsing without key bindings (dmenu, custom menus) ----
@@ -1003,6 +1022,9 @@ eq 'bm-html has one inline script, a filter box hidden without it, and ?q=' \
     "$(printf '%s\n' "$page" | grep -c '<script>') $(printf '%s\n' "$page" | grep -c '<input id=q type=search placeholder="filter 3 bookmarks" hidden>') $(printf '%s\n' "$page" | grep -c 'URLSearchParams(location.search).get("q")')" \
     '1 1 1'
 eq 'bm-html loads nothing from elsewhere' "$(printf '%s\n' "$page" | grep -c -e ' src=' -e '<link' -e '@import')" '0'
+eq 'bm-html has a preview pane that only its script shows, with a sandboxed frame and no referrer' \
+    "$(printf '%s\n' "$page" | grep -c '^<aside id=preview hidden>') $(printf '%s\n' "$page" | grep -c '<iframe title="Preview of the page" sandbox="allow-scripts allow-same-origin" referrerpolicy=no-referrer></iframe></aside>') $(printf '%s\n' "$page" | grep -c 'pane.hidden=false')" \
+    '1 1 1'
 eq 'bm-html is a filter' "$($BM -t lib -l | $HTML - | grep -c '^<li>')" '2'
 eq 'bm-html reads the bookmark file by default, whatever stdin is' \
     "$($HTML </dev/null | grep -c '^<li>')" '4'
@@ -1277,6 +1299,12 @@ if command -v make >/dev/null 2>&1; then
     make -s -C "$top" install DESTDIR="$work/dest" PREFIX=/usr TOOLS='bm bm-html' >/dev/null
     eq 'make install installs what TOOLS names, and only that' \
         "$(cd "$work/dest/usr/bin" && for f in *; do [ -x "$f" ] && printf '%s ' "$f"; done)" 'bm bm-html '
+    eq 'make install puts sbm in the app menu: bm in a terminal, so fzf with the preview' \
+        "$(grep -E '^(Exec|Terminal)=' "$work/dest/usr/share/applications/sbm.desktop" | paste -sd '|' -)" \
+        'Exec=env SBM_FZF_OPTS=--no-height bm|Terminal=true'
+    make -s -C "$top" uninstall DESTDIR="$work/dest" PREFIX=/usr >/dev/null
+    eq 'make uninstall removes the app-menu entry' \
+        "$(ls "$work/dest/usr/share/applications" | wc -l | tr -d ' ')" '0'
 fi
 
 # ---- defaults ----