All files / src/app sessions.ts

54.34% Statements 25/46
27.27% Branches 3/11
100% Functions 3/3
54.34% Lines 25/46

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                                          1x                           1x   1x 32x 32x 32x     32x                 1x 57x 57x 57x 57x         57x   57x 57x 57x 57x                             57x 57x 57x                 1x 76x 76x       76x 76x  
/**
 * What the tab strip remembers between visits.
 *
 * `sessionStorage`, like everything else the app remembers that is not a
 * preference (`state/UiMemory.ts`, spec §8.6): a set of open files is a train of
 * thought and it belongs to the window it was opened in, not to the browser
 * profile. Closing the tab is meant to end it.
 *
 * What is stored is a **serialised view state per session, and which one was
 * active** — nothing else. That is deliberately the same string the address bar
 * carries, so a remembered tab and a shared link are the same kind of thing and
 * there is one parser for both. It also bounds what a restore can go wrong
 * about: a stored entry is either a legal view state or it is dropped.
 *
 * A restored tab is *named*, not opened. A remote file re-opens itself when the
 * tab is clicked; a local one cannot — no browser re-reads a dropped file
 * without a fresh gesture — and comes back as the picker with the link's own
 * explanation, which is the behaviour a pasted link to a local file has always
 * had.
 */
 
const KEY = 'chromosomix:sessions'
 
export interface SessionSnapshot {
  id: string
  /** The session's view state, serialised exactly as the URL hash carries it. */
  hash: string
}
 
export interface StoredSessions {
  sessions: SessionSnapshot[]
  /** The id that was on screen, or `null` when nothing was. */
  active: string | null
}
 
const EMPTY: StoredSessions = { sessions: [], active: null }
 
export function writeSessions(value: StoredSessions): void {
  try {
    window.sessionStorage.setItem(KEY, JSON.stringify(value))
  } catch {
    /* a browser that refuses storage still runs; it just starts fresh */
  }
}
 
/**
 * What a previous visit left, validated.
 *
 * Untrusted input, exactly like the URL hash: stored state outlives the build
 * that wrote it, so every field is checked and anything that fails is dropped
 * rather than repaired into a tab pointing at nothing.
 */
export function readSessions(): StoredSessions {
  let raw: unknown
  try {
    const text = window.sessionStorage.getItem(KEY)
    if (text === null) return EMPTY
    raw = JSON.parse(text)
  } catch {
    return EMPTY
  }
  if (typeof raw !== 'object' || raw === null) return EMPTY
  const record = raw as Record<string, unknown>
  const list = Array.isArray(record['sessions']) ? record['sessions'] : []
  const sessions: SessionSnapshot[] = []
  const seen = new Set<string>()
  for (const entry of list) {
    if (typeof entry !== 'object' || entry === null) continue
    const row = entry as Record<string, unknown>
    const id = row['id']
    const hash = row['hash']
    if (typeof id !== 'string' || typeof hash !== 'string' || id === '' || hash === '') continue
    // Two tabs with one id would share every scope keyed on it — their tracks,
    // their filters, their marks. A duplicate is corruption, not a session.
    if (seen.has(id)) continue
    seen.add(id)
    sessions.push({ id, hash })
  }
  const active = record['active']
  return {
    sessions,
    active: typeof active === 'string' && seen.has(active) ? active : null,
  }
}
 
/**
 * The number a fresh session id must start above, so a restored `s3` and a new
 * one cannot collide.
 *
 * Ids are minted per page load and the counter starts at 1, so restoring three
 * tabs and opening a fourth would otherwise hand out `s1` twice.
 */
export function highestId(sessions: readonly SessionSnapshot[]): number {
  let high = 0
  for (const session of sessions) {
    const match = /^s(\d+)$/.exec(session.id)
    if (match !== null) high = Math.max(high, Number(match[1]))
  }
  return high
}