From 27018bd4ca21825a837610a15e69c732bc545744 Mon Sep 17 00:00:00 2001 From: 844196 <844196@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:43:34 +0900 Subject: [PATCH] feat!: Let local bindings override global ones and sort the menu by key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `globalBindings.concat(localBindings)` conflated two unrelated concerns: which definition won when global and local shared a key (main.ts's `find` always resolved to the first match, so global won), and what order the menu drew rows in (declaration order, since nothing reordered the array). Local bindings now shadow global ones sharing a key, whole-entry — a group and a command never partially merge, and a group's own nested `bindings` never cross the global/local boundary. Menu rows sort by key with a fixed locale (natural for digit runs, so f2 comes before f10; uppercase before lowercase; an exact-string tie-break where the collation itself calls two distinct keys equal, e.g. f2 vs f02), independent of declaration order or global/local origin, at every nesting level. A duplicate key now collapses to a single row before it reaches the menu, matching what a keypress already resolved to. BREAKING CHANGE: a local binding used to lose to a global one sharing the same key; it now wins. Menu rows used to draw in declaration order; they now sort by key. Co-Authored-By: Claude Sonnet 5 --- e2e/tests/04_config.bats | 45 +++++++++++- e2e/tests/07_render.bats | 154 +++++++++++++++++++++++++++++++++++++-- src/run.ts | 37 +++++++++- 3 files changed, 226 insertions(+), 10 deletions(-) diff --git a/e2e/tests/04_config.bats b/e2e/tests/04_config.bats index af03958..7fa87d8 100644 --- a/e2e/tests/04_config.bats +++ b/e2e/tests/04_config.bats @@ -156,11 +156,14 @@ YAML assert_equal "$stderr" '~/.config/wk/bindings.yaml: expected a list of bindings' } -@test "global and local bindings are concatenated with global first" { +@test "local bindings override global ones with the same key" { write_bindings <<'YAML' - key: l type: command buffer: from-global +- key: g + type: command + buffer: global-only YAML write_local_bindings <<'YAML' - key: l @@ -171,16 +174,52 @@ YAML buffer: local-only YAML - # Both files contribute, and the global definition of a shared key wins. + # A shared key resolves to the local definition... wk_run --inputs 'l' assert_equal "$status" 0 - assert_equal "$output" $'\t\tfrom-global' + assert_equal "$output" $'\t\tfrom-local' + + # ...but an untouched global key still contributes... + wk_run --inputs 'g' + assert_equal "$status" 0 + assert_equal "$output" $'\t\tglobal-only' + # ...alongside a local-only one. wk_run --inputs 'x' assert_equal "$status" 0 assert_equal "$output" $'\t\tlocal-only' } +@test "a local group replaces a global group of the same key entirely" { + write_bindings <<'YAML' +- key: g + type: bindings + desc: Git (global) + bindings: + - key: p + type: command + buffer: git push +YAML + write_local_bindings <<'YAML' +- key: g + type: bindings + desc: Git (local) + bindings: + - key: c + type: command + buffer: git commit +YAML + + # The whole group is swapped, not merged: the global group's own + # sub-binding is gone rather than sitting alongside the local one. + wk_run --inputs 'g p' + assert_equal "$status" 5 + + wk_run --inputs 'g c' + assert_equal "$status" 0 + assert_equal "$output" $'\t\tgit commit' +} + @test "local bindings alone are enough" { write_local_bindings <<'YAML' - key: x diff --git a/e2e/tests/07_render.bats b/e2e/tests/07_render.bats index 389fe93..5b6d2c1 100644 --- a/e2e/tests/07_render.bats +++ b/e2e/tests/07_render.bats @@ -13,8 +13,12 @@ teardown() { stop_zsh_session } -@test "each row is the key, the separator and the description, in binding order" { +@test "rows are ordered by key regardless of declaration order or global/local origin" { write_bindings <<'YAML' +- key: l + type: command + desc: List + buffer: ls -la - key: g type: bindings desc: Git @@ -23,18 +27,156 @@ teardown() { type: command desc: Push buffer: git push -- key: l +YAML + write_local_bindings <<'YAML' +- key: i type: command - desc: List - buffer: ls -la + desc: Info + buffer: info YAML start_wk_session wait_for_screen 'List' run capture_screen - # A group is marked with symbols.group in front of its description. + # Declared l-then-g, and i comes from a separate (local) source, but + # sorting interleaves all three by key: g, i, l. assert_line --index 1 ' g ➜ +Git' - assert_line --index 2 ' l ➜ List' + assert_line --index 2 ' i ➜ Info' + assert_line --index 3 ' l ➜ List' +} + +@test "a duplicated key renders as a single row" { + write_bindings <<'YAML' +- key: l + type: command + desc: First + buffer: first +- key: l + type: command + desc: Second + buffer: second +YAML + start_wk_session + wait_for_screen 'First' + + run capture_screen + # The row a keypress can never reach (main.ts's `find` always resolves to + # the first definition) is dropped before rendering, not just shown twice. + assert_line --index 1 ' l ➜ First' + refute_line --partial 'Second' +} + +@test "a duplicated key inside a nested group renders as a single row" { + write_bindings <<'YAML' +- key: g + type: bindings + desc: Git + bindings: + - key: p + type: command + desc: First + buffer: first + - key: p + type: command + desc: Second + buffer: second +YAML + start_wk_session --inputs 'g' + wait_for_screen 'First' + + run capture_screen + # Deduping isn't only a top-level, pre-merge concern: it applies at every + # nesting level, same as sorting. + assert_line --index 1 ' p ➜ First' + refute_line --partial 'Second' +} + +@test "keys that collate as equal still sort deterministically" { + write_bindings <<'YAML' +- key: "1" + type: command + desc: One + buffer: one +- key: "01" + type: command + desc: ZeroOne + buffer: zero-one +YAML + start_wk_session + wait_for_screen 'One' + + run capture_screen + # `numeric` collation treats "01" and "1" as the same value; an exact + # string tie-break is what keeps their order from being merge-order noise. + assert_line --index 1 ' 01 ➜ ZeroOne' + assert_line --index 2 ' 1 ➜ One' +} + +@test "keys sort naturally, not lexicographically" { + write_bindings <<'YAML' +- key: f10 + type: command + desc: Ten + buffer: ten +- key: f2 + type: command + desc: Two + buffer: two +- key: f1 + type: command + desc: One + buffer: one +YAML + start_wk_session + wait_for_screen 'Ten' + + run capture_screen + assert_line --index 1 " 󱊫 ➜ One" + assert_line --index 2 " 󱊬 ➜ Two" + assert_line --index 3 " 󱊴 ➜ Ten" +} + +@test "an uppercase key sorts before its lowercase counterpart" { + write_bindings <<'YAML' +- key: g + type: command + desc: Lower + buffer: lower +- key: G + type: command + desc: Upper + buffer: upper +YAML + start_wk_session + wait_for_screen 'Upper' + + run capture_screen + assert_line --index 1 ' G ➜ Upper' + assert_line --index 2 ' g ➜ Lower' +} + +@test "sorting is applied recursively to nested groups" { + write_bindings <<'YAML' +- key: g + type: bindings + desc: Git + bindings: + - key: p + type: command + desc: Push + buffer: git push + - key: c + type: command + desc: Commit + buffer: git commit +YAML + start_wk_session --inputs 'g' + wait_for_screen 'Commit' + + run capture_screen + # Declared p-then-c, but c sorts first inside the group too. + assert_line --index 1 ' c ➜ Commit' + assert_line --index 2 ' p ➜ Push' } @test "a command without a description falls back to its buffer" { diff --git a/src/run.ts b/src/run.ts index c51a117..ee2ad65 100644 --- a/src/run.ts +++ b/src/run.ts @@ -66,6 +66,41 @@ function unescapeAnsi(given: string): string { return given.replace(/\\x([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) } +// `main.ts`'s `find` always resolves a duplicated key to its first match, so +// keeping the first occurrence here (rather than e.g. the last) is what +// keeps a merge or a sort from disagreeing with that. +function firstByKey(bindings: Binding[]): Map { + const byKey = new Map() + for (const binding of bindings) { + if (!byKey.has(binding.key)) byKey.set(binding.key, binding) + } + return byKey +} + +// Local bindings shadow global ones sharing the same key, whole-entry — a +// group and a command never partially merge, and a group's own nested +// `bindings` never cross the boundary either. `local` goes first so +// `firstByKey` keeps its entry over global's for a shared key. +function mergeBindings(global: Binding[], local: Binding[]): Binding[] { + return [...firstByKey([...local, ...global]).values()] +} + +// A fixed locale rather than the ambient one, so key order doesn't shift +// with the user's `LANG`. `numeric` compares a digit run by value (f2 +// before f10 — ICU chunks past 254 significant digits, well past any real +// key name), and `caseFirst: 'upper'` keeps `G` before `g`. +const keyCollator = new Intl.Collator('en', { numeric: true, caseFirst: 'upper' }) + +// Applied at every nesting level, not just the merged top level. Two +// distinct keys can collate as equal (e.g. `f2` and `f02` under `numeric`), +// and `toSorted` is stable, so an exact-string tie-break keeps their order +// from depending on where each one came from. +function sortBindings(bindings: Binding[]): Binding[] { + return [...firstByKey(bindings).values()] + .toSorted((a, b) => keyCollator.compare(a.key, b.key) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) + .map((binding) => binding.type === 'bindings' ? { ...binding, bindings: sortBindings(binding.bindings) } : binding) +} + export const runCommand = new Command() .description('Run.') .type('boolOrAuto', new EnumType(['true', 'false', 'auto'])) @@ -90,7 +125,7 @@ For example, this simulates pressing "g", "p", and "f".`, const empty: Binding[] = [] const globalBindings = await loadYaml(joinPath(WK_CONFIG_HOME, 'bindings.yaml'), empty, parseBindings) const localBindings = await loadYaml(joinPath(Deno.cwd(), 'wk.bindings.yaml'), empty, parseBindings) - return [ctx, globalBindings.concat(localBindings)] as const + return [ctx, sortBindings(mergeBindings(globalBindings, localBindings))] as const } const tty = await Deno.open('/dev/tty', { read: true, write: true })