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
//! Mount runtime + integration manifest refresh methods on `App`.
//!
//! Previously this file also owned the legacy family-catalog install
//! flow (`install_integration_with_action` + `open_mount_install_picker`
//! + `open_integration_install_picker` + the CloudWatch/S3 install
//! prompt). That path was retired 2026-08-08 when `CATALOG` was
//! emptied — every install now goes through the Marketplace panel
//! (see `src/marketplace.rs`).
use super::*;
impl App {
/// Refresh the manifest list — re-scans both manifest dirs.
/// Called by the `mounts.refresh` palette command + on app
/// resume from background.
pub fn refresh_mount_manifests(&mut self) {
self.mount_manifests = crate::mount_manifest::load_all(&self.workspace);
self.toast(format!(
"mounts: {} manifest(s) loaded",
self.mount_manifests.len()
));
}
/// Refresh the integration manifest list — re-scans both dirs
/// and re-merges chips + commands. Called by the
/// `integrations.refresh` palette command; also fires
/// implicitly after an integration's `--install` writes a new
/// manifest file (user runs the palette command to pick up
/// the change without restarting mnml).
pub fn refresh_integration_manifests(&mut self) {
self.integration_manifests = crate::integration_manifest::load_all(&self.workspace);
// 2026-07-31 — re-run integration-glyph discovery FIRST so any
// newly-installed SVG in ~/.config/mnml/glyphs/ shows up
// before the merge pass fills IntegrationIcon.glyph.
self.discover_integration_glyphs();
// #1102 f/u (2026-08-20) — refresh the binary → version
// cache BEFORE merge so `IntegrationIcon.version` picks up
// the live `<binary> --version` result. Ordering matters:
// merge reads from the cache to populate the icon field.
self.refresh_binary_version_cache();
self.merge_integration_manifests();
// 2026-08-17 — re-spawn statusline-segment workers after
// the manifest re-scan so newly-added or removed
// `[[values_sources]]` entries take effect without a
// restart. Dropping the old sender inside `start_…`
// signals in-flight workers to exit on their next send.
self.start_statusline_segment_workers();
// #1117 (2026-08-21) — refresh the prefetch fleet on
// manifest re-scan so newly-added or removed
// `[[prefetch]]` entries take effect without a restart.
self.start_prefetch_workers();
// R9 api-workflow SEV-3 — users hitting the palette
// `integrations.refresh` reasonably expect ALL on-disk
// scanned state to be re-read, not just integration
// manifests. HTTP panel's MOCKS section reads
// `http_panel_mocks_cache` which was only rebuilt via the
// separate `http.refresh` command or a full restart, so a
// fresh `.mock.json` created by an integration didn't appear.
// Piggyback on this palette-level refresh.
self.http_panel_refresh();
self.toast(format!(
"integrations: {} manifest(s) loaded ({} integration glyph(s))",
self.integration_manifests.len(),
self.integration_glyph_svgs.len(),
));
}
/// #1102 f/u (2026-08-20) — resolve `<binary> --version` for
/// every unique integration binary declared across the loaded
/// manifests, keyed by binary basename. Clap's default
/// `--version` format is `<name> <version>` (single line);
/// we take the LAST whitespace-separated token so leading
/// hyphenated names (`mnml-forge-bitbucket`) don't confuse
/// the parse.
///
/// Runs synchronously — each `--version` call is a cheap
/// process spawn on an already-installed binary. On my box a
/// full 15-integration sweep is <200ms. Called after
/// manifest re-scan + post-install; NOT on every render.
/// A binary that fails to spawn or exits non-zero silently
/// drops out of the cache and the manifest's `version` field
/// takes over as the byline fallback.
pub fn refresh_binary_version_cache(&mut self) {
use std::collections::HashSet;
use std::process::{Command, Stdio};
use std::time::Duration;
let mut seen: HashSet<String> = HashSet::new();
let mut fresh: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let binaries: Vec<String> = self
.integration_manifests
.iter()
.filter_map(|m| m.binary.clone())
.collect();
for binary in binaries {
let basename = binary.rsplit('/').next().unwrap_or(&binary).to_string();
if !seen.insert(basename.clone()) {
continue;
}
let Ok(mut child) = Command::new(&binary)
.arg("--version")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.stdin(Stdio::null())
.spawn()
else {
continue;
};
// Cheap poll-with-deadline — clap's --version prints
// to stdout and exits immediately; anything taking
// more than 500ms is misbehaving.
let deadline = std::time::Instant::now() + Duration::from_millis(500);
let mut finished = false;
while std::time::Instant::now() < deadline {
match child.try_wait() {
Ok(Some(_)) => {
finished = true;
break;
}
Ok(None) => std::thread::sleep(Duration::from_millis(10)),
Err(_) => break,
}
}
if !finished {
let _ = child.kill();
let _ = child.wait();
continue;
}
let Ok(output) = child.wait_with_output() else {
continue;
};
if !output.status.success() {
continue;
}
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(tok) = stdout.split_whitespace().next_back()
&& !tok.is_empty()
{
fresh.insert(basename, tok.to_string());
}
}
self.binary_version_cache = fresh;
}
/// Spawn a hosted integration as a `Pane::Mount`. Called by the
/// MountBinary prompt's accept handler.
pub fn open_mount(&mut self, binary: &str) {
let label = binary.rsplit('/').next().unwrap_or(binary).to_string();
self.open_mount_with_label(binary, &label);
}
/// Same as `open_mount` but with an explicit display label —
/// used by manifest mounts so the pane tab shows the manifest
/// `name` instead of the raw binary basename.
pub fn open_mount_with_label(&mut self, binary: &str, label: &str) {
self.open_mount_with_args(binary, label, &[]);
}
/// Full form — accepts extra CLI args to hand to the mount
/// binary. Used by manifest mounts whose integration needs flags
/// (e.g. `mnml-forge-bitbucket --only prs`). 2026-07-20.
pub fn open_mount_with_args(&mut self, binary: &str, label: &str, args: &[String]) {
let geometry = mnml_bridge::Geometry { cols: 80, rows: 24 };
let env = self.bridge_env();
let workspace = self.workspace.clone();
match crate::mount::MountSession::spawn(
&workspace,
label.to_string(),
binary,
args,
&env,
Some(&workspace),
geometry,
) {
Ok(session) => {
let pane = Pane::Mount(session);
match self.active {
Some(cur) => {
let new_id =
self.split_leaf_with(cur, crate::layout::SplitDir::Horizontal, 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;
self.toast(format!("mounted {binary}"));
}
Err(e) => {
self.toast(format!("mount failed: {e}"));
}
}
}
/// #992 (2026-08-18) — apply an available integration update.
/// Shared by (a) the marketplace-row `↑ Update to X` chip click,
/// (b) the integration-chip right-click "Update to X" menu item.
/// Both surfaces need the SAME install-spec routing (Cargo vs
/// CargoGit vs LauncherToml vs plain-id fallback) and the same
/// optimistic clear of `integration_updates`.
///
/// Silent no-op with toast if the id's install spec can't be
/// classified — the chip / menu item shouldn't render in that
/// case, but defense against races.
pub fn apply_integration_update(&mut self, id: &str) {
let install_spec = self
.marketplace_entries
.iter()
.find(|e| e.id == id)
.map(|e| e.install.clone());
let cmd = match install_spec {
Some(crate::marketplace::InstallSpec::Cargo { name })
if crate::marketplace::is_safe_crate_component(&name) =>
{
Some(format!(
"term cargo install --force {name} && $HOME/.cargo/bin/{name} --install && echo '✓ {name} updated'",
))
}
Some(crate::marketplace::InstallSpec::Cargo { .. }) => None,
Some(crate::marketplace::InstallSpec::CargoGit { repo, .. }) => {
if crate::marketplace::is_safe_repo_slug(&repo)
&& crate::marketplace::is_safe_crate_component(id)
{
Some(format!(
"term cargo install --force --git https://github.com/{repo}.git {id} && $HOME/.cargo/bin/{id} --install && echo '✓ {id} updated'",
))
} else {
None
}
}
// LauncherToml installs have no cargo version — never
// enqueued for update checks, so this branch is
// effectively unreachable.
Some(crate::marketplace::InstallSpec::LauncherToml { .. }) => None,
None if crate::marketplace::is_safe_crate_component(id) => Some(format!(
"term cargo install --force {id} && $HOME/.cargo/bin/{id} --install && echo '✓ {id} updated'",
)),
None => None,
};
if let Some(cmd) = cmd {
self.run_ex_command(&cmd);
// Optimistic clear — the background sweep will
// re-populate if the install fails or the version
// didn't move. Makes the chip disappear immediately
// (matches "I clicked, so it's happening").
if let Ok(mut guard) = self.integration_updates.lock() {
guard.remove(id);
}
self.toast(format!("updating {id}..."));
} else {
self.toast(format!("cannot update {id}: unknown install source"));
}
}
}