ompchamber 1.0.0 → 1.2.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.
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/index.b938f4a693.css +2 -0
- package/dist/client/static/js/77.8022a800b5.js +139 -0
- package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
- package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
- package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
- package/dist/client/static/js/async/66.86544ad741.js +93 -0
- package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
- package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
- package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
- package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
- package/dist/client/static/js/async/e.6443e739a2.js +79 -0
- package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
- package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
- package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
- package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
- package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
- package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
- package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
- package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
- package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
- package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
- package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
- package/dist/client/static/js/async/{5b.0d6ae50fe8.js → q.b453193538.js} +10 -10
- package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
- package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
- package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
- package/dist/client/static/js/async/vt.20406a5604.js +1 -0
- package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
- package/dist/client/static/js/async/yx.af2936c368.js +10 -0
- package/dist/client/static/js/index.57b39ffa7f.js +34 -0
- package/package.json +1 -1
- package/src/client/components/common/ComposerPicker.tsx +16 -4
- package/src/client/components/common/ComposerTextarea.tsx +1 -0
- package/src/client/components/common/file-icon/index.tsx +4 -1
- package/src/client/components/common/image-viewer/index.tsx +224 -0
- package/src/client/components/layout/RightActivityBar.tsx +1 -1
- package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
- package/src/client/components/layout/desktop-layout/index.tsx +25 -96
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
- package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
- package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
- package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
- package/src/client/components/settings/Modal.tsx +1 -0
- package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
- package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
- package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
- package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
- package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
- package/src/client/components/workspace/btw-panel/index.tsx +83 -0
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +39 -20
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +67 -43
- package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
- package/src/client/components/workspace/editor/index.tsx +45 -36
- package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
- package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
- package/src/client/hooks/chat/btw/index.ts +180 -0
- package/src/client/hooks/chat/btw/intercept.ts +43 -0
- package/src/client/hooks/chat/btw/mode.ts +179 -0
- package/src/client/hooks/chat/composer/attachments.ts +198 -0
- package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
- package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
- package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
- package/src/client/hooks/chat/composer/drop-references.ts +106 -0
- package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
- package/src/client/hooks/chat/composer/file-drop.ts +226 -0
- package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
- package/src/client/hooks/chat/composer/file-reads.ts +238 -0
- package/src/client/hooks/chat/composer/index.ts +35 -9
- package/src/client/hooks/chat/composer/pipeline.ts +181 -0
- package/src/client/hooks/chat/omp/index.ts +3 -3
- package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
- package/src/client/hooks/chat/timeline/actions.ts +40 -4
- package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
- package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
- package/src/client/hooks/chat/timeline/index.ts +29 -9
- package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
- package/src/client/hooks/chat/timeline/send.ts +45 -70
- package/src/client/hooks/chat/timeline/session-load.ts +2 -1
- package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
- package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
- package/src/client/hooks/editor/file-actions.ts +49 -0
- package/src/client/hooks/editor/use-file-editor.ts +38 -27
- package/src/client/hooks/editor/use-file-transfer.ts +78 -0
- package/src/client/hooks/workspace/available-width.ts +33 -0
- package/src/client/hooks/workspace/panel-widths.ts +34 -37
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.ts +21 -287
- package/src/server/lib/btw/frames.test.ts +68 -0
- package/src/server/lib/btw/frames.ts +56 -0
- package/src/server/lib/btw/prompt.ts +28 -0
- package/src/server/lib/btw/registry.server.ts +141 -0
- package/src/server/lib/btw/runtime.server.ts +324 -0
- package/src/server/lib/btw/schema.server.ts +44 -0
- package/src/server/lib/btw/service.server.ts +171 -0
- package/src/server/lib/btw/session-copy.server.ts +213 -0
- package/src/server/lib/btw/session-copy.test.ts +166 -0
- package/src/server/lib/btw/store.server.ts +240 -0
- package/src/server/lib/db/schema.ts +110 -0
- package/src/server/lib/db/seed.ts +160 -0
- package/src/server/lib/db/workspace-sync.ts +70 -0
- package/src/server/lib/fs/root.ts +37 -0
- package/src/server/lib/models/preferences.server.ts +110 -0
- package/src/server/lib/models/registry.server.ts +206 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
- package/src/server/lib/omp/rpc/manager.ts +27 -123
- package/src/server/lib/omp/rpc/utility.ts +7 -1
- package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
- package/src/server/lib/omp/session/auto-title.server.ts +152 -0
- package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
- package/src/server/lib/omp/session/blobs.server.ts +76 -0
- package/src/server/lib/omp/session/file-scan.ts +174 -0
- package/src/server/lib/omp/session/files.test.ts +147 -0
- package/src/server/lib/omp/session/files.ts +52 -35
- package/src/server/lib/omp/session/locator.ts +2 -2
- package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
- package/src/server/lib/omp/session/merge-stored.ts +171 -0
- package/src/server/lib/omp/session/messages.ts +34 -0
- package/src/server/lib/omp/session/reader.ts +2 -2
- package/src/server/lib/omp/session/subagent-presence.ts +9 -6
- package/src/server/lib/queue/delivery.server.test.ts +260 -0
- package/src/server/lib/queue/delivery.server.ts +63 -11
- package/src/server/plugins/ssr.ts +29 -2
- package/src/server/routes/btw/events.ts +70 -0
- package/src/server/routes/btw/index.ts +8 -0
- package/src/server/routes/btw/topics.ts +87 -0
- package/src/server/routes/btw/ws.ts +136 -0
- package/src/server/routes/chat/index.ts +2 -0
- package/src/server/routes/chat/session.ts +1 -115
- package/src/server/routes/chat/turns.ts +87 -0
- package/src/server/routes/fs/index.ts +4 -1
- package/src/server/routes/fs/raw.ts +85 -0
- package/src/server/routes/fs/read.ts +58 -3
- package/src/server/routes/index.ts +7 -1
- package/src/server/routes/models/root.ts +88 -215
- package/src/server/routes/settings/commands.ts +59 -18
- package/src/shared/lib/chat/attachments.test.ts +208 -0
- package/src/shared/lib/chat/attachments.ts +200 -15
- package/src/shared/lib/chat/btw/client.ts +94 -0
- package/src/shared/lib/chat/composer/client.test.ts +48 -0
- package/src/shared/lib/chat/composer/client.ts +23 -9
- package/src/shared/lib/chat/composer/commands.ts +290 -0
- package/src/shared/lib/chat/composer/composer.test.ts +195 -0
- package/src/shared/lib/chat/composer/filter.ts +21 -7
- package/src/shared/lib/chat/composer/trigger.ts +154 -21
- package/src/shared/lib/chat/omp/agent-events.ts +33 -80
- package/src/shared/lib/chat/omp/socket.ts +11 -6
- package/src/shared/lib/chat/omp/sse.ts +11 -6
- package/src/shared/lib/chat/omp/tool-results.ts +102 -0
- package/src/shared/lib/chat/omp/transport.ts +19 -3
- package/src/shared/lib/chat/text-file.ts +148 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
- package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
- package/src/shared/lib/chat/timeline/turns.ts +7 -0
- package/src/shared/lib/fs/file-kind.ts +50 -0
- package/src/shared/lib/fs/paths.ts +19 -1
- package/src/shared/lib/markdown/math.test.ts +20 -3
- package/src/shared/lib/models/preferences.test.ts +131 -0
- package/src/shared/lib/models/preferences.ts +88 -0
- package/src/shared/lib/models/provider-models.ts +8 -1
- package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
- package/src/shared/lib/omp/session/mapper.ts +24 -4
- package/src/shared/lib/omp/session/messages-map.ts +16 -3
- package/src/shared/lib/omp/session/messages-parse.ts +2 -2
- package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
- package/src/shared/lib/omp/subagent/label.test.ts +94 -0
- package/src/shared/lib/omp/subagent/label.ts +84 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
- package/src/shared/lib/workspace/panel-widths.ts +161 -30
- package/src/shared/lib/workspace/right-panels.ts +50 -11
- package/src/shared/lib/workspace/session-state/store.ts +3 -0
- package/src/shared/types/btw.ts +65 -0
- package/src/shared/types/chat.ts +77 -66
- package/src/shared/types/index.ts +2 -0
- package/src/shared/types/models.ts +101 -0
- package/src/shared/types/omp/agent.ts +14 -1
- package/src/shared/types/omp/session.ts +4 -1
- package/src/shared/types/omp/subagent.ts +4 -0
- package/src/shared/types/settings/command.ts +13 -0
- package/src/shared/types/settings/state.ts +5 -0
- package/dist/client/static/css/index.e880517fcd.css +0 -2
- package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
- package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
- package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
- package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
- package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
- package/dist/client/static/js/async/e.93214d622b.js +0 -79
- package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
- package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
- package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
- package/dist/client/static/js/async/jl.874d352278.js +0 -1
- package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
- package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
- package/dist/client/static/js/async/q.23af2a358f.js +0 -21
- package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
- package/dist/client/static/js/async/s.222ee974f6.js +0 -2
- package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
- package/dist/client/static/js/async/vt.42416b707d.js +0 -1
- package/dist/client/static/js/async/x1.da8687159b.js +0 -90
- package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
- package/dist/client/static/js/index.0de15c5e17.js +0 -33
- package/dist/client/static/js/jp.50817896f3.js +0 -139
- /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reading a drop's payload.
|
|
8
|
+
*
|
|
9
|
+
* Every host describes a dragged file differently, and the differences decide
|
|
10
|
+
* whether a drop is accepted at all:
|
|
11
|
+
*
|
|
12
|
+
* - A file manager (Finder, Explorer, Nautilus) exposes real `File` entries.
|
|
13
|
+
* macOS additionally attaches a `text/plain` item carrying the absolute path,
|
|
14
|
+
* so the item list holds a `string` entry beside the `file` one.
|
|
15
|
+
* - A browser page dragging a DOWNLOAD (WhatsApp Web, Gmail, Slack) has no File
|
|
16
|
+
* at all: it offers a URL in `DownloadURL` / `text/uri-list`. Gating on the
|
|
17
|
+
* `Files` type rejected those outright, which is why a file dragged out of
|
|
18
|
+
* WhatsApp never attached.
|
|
19
|
+
* - VS Code's explorer offers only proprietary types whose payloads must be
|
|
20
|
+
* parsed for paths.
|
|
21
|
+
*
|
|
22
|
+
* These helpers answer the three questions the composer asks of a
|
|
23
|
+
* `DataTransfer`, and are pure so they can be exercised without a browser.
|
|
24
|
+
*
|
|
25
|
+
* `getData` throws in some hosts when called during `dragover` rather than
|
|
26
|
+
* `drop`; every read is guarded so one unreadable type cannot abort the scan.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
/** Data types that, by their presence alone, mean files are being dragged. */
|
|
31
|
+
const FILE_BEARING_TYPES = ['files', 'text/uri-list', 'codefiles', 'downloadurl'];
|
|
32
|
+
|
|
33
|
+
/** Types whose payload is a path or URL to resolve, not a readable File. */
|
|
34
|
+
const PATH_BEARING_TYPES = ['text/uri-list', 'downloadurl', 'text/plain', 'codefiles'];
|
|
35
|
+
|
|
36
|
+
/** Data type marking a drag that started in the chamber's own file tree. */
|
|
37
|
+
export const INTERNAL_FILE_PATH_TYPE = 'application/x-chamber-file-path';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read one declared type's payload.
|
|
41
|
+
*
|
|
42
|
+
* `getData` is case-sensitive while the format names hosts use are not: Chrome
|
|
43
|
+
* reports `downloadurl` in `types` after a page set `DownloadURL`, and a
|
|
44
|
+
* lowercase lookup misses it. The declared list is the ground truth, so the
|
|
45
|
+
* requested type is matched against it first and the host's own spelling is used
|
|
46
|
+
* for the read.
|
|
47
|
+
*/
|
|
48
|
+
function readData(dataTransfer: DataTransfer, type: string): string {
|
|
49
|
+
try {
|
|
50
|
+
const declared = dataTransfer.types ? Array.from(dataTransfer.types) : [];
|
|
51
|
+
const match = declared.find((candidate) => candidate.toLowerCase() === type.toLowerCase());
|
|
52
|
+
return match ? dataTransfer.getData(match) : '';
|
|
53
|
+
} catch {
|
|
54
|
+
return '';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether this drag carries files at all — the gate for showing the drop target
|
|
60
|
+
* and for cancelling the browser's own handling.
|
|
61
|
+
*
|
|
62
|
+
* Checked on dragenter/dragover, where payloads are often unreadable, so the
|
|
63
|
+
* declared types are the primary signal and the file list is the fallback for
|
|
64
|
+
* hosts that declare nothing useful. A `text/plain` payload is NOT accepted on
|
|
65
|
+
* its own: that would make every text selection a drop target.
|
|
66
|
+
*/
|
|
67
|
+
export function hasDraggedFiles(dataTransfer: DataTransfer | null | undefined): boolean {
|
|
68
|
+
if (!dataTransfer) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
if (dataTransfer.files && dataTransfer.files.length > 0) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const types = dataTransfer.types ? Array.from(dataTransfer.types).map((type) => type.toLowerCase()) : [];
|
|
76
|
+
if (FILE_BEARING_TYPES.some((type) => types.includes(type))) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
if (types.some((type) => type.includes('vnd.code.tree'))) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A path-bearing type only counts when its payload looks like a path or URL:
|
|
84
|
+
// `text/plain` alone is how every text selection arrives, and treating that as
|
|
85
|
+
// a file drag would make the drop target light up for ordinary prose.
|
|
86
|
+
const references = collectDroppedFileUris(dataTransfer);
|
|
87
|
+
return references.length > 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True for a payload that is a path or URL rather than a document body. */
|
|
91
|
+
function looksLikeFileReference(value: string): boolean {
|
|
92
|
+
const candidate = value.trim();
|
|
93
|
+
if (!candidate || candidate.includes('\n')) return false;
|
|
94
|
+
if (/^(file|https?):\/\/\S+$/i.test(candidate)) return true;
|
|
95
|
+
// A POSIX absolute path, a `~` path, or a Windows drive path.
|
|
96
|
+
return /^(~\/|\/|[A-Za-z]:[\\/])\S*$/.test(candidate);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* File references from a drop that carries no `File` objects — a download
|
|
101
|
+
* dragged out of a web page, or VS Code handing over paths.
|
|
102
|
+
*
|
|
103
|
+
* macOS puts up to two payloads on the same drag: a `text/uri-list` with a
|
|
104
|
+
* `file://` URL and a `text/plain` with the bare path. `file://` wins outright
|
|
105
|
+
* when present, because the bare path is the same file spelled less precisely —
|
|
106
|
+
* collecting both would attach one file twice.
|
|
107
|
+
*/
|
|
108
|
+
export function collectDroppedFileUris(dataTransfer: DataTransfer | null | undefined): string[] {
|
|
109
|
+
if (!dataTransfer || typeof dataTransfer.getData !== 'function') return [];
|
|
110
|
+
|
|
111
|
+
const references: string[] = [];
|
|
112
|
+
const seen = new Set<string>();
|
|
113
|
+
const add = (value: string) => {
|
|
114
|
+
const candidate = value.trim();
|
|
115
|
+
// A uri-list is newline-separated and may carry `#` comments.
|
|
116
|
+
for (const line of candidate.split('\n')) {
|
|
117
|
+
const entry = line.trim();
|
|
118
|
+
if (!entry || entry.startsWith('#') || !looksLikeFileReference(entry)) continue;
|
|
119
|
+
if (seen.has(entry)) continue;
|
|
120
|
+
seen.add(entry);
|
|
121
|
+
references.push(entry);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
for (const type of PATH_BEARING_TYPES) {
|
|
126
|
+
if (type === 'text/plain' && references.length > 0) continue;
|
|
127
|
+
add(readData(dataTransfer, type));
|
|
128
|
+
}
|
|
129
|
+
return references;
|
|
130
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Turning a dropped file REFERENCE into an attachable File.
|
|
8
|
+
*
|
|
9
|
+
* A download dragged out of a web page (WhatsApp Web, Gmail, Slack) hands the
|
|
10
|
+
* composer a URL, not bytes: there is no `File` in the drag at all. The browser
|
|
11
|
+
* cannot read it either — a cross-origin `file://` path and a remote URL are
|
|
12
|
+
* both outside what the page may fetch. The chamber server can, so the
|
|
13
|
+
* reference is resolved there and returned as text the composer attaches like
|
|
14
|
+
* any other file.
|
|
15
|
+
*
|
|
16
|
+
* Only text is supported on this path. An image or an arbitrary binary cannot
|
|
17
|
+
* round-trip through JSON as a string, and the drop's own `File` (when the host
|
|
18
|
+
* provides one) is the path that carries those.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
/** Last path segment of a URL or path, for the attachment's display name. */
|
|
23
|
+
export function referenceName(reference: string): string {
|
|
24
|
+
const withoutQuery = reference.split(/[?#]/)[0] ?? reference;
|
|
25
|
+
const segment = withoutQuery.replace(/\/+$/, '').split('/').pop();
|
|
26
|
+
if (!segment) return 'attachment';
|
|
27
|
+
try {
|
|
28
|
+
return decodeURIComponent(segment);
|
|
29
|
+
} catch {
|
|
30
|
+
return segment;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Why a dropped reference could not be turned into a file. */
|
|
35
|
+
export type ReferenceFailure =
|
|
36
|
+
/** The path exists but lies outside the roots the chamber may read. */
|
|
37
|
+
| 'outside-roots'
|
|
38
|
+
/** A URL from a web page; the chamber does not fetch outbound. */
|
|
39
|
+
| 'remote'
|
|
40
|
+
/** The path exists but its contents are not text (an image, an archive). */
|
|
41
|
+
| 'binary'
|
|
42
|
+
/** The server could not read it at all — gone, or an unreadable permission. */
|
|
43
|
+
| 'unavailable';
|
|
44
|
+
|
|
45
|
+
export type ReferenceReadResult =
|
|
46
|
+
| { ok: true; file: File }
|
|
47
|
+
| { ok: false; reason: ReferenceFailure };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read a dropped reference through the server.
|
|
51
|
+
*
|
|
52
|
+
* `path` is used as the filesystem root hint so a relative reference resolves
|
|
53
|
+
* inside the active workspace; an absolute path or URL ignores it. The failure
|
|
54
|
+
* carries its REASON because the three cases need three different actions from
|
|
55
|
+
* the user, and a single "could not be read" left them with no way to tell them
|
|
56
|
+
* apart — or the developer with no way to tell which branch ran.
|
|
57
|
+
*/
|
|
58
|
+
export async function readDroppedReference(reference: string, rootPath: string | null): Promise<ReferenceReadResult> {
|
|
59
|
+
const params = new URLSearchParams({ path: reference });
|
|
60
|
+
if (rootPath) params.set('root', rootPath);
|
|
61
|
+
const url = `/api/fs/read-reference?${params.toString()}`;
|
|
62
|
+
let res: Response;
|
|
63
|
+
try {
|
|
64
|
+
res = await fetch(url);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return { ok: false, reason: 'unavailable' };
|
|
67
|
+
}
|
|
68
|
+
const body = (await res.json().catch(() => null)) as { content?: unknown; name?: unknown; error?: unknown } | null;
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
const error = typeof body?.error === 'string' ? body.error : '';
|
|
71
|
+
if (/remote/i.test(error)) return { ok: false, reason: 'remote' };
|
|
72
|
+
if (/image|raw/i.test(error)) return { ok: false, reason: 'binary' };
|
|
73
|
+
if (res.status === 404 || /not found/i.test(error)) return { ok: false, reason: 'outside-roots' };
|
|
74
|
+
return { ok: false, reason: 'unavailable' };
|
|
75
|
+
}
|
|
76
|
+
if (!body || typeof body.content !== 'string') return { ok: false, reason: 'unavailable' };
|
|
77
|
+
// The server reports the basename it actually read, which is more reliable
|
|
78
|
+
// than re-deriving one from a percent-encoded URL.
|
|
79
|
+
const name = typeof body.name === 'string' && body.name ? body.name : referenceName(reference);
|
|
80
|
+
return { ok: true, file: new File([body.content], name, { type: 'text/plain' }) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** User-facing reason for files whose contents could not be read. */
|
|
84
|
+
export function describeUnreadable(names: string[]): string {
|
|
85
|
+
if (names.length === 1) {
|
|
86
|
+
return `"${names[0]}" could not be read and was not attached.`;
|
|
87
|
+
}
|
|
88
|
+
return `${names.length} files could not be read and were not attached: ${names.slice(0, 3).join(', ')}${names.length > 3 ? '…' : ''}.`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** User-facing reason for a dropped reference the server refused, per cause. */
|
|
92
|
+
export function describeReferenceFailure(names: string[], reason: ReferenceFailure): string {
|
|
93
|
+
const list = names.slice(0, 3).join(', ');
|
|
94
|
+
const subject = names.length === 1 ? `"${names[0]}"` : `${names.length} files`;
|
|
95
|
+
const tail = names.length > 3 ? '…' : '';
|
|
96
|
+
switch (reason) {
|
|
97
|
+
case 'outside-roots':
|
|
98
|
+
return `${subject} lie${names.length === 1 ? 's' : ''} outside the folders the chamber may read, so ${names.length === 1 ? 'it was' : 'they were'} not attached (${list}${tail}).`;
|
|
99
|
+
case 'remote':
|
|
100
|
+
return `${subject} ${names.length === 1 ? 'is a web download' : 'are web downloads'}; save ${names.length === 1 ? 'it' : 'them'} into a workspace folder first (${list}${tail}).`;
|
|
101
|
+
case 'binary':
|
|
102
|
+
return `${subject} ${names.length === 1 ? 'is not' : 'are not'} a text file and ${names.length === 1 ? 'was' : 'were'} not attached (${list}${tail}).`;
|
|
103
|
+
default:
|
|
104
|
+
return describeUnreadable(names);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Dropped-directory resolution for the composer.
|
|
8
|
+
*
|
|
9
|
+
* The browser APIs this reads (`webkitGetAsEntry`, `readEntries`) are awkward
|
|
10
|
+
* in ways that fail silently: a directory's `dataTransfer.files` entry is a
|
|
11
|
+
* size-0 stub, and `readEntries` returns at most 100 children per call, so a
|
|
12
|
+
* single call looks like a complete directory of ≤100 files. These tests pin
|
|
13
|
+
* the contract those quirks break — recursive expansion, paged reads, and the
|
|
14
|
+
* caps that keep a dropped repo root from queueing thousands of attachments.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
import { carriesBytes } from '@/client/hooks/chat/composer/file-drop';
|
|
19
|
+
import {
|
|
20
|
+
expandDroppedFiles,
|
|
21
|
+
readDroppedEntries,
|
|
22
|
+
MAX_DROPPED_FILES,
|
|
23
|
+
} from '@/client/hooks/chat/composer/drop-entries';
|
|
24
|
+
|
|
25
|
+
function fileEntry(name: string, content = 'x'): FileSystemFileEntry {
|
|
26
|
+
return {
|
|
27
|
+
isFile: true,
|
|
28
|
+
isDirectory: false,
|
|
29
|
+
name,
|
|
30
|
+
file: (ok: (f: File) => void) => ok(new File([content], name, { type: 'text/plain' })),
|
|
31
|
+
} as unknown as FileSystemFileEntry;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Directory whose reader hands back `children` in fixed-size batches. */
|
|
35
|
+
function dirEntry(name: string, children: FileSystemEntry[], batchSize = 100): FileSystemDirectoryEntry {
|
|
36
|
+
return {
|
|
37
|
+
isFile: false,
|
|
38
|
+
isDirectory: true,
|
|
39
|
+
name,
|
|
40
|
+
createReader: () => {
|
|
41
|
+
let cursor = 0;
|
|
42
|
+
return {
|
|
43
|
+
readEntries: (ok: (batch: FileSystemEntry[]) => void) => {
|
|
44
|
+
const batch = children.slice(cursor, cursor + batchSize);
|
|
45
|
+
cursor += batchSize;
|
|
46
|
+
ok(batch);
|
|
47
|
+
},
|
|
48
|
+
} as unknown as FileSystemDirectoryReader;
|
|
49
|
+
},
|
|
50
|
+
} as unknown as FileSystemDirectoryEntry;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fakeDataTransfer(
|
|
54
|
+
items: { entry: FileSystemEntry | null; file?: File }[],
|
|
55
|
+
files: File[] = [],
|
|
56
|
+
): DataTransfer {
|
|
57
|
+
return {
|
|
58
|
+
items: items.map(({ entry, file }) => ({
|
|
59
|
+
kind: 'file',
|
|
60
|
+
webkitGetAsEntry: () => entry,
|
|
61
|
+
getAsFile: () => file ?? null,
|
|
62
|
+
})),
|
|
63
|
+
files,
|
|
64
|
+
} as unknown as DataTransfer;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('readDroppedEntries', () => {
|
|
68
|
+
// The two views are not interchangeable: the item entry can carry correct
|
|
69
|
+
// metadata over a backing store that no longer reads, while `files` hands over
|
|
70
|
+
// one that does. A drop in an installed app window exposed exactly that — the
|
|
71
|
+
// chip had the right name and size and its contents never arrived, because the
|
|
72
|
+
// item-list File was the one being read.
|
|
73
|
+
test('the file list wins over the item list when it holds bytes', () => {
|
|
74
|
+
const fromItems = new File(['from items'], 'notes.md', { type: 'text/markdown' });
|
|
75
|
+
const fromFiles = new File(['from files'], 'notes.md', { type: 'text/markdown' });
|
|
76
|
+
const entries = readDroppedEntries(fakeDataTransfer([{ entry: fileEntry('notes.md'), file: fromItems }], [fromFiles]));
|
|
77
|
+
|
|
78
|
+
expect(entries.files.length).toBe(1);
|
|
79
|
+
expect(entries.files[0]).toBe(fromFiles);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// A 0-byte stub in `files` is how a DIRECTORY is listed, so taking it would
|
|
83
|
+
// attach an empty file in place of the tree the walk is about to expand.
|
|
84
|
+
test('a stub in the file list falls through to the item entry', () => {
|
|
85
|
+
const stub = new File([], 'notes.md', { type: '' });
|
|
86
|
+
const real = new File(['# Title\n\nBody'], 'notes.md', { type: 'text/markdown' });
|
|
87
|
+
const entries = readDroppedEntries(fakeDataTransfer([{ entry: fileEntry('notes.md'), file: real }], [stub]));
|
|
88
|
+
|
|
89
|
+
expect(entries.files.length).toBe(1);
|
|
90
|
+
expect(entries.files[0].size).toBeGreaterThan(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('falls back to the item list when the file list is empty', () => {
|
|
94
|
+
const fromItems = new File(['item body'], 'only-items.txt', { type: 'text/plain' });
|
|
95
|
+
const entries = readDroppedEntries(fakeDataTransfer([{ entry: fileEntry('only-items.txt'), file: fromItems }]));
|
|
96
|
+
|
|
97
|
+
expect(entries.files.map((f) => f.name)).toEqual(['only-items.txt']);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('stubs are kept when nothing else holds bytes', () => {
|
|
101
|
+
const stub = new File([], 'from-a-page.txt', { type: 'text/plain' });
|
|
102
|
+
const entries = readDroppedEntries(fakeDataTransfer([], [stub]));
|
|
103
|
+
|
|
104
|
+
expect(entries.files.map((f) => f.name)).toEqual(['from-a-page.txt']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('passes plain files through untouched', async () => {
|
|
108
|
+
const file = new File(['a'], 'a.txt', { type: 'text/plain' });
|
|
109
|
+
const { files, incomplete } = await expandDroppedFiles(
|
|
110
|
+
readDroppedEntries(fakeDataTransfer([{ entry: fileEntry('a.txt'), file }])),
|
|
111
|
+
);
|
|
112
|
+
expect(files.map((f: File) => f.name)).toEqual(['a.txt']);
|
|
113
|
+
expect(incomplete).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('expands a dropped directory recursively', async () => {
|
|
117
|
+
const root = dirEntry('root', [
|
|
118
|
+
fileEntry('a.txt'),
|
|
119
|
+
dirEntry('sub', [fileEntry('b.txt'), dirEntry('deeper', [fileEntry('c.txt')])]),
|
|
120
|
+
]);
|
|
121
|
+
const { files, incomplete } = await expandDroppedFiles(readDroppedEntries(fakeDataTransfer([{ entry: root }])));
|
|
122
|
+
expect(files.map((f: File) => f.name).sort()).toEqual(['a.txt', 'b.txt', 'c.txt']);
|
|
123
|
+
expect(incomplete).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('keeps paging past the 100-children-per-read limit', async () => {
|
|
127
|
+
const many = Array.from({ length: 250 }, (_, i) => fileEntry(`f${i}.txt`));
|
|
128
|
+
const { files, incomplete } = await expandDroppedFiles(readDroppedEntries(fakeDataTransfer([{ entry: dirEntry('big', many) }])));
|
|
129
|
+
expect(files.length).toBe(MAX_DROPPED_FILES);
|
|
130
|
+
// More files existed than were taken, and the caller must be able to say so.
|
|
131
|
+
expect(incomplete).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('stops at the nesting cap instead of walking a cycle forever', async () => {
|
|
135
|
+
let nested: FileSystemEntry = dirEntry('leaf', [fileEntry('deep.txt')]);
|
|
136
|
+
for (let i = 0; i < 14; i += 1) nested = dirEntry(`wrap${i}`, [nested]);
|
|
137
|
+
const { incomplete } = await expandDroppedFiles(readDroppedEntries(fakeDataTransfer([{ entry: nested }])));
|
|
138
|
+
expect(incomplete).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('falls back to the flat file list when no item entries exist', async () => {
|
|
142
|
+
const listed = Array.from({ length: MAX_DROPPED_FILES + 10 }, (_, i) => new File(['x'], `g${i}.txt`));
|
|
143
|
+
const { files, incomplete } = await expandDroppedFiles(readDroppedEntries(fakeDataTransfer([], listed)));
|
|
144
|
+
expect(files.length).toBe(MAX_DROPPED_FILES);
|
|
145
|
+
expect(incomplete).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('tolerates a directory whose read fails', async () => {
|
|
149
|
+
const broken = {
|
|
150
|
+
isFile: false,
|
|
151
|
+
isDirectory: true,
|
|
152
|
+
name: 'broken',
|
|
153
|
+
createReader: () => ({
|
|
154
|
+
readEntries: (_ok: unknown, err?: (e: unknown) => void) => err?.(new Error('denied')),
|
|
155
|
+
}),
|
|
156
|
+
} as unknown as FileSystemDirectoryEntry;
|
|
157
|
+
const { files, incomplete } = await expandDroppedFiles(readDroppedEntries(fakeDataTransfer([{ entry: broken }])));
|
|
158
|
+
expect(files).toEqual([]);
|
|
159
|
+
expect(incomplete).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('carriesBytes', () => {
|
|
164
|
+
test('a file whose declared size is 0 but holds bytes counts', async () => {
|
|
165
|
+
// A .md dropped from Finder can report size 0 while its bytes are present.
|
|
166
|
+
// Treating that as a "path stub" routed it to the reference resolver, which
|
|
167
|
+
// only reads inside the allow-list — a file in ~/Downloads was refused with
|
|
168
|
+
// no way for the user to tell why.
|
|
169
|
+
const real = new File([new TextEncoder().encode('# Title\n\nBody')], 'notes.md', { type: '' });
|
|
170
|
+
Object.defineProperty(real, 'size', { value: 0 });
|
|
171
|
+
expect(real.size).toBe(0);
|
|
172
|
+
expect(await carriesBytes([real])).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('a declared size above zero is enough without reading', async () => {
|
|
176
|
+
const file = new File([new Uint8Array([1, 2, 3])], 'a.bin', { type: 'application/octet-stream' });
|
|
177
|
+
expect(await carriesBytes([file])).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('a genuine stub holds nothing, so the reference path still runs', async () => {
|
|
181
|
+
const stub = new File([], 'from-a-web-page.txt', { type: 'text/plain' });
|
|
182
|
+
expect(await carriesBytes([stub])).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('one readable file in a batch is enough', async () => {
|
|
186
|
+
const empty = new File([], 'empty.txt', { type: 'text/plain' });
|
|
187
|
+
const real = new File([new Uint8Array([65])], 'real.txt', { type: 'text/plain' });
|
|
188
|
+
expect(await carriesBytes([empty, real])).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('an empty batch carries nothing', async () => {
|
|
192
|
+
expect(await carriesBytes([])).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* OS file drag-and-drop for the chat composer.
|
|
8
|
+
*
|
|
9
|
+
* Three browser rules shape this module:
|
|
10
|
+
*
|
|
11
|
+
* 1. A `drop` is only delivered when the preceding `dragover` was cancelled, so
|
|
12
|
+
* the handlers belong on the whole composer card — a drop aimed at the
|
|
13
|
+
* textarea bubbles up to it, and cancelling there also suppresses the
|
|
14
|
+
* browser's own "insert the dropped file's path as text" default.
|
|
15
|
+
* 2. A dropped DIRECTORY is not a readable File: `dataTransfer.files` carries a
|
|
16
|
+
* size-0 stub for it. Only `DataTransferItem.webkitGetAsEntry()` reveals it
|
|
17
|
+
* as a directory, so those are walked recursively and attached file by file.
|
|
18
|
+
* 3. A file dropped anywhere the app does not cancel lands in the tab as
|
|
19
|
+
* `file://` navigation, which throws the chamber away. The guard below is
|
|
20
|
+
* window-wide for exactly that reason; it only ever cancels file drags, so
|
|
21
|
+
* text and URL drops keep their native behaviour.
|
|
22
|
+
*
|
|
23
|
+
* What counts as a file drag is decided in `drop-payload.ts`, because hosts
|
|
24
|
+
* disagree: a download dragged out of a web page offers no File at all, only a
|
|
25
|
+
* URL, and those references are handed back to the caller to resolve.
|
|
26
|
+
*
|
|
27
|
+
* The drag data store is cleared the moment the drop handler returns, so every
|
|
28
|
+
* store-bound read (`getAsFile`, `webkitGetAsEntry`, `getData`) happens
|
|
29
|
+
* synchronously; the entries and Files they return stay valid while the walk
|
|
30
|
+
* awaits.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
34
|
+
import type { TargetedDragEvent } from 'preact';
|
|
35
|
+
import { collectDroppedFileUris, hasDraggedFiles } from '@/client/hooks/chat/composer/drop-payload';
|
|
36
|
+
import { primeFileReads, primedPrefix } from '@/client/hooks/chat/composer/file-reads';
|
|
37
|
+
import type { PrimedReads } from '@/client/hooks/chat/composer/file-reads';
|
|
38
|
+
import {
|
|
39
|
+
expandDroppedFiles,
|
|
40
|
+
readDroppedEntries,
|
|
41
|
+
MAX_DROP_DEPTH,
|
|
42
|
+
MAX_DROPPED_FILES,
|
|
43
|
+
} from '@/client/hooks/chat/composer/drop-entries';
|
|
44
|
+
import type { DroppedEntries } from '@/client/hooks/chat/composer/drop-entries';
|
|
45
|
+
|
|
46
|
+
/** Reason shown when a drop yielded more than the composer can take. */
|
|
47
|
+
export const DROP_LIMIT_NOTICE =
|
|
48
|
+
`Some files were skipped — a drop is limited to ${MAX_DROPPED_FILES} files and ${MAX_DROP_DEPTH} folder levels deep.`;
|
|
49
|
+
|
|
50
|
+
export interface UseFileDropOptions {
|
|
51
|
+
/**
|
|
52
|
+
* Called once per drop with every file it resolved to (directories expanded),
|
|
53
|
+
* plus any file REFERENCES the host offered instead of bytes (a download
|
|
54
|
+
* dragged out of a web page) for the caller to resolve server-side.
|
|
55
|
+
*/
|
|
56
|
+
onFiles: (files: File[], incomplete: boolean, references: string[], primed: PrimedReads) => void;
|
|
57
|
+
/** While true a drop is cancelled but not attached — the composer is closed. */
|
|
58
|
+
disabled?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Drag handlers to spread onto the drop target. */
|
|
62
|
+
export interface FileDropProps {
|
|
63
|
+
onDragEnter: (e: TargetedDragEvent<HTMLElement>) => void;
|
|
64
|
+
onDragOver: (e: TargetedDragEvent<HTMLElement>) => void;
|
|
65
|
+
onDragLeave: (e: TargetedDragEvent<HTMLElement>) => void;
|
|
66
|
+
onDrop: (e: TargetedDragEvent<HTMLElement>) => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface UseFileDropResult {
|
|
70
|
+
/** True while a file drag hovers the bound element (drives the overlay). */
|
|
71
|
+
isDragging: boolean;
|
|
72
|
+
dropProps: FileDropProps;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function useFileDrop({ onFiles, disabled = false }: UseFileDropOptions): UseFileDropResult {
|
|
76
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
77
|
+
// dragenter/dragleave fire for every nested child the pointer crosses, so a
|
|
78
|
+
// boolean flag drops to false the instant the cursor moves from the card onto
|
|
79
|
+
// the textarea. Counting them keeps the overlay steady.
|
|
80
|
+
const depthRef = useRef(0);
|
|
81
|
+
// Latest callback and flag behind refs, so the handlers stay referentially
|
|
82
|
+
// stable and the window guard sees the current `disabled`.
|
|
83
|
+
const onFilesRef = useRef(onFiles);
|
|
84
|
+
onFilesRef.current = onFiles;
|
|
85
|
+
const disabledRef = useRef(disabled);
|
|
86
|
+
disabledRef.current = disabled;
|
|
87
|
+
|
|
88
|
+
const reset = useCallback(() => {
|
|
89
|
+
depthRef.current = 0;
|
|
90
|
+
setIsDragging(false);
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
// A drag can end without reaching this element again — released over the
|
|
94
|
+
// page, cancelled with Escape, or its source node unmounted mid-drag. The
|
|
95
|
+
// composer's own handlers have already run by the time these fire, so this
|
|
96
|
+
// only clears state the drop handler did not.
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
window.addEventListener('dragend', reset);
|
|
99
|
+
window.addEventListener('drop', reset);
|
|
100
|
+
return () => {
|
|
101
|
+
window.removeEventListener('dragend', reset);
|
|
102
|
+
window.removeEventListener('drop', reset);
|
|
103
|
+
};
|
|
104
|
+
}, [reset]);
|
|
105
|
+
|
|
106
|
+
// Files dropped anywhere else would navigate the tab to `file://` and discard
|
|
107
|
+
// the chamber, so the default is cancelled app-wide. This runs after the
|
|
108
|
+
// composer's own drop handler (the event bubbles up to window), by which point
|
|
109
|
+
// that handler has already read the data store it needed.
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
const guard = (e: DragEvent) => {
|
|
112
|
+
if (hasDraggedFiles(e.dataTransfer)) e.preventDefault();
|
|
113
|
+
};
|
|
114
|
+
window.addEventListener('dragover', guard);
|
|
115
|
+
window.addEventListener('drop', guard);
|
|
116
|
+
return () => {
|
|
117
|
+
window.removeEventListener('dragover', guard);
|
|
118
|
+
window.removeEventListener('drop', guard);
|
|
119
|
+
};
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
const onDragEnter = useCallback((e: TargetedDragEvent<HTMLElement>) => {
|
|
123
|
+
if (!hasDraggedFiles(e.dataTransfer)) return;
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
if (disabledRef.current) return;
|
|
126
|
+
depthRef.current += 1;
|
|
127
|
+
setIsDragging(true);
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
130
|
+
const onDragOver = useCallback((e: TargetedDragEvent<HTMLElement>) => {
|
|
131
|
+
if (!hasDraggedFiles(e.dataTransfer)) return;
|
|
132
|
+
// Required: without it the browser refuses the drop and navigates to the
|
|
133
|
+
// file instead of handing it to the app.
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
if (!e.dataTransfer) return;
|
|
136
|
+
// `none` is the only cursor that tells the truth about a closed composer.
|
|
137
|
+
e.dataTransfer.dropEffect = disabledRef.current ? 'none' : 'copy';
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
// Not gated on the payload: `dataTransfer.types` can already be empty on
|
|
141
|
+
// dragleave. The counter is the gate — a text drag never raised it.
|
|
142
|
+
const onDragLeave = useCallback(() => {
|
|
143
|
+
if (depthRef.current === 0) return;
|
|
144
|
+
depthRef.current -= 1;
|
|
145
|
+
if (depthRef.current === 0) setIsDragging(false);
|
|
146
|
+
}, []);
|
|
147
|
+
|
|
148
|
+
const onDrop = useCallback((e: TargetedDragEvent<HTMLElement>) => {
|
|
149
|
+
const dt = e.dataTransfer;
|
|
150
|
+
// Read the payload in ONE pass before anything else touches it:
|
|
151
|
+
// `getAsFile()` and `webkitGetAsEntry()` are not pure accessors on a
|
|
152
|
+
// file-manager drag, and each call can consume state the other needs.
|
|
153
|
+
const entries: DroppedEntries = dt ? readDroppedEntries(dt) : { files: [], directories: [], fallbacks: new Map() };
|
|
154
|
+
const references = dt ? collectDroppedFileUris(dt) : [];
|
|
155
|
+
|
|
156
|
+
if (!dt || !hasDraggedFiles(dt)) return;
|
|
157
|
+
e.preventDefault();
|
|
158
|
+
reset();
|
|
159
|
+
if (disabledRef.current) return;
|
|
160
|
+
|
|
161
|
+
// START THE READS NOW, in the drop handler's own tick. A dropped File's
|
|
162
|
+
// read permission is released when this handler returns, and a read started
|
|
163
|
+
// afterwards fails permanently — the chip rendered with the right name and
|
|
164
|
+
// size while its contents never arrived, on every retry. `FileReader` takes
|
|
165
|
+
// the permission at call time, so starting here is what makes the bytes
|
|
166
|
+
// reachable; the pipeline only awaits these later.
|
|
167
|
+
const primed = primeFileReads(entries.files, entries.fallbacks);
|
|
168
|
+
|
|
169
|
+
if (entries.files.length === 0 && entries.directories.length === 0) {
|
|
170
|
+
// No File at all: the host named the file without handing it over, and the
|
|
171
|
+
// reference resolver is the only route left to its contents.
|
|
172
|
+
if (references.length > 0) onFilesRef.current([], false, references, primed);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
void expandDroppedFiles(entries).then(async ({ files: resolved, incomplete }) => {
|
|
177
|
+
const carries = resolved.length > 0 ? await carriesBytes(resolved, primed) : false;
|
|
178
|
+
// A File that carries bytes is attached directly. Deciding from metadata
|
|
179
|
+
// instead — "size 0 and no type, so it must be a path stub" — misread a
|
|
180
|
+
// real file whose size the browser had not filled in, and sent it to the
|
|
181
|
+
// reference resolver, which only reads inside the allow-list. The file
|
|
182
|
+
// was then refused with no way for the user to tell why. Bytes settle it.
|
|
183
|
+
if (resolved.length > 0 && carries) {
|
|
184
|
+
onFilesRef.current(resolved, incomplete, [], primed);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (references.length > 0) {
|
|
188
|
+
onFilesRef.current([], incomplete, references, primed);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Files that declare no bytes are attached as-is; the attachment hook
|
|
192
|
+
// reports the ones that never produced content.
|
|
193
|
+
if (resolved.length > 0) onFilesRef.current(resolved, incomplete, [], primed);
|
|
194
|
+
});
|
|
195
|
+
}, [reset]);
|
|
196
|
+
|
|
197
|
+
return { isDragging, dropProps: { onDragEnter, onDragOver, onDragLeave, onDrop } };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Whether any dropped file actually holds bytes.
|
|
202
|
+
*
|
|
203
|
+
* A promised file (another app's drag) reports `size: 0` until its contents
|
|
204
|
+
* land, so the check reads rather than trusting the declared size. One readable
|
|
205
|
+
* file is enough: the batch is attached as a unit, and a member that stays
|
|
206
|
+
* empty is reported by the attachment hook rather than by re-routing the whole
|
|
207
|
+
* drop.
|
|
208
|
+
*/
|
|
209
|
+
export async function carriesBytes(files: File[], primed?: PrimedReads): Promise<boolean> {
|
|
210
|
+
const sample = files.slice(0, 4);
|
|
211
|
+
const results = await Promise.all(sample.map(async (file) => {
|
|
212
|
+
if (file.size > 0) return true;
|
|
213
|
+
// A stub declaring no size is settled by its bytes — read from the primed
|
|
214
|
+
// prefix where the drop provided one, because a `slice()` started after the
|
|
215
|
+
// drop window closes is not authorized to read a dropped file at all.
|
|
216
|
+
const bytes = await primedPrefix(file, primed);
|
|
217
|
+
if (bytes !== undefined) return bytes.byteLength > 0;
|
|
218
|
+
try {
|
|
219
|
+
const raw = await file.slice(0, 1).arrayBuffer();
|
|
220
|
+
return raw.byteLength > 0;
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}));
|
|
225
|
+
return results.some(Boolean);
|
|
226
|
+
}
|