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 ----