feat(sbx,ft): share VirtualBox VMs — vbox launch grammar, picker & streaming transfer
Unify sandbox summoning under `/sbx launch <docker|multipass|vbox>`, each running on the invoker's own machine. For vbox: - `/sbx launch vbox` opens an arrow-navigable VM picker (↑↓/Enter/Tab/Esc). - `/sbx launch vbox [gui] <vm>` boots frictionlessly for a host that already has VirtualBox + the VM imported; a non-host appends `yes` to install VirtualBox, import the shared appliance, and/or free VT-x first. - `/sbx gui <vm> [yes]` kept as an alias. Add `vm_registered()` + `import_appliance()` (VBoxManage import) in sbx.rs. Make file transfer stream disk-to-disk so multi-GB VM images can be shared (the old 50 MB in-memory cap blocked them): `STREAM_MAX` = 16 GiB for `/send` (`MAX_SIZE` now guards only the in-memory tar_path for sandbox injection). `prepare_send` stat+stream-hashes off the UI thread; `Sink`/`commit` write incoming chunks straight to a temp `.part` file and verify SHA before moving into place. Wire frames (offer/accept/chunk/done, 64 KB, base64) unchanged. A received `.ova`/`.ovf` auto-imports so the recipient can immediately `/sbx launch vbox gui <vm>`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+90
-17
@@ -47,6 +47,9 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
|
||||
if app.show_help {
|
||||
draw_help(f, f.area(), app, theme);
|
||||
}
|
||||
if app.vbox_picker.is_some() {
|
||||
draw_vbox_picker(f, f.area(), app, theme);
|
||||
}
|
||||
if let Some(msg) = &app.error {
|
||||
draw_error(f, f.area(), theme, msg);
|
||||
}
|
||||
@@ -150,8 +153,8 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
||||
title: "SANDBOX",
|
||||
items: vec![
|
||||
kv(
|
||||
"/sbx launch [backend]",
|
||||
"summon a sandbox: local | docker | multipass",
|
||||
"/sbx launch <docker|multipass|vbox>",
|
||||
"summon a sandbox on YOUR machine — docker/multipass relay a shared shell; vbox <vm> opens a local GUI (or local)",
|
||||
),
|
||||
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
|
||||
kv(
|
||||
@@ -172,22 +175,26 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
||||
HelpCluster {
|
||||
title: "VIRTUALBOX (local GUI VM)",
|
||||
items: vec![
|
||||
kv(
|
||||
"/sbx launch vbox",
|
||||
"open the arrow-navigable VM picker (↑↓ move · Enter/Tab boot · Esc dismiss)",
|
||||
),
|
||||
kv(
|
||||
"/sbx launch vbox [gui] <vm>",
|
||||
"boot a VM's GUI on YOUR machine — host with the VM already imported launches instantly",
|
||||
),
|
||||
kv(
|
||||
"/sbx launch vbox gui <vm> yes",
|
||||
"non-host opt-in: append yes to install VirtualBox and/or import the shared .ova, then boot",
|
||||
),
|
||||
kv(
|
||||
"/sbx gui <vm> [yes]",
|
||||
"alias of /sbx launch vbox gui <vm> [yes]",
|
||||
),
|
||||
kv("/sbx vms", "detect VirtualBox + list local VMs"),
|
||||
kv(
|
||||
"/sbx gui <vm> [--install]",
|
||||
"open a shared VM locally — host & guest each get their own copy",
|
||||
),
|
||||
kv(
|
||||
"/sbx gui yes",
|
||||
"confirm a pending launch (advance the consent gate)",
|
||||
),
|
||||
kv(
|
||||
"/sbx gui cancel",
|
||||
"abort a pending launch (nothing stopped or installed)",
|
||||
),
|
||||
kv(
|
||||
"/sbx vmsave <vm> [label] [--local]",
|
||||
"snapshot a VM (--local also exports a portable .ova)",
|
||||
"snapshot a VM (--local also exports a portable .ova to /send)",
|
||||
),
|
||||
kv("/sbx vmsnaps <vm>", "list a VM's snapshots"),
|
||||
],
|
||||
@@ -238,8 +245,8 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
||||
HelpCluster {
|
||||
title: "FILES",
|
||||
items: vec![
|
||||
kv("/send <file>", "offer a file to the room"),
|
||||
kv("/sendd <dir>", "offer a directory (sent as a tar)"),
|
||||
kv("/send <user> <path>", "send a file/dir directly to one member"),
|
||||
kv("/sendroom <path>", "offer a file/dir to the whole room"),
|
||||
kv("/accept · /reject", "respond to an incoming file offer"),
|
||||
],
|
||||
},
|
||||
@@ -277,6 +284,7 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
||||
"reconnect to the house after a drop / AFK",
|
||||
),
|
||||
kv("/pw", "show this room's password (local only)"),
|
||||
kv("/clear", "wipe your chat scrollback (local only)"),
|
||||
kv("Ctrl-C · Ctrl-Q", "quit hack-house"),
|
||||
],
|
||||
},
|
||||
@@ -343,6 +351,71 @@ fn help_render_lines(app: &App, theme: &Theme) -> Vec<Line<'static>> {
|
||||
lines
|
||||
}
|
||||
|
||||
/// Arrow-navigable VirtualBox VM picker — a small dropdown anchored just above
|
||||
/// the input box. The highlighted row is inverted (theme bg on accent); Enter or
|
||||
/// Tab boots it, Esc dismisses. Key handling lives in the run loop.
|
||||
fn draw_vbox_picker(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
|
||||
let Some(picker) = &app.vbox_picker else {
|
||||
return;
|
||||
};
|
||||
// Width: widest VM name (plus a little chrome), clamped to the terminal.
|
||||
let longest = picker
|
||||
.vms
|
||||
.iter()
|
||||
.map(|v| v.chars().count())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let w = (longest as u16 + 6).clamp(24, area.width.saturating_sub(2)).max(8);
|
||||
// Height: one row per VM + borders, capped so it never swallows the screen.
|
||||
let max_rows = area.height.saturating_sub(6).max(1);
|
||||
let body = (picker.vms.len() as u16).min(max_rows);
|
||||
let h = body + 2;
|
||||
// Anchor bottom-left, riding just above the 3-row input box.
|
||||
let x = area.x + 1;
|
||||
let y = area
|
||||
.y
|
||||
.saturating_add(area.height.saturating_sub(h + 3));
|
||||
let rect = Rect {
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
};
|
||||
|
||||
// Scroll the window so the selection stays visible in tall lists.
|
||||
let first = picker.selected.saturating_sub(body.saturating_sub(1) as usize);
|
||||
let items: Vec<ListItem> = picker
|
||||
.vms
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(first)
|
||||
.take(body as usize)
|
||||
.map(|(i, vm)| {
|
||||
let style = if i == picker.selected {
|
||||
Style::default()
|
||||
.fg(theme.bg)
|
||||
.bg(theme.accent)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.title)
|
||||
};
|
||||
let marker = if i == picker.selected { "▸ " } else { " " };
|
||||
ListItem::new(Line::from(Span::styled(format!("{marker}{vm}"), style)))
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(Clear, rect);
|
||||
let list = List::new(items).style(Style::default().bg(theme.bg)).block(
|
||||
Block::bordered()
|
||||
.border_style(Style::default().fg(theme.accent))
|
||||
.title(Span::styled(
|
||||
format!(" {} pick a VM · ↑↓ ⏎ Esc ", theme.sigil),
|
||||
Style::default().fg(theme.title).add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
);
|
||||
f.render_widget(list, rect);
|
||||
}
|
||||
|
||||
fn draw_help(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
|
||||
let w = help_popup(area);
|
||||
let inner_w = w.width.saturating_sub(2);
|
||||
|
||||
Reference in New Issue
Block a user