ppicker 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {ppicker-0.2.0 → ppicker-0.3.0}/Cargo.lock +1 -1
- {ppicker-0.2.0 → ppicker-0.3.0}/Cargo.toml +1 -1
- {ppicker-0.2.0 → ppicker-0.3.0}/PKG-INFO +20 -2
- {ppicker-0.2.0 → ppicker-0.3.0}/README.md +19 -1
- ppicker-0.3.0/assets/09-stay-open.png +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/e2e/scenario.sh +17 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/src/main.rs +22 -8
- {ppicker-0.2.0 → ppicker-0.3.0}/src/store.rs +4 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/src/tui.rs +371 -120
- {ppicker-0.2.0 → ppicker-0.3.0}/.gitattributes +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/.github/workflows/ci.yml +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/.github/workflows/crates.yml +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/.github/workflows/release.yml +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/.gitignore +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/assets/01-search.png +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/assets/02-open-with.png +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/assets/03-remembered.png +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/assets/04-switch-editor.png +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/assets/05-rofi-project.png +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/assets/06-rofi-editor.png +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/assets/07-workspace.png +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/assets/08-workspace-open-with.png +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/assets/hero.png +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/docs/MIGRATION.md +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/e2e/Dockerfile +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/e2e/Dockerfile.dockerignore +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/e2e/README.md +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/e2e/i3config +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/pyproject.toml +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/src/editors.rs +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/src/exec.rs +0 -0
- {ppicker-0.2.0 → ppicker-0.3.0}/src/menu.rs +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ppicker
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Classifier: Development Status :: 4 - Beta
|
|
5
5
|
Classifier: Environment :: Console
|
|
6
6
|
Classifier: Programming Language :: Rust
|
|
@@ -72,6 +72,19 @@ editor will be used, when you last opened it, and its tags. Press `Tab` to filte
|
|
|
72
72
|
Press **Enter**. The project opens in its editor and ppicker closes. The editor is started in
|
|
73
73
|
the project's folder and keeps running on its own, so closing the terminal does not close it.
|
|
74
74
|
|
|
75
|
+
### Open several projects at once
|
|
76
|
+
|
|
77
|
+
Press **Ctrl-o** instead of Enter. The project opens just the same, but ppicker **stays open**, so
|
|
78
|
+
you can go straight on to the next one: type, `Ctrl-o`, type, `Ctrl-o`, then `Esc` when you are
|
|
79
|
+
done. A line at the bottom confirms each one (`opened web-ui in pycharm`), and the list updates
|
|
80
|
+
the editor and "last used" time as you go.
|
|
81
|
+
|
|
82
|
+

|
|
83
|
+
|
|
84
|
+
Prefer it the other way round? Set `keep_open = true` in the config: then `Enter` opens and stays,
|
|
85
|
+
and `Ctrl-o` opens and quits. If an editor cannot be started (say, its command is not installed),
|
|
86
|
+
ppicker stays open and shows the error instead of closing.
|
|
87
|
+
|
|
75
88
|
The first time you open a project, it uses your default editor, the first one in your config
|
|
76
89
|
(see [Editors](https://github.com/roman-right/ppicker#editors)). After that it uses whichever editor you opened it with last.
|
|
77
90
|
|
|
@@ -155,7 +168,8 @@ For other launchers, pass the command to use: `pp menu --cmd "fuzzel --dmenu"` (
|
|
|
155
168
|
|---|---|
|
|
156
169
|
| type | search by name or path |
|
|
157
170
|
| `↑` `↓` (or `Ctrl-p` `Ctrl-n`) | move through the list |
|
|
158
|
-
| `Enter` | open the project in its editor |
|
|
171
|
+
| `Enter` | open the project in its editor, then close |
|
|
172
|
+
| `Ctrl-o` | open the project and **keep the picker open** (to open several) |
|
|
159
173
|
| `→` | open with a different editor |
|
|
160
174
|
| `1`-`9` | in the editor list, open with that editor |
|
|
161
175
|
| `Ctrl-e` | switch the project's editor without opening it |
|
|
@@ -163,6 +177,9 @@ For other launchers, pass the command to use: `pp menu --cmd "fuzzel --dmenu"` (
|
|
|
163
177
|
| `Ctrl-u` | clear the search |
|
|
164
178
|
| `←` or `Esc` | back from the editor list; `Esc` in the main list quits |
|
|
165
179
|
|
|
180
|
+
The `Ctrl` shortcuts also work when a Russian keyboard layout is active (`Ctrl-щ` is `Ctrl-o`).
|
|
181
|
+
With `keep_open = true` in the config, `Enter` and `Ctrl-o` swap roles.
|
|
182
|
+
|
|
166
183
|
## Editors
|
|
167
184
|
|
|
168
185
|
List the editors you use in the config file (run `pp paths` to see where it is). The first one is
|
|
@@ -171,6 +188,7 @@ the default for projects you have not opened yet:
|
|
|
171
188
|
```toml
|
|
172
189
|
ides = ["vscode", "pycharm", "zed"]
|
|
173
190
|
scan_dirs = ["/home/me/code"] # used by `pp scan` when you give no folder
|
|
191
|
+
keep_open = false # true: Enter opens and stays open, Ctrl-o opens and quits
|
|
174
192
|
```
|
|
175
193
|
|
|
176
194
|
Each entry is either a **name** ppicker knows, or a plain **command**.
|
|
@@ -57,6 +57,19 @@ editor will be used, when you last opened it, and its tags. Press `Tab` to filte
|
|
|
57
57
|
Press **Enter**. The project opens in its editor and ppicker closes. The editor is started in
|
|
58
58
|
the project's folder and keeps running on its own, so closing the terminal does not close it.
|
|
59
59
|
|
|
60
|
+
### Open several projects at once
|
|
61
|
+
|
|
62
|
+
Press **Ctrl-o** instead of Enter. The project opens just the same, but ppicker **stays open**, so
|
|
63
|
+
you can go straight on to the next one: type, `Ctrl-o`, type, `Ctrl-o`, then `Esc` when you are
|
|
64
|
+
done. A line at the bottom confirms each one (`opened web-ui in pycharm`), and the list updates
|
|
65
|
+
the editor and "last used" time as you go.
|
|
66
|
+
|
|
67
|
+

|
|
68
|
+
|
|
69
|
+
Prefer it the other way round? Set `keep_open = true` in the config: then `Enter` opens and stays,
|
|
70
|
+
and `Ctrl-o` opens and quits. If an editor cannot be started (say, its command is not installed),
|
|
71
|
+
ppicker stays open and shows the error instead of closing.
|
|
72
|
+
|
|
60
73
|
The first time you open a project, it uses your default editor, the first one in your config
|
|
61
74
|
(see [Editors](https://github.com/roman-right/ppicker#editors)). After that it uses whichever editor you opened it with last.
|
|
62
75
|
|
|
@@ -140,7 +153,8 @@ For other launchers, pass the command to use: `pp menu --cmd "fuzzel --dmenu"` (
|
|
|
140
153
|
|---|---|
|
|
141
154
|
| type | search by name or path |
|
|
142
155
|
| `↑` `↓` (or `Ctrl-p` `Ctrl-n`) | move through the list |
|
|
143
|
-
| `Enter` | open the project in its editor |
|
|
156
|
+
| `Enter` | open the project in its editor, then close |
|
|
157
|
+
| `Ctrl-o` | open the project and **keep the picker open** (to open several) |
|
|
144
158
|
| `→` | open with a different editor |
|
|
145
159
|
| `1`-`9` | in the editor list, open with that editor |
|
|
146
160
|
| `Ctrl-e` | switch the project's editor without opening it |
|
|
@@ -148,6 +162,9 @@ For other launchers, pass the command to use: `pp menu --cmd "fuzzel --dmenu"` (
|
|
|
148
162
|
| `Ctrl-u` | clear the search |
|
|
149
163
|
| `←` or `Esc` | back from the editor list; `Esc` in the main list quits |
|
|
150
164
|
|
|
165
|
+
The `Ctrl` shortcuts also work when a Russian keyboard layout is active (`Ctrl-щ` is `Ctrl-o`).
|
|
166
|
+
With `keep_open = true` in the config, `Enter` and `Ctrl-o` swap roles.
|
|
167
|
+
|
|
151
168
|
## Editors
|
|
152
169
|
|
|
153
170
|
List the editors you use in the config file (run `pp paths` to see where it is). The first one is
|
|
@@ -156,6 +173,7 @@ the default for projects you have not opened yet:
|
|
|
156
173
|
```toml
|
|
157
174
|
ides = ["vscode", "pycharm", "zed"]
|
|
158
175
|
scan_dirs = ["/home/me/code"] # used by `pp scan` when you give no folder
|
|
176
|
+
keep_open = false # true: Enter opens and stays open, Ctrl-o opens and quits
|
|
159
177
|
```
|
|
160
178
|
|
|
161
179
|
Each entry is either a **name** ppicker knows, or a plain **command**.
|
|
Binary file
|
|
@@ -122,6 +122,23 @@ $PP editor "Shop Front" --reset >/dev/null
|
|
|
122
122
|
check "no editor can open it: error says what to add" "$PP open 'Shop Front' 2>&1 | grep -q 'VS Code.*vscode'"
|
|
123
123
|
printf 'ides = ["code", "pycharm", "vim"]\n' > ~/.config/ppicker/config.toml
|
|
124
124
|
|
|
125
|
+
echo "=== 8. Ctrl-O opens several projects without closing the picker ==="
|
|
126
|
+
reset_calls
|
|
127
|
+
popup
|
|
128
|
+
xdotool type --delay 120 "api"; sleep 1
|
|
129
|
+
xdotool key ctrl+o; sleep 1.5
|
|
130
|
+
check "picker is still open after Ctrl-O" "pgrep -x pp >/dev/null"
|
|
131
|
+
check "first project opened (api-server, in code)" "[ \"\$(called code)\" = /root/code/api-server ]"
|
|
132
|
+
xdotool key ctrl+u; sleep 0.3
|
|
133
|
+
xdotool type --delay 120 "web"; sleep 1
|
|
134
|
+
xdotool key ctrl+o; sleep 1.5
|
|
135
|
+
shot 9-stay-open # status line: "opened web-ui in pycharm"
|
|
136
|
+
check "second project opened too (web-ui, in pycharm)" "[ \"\$(called pycharm)\" = /root/code/web-ui ]"
|
|
137
|
+
check "picker still open after the second" "pgrep -x pp >/dev/null"
|
|
138
|
+
xdotool key Escape; sleep 1.2
|
|
139
|
+
check "Esc then closes it" "! pgrep -x pp >/dev/null"
|
|
140
|
+
pkill alacritty 2>/dev/null; sleep 1
|
|
141
|
+
|
|
125
142
|
echo "=== overview: every project, each with the editor it was last opened in ==="
|
|
126
143
|
popup
|
|
127
144
|
shot 0-overview
|
|
@@ -106,15 +106,11 @@ fn real_main() -> Result<()> {
|
|
|
106
106
|
store.sort_recent();
|
|
107
107
|
let tags = store.all_tags();
|
|
108
108
|
let projects = store.projects.clone();
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
let _ = store.save();
|
|
109
|
+
let host = Box::new(PickerHost {
|
|
110
|
+
store: &mut store,
|
|
111
|
+
cfg: &cfg,
|
|
113
112
|
});
|
|
114
|
-
|
|
115
|
-
if let Some(p) = picked {
|
|
116
|
-
launch(&mut store, &cfg, &p.project, &p.ide)?;
|
|
117
|
-
}
|
|
113
|
+
tui::pick(projects, tags, &cfg, host)?;
|
|
118
114
|
}
|
|
119
115
|
Some(Cmd::Add {
|
|
120
116
|
path,
|
|
@@ -297,6 +293,24 @@ fn real_main() -> Result<()> {
|
|
|
297
293
|
Ok(())
|
|
298
294
|
}
|
|
299
295
|
|
|
296
|
+
/// What the picker uses to start editors and save changes while it runs.
|
|
297
|
+
struct PickerHost<'a> {
|
|
298
|
+
store: &'a mut Store,
|
|
299
|
+
cfg: &'a Config,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
impl tui::Host for PickerHost<'_> {
|
|
303
|
+
/// Saved the moment it changes, so quitting any way loses nothing.
|
|
304
|
+
fn ide_changed(&mut self, path: &Path, ide: Option<&str>) {
|
|
305
|
+
self.store.set_ide(path, ide.map(String::from));
|
|
306
|
+
let _ = self.store.save();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
fn open(&mut self, project: &Project, ide: &str) -> Result<()> {
|
|
310
|
+
launch(self.store, self.cfg, project, ide)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
300
314
|
/// Start the editor first, and only remember it (and the open time) if that
|
|
301
315
|
/// worked, so a typo'd or missing command is never saved as the project's editor.
|
|
302
316
|
fn launch(store: &mut Store, cfg: &Config, project: &Project, ide: &str) -> Result<()> {
|
|
@@ -237,6 +237,9 @@ pub struct Config {
|
|
|
237
237
|
/// Overrides for what a name runs: `vscode = ["code-insiders", "code"]`
|
|
238
238
|
/// (the first command found on PATH is used). Can also define new names.
|
|
239
239
|
pub commands: HashMap<String, Vec<String>>,
|
|
240
|
+
/// If true, `Enter` opens a project and leaves the picker open (and
|
|
241
|
+
/// `Ctrl-o` opens and quits). By default it is the other way round.
|
|
242
|
+
pub keep_open: bool,
|
|
240
243
|
}
|
|
241
244
|
|
|
242
245
|
impl Default for Config {
|
|
@@ -245,6 +248,7 @@ impl Default for Config {
|
|
|
245
248
|
ides: vec!["code".into()],
|
|
246
249
|
scan_dirs: vec![],
|
|
247
250
|
commands: HashMap::new(),
|
|
251
|
+
keep_open: false,
|
|
248
252
|
}
|
|
249
253
|
}
|
|
250
254
|
}
|
|
@@ -27,15 +27,17 @@ enum Stage {
|
|
|
27
27
|
Editors,
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/// What the
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
/// What the picker asks of the program around it: starting editors and
|
|
31
|
+
/// saving changes. The picker calls these while it is running, so it can stay
|
|
32
|
+
/// open after launching something.
|
|
33
|
+
pub trait Host {
|
|
34
|
+
/// A project's remembered editor changed (`None` = the global default).
|
|
35
|
+
fn ide_changed(&mut self, path: &Path, ide: Option<&str>);
|
|
36
|
+
/// Start `ide` on `project`. An error (say, the command is not installed)
|
|
37
|
+
/// is shown in the picker instead of closing it.
|
|
38
|
+
fn open(&mut self, project: &Project, ide: &str) -> Result<()>;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
/// Receives `(project path, remembered editor)` each time the editor changes.
|
|
37
|
-
type IdeChanged<'a> = Box<dyn FnMut(&Path, Option<&str>) + 'a>;
|
|
38
|
-
|
|
39
41
|
struct App<'a> {
|
|
40
42
|
cfg: &'a Config,
|
|
41
43
|
projects: Vec<Project>,
|
|
@@ -50,9 +52,9 @@ struct App<'a> {
|
|
|
50
52
|
editors: Vec<String>,
|
|
51
53
|
editor_list: ListState,
|
|
52
54
|
matcher: Matcher,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
/// Set when the picker should close.
|
|
56
|
+
done: bool,
|
|
57
|
+
host: Box<dyn Host + 'a>,
|
|
56
58
|
/// One-line feedback shown in place of the key hints until the next key.
|
|
57
59
|
status: Option<String>,
|
|
58
60
|
}
|
|
@@ -62,7 +64,7 @@ impl<'a> App<'a> {
|
|
|
62
64
|
projects: Vec<Project>,
|
|
63
65
|
tags: Vec<String>,
|
|
64
66
|
cfg: &'a Config,
|
|
65
|
-
|
|
67
|
+
host: Box<dyn Host + 'a>,
|
|
66
68
|
) -> Self {
|
|
67
69
|
let mut app = Self {
|
|
68
70
|
cfg,
|
|
@@ -76,8 +78,8 @@ impl<'a> App<'a> {
|
|
|
76
78
|
editors: vec![],
|
|
77
79
|
editor_list: ListState::default(),
|
|
78
80
|
matcher: Matcher::new(MatcherConfig::DEFAULT.match_paths()),
|
|
79
|
-
done:
|
|
80
|
-
|
|
81
|
+
done: false,
|
|
82
|
+
host,
|
|
81
83
|
status: None,
|
|
82
84
|
};
|
|
83
85
|
app.refilter();
|
|
@@ -157,20 +159,48 @@ impl<'a> App<'a> {
|
|
|
157
159
|
self.cfg.effective_ide(p)
|
|
158
160
|
}
|
|
159
161
|
|
|
160
|
-
///
|
|
161
|
-
|
|
162
|
+
/// Whether the picker stays open after opening. `Enter` follows the
|
|
163
|
+
/// `keep_open` setting; the alternate key (`Ctrl-o`) does the opposite.
|
|
164
|
+
fn stays_open(&self, alternate: bool) -> bool {
|
|
165
|
+
self.cfg.keep_open != alternate
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// Open the selected project in its own editor.
|
|
169
|
+
fn open_current(&mut self, alternate: bool) {
|
|
162
170
|
let Some(p) = self.current() else { return };
|
|
163
171
|
match self.effective_ide(p) {
|
|
164
172
|
Some((ide, _)) => {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
ide: ide.to_string(),
|
|
168
|
-
}));
|
|
173
|
+
let ide = ide.to_string();
|
|
174
|
+
self.open_selected(ide, self.stays_open(alternate));
|
|
169
175
|
}
|
|
170
176
|
None => self.status = Some(no_editor_message(p)),
|
|
171
177
|
}
|
|
172
178
|
}
|
|
173
179
|
|
|
180
|
+
/// Start `ide` on the selected project. On success the picker closes, or
|
|
181
|
+
/// stays open (ready for the next project) if `stay`. On failure it always
|
|
182
|
+
/// stays open and says why.
|
|
183
|
+
fn open_selected(&mut self, ide: String, stay: bool) {
|
|
184
|
+
let Some(&idx) = self.list.selected().and_then(|i| self.visible.get(i)) else {
|
|
185
|
+
return;
|
|
186
|
+
};
|
|
187
|
+
let project = self.projects[idx].clone();
|
|
188
|
+
match self.host.open(&project, &ide) {
|
|
189
|
+
Ok(()) => {
|
|
190
|
+
// Mirror what the host saved, so the list and details are current.
|
|
191
|
+
self.projects[idx].ide = Some(ide.clone());
|
|
192
|
+
self.projects[idx].last_opened = Some(now());
|
|
193
|
+
if stay {
|
|
194
|
+
self.stage = Stage::Projects;
|
|
195
|
+
self.status = Some(format!("opened {} in {ide}", project.name));
|
|
196
|
+
} else {
|
|
197
|
+
self.done = true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
Err(e) => self.status = Some(format!("{e:#}")),
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
174
204
|
/// Show the "Open with..." list (Right arrow). Only editors that can open
|
|
175
205
|
/// this kind of project (folder or workspace file) are listed.
|
|
176
206
|
fn enter_editors(&mut self) {
|
|
@@ -185,12 +215,9 @@ impl<'a> App<'a> {
|
|
|
185
215
|
self.stage = Stage::Editors;
|
|
186
216
|
}
|
|
187
217
|
|
|
188
|
-
fn open_with(&mut self, index: usize) {
|
|
189
|
-
if let
|
|
190
|
-
self.
|
|
191
|
-
project: p.clone(),
|
|
192
|
-
ide: ide.clone(),
|
|
193
|
-
}));
|
|
218
|
+
fn open_with(&mut self, index: usize, alternate: bool) {
|
|
219
|
+
if let Some(ide) = self.editors.get(index).cloned() {
|
|
220
|
+
self.open_selected(ide, self.stays_open(alternate));
|
|
194
221
|
}
|
|
195
222
|
}
|
|
196
223
|
|
|
@@ -202,8 +229,8 @@ impl<'a> App<'a> {
|
|
|
202
229
|
};
|
|
203
230
|
self.projects[idx].ide = ide.clone();
|
|
204
231
|
let p = &self.projects[idx];
|
|
205
|
-
|
|
206
|
-
let (name, remembered) = self.effective_ide(p).unwrap_or(("none", false));
|
|
232
|
+
self.host.ide_changed(&p.path, ide.as_deref());
|
|
233
|
+
let (name, remembered) = self.cfg.effective_ide(p).unwrap_or(("none", false));
|
|
207
234
|
self.status = Some(format!(
|
|
208
235
|
"{}: editor is now {name}{}",
|
|
209
236
|
p.name,
|
|
@@ -235,23 +262,41 @@ impl<'a> App<'a> {
|
|
|
235
262
|
}
|
|
236
263
|
|
|
237
264
|
fn on_key(&mut self, code: KeyCode, mods: KeyModifiers) {
|
|
238
|
-
let ctrl = mods.contains(KeyModifiers::CONTROL);
|
|
265
|
+
let mut ctrl = mods.contains(KeyModifiers::CONTROL);
|
|
266
|
+
// Some terminals (and Windows consoles) deliver Ctrl+letter as the raw
|
|
267
|
+
// control character (^O = 0x0F), with or without the Ctrl modifier. Treat
|
|
268
|
+
// it as Ctrl+letter instead of typing garbage into the search box. Tab,
|
|
269
|
+
// Enter and Backspace arrive as their own keys, so their code points
|
|
270
|
+
// are left alone.
|
|
271
|
+
let code = match code {
|
|
272
|
+
KeyCode::Char(c) if ('\x01'..='\x1a').contains(&c) && !"\x08\t\n\r".contains(c) => {
|
|
273
|
+
ctrl = true;
|
|
274
|
+
KeyCode::Char((c as u8 - 1 + b'a') as char)
|
|
275
|
+
}
|
|
276
|
+
// With a non-Latin keyboard layout active, Ctrl+<key> reports that
|
|
277
|
+
// layout's letter (Russian: Ctrl+O arrives as Ctrl+щ). Shortcuts
|
|
278
|
+
// should work in any layout, so map by key position.
|
|
279
|
+
KeyCode::Char(c) if ctrl => KeyCode::Char(latin_key(c)),
|
|
280
|
+
other => other,
|
|
281
|
+
};
|
|
239
282
|
self.status = None;
|
|
240
283
|
match (code, ctrl) {
|
|
241
|
-
(KeyCode::Char('c'), true) => self.done =
|
|
284
|
+
(KeyCode::Char('c'), true) => self.done = true,
|
|
242
285
|
(KeyCode::Down, _) | (KeyCode::Char('n' | 'j'), true) => self.move_sel(1),
|
|
243
286
|
(KeyCode::Up, _) | (KeyCode::Char('p' | 'k'), true) => self.move_sel(-1),
|
|
244
287
|
_ => match self.stage {
|
|
245
288
|
Stage::Projects => self.on_key_projects(code, ctrl),
|
|
246
|
-
Stage::Editors => self.on_key_editors(code),
|
|
289
|
+
Stage::Editors => self.on_key_editors(code, ctrl),
|
|
247
290
|
},
|
|
248
291
|
}
|
|
249
292
|
}
|
|
250
293
|
|
|
251
294
|
fn on_key_projects(&mut self, code: KeyCode, ctrl: bool) {
|
|
252
295
|
match (code, ctrl) {
|
|
253
|
-
(KeyCode::Esc, _) => self.done =
|
|
254
|
-
(KeyCode::Enter, _) => self.open_current(),
|
|
296
|
+
(KeyCode::Esc, _) => self.done = true,
|
|
297
|
+
(KeyCode::Enter, _) => self.open_current(false),
|
|
298
|
+
// Ctrl-O: open, but do the opposite of Enter about closing the picker.
|
|
299
|
+
(KeyCode::Char('o'), true) => self.open_current(true),
|
|
255
300
|
(KeyCode::Right, _) => self.enter_editors(),
|
|
256
301
|
(KeyCode::Char('e'), true) => self.cycle_ide(),
|
|
257
302
|
(KeyCode::Tab, _) => self.cycle_tag(true),
|
|
@@ -264,7 +309,7 @@ impl<'a> App<'a> {
|
|
|
264
309
|
self.query.pop();
|
|
265
310
|
self.refilter();
|
|
266
311
|
}
|
|
267
|
-
(KeyCode::Char(c), false) => {
|
|
312
|
+
(KeyCode::Char(c), false) if !c.is_control() => {
|
|
268
313
|
self.query.push(c);
|
|
269
314
|
self.refilter();
|
|
270
315
|
}
|
|
@@ -272,18 +317,23 @@ impl<'a> App<'a> {
|
|
|
272
317
|
}
|
|
273
318
|
}
|
|
274
319
|
|
|
275
|
-
fn on_key_editors(&mut self, code: KeyCode) {
|
|
276
|
-
match code {
|
|
277
|
-
KeyCode::Esc | KeyCode::Left | KeyCode::Backspace => self.stage = Stage::Projects,
|
|
278
|
-
KeyCode::Enter => {
|
|
320
|
+
fn on_key_editors(&mut self, code: KeyCode, ctrl: bool) {
|
|
321
|
+
match (code, ctrl) {
|
|
322
|
+
(KeyCode::Esc | KeyCode::Left | KeyCode::Backspace, _) => self.stage = Stage::Projects,
|
|
323
|
+
(KeyCode::Enter, _) => {
|
|
324
|
+
if let Some(i) = self.editor_list.selected() {
|
|
325
|
+
self.open_with(i, false);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
(KeyCode::Char('o'), true) => {
|
|
279
329
|
if let Some(i) = self.editor_list.selected() {
|
|
280
|
-
self.open_with(i);
|
|
330
|
+
self.open_with(i, true);
|
|
281
331
|
}
|
|
282
332
|
}
|
|
283
|
-
KeyCode::Char(c @ '1'..='9') => {
|
|
333
|
+
(KeyCode::Char(c @ '1'..='9'), false) => {
|
|
284
334
|
let i = c as usize - '1' as usize;
|
|
285
335
|
if i < self.editors.len() {
|
|
286
|
-
self.open_with(i);
|
|
336
|
+
self.open_with(i, false);
|
|
287
337
|
}
|
|
288
338
|
}
|
|
289
339
|
_ => {}
|
|
@@ -530,14 +580,33 @@ impl<'a> App<'a> {
|
|
|
530
580
|
);
|
|
531
581
|
return;
|
|
532
582
|
}
|
|
583
|
+
let (enter, ctrl_o) = if self.cfg.keep_open {
|
|
584
|
+
("open, stay", "open, quit")
|
|
585
|
+
} else {
|
|
586
|
+
("open", "open, stay")
|
|
587
|
+
};
|
|
533
588
|
let text = match self.stage {
|
|
534
|
-
Stage::Projects =>
|
|
535
|
-
|
|
589
|
+
Stage::Projects => format!(
|
|
590
|
+
" ⏎ {enter} ^o {ctrl_o} → open with… ^e switch editor ⇥ tag esc quit"
|
|
591
|
+
),
|
|
592
|
+
Stage::Editors => format!(" ⏎ {enter} 1-9 quick open ^o {ctrl_o} esc back"),
|
|
536
593
|
};
|
|
537
594
|
f.render_widget(Paragraph::new(text).style(Style::new().fg(DIM)), area);
|
|
538
595
|
}
|
|
539
596
|
}
|
|
540
597
|
|
|
598
|
+
/// The Latin letter on the same physical key as a Russian (ЙЦУКЕН) letter, so
|
|
599
|
+
/// `Ctrl+щ` works as `Ctrl+o`. Anything else is returned unchanged.
|
|
600
|
+
fn latin_key(c: char) -> char {
|
|
601
|
+
const RU: &str = "йцукенгшщзфывапролдячсмить";
|
|
602
|
+
const EN: &str = "qwertyuiopasdfghjklzxcvbnm";
|
|
603
|
+
let lower = c.to_lowercase().next().unwrap_or(c);
|
|
604
|
+
RU.chars()
|
|
605
|
+
.position(|r| r == lower)
|
|
606
|
+
.and_then(|i| EN.chars().nth(i))
|
|
607
|
+
.unwrap_or(c)
|
|
608
|
+
}
|
|
609
|
+
|
|
541
610
|
fn no_editor_message(p: &Project) -> String {
|
|
542
611
|
format!(
|
|
543
612
|
"no configured editor can open this {}; add {} to `ides`",
|
|
@@ -577,43 +646,87 @@ fn ago(ts: Option<i64>) -> String {
|
|
|
577
646
|
}
|
|
578
647
|
}
|
|
579
648
|
|
|
580
|
-
/// Run the picker.
|
|
649
|
+
/// Run the picker until the user quits. Opening and saving go through `host`.
|
|
581
650
|
pub fn pick<'a>(
|
|
582
651
|
projects: Vec<Project>,
|
|
583
652
|
tags: Vec<String>,
|
|
584
653
|
cfg: &'a Config,
|
|
585
|
-
|
|
586
|
-
) -> Result<
|
|
654
|
+
host: Box<dyn Host + 'a>,
|
|
655
|
+
) -> Result<()> {
|
|
587
656
|
let mut terminal: DefaultTerminal = ratatui::init();
|
|
588
|
-
let result = run_loop(&mut terminal, App::new(projects, tags, cfg,
|
|
657
|
+
let result = run_loop(&mut terminal, App::new(projects, tags, cfg, host));
|
|
589
658
|
ratatui::restore();
|
|
590
659
|
result
|
|
591
660
|
}
|
|
592
661
|
|
|
593
|
-
fn run_loop(terminal: &mut DefaultTerminal, mut app: App) -> Result<
|
|
594
|
-
|
|
662
|
+
fn run_loop(terminal: &mut DefaultTerminal, mut app: App) -> Result<()> {
|
|
663
|
+
while !app.done {
|
|
595
664
|
terminal.draw(|f| app.draw(f))?;
|
|
596
665
|
if let Event::Key(key) = event::read()?
|
|
597
666
|
&& key.kind == KeyEventKind::Press
|
|
598
667
|
{
|
|
599
668
|
app.on_key(key.code, key.modifiers);
|
|
600
669
|
}
|
|
601
|
-
if let Some(done) = app.done.take() {
|
|
602
|
-
return Ok(done);
|
|
603
|
-
}
|
|
604
670
|
}
|
|
671
|
+
Ok(())
|
|
605
672
|
}
|
|
606
673
|
|
|
607
674
|
#[cfg(test)]
|
|
608
675
|
mod tests {
|
|
609
676
|
use super::*;
|
|
677
|
+
use anyhow::bail;
|
|
610
678
|
use ratatui::Terminal;
|
|
611
679
|
use ratatui::backend::TestBackend;
|
|
612
|
-
use std::cell::RefCell;
|
|
680
|
+
use std::cell::{Cell, RefCell};
|
|
613
681
|
use std::path::PathBuf;
|
|
614
682
|
use std::rc::Rc;
|
|
615
683
|
|
|
616
|
-
type
|
|
684
|
+
type IdeLog = Rc<RefCell<Vec<(PathBuf, Option<String>)>>>;
|
|
685
|
+
|
|
686
|
+
/// What the fake host saw, shared with the test.
|
|
687
|
+
#[derive(Clone, Default)]
|
|
688
|
+
struct Rec {
|
|
689
|
+
ide_changes: IdeLog,
|
|
690
|
+
/// (project name, editor) for every successful open.
|
|
691
|
+
opened: Rc<RefCell<Vec<(String, String)>>>,
|
|
692
|
+
fail_open: Rc<Cell<bool>>,
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
impl Rec {
|
|
696
|
+
fn ide_log(&self) -> Vec<Option<String>> {
|
|
697
|
+
self.ide_changes
|
|
698
|
+
.borrow()
|
|
699
|
+
.iter()
|
|
700
|
+
.map(|(_, i)| i.clone())
|
|
701
|
+
.collect()
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
fn opened(&self) -> Vec<(String, String)> {
|
|
705
|
+
self.opened.borrow().clone()
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
struct FakeHost(Rec);
|
|
710
|
+
|
|
711
|
+
impl Host for FakeHost {
|
|
712
|
+
fn ide_changed(&mut self, path: &Path, ide: Option<&str>) {
|
|
713
|
+
self.0
|
|
714
|
+
.ide_changes
|
|
715
|
+
.borrow_mut()
|
|
716
|
+
.push((path.to_path_buf(), ide.map(String::from)));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
fn open(&mut self, p: &Project, ide: &str) -> Result<()> {
|
|
720
|
+
if self.0.fail_open.get() {
|
|
721
|
+
bail!("command not found: {ide}");
|
|
722
|
+
}
|
|
723
|
+
self.0
|
|
724
|
+
.opened
|
|
725
|
+
.borrow_mut()
|
|
726
|
+
.push((p.name.clone(), ide.to_string()));
|
|
727
|
+
Ok(())
|
|
728
|
+
}
|
|
729
|
+
}
|
|
617
730
|
|
|
618
731
|
fn proj(name: &str, tags: &[&str], ide: Option<&str>) -> Project {
|
|
619
732
|
Project {
|
|
@@ -632,8 +745,26 @@ mod tests {
|
|
|
632
745
|
}
|
|
633
746
|
}
|
|
634
747
|
|
|
748
|
+
/// Three projects (beta already remembers vim) with a recording host.
|
|
749
|
+
fn app(cfg: &Config) -> (App<'_>, Rec) {
|
|
750
|
+
let rec = Rec::default();
|
|
751
|
+
let ps = vec![
|
|
752
|
+
proj("alpha", &["work"], None),
|
|
753
|
+
proj("beta", &["oss"], Some("vim")),
|
|
754
|
+
proj("gamma", &["work"], None),
|
|
755
|
+
];
|
|
756
|
+
let app = App::new(
|
|
757
|
+
ps,
|
|
758
|
+
vec!["oss".into(), "work".into()],
|
|
759
|
+
cfg,
|
|
760
|
+
Box::new(FakeHost(rec.clone())),
|
|
761
|
+
);
|
|
762
|
+
(app, rec)
|
|
763
|
+
}
|
|
764
|
+
|
|
635
765
|
/// A folder and a VS Code workspace file side by side.
|
|
636
|
-
fn app_with_workspace(cfg: &Config) -> App<'_
|
|
766
|
+
fn app_with_workspace(cfg: &Config) -> (App<'_>, Rec) {
|
|
767
|
+
let rec = Rec::default();
|
|
637
768
|
let ps = vec![
|
|
638
769
|
proj("alpha", &[], None),
|
|
639
770
|
Project {
|
|
@@ -644,29 +775,8 @@ mod tests {
|
|
|
644
775
|
ide: None,
|
|
645
776
|
},
|
|
646
777
|
];
|
|
647
|
-
App::new(ps, vec![], cfg, Box::new(
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
/// Three projects; every editor change is recorded in the returned log,
|
|
651
|
-
/// standing in for the saved store.
|
|
652
|
-
fn app(cfg: &Config) -> (App<'_>, Saved) {
|
|
653
|
-
let saved: Saved = Default::default();
|
|
654
|
-
let sink = saved.clone();
|
|
655
|
-
let ps = vec![
|
|
656
|
-
proj("alpha", &["work"], None),
|
|
657
|
-
proj("beta", &["oss"], Some("vim")),
|
|
658
|
-
proj("gamma", &["work"], None),
|
|
659
|
-
];
|
|
660
|
-
let app = App::new(
|
|
661
|
-
ps,
|
|
662
|
-
vec!["oss".into(), "work".into()],
|
|
663
|
-
cfg,
|
|
664
|
-
Box::new(move |p, ide| {
|
|
665
|
-
sink.borrow_mut()
|
|
666
|
-
.push((p.to_path_buf(), ide.map(String::from)))
|
|
667
|
-
}),
|
|
668
|
-
);
|
|
669
|
-
(app, saved)
|
|
778
|
+
let app = App::new(ps, vec![], cfg, Box::new(FakeHost(rec.clone())));
|
|
779
|
+
(app, rec)
|
|
670
780
|
}
|
|
671
781
|
|
|
672
782
|
fn key(a: &mut App, code: KeyCode) {
|
|
@@ -683,6 +793,21 @@ mod tests {
|
|
|
683
793
|
}
|
|
684
794
|
}
|
|
685
795
|
|
|
796
|
+
/// Everything on screen as one string.
|
|
797
|
+
fn screen(a: &mut App) -> String {
|
|
798
|
+
let mut t = Terminal::new(TestBackend::new(100, 24)).unwrap();
|
|
799
|
+
t.draw(|f| a.draw(f)).unwrap();
|
|
800
|
+
let buf = t.backend().buffer().clone();
|
|
801
|
+
let mut out = String::new();
|
|
802
|
+
for y in 0..buf.area.height {
|
|
803
|
+
for x in 0..buf.area.width {
|
|
804
|
+
out.push_str(buf[(x, y)].symbol());
|
|
805
|
+
}
|
|
806
|
+
out.push('\n');
|
|
807
|
+
}
|
|
808
|
+
out
|
|
809
|
+
}
|
|
810
|
+
|
|
686
811
|
#[test]
|
|
687
812
|
fn fuzzy_filter_narrows_and_ranks() {
|
|
688
813
|
let cfg = three_ides();
|
|
@@ -707,52 +832,188 @@ mod tests {
|
|
|
707
832
|
}
|
|
708
833
|
|
|
709
834
|
#[test]
|
|
710
|
-
fn
|
|
835
|
+
fn enter_opens_in_the_global_default_and_closes() {
|
|
711
836
|
let cfg = three_ides();
|
|
712
|
-
let (mut a,
|
|
837
|
+
let (mut a, rec) = app(&cfg);
|
|
713
838
|
type_str(&mut a, "alpha");
|
|
714
839
|
key(&mut a, KeyCode::Enter);
|
|
715
|
-
|
|
716
|
-
assert_eq!(
|
|
717
|
-
assert_eq!(picked.ide, "code");
|
|
840
|
+
assert!(a.done);
|
|
841
|
+
assert_eq!(rec.opened(), vec![("alpha".into(), "code".into())]);
|
|
718
842
|
}
|
|
719
843
|
|
|
720
844
|
#[test]
|
|
721
845
|
fn enter_uses_the_projects_own_editor() {
|
|
722
846
|
let cfg = three_ides();
|
|
723
|
-
let (mut a,
|
|
847
|
+
let (mut a, rec) = app(&cfg);
|
|
724
848
|
type_str(&mut a, "beta"); // remembered vim
|
|
725
849
|
key(&mut a, KeyCode::Enter);
|
|
726
|
-
assert_eq!(
|
|
850
|
+
assert_eq!(rec.opened(), vec![("beta".into(), "vim".into())]);
|
|
727
851
|
}
|
|
728
852
|
|
|
729
853
|
#[test]
|
|
730
854
|
fn right_arrow_lists_editors_remembered_first_and_number_opens() {
|
|
731
855
|
let cfg = three_ides();
|
|
732
|
-
let (mut a,
|
|
856
|
+
let (mut a, rec) = app(&cfg);
|
|
733
857
|
type_str(&mut a, "beta");
|
|
734
858
|
key(&mut a, KeyCode::Right);
|
|
735
859
|
assert!(a.stage == Stage::Editors);
|
|
736
860
|
assert_eq!(a.editors, vec!["vim", "code", "pycharm"]);
|
|
737
861
|
key(&mut a, KeyCode::Char('3'));
|
|
738
|
-
assert_eq!(
|
|
862
|
+
assert_eq!(rec.opened(), vec![("beta".into(), "pycharm".into())]);
|
|
863
|
+
assert!(a.done);
|
|
739
864
|
}
|
|
740
865
|
|
|
741
866
|
#[test]
|
|
742
867
|
fn esc_goes_back_then_quits() {
|
|
743
868
|
let cfg = three_ides();
|
|
744
|
-
let (mut a,
|
|
869
|
+
let (mut a, rec) = app(&cfg);
|
|
745
870
|
key(&mut a, KeyCode::Right);
|
|
746
871
|
key(&mut a, KeyCode::Esc);
|
|
747
|
-
assert!(a.stage == Stage::Projects && a.done
|
|
872
|
+
assert!(a.stage == Stage::Projects && !a.done);
|
|
748
873
|
key(&mut a, KeyCode::Esc);
|
|
749
|
-
assert!(
|
|
874
|
+
assert!(a.done);
|
|
875
|
+
assert!(rec.opened().is_empty());
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
#[test]
|
|
879
|
+
fn ctrl_o_opens_and_keeps_the_picker_open_for_the_next_project() {
|
|
880
|
+
let cfg = three_ides();
|
|
881
|
+
let (mut a, rec) = app(&cfg);
|
|
882
|
+
type_str(&mut a, "alpha");
|
|
883
|
+
ctrl(&mut a, 'o');
|
|
884
|
+
assert!(!a.done, "picker must stay open");
|
|
885
|
+
assert_eq!(a.status.as_deref(), Some("opened alpha in code"));
|
|
886
|
+
|
|
887
|
+
// Straight on to a second project, in the same session.
|
|
888
|
+
ctrl(&mut a, 'u');
|
|
889
|
+
type_str(&mut a, "gamma");
|
|
890
|
+
ctrl(&mut a, 'o');
|
|
891
|
+
assert!(!a.done);
|
|
892
|
+
assert_eq!(
|
|
893
|
+
rec.opened(),
|
|
894
|
+
vec![
|
|
895
|
+
("alpha".into(), "code".into()),
|
|
896
|
+
("gamma".into(), "code".into())
|
|
897
|
+
]
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
// The list reflects what was just done.
|
|
901
|
+
let alpha = a.projects.iter().find(|p| p.name == "alpha").unwrap();
|
|
902
|
+
assert_eq!(alpha.ide.as_deref(), Some("code"));
|
|
903
|
+
assert!(alpha.last_opened.is_some());
|
|
904
|
+
|
|
905
|
+
key(&mut a, KeyCode::Esc);
|
|
906
|
+
assert!(a.done);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
#[test]
|
|
910
|
+
fn ctrl_o_in_the_editor_list_opens_with_that_editor_and_stays() {
|
|
911
|
+
let cfg = three_ides();
|
|
912
|
+
let (mut a, rec) = app(&cfg);
|
|
913
|
+
type_str(&mut a, "beta");
|
|
914
|
+
key(&mut a, KeyCode::Right);
|
|
915
|
+
key(&mut a, KeyCode::Down); // "code"
|
|
916
|
+
ctrl(&mut a, 'o');
|
|
917
|
+
assert_eq!(rec.opened(), vec![("beta".into(), "code".into())]);
|
|
918
|
+
assert!(!a.done);
|
|
919
|
+
assert!(a.stage == Stage::Projects, "back to the project list");
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
#[test]
|
|
923
|
+
fn keep_open_setting_swaps_enter_and_ctrl_o() {
|
|
924
|
+
let cfg = Config {
|
|
925
|
+
keep_open: true,
|
|
926
|
+
..three_ides()
|
|
927
|
+
};
|
|
928
|
+
let (mut a, rec) = app(&cfg);
|
|
929
|
+
type_str(&mut a, "alpha");
|
|
930
|
+
key(&mut a, KeyCode::Enter);
|
|
931
|
+
assert!(!a.done, "Enter stays open with keep_open");
|
|
932
|
+
ctrl(&mut a, 'o');
|
|
933
|
+
assert!(a.done, "Ctrl-o quits with keep_open");
|
|
934
|
+
assert_eq!(rec.opened().len(), 2);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
#[test]
|
|
938
|
+
fn a_failed_open_keeps_the_picker_open_and_says_why() {
|
|
939
|
+
let cfg = three_ides();
|
|
940
|
+
let (mut a, rec) = app(&cfg);
|
|
941
|
+
rec.fail_open.set(true);
|
|
942
|
+
type_str(&mut a, "alpha");
|
|
943
|
+
key(&mut a, KeyCode::Enter);
|
|
944
|
+
assert!(!a.done);
|
|
945
|
+
assert_eq!(a.status.as_deref(), Some("command not found: code"));
|
|
946
|
+
assert!(rec.opened().is_empty());
|
|
947
|
+
// Nothing is remembered for a launch that did not happen.
|
|
948
|
+
assert_eq!(a.current().unwrap().ide, None);
|
|
949
|
+
assert_eq!(a.current().unwrap().last_opened, None);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
#[test]
|
|
953
|
+
fn raw_control_characters_act_as_ctrl_keys_and_never_enter_the_search() {
|
|
954
|
+
let cfg = three_ides();
|
|
955
|
+
let (mut a, rec) = app(&cfg);
|
|
956
|
+
type_str(&mut a, "alpha");
|
|
957
|
+
// ^O as a bare 0x0F, the way some terminals deliver it.
|
|
958
|
+
a.on_key(KeyCode::Char('\x0f'), KeyModifiers::NONE);
|
|
959
|
+
assert!(!a.done);
|
|
960
|
+
assert_eq!(rec.opened(), vec![("alpha".into(), "code".into())]);
|
|
961
|
+
// ^U (0x15) clears the search; nothing invisible is left behind.
|
|
962
|
+
a.on_key(KeyCode::Char('\x15'), KeyModifiers::NONE);
|
|
963
|
+
assert_eq!(a.query, "");
|
|
964
|
+
type_str(&mut a, "gamma");
|
|
965
|
+
assert_eq!(a.query, "gamma");
|
|
966
|
+
assert_eq!(a.current().unwrap().name, "gamma");
|
|
967
|
+
// A control character that means nothing is simply ignored.
|
|
968
|
+
a.on_key(KeyCode::Char('\x07'), KeyModifiers::NONE);
|
|
969
|
+
assert_eq!(a.query, "gamma");
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
#[test]
|
|
973
|
+
fn shortcuts_work_with_a_russian_keyboard_layout() {
|
|
974
|
+
let cfg = three_ides();
|
|
975
|
+
let (mut a, rec) = app(&cfg);
|
|
976
|
+
type_str(&mut a, "alpha");
|
|
977
|
+
// Ctrl+O with the Russian layout active arrives as Ctrl+щ.
|
|
978
|
+
a.on_key(KeyCode::Char('щ'), KeyModifiers::CONTROL);
|
|
979
|
+
assert_eq!(rec.opened(), vec![("alpha".into(), "code".into())]);
|
|
980
|
+
assert!(!a.done);
|
|
981
|
+
// Ctrl+у is Ctrl+e: switch editor.
|
|
982
|
+
a.on_key(KeyCode::Char('у'), KeyModifiers::CONTROL);
|
|
983
|
+
assert_eq!(a.current().unwrap().ide.as_deref(), Some("pycharm"));
|
|
984
|
+
// Ctrl+г is Ctrl+u: clear the search.
|
|
985
|
+
a.on_key(KeyCode::Char('г'), KeyModifiers::CONTROL);
|
|
986
|
+
assert_eq!(a.query, "");
|
|
987
|
+
// Plain Cyrillic typing still searches (project names may be Russian).
|
|
988
|
+
type_str(&mut a, "щ");
|
|
989
|
+
assert_eq!(a.query, "щ");
|
|
990
|
+
assert_eq!(latin_key('Щ'), 'o');
|
|
991
|
+
assert_eq!(latin_key('x'), 'x');
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
#[test]
|
|
995
|
+
fn the_hint_line_describes_the_keys_for_the_current_setting() {
|
|
996
|
+
let cfg = three_ides();
|
|
997
|
+
let (mut a, _) = app(&cfg);
|
|
998
|
+
let s = screen(&mut a);
|
|
999
|
+
assert!(s.contains("⏎ open ") && s.contains("^o open, stay"), "{s}");
|
|
1000
|
+
|
|
1001
|
+
let cfg = Config {
|
|
1002
|
+
keep_open: true,
|
|
1003
|
+
..three_ides()
|
|
1004
|
+
};
|
|
1005
|
+
let (mut a, _) = app(&cfg);
|
|
1006
|
+
let s = screen(&mut a);
|
|
1007
|
+
assert!(
|
|
1008
|
+
s.contains("⏎ open, stay") && s.contains("^o open, quit"),
|
|
1009
|
+
"{s}"
|
|
1010
|
+
);
|
|
750
1011
|
}
|
|
751
1012
|
|
|
752
1013
|
#[test]
|
|
753
1014
|
fn ctrl_e_cycles_editors_saves_each_and_wraps_to_default() {
|
|
754
1015
|
let cfg = three_ides();
|
|
755
|
-
let (mut a,
|
|
1016
|
+
let (mut a, rec) = app(&cfg);
|
|
756
1017
|
type_str(&mut a, "alpha");
|
|
757
1018
|
|
|
758
1019
|
ctrl(&mut a, 'e');
|
|
@@ -762,27 +1023,29 @@ mod tests {
|
|
|
762
1023
|
ctrl(&mut a, 'e'); // back to the global default, stored as "not set"
|
|
763
1024
|
assert_eq!(a.current().unwrap().ide, None);
|
|
764
1025
|
|
|
765
|
-
|
|
766
|
-
|
|
1026
|
+
assert_eq!(
|
|
1027
|
+
rec.ide_log(),
|
|
1028
|
+
vec![Some("pycharm".into()), Some("vim".into()), None]
|
|
1029
|
+
);
|
|
767
1030
|
}
|
|
768
1031
|
|
|
769
1032
|
#[test]
|
|
770
1033
|
fn enter_after_ctrl_e_opens_the_chosen_editor() {
|
|
771
1034
|
let cfg = three_ides();
|
|
772
|
-
let (mut a,
|
|
1035
|
+
let (mut a, rec) = app(&cfg);
|
|
773
1036
|
type_str(&mut a, "alpha");
|
|
774
1037
|
ctrl(&mut a, 'e');
|
|
775
1038
|
key(&mut a, KeyCode::Enter);
|
|
776
|
-
assert_eq!(
|
|
1039
|
+
assert_eq!(rec.opened(), vec![("alpha".into(), "pycharm".into())]);
|
|
777
1040
|
}
|
|
778
1041
|
|
|
779
1042
|
#[test]
|
|
780
1043
|
fn ctrl_e_with_one_editor_explains_instead_of_changing() {
|
|
781
1044
|
let cfg = Config::default();
|
|
782
|
-
let (mut a,
|
|
1045
|
+
let (mut a, rec) = app(&cfg);
|
|
783
1046
|
ctrl(&mut a, 'e');
|
|
784
1047
|
assert!(a.status.as_deref().unwrap().contains("only one editor"));
|
|
785
|
-
assert!(
|
|
1048
|
+
assert!(rec.ide_log().is_empty());
|
|
786
1049
|
}
|
|
787
1050
|
|
|
788
1051
|
#[test]
|
|
@@ -791,17 +1054,17 @@ mod tests {
|
|
|
791
1054
|
ides: vec!["pycharm".into(), "code".into(), "vim".into()],
|
|
792
1055
|
..Config::default()
|
|
793
1056
|
};
|
|
794
|
-
let mut a = app_with_workspace(&cfg);
|
|
1057
|
+
let (mut a, rec) = app_with_workspace(&cfg);
|
|
795
1058
|
type_str(&mut a, "shop");
|
|
796
1059
|
// Enter picks the first editor that can open it: "code", not "pycharm".
|
|
797
1060
|
key(&mut a, KeyCode::Right);
|
|
798
1061
|
assert_eq!(a.editors, vec!["code"]);
|
|
799
1062
|
key(&mut a, KeyCode::Esc);
|
|
800
1063
|
key(&mut a, KeyCode::Enter);
|
|
801
|
-
assert_eq!(
|
|
1064
|
+
assert_eq!(rec.opened(), vec![("shop".into(), "code".into())]);
|
|
802
1065
|
|
|
803
1066
|
// The folder next to it still gets everything.
|
|
804
|
-
let mut a = app_with_workspace(&cfg);
|
|
1067
|
+
let (mut a, _) = app_with_workspace(&cfg);
|
|
805
1068
|
type_str(&mut a, "alpha");
|
|
806
1069
|
key(&mut a, KeyCode::Right);
|
|
807
1070
|
assert_eq!(a.editors, vec!["pycharm", "code", "vim"]);
|
|
@@ -813,10 +1076,10 @@ mod tests {
|
|
|
813
1076
|
ides: vec!["pycharm".into(), "vim".into()],
|
|
814
1077
|
..Config::default()
|
|
815
1078
|
};
|
|
816
|
-
let mut a = app_with_workspace(&cfg);
|
|
1079
|
+
let (mut a, rec) = app_with_workspace(&cfg);
|
|
817
1080
|
type_str(&mut a, "shop");
|
|
818
1081
|
key(&mut a, KeyCode::Enter);
|
|
819
|
-
assert!(a.done.
|
|
1082
|
+
assert!(!a.done && rec.opened().is_empty());
|
|
820
1083
|
let msg = a.status.clone().unwrap();
|
|
821
1084
|
assert!(
|
|
822
1085
|
msg.contains("VS Code workspace") && msg.contains("vscode"),
|
|
@@ -832,7 +1095,7 @@ mod tests {
|
|
|
832
1095
|
ides: vec!["code".into(), "pycharm".into(), "codium".into()],
|
|
833
1096
|
..Config::default()
|
|
834
1097
|
};
|
|
835
|
-
let mut a = app_with_workspace(&cfg);
|
|
1098
|
+
let (mut a, _) = app_with_workspace(&cfg);
|
|
836
1099
|
type_str(&mut a, "shop");
|
|
837
1100
|
ctrl(&mut a, 'e');
|
|
838
1101
|
assert_eq!(a.current().unwrap().ide.as_deref(), Some("codium")); // skips pycharm
|
|
@@ -843,17 +1106,11 @@ mod tests {
|
|
|
843
1106
|
#[test]
|
|
844
1107
|
fn workspaces_are_marked_in_the_list_and_described_in_details() {
|
|
845
1108
|
let cfg = three_ides();
|
|
846
|
-
let mut a = app_with_workspace(&cfg);
|
|
847
|
-
let mut t = Terminal::new(TestBackend::new(96, 24)).unwrap();
|
|
1109
|
+
let (mut a, _) = app_with_workspace(&cfg);
|
|
848
1110
|
type_str(&mut a, "shop");
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)).collect::<Vec<_>>())
|
|
853
|
-
.map(|(x, y)| buf[(x, y)].symbol().to_string())
|
|
854
|
-
.collect();
|
|
855
|
-
assert!(screen.contains('◇'), "list marker missing");
|
|
856
|
-
assert!(screen.contains("VS Code workspace"), "type row missing");
|
|
1111
|
+
let s = screen(&mut a);
|
|
1112
|
+
assert!(s.contains('◇'), "list marker missing");
|
|
1113
|
+
assert!(s.contains("VS Code workspace"), "type row missing");
|
|
857
1114
|
}
|
|
858
1115
|
|
|
859
1116
|
#[test]
|
|
@@ -874,16 +1131,10 @@ mod tests {
|
|
|
874
1131
|
fn dump_screens() {
|
|
875
1132
|
let cfg = three_ides();
|
|
876
1133
|
let (mut a, _) = app(&cfg);
|
|
877
|
-
let mut t = Terminal::new(TestBackend::new(96, 24)).unwrap();
|
|
878
1134
|
for step in 0..2 {
|
|
879
|
-
t.draw(|f| a.draw(f)).unwrap();
|
|
880
|
-
let buf = t.backend().buffer().clone();
|
|
881
1135
|
println!("--- screen {step} ---");
|
|
882
|
-
for
|
|
883
|
-
|
|
884
|
-
.map(|x| buf[(x, y)].symbol().to_string())
|
|
885
|
-
.collect();
|
|
886
|
-
println!("{}", row.trim_end());
|
|
1136
|
+
for line in screen(&mut a).lines() {
|
|
1137
|
+
println!("{}", line.trim_end());
|
|
887
1138
|
}
|
|
888
1139
|
key(&mut a, KeyCode::Right);
|
|
889
1140
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|