dsh-folder-path 0.0.0-stage → 0.1.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.
@@ -0,0 +1,21 @@
1
+ # The dsh-folder-path bundle patch: one row over the profile root.
2
+ #
3
+ # The row name resolves from the profile's own node_modules, where
4
+ # `dsh plugin --profile web add <path>` installed this package. Both halves of
5
+ # the plugin ride this one row: the host half registers the clipboard route, and
6
+ # the browser half (`dsh.client.platform: web` in package.json) is served as a
7
+ # client bundle the module system loads at boot.
8
+ #
9
+ # Every knob is optional. `enabled: false` mounts the host half inert; the
10
+ # browser half then keeps its hands off folder pastes and DSH behaves as before.
11
+ - insert:
12
+ - id: folder-path
13
+ name: dsh-folder-path
14
+ # config:
15
+ # enabled: true
16
+ # timeoutMs: 5000
17
+ # # Executable used for the clipboard probe, per platform:
18
+ # # Windows — PowerShell (default: powershell.exe); it needs Windows Forms.
19
+ # # macOS — osascript (default: /usr/bin/osascript), run with -l JavaScript.
20
+ # powershell: powershell.exe
21
+ # osascript: /usr/bin/osascript
package/docs/DETAIL.md ADDED
@@ -0,0 +1,10 @@
1
+ # Paste folder path
2
+
3
+ Pasting a folder into the composer normally tries to upload it and fails. This plugin reads the folder path from the system clipboard and inserts it instead.
4
+
5
+ ## Behavior
6
+ - Windows and macOS only; other platforms report unsupported-platform.
7
+ - The client half intercepts paste; the host half reads the system clipboard (CF_HDROP on Windows, NSPasteboard on macOS).
8
+
9
+ ## Notes
10
+ - Falls back to default behavior when the clipboard is unavailable.
@@ -0,0 +1,10 @@
1
+ # 粘贴文件夹路径
2
+
3
+ 浏览器里往输入框粘贴文件夹时,默认行为是尝试上传(文件夹无法上传,只会失败)。这个插件改成读取系统剪贴板里的文件夹路径并插入输入框。
4
+
5
+ ## 行为
6
+ - 仅 Windows / macOS 生效;其它平台返回 unsupported-platform。
7
+ - 浏览器半边拦截粘贴事件,宿主半边读系统剪贴板拿路径(Windows 走 CF_HDROP,macOS 走 NSPasteboard)。
8
+
9
+ ## 注意
10
+ - 需要宿主能读系统剪贴板;远程或无剪贴板权限时退回默认行为。
package/icon.svg ADDED
@@ -0,0 +1 @@
1
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 11a2 2 0 012-2h6l2.5 3H28a2 2 0 012 2v11a2 2 0 01-2 2H8a2 2 0 01-2-2V11z" fill="#22C55E"/><path d="M18 16v8m0 0l-3-3m3 3l3-3" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
package/lib/client.js ADDED
@@ -0,0 +1,319 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-folder-path",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ // ---- client/index.js ----
8
+ /**
9
+ * `dsh-folder-path` browser half.
10
+ *
11
+ * Shipped as the package's `./client` bundle: the DSH client module system
12
+ * loads it as a `window.__ModuleLoader__.load({ id, factory })` script, calls
13
+ * `factory(require)`, and mounts the exported plugin (`apply`) into the browser
14
+ * Cordis tree.
15
+ *
16
+ * What it does — one thing, in the capture phase, before the conversation
17
+ * plugin's Lexical paste command can turn the folder into a doomed upload:
18
+ *
19
+ * * a paste is inspected for entries that are *directories*
20
+ * (`webkitGetAsEntry().isDirectory`; the paste payload is a `File` of size 0
21
+ * with no bytes to upload, which is why 上传失败 appears today);
22
+ * * the pasted folder names go to the host half, which reads the host
23
+ * machine's clipboard (Windows file-drop list, macOS NSPasteboard file
24
+ * URLs) and answers with the real full paths;
25
+ * * those paths are inserted into the composer as plain text by re-dispatching
26
+ * a synthetic text/plain paste, so the insertion goes through DSH's own
27
+ * composer path (command menu, draft state, undo history) instead of
28
+ * poking at the DOM.
29
+ *
30
+ * Ordinary pastes — text, images, real files — are never touched: the handler
31
+ * returns before `preventDefault()` unless a directory is present.
32
+ */
33
+
34
+ /** The host route; hand-mirrored from `lib/index.js` (ROUTE_PATH). */
35
+ var ROUTE_PATH = '/dsh-folder-path/api/v1/clipboard-paths'
36
+
37
+ /** Marks the synthetic events this plugin dispatches, so they are not re-read. */
38
+ var SYNTHETIC = '__dshFolderPathSynthetic'
39
+
40
+ /** The composer's editable root, as Lexical renders it. */
41
+ var EDITABLE_SELECTOR = '[contenteditable=""],[contenteditable="true"]'
42
+
43
+ /** How long a failure notice stays on screen. */
44
+ var TOAST_MS = 5000
45
+
46
+ /**
47
+ * Split a paste payload into directories and files.
48
+ *
49
+ * `webkitGetAsEntry()` is the only reliable directory signal. The
50
+ * `size === 0 && type === ''` arm is a fallback for engines without the entry
51
+ * API; it is deliberately not applied when an entry exists, so a genuinely
52
+ * empty file is never mistaken for a folder.
53
+ *
54
+ * @param dataTransfer - the event's clipboard or drag data.
55
+ * @returns directory basenames and the non-directory files.
56
+ */
57
+ function classifyPayload(dataTransfer) {
58
+ var directories = []
59
+ var files = []
60
+ var items = dataTransfer.items ? Array.prototype.slice.call(dataTransfer.items) : []
61
+
62
+ for (var index = 0; index < items.length; index += 1) {
63
+ var item = items[index]
64
+ if (item.kind !== 'file') continue
65
+
66
+ var entry = null
67
+ try {
68
+ entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null
69
+ } catch (error) {
70
+ entry = null
71
+ }
72
+
73
+ if (entry !== null) {
74
+ // The entry API answers both questions. Reading the file of a directory is
75
+ // deliberately skipped: the browser hands over a zero-byte `File` whose
76
+ // bytes cannot be read, which is the upload failure this plugin exists to
77
+ // avoid, so there is nothing to forward for it.
78
+ if (entry.isDirectory === true) {
79
+ if (entry.name) directories.push(entry.name)
80
+ continue
81
+ }
82
+ var entryFile = null
83
+ try {
84
+ entryFile = item.getAsFile()
85
+ } catch (error) {
86
+ entryFile = null
87
+ }
88
+ if (entryFile !== null) files.push(entryFile)
89
+ continue
90
+ }
91
+
92
+ var file = null
93
+ try {
94
+ file = item.getAsFile()
95
+ } catch (error) {
96
+ file = null
97
+ }
98
+ if (file === null) continue
99
+ if (file.size === 0 && file.type === '') {
100
+ // No entry API (a non-Chromium engine): a zero-byte, type-less item is
101
+ // the only directory-shaped signal left to go on.
102
+ if (file.name) directories.push(file.name)
103
+ continue
104
+ }
105
+ files.push(file)
106
+ }
107
+
108
+ return { directories: directories, files: files }
109
+ }
110
+
111
+ /**
112
+ * Find the composer root a paste belongs to.
113
+ *
114
+ * A paste into anything that is not an editable surface is left alone: the
115
+ * plugin swallows events it handles, and swallowing a paste it cannot insert
116
+ * into would silently eat user data.
117
+ *
118
+ * @param target - the event target.
119
+ * @returns the editable root element, or null.
120
+ */
121
+ function editableRootOf(target) {
122
+ var candidates = [target, document.activeElement]
123
+ for (var index = 0; index < candidates.length; index += 1) {
124
+ var candidate = candidates[index]
125
+ if (candidate && typeof candidate.closest === 'function') {
126
+ var root = candidate.closest(EDITABLE_SELECTOR)
127
+ if (root !== null) return root
128
+ }
129
+ }
130
+ return null
131
+ }
132
+
133
+ /**
134
+ * Ask the host half for the full paths behind the pasted folder names.
135
+ * @param names - directory basenames the browser saw.
136
+ * @returns the resolved paths (empty when the host could not answer).
137
+ */
138
+ async function requestPaths(names) {
139
+ var response = await fetch(ROUTE_PATH, {
140
+ method: 'POST',
141
+ headers: { 'content-type': 'application/json' },
142
+ body: JSON.stringify({ names: names }),
143
+ })
144
+ if (!response.ok) return { paths: [], reason: 'http-' + String(response.status) }
145
+ var payload = await response.json()
146
+ if (payload && payload.ok === true && Array.isArray(payload.paths)) {
147
+ return { paths: payload.paths, reason: null }
148
+ }
149
+ return { paths: [], reason: (payload && payload.reason) || 'unknown' }
150
+ }
151
+
152
+ /**
153
+ * Insert plain text through DSH's own paste path.
154
+ *
155
+ * A synthetic `paste` carrying only `text/plain` reaches the same Lexical
156
+ * command a real text paste does, so the draft, command menu and undo history
157
+ * stay consistent. `execCommand` is the fallback for engines that refuse to
158
+ * construct a `ClipboardEvent` with clipboard data.
159
+ *
160
+ * @param root - the composer's editable root.
161
+ * @param text - the text to insert.
162
+ */
163
+ function insertText(root, text) {
164
+ root.focus({ preventScroll: true })
165
+ try {
166
+ var dataTransfer = new DataTransfer()
167
+ dataTransfer.setData('text/plain', text)
168
+ var event = new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true })
169
+ event[SYNTHETIC] = true
170
+ root.dispatchEvent(event)
171
+ if (event.defaultPrevented) return
172
+ } catch (error) {
173
+ /* fall through to the DOM insertion below */
174
+ }
175
+ document.execCommand('insertText', false, text)
176
+ }
177
+
178
+ /**
179
+ * Hand non-directory files back to the composer.
180
+ *
181
+ * Copying a folder and files together is legal; the files still belong to the
182
+ * attachment flow, so they are forwarded as one synthetic file paste after the
183
+ * paths are in.
184
+ *
185
+ * @param root - the composer's editable root.
186
+ * @param files - the files taken out of the original paste.
187
+ */
188
+ function forwardFiles(root, files) {
189
+ if (files.length === 0) return
190
+ try {
191
+ var dataTransfer = new DataTransfer()
192
+ for (var index = 0; index < files.length; index += 1) dataTransfer.items.add(files[index])
193
+ var event = new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true })
194
+ event[SYNTHETIC] = true
195
+ root.dispatchEvent(event)
196
+ } catch (error) {
197
+ /* the paths are the point; a failed forward must not undo them */
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Failure copy for a host answer.
203
+ * @param reason - the host's reason code.
204
+ * @returns the message to show.
205
+ */
206
+ function failureText(reason) {
207
+ if (reason === 'unsupported-platform') return '当前宿主系统不支持读取剪贴板里的文件夹路径(仅支持 Windows / macOS)'
208
+ if (reason === 'remote-not-supported') return '当前页面不在运行 dsh web 的这台机器上,读不到这台机器的剪贴板'
209
+ if (reason === 'clipboard-error') return '读取剪贴板失败,没能解析文件夹的完整路径'
210
+ if (reason === 'no-match') return '剪贴板里的内容与粘贴的文件夹不一致,没能解析完整路径'
211
+ return '没能解析文件夹的完整路径(宿主未响应)'
212
+ }
213
+
214
+ /**
215
+ * Show a short-lived notice above the composer.
216
+ * @param message - the text to show.
217
+ */
218
+ function toast(message) {
219
+ try {
220
+ var element = document.createElement('div')
221
+ element.textContent = message
222
+ element.setAttribute('data-dsh-folder-path-toast', '')
223
+ element.style.cssText =
224
+ 'position:fixed;left:50%;bottom:96px;transform:translateX(-50%);z-index:2147483000;' +
225
+ 'max-width:70vw;padding:8px 14px;border-radius:10px;background:rgba(28,28,30,0.92);color:#fff;' +
226
+ 'font-size:13px;line-height:1.45;box-shadow:0 6px 24px rgba(0,0,0,0.28);pointer-events:none;'
227
+ document.body.appendChild(element)
228
+ setTimeout(function () {
229
+ element.remove()
230
+ }, TOAST_MS)
231
+ } catch (error) {
232
+ /* a missing notice must never break the paste */
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Build the browser-half plugin from the bundle's `require`.
238
+ * @param require - the module system's synchronous require.
239
+ * @returns the plugin's `apply`.
240
+ */
241
+ function createPlugin(require) {
242
+ /**
243
+ * Resolve the pasted folders and put their paths in the composer.
244
+ * @param root - the composer's editable root.
245
+ * @param directories - the pasted directory basenames.
246
+ * @param files - the non-directory files from the same paste.
247
+ */
248
+ async function resolveAndInsert(root, directories, files) {
249
+ var result
250
+ try {
251
+ result = await requestPaths(directories)
252
+ } catch (error) {
253
+ result = { paths: [], reason: 'host-unreachable' }
254
+ }
255
+ if (result.paths.length === 0) {
256
+ toast(failureText(result.reason))
257
+ forwardFiles(root, files)
258
+ return
259
+ }
260
+ if (!root.isConnected) {
261
+ toast('输入框已经关闭,路径没有插入:' + result.paths.join('、'))
262
+ return
263
+ }
264
+ insertText(root, result.paths.join('\n'))
265
+ forwardFiles(root, files)
266
+ }
267
+
268
+ /**
269
+ * Capture-phase paste handler: intercepts directory pastes only.
270
+ * @param event - the paste event.
271
+ */
272
+ function onPaste(event) {
273
+ if (event[SYNTHETIC] === true) return
274
+ if (!event.clipboardData) return
275
+
276
+ var payload = classifyPayload(event.clipboardData)
277
+ if (payload.directories.length === 0) return
278
+
279
+ var root = editableRootOf(event.target)
280
+ if (root === null) return
281
+
282
+ event.preventDefault()
283
+ event.stopPropagation()
284
+ void resolveAndInsert(root, payload.directories, payload.files)
285
+ }
286
+
287
+ /**
288
+ * Mount the listener; the returned plugin is applied by the client runner.
289
+ * @param ctx - browser Cordis context.
290
+ */
291
+ function apply(ctx) {
292
+ document.addEventListener('paste', onPaste, true)
293
+ ctx.effect(
294
+ function () {
295
+ return function () {
296
+ document.removeEventListener('paste', onPaste, true)
297
+ }
298
+ },
299
+ 'dsh-folder-path: paste listener',
300
+ )
301
+
302
+ // Troubleshooting hook: the same resolution the paste path uses, callable
303
+ // from the devtools console (`await __dshFolderPath.probe(['name'])`).
304
+ window.__dshFolderPath = {
305
+ route: ROUTE_PATH,
306
+ probe: requestPaths,
307
+ insert: insertText,
308
+ classify: classifyPayload,
309
+ }
310
+ }
311
+
312
+ return { apply: apply }
313
+ }
314
+
315
+ const plugin = createPlugin(require);
316
+ exports.apply = plugin.apply;
317
+ return module.exports;
318
+ }
319
+ });