acstack 0.49.0 → 0.49.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/assets/terminal-workspace/helpers/acstack-bridge +185 -0
- package/assets/terminal-workspace/helpers/acstack-clip +88 -0
- package/assets/terminal-workspace/helpers/acstack-dash +25 -0
- package/assets/terminal-workspace/helpers/acstack-db +86 -0
- package/assets/terminal-workspace/helpers/acstack-diff +268 -0
- package/assets/terminal-workspace/helpers/acstack-edit +25 -0
- package/assets/terminal-workspace/helpers/acstack-gh +47 -0
- package/assets/terminal-workspace/helpers/acstack-herdr-renumber +93 -0
- package/assets/terminal-workspace/helpers/acstack-herdr-tab +49 -0
- package/assets/terminal-workspace/helpers/acstack-md +401 -0
- package/assets/terminal-workspace/helpers/acstack-md-lib +219 -0
- package/assets/terminal-workspace/helpers/acstack-open +217 -0
- package/assets/terminal-workspace/helpers/acstack-popup +13 -0
- package/assets/terminal-workspace/helpers/acstack-view +33 -0
- package/assets/terminal-workspace/helpers/xclip +31 -0
- package/assets/terminal-workspace/lib/apply-ghdash.sh +78 -0
- package/assets/terminal-workspace/lib/apply-herdr-numbers.sh +49 -0
- package/assets/terminal-workspace/lib/apply-herdr.sh +308 -0
- package/assets/terminal-workspace/lib/apply-tools.sh +293 -0
- package/assets/terminal-workspace/lib/apply-tuicr.sh +133 -0
- package/assets/terminal-workspace/lib/apply-yazi.sh +389 -0
- package/assets/terminal-workspace/lib/commands.sh +303 -0
- package/assets/terminal-workspace/lib/core.sh +184 -0
- package/assets/terminal-workspace/lib/helper-install.sh +35 -0
- package/assets/terminal-workspace/lib/pi-packages.sh +360 -0
- package/assets/terminal-workspace/lib/pi-state.sh +479 -0
- package/assets/terminal-workspace/lib/versions.sh +254 -0
- package/assets/terminal-workspace/pi/acstack-question-blocked.ts +31 -0
- package/assets/terminal-workspace/setup.sh +25 -4637
- package/cli/package.json +1 -1
- package/docs/terminal-workspace.md +10 -1
- package/package.json +1 -1
- package/skills/handoff/SKILL.md +14 -14
- package/skills/handoff/launch-herdr.md +15 -4
- package/skills/implement/SKILL.md +5 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
build_tuicr_fork() {
|
|
2
|
+
# TEMPORARY path. Builds the two unmerged upstream pull requests the config
|
|
3
|
+
# comments name. Once both land, set build_from_fork: false and this whole
|
|
4
|
+
# function stops being reachable.
|
|
5
|
+
# `have cargo` is not enough: a rustup shim on PATH with no default
|
|
6
|
+
# toolchain installed exits non-zero on every invocation.
|
|
7
|
+
if ! cargo --version >/dev/null 2>&1; then
|
|
8
|
+
say "tuicr fork build needs a working cargo (see rustup.rs), skipping"
|
|
9
|
+
cargo --version 2>&1 | head -2 | sed 's/^/ /'
|
|
10
|
+
return 1
|
|
11
|
+
fi
|
|
12
|
+
have git || { say "tuicr fork build needs git, skipping"; return 1; }
|
|
13
|
+
local url branch dir
|
|
14
|
+
url=$(cfg_get tuicr.fork "https://github.com/ac8318740/tuicr")
|
|
15
|
+
branch=$(cfg_get tuicr.fork_branch "local/daily")
|
|
16
|
+
dir="${ACSTACK_TUICR_SRC:-$HOME/tuicr}"
|
|
17
|
+
if [ -d "$dir/.git" ]; then
|
|
18
|
+
git -C "$dir" fetch --quiet --all 2>/dev/null
|
|
19
|
+
git -C "$dir" checkout --quiet "$branch" 2>/dev/null || { say "tuicr: no branch $branch in $dir"; return 1; }
|
|
20
|
+
git -C "$dir" pull --quiet --ff-only 2>/dev/null
|
|
21
|
+
else
|
|
22
|
+
git clone --quiet -b "$branch" "$url" "$dir" || { say "tuicr: clone failed"; return 1; }
|
|
23
|
+
fi
|
|
24
|
+
say "building tuicr from $branch (a few minutes on a cold cache)"
|
|
25
|
+
local log; log=$(mktemp)
|
|
26
|
+
if ! ( cd "$dir" && cargo build --release ) >"$log" 2>&1; then
|
|
27
|
+
say "tuicr: build failed in $dir"
|
|
28
|
+
# Swallowing cargo's output here made a missing toolchain look identical
|
|
29
|
+
# to a compile error.
|
|
30
|
+
grep -E '^(error|warning: unused)' "$log" | head -5 | sed 's/^/ /'
|
|
31
|
+
tail -3 "$log" | sed 's/^/ /'
|
|
32
|
+
rm -f "$log"; return 1
|
|
33
|
+
fi
|
|
34
|
+
rm -f "$log"
|
|
35
|
+
mkdir -p "$BIN"
|
|
36
|
+
# mv, not cp: cp fails with "Text file busy" when tuicr is running.
|
|
37
|
+
cp "$dir/target/release/tuicr" "$BIN/tuicr.new" && mv -f "$BIN/tuicr.new" "$BIN/tuicr"
|
|
38
|
+
say "tuicr built from $branch and installed to $BIN/tuicr"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
apply_tuicr() {
|
|
42
|
+
local from_fork; from_fork=$(cfg_get tuicr.build_from_fork false)
|
|
43
|
+
if ! have tuicr; then
|
|
44
|
+
if [ "$from_fork" = "true" ]; then
|
|
45
|
+
build_tuicr_fork || return 0
|
|
46
|
+
else
|
|
47
|
+
install_binary tuicr agavra/tuicr x86_64-unknown-linux-gnu || return 0
|
|
48
|
+
fi
|
|
49
|
+
fi
|
|
50
|
+
have tuicr || { say "tuicr not installed, skipping config"; return 0; }
|
|
51
|
+
|
|
52
|
+
mkdir -p "$HOME/.config/tuicr"
|
|
53
|
+
# show_file_line_stats and file_list_width only exist in the fork build.
|
|
54
|
+
# Writing them against a stock release makes tuicr warn on every start.
|
|
55
|
+
ACSTACK_ARGS="$from_fork|$(cfg_get tuicr.file_list_width 30)|$(cfg_get tuicr.show_file_line_stats true)|$(cfg_get tuicr.appearance "")|$BEGIN|$END" \
|
|
56
|
+
py "$HOME/.config/tuicr/config.toml" <<'PY'
|
|
57
|
+
import os, re, sys
|
|
58
|
+
path = sys.argv[1]
|
|
59
|
+
from_fork, width, stats, appearance, begin, end = os.environ["ACSTACK_ARGS"].split("|")
|
|
60
|
+
text = open(path).read() if os.path.isfile(path) else ""
|
|
61
|
+
text = re.sub(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", "", text, flags=re.S)
|
|
62
|
+
|
|
63
|
+
block = [begin]
|
|
64
|
+
managed = set()
|
|
65
|
+
if from_fork == "true":
|
|
66
|
+
block += [
|
|
67
|
+
"# These two keys exist only in the fork build. See the tuicr section",
|
|
68
|
+
"# of ~/.config/acstack/terminal-workspace.yaml.",
|
|
69
|
+
f"show_file_line_stats = {stats}",
|
|
70
|
+
f"file_list_width = {width}",
|
|
71
|
+
"# A local build must not be replaced by a stock release.",
|
|
72
|
+
"no_update_check = true",
|
|
73
|
+
]
|
|
74
|
+
managed |= {"show_file_line_stats", "file_list_width", "no_update_check"}
|
|
75
|
+
else:
|
|
76
|
+
block += ["# Stock release: the fork-only keys are omitted so tuicr does not",
|
|
77
|
+
"# warn about unknown config keys at startup."]
|
|
78
|
+
# `system` asks the terminal for its background colour over OSC 11. Under
|
|
79
|
+
# `herdr --remote` nothing answers, so tuicr falls back to a desktop setting
|
|
80
|
+
# that a headless machine reports as light, and paints dark text on a black
|
|
81
|
+
# terminal. Pinning the value sidesteps the whole detection path.
|
|
82
|
+
if appearance:
|
|
83
|
+
block += ["# Pinned rather than detected. See the tuicr section of",
|
|
84
|
+
"# ~/.config/acstack/terminal-workspace.yaml.",
|
|
85
|
+
f'appearance = "{appearance}"']
|
|
86
|
+
managed.add("appearance")
|
|
87
|
+
block.append(end)
|
|
88
|
+
|
|
89
|
+
# TOML forbids a duplicate key, so a value the user set by hand before this
|
|
90
|
+
# script managed it would make the merged file unparseable and tuicr would
|
|
91
|
+
# reject the lot. Drop only the keys this run writes, and the comment block
|
|
92
|
+
# that introduced each one: the setting alone leaves a heading over nothing.
|
|
93
|
+
kept, pending, cut = [], [], None
|
|
94
|
+
for ln in text.splitlines():
|
|
95
|
+
stripped = ln.strip()
|
|
96
|
+
if stripped.startswith("#") or not stripped:
|
|
97
|
+
pending.append(ln)
|
|
98
|
+
continue
|
|
99
|
+
# Every key this script writes is top level, so the first table header ends
|
|
100
|
+
# the search. A key of the same name inside a table is a different key.
|
|
101
|
+
if cut is None and stripped.startswith("["):
|
|
102
|
+
# Before the comments introducing that header, not after them.
|
|
103
|
+
cut = len(kept)
|
|
104
|
+
if cut is None and "=" in ln and ln.split("=", 1)[0].strip().strip('"') in managed:
|
|
105
|
+
pending = []
|
|
106
|
+
continue
|
|
107
|
+
kept.extend(pending)
|
|
108
|
+
pending = []
|
|
109
|
+
kept.append(ln)
|
|
110
|
+
kept.extend(pending)
|
|
111
|
+
|
|
112
|
+
# The block holds top-level keys, so it goes above the first table header
|
|
113
|
+
# rather than at the end of the file. Appended after a `[export]` or
|
|
114
|
+
# `[forge]` section, every key in it would belong to that section instead:
|
|
115
|
+
# tuicr would warn about an unknown key there and never see the setting, and
|
|
116
|
+
# a name the section already uses makes the file invalid outright.
|
|
117
|
+
if cut is None:
|
|
118
|
+
text = "\n".join(kept).rstrip("\n")
|
|
119
|
+
text = (text + "\n\n" if text else "") + "\n".join(block) + "\n"
|
|
120
|
+
else:
|
|
121
|
+
head = "\n".join(kept[:cut]).rstrip("\n")
|
|
122
|
+
text = ((head + "\n\n" if head else "") + "\n".join(block) + "\n\n"
|
|
123
|
+
+ "\n".join(kept[cut:]).strip("\n") + "\n")
|
|
124
|
+
open(path, "w").write(text)
|
|
125
|
+
PY
|
|
126
|
+
say "tuicr config written"
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# The sidebar draws a workspace's position in herdr's stored list, but
|
|
130
|
+
# prefix+1..9 targets its row in the grouped sidebar. Creating or removing a
|
|
131
|
+
# worktree moves one and not the other, so the numbers you read stop being the
|
|
132
|
+
# numbers you press. This links a tiny herdr plugin that reruns the alignment
|
|
133
|
+
# on every event that can move a row, so it never needs remembering.
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
apply_yazi() {
|
|
2
|
+
[ "$(cfg_get yazi.enabled true)" = "true" ] || return 0
|
|
3
|
+
if ! have yazi; then
|
|
4
|
+
install_binary yazi sxyazi/yazi x86_64-unknown-linux-gnu || return 0
|
|
5
|
+
install_binary ya sxyazi/yazi x86_64-unknown-linux-gnu || true
|
|
6
|
+
fi
|
|
7
|
+
have yazi || { say "yazi not installed, skipping config"; return 0; }
|
|
8
|
+
|
|
9
|
+
# piper turns any shell command into a previewer, which is how acstack-md
|
|
10
|
+
# gets to draw markdown in the preview pane.
|
|
11
|
+
if have ya && ! ya pkg list 2>/dev/null | grep -q "plugins:piper"; then
|
|
12
|
+
ya pkg add yazi-rs/plugins:piper >/dev/null 2>&1 \
|
|
13
|
+
&& say "yazi piper plugin installed" \
|
|
14
|
+
|| say "piper install failed; markdown will preview as plain text"
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
mkdir -p "$HOME/.config/yazi"
|
|
18
|
+
ACSTACK_ARGS="$(cfg_get yazi.show_hidden true)|$BEGIN|$END" \
|
|
19
|
+
py "$HOME/.config/yazi/yazi.toml" <<'PY'
|
|
20
|
+
import os, re, sys
|
|
21
|
+
path = sys.argv[1]
|
|
22
|
+
hidden, begin, end = os.environ["ACSTACK_ARGS"].split("|")
|
|
23
|
+
text = open(path).read() if os.path.isfile(path) else ""
|
|
24
|
+
# Drop any previous managed region, both to stay idempotent and so what is
|
|
25
|
+
# left to inspect below is exactly the config the user wrote.
|
|
26
|
+
text = re.sub(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", "", text, flags=re.S)
|
|
27
|
+
|
|
28
|
+
# Every namespace the block writes into. TOML forbids declaring any of them
|
|
29
|
+
# twice, and yazi answers one duplicate by throwing the entire config away and
|
|
30
|
+
# falling back to presets. So a namespace the user already occupies is theirs:
|
|
31
|
+
# the block gives that piece up rather than cost them every other setting they
|
|
32
|
+
# have. Deciding which are taken is a question about the parsed document, not
|
|
33
|
+
# about the text: [ mgr ], [ "mgr" ], [ 'mgr' ] and mgr.show_hidden = false are
|
|
34
|
+
# all the same table, and only a parser knows that.
|
|
35
|
+
NAMESPACES = ("mgr", "opener.markdown", "plugin.prepend_previewers", "open.prepend_rules")
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
import tomllib
|
|
39
|
+
except ImportError: # tomllib is 3.11 and newer
|
|
40
|
+
tomllib = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def claimed(cfg):
|
|
44
|
+
"""Which of NAMESPACES the user's own config already occupies."""
|
|
45
|
+
if tomllib is None:
|
|
46
|
+
# No parser to reason with, so read the text conservatively: name the
|
|
47
|
+
# top-level table wherever TOML could open one, as a header or as a
|
|
48
|
+
# key of its own. That concedes namespaces which would have been safe
|
|
49
|
+
# to write, and each of those costs one setting; guessing the other
|
|
50
|
+
# way costs the file. One thing text cannot see is that the config is
|
|
51
|
+
# broken already, so unlike the parsed path below this branch does not
|
|
52
|
+
# concede everything for a file that never parsed.
|
|
53
|
+
found = set()
|
|
54
|
+
for ns in NAMESPACES:
|
|
55
|
+
root = re.escape(ns.split(".")[0])
|
|
56
|
+
# A key may be bare, a basic string or a literal string, and all
|
|
57
|
+
# three name the same table: [mgr], ["mgr"] and ['mgr'] are one.
|
|
58
|
+
name = r"""(?:%s|"%s"|'%s')""" % (root, root, root)
|
|
59
|
+
header = r'\[\[?\s*%s\s*[.\]]' % name
|
|
60
|
+
key = r'%s\s*[.=]' % name
|
|
61
|
+
if re.search(r"(?m)^\s*(?:%s|%s)" % (header, key), cfg):
|
|
62
|
+
found.add(ns)
|
|
63
|
+
return found
|
|
64
|
+
try:
|
|
65
|
+
tomllib.loads(cfg)
|
|
66
|
+
except tomllib.TOMLDecodeError:
|
|
67
|
+
# Their config does not parse as it stands. We cannot tell what it
|
|
68
|
+
# claims, and repairing it is not ours to do, so concede everything:
|
|
69
|
+
# the file ends up exactly as broken as it arrived, never more.
|
|
70
|
+
return set(NAMESPACES)
|
|
71
|
+
found = set()
|
|
72
|
+
for ns in NAMESPACES:
|
|
73
|
+
# Whether a namespace is free is not "is the key there". TOML also
|
|
74
|
+
# refuses to reopen an inline table and to overwrite a scalar, so
|
|
75
|
+
# opener = { text = [...] } and opener = "nope" both have no
|
|
76
|
+
# opener.markdown key and are both fatal to write under. tomllib
|
|
77
|
+
# hands an inline table back as an ordinary dict, so no walk over the
|
|
78
|
+
# parsed document can tell those apart from a table that is still
|
|
79
|
+
# open. Put the question to the parser instead, in the exact form the
|
|
80
|
+
# block would write it: append that header to their config and see
|
|
81
|
+
# whether the whole thing still parses. Whatever it refuses is theirs.
|
|
82
|
+
probe = "[mgr]" if ns == "mgr" else "[[%s]]" % ns
|
|
83
|
+
try:
|
|
84
|
+
tomllib.loads(cfg + "\n" + probe + "\n")
|
|
85
|
+
except tomllib.TOMLDecodeError:
|
|
86
|
+
found.add(ns)
|
|
87
|
+
return found
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
taken = claimed(text)
|
|
91
|
+
parts = []
|
|
92
|
+
|
|
93
|
+
if "plugin.prepend_previewers" not in taken:
|
|
94
|
+
parts.append("""
|
|
95
|
+
[[plugin.prepend_previewers]]
|
|
96
|
+
url = "*.md"
|
|
97
|
+
run = 'piper -- COLUMNS=$w acstack-md --preview "$1"'
|
|
98
|
+
|
|
99
|
+
[[plugin.prepend_previewers]]
|
|
100
|
+
url = "*.markdown"
|
|
101
|
+
run = 'piper -- COLUMNS=$w acstack-md --preview "$1"'
|
|
102
|
+
""")
|
|
103
|
+
|
|
104
|
+
if "opener.markdown" not in taken:
|
|
105
|
+
# An array of tables rather than a markdown key under [opener]: a second
|
|
106
|
+
# [opener] table would be a duplicate, while a subtable of one the user
|
|
107
|
+
# has already declared is legal.
|
|
108
|
+
parts.append("""
|
|
109
|
+
# The preview pane is narrow, so a wide diagram shows its placeholder there.
|
|
110
|
+
# Enter opens the same renderer full width, where more of them fit.
|
|
111
|
+
#
|
|
112
|
+
# %s is what yazi substitutes for the files being opened, already quoted. Not
|
|
113
|
+
# "$@": an opener template is run as `sh -c '<run>'` with nothing after it, so
|
|
114
|
+
# $0 is "sh" and $@ is empty - measured on yazi 26.8.15, where "$@" left
|
|
115
|
+
# acstack-md with no file at all and Enter did nothing but print its usage.
|
|
116
|
+
[[opener.markdown]]
|
|
117
|
+
run = 'acstack-md %s'
|
|
118
|
+
block = true
|
|
119
|
+
desc = "Read (acstack-md)"
|
|
120
|
+
|
|
121
|
+
# Rendered markdown has no line number to quote back in a review, so the same
|
|
122
|
+
# file is also readable as its own source. Second, not first: it answers a
|
|
123
|
+
# narrower question than reading does, and still a readier one than editing.
|
|
124
|
+
# Enter takes the first entry, so O is the key that offers this one.
|
|
125
|
+
[[opener.markdown]]
|
|
126
|
+
run = 'acstack-md --numbered %s'
|
|
127
|
+
block = true
|
|
128
|
+
desc = "Read with line numbers"
|
|
129
|
+
|
|
130
|
+
[[opener.markdown]]
|
|
131
|
+
run = '${EDITOR:-vi} %s'
|
|
132
|
+
block = true
|
|
133
|
+
desc = "Edit"
|
|
134
|
+
""")
|
|
135
|
+
|
|
136
|
+
if "open.prepend_rules" not in taken:
|
|
137
|
+
# Worth writing even when the opener above was skipped: the rules point
|
|
138
|
+
# markdown files at whichever opener.markdown ends up in the file.
|
|
139
|
+
parts.append("""
|
|
140
|
+
[[open.prepend_rules]]
|
|
141
|
+
url = "*.md"
|
|
142
|
+
use = "markdown"
|
|
143
|
+
|
|
144
|
+
[[open.prepend_rules]]
|
|
145
|
+
url = "*.markdown"
|
|
146
|
+
use = "markdown"
|
|
147
|
+
""")
|
|
148
|
+
|
|
149
|
+
if "mgr" not in taken:
|
|
150
|
+
parts.append(f"[mgr]\nshow_hidden = {hidden}")
|
|
151
|
+
|
|
152
|
+
# The markers go down even when every piece was conceded, so the shell below
|
|
153
|
+
# can still read the region back and see what is missing from it.
|
|
154
|
+
block = begin + "\n" + "\n\n".join(p.strip("\n") for p in parts) + "\n" + end
|
|
155
|
+
text = text.rstrip("\n")
|
|
156
|
+
open(path, "w").write((text + "\n\n" if text else "") + block + "\n")
|
|
157
|
+
PY
|
|
158
|
+
# A setting the block gave up to the user's own config is simply absent from
|
|
159
|
+
# what was written, so read the region back rather than let it go unsaid.
|
|
160
|
+
local written; written="$(sed -n "/$BEGIN/,/$END/p" "$HOME/.config/yazi/yazi.toml")"
|
|
161
|
+
case "$written" in *"[[plugin.prepend_previewers]]"*) ;; *)
|
|
162
|
+
say "yazi: your yazi.toml already sets plugin.prepend_previewers, so the"
|
|
163
|
+
say " markdown previewer was left alone. Add a *.md entry there to"
|
|
164
|
+
say " preview markdown with acstack-md." ;;
|
|
165
|
+
esac
|
|
166
|
+
case "$written" in *"[[opener.markdown]]"*) ;; *)
|
|
167
|
+
say "yazi: your yazi.toml already opens markdown its own way, so it was left"
|
|
168
|
+
say " alone. Add acstack-md to that opener to read markdown with it." ;;
|
|
169
|
+
esac
|
|
170
|
+
case "$written" in *"[[open.prepend_rules]]"*) ;; *)
|
|
171
|
+
say "yazi: your yazi.toml already sets open.prepend_rules, so the markdown"
|
|
172
|
+
say " rules were left alone. Add *.md and *.markdown entries there"
|
|
173
|
+
say " pointing at the markdown opener." ;;
|
|
174
|
+
esac
|
|
175
|
+
case "$written" in *show_hidden*) ;; *)
|
|
176
|
+
say "yazi: your yazi.toml already claims mgr itself, so show_hidden was left"
|
|
177
|
+
say " alone. Set it there yourself to show hidden files." ;;
|
|
178
|
+
esac
|
|
179
|
+
# The keymap is its own file, and prepend_keymap has two spellings: an array
|
|
180
|
+
# of tables, which is additive and safe to extend, and an inline array under
|
|
181
|
+
# [mgr], which is one key that TOML forbids declaring twice. Only the second
|
|
182
|
+
# collides with the entry below, and telling them apart takes the text - both
|
|
183
|
+
# spellings parse to the same list.
|
|
184
|
+
ACSTACK_ARGS="$(cfg_get yazi.browser_key "b")|$(cfg_get yazi.line_numbers_key "#")|$(cfg_get yazi.download_key "D")|$(cfg_get yazi.download_target "")|$(cfg_get yazi.edit_key "e")|$BEGIN|$END" \
|
|
185
|
+
py "$HOME/.config/yazi/keymap.toml" <<'PY'
|
|
186
|
+
import os, re, sys
|
|
187
|
+
|
|
188
|
+
path = sys.argv[1]
|
|
189
|
+
key, numkey, dlkey, dltarget, editkey, begin, end = os.environ["ACSTACK_ARGS"].split("|")
|
|
190
|
+
text = open(path).read() if os.path.isfile(path) else ""
|
|
191
|
+
# Drop any previous managed region, both to stay idempotent and so what is
|
|
192
|
+
# left to inspect below is exactly the keymap the user wrote.
|
|
193
|
+
text = re.sub(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", "", text, flags=re.S)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
import tomllib
|
|
197
|
+
except ImportError: # tomllib is 3.11 and newer
|
|
198
|
+
tomllib = None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def claimed(t):
|
|
202
|
+
"""True when the user's keymap declares prepend_keymap as an inline array
|
|
203
|
+
under [mgr] - the one spelling our own entry cannot sit beside."""
|
|
204
|
+
if not re.search(r"^\s*prepend_keymap\s*=", t, flags=re.M):
|
|
205
|
+
return False
|
|
206
|
+
if tomllib is None:
|
|
207
|
+
# No parser to say which table that assignment sits in, so assume the
|
|
208
|
+
# worst. A conceded binding costs one key; a collision costs the whole
|
|
209
|
+
# keymap, because yazi answers invalid TOML by falling back to presets.
|
|
210
|
+
return True
|
|
211
|
+
try:
|
|
212
|
+
return "prepend_keymap" in (tomllib.loads(t).get("mgr") or {})
|
|
213
|
+
except Exception:
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
CLAIMED = {k for k in (key, numkey, editkey) if k}
|
|
218
|
+
if dltarget and dlkey:
|
|
219
|
+
CLAIMED.add(dlkey)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def drop_claimed(t):
|
|
223
|
+
"""Remove any prepend_keymap block the user wrote on a key this script is
|
|
224
|
+
about to bind.
|
|
225
|
+
|
|
226
|
+
yazi accepts both, lists both in its help, and runs one of them. So a
|
|
227
|
+
binding written by hand before this script managed the key leaves two
|
|
228
|
+
entries saying different things and no way to tell which one fires. Both
|
|
229
|
+
table spellings, because `manager` is what yazi called `mgr` before the
|
|
230
|
+
rename and an older keymap still says it.
|
|
231
|
+
"""
|
|
232
|
+
out, block = [], None
|
|
233
|
+
|
|
234
|
+
def bound_key(lines):
|
|
235
|
+
"""The single key this block binds, or None.
|
|
236
|
+
|
|
237
|
+
yazi spells `on` four ways: a double-quoted scalar, a single-quoted
|
|
238
|
+
one, a one-element array, and any of those with a trailing comment.
|
|
239
|
+
Reading only the first spelling leaves the other three in place, which
|
|
240
|
+
is the state this function exists to prevent. A longer array is a
|
|
241
|
+
multi-key sequence and never collides with a single key.
|
|
242
|
+
"""
|
|
243
|
+
body = "\n".join(lines[1:])
|
|
244
|
+
if tomllib is not None:
|
|
245
|
+
try:
|
|
246
|
+
on = tomllib.loads(body).get("on")
|
|
247
|
+
except Exception:
|
|
248
|
+
on = ... # unparseable, so fall through to the regex
|
|
249
|
+
if on is not ...:
|
|
250
|
+
if isinstance(on, str):
|
|
251
|
+
return on
|
|
252
|
+
if isinstance(on, list) and len(on) == 1 and isinstance(on[0], str):
|
|
253
|
+
return on[0]
|
|
254
|
+
return None
|
|
255
|
+
m = re.search(r"""(?m)^\s*on\s*=\s*\[?\s*(?:"([^"]*)"|'([^']*)')""", body)
|
|
256
|
+
return (m.group(1) if m.group(1) is not None else m.group(2)) if m else None
|
|
257
|
+
|
|
258
|
+
def close():
|
|
259
|
+
nonlocal block
|
|
260
|
+
if block is None:
|
|
261
|
+
return
|
|
262
|
+
# Trailing blank lines belong to whatever comes next, not to the
|
|
263
|
+
# binding, so they stay even when the binding goes.
|
|
264
|
+
tail = []
|
|
265
|
+
while block and not block[-1].strip():
|
|
266
|
+
tail.insert(0, block.pop())
|
|
267
|
+
if bound_key(block) not in CLAIMED:
|
|
268
|
+
out.extend(block)
|
|
269
|
+
out.extend(tail)
|
|
270
|
+
block = None
|
|
271
|
+
|
|
272
|
+
for ln in t.splitlines():
|
|
273
|
+
if re.match(r"^\s*\[\[\s*(?:mgr|manager)\.prepend_keymap\s*\]\]\s*$", ln):
|
|
274
|
+
close()
|
|
275
|
+
block = [ln]
|
|
276
|
+
continue
|
|
277
|
+
if block is not None:
|
|
278
|
+
# Any other table header ends this binding and belongs to what
|
|
279
|
+
# follows, so it is never dropped with the block.
|
|
280
|
+
if ln.lstrip().startswith("["):
|
|
281
|
+
close()
|
|
282
|
+
out.append(ln)
|
|
283
|
+
continue
|
|
284
|
+
block.append(ln)
|
|
285
|
+
continue
|
|
286
|
+
out.append(ln)
|
|
287
|
+
close()
|
|
288
|
+
return "\n".join(out)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
parts = []
|
|
292
|
+
if not claimed(text):
|
|
293
|
+
# A binding conceded below is one this script never writes, so the user's
|
|
294
|
+
# own must survive. Only strip when the bindings are actually going in.
|
|
295
|
+
text = drop_claimed(text)
|
|
296
|
+
# %h is the hovered file. $0 and $@ are not: yazi runs the template through
|
|
297
|
+
# a shell, where $0 names the shell itself and $@ is empty - both measured,
|
|
298
|
+
# and both would open the browser on nothing at all.
|
|
299
|
+
#
|
|
300
|
+
# %h stays bare, here and in every binding below. yazi shell-quotes it
|
|
301
|
+
# itself, so quotes around it end up inside the path and nothing is found.
|
|
302
|
+
#
|
|
303
|
+
# --block because the delivery has something to say. On the bridge it is a
|
|
304
|
+
# line of confirmation; on every other route it is a link, and a served
|
|
305
|
+
# page that has to stay up. Detached, all of that goes nowhere.
|
|
306
|
+
parts.append(
|
|
307
|
+
"[[mgr.prepend_keymap]]\n"
|
|
308
|
+
f'on = "{key}"\n'
|
|
309
|
+
"""run = 'shell --block -- acstack-md --browser %h'\n"""
|
|
310
|
+
'desc = "Open in the browser you are sitting at"'
|
|
311
|
+
)
|
|
312
|
+
# Flipping the flag changes nothing a reader can see: the pane already
|
|
313
|
+
# holds the view it drew before the key was pressed. So the binding is two
|
|
314
|
+
# actions, and peek redraws it.
|
|
315
|
+
#
|
|
316
|
+
# --block, though the toggle has nothing to say. Detached, yazi spawns it
|
|
317
|
+
# and runs peek immediately, and the pane is redrawn while the flag is
|
|
318
|
+
# still whatever it was - a race the reader settles by pressing the key
|
|
319
|
+
# twice. Blocking costs a frame and wins it outright.
|
|
320
|
+
parts.append(
|
|
321
|
+
"[[mgr.prepend_keymap]]\n"
|
|
322
|
+
f'on = "{numkey}"\n'
|
|
323
|
+
"run = [ 'shell --block -- acstack-md --toggle-line-numbers', 'peek --force' ]\n"
|
|
324
|
+
'desc = "Preview markdown as source with line numbers"'
|
|
325
|
+
)
|
|
326
|
+
# `e` edits, everywhere. tuicr's diff and file tree both put the editor on
|
|
327
|
+
# `e`, so yazi matching it means one key means edit across the workspace.
|
|
328
|
+
# `o` is left alone: it opens by rule, which for markdown is the reader.
|
|
329
|
+
#
|
|
330
|
+
# ${EDITOR:-vi} rather than a fixed editor, the same expansion the opener
|
|
331
|
+
# in yazi.toml uses, so both paths land in whatever the user actually runs.
|
|
332
|
+
parts.append(
|
|
333
|
+
"[[mgr.prepend_keymap]]\n"
|
|
334
|
+
f'on = "{editkey}"\n'
|
|
335
|
+
"""run = 'shell --block -- ${EDITOR:-vi} %h'\n"""
|
|
336
|
+
'desc = "Edit in $EDITOR"'
|
|
337
|
+
)
|
|
338
|
+
# Taildrop is Tailscale's file send. It is the one route off a headless
|
|
339
|
+
# machine that needs nothing installed on the machine the user sits at,
|
|
340
|
+
# and no inbound port there either.
|
|
341
|
+
#
|
|
342
|
+
# %h, the hovered file, for the same reason the browser key uses it. %* is
|
|
343
|
+
# not a keybinding placeholder at all - yazi passes it through untouched,
|
|
344
|
+
# and tailscale then reports `open %*: no such file or directory`.
|
|
345
|
+
# Measured on yazi 26.8.15.
|
|
346
|
+
#
|
|
347
|
+
# --block because a send reports progress, and a refused one reports why.
|
|
348
|
+
if dltarget:
|
|
349
|
+
parts.append(
|
|
350
|
+
"[[mgr.prepend_keymap]]\n"
|
|
351
|
+
f'on = "{dlkey}"\n'
|
|
352
|
+
f"""run = 'shell --block -- tailscale file cp %h {dltarget}:'\n"""
|
|
353
|
+
f'desc = "Send this file to {dltarget} over Taildrop"'
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# The markers go down even when the binding was conceded, so the shell below
|
|
357
|
+
# can read the region back and see what is missing from it.
|
|
358
|
+
block = begin + "\n" + "\n\n".join(parts) + ("\n" if parts else "") + end
|
|
359
|
+
text = text.rstrip("\n")
|
|
360
|
+
open(path, "w").write((text + "\n\n" if text else "") + block + "\n")
|
|
361
|
+
PY
|
|
362
|
+
local kmwritten; kmwritten="$(sed -n "/$BEGIN/,/$END/p" "$HOME/.config/yazi/keymap.toml")"
|
|
363
|
+
case "$kmwritten" in *prepend_keymap*) ;; *)
|
|
364
|
+
say "yazi: your keymap.toml already sets mgr.prepend_keymap as an inline"
|
|
365
|
+
say " array, which these bindings cannot sit beside. Add them there"
|
|
366
|
+
say " yourself: shell --block -- acstack-md --browser %h"
|
|
367
|
+
say " and: [ 'shell --block -- acstack-md --toggle-line-numbers', 'peek --force' ]"
|
|
368
|
+
say " and: shell --block -- \${EDITOR:-vi} %h" ;;
|
|
369
|
+
esac
|
|
370
|
+
# The download key is written from the config alone, so it can be wrong in
|
|
371
|
+
# three ways the config cannot see. Name whichever one holds, rather than
|
|
372
|
+
# leaving the user to read `status code: 1` out of a yazi popup.
|
|
373
|
+
local dltarget; dltarget="$(cfg_get yazi.download_target "")"
|
|
374
|
+
if [ -n "$dltarget" ]; then
|
|
375
|
+
if ! have tailscale; then
|
|
376
|
+
say "yazi: download_target names $dltarget, but this machine has no"
|
|
377
|
+
say " tailscale. Install it, or clear the key to drop the binding."
|
|
378
|
+
elif ! tailscale file cp --targets >/dev/null 2>&1; then
|
|
379
|
+
say "yazi: tailscale refuses file access to this user account. Run once:"
|
|
380
|
+
say " sudo tailscale set --operator=\$USER"
|
|
381
|
+
elif ! tailscale file cp --targets 2>/dev/null \
|
|
382
|
+
| awk -F'\t' -v t="$dltarget" '$2 == t { f = 1 } END { exit !f }'; then
|
|
383
|
+
say "yazi: $dltarget is not a Taildrop target of this machine. Taildrop"
|
|
384
|
+
say " only sends between devices one Tailscale account owns on one"
|
|
385
|
+
say " tailnet. Check both ends with: tailscale file cp --targets"
|
|
386
|
+
fi
|
|
387
|
+
fi
|
|
388
|
+
say "yazi config written"
|
|
389
|
+
}
|