Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e9ed580ea | ||
|
|
2d5e2f1718 | ||
|
|
56d1afe9bd | ||
|
|
095f810176 | ||
|
|
a85773cef0 |
@@ -238,6 +238,26 @@ mod tests {
|
||||
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
||||
}
|
||||
|
||||
/// Regression for #70: the previous canonical form of a real Shift+S
|
||||
/// keypress was `"shift-S"`, which the default `"S"` binding never
|
||||
/// matched. Drive the actual crossterm event, not the binding string,
|
||||
/// so the dead-key bug can't come back unnoticed.
|
||||
#[test]
|
||||
fn real_shift_s_keypress_resolves_to_fast_forward_spawn() {
|
||||
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::input::{canonicalise, Region};
|
||||
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
|
||||
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
|
||||
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, &key.canonical()),
|
||||
Some(Action::FastForwardSpawn),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_chord_timeout_is_applied() {
|
||||
let mut section = TuiSection::default();
|
||||
|
||||
@@ -117,7 +117,9 @@ impl InputKey {
|
||||
if *alt {
|
||||
out.push_str("alt-");
|
||||
}
|
||||
if *shift {
|
||||
// Uppercase already implies shift; crossterm sets the flag
|
||||
// on every such char, so skip the redundant prefix (#70).
|
||||
if *shift && !c.is_uppercase() {
|
||||
out.push_str("shift-");
|
||||
}
|
||||
out.push(*c);
|
||||
@@ -580,12 +582,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn canonical_round_trips_simple_chars() {
|
||||
for s in ["a", "ctrl-a", "ctrl-w", "alt-x", "shift-A"] {
|
||||
for s in ["a", "ctrl-a", "ctrl-w", "alt-x"] {
|
||||
let k = InputKey::parse(s).unwrap();
|
||||
assert_eq!(k.canonical(), s, "round-trip {s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// crossterm 0.28 attaches `KeyModifiers::SHIFT` to any uppercase char,
|
||||
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
|
||||
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
|
||||
/// the uppercase letter already says "shifted"; a redundant prefix would
|
||||
/// make the default `S` binding unreachable from the keyboard (#70).
|
||||
#[test]
|
||||
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
|
||||
let k = InputKey::Char {
|
||||
c: 'S',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: true,
|
||||
};
|
||||
assert_eq!(k.canonical(), "S");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_round_trips_specials() {
|
||||
for s in [
|
||||
|
||||
@@ -202,3 +202,83 @@ describe("multiview openDraft (#62)", () => {
|
||||
expect(leaf.draft?.command).toBe("claude");
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiview pane-drag swap (#73)", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** Drag `sourcePane`'s title onto `targetPane`'s center, as the real pane
|
||||
* drag handler does — modelled after the pointer sequence in runDrag()/
|
||||
* updateDock() in multiview.ts, since neither is exposed on MultiView. */
|
||||
async function dragPaneOntoCenter(sourcePane: HTMLElement, targetPane: HTMLElement): Promise<void> {
|
||||
// The real drag-start gate requires the pointerdown target to sit inside
|
||||
// an `.attach-header` (attach.ts's session title bar). Draft panes mount
|
||||
// no such element, so stand one in for the gate check itself.
|
||||
const marker = document.createElement("div");
|
||||
marker.className = "attach-header";
|
||||
sourcePane.appendChild(marker);
|
||||
|
||||
vi.spyOn(targetPane, "getBoundingClientRect").mockReturnValue({
|
||||
left: 100,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
right: 200,
|
||||
bottom: 100,
|
||||
x: 100,
|
||||
y: 0,
|
||||
toJSON() {},
|
||||
} as DOMRect);
|
||||
(document as unknown as { elementsFromPoint: (x: number, y: number) => Element[] }).elementsFromPoint = () => [
|
||||
targetPane,
|
||||
];
|
||||
|
||||
marker.dispatchEvent(
|
||||
new PointerEvent("pointerdown", { clientX: 0, clientY: 0, bubbles: true, cancelable: true }),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new PointerEvent("pointermove", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }),
|
||||
);
|
||||
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }));
|
||||
await flush();
|
||||
}
|
||||
|
||||
it("moves each draft's form along with its data when two draft panes are swapped", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const leaf1 = splitLeaf(tab, "", null, "row", true);
|
||||
const leaf2 = splitLeaf(tab, leaf1, null, "row", true);
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
await flush();
|
||||
|
||||
const paneOf = (leafId: string) => mv.el.querySelector(`[data-leaf-id="${leafId}"]`) as HTMLElement;
|
||||
const nameInputOf = (leafId: string) => paneOf(leafId).querySelector("input[name=name]") as HTMLInputElement;
|
||||
|
||||
nameInputOf(leaf1).value = "west";
|
||||
nameInputOf(leaf1).dispatchEvent(new Event("input"));
|
||||
nameInputOf(leaf2).value = "east";
|
||||
nameInputOf(leaf2).dispatchEvent(new Event("input"));
|
||||
await flush();
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("west");
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("east");
|
||||
|
||||
await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2));
|
||||
|
||||
// The leaf data swaps (already correct pre-fix)...
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("east");
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("west");
|
||||
// ...and the mounted forms must follow, not stay behind showing the old
|
||||
// pane's values (#73).
|
||||
expect(nameInputOf(leaf1).value).toBe("east");
|
||||
expect(nameInputOf(leaf2).value).toBe("west");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -701,6 +701,16 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
const dragged = findLeaf(tab.root, self)!;
|
||||
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
|
||||
[dragged.draft, target.draft] = [target.draft, dragged.draft];
|
||||
// The leaf data moved but each pane's mounted form is still keyed to
|
||||
// its old leaf (#73) — tear both down like openDraft's reuse case
|
||||
// (#72) so render() remounts fresh forms seeded from the swapped data.
|
||||
for (const id of [dragged.id, target.id]) {
|
||||
const stale = draftForms.get(id);
|
||||
if (stale) {
|
||||
stale.destroy();
|
||||
draftForms.delete(id);
|
||||
}
|
||||
}
|
||||
tab.focusedLeafId = target.id;
|
||||
} else if (sessionId) {
|
||||
const loc = locateSession(layout, sessionId);
|
||||
|
||||
Reference in New Issue
Block a user