1use std::sync::Arc;
23
24use crate::backend::fs_ops;
25use crate::backend::rpc::engine::WshRpcEngine;
26use crate::backend::rpc_types::{
27 FsCreateReq, FsDeleteReq, FsEmptyResult, FsListReq, FsOpCancelReq, FsOpResolveReq, FsOpResult,
28 FsOpResults, FsOpStartReq, FsPathReq, FsPlace, FsPlaceKind, FsPlacesReq, FsPlacesResult,
29 FsCreateResult, FsRenameReq, FsRenameResult, FsRestoreReq, FsTrashReq, FsUnwatchReq, FsWatchReq,
30 FsWatchResult,
31};
32
33use super::AppState;
34
35async fn blocking<T, F>(f: F) -> Result<T, String>
37where
38 T: Send + 'static,
39 F: FnOnce() -> T + Send + 'static,
40{
41 tokio::task::spawn_blocking(f).await.map_err(|e| format!("filesystem task failed: {e}"))
42}
43
44fn op_results(paths: Vec<String>, outcomes: Vec<Result<(), String>>) -> FsOpResults {
46 FsOpResults {
47 results: paths
48 .into_iter()
49 .zip(outcomes)
50 .map(|(path, outcome)| FsOpResult { path, ok: outcome.is_ok(), error: outcome.err() })
51 .collect(),
52 }
53}
54
55use crate::backend::fs_ops::remote as fs_remote;
56
57fn remote_of<'a>(conn: &'a str, block_id: Option<&'a str>, auth_key: &'a str) -> fs_remote::Remote<'a> {
58 fs_remote::Remote {
59 connection: conn,
60 block_id,
61 auth_key,
62 }
63}
64
65pub fn register_fs_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
66 let auth_key = state.auth_key.clone();
71 engine.register_typed("fs.list", move |cmd: FsListReq, _ctx| {
72 let auth_key = auth_key.clone();
73 async move {
74 if let Some(conn) = fs_remote::ssh_connection(cmd.connection.as_deref()) {
75 let r = remote_of(conn, cmd.block_id.as_deref(), &auth_key);
76 return fs_remote::list(r, &cmd.path).await;
77 }
78 fs_ops::ensure_cursor_sweeper();
79 blocking(move || fs_ops::list_page(&cmd)).await
80 }
81 });
82
83 engine.register_typed(
86 "fs.places",
87 |_req: Option<FsPlacesReq>, _ctx| async move {
89 let distros = crate::backend::remote::wsl::list_cached().await;
90 blocking(move || {
91 let (home, mut places) = fs_ops::home_and_known_places();
92 places.extend(super::editor_handlers::list_drives().into_iter().map(|d| FsPlace {
93 id: format!("drive:{}", d.path),
94 label: d.name,
95 path: d.path,
96 kind: FsPlaceKind::Drive,
97 }));
98 places.extend(distros.into_iter().map(|d| FsPlace {
99 id: format!("wsl:{d}"),
100 path: crate::backend::remote::wsl_fs::share_path(&d, "/"),
101 label: d,
102 kind: FsPlaceKind::Wsl,
103 }));
104 FsPlacesResult { home, sep: std::path::MAIN_SEPARATOR.to_string(), places }
105 })
106 .await
107 },
108 );
109
110 {
113 let watcher = state.files_watcher.clone();
114 engine.register_typed("fs.watch", move |cmd: FsWatchReq, _ctx| {
115 let watcher = watcher.clone();
116 async move {
117 let path = cmd.path.clone();
118 let dir = blocking(move || -> Result<std::path::PathBuf, String> {
119 let canonical = fs_ops::resolve_request_path(&path)?
120 .canonicalize()
121 .map_err(|e| fs_ops::classify_folder_error(&e).1)?;
122 if !canonical.is_dir() {
123 return Err("This isn't a folder.".to_string());
124 }
125 Ok(canonical)
126 })
127 .await??;
128 Ok(FsWatchResult { watch_id: watcher.watch(&dir, &cmd.block_id) })
129 }
130 });
131 }
132 {
133 let watcher = state.files_watcher.clone();
134 engine.register_typed("fs.unwatch", move |cmd: FsUnwatchReq, _ctx| {
135 let watcher = watcher.clone();
136 async move {
137 watcher.unwatch(&cmd.watch_id);
138 Ok(FsEmptyResult {})
139 }
140 });
141 }
142
143 let auth_key = state.auth_key.clone();
146 engine.register_typed("fs.rename", move |cmd: FsRenameReq, _ctx| {
147 let auth_key = auth_key.clone();
148 async move {
149 if let Some(conn) = fs_remote::ssh_connection(cmd.connection.as_deref()) {
150 let r = remote_of(conn, cmd.block_id.as_deref(), &auth_key);
151 let new_path = fs_remote::rename(r, &cmd.path, &cmd.new_name).await?;
152 return Ok(FsRenameResult { new_path });
153 }
154 blocking(move || fs_ops::rename(&cmd)).await?
155 }
156 });
157
158 let auth_key = state.auth_key.clone();
159 engine.register_typed("fs.create", move |cmd: FsCreateReq, _ctx| {
160 let auth_key = auth_key.clone();
161 async move {
162 if let Some(conn) = fs_remote::ssh_connection(cmd.connection.as_deref()) {
163 let r = remote_of(conn, cmd.block_id.as_deref(), &auth_key);
164 let path = fs_remote::create(r, &cmd.parent, &cmd.name, cmd.kind).await?;
165 return Ok(FsCreateResult { path });
166 }
167 blocking(move || fs_ops::create(&cmd)).await?
168 }
169 });
170
171 engine.register_typed("fs.trash", |cmd: FsTrashReq, _ctx| async move {
173 if let Some(conn) = fs_remote::ssh_connection(cmd.connection.as_deref()) {
174 return Err(fs_remote::no_trash(conn));
175 }
176 let paths = cmd.paths;
177 let for_worker = paths.clone();
178 let outcomes = fs_ops::trash_worker::run(move || for_worker.iter().map(|p| fs_ops::trash(p)).collect()).await?;
179 Ok(op_results(paths, outcomes))
180 });
181
182 engine.register_typed("fs.restore", |cmd: FsRestoreReq, _ctx| async move {
184 if let Some(conn) = fs_remote::ssh_connection(cmd.connection.as_deref()) {
185 return Err(fs_remote::no_trash(conn));
186 }
187 let paths = cmd.paths;
188 let for_worker = paths.clone();
189 let outcomes = fs_ops::trash_worker::run(move || fs_ops::restore(&for_worker)).await?;
190 Ok(op_results(paths, outcomes))
191 });
192
193 let auth_key = state.auth_key.clone();
195 engine.register_typed("fs.delete", move |cmd: FsDeleteReq, _ctx| {
196 let auth_key = auth_key.clone();
197 async move {
198 if let Some(conn) = fs_remote::ssh_connection(cmd.connection.as_deref()) {
199 let r = remote_of(conn, cmd.block_id.as_deref(), &auth_key);
200 return Ok(FsOpResults {
201 results: fs_remote::delete(r, &cmd.paths).await,
202 });
203 }
204 let paths = cmd.paths;
205 let for_worker = paths.clone();
206 let outcomes =
207 blocking(move || for_worker.iter().map(|p| fs_ops::delete_permanently(p)).collect()).await?;
208 Ok(op_results(paths, outcomes))
209 }
210 });
211
212 engine.register_typed("fs.open", |cmd: FsPathReq, _ctx| async move {
215 blocking(move || fs_ops::open_or_reveal(&cmd.path, false)).await??;
216 Ok(FsEmptyResult {})
217 });
218 engine.register_typed("fs.reveal", |cmd: FsPathReq, _ctx| async move {
219 blocking(move || fs_ops::open_or_reveal(&cmd.path, true)).await??;
220 Ok(FsEmptyResult {})
221 });
222
223 {
228 let broker = state.broker.clone();
229 let auth_key = state.auth_key.clone();
230 engine.register_typed("fs.op.start", move |cmd: FsOpStartReq, _ctx| {
231 let broker = broker.clone();
232 let auth_key = auth_key.clone();
233 async move {
234 if cmd.block_id.is_empty() {
235 return Err("No pane was named to report the operation's progress to.".to_string());
236 }
237 let emit = fs_ops::jobs::broker_emitter(broker, cmd.block_id.clone());
238 let from = fs_remote::ssh_connection(cmd.source_connection.as_deref());
241 let to = fs_remote::ssh_connection(cmd.connection.as_deref());
242 if from.is_some() || to.is_some() {
243 let ask = crate::backend::remote::sessions::AskIn {
244 block_id: &cmd.block_id,
245 auth_key: &auth_key,
246 };
247 let connect = |conn: Option<&str>| {
250 let conn = conn.map(str::to_string);
251 async move {
252 match conn {
253 None => Ok(fs_ops::host_jobs::End::Local),
254 Some(c) => crate::backend::remote::files::connect(&c, Some(ask))
255 .await
256 .map(fs_ops::host_jobs::End::host)
257 .map_err(|e| e.message),
258 }
259 }
260 };
261 let (src, dest) = tokio::join!(connect(from), connect(to));
262 let (src, dest) = (src?, dest?);
263 return fs_ops::host_jobs::start(cmd.kind, src, cmd.sources, dest, cmd.dest_dir, emit).await;
264 }
265 blocking(move || fs_ops::jobs::JOBS.start(&cmd, emit)).await?
266 }
267 });
268 }
269
270 engine.register_typed("fs.op.resolve", |cmd: FsOpResolveReq, _ctx| async move {
273 fs_ops::jobs::JOBS.resolve(&cmd.op_id, cmd.choice, cmd.apply_to_all)?;
274 Ok(FsEmptyResult {})
275 });
276 engine.register_typed("fs.op.cancel", |cmd: FsOpCancelReq, _ctx| async move {
277 fs_ops::jobs::JOBS.cancel(&cmd.op_id);
278 Ok(FsEmptyResult {})
279 });
280
281 engine.register_typed("fs.git_status", |cmd: FsPathReq, _ctx| async move {
284 Ok::<_, String>(fs_ops::git::git_status(&cmd.path).await)
285 });
286}
287
288#[cfg(test)]
289mod tests {
290 use crate::backend::rpc_types::RpcMessage;
291
292 #[tokio::test]
295 async fn every_fs_command_records_its_types() {
296 let state = crate::server::tests::test_state();
297 let (engine, _rx) = crate::backend::rpc::engine::WshRpcEngine::new();
298 super::register_fs_handlers(&engine, &state);
299 let schema = engine.schema_json();
300 let rows = schema.as_array().unwrap();
301 let find = |cmd: &str| {
302 rows.iter()
303 .find(|r| r["command"] == cmd)
304 .unwrap_or_else(|| panic!("{cmd} missing from the schema"))
305 };
306 for (cmd, req, resp) in [
307 ("fs.list", "FsListReq", "FsListResult"),
308 ("fs.watch", "FsWatchReq", "FsWatchResult"),
309 ("fs.unwatch", "FsUnwatchReq", "FsEmptyResult"),
310 ("fs.rename", "FsRenameReq", "FsRenameResult"),
311 ("fs.create", "FsCreateReq", "FsCreateResult"),
312 ("fs.trash", "FsTrashReq", "FsOpResults"),
313 ("fs.restore", "FsRestoreReq", "FsOpResults"),
314 ("fs.delete", "FsDeleteReq", "FsOpResults"),
315 ("fs.open", "FsPathReq", "FsEmptyResult"),
316 ("fs.reveal", "FsPathReq", "FsEmptyResult"),
317 ("fs.op.start", "FsOpStartReq", "FsOpStartResult"),
318 ("fs.op.resolve", "FsOpResolveReq", "FsEmptyResult"),
319 ("fs.op.cancel", "FsOpCancelReq", "FsEmptyResult"),
320 ("fs.git_status", "FsPathReq", "FsGitStatus"),
321 ] {
322 let row = find(cmd);
323 assert_eq!(row["requestName"], req, "{cmd} request");
324 assert_eq!(row["responseName"], resp, "{cmd} response");
325 }
326 assert_eq!(find("fs.places")["responseName"], "FsPlacesResult");
327 }
328
329 #[tokio::test]
332 async fn places_accepts_both_encodings_of_no_argument() {
333 let state = crate::server::tests::test_state();
334 let (engine, mut rx) = crate::backend::rpc::engine::WshRpcEngine::new();
335 super::register_fs_handlers(&engine, &state);
336 for (i, payload) in [serde_json::json!({}), serde_json::Value::Null].into_iter().enumerate() {
337 engine.handle_message(RpcMessage {
338 command: "fs.places".to_string(),
339 reqid: format!("places-{i}"),
340 data: Some(payload.clone()),
341 ..Default::default()
342 });
343 let resp = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()).await.unwrap().unwrap();
344 assert!(resp.error.is_empty(), "fs.places should accept {payload}, got: {}", resp.error);
345 let data = resp.data.unwrap();
346 assert_eq!(data["sep"], std::path::MAIN_SEPARATOR.to_string());
347 assert!(data["places"].as_array().unwrap().iter().any(|p| p["kind"] == "home"));
348 }
349 }
350
351 #[tokio::test]
354 async fn a_batch_answers_per_path() {
355 let state = crate::server::tests::test_state();
356 let (engine, mut rx) = crate::backend::rpc::engine::WshRpcEngine::new();
357 super::register_fs_handlers(&engine, &state);
358 let dir = tempfile::tempdir().unwrap();
359 let file = dir.path().join("doomed.txt");
360 std::fs::write(&file, "x").unwrap();
361 let missing = dir.path().join("missing.txt");
362
363 engine.handle_message(RpcMessage {
364 command: "fs.delete".to_string(),
365 reqid: "del".to_string(),
366 data: Some(serde_json::json!({ "paths": [file.to_string_lossy(), missing.to_string_lossy()] })),
367 ..Default::default()
368 });
369 let resp = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()).await.unwrap().unwrap();
370 assert!(resp.error.is_empty(), "{}", resp.error);
371 let results = resp.data.unwrap()["results"].as_array().unwrap().clone();
372 assert_eq!(results.len(), 2);
373 assert_eq!(results[0]["ok"], true);
374 assert_eq!(results[1]["ok"], false);
375 assert!(results[1]["error"].is_string());
376 assert!(!file.exists());
377 }
378}