Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af8acbfbf2 | ||
|
|
1ad7845f51 |
Generated
+1
@@ -1051,6 +1051,7 @@ dependencies = [
|
|||||||
"maestro-cli",
|
"maestro-cli",
|
||||||
"maestro-daemon",
|
"maestro-daemon",
|
||||||
"maestro-tui",
|
"maestro-tui",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -29,3 +29,6 @@ tracing-subscriber.workspace = true
|
|||||||
maestro-cli = { path = "../maestro-cli", optional = true, default-features = false }
|
maestro-cli = { path = "../maestro-cli", optional = true, default-features = false }
|
||||||
maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false }
|
maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false }
|
||||||
maestro-tui = { path = "../maestro-tui", optional = true }
|
maestro-tui = { path = "../maestro-tui", optional = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
//! Process-level routing for the umbrella `maestro` binary (#10): each
|
||||||
|
//! enabled top-level subcommand must reach its own crate's runtime dispatch,
|
||||||
|
//! not just parse successfully.
|
||||||
|
//!
|
||||||
|
//! A malformed `--config` file makes every crate's dispatch fail identically
|
||||||
|
//! at config load, before any subcommand touches tmux, the network, or a
|
||||||
|
//! terminal. That lets the assertions tell "clap parsed it but the handler
|
||||||
|
//! never ran" (a usage error, exit 2) apart from "it dispatched into the real
|
||||||
|
//! crate" (a `toml decode` runtime error via `main`'s `maestro: {e}` printer,
|
||||||
|
//! exit 1).
|
||||||
|
//!
|
||||||
|
//! The absence tests below only compile when the matching feature is off, so
|
||||||
|
//! they run under CI's per-feature-subset matrix (`--no-default-features
|
||||||
|
//! --features cli|daemon|tui`) rather than the default all-features build.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Command, Output};
|
||||||
|
|
||||||
|
fn bin() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_BIN_EXE_maestro"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bad_config() -> tempfile::NamedTempFile {
|
||||||
|
let mut f = tempfile::NamedTempFile::new().expect("create temp config file");
|
||||||
|
write!(f, "this is not valid toml {{{{{{").expect("write temp config");
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(args: &[&str]) -> Output {
|
||||||
|
Command::new(bin())
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.unwrap_or_else(|e| panic!("spawn `maestro {args:?}`: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real dispatch happened: exit 1, `main`'s `"maestro: {e}"` prefix, and the
|
||||||
|
/// config crate's error surfacing through — never clap's own usage-error
|
||||||
|
/// page, which would mean the subcommand never reached its handler.
|
||||||
|
fn assert_dispatched(out: &Output) {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert_eq!(out.status.code(), Some(1), "stderr:\n{stderr}");
|
||||||
|
assert!(stderr.starts_with("maestro: "), "stderr:\n{stderr}");
|
||||||
|
assert!(stderr.contains("toml decode"), "stderr:\n{stderr}");
|
||||||
|
assert!(!stderr.contains("Usage:"), "stderr:\n{stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Never reached a handler: clap rejected the subcommand outright because the
|
||||||
|
/// feature that defines it is off. Only used by the `not(feature = "...")`
|
||||||
|
/// tests below, which themselves only exist in a single-feature build.
|
||||||
|
#[cfg(not(all(feature = "cli", feature = "daemon", feature = "tui")))]
|
||||||
|
fn assert_unrecognized(out: &Output, name: &str) {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert_eq!(out.status.code(), Some(2), "stderr:\n{stderr}");
|
||||||
|
assert!(
|
||||||
|
stderr.contains("unrecognized subcommand"),
|
||||||
|
"stderr:\n{stderr}"
|
||||||
|
);
|
||||||
|
assert!(stderr.contains(name), "stderr:\n{stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "cli")]
|
||||||
|
#[test]
|
||||||
|
fn list_dispatches_into_maestro_cli() {
|
||||||
|
let cfg = bad_config();
|
||||||
|
let out = run(&["list", "--config", cfg.path().to_str().unwrap()]);
|
||||||
|
assert_dispatched(&out);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "cli"))]
|
||||||
|
#[test]
|
||||||
|
fn list_is_unrecognized_without_cli_feature() {
|
||||||
|
assert_unrecognized(&run(&["list"]), "list");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "daemon")]
|
||||||
|
#[test]
|
||||||
|
fn daemon_dispatches_into_maestro_daemon() {
|
||||||
|
let cfg = bad_config();
|
||||||
|
let out = run(&["daemon", "--config", cfg.path().to_str().unwrap()]);
|
||||||
|
assert_dispatched(&out);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "daemon"))]
|
||||||
|
#[test]
|
||||||
|
fn daemon_is_unrecognized_without_daemon_feature() {
|
||||||
|
assert_unrecognized(&run(&["daemon"]), "daemon");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
|
#[test]
|
||||||
|
fn tui_dispatches_into_maestro_tui() {
|
||||||
|
let cfg = bad_config();
|
||||||
|
let out = run(&["tui", "--config", cfg.path().to_str().unwrap()]);
|
||||||
|
assert_dispatched(&out);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "tui"))]
|
||||||
|
#[test]
|
||||||
|
fn tui_is_unrecognized_without_tui_feature() {
|
||||||
|
assert_unrecognized(&run(&["tui"]), "tui");
|
||||||
|
}
|
||||||
+19
-6
@@ -43,6 +43,10 @@ export interface AttachConnection {
|
|||||||
* handling — the leader's literal Ctrl-K escape hatch
|
* handling — the leader's literal Ctrl-K escape hatch
|
||||||
* (docs/COMMAND-BAR.md). */
|
* (docs/COMMAND-BAR.md). */
|
||||||
sendInput(data: string): void;
|
sendInput(data: string): void;
|
||||||
|
/** Refresh the header's name/command (e.g. after autoname renames the
|
||||||
|
* session) — the connection outlives mount/unmount, so this is the only
|
||||||
|
* way an already-open header picks up a new name. */
|
||||||
|
updateInfo(info: AttachInfo): void;
|
||||||
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
|
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
@@ -85,21 +89,25 @@ export function createAttachConnection(
|
|||||||
el.className = "attach";
|
el.className = "attach";
|
||||||
// Useful identity over a bare UUID: name · command. The full id stays in the
|
// Useful identity over a bare UUID: name · command. The full id stays in the
|
||||||
// tooltip for when you really need it.
|
// tooltip for when you really need it.
|
||||||
const titleHtml = info
|
const titleMarkup = (i?: AttachInfo): { html: string; attr: string } => ({
|
||||||
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
|
html: i
|
||||||
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
|
? `<span class="at-name">${escapeHtml(i.name)}</span>` +
|
||||||
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
|
(i.command && i.command !== i.name ? commandFragment(i.command) : "")
|
||||||
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
|
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`,
|
||||||
|
attr: i ? `${i.name} · ${i.command} (${sessionId})` : sessionId,
|
||||||
|
});
|
||||||
|
const initialTitle = titleMarkup(info);
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<header class="attach-header">
|
<header class="attach-header">
|
||||||
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
||||||
<span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
|
<span class="attach-title" title="${escapeHtml(initialTitle.attr)}">${initialTitle.html}</span>
|
||||||
<span class="attach-state" data-state="connecting">connecting…</span>
|
<span class="attach-state" data-state="connecting">connecting…</span>
|
||||||
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="attach-body"></div>
|
<div class="attach-body"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const titleEl = el.querySelector(".attach-title") as HTMLSpanElement;
|
||||||
const body = el.querySelector(".attach-body") as HTMLDivElement;
|
const body = el.querySelector(".attach-body") as HTMLDivElement;
|
||||||
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
||||||
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
||||||
@@ -499,6 +507,11 @@ export function createAttachConnection(
|
|||||||
// The closure above: the same bytes-out path keystrokes and the mobile key
|
// The closure above: the same bytes-out path keystrokes and the mobile key
|
||||||
// bar already take.
|
// bar already take.
|
||||||
sendInput,
|
sendInput,
|
||||||
|
updateInfo(next: AttachInfo) {
|
||||||
|
const { html, attr } = titleMarkup(next);
|
||||||
|
titleEl.innerHTML = html;
|
||||||
|
titleEl.title = attr;
|
||||||
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ vi.mock("./attach.js", () => ({
|
|||||||
sendInput(data: string) {
|
sendInput(data: string) {
|
||||||
sent.push({ id, data });
|
sent.push({ id, data });
|
||||||
},
|
},
|
||||||
|
updateInfo() {},
|
||||||
dispose() {},
|
dispose() {},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export class ConnectionPool {
|
|||||||
if (conn) {
|
if (conn) {
|
||||||
this.cache.delete(id);
|
this.cache.delete(id);
|
||||||
this.cache.set(id, conn);
|
this.cache.set(id, conn);
|
||||||
|
if (info) conn.updateInfo(info);
|
||||||
} else {
|
} else {
|
||||||
conn = createAttachConnection(
|
conn = createAttachConnection(
|
||||||
id,
|
id,
|
||||||
|
|||||||
Reference in New Issue
Block a user