ompchamber 1.1.0 → 1.2.1
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.a5ccbc19dc.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.5fe7a2cb64.js +34 -0
- package/package.json +1 -1
- 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/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 +183 -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 +88 -0
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +37 -21
- package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
- 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 +44 -40
- package/src/client/components/workspace/chat-timeline/index.tsx +11 -9
- 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/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 +7 -1
- 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 +16 -1
- package/src/client/hooks/chat/timeline/index.ts +16 -8
- package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
- package/src/client/hooks/chat/timeline/send.ts +29 -65
- 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/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/omp/rpc/frame-fold.ts +207 -0
- package/src/server/lib/omp/rpc/manager.ts +27 -123
- 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 +57 -0
- package/src/server/lib/queue/delivery.server.ts +25 -3
- 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/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 +6 -1
- package/src/shared/lib/chat/composer/composer.test.ts +39 -1
- package/src/shared/lib/chat/composer/trigger.ts +45 -1
- 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/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/types/btw.ts +65 -0
- package/src/shared/types/chat.ts +39 -1
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/omp/agent.ts +14 -1
- package/src/shared/types/omp/session.ts +4 -1
- package/src/shared/types/settings/state.ts +5 -0
- package/dist/client/static/css/index.b859cc1cc5.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.6e065017eb.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.a5ccbc19dc.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.6e065017eb.js.LICENSE.txt → index.5fe7a2cb64.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Attachment lifecycle for the composer: accept a batch of files (picker,
|
|
8
|
+
* paste, or OS drop), gate the text ones against the inline budget, hold blob
|
|
9
|
+
* previews, and read the bytes into the attachment.
|
|
10
|
+
*
|
|
11
|
+
* Bytes are read AT ATTACH TIME, not at send time. A `File` handed over by a
|
|
12
|
+
* drop or a paste is a live handle onto the drag data store, and reading it
|
|
13
|
+
* later — after the composer has re-rendered, or after the drag that produced
|
|
14
|
+
* it has ended — is what made a dropped file arrive with an empty body while
|
|
15
|
+
* its chip still rendered. Reading once, here, means every later step (send,
|
|
16
|
+
* queue, retry) works from the persisted copy.
|
|
17
|
+
*
|
|
18
|
+
* Two shapes of "no content yet" have to be told apart, and getting it wrong is
|
|
19
|
+
* what produced a prompt sent with no message at all:
|
|
20
|
+
*
|
|
21
|
+
* - A file from another app arrives as a PROMISE: the entry exists before its
|
|
22
|
+
* bytes do, so the first read comes back empty. That read is retried.
|
|
23
|
+
* - A genuinely empty file reads empty forever. It is accepted as-is.
|
|
24
|
+
*
|
|
25
|
+
* Only a read that stays empty while the file claims bytes is a failure, and a
|
|
26
|
+
* failure is always reported — never silently omitted.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { useCallback, useRef } from 'preact/hooks';
|
|
30
|
+
import type { SetStateAction } from 'preact/compat';
|
|
31
|
+
import type { Attachment } from '@/shared/types';
|
|
32
|
+
import { applyTextAttachmentBudget, isTextAttachment, looksLikeTextFile } from '@/shared/lib/chat/attachments';
|
|
33
|
+
import { delay, primedPrefix, readAsDataUrl, readFileTextWithRetry } from '@/client/hooks/chat/composer/file-reads';
|
|
34
|
+
import type { PrimedReads } from '@/client/hooks/chat/composer/file-reads';
|
|
35
|
+
|
|
36
|
+
export interface UseComposerAttachmentsOptions {
|
|
37
|
+
/** Current attachments; the text budget is measured against them. */
|
|
38
|
+
attachments: Attachment[];
|
|
39
|
+
setAttachments: (updater: SetStateAction<Attachment[]>) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AddFilesOutcome {
|
|
43
|
+
/** How many files were attached. */
|
|
44
|
+
added: number;
|
|
45
|
+
/** Files refused by the inline text budget, for the caller to report. */
|
|
46
|
+
skipped: File[];
|
|
47
|
+
/** Names of files whose contents could not be read, for the caller to report. */
|
|
48
|
+
unreadable: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ComposerAttachments {
|
|
52
|
+
/**
|
|
53
|
+
* Reads every accepted file before resolving, so `unreadable` is complete.
|
|
54
|
+
*
|
|
55
|
+
* `primed` carries the reads already started inside the drop handler. Passing
|
|
56
|
+
* it is not an optimization: a dropped `File` can only be read from a read
|
|
57
|
+
* begun while the drop's permission was live, so a batch that arrives without
|
|
58
|
+
* one is the batch that fails. See `file-reads.ts`.
|
|
59
|
+
*/
|
|
60
|
+
addFiles: (files: File[], primed?: PrimedReads) => Promise<AddFilesOutcome>;
|
|
61
|
+
removeAttachment: (id: string) => void;
|
|
62
|
+
/**
|
|
63
|
+
* Wait for every in-flight read, then return the attachments AS THEY STAND
|
|
64
|
+
* once those reads have landed.
|
|
65
|
+
*
|
|
66
|
+
* The list is returned rather than left to the caller because a read patches
|
|
67
|
+
* the stored attachment: a caller that captured the array before waiting
|
|
68
|
+
* would still hold the pre-read entry — the one with no content — and send
|
|
69
|
+
* that.
|
|
70
|
+
*/
|
|
71
|
+
readyForSend: () => Promise<Attachment[]>;
|
|
72
|
+
/**
|
|
73
|
+
* The attachment list as of right now, read synchronously.
|
|
74
|
+
*
|
|
75
|
+
* A drop registers its files in this hook before the enclosing component has
|
|
76
|
+
* re-rendered, so the component's own `attachments` prop lags by a frame.
|
|
77
|
+
* Deciding a send from that prop lost the attachment entirely: a send clicked
|
|
78
|
+
* immediately after a drop saw an empty list, cleared the composer and
|
|
79
|
+
* dispatched a prompt with nothing in it.
|
|
80
|
+
*/
|
|
81
|
+
peek: () => Attachment[];
|
|
82
|
+
/** Empty the composer (state and the hook's own list together). */
|
|
83
|
+
clear: () => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function useComposerAttachments({
|
|
87
|
+
attachments,
|
|
88
|
+
setAttachments,
|
|
89
|
+
}: UseComposerAttachmentsOptions): ComposerAttachments {
|
|
90
|
+
// In-flight work, so a send can wait for a batch it cannot see the end of.
|
|
91
|
+
const pendingReadsRef = useRef(new Set<Promise<void>>());
|
|
92
|
+
// The authoritative list. It is mirrored into the prop on every render, and
|
|
93
|
+
// updated here the moment this hook changes it — the component has not
|
|
94
|
+
// re-rendered yet at that point, so the prop is a frame behind.
|
|
95
|
+
const attachmentsRef = useRef(attachments);
|
|
96
|
+
attachmentsRef.current = attachments;
|
|
97
|
+
const setBoth = useCallback((next: Attachment[]) => {
|
|
98
|
+
attachmentsRef.current = next;
|
|
99
|
+
setAttachments(next);
|
|
100
|
+
}, [setAttachments]);
|
|
101
|
+
|
|
102
|
+
const addFiles = useCallback(async (files: File[], primed?: PrimedReads): Promise<AddFilesOutcome> => {
|
|
103
|
+
const { accepted, skipped } = applyTextAttachmentBudget(files, attachments);
|
|
104
|
+
if (accepted.length === 0) return { added: 0, skipped, unreadable: [] };
|
|
105
|
+
|
|
106
|
+
// Display fields are captured at creation: a File does not survive the
|
|
107
|
+
// queue's JSON round trip or committed history, and every replay path reads
|
|
108
|
+
// these instead of `file`.
|
|
109
|
+
const newAttachments: Attachment[] = accepted.map((file) => ({
|
|
110
|
+
id: Math.random().toString(36).substring(7),
|
|
111
|
+
name: file.name,
|
|
112
|
+
type: file.type,
|
|
113
|
+
size: file.size,
|
|
114
|
+
file,
|
|
115
|
+
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : '',
|
|
116
|
+
}));
|
|
117
|
+
setBoth([...attachmentsRef.current, ...newAttachments]);
|
|
118
|
+
|
|
119
|
+
const { promise: batch, resolve } = Promise.withResolvers<void>();
|
|
120
|
+
pendingReadsRef.current.add(batch);
|
|
121
|
+
const unreadable: string[] = [];
|
|
122
|
+
try {
|
|
123
|
+
await Promise.all(newAttachments.map(async (att) => {
|
|
124
|
+
const file = att.file;
|
|
125
|
+
if (!file) return;
|
|
126
|
+
// The read resolves after this call returns, so it addresses the entry
|
|
127
|
+
// by id — a captured array would be a pre-update snapshot.
|
|
128
|
+
const store = (patch: Partial<Attachment>) => {
|
|
129
|
+
setBoth(attachmentsRef.current.map((a) => (a.id === att.id ? { ...a, ...patch } : a)));
|
|
130
|
+
};
|
|
131
|
+
const label = att.name ?? file.name;
|
|
132
|
+
|
|
133
|
+
if (file.type.startsWith('image/')) {
|
|
134
|
+
const base64 = await readAsDataUrl(file, primed);
|
|
135
|
+
if (base64) store({ dataBase64: base64 });
|
|
136
|
+
else unreadable.push(label);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Metadata answers for a classified file; anything else is decided by
|
|
141
|
+
// its bytes, because a promised-file drag names itself `document` and
|
|
142
|
+
// reports no type.
|
|
143
|
+
const classified = isTextAttachment(att);
|
|
144
|
+
const content = await readFileTextWithRetry(file, primed);
|
|
145
|
+
if (content === null) {
|
|
146
|
+
unreadable.push(label);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (content.length === 0) {
|
|
150
|
+
// Nothing came back. A promised file whose bytes never arrive reads
|
|
151
|
+
// empty on every attempt, so this is the only place the user can be
|
|
152
|
+
// told — waiting for the send would just produce an empty prompt.
|
|
153
|
+
unreadable.push(label);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const prefix = await primedPrefix(file, primed);
|
|
157
|
+
if (!classified && !(await looksLikeTextFile(file, prefix))) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// The marker travels with the content: without it the send path would
|
|
161
|
+
// re-classify from the same uninformative name and drop it again.
|
|
162
|
+
store({ content, ...(classified ? {} : { sniffedText: true }) });
|
|
163
|
+
}));
|
|
164
|
+
} finally {
|
|
165
|
+
pendingReadsRef.current.delete(batch);
|
|
166
|
+
resolve();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { added: newAttachments.length, skipped, unreadable };
|
|
170
|
+
}, [attachments, setAttachments]);
|
|
171
|
+
|
|
172
|
+
const removeAttachment = useCallback((id: string) => {
|
|
173
|
+
const att = attachmentsRef.current.find((p) => p.id === id);
|
|
174
|
+
if (att?.preview.startsWith('blob:')) URL.revokeObjectURL(att.preview);
|
|
175
|
+
setBoth(attachmentsRef.current.filter((p) => p.id !== id));
|
|
176
|
+
}, [setBoth]);
|
|
177
|
+
|
|
178
|
+
const peek = useCallback(() => attachmentsRef.current, []);
|
|
179
|
+
|
|
180
|
+
const clear = useCallback(() => {
|
|
181
|
+
attachmentsRef.current = [];
|
|
182
|
+
setAttachments([]);
|
|
183
|
+
}, [setAttachments]);
|
|
184
|
+
|
|
185
|
+
const readyForSend = useCallback(async (): Promise<Attachment[]> => {
|
|
186
|
+
// A batch that finishes during this loop is removed from the set, so the
|
|
187
|
+
// snapshot below is the complete in-flight set at the time of the call.
|
|
188
|
+
while (pendingReadsRef.current.size > 0) {
|
|
189
|
+
await Promise.all([...pendingReadsRef.current]);
|
|
190
|
+
}
|
|
191
|
+
// Yield once so the state updates those reads queued are committed before
|
|
192
|
+
// the list is read back.
|
|
193
|
+
await delay(0);
|
|
194
|
+
return attachmentsRef.current;
|
|
195
|
+
}, []);
|
|
196
|
+
|
|
197
|
+
return { addFiles, removeAttachment, readyForSend, peek, clear };
|
|
198
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Turning a `DataTransfer` into the flat list of `File`s a drop attaches.
|
|
8
|
+
*
|
|
9
|
+
* Split out of `file-drop.ts`, which owns the event wiring: everything here is
|
|
10
|
+
* about reading the payload, and it is the part with the browser rules worth
|
|
11
|
+
* documenting.
|
|
12
|
+
*
|
|
13
|
+
* Two rules, both of which produced a bug when ignored:
|
|
14
|
+
*
|
|
15
|
+
* 1. 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
|
+
* 2. `getAsFile()` and `webkitGetAsEntry()` are read from the SAME item, and on
|
|
19
|
+
* a file-manager drag each call can consume state the other needs — so the
|
|
20
|
+
* item list is read exactly ONCE, capturing both views in a single pass.
|
|
21
|
+
*
|
|
22
|
+
* The `File`s this returns are only as good as the tick they were captured in:
|
|
23
|
+
* a dropped file's read permission is released when the drop handler returns,
|
|
24
|
+
* so `file-drop.ts` primes the reads before awaiting anything. See `file-reads.ts`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Safety valve: a dropped tree (a repo root, `node_modules`) must not queue
|
|
29
|
+
* thousands of attachments. The walk stops as soon as it is reached.
|
|
30
|
+
*/
|
|
31
|
+
export const MAX_DROPPED_FILES = 50;
|
|
32
|
+
|
|
33
|
+
/** Directory nesting limit — a symlinked cycle would otherwise never end. */
|
|
34
|
+
export const MAX_DROP_DEPTH = 10;
|
|
35
|
+
|
|
36
|
+
function readEntryFile(entry: FileSystemFileEntry): Promise<File | null> {
|
|
37
|
+
const { promise, resolve } = Promise.withResolvers<File | null>();
|
|
38
|
+
entry.file(resolve, () => resolve(null));
|
|
39
|
+
return promise;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `readEntries` yields at most 100 children per call; keep pulling until empty. */
|
|
43
|
+
function readAllEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
|
|
44
|
+
const { promise, resolve } = Promise.withResolvers<FileSystemEntry[]>();
|
|
45
|
+
const all: FileSystemEntry[] = [];
|
|
46
|
+
const readBatch = () => {
|
|
47
|
+
reader.readEntries((batch) => {
|
|
48
|
+
if (batch.length === 0) {
|
|
49
|
+
resolve(all);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
all.push(...batch);
|
|
53
|
+
readBatch();
|
|
54
|
+
}, () => resolve(all));
|
|
55
|
+
};
|
|
56
|
+
readBatch();
|
|
57
|
+
return promise;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Walk one directory tree into `out`; true when a cap stopped the walk early. */
|
|
61
|
+
async function walkDirectory(
|
|
62
|
+
directory: FileSystemDirectoryEntry,
|
|
63
|
+
out: File[],
|
|
64
|
+
depth: number,
|
|
65
|
+
): Promise<boolean> {
|
|
66
|
+
if (depth >= MAX_DROP_DEPTH) return true;
|
|
67
|
+
const children = await readAllEntries(directory.createReader());
|
|
68
|
+
for (const child of children) {
|
|
69
|
+
if (out.length >= MAX_DROPPED_FILES) return true;
|
|
70
|
+
if (child.isFile) {
|
|
71
|
+
const file = await readEntryFile(child as FileSystemFileEntry);
|
|
72
|
+
if (file) out.push(file);
|
|
73
|
+
} else if (child.isDirectory) {
|
|
74
|
+
if (await walkDirectory(child as FileSystemDirectoryEntry, out, depth + 1)) return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The two views of a drop's item list, captured in ONE pass.
|
|
82
|
+
*
|
|
83
|
+
* `getAsFile()` and `webkitGetAsEntry()` must both be read from the same item,
|
|
84
|
+
* and on a file-manager drag each call can consume state the other needs. Two
|
|
85
|
+
* separate loops over `items` therefore lose one of them; this captures both
|
|
86
|
+
* while the item is still fresh.
|
|
87
|
+
*
|
|
88
|
+
* `dataTransfer.files` is the PREFERRED source of the `File` objects, not the
|
|
89
|
+
* item list. On a file-manager drag the two are not interchangeable: the item
|
|
90
|
+
* entry can carry correct metadata (name, size) over a backing store that no
|
|
91
|
+
* longer reads, while `files` hands over one that does. Preferring the item list
|
|
92
|
+
* produced a chip with the right name and size whose contents never arrived, and
|
|
93
|
+
* the failure survived every retry because the object itself was unreadable.
|
|
94
|
+
* `files` is also what the working reference implementations read first.
|
|
95
|
+
*
|
|
96
|
+
* The item list still earns its place: it is the ONLY way to see a dropped
|
|
97
|
+
* DIRECTORY (`files` carries a size-0 stub for one), and it is the fallback for
|
|
98
|
+
* a host that populates only `items`.
|
|
99
|
+
*/
|
|
100
|
+
export interface DroppedEntries {
|
|
101
|
+
/** Real `File` objects, stubs excluded when a better entry exists. */
|
|
102
|
+
files: File[];
|
|
103
|
+
/** Directory entries, for the recursive walk. */
|
|
104
|
+
directories: FileSystemDirectoryEntry[];
|
|
105
|
+
/**
|
|
106
|
+
* The other `File` object for the same dropped file, when the payload exposed
|
|
107
|
+
* it twice — once through `dataTransfer.files`, once through the item list.
|
|
108
|
+
*
|
|
109
|
+
* Which of the two is readable is not knowable up front and differs by
|
|
110
|
+
* context: a drop that reads fine in a browser tab has been observed to fail
|
|
111
|
+
* in an installed app window, with the failing object still reporting the
|
|
112
|
+
* correct name and size. So this is a pair to try, not a preference to settle,
|
|
113
|
+
* and the read path falls back to this entry when the primary yields nothing.
|
|
114
|
+
*/
|
|
115
|
+
fallbacks: Map<File, File>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function readDroppedEntries(dt: DataTransfer): DroppedEntries {
|
|
119
|
+
const itemFiles: File[] = [];
|
|
120
|
+
const directories: FileSystemDirectoryEntry[] = [];
|
|
121
|
+
|
|
122
|
+
for (const item of Array.from(dt.items ?? [])) {
|
|
123
|
+
if (item.kind !== 'file') continue;
|
|
124
|
+
// Order matters: the entry first, then the file, in the same iteration.
|
|
125
|
+
const entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
|
|
126
|
+
if (entry?.isDirectory) {
|
|
127
|
+
directories.push(entry as FileSystemDirectoryEntry);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const file = typeof item.getAsFile === 'function' ? item.getAsFile() : null;
|
|
131
|
+
// A directory arrives as a 0-byte, type-less stub; it is not a file to read.
|
|
132
|
+
if (file && (file.size > 0 || file.type !== '')) itemFiles.push(file);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const direct = Array.from(dt.files ?? []);
|
|
136
|
+
const directWithBytes = direct.filter((file) => file.size > 0);
|
|
137
|
+
const itemsWithBytes = itemFiles.filter((file) => file.size > 0);
|
|
138
|
+
|
|
139
|
+
// `files` first, then the item list, then whatever exists. Every step keeps
|
|
140
|
+
// the counterpart as a fallback so a read can try the other object rather than
|
|
141
|
+
// depend on this choice being the right one.
|
|
142
|
+
if (directWithBytes.length > 0) {
|
|
143
|
+
return { files: directWithBytes, directories, fallbacks: pairByName(directWithBytes, itemFiles) };
|
|
144
|
+
}
|
|
145
|
+
if (itemsWithBytes.length > 0) {
|
|
146
|
+
return { files: itemsWithBytes, directories, fallbacks: pairByName(itemsWithBytes, direct) };
|
|
147
|
+
}
|
|
148
|
+
if (itemFiles.length > 0) {
|
|
149
|
+
return { files: itemFiles, directories, fallbacks: pairByName(itemFiles, direct) };
|
|
150
|
+
}
|
|
151
|
+
return { files: direct, directories, fallbacks: new Map() };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Pair each primary file with its counterpart of the same name, so the read path
|
|
156
|
+
* has something else to try. Matching is by name because that is all the two
|
|
157
|
+
* views of one dropped file reliably share.
|
|
158
|
+
*/
|
|
159
|
+
function pairByName(primary: readonly File[], others: readonly File[]): Map<File, File> {
|
|
160
|
+
const byName = new Map<string, File>();
|
|
161
|
+
for (const file of others) {
|
|
162
|
+
if (!byName.has(file.name)) byName.set(file.name, file);
|
|
163
|
+
}
|
|
164
|
+
const pairs = new Map<File, File>();
|
|
165
|
+
for (const file of primary) {
|
|
166
|
+
const other = byName.get(file.name);
|
|
167
|
+
if (other && other !== file) pairs.set(file, other);
|
|
168
|
+
}
|
|
169
|
+
return pairs;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Expand a drop into the flat file list the composer attaches: the files it
|
|
174
|
+
* already carries plus every directory's contents, walked recursively.
|
|
175
|
+
* `incomplete` reports that a cap cut the drop short, so the composer can say so
|
|
176
|
+
* instead of silently attaching a prefix.
|
|
177
|
+
*
|
|
178
|
+
* Takes the entries rather than the `DataTransfer`: see {@link readDroppedEntries}
|
|
179
|
+
* for why the item list may only be read once.
|
|
180
|
+
*/
|
|
181
|
+
export async function expandDroppedFiles(
|
|
182
|
+
entries: DroppedEntries,
|
|
183
|
+
): Promise<{ files: File[]; incomplete: boolean }> {
|
|
184
|
+
const files: File[] = [...entries.files];
|
|
185
|
+
|
|
186
|
+
// No directory in the payload: the flat list is already the answer.
|
|
187
|
+
if (entries.directories.length === 0) {
|
|
188
|
+
return { files: files.slice(0, MAX_DROPPED_FILES), incomplete: files.length > MAX_DROPPED_FILES };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let incomplete = false;
|
|
192
|
+
for (const directory of entries.directories) {
|
|
193
|
+
if (files.length >= MAX_DROPPED_FILES) {
|
|
194
|
+
incomplete = true;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
if (await walkDirectory(directory, files, 0)) {
|
|
198
|
+
incomplete = true;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { files: files.slice(0, MAX_DROPPED_FILES), incomplete };
|
|
203
|
+
}
|
|
204
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reading a drop's payload.
|
|
8
|
+
*
|
|
9
|
+
* Hosts describe a dragged file in incompatible ways, and the difference decides
|
|
10
|
+
* whether the drop is accepted at all. Two failures this pins:
|
|
11
|
+
*
|
|
12
|
+
* - Gating on the `Files` type alone rejected every drag that carries no File —
|
|
13
|
+
* a download dragged out of WhatsApp Web offers only a URL in `DownloadURL`,
|
|
14
|
+
* and a bare path arrives as `text/plain`.
|
|
15
|
+
* - Accepting `text/plain` on its own would turn every text selection into a
|
|
16
|
+
* file drag, so a path-bearing payload only counts when it actually looks like
|
|
17
|
+
* a path or URL.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, expect, test } from 'bun:test';
|
|
21
|
+
import { collectDroppedFileUris, hasDraggedFiles } from '@/client/hooks/chat/composer/drop-payload';
|
|
22
|
+
|
|
23
|
+
function transfer(types: Record<string, string>, files: File[] = [], items: DataTransferItem[] = []): DataTransfer {
|
|
24
|
+
return {
|
|
25
|
+
types: Object.keys(types),
|
|
26
|
+
files: files as unknown as FileList,
|
|
27
|
+
items: items as unknown as DataTransferItemList,
|
|
28
|
+
getData: (type: string) => types[type] ?? types[type.toLowerCase()] ?? '',
|
|
29
|
+
} as unknown as DataTransfer;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('hasDraggedFiles', () => {
|
|
33
|
+
test('a real file list is always a file drag', () => {
|
|
34
|
+
const file = new File(['x'], 'a.txt', { type: 'text/plain' });
|
|
35
|
+
expect(hasDraggedFiles(transfer({}, [file]))).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a browser download with no File is still a file drag', () => {
|
|
39
|
+
// WhatsApp Web / Gmail / Slack: the payload is a URL, nothing else.
|
|
40
|
+
expect(hasDraggedFiles(transfer({
|
|
41
|
+
'text/plain': 'https://web.whatsapp.com/download/xyz.txt',
|
|
42
|
+
DownloadURL: 'text/plain:xyz.txt:https://web.whatsapp.com/download/xyz.txt',
|
|
43
|
+
}))).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a uri-list is a file drag', () => {
|
|
47
|
+
expect(hasDraggedFiles(transfer({ 'text/uri-list': 'file:///repo/a.ts' }))).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('a bare absolute path is a file drag', () => {
|
|
51
|
+
expect(hasDraggedFiles(transfer({ 'text/plain': '/repo/a.ts' }))).toBe(true);
|
|
52
|
+
expect(hasDraggedFiles(transfer({ 'text/plain': '~/notes.md' }))).toBe(true);
|
|
53
|
+
expect(hasDraggedFiles(transfer({ 'text/plain': 'C:\\repo\\a.ts' }))).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('selected prose is NOT a file drag', () => {
|
|
57
|
+
// Otherwise every text selection would light up the drop target.
|
|
58
|
+
expect(hasDraggedFiles(transfer({ 'text/plain': 'just some selected words' }))).toBe(false);
|
|
59
|
+
expect(hasDraggedFiles(transfer({ 'text/plain': 'see /repo/a.ts for details' }))).toBe(false);
|
|
60
|
+
expect(hasDraggedFiles(transfer({}))).toBe(false);
|
|
61
|
+
expect(hasDraggedFiles(null)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('the check is case-insensitive', () => {
|
|
65
|
+
// Chrome lowercases the declared types; some hosts do not.
|
|
66
|
+
expect(hasDraggedFiles(transfer({ Files: '' }))).toBe(true);
|
|
67
|
+
expect(hasDraggedFiles(transfer({ DownloadURL: 'https://x/y.txt' }))).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('collectDroppedFileUris', () => {
|
|
72
|
+
test('reads a uri-list, skipping comments and blanks', () => {
|
|
73
|
+
const dt = transfer({ 'text/uri-list': '# a comment\nfile:///repo/a.ts\n\nfile:///repo/b.ts' });
|
|
74
|
+
expect(collectDroppedFileUris(dt)).toEqual(['file:///repo/a.ts', 'file:///repo/b.ts']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('prefers a file URL over the bare path on the same drag', () => {
|
|
78
|
+
// macOS puts both on a Finder drag; one file must not become two entries.
|
|
79
|
+
const dt = transfer({ 'text/uri-list': 'file:///repo/a.ts', 'text/plain': '/repo/a.ts' });
|
|
80
|
+
expect(collectDroppedFileUris(dt)).toEqual(['file:///repo/a.ts']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('ignores a payload that is not a path or URL', () => {
|
|
84
|
+
expect(collectDroppedFileUris(transfer({ 'text/plain': 'hello world' }))).toEqual([]);
|
|
85
|
+
expect(collectDroppedFileUris(transfer({ 'text/plain': 'line one\nline two' }))).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('keeps a remote download URL', () => {
|
|
89
|
+
expect(collectDroppedFileUris(transfer({ DownloadURL: 'https://x.example/f.txt' }))).toEqual(['https://x.example/f.txt']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('an unreadable data store yields nothing instead of throwing', () => {
|
|
93
|
+
const dt = { getData: () => { throw new Error('not allowed during dragover'); } } as unknown as DataTransfer;
|
|
94
|
+
expect(collectDroppedFileUris(dt)).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
@@ -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
|
+
}
|