Skip to content

Commit f2bc76b

Browse files
authored
Merge pull request #107 from martindurant/moer
More features for the file browser
2 parents 535c859 + e201be8 commit f2bc76b

16 files changed

Lines changed: 1695 additions & 111 deletions

File tree

pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/ProjspecToolWindowPanel.kt

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,14 @@ class ProjspecToolWindowPanel(
300300
msg["name"] as? String ?: "", so) }
301301
"deleteEntry" -> pool { fbDeleteEntry(msg["url"] as? String ?: "",
302302
msg["isDir"] == true, so) }
303+
"deleteEntries" -> pool { fbDeleteEntries(msg["items"] as? List<*> ?: emptyList<Any>()) }
303304
"renameEntry" -> pool { fbRenameEntry(msg["url"] as? String ?: "",
304305
msg["newName"] as? String ?: "", so) }
306+
"paste" -> pool { fbPasteEntry(msg["items"] as? List<*> ?: emptyList<Any>(),
307+
msg["dstDir"] as? String ?: "",
308+
msg["dstStorageOptions"] as? String,
309+
msg["mode"] as? String ?: "copy",
310+
msg["confirmed"] == true) }
305311
"mkdir" -> pool { fbMkdir(msg["parentUrl"] as? String ?: "",
306312
msg["name"] as? String ?: "", so) }
307313
"addBookmark" -> pool { fbBookmarkAdd(msg["url"] as? String ?: "",
@@ -829,6 +835,37 @@ class ProjspecToolWindowPanel(
829835
fbBrowse(url.trimEnd('/').substringBeforeLast('/').ifBlank { "/" }, storageOptions, false)
830836
}
831837

838+
/**
839+
* Delete one or more entries (multi-select). Deletes each item
840+
* individually (fire-and-forget, like [fbDeleteEntry]) and reports
841+
* per-item results so partial failures are visible in the webview.
842+
*/
843+
private fun fbDeleteEntries(items: List<*>) {
844+
if (items.isEmpty()) return
845+
val results = mutableListOf<Map<String, Any?>>()
846+
var refreshDir: String? = null
847+
var refreshSo: String? = null
848+
for (raw in items) {
849+
@Suppress("UNCHECKED_CAST")
850+
val item = raw as? Map<String, Any?> ?: continue
851+
val url = item["url"] as? String ?: continue
852+
val isDir = item["isDir"] == true
853+
val storageOptions = item["storageOptions"] as? String
854+
val so = server.parseSo(storageOptions)
855+
val error = if (server.delete(url, isDir, so) != null) {
856+
null
857+
} else {
858+
ProjspecRunner.runFbDelete(url, isDir, storageOptions)
859+
null
860+
}
861+
results.add(mapOf("url" to url, "error" to error))
862+
refreshDir = url.trimEnd('/').substringBeforeLast('/').ifBlank { "/" }
863+
refreshSo = storageOptions
864+
}
865+
deliverToFbWebview(mapOf("type" to "deleteEntriesResult", "results" to results))
866+
if (refreshDir != null) fbBrowse(refreshDir, refreshSo, false)
867+
}
868+
832869
private fun fbRenameEntry(url: String, newName: String, storageOptions: String?) {
833870
val parent = url.trimEnd('/').substringBeforeLast('/').ifBlank { "/" }
834871
val dst = parent.trimEnd('/') + "/" + newName
@@ -837,6 +874,81 @@ class ProjspecToolWindowPanel(
837874
fbBrowse(parent, storageOptions, false)
838875
}
839876

877+
/**
878+
* Paste one or more previously copied/cut entries into `dstDir`. `mode`
879+
* is "copy" or "cut" — "cut" maps to `move()` per item (fire-and-forget,
880+
* like [fbRenameEntry]); "copy" first checks the *aggregate* size of all
881+
* items via a single `totalSize()` call, which reports `needsConfirm`
882+
* using the same `filebrowser_copy_confirm_bytes` threshold `copy()`
883+
* itself enforces per item. If confirmation is needed and `confirmed`
884+
* wasn't already set, nothing is copied yet — `pasteNeedsConfirm` is
885+
* delivered to the webview, which re-sends this same message with
886+
* `confirmed=true` once the user accepts. Each item is then
887+
* copied/moved individually (passing `confirmed=true` through so the
888+
* per-item calls don't redundantly re-check a threshold already cleared
889+
* for the batch) and per-item results are reported.
890+
*/
891+
private fun fbPasteEntry(
892+
items: List<*>,
893+
dstDir: String,
894+
dstStorageOptions: String?,
895+
mode: String,
896+
confirmed: Boolean,
897+
) {
898+
@Suppress("UNCHECKED_CAST")
899+
val parsedItems = items.mapNotNull { it as? Map<String, Any?> }
900+
if (parsedItems.isEmpty()) return
901+
val firstSrcSo = parsedItems[0]["srcStorageOptions"] as? String
902+
val soStr = firstSrcSo ?: dstStorageOptions
903+
val so = server.parseSo(soStr)
904+
905+
if (mode != "cut" && !confirmed) {
906+
val urls = parsedItems.mapNotNull { it["src"] as? String }
907+
val tsData: Map<String, Any?> = server.totalSize(urls, so) ?: run {
908+
val raw = ProjspecRunner.runFbTotalSize(urls, soStr)
909+
try {
910+
@Suppress("UNCHECKED_CAST")
911+
gson.fromJson(raw, Map::class.java) as Map<String, Any?>
912+
} catch (_: Exception) {
913+
mapOf("total_size" to null, "error" to raw, "needs_confirm" to false)
914+
}
915+
}
916+
if (tsData["needs_confirm"] == true) {
917+
deliverToFbWebview(mapOf(
918+
"type" to "pasteNeedsConfirm",
919+
"items" to items, "dstDir" to dstDir, "dstStorageOptions" to dstStorageOptions,
920+
"mode" to mode, "totalSize" to tsData["total_size"],
921+
))
922+
return
923+
}
924+
}
925+
926+
val results = mutableListOf<Map<String, Any?>>()
927+
for (item in parsedItems) {
928+
val src = item["src"] as? String ?: continue
929+
val itemSoStr = (item["srcStorageOptions"] as? String) ?: dstStorageOptions
930+
val itemSo = server.parseSo(itemSoStr) ?: so
931+
val dst = dstDir.trimEnd('/') + "/" + (src.trimEnd('/').substringAfterLast('/'))
932+
val error: Any? = if (mode == "cut") {
933+
if (server.move(src, dst, itemSo) == null) ProjspecRunner.runFbMove(src, dst, itemSoStr)
934+
null
935+
} else {
936+
val data: Map<String, Any?> = server.copy(src, dst, itemSo, true) ?: run {
937+
val raw = ProjspecRunner.runFbCopy(src, dst, itemSoStr, true)
938+
try {
939+
@Suppress("UNCHECKED_CAST")
940+
gson.fromJson(raw, Map::class.java) as Map<String, Any?>
941+
} catch (_: Exception) {
942+
mapOf("error" to raw)
943+
}
944+
}
945+
data["error"]
946+
}
947+
results.add(mapOf("src" to src, "dst" to dst, "error" to error))
948+
}
949+
deliverToFbWebview(mapOf("type" to "pasteResult", "mode" to mode, "results" to results, "error" to null))
950+
}
951+
840952
private fun fbMkdir(parentUrl: String, name: String, storageOptions: String?) {
841953
val newUrl = parentUrl.trimEnd('/') + "/" + name
842954
val so = server.parseSo(storageOptions)

pycharm_plugin/src/main/kotlin/com/projspec/util/ProjspecRunner.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,30 @@ print(json.dumps(result))
186186
run(args)
187187
}
188188

189+
/** `projspec filebrowser copy <src> <dst> [--confirmed] [--storage-options JSON]` → JSON result */
190+
fun runFbCopy(src: String, dst: String, storageOptions: String?, confirmed: Boolean): String {
191+
val args = mutableListOf(cli, "filebrowser", "copy", src, dst)
192+
if (confirmed) args.add("--confirmed")
193+
if (!storageOptions.isNullOrBlank()) { args.add("--storage-options"); args.add(storageOptions) }
194+
return when (val r = run(args)) {
195+
is CliResult.Success -> extractJson(r.stdout).ifBlank { "{}" }
196+
is CliResult.Failure ->
197+
"""{"src":"$src","dst":"$dst","error":"${r.message.replace("\"", "'")}","needs_confirm":false,"total_size":null}"""
198+
}
199+
}
200+
201+
/** `projspec filebrowser total-size <url>... [--storage-options JSON]` → JSON result */
202+
fun runFbTotalSize(urls: List<String>, storageOptions: String?): String {
203+
val args = mutableListOf(cli, "filebrowser", "total-size")
204+
args.addAll(urls)
205+
if (!storageOptions.isNullOrBlank()) { args.add("--storage-options"); args.add(storageOptions) }
206+
return when (val r = run(args)) {
207+
is CliResult.Success -> extractJson(r.stdout).ifBlank { "{}" }
208+
is CliResult.Failure ->
209+
"""{"total_size":null,"error":"${r.message.replace("\"", "'")}","needs_confirm":false}"""
210+
}
211+
}
212+
189213
/** `projspec filebrowser mkdir <url> [--storage-options JSON]` */
190214
fun runFbMkdir(url: String, storageOptions: String?) {
191215
val args = mutableListOf(cli, "filebrowser", "mkdir", url)

pycharm_plugin/src/main/kotlin/com/projspec/util/ProjspecServer.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,16 @@ class ProjspecServer {
362362
fun move(src: String, dst: String, so: Map<String, Any?>? = null): Map<String, Any?>? =
363363
post("/filebrowser/move", mapOf("src" to src, "dst" to dst, "storage_options" to so)) as? Map<String, Any?>
364364

365+
fun copy(src: String, dst: String, so: Map<String, Any?>? = null, confirmed: Boolean = false): Map<String, Any?>? =
366+
post("/filebrowser/copy", mapOf(
367+
"src" to src, "dst" to dst, "storage_options" to so, "confirmed" to confirmed,
368+
)) as? Map<String, Any?>
369+
370+
fun totalSize(urls: List<String>, so: Map<String, Any?>? = null): Map<String, Any?>? =
371+
post("/filebrowser/total_size", mapOf(
372+
"urls" to urls, "storage_options" to so,
373+
)) as? Map<String, Any?>
374+
365375
fun mkdir(url: String, so: Map<String, Any?>? = null): Map<String, Any?>? =
366376
post("/filebrowser/mkdir", mapOf("url" to url, "storage_options" to so)) as? Map<String, Any?>
367377

src/projspec/__main__.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,71 @@ def fb_move(src, dst, storage_options):
468468
print(json.dumps(move(src, dst, storage_options=so)))
469469

470470

471+
@filebrowser.command("copy")
472+
@click.argument("src")
473+
@click.argument("dst")
474+
@click.option("--storage-options", default="", help="fsspec storage options as JSON")
475+
@click.option(
476+
"--no-recursive",
477+
"recursive",
478+
is_flag=True,
479+
default=True,
480+
flag_value=False,
481+
help="Do not copy directory contents recursively (default: recursive)",
482+
)
483+
@click.option(
484+
"--confirmed",
485+
is_flag=True,
486+
default=False,
487+
help=(
488+
"Proceed even if the total size exceeds filebrowser_copy_confirm_bytes. "
489+
"Without this flag, a copy exceeding that threshold is not performed; "
490+
"the command instead reports needs_confirm=true and the total size."
491+
),
492+
)
493+
def fb_copy(src, dst, storage_options, recursive, confirmed):
494+
"""Copy SRC to DST, recursively for directories.
495+
496+
Uses fsspec.generic to support copying between different filesystems.
497+
498+
Outputs JSON with keys: src, dst, error, needs_confirm, total_size.
499+
"""
500+
from projspec.filebrowser import copy
501+
502+
so = json.loads(storage_options) if storage_options.strip() else None
503+
print(
504+
json.dumps(
505+
copy(
506+
src,
507+
dst,
508+
storage_options=so,
509+
recursive=recursive,
510+
confirmed=confirmed,
511+
)
512+
)
513+
)
514+
515+
516+
@filebrowser.command("total-size")
517+
@click.argument("urls", nargs=-1, required=True)
518+
@click.option("--storage-options", default="", help="fsspec storage options as JSON")
519+
def fb_total_size(urls, storage_options):
520+
"""Compute the combined size (bytes) of one or more URLs (files/dirs).
521+
522+
Used by the multi-select copy/paste confirmation gate to check the
523+
total size of a batch in a single call instead of once per item.
524+
525+
Outputs JSON with keys: total_size, error.
526+
"""
527+
from projspec.filebrowser import total_size
528+
529+
so = json.loads(storage_options) if storage_options.strip() else None
530+
# NB: do not call `list(urls)` here — this module defines a click
531+
# command named `list` (see `library list`) which shadows the builtin.
532+
# `urls` (a tuple from nargs=-1) is already a fine iterable for total_size().
533+
print(json.dumps(total_size(urls, storage_options=so)))
534+
535+
471536
@filebrowser.command("mkdir")
472537
@click.argument("url")
473538
@click.option("--storage-options", default="", help="fsspec storage options as JSON")

src/projspec/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def defaults():
4646
"data_min_play_size": 1, # 64 * 1024,
4747
"data_consolidate_min_group": 3,
4848
"data_inspect_max_datasets": 50,
49+
"filebrowser_copy_confirm_bytes": 256 * 1024 * 1024, # 256 MB
4950
"excludes": [
5051
"bld",
5152
"build",
@@ -108,6 +109,11 @@ def defaults():
108109
"do not run intake inspection if more than this many distinct datasets "
109110
"are found in a directory (avoids huge scans)."
110111
),
112+
"filebrowser_copy_confirm_bytes": (
113+
"in the file browser UI, ask for confirmation before copying/pasting "
114+
"a file or directory tree whose total size (bytes) exceeds this "
115+
"value. Default is 256 MB."
116+
),
111117
"excludes": (
112118
"directory names to skip when walking a project tree for child projects "
113119
"and file statistics. Directories whose names start with '.' or '_' are "

0 commit comments

Comments
 (0)