Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d336bd802d | ||
|
|
67d17f48d0 | ||
|
|
ec45312400 | ||
|
|
525dc9a04a | ||
|
|
264332266c | ||
|
|
37338def71 |
@@ -12,12 +12,13 @@
|
|||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::io::Stdout;
|
use std::io::Stdout;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use crossterm::event::{Event as CtEvent, EventStream};
|
use crossterm::event::{Event as CtEvent, EventStream};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use maestro_protocol::{routes, CreateSessionRequest, DaemonClient, NameBatch};
|
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -88,6 +89,15 @@ pub enum Effect {
|
|||||||
LoadDraft(usize),
|
LoadDraft(usize),
|
||||||
/// A reroll drained the draft's local name batch (#64).
|
/// A reroll drained the draft's local name batch (#64).
|
||||||
RefillNames(usize),
|
RefillNames(usize),
|
||||||
|
/// The draft's target changed: probe `/api/gitroot` so the worktree
|
||||||
|
/// section knows its base repo (#65). `seq` guards stale answers.
|
||||||
|
ProbeGitRoot {
|
||||||
|
tab: usize,
|
||||||
|
seq: u32,
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
/// Fetch the draft target's branches from `/api/branches` (#65).
|
||||||
|
LoadBranches(usize),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -193,6 +203,10 @@ impl App {
|
|||||||
Effect::Kill(id) => self.submit_kill(id).await,
|
Effect::Kill(id) => self.submit_kill(id).await,
|
||||||
Effect::LoadDraft(tab_idx) => self.load_draft_data(tab_idx).await,
|
Effect::LoadDraft(tab_idx) => self.load_draft_data(tab_idx).await,
|
||||||
Effect::RefillNames(tab_idx) => self.refill_names(tab_idx).await,
|
Effect::RefillNames(tab_idx) => self.refill_names(tab_idx).await,
|
||||||
|
Effect::ProbeGitRoot { tab, seq, path } => {
|
||||||
|
self.probe_git_root(tab, seq, path).await
|
||||||
|
}
|
||||||
|
Effect::LoadBranches(tab) => self.load_branches(tab).await,
|
||||||
Effect::Forward { id, bytes } => {
|
Effect::Forward { id, bytes } => {
|
||||||
if let Some(handle) = self.emulators.get(id) {
|
if let Some(handle) = self.emulators.get(id) {
|
||||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||||
@@ -349,6 +363,16 @@ impl App {
|
|||||||
if draft.take_name_refill() {
|
if draft.take_name_refill() {
|
||||||
effects.push(Effect::RefillNames(tab_idx));
|
effects.push(Effect::RefillNames(tab_idx));
|
||||||
}
|
}
|
||||||
|
if let Some((seq, path)) = draft.take_gitroot_probe() {
|
||||||
|
effects.push(Effect::ProbeGitRoot {
|
||||||
|
tab: tab_idx,
|
||||||
|
seq,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if draft.take_branch_load().is_some() {
|
||||||
|
effects.push(Effect::LoadBranches(tab_idx));
|
||||||
|
}
|
||||||
effects
|
effects
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +446,72 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the draft target's enclosing repository via `/api/gitroot`
|
||||||
|
/// (#65). A miss unticks the worktree toggle inside `resolve_gitroot`;
|
||||||
|
/// a hit may schedule a branch load (Existing mode), served inline.
|
||||||
|
async fn probe_git_root(&mut self, tab_idx: usize, seq: u32, path: PathBuf) {
|
||||||
|
let root = self
|
||||||
|
.daemon
|
||||||
|
.git_root(&path.to_string_lossy())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|g| g.root);
|
||||||
|
let Some(PaneRef::Draft(draft)) = self
|
||||||
|
.state
|
||||||
|
.tabs
|
||||||
|
.get_mut(tab_idx)
|
||||||
|
.and_then(|t| t.pane.as_mut())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
draft.resolve_gitroot(seq, root);
|
||||||
|
if draft.take_branch_load().is_some() {
|
||||||
|
self.load_branches(tab_idx).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `/api/branches` batch for the draft's current cwd (#65). Same
|
||||||
|
/// reqwest route as [`Self::fetch_names`]: no typed client call exists.
|
||||||
|
async fn load_branches(&mut self, tab_idx: usize) {
|
||||||
|
let Some(path) = self
|
||||||
|
.state
|
||||||
|
.tabs
|
||||||
|
.get(tab_idx)
|
||||||
|
.and_then(|t| t.pane.as_ref())
|
||||||
|
.and_then(|p| match p {
|
||||||
|
PaneRef::Draft(d) => d.cwd(),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let url = format!(
|
||||||
|
"{}{}",
|
||||||
|
maestro_protocol::routes::normalize_base(self.daemon.base()),
|
||||||
|
routes::BRANCHES
|
||||||
|
);
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.get(url)
|
||||||
|
.query(&[(routes::PATH_QUERY, path.to_string_lossy().as_ref())])
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
// On failure the picker stays empty; "new" mode still works.
|
||||||
|
let branches = match resp {
|
||||||
|
Ok(resp) => resp.json::<Vec<BranchInfo>>().await.unwrap_or_default(),
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let Some(PaneRef::Draft(draft)) = self
|
||||||
|
.state
|
||||||
|
.tabs
|
||||||
|
.get_mut(tab_idx)
|
||||||
|
.and_then(|t| t.pane.as_mut())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
draft.set_branches(&path, branches);
|
||||||
|
}
|
||||||
|
|
||||||
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
|
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
|
||||||
match self.daemon.create_session(&req).await {
|
match self.daemon.create_session(&req).await {
|
||||||
Ok(session) => {
|
Ok(session) => {
|
||||||
@@ -613,6 +703,7 @@ fn render_help_lines(cfg: &TuiConfig) -> Vec<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
|
use crate::draft::DraftField;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
use maestro_config::TuiSection;
|
use maestro_config::TuiSection;
|
||||||
|
|
||||||
@@ -767,6 +858,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
||||||
|
draft.base_repo = Some(std::path::PathBuf::from("/srv/work"));
|
||||||
|
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
||||||
draft.selected_preset = draft.presets.len() - 1; // custom
|
draft.selected_preset = draft.presets.len() - 1; // custom
|
||||||
app.handle_draft_input(&InputKey::Tab); // preset → custom command
|
app.handle_draft_input(&InputKey::Tab); // preset → custom command
|
||||||
for c in "claude".chars() {
|
for c in "claude".chars() {
|
||||||
@@ -777,6 +870,9 @@ mod tests {
|
|||||||
for c in "crimson-otter".chars() {
|
for c in "crimson-otter".chars() {
|
||||||
app.handle_draft_input(&InputKey::char(c));
|
app.handle_draft_input(&InputKey::char(c));
|
||||||
}
|
}
|
||||||
|
app.handle_draft_input(&InputKey::Tab); // → worktree toggle
|
||||||
|
app.handle_draft_input(&InputKey::Tab); // → branch mode
|
||||||
|
app.handle_draft_input(&InputKey::Tab); // → new-branch name
|
||||||
app.handle_draft_input(&InputKey::Tab); // → submit
|
app.handle_draft_input(&InputKey::Tab); // → submit
|
||||||
|
|
||||||
match app.handle_draft_input(&InputKey::Enter).as_slice() {
|
match app.handle_draft_input(&InputKey::Enter).as_slice() {
|
||||||
@@ -793,6 +889,49 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Editing the raw path with the worktree toggle on schedules a
|
||||||
|
/// `/api/gitroot` probe so the section learns its base repo (#65).
|
||||||
|
#[test]
|
||||||
|
fn typing_a_raw_path_schedules_a_gitroot_probe() {
|
||||||
|
let mut app = make_app();
|
||||||
|
app.dispatch(Action::NewDraftPane);
|
||||||
|
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
||||||
|
panic!("expected a draft pane");
|
||||||
|
};
|
||||||
|
draft.cwd_raw_visible = true;
|
||||||
|
draft.focused_field = DraftField::CwdRaw;
|
||||||
|
|
||||||
|
let effects = app.handle_draft_input(&InputKey::char('/'));
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
effects.as_slice(),
|
||||||
|
[Effect::ProbeGitRoot { tab: 0, seq: 2, .. }]
|
||||||
|
),
|
||||||
|
"{effects:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switching the worktree branch source to Existing schedules one
|
||||||
|
/// `/api/branches` load for the current target (#65).
|
||||||
|
#[test]
|
||||||
|
fn switching_to_existing_branch_mode_schedules_a_branch_load() {
|
||||||
|
let mut app = make_app();
|
||||||
|
app.dispatch(Action::NewDraftPane);
|
||||||
|
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
|
||||||
|
panic!("expected a draft pane");
|
||||||
|
};
|
||||||
|
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/repo"));
|
||||||
|
draft.base_repo = Some(std::path::PathBuf::from("/srv/repo"));
|
||||||
|
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/repo"));
|
||||||
|
draft.focused_field = DraftField::BranchMode;
|
||||||
|
|
||||||
|
let effects = app.handle_draft_input(&InputKey::Right);
|
||||||
|
assert!(
|
||||||
|
matches!(effects.as_slice(), [Effect::LoadBranches(0)]),
|
||||||
|
"{effects:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Submitting with no directory names the missing field inside the form
|
/// Submitting with no directory names the missing field inside the form
|
||||||
/// and posts nothing (#64).
|
/// and posts nothing (#64).
|
||||||
#[test]
|
#[test]
|
||||||
@@ -800,7 +939,7 @@ mod tests {
|
|||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
app.dispatch(Action::NewDraftPane);
|
app.dispatch(Action::NewDraftPane);
|
||||||
// Walk to Submit without picking anything.
|
// Walk to Submit without picking anything.
|
||||||
for _ in 0..3 {
|
for _ in 0..4 {
|
||||||
app.handle_draft_input(&InputKey::Tab);
|
app.handle_draft_input(&InputKey::Tab);
|
||||||
}
|
}
|
||||||
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
|
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -122,6 +122,31 @@ describe("multiview openDraft (#62)", () => {
|
|||||||
expect(tab.focusedLeafId).toBe(empty);
|
expect(tab.focusedLeafId).toBe(empty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the draft form instance when an unrelated session closes (#68)", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
const s1 = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
const draftLeaf = splitLeaf(tab, s1, null, "row", true);
|
||||||
|
tab.focusedLeafId = draftLeaf;
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const nameInput = mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement;
|
||||||
|
nameInput.value = "wip";
|
||||||
|
nameInput.dispatchEvent(new Event("input"));
|
||||||
|
await flush();
|
||||||
|
expect(leaves(tab.root).find((l) => l.id === draftLeaf)?.draft?.name).toBe("wip");
|
||||||
|
|
||||||
|
const formBefore = mv.el.querySelector(".mv-draft");
|
||||||
|
mv.closeSession("s1");
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(mv.el.querySelector(".mv-draft")).toBe(formBefore);
|
||||||
|
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
|
||||||
|
});
|
||||||
|
|
||||||
it("seeds a clone's cwd and command into the draft", async () => {
|
it("seeds a clone's cwd and command into the draft", async () => {
|
||||||
const layout = emptyLayout();
|
const layout = emptyLayout();
|
||||||
const mv = makeMv(layout);
|
const mv = makeMv(layout);
|
||||||
|
|||||||
+55
-29
@@ -70,9 +70,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
|
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
|
||||||
const persist = () => opts.onLayoutChange?.();
|
const persist = () => opts.onLayoutChange?.();
|
||||||
|
|
||||||
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so each
|
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so forms
|
||||||
// render tears down the previous generation before rebuilding the canvas.
|
// whose leaves are gone are destroyed on each render. Forms for draft leaves
|
||||||
const draftForms = new Set<SpawnFormHandle>();
|
// that survive the render are reused in place (#68): rebuilding one mid-edit
|
||||||
|
// would drop focus, caret and selection, and re-fetch info/projects/names.
|
||||||
|
const draftForms = new Map<string, SpawnFormHandle>();
|
||||||
|
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "mv";
|
el.className = "mv";
|
||||||
@@ -150,30 +152,35 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
// Draft pane (#57): an empty pane is a spawn form. Values hang off the
|
// Draft pane (#57): an empty pane is a spawn form. Values hang off the
|
||||||
// leaf (never persisted) so each draft keeps its own half-filled state;
|
// leaf (never persisted) so each draft keeps its own half-filled state;
|
||||||
// submitting fills this leaf with the new session in place.
|
// submitting fills this leaf with the new session in place.
|
||||||
const form = createSpawnForm({
|
const form = draftForms.get(leaf.id);
|
||||||
seed: leaf.draft ?? undefined,
|
if (form) {
|
||||||
class: "mv-draft",
|
host.appendChild(form.el);
|
||||||
onChange: (values) => {
|
} else {
|
||||||
leaf.draft = values;
|
const created = createSpawnForm({
|
||||||
},
|
seed: leaf.draft ?? undefined,
|
||||||
onSuccess: (session) => {
|
class: "mv-draft",
|
||||||
// The pane can be closed while the POST is in flight. Mounting the
|
onChange: (values) => {
|
||||||
// session onto a leaf no longer in the tree would strand it in no
|
leaf.draft = values;
|
||||||
// pane at all, so only claim the leaf while it is still there —
|
},
|
||||||
// searching every tab, since the operator may have switched tabs
|
onSuccess: (session) => {
|
||||||
// while the request was in flight.
|
// The pane can be closed while the POST is in flight. Mounting the
|
||||||
const host = layout.tabs.find((t) => leaves(t.root).includes(leaf));
|
// session onto a leaf no longer in the tree would strand it in no
|
||||||
if (host) {
|
// pane at all, so only claim the leaf while it is still there —
|
||||||
leaf.sessionId = session.id;
|
// searching every tab, since the operator may have switched tabs
|
||||||
leaf.draft = null;
|
// while the request was in flight.
|
||||||
host.focusedLeafId = leaf.id;
|
const host = layout.tabs.find((t) => leaves(t.root).includes(leaf));
|
||||||
}
|
if (host) {
|
||||||
render();
|
leaf.sessionId = session.id;
|
||||||
opts.onOpenSessionsChange?.();
|
leaf.draft = null;
|
||||||
},
|
host.focusedLeafId = leaf.id;
|
||||||
});
|
}
|
||||||
draftForms.add(form);
|
render();
|
||||||
host.appendChild(form.el);
|
opts.onOpenSessionsChange?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
draftForms.set(leaf.id, created);
|
||||||
|
host.appendChild(created.el);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tools = document.createElement("div");
|
const tools = document.createElement("div");
|
||||||
@@ -395,9 +402,25 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
|
|
||||||
// ---- top-level render ----
|
// ---- top-level render ----
|
||||||
|
|
||||||
|
/** Destroy forms whose leaf is no longer a draft (leaf removed or filled by
|
||||||
|
* a session). Surviving draft leaves keep their form instance (#68). */
|
||||||
|
function pruneForms(tab: { root: Node | null }): void {
|
||||||
|
const alive = new Set(
|
||||||
|
tab.root
|
||||||
|
? leaves(tab.root)
|
||||||
|
.filter((l) => !l.sessionId)
|
||||||
|
.map((l) => l.id)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
for (const [id, f] of draftForms) {
|
||||||
|
if (!alive.has(id)) {
|
||||||
|
f.destroy();
|
||||||
|
draftForms.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function render(): void {
|
function render(): void {
|
||||||
for (const f of draftForms) f.destroy();
|
|
||||||
draftForms.clear();
|
|
||||||
renderTabs();
|
renderTabs();
|
||||||
canvas.replaceChildren();
|
canvas.replaceChildren();
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
@@ -406,6 +429,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
|
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
|
||||||
|
|
||||||
if (!tab.root) {
|
if (!tab.root) {
|
||||||
|
pruneForms(tab);
|
||||||
pool.setVisible([]);
|
pool.setVisible([]);
|
||||||
pool.setFocus(null);
|
pool.setFocus(null);
|
||||||
canvas.appendChild(renderWelcome());
|
canvas.appendChild(renderWelcome());
|
||||||
@@ -421,12 +445,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
renderMobile(tab);
|
renderMobile(tab);
|
||||||
|
pruneForms(tab);
|
||||||
persist();
|
persist();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
|
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
|
||||||
canvas.appendChild(buildTree(tab.root, hosts));
|
canvas.appendChild(buildTree(tab.root, hosts));
|
||||||
|
pruneForms(tab);
|
||||||
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
|
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
|
||||||
syncFocus();
|
syncFocus();
|
||||||
persist();
|
persist();
|
||||||
|
|||||||
Reference in New Issue
Block a user