Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05e7293af2 | ||
|
|
b1527f5fc1 |
@@ -52,10 +52,11 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
// because Normal mode only exists while the sidebar is focused, so
|
// because Normal mode only exists while the sidebar is focused, so
|
||||||
// nothing forwards it to a pty.
|
// nothing forwards it to a pty.
|
||||||
("tab", "AttachHighlighted"),
|
("tab", "AttachHighlighted"),
|
||||||
("s", "OpenSpawnModal"),
|
// `n`/`N`, the same pair the web client binds.
|
||||||
// Fast-forward spawn (#67): the spawn key's neighbour does the same
|
("n", "OpenSpawnModal"),
|
||||||
|
// Fast-forward spawn (#67): the spawn key's shifted twin does the same
|
||||||
// thing with every default resolved and no form.
|
// thing with every default resolved and no form.
|
||||||
("S", "FastForwardSpawn"),
|
("N", "FastForwardSpawn"),
|
||||||
("x", "KillHighlighted"),
|
("x", "KillHighlighted"),
|
||||||
("r", "RenameHighlighted"),
|
("r", "RenameHighlighted"),
|
||||||
// Focus
|
// Focus
|
||||||
@@ -220,11 +221,11 @@ mod tests {
|
|||||||
Some(Action::AttachHighlighted),
|
Some(Action::AttachHighlighted),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.keymap.resolve(Mode::Normal, "s"),
|
cfg.keymap.resolve(Mode::Normal, "n"),
|
||||||
Some(Action::OpenSpawnModal),
|
Some(Action::OpenSpawnModal),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.keymap.resolve(Mode::Normal, "S"),
|
cfg.keymap.resolve(Mode::Normal, "N"),
|
||||||
Some(Action::FastForwardSpawn),
|
Some(Action::FastForwardSpawn),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -311,18 +312,18 @@ mod tests {
|
|||||||
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression for #70: the previous canonical form of a real Shift+S
|
/// Regression for #70: the previous canonical form of a real Shift+N
|
||||||
/// keypress was `"shift-S"`, which the default `"S"` binding never
|
/// keypress was `"shift-N"`, which the default `"N"` binding never
|
||||||
/// matched. Drive the actual crossterm event, not the binding string,
|
/// matched. Drive the actual crossterm event, not the binding string,
|
||||||
/// so the dead-key bug can't come back unnoticed.
|
/// so the dead-key bug can't come back unnoticed.
|
||||||
#[test]
|
#[test]
|
||||||
fn real_shift_s_keypress_resolves_to_fast_forward_spawn() {
|
fn real_shift_n_keypress_resolves_to_fast_forward_spawn() {
|
||||||
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
use crate::input::{canonicalise, Region};
|
use crate::input::{canonicalise, Region};
|
||||||
|
|
||||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||||
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
|
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT));
|
||||||
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
|
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -597,7 +597,8 @@ mod tests {
|
|||||||
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
|
/// 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"` —
|
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
|
||||||
/// the uppercase letter already says "shifted"; a redundant prefix would
|
/// the uppercase letter already says "shifted"; a redundant prefix would
|
||||||
/// make the default `S` binding unreachable from the keyboard (#70).
|
/// make every uppercase binding (the default `N`) unreachable from the
|
||||||
|
/// keyboard (#70).
|
||||||
#[test]
|
#[test]
|
||||||
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
|
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
|
||||||
let k = InputKey::Char {
|
let k = InputKey::Char {
|
||||||
|
|||||||
+2
-1
@@ -141,7 +141,8 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
|||||||
|
|
||||||
[tui.keymap.normal]
|
[tui.keymap.normal]
|
||||||
"i" = "EnterInsert"
|
"i" = "EnterInsert"
|
||||||
"s" = "OpenSpawnModal"
|
"n" = "OpenSpawnModal"
|
||||||
|
"N" = "FastForwardSpawn"
|
||||||
"x" = "KillHighlighted"
|
"x" = "KillHighlighted"
|
||||||
"r" = "RenameHighlighted"
|
"r" = "RenameHighlighted"
|
||||||
"?" = "ShowHelp"
|
"?" = "ShowHelp"
|
||||||
|
|||||||
@@ -274,3 +274,21 @@ than jumping to the end of the list.
|
|||||||
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
|
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
|
||||||
same chord back): an attached terminal owns every plain keystroke. The TUI uses
|
same chord back): an attached terminal owns every plain keystroke. The TUI uses
|
||||||
`ctrl-w h`.
|
`ctrl-w h`.
|
||||||
|
|
||||||
|
Everything else is the same key on both clients:
|
||||||
|
|
||||||
|
| key | action |
|
||||||
|
|---------|---------------------------------------------|
|
||||||
|
| `j`/`k` | move the highlight (arrows too) |
|
||||||
|
| `Enter` | open the session, or expand the folder |
|
||||||
|
| `Tab` | hand the keyboard to the pane |
|
||||||
|
| `c` | collapse or expand the highlighted folder |
|
||||||
|
| `r` | rename the highlighted session |
|
||||||
|
| `x` | kill the highlighted session |
|
||||||
|
| `n` | open the spawn form |
|
||||||
|
| `N` | spawn with every default resolved |
|
||||||
|
| `?` | show the shortcut overlay |
|
||||||
|
|
||||||
|
The TUI binds these through its keymap, so a user can rebind any of them; the
|
||||||
|
web client's are fixed. A binding added to one client belongs in this table and
|
||||||
|
in both help overlays.
|
||||||
|
|||||||
Generated
+1645
File diff suppressed because it is too large
Load Diff
+27
-1
@@ -566,6 +566,25 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
|
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
|
||||||
// keyboard stays here while j/k runs.
|
// keyboard stays here while j/k runs.
|
||||||
treeHost.tabIndex = -1;
|
treeHost.tabIndex = -1;
|
||||||
|
/** The session behind the highlighted row, if the highlight is on a card. */
|
||||||
|
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
|
||||||
|
const row = rowFor(navKey);
|
||||||
|
const id = row?.dataset.sessionId;
|
||||||
|
if (!row || !id) return undefined;
|
||||||
|
const session = store.snapshot().sessions.get(id);
|
||||||
|
return session ? { row, session } : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `c`: collapse or expand the highlighted folder, the TUI's
|
||||||
|
* `ToggleSidebarGroup`. Clicking the header is what toggles it; on a card
|
||||||
|
* the key does nothing rather than toggling some other folder. */
|
||||||
|
const toggleHighlightedFolder = () => {
|
||||||
|
const row = rowFor(navKey);
|
||||||
|
if (!row || !row.classList.contains("tree-folder-header")) return;
|
||||||
|
row.click();
|
||||||
|
applyNav();
|
||||||
|
};
|
||||||
|
|
||||||
treeHost.addEventListener("keydown", (e) => {
|
treeHost.addEventListener("keydown", (e) => {
|
||||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||||
// The inline rename field lives inside a row and owns its own keys.
|
// The inline rename field lives inside a row and owns its own keys.
|
||||||
@@ -574,7 +593,14 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
|
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
|
||||||
else if (e.key === "Enter") activateNav();
|
else if (e.key === "Enter") activateNav();
|
||||||
else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
|
else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
|
||||||
else return;
|
else if (e.key === "c") toggleHighlightedFolder();
|
||||||
|
else if (e.key === "x") {
|
||||||
|
const hit = highlightedSession();
|
||||||
|
if (hit) void requestKill(hit.session, sessionName(hit.session));
|
||||||
|
} else if (e.key === "r") {
|
||||||
|
const hit = highlightedSession();
|
||||||
|
if (hit) beginRename(hit.row, hit.session);
|
||||||
|
} else return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export const SHORTCUTS: ShortcutGroup[] = [
|
|||||||
{ keys: ["j"], description: "Highlight the next session" },
|
{ keys: ["j"], description: "Highlight the next session" },
|
||||||
{ keys: ["k"], description: "Highlight the previous session" },
|
{ keys: ["k"], description: "Highlight the previous session" },
|
||||||
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
|
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
|
||||||
|
{ keys: ["c"], description: "Collapse or expand the highlighted folder" },
|
||||||
|
{ keys: ["r"], description: "Rename the highlighted session" },
|
||||||
|
{ keys: ["x"], description: "Kill the highlighted session" },
|
||||||
{ keys: ["Tab"], description: "Send the keyboard to the pane" },
|
{ keys: ["Tab"], description: "Send the keyboard to the pane" },
|
||||||
{ keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" },
|
{ keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -117,6 +117,43 @@ describe("sidebar keyboard navigation", () => {
|
|||||||
expect(asked).toBe(1);
|
expect(asked).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("c collapses and expands the highlighted folder", () => {
|
||||||
|
const { el } = twoFolders();
|
||||||
|
const header = el.querySelectorAll<HTMLElement>(".tree-folder-header")[0];
|
||||||
|
header.click(); // collapse /one, so the walk stops on its header
|
||||||
|
|
||||||
|
press(tree(el), "j");
|
||||||
|
expect(highlighted(el)).toBe("folder:one");
|
||||||
|
press(tree(el), "c");
|
||||||
|
// Expanded: the card it was hiding is back in the walk.
|
||||||
|
expect(el.querySelector(`[data-nav-key="session:${A}"]`)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("r renames the highlighted session in place", () => {
|
||||||
|
const { el } = twoFolders();
|
||||||
|
press(tree(el), "j");
|
||||||
|
press(tree(el), "r");
|
||||||
|
const field = el.querySelector<HTMLInputElement>(".leaf-rename");
|
||||||
|
expect(field).not.toBeNull();
|
||||||
|
// Escape settles the rename, so the module-level "a row is being renamed"
|
||||||
|
// flag doesn't leak into the next test.
|
||||||
|
field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("x kills the highlighted session", async () => {
|
||||||
|
const { el } = twoFolders();
|
||||||
|
const killed: string[] = [];
|
||||||
|
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
killed.push(`${init?.method ?? "GET"} ${String(input)}`);
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
}) as typeof window.fetch;
|
||||||
|
|
||||||
|
press(tree(el), "j");
|
||||||
|
press(tree(el), "x");
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves the keyboard alone while a row is being renamed", () => {
|
it("leaves the keyboard alone while a row is being renamed", () => {
|
||||||
const { el } = twoFolders();
|
const { el } = twoFolders();
|
||||||
press(tree(el), "j");
|
press(tree(el), "j");
|
||||||
|
|||||||
Reference in New Issue
Block a user