Merge branch 'feat/drag-out' into integration/round-1
This commit is contained in:
@@ -79,6 +79,22 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
|
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
|
||||||
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
|
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
|
||||||
modals — see "UI conventions" below.
|
modals — see "UI conventions" below.
|
||||||
|
- **Files moves in both directions and neither direction uses HTML5 drag.** Dropping *into*
|
||||||
|
the pane is Tauri's native `onDragDropEvent`, which is window-wide and therefore routed by
|
||||||
|
a hit-test of the physical-pixel payload position against the pane's rect ÷
|
||||||
|
`devicePixelRatio` — a hidden pane has a zero-size rect, which is what stops it and
|
||||||
|
`TerminalView`'s listener both firing. Dragging *out* is pointer events into
|
||||||
|
`tauri-plugin-drag`, for the same `dragDropEnabled` reason the tab strip is pointer-driven.
|
||||||
|
- **A drag-out is a copy first and a drag second.** The OS can only drag a path that exists
|
||||||
|
on the host, and these files are inside a container, so `stage_container_file_for_drag`
|
||||||
|
materialises one into `<os-temp>/triple-c-drag-out/<session>/` (via the shared
|
||||||
|
`fetch_container_file`, keeping the original filename, capped at the same 256 MiB as an
|
||||||
|
upload) and `startDrag` is handed *that*. Two consequences worth keeping: the copy is an
|
||||||
|
async gap inside a gesture that feels instantaneous, so the staged path is cached and the
|
||||||
|
UI says "drag it again" when the pointer came up first; and the staging directory is
|
||||||
|
cleared on exit **and** reaped at startup, because a drag-out quietly filling the host temp
|
||||||
|
dir with whole files would be the disk problem this project just fixed, in a new place.
|
||||||
|
"Save to host…" stays — `startDrag` is an enhancement and can fail per platform.
|
||||||
- **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth
|
- **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth
|
||||||
- **`components/ui/`** — Shared primitives. **Use these; do not hand-roll replacements.**
|
- **`components/ui/`** — Shared primitives. **Use these; do not hand-roll replacements.**
|
||||||
`Modal` (the only correct way to build a dialog — it supplies `role="dialog"`, `aria-modal`,
|
`Modal` (the only correct way to build a dialog — it supplies `role="dialog"`, `aria-modal`,
|
||||||
|
|||||||
Generated
+9
@@ -8,6 +8,7 @@
|
|||||||
"name": "triple-c",
|
"name": "triple-c",
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@crabnebula/tauri-plugin-drag": "^2.1.0",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
@@ -414,6 +415,14 @@
|
|||||||
"specificity": "bin/cli.js"
|
"specificity": "bin/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@crabnebula/tauri-plugin-drag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@crabnebula/tauri-plugin-drag/-/tauri-plugin-drag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-LnUXAZwQt1cdMoGDLJ6ogW9wFCYServCZXlGadS7CA+CZ9eXS7L+Q7QyQW6g/zGw9YI2MwKFqf1aSNBGyWw+OA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@csstools/color-helpers": {
|
"node_modules/@csstools/color-helpers": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@crabnebula/tauri-plugin-drag": "^2.1.0",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
|
|||||||
Generated
+190
-20
@@ -630,6 +630,19 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-graphics"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-graphics-types",
|
||||||
|
"foreign-types",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-graphics"
|
name = "core-graphics"
|
||||||
version = "0.25.0"
|
version = "0.25.0"
|
||||||
@@ -1016,6 +1029,28 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "drag"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e90b4a25ace5ce0561534b073943594cbcd21af936e64d09aec444568411f8c"
|
||||||
|
dependencies = [
|
||||||
|
"core-graphics 0.24.0",
|
||||||
|
"dunce",
|
||||||
|
"gdk",
|
||||||
|
"gdkx11",
|
||||||
|
"gtk",
|
||||||
|
"log",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-foundation",
|
||||||
|
"raw-window-handle",
|
||||||
|
"serde",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"windows 0.52.0",
|
||||||
|
"windows-core 0.58.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dtoa"
|
name = "dtoa"
|
||||||
version = "1.0.11"
|
version = "1.0.11"
|
||||||
@@ -1135,7 +1170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2647,9 +2682,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"block2",
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
|
"objc2-cloud-kit",
|
||||||
|
"objc2-core-data",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-core-image",
|
||||||
|
"objc2-core-text",
|
||||||
|
"objc2-core-video",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
|
"objc2-quartz-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2669,6 +2712,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
@@ -2729,6 +2773,19 @@ dependencies = [
|
|||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-video"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-io-surface",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-encode"
|
name = "objc2-encode"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@@ -3394,7 +3451,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3743,7 +3800,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4400,7 +4457,7 @@ dependencies = [
|
|||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics 0.25.0",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dbus",
|
"dbus",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
@@ -4425,7 +4482,7 @@ dependencies = [
|
|||||||
"tao-macros",
|
"tao-macros",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
@@ -4508,7 +4565,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"window-vibrancy",
|
"window-vibrancy",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4608,6 +4665,21 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-drag"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "729ca0ce4b1169869d3405216d3c09a524f41ea5e2eec89f917cd6623f8a70ca"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"drag",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-fs"
|
name = "tauri-plugin-fs"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -4650,7 +4722,7 @@ dependencies = [
|
|||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4692,7 +4764,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4717,7 +4789,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"wry",
|
"wry",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4782,7 +4854,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.1",
|
"getrandom 0.4.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5186,6 +5258,7 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
|
"tauri-plugin-drag",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -5639,10 +5712,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"webview2-com-macros",
|
"webview2-com-macros",
|
||||||
"webview2-com-sys",
|
"webview2-com-sys",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-implement",
|
"windows-implement 0.60.2",
|
||||||
"windows-interface",
|
"windows-interface 0.59.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5663,7 +5736,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5689,7 +5762,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5713,6 +5786,18 @@ dependencies = [
|
|||||||
"windows-version",
|
"windows-version",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.52.0",
|
||||||
|
"windows-implement 0.52.0",
|
||||||
|
"windows-interface 0.52.0",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.61.3"
|
version = "0.61.3"
|
||||||
@@ -5735,14 +5820,36 @@ dependencies = [
|
|||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement 0.58.0",
|
||||||
|
"windows-interface 0.58.0",
|
||||||
|
"windows-result 0.2.0",
|
||||||
|
"windows-strings 0.1.0",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-implement",
|
"windows-implement 0.60.2",
|
||||||
"windows-interface",
|
"windows-interface 0.59.3",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-result 0.3.4",
|
"windows-result 0.3.4",
|
||||||
"windows-strings 0.4.2",
|
"windows-strings 0.4.2",
|
||||||
@@ -5754,8 +5861,8 @@ version = "0.62.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-implement",
|
"windows-implement 0.60.2",
|
||||||
"windows-interface",
|
"windows-interface 0.59.3",
|
||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
"windows-result 0.4.1",
|
"windows-result 0.4.1",
|
||||||
"windows-strings 0.5.1",
|
"windows-strings 0.5.1",
|
||||||
@@ -5772,6 +5879,28 @@ dependencies = [
|
|||||||
"windows-threading",
|
"windows-threading",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12168c33176773b86799be25e2a2ba07c7aab9968b37541f1094dbd7a60c8946"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-implement"
|
name = "windows-implement"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
@@ -5783,6 +5912,28 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d8dc32e0095a7eeccebd0e3f09e9509365ecb3fc6ac4d6f5f14a3f6392942d1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-interface"
|
name = "windows-interface"
|
||||||
version = "0.59.3"
|
version = "0.59.3"
|
||||||
@@ -5816,6 +5967,15 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -5834,6 +5994,16 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||||
|
dependencies = [
|
||||||
|
"windows-result 0.2.0",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-strings"
|
name = "windows-strings"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -6261,7 +6431,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ tower-http = { version = "0.6", features = ["cors"] }
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
local-ip-address = "0.6"
|
local-ip-address = "0.6"
|
||||||
|
tauri-plugin-drag = "2.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
"store:allow-save",
|
"store:allow-save",
|
||||||
"store:allow-clear",
|
"store:allow-clear",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"opener:allow-open-url"
|
"opener:allow-open-url",
|
||||||
|
"drag:default",
|
||||||
|
"drag:allow-start-drag"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"default":{"identifier":"default","description":"Default capabilities for Triple-C","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit-to","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm","store:default","store:allow-get","store:allow-set","store:allow-delete","store:allow-keys","store:allow-values","store:allow-entries","store:allow-length","store:allow-load","store:allow-reset","store:allow-save","store:allow-clear","opener:default","opener:allow-open-url"]}}
|
{"default":{"identifier":"default","description":"Default capabilities for Triple-C","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit-to","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm","store:default","store:allow-get","store:allow-set","store:allow-delete","store:allow-keys","store:allow-values","store:allow-entries","store:allow-length","store:allow-load","store:allow-reset","store:allow-save","store:allow-clear","opener:default","opener:allow-open-url","drag:default","drag:allow-start-drag"]}}
|
||||||
@@ -2426,6 +2426,24 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "drag:default",
|
||||||
|
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the start_drag command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "drag:allow-start-drag",
|
||||||
|
"markdownDescription": "Enables the start_drag command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the start_drag command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "drag:deny-start-drag",
|
||||||
|
"markdownDescription": "Denies the start_drag command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -2426,6 +2426,24 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "drag:default",
|
||||||
|
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the start_drag command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "drag:allow-start-drag",
|
||||||
|
"markdownDescription": "Enables the start_drag command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the start_drag command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "drag:deny-start-drag",
|
||||||
|
"markdownDescription": "Denies the start_drag command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use bollard::container::{DownloadFromContainerOptions, LogOutput, UploadToContainerOptions};
|
use bollard::container::{DownloadFromContainerOptions, LogOutput, UploadToContainerOptions};
|
||||||
use bollard::exec::{CreateExecOptions, StartExecResults};
|
use bollard::exec::{CreateExecOptions, StartExecResults};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::State;
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
|
||||||
use crate::docker::client::get_docker;
|
use crate::docker::client::get_docker;
|
||||||
use crate::docker::exec::{
|
use crate::docker::exec::{
|
||||||
@@ -334,6 +338,239 @@ pub async fn read_container_file(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Drag-out staging
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Dragging a file onto the host desktop hands the OS a *host* path, and the
|
||||||
|
// files in this panel live inside a container, where nothing on the desktop can
|
||||||
|
// reach them. So a drag-out is really a copy-then-drag: materialise the file
|
||||||
|
// into a host temp directory first, then start the native drag on that copy.
|
||||||
|
//
|
||||||
|
// The copy is the reason this section carries a lifecycle. A staging directory
|
||||||
|
// nobody empties is a disk leak with a gesture attached to it, so there are two
|
||||||
|
// halves and both matter: `clear_drag_staging` on exit, and
|
||||||
|
// `reap_drag_staging` at startup for whatever a crash left behind.
|
||||||
|
|
||||||
|
/// Ceiling on one staged copy. Deliberately the same 256 MiB as
|
||||||
|
/// [`MAX_UPLOAD_BYTES`] — it is the same whole-file-through-host-RAM round trip,
|
||||||
|
/// only in the other direction.
|
||||||
|
const MAX_DRAG_STAGE_BYTES: u64 = 256 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Name of the app-owned directory inside the OS temp dir. Everything staged by
|
||||||
|
/// any Triple-C process lives under it, so housekeeping has exactly one place to
|
||||||
|
/// look and never walks the rest of the user's temp dir.
|
||||||
|
const DRAG_STAGE_DIR_NAME: &str = "triple-c-drag-out";
|
||||||
|
|
||||||
|
/// How long *another* process's leftover staging directory may sit before
|
||||||
|
/// startup housekeeping deletes it.
|
||||||
|
///
|
||||||
|
/// Only ever applied to directories this process does not own (see
|
||||||
|
/// [`drag_stage_session_dir`]), so it is not a limit on how long a staged file
|
||||||
|
/// survives in a live session — it is the crash-recovery threshold, and it is
|
||||||
|
/// generous because a second Triple-C running right now would also look like a
|
||||||
|
/// leftover.
|
||||||
|
const DRAG_STAGE_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
|
|
||||||
|
/// This process's own sub-directory name, stable for the life of the process.
|
||||||
|
///
|
||||||
|
/// Per-process rather than shared so exit cleanup can delete *ours* outright
|
||||||
|
/// without reaching into a directory another instance may be dragging out of.
|
||||||
|
fn drag_stage_session() -> &'static str {
|
||||||
|
static SESSION: OnceLock<String> = OnceLock::new();
|
||||||
|
SESSION.get_or_init(|| uuid::Uuid::new_v4().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app-owned staging root inside `temp_dir`.
|
||||||
|
///
|
||||||
|
/// Takes the temp dir rather than reading it, because on Windows it is neither
|
||||||
|
/// `/tmp` nor a constant — Tauri's path API is the only thing that knows it —
|
||||||
|
/// and because a pure function is what the tests can drive.
|
||||||
|
pub fn drag_stage_root(temp_dir: &Path) -> PathBuf {
|
||||||
|
temp_dir.join(DRAG_STAGE_DIR_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This process's staging directory: `<temp>/triple-c-drag-out/<session>`.
|
||||||
|
pub fn drag_stage_session_dir(temp_dir: &Path) -> PathBuf {
|
||||||
|
drag_stage_root(temp_dir).join(drag_stage_session())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-file sub-directory a staged copy lives in, derived from the
|
||||||
|
/// container path.
|
||||||
|
///
|
||||||
|
/// Filenames are only unique within a directory, so `a/notes.txt` and
|
||||||
|
/// `b/notes.txt` would otherwise be the same host path — and the second drag
|
||||||
|
/// would silently rewrite the first one's contents under the first one's cached
|
||||||
|
/// path. A digest of the full container path separates them while staying
|
||||||
|
/// *deterministic*, so re-staging the same file reuses its slot instead of
|
||||||
|
/// growing a new one every drag.
|
||||||
|
fn drag_stage_slot(container_path: &str) -> String {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let digest = Sha256::digest(container_path.as_bytes());
|
||||||
|
digest[..8].iter().map(|b| format!("{:02x}", b)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name the staged copy is given on the host.
|
||||||
|
///
|
||||||
|
/// The whole point is that what lands on the desktop is called `notes.txt` and
|
||||||
|
/// not `tmp1234`, so the container's basename is kept verbatim wherever it can
|
||||||
|
/// be. Only the characters Windows refuses outright are substituted — a Linux
|
||||||
|
/// file really can be called `a:b`, and the staged copy has to exist on NTFS.
|
||||||
|
/// A name that is not a filename at all (empty, `.`, `..`) is rejected rather
|
||||||
|
/// than invented: that means the caller passed something that never named a
|
||||||
|
/// file, and quietly inventing a name would stage the wrong thing.
|
||||||
|
fn stage_file_name(container_path: &str) -> Result<String, String> {
|
||||||
|
let base = container_path
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let cleaned: String = base
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
|
||||||
|
c if (c as u32) < 0x20 => '_',
|
||||||
|
c => c,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Windows also silently drops a trailing dot or space, which would make the
|
||||||
|
// path we hand back not the path that exists.
|
||||||
|
let cleaned = cleaned.trim_end_matches([' ', '.']);
|
||||||
|
|
||||||
|
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
|
||||||
|
return Err(format!("{} does not name a file", container_path));
|
||||||
|
}
|
||||||
|
Ok(cleaned.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject an oversize file *by its real size*, before anything is written.
|
||||||
|
///
|
||||||
|
/// Split out so the ceiling and its wording are testable without a container.
|
||||||
|
/// The message names the fallback, because "too large" with no way forward is
|
||||||
|
/// the one thing a size cap must not be.
|
||||||
|
fn check_stage_size(size: u64) -> Result<(), String> {
|
||||||
|
if size > MAX_DRAG_STAGE_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"{:.0} MB is too large to drag out (limit {} MB) — use \"Save to host…\" instead.",
|
||||||
|
size as f64 / (1024.0 * 1024.0),
|
||||||
|
MAX_DRAG_STAGE_BYTES / (1024 * 1024)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a leftover staging directory is old enough to delete.
|
||||||
|
///
|
||||||
|
/// A modification time in the *future* (a clock step, a copied temp dir) makes
|
||||||
|
/// `duration_since` fail, and that answers "not stale" — housekeeping deleting
|
||||||
|
/// something it cannot date is worse than leaving it for the next startup.
|
||||||
|
fn drag_stage_is_stale(modified: SystemTime, now: SystemTime, max_age: Duration) -> bool {
|
||||||
|
now.duration_since(modified)
|
||||||
|
.map(|age| age >= max_age)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete every staging directory except this process's own, once it is older
|
||||||
|
/// than [`DRAG_STAGE_MAX_AGE`]. Called from startup housekeeping.
|
||||||
|
pub async fn reap_drag_staging(temp_dir: PathBuf) {
|
||||||
|
let root = drag_stage_root(&temp_dir);
|
||||||
|
let keep = drag_stage_session_dir(&temp_dir);
|
||||||
|
let now = SystemTime::now();
|
||||||
|
|
||||||
|
let mut dir = match tokio::fs::read_dir(&root).await {
|
||||||
|
Ok(dir) => dir,
|
||||||
|
// Nothing staged yet is the normal case, not a problem.
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut reaped = 0usize;
|
||||||
|
while let Ok(Some(entry)) = dir.next_entry().await {
|
||||||
|
let path = entry.path();
|
||||||
|
if path == keep {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let stale = match entry.metadata().await.and_then(|m| m.modified()) {
|
||||||
|
Ok(modified) => drag_stage_is_stale(modified, now, DRAG_STAGE_MAX_AGE),
|
||||||
|
Err(_) => false,
|
||||||
|
};
|
||||||
|
if !stale {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if tokio::fs::remove_dir_all(&path).await.is_ok() {
|
||||||
|
reaped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if reaped > 0 {
|
||||||
|
log::info!("Startup housekeeping removed {} stale drag-out staging directory(ies)", reaped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete this process's staging directory. Called from the shutdown teardown.
|
||||||
|
pub async fn clear_drag_staging(temp_dir: PathBuf) {
|
||||||
|
let dir = drag_stage_session_dir(&temp_dir);
|
||||||
|
if let Err(e) = tokio::fs::remove_dir_all(&dir).await {
|
||||||
|
if e.kind() != std::io::ErrorKind::NotFound {
|
||||||
|
log::warn!("Failed to clear drag-out staging at {}: {}", dir.display(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Best effort: leave no empty root behind either. Fails harmlessly while
|
||||||
|
// another instance still has a directory in there.
|
||||||
|
let _ = tokio::fs::remove_dir(drag_stage_root(&temp_dir)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy a container file onto the host so it can be dragged to the desktop, and
|
||||||
|
/// return the absolute host path.
|
||||||
|
///
|
||||||
|
/// Reuses [`fetch_container_file`] rather than extracting a second way, so a
|
||||||
|
/// dragged file, a downloaded file and a previewed file are byte-identical and
|
||||||
|
/// refuse folders and links with the same words. The fetch is capped at
|
||||||
|
/// [`MAX_DRAG_STAGE_BYTES`], so an oversize file is recognised from the tar
|
||||||
|
/// header without being pulled across the socket in full.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stage_container_file_for_drag(
|
||||||
|
app: AppHandle,
|
||||||
|
project_id: String,
|
||||||
|
path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
let container_id = project
|
||||||
|
.container_id
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "Container not running".to_string())?;
|
||||||
|
|
||||||
|
// Before the transfer: a path that cannot become a host filename is not
|
||||||
|
// worth a round trip.
|
||||||
|
let file_name = stage_file_name(&path)?;
|
||||||
|
|
||||||
|
let fetched = fetch_container_file(container_id, &path, Some(MAX_DRAG_STAGE_BYTES)).await?;
|
||||||
|
// `size` is the tar header's, i.e. the file's real size, which is exactly
|
||||||
|
// what a truncated fetch does not tell you from `bytes.len()`.
|
||||||
|
check_stage_size(fetched.size)?;
|
||||||
|
|
||||||
|
let temp_dir = app
|
||||||
|
.path()
|
||||||
|
.temp_dir()
|
||||||
|
.map_err(|e| format!("No host temporary directory available: {}", e))?;
|
||||||
|
let dir = drag_stage_session_dir(&temp_dir).join(drag_stage_slot(&path));
|
||||||
|
tokio::fs::create_dir_all(&dir)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create the drag staging directory: {}", e))?;
|
||||||
|
|
||||||
|
let dest = dir.join(&file_name);
|
||||||
|
tokio::fs::write(&dest, &fetched.bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to stage {} on the host: {}", file_name, e))?;
|
||||||
|
|
||||||
|
Ok(dest.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Rename an entry in place. `to_path` is the **new name**, not a destination
|
/// Rename an entry in place. `to_path` is the **new name**, not a destination
|
||||||
/// path — moving between directories is deliberately not offered here, so the
|
/// path — moving between directories is deliberately not offered here, so the
|
||||||
/// name is validated to carry no `/`.
|
/// name is validated to carry no `/`.
|
||||||
@@ -849,4 +1086,114 @@ mod tests {
|
|||||||
assert_eq!(Some(u64::MAX).unwrap().min(MAX_READ_BYTES), MAX_READ_BYTES);
|
assert_eq!(Some(u64::MAX).unwrap().min(MAX_READ_BYTES), MAX_READ_BYTES);
|
||||||
assert!(MAX_READ_BYTES < MAX_UPLOAD_BYTES);
|
assert!(MAX_READ_BYTES < MAX_UPLOAD_BYTES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Drag-out staging ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_staging_path_is_built_under_the_supplied_temp_dir() {
|
||||||
|
// Never `/tmp`: on Windows the temp dir is per-user and nowhere near it,
|
||||||
|
// so the whole path has to be derived from what Tauri hands us.
|
||||||
|
let temp = Path::new("/somewhere/else");
|
||||||
|
let root = drag_stage_root(temp);
|
||||||
|
assert_eq!(root, Path::new("/somewhere/else/triple-c-drag-out"));
|
||||||
|
|
||||||
|
let session = drag_stage_session_dir(temp);
|
||||||
|
assert_eq!(session.parent(), Some(root.as_path()));
|
||||||
|
assert!(session.starts_with(root));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_call_in_a_process_stages_into_the_same_session_directory() {
|
||||||
|
// Exit cleanup deletes this directory by name rather than tracking what
|
||||||
|
// it wrote, which only works if the name does not move.
|
||||||
|
let temp = Path::new("/tmp-ish");
|
||||||
|
assert_eq!(drag_stage_session_dir(temp), drag_stage_session_dir(temp));
|
||||||
|
assert_ne!(drag_stage_session_dir(temp), drag_stage_root(temp));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_staged_copy_keeps_the_original_file_name() {
|
||||||
|
// The reason the feature stages into a per-session directory at all: a
|
||||||
|
// plain temp file would be dropped onto the desktop called `tmp1234`.
|
||||||
|
assert_eq!(stage_file_name("/workspace/notes.txt").unwrap(), "notes.txt");
|
||||||
|
assert_eq!(stage_file_name("/workspace/a b/.env").unwrap(), ".env");
|
||||||
|
assert_eq!(stage_file_name("report.pdf").unwrap(), "report.pdf");
|
||||||
|
assert_eq!(stage_file_name("/workspace/über.md").unwrap(), "über.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_name_windows_cannot_hold_is_substituted_rather_than_dropped() {
|
||||||
|
// These are all legal on Linux and all refused by NTFS, and the staged
|
||||||
|
// copy has to exist on the host we are dragging onto.
|
||||||
|
assert_eq!(stage_file_name("/workspace/a:b.txt").unwrap(), "a_b.txt");
|
||||||
|
assert_eq!(stage_file_name("/workspace/q?.log").unwrap(), "q_.log");
|
||||||
|
assert_eq!(stage_file_name("/workspace/a\\b").unwrap(), "a_b");
|
||||||
|
// A trailing dot or space is not refused, it is silently dropped — so
|
||||||
|
// the path we return would not be the path that exists.
|
||||||
|
assert_eq!(stage_file_name("/workspace/trailing. ").unwrap(), "trailing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_path_that_does_not_name_a_file_is_refused_not_invented() {
|
||||||
|
assert!(stage_file_name("/").is_err());
|
||||||
|
assert!(stage_file_name("").is_err());
|
||||||
|
assert!(stage_file_name("/workspace/..").is_err());
|
||||||
|
assert!(stage_file_name("/workspace/.").is_err());
|
||||||
|
// Trims down to nothing, which is the same problem one step later.
|
||||||
|
assert!(stage_file_name("/workspace/...").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_files_with_the_same_name_stage_to_different_places() {
|
||||||
|
// Names are unique per directory, not per container — and the second
|
||||||
|
// drag would otherwise rewrite the first one's bytes under the path the
|
||||||
|
// first one is still cached at.
|
||||||
|
assert_ne!(
|
||||||
|
drag_stage_slot("/workspace/a/notes.txt"),
|
||||||
|
drag_stage_slot("/workspace/b/notes.txt")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn re_staging_the_same_file_reuses_its_slot() {
|
||||||
|
// Deterministic, so a file dragged repeatedly does not grow a new
|
||||||
|
// directory in the host temp dir every time.
|
||||||
|
assert_eq!(
|
||||||
|
drag_stage_slot("/workspace/notes.txt"),
|
||||||
|
drag_stage_slot("/workspace/notes.txt")
|
||||||
|
);
|
||||||
|
// Short enough to keep the path sane, long enough not to collide.
|
||||||
|
assert_eq!(drag_stage_slot("/workspace/notes.txt").len(), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_drag_size_cap_matches_the_established_ceiling_and_names_the_fallback() {
|
||||||
|
assert_eq!(MAX_DRAG_STAGE_BYTES, MAX_UPLOAD_BYTES);
|
||||||
|
assert!(check_stage_size(MAX_DRAG_STAGE_BYTES).is_ok());
|
||||||
|
|
||||||
|
let err = check_stage_size(MAX_DRAG_STAGE_BYTES + 1).unwrap_err();
|
||||||
|
assert!(err.contains("256 MB"), "{}", err);
|
||||||
|
// A size cap with no way forward is the one thing this must not be.
|
||||||
|
assert!(err.contains("Save to host"), "{}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_reaper_only_takes_entries_past_the_age_threshold() {
|
||||||
|
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
|
||||||
|
let age = Duration::from_secs(3_600);
|
||||||
|
|
||||||
|
assert!(drag_stage_is_stale(now - Duration::from_secs(3_601), now, age));
|
||||||
|
assert!(drag_stage_is_stale(now - age, now, age));
|
||||||
|
assert!(!drag_stage_is_stale(now - Duration::from_secs(3_599), now, age));
|
||||||
|
assert!(!drag_stage_is_stale(now, now, age));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_future_timestamp_is_left_alone_rather_than_reaped() {
|
||||||
|
// A clock step must not turn housekeeping into deletion of something it
|
||||||
|
// cannot date.
|
||||||
|
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
|
||||||
|
let age = Duration::from_secs(3_600);
|
||||||
|
assert!(!drag_stage_is_stale(now + Duration::from_secs(60), now, age));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,6 +215,11 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
// Drag a file from the Files tab onto the host desktop. The gesture is
|
||||||
|
// pointer-driven for the same reason the tab drag is (see MainTabs):
|
||||||
|
// `dragDropEnabled` is on for the terminal's sake and blocks HTML5 drag
|
||||||
|
// inside the webview, so this plugin's native drag is the only route out.
|
||||||
|
.plugin(tauri_plugin_drag::init())
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
projects_store,
|
projects_store,
|
||||||
settings_store,
|
settings_store,
|
||||||
@@ -250,13 +255,22 @@ pub fn run() {
|
|||||||
// an image open and the sweep will not force; pins are untagged
|
// an image open and the sweep will not force; pins are untagged
|
||||||
// second so the images they were holding are dangling by the time
|
// second so the images they were holding are dangling by the time
|
||||||
// the sweep lists them; the sweep runs last and collects both.
|
// the sweep lists them; the sweep runs last and collects both.
|
||||||
tauri::async_runtime::spawn(async {
|
//
|
||||||
|
// Drag-out staging is swept here too, and it is the *other* half of
|
||||||
|
// a lifecycle whose first half is the exit cleanup below: a run that
|
||||||
|
// crashed never got to clear its staged copies, and those are whole
|
||||||
|
// files, not metadata.
|
||||||
|
let drag_temp_dir = app.path().temp_dir().ok();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
crate::docker::reap_probe_containers().await;
|
crate::docker::reap_probe_containers().await;
|
||||||
let reaped = crate::docker::reap_stale_migration_pins().await;
|
let reaped = crate::docker::reap_stale_migration_pins().await;
|
||||||
if reaped > 0 {
|
if reaped > 0 {
|
||||||
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
|
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
|
||||||
}
|
}
|
||||||
crate::docker::sweep_orphaned_snapshots_logged("startup").await;
|
crate::docker::sweep_orphaned_snapshots_logged("startup").await;
|
||||||
|
if let Some(temp_dir) = drag_temp_dir {
|
||||||
|
commands::file_commands::reap_drag_staging(temp_dir).await;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-start web terminal server if enabled in settings
|
// Auto-start web terminal server if enabled in settings
|
||||||
@@ -383,6 +397,10 @@ pub fn run() {
|
|||||||
let _ = window.emit("app-shutting-down", ());
|
let _ = window.emit("app-shutting-down", ());
|
||||||
|
|
||||||
let app_handle = window.app_handle().clone();
|
let app_handle = window.app_handle().clone();
|
||||||
|
// Resolved here rather than inside the teardown, which is
|
||||||
|
// already under a wall-clock budget and should not spend any of
|
||||||
|
// it asking where the temp dir is.
|
||||||
|
let drag_temp_dir = app_handle.path().temp_dir().ok();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let teardown = async {
|
let teardown = async {
|
||||||
// First: let the auto-starts unwind. Anything they are
|
// First: let the auto-starts unwind. Anything they are
|
||||||
@@ -409,10 +427,20 @@ pub fn run() {
|
|||||||
log::warn!("Failed to stop the model gateway on exit: {}", e);
|
log::warn!("Failed to stop the model gateway on exit: {}", e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Whole files copied out of containers for drag-out.
|
||||||
|
// Left behind they are a disk leak with a gesture
|
||||||
|
// attached; startup housekeeping is the backstop for a
|
||||||
|
// run that never reaches this point.
|
||||||
|
let clear_drag_staging = async {
|
||||||
|
if let Some(temp_dir) = drag_temp_dir {
|
||||||
|
commands::file_commands::clear_drag_staging(temp_dir).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
tokio::join!(
|
tokio::join!(
|
||||||
web_terminal,
|
web_terminal,
|
||||||
stop_stt,
|
stop_stt,
|
||||||
stop_gateway,
|
stop_gateway,
|
||||||
|
clear_drag_staging,
|
||||||
exec_manager.close_all_sessions(),
|
exec_manager.close_all_sessions(),
|
||||||
auth_bridge.stop_all(),
|
auth_bridge.stop_all(),
|
||||||
browser_view::manager().stop_all(),
|
browser_view::manager().stop_all(),
|
||||||
@@ -502,6 +530,7 @@ pub fn run() {
|
|||||||
commands::file_commands::read_container_file,
|
commands::file_commands::read_container_file,
|
||||||
commands::file_commands::rename_container_path,
|
commands::file_commands::rename_container_path,
|
||||||
commands::file_commands::create_container_directory,
|
commands::file_commands::create_container_directory,
|
||||||
|
commands::file_commands::stage_container_file_for_drag,
|
||||||
// AWS
|
// AWS
|
||||||
commands::aws_commands::aws_sso_refresh,
|
commands::aws_commands::aws_sso_refresh,
|
||||||
// Updates
|
// Updates
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const uploadFileToContainer = vi.fn(async () => {});
|
|||||||
const renameContainerPath = vi.fn(async () => "");
|
const renameContainerPath = vi.fn(async () => "");
|
||||||
const createContainerDirectory = vi.fn(async () => "");
|
const createContainerDirectory = vi.fn(async () => "");
|
||||||
const readContainerFile = vi.fn();
|
const readContainerFile = vi.fn();
|
||||||
|
const stageContainerFileForDrag = vi.fn(async () => "/tmp/triple-c-drag-out/s1/notes.txt");
|
||||||
|
|
||||||
vi.mock("../../../lib/tauri-commands", () => ({
|
vi.mock("../../../lib/tauri-commands", () => ({
|
||||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||||
@@ -18,6 +19,13 @@ vi.mock("../../../lib/tauri-commands", () => ({
|
|||||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||||
createContainerDirectory(p, parent, n),
|
createContainerDirectory(p, parent, n),
|
||||||
readContainerFile: (p: string, path: string, max?: number) => readContainerFile(p, path, max),
|
readContainerFile: (p: string, path: string, max?: number) => readContainerFile(p, path, max),
|
||||||
|
stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** The OS-level drag. Nothing in jsdom can start one, so it is only observed. */
|
||||||
|
const startDrag = vi.fn(async () => {});
|
||||||
|
vi.mock("@crabnebula/tauri-plugin-drag", () => ({
|
||||||
|
startDrag: (opts: unknown) => startDrag(opts),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const save = vi.fn(async () => "/host/out");
|
const save = vi.fn(async () => "/host/out");
|
||||||
@@ -78,9 +86,32 @@ async function drop(paths: string[], position = { x: 100, y: 100 }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pointer event carrying real coordinates.
|
||||||
|
*
|
||||||
|
* jsdom implements no `PointerEvent` and Testing Library's synthesized one has
|
||||||
|
* no coordinates — which is the whole gesture here, since the drag only starts
|
||||||
|
* once the pointer has travelled past the threshold. `MouseEvent` has them, and
|
||||||
|
* React dispatches on the type name either way.
|
||||||
|
*/
|
||||||
|
function pointer(el: Element, type: string, clientX: number, clientY: number) {
|
||||||
|
fireEvent(
|
||||||
|
el,
|
||||||
|
new MouseEvent(type, { bubbles: true, cancelable: true, clientX, clientY, button: 0 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Press on a row and move far enough to become a drag, leaving the button down. */
|
||||||
|
function dragRow(el: Element) {
|
||||||
|
pointer(el, "pointerdown", 10, 10);
|
||||||
|
pointer(el, "pointermove", 60, 10);
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
dragHandler = null;
|
dragHandler = null;
|
||||||
|
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/notes.txt");
|
||||||
|
startDrag.mockResolvedValue(undefined);
|
||||||
listContainerFiles.mockResolvedValue([
|
listContainerFiles.mockResolvedValue([
|
||||||
entry("src", { is_directory: true, path: "/workspace/src" }),
|
entry("src", { is_directory: true, path: "/workspace/src" }),
|
||||||
entry("notes.txt"),
|
entry("notes.txt"),
|
||||||
@@ -93,6 +124,10 @@ beforeEach(() => {
|
|||||||
// Not implemented in jsdom; the image preview needs both halves.
|
// Not implemented in jsdom; the image preview needs both halves.
|
||||||
URL.createObjectURL = vi.fn(() => "blob:mock-url");
|
URL.createObjectURL = vi.fn(() => "blob:mock-url");
|
||||||
URL.revokeObjectURL = vi.fn();
|
URL.revokeObjectURL = vi.fn();
|
||||||
|
// Nor is canvas, which the drag preview draws on. Stubbed to the null jsdom
|
||||||
|
// would return anyway, minus the "not implemented" noise on every drag —
|
||||||
|
// `dragPreview.test.ts` covers what the fallback then produces.
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("FilesTab listing", () => {
|
describe("FilesTab listing", () => {
|
||||||
@@ -348,3 +383,120 @@ describe("FilesTab save to host", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "Save src to host" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Save src to host" })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("FilesTab drag-out", () => {
|
||||||
|
it("stages the file on the host and starts the native drag on that copy", async () => {
|
||||||
|
// The container path is not draggable — only the host copy is — so the
|
||||||
|
// thing handed to the OS must be what staging returned.
|
||||||
|
await renderTab();
|
||||||
|
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(startDrag).toHaveBeenCalled());
|
||||||
|
expect(stageContainerFileForDrag).toHaveBeenCalledWith("p1", "/workspace/notes.txt");
|
||||||
|
expect(startDrag).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ item: ["/tmp/triple-c-drag-out/s1/notes.txt"] }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never drags a directory, which cannot be staged as one file", async () => {
|
||||||
|
await renderTab();
|
||||||
|
dragRow(screen.getByText("src").closest("tr")!);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(stageContainerFileForDrag).not.toHaveBeenCalled();
|
||||||
|
expect(startDrag).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays a click until the pointer has actually travelled", async () => {
|
||||||
|
await renderTab();
|
||||||
|
const row = screen.getByText("notes.txt").closest("tr")!;
|
||||||
|
pointer(row, "pointerdown", 10, 10);
|
||||||
|
pointer(row, "pointermove", 12, 11);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(stageContainerFileForDrag).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says what went wrong instead of leaving a gesture that did nothing", async () => {
|
||||||
|
stageContainerFileForDrag.mockRejectedValue(
|
||||||
|
'900 MB is too large to drag out (limit 256 MB) — use "Save to host…" instead.',
|
||||||
|
);
|
||||||
|
await renderTab();
|
||||||
|
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("too large"));
|
||||||
|
expect(startDrag).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("points at the fallback when the platform refuses the drag itself", async () => {
|
||||||
|
startDrag.mockRejectedValue("drag image not found");
|
||||||
|
await renderTab();
|
||||||
|
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("Save to host"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells the user the copy is ready when the drag outlived the gesture", async () => {
|
||||||
|
// Staging is a whole-file copy, and the OS only adopts a drag while the
|
||||||
|
// button is down. Releasing mid-copy used to be — and must not be — a
|
||||||
|
// gesture that did nothing and explained nothing.
|
||||||
|
let release: (path: string) => void = () => {};
|
||||||
|
stageContainerFileForDrag.mockReturnValue(
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await renderTab();
|
||||||
|
const row = screen.getByText("notes.txt").closest("tr")!;
|
||||||
|
dragRow(row);
|
||||||
|
pointer(row, "pointerup", 60, 10);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
release("/tmp/triple-c-drag-out/s1/notes.txt");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText(/is ready/).textContent).toContain("notes.txt"));
|
||||||
|
expect(startDrag).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drags immediately on the retry, reusing the copy it already made", async () => {
|
||||||
|
// The instruction "drag it again" is only honest if the second attempt does
|
||||||
|
// not repeat the copy that made the first one too slow.
|
||||||
|
await renderTab();
|
||||||
|
const row = screen.getByText("notes.txt").closest("tr")!;
|
||||||
|
|
||||||
|
dragRow(row);
|
||||||
|
await waitFor(() => expect(startDrag).toHaveBeenCalledTimes(1));
|
||||||
|
pointer(row, "pointerup", 60, 10);
|
||||||
|
|
||||||
|
dragRow(row);
|
||||||
|
await waitFor(() => expect(startDrag).toHaveBeenCalledTimes(2));
|
||||||
|
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves Save to host… working — drag-out is the enhancement, not the replacement", async () => {
|
||||||
|
await renderTab();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByLabelText("Save notes.txt to host"));
|
||||||
|
});
|
||||||
|
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out");
|
||||||
|
expect(startDrag).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still accepts a drop into the pane — the two directions coexist", async () => {
|
||||||
|
// The drag-out gesture is pointer-driven precisely so it does not need the
|
||||||
|
// HTML5 machinery that Tauri's native drop listener rules out.
|
||||||
|
await renderTab();
|
||||||
|
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||||
|
await waitFor(() => expect(startDrag).toHaveBeenCalled());
|
||||||
|
|
||||||
|
await drop(["/host/a.txt"]);
|
||||||
|
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.txt", "/workspace");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||||
|
import { startDrag } from "@crabnebula/tauri-plugin-drag";
|
||||||
import type { FileEntry, Project } from "../../../lib/types";
|
import type { FileEntry, Project } from "../../../lib/types";
|
||||||
import { useFileManager } from "../../../hooks/useFileManager";
|
import { useFileManager } from "../../../hooks/useFileManager";
|
||||||
import Button from "../../ui/Button";
|
import Button from "../../ui/Button";
|
||||||
import FileViewerModal from "./FileViewerModal";
|
import FileViewerModal from "./FileViewerModal";
|
||||||
|
import { dragPreviewIcon } from "./dragPreview";
|
||||||
import { formatBytes } from "./format";
|
import { formatBytes } from "./format";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the pointer must travel before a press becomes a drag. Same few
|
||||||
|
* pixels of slop as the tab strip, so a click that trembles stays a click.
|
||||||
|
*/
|
||||||
|
const DRAG_THRESHOLD = 4;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The project's file manager.
|
* The project's file manager.
|
||||||
*
|
*
|
||||||
@@ -32,8 +40,10 @@ export default function FilesTab({ project }: Props) {
|
|||||||
downloadFile,
|
downloadFile,
|
||||||
uploadFile,
|
uploadFile,
|
||||||
uploadPaths,
|
uploadPaths,
|
||||||
|
stageForDrag,
|
||||||
renameEntry,
|
renameEntry,
|
||||||
createFolder,
|
createFolder,
|
||||||
|
setError,
|
||||||
} = useFileManager(project.id);
|
} = useFileManager(project.id);
|
||||||
|
|
||||||
const running = project.status === "running";
|
const running = project.status === "running";
|
||||||
@@ -47,6 +57,8 @@ export default function FilesTab({ project }: Props) {
|
|||||||
const [viewing, setViewing] = useState<FileEntry | null>(null);
|
const [viewing, setViewing] = useState<FileEntry | null>(null);
|
||||||
/** A host drag is currently over this pane. */
|
/** A host drag is currently over this pane. */
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
/** Name of a file staged for drag-out whose gesture did not reach the OS. */
|
||||||
|
const [dragNotice, setDragNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
const paneRef = useRef<HTMLDivElement>(null);
|
const paneRef = useRef<HTMLDivElement>(null);
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -61,6 +73,7 @@ export default function FilesTab({ project }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setRenaming(null);
|
setRenaming(null);
|
||||||
|
setDragNotice(null);
|
||||||
}, [currentPath]);
|
}, [currentPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -119,6 +132,112 @@ export default function FilesTab({ project }: Props) {
|
|||||||
[navigate],
|
[navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Container → host drag-out.
|
||||||
|
//
|
||||||
|
// The mirror image of the drop path below, and it has the same constraint
|
||||||
|
// pushing it: `dragDropEnabled` blocks HTML5 drag inside the webview, so
|
||||||
|
// `draggable` + `DataTransfer` is not available and the gesture is driven
|
||||||
|
// from pointer events into the native drag plugin — exactly the shape the tab
|
||||||
|
// strip uses, and for the same reason.
|
||||||
|
//
|
||||||
|
// What makes it more than a pointer gesture is that the file being dragged
|
||||||
|
// does not exist on the host at all: it lives in the container, and the OS
|
||||||
|
// can only drag a real host path. So every drag-out is a copy first (see
|
||||||
|
// `stageForDrag`) and a drag second, which is why the gesture has an async
|
||||||
|
// gap in the middle of something that feels instantaneous.
|
||||||
|
const dragOut = useRef<{
|
||||||
|
path: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
down: boolean;
|
||||||
|
started: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Pointer-up almost never lands on the row it started on — the pointer has
|
||||||
|
// moved off it by definition, and once the OS takes the drag the webview stops
|
||||||
|
// seeing the pointer at all, which is what makes a lost focus the only
|
||||||
|
// "the button came up" signal left.
|
||||||
|
useEffect(() => {
|
||||||
|
const release = () => {
|
||||||
|
if (dragOut.current) dragOut.current.down = false;
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerup", release);
|
||||||
|
window.addEventListener("pointercancel", release);
|
||||||
|
window.addEventListener("blur", release);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerup", release);
|
||||||
|
window.removeEventListener("pointercancel", release);
|
||||||
|
window.removeEventListener("blur", release);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const beginDragOut = useCallback(
|
||||||
|
async (entry: FileEntry) => {
|
||||||
|
setDragNotice(null);
|
||||||
|
const staged = await stageForDrag(entry);
|
||||||
|
// `stageForDrag` has already put the reason in `error`.
|
||||||
|
if (!staged) return;
|
||||||
|
|
||||||
|
// The OS only adopts a drag while the button is still down, and the copy
|
||||||
|
// that just ran can easily outlast a flick of the wrist. Say so rather
|
||||||
|
// than leaving a gesture that did nothing and explained nothing — and it
|
||||||
|
// is a real instruction, not an apology: the copy is kept, so the second
|
||||||
|
// attempt starts immediately.
|
||||||
|
if (dragOut.current?.path !== entry.path || !dragOut.current.down) {
|
||||||
|
setDragNotice(entry.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startDrag({ item: [staged.hostPath], icon: dragPreviewIcon(entry.name) });
|
||||||
|
} catch (e) {
|
||||||
|
// Drag-out is the enhancement; "Save to host…" is the path that always
|
||||||
|
// works, so a platform that refuses the drag says where to go instead.
|
||||||
|
setError(`Could not start the drag: ${e}. Use "Save to host…" instead.`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[stageForDrag, setError],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pointer wiring for one row. Directories get none of it: staging copies a
|
||||||
|
* single regular file, and a folder would only ever produce an error.
|
||||||
|
*/
|
||||||
|
const dragOutProps = (entry: FileEntry) => {
|
||||||
|
if (entry.is_directory) return {};
|
||||||
|
return {
|
||||||
|
onPointerDown: (e: React.PointerEvent<HTMLTableRowElement>) => {
|
||||||
|
if (e.button !== 0 || renaming === entry.name) return;
|
||||||
|
// The row's own controls, and the rename input, where a drag is a text
|
||||||
|
// selection.
|
||||||
|
if ((e.target as HTMLElement).closest("button, input")) return;
|
||||||
|
dragOut.current = {
|
||||||
|
path: entry.path,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
down: true,
|
||||||
|
started: false,
|
||||||
|
};
|
||||||
|
// Deliberately no `setPointerCapture` — unlike the tab strip, which
|
||||||
|
// draws its own ghost. Here the OS has to take the pointer over, and a
|
||||||
|
// capture held in the webview is exactly what stops it.
|
||||||
|
},
|
||||||
|
onPointerMove: (e: React.PointerEvent<HTMLTableRowElement>) => {
|
||||||
|
const gesture = dragOut.current;
|
||||||
|
if (!gesture || gesture.started || !gesture.down) return;
|
||||||
|
if (gesture.path !== entry.path) return;
|
||||||
|
if (
|
||||||
|
Math.abs(e.clientX - gesture.x) < DRAG_THRESHOLD &&
|
||||||
|
Math.abs(e.clientY - gesture.y) < DRAG_THRESHOLD
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gesture.started = true;
|
||||||
|
void beginDragOut(entry);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Host → container drag and drop.
|
// Host → container drag and drop.
|
||||||
//
|
//
|
||||||
// This is Tauri's *native* drag-drop event, not HTML5 `ondrop`, for the same
|
// This is Tauri's *native* drag-drop event, not HTML5 `ondrop`, for the same
|
||||||
@@ -226,6 +345,11 @@ export default function FilesTab({ project }: Props) {
|
|||||||
{busy}
|
{busy}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{!busy && dragNotice && (
|
||||||
|
<span role="status" className="mr-2 text-[var(--text-secondary)] whitespace-nowrap">
|
||||||
|
"{dragNotice}" is ready — drag it again to drop it on the desktop.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFolderDraft("");
|
setFolderDraft("");
|
||||||
@@ -310,6 +434,7 @@ export default function FilesTab({ project }: Props) {
|
|||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
onClick={() => setSelected(entry.name)}
|
onClick={() => setSelected(entry.name)}
|
||||||
onDoubleClick={() => openEntry(entry)}
|
onDoubleClick={() => openEntry(entry)}
|
||||||
|
{...dragOutProps(entry)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (isRenaming) return;
|
if (isRenaming) return;
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { dragPreviewIcon } from "./dragPreview";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dragPreviewIcon", () => {
|
||||||
|
it("falls back to a PNG data URL when there is no 2D context", () => {
|
||||||
|
// jsdom has no canvas, and a webview can refuse one. `startDrag` requires
|
||||||
|
// an image and the Rust side accepts nothing but a PNG data URL, so a
|
||||||
|
// fallback that is not one takes the whole drag down with it.
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||||
|
expect(dragPreviewIcon("notes.txt")).toMatch(/^data:image\/png;base64,[A-Za-z0-9+/=]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back rather than throwing when the canvas throws", () => {
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => {
|
||||||
|
throw new Error("no canvas here");
|
||||||
|
});
|
||||||
|
expect(dragPreviewIcon("notes.txt")).toMatch(/^data:image\/png;base64,/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a canvas that encoded nothing", () => {
|
||||||
|
// jsdom's `toDataURL` answers `data:,` — which the Rust side rejects
|
||||||
|
// outright, so returning it would be worse than not drawing at all.
|
||||||
|
const ctx = {
|
||||||
|
scale: vi.fn(),
|
||||||
|
measureText: () => ({ width: 60 }),
|
||||||
|
beginPath: vi.fn(),
|
||||||
|
roundRect: vi.fn(),
|
||||||
|
fill: vi.fn(),
|
||||||
|
stroke: vi.fn(),
|
||||||
|
fillRect: vi.fn(),
|
||||||
|
strokeRect: vi.fn(),
|
||||||
|
fillText: vi.fn(),
|
||||||
|
font: "",
|
||||||
|
fillStyle: "",
|
||||||
|
strokeStyle: "",
|
||||||
|
lineWidth: 0,
|
||||||
|
textBaseline: "",
|
||||||
|
} as unknown as CanvasRenderingContext2D;
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctx);
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue("data:,");
|
||||||
|
|
||||||
|
expect(dragPreviewIcon("notes.txt")).toMatch(/^data:image\/png;base64,[A-Za-z0-9+/=]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses what the canvas drew when there is one", () => {
|
||||||
|
const ctx = {
|
||||||
|
scale: vi.fn(),
|
||||||
|
measureText: () => ({ width: 60 }),
|
||||||
|
beginPath: vi.fn(),
|
||||||
|
roundRect: vi.fn(),
|
||||||
|
fill: vi.fn(),
|
||||||
|
stroke: vi.fn(),
|
||||||
|
fillRect: vi.fn(),
|
||||||
|
strokeRect: vi.fn(),
|
||||||
|
fillText: vi.fn(),
|
||||||
|
font: "",
|
||||||
|
fillStyle: "",
|
||||||
|
strokeStyle: "",
|
||||||
|
lineWidth: 0,
|
||||||
|
textBaseline: "",
|
||||||
|
} as unknown as CanvasRenderingContext2D;
|
||||||
|
const drawn = "data:image/png;base64,AAAA";
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctx);
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue(drawn);
|
||||||
|
|
||||||
|
expect(dragPreviewIcon("notes.txt")).toBe(drawn);
|
||||||
|
// A long name is elided rather than drawn off the edge of the preview.
|
||||||
|
expect(dragPreviewIcon("a-really-quite-long-file-name-indeed.txt")).toBe(drawn);
|
||||||
|
expect(ctx.fillText).toHaveBeenLastCalledWith(
|
||||||
|
expect.stringContaining("…"),
|
||||||
|
expect.any(Number),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* The image the OS shows under the cursor during a drag-out.
|
||||||
|
*
|
||||||
|
* `startDrag` requires one — the plugin's `image` argument is not optional, and
|
||||||
|
* it only accepts a `data:image/png;base64,` URL — so this is drawn rather than
|
||||||
|
* shipped as an asset. Drawing it is also what keeps the colours honest: the
|
||||||
|
* palette lives in CSS custom properties, and reading them off the document is
|
||||||
|
* the only way a raw-pixel preview can still come from the design tokens rather
|
||||||
|
* than from hard-coded hexes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 1x1 transparent PNG, used when no 2D canvas is available — jsdom has none,
|
||||||
|
* and a webview can refuse a context under memory pressure. `startDrag` needs
|
||||||
|
* *an* image, and a drag with an invisible preview is much better than no drag.
|
||||||
|
*/
|
||||||
|
const TRANSPARENT_PNG =
|
||||||
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=";
|
||||||
|
|
||||||
|
/** Longest filename drawn in full; past this the middle is elided. */
|
||||||
|
const MAX_LABEL = 28;
|
||||||
|
|
||||||
|
function cssVar(name: string, fallback: string): string {
|
||||||
|
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||||
|
return value || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep both ends of a long name — the extension is the informative half. */
|
||||||
|
function elide(label: string): string {
|
||||||
|
if (label.length <= MAX_LABEL) return label;
|
||||||
|
const head = label.slice(0, MAX_LABEL - 12);
|
||||||
|
const tail = label.slice(-9);
|
||||||
|
return `${head}…${tail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dragPreviewIcon(label: string): string {
|
||||||
|
try {
|
||||||
|
const text = elide(label);
|
||||||
|
// Cap the scale: the OS draws this at logical size, so a 3x buffer is only
|
||||||
|
// bytes over IPC.
|
||||||
|
const scale = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
const height = 24;
|
||||||
|
const padding = 8;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
|
||||||
|
// Measuring needs a context, and sizing the canvas resets it — so measure
|
||||||
|
// on a throwaway pass, then size, then draw.
|
||||||
|
const probe = canvas.getContext("2d");
|
||||||
|
if (!probe) return TRANSPARENT_PNG;
|
||||||
|
const font = "12px ui-monospace, SFMono-Regular, Menlo, monospace";
|
||||||
|
probe.font = font;
|
||||||
|
const width = Math.ceil(probe.measureText(text).width) + padding * 2;
|
||||||
|
|
||||||
|
canvas.width = Math.round(width * scale);
|
||||||
|
canvas.height = Math.round(height * scale);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return TRANSPARENT_PNG;
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
|
||||||
|
ctx.fillStyle = cssVar("--bg-tertiary", "#2a2a2a");
|
||||||
|
ctx.strokeStyle = cssVar("--accent", "#6aa8ff");
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
if (typeof ctx.roundRect === "function") {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(0.5, 0.5, width - 1, height - 1, 4);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
} else {
|
||||||
|
ctx.fillRect(0.5, 0.5, width - 1, height - 1);
|
||||||
|
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.font = font;
|
||||||
|
ctx.fillStyle = cssVar("--text-primary", "#e6e6e6");
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText(text, padding, height / 2);
|
||||||
|
|
||||||
|
const url = canvas.toDataURL("image/png");
|
||||||
|
// jsdom (and a canvas that failed to encode) answers `data:,` — which the
|
||||||
|
// Rust side rejects outright, taking the whole drag with it.
|
||||||
|
return url.startsWith("data:image/png;base64,") ? url : TRANSPARENT_PNG;
|
||||||
|
} catch {
|
||||||
|
return TRANSPARENT_PNG;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ const downloadContainerFile = vi.fn();
|
|||||||
const uploadFileToContainer = vi.fn();
|
const uploadFileToContainer = vi.fn();
|
||||||
const renameContainerPath = vi.fn();
|
const renameContainerPath = vi.fn();
|
||||||
const createContainerDirectory = vi.fn();
|
const createContainerDirectory = vi.fn();
|
||||||
|
const stageContainerFileForDrag = vi.fn();
|
||||||
|
|
||||||
vi.mock("../lib/tauri-commands", () => ({
|
vi.mock("../lib/tauri-commands", () => ({
|
||||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||||
@@ -17,6 +18,7 @@ vi.mock("../lib/tauri-commands", () => ({
|
|||||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||||
createContainerDirectory(p, parent, n),
|
createContainerDirectory(p, parent, n),
|
||||||
readContainerFile: vi.fn(),
|
readContainerFile: vi.fn(),
|
||||||
|
stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const save = vi.fn();
|
const save = vi.fn();
|
||||||
@@ -209,3 +211,82 @@ describe("useFileManager save to host", () => {
|
|||||||
expect(result.current.error).toContain("is a folder");
|
expect(result.current.error).toContain("is a folder");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("useFileManager drag-out staging", () => {
|
||||||
|
it("copies the file onto the host and hands back the host path", async () => {
|
||||||
|
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
|
||||||
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
|
|
||||||
|
let staged: { hostPath: string; cached: boolean } | null = null;
|
||||||
|
await act(async () => {
|
||||||
|
staged = await result.current.stageForDrag(file("a.txt"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stageContainerFileForDrag).toHaveBeenCalledWith("p1", "/workspace/a.txt");
|
||||||
|
expect(staged).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: false });
|
||||||
|
// The note is transient — it must not still be sitting there afterwards.
|
||||||
|
expect(result.current.busy).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses the copy on a second drag of the same entry", async () => {
|
||||||
|
// The whole point of the cache: the copy is the slow half of the gesture,
|
||||||
|
// and a retry after a drag the OS missed has to be immediate.
|
||||||
|
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
|
||||||
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
|
|
||||||
|
let second: { hostPath: string; cached: boolean } | null = null;
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.stageForDrag(file("a.txt"));
|
||||||
|
second = await result.current.stageForDrag(file("a.txt"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(1);
|
||||||
|
expect(second).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-stages once the entry has changed underneath it", async () => {
|
||||||
|
// Keyed on size and mtime, so a file edited in the container is copied
|
||||||
|
// again rather than dragged out at its old contents.
|
||||||
|
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
|
||||||
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.stageForDrag(file("a.txt", { size: 10 }));
|
||||||
|
await result.current.stageForDrag(file("a.txt", { size: 4096 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a refused staging instead of returning a path that is not there", async () => {
|
||||||
|
stageContainerFileForDrag.mockRejectedValue(
|
||||||
|
'900 MB is too large to drag out (limit 256 MB) — use "Save to host…" instead.',
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
|
|
||||||
|
let staged: { hostPath: string; cached: boolean } | null = null;
|
||||||
|
await act(async () => {
|
||||||
|
staged = await result.current.stageForDrag(file("huge.bin"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(staged).toBeNull();
|
||||||
|
expect(result.current.error).toContain("too large to drag out");
|
||||||
|
expect(result.current.error).toContain("Save to host");
|
||||||
|
expect(result.current.busy).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not cache a failure, so a retry actually retries", async () => {
|
||||||
|
stageContainerFileForDrag.mockRejectedValueOnce("Container not running");
|
||||||
|
stageContainerFileForDrag.mockResolvedValueOnce("/tmp/triple-c-drag-out/s1/a.txt");
|
||||||
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
|
|
||||||
|
let staged: { hostPath: string; cached: boolean } | null = null;
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.stageForDrag(file("a.txt"));
|
||||||
|
staged = await result.current.stageForDrag(file("a.txt"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(2);
|
||||||
|
expect(staged).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useRef } from "react";
|
||||||
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
|
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
import type { FileEntry } from "../lib/types";
|
import type { FileEntry } from "../lib/types";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
@@ -83,6 +83,44 @@ export function useFileManager(projectId: string) {
|
|||||||
[projectId, currentPath, navigate],
|
[projectId, currentPath, navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host paths already copied out this session, keyed by the entry they came
|
||||||
|
* from. Size and mtime are in the key, so an entry that changed since the
|
||||||
|
* last listing re-stages rather than dragging a stale copy.
|
||||||
|
*/
|
||||||
|
const stagedRef = useRef(new Map<string, string>());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy an entry onto the host so the OS can drag it, and return the absolute
|
||||||
|
* host path — or `null`, having set `error`, if it could not be staged.
|
||||||
|
*
|
||||||
|
* `cached` is what the caller needs to tell a gesture that will feel
|
||||||
|
* instantaneous from one that has a whole-file copy in front of it: the copy
|
||||||
|
* is the slow half of a drag-out, and the OS only picks a drag up while the
|
||||||
|
* button is still down.
|
||||||
|
*/
|
||||||
|
const stageForDrag = useCallback(
|
||||||
|
async (entry: FileEntry): Promise<{ hostPath: string; cached: boolean } | null> => {
|
||||||
|
const key = `${entry.path}|${entry.size}|${entry.modified}`;
|
||||||
|
const cached = stagedRef.current.get(key);
|
||||||
|
if (cached) return { hostPath: cached, cached: true };
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setBusy(`Preparing "${entry.name}"…`);
|
||||||
|
try {
|
||||||
|
const hostPath = await commands.stageContainerFileForDrag(projectId, entry.path);
|
||||||
|
stagedRef.current.set(key, hostPath);
|
||||||
|
return { hostPath, cached: false };
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId],
|
||||||
|
);
|
||||||
|
|
||||||
const uploadFile = useCallback(async () => {
|
const uploadFile = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const selected = await openDialog({ multiple: true, directory: false });
|
const selected = await openDialog({ multiple: true, directory: false });
|
||||||
@@ -145,6 +183,7 @@ export function useFileManager(projectId: string) {
|
|||||||
downloadFile,
|
downloadFile,
|
||||||
uploadFile,
|
uploadFile,
|
||||||
uploadPaths,
|
uploadPaths,
|
||||||
|
stageForDrag,
|
||||||
renameEntry,
|
renameEntry,
|
||||||
createFolder,
|
createFolder,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ export const renameContainerPath = (projectId: string, fromPath: string, toPath:
|
|||||||
invoke<string>("rename_container_path", { projectId, fromPath, toPath });
|
invoke<string>("rename_container_path", { projectId, fromPath, toPath });
|
||||||
export const createContainerDirectory = (projectId: string, parentPath: string, name: string) =>
|
export const createContainerDirectory = (projectId: string, parentPath: string, name: string) =>
|
||||||
invoke<string>("create_container_directory", { projectId, parentPath, name });
|
invoke<string>("create_container_directory", { projectId, parentPath, name });
|
||||||
|
/**
|
||||||
|
* Copy a container file into an app-owned host temp directory and return the
|
||||||
|
* absolute host path. The OS can only drag a file that exists on the host, so
|
||||||
|
* this is the first half of every drag-out.
|
||||||
|
*/
|
||||||
|
export const stageContainerFileForDrag = (projectId: string, path: string) =>
|
||||||
|
invoke<string>("stage_container_file_for_drag", { projectId, path });
|
||||||
|
|
||||||
// Updates
|
// Updates
|
||||||
export const getAppVersion = () => invoke<string>("get_app_version");
|
export const getAppVersion = () => invoke<string>("get_app_version");
|
||||||
|
|||||||
Reference in New Issue
Block a user