OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / ui / lib / tabs.js

210 lines · javascript ref:
// Tab model + per-tab history/session helpers for the Browse view.
//
// All functions here are pure (except makeTab/makeTabId, which mint an id
// from a counter + timestamp) and framework-free so the session-restore
// logic can be unit-tested under plain node (see test/tabs.test.js).
//
// A persisted tab snapshot looks like:
//   { url, displayUrl, title, history: [url...], histIdx, pinned, active? }
// History is de-duplicated (no consecutive repeats) and capped at
// MAX_TAB_HISTORY; the closed-tab stack is capped at MAX_CLOSED_TABS.

import { driveKeyFromHyperRef } from './keys.js'

export const MAX_TAB_HISTORY = 50
export const MAX_CLOSED_TABS = 20

let _tabIdSeq = 0
export function makeTabId () { _tabIdSeq += 1; return 'tab-' + _tabIdSeq + '-' + Date.now().toString(36) }

export function cleanTabUrl (value) {
  return typeof value === 'string' ? value.trim() : ''
}

export function cleanTabTitle (value, fallback = 'New tab') {
  return typeof value === 'string' && value.trim() ? value : fallback
}

export function normalizeTabHistory (history, fallbackUrl = '') {
  const out = []
  if (Array.isArray(history)) {
    for (const entry of history) {
      const url = cleanTabUrl(entry)
      if (!url) continue
      if (out[out.length - 1] !== url) out.push(url)
    }
  }
  const fallback = cleanTabUrl(fallbackUrl)
  if (out.length === 0 && fallback) out.push(fallback)
  return out.slice(-MAX_TAB_HISTORY)
}

export function clampHistoryIndex (history, histIdx) {
  if (!Array.isArray(history) || history.length === 0) return -1
  const idx = Number.isInteger(histIdx) ? histIdx : history.length - 1
  return Math.max(0, Math.min(idx, history.length - 1))
}

export function pushTabHistory (history, histIdx, targetUrl) {
  const target = cleanTabUrl(targetUrl)
  const base = normalizeTabHistory(history)
  const idx = clampHistoryIndex(base, histIdx)
  const next = idx >= 0 ? base.slice(0, idx + 1) : []
  if (target && next[next.length - 1] !== target) next.push(target)
  const offset = Math.max(0, next.length - MAX_TAB_HISTORY)
  const trimmed = next.slice(offset)
  return { history: trimmed, histIdx: trimmed.length ? trimmed.length - 1 : -1 }
}

export function normalizeTabSnapshot (source) {
  if (!source || typeof source !== 'object') return null
  const url = cleanTabUrl(source.url)
  let history = normalizeTabHistory(source.history, url)
  let histIdx = clampHistoryIndex(history, source.histIdx)
  if (url && (histIdx < 0 || history[histIdx] !== url)) {
    const pushed = pushTabHistory(history, histIdx, url)
    history = pushed.history
    histIdx = pushed.histIdx
  }
  const activeUrl = histIdx >= 0 ? history[histIdx] : url
  const displayUrl = cleanTabUrl(source.displayUrl) || activeUrl || url
  if (!activeUrl && !displayUrl && history.length === 0) return null
  return {
    url: activeUrl || url,
    displayUrl,
    title: cleanTabTitle(source.title, activeUrl || 'New tab'),
    history,
    histIdx,
    pinned: !!source.pinned
  }
}

export function serializeTab (tab, activeId) {
  const snap = normalizeTabSnapshot(tab) || {
    url: '',
    displayUrl: '',
    title: cleanTabTitle(tab?.title),
    history: [],
    histIdx: -1,
    pinned: !!tab?.pinned
  }
  return { ...snap, active: tab?.id === activeId }
}

export function restoreSavedTab (source) {
  if (!source || typeof source !== 'object') return null
  const snap = normalizeTabSnapshot(source) || {
    url: '',
    displayUrl: '',
    title: cleanTabTitle(source.title),
    history: [],
    histIdx: -1,
    pinned: !!source.pinned
  }
  return makeTab(snap.url, snap)
}

export function normalizeDefaultTab (entry) {
  if (typeof entry === 'string') return { url: cleanTabUrl(entry), title: '' }
  if (!entry || typeof entry !== 'object') return { url: '', title: '' }
  return {
    url: cleanTabUrl(entry.url),
    title: cleanTabTitle(entry.title, '')
  }
}

export function restoreStartupTabs (savedTabs, defaultUrls = []) {
  const tabs = []
  const seenUrls = new Set()
  const add = (tab) => {
    if (!tab) return
    const key = cleanTabUrl(tab.url || tab.displayUrl)
    if (key && seenUrls.has(key)) return
    if (key) seenUrls.add(key)
    tabs.push(tab)
  }

  for (const entry of defaultUrls) {
    const { url, title } = normalizeDefaultTab(entry)
    // A named blank entry is the browser-owned start page. It has no URL to
    // fetch, but still belongs at the front of every restored session.
    if (url || title) add(makeTab(url, title ? { title } : {}))
  }

  const restoredPairs = Array.isArray(savedTabs)
    ? savedTabs
      .map((saved) => ({ saved, tab: restoreSavedTab(saved) }))
      .filter((entry) => entry.tab && (entry.tab.url || entry.tab.displayUrl))
    : []
  for (const entry of restoredPairs) add(entry.tab)

  if (tabs.length === 0 && restoredPairs.length > 0) {
    for (const entry of restoredPairs) add(entry.tab)
  }

  return {
    tabs,
    activeId: tabs[0]?.id || ''
  }
}

export function sortTabsPinnedFirst (list) {
  return [
    ...list.filter((tab) => tab.pinned),
    ...list.filter((tab) => !tab.pinned)
  ]
}

export function driveKeyFromTabAddress (value) {
  const clean = cleanTabUrl(value)
  if (!clean) return ''
  const hyperDrive = driveKeyFromHyperRef(clean)
  if (hyperDrive) return hyperDrive
  try {
    const parsed = new URL(clean)
    if (parsed.protocol !== 'http:' || (parsed.hostname !== '127.0.0.1' && parsed.hostname !== 'localhost')) return ''
    const match = parsed.pathname.match(/^\/(?:hyper|app)\/([0-9a-f]{64})(?:\/|$)/i)
    return match ? match[1].toLowerCase() : ''
  } catch {
    return ''
  }
}

export function tabDriveKey (tab) {
  if (!tab || typeof tab !== 'object') return ''
  return driveKeyFromTabAddress(tab.url) ||
    driveKeyFromTabAddress(tab.displayUrl) ||
    driveKeyFromTabAddress(tab.src)
}

export function tabListUsesDriveKey (tabs, driveKeyHex) {
  const key = typeof driveKeyHex === 'string' ? driveKeyHex.toLowerCase() : ''
  if (!/^[0-9a-f]{64}$/.test(key) || !Array.isArray(tabs)) return false
  return tabs.some((tab) => tabDriveKey(tab) === key)
}

export function makeTab (initialUrl = '', opts = {}) {
  const history = Array.isArray(opts.history) ? normalizeTabHistory(opts.history, initialUrl) : []
  const histIdx = clampHistoryIndex(history, opts.histIdx)
  const historyUrl = histIdx >= 0 ? history[histIdx] : ''
  const url = cleanTabUrl(historyUrl || initialUrl)
  const kind = opts.kind === 'clearnet' || opts.kind === 'hyper' || opts.kind === 'loopback'
    ? opts.kind
    : (url && /^https?:\/\//i.test(url) && !/^https?:\/\/(?:127\.0\.0\.1|localhost)\b/i.test(url)
      ? 'clearnet'
      : 'hyper')
  return {
    id: makeTabId(),
    url,
    displayUrl: cleanTabUrl(opts.displayUrl) || url,
    src: null,
    history,
    histIdx,
    status: '',
    title: cleanTabTitle(opts.title),
    pinned: !!opts.pinned,
    kind,
    clearnetMode: opts.clearnetMode || null
  }
}