feat(#3845): add J/K keyboard shortcuts for previous/next session navigation
Global j/k keydown bindings navigate the session list (j=next, k=prev), guarded by _isInteractiveSwipeTarget so they never fire while typing in the composer or any input/textarea/contenteditable. Modifier-key combos are ignored. Closes #3845. Co-authored-by: Rod Boev <rod.boev@gmail.com>
This commit is contained in:
@@ -6169,3 +6169,22 @@ async function _confirmDeleteProject(proj){
|
||||
document.addEventListener('keydown',(e)=>{
|
||||
if(e.key==='Escape'&&_sessionSelectMode) exitSessionSelectMode();
|
||||
});
|
||||
|
||||
// Keyboard session navigation — J/K bindings
|
||||
function navigateSession(dir){
|
||||
const rows=[...document.querySelectorAll('.session-item[data-sid]')];
|
||||
const sids=rows.map(r=>r.dataset.sid);
|
||||
const cur=S.session&&S.session.session_id;
|
||||
const i=sids.indexOf(cur);
|
||||
if(i<0||!sids.length)return;
|
||||
const next=sids[Math.min(Math.max(i+dir,0),sids.length-1)];
|
||||
if(next&&next!==cur) loadSession(next);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown',(e)=>{
|
||||
if(e.key!=='j'&&e.key!=='k') return;
|
||||
if(e.ctrlKey||e.metaKey||e.altKey) return;
|
||||
if(typeof _isInteractiveSwipeTarget==='function'&&_isInteractiveSwipeTarget(e.target)) return;
|
||||
e.preventDefault();
|
||||
navigateSession(e.key==='j'?1:-1);
|
||||
});
|
||||
|
||||
99
tests/test_3845_keyboard_session_nav.py
Normal file
99
tests/test_3845_keyboard_session_nav.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Regression check for #3845 — keyboard session navigation with J/K bindings.
|
||||
|
||||
The session list supports keyboard navigation using J (next) and K (previous)
|
||||
to move focus through sessions without mouse interaction. Verified at the source
|
||||
level so this stays fast.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).parent.parent
|
||||
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_navigateSession_function_exists():
|
||||
"""navigateSession function is defined and exported to global scope."""
|
||||
assert "function navigateSession(dir)" in SESSIONS_JS
|
||||
|
||||
|
||||
def test_navigateSession_calls_loadSession():
|
||||
"""navigateSession calls loadSession(next) when switching sessions."""
|
||||
# Extract the navigateSession function definition
|
||||
start = SESSIONS_JS.index("function navigateSession(dir)")
|
||||
end = SESSIONS_JS.index("}", start) + 1
|
||||
func_body = SESSIONS_JS[start:end]
|
||||
assert "loadSession(next)" in func_body
|
||||
|
||||
|
||||
def test_navigateSession_queries_session_items():
|
||||
"""navigateSession queries .session-item[data-sid] elements."""
|
||||
start = SESSIONS_JS.index("function navigateSession(dir)")
|
||||
end = SESSIONS_JS.index("}", start) + 1
|
||||
func_body = SESSIONS_JS[start:end]
|
||||
assert ".session-item[data-sid]" in func_body
|
||||
|
||||
|
||||
def test_j_k_keydown_listener_exists():
|
||||
"""J/K keyboard listener is registered on document."""
|
||||
# Find the keyboard listener that checks for j and k keys
|
||||
assert "if(e.key!=='j'&&e.key!=='k')" in SESSIONS_JS
|
||||
|
||||
|
||||
def test_j_k_listener_appears_after_navigateSession():
|
||||
"""J/K listener appears after navigateSession function definition."""
|
||||
nav_start = SESSIONS_JS.index("function navigateSession(dir)")
|
||||
jk_start = SESSIONS_JS.index("if(e.key!=='j'&&e.key!=='k')", nav_start)
|
||||
assert jk_start > nav_start
|
||||
|
||||
|
||||
def test_j_k_listener_prevents_default():
|
||||
"""e.preventDefault() is called before navigateSession in J/K listener."""
|
||||
jk_start = SESSIONS_JS.index("if(e.key!=='j'&&e.key!=='k')")
|
||||
# Extract the J/K listener code block
|
||||
jk_block_end = SESSIONS_JS.index("});", jk_start)
|
||||
jk_block = SESSIONS_JS[jk_start:jk_block_end]
|
||||
|
||||
assert "e.preventDefault()" in jk_block
|
||||
# Verify preventDefault comes before navigateSession call
|
||||
prevent_idx = jk_block.index("e.preventDefault()")
|
||||
nav_idx = jk_block.index("navigateSession")
|
||||
assert prevent_idx < nav_idx
|
||||
|
||||
|
||||
def test_j_k_listener_checks_modifiers():
|
||||
"""J/K listener ignores when Ctrl, Meta, or Alt modifiers are active."""
|
||||
jk_start = SESSIONS_JS.index("if(e.key!=='j'&&e.key!=='k')")
|
||||
jk_block_end = SESSIONS_JS.index("});", jk_start)
|
||||
jk_block = SESSIONS_JS[jk_start:jk_block_end]
|
||||
|
||||
assert "e.ctrlKey" in jk_block
|
||||
assert "e.metaKey" in jk_block
|
||||
assert "e.altKey" in jk_block
|
||||
|
||||
|
||||
def test_j_k_listener_checks_interactive_swipe_target():
|
||||
"""J/K listener checks _isInteractiveSwipeTarget to avoid input fields."""
|
||||
jk_start = SESSIONS_JS.index("if(e.key!=='j'&&e.key!=='k')")
|
||||
jk_block_end = SESSIONS_JS.index("});", jk_start)
|
||||
jk_block = SESSIONS_JS[jk_start:jk_block_end]
|
||||
|
||||
assert "_isInteractiveSwipeTarget" in jk_block
|
||||
assert "typeof _isInteractiveSwipeTarget===" in jk_block
|
||||
|
||||
|
||||
def test_j_navigates_forward():
|
||||
"""Pressing J calls navigateSession with dir=1 (forward)."""
|
||||
jk_start = SESSIONS_JS.index("if(e.key!=='j'&&e.key!=='k')")
|
||||
jk_block_end = SESSIONS_JS.index("});", jk_start)
|
||||
jk_block = SESSIONS_JS[jk_start:jk_block_end]
|
||||
|
||||
assert "navigateSession(e.key==='j'?1:-1)" in jk_block
|
||||
|
||||
|
||||
def test_k_navigates_backward():
|
||||
"""Pressing K calls navigateSession with dir=-1 (backward)."""
|
||||
jk_start = SESSIONS_JS.index("if(e.key!=='j'&&e.key!=='k')")
|
||||
jk_block_end = SESSIONS_JS.index("});", jk_start)
|
||||
jk_block = SESSIONS_JS[jk_start:jk_block_end]
|
||||
|
||||
# The ternary "e.key==='j'?1:-1" encodes both J and K behavior
|
||||
assert "navigateSession(e.key==='j'?1:-1)" in jk_block
|
||||
Reference in New Issue
Block a user