pinokiod 3.184.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.184.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,92 +894,225 @@ 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
+ }
896
960
  async collectFilesFromDataTransfer(dataTransfer) {
897
961
  if (!dataTransfer) {
898
- return []
962
+ return { urls: [] }
899
963
  }
900
- const items = dataTransfer.items ? Array.from(dataTransfer.items) : []
901
- const stringItems = items.filter((item) => item && item.kind === "string" && typeof item.getAsString === "function")
902
- const collectedStrings = []
903
- if (stringItems.length > 0) {
904
- const stringValues = await Promise.all(stringItems.map((item) => new Promise((resolve) => {
905
- try {
906
- item.getAsString((value) => resolve({ type: item.type || "text/plain", value }))
907
- } catch (_) {
908
- resolve({ type: item.type || "text/plain", value: "" })
909
- }
910
- })))
911
- for (const entry of stringValues) {
912
- if (entry && typeof entry.value === "string" && entry.value.trim()) {
913
- collectedStrings.push(entry)
914
- }
964
+ const resourceMap = new Map()
965
+ const ensureResource = (input, nameHint) => {
966
+ if (!input) {
967
+ return
915
968
  }
916
- } else if (typeof dataTransfer.getData === "function") {
917
- const uriList = dataTransfer.getData("text/uri-list")
918
- if (uriList && uriList.trim()) {
919
- collectedStrings.push({ type: "text/uri-list", value: uriList })
969
+ let resolved
970
+ try {
971
+ resolved = new URL(input, window.location.href)
972
+ } catch (_) {
973
+ return
920
974
  }
921
- const plain = dataTransfer.getData("text/plain")
922
- if (plain && plain.trim()) {
923
- collectedStrings.push({ type: "text/plain", value: plain })
975
+ if (!resolved || !resolved.protocol || !/^https?:$/i.test(resolved.protocol)) {
976
+ return
924
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 })
925
987
  }
926
- if (!collectedStrings.length) {
927
- return []
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)
928
1001
  }
929
- const origin = window.location.origin
930
- const urls = new Set()
931
- for (const { type, value } of collectedStrings) {
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()
932
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("#")) {
933
1051
  continue
934
1052
  }
935
- const trimmed = value.trim()
936
- if (!trimmed) {
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)) {
937
1065
  continue
938
1066
  }
939
- const candidates = (type === "text/uri-list" ? trimmed.split(/\r?\n/) : [trimmed]).filter((line) => line && !line.startsWith("#"))
940
- for (const candidate of candidates) {
941
- let resolved
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) => {
942
1074
  try {
943
- resolved = new URL(candidate, origin)
1075
+ item.getAsString((value) => resolve({ type: item.type || "text/plain", value }))
944
1076
  } catch (_) {
945
- continue
1077
+ resolve(null)
946
1078
  }
947
- if (resolved.origin !== origin) {
948
- continue
1079
+ })))
1080
+ for (const entry of stringValues) {
1081
+ if (entry) {
1082
+ handleStringEntry(entry.type, entry.value)
949
1083
  }
950
- urls.add(resolved.href)
951
1084
  }
952
1085
  }
953
- if (!urls.size) {
954
- return []
955
- }
956
- const FileCtor = typeof window !== "undefined" ? window.File : null
957
- if (typeof FileCtor !== "function") {
958
- return []
959
- }
960
- const files = []
961
- for (const href of urls) {
962
- try {
963
- const response = await fetch(href, { credentials: "include" })
964
- if (!response || !response.ok) {
965
- continue
966
- }
967
- const blob = await response.blob()
968
- const nameSegment = href.split("/").pop() || "download"
969
- let filename = nameSegment || "download"
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) {
970
1089
  try {
971
- filename = decodeURIComponent(filename)
1090
+ const value = dataTransfer.getData(type)
1091
+ if (value) {
1092
+ handleStringEntry(type, value)
1093
+ }
972
1094
  } catch (_) {}
973
- if (!filename) {
974
- filename = "download"
975
- }
976
- files.push(new FileCtor([blob], filename, { type: blob.type || "application/octet-stream", lastModified: Date.now() }))
977
- } catch (error) {
978
- console.warn("Failed to resolve dropped resource", href, error)
979
1095
  }
980
1096
  }
981
- return files
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 }
982
1116
  }
983
1117
  async createTerm (_theme) {
984
1118
  console.log(xtermTheme)
@@ -1052,25 +1186,39 @@ document.addEventListener("DOMContentLoaded", async () => {
1052
1186
  dragDepth = 0
1053
1187
  dropOverlay.classList.remove("active")
1054
1188
  const files = Array.from(event.dataTransfer ? event.dataTransfer.files || [] : [])
1189
+ let remoteResources = []
1055
1190
  try {
1056
1191
  const extra = await this.collectFilesFromDataTransfer(event.dataTransfer)
1057
- if (Array.isArray(extra) && extra.length) {
1058
- const seen = new Set(files.map((file) => `${file.name}-${file.size}`))
1059
- for (const file of extra) {
1060
- const key = `${file.name}-${file.size}`
1061
- if (!seen.has(key)) {
1062
- seen.add(key)
1063
- files.push(file)
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
1064
1197
  }
1065
- }
1198
+ const key = item.href.trim()
1199
+ if (!key || seenUrls.has(key)) {
1200
+ return false
1201
+ }
1202
+ seenUrls.add(key)
1203
+ return true
1204
+ })
1066
1205
  }
1067
1206
  } catch (error) {
1068
1207
  console.warn("Failed to collect files from drop payload", error)
1069
1208
  }
1070
- if (!files.length) {
1209
+ if (!files.length && (!remoteResources || remoteResources.length === 0)) {
1071
1210
  return
1072
1211
  }
1073
- await this.uploadFiles(files, 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
+ }
1074
1222
  this.term.focus()
1075
1223
  })
1076
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,92 +981,234 @@ 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
+ }
983
1056
  async collectFilesFromDataTransfer(dataTransfer) {
984
1057
  if (!dataTransfer) {
985
- return []
1058
+ return { urls: [] }
986
1059
  }
987
- const items = dataTransfer.items ? Array.from(dataTransfer.items) : []
988
- const stringItems = items.filter((item) => item && item.kind === "string" && typeof item.getAsString === "function")
989
- const collectedStrings = []
990
- if (stringItems.length > 0) {
991
- const stringValues = await Promise.all(stringItems.map((item) => new Promise((resolve) => {
992
- try {
993
- item.getAsString((value) => resolve({ type: item.type || "text/plain", value }))
994
- } catch (_) {
995
- resolve({ type: item.type || "text/plain", value: "" })
996
- }
997
- })))
998
- for (const entry of stringValues) {
999
- if (entry && typeof entry.value === "string" && entry.value.trim()) {
1000
- collectedStrings.push(entry)
1001
- }
1060
+ const resourceMap = new Map()
1061
+ const ensureResource = (input, nameHint) => {
1062
+ if (!input) {
1063
+ return
1002
1064
  }
1003
- } else if (typeof dataTransfer.getData === "function") {
1004
- const uriList = dataTransfer.getData("text/uri-list")
1005
- if (uriList && uriList.trim()) {
1006
- collectedStrings.push({ type: "text/uri-list", value: uriList })
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
1007
1073
  }
1008
- const plain = dataTransfer.getData("text/plain")
1009
- if (plain && plain.trim()) {
1010
- collectedStrings.push({ type: "text/plain", value: plain })
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
1011
1081
  }
1082
+ resourceMap.set(href, { href, url: resolved, nameHint: nameHint || null })
1012
1083
  }
1013
- if (!collectedStrings.length) {
1014
- return []
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)
1015
1097
  }
1016
- const origin = window.location.origin
1017
- const urls = new Set()
1018
- for (const { type, value } of collectedStrings) {
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()
1019
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 (_) {
1020
1154
  continue
1021
1155
  }
1022
- const trimmed = value.trim()
1023
- if (!trimmed) {
1156
+ let decodedHref = resolvedHref
1157
+ try {
1158
+ decodedHref = decodeURIComponent(resolvedHref)
1159
+ } catch (_) {}
1160
+ if (/[<>"']/.test(decodedHref)) {
1024
1161
  continue
1025
1162
  }
1026
- const candidates = (type === "text/uri-list" ? trimmed.split(/\r?\n/) : [trimmed]).filter((line) => line && !line.startsWith("#"))
1027
- for (const candidate of candidates) {
1028
- let resolved
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) => {
1029
1170
  try {
1030
- resolved = new URL(candidate, origin)
1171
+ item.getAsString((value) => resolve({ type: item.type || "text/plain", value }))
1031
1172
  } catch (_) {
1032
- continue
1173
+ resolve(null)
1033
1174
  }
1034
- if (resolved.origin !== origin) {
1035
- continue
1175
+ })))
1176
+ for (const entry of stringValues) {
1177
+ if (entry) {
1178
+ handleStringEntry(entry.type, entry.value)
1036
1179
  }
1037
- urls.add(resolved.href)
1038
1180
  }
1039
1181
  }
1040
- if (!urls.size) {
1041
- return []
1042
- }
1043
- const FileCtor = typeof window !== "undefined" ? window.File : null
1044
- if (typeof FileCtor !== "function") {
1045
- return []
1046
- }
1047
- const files = []
1048
- for (const href of urls) {
1049
- try {
1050
- const response = await fetch(href, { credentials: "include" })
1051
- if (!response || !response.ok) {
1052
- continue
1053
- }
1054
- const blob = await response.blob()
1055
- const nameSegment = href.split("/").pop() || "download"
1056
- let filename = nameSegment || "download"
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) {
1057
1185
  try {
1058
- filename = decodeURIComponent(filename)
1186
+ const value = dataTransfer.getData(type)
1187
+ if (value) {
1188
+ handleStringEntry(type, value)
1189
+ }
1059
1190
  } catch (_) {}
1060
- if (!filename) {
1061
- filename = "download"
1062
- }
1063
- files.push(new FileCtor([blob], filename, { type: blob.type || "application/octet-stream", lastModified: Date.now() }))
1064
- } catch (error) {
1065
- console.warn("Failed to resolve dropped resource", href, error)
1066
1191
  }
1067
1192
  }
1068
- return files
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 }
1069
1212
  }
1070
1213
  async createTerm (_theme) {
1071
1214
  if (!this.term) {
@@ -1136,25 +1279,43 @@ document.addEventListener("DOMContentLoaded", async () => {
1136
1279
  dragDepth = 0
1137
1280
  dropOverlay.classList.remove("active")
1138
1281
  const files = Array.from(event.dataTransfer ? event.dataTransfer.files || [] : [])
1282
+ let remoteResources = []
1139
1283
  try {
1140
1284
  const extra = await this.collectFilesFromDataTransfer(event.dataTransfer)
1141
- if (Array.isArray(extra) && extra.length) {
1142
- const seen = new Set(files.map((file) => `${file.name}-${file.size}`))
1143
- for (const file of extra) {
1144
- const key = `${file.name}-${file.size}`
1145
- if (!seen.has(key)) {
1146
- seen.add(key)
1147
- files.push(file)
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
1148
1290
  }
1149
- }
1291
+ const key = item.href.trim()
1292
+ if (!key || seenUrls.has(key)) {
1293
+ return false
1294
+ }
1295
+ seenUrls.add(key)
1296
+ return true
1297
+ })
1150
1298
  }
1151
1299
  } catch (error) {
1152
1300
  console.warn("Failed to collect files from drop payload", error)
1153
1301
  }
1154
- if (!files.length) {
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
+ })
1155
1307
  return
1156
1308
  }
1157
- await this.uploadFiles(files, 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
+ }
1158
1319
  this.term.focus()
1159
1320
  })
1160
1321
  term.attachCustomKeyEventHandler(event => {