mnml-rs 0.2.13

A NvChad-style terminal IDE in Rust — vim or standard editing, LSP, git, and an embedded HTTP client.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
//! Playwright runner + flaky-test dashboard + trace viewer.
//!
//! Extracted from `app/mod.rs` in the file-split refactor
//!. Pure non-destructive move: no API
//! change. Owns the `test.*` palette commands, the `Pane::Tests` /
//! `Pane::Trace` / `Pane::Flaky` lifecycle, and the heal-with-AI
//! handoffs into a `Pane::Ai`.

use super::*;

impl App {
    /// Build a fresh [`crate::playwright::flaky_pane::FlakyPane`] from the
    /// current [`crate::playwright::history::TestHistory`].
    fn build_flaky_pane(&self) -> crate::playwright::flaky_pane::FlakyPane {
        let ws = self.workspace.clone();
        let rows = self.test_history.wobbly_tests();
        crate::playwright::flaky_pane::FlakyPane::build(rows, move |rel| ws.join(rel))
    }

    /// `flaky.show` — open the flaky-test dashboard (or refocus + refresh
    /// the one that's already open) in a split below the focused leaf.
    pub fn open_flaky_pane(&mut self) {
        if let Some(id) = self.panes.iter().position(|p| matches!(p, Pane::Flaky(_))) {
            let fresh = self.build_flaky_pane();
            if let Some(Pane::Flaky(f)) = self.panes.get_mut(id) {
                f.items = fresh.items;
                f.clamp();
            }
            self.reveal_pane(id);
            return;
        }
        let pane = Pane::Flaky(self.build_flaky_pane());
        match self.active {
            Some(cur) => {
                let new_id = self.split_leaf_with(cur, crate::layout::SplitDir::Vertical, pane);
                self.active = Some(new_id);
            }
            None => {
                self.panes.push(pane);
                let id = self.panes.len() - 1;
                *self.layout_mut() = Layout::leaf(id);
                self.active = Some(id);
            }
        }
        self.focus = Focus::Pane;
    }

    /// Rebuild the item list of any open flaky panes (called after each test
    /// run, or on the pane's `r` key).
    pub fn refresh_flaky_panes(&mut self) {
        if !self.panes.iter().any(|p| matches!(p, Pane::Flaky(_))) {
            return;
        }
        let fresh = self.build_flaky_pane();
        for pane in &mut self.panes {
            if let Pane::Flaky(f) = pane {
                f.items = fresh.items.clone();
                f.clamp();
            }
        }
    }

    pub fn move_flaky_selection(&mut self, delta: isize) {
        if let Some(Pane::Flaky(f)) = self.active.and_then(|i| self.panes.get_mut(i)) {
            f.move_selection(delta);
        }
    }

    /// Open the highlighted test's file and place the cursor on its line.
    pub fn jump_to_selected_flaky(&mut self) {
        let target = match self.active.and_then(|i| self.panes.get(i)) {
            Some(Pane::Flaky(f)) => f.selected_item().map(|it| (it.path.clone(), it.line)),
            _ => None,
        };
        let Some((path, line)) = target else {
            return;
        };
        self.open_path(&path);
        if let Some(b) = self.active_editor_mut() {
            b.editor.place_cursor(line as usize, 0);
        }
    }

    /// Open a `Pane::Tests` and kick off `npx playwright test --reporter=json
    /// <extra_args>` on a worker thread (`tick` delivers the results).
    fn run_playwright(&mut self, extra_args: Vec<String>) {
        let job_id = self.next_job_id;
        self.next_job_id += 1;
        let tx = self
            .tests_chan
            .get_or_insert_with(std::sync::mpsc::channel)
            .0
            .clone();
        let ws = self.workspace.clone();
        let args = extra_args.clone();
        std::thread::spawn(move || {
            let _ = tx.send((job_id, crate::playwright::run(&ws, &args)));
        });
        // Re-use an existing tests pane if there is one; else open a split.
        if let Some(id) = self.panes.iter().position(|p| matches!(p, Pane::Tests(_))) {
            if let Some(Pane::Tests(t)) = self.panes.get_mut(id) {
                t.state = crate::playwright::TestsState::Running;
                t.last_args = extra_args;
                t.job_id = job_id;
                t.scroll = 0;
                t.selected = 0;
            }
            // Right-panel v5 — re-host the existing tests pane in
            // the strip if it's already a panel tab; otherwise go
            // through the layout-tree reveal.
            if let Some(idx) = self.right_panel_panes.iter().position(|&pid| pid == id) {
                self.right_panel_active_idx = idx;
            } else {
                self.reveal_pane(id);
            }
            return;
        }
        let pane = Pane::Tests(crate::playwright::TestsPane::new(
            self.workspace.clone(),
            extra_args,
            job_id,
        ));
        // Right-panel v5: host in the panel as a new tab when it's
        // visible. Tests rows (test name + status) read fine at the
        // 32-cell default width.
        if self.right_panel_visible {
            self.panes.push(pane);
            let new_id = self.panes.len() - 1;
            self.right_panel_push(new_id);
            return;
        }
        match self.active {
            Some(cur) => {
                let new_id = self.split_leaf_with(cur, crate::layout::SplitDir::Vertical, pane);
                self.active = Some(new_id);
            }
            None => {
                self.panes.push(pane);
                let id = self.panes.len() - 1;
                *self.layout_mut() = Layout::leaf(id);
                self.active = Some(id);
            }
        }
        self.focus = Focus::Pane;
    }

    /// Open a pty pane running `cargo <subcmd>` in the workspace.
    /// Used by the cargo.* family of palette commands. Toasts when
    /// no Cargo.toml is found in the workspace or any parent.
    pub fn run_cargo_subcommand(&mut self, subcmd: &str) {
        let slug = subcmd.split_whitespace().next().unwrap_or(subcmd);
        self.run_manifest_command("Cargo.toml", "cargo", slug, subcmd);
    }

    /// Open a prompt for an npm script name, then run
    /// `npm run <script>` in a pty pane. 2026-06-21 multilang SEV-3
    /// fix for `:npm.run` being hardcoded to `npm run dev`.
    pub fn open_npm_run_script_prompt(&mut self) {
        // qa-7th multilang SEV-2 2026-06-30 — was walking from
        // self.workspace, which fails in a monorepo where
        // package.json lives only under packages/<app>/. Match
        // the integration runners (run_package_manager_command at
        // playwright.rs:407) and walk from the active editor's
        // directory first.
        let start_dir = self
            .most_recent_editor_path()
            .and_then(|p| p.parent())
            .map(|p| p.to_path_buf())
            .unwrap_or_else(|| self.workspace.clone());
        let pkg = find_manifest_dir(&start_dir, &["package.json"], &self.workspace);
        if pkg.is_none() {
            self.toast("npm.run_script: no package.json found");
            return;
        }
        self.prompt = Some(crate::prompt::Prompt::new(
            crate::prompt::PromptKind::NpmRunScript,
            "npm run: script name".to_string(),
        ));
    }

    /// Accept handler for `:npm.run_script` — fires the pty.
    pub fn npm_run_script_accept(&mut self, script: String) {
        let script = script.trim().to_string();
        if script.is_empty() {
            self.toast("npm.run_script: empty script name");
            return;
        }
        self.run_npm_subcommand(&script, &format!("run {script}"));
    }

    /// `:go.run_path` — prompt for a package path, then run
    /// `go run <path>`. Most non-trivial Go projects put main in
    /// `cmd/<app>/main.go` rather than the module root; the bare
    /// `:go.run` (hardcoded `.`) is wrong for those.
    pub fn open_go_run_path_prompt(&mut self) {
        if find_manifest_dir(&self.workspace, &["go.mod"], &self.workspace).is_none() {
            self.toast("go.run_path: no go.mod found");
            return;
        }
        self.prompt = Some(crate::prompt::Prompt::seeded(
            crate::prompt::PromptKind::GoRunPath,
            "go run: package path",
            "./",
        ));
    }

    /// Accept handler for `:go.run_path`.
    pub fn go_run_path_accept(&mut self, path: String) {
        let path = path.trim().to_string();
        if path.is_empty() {
            self.toast("go.run_path: empty path");
            return;
        }
        self.run_go_subcommand(&format!("run {path}"));
    }

    /// `npm <subcmd>` (test / run dev / build / start / install /
    /// lint). Requires a package.json at the workspace root.
    pub fn run_npm_subcommand(&mut self, slug: &str, subcmd: &str) {
        // multilang 3rd 2026-06-28 F6: if the subcmd is "run X",
        // verify the script exists in the nearest package.json so
        // we toast a friendly "missing script" message instead of
        // letting npm fail inside the pty (which the user has to
        // scroll to see).
        if let Some(script_name) = subcmd.strip_prefix("run ").map(|s| s.trim()) {
            let start_dir = self
                .active_editor()
                .and_then(|b| b.path.as_ref())
                .and_then(|p| p.parent())
                .map(|p| p.to_path_buf())
                .unwrap_or_else(|| self.workspace.clone());
            if let Some(pkg_dir) = find_manifest_dir(&start_dir, &["package.json"], &self.workspace)
                && let Ok(contents) = std::fs::read_to_string(pkg_dir.join("package.json"))
                && let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents)
                && let Some(scripts) = json.get("scripts").and_then(|s| s.as_object())
                && !scripts.contains_key(script_name)
            {
                let names: Vec<&str> = scripts.keys().map(String::as_str).collect();
                let preview = if names.is_empty() {
                    "(none defined)".to_string()
                } else {
                    names.join(" / ")
                };
                self.toast(format!(
                    "npm.{slug}: no `{script_name}` script in package.json — available: {preview}"
                ));
                return;
            }
        }
        self.run_manifest_command("package.json", "npm", slug, subcmd);
    }

    /// `pytest <args>`. Requires pyproject.toml OR setup.py OR
    /// a `tests/` dir that actually contains `test_*.py` files
    /// (a bare `tests/` dir is not enough — common in Rust
    /// repos, where it'd false-positive into "this is a Python
    /// project" and spawn pytest against Rust code).
    pub fn run_pytest(&mut self, args: &str) {
        // multilang 3rd 2026-06-28 SEV-2: also detect requirements.txt
        // (matches pyright's LSP root_markers). Was emitting a
        // "no pyproject.toml / setup.py / test files" toast on
        // requirements.txt-only projects while the LSP cheerfully
        // attached — contradictory signal.
        let root = find_manifest_dir(
            &self.workspace,
            &["pyproject.toml", "setup.py", "requirements.txt"],
            &self.workspace,
        )
        .unwrap_or_else(|| self.workspace.clone());
        let has_pyproject = root.join("pyproject.toml").exists();
        let has_setup = root.join("setup.py").exists();
        let has_requirements = root.join("requirements.txt").exists();
        // multilang-dev-user F7 — accept tests in `tests/` or `test/`
        // and walk one level deep (so `tests/unit/test_foo.py` counts)
        // and also accept `*_test.py` suffix. Old version was too
        // shallow and rejected common Python layouts.
        let is_pytest_file = |name: &str| -> bool {
            (name.starts_with("test_") && name.ends_with(".py")) || name.ends_with("_test.py")
        };
        let dir_has_pytest_file = |dir: std::path::PathBuf| -> bool {
            let Ok(rd) = std::fs::read_dir(&dir) else {
                return false;
            };
            for entry in rd.filter_map(|e| e.ok()) {
                let n = entry.file_name();
                let s = n.to_string_lossy();
                if is_pytest_file(&s) {
                    return true;
                }
                // One-level recurse so `tests/unit/test_x.py` matches.
                if entry.file_type().map(|t| t.is_dir()).unwrap_or(false)
                    && let Ok(sub_rd) = std::fs::read_dir(entry.path())
                {
                    for sub in sub_rd.filter_map(|e| e.ok()) {
                        let sn = sub.file_name();
                        if is_pytest_file(&sn.to_string_lossy()) {
                            return true;
                        }
                    }
                }
            }
            false
        };
        let has_real_tests = ["tests", "test"]
            .iter()
            .any(|d| dir_has_pytest_file(root.join(d)));
        if !has_pyproject && !has_setup && !has_requirements && !has_real_tests {
            self.toast(format!(
                "pytest: no pyproject.toml / setup.py / requirements.txt / test files at {}",
                self.workspace.display()
            ));
            return;
        }
        let cmdline = if args.is_empty() {
            "pytest".to_string()
        } else {
            format!("pytest {args}")
        };
        let label = cmdline.clone();
        let profile = crate::pty_pane::BinaryProfile::task(&label, &cmdline, root);
        self.open_pty(profile);
    }

    /// `go <subcmd>` (test ./... / build / run / vet). Requires
    /// a go.mod somewhere in the ancestor chain.
    ///
    /// 2026-06-21 — `go run` auto-detects `cmd/<app>/` packages
    /// at the module root. Most non-trivial Go projects put main
    /// in `cmd/<app>/main.go` rather than the module root, so
    /// `go run .` is wrong there. Behavior:
    ///   - 0 `cmd/<app>/` dirs → run `go <subcmd>` literally
    ///     (default behavior, covers `go test ./...` etc.).
    ///   - 1 `cmd/<app>/` dir AND subcmd is `run .` → run
    ///     `go run ./cmd/<app>` (auto-pick the only binary).
    ///   - 2+ `cmd/<app>/` dirs AND subcmd is `run .` → open a
    ///     picker over them. Accept fires `go run ./cmd/<pick>`.
    pub fn run_go_subcommand(&mut self, subcmd: &str) {
        if subcmd == "run ." {
            let root = crate::app::playwright::find_manifest_dir(
                &self.workspace,
                &["go.mod"],
                &self.workspace,
            );
            if let Some(root) = root {
                let cmd_dir = root.join("cmd");
                let entries: Vec<std::path::PathBuf> = std::fs::read_dir(&cmd_dir)
                    .ok()
                    .into_iter()
                    .flatten()
                    .filter_map(|e| e.ok())
                    .filter(|e| e.path().is_dir())
                    .map(|e| e.path())
                    .collect();
                match entries.len() {
                    0 => {} // fall through to default `go run .`
                    1 => {
                        let app = entries[0]
                            .file_name()
                            .unwrap()
                            .to_string_lossy()
                            .to_string();
                        return self.run_manifest_command(
                            "go.mod",
                            "go",
                            "run",
                            &format!("run ./cmd/{app}"),
                        );
                    }
                    _ => {
                        use crate::picker::{Picker, PickerItem, PickerKind};
                        let items: Vec<PickerItem> = entries
                            .iter()
                            .map(|p| {
                                let name = p.file_name().unwrap().to_string_lossy().to_string();
                                PickerItem::new(name.clone(), format!("cmd/{name}"), name)
                            })
                            .collect();
                        self.open_picker(Picker::new(
                            PickerKind::GoRunCmd,
                            "go run: pick a cmd/<app>",
                            items,
                        ));
                        return;
                    }
                }
            }
        }
        let slug = subcmd.split_whitespace().next().unwrap_or(subcmd);
        self.run_manifest_command("go.mod", "go", slug, subcmd);
    }

    /// `slug` is the command's toast identity (e.g. `build` for `npm.build`,
    /// which runs `npm run build`) — it can't be derived from `subcmd`
    /// reliably, since `npm.run`→"run dev" wants "run" but `npm.build`→
    /// "run build" wants "build".
    pub(crate) fn run_manifest_command(
        &mut self,
        manifest: &str,
        bin: &str,
        slug: &str,
        subcmd: &str,
    ) {
        // 2026-06-21 multilang+lsp-cheat-test SEV-2: was checking
        // only `self.workspace.join(manifest)`, so subdir of a
        // monorepo (e.g. `/repo/cmd/server` with go.mod at /repo/)
        // got "no manifest" even though Go itself would have
        // found one. Walk up until we hit a manifest or the
        // filesystem root.
        //
        // multilang 3rd 2026-06-28 SEV-2: in a pnpm/yarn monorepo
        // with package.json at the root AND a per-package
        // package.json, walking from `self.workspace` always picks
        // the root one — running `npm test` at the monorepo root
        // instead of in the user's currently-edited package. Walk
        // from the ACTIVE editor's directory first when there's an
        // open file; fall back to workspace for non-pane focus.
        // multilang 3rd 2026-06-28 SEV-2: use most_recent_editor_path,
        // not just active_editor. After the first runner command opens
        // a pty pane (npm.test → pty pane → app.active = pty), the
        // active_editor returns None and a follow-up runner command
        // would fall back to self.workspace, losing the monorepo
        // sub-package context.
        let start_dir = self
            .most_recent_editor_path()
            .and_then(|p| p.parent())
            .map(|p| p.to_path_buf())
            .unwrap_or_else(|| self.workspace.clone());
        let root = find_manifest_dir(&start_dir, &[manifest], &self.workspace)
            .unwrap_or_else(|| self.workspace.clone());
        if !root.join(manifest).exists() {
            self.toast(format!(
                "{bin}.{slug}: no {manifest} found in {} or any parent",
                self.workspace.display()
            ));
            return;
        }
        let label = format!("{bin} {subcmd}");
        let cmdline = format!("{bin} {subcmd}");
        let profile = crate::pty_pane::BinaryProfile::task(&label, &cmdline, root);
        self.open_pty(profile);
        // Record for the statusline `🧪` chip — `open_pty` appends
        // to `panes`, so the last index is this new pty.
        if !self.panes.is_empty() {
            self.last_test_run = Some((label, self.panes.len() - 1));
        }
    }

    /// `test.run_all` — the whole Playwright suite.
    pub fn run_tests_all(&mut self) {
        self.run_playwright(Vec::new());
    }

    /// `test.run_file` — the active editor's spec file.
    pub fn run_tests_file(&mut self) {
        match self.active_editor().and_then(|b| b.path.as_deref()) {
            Some(p) => {
                let rel = rel_path(&self.workspace, p);
                self.run_playwright(vec![rel]);
            }
            None => self.toast("open a .spec file first"),
        }
    }

    /// `test.run_at_cursor` — the test at the cursor (Playwright's `file:line` selector).
    pub fn run_tests_at_cursor(&mut self) {
        match self.active_editor() {
            Some(b) => match &b.path {
                Some(p) => {
                    let rel = rel_path(&self.workspace, p);
                    let line = b.editor.row_col().0 + 1;
                    self.run_playwright(vec![format!("{rel}:{line}")]);
                }
                None => self.toast("open a saved .spec file first"),
            },
            None => self.toast("open a .spec file first"),
        }
    }

    /// `test.rerun_failed` — re-run just the failures of the last run (Playwright's `--last-failed`).
    pub fn rerun_failed_tests(&mut self) {
        self.run_playwright(vec!["--last-failed".to_string()]);
    }

    /// `r` in a tests pane — re-run with the same args as last time.
    pub fn rerun_active_tests(&mut self) {
        let args = match self.active.and_then(|i| self.panes.get(i)) {
            Some(Pane::Tests(t)) => t.last_args.clone(),
            _ => return,
        };
        self.run_playwright(args);
    }

    /// `t` in a tests pane — toasts a hint to run
    /// `mnml-test-playwright <path>` in any shell pane manually.
    pub fn open_selected_test_trace(&mut self) {
        self.toast("trace viewer: run `mnml-test-playwright <path>` in a shell");
    }

    /// `test.heal` (`h` in a tests pane) — hand the highlighted *failing* test (its
    /// title, file, error, and the spec source) to `claude -p` and ask for a fix.
    /// Reuses the AI machinery; `c` in the resulting `Pane::Ai` promotes it to an
    /// interactive Claude Code session (which can actually apply the fix / call
    /// your healer agent).
    pub fn heal_selected_test(&mut self) {
        let info = match self.active.and_then(|i| self.panes.get(i)) {
            Some(Pane::Tests(t)) => match t.selected_test() {
                Some(tc) if tc.status == crate::playwright::TestStatus::Failed => Some((
                    tc.title.clone(),
                    tc.suite_path.clone(),
                    tc.file.clone(),
                    tc.line,
                    tc.error.clone().unwrap_or_default(),
                )),
                Some(_) => {
                    self.toast("that test isn't failing — nothing to heal");
                    None
                }
                None => None,
            },
            _ => {
                self.toast("select a failing test in the results pane first");
                None
            }
        };
        let Some((title, suite, file, line, error)) = info else {
            return;
        };
        let src = std::fs::read_to_string(self.workspace.join(&file)).unwrap_or_default();
        let where_ = if suite.is_empty() {
            format!("{file}:{line}")
        } else {
            format!("{suite}{title}  ({file}:{line})")
        };
        let prompt = format!(
            "This Playwright test is failing. Work out why and propose a fix — change the \
             test or the code under test as appropriate. Be concise; reply with the patch in a \
             fenced block plus a short note.\n\n## Failing test\n{where_}\n\n## Error\n```\n{error}\n```\n\n## {file}\n```ts\n{src}\n```"
        );
        self.ask_ai(format!("AI: heal {title}"), prompt);
    }

    /// Stub kept after the Trace pane moved out — `heal_from_active_trace`
    /// used to read the trace events from a `Pane::Trace`, but those
    /// live in the standalone mnml-test-playwright now. The command surface
    /// is preserved as a no-op toast.
    pub fn heal_from_active_trace(&mut self) {
        self.toast(
            "trace-driven heal moved with the trace viewer to mnml-test-playwright; \
             use `tests.heal` (`h` on the test row) for the spec-only heal flow",
        );
    }

    /// Jump the editor to the source of the highlighted test in a `Pane::Tests`.
    pub fn jump_to_selected_test(&mut self) {
        let Some(cur) = self.active else { return };
        let (rel, line) = match self.panes.get(cur) {
            Some(Pane::Tests(t)) => match t.selected_test() {
                Some(tc) if !tc.file.is_empty() => {
                    (tc.file.clone(), tc.line.saturating_sub(1) as usize)
                }
                _ => return,
            },
            _ => return,
        };
        let path = self.workspace.join(&rel);
        if let Some(id) = self
            .panes
            .iter()
            .position(|p| matches!(p, Pane::Editor(b) if b.is_at(&path)))
        {
            if let Some(Pane::Editor(b)) = self.panes.get_mut(id) {
                b.editor.place_cursor(line, 0);
            }
            self.active = Some(id);
            self.focus = Focus::Pane;
        } else {
            self.open_path(&path);
            if let Some(Pane::Editor(b)) = self.active.and_then(|i| self.panes.get_mut(i)) {
                b.editor.place_cursor(line, 0);
            }
        }
    }

    /// Move the highlighted-test cursor in a `Pane::Tests`.
    pub fn tests_move_selection(&mut self, delta: isize) {
        if let Some(Pane::Tests(t)) = self.active.and_then(|i| self.panes.get_mut(i))
            && let crate::playwright::TestsState::Done(r) = &t.state
        {
            let n = r.tests.len();
            if n == 0 {
                return;
            }
            let new = (t.selected as isize + delta).clamp(0, n as isize - 1) as usize;
            t.selected = new;
        }
    }

    pub(super) fn drain_tests_jobs(&mut self) {
        use crate::playwright::TestsState;
        let Some((_, rx)) = &self.tests_chan else {
            return;
        };
        let done: Vec<TestsJobDone> = rx.try_iter().collect();
        let mut toasts: Vec<String> = Vec::new();
        let mut refresh_flaky = false;
        for (job_id, result) in done {
            let Some(Pane::Tests(t)) = self.panes.iter_mut().find(
                |p| matches!(p, Pane::Tests(t) if t.job_id == job_id && matches!(t.state, TestsState::Running)),
            ) else {
                continue;
            };
            match result {
                Ok(run) => {
                    let (p, f, s) = (run.passed(), run.failed(), run.skipped());
                    toasts.push(if f > 0 {
                        format!(
                            "tests: {f} failed, {p} passed{}",
                            if s > 0 {
                                format!(", {s} skipped")
                            } else {
                                String::new()
                            }
                        )
                    } else {
                        format!(
                            "tests: all {p} passed{}",
                            if s > 0 {
                                format!(" ({s} skipped)")
                            } else {
                                String::new()
                            }
                        )
                    });
                    t.selected = run
                        .tests
                        .iter()
                        .position(|tc| tc.status == crate::playwright::TestStatus::Failed)
                        .unwrap_or(0);
                    // Update the workspace's persistent test-outcome history so
                    // run-to-run wobbly tests light up with a `≋` glyph.
                    self.test_history.record_run(&run);
                    self.test_history.save(&self.workspace);
                    t.state = TestsState::Done(Box::new(run));
                    // History changed ⇒ any open flaky pane should reflect it.
                    refresh_flaky = true;
                }
                Err(e) => {
                    toasts.push(format!(
                        "playwright: {}",
                        e.lines().next().unwrap_or("error")
                    ));
                    t.state = TestsState::Failed(e);
                }
            }
        }
        for tt in toasts {
            self.toast(tt);
        }
        if refresh_flaky {
            self.refresh_flaky_panes();
        }
    }
}

/// Walk up from `start` until we find a directory containing any
/// of `manifests`, stopping at `workspace` (inclusive) so we never
/// pick up a `package.json` / `go.mod` / `pyproject.toml` from
/// outside the folder the user actually opened. Returns the
/// matching directory or `None`.
///
/// Used by the npm/pytest/cargo/go runners to handle monorepo
/// subdirs the way the tools themselves do (2026-06-21 SEV-2
/// fix). The workspace boundary was added 2026-07-06 after
/// multilang-dev-user SEV-2 flagged that a workspace with no
/// inner manifest but a stray ancestor manifest (e.g. `~/package.json`,
/// a integration scratch project) would silently escape and run the
/// command in the wrong directory.
pub fn find_manifest_dir(
    start: &std::path::Path,
    manifests: &[&str],
    workspace: &std::path::Path,
) -> Option<std::path::PathBuf> {
    let mut cur = start.to_path_buf();
    loop {
        for m in manifests {
            if cur.join(m).exists() {
                return Some(cur);
            }
        }
        // Stop AT the workspace root — searching one level deeper
        // than the opened folder would be a footgun.
        if cur == workspace {
            return None;
        }
        if !cur.pop() {
            return None;
        }
    }
}

#[cfg(test)]
mod playwright_tests {
    use super::*;

    #[test]
    fn find_manifest_dir_stops_at_workspace_root() {
        // Regression for multilang-dev-user 2026-07-06 SEV-2 —
        // `find_manifest_dir` used to walk the FULL path to `/`,
        // silently picking up a `~/package.json` or an integration
        // scratch project's manifest and running commands there.
        // Now it stops at the workspace boundary.
        let outer = tempfile::tempdir().unwrap();
        let ws = outer.path().join("workspace");
        let sub = ws.join("subdir");
        std::fs::create_dir_all(&sub).unwrap();
        // Manifest at the OUTER level (outside the workspace).
        std::fs::write(outer.path().join("package.json"), "{}").unwrap();
        // Without a boundary the walk would find `outer/package.json`.
        assert!(find_manifest_dir(&sub, &["package.json"], &ws).is_none());
        // With a manifest INSIDE the workspace, the walk still finds it.
        std::fs::write(ws.join("package.json"), "{}").unwrap();
        assert_eq!(
            find_manifest_dir(&sub, &["package.json"], &ws).as_deref(),
            Some(ws.as_path())
        );
    }

    #[test]
    fn find_manifest_dir_finds_workspace_root_manifest() {
        // Start point == workspace root — should still find a
        // manifest sitting right there.
        let d = tempfile::tempdir().unwrap();
        std::fs::write(d.path().join("go.mod"), "module x").unwrap();
        assert_eq!(
            find_manifest_dir(d.path(), &["go.mod"], d.path()).as_deref(),
            Some(d.path())
        );
    }
}