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
//! The right-click context menu — a small floating list of actions, anchored at
//! the click. Opened from the file tree (on a file / dir) or a bufferline tab;
//! steals key + mouse input like the picker until dismissed. `App` owns an
//! `Option<ContextMenu>` and maps the chosen [`MenuAction`] to an effect.

use std::path::PathBuf;

use crate::layout::PaneId;

/// What a menu entry does when chosen.
#[derive(Debug, Clone)]
pub enum MenuAction {
    /// Open the file (in the focused leaf).
    OpenPath(PathBuf),
    /// #polish 2026-07-06 — force-open a `.http`/`.curl`/`.rest`
    /// file as a plain text Editor pane (skips the Request-pane
    /// routing in `open_path`). Right-click "Open as text" on
    /// HTTP-panel rows.
    OpenPathAsText(PathBuf),
    /// Open the file in a new split to the right.
    OpenInSplit(PathBuf),
    /// `open -R <path>` (macOS Finder reveal); a no-op elsewhere.
    RevealInFinder(PathBuf),
    /// Hand `path` to the OS's default app — `open` / `xdg-open` / `start`.
    OpenExternally(PathBuf),
    /// Open a shell pty pane with its cwd set to `dir` (the right-clicked
    /// folder, or a right-clicked file's parent folder) — VS Code's
    /// "Open in Integrated Terminal".
    OpenTerminal(PathBuf),
    /// Copy `text` (a workspace-relative path) to the clipboard.
    CopyPath(String),
    /// Promote the right-clicked folder to the primary workspace.
    /// Replaces `App.workspace` + reloads the tree. Surfaced on the
    /// tree's directory-row context menu. User-requested 2026-06-18
    /// for "I opened at ~/Projects, drill into one of these into."
    SetAsWorkspace(PathBuf),
    /// qa-feature 2026-07-01 — recursively expand/collapse a dir + all its
    /// descendants. Surfaced from the tree dir right-click menu; equivalent
    /// to Alt+click on the dir row.
    TreeExpandRecursive(PathBuf),
    TreeCollapseRecursive(PathBuf),
    /// qa-feature 2026-07-01 — remove the currently-primary workspace
    /// (promotes the first extra in position order to primary first, then
    /// drops the demoted old primary). No-op / hidden when there are no
    /// extras, since removing would leave the app with nothing loaded.
    RemovePrimaryWorkspace,
    /// Toggle whether `App.workspace` is the persisted default
    /// (`[startup] default_workspace` in the global config).
    /// #polish 2026-07-06 — written to disk on click.
    SetDefaultWorkspace,
    /// Same as `SetDefaultWorkspace` but for an extra workspace row
    /// — carries the target path so we don't rely on `App.workspace`.
    SetDefaultWorkspaceAt(PathBuf),
    /// Open the integration-edit panel for the integration with the
    /// given id, pre-filled with that entry's glyph/color/tooltip.
    /// Surfaced by the integration-chip right-click menu so users
    /// can tweak a chip without going through the discovery overlay.
    EditIntegration(String),
    /// #1088 (2026-08-19) — per-integration auto-update opt-in.
    /// Writes/rewrites `~/.config/mnml/integrations/<id>.override.toml`
    /// with `auto_update = <bool>`. Wins over the global
    /// `[integrations] auto_update_cargo/git` toggles (see
    /// `integration_updates::effective_auto_update`).
    /// Payload: (id, next-value).
    SetIntegrationAutoUpdate(String, bool),
    /// 2026-07-31 — Open the read-only `Pane::IntegrationDetail`
    /// pane in the right side panel for a specific integration id.
    /// Surfaced on the integration-chip right-click menu ("View
    /// details") + also fires from `integrations.show_details` when
    /// the palette command takes an id.
    ShowIntegrationDetails(String),
    /// #992 (2026-08-18) — surface this integration inside the
    /// activity-bar Marketplace tab. Switches to Integrations panel,
    /// Marketplace tab, and pre-fills the panel filter with the id
    /// so the row scrolls into view. Available on every integration
    /// chip regardless of update state.
    ShowIntegrationInMarketplace(String),
    /// #992 (2026-08-18) — apply an available update for this
    /// integration id (equivalent to clicking the `↑ Update to X`
    /// chip on the marketplace row). Only added to the menu when
    /// `integration_updates` holds a live UpdateCheck reporting the
    /// current version differs from the latest.
    UpdateIntegration(String),
    /// Drop the integration from the rail (config + persist). Same
    /// effect as clicking the chip's row in the discovery overlay
    /// when it's already InRail. Surfaced by the chip right-click
    /// menu's "Remove from rail" entry.
    RemoveIntegration(String),
    /// 2026-07-09 — copy the integration id to the system
    /// clipboard. Useful for pasting into a chord binding, a
    /// palette command, or a integration install script.
    CopyIntegrationId(String),
    /// 2026-07-09 — open the on-disk TOML manifest for the
    /// integration in an editor pane so users can hand-edit
    /// without leaving mnml.
    ShowIntegrationManifest(String),
    /// 2026-08-11 (Phase 2B) — open the per-integration Settings
    /// pane for the integration id, letting the user edit its
    /// declared `[[auth]]` fields (tokens, base URLs, etc.).
    /// Surfaced by the chip right-click menu when the integration
    /// declares at least one auth field.
    ConfigureIntegration(String),
    /// v0.2.0 — set the per-workspace launcher script for an
    /// integration that spawns a binary (`claude_code`, `codex`).
    /// Opens a `PromptKind::IntegrationLauncher` prompt seeded with
    /// the current value; accept writes to
    /// `<workspace>/.mnml/integrations/<id>.toml`.
    SetIntegrationLauncher(String),
    /// #1103 f/u7 (2026-08-20) — spawn `<binary> --diag` as a Pty
    /// pane. Every integration that follows the mnml-bridge SDK
    /// contract supports the `--diag` subcommand; the output is a
    /// human-readable auth / config / runtime tree. Payload is the
    /// integration id — mnml resolves it to `manifest.binary` and
    /// dispatches via `:term <binary> --diag`.
    RunIntegrationDiag(String),
    /// #1102 (2026-08-20) — reorder a dynamic statusline segment
    /// in `[ui] statusline_segment_order`. Payload: (segment_id,
    /// delta) where `delta = -1` moves left, `+1` moves right. The
    /// segment's id is inserted into the order list if not already
    /// present, then swapped with its neighbor at ±1. Persisted via
    /// the same helper other `[ui]` toggles use.
    ReorderStatuslineSegment(String, i8),
    /// Run a registered command by id (e.g. `tree.refresh`).
    Command(&'static str),
    /// R7 vscode-mouse F1 2026-08-09 — set the current mnml theme
    /// to the named one. Surfaced by the bufferline theme-toggle
    /// chip's right-click menu so users can pick a specific theme
    /// without leaving the tab bar (Chrome / Firefox extension menu
    /// idiom). Wraps `App::set_theme(&name)`.
    SetTheme(String),
    /// Open the glyph builder pre-loaded with a specific codepoint
    /// so the user can nudge width / height / center. Surfaced by
    /// the tab-bar AI chip right-click for baseline-drift fixes.
    /// 2026-07-13.
    OpenGlyphBuilderForCp(u32),
    /// #814 — one-tap rebake for a codepoint. Skips the visual
    /// builder: shells out to fontforge with the stored meta
    /// (or the builtin catalog fallback) as-is. Surfaced from the
    /// integration chip right-click menu next to "Bake / tune glyph…"
    /// for when a builtin SVG has been edited on disk and just
    /// needs the font regenerated.
    RebakeGlyphForCp(u32),
    CloseTab(PaneId),
    CloseOtherTabs(PaneId),
    CloseAllTabs,
    /// Right-panel v3 tab right-click "Switch to this tab" — sets
    /// `right_panel_active_idx` directly to the given index instead
    /// of cycling via next_tab. Future-proofs the action when the
    /// 2-tab cap lifts (render-reviewer-4th W-1).
    SetRightPanelTab(usize),
    /// Right-panel v5 polish 2026-06-29: close every panel tab
    /// EXCEPT the one at this idx.
    CloseOtherRightPanelTabs(usize),
    /// Right-panel v5 polish 2026-06-29: close every panel tab.
    CloseAllRightPanelTabs,
    /// Save the specific pane (an editor) without changing focus.
    /// Surfaced from the bufferline tab right-click menu — the
    /// VS-Code-mouse hunt's SEV-2 "no Save button anywhere" finding.
    SavePane(PaneId),
    /// 2026-06-21 — VS Code-style pin / unpin for a specific editor
    /// tab. Pinned tabs sort to the front of the bufferline strip
    /// (📌 glyph) and are immune to Close all / Close others.
    PinTab(PaneId),
    /// Rename a pty session (Claude / Codex / shell) — reveals the
    /// pane, then opens the session-name prompt.
    RenameSession(PaneId),
    /// mouse-round-7 SEV-3 2026-07-12 — right-click on a Pty tab
    /// → kill + respawn the underlying process. Handler switches
    /// `App.active` to the target pane and calls `term.restart` so
    /// the menu can't act on the wrong session.
    PtyRestart(PaneId),
    /// mouse-round-7 SEV-3 2026-07-12 — send Ctrl+C to the pty.
    /// Same routing pattern as `PtyRestart`.
    PtyInterrupt(PaneId),
    /// mouse-round-7 SEV-3 2026-07-12 — send Ctrl+L (clear screen)
    /// to the pty. Same routing pattern as `PtyRestart`.
    PtyClear(PaneId),
    /// Prompt for a name and create an empty file in `parent_dir`.
    NewFile(PathBuf),
    /// Prompt for a name and create an empty directory in `parent_dir`.
    NewFolder(PathBuf),
    /// Prompt for a new name and rename `path` (kept in the same dir).
    Rename(PathBuf),
    /// Prompt for the filename as a confirmation; on exact match, delete
    /// `path` (`rm` for a file, `rm -rf` for a directory).
    Delete(PathBuf),
    /// Stage `path` on `App.file_clipboard` with cut semantics — a
    /// subsequent `FilePaste` will MOVE it into the target dir.
    FileCut(PathBuf),
    /// Stage `path` on `App.file_clipboard` with copy semantics — a
    /// subsequent `FilePaste` DUPLICATES it into the target dir.
    FileCopy(PathBuf),
    /// Paste the clipboard into `target_dir` (or the file's parent if
    /// the click target was a file). Move-vs-copy is decided by
    /// `App.file_clipboard_cut`.
    FilePaste(PathBuf),
    /// Duplicate `path` in place — creates `name-copy.ext` (or
    /// `name-copy-N.ext` when the first collides).
    FileDuplicate(PathBuf),
    /// Open a folder picker; on selection, move `path` into the chosen
    /// folder. Cross-workspace and outside-workspace targets are both
    /// allowed — the underlying rename() enforces same-filesystem.
    FileMoveTo(PathBuf),
    /// Right-click on a `{{VAR}}` token → open the env-value edit
    /// prompt seeded with the current value (empty when undefined).
    /// Accept upserts into the active env file. 2026-07-07.
    SetEnvVarValue(String),
    /// Right-click on a `{{VAR}}` token → same as a left-click
    /// (open the env file at the definition line, or EOF if
    /// undefined) — surfaced as an explicit menu item for
    /// discoverability.
    JumpToEnvVar(String),
    /// Git rail — checkout an existing local branch.
    GitCheckoutBranch(String),
    /// #polish 2026-07-06 — merge the named branch into the
    /// current branch. Uses the existing GitJob::Merge job.
    GitMergeBranchInto(String),
    /// #polish 2026-07-06 — rebase the current branch onto the
    /// named branch. Uses the existing GitJob::Rebase job.
    GitRebaseCurrentOnto(String),
    /// Git rail — prompt for a new branch name (off the named base; first cut
    /// just branches off `HEAD`).
    GitNewBranchFrom(String),
    /// Git rail — confirm + `git branch -D <name>`.
    GitDeleteBranch(String),
    /// Git rail — open a shell pane rooted in the worktree directory.
    GitWorktreeShell(PathBuf),
    /// Git rail — confirm + `git worktree remove <path>`.
    GitWorktreeRemove(PathBuf),
    /// Git palette stash — `git stash pop <id>` (applies + drops).
    GitStashPop(String),
    /// Git palette stash — `git stash apply <id>` (applies, keeps).
    GitStashApply(String),
    /// Git palette stash — confirm + `git stash drop <id>`.
    GitStashDrop(String),
    /// Git palette tag — confirm + `git tag -d <name>`.
    GitTagDelete(String),
    /// Git palette remote-branch — `git checkout <name>` (creates a
    /// local tracking branch). Wraps `App::checkout_branch` which
    /// already handles the remote-ref form.
    GitRemoteCheckout(String),
    /// Sessions panel — open the rename prompt for the pty pane
    /// at `pane_id`. Reuses `PromptKind::PtySessionName`.
    SessionRename(usize),
    /// Sessions panel — set the per-pane accent color to a
    /// named theme color (Green / Blue / Yellow / Orange / Red /
    /// Purple / Cyan / None).
    SessionSetColor(usize, &'static str),
    /// Sessions panel — close (kill child + drop pane) the pty
    /// at `pane_id`.
    SessionClose(usize),
    /// Sessions panel — toggle the pin flag for this session.
    /// Pinned sessions bubble to the top of the panel regardless
    /// of the active sort mode.
    SessionTogglePin(usize),
    /// Sessions panel — move this session up one slot in the
    /// user-controlled manual order (switches the panel out of
    /// Auto mode).
    SessionMoveUp(usize),
    /// Sessions panel — move this session down one slot.
    SessionMoveDown(usize),
    /// Sessions panel — move to the top of the manual order.
    SessionMoveToTop(usize),
    /// Sessions panel — move to the bottom of the manual order.
    SessionMoveToBottom(usize),
    /// Sessions panel — switch the sort mode to Auto (running →
    /// idle → exited, with pinned above), clearing the user's
    /// manual order.
    SessionSortAuto,
    /// Workspaces editor — open the rename prompt for the row.
    WorkspaceEditName(usize),
    /// Workspaces editor — open the path-edit prompt.
    WorkspaceEditPath(usize),
    /// Workspaces editor — open the group-edit prompt.
    WorkspaceEditGroup(usize),
    /// Workspaces editor — remove the workspace at this index.
    WorkspaceDelete(usize),
    /// #polish 2026-07-06 — Workspaces editor — toggle whether
    /// this row's path is the persisted `[startup] default_workspace`.
    WorkspaceSetDefault(usize),
    /// Workspaces editor — swap with the row above.
    WorkspaceMoveUp(usize),
    /// Workspaces editor — swap with the row below.
    WorkspaceMoveDown(usize),
    /// #polish 2026-07-06 — rail-level reorder for an extra
    /// workspace. Swaps its rail slot with the adjacent extra
    /// (up if the payload is Up, down if Down) and persists to
    /// `[[workspaces]]`. Accessible from the extra-workspace
    /// header right-click without opening Manage.
    ExtraWorkspaceMoveUp(usize),
    ExtraWorkspaceMoveDown(usize),
    /// Switch to the workspace at the given 1-based index — 0 is
    /// the primary; 1.. map to entries in `[[workspaces]]`. Used
    /// by the "Set as current" right-click on an extra workspace
    /// header.
    SwitchToExtraWorkspace(usize),
    /// Open a rendered-markdown preview for `path` in a split. Surfaced from
    /// the tree (right-click an `.md`/`.markdown`/`.mdx`/`.mkd` file) and
    /// from a bufferline tab right-click on the same.
    PreviewMarkdown(PathBuf),
    /// Open a URL via the OS default browser. Used by the git rail's
    /// `Pull` row context menu.
    OpenUrl(String),
    /// Copy a literal string to the clipboard. Used by the git rail's
    /// `Pull` row context menu ("Copy URL").
    CopyText(String),
    /// Split the leaf containing the tab and put the tab in the
    /// new half. Direction is the DropZone (Left/Right/Top/Bottom).
    /// Used by tab right-click "Split Right / Down / Left / Up".
    SplitTabInto(PaneId, crate::app::tab_drop::DropZone),
    /// #906 slice C (2026-08-20) — dock the pane at `PaneId` into
    /// the bottom panel. Used by tab right-click "Move to bottom
    /// panel". Only offered when the pane kind has a right-panel-
    /// style draw fn — see the `hostable` guard in
    /// `open_tab_context_menu`.
    HostInBottomPanel(PaneId),
    /// Open the CloudAgentRun detail pane for a row at `idx` in
    /// `cloud_agents_rows`. Used by the managed-agent right-click
    /// menu's "View details" entry.
    OpenCloudAgentRunDetail(usize),
    /// `POST /v1/sessions/{id}/stop` on an Anthropic Managed Agents
    /// session — asks the worker to wind down cleanly. Used by
    /// the managed-agent right-click menu's "Stop session" entry.
    StopManagedSession(String),
    /// Toggle an integration chip's `enabled` field by `id`. Hidden
    /// chips reappear in the palette bar; visible chips hide.
    /// Persists to user config TOML.
    ToggleIntegrationEnabled(String),
    /// 2026-08-06 — toggle an integration's `in_palette_bar` field.
    /// When true the chip appears in the top-right palette-bar
    /// cluster; when false only in the sidebar Integrations list.
    /// Persists to user config TOML.
    ToggleIntegrationPaletteBar(String),
    /// Move an integration chip one position earlier in the ordered
    /// list. No-op when already first. Persists via
    /// `persist_integration_icons`.
    MoveIntegrationUp(String),
    /// Move an integration chip one position later in the ordered
    /// list. No-op when already last. Persists.
    MoveIntegrationDown(String),
    /// Move an integration chip to position 0. No-op when already
    /// there. Persists.
    MoveIntegrationToTop(String),
    /// Move an integration chip to the last position. No-op when
    /// already there. Persists.
    MoveIntegrationToBottom(String),
    /// Right-click "Add to activity bar" on an integration chip
    /// row — writes a mount manifest for this integration so its
    /// icon appears in the activity bar and clicking it opens the
    /// integration as a docked Mount pane. 2026-07-20.
    AddIntegrationToActivityBar(String),
    /// Right-click "Remove from activity bar" on an integration
    /// chip row whose manifest already exists — deletes the
    /// `~/.config/mnml/mounts/<id>.toml` file + refreshes.
    RemoveIntegrationFromActivityBar(String),
    /// Right-click on a pinned launcher icon in the activity
    /// bar → "Launch" — fires the underlying chip's command.
    LaunchPinnedIntegration(String),
    /// Reorder actions on the pinned launcher icons —
    /// move the id up, down, to the top, or to the bottom of
    /// `config.ui.activity_bar_pinned_integrations`. Match the
    /// integration-chip right-click's set + order.
    /// 2026-07-20.
    MovePinnedIntegrationUp(String),
    MovePinnedIntegrationDown(String),
    MovePinnedIntegrationToTop(String),
    MovePinnedIntegrationToBottom(String),
    /// Toggle a launcher chip's `enabled` field by `id`.
    ToggleLauncherEnabled(String),
    /// Set `[ui] top_bar_cluster_mode` to one of
    /// `"auto"` / `"expanded"` / `"compact"` and persist to user config.
    SetTopBarClusterMode(&'static str),
    /// Spawn the `mnml-aws-cloudwatch-logs` integration tool in a Pty
    /// pane, pre-filtered to the given log group + filter pattern.
    /// Used by the Cloud Agents row context menu's "Tail logs in
    /// mnml" entry — handoffs the runId as a filter so the pane
    /// shows only that run's log lines.
    OpenCloudWatchPane {
        log_group: String,
        filter: String,
        label: String,
    },
    /// Spawn the `mnml-fs-s3` integration in a Pty pane, pre-filtered
    /// to a specific bucket + prefix. Used by the Cloud Agents
    /// row context menu's "Open S3 artifacts in mnml" entry —
    /// drops the user straight into the qwe-run's artifact tree.
    OpenS3Pane {
        bucket: String,
        prefix: String,
        label: String,
    },
    /// Diff pane / embedded diff: open `<rel_path>` at the file's
    /// pre-commit revision (`git show <hash>:<rel>`) as a scratch
    /// buffer. The user can read the file as it existed at that
    /// commit.
    DiffOpenAtRevision {
        hash: String,
        rel: PathBuf,
    },
    /// Diff pane / embedded diff: dispatch a per-hunk action against
    /// `(pane_id, hunk_index)` — same as a chip click.
    DiffHunkAction {
        pane_id: PaneId,
        hunk_index: usize,
        action: crate::DiffHunkAction,
    },
    /// `git add -- <rel>` against the active repo.
    GitStageFile(PathBuf),
    /// `git restore --staged -- <rel>` (fall back to `reset HEAD --`).
    GitUnstageFile(PathBuf),
    /// `git restore -- <rel>` *iff* the user types the filename to
    /// confirm. Destructive — discards working-tree changes back to
    /// HEAD. Captured via the prompt at `pending_discard_file`.
    GitDiscardFile(PathBuf),
    /// Append `<rel>` to `.gitignore` (creating it if missing).
    GitIgnoreFile(PathBuf),
    /// Append `*.<ext>` to `.gitignore` — ignore all files of this
    /// type. The action carries the extension *with* the leading dot
    /// stripped (e.g. `"log"`).
    GitIgnoreExtension(String),
    /// `git stash push -u -- <rel>` — stash just this file's changes.
    GitStashFile(PathBuf),
    /// Run an owned command string. Same dispatch shape as an
    /// integration chip click: `":<ex>"` runs as an ex-command,
    /// anything else goes through the command registry. Used by
    /// the `+` tab menu's integrations tail so dynamically-loaded
    /// chip commands (`:term mnml-forge-bitbucket --only prs`) can
    /// be invoked from a menu that only sees `&'static str` for the
    /// legacy `MenuAction::Command`.
    RunCmd(String),
}

#[derive(Debug, Clone)]
pub struct MenuItem {
    pub label: String,
    pub action: MenuAction,
    /// Render the row's fg as `t.red` when idle (selection style still
    /// wins) — matches the widget-kebab "Close" affordance. Used for
    /// destructive actions (Close / Delete) so a mouse user can spot
    /// them at a glance. 2026-08-11.
    pub destructive: bool,
}

impl MenuItem {
    pub fn new(label: impl Into<String>, action: MenuAction) -> Self {
        MenuItem {
            label: label.into(),
            action,
            destructive: false,
        }
    }
    /// Same as [`Self::new`] but tags the item as destructive so the
    /// renderer paints it red.
    pub fn destructive(label: impl Into<String>, action: MenuAction) -> Self {
        MenuItem {
            label: label.into(),
            action,
            destructive: true,
        }
    }
}

pub struct ContextMenu {
    /// Optional heading shown above the items (e.g. the file name).
    pub title: Option<String>,
    pub items: Vec<MenuItem>,
    /// Where the menu's top-left should sit (the click cell) — clamped on render.
    pub anchor: (u16, u16),
    pub selected: usize,
    /// True once the user has actively moved focus (mouse hover or
    /// arrow keys). When false, the renderer paints no row highlight
    /// — matches macOS/Cursor's right-click menu, where nothing is
    /// pre-selected until you interact. Enter / click still fire the
    /// item at `selected` (0 by default), so the "no highlight"
    /// state isn't actually inert.
    pub interacted: bool,
}

impl ContextMenu {
    pub fn new(title: Option<String>, anchor: (u16, u16), items: Vec<MenuItem>) -> Self {
        ContextMenu {
            title,
            items,
            anchor,
            selected: 0,
            interacted: false,
        }
    }
    pub fn move_up(&mut self) {
        self.interacted = true;
        if self.selected == 0 {
            self.selected = self.items.len().saturating_sub(1);
        } else {
            self.selected -= 1;
        }
    }
    pub fn move_down(&mut self) {
        self.interacted = true;
        if self.items.is_empty() {
            return;
        }
        self.selected = (self.selected + 1) % self.items.len();
    }
    pub fn set_selected(&mut self, i: usize) {
        if i < self.items.len() {
            self.selected = i;
            self.interacted = true;
        }
    }
    /// Inner content width (the longest label + a little padding).
    pub fn content_width(&self) -> usize {
        let longest = self
            .items
            .iter()
            .map(|i| i.label.chars().count())
            .chain(self.title.iter().map(|t| t.chars().count()))
            .max()
            .unwrap_or(8);
        (longest + 2).max(12)
    }
}