Files
shell-rs/src/explorer/file_edit.rs
T
XuzandClaude Opus 5.5 8e290562f8 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>
2026-10-05 22:50:02 +08:00

476 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Files for the editor and for previews: an SFTP tab reads them and writes
//! the remote ones back over its own connection, so neither ever logs in
//! again.
//!
//! A file is read before any editor tab or preview exists: one that cannot
//! be shown (too large, binary, not UTF-8) is explained here and never
//! flashes a tab.
use super::preview::{IMAGE_LIMIT, Preview, PreviewContent, PreviewKind, open_preview_dialog};
use super::{ExplorerPanel, ExplorerPanelEvent, format_size};
use crate::app::{ExplorerAction, ExplorerCommand, ExplorerDispatch as _};
use crate::host::ConnectionState;
use crate::sftp::{
EDIT_LIMIT, FileBytes, FileStamp, ReadFailure, RemotePath, SaveFailure, SftpCommand, TextFile,
read_local_bytes, read_local_text,
};
use futures::channel::oneshot;
use gpui_kit::component::{WindowExt as _, dialog::DialogButtonProps, notification::Notification};
use gpui_kit::*;
use std::path::PathBuf;
use std::rc::Rc;
/// Where a file to edit lives: on the server of an SFTP tab, or here.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum FileLocation {
Remote(RemotePath),
Local(PathBuf),
}
impl FileLocation {
pub fn is_remote(&self) -> bool {
matches!(self, FileLocation::Remote(_))
}
/// The file's own name.
pub fn name(&self) -> String {
match self {
FileLocation::Remote(path) => path.file_name().to_string(),
FileLocation::Local(path) => path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string()),
}
}
/// The full path, as a pane shows paths.
pub fn path(&self) -> String {
match self {
FileLocation::Remote(path) => path.to_string(),
FileLocation::Local(path) => path.to_string_lossy().into_owned(),
}
}
/// The directory the file is in, as a pane shows paths.
pub fn directory(&self) -> String {
match self {
FileLocation::Remote(path) => path.parent().to_string(),
FileLocation::Local(path) => path
.parent()
.map(|parent| parent.to_string_lossy().into_owned())
.unwrap_or_default(),
}
}
}
/// A read or a write waiting for the worker, answered from `on_event`.
pub(super) enum PendingFile {
Read(oneshot::Sender<Result<TextFile, ReadFailure>>),
Bytes(oneshot::Sender<Result<FileBytes, ReadFailure>>),
Write(oneshot::Sender<Result<FileStamp, SaveFailure>>),
}
/// Why a file is read: for the editor, or for a preview of its kind.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Opening {
Edit,
Preview(PreviewKind),
}
/// A file as read for an `Opening`: text, or an image's bytes.
enum Opened {
Text(TextFile),
Bytes(FileBytes),
}
impl ExplorerPanel {
/// The file `path` names on one side, or the one under that side's
/// cursor; `None` for a directory or nothing.
pub fn edit_target(&self, remote: bool, path: Option<&str>, cx: &App) -> Option<FileLocation> {
let pane = self.pane(remote).read(cx);
let path = match path {
Some(path) => path.to_string(),
None => {
let entry = pane.cursor_entry(cx)?;
if entry.is_dir() || entry.is_parent() {
return None;
}
pane.child_path_of(&entry.name)
}
};
Some(if remote {
FileLocation::Remote(RemotePath::new(path).ok()?)
} else {
FileLocation::Local(PathBuf::from(path))
})
}
/// The file `path` names on one side, or the one under the cursor,
/// when it can be previewed.
pub fn preview_target(
&self,
remote: bool,
path: Option<&str>,
cx: &App,
) -> Option<(FileLocation, PreviewKind)> {
let location = self.edit_target(remote, path, cx)?;
let kind = PreviewKind::of(&location.name())?;
Some((location, kind))
}
/// Read a file for a new editor tab or a preview. `EditFile` brings one
/// for the editor to the workspace; a preview opens here; a refusal is
/// explained here.
pub(super) fn open_file(
&mut self,
location: FileLocation,
opening: Opening,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.opening.insert(location.clone()) {
return;
}
let read = match opening {
Opening::Preview(PreviewKind::Image(_)) => self
.read_bytes(&location, IMAGE_LIMIT, window, cx)
.map(|read| cx.spawn(async move |_, _| read.await.map(Opened::Bytes))),
Opening::Edit | Opening::Preview(PreviewKind::Markdown) => self
.read_file(&location, window, cx)
.map(|read| cx.spawn(async move |_, _| read.await.map(Opened::Text))),
};
let Some(read) = read else {
self.opening.remove(&location);
return;
};
let remote = location.is_remote();
let name = location.name();
self.pane(remote)
.clone()
.update(cx, |pane, cx| pane.begin_opening(name, cx));
cx.spawn_in(window, async move |this, cx| {
let result = read.await;
let _ = this.update_in(cx, |this, window, cx| {
this.opening.remove(&location);
this.pane(remote)
.clone()
.update(cx, |pane, cx| pane.finish_opening(cx));
match (opening, result) {
(Opening::Edit, Ok(Opened::Text(file))) => cx.emit(
ExplorerPanelEvent::EditFile(this.id(), location, Rc::new(file)),
),
(Opening::Preview(kind), Ok(opened)) => {
this.show_preview(&location, kind, opened, window, cx)
}
(Opening::Edit, Ok(Opened::Bytes(_))) => {}
(_, Err(failure)) => this.refuse_open(&location, opening, failure, window, cx),
}
});
})
.detach();
}
/// Read a whole text file. `None` when the remote side is not connected,
/// which this tab has already told the user about.
pub fn read_file(
&mut self,
location: &FileLocation,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<Result<TextFile, ReadFailure>>> {
match location {
FileLocation::Local(path) => {
let (provider, path) = (self.local_provider.clone(), path.clone());
Some(cx.background_spawn(async move { read_local_text(provider.as_ref(), &path) }))
}
FileLocation::Remote(path) => {
if !self.reachable(window, cx) {
return None;
}
let (sender, receiver) = oneshot::channel();
let request_id = self.next_file_request(PendingFile::Read(sender));
self.send(SftpCommand::ReadFile {
request_id,
path: path.clone(),
});
Some(cx.spawn(async move |_, _| {
receiver
.await
.unwrap_or_else(|_| Err(ReadFailure::Failed("SFTP 连接已断开".into())))
}))
}
}
}
/// Read a whole file as it is, up to `limit` bytes. `None` when the
/// remote side is not connected, which this tab has already said.
pub(super) fn read_bytes(
&mut self,
location: &FileLocation,
limit: u64,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<Result<FileBytes, ReadFailure>>> {
match location {
FileLocation::Local(path) => {
let (provider, path) = (self.local_provider.clone(), path.clone());
Some(cx.background_spawn(async move {
read_local_bytes(provider.as_ref(), &path, limit)
}))
}
FileLocation::Remote(path) => {
if !self.reachable(window, cx) {
return None;
}
let (sender, receiver) = oneshot::channel();
let request_id = self.next_file_request(PendingFile::Bytes(sender));
self.send(SftpCommand::ReadBytes {
request_id,
path: path.clone(),
limit,
});
Some(cx.spawn(async move |_, _| {
receiver
.await
.unwrap_or_else(|_| Err(ReadFailure::Failed("SFTP 连接已断开".into())))
}))
}
}
}
/// Write bytes over a remote file in place. `None` when not connected,
/// which this tab has already told the user about.
pub fn write_remote_file(
&mut self,
path: RemotePath,
bytes: Vec<u8>,
expected: Option<FileStamp>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<Result<FileStamp, SaveFailure>>> {
if !self.reachable(window, cx) {
return None;
}
let (sender, receiver) = oneshot::channel();
let request_id = self.next_file_request(PendingFile::Write(sender));
self.send(SftpCommand::WriteFile {
request_id,
path,
bytes,
expected,
});
Some(cx.spawn(async move |_, _| {
// The connection dropped before the answer: the write may have
// stopped halfway.
receiver
.await
.unwrap_or_else(|_| Err(SaveFailure::Interrupted("SFTP 连接已断开".into())))
}))
}
/// Re-read a pane that shows the directory `location` is in, so its
/// size and time follow a save.
pub fn reload_if_showing(
&mut self,
location: &FileLocation,
window: &mut Window,
cx: &mut Context<Self>,
) {
let remote = location.is_remote();
if remote && self.connection_state() != ConnectionState::Connected {
return;
}
if self.pane(remote).read(cx).path() == location.directory() {
self.reload(remote, window, cx);
}
}
pub(super) fn finish_file_read(&mut self, id: u64, result: Result<TextFile, ReadFailure>) {
if let Some(PendingFile::Read(sender)) = self.files.remove(&id) {
let _ = sender.send(result);
}
}
pub(super) fn finish_bytes_read(&mut self, id: u64, result: Result<FileBytes, ReadFailure>) {
if let Some(PendingFile::Bytes(sender)) = self.files.remove(&id) {
let _ = sender.send(result);
}
}
pub(super) fn finish_file_write(&mut self, id: u64, result: Result<FileStamp, SaveFailure>) {
if let Some(PendingFile::Write(sender)) = self.files.remove(&id) {
let _ = sender.send(result);
}
}
/// The remote side is connected; otherwise say why and offer to
/// reconnect.
fn reachable(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
match self.connection_state() {
ConnectionState::Connected => true,
ConnectionState::Disconnected => {
self.offer_reconnect(window, cx);
false
}
ConnectionState::Connecting => {
window.push_notification(Notification::warning("正在连接 SFTP,请稍候再试。"), cx);
false
}
}
}
fn next_file_request(&mut self, pending: PendingFile) -> u64 {
self.next_operation += 1;
self.files.insert(self.next_operation, pending);
self.next_operation
}
/// The preview dialog for a file just read.
fn show_preview(
&mut self,
location: &FileLocation,
kind: PreviewKind,
opened: Opened,
window: &mut Window,
cx: &mut Context<Self>,
) {
let remote = location.is_remote();
let (content, size) = match (kind, opened) {
(PreviewKind::Image(format), Opened::Bytes(bytes)) => {
let size = bytes.len() as u64;
let image = Image::from_bytes(format, bytes.into_inner());
(PreviewContent::Image(image), size)
}
(PreviewKind::Markdown, Opened::Text(file)) => {
let size = file.stamp().size();
let (text, _, _) = file.into_parts();
(PreviewContent::Markdown(text.into()), size)
}
_ => return,
};
let id = self.id();
let download = self.download_action(location, cx);
let edit = (kind == PreviewKind::Markdown).then(|| {
ExplorerAction::new(
id,
ExplorerCommand::Edit {
remote,
path: Some(location.path()),
},
)
});
open_preview_dialog(
Preview {
name: location.name(),
size,
content,
download: download.map(|command| ExplorerAction::new(id, command)),
edit,
dispatch: self.dispatch.clone(),
},
window,
cx,
);
}
/// 下载… for a remote file, into the local pane's directory.
fn download_action(&self, location: &FileLocation, cx: &App) -> Option<ExplorerCommand> {
match location {
FileLocation::Remote(path) => Some(ExplorerCommand::DownloadPaths {
paths: vec![path.to_string()],
target: self.local().read(cx).path(),
}),
FileLocation::Local(_) => None,
}
}
/// Why a file was not opened. Too large, binary or not UTF-8 is a
/// decision to make (a remote file can still be downloaded); anything
/// else is an error.
fn refuse_open(
&mut self,
location: &FileLocation,
opening: Opening,
failure: ReadFailure,
window: &mut Window,
cx: &mut Context<Self>,
) {
let name = location.name();
let (title, what, limit) = match opening {
Opening::Edit => (format!("无法在编辑器中打开“{name}”"), "编辑器", EDIT_LIMIT),
Opening::Preview(PreviewKind::Image(_)) => {
(format!("无法预览“{name}”"), "预览", IMAGE_LIMIT)
}
Opening::Preview(PreviewKind::Markdown) => {
(format!("无法预览“{name}”"), "预览", EDIT_LIMIT)
}
};
let reason = match &failure {
ReadFailure::TooLarge(size) => format!(
"文件有 {},{what}只打开 {} 以内的文件。",
format_size(*size),
format_size(limit)
),
ReadFailure::NotText => {
"它不是 UTF-8 编码的文本文件:可能是二进制文件,或用了 GBK 等其他编码。".into()
}
ReadFailure::NotFile => "它不是普通文件。".into(),
ReadFailure::Failed(message) => {
window.push_notification(
Notification::error(message.clone()).title(format!("无法打开“{name}”")),
cx,
);
return;
}
};
let download = self.download_action(location, cx);
let description = if download.is_some() {
format!("{reason}可以下载到本机,用别的程序打开。")
} else {
reason
};
let (dispatch, id) = (self.dispatch.clone(), self.id());
window.open_alert_dialog(cx, move |alert, _, _| {
let alert = alert.title(title.clone()).description(description.clone());
match download.clone() {
Some(download) => alert
.button_props(
DialogButtonProps::default()
.ok_text("下载…")
.cancel_text("取消"),
)
.show_cancel(true)
.on_ok({
let dispatch = dispatch.clone();
move |_, window, cx| {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(id, download.clone()),
window,
cx,
);
true
}
}),
None => alert.button_props(DialogButtonProps::default().ok_text("知道了")),
}
});
}
}
#[cfg(test)]
mod tests {
use super::FileLocation;
use crate::sftp::RemotePath;
use std::path::PathBuf;
#[test]
fn a_location_names_its_file_and_directory_as_a_pane_does() {
let remote = FileLocation::Remote(RemotePath::new("/etc/nginx/nginx.conf").unwrap());
assert_eq!(remote.name(), "nginx.conf");
assert_eq!(remote.directory(), "/etc/nginx");
let top = FileLocation::Remote(RemotePath::new("/hosts").unwrap());
assert_eq!(top.directory(), "/");
let local = FileLocation::Local(PathBuf::from("/Users/me/notes.md"));
assert_eq!(local.name(), "notes.md");
assert_eq!(local.directory(), "/Users/me");
assert_eq!(local.path(), "/Users/me/notes.md");
}
}