feat: align buffer picker with editor UI
This commit is contained in:
@@ -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.
|
||||
- [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.
|
||||
- [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.
|
||||
- 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.
|
||||
@@ -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.
|
||||
- [ ] 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.
|
||||
- [ ] improve buffer picker and align with the rest of the editor ui/design language.
|
||||
- [ ] pull a pr description and display it in a buffer:
|
||||
- open a selector that allows you to pick a pr from remote
|
||||
- selector should be searchable
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
## File picker and explorer
|
||||
|
||||
+22
-7
@@ -189,6 +189,13 @@ pub struct PaletteEntry {
|
||||
pub bindings: Vec<String>,
|
||||
pub enabled: bool,
|
||||
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)]
|
||||
@@ -5474,16 +5481,17 @@ impl Editor {
|
||||
if palette.buffers {
|
||||
palette.entries.clear();
|
||||
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,
|
||||
if buffer.active { "%" } else { "" },
|
||||
if buffer.dirty { "+" } else { "" },
|
||||
buffer
|
||||
.path
|
||||
.map_or_else(|| Cow::Borrowed("[No Name]"), Path::to_string_lossy),
|
||||
));
|
||||
let searchable = label.to_lowercase();
|
||||
)
|
||||
.to_lowercase();
|
||||
if query
|
||||
.split_whitespace()
|
||||
.all(|token| searchable.contains(token))
|
||||
@@ -5495,6 +5503,11 @@ impl Editor {
|
||||
bindings: Vec::new(),
|
||||
enabled: true,
|
||||
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
|
||||
) || self.document_actions_available()),
|
||||
modes: modes.to_vec(),
|
||||
buffer: None,
|
||||
});
|
||||
}
|
||||
for feature in self.features.values() {
|
||||
@@ -5562,6 +5576,7 @@ impl Editor {
|
||||
bindings: self.palette_bindings(None, command),
|
||||
enabled: self.active.view.mode == Mode::Normal,
|
||||
modes: vec![Mode::Normal],
|
||||
buffer: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+127
-31
@@ -4357,35 +4357,49 @@ fn draw_palette(
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let [_, column, _] = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(area.width.min(100)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(area);
|
||||
let [_, panel, _] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(area.height.min(16)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(column);
|
||||
let panel = if palette.buffers {
|
||||
floating_panel(area)
|
||||
} else {
|
||||
let [_, column, _] = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(area.width.min(100)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(area);
|
||||
let [_, panel, _] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(area.height.min(16)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(column);
|
||||
panel
|
||||
};
|
||||
frame.render_widget(Clear, panel);
|
||||
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()
|
||||
.borders(if bordered {
|
||||
Borders::ALL
|
||||
} else {
|
||||
Borders::NONE
|
||||
})
|
||||
.title(format!(
|
||||
" {} · {mode} ",
|
||||
if palette.buffers {
|
||||
"Buffers"
|
||||
} else {
|
||||
"Commands"
|
||||
}
|
||||
));
|
||||
let content = if bordered { block.inner(panel) } else { panel };
|
||||
.border_style(if palette.buffers {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
})
|
||||
.padding(ratatui::widgets::Padding::horizontal(padding))
|
||||
.title(if palette.buffers {
|
||||
" Buffers ".into()
|
||||
} else {
|
||||
format!(" Commands · {mode} ")
|
||||
});
|
||||
let content = if bordered {
|
||||
block.inner(panel)
|
||||
} else {
|
||||
panel.inner(ratatui::layout::Margin::new(padding, 0))
|
||||
};
|
||||
if bordered {
|
||||
frame.render_widget(block, panel);
|
||||
}
|
||||
@@ -4398,7 +4412,7 @@ fn draw_palette(
|
||||
let query = format!("> {}", palette.query);
|
||||
let query_width = display_column(&query, usize::MAX);
|
||||
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(
|
||||
Paragraph::new(visible_line(&query, offset, width))
|
||||
.style(Style::default().fg(Color::Yellow)),
|
||||
@@ -4422,6 +4436,11 @@ fn draw_palette(
|
||||
};
|
||||
let count = usize::from(results_area.height) / row_height;
|
||||
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);
|
||||
for (index, entry) in palette.entries.iter().enumerate().skip(first).take(count) {
|
||||
let mut style = Style::default();
|
||||
@@ -4431,13 +4450,28 @@ fn draw_palette(
|
||||
if index == palette.selected {
|
||||
style = style.bg(Color::Blue).add_modifier(Modifier::BOLD);
|
||||
}
|
||||
if palette.buffers {
|
||||
if let Some(buffer) = &entry.buffer {
|
||||
lines.push(
|
||||
Line::from(format!(
|
||||
"{} {}",
|
||||
if index == palette.selected { ">" } else { " " },
|
||||
entry.label,
|
||||
))
|
||||
Line::from(vec![
|
||||
Span::raw(if index == palette.selected {
|
||||
"> "
|
||||
} 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),
|
||||
);
|
||||
continue;
|
||||
@@ -4470,9 +4504,16 @@ fn draw_palette(
|
||||
frame.render_widget(Paragraph::new(lines), results_area);
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"{mode} · {navigation_hint} · Enter accept · Esc/Ctrl+p cancel"
|
||||
)),
|
||||
Paragraph::new(if palette.buffers {
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -14419,6 +14460,61 @@ end
|
||||
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]
|
||||
fn palette_query_scrolls_graphemes_tabs_and_wide_cells_on_tiny_offset_areas() {
|
||||
let editor = Editor::open(None, vec![]).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user