SFTP 加图片和 Markdown 预览:双击图片预览,可缩放、原图和滚动

图片(PNG、JPG、GIF、WebP、SVG、BMP、ICO、TIFF)和 Markdown 在一个像快速查看的大对话框里预览,
Esc 关闭,远程文件可以「下载…」,Markdown 可以「编辑」。双击图片就预览;Markdown 和 SVG 是文本,
双击仍是编辑,右键另有「预览」。图片上限 20 MB,Markdown 同编辑器 5 MB。

图片默认适合窗口、居中显示;可以放大、缩小(⌘= / ⌘-,按住 ⌘ 滚动连续缩放)、看原图(⌘0)、
回到适合窗口(⌘9),双击图片在两者之间切换;比预览区大时可以滚动,缩放时中间的位置不动。
解不开的图片写明「无法显示这张图片」。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-05 22:50:02 +08:00
1 parent c679d9d8a0
commit 8e290562f8
21 files changed
+1484 -72

No files matched your search

+318 -3
View File
@@ -1,12 +1,13 @@
//! The built-in editor: opening a file from an SFTP tab, saving it back in
//! place, and what is asked before changes would be lost.
//! The built-in editor and previews: opening a file from an SFTP tab,
//! saving it back in place, what is asked before changes would be lost, and
//! images and Markdown shown in a preview.
use crate::support::*;
use shellrs::app::{
CloseActiveTab, CloseEditor, CloseExplorer, ExplorerAction, ExplorerCommand, Quit,
};
use shellrs::editor::EditorId;
use shellrs::explorer::{NewEntryKind, PaneOperation};
use shellrs::explorer::{IMAGE_LIMIT, NewEntryKind, PaneOperation};
const REMOTE_FILE: &str = "/home/tester/文件 甲.txt";
const OTHER_REMOTE_FILE: &str = "/home/tester/文件 乙.txt";
@@ -548,3 +549,317 @@ async fn closing_all_tabs_asks_once_for_every_unsaved_file(cx: &mut TestAppConte
assert!(!is_open(&workspace, EditorId(2), cx));
assert!(provider.writes.lock().unwrap().is_empty());
}
/// A 1×1 PNG.
const PNG: &[u8] = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\x0d\x49\x44\x41\x54\x78\x9c\x63\xf8\xcf\xc0\xf0\x1f\x00\x05\x00\x01\xff\x89\x99\x3d\x1d\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82";
/// An SFTP tab whose remote side lists `/pictures`, where these files are.
async fn open_pictures(
cx: &mut TestAppContext,
files: &[(&str, &[u8])],
) -> (WindowHandle<Root>, Entity<Workspace>, Arc<FakeSftpProvider>) {
let (handle, workspace, provider) = open_server(cx, files).await;
in_frame(cx, handle, |window, cx| {
window.dispatch_action(
Box::new(ExplorerAction::new(
ExplorerId(SFTP_TAB),
ExplorerCommand::Navigate {
remote: true,
path: "/pictures".into(),
},
)),
cx,
)
});
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("remote-path").value() == Some("/pictures")
})
.await;
(handle, workspace, provider)
}
fn preview(cx: &mut TestAppContext, handle: WindowHandle<Root>, remote: bool, path: &str) {
let path = path.to_string();
in_frame(cx, handle, move |window, cx| {
window.dispatch_action(
Box::new(ExplorerAction::new(
ExplorerId(SFTP_TAB),
ExplorerCommand::Preview {
remote,
path: Some(path),
},
)),
cx,
)
});
}
#[gpui_kit::test]
async fn double_clicking_an_image_previews_it(cx: &mut TestAppContext) {
let (handle, workspace, provider) = open_pictures(cx, &[("/pictures/图.png", PNG)]).await;
open_file(cx, handle, "remote-pane", "图.png");
wait_for(cx, handle, "file-preview").await;
in_frame(cx, handle, |window, _| {
assert_eq!(window.find("file-preview").label(), Some("图.png"));
assert!(window.try_find("preview-image").is_some());
assert!(
window
.find("preview-info")
.label()
.is_some_and(|info| info.ends_with("70 B"))
);
// A remote image can be downloaded; it cannot be edited.
assert!(window.try_find("preview-download").is_some());
assert!(window.try_find("preview-edit").is_none());
});
assert!(!is_open(&workspace, FIRST, cx));
assert_eq!(*provider.reads.lock().unwrap(), vec!["/pictures/图.png"]);
in_frame(cx, handle, |window, cx| window.press("escape", cx));
in_frame(cx, handle, |window, _| {
assert!(window.try_find("file-preview").is_none())
});
}
#[gpui_kit::test]
async fn markdown_is_edited_on_a_double_click_and_previewed_from_the_menu(cx: &mut TestAppContext) {
let (handle, workspace, _) = open_pictures(
cx,
&[("/pictures/README.md", "# 标题\n\n正文\n".as_bytes())],
)
.await;
open_file(cx, handle, "remote-pane", "README.md");
wait_for_editor(cx, handle, FIRST).await;
preview(cx, handle, true, "/pictures/README.md");
wait_for(cx, handle, "preview-markdown").await;
in_frame(cx, handle, |window, _| {
assert!(
window
.find("preview-info")
.label()
.is_some_and(|info| info.starts_with("Markdown"))
);
});
// 编辑 goes to the editor already open, not a second one.
in_frame(cx, handle, |window, cx| window.click("preview-edit", cx));
in_frame(cx, handle, |window, _| {
assert!(window.try_find("file-preview").is_none());
assert!(window.try_find(("editor", FIRST.0)).is_some());
});
assert!(!is_open(&workspace, EditorId(2), cx));
// 下载… asks where to, as a download does.
preview(cx, handle, true, "/pictures/README.md");
wait_for(cx, handle, "preview-download").await;
in_frame(cx, handle, |window, cx| {
window.click("preview-download", cx)
});
wait_for(cx, handle, "download-confirm").await;
}
#[gpui_kit::test]
async fn an_image_too_large_to_preview_offers_the_download(cx: &mut TestAppContext) {
let large = vec![0; IMAGE_LIMIT as usize + 1];
let (handle, _, _) = open_pictures(cx, &[("/pictures/图.png", &large)]).await;
open_file(cx, handle, "remote-pane", "图.png");
wait_for(cx, handle, "ok").await;
in_frame(cx, handle, |window, _| {
assert!(window.try_find("file-preview").is_none())
});
in_frame(cx, handle, |window, cx| window.click("ok", cx));
wait_for(cx, handle, "download-confirm").await;
}
#[gpui_kit::test]
async fn a_local_image_is_previewed_without_a_download(cx: &mut TestAppContext) {
let local = FakeLocalDirectory::default();
local
.files
.lock()
.unwrap()
.insert("/local/tester/图.png".into(), PNG.to_vec());
let (handle, _) =
open_workspace_with_services(cx, Arc::new(FakeSftpProvider::default()), local);
open_test_explorer(cx, handle).await;
preview(cx, handle, false, "/local/tester/图.png");
wait_for(cx, handle, "file-preview").await;
in_frame(cx, handle, |window, _| {
assert!(window.try_find("preview-image").is_some());
assert!(window.try_find("preview-download").is_none());
});
}
/// A 4×2000 PNG: a long screenshot, in small, taller than any frame.
const LONG_PNG: &[u8] = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x04\x00\x00\x07\xd0\x08\x06\x00\x00\x00\xe5\xf9\xfe\xc9\x00\x00\x00\x6e\x49\x44\x41\x54\x78\xda\xed\xc8\xb1\x0d\x00\x00\x08\xc0\xa0\xfe\xff\xb4\xee\x7e\x60\xc2\xc0\x42\xd5\x1c\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x08\x21\x84\x10\x42\x88\x9f\xb1\xc4\x5f\x22\x92\xa1\xbb\x97\x39\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82";
/// The preview's zoom, once the image is decoded and the frame measured.
async fn wait_for_zoom(cx: &mut TestAppContext, handle: WindowHandle<Root>, zoom: &'static str) {
cx.wait_for(handle.into(), Duration::from_secs(2), move |window, cx| {
window.render_frame(cx);
window
.try_find("preview-zoom")
.is_some_and(|label| label.label() == Some(zoom))
})
.await;
}
fn bounds_of(
cx: &mut TestAppContext,
handle: WindowHandle<Root>,
id: &'static str,
) -> gpui_kit::Bounds<gpui_kit::Pixels> {
in_frame(cx, handle, |window, _| window.find(id).bounds())
}
fn scroll_preview(cx: &mut TestAppContext, handle: WindowHandle<Root>, by: f32, zoom: bool) {
in_frame(cx, handle, |window, cx| {
let position = window.find("preview-image").bounds().center();
window.dispatch_event(
gpui_kit::ScrollWheelEvent {
position,
delta: gpui_kit::ScrollDelta::Pixels(point(px(0.), px(by))),
modifiers: if zoom {
gpui_kit::Modifiers::secondary_key()
} else {
gpui_kit::Modifiers::default()
},
touch_phase: gpui_kit::TouchPhase::Moved,
}
.to_platform_input(),
cx,
);
});
}
#[gpui_kit::test]
async fn a_previewed_image_fits_its_frame_in_the_middle(cx: &mut TestAppContext) {
let (handle, _, _) = open_pictures(
cx,
&[("/pictures/长图.png", LONG_PNG), ("/pictures/点.png", PNG)],
)
.await;
// A long image: scaled down to the frame's height, in its middle.
open_file(cx, handle, "remote-pane", "长图.png");
wait_for_zoom(cx, handle, "28%").await;
let frame = bounds_of(cx, handle, "preview-image");
let picture = bounds_of(cx, handle, "preview-picture");
in_frame(cx, handle, |window, _| {
assert!(
window
.find("preview-info")
.label()
.is_some_and(|info| info.starts_with("4 × 2000 像素"))
);
});
assert!(
(picture.size.height - frame.size.height).abs() < px(1.),
"{picture:?} in {frame:?}"
);
assert!(
(picture.center().x - frame.center().x).abs() < px(1.),
"{picture:?} in {frame:?}"
);
in_frame(cx, handle, |window, cx| window.press("escape", cx));
// A small one keeps its size, in the middle both ways.
open_file(cx, handle, "remote-pane", "点.png");
wait_for_zoom(cx, handle, "100%").await;
let frame = bounds_of(cx, handle, "preview-image");
let picture = bounds_of(cx, handle, "preview-picture");
assert_eq!(picture.size, size(px(1.), px(1.)));
assert!((picture.center().x - frame.center().x).abs() < px(1.));
assert!((picture.center().y - frame.center().y).abs() < px(1.));
}
#[gpui_kit::test]
async fn a_previewed_image_zooms_and_scrolls(cx: &mut TestAppContext) {
let (handle, _, _) = open_pictures(cx, &[("/pictures/长图.png", LONG_PNG)]).await;
open_file(cx, handle, "remote-pane", "长图.png");
wait_for_zoom(cx, handle, "28%").await;
// 原图: its own size, larger than the frame, from its middle.
in_frame(cx, handle, |window, cx| {
window.click("preview-actual-size", cx)
});
wait_for_zoom(cx, handle, "100%").await;
let frame = bounds_of(cx, handle, "preview-image");
let picture = bounds_of(cx, handle, "preview-picture");
assert_eq!(picture.size.height, px(2000.));
assert!((picture.center().y - frame.center().y).abs() < px(1.));
// It scrolls.
scroll_preview(cx, handle, -300., false);
let scrolled = bounds_of(cx, handle, "preview-picture");
assert_eq!(scrolled.top(), picture.top() - px(300.));
// The buttons and the keys step the zoom.
in_frame(cx, handle, |window, cx| window.click("preview-zoom-in", cx));
wait_for_zoom(cx, handle, "125%").await;
in_frame(cx, handle, |window, cx| window.press("cmd--", cx));
wait_for_zoom(cx, handle, "100%").await;
in_frame(cx, handle, |window, cx| window.press("cmd-9", cx));
wait_for_zoom(cx, handle, "28%").await;
in_frame(cx, handle, |window, cx| {
window.click("preview-zoom-out", cx)
});
wait_for_zoom(cx, handle, "25%").await;
in_frame(cx, handle, |window, cx| window.press("cmd-0", cx));
wait_for_zoom(cx, handle, "100%").await;
in_frame(cx, handle, |window, cx| window.click("preview-fit", cx));
wait_for_zoom(cx, handle, "28%").await;
// A double click goes to 100% and back.
in_frame(cx, handle, |window, cx| {
window.double_click("preview-picture", cx)
});
wait_for_zoom(cx, handle, "100%").await;
in_frame(cx, handle, |window, cx| {
window.double_click("preview-picture", cx)
});
wait_for_zoom(cx, handle, "28%").await;
// ⌘ with the wheel zooms instead of scrolling.
scroll_preview(cx, handle, 100., true);
in_frame(cx, handle, |window, _| {
assert_eq!(window.find("preview-zoom").label(), Some("42%"));
});
}
#[gpui_kit::test]
async fn an_svg_is_edited_on_a_double_click_and_previewed_from_the_menu(cx: &mut TestAppContext) {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="20"><rect width="10" height="20" fill="red"/></svg>"#;
let (handle, workspace, _) = open_pictures(cx, &[("/pictures/logo.svg", svg.as_bytes())]).await;
// An SVG is text: a double click edits it.
open_file(cx, handle, "remote-pane", "logo.svg");
wait_for_editor(cx, handle, FIRST).await;
assert_eq!(text_of(&workspace, FIRST, cx), svg);
in_frame(cx, handle, |window, _| {
assert!(window.try_find("file-preview").is_none())
});
preview(cx, handle, true, "/pictures/logo.svg");
wait_for(cx, handle, "file-preview").await;
in_frame(cx, handle, |window, _| {
assert!(window.try_find("preview-image").is_some());
// The editor is open already; the preview offers no second way in.
assert!(window.try_find("preview-edit").is_none());
});
}
#[gpui_kit::test]
async fn an_image_that_cannot_be_decoded_says_so(cx: &mut TestAppContext) {
let (handle, _, _) = open_pictures(cx, &[("/pictures/坏.png", b"not a png at all")]).await;
open_file(cx, handle, "remote-pane", "坏.png");
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window
.try_find("preview-note")
.is_some_and(|note| note.label() == Some("无法显示这张图片"))
})
.await;
in_frame(cx, handle, |window, _| {
assert!(window.try_find("preview-picture").is_none())
});
}
+1 -1
View File
@@ -37,7 +37,7 @@ pub use shellrs::host::{
pub use shellrs::secrets::{InMemorySecretStore, SecretRef, SecretStore as _};
pub use shellrs::settings::{Appearance, InterfaceLanguage, SettingsStore};
pub use shellrs::sftp::{
DirectoryEntry, DirectoryListing, EDIT_LIMIT, EntryKind, FileMetadata, FileStamp,
DirectoryEntry, DirectoryListing, EDIT_LIMIT, EntryKind, FileBytes, FileMetadata, FileStamp,
LocalDirectoryProvider, ReadFailure, RemotePath, SaveFailure, SftpCommand, SftpEvent,
SftpTransport, SftpTransportProvider, TextFile, UploadRequest,
};
+43
View File
@@ -119,6 +119,8 @@ impl SftpTransport for FakeSftpTransport {
let result = match path.as_str() {
"/denied" => Err("权限不足".into()),
"/empty" => Ok(DirectoryListing::new("/empty", vec![])),
// The files put there, as `with_files` gave them.
"/pictures" => Ok(files_listing(&self.files, "/pictures")),
_ => Ok(fake_listing(path.as_str())),
};
events.send_blocking(SftpEvent::Listed { request_id, result })?;
@@ -160,6 +162,24 @@ impl SftpTransport for FakeSftpTransport {
};
events.send_blocking(SftpEvent::FileRead { request_id, result })?;
}
SftpCommand::ReadBytes {
request_id,
path,
limit,
} => {
self.reads.lock().unwrap().push(path.to_string());
if path.as_str().starts_with("/slow") {
continue;
}
let result = match self.files.lock().unwrap().get(path.as_str()) {
Some((bytes, _)) if bytes.len() as u64 > limit => {
Err(ReadFailure::TooLarge(bytes.len() as u64))
}
Some((bytes, _)) => Ok(FileBytes::new(bytes.clone())),
None => Err(ReadFailure::Failed("文件不存在".into())),
};
events.send_blocking(SftpEvent::BytesRead { request_id, result })?;
}
SftpCommand::WriteFile {
request_id,
path,
@@ -330,6 +350,29 @@ impl LocalDirectoryProvider for FakeLocalDirectory {
}
}
/// A directory of the fake server's files, as they are now.
pub fn files_listing(files: &FakeFiles, path: &str) -> DirectoryListing {
let prefix = format!("{path}/");
let entries = files
.lock()
.unwrap()
.iter()
.filter_map(|(file, (bytes, modified))| {
let name = file.strip_prefix(&prefix)?;
Some(DirectoryEntry::new(
name,
FileMetadata::new(
EntryKind::File,
bytes.len() as u64,
Some(*modified),
Some(0o644),
),
))
})
.collect();
DirectoryListing::new(path, entries)
}
pub fn fake_listing(path: &str) -> DirectoryListing {
DirectoryListing::new(
path,