tmux-fuzzy-motion 0.0.5 → 0.0.8
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/README.ja.md +43 -20
- package/README.md +48 -20
- package/dist/cli.js +336 -33
- package/package.json +1 -1
package/README.ja.md
CHANGED
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
[English README](./README.md)
|
|
4
4
|
|
|
5
|
-
`tmux-fuzzy-motion`
|
|
6
|
-
|
|
5
|
+
`tmux-fuzzy-motion` は、tmux pane 内で素早くカーソル移動するための CLI です。
|
|
6
|
+
現在の viewport からジャンプ対象を抽出し、fuzzy search で絞り込み、
|
|
7
7
|
大文字の hint で移動できます。英字のローマ字 query に対しては Migemo による
|
|
8
8
|
日本語マッチも行います。
|
|
9
9
|
|
|
10
10
|
## 特徴
|
|
11
11
|
|
|
12
12
|
- `tmux copy-mode` 内で動作
|
|
13
|
+
- `start` は copy mode の外から起動しても自動で copy mode に入る
|
|
14
|
+
- `start --scope all` は current window の visible pane 全体を対象にできる
|
|
13
15
|
- 現在の viewport から URL、path、filename、symbol、一般的な単語を抽出
|
|
14
16
|
- `fzf` による fuzzy match
|
|
15
17
|
- `jsmigemo` による英字 query の Migemo マッチ
|
|
@@ -56,13 +58,32 @@ bind-key -T copy-mode-vi s run-shell 'tmux-fuzzy-motion start #{pane_id} #{clien
|
|
|
56
58
|
bind-key -T copy-mode s run-shell 'tmux-fuzzy-motion start #{pane_id} #{client_tty}'
|
|
57
59
|
```
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
copy mode の外からも起動したい場合は、root table にも bind を追加します。
|
|
62
|
+
|
|
63
|
+
```tmux
|
|
64
|
+
bind-key s run-shell 'tmux-fuzzy-motion start #{pane_id} #{client_tty}'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
current window の visible pane 全体から選びたい場合は、`--scope all` を付けた bind を追加します。
|
|
68
|
+
|
|
69
|
+
```tmux
|
|
70
|
+
bind-key S run-shell 'tmux-fuzzy-motion start --scope all #{pane_id} #{client_tty}'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`start` サブコマンドを経由せずに tmux から直接 popup を開きたい場合は、次の設定も使えます。
|
|
60
74
|
|
|
61
75
|
```tmux
|
|
62
76
|
bind-key -T copy-mode-vi s run-shell -C "display-popup -E -B -x '##{popup_pane_left}' -y '##{popup_pane_top}' -w '#{pane_width}' -h '#{pane_height}' 'tmux-fuzzy-motion popup-live #{pane_id}'"
|
|
63
77
|
bind-key -T copy-mode s run-shell -C "display-popup -E -B -x '##{popup_pane_left}' -y '##{popup_pane_top}' -w '#{pane_width}' -h '#{pane_height}' 'tmux-fuzzy-motion popup-live #{pane_id}'"
|
|
64
78
|
```
|
|
65
79
|
|
|
80
|
+
> [!NOTE]
|
|
81
|
+
> 後述の手順の`2.`で`'tmux-fuzzy-motion start %25 /dev/ttys000' returned 127`のようなエラーが表示される場合は以下のようにrun-shell環境のPATHに`tmux-fuzzy-motion`を含める必要があります。
|
|
82
|
+
>
|
|
83
|
+
> ```tmux
|
|
84
|
+
> set-environment -g PATH "/path/to/node/bin:$PATH"
|
|
85
|
+
> ```
|
|
86
|
+
|
|
66
87
|
設定変更後は tmux を reload します。
|
|
67
88
|
|
|
68
89
|
```bash
|
|
@@ -71,13 +92,15 @@ tmux source-file ~/.tmux.conf
|
|
|
71
92
|
|
|
72
93
|
## 使い方
|
|
73
94
|
|
|
74
|
-
1. `
|
|
75
|
-
2. `
|
|
76
|
-
3.
|
|
77
|
-
4.
|
|
78
|
-
5.
|
|
79
|
-
6.
|
|
80
|
-
7.
|
|
95
|
+
1. `tmux-fuzzy-motion start` を bind したキーを押す
|
|
96
|
+
2. `--scope current`(default)は current pane のみを対象にし、pane がまだ `copy-mode` でなければ先に `copy-mode` に入る
|
|
97
|
+
3. `--scope all` は current window の visible pane 全体を popup に合成して対象にする
|
|
98
|
+
4. 小文字や記号で query を入力する
|
|
99
|
+
5. fuzzy match で候補を絞り込む
|
|
100
|
+
6. 英字 query の場合は Migemo による日本語候補も対象になる
|
|
101
|
+
7. 大文字 hint を押して即座に移動する
|
|
102
|
+
8. `--scope all` で選択した場合は、該当 pane を active にして必要なら `copy-mode` に入ってから移動する
|
|
103
|
+
9. `Esc` または `Ctrl-[` でキャンセルする
|
|
81
104
|
|
|
82
105
|
## 入力キー
|
|
83
106
|
|
|
@@ -91,7 +114,7 @@ tmux source-file ~/.tmux.conf
|
|
|
91
114
|
## コマンド
|
|
92
115
|
|
|
93
116
|
```text
|
|
94
|
-
tmux-fuzzy-motion start <pane-id> <client-tty>
|
|
117
|
+
tmux-fuzzy-motion start [--scope current|all] <pane-id> <client-tty>
|
|
95
118
|
tmux-fuzzy-motion popup-live <pane-id>
|
|
96
119
|
tmux-fuzzy-motion doctor
|
|
97
120
|
```
|
|
@@ -99,6 +122,11 @@ tmux-fuzzy-motion doctor
|
|
|
99
122
|
`popup` と `daemon` は内部サブコマンドです。`popup-live` は `display-popup`
|
|
100
123
|
から直接起動する設定向けです。
|
|
101
124
|
|
|
125
|
+
`--scope`:
|
|
126
|
+
|
|
127
|
+
- `current`: current pane のみを対象にする。default
|
|
128
|
+
- `all`: current window の visible pane 全体を対象にする
|
|
129
|
+
|
|
102
130
|
## Doctor
|
|
103
131
|
|
|
104
132
|
ローカル環境の確認には `doctor` を使います。
|
|
@@ -141,17 +169,12 @@ pnpm run dev
|
|
|
141
169
|
pnpm check
|
|
142
170
|
```
|
|
143
171
|
|
|
144
|
-
warm な daemon を前提に popup 起動時間を測る場合:
|
|
145
|
-
|
|
146
|
-
```bash
|
|
147
|
-
pnpm bench:startup dist/cli.js
|
|
148
|
-
```
|
|
149
|
-
|
|
150
172
|
## 制約
|
|
151
173
|
|
|
152
|
-
-
|
|
153
|
-
- `
|
|
174
|
+
- 対象は各 pane の現在の viewport のみ
|
|
175
|
+
- `--scope all` の対象は current window の visible pane のみ
|
|
176
|
+
- zoom 中の `--scope all` は見えている pane のみを対象にする
|
|
154
177
|
- query 入力は ASCII 寄り
|
|
155
178
|
- combining character の完全な扱いは未保証
|
|
156
179
|
- `display-popup` が必要なため、tmux 3.2 以上が必須
|
|
157
|
-
- query は
|
|
180
|
+
- query は popup の最下行右端に描画する
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[日本語版はこちら](./README.ja.md)
|
|
4
4
|
|
|
5
|
-
`tmux-fuzzy-motion` is a CLI for quick cursor jumps
|
|
5
|
+
`tmux-fuzzy-motion` is a CLI for quick cursor jumps in tmux panes.
|
|
6
6
|
It scans the current viewport, extracts jump targets, filters them with fuzzy
|
|
7
7
|
search, and lets you jump with uppercase hints. Roman queries can also match
|
|
8
8
|
Japanese text through Migemo.
|
|
@@ -10,6 +10,8 @@ Japanese text through Migemo.
|
|
|
10
10
|
## Features
|
|
11
11
|
|
|
12
12
|
- Works inside `tmux copy-mode`
|
|
13
|
+
- `start` can also be launched outside copy-mode and enters copy-mode automatically
|
|
14
|
+
- `start --scope all` can target every visible pane in the current window
|
|
13
15
|
- Extracts URLs, paths, filenames, symbols, and general words from the current viewport
|
|
14
16
|
- Supports fuzzy matching with `fzf`
|
|
15
17
|
- Supports Migemo matching for alphabetic queries via `jsmigemo`
|
|
@@ -56,14 +58,35 @@ bind-key -T copy-mode-vi s run-shell 'tmux-fuzzy-motion start #{pane_id} #{clien
|
|
|
56
58
|
bind-key -T copy-mode s run-shell 'tmux-fuzzy-motion start #{pane_id} #{client_tty}'
|
|
57
59
|
```
|
|
58
60
|
|
|
59
|
-
If you want
|
|
60
|
-
|
|
61
|
+
If you also want to launch it outside copy-mode, add a binding in the root
|
|
62
|
+
table as well:
|
|
63
|
+
|
|
64
|
+
```tmux
|
|
65
|
+
bind-key s run-shell 'tmux-fuzzy-motion start #{pane_id} #{client_tty}'
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
If you want to search across every visible pane in the current window, add a
|
|
69
|
+
binding with `--scope all`.
|
|
70
|
+
|
|
71
|
+
```tmux
|
|
72
|
+
bind-key S run-shell 'tmux-fuzzy-motion start --scope all #{pane_id} #{client_tty}'
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
If you want tmux to open the popup directly without going through the `start`
|
|
76
|
+
subcommand, use this instead:
|
|
61
77
|
|
|
62
78
|
```tmux
|
|
63
79
|
bind-key -T copy-mode-vi s run-shell -C "display-popup -E -B -x '##{popup_pane_left}' -y '##{popup_pane_top}' -w '#{pane_width}' -h '#{pane_height}' 'tmux-fuzzy-motion popup-live #{pane_id}'"
|
|
64
80
|
bind-key -T copy-mode s run-shell -C "display-popup -E -B -x '##{popup_pane_left}' -y '##{popup_pane_top}' -w '#{pane_width}' -h '#{pane_height}' 'tmux-fuzzy-motion popup-live #{pane_id}'"
|
|
65
81
|
```
|
|
66
82
|
|
|
83
|
+
> [!NOTE]
|
|
84
|
+
> If you see an error like `'tmux-fuzzy-motion start %25 /dev/ttys000' returned 127` at step 2 below, you need to add `tmux-fuzzy-motion` to the PATH in the run-shell environment:
|
|
85
|
+
>
|
|
86
|
+
> ```tmux
|
|
87
|
+
> set-environment -g PATH "/path/to/node/bin:$PATH"
|
|
88
|
+
> ```
|
|
89
|
+
|
|
67
90
|
Reload tmux after editing the config:
|
|
68
91
|
|
|
69
92
|
```bash
|
|
@@ -72,13 +95,18 @@ tmux source-file ~/.tmux.conf
|
|
|
72
95
|
|
|
73
96
|
## Usage
|
|
74
97
|
|
|
75
|
-
1.
|
|
76
|
-
2.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
98
|
+
1. Press the key bound to `tmux-fuzzy-motion start`.
|
|
99
|
+
2. `--scope current` (the default) targets only the current pane and enters
|
|
100
|
+
copy-mode first if needed.
|
|
101
|
+
3. `--scope all` targets every visible pane in the current window by composing
|
|
102
|
+
them into a single popup.
|
|
103
|
+
4. Type a query in lowercase or symbols.
|
|
104
|
+
5. Narrow the candidates with fuzzy matching.
|
|
105
|
+
6. For alphabetic queries, Migemo also expands roman input to Japanese matches.
|
|
106
|
+
7. Press an uppercase hint to jump immediately.
|
|
107
|
+
8. In `--scope all`, the selected pane becomes active and enters copy-mode if
|
|
108
|
+
needed before the cursor moves.
|
|
109
|
+
9. Press `Esc` or `Ctrl-[` to cancel.
|
|
82
110
|
|
|
83
111
|
## Input Keys
|
|
84
112
|
|
|
@@ -92,7 +120,7 @@ tmux source-file ~/.tmux.conf
|
|
|
92
120
|
## Commands
|
|
93
121
|
|
|
94
122
|
```text
|
|
95
|
-
tmux-fuzzy-motion start <pane-id> <client-tty>
|
|
123
|
+
tmux-fuzzy-motion start [--scope current|all] <pane-id> <client-tty>
|
|
96
124
|
tmux-fuzzy-motion popup-live <pane-id>
|
|
97
125
|
tmux-fuzzy-motion doctor
|
|
98
126
|
```
|
|
@@ -100,6 +128,11 @@ tmux-fuzzy-motion doctor
|
|
|
100
128
|
`popup` and `daemon` are internal subcommands. `popup-live` is intended for
|
|
101
129
|
direct `display-popup` bindings.
|
|
102
130
|
|
|
131
|
+
`--scope`:
|
|
132
|
+
|
|
133
|
+
- `current`: target only the current pane. This is the default.
|
|
134
|
+
- `all`: target every visible pane in the current window.
|
|
135
|
+
|
|
103
136
|
## Doctor
|
|
104
137
|
|
|
105
138
|
Use `doctor` to verify the local environment:
|
|
@@ -142,17 +175,12 @@ Run the full local check:
|
|
|
142
175
|
pnpm check
|
|
143
176
|
```
|
|
144
177
|
|
|
145
|
-
Measure popup startup against a warm daemon:
|
|
146
|
-
|
|
147
|
-
```bash
|
|
148
|
-
pnpm bench:startup dist/cli.js
|
|
149
|
-
```
|
|
150
|
-
|
|
151
178
|
## Limitations
|
|
152
179
|
|
|
153
|
-
- Targets are limited to the current viewport
|
|
154
|
-
-
|
|
180
|
+
- Targets are limited to the current viewport of each pane
|
|
181
|
+
- `--scope all` targets only visible panes in the current window
|
|
182
|
+
- Zoomed windows with `--scope all` only target the pane that is visible
|
|
155
183
|
- Query input is ASCII-oriented
|
|
156
184
|
- Exact behavior for combining characters is not fully guaranteed
|
|
157
185
|
- Requires `display-popup`, so tmux 3.2 or later is mandatory
|
|
158
|
-
- The query is drawn on the bottom row
|
|
186
|
+
- The query is drawn on the popup's bottom row, aligned to the right edge
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { execFile, spawn } from "node:child_process";
|
|
4
|
-
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { access, appendFile, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import { dirname, join, resolve } from "node:path";
|
|
6
6
|
import { createConnection, createServer } from "node:net";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
|
+
import { statSync } from "node:fs";
|
|
9
10
|
//#endregion
|
|
10
11
|
//#region src/infra/process.ts
|
|
11
12
|
const runProcess = async (command, args) => new Promise((resolve, reject) => {
|
|
@@ -52,6 +53,17 @@ const focusClientPane = async (tmux, paneId, clientTty) => {
|
|
|
52
53
|
throw new Error("tmux-fuzzy-motion: client not found", { cause: error });
|
|
53
54
|
}
|
|
54
55
|
};
|
|
56
|
+
const enterCopyMode = async (tmux, paneId) => {
|
|
57
|
+
try {
|
|
58
|
+
await tmux.run([
|
|
59
|
+
"copy-mode",
|
|
60
|
+
"-t",
|
|
61
|
+
paneId
|
|
62
|
+
]);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw new Error("tmux-fuzzy-motion: failed to enter copy-mode", { cause: error });
|
|
65
|
+
}
|
|
66
|
+
};
|
|
55
67
|
const getPaneStartContext = async (tmux, paneId) => {
|
|
56
68
|
let output = "";
|
|
57
69
|
try {
|
|
@@ -67,10 +79,9 @@ const getPaneStartContext = async (tmux, paneId) => {
|
|
|
67
79
|
}
|
|
68
80
|
const [resolvedPaneId, paneInMode, width, height, currentPath] = output.split(" ");
|
|
69
81
|
if (!resolvedPaneId || !paneInMode || !currentPath || [width, height].map((value) => Number(value)).some((value) => !Number.isFinite(value))) throw new Error("tmux-fuzzy-motion: failed to resolve pane context");
|
|
70
|
-
if (paneInMode !== "1") throw new Error("tmux-fuzzy-motion: pane is not in copy-mode");
|
|
71
82
|
return {
|
|
72
83
|
paneId: resolvedPaneId,
|
|
73
|
-
inCopyMode:
|
|
84
|
+
inCopyMode: paneInMode === "1",
|
|
74
85
|
width: Number(width),
|
|
75
86
|
height: Number(height),
|
|
76
87
|
currentPath
|
|
@@ -88,9 +99,9 @@ const displayPopup = async (tmux, options) => {
|
|
|
88
99
|
"-d",
|
|
89
100
|
options.currentPath,
|
|
90
101
|
"-x",
|
|
91
|
-
"#{popup_pane_left}",
|
|
102
|
+
String(options.x ?? "#{popup_pane_left}"),
|
|
92
103
|
"-y",
|
|
93
|
-
"#{popup_pane_top}",
|
|
104
|
+
String(options.y ?? "#{popup_pane_top}"),
|
|
94
105
|
"-w",
|
|
95
106
|
String(options.width),
|
|
96
107
|
"-h",
|
|
@@ -98,6 +109,64 @@ const displayPopup = async (tmux, options) => {
|
|
|
98
109
|
...options.command
|
|
99
110
|
]);
|
|
100
111
|
};
|
|
112
|
+
const listWindowPanes = async (tmux, paneId) => {
|
|
113
|
+
let output = "";
|
|
114
|
+
try {
|
|
115
|
+
output = (await tmux.capture([
|
|
116
|
+
"list-panes",
|
|
117
|
+
"-t",
|
|
118
|
+
paneId,
|
|
119
|
+
"-F",
|
|
120
|
+
"#{pane_id} #{pane_in_mode} #{pane_width} #{pane_height} #{pane_current_path} #{pane_left} #{pane_top} #{?pane_active,1,0} #{window_zoomed_flag}"
|
|
121
|
+
])).trim();
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new Error("tmux-fuzzy-motion: pane not found", { cause: error });
|
|
124
|
+
}
|
|
125
|
+
const panes = output.split("\n").filter((line) => line.length > 0).map((line) => {
|
|
126
|
+
const [resolvedPaneId, paneInMode, width, height, currentPath, left, top, active, zoomed] = line.split(" ");
|
|
127
|
+
const numeric = [
|
|
128
|
+
width,
|
|
129
|
+
height,
|
|
130
|
+
left,
|
|
131
|
+
top
|
|
132
|
+
].map((value) => Number(value));
|
|
133
|
+
if (!resolvedPaneId || !paneInMode || !currentPath || [active, zoomed].some((value) => value === void 0) || numeric.some((value) => !Number.isFinite(value))) throw new Error("tmux-fuzzy-motion: failed to resolve window panes");
|
|
134
|
+
return {
|
|
135
|
+
paneId: resolvedPaneId,
|
|
136
|
+
inCopyMode: paneInMode === "1",
|
|
137
|
+
width: Number(width),
|
|
138
|
+
height: Number(height),
|
|
139
|
+
currentPath,
|
|
140
|
+
left: Number(left),
|
|
141
|
+
top: Number(top),
|
|
142
|
+
active: active === "1",
|
|
143
|
+
zoomed: zoomed === "1"
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
if (panes.length === 0) throw new Error("tmux-fuzzy-motion: pane not found");
|
|
147
|
+
const zoomed = panes.some((pane) => pane.zoomed);
|
|
148
|
+
return panes.filter((pane) => !zoomed || pane.active).map((pane) => {
|
|
149
|
+
const { zoomed: paneZoomed, ...rest } = pane;
|
|
150
|
+
return rest;
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
const getPaneBorderLines = async (tmux, paneId) => {
|
|
154
|
+
let output = "";
|
|
155
|
+
try {
|
|
156
|
+
output = (await tmux.capture([
|
|
157
|
+
"show-options",
|
|
158
|
+
"-A",
|
|
159
|
+
"-wv",
|
|
160
|
+
"-t",
|
|
161
|
+
paneId,
|
|
162
|
+
"pane-border-lines"
|
|
163
|
+
])).trim();
|
|
164
|
+
} catch (error) {
|
|
165
|
+
throw new Error("tmux-fuzzy-motion: failed to resolve pane border lines", { cause: error });
|
|
166
|
+
}
|
|
167
|
+
if (output === "single" || output === "double" || output === "heavy" || output === "simple" || output === "number" || output === "spaces") return output;
|
|
168
|
+
throw new Error("tmux-fuzzy-motion: failed to resolve pane border lines");
|
|
169
|
+
};
|
|
101
170
|
const getTmuxVersion = async () => {
|
|
102
171
|
return (await runProcess("tmux", ["-V"])).stdout.trim();
|
|
103
172
|
};
|
|
@@ -4789,7 +4858,7 @@ const extractCandidates = (lines) => {
|
|
|
4789
4858
|
//#region src/core/hint.ts
|
|
4790
4859
|
const HINT_CHARS$1 = "ASDFGHJKLQWERTYUIOPZXCVBNM";
|
|
4791
4860
|
const MAX_TARGETS = 200;
|
|
4792
|
-
const createTargetKey = (target) => `${target.line}:${target.col}:${target.text}`;
|
|
4861
|
+
const createTargetKey = (target) => `${target.paneId ?? ""}:${target.line}:${target.col}:${target.text}`;
|
|
4793
4862
|
const generateHints = (characters, maxHintLength) => {
|
|
4794
4863
|
const single = [...characters];
|
|
4795
4864
|
if (maxHintLength === 1) return single;
|
|
@@ -5602,6 +5671,7 @@ var Fzf = class {
|
|
|
5602
5671
|
//#endregion
|
|
5603
5672
|
//#region src/core/matcher.ts
|
|
5604
5673
|
const candidateKey = (candidate) => [
|
|
5674
|
+
candidate.paneId ?? "",
|
|
5605
5675
|
candidate.kind,
|
|
5606
5676
|
candidate.text,
|
|
5607
5677
|
String(candidate.line),
|
|
@@ -5680,7 +5750,17 @@ const createMatcher = (candidates, migemo) => {
|
|
|
5680
5750
|
//#region src/commands/runtime.ts
|
|
5681
5751
|
const sleep = async (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
5682
5752
|
const resolveCliEntrypoint = () => process.argv[1] ?? resolve(process.cwd(), "dist/cli.js");
|
|
5683
|
-
const
|
|
5753
|
+
const createDaemonIdentity = () => {
|
|
5754
|
+
const entrypoint = resolveCliEntrypoint();
|
|
5755
|
+
const entrypointMtimeMs = statSync(entrypoint).mtimeMs;
|
|
5756
|
+
return [
|
|
5757
|
+
process.env.TMUX ?? "tmux-fuzzy-motion",
|
|
5758
|
+
process.execPath,
|
|
5759
|
+
entrypoint,
|
|
5760
|
+
String(entrypointMtimeMs)
|
|
5761
|
+
].join("\0");
|
|
5762
|
+
};
|
|
5763
|
+
const createDaemonSocketPath = () => join("/tmp", `tfm-${createHash("sha1").update(createDaemonIdentity()).digest("hex")}.sock`);
|
|
5684
5764
|
const pathExists = async (path) => {
|
|
5685
5765
|
try {
|
|
5686
5766
|
await access(path);
|
|
@@ -5729,7 +5809,7 @@ const isDaemonHealthy = async (socketPath) => new Promise((resolve) => {
|
|
|
5729
5809
|
});
|
|
5730
5810
|
});
|
|
5731
5811
|
const waitForDaemon = async (socketPath) => {
|
|
5732
|
-
for (let attempt = 0; attempt <
|
|
5812
|
+
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
5733
5813
|
if (await isDaemonHealthy(socketPath)) return;
|
|
5734
5814
|
await sleep(25);
|
|
5735
5815
|
}
|
|
@@ -5773,14 +5853,16 @@ const measureCellWidth = (cells, start) => {
|
|
|
5773
5853
|
const createOverlayRenderer = (lines) => {
|
|
5774
5854
|
const baseCellsByLine = lines.map((line) => createStyledDisplayCells(line));
|
|
5775
5855
|
const baseLines = baseCellsByLine.map((cells) => cells.join(""));
|
|
5856
|
+
const targetLine = (target) => (target.screenLine ?? target.line) - 1;
|
|
5857
|
+
const targetCol = (target) => target.screenCol ?? target.col;
|
|
5776
5858
|
return (targets) => {
|
|
5777
5859
|
const rendered = [...baseLines];
|
|
5778
5860
|
const mutableCells = /* @__PURE__ */ new Map();
|
|
5779
5861
|
const occupiedByLine = /* @__PURE__ */ new Map();
|
|
5780
5862
|
const enterTarget = targets[0];
|
|
5781
|
-
const sorted = [...targets].sort((left, right) => left
|
|
5863
|
+
const sorted = [...targets].sort((left, right) => targetLine(left) - targetLine(right) || targetCol(left) + left.primary - (targetCol(right) + right.primary));
|
|
5782
5864
|
for (const target of sorted) {
|
|
5783
|
-
const lineIndex = target
|
|
5865
|
+
const lineIndex = targetLine(target);
|
|
5784
5866
|
const baseCells = baseCellsByLine[lineIndex];
|
|
5785
5867
|
if (!baseCells) continue;
|
|
5786
5868
|
let cells = mutableCells.get(lineIndex);
|
|
@@ -5793,14 +5875,14 @@ const createOverlayRenderer = (lines) => {
|
|
|
5793
5875
|
lineOccupied = Array.from({ length: baseCells.length }, () => false);
|
|
5794
5876
|
occupiedByLine.set(lineIndex, lineOccupied);
|
|
5795
5877
|
}
|
|
5796
|
-
const matchCol = target
|
|
5878
|
+
const matchCol = targetCol(target) + target.primary;
|
|
5797
5879
|
const hintCol = findOverlayStart(cells, matchCol);
|
|
5798
5880
|
const baseWidth = measureCellWidth(cells, hintCol);
|
|
5799
5881
|
const paddedHint = hintCol < matchCol ? target.hint.padEnd(baseWidth, " ") : target.hint;
|
|
5800
5882
|
const hintWidth = displayWidth(paddedHint);
|
|
5801
5883
|
const shouldHighlightPrimary = hintCol < matchCol;
|
|
5802
|
-
const highlightCols = target.positions.filter((position) => position !== target.primary || shouldHighlightPrimary).map((position) => target
|
|
5803
|
-
const isEnterTarget = enterTarget
|
|
5884
|
+
const highlightCols = target.positions.filter((position) => position !== target.primary || shouldHighlightPrimary).map((position) => targetCol(target) + position);
|
|
5885
|
+
const isEnterTarget = enterTarget !== void 0 && enterTarget.paneId === target.paneId && targetLine(enterTarget) === lineIndex && targetCol(enterTarget) === targetCol(target) && enterTarget.text === target.text;
|
|
5804
5886
|
const hintStyle = isEnterTarget ? PRIMARY_HINT_STYLE : SECONDARY_HINT_STYLE;
|
|
5805
5887
|
const highlightStyle = isEnterTarget ? PRIMARY_HIGHLIGHT_STYLE : SECONDARY_HIGHLIGHT_STYLE;
|
|
5806
5888
|
const overlapsHint = Array.from({ length: hintWidth }, (_, offset) => lineOccupied[hintCol + offset]).some(Boolean);
|
|
@@ -6006,8 +6088,13 @@ const computeMatches = (query, previousHints, matcher) => assignHints(matcher(qu
|
|
|
6006
6088
|
maxHintLength: 1,
|
|
6007
6089
|
maxTargets: 26
|
|
6008
6090
|
});
|
|
6009
|
-
const createPreparedMatcher = async (
|
|
6010
|
-
const candidates = extractCandidates(
|
|
6091
|
+
const createPreparedMatcher = async (state, migemoPromise) => {
|
|
6092
|
+
const candidates = state.scope === "all" ? state.panes.flatMap((pane) => extractCandidates(pane.plainLines).map((candidate) => ({
|
|
6093
|
+
...candidate,
|
|
6094
|
+
paneId: pane.paneId,
|
|
6095
|
+
screenLine: pane.top + candidate.line,
|
|
6096
|
+
screenCol: pane.left + candidate.col
|
|
6097
|
+
}))) : extractCandidates(state.plainLines);
|
|
6011
6098
|
const migemo = await migemoPromise;
|
|
6012
6099
|
return {
|
|
6013
6100
|
candidateCount: candidates.length,
|
|
@@ -6193,8 +6280,10 @@ const runPopupLive = async (args) => {
|
|
|
6193
6280
|
const socketPath = createDaemonSocketPath();
|
|
6194
6281
|
try {
|
|
6195
6282
|
const pane = await getPaneStartContext(tmux, paneId);
|
|
6283
|
+
if (!pane.inCopyMode) throw new Error("tmux-fuzzy-motion: pane is not in copy-mode");
|
|
6196
6284
|
const capture = fitCaptureToHeight(await capturePane(tmux, paneId), pane.height);
|
|
6197
6285
|
const state = {
|
|
6286
|
+
scope: "current",
|
|
6198
6287
|
paneId,
|
|
6199
6288
|
clientTty: "",
|
|
6200
6289
|
displayLines: capture.displayLines,
|
|
@@ -6279,7 +6368,7 @@ const runDaemon = async (args) => {
|
|
|
6279
6368
|
}
|
|
6280
6369
|
if (request.type === "prepare") {
|
|
6281
6370
|
activeSocket = socket;
|
|
6282
|
-
const prepared = await createPreparedMatcher(JSON.parse(await readFile(request.stateFile, "utf8"))
|
|
6371
|
+
const prepared = await createPreparedMatcher(JSON.parse(await readFile(request.stateFile, "utf8")), migemoPromise);
|
|
6283
6372
|
activeMatcher = prepared.matcher;
|
|
6284
6373
|
await writeMessage({
|
|
6285
6374
|
type: "prepared",
|
|
@@ -6337,6 +6426,21 @@ const runDaemon = async (args) => {
|
|
|
6337
6426
|
};
|
|
6338
6427
|
//#endregion
|
|
6339
6428
|
//#region src/commands/start.ts
|
|
6429
|
+
const writeDebugLog = async (payload) => {
|
|
6430
|
+
const debugLogPath = process.env.TMUX_FUZZY_MOTION_DEBUG_LOG;
|
|
6431
|
+
if (!debugLogPath) return;
|
|
6432
|
+
const line = JSON.stringify({
|
|
6433
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6434
|
+
...payload
|
|
6435
|
+
});
|
|
6436
|
+
try {
|
|
6437
|
+
await appendFile(debugLogPath, `${line}\n`, "utf8");
|
|
6438
|
+
} catch {}
|
|
6439
|
+
};
|
|
6440
|
+
const buildPopupRelativePosition = (axis, targetOrigin) => {
|
|
6441
|
+
if (axis === "x") return `#{e|+|:#{popup_pane_left},#{e|-|:${targetOrigin},#{pane_left}}}`;
|
|
6442
|
+
return `#{e|+|:#{popup_height},${`#{e|+|:#{e|-|:${targetOrigin},#{window_offset_y}},#{?#{==:#{status-position},top},#{e|-|:#{client_height},#{window_height}},0}}`}}`;
|
|
6443
|
+
};
|
|
6340
6444
|
const buildPopupCommand = (stateFile, resultFile, socketPath) => [
|
|
6341
6445
|
process.execPath,
|
|
6342
6446
|
resolveCliEntrypoint(),
|
|
@@ -6355,8 +6459,194 @@ const readResult = async (resultFile) => {
|
|
|
6355
6459
|
throw new Error("tmux-fuzzy-motion: popup did not produce result", { cause: error });
|
|
6356
6460
|
}
|
|
6357
6461
|
};
|
|
6462
|
+
const parseStartArgs = (args) => {
|
|
6463
|
+
let scope = "current";
|
|
6464
|
+
const positional = [];
|
|
6465
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
6466
|
+
const value = args[index];
|
|
6467
|
+
if (value === "--scope") {
|
|
6468
|
+
const nextScope = args[index + 1];
|
|
6469
|
+
if (nextScope === "current" || nextScope === "all") scope = nextScope;
|
|
6470
|
+
index += 1;
|
|
6471
|
+
continue;
|
|
6472
|
+
}
|
|
6473
|
+
positional.push(value ?? "");
|
|
6474
|
+
}
|
|
6475
|
+
return {
|
|
6476
|
+
scope,
|
|
6477
|
+
paneId: positional[0] ?? "",
|
|
6478
|
+
clientTty: positional[1] ?? ""
|
|
6479
|
+
};
|
|
6480
|
+
};
|
|
6481
|
+
const createBlankRow = (width) => Array.from({ length: width }, () => " ");
|
|
6482
|
+
const BORDER_SETS = {
|
|
6483
|
+
single: {
|
|
6484
|
+
vertical: "│",
|
|
6485
|
+
horizontal: "─",
|
|
6486
|
+
intersection: "┼"
|
|
6487
|
+
},
|
|
6488
|
+
double: {
|
|
6489
|
+
vertical: "║",
|
|
6490
|
+
horizontal: "═",
|
|
6491
|
+
intersection: "╬"
|
|
6492
|
+
},
|
|
6493
|
+
heavy: {
|
|
6494
|
+
vertical: "┃",
|
|
6495
|
+
horizontal: "━",
|
|
6496
|
+
intersection: "╋"
|
|
6497
|
+
},
|
|
6498
|
+
simple: {
|
|
6499
|
+
vertical: "|",
|
|
6500
|
+
horizontal: "-",
|
|
6501
|
+
intersection: "+"
|
|
6502
|
+
},
|
|
6503
|
+
spaces: {
|
|
6504
|
+
vertical: " ",
|
|
6505
|
+
horizontal: " ",
|
|
6506
|
+
intersection: " "
|
|
6507
|
+
}
|
|
6508
|
+
};
|
|
6509
|
+
const createOccupancyGrid = (panes, width, height) => {
|
|
6510
|
+
const occupied = Array.from({ length: height }, () => Array.from({ length: width }, () => false));
|
|
6511
|
+
for (const pane of panes) for (let row = pane.top; row < pane.top + pane.height; row += 1) {
|
|
6512
|
+
const line = occupied[row];
|
|
6513
|
+
if (!line) continue;
|
|
6514
|
+
for (let column = pane.left; column < pane.left + pane.width; column += 1) if (column >= 0 && column < line.length) line[column] = true;
|
|
6515
|
+
}
|
|
6516
|
+
return occupied;
|
|
6517
|
+
};
|
|
6518
|
+
const resolveBorderSet = (borderLines) => borderLines === "number" ? BORDER_SETS.simple : BORDER_SETS[borderLines];
|
|
6519
|
+
const drawPaneBorders = (rows, occupied, borderLines) => {
|
|
6520
|
+
const borderSet = resolveBorderSet(borderLines);
|
|
6521
|
+
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
|
|
6522
|
+
const row = rows[rowIndex];
|
|
6523
|
+
const occupiedRow = occupied[rowIndex];
|
|
6524
|
+
if (!row || !occupiedRow) continue;
|
|
6525
|
+
for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
|
|
6526
|
+
if (occupiedRow[columnIndex]) continue;
|
|
6527
|
+
const left = occupiedRow[columnIndex - 1] ?? false;
|
|
6528
|
+
const right = occupiedRow[columnIndex + 1] ?? false;
|
|
6529
|
+
const top = occupied[rowIndex - 1]?.[columnIndex] ?? false;
|
|
6530
|
+
const bottom = occupied[rowIndex + 1]?.[columnIndex] ?? false;
|
|
6531
|
+
const hasVertical = left || right;
|
|
6532
|
+
const hasHorizontal = top || bottom;
|
|
6533
|
+
if (hasVertical && hasHorizontal) row[columnIndex] = borderSet.intersection;
|
|
6534
|
+
else if (hasVertical) row[columnIndex] = borderSet.vertical;
|
|
6535
|
+
else if (hasHorizontal) row[columnIndex] = borderSet.horizontal;
|
|
6536
|
+
}
|
|
6537
|
+
}
|
|
6538
|
+
};
|
|
6539
|
+
const composeDisplayLines = (panes, width, height, borderLines) => {
|
|
6540
|
+
const rows = Array.from({ length: height }, () => createBlankRow(width));
|
|
6541
|
+
const occupied = createOccupancyGrid(panes, width, height);
|
|
6542
|
+
for (const pane of panes) pane.displayLines.forEach((line, lineIndex) => {
|
|
6543
|
+
const row = rows[pane.top + lineIndex];
|
|
6544
|
+
if (!row) return;
|
|
6545
|
+
createStyledDisplayCells(line).forEach((cell, cellIndex) => {
|
|
6546
|
+
const column = pane.left + cellIndex;
|
|
6547
|
+
if (column < 0 || column >= row.length) return;
|
|
6548
|
+
row[column] = cell;
|
|
6549
|
+
});
|
|
6550
|
+
});
|
|
6551
|
+
drawPaneBorders(rows, occupied, borderLines);
|
|
6552
|
+
return rows.map((row) => row.join(""));
|
|
6553
|
+
};
|
|
6554
|
+
const buildCurrentState = async (tmux, pane, paneId, clientTty) => {
|
|
6555
|
+
if (!pane.inCopyMode) await enterCopyMode(tmux, paneId);
|
|
6556
|
+
const capture = fitCaptureToHeight(await capturePane(tmux, paneId), pane.height);
|
|
6557
|
+
return {
|
|
6558
|
+
currentPath: pane.currentPath,
|
|
6559
|
+
state: {
|
|
6560
|
+
scope: "current",
|
|
6561
|
+
paneId,
|
|
6562
|
+
clientTty,
|
|
6563
|
+
displayLines: capture.displayLines,
|
|
6564
|
+
plainLines: capture.lines,
|
|
6565
|
+
width: pane.width,
|
|
6566
|
+
height: pane.height
|
|
6567
|
+
}
|
|
6568
|
+
};
|
|
6569
|
+
};
|
|
6570
|
+
const buildAllPaneState = async (tmux, pane, paneId, clientTty) => {
|
|
6571
|
+
const panes = await listWindowPanes(tmux, paneId);
|
|
6572
|
+
const borderLines = await getPaneBorderLines(tmux, paneId);
|
|
6573
|
+
const bounds = panes.reduce((accumulator, item) => ({
|
|
6574
|
+
left: Math.min(accumulator.left, item.left),
|
|
6575
|
+
top: Math.min(accumulator.top, item.top),
|
|
6576
|
+
right: Math.max(accumulator.right, item.left + item.width),
|
|
6577
|
+
bottom: Math.max(accumulator.bottom, item.top + item.height)
|
|
6578
|
+
}), {
|
|
6579
|
+
left: Number.POSITIVE_INFINITY,
|
|
6580
|
+
top: Number.POSITIVE_INFINITY,
|
|
6581
|
+
right: Number.NEGATIVE_INFINITY,
|
|
6582
|
+
bottom: Number.NEGATIVE_INFINITY
|
|
6583
|
+
});
|
|
6584
|
+
const snapshots = [];
|
|
6585
|
+
for (const item of panes) {
|
|
6586
|
+
const capture = fitCaptureToHeight(await capturePane(tmux, item.paneId), item.height);
|
|
6587
|
+
snapshots.push({
|
|
6588
|
+
paneId: item.paneId,
|
|
6589
|
+
inCopyMode: item.inCopyMode,
|
|
6590
|
+
width: item.width,
|
|
6591
|
+
height: item.height,
|
|
6592
|
+
left: item.left - bounds.left,
|
|
6593
|
+
top: item.top - bounds.top,
|
|
6594
|
+
plainLines: capture.lines,
|
|
6595
|
+
displayLines: capture.displayLines
|
|
6596
|
+
});
|
|
6597
|
+
}
|
|
6598
|
+
const width = Math.max(0, bounds.right - bounds.left);
|
|
6599
|
+
const height = Math.max(0, bounds.bottom - bounds.top);
|
|
6600
|
+
const x = buildPopupRelativePosition("x", bounds.left);
|
|
6601
|
+
const y = buildPopupRelativePosition("y", bounds.top);
|
|
6602
|
+
await writeDebugLog({
|
|
6603
|
+
event: "start.build-all-pane-state",
|
|
6604
|
+
paneId,
|
|
6605
|
+
clientTty,
|
|
6606
|
+
targetPane: {
|
|
6607
|
+
paneId: pane.paneId,
|
|
6608
|
+
width: pane.width,
|
|
6609
|
+
height: pane.height,
|
|
6610
|
+
inCopyMode: pane.inCopyMode,
|
|
6611
|
+
currentPath: pane.currentPath
|
|
6612
|
+
},
|
|
6613
|
+
bounds,
|
|
6614
|
+
size: {
|
|
6615
|
+
width,
|
|
6616
|
+
height
|
|
6617
|
+
},
|
|
6618
|
+
popupPosition: {
|
|
6619
|
+
x,
|
|
6620
|
+
y
|
|
6621
|
+
},
|
|
6622
|
+
panes: panes.map((item) => ({
|
|
6623
|
+
paneId: item.paneId,
|
|
6624
|
+
left: item.left,
|
|
6625
|
+
top: item.top,
|
|
6626
|
+
width: item.width,
|
|
6627
|
+
height: item.height,
|
|
6628
|
+
active: item.active,
|
|
6629
|
+
inCopyMode: item.inCopyMode,
|
|
6630
|
+
currentPath: item.currentPath
|
|
6631
|
+
}))
|
|
6632
|
+
});
|
|
6633
|
+
return {
|
|
6634
|
+
currentPath: pane.currentPath,
|
|
6635
|
+
x,
|
|
6636
|
+
y,
|
|
6637
|
+
state: {
|
|
6638
|
+
scope: "all",
|
|
6639
|
+
paneId,
|
|
6640
|
+
clientTty,
|
|
6641
|
+
displayLines: composeDisplayLines(snapshots, width, height, borderLines),
|
|
6642
|
+
panes: snapshots,
|
|
6643
|
+
width,
|
|
6644
|
+
height
|
|
6645
|
+
}
|
|
6646
|
+
};
|
|
6647
|
+
};
|
|
6358
6648
|
const runStart = async (args) => {
|
|
6359
|
-
const
|
|
6649
|
+
const { scope, paneId, clientTty } = parseStartArgs(args);
|
|
6360
6650
|
if (!process.env.TMUX) {
|
|
6361
6651
|
console.error("tmux-fuzzy-motion: must be run inside tmux");
|
|
6362
6652
|
return 2;
|
|
@@ -6377,33 +6667,46 @@ const runStart = async (args) => {
|
|
|
6377
6667
|
try {
|
|
6378
6668
|
const pane = await getPaneStartContext(tmux, paneId);
|
|
6379
6669
|
await focusClientPane(tmux, paneId, clientTty);
|
|
6380
|
-
const
|
|
6381
|
-
const state =
|
|
6382
|
-
paneId,
|
|
6383
|
-
clientTty,
|
|
6384
|
-
displayLines: capture.displayLines,
|
|
6385
|
-
plainLines: capture.lines,
|
|
6386
|
-
width: pane.width,
|
|
6387
|
-
height: pane.height
|
|
6388
|
-
};
|
|
6670
|
+
const popupState = scope === "all" ? await buildAllPaneState(tmux, pane, paneId, clientTty) : await buildCurrentState(tmux, pane, paneId, clientTty);
|
|
6671
|
+
const state = popupState.state;
|
|
6389
6672
|
await writeFile(stateFile, JSON.stringify(state), "utf8");
|
|
6390
6673
|
await ensureDaemon(socketPath);
|
|
6391
|
-
|
|
6674
|
+
const popupOptions = {
|
|
6392
6675
|
command: buildPopupCommand(stateFile, resultFile, socketPath),
|
|
6393
|
-
currentPath:
|
|
6394
|
-
height:
|
|
6676
|
+
currentPath: popupState.currentPath,
|
|
6677
|
+
height: state.height,
|
|
6395
6678
|
targetClient: clientTty,
|
|
6396
6679
|
targetPane: paneId,
|
|
6397
|
-
width:
|
|
6680
|
+
width: state.width
|
|
6681
|
+
};
|
|
6682
|
+
if (popupState.x !== void 0) popupOptions.x = popupState.x;
|
|
6683
|
+
if (popupState.y !== void 0) popupOptions.y = popupState.y;
|
|
6684
|
+
await writeDebugLog({
|
|
6685
|
+
event: "start.display-popup",
|
|
6686
|
+
scope,
|
|
6687
|
+
paneId,
|
|
6688
|
+
clientTty,
|
|
6689
|
+
popupOptions: {
|
|
6690
|
+
targetPane: popupOptions.targetPane,
|
|
6691
|
+
targetClient: popupOptions.targetClient,
|
|
6692
|
+
currentPath: popupOptions.currentPath,
|
|
6693
|
+
width: popupOptions.width,
|
|
6694
|
+
height: popupOptions.height,
|
|
6695
|
+
x: popupOptions.x ?? "#{popup_pane_left}",
|
|
6696
|
+
y: popupOptions.y ?? "#{popup_pane_top}"
|
|
6697
|
+
}
|
|
6398
6698
|
});
|
|
6699
|
+
await displayPopup(tmux, popupOptions);
|
|
6399
6700
|
const result = await readResult(resultFile);
|
|
6400
6701
|
if (result.status === "selected") {
|
|
6702
|
+
const targetPaneId = result.target.paneId ?? paneId;
|
|
6401
6703
|
await tmux.runQuiet([
|
|
6402
6704
|
"select-pane",
|
|
6403
6705
|
"-t",
|
|
6404
|
-
|
|
6706
|
+
targetPaneId
|
|
6405
6707
|
]);
|
|
6406
|
-
|
|
6708
|
+
if (state.scope === "all" && !state.panes.some((targetPane) => targetPane.paneId === targetPaneId && targetPane.inCopyMode)) await enterCopyMode(tmux, targetPaneId);
|
|
6709
|
+
await moveCopyCursor(tmux, targetPaneId, result.target);
|
|
6407
6710
|
}
|
|
6408
6711
|
return 0;
|
|
6409
6712
|
} catch (error) {
|
|
@@ -6422,7 +6725,7 @@ const runStart = async (args) => {
|
|
|
6422
6725
|
const usage = `tmux-fuzzy-motion
|
|
6423
6726
|
|
|
6424
6727
|
Usage:
|
|
6425
|
-
tmux-fuzzy-motion start <pane-id> <client-tty>
|
|
6728
|
+
tmux-fuzzy-motion start [--scope current|all] <pane-id> <client-tty>
|
|
6426
6729
|
tmux-fuzzy-motion popup --state-file <path> --result-file <path> --socket <path>
|
|
6427
6730
|
tmux-fuzzy-motion popup-live <pane-id>
|
|
6428
6731
|
tmux-fuzzy-motion daemon --socket <path>
|