All files / src/ui RestoreCard.ts

41.73% Statements 48/115
100% Branches 32/32
66.66% Functions 4/6
41.73% Lines 48/115

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                                                                    2x 2x 2x 2x                                           2x 9x 9x 7x     9x 9x 5x 9x 4x 9x 2x 9x 9x     2x 8x 8x 8x 8x 2x 2x     2x 3x 3x 3x 3x 3x 3x                   2x                                                                             2x 12x 12x 12x 12x 12x 12x 12x 12x 12x     12x 12x 12x 12x 12x                                   2x                                                                                                  
/**
 * The offer to pick a workspace back up, and the account of what is being kept.
 *
 * Two surfaces, one subject, so they live together: the banner that says *you
 * had six tabs open, would you like them back*, and the dialog that answers
 * *what exactly is this app remembering about me* — including the button that
 * makes the answer "nothing".
 *
 * ## Why this is an offer and not a restore
 *
 * `app/sessions.ts` decided, deliberately, that a set of open files belongs to
 * the window it was opened in and that closing the tab ends it. Nothing here
 * overturns that; the durable record (`state/workspace.ts`) is consulted only
 * when session storage is *absent*, which is exactly "the browser was closed".
 * Even then it is offered rather than applied, for two reasons that are not the
 * same reason. Reopening six files a week later reverses a decision the reader
 * made by closing them. And on a shared machine — the laptop §11 is written for,
 * holding data nobody has published — it would put somebody else's unpublished
 * figure on screen for whoever sat down next.
 *
 * So the banner asks, and **closing it forgets the record** rather than merely
 * hiding the banner — the × is a close cross in the corner, not a second,
 * unlabelled way to hide the note without answering it. An offer that comes
 * back tomorrow having been declined today is not an offer.
 *
 * ## Why the banner is the window's rather than the picker's
 *
 * It is a statement about *N tabs*, and a `Session` deliberately knows nothing
 * about the window it is in. Putting it on the picker would also make it
 * invisible in the case it is most useful: a link opened into a fresh browser
 * shows that file rather than the picker, and the reader's six other tabs are
 * exactly as worth offering then.
 */
 
import { Modal } from './Modal'
import { button, el } from './dom'
import { cardTitle, iconButton } from './icons'
import { fileLabel } from './labels'
import type { WorkspaceSummary } from '../state/workspace'
import type { StoredItem } from '../state/stored'
 
export interface RestoreBannerProps {
  summary: WorkspaceSummary
  /** ISO stamp the record carries, or `''` when it carries none. */
  savedAt: string
  onRestore(): void
  /** The × was clicked: the record is forgotten, not merely dismissed. */
  onDismiss(): void
}
 
/**
 * How long ago, in the coarsest unit that is still true.
 *
 * Coarse on purpose: "3 days ago" is what decides whether a reader wants these
 * tabs back, and a timestamp to the minute invites them to work out the answer
 * instead of reading it. An unparseable or absent stamp yields `null` and the
 * sentence simply omits the clause, because a wrong time here is worse than no
 * time — it is the only evidence the reader has about which session this was.
 */
export function agoLabel(savedAt: string, now: Date): string | null {
  const then = Date.parse(savedAt)
  if (Number.isNaN(then)) return null
  const seconds = Math.round((now.getTime() - then) / 1000)
  // A record from the future is a clock that moved, not a session from next
  // week. Saying nothing is the honest answer.
  if (seconds < 0) return null
  if (seconds < 90) return 'just now'
  const minutes = Math.round(seconds / 60)
  if (minutes < 60) return `${minutes} minutes ago`
  const hours = Math.round(minutes / 60)
  if (hours < 24) return hours === 1 ? 'an hour ago' : `${hours} hours ago`
  const days = Math.round(hours / 24)
  return days === 1 ? 'yesterday' : `${days} days ago`
}
 
/** `yeast.mcool, ATAC.bw and 4 more` — what the reader recognises it by. */
export function filesLabel(files: readonly string[]): string {
  const names = files.map((uri) => fileLabel(uri))
  if (names.length === 0) return 'nothing'
  if (names.length === 1) return names[0]
  if (names.length === 2) return `${names[0]} and ${names[1]}`
  return `${names[0]}, ${names[1]} and ${names.length - 2} more`
}
 
/** One line of plain English about a stored workspace. */
export function bannerLine(summary: WorkspaceSummary, ago: string | null): string {
  const tabs = summary.tabs === 1 ? '1 tab' : `${summary.tabs} tabs`
  const tracks =
    summary.tracks === 0 ? '' : summary.tracks === 1 ? ', with 1 track' : `, with ${summary.tracks} tracks`
  const when = ago === null ? '' : ` ${ago}`
  return `You had ${tabs} open${when}: ${filesLabel(summary.files)}${tracks}.`
}
 
/**
 * The banner itself.
 *
 * `role="status"` rather than an alert: it is worth announcing to a screen
 * reader on arrival and it is not an interruption — nothing is wrong, and
 * ignoring it entirely is a legitimate answer that leaves the record alone for
 * next time.
 */
export function restoreBanner(props: RestoreBannerProps): HTMLElement {
  const ago = agoLabel(props.savedAt, new Date())
  return el(
    'div',
    { class: 'restore-banner', attrs: { role: 'status' } },
    el('div', { class: 'restore-text' }, el('span', { text: bannerLine(props.summary, ago) })),
    el(
      'div',
      { class: 'restore-actions' },
      button('restore', () => props.onRestore(), {
        class: 'btn btn-primary',
        title: 'Reopen those tabs, with their tracks, marks and filters.',
      }),
      // Said out loud in the title, because a close cross that also deletes is
      // not what a reader expects one to do — an icon alone cannot carry that,
      // so the accessible name does.
      iconButton(
        'x',
        'Close. This deletes the remembered workspace rather than hiding this note.',
        () => props.onDismiss(),
        { class: 'btn-close' },
      ),
    ),
  )
}
 
/**
 * A passing remark the reader can dismiss — not a modal, not an error view.
 *
 * The app had two ways to say something and no third: a `Modal`, which takes the
 * keyboard and demands an answer, and the error view, which replaces what is on
 * screen. Both are far too loud for *"this opened, and here is something you may
 * want to know about it"* — and a remark that has to be dismissed before the
 * reader can look at their map would be worse than not making it.
 *
 * So: the same row the restore offer uses, in the same place, with an × on it.
 * `role="status"` rather than `alert` for the same reason it is dismissible —
 * nothing is wrong, and ignoring it entirely is a legitimate answer.
 */
export function noticeBanner(message: string, onDismiss: () => void): HTMLElement {
  const row = el(
    'div',
    { class: 'restore-banner notice-banner', attrs: { role: 'status' } },
    el('div', { class: 'restore-text' }, el('span', { text: message })),
  )
  row.appendChild(
    el(
      'div',
      { class: 'restore-actions' },
      // Bordered like every other action: a quiet word is for a link inside
      // running prose, not for the one control on a banner (§26.6).
      button('dismiss', onDismiss, { class: 'btn', title: 'Hide this note.' }),
    ),
  )
  return row
}
 
export interface StoredDataProps {
  items: StoredItem[]
  onForgetAll(): void
  onClose?(): void
}
 
/**
 * What this app is keeping, and the button that ends it.
 *
 * The list is built by the caller rather than gathered here, because the stores
 * are spread across three layers and this module has no business knowing about
 * any of them. What it does own is the promise the dialog makes: if a thing is
 * held, it is named here, and **forget everything** means everything — not the
 * new IndexedDB half while thirteen `localStorage` keys, one of them holding an
 * S3 secret in cleartext, quietly stay put.
 */
export function openStoredData(props: StoredDataProps): Modal {
  return new Modal({
    title: 'What this browser is remembering',
    ...(props.onClose === undefined ? {} : { onClose: props.onClose }),
    body: (close) =>
      el(
        'div',
        { class: 'stored-body' },
        el('p', {
          class: 'hint',
          text:
            'All of it is on this computer and none of it has been anywhere else — and file ' +
            'contents are never among it: what is kept is which files were open and how you had ' +
            'them set up, never a copy of the data.',
        }),
        props.items.length === 0
          ? el('p', { text: 'Nothing is being kept.' })
          : el(
              'div',
              { class: 'card' },
              cardTitle('Stored', 'save'),
              el(
                'div',
                { class: 'kv' },
                ...props.items.flatMap((item) => [
                  el('div', { class: 'k', text: item.label }),
                  el('div', { class: 'v', text: item.detail, title: item.detail }),
                ]),
              ),
            ),
        el(
          'div',
          { class: 'stored-actions' },
          button(
            'forget everything',
            () => {
              props.onForgetAll()
              close()
            },
            {
              class: 'btn btn-danger',
              title:
                'Clears every remembered workspace, preference and saved bucket key from this browser.',
            },
          ),
        ),
      ),
  })
}