feat(kebab-tui): p9-fb-09 external editor return — terminal restore
Search `g` 키 (citation jump) 후 TUI 화면이 깨지는 버그 수정. 도그푸딩
item 7 — `g` 로 vim 띄우고 `:q` 후 복귀하면 이전 frame 의 잔상이 새
draw 위에 겹쳐 보였음.
## 핵심 변경
- **`kebab-tui::editor::with_external_program(&mut TuiTerminal, Command)`**
helper 추가. suspend / spawn / restore 시퀀스를 RAII guard 로 atomic
하게 묶어 panic 발생해도 raw mode + alt screen 복구 보장:
1. LeaveAlternateScreen + Show cursor + disable_raw_mode
2. Command::status() 로 child 실행
3. enable_raw_mode + EnterAlternateScreen + Hide cursor +
`terminal.clear()` ← 이 한 줄이 핵심 fix
- **`App.pending_editor: Option<EditorRequest>`** 추가. 키 핸들러
(현재 `kebab-tui::search::handle_key_search` 의 `g`) 가 직접 spawn
하는 대신 EditorRequest 를 enqueue, 실제 spawn 은 run loop 가
`TuiTerminal` 핸들 in scope 일 때 처리.
- **`App.force_redraw: bool`** ratchet. with_external_program 종료 후
set, run loop draw 직전 check → terminal.clear() 후 reset. editor
외 다른 향후 use case (config reload, theme change 등) 도 같은 hook
사용 가능.
## 가시성 정리
`with_external_program` / `jump_to_citation` 은 `pub(crate)` 로 좁혀짐
— `TuiTerminal` 자체가 module-private (raw mode + alt screen 의 안전
한 lifecycle 은 `Drop` 만 보장) 이므로 외부 caller 는 `App.pending_
editor` enqueue 패턴으로만 spawn 요청 가능. 외부 surface (`build_jump_
command`, `handle_key_search`, `render_search`) 는 그대로.
## 테스트
- `unspawnable_program_surfaces_program_name_in_error` — helper 의 spawn
실패 경로 (ENOENT) error context 검증
- `g_key_enqueues_pending_editor_request` — `g` on hit → EditorRequest
enqueue, citation 정보 보존
- `g_key_with_no_hits_does_not_enqueue` — empty hits → no-op
- 기존 17 개 search 테스트 + 14 lib + 18 ask + 12 inspect + 10 library
모두 통과
- `cargo clippy -p kebab-tui --all-targets -- -D warnings` clean
## 문서
- README: `kebab tui` 행에 Search `g` 동작 + 자동 redraw 안내
- HANDOFF: 2026-05-03 머지 후 발견 entry
- spec status: `planned` → `in_progress`
후속 task (p9-fb-20 의 citation jump in TUI Ask 등) 가 같은
`pending_editor` queue + `with_external_program` helper 위에 build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -287,6 +287,54 @@ fn shift_g_does_not_trigger_editor_jump() {
|
||||
assert_eq!(app.search.as_ref().unwrap().input, "G");
|
||||
}
|
||||
|
||||
/// p9-fb-09 — `g` on a hit enqueues an `EditorRequest` on `App.pending_editor`
|
||||
/// rather than spawning the child synchronously. The run loop services the
|
||||
/// queue with the `TuiTerminal` handle in scope so the post-resume
|
||||
/// `terminal.clear()` can land (preventing the corrupted-redraw bug).
|
||||
#[test]
|
||||
fn g_key_enqueues_pending_editor_request() {
|
||||
let mut app = fresh_app();
|
||||
{
|
||||
let s = app.search.as_mut().unwrap();
|
||||
s.hits = vec![make_hit(1, "notes/x.md", "snippet", line_citation("notes/x.md", 42))];
|
||||
s.selected_hit = 0;
|
||||
}
|
||||
assert!(app.pending_editor.is_none(), "queue starts empty");
|
||||
let outcome = handle_key_search(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(outcome, KeyOutcome::Continue);
|
||||
let req = app
|
||||
.pending_editor
|
||||
.as_ref()
|
||||
.expect("g on a hit must enqueue an EditorRequest");
|
||||
match &req.citation {
|
||||
Citation::Line { path, start, .. } => {
|
||||
assert_eq!(path.0, "notes/x.md");
|
||||
assert_eq!(*start, 42);
|
||||
}
|
||||
other => panic!("unexpected citation variant: {other:?}"),
|
||||
}
|
||||
// editor_env reads $EDITOR — fall back to "vi" for tests.
|
||||
assert!(!req.editor_env.is_empty(), "editor_env must be populated");
|
||||
}
|
||||
|
||||
/// p9-fb-09 — `g` with no hits is a no-op; the queue stays empty.
|
||||
#[test]
|
||||
fn g_key_with_no_hits_does_not_enqueue() {
|
||||
let mut app = fresh_app();
|
||||
// Search slot present, hits empty.
|
||||
let _outcome = handle_key_search(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(
|
||||
app.pending_editor.is_none(),
|
||||
"g with no hits must not enqueue"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_search_state_returns_to_library() {
|
||||
let mut config = Config::defaults();
|
||||
|
||||
Reference in New Issue
Block a user