Compare commits
3
Commits
v0.1.6-beta.1
...
v0.1.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63d1e4281c | ||
|
|
b834477bf4 | ||
|
|
65ea32acfb |
No files matched your search
@@ -2,6 +2,13 @@
|
||||
|
||||
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和官网的更新日志里(ShellRS 的更新对话框用「查看更新内容」打开官网的更新日志),用 Markdown 书写,面向使用者。
|
||||
|
||||
## [0.1.6]
|
||||
|
||||
### 新增
|
||||
|
||||
- 外部 CLI 新增 `shellrs exec --terminal`:在 ShellRS 已打开的终端里执行命令,适用于只允许登录一次的堡垒机。
|
||||
- 终端右侧显示滚动条,可拖动翻看历史输出。
|
||||
|
||||
## [0.1.6-beta.1]
|
||||
|
||||
### 新增
|
||||
|
||||
Generated
+1
-1
@@ -7738,7 +7738,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shellrs"
|
||||
version = "0.1.6-beta.1"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shellrs"
|
||||
version = "0.1.6-beta.1"
|
||||
version = "0.1.6"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
description = "SSH client and host manager: terminal, SFTP, port forwarding"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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://`)不当作链接。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,6 +11,7 @@ mod model;
|
||||
mod mouse;
|
||||
mod notices;
|
||||
mod run;
|
||||
mod scrollbar;
|
||||
mod search;
|
||||
mod terminal_panel;
|
||||
mod terminal_view;
|
||||
|
||||
@@ -444,7 +444,11 @@ mod sh {
|
||||
|
||||
// Several lines, and a command killed by a signal.
|
||||
let (found, _) = run_line("cd /\npwd\nkill -TERM $$");
|
||||
assert_eq!(found[1], Scanned::Output(b"/\n".to_vec()));
|
||||
// dash, as the sh that waits for it, also says "Terminated" there.
|
||||
let Scanned::Output(output) = &found[1] else {
|
||||
panic!("{found:?}");
|
||||
};
|
||||
assert!(output.starts_with(b"/\n"), "{found:?}");
|
||||
assert_eq!(found[2], Scanned::End(143));
|
||||
let (found, _) = run_line("kill -INT $$");
|
||||
assert_eq!(found.last(), Some(&Scanned::End(130)));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user