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 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 91x 1x 1x 1x 1x 1x 116x 116x 116x 116x 116x 116x 116x 116x 116x 1x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 1x 1x 18x 18x 18x 18x 18x 18x 18x 18x 23x 23x 23x 18x 18x 18x 18x 18x 18x 18x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 20x 20x 4x 1x 20x 20x 1x 1x 1x 1x 20x 20x 22x 19x 19x 19x 20x 22x 1x 18x 18x 18x 18x 18x 18x 18x 1x 1x 1x 18x 18x 18x 18x 18x 18x 18x 18x 18x | /**
* One level of one folder, with the way back up and a filter over it.
*
* This was `ui/bucketBrowse.ts` and was a bucket's, and the day a directory on
* the machine became a folder too it stopped being able to be: crumbs, a filter,
* a list of rows, a navigation token and a line saying what is not showing are
* the same five things over a `ListObjectsV2` answer and over a
* `FileSystemDirectoryHandle`. So the view took `browse/folder.ts`'s interface
* instead of a `BucketScope`, and the difference between a bucket and a disk is
* now entirely inside the two backends. `bucketBrowse.ts` is the bucket-shaped
* door onto this, unchanged in what it offers.
*
* ## Filtering is filtering, not searching
*
* The box above the list narrows **what was listed**, which is one level of one
* folder. It is not a search and does not pretend to be. On a bucket there is no
* such call — `ListObjectsV2` matches a key *prefix*, never a substring — so a
* search across a collection means walking every sub-prefix, one signed request
* each, against a key that may be allowed some of them and not others. On a disk
* it would be an unbounded walk of somebody's home directory. The count under
* the box says how many of how many are showing, which is the line that keeps
* "not in this folder" from reading as "not anywhere".
*
* ## A row is a button, and may carry a second one
*
* Clicking a row is the plain gesture and it means *this one*: a folder opens, a
* file is picked. The Files panel needs a second verb on the same row — load it
* into the tab that is open, without leaving the folder — so a row may be given
* trailing controls. They are `<button>`s inside the row's own `<button>`,
* which is invalid HTML, so the row is a `<div role="button">` with its own key
* handling instead. That is the one piece of DOM ceremony in this file and it
* buys the whole two-verb arrangement.
*
* ## What a row cannot open, it says rather than hides
*
* A directory holds `.bam`s and `README`s and this app opens neither. Dropping
* them from the list would make the folder view disagree with the folder, which
* is the one thing it is for; so they are drawn dimmed, unclickable, with the
* reason in the tooltip (`browse/route.ts`).
*/
import { button, el, fmt } from './dom'
import { icon } from './icons'
import { unroutableNote } from '../browse/route'
import { crumbsOf, type ConnectedFolder, type FolderEntry, type FolderListing } from '../browse/folder'
export interface FolderBrowseProps {
/** The plain click on a file row. */
onPick(entry: FolderEntry, folder: ConnectedFolder): void
/**
* Trailing controls for a file row — the Files panel's load arrow.
*
* Given the entry and its folder, and expected to return controls or nothing.
* A function rather than a flag because the row cannot know what the verbs
* are, and the host cannot know which rows exist until the listing lands.
*/
rowExtras?(entry: FolderEntry, folder: ConnectedFolder): HTMLElement[]
/** Make file rows draggable, and say what a drag carries. */
onDragStart?(event: DragEvent, entry: FolderEntry, folder: ConnectedFolder): void
/** Whether a file row the folder offers can be dragged; every one, where this is absent. */
canDrag?(entry: FolderEntry): boolean
}
/**
* Holds the listing it drew, so typing in the filter repaints from memory
* rather than re-listing: a signed request per keystroke would be a preflight
* per keystroke, and the answer would not change.
*/
export class FolderBrowseView {
readonly el: HTMLElement
private readonly crumbsEl = el('div', { class: 'folder-crumbs' })
private readonly filterRow: HTMLElement
private readonly filterInput: HTMLInputElement
private readonly entriesEl = el('div', { class: 'folder-entries' })
private readonly noteEl = el('p', { class: 'hint folder-note' })
/** Bumped on every navigation, so a slow listing cannot overwrite a fast one. */
private navigation = 0
private at: { folder: ConnectedFolder; path: string } | null = null
private listing: FolderListing | null = null
constructor(private readonly props: FolderBrowseProps) {
// Built once and never replaced, because it is where the caret is: a filter
// input rebuilt on each repaint loses focus on the first character typed
// into it, which reads as the box refusing to accept typing.
this.filterInput = el('input', {
class: 'input input-tiny folder-filter',
type: 'text',
placeholder: 'filter this folder',
})
this.filterInput.addEventListener('input', () => this.paint())
this.filterRow = el('div', { class: 'folder-filter-row hidden' }, this.filterInput)
this.el = el(
'div',
{ class: 'folder-browse hidden' },
this.crumbsEl,
this.filterRow,
this.entriesEl,
this.noteEl,
)
}
/** Whether the folder with this id is the one on screen. */
isShowing(id: string): boolean {
return this.at?.folder.id === id
}
/** The folder on screen, or `null`. */
get folder(): ConnectedFolder | null {
return this.at?.folder ?? null
}
/** Put the view back to showing nothing — a folder was removed, or a dialog closed. */
clear(): void {
this.navigation++
this.at = null
this.listing = null
this.el.classList.add('hidden')
this.crumbsEl.replaceChildren()
this.entriesEl.replaceChildren()
this.noteEl.textContent = ''
this.filterRow.classList.add('hidden')
}
/** List one level of `path` in `folder`. */
async browse(folder: ConnectedFolder, path: string): Promise<void> {
this.at = { folder, path }
this.listing = null
// The filter belongs to the folder it was typed in. Carried down into a
// sub-folder it would hide most of what the reader had just opened, with
// the reason for it two lines above the list and easy to miss.
this.filterInput.value = ''
const token = ++this.navigation
this.el.classList.remove('hidden')
this.crumbsEl.replaceChildren(...this.crumbs(folder, path))
this.filterRow.classList.add('hidden')
this.entriesEl.replaceChildren()
this.noteEl.textContent = 'Listing…'
this.noteEl.classList.remove('folder-error')
let listing: FolderListing
try {
listing = await folder.list(path)
} catch (err) {
// A stale failure must not replace a newer view either: clicking two
// folders quickly and having the first one's 403 land on the second is an
// error message about a folder that is not on screen.
if (token !== this.navigation) return
this.noteEl.textContent = err instanceof Error ? err.message : String(err)
this.noteEl.classList.add('folder-error')
return
}
if (token !== this.navigation) return
this.listing = listing
this.paint()
}
/** Re-list what is on screen — a folder reconnected, or a directory that changed. */
async refresh(): Promise<void> {
if (this.at === null) return
await this.browse(this.at.folder, this.at.path)
}
private paint(): void {
const at = this.at
const listing = this.listing
if (at === null || listing === null) return
const { folder, path } = at
const needle = this.filterInput.value.trim().toLowerCase()
const shown = (name: string): boolean => needle === '' || name.toLowerCase().includes(needle)
const rows: HTMLElement[] = []
for (const entry of listing.entries) {
if (!shown(entry.name)) continue
rows.push(entry.kind === 'folder' ? this.folderRow(entry, folder) : this.fileRow(entry, folder))
}
const total = listing.entries.length
// The filter appears only when there is enough to be worth narrowing. Under
// that, it is a control that costs a line and saves nothing — and one that
// makes a folder holding three files look like a search screen.
this.filterRow.classList.toggle('hidden', total < FILTER_FROM)
this.entriesEl.replaceChildren(...rows)
this.entriesEl.classList.toggle('hidden', rows.length === 0)
this.noteEl.classList.remove('folder-error')
this.noteEl.textContent = note(total, rows.length, needle !== '', listing.truncated, path)
}
private folderRow(entry: FolderEntry, folder: ConnectedFolder): HTMLElement {
const row = el(
'button',
{ class: 'folder-entry', type: 'button' },
icon('chevron-right', { size: 14 }),
el('span', { class: 'folder-entry-name', text: `${entry.name}/` }),
)
row.addEventListener('click', () => void this.browse(folder, entry.path))
return row
}
/**
* A file row: the name, its size, and whatever verbs the host added.
*
* A `div` with `role="button"` rather than a `<button>`, because the load
* arrow is a button and a button inside a button is invalid HTML that browsers
* resolve by dropping one of them. The keyboard handling a real button would
* have brought is written out below; there is no third option that keeps both
* verbs reachable.
*/
private fileRow(entry: FolderEntry, folder: ConnectedFolder): HTMLElement {
const why = unroutableNote(entry.name)
const extras = why === null ? (this.props.rowExtras?.(entry, folder) ?? []) : []
const row = el(
'div',
{
class: why === null ? 'folder-entry folder-entry-file' : 'folder-entry folder-entry-inert',
title: why ?? '',
attrs: why === null ? { role: 'button', tabindex: '0' } : {},
},
el('span', { class: 'folder-entry-name', text: entry.name }),
entry.size === null ? null : el('span', { class: 'folder-entry-size', text: fmt.bytes(entry.size) }),
...extras,
)
if (why !== null) return row
const pick = (): void => this.props.onPick(entry, folder)
row.addEventListener('click', (event) => {
// A click that landed on one of the trailing verbs is that verb's, not the
// row's. Without this, pressing the load arrow would also pick the file.
//
// `Element`, not `HTMLElement`, and that distinction is the whole of the
// guard: the arrow's visible pixels are an inline `<svg>`, which is an
// `SVGElement` — so a test against `HTMLElement` passes for a click on the
// button's padding and fails for a click on the glyph in the middle of it,
// which is where a hand actually lands. `App.ts` records the same mistake
// costing a deck edge its drop.
if (event.target instanceof Element && event.target.closest('button') !== null) return
pick()
})
row.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return
if (event.target !== row) return
event.preventDefault()
pick()
})
const drag = this.props.onDragStart
if (drag !== undefined && (this.props.canDrag?.(entry) ?? true)) {
row.draggable = true
row.addEventListener('dragstart', (event) => drag(event, entry, folder))
}
return row
}
/**
* The path back up, as clickable segments.
*
* Rooted at the folder's **own** root rather than at the top of the bucket or
* the disk: a bucket key may well not be allowed to list above the prefix it
* was given, and a directory grant reaches down and never up, so a crumb
* leading there would be a click that fails.
*/
private crumbs(folder: ConnectedFolder, path: string): HTMLElement[] {
const nodes: HTMLElement[] = [
button(folder.label, () => void this.browse(folder, folder.root), { class: 'btn-link' }),
]
let walked = folder.root
for (const part of crumbsOf(folder.root, path)) {
walked = `${walked}${part}/`
const target = walked
nodes.push(el('span', { class: 'folder-crumb-sep', text: '/' }))
// A backend whose paths are ids rather than names says what to print.
const label = folder.labelOf?.(target) ?? part
nodes.push(button(label, () => void this.browse(folder, target), { class: 'btn-link' }))
}
return nodes
}
}
/** Below this many entries a folder is read rather than searched. */
const FILTER_FROM = 8
/**
* The line under the list: what is showing, and what is not.
*
* Three facts compete for it and only one can be wrong in a way that costs
* anybody an afternoon, so truncation wins whenever it applies: "my file is not
* here" and "my file is past the cap" look identical otherwise, and the second
* is not a thing a reader can guess.
*/
export function note(
total: number,
showing: number,
filtered: boolean,
truncated: boolean,
path: string,
): string {
if (truncated) {
return (
`Only the first 1,000 entries of ${path === '' ? 'this folder' : path} are listed and there ` +
'are more. Open a sub-folder, or name the file directly.'
)
}
if (total === 0) return 'Nothing in this folder.'
if (!filtered) return ''
if (showing === 0) {
return `Nothing here matches. ${total} ${total === 1 ? 'entry' : 'entries'} in this folder.`
}
return `${showing} of ${total} in this folder. The filter reads this level, not the whole tree.`
}
|