pinokiod 3.183.0 → 3.185.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,8 +28,20 @@ class Terminal {
28
28
  await fs.promises.mkdir(uploadRoot, { recursive: true })
29
29
 
30
30
  const saved = []
31
+ const failures = []
32
+
33
+ const remoteFiles = []
34
+ const localFiles = []
31
35
 
32
36
  for (const file of files) {
37
+ if (file && typeof file.url === 'string' && file.url.trim().length > 0) {
38
+ remoteFiles.push(file)
39
+ } else {
40
+ localFiles.push(file)
41
+ }
42
+ }
43
+
44
+ for (const file of localFiles) {
33
45
  const key = file && file.key
34
46
  if (!key || !buffers[key]) {
35
47
  continue
@@ -66,11 +78,62 @@ class Terminal {
66
78
 
67
79
  }
68
80
 
81
+ for (const file of remoteFiles) {
82
+ const url = typeof file.url === 'string' ? file.url.trim() : ''
83
+ if (!url) {
84
+ continue
85
+ }
86
+ let originalName = typeof file.name === 'string' && file.name.trim().length > 0
87
+ ? file.name.trim()
88
+ : null
89
+ if (!originalName) {
90
+ try {
91
+ const parsed = new URL(url)
92
+ const baseSegment = parsed.pathname ? parsed.pathname.split('/').filter(Boolean).pop() : null
93
+ originalName = baseSegment || 'download'
94
+ } catch (_) {
95
+ originalName = 'download'
96
+ }
97
+ }
98
+ let sanitized = sanitize(originalName) || 'download'
99
+ const targetName = await this.uniqueFilename(uploadRoot, sanitized)
100
+ const targetPath = path.join(uploadRoot, targetName)
101
+ try {
102
+ await kernel.download({ uri: url, path: uploadRoot, filename: targetName }, ondata || (() => {}))
103
+ const stats = await fs.promises.stat(targetPath)
104
+ const size = stats.size
105
+ const homeRelativePath = path.relative(kernel.homedir, targetPath)
106
+ const normalizedHomeRelativePath = homeRelativePath.split(path.sep).join('/')
107
+ const cliPath = targetPath
108
+ const cliBase = baseCwd || kernel.homedir
109
+ const cliRelative = cliBase ? path.relative(cliBase, targetPath) : null
110
+ const cliRelativePath = cliRelative ? cliRelative.split(path.sep).join('/') : null
111
+
112
+ saved.push({
113
+ originalName,
114
+ storedAs: targetName,
115
+ path: targetPath,
116
+ size,
117
+ mimeType: typeof file.type === 'string' ? file.type : '',
118
+ homeRelativePath: normalizedHomeRelativePath,
119
+ displayPath: `~/${normalizedHomeRelativePath}`,
120
+ cliPath,
121
+ cliRelativePath,
122
+ sourceUrl: url
123
+ })
124
+ } catch (error) {
125
+ failures.push({ url, error: error.message })
126
+ try {
127
+ await fs.promises.rm(targetPath, { force: true })
128
+ } catch (_) {}
129
+ }
130
+ }
131
+
69
132
  if (saved.length === 0) {
70
133
  if (ondata) {
71
134
  ondata({ raw: "\r\nNo files were saved.\r\n" })
72
135
  }
73
- return { files: saved }
136
+ return { files: saved, errors: failures }
74
137
  }
75
138
 
76
139
  const marker = '[attachment] '
@@ -95,7 +158,7 @@ class Terminal {
95
158
  req.params.buffers = {}
96
159
  }
97
160
 
98
- return { files: saved }
161
+ return { files: saved, errors: failures }
99
162
  }
100
163
 
101
164
  resolveShellInstance(params, kernel) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "3.183.0",
3
+ "version": "3.185.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -26,6 +26,7 @@ const axios = require('axios')
26
26
  const crypto = require('crypto')
27
27
  const serveIndex = require('./serveIndex')
28
28
  const registerFileRoutes = require('./routes/files')
29
+ const TerminalApi = require('../kernel/api/terminal')
29
30
 
30
31
  const git = require('isomorphic-git')
31
32
  const http = require('isomorphic-git/http/node')
@@ -4799,6 +4800,79 @@ class Server {
4799
4800
  res.json({ error: e.stack })
4800
4801
  }
4801
4802
  }))
4803
+ this.app.post("/terminal/url-upload", ex(async (req, res) => {
4804
+ const payload = req.body || {}
4805
+ const id = typeof payload.id === 'string' ? payload.id.trim() : ''
4806
+ const cwd = typeof payload.cwd === 'string' ? payload.cwd : null
4807
+ const inputUrls = Array.isArray(payload.urls) ? payload.urls : []
4808
+ if (!id) {
4809
+ res.status(400).json({ error: 'terminal id is required' })
4810
+ return
4811
+ }
4812
+ if (inputUrls.length === 0) {
4813
+ res.status(400).json({ error: 'at least one url is required' })
4814
+ return
4815
+ }
4816
+ const normalized = []
4817
+ const hostHeader = typeof req.get === 'function' ? req.get('host') : null
4818
+ const baseOrigin = hostHeader ? `${req.protocol || 'http'}://${hostHeader}` : null
4819
+ const seen = new Set()
4820
+ for (const entry of inputUrls) {
4821
+ const rawHref = (entry && typeof entry === 'object') ? entry.href : entry
4822
+ const nameHint = entry && typeof entry === 'object' && typeof entry.name === 'string' ? entry.name : undefined
4823
+ if (typeof rawHref !== 'string') {
4824
+ continue
4825
+ }
4826
+ const trimmed = rawHref.trim()
4827
+ if (!trimmed) {
4828
+ continue
4829
+ }
4830
+ let resolved
4831
+ try {
4832
+ resolved = baseOrigin ? new URL(trimmed, baseOrigin) : new URL(trimmed)
4833
+ } catch (_) {
4834
+ try {
4835
+ resolved = new URL(trimmed)
4836
+ } catch (_) {
4837
+ continue
4838
+ }
4839
+ }
4840
+ if (!resolved || !/^https?:$/i.test(resolved.protocol)) {
4841
+ continue
4842
+ }
4843
+ const href = resolved.href
4844
+ if (seen.has(href)) {
4845
+ continue
4846
+ }
4847
+ seen.add(href)
4848
+ const item = { url: href }
4849
+ if (nameHint && nameHint.trim()) {
4850
+ item.name = nameHint.trim()
4851
+ }
4852
+ normalized.push(item)
4853
+ }
4854
+ if (normalized.length === 0) {
4855
+ res.status(400).json({ error: 'no valid urls' })
4856
+ return
4857
+ }
4858
+ const terminalApi = new TerminalApi()
4859
+ const requestPayload = {
4860
+ params: {
4861
+ id,
4862
+ cwd,
4863
+ files: normalized,
4864
+ buffers: {}
4865
+ }
4866
+ }
4867
+ try {
4868
+ const result = await terminalApi.upload(requestPayload, () => {}, this.kernel)
4869
+ const files = result && Array.isArray(result.files) ? result.files : []
4870
+ const errors = result && Array.isArray(result.errors) ? result.errors : []
4871
+ res.json({ files, errors })
4872
+ } catch (error) {
4873
+ res.status(500).json({ error: error && error.message ? error.message : 'remote upload failed' })
4874
+ }
4875
+ }))
4802
4876
  this.app.post("/push", ex(async (req, res) => {
4803
4877
  try {
4804
4878
  const payload = { ...(req.body || {}) }
@@ -2190,21 +2190,209 @@ document.addEventListener("DOMContentLoaded", () => {
2190
2190
  });
2191
2191
  observer.observe(document.body, { attributes: true });
2192
2192
 
2193
- if (document.querySelector("#screenshot")) {
2194
- document.querySelector("#screenshot").addEventListener("click", (e) => {
2195
- screenshot()
2196
- })
2193
+ const createFrameHistoryController = () => {
2194
+ const sanitizeStack = (input) => {
2195
+ if (!Array.isArray(input)) {
2196
+ return []
2197
+ }
2198
+ return input.filter((value) => typeof value === 'string' && value.length > 0)
2199
+ }
2200
+ const resolveFrameKey = () => {
2201
+ try {
2202
+ if (typeof window.name === 'string' && window.name.length > 0) {
2203
+ return `name:${window.name}`
2204
+ }
2205
+ } catch (_) {}
2206
+ try {
2207
+ if (window.frameElement && window.frameElement.id) {
2208
+ return `frame:${window.frameElement.id}`
2209
+ }
2210
+ } catch (_) {}
2211
+ return 'top'
2212
+ }
2213
+ const frameKey = resolveFrameKey()
2214
+ const storageKey = `pinokio:frame-history:v1:${frameKey}`
2215
+ const MAX_ENTRIES = 64
2216
+ let storageFailed = false
2217
+
2218
+ const normalizeState = (value) => {
2219
+ const past = sanitizeStack(value && value.past)
2220
+ const future = sanitizeStack(value && value.future)
2221
+ const trimmedPast = past.length > MAX_ENTRIES ? past.slice(-MAX_ENTRIES) : past
2222
+ const trimmedFuture = future.length > MAX_ENTRIES ? future.slice(-MAX_ENTRIES) : future
2223
+ return { past: trimmedPast.slice(), future: trimmedFuture.slice() }
2224
+ }
2225
+ const readState = () => {
2226
+ if (storageFailed) {
2227
+ return { past: [], future: [] }
2228
+ }
2229
+ try {
2230
+ const raw = sessionStorage.getItem(storageKey)
2231
+ if (!raw) {
2232
+ return { past: [], future: [] }
2233
+ }
2234
+ return normalizeState(JSON.parse(raw))
2235
+ } catch (_) {
2236
+ storageFailed = true
2237
+ return { past: [], future: [] }
2238
+ }
2239
+ }
2240
+ const writeState = (state) => {
2241
+ if (storageFailed) {
2242
+ return false
2243
+ }
2244
+ try {
2245
+ sessionStorage.setItem(storageKey, JSON.stringify(normalizeState(state)))
2246
+ return true
2247
+ } catch (_) {
2248
+ storageFailed = true
2249
+ return false
2250
+ }
2251
+ }
2252
+
2253
+ const ensureCurrentRecorded = () => {
2254
+ const state = readState()
2255
+ if (storageFailed) {
2256
+ return
2257
+ }
2258
+ const currentUrl = window.location.href
2259
+ const last = state.past[state.past.length - 1]
2260
+ if (last !== currentUrl) {
2261
+ state.past.push(currentUrl)
2262
+ if (state.past.length > MAX_ENTRIES) {
2263
+ state.past = state.past.slice(-MAX_ENTRIES)
2264
+ }
2265
+ state.future = []
2266
+ if (!writeState(state)) {
2267
+ return
2268
+ }
2269
+ }
2270
+ }
2271
+
2272
+ try {
2273
+ ensureCurrentRecorded()
2274
+ } catch (_) {
2275
+ storageFailed = true
2276
+ }
2277
+
2278
+ if (storageFailed) {
2279
+ return null
2280
+ }
2281
+
2282
+ const navigateByDelta = (delta) => {
2283
+ if (!Number.isFinite(delta) || delta === 0) {
2284
+ return false
2285
+ }
2286
+ if (storageFailed) {
2287
+ return false
2288
+ }
2289
+ const state = readState()
2290
+ if (storageFailed) {
2291
+ return false
2292
+ }
2293
+ if (delta < 0) {
2294
+ const available = state.past.length - 1
2295
+ if (available <= 0) {
2296
+ return true
2297
+ }
2298
+ let steps = Math.min(-delta, available)
2299
+ while (steps > 0) {
2300
+ const current = state.past.pop()
2301
+ if (typeof current === 'string' && current.length > 0) {
2302
+ state.future.push(current)
2303
+ }
2304
+ steps -= 1
2305
+ }
2306
+ if (state.future.length > MAX_ENTRIES) {
2307
+ state.future = state.future.slice(-MAX_ENTRIES)
2308
+ }
2309
+ const target = state.past[state.past.length - 1]
2310
+ if (!target) {
2311
+ return false
2312
+ }
2313
+ if (!writeState(state)) {
2314
+ return false
2315
+ }
2316
+ try {
2317
+ window.location.replace(target)
2318
+ } catch (_) {
2319
+ window.location.href = target
2320
+ }
2321
+ return true
2322
+ }
2323
+ if (delta > 0) {
2324
+ if (state.future.length === 0) {
2325
+ return true
2326
+ }
2327
+ let steps = Math.min(delta, state.future.length)
2328
+ let target = null
2329
+ while (steps > 0) {
2330
+ target = state.future.pop() || target
2331
+ if (target) {
2332
+ state.past.push(target)
2333
+ }
2334
+ steps -= 1
2335
+ }
2336
+ if (!target) {
2337
+ return false
2338
+ }
2339
+ if (state.past.length > MAX_ENTRIES) {
2340
+ state.past = state.past.slice(-MAX_ENTRIES)
2341
+ }
2342
+ if (!writeState(state)) {
2343
+ return false
2344
+ }
2345
+ try {
2346
+ window.location.replace(target)
2347
+ } catch (_) {
2348
+ window.location.href = target
2349
+ }
2350
+ return true
2351
+ }
2352
+ return false
2353
+ }
2354
+
2355
+ return {
2356
+ get enabled() {
2357
+ return !storageFailed
2358
+ },
2359
+ go: (delta) => {
2360
+ if (storageFailed) {
2361
+ return false
2362
+ }
2363
+ return navigateByDelta(delta)
2364
+ }
2365
+ }
2197
2366
  }
2198
- if (document.querySelector("#back")) {
2199
- document.querySelector("#back").addEventListener("click", (e) => {
2200
- history.back()
2367
+
2368
+ const frameHistoryController = createFrameHistoryController()
2369
+ const bindHistoryButton = (selector, delta) => {
2370
+ const button = document.querySelector(selector)
2371
+ if (!button) {
2372
+ return
2373
+ }
2374
+ button.addEventListener("click", (event) => {
2375
+ if (frameHistoryController && frameHistoryController.enabled) {
2376
+ event.preventDefault()
2377
+ event.stopPropagation()
2378
+ frameHistoryController.go(delta)
2379
+ return
2380
+ }
2381
+ if (delta < 0) {
2382
+ history.back()
2383
+ } else if (delta > 0) {
2384
+ history.forward()
2385
+ }
2201
2386
  })
2202
2387
  }
2203
- if (document.querySelector("#forward")) {
2204
- document.querySelector("#forward").addEventListener("click", (e) => {
2205
- history.forward()
2388
+
2389
+ if (document.querySelector("#screenshot")) {
2390
+ document.querySelector("#screenshot").addEventListener("click", (e) => {
2391
+ screenshot()
2206
2392
  })
2207
2393
  }
2394
+ bindHistoryButton("#back", -1)
2395
+ bindHistoryButton("#forward", 1)
2208
2396
  if (document.querySelector("#refresh-page")) {
2209
2397
  document.querySelector("#refresh-page").addEventListener("click", (e) => {
2210
2398
  try {
@@ -33,7 +33,7 @@
33
33
  background: var(--layout-gutter-bg);
34
34
  }
35
35
  body.dark > #dragger {
36
- background: rgb(27, 28, 29);
36
+ background: var(--layout-gutter-bg);
37
37
  }
38
38
 
39
39
  body {
@@ -829,7 +829,8 @@ document.addEventListener("DOMContentLoaded", async () => {
829
829
  await this.start(mode)
830
830
  }
831
831
  async uploadFiles(files, overlay) {
832
- if (!files || files.length === 0) {
832
+ const localFiles = Array.isArray(files) ? files : []
833
+ if (localFiles.length === 0) {
833
834
  return
834
835
  }
835
836
  if (!this.socket || !this.socket.ws || this.socket.ws.readyState !== WebSocket.OPEN) {
@@ -840,8 +841,8 @@ document.addEventListener("DOMContentLoaded", async () => {
840
841
  return
841
842
  }
842
843
  const entries = []
843
- for (let i = 0; i < files.length; i++) {
844
- const file = files[i]
844
+ for (let i = 0; i < localFiles.length; i++) {
845
+ const file = localFiles[i]
845
846
  if (!file || typeof file.arrayBuffer !== "function") {
846
847
  continue
847
848
  }
@@ -893,6 +894,226 @@ document.addEventListener("DOMContentLoaded", async () => {
893
894
  }
894
895
  }
895
896
  }
897
+ async uploadRemoteResources(resources, overlay) {
898
+ const remoteItems = Array.isArray(resources) ? resources.filter((item) => item && typeof item.href === "string" && item.href.trim()) : []
899
+ if (remoteItems.length === 0) {
900
+ return
901
+ }
902
+ const payload = {
903
+ id: shell_id,
904
+ cwd: this.uploadContext.cwd,
905
+ urls: remoteItems
906
+ }
907
+ try {
908
+ if (overlay) {
909
+ overlay.classList.add("active")
910
+ overlay.textContent = "Downloading..."
911
+ }
912
+ const response = await fetch('/terminal/url-upload', {
913
+ method: 'POST',
914
+ headers: {
915
+ 'Content-Type': 'application/json'
916
+ },
917
+ body: JSON.stringify(payload)
918
+ })
919
+ if (!response.ok) {
920
+ throw new Error(`HTTP ${response.status}`)
921
+ }
922
+ const result = await response.json().catch(() => ({}))
923
+ const files = Array.isArray(result.files) ? result.files : []
924
+ const failures = Array.isArray(result.errors) ? result.errors : []
925
+ if (files.length > 0) {
926
+ try {
927
+ refreshParent({
928
+ type: "terminal.upload",
929
+ files
930
+ })
931
+ if (typeof reloadMemory === 'function') {
932
+ reloadMemory()
933
+ }
934
+ } catch (_) {}
935
+ n.Noty({
936
+ text: files.length > 1 ? `${files.length} files attached` : `File attached`,
937
+ timeout: 4000
938
+ })
939
+ }
940
+ if (failures.length > 0) {
941
+ const plural = failures.length > 1
942
+ n.Noty({
943
+ text: plural ? `${failures.length} remote files failed to download` : `Remote file failed to download`,
944
+ type: "error",
945
+ timeout: 5000
946
+ })
947
+ }
948
+ } catch (error) {
949
+ n.Noty({
950
+ text: `Remote upload failed: ${error.message}`,
951
+ type: "error"
952
+ })
953
+ } finally {
954
+ if (overlay) {
955
+ overlay.classList.remove("active")
956
+ overlay.textContent = "Drop files to upload"
957
+ }
958
+ }
959
+ }
960
+ async collectFilesFromDataTransfer(dataTransfer) {
961
+ if (!dataTransfer) {
962
+ return { urls: [] }
963
+ }
964
+ const resourceMap = new Map()
965
+ const ensureResource = (input, nameHint) => {
966
+ if (!input) {
967
+ return
968
+ }
969
+ let resolved
970
+ try {
971
+ resolved = new URL(input, window.location.href)
972
+ } catch (_) {
973
+ return
974
+ }
975
+ if (!resolved || !resolved.protocol || !/^https?:$/i.test(resolved.protocol)) {
976
+ return
977
+ }
978
+ const href = resolved.href
979
+ const existing = resourceMap.get(href)
980
+ if (existing) {
981
+ if (nameHint && !existing.nameHint) {
982
+ existing.nameHint = nameHint
983
+ }
984
+ return
985
+ }
986
+ resourceMap.set(href, { href, url: resolved, nameHint: nameHint || null })
987
+ }
988
+ const handleDownloadUrl = (value) => {
989
+ if (!value) {
990
+ return
991
+ }
992
+ const firstColon = value.indexOf(":")
993
+ const lastColon = value.lastIndexOf(":")
994
+ if (firstColon === -1 || lastColon === -1 || lastColon <= firstColon) {
995
+ ensureResource(value, null)
996
+ return
997
+ }
998
+ const filename = value.slice(firstColon + 1, lastColon)
999
+ const url = value.slice(lastColon + 1)
1000
+ ensureResource(url, filename)
1001
+ }
1002
+ const handleStringEntry = (type, rawValue) => {
1003
+ if (!rawValue) {
1004
+ return
1005
+ }
1006
+ if (type === "DownloadURL") {
1007
+ handleDownloadUrl(rawValue)
1008
+ return
1009
+ }
1010
+ const value = rawValue.trim()
1011
+ if (!value) {
1012
+ return
1013
+ }
1014
+ if (type === "text/html") {
1015
+ try {
1016
+ const parser = new DOMParser()
1017
+ const doc = parser.parseFromString(value, "text/html")
1018
+ const anchors = doc.querySelectorAll("a[href]")
1019
+ anchors.forEach((anchor) => {
1020
+ const href = anchor.getAttribute("href")
1021
+ const nameHint = anchor.getAttribute("download") || (anchor.textContent ? anchor.textContent.trim() : null)
1022
+ ensureResource(href, nameHint)
1023
+ })
1024
+ const srcElements = doc.querySelectorAll("[src]")
1025
+ srcElements.forEach((element) => {
1026
+ const src = element.getAttribute("src")
1027
+ if (!src) {
1028
+ return
1029
+ }
1030
+ const title = element.getAttribute("download") || element.getAttribute("title") || element.getAttribute("alt") || null
1031
+ ensureResource(src, title)
1032
+ })
1033
+ } catch (error) {
1034
+ console.warn("Failed to parse dropped HTML for resources", error)
1035
+ }
1036
+ return
1037
+ }
1038
+ if (type === "text/x-moz-url") {
1039
+ const parts = value.split(/\r?\n/)
1040
+ const url = parts[0] ? parts[0].trim() : ""
1041
+ const title = parts[1] ? parts[1].trim() : null
1042
+ if (url) {
1043
+ ensureResource(url, title)
1044
+ }
1045
+ return
1046
+ }
1047
+ const lines = value.split(/\r?\n/)
1048
+ for (const line of lines) {
1049
+ const candidate = line.trim()
1050
+ if (!candidate || candidate.startsWith("#")) {
1051
+ continue
1052
+ }
1053
+ let resolvedHref
1054
+ try {
1055
+ const resolved = new URL(candidate, window.location.href)
1056
+ resolvedHref = resolved.href
1057
+ } catch (_) {
1058
+ continue
1059
+ }
1060
+ let decodedHref = resolvedHref
1061
+ try {
1062
+ decodedHref = decodeURIComponent(resolvedHref)
1063
+ } catch (_) {}
1064
+ if (/[<>"']/.test(decodedHref)) {
1065
+ continue
1066
+ }
1067
+ ensureResource(resolvedHref, null)
1068
+ }
1069
+ }
1070
+ const items = dataTransfer.items ? Array.from(dataTransfer.items) : []
1071
+ const stringItems = items.filter((item) => item && item.kind === "string" && typeof item.getAsString === "function")
1072
+ if (stringItems.length > 0) {
1073
+ const stringValues = await Promise.all(stringItems.map((item) => new Promise((resolve) => {
1074
+ try {
1075
+ item.getAsString((value) => resolve({ type: item.type || "text/plain", value }))
1076
+ } catch (_) {
1077
+ resolve(null)
1078
+ }
1079
+ })))
1080
+ for (const entry of stringValues) {
1081
+ if (entry) {
1082
+ handleStringEntry(entry.type, entry.value)
1083
+ }
1084
+ }
1085
+ }
1086
+ const fallbackTypes = ["DownloadURL", "text/uri-list", "text/plain", "text/html", "text/x-moz-url"]
1087
+ if (typeof dataTransfer.getData === "function") {
1088
+ for (const type of fallbackTypes) {
1089
+ try {
1090
+ const value = dataTransfer.getData(type)
1091
+ if (value) {
1092
+ handleStringEntry(type, value)
1093
+ }
1094
+ } catch (_) {}
1095
+ }
1096
+ }
1097
+ const resources = Array.from(resourceMap.values())
1098
+ if (!resources.length) {
1099
+ return { urls: [] }
1100
+ }
1101
+ const urls = resources.map((resource) => {
1102
+ const basePath = resource.url ? resource.url.pathname : new URL(resource.href).pathname
1103
+ let filename = resource.nameHint || basePath.split("/").pop() || "download"
1104
+ try {
1105
+ filename = decodeURIComponent(filename)
1106
+ } catch (_) {}
1107
+ if (!filename) {
1108
+ filename = "download"
1109
+ }
1110
+ return {
1111
+ href: resource.href,
1112
+ name: filename
1113
+ }
1114
+ })
1115
+ return { urls }
1116
+ }
896
1117
  async createTerm (_theme) {
897
1118
  console.log(xtermTheme)
898
1119
  if (!this.term) {
@@ -964,11 +1185,40 @@ document.addEventListener("DOMContentLoaded", async () => {
964
1185
  prevent(event)
965
1186
  dragDepth = 0
966
1187
  dropOverlay.classList.remove("active")
967
- const dropped = Array.from(event.dataTransfer ? event.dataTransfer.files || [] : [])
968
- if (!dropped.length) {
1188
+ const files = Array.from(event.dataTransfer ? event.dataTransfer.files || [] : [])
1189
+ let remoteResources = []
1190
+ try {
1191
+ const extra = await this.collectFilesFromDataTransfer(event.dataTransfer)
1192
+ if (extra && Array.isArray(extra.urls) && extra.urls.length) {
1193
+ const seenUrls = new Set()
1194
+ remoteResources = extra.urls.filter((item) => {
1195
+ if (!item || typeof item.href !== "string") {
1196
+ return false
1197
+ }
1198
+ const key = item.href.trim()
1199
+ if (!key || seenUrls.has(key)) {
1200
+ return false
1201
+ }
1202
+ seenUrls.add(key)
1203
+ return true
1204
+ })
1205
+ }
1206
+ } catch (error) {
1207
+ console.warn("Failed to collect files from drop payload", error)
1208
+ }
1209
+ if (!files.length && (!remoteResources || remoteResources.length === 0)) {
969
1210
  return
970
1211
  }
971
- await this.uploadFiles(dropped, dropOverlay)
1212
+ try {
1213
+ if (remoteResources && remoteResources.length > 0) {
1214
+ await this.uploadRemoteResources(remoteResources, dropOverlay)
1215
+ }
1216
+ } catch (error) {
1217
+ console.warn("Remote upload failed", error)
1218
+ }
1219
+ if (files.length > 0) {
1220
+ await this.uploadFiles(files, dropOverlay)
1221
+ }
972
1222
  this.term.focus()
973
1223
  })
974
1224
  term.attachCustomKeyEventHandler(event => {
@@ -916,7 +916,8 @@ document.addEventListener("DOMContentLoaded", async () => {
916
916
  await this.start(mode)
917
917
  }
918
918
  async uploadFiles(files, overlay) {
919
- if (!files || files.length === 0) {
919
+ const localFiles = Array.isArray(files) ? files : []
920
+ if (localFiles.length === 0) {
920
921
  return
921
922
  }
922
923
  if (!this.socket || !this.socket.ws || this.socket.ws.readyState !== WebSocket.OPEN) {
@@ -927,8 +928,8 @@ document.addEventListener("DOMContentLoaded", async () => {
927
928
  return
928
929
  }
929
930
  const entries = []
930
- for (let i = 0; i < files.length; i++) {
931
- const file = files[i]
931
+ for (let i = 0; i < localFiles.length; i++) {
932
+ const file = localFiles[i]
932
933
  if (!file || typeof file.arrayBuffer !== "function") {
933
934
  continue
934
935
  }
@@ -980,6 +981,235 @@ document.addEventListener("DOMContentLoaded", async () => {
980
981
  }
981
982
  }
982
983
  }
984
+ async uploadRemoteResources(resources, overlay) {
985
+ const remoteItems = Array.isArray(resources) ? resources.filter((item) => item && typeof item.href === "string" && item.href.trim()) : []
986
+ if (remoteItems.length === 0) {
987
+ return
988
+ }
989
+ const payload = {
990
+ id: shell_id,
991
+ cwd: this.uploadContext.cwd,
992
+ urls: remoteItems
993
+ }
994
+ try {
995
+ if (overlay) {
996
+ overlay.classList.add("active")
997
+ overlay.textContent = "Downloading..."
998
+ }
999
+ const response = await fetch('/terminal/url-upload', {
1000
+ method: 'POST',
1001
+ headers: {
1002
+ 'Content-Type': 'application/json'
1003
+ },
1004
+ body: JSON.stringify(payload)
1005
+ })
1006
+ if (!response.ok) {
1007
+ throw new Error(`HTTP ${response.status}`)
1008
+ }
1009
+ const result = await response.json().catch(() => ({}))
1010
+ const files = Array.isArray(result.files) ? result.files : []
1011
+ const failures = Array.isArray(result.errors) ? result.errors : []
1012
+ if (files.length > 0) {
1013
+ const mappedFiles = files.map((file) => ({
1014
+ originalName: file.originalName || file.name || file.storedAs,
1015
+ storedAs: file.storedAs,
1016
+ path: file.path,
1017
+ displayPath: file.displayPath || file.path,
1018
+ homeRelativePath: file.homeRelativePath || '',
1019
+ cliPath: file.cliPath || null,
1020
+ cliRelativePath: file.cliRelativePath || null
1021
+ }))
1022
+ try {
1023
+ refreshParent({
1024
+ type: "terminal.upload",
1025
+ files: mappedFiles
1026
+ })
1027
+ if (typeof reloadMemory === 'function') {
1028
+ reloadMemory()
1029
+ }
1030
+ } catch (_) {}
1031
+ n.Noty({
1032
+ text: mappedFiles.length > 1 ? `${mappedFiles.length} files attached` : `File attached`,
1033
+ timeout: 4000
1034
+ })
1035
+ }
1036
+ if (failures.length > 0) {
1037
+ const plural = failures.length > 1
1038
+ n.Noty({
1039
+ text: plural ? `${failures.length} remote files failed to download` : `Remote file failed to download`,
1040
+ type: "error",
1041
+ timeout: 5000
1042
+ })
1043
+ }
1044
+ } catch (error) {
1045
+ n.Noty({
1046
+ text: `Remote upload failed: ${error.message}`,
1047
+ type: "error"
1048
+ })
1049
+ } finally {
1050
+ if (overlay) {
1051
+ overlay.classList.remove("active")
1052
+ overlay.textContent = "Drop files to upload"
1053
+ }
1054
+ }
1055
+ }
1056
+ async collectFilesFromDataTransfer(dataTransfer) {
1057
+ if (!dataTransfer) {
1058
+ return { urls: [] }
1059
+ }
1060
+ const resourceMap = new Map()
1061
+ const ensureResource = (input, nameHint) => {
1062
+ if (!input) {
1063
+ return
1064
+ }
1065
+ let resolved
1066
+ try {
1067
+ resolved = new URL(input, window.location.href)
1068
+ } catch (_) {
1069
+ return
1070
+ }
1071
+ if (!resolved || !resolved.protocol || !/^https?:$/i.test(resolved.protocol)) {
1072
+ return
1073
+ }
1074
+ const href = resolved.href
1075
+ const existing = resourceMap.get(href)
1076
+ if (existing) {
1077
+ if (nameHint && !existing.nameHint) {
1078
+ existing.nameHint = nameHint
1079
+ }
1080
+ return
1081
+ }
1082
+ resourceMap.set(href, { href, url: resolved, nameHint: nameHint || null })
1083
+ }
1084
+ const handleDownloadUrl = (value) => {
1085
+ if (!value) {
1086
+ return
1087
+ }
1088
+ const firstColon = value.indexOf(":")
1089
+ const lastColon = value.lastIndexOf(":")
1090
+ if (firstColon === -1 || lastColon === -1 || lastColon <= firstColon) {
1091
+ ensureResource(value, null)
1092
+ return
1093
+ }
1094
+ const filename = value.slice(firstColon + 1, lastColon)
1095
+ const url = value.slice(lastColon + 1)
1096
+ ensureResource(url, filename)
1097
+ }
1098
+ const handleStringEntry = (type, rawValue) => {
1099
+ if (!rawValue) {
1100
+ return
1101
+ }
1102
+ if (type === "DownloadURL") {
1103
+ handleDownloadUrl(rawValue)
1104
+ return
1105
+ }
1106
+ const value = rawValue.trim()
1107
+ if (!value) {
1108
+ return
1109
+ }
1110
+ if (type === "text/html") {
1111
+ try {
1112
+ const parser = new DOMParser()
1113
+ const doc = parser.parseFromString(value, "text/html")
1114
+ const anchors = doc.querySelectorAll("a[href]")
1115
+ anchors.forEach((anchor) => {
1116
+ const href = anchor.getAttribute("href")
1117
+ const nameHint = anchor.getAttribute("download") || (anchor.textContent ? anchor.textContent.trim() : null)
1118
+ ensureResource(href, nameHint)
1119
+ })
1120
+ const srcElements = doc.querySelectorAll("[src]")
1121
+ srcElements.forEach((element) => {
1122
+ const src = element.getAttribute("src")
1123
+ if (!src) {
1124
+ return
1125
+ }
1126
+ const title = element.getAttribute("download") || element.getAttribute("title") || element.getAttribute("alt") || null
1127
+ ensureResource(src, title)
1128
+ })
1129
+ } catch (error) {
1130
+ console.warn("Failed to parse dropped HTML for resources", error)
1131
+ }
1132
+ return
1133
+ }
1134
+ if (type === "text/x-moz-url") {
1135
+ const parts = value.split(/\r?\n/)
1136
+ const url = parts[0] ? parts[0].trim() : ""
1137
+ const title = parts[1] ? parts[1].trim() : null
1138
+ if (url) {
1139
+ ensureResource(url, title)
1140
+ }
1141
+ return
1142
+ }
1143
+ const lines = value.split(/\r?\n/)
1144
+ for (const line of lines) {
1145
+ const candidate = line.trim()
1146
+ if (!candidate || candidate.startsWith("#")) {
1147
+ continue
1148
+ }
1149
+ let resolvedHref
1150
+ try {
1151
+ const resolved = new URL(candidate, window.location.href)
1152
+ resolvedHref = resolved.href
1153
+ } catch (_) {
1154
+ continue
1155
+ }
1156
+ let decodedHref = resolvedHref
1157
+ try {
1158
+ decodedHref = decodeURIComponent(resolvedHref)
1159
+ } catch (_) {}
1160
+ if (/[<>"']/.test(decodedHref)) {
1161
+ continue
1162
+ }
1163
+ ensureResource(resolvedHref, null)
1164
+ }
1165
+ }
1166
+ const items = dataTransfer.items ? Array.from(dataTransfer.items) : []
1167
+ const stringItems = items.filter((item) => item && item.kind === "string" && typeof item.getAsString === "function")
1168
+ if (stringItems.length > 0) {
1169
+ const stringValues = await Promise.all(stringItems.map((item) => new Promise((resolve) => {
1170
+ try {
1171
+ item.getAsString((value) => resolve({ type: item.type || "text/plain", value }))
1172
+ } catch (_) {
1173
+ resolve(null)
1174
+ }
1175
+ })))
1176
+ for (const entry of stringValues) {
1177
+ if (entry) {
1178
+ handleStringEntry(entry.type, entry.value)
1179
+ }
1180
+ }
1181
+ }
1182
+ const fallbackTypes = ["DownloadURL", "text/uri-list", "text/plain", "text/html", "text/x-moz-url"]
1183
+ if (typeof dataTransfer.getData === "function") {
1184
+ for (const type of fallbackTypes) {
1185
+ try {
1186
+ const value = dataTransfer.getData(type)
1187
+ if (value) {
1188
+ handleStringEntry(type, value)
1189
+ }
1190
+ } catch (_) {}
1191
+ }
1192
+ }
1193
+ const resources = Array.from(resourceMap.values())
1194
+ if (!resources.length) {
1195
+ return { urls: [] }
1196
+ }
1197
+ const urls = resources.map((resource) => {
1198
+ const basePath = resource.url ? resource.url.pathname : new URL(resource.href).pathname
1199
+ let filename = resource.nameHint || basePath.split("/").pop() || "download"
1200
+ try {
1201
+ filename = decodeURIComponent(filename)
1202
+ } catch (_) {}
1203
+ if (!filename) {
1204
+ filename = "download"
1205
+ }
1206
+ return {
1207
+ href: resource.href,
1208
+ name: filename
1209
+ }
1210
+ })
1211
+ return { urls }
1212
+ }
983
1213
  async createTerm (_theme) {
984
1214
  if (!this.term) {
985
1215
  const theme = Object.assign({ }, _theme, {
@@ -1048,11 +1278,44 @@ document.addEventListener("DOMContentLoaded", async () => {
1048
1278
  prevent(event)
1049
1279
  dragDepth = 0
1050
1280
  dropOverlay.classList.remove("active")
1051
- const dropped = Array.from(event.dataTransfer ? event.dataTransfer.files || [] : [])
1052
- if (!dropped.length) {
1281
+ const files = Array.from(event.dataTransfer ? event.dataTransfer.files || [] : [])
1282
+ let remoteResources = []
1283
+ try {
1284
+ const extra = await this.collectFilesFromDataTransfer(event.dataTransfer)
1285
+ if (extra && Array.isArray(extra.urls) && extra.urls.length) {
1286
+ const seenUrls = new Set()
1287
+ remoteResources = extra.urls.filter((item) => {
1288
+ if (!item || typeof item.href !== "string") {
1289
+ return false
1290
+ }
1291
+ const key = item.href.trim()
1292
+ if (!key || seenUrls.has(key)) {
1293
+ return false
1294
+ }
1295
+ seenUrls.add(key)
1296
+ return true
1297
+ })
1298
+ }
1299
+ } catch (error) {
1300
+ console.warn("Failed to collect files from drop payload", error)
1301
+ }
1302
+ if (!files.length && (!remoteResources || remoteResources.length === 0)) {
1303
+ n.Noty({
1304
+ text: "Dropped item did not include an accessible file",
1305
+ type: "error"
1306
+ })
1053
1307
  return
1054
1308
  }
1055
- await this.uploadFiles(dropped, dropOverlay)
1309
+ try {
1310
+ if (remoteResources && remoteResources.length > 0) {
1311
+ await this.uploadRemoteResources(remoteResources, dropOverlay)
1312
+ }
1313
+ } catch (error) {
1314
+ console.warn("Remote upload failed", error)
1315
+ }
1316
+ if (files.length > 0) {
1317
+ await this.uploadFiles(files, dropOverlay)
1318
+ }
1056
1319
  this.term.focus()
1057
1320
  })
1058
1321
  term.attachCustomKeyEventHandler(event => {