feat: align buffer picker with editor UI

This commit is contained in:
2026-10-13 16:11:10 +02:00
parent f5ba61624f
commit 9d6a0eaeca
4 changed files with 153 additions and 39 deletions
+2 -1
View File
@@ -31,6 +31,8 @@
- descendant-only jj/Git checkout tracking, frozen Before, inactive-tab refresh, and `config.diff_follow_working_copy` opt-out; preserve explorer/layout choices and unsaved buffers. - descendant-only jj/Git checkout tracking, frozen Before, inactive-tab refresh, and `config.diff_follow_working_copy` opt-out; preserve explorer/layout choices and unsaved buffers.
- [x] buffer picker (with search). - [x] buffer picker (with search).
- `:buffers` / Space b l opens a searchable picker; shared selection bindings, stable IDs, dirty/active marks, cross-tab buffers, and cancellation without switching. - `:buffers` / Space b l opens a searchable picker; shared selection bindings, stable IDs, dirty/active marks, cross-tab buffers, and cancellation without switching.
- [x] improve buffer picker and align with the rest of the editor ui/design language.
- shared floating-panel geometry, cyan border, padded search, aligned IDs/status columns, colored active/dirty marks, blue selection, and dimmed controls; preserve tiny-terminal navigation and switching.
- [ ] sandboxed, revision-specific LSP analysis for historical diff states and historical tabs. - [ ] sandboxed, revision-specific LSP analysis for historical diff states and historical tabs.
- current limitation: immutable snapshot buffers still use the live repository as their LSP workspace; cross-file analysis can mix historical text with current disk contents. - current limitation: immutable snapshot buffers still use the live repository as their LSP workspace; cross-file analysis can mix historical text with current disk contents.
- lazily materialize the complete selected revision, including unchanged files, manifests, and project configuration; run a separate LSP instance rooted in that snapshot. - lazily materialize the complete selected revision, including unchanged files, manifests, and project configuration; run a separate LSP instance rooted in that snapshot.
@@ -44,7 +46,6 @@
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files. - design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
- [ ] remove the diff palette polling fallback once a released Herdr fix delivers palette-ready DEC 2031 notifications, including palette-only changes with an unchanged light/dark preference. Related upstream issue: [herdrdev/herdr#4183](https://github.com/herdrdev/herdr/issues/4183). - [ ] remove the diff palette polling fallback once a released Herdr fix delivers palette-ready DEC 2031 notifications, including palette-only changes with an unchanged light/dark preference. Related upstream issue: [herdrdev/herdr#4183](https://github.com/herdrdev/herdr/issues/4183).
- verify that notification-triggered queries return fresh colors in Ghostty through Herdr before removal; retain startup, focus-regain, and notification-driven palette refresh. - verify that notification-triggered queries return fresh colors in Ghostty through Herdr before removal; retain startup, focus-regain, and notification-driven palette refresh.
- [ ] improve buffer picker and align with the rest of the editor ui/design language.
- [ ] pull a pr description and display it in a buffer: - [ ] pull a pr description and display it in a buffer:
- open a selector that allows you to pick a pr from remote - open a selector that allows you to pick a pr from remote
- selector should be searchable - selector should be searchable
+2
View File
@@ -290,6 +290,8 @@ Switching from Insert commits its pending undo group and parks that view in Norm
**Space b l**, `:buffers`, or the **Buffers** command-palette action opens the picker. Type to filter IDs and paths with case-insensitive, whitespace-separated substring tokens; every token must match. Results retain ascending ID order. Ctrl+j/Ctrl+n/Down and Ctrl+k/Up select with wraparound, using the shared Selection bindings and overrides. Backspace edits the query; Ctrl+u clears it. Enter switches only the focused pane to the selected buffer without saving or discarding edits; no matches leave the picker open. Esc or Ctrl+p cancels without switching. Printable keys, including `:` and leader keys, belong to the query. **Space b l**, `:buffers`, or the **Buffers** command-palette action opens the picker. Type to filter IDs and paths with case-insensitive, whitespace-separated substring tokens; every token must match. Results retain ascending ID order. Ctrl+j/Ctrl+n/Down and Ctrl+k/Up select with wraparound, using the shared Selection bindings and overrides. Backspace edits the query; Ctrl+u clears it. Enter switches only the focused pane to the selected buffer without saving or discarding edits; no matches leave the picker open. Esc or Ctrl+p cancels without switching. Printable keys, including `:` and leader keys, belong to the query.
The picker uses the file explorer's centered floating-panel geometry, a cyan border, padded search input, and dimmed controls. Buffer IDs and status columns align across rows: `%` is cyan for the active buffer, `+` is yellow for unsaved changes, and the selected row is blue. Narrow or short terminals drop padding/borders as needed while keeping the query cursor and selected result visible whenever a result row fits.
The picker includes ordinary buffers from every tab and unnamed buffers as `[No Name]`; immutable diff snapshots are excluded. Labels sanitize path control characters, but selection uses stable buffer IDs, so displayed-name collisions still select the correct buffer. Switching out of a diff follows ordinary `:buffer N` behavior. The picker includes ordinary buffers from every tab and unnamed buffers as `[No Name]`; immutable diff snapshots are excluded. Labels sanitize path control characters, but selection uses stable buffer IDs, so displayed-name collisions still select the correct buffer. Switching out of a diff follows ordinary `:buffer N` behavior.
## File picker and explorer ## File picker and explorer
+22 -7
View File
@@ -189,6 +189,13 @@ pub struct PaletteEntry {
pub bindings: Vec<String>, pub bindings: Vec<String>,
pub enabled: bool, pub enabled: bool,
pub modes: Vec<Mode>, pub modes: Vec<Mode>,
pub buffer: Option<PaletteBuffer>,
}
pub struct PaletteBuffer {
pub id: usize,
pub active: bool,
pub dirty: bool,
} }
#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] #[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
@@ -5474,16 +5481,17 @@ impl Editor {
if palette.buffers { if palette.buffers {
palette.entries.clear(); palette.entries.clear();
for buffer in self.buffers() { for buffer in self.buffers() {
let label = sanitize_label(format!( let label = sanitize_label(buffer.path.map_or_else(
"{}{}{} {}", || "[No Name]".into(),
|path| path.to_string_lossy().into_owned(),
));
let searchable = format!(
"{}{}{} {label}",
buffer.id, buffer.id,
if buffer.active { "%" } else { "" }, if buffer.active { "%" } else { "" },
if buffer.dirty { "+" } else { "" }, if buffer.dirty { "+" } else { "" },
buffer )
.path .to_lowercase();
.map_or_else(|| Cow::Borrowed("[No Name]"), Path::to_string_lossy),
));
let searchable = label.to_lowercase();
if query if query
.split_whitespace() .split_whitespace()
.all(|token| searchable.contains(token)) .all(|token| searchable.contains(token))
@@ -5495,6 +5503,11 @@ impl Editor {
bindings: Vec::new(), bindings: Vec::new(),
enabled: true, enabled: true,
modes: Vec::new(), modes: Vec::new(),
buffer: Some(PaletteBuffer {
id: buffer.id,
active: buffer.active,
dirty: buffer.dirty,
}),
}); });
} }
} }
@@ -5543,6 +5556,7 @@ impl Editor {
Action::LspDefinition | Action::LspHover | Action::LspDiagnostics Action::LspDefinition | Action::LspHover | Action::LspDiagnostics
) || self.document_actions_available()), ) || self.document_actions_available()),
modes: modes.to_vec(), modes: modes.to_vec(),
buffer: None,
}); });
} }
for feature in self.features.values() { for feature in self.features.values() {
@@ -5562,6 +5576,7 @@ impl Editor {
bindings: self.palette_bindings(None, command), bindings: self.palette_bindings(None, command),
enabled: self.active.view.mode == Mode::Normal, enabled: self.active.view.mode == Mode::Normal,
modes: vec![Mode::Normal], modes: vec![Mode::Normal],
buffer: None,
}); });
} }
} }
+127 -31
View File
@@ -4357,35 +4357,49 @@ fn draw_palette(
if area.width == 0 || area.height == 0 { if area.width == 0 || area.height == 0 {
return; return;
} }
let [_, column, _] = Layout::horizontal([ let panel = if palette.buffers {
Constraint::Fill(1), floating_panel(area)
Constraint::Length(area.width.min(100)), } else {
Constraint::Fill(1), let [_, column, _] = Layout::horizontal([
]) Constraint::Fill(1),
.areas(area); Constraint::Length(area.width.min(100)),
let [_, panel, _] = Layout::vertical([ Constraint::Fill(1),
Constraint::Fill(1), ])
Constraint::Length(area.height.min(16)), .areas(area);
Constraint::Fill(1), let [_, panel, _] = Layout::vertical([
]) Constraint::Fill(1),
.areas(column); Constraint::Length(area.height.min(16)),
Constraint::Fill(1),
])
.areas(column);
panel
};
frame.render_widget(Clear, panel); frame.render_widget(Clear, panel);
let bordered = panel.height >= 4 && panel.width >= 4; let bordered = panel.height >= 4 && panel.width >= 4;
let padding =
u16::from(palette.buffers && panel.width.saturating_sub(2 * u16::from(bordered)) >= 3);
let block = Block::default() let block = Block::default()
.borders(if bordered { .borders(if bordered {
Borders::ALL Borders::ALL
} else { } else {
Borders::NONE Borders::NONE
}) })
.title(format!( .border_style(if palette.buffers {
" {} · {mode} ", Style::default().fg(Color::Cyan)
if palette.buffers { } else {
"Buffers" Style::default()
} else { })
"Commands" .padding(ratatui::widgets::Padding::horizontal(padding))
} .title(if palette.buffers {
)); " Buffers ".into()
let content = if bordered { block.inner(panel) } else { panel }; } else {
format!(" Commands · {mode} ")
});
let content = if bordered {
block.inner(panel)
} else {
panel.inner(ratatui::layout::Margin::new(padding, 0))
};
if bordered { if bordered {
frame.render_widget(block, panel); frame.render_widget(block, panel);
} }
@@ -4398,7 +4412,7 @@ fn draw_palette(
let query = format!("> {}", palette.query); let query = format!("> {}", palette.query);
let query_width = display_column(&query, usize::MAX); let query_width = display_column(&query, usize::MAX);
let width = usize::from(query_area.width); let width = usize::from(query_area.width);
let offset = query_width.saturating_sub(width - 1); let offset = query_width.saturating_sub(width.saturating_sub(1));
frame.render_widget( frame.render_widget(
Paragraph::new(visible_line(&query, offset, width)) Paragraph::new(visible_line(&query, offset, width))
.style(Style::default().fg(Color::Yellow)), .style(Style::default().fg(Color::Yellow)),
@@ -4422,6 +4436,11 @@ fn draw_palette(
}; };
let count = usize::from(results_area.height) / row_height; let count = usize::from(results_area.height) / row_height;
let first = palette.selected.saturating_sub(count - 1); let first = palette.selected.saturating_sub(count - 1);
let id_width = palette
.entries
.last()
.and_then(|entry| entry.buffer.as_ref())
.map_or(1, |buffer| buffer.id.ilog10() as usize + 1);
let mut lines = Vec::with_capacity(count * row_height); let mut lines = Vec::with_capacity(count * row_height);
for (index, entry) in palette.entries.iter().enumerate().skip(first).take(count) { for (index, entry) in palette.entries.iter().enumerate().skip(first).take(count) {
let mut style = Style::default(); let mut style = Style::default();
@@ -4431,13 +4450,28 @@ fn draw_palette(
if index == palette.selected { if index == palette.selected {
style = style.bg(Color::Blue).add_modifier(Modifier::BOLD); style = style.bg(Color::Blue).add_modifier(Modifier::BOLD);
} }
if palette.buffers { if let Some(buffer) = &entry.buffer {
lines.push( lines.push(
Line::from(format!( Line::from(vec![
"{} {}", Span::raw(if index == palette.selected {
if index == palette.selected { ">" } else { " " }, "> "
entry.label, } else {
)) " "
}),
Span::styled(
format!("{:>id_width$} ", buffer.id),
Style::default().fg(Color::DarkGray),
),
Span::styled(
if buffer.active { "%" } else { " " },
Style::default().fg(Color::Cyan),
),
Span::styled(
if buffer.dirty { "+ " } else { " " },
Style::default().fg(Color::Yellow),
),
Span::raw(&entry.label),
])
.style(style), .style(style),
); );
continue; continue;
@@ -4470,9 +4504,16 @@ fn draw_palette(
frame.render_widget(Paragraph::new(lines), results_area); frame.render_widget(Paragraph::new(lines), results_area);
} }
frame.render_widget( frame.render_widget(
Paragraph::new(format!( Paragraph::new(if palette.buffers {
"{mode} · {navigation_hint} · Enter accept · Esc/Ctrl+p cancel" format!("{navigation_hint} · Enter open · Esc/^P cancel · ^U clear")
)), } else {
format!("{mode} · {navigation_hint} · Enter accept · Esc/Ctrl+p cancel")
})
.style(if palette.buffers {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
}),
help_area, help_area,
); );
} }
@@ -14419,6 +14460,61 @@ end
assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.mode(), Mode::Insert);
} }
#[test]
fn buffer_picker_keeps_selected_status_and_paths_visible_after_resize_and_filter() {
let mut editor = Editor::open(None, vec![]).unwrap();
for id in 2..=12 {
editor.execute(&format!("edit picker-{id}.txt"));
}
editor
.apply(vec![crate::editor::Effect::Replace("unsaved".into())])
.unwrap();
editor.execute("buffers");
editor.handle(Input::Ctrl('k'));
for (width, height) in [(80, 12), (32, 6), (1, 1), (4, 4), (80, 12)] {
let area = Rect::new(3, 2, width, height);
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(90, 30),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
terminal
.draw(|frame| {
draw_palette(
frame,
editor.palette().unwrap(),
area,
editor.mode(),
editor.selection_navigation_hint(),
)
})
.unwrap();
assert!(area.contains(terminal.get_cursor_position().unwrap()));
if width >= 32 {
let buffer = terminal.backend().buffer();
let selected_y = (area.y..area.bottom())
.find(|&y| buffer[(area.x + 7, y)].symbol() == "%")
.unwrap();
assert_eq!(buffer[(area.x + 7, selected_y)].fg, Color::Cyan);
assert_eq!(buffer[(area.x + 8, selected_y)].symbol(), "+");
assert_eq!(buffer[(area.x + 8, selected_y)].fg, Color::Yellow);
assert_eq!(buffer[(area.x + 10, selected_y)].bg, Color::Blue);
assert!(
pane_screen(&terminal, Rect::new(area.x, selected_y, width, 1))
.contains("picker-12.txt")
);
}
}
editor.handle(Input::Char('2'));
editor.handle(Input::Ctrl('j'));
editor.handle(Input::Enter);
assert_eq!(editor.buffer_id(), 12);
assert_eq!(editor.context().text, "unsaved");
assert!(editor.dirty());
}
#[test] #[test]
fn palette_query_scrolls_graphemes_tabs_and_wide_cells_on_tiny_offset_areas() { fn palette_query_scrolls_graphemes_tabs_and_wide_cells_on_tiny_offset_areas() {
let editor = Editor::open(None, vec![]).unwrap(); let editor = Editor::open(None, vec![]).unwrap();