Files
shell-rs/src/explorer/transfer_queue.rs
T
XuzandClaude 341ae93618 重构:删除死代码、合并重复逻辑,迁移链压成一份 schema,修正重试时的总字节数
功能不变的精简(src 净减约 1200 行)
- 死代码:没人调用的 Workspace 构造函数、终端引擎 / 视图的 Changed 事件、
  append_line 一串、known_hosts_path、若干无人调用的访问器和字段,以及
  no_json 动作上用不到的 Deserialize
- 上传 / 下载各写一遍的进度记账抽成 TransferMeter(sftp/meter.rs);两个
  确认框合成一个 TransferForm;面板和目录行的拖放处理合成 accept_drops
- ExplorerCommand 里只做转手的 6 个变体收成 Enqueue(TransferJob) 和
  Operate { remote, operation }
- SessionStore 的写库报错收成 persist,拖动排序的分组 / 会话分支共用
  reordered;认证和主机密钥提示的回调共用一个闭包;5 个动作宏合成
  id_actions!;表单对话框的页脚和错误行抽到 shared/dialog.rs
- CLI 服务端的 serve 改为 respond 返回 Result
- 去掉 TerminalPrompt* 别名层,统一用 connection::Connection*;
  WorkspaceStatus 改为枚举;SSH 认证函数去掉只有一个实现的泛型
- 上传续传记录不再接受从未写出过的 version 1 格式

数据库
- SCHEMA_V1…V7 和七级迁移压成一份完整的 SCHEMA,新库直接建成当前结构。
  版本号保持 7,已有的库照常识别;版本 1–6 的旧库不再能打开(项目尚未
  发布)

修正
- 目录扫描失败后点「重试」,total_bytes 不再多减目录自身的大小(上传和
  下载都是),补了两个回归测试
- UI 测试 canceling_unknown_host_prompt_keeps_session_disconnected 等
  传输线程处理完取消再断言,去掉一处线程竞态

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-30 20:21:29 +08:00

485 lines
16 KiB
Rust

//! The SFTP tab's transfer queue, as WinSCP keeps one: batches waiting
//! their turn, the one being transferred, and the ones that have ended.
//! Plain bookkeeping; the panel hands batches to the engine, which runs one
//! at a time and knows nothing of what waits behind it.
use std::{collections::HashSet, path::PathBuf};
use crate::sftp::{TransferDirection, TransferPhase, TransferProgress};
/// Why a batch left the queue before it was done.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Removal {
/// 移出队列 after it stopped.
TakenOff,
/// 丢弃续传进度.
Discarded,
/// It could not be handed to the engine.
NotStarted(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct QueueId(pub u64);
/// What a batch copies, as the user confirmed it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TransferJob {
Upload { paths: Vec<PathBuf>, target: String },
Download { paths: Vec<String>, target: String },
}
impl TransferJob {
pub fn direction(&self) -> TransferDirection {
match self {
TransferJob::Upload { .. } => TransferDirection::Upload,
TransferJob::Download { .. } => TransferDirection::Download,
}
}
/// The 来源 column: the one item, the folder the items share, or
/// 多个位置.
pub fn source_label(&self) -> String {
let (paths, folders): (Vec<String>, Vec<String>) = match self {
TransferJob::Upload { paths, .. } => paths
.iter()
.map(|path| {
(
path.display().to_string(),
path.parent()
.map(|folder| folder.display().to_string())
.unwrap_or_default(),
)
})
.unzip(),
TransferJob::Download { paths, .. } => paths
.iter()
.map(|path| {
let folder = match path.rsplit_once('/') {
Some(("", _)) => "/".to_string(),
Some((folder, _)) => folder.to_string(),
None => String::new(),
};
(path.clone(), folder)
})
.unzip(),
};
match (paths.as_slice(), folders.first()) {
([one], _) => one.clone(),
(_, Some(first)) if folders.iter().all(|folder| folder == first) => first.clone(),
_ => "多个位置".to_string(),
}
}
/// The 目标 column.
pub fn target(&self) -> &str {
match self {
TransferJob::Upload { target, .. } | TransferJob::Download { target, .. } => target,
}
}
}
/// Where a batch stands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QueueState {
/// Waiting its turn.
Pending,
/// Handed to the engine: scanning, transferring, waiting on a question
/// or reconnecting.
Active,
/// Stopped with its progress kept. It holds the head of the queue until
/// the user resumes it, discards it or takes it off the queue: the
/// engine keeps it for 继续, and starting another batch would drop it.
Stopped,
/// Done, some items perhaps skipped or failed.
Done,
/// Taken off the queue after it stopped, or its progress discarded.
Removed,
}
impl QueueState {
pub fn is_finished(self) -> bool {
matches!(self, QueueState::Done | QueueState::Removed)
}
}
pub struct QueueEntry {
id: QueueId,
job: TransferJob,
started: bool,
removed: Option<Removal>,
progress: Option<TransferProgress>,
}
impl QueueEntry {
pub fn id(&self) -> QueueId {
self.id
}
pub fn job(&self) -> &TransferJob {
&self.job
}
/// The engine's last word on this batch.
pub fn progress(&self) -> Option<&TransferProgress> {
self.progress.as_ref()
}
pub fn state(&self) -> QueueState {
if self.removed.is_some() {
return QueueState::Removed;
}
match self.progress.as_ref().map(TransferProgress::phase) {
None if !self.started => QueueState::Pending,
Some(TransferPhase::Stopped) => QueueState::Stopped,
Some(TransferPhase::Completed) => QueueState::Done,
_ => QueueState::Active,
}
}
/// The 进度 column in words, for everything but a running transfer's
/// bar, which shows the percentage beside it.
pub fn status(&self) -> String {
let progress = self.progress.as_ref();
match self.state() {
QueueState::Pending => "等待中".into(),
QueueState::Active => match progress.map(TransferProgress::phase) {
None | Some(TransferPhase::Scanning) => "正在扫描".into(),
Some(TransferPhase::Waiting) => "等待回答".into(),
Some(TransferPhase::Reconnecting) => progress
.and_then(TransferProgress::note)
.unwrap_or("正在重连")
.into(),
Some(_) => percent(progress.map_or(0.0, TransferProgress::fraction)),
},
QueueState::Stopped => "已停止".into(),
QueueState::Done => match progress.map_or(0, TransferProgress::failed) {
0 => "已完成".into(),
failed => format!("部分失败({failed} 项)"),
},
QueueState::Removed => match &self.removed {
Some(Removal::Discarded) => "已丢弃续传进度".into(),
Some(Removal::NotStarted(reason)) => format!("无法开始:{reason}"),
_ => "已移出".into(),
},
}
}
}
/// `0.294` as `29%`.
pub fn percent(fraction: f32) -> String {
format!("{}%", (fraction * 100.0).floor() as u32)
}
#[derive(Default)]
pub struct TransferQueue {
entries: Vec<QueueEntry>,
next_id: u64,
selected: Option<QueueId>,
expanded: HashSet<QueueId>,
}
impl TransferQueue {
pub fn push(&mut self, job: TransferJob) -> QueueId {
self.next_id += 1;
let id = QueueId(self.next_id);
self.entries.push(QueueEntry {
id,
job,
started: false,
removed: None,
progress: None,
});
id
}
pub fn entries(&self) -> &[QueueEntry] {
&self.entries
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn entry(&self, id: QueueId) -> Option<&QueueEntry> {
self.entries.iter().find(|entry| entry.id == id)
}
/// The batch the engine has: running, or stopped and kept for 继续.
pub fn head(&self) -> Option<&QueueEntry> {
self.entries
.iter()
.find(|entry| matches!(entry.state(), QueueState::Active | QueueState::Stopped))
}
fn head_mut(&mut self) -> Option<&mut QueueEntry> {
self.entries
.iter_mut()
.find(|entry| matches!(entry.state(), QueueState::Active | QueueState::Stopped))
}
/// The next batch to hand the engine, now marked as started: the first
/// one waiting, unless another still holds the head.
pub fn start_next(&mut self) -> Option<(QueueId, TransferJob)> {
if self.head().is_some() {
return None;
}
let entry = self
.entries
.iter_mut()
.find(|entry| entry.state() == QueueState::Pending)?;
entry.started = true;
Some((entry.id, entry.job.clone()))
}
/// The engine reports on the batch it has, which is the head. A report
/// with no head, such as the empty one after 丢弃续传进度, is dropped.
pub fn on_progress(&mut self, progress: TransferProgress) {
if let Some(entry) = self.head_mut() {
entry.progress = Some(progress);
}
}
/// The head leaves the queue: it could not be handed over, or its
/// progress is being discarded.
pub fn abandon_head(&mut self, why: Removal) {
if let Some(entry) = self.head_mut() {
entry.removed = Some(why);
}
}
/// What 移出队列 acts on: the selected batch unless it is running, else
/// a stopped head.
pub fn removable(&self) -> Option<QueueId> {
let selected = self
.selected
.and_then(|id| self.entry(id))
.filter(|entry| entry.state() != QueueState::Active);
selected
.or_else(|| {
self.head()
.filter(|entry| entry.state() == QueueState::Stopped)
})
.map(QueueEntry::id)
}
/// Take a batch off the queue. One waiting or ended goes; a stopped head
/// stays listed as taken off, and the queue moves on past it. A running
/// batch has to stop first.
pub fn remove(&mut self, id: QueueId) -> bool {
let Some(index) = self.entries.iter().position(|entry| entry.id == id) else {
return false;
};
match self.entries[index].state() {
QueueState::Active => false,
QueueState::Stopped => {
self.entries[index].removed = Some(Removal::TakenOff);
true
}
_ => {
self.entries.remove(index);
self.expanded.remove(&id);
true
}
}
}
pub fn has_finished(&self) -> bool {
self.entries.iter().any(|entry| entry.state().is_finished())
}
/// 清除已完成.
pub fn clear_finished(&mut self) {
let expanded = &mut self.expanded;
self.entries.retain(|entry| {
let keep = !entry.state().is_finished();
if !keep {
expanded.remove(&entry.id);
}
keep
});
}
/// Batches not yet ended: waiting, running or stopped.
pub fn unfinished_count(&self) -> usize {
self.entries
.iter()
.filter(|entry| !entry.state().is_finished())
.count()
}
/// Which way the batch at the head goes, or the next one, for questions
/// and the close confirmation.
pub fn direction(&self) -> Option<TransferDirection> {
self.head()
.or_else(|| {
self.entries
.iter()
.find(|entry| entry.state() == QueueState::Pending)
})
.map(|entry| entry.job.direction())
}
pub fn select(&mut self, id: QueueId) {
self.selected = Some(id);
}
pub fn is_selected(&self, id: QueueId) -> bool {
self.selected == Some(id)
}
pub fn toggle_expanded(&mut self, id: QueueId) {
if !self.expanded.remove(&id) {
self.expanded.insert(id);
}
}
pub fn is_expanded(&self, id: QueueId) -> bool {
self.expanded.contains(&id)
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::{QueueId, QueueState, Removal, TransferJob, TransferQueue};
use crate::sftp::{TransferDetail, TransferPhase, TransferProgress};
fn upload(name: &str) -> TransferJob {
TransferJob::Upload {
paths: vec![PathBuf::from(format!("/local/{name}"))],
target: "/srv".into(),
}
}
fn states(queue: &TransferQueue) -> Vec<QueueState> {
queue.entries().iter().map(|entry| entry.state()).collect()
}
fn report(queue: &mut TransferQueue, phase: TransferPhase) {
queue.on_progress(TransferProgress::new(phase));
}
#[test]
fn batches_run_one_after_another() {
let mut queue = TransferQueue::default();
let first = queue.push(upload("a"));
let second = queue.push(upload("b"));
assert_eq!(states(&queue), [QueueState::Pending, QueueState::Pending]);
assert_eq!(queue.start_next().map(|(id, _)| id), Some(first));
// The first one holds the head until it is done.
assert_eq!(queue.start_next(), None);
report(&mut queue, TransferPhase::Transferring);
assert_eq!(states(&queue), [QueueState::Active, QueueState::Pending]);
assert_eq!(queue.unfinished_count(), 2);
report(&mut queue, TransferPhase::Completed);
assert_eq!(queue.start_next().map(|(id, _)| id), Some(second));
report(&mut queue, TransferPhase::Completed);
assert_eq!(states(&queue), [QueueState::Done, QueueState::Done]);
assert_eq!(queue.unfinished_count(), 0);
// The empty report 丢弃续传进度 answers with goes nowhere.
report(&mut queue, TransferPhase::Completed);
queue.clear_finished();
assert!(queue.is_empty());
}
#[test]
fn a_stopped_batch_holds_the_queue_until_resumed_or_taken_off() {
let mut queue = TransferQueue::default();
let first = queue.push(upload("a"));
let second = queue.push(upload("b"));
queue.start_next();
report(&mut queue, TransferPhase::Stopped);
assert_eq!(queue.start_next(), None);
assert_eq!(queue.removable(), Some(first));
// Resumed: running again, still first.
report(&mut queue, TransferPhase::Transferring);
assert_eq!(queue.removable(), None);
assert!(!queue.remove(first));
report(&mut queue, TransferPhase::Stopped);
// Taken off: listed as such, and the next one's turn.
assert!(queue.remove(first));
assert_eq!(states(&queue), [QueueState::Removed, QueueState::Pending]);
assert_eq!(queue.start_next().map(|(id, _)| id), Some(second));
}
#[test]
fn waiting_and_ended_batches_can_be_removed_but_a_running_one_cannot() {
let mut queue = TransferQueue::default();
let first = queue.push(upload("a"));
let second = queue.push(upload("b"));
let third = queue.push(upload("c"));
queue.start_next();
report(&mut queue, TransferPhase::Transferring);
queue.select(first);
assert_eq!(queue.removable(), None);
queue.select(second);
assert_eq!(queue.removable(), Some(second));
assert!(queue.remove(second));
let left: Vec<QueueId> = queue.entries().iter().map(|entry| entry.id()).collect();
assert_eq!(left, [first, third]);
report(&mut queue, TransferPhase::Completed);
queue.select(first);
assert!(queue.has_finished());
assert!(queue.remove(first));
assert!(!queue.has_finished());
}
#[test]
fn each_state_reads_as_winscp_words() {
let mut queue = TransferQueue::default();
let first = queue.push(upload("a"));
queue.push(upload("b"));
let status = |queue: &TransferQueue, index: usize| queue.entries()[index].status();
assert_eq!(status(&queue, 0), "等待中");
queue.start_next();
assert_eq!(status(&queue, 0), "正在扫描");
queue.on_progress(TransferProgress::new(TransferPhase::Transferring).with_bytes(294, 1000));
assert_eq!(status(&queue, 0), "29%");
report(&mut queue, TransferPhase::Waiting);
assert_eq!(status(&queue, 0), "等待回答");
report(&mut queue, TransferPhase::Stopped);
assert_eq!(status(&queue, 0), "已停止");
queue.abandon_head(Removal::Discarded);
assert_eq!(status(&queue, 0), "已丢弃续传进度");
assert_eq!(queue.entries()[0].id(), first);
queue.start_next();
queue.on_progress(
TransferProgress::new(TransferPhase::Completed).with_details(vec![
TransferDetail::done("/srv/a"),
TransferDetail::failed("/srv/b", "permission denied"),
]),
);
assert_eq!(status(&queue, 1), "部分失败(1 项)");
}
#[test]
fn the_source_is_the_item_its_folder_or_several_places() {
let upload = |paths: &[&str]| TransferJob::Upload {
paths: paths.iter().map(PathBuf::from).collect(),
target: "/srv".into(),
};
assert_eq!(upload(&["/data/a.zip"]).source_label(), "/data/a.zip");
assert_eq!(upload(&["/data/a.zip", "/data/b"]).source_label(), "/data");
assert_eq!(upload(&["/data/a", "/tmp/b"]).source_label(), "多个位置");
let download = TransferJob::Download {
paths: vec!["/var/log".into(), "/var/tmp".into()],
target: "/local".into(),
};
assert_eq!(download.source_label(), "/var");
let root = TransferJob::Download {
paths: vec!["/boot".into(), "/etc".into()],
target: "/local".into(),
};
assert_eq!(root.source_label(), "/");
}
}