From f033b29a7de329d160f8129c957fcfb3ba0d98fe Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Wed, 30 Sep 2026 11:14:12 -0400 Subject: [PATCH] feat(cli): add mtime-sort utility, enhance pj task runner, and remove legacy executable_gho --- dot_local/bin/executable_gho | 31 ----- dot_local/bin/executable_mtime-sort | 24 ++++ dot_local/bin/executable_pj | 181 +++++++++++++++------------- 3 files changed, 121 insertions(+), 115 deletions(-) delete mode 100755 dot_local/bin/executable_gho create mode 100755 dot_local/bin/executable_mtime-sort diff --git a/dot_local/bin/executable_gho b/dot_local/bin/executable_gho deleted file mode 100755 index bba0c58..0000000 --- a/dot_local/bin/executable_gho +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh -# Pick a ghq repo or an Obsidian vault with fzf (tab toggles the list); -# prints the directory for the shell wrapper to cd into. -vaults="$HOME/Documents/Obsidian/Personal $HOME/Documents/Obsidian/Walletbase $HOME/Documents/Obsidian/Research" - -# chezmoi's source tree is a normal checkout of github.com/prdlk/dotfiles, but -# it lives under ~/.local/share, not ghq's root, so `ghq list` never sees it. -# List it under its GitHub name and translate that back to the real path below. -chezmoi_dir="$HOME/.local/share/chezmoi" -chezmoi_entry="github.com/prdlk/dotfiles" - -# One command string, used both for the initial list and for fzf's reload -# binding, so the two can't drift. -repos="{ ghq list; [ -d $chezmoi_dir ] && echo $chezmoi_entry; } | sort" - -# fzf runs bind actions with $SHELL -c; pin it to sh so the transform -# snippet below is interpreted consistently regardless of login shell. -selection=$(eval "$repos" | SHELL=/bin/sh fzf --prompt="repo: " --tiebreak=end \ - --bind "tab:transform:if [ \"\$FZF_PROMPT\" = 'repo: ' ]; then - echo \"change-prompt(vault: )+reload(printf '%s\\n' $vaults)\" - else - echo 'change-prompt(repo: )+reload($repos)' - fi") || exit 1 -[ -n "$selection" ] || exit 1 - -# Vault entries are absolute paths; ghq entries are host/user/repo -case "$selection" in - /*) printf '%s\n' "$selection" ;; - "$chezmoi_entry") printf '%s\n' "$chezmoi_dir" ;; - *) printf '%s\n' "$(ghq root)/$selection" ;; -esac diff --git a/dot_local/bin/executable_mtime-sort b/dot_local/bin/executable_mtime-sort new file mode 100755 index 0000000..41eea11 --- /dev/null +++ b/dot_local/bin/executable_mtime-sort @@ -0,0 +1,24 @@ +#!/bin/sh +# Sort stdin lines newest-first by mtime — the base order of the television +# cables (tv then layers match quality and frecency on top). +# +# The path is each line's last tab-separated field (the whole line when it +# has no tab). With --repo, /.git/index is stat'ed first, so a repo +# ranks by its last git activity rather than its directory mtime. Lines whose +# path does not exist are dropped; ties keep their input order. +# +# Usage: | mtime-sort [--repo] +exec perl -e ' + my $repo = @ARGV && $ARGV[0] eq "--repo"; + my ($i, @rows) = (0); + while (my $line = ) { + chomp $line; + my $path = (split /\t/, $line)[-1]; + next unless defined $path && length $path; + my $mtime; + $mtime = (stat "$path/.git/index")[9] if $repo; + $mtime = (stat $path)[9] unless defined $mtime; + push @rows, [$mtime, $i++, $line] if defined $mtime; + } + print "$_->[2]\n" for sort { $b->[0] <=> $a->[0] || $a->[1] <=> $b->[1] } @rows; +' -- "$@" diff --git a/dot_local/bin/executable_pj b/dot_local/bin/executable_pj index c47e76f..8bb05d1 100755 --- a/dot_local/bin/executable_pj +++ b/dot_local/bin/executable_pj @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# pj — fzf task runner across every task source in the project. +# pj — task runner across every task source in the project, picked with the +# television `pj` cable (~/.config/television/cable/pj.toml). # # Sources: package.json scripts, Makefile targets, justfile recipes, mise # tasks, devbox scripts, and pyproject.toml task tables (poe, pdm, rye, @@ -8,22 +9,21 @@ # Scanning: cwd upward to the git toplevel (inclusive); outside a repo it # stops at the nearest directory that holds a task source. A source is only # listed when its runner is actually installed, so the list is never dead. +# Tasks from the most recently modified manifest come first. +# +# Usage: pj [extra args…] extra args are forwarded to the picked task # # Env: -# PJ_QUIET=1 suppress the "no task sources" error — the fish down-arrow -# binding uses this, where an empty tree is the normal case. +# PJ_QUIET=1 suppress the "no task sources" error — the down-arrow +# bindings use this, where an empty tree is the normal case. # -# Internal: `pj --preview KIND TARGET ROOT FILE` renders the fzf preview. +# Cable entry points (tab-separated record fields, see `--list`): +# pj --list print one record per task +# pj --preview KIND TARGET ROOT FILE render a task's body +# pj --run KIND TARGET ROOT RUNNER run a task ($PJ_EXTRA holds extra args) have() { command -v "$1" >/dev/null 2>&1; } -# Resolve this script's path so the fzf preview can re-enter it. -self=$0 -case $self in - */*) ;; - *) self=$(command -v -- "$self" 2>/dev/null || echo "$self") ;; -esac - # ── pyproject.toml reader ────────────────────────────────────────── # `list FILE` prints "tooltarget" per task; `show FILE TARGET TOOL` # prints one task's body. tomllib is stdlib on python 3.11+. @@ -102,7 +102,7 @@ else: PY } -# ── fzf preview ──────────────────────────────────────────────────── +# ── Preview ──────────────────────────────────────────────────────── if [ "${1-}" = --preview ]; then kind=${2-} target=${3-} root=${4-} file=${5-} cd "$root" 2>/dev/null || exit 0 @@ -146,6 +146,55 @@ if [ "${1-}" = --preview ]; then exit 0 fi +# ── Run (the cable's enter action) ──────────────────────────────── +if [ "${1-}" = --run ]; then + kind=${2-} target=${3-} root=${4-} runner=${5-} + cd "$root" || exit 1 + + # Extra args from `pj a b …`, shell-quoted into $PJ_EXTRA by the picker. + extra=() + [ -n "${PJ_EXTRA-}" ] && eval "extra=($PJ_EXTRA)" + # Runners that need `--` to forward extra args to the task itself. + if [ ${#extra[@]} -gt 0 ]; then + case "$kind:$runner" in + npm:'npm run' | mise:* | devbox:*) extra=(-- "${extra[@]}") ;; + esac + fi + + # shellcheck disable=SC2206 # word splitting is how the runner prefix is built + cmd=(${runner} "$target" "${extra[@]}") + + # Wrap long-running server tasks in openlogs (live log tail for agents). + # Matches dev/start/serve/watch and namespaced variants like dev:web. + wrapper="" + if [[ $target =~ ^(dev|start|serve|watch)(:.*)?$ ]]; then + # Resolve the binary: PATH → mise resolution → mise installs glob + ol=$(command -v openlogs || true) + if [ -z "$ol" ] && have mise; then + ol=$(mise which openlogs 2>/dev/null || true) + fi + if [ -z "$ol" ]; then + for cand in "$HOME"/.local/share/mise/installs/node/*/bin/openlogs; do + [ -x "$cand" ] && { ol=$cand; break; } + done + fi + + if [ -n "$ol" ] && [ -x "$ol" ]; then + wrapper=$ol + else + echo "pj: openlogs not found, running unwrapped" >&2 + fi + fi + + if [ -n "$wrapper" ]; then + echo "→ $wrapper ${cmd[*]}" >&2 + exec "$wrapper" "${cmd[@]}" + else + echo "→ ${cmd[*]}" >&2 + exec "${cmd[@]}" + fi +fi + # ── Collect targets ──────────────────────────────────────────────── mise_config() { local d=$1 c @@ -341,10 +390,12 @@ if [ ${#recs[@]} -eq 0 ]; then exit 1 fi -# ── Pick a target ────────────────────────────────────────────────── -# Tag each row with its kind (npm rows use the package manager) so `make`, -# `just` or `poe` are typeable filters in fzf. -# The directory column only appears when more than one project contributed. +# ── List ─────────────────────────────────────────────────────────── +# Record (tab separated): display, kind, target, root, runner, manifest. +# Tag each display with its kind (npm rows use the package manager) so `make`, +# `just` or `poe` are typeable filters. The directory column only appears +# when more than one project contributed. The manifest is the last field so +# mtime-sort ranks tasks by their manifest's mtime. primary=${recs[0]#*$'\t'} primary=${primary#*$'\t'} primary=${primary%%$'\t'*} @@ -356,75 +407,37 @@ for r in "${recs[@]}"; do [ "$root" = "$primary" ] || { multi=1; break; } done -lines=() -for r in "${recs[@]}"; do - IFS=$'\t' read -r kind target root _file runner <<<"$r" - case $kind in - npm) tag=${runner%% *} ;; # npm | pnpm | yarn | bun - script) tag=py ;; # pyproject console entry points - *) tag=$kind ;; - esac - if [ "$multi" = 1 ]; then - display=$(printf '%-8s %-26s \033[2m%s\033[0m' "$tag" "$target" "${root##*/}") - else - display=$(printf '%-8s %s' "$tag" "$target") - fi - lines+=("$display"$'\t'"$r") -done +list() { + local r kind target root file runner tag display + for r in "${recs[@]}"; do + IFS=$'\t' read -r kind target root file runner <<<"$r" + case $kind in + npm) tag=${runner%% *} ;; # npm | pnpm | yarn | bun + script) tag=py ;; # pyproject console entry points + *) tag=$kind ;; + esac + if [ "$multi" = 1 ]; then + display=$(printf '%-8s %-26s %s' "$tag" "$target" "${root##*/}") + else + display=$(printf '%-8s %s' "$tag" "$target") + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$display" "$kind" "$target" "$root" "$runner" "$file" + done | mtime-sort +} -selection=$(printf '%s\n' "${lines[@]}" | fzf \ - --ansi \ - --delimiter='\t' --with-nth=1 \ - --prompt='run ❯ ' \ - --header="$primary" \ - --height=40% --layout=reverse --border \ - --preview="'$self' --preview {2} {3} {4} {5}" \ - --preview-window=up:6:wrap) +if [ "${1-}" = --list ]; then + list + exit 0 +fi -[ -n "$selection" ] || exit 0 - -IFS=$'\t' read -r _display kind target root _file runner <<<"$selection" - -# ── Run ──────────────────────────────────────────────────────────── -cd "$root" || exit 1 - -# Runners that need `--` to forward extra args to the task itself. -extra=("$@") +# ── Pick ─────────────────────────────────────────────────────────── +# The scan already ran, so hand tv the result instead of letting the cable +# re-run `pj --list`; tv's enter action calls `pj --run`. +tmp=$(mktemp "${TMPDIR:-/tmp}/pj.XXXXXX") || exit 1 +trap 'rm -f "$tmp"' EXIT +list >"$tmp" if [ $# -gt 0 ]; then - case "$kind:$runner" in - npm:'npm run' | mise:* | devbox:*) extra=(-- "$@") ;; - esac -fi - -# shellcheck disable=SC2206 # word splitting is how the runner prefix is built -cmd=(${runner} "$target" "${extra[@]}") - -# Wrap long-running server tasks in openlogs (live log tail for agents). -# Matches dev/start/serve/watch and namespaced variants like dev:web. -wrapper="" -if [[ $target =~ ^(dev|start|serve|watch)(:.*)?$ ]]; then - # Resolve the binary: PATH → mise resolution → mise installs glob - ol=$(command -v openlogs || true) - if [ -z "$ol" ] && have mise; then - ol=$(mise which openlogs 2>/dev/null || true) - fi - if [ -z "$ol" ]; then - for cand in "$HOME"/.local/share/mise/installs/node/*/bin/openlogs; do - [ -x "$cand" ] && { ol=$cand; break; } - done - fi - - if [ -n "$ol" ] && [ -x "$ol" ]; then - wrapper=$ol - else - echo "pj: openlogs not found, running unwrapped" >&2 - fi -fi - -if [ -n "$wrapper" ]; then - echo "→ $wrapper ${cmd[*]}" >&2 - exec "$wrapper" "${cmd[@]}" -else - echo "→ ${cmd[*]}" >&2 - exec "${cmd[@]}" + PJ_EXTRA=$(printf '%q ' "$@") + export PJ_EXTRA fi +tv pj --source-command "cat '$tmp'" --input-header "$primary"