终端加上滚动条:翻看回滚历史时出现在右侧,可拖动、点轨道跳转

用 gpui-kit 的 Scrollbar,配一个按回滚行数换算像素的 ScrollbarHandle
(terminal/scrollbar.rs)。拖动的回调没有 context,经引擎给出的
Scrollback 直接滚动终端,再由滚动条通知视图重画。滚动条盖在整个边距上,
滑块大多落在文字旁边;全屏程序没有回滚,不显示。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-08 18:23:22 +08:00
1 parent 65ea32acfb
commit b834477bf4
7 files changed
+198 -1

No files matched your search

+1
View File
@@ -193,6 +193,7 @@ Remote terminal tabs and SFTP tabs use the host’s system badge as their icon (
The terminal’s context menu has “Copy”, “Paste”, “Find…” and “Clear screen”; a remote terminal’s also has “Open SFTP”, “Reconnect” and “Disconnect”. “Disconnect” disconnects only this tab: the tab stays and can reconnect at any time, and other tabs and SFTP for the same host aren’t affected.
- **Scrollback**: each terminal keeps the last 10,000 lines. Scroll up with the wheel or trackpad and a scrollbar appears on the right (as in the app’s other lists, following the system’s “Show scroll bars” setting); drag it, or click the track to jump there. Full-screen programs such as vim, less and top have no scrollback and show no scrollbar.
- **Find** (⌘F, or Ctrl+Shift+F on Windows and Linux): opens the find bar at the top right of the terminal and searches the screen and the scrollback for the literal text. An all-lowercase query ignores case; one with capital letters matches case. All matches are highlighted, the current one darker, and the find bar shows “current/total”. Enter or ⌘G (F3) goes to the next match (newer output), Shift+Enter or ⌘⇧G (Shift+F3) to the previous one (older output), wrapping around at the ends. Esc closes it. If text is selected when it opens, that text becomes the query.
- **Clear screen** (⌘K, or Ctrl+Shift+K on Windows and Linux; when the terminal doesn’t have focus, ⌘K still focuses the sidebar’s search box): clears the screen and the scrollback, leaving only the prompt line the cursor is on, including anything typed but not yet entered. Clearing happens only locally; nothing is sent to the server. It isn’t available while a full-screen program such as vim or top is running.
- **Links**: `http://` and `https://` addresses in the output, and links that programs mark explicitly (such as `ls --hyperlink` and gcc’s error messages), are shown in blue and underlined. Hold ⌘ (Ctrl on Windows and Linux) and click to open one in the system browser; a plain click still selects text. Rest the pointer on a link for a moment to see its real address: for a link a program marks, the text shown can differ from the address. A trailing period, comma or unmatched closing parenthesis isn’t part of the address, and a long address wrapped over two lines is still one link. Other schemes (such as `file://`) aren’t treated as links.
+1
View File
@@ -199,6 +199,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
右键终端内容区可复制、粘贴、查找、清屏;远程终端另有打开 SFTP、重新连接和断开连接。断开连接只断开这一个标签,标签保留,可随时重新连接,同一台主机的其他标签和 SFTP 不受影响。
- **回滚历史**:每个终端保留最近 10000 行。用滚轮或触控板往上翻看,这时右侧出现滚动条(和应用里其他列表一样,按系统的「显示滚动条」设置),可以拖动,点轨道直接跳到那个位置。vim、less、top 这类全屏程序没有回滚历史,不显示滚动条。
- **查找**(⌘F,其他平台 Ctrl+Shift+F):在终端右上角打开查找条,按字面量搜索屏幕和回滚历史。查询全为小写时不区分大小写,含大写字母时区分。所有匹配高亮显示,当前项颜色更深,查找条显示「当前项/总数」。回车或 ⌘G(F3)跳到下一项(更新的输出),Shift+回车或 ⌘⇧G(Shift+F3)跳到上一项(更早的输出),到头后循环。Esc 关闭。打开时如有选中文字,会作为查询词。
- **清屏**(⌘K,其他平台 Ctrl+Shift+K;焦点不在终端时 ⌘K 仍是聚焦侧栏的搜索框):清掉屏幕和回滚历史,只留下光标所在的提示符行,包括已输入但未回车的内容。清屏只在本地进行,不向服务器发送任何内容。vim、top 等全屏程序运行时不可用。
- **链接**:输出里的 `http://`、`https://` 地址,以及程序明确标出的链接(如 `ls --hyperlink`、gcc 的报错),显示为带下划线的蓝色。按住 ⌘(其他平台 Ctrl)单击,用系统浏览器打开;不按时单击照旧是选择文字。鼠标在链接上停一会儿会显示它真正的地址:程序标出的链接,显示的文字可以和地址不同。地址末尾的句号、逗号和不成对的右括号不算在内;被折成两行的长地址仍是一个链接。其他协议(如 `file://`)不当作链接。
+45
View File
@@ -63,6 +63,8 @@ pub struct TerminalSnapshot {
pub cursor_shape: CursorShape,
pub cursor_blinking: bool,
pub display_offset: usize,
/// The lines above the screen, which the view can scroll back through.
pub history_size: usize,
pub colors: [Option<Rgb>; COUNT],
/// The addresses of the links on screen, by the cells' `link`.
pub links: Vec<gpui_kit::SharedString>,
@@ -360,6 +362,11 @@ impl TerminalEngine {
cx.notify();
}
/// Scrolls the view without a context, for the scrollbar's drag.
pub fn scrollback(&self) -> Scrollback {
Scrollback(self.runtime.term.clone())
}
pub fn start_selection(
&mut self,
point: Point,
@@ -588,12 +595,35 @@ impl TerminalEngine {
cursor_shape: content.cursor.shape,
cursor_blinking: term.cursor_style().blinking,
display_offset: content.display_offset,
history_size: term.grid().history_size(),
colors: std::array::from_fn(|index| content.colors[index]),
links: links.into_links(),
}
}
}
/// A terminal's view of its history, for the scrollbar, whose drag hands
/// over a position and no context: whoever draws the terminal is told to
/// redraw it. Taken for each frame, as a restart replaces the terminal.
#[derive(Clone)]
pub struct Scrollback(Arc<FairMutex<AlacrittyTerm>>);
impl Scrollback {
/// Show the history from `lines` below its oldest line.
pub fn scroll_to(&self, lines: usize) {
scroll_to(&mut self.0.lock(), lines);
}
}
/// Lines are counted from the top, which stays put while output arrives
/// below: the view stays where the pointer let it go.
fn scroll_to(term: &mut AlacrittyTerm, lines: usize) {
let grid = term.grid();
let offset = grid.history_size().saturating_sub(lines);
let delta = offset as i32 - grid.display_offset() as i32;
term.scroll_display(Scroll::Delta(delta));
}
/// The text of the line the cursor is on, or of the nearest line above it
/// on screen that has any: where a program that rings for an answer has
/// its question.
@@ -1592,6 +1622,21 @@ mod tests {
assert_eq!(cells[2].search, SearchMark::None);
}
#[test]
fn the_scrollbar_counts_lines_from_the_top_of_the_history() {
let mut term = test_term(8, 2);
feed(&mut term, "1\r\n2\r\n3\r\n4\r\n5".as_bytes());
assert_eq!(term.grid().history_size(), 3);
scroll_to(&mut term, 0);
assert_eq!(term.grid().display_offset(), 3);
scroll_to(&mut term, 2);
assert_eq!(term.grid().display_offset(), 1);
// Past the bottom is the bottom.
scroll_to(&mut term, 10);
assert_eq!(term.grid().display_offset(), 0);
}
#[test]
fn user_input_returns_to_bottom_and_clears_selection() {
let mut term = test_term(8, 2);
+1
View File
@@ -11,6 +11,7 @@ mod model;
mod mouse;
mod notices;
mod run;
mod scrollbar;
mod search;
mod terminal_panel;
mod terminal_view;
+103
View File
@@ -0,0 +1,103 @@
//! The terminal's scrollbar: gpui-kit's, over the scrollback.
//!
//! The grid is drawn afresh each frame and scrolls by whole lines, so there
//! is no scroll handle for gpui-kit's `Scrollbar` to follow. This one gives
//! it the history in pixels: a line is a row's height, the content is the
//! scrollbar's own height plus the history, and the offset is how far the
//! view is from the oldest line.
use std::cell::Cell;
use std::rc::Rc;
use gpui_kit::component::scroll::ScrollbarHandle;
use gpui_kit::{Bounds, Pixels, Point, Size, point, px, size};
use super::TerminalSnapshot;
use super::engine::Scrollback;
#[derive(Clone)]
pub(super) struct ScrollbackHandle {
scrollback: Scrollback,
history_size: usize,
display_offset: usize,
line_height: Pixels,
/// Where the scrollbar lies, which the canvas under it notes before the
/// scrollbar is laid out in the same frame.
viewport: Rc<Cell<Bounds<Pixels>>>,
}
impl ScrollbackHandle {
pub(super) fn new(
scrollback: Scrollback,
snapshot: &TerminalSnapshot,
line_height: Pixels,
) -> Self {
Self {
scrollback,
history_size: snapshot.history_size,
display_offset: snapshot.display_offset,
line_height,
viewport: Rc::default(),
}
}
pub(super) fn set_viewport(&self, bounds: Bounds<Pixels>) {
self.viewport.set(bounds);
}
}
impl ScrollbarHandle for ScrollbackHandle {
fn viewport_bounds(&self) -> Bounds<Pixels> {
self.viewport.get()
}
fn offset(&self) -> Point<Pixels> {
point(
px(0.),
offset(self.history_size, self.display_offset, self.line_height),
)
}
fn set_offset(&self, offset: Point<Pixels>) {
self.scrollback
.scroll_to(lines_from_top(offset.y, self.line_height));
}
fn content_size(&self) -> Size<Pixels> {
let viewport = self.viewport.get().size;
size(
viewport.width,
viewport.height + self.line_height * self.history_size as f32,
)
}
}
/// How far the view is scrolled down from the oldest line, as gpui-kit
/// counts it: zero at the top, negative below.
fn offset(history_size: usize, display_offset: usize, line_height: Pixels) -> Pixels {
-(line_height * history_size.saturating_sub(display_offset) as f32)
}
/// The line at the top of the view for a scroll `offset`.
fn lines_from_top(offset: Pixels, line_height: Pixels) -> usize {
(-offset / line_height).round().max(0.) as usize
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_offset_counts_whole_lines_from_the_oldest() {
let line = px(17.);
// At the bottom of 100 lines of history, 100 lines down.
assert_eq!(offset(100, 0, line), px(-1700.));
// At the top.
assert_eq!(offset(100, 100, line), px(0.));
assert_eq!(lines_from_top(offset(100, 40, line), line), 60);
// A drag lands between lines; past the top is the top.
assert_eq!(lines_from_top(px(-25.), line), 1);
assert_eq!(lines_from_top(px(-26.), line), 2);
assert_eq!(lines_from_top(px(5.), line), 0);
}
}
+28 -1
View File
@@ -14,6 +14,7 @@ use gpui_kit::component::{
h_flex,
input::{Input, InputEvent, InputState},
menu::PopupMenu,
scroll::Scrollbar,
tooltip::Tooltip,
v_flex,
};
@@ -29,6 +30,7 @@ use crate::connection::{ConnectionPromptReply, Latency};
use crate::i18n::{UiLocale, t};
use super::mouse::{self, MouseReport, ReportButton, ReportKind};
use super::scrollbar::ScrollbackHandle;
use super::search::SearchMark;
use super::{
SearchDirection, SharedTerminalTransportFactory, TerminalColors, TerminalEngine, TerminalEvent,
@@ -915,6 +917,12 @@ impl EntityInputHandler for TerminalView {
impl Render for TerminalView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let snapshot = self.engine.read(cx).snapshot();
let font = TerminalFont::current(cx);
let scrollback = ScrollbackHandle::new(
self.engine.read(cx).scrollback(),
&snapshot,
font.row_height(window),
);
let view = cx.entity();
let context_menu = self.context_menu.clone();
let element = TerminalElement {
@@ -927,7 +935,6 @@ impl Render for TerminalView {
requested_size: self.requested_size,
};
let font = TerminalFont::current(cx);
let terminal = div()
.id(self.element_id.clone())
.test_support()
@@ -984,6 +991,26 @@ impl Render for TerminalView {
// the terminal's too.
.bg(TerminalColors::current(cx).background())
.child(terminal)
// Over the whole margin, so the thumb lies mostly beside the text.
.child(
div()
.absolute()
.inset_0()
.child(
canvas(
{
let scrollback = scrollback.clone();
move |bounds, _, _| scrollback.set_viewport(bounds)
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.child(Scrollbar::vertical(&scrollback).id("terminal-scrollbar")),
)
.when_some(self.find.as_ref(), |container, find| {
container.child(self.render_find_bar(find, cx))
})
+19
View File
@@ -921,6 +921,25 @@ async fn programs_that_ask_for_the_mouse_hear_clicks_and_the_wheel(cx: &mut Test
take_written(cx, handle, &factory, "\x1b[<64;5;2M").await;
}
#[gpui_kit::test]
async fn the_scrollbar_scrolls_back_through_the_history(cx: &mut TestAppContext) {
let text: String = (1..=200).map(|line| format!("line {line}\r\n")).collect();
let (handle, workspace, _) = terminal_printing(cx, String::leak(text + "end"), "end").await;
let screen = |cx: &mut TestAppContext| cx.read(|cx| local_screen(&workspace, cx));
assert!(!screen(cx).lines().any(|line| line == "line 1"));
// A click at the top of the scrollbar's track, in the margin beside the
// text, goes to the oldest line.
in_frame(cx, handle, |window, cx| {
let terminal = window.find(("local-terminal", 1_u64)).bounds();
let top = point(terminal.right() + px(2.), terminal.top() + px(2.));
click_at(window, top, gpui_kit::Modifiers::none(), cx);
});
let lines = screen(cx);
assert!(lines.lines().any(|line| line == "line 1"), "{lines}");
assert!(!lines.contains("end"), "{lines}");
}
#[gpui_kit::test]
async fn a_program_copies_to_the_local_clipboard(cx: &mut TestAppContext) {
// 「你好」 in base64.