All files / src/ui fileDrag.ts

92.98% Statements 53/57
70% Branches 21/30
100% Functions 4/4
92.98% Lines 53/57

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155                                                                                        2x   2x                 2x     2x 49x 49x     2x 1x 1x 1x 1x 1x 1x 1x 1x 1x     1x 1x           2x   2x                                   2x 4x 4x 4x 4x 4x 4x     4x 4x                         11x 11x 11x 10x 10x 10x 11x     11x 10x 11x 7x 7x 7x 7x 11x 9x 3x 9x 1x 1x   1x 1x 9x     11x  
/**
 * A file that has not been read yet, dragged.
 *
 * Every drop target in this application was written for one of two things: an
 * operating-system drag carrying `DataTransfer.files`, or a track row being
 * moved between the map's edges (`TRACK_DRAG_TYPE`). A row in the Files panel is
 * neither. It names a file in a connected folder — a key on a bucket, an entry
 * in a granted directory — and there are no bytes behind it until somebody asks
 * for them, which is the whole reason the panel can list four hundred rows
 * without reading anything.
 *
 * So it travels as a third type, and the targets learn one more branch. That is
 * the same shape `TRACK_DRAG_TYPE` established and it is here for the same
 * reason: `types` is all a browser exposes *during* a drag, and a target has to
 * decide whether to light up long before it can look at what it is being handed.
 *
 * **A row of a store is the same thing** (§24.2): a file nobody has read, named
 * by an address rather than by a folder. It travels under the same type, so
 * every target that takes a Files row takes a store row too — a map's edges and
 * middle, a cell of the deck, the Tracks card, an analysis form's file box, the
 * landing page — and none of them can tell which list the hand came from.
 *
 * ## Two payloads, and only one of them is trustworthy
 *
 * The custom type carries JSON — the folder's id and the entry's path, or, for
 * a store row, a token naming the drag ({@link writePickedDrag}) — which is
 * what {@link readFileDrag} resolves back into a real file. It is readable only
 * at `drop`, like every other `getData`.
 *
 * `text/plain` is set as well and carries the **name** — a store row's address,
 * which is the useful thing about it outside this app — so dragging a row into
 * a text box somewhere else does something sensible instead of nothing. Nothing
 * in this app ever reads it: a plain string is what a text selection dragged out
 * of any input carries too, and a target that trusted it would offer to load a
 * file because somebody dragged a word across the map.
 *
 * ## Resolving happens at the drop, never at the drag
 *
 * A drag may end anywhere or nowhere, and reading a file for one that is going
 * to be cancelled is a permission prompt or a network request for a gesture the
 * reader abandoned. {@link readFileDrag} is therefore async and is called by the
 * target that accepted the drop, once.
 */
 
import { folderById } from '../browse/connected'
import type { PickedFile } from '../browse/folder'
import { noteLocation } from '../state/locations'
 
/**
 * The type a dragged **unread file** carries.
 *
 * Lowercase, because a browser lowercases every type it stores and a comparison
 * against a capital would never match — the note `TRACK_DRAG_TYPE` carries, for
 * the same reason.
 */
export const FILE_DRAG_TYPE = 'application/x-chromosomix-file'
 
/** Whether a drag is carrying a row out of the Files panel. */
export function dragHasFileRow(event: DragEvent): boolean {
  return Array.from(event.dataTransfer?.types ?? []).includes(FILE_DRAG_TYPE)
}
 
/** Mark a drag as carrying `path` in the folder `folderId`. */
export function writeFileDrag(
  event: DragEvent,
  folderId: string,
  path: string,
  name: string,
): void {
  const transfer = event.dataTransfer
  if (transfer === null) return
  transfer.effectAllowed = 'copy'
  transfer.setData(FILE_DRAG_TYPE, JSON.stringify({ folder: folderId, path, name }))
  // For everywhere that is not this app. See the header on why nothing here
  // reads it back.
  transfer.setData('text/plain', name)
}
 
/**
 * The file a drag out of a store is carrying, held here and named in the
 * payload by its token — see {@link writePickedDrag}.
 */
let carried: { token: string; picked: PickedFile; taken?: () => void } | null = null
/** How many such drags this page has started, which is what a token is minted from. */
let carriedCount = 0
 
/**
 * Mark a drag as carrying a file this window already has the address of — a
 * row of a store (§24.2) — rather than an entry of a connected folder.
 *
 * The payload names a **token**, and the file stays here. An address in the
 * payload would make this type a way for any page to have this one open any
 * URL, by dragging something across it with the right label on; a token
 * resolves only to the drag this window started, as a folder id resolves only
 * to a folder this window is connected to. A drag from another window of the
 * app resolves to nothing, as a row of a folder that window never connected
 * does. One is held at a time, since there is one hand.
 *
 * `taken` runs when a target takes the file, before it is opened: a store row
 * registers the tracks it brings with it there (`App.noteShelf`), as its two
 * buttons do — and a drag that was abandoned registers nothing.
 */
export function writePickedDrag(event: DragEvent, picked: PickedFile, taken?: () => void): void {
  const transfer = event.dataTransfer
  if (transfer === null) return
  const token = `drag-${++carriedCount}`
  carried = { token, picked, ...(taken === undefined ? {} : { taken }) }
  transfer.effectAllowed = 'copy'
  transfer.setData(FILE_DRAG_TYPE, JSON.stringify({ held: token, name: picked.name ?? picked.uri }))
  // The address: a store's file is somewhere anybody can fetch it, so its URL
  // is what a terminal or a notebook it is dropped into can use.
  transfer.setData('text/plain', picked.uri)
}
 
/**
 * The file a drop was carrying, or `null`.
 *
 * `null` covers every way this can fail to produce a file — a payload that is
 * not ours, a folder disconnected between the drag and the drop, an entry that
 * has been deleted since it was listed, a lapsed grant, a store row's drag
 * that another has replaced — because there is one thing a drop target does
 * about all of them, which is nothing. The panel the drag came from is still on
 * screen with the same row on it, and a message about a drag the reader has
 * already finished is a message with nowhere to go.
 */
export async function readFileDrag(event: DragEvent): Promise<PickedFile | null> {
  const raw = event.dataTransfer?.getData(FILE_DRAG_TYPE) ?? ''
  if (raw === '') return null
  let parsed: unknown
  try {
    parsed = JSON.parse(raw)
  } catch {
    return null
  }
  if (typeof parsed !== 'object' || parsed === null) return null
  const payload = parsed as { folder?: unknown; path?: unknown; name?: unknown; held?: unknown }
  if (typeof payload.held === 'string') {
    if (carried === null || carried.token !== payload.held) return null
    carried.taken?.()
    return carried.picked
  }
  if (typeof payload.folder !== 'string' || typeof payload.path !== 'string') return null
  if (typeof payload.name !== 'string') return null
  const folder = folderById(payload.folder)
  if (folder === null) return null
  try {
    const picked = await folder.pick({ kind: 'file', name: payload.name, path: payload.path, size: null })
    // As a row opened by a click is (`FilesCard`): where it came from (§20.6).
    noteLocation(picked)
    return picked
  } catch {
    return null
  }
}