Ctrl K
ComunidadGuías…

lib/app-tabs-session.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { createHomeTab, moveAppTabBefore, reconcileAppTabs, selectTabAfterClose, sortAppTabs, type AppTab, type AppTabPatch } from "./app-tabs";
import { appTabDestinationKey, normalizeAppTabLocation, sameAppTabDestination } from "./app-tab-location";
import { AppTabRequestError } from "./app-tabs-api";

/** Whether `url` reopens the remembered destination's page while carrying no
 *  selection of its own: same path, and every URL param or anchor already
 *  present in the remembered href. An anchor the memory LACKS is not a
 *  selection of its own either — in-page anchors are sub-selections a previous
 *  session may not have published — only two DIFFERENT anchors are two
 *  destinations. */
function reopensRemembered(url: string, remembered: string): boolean {
  const parts = (href: string) => {
    const [body = "", ...hash] = destinationKey(href).split("#");
    const [path = "", query = ""] = body.split("?");
    return { path, params: new URLSearchParams(query), hash: hash.join("#") };
  };
  const from = parts(url);
  const to = parts(remembered);
  if (from.path !== to.path) return false;
  if (from.hash && to.hash && from.hash !== to.hash) return false;
  for (const [key, value] of from.params) {
    if (to.params.get(key) !== value) return false;
  }
  return true;
}

/** The destination an href names. The address carries short codes while a
 *  stored row or the window's memory may still hold full ids (MIN-875): raw,
 *  the two read as different pages and a reload claimed a duplicate row. */
function destinationKey(href: string): string {
  return appTabDestinationKey(href) ?? href;
}

export interface AppTabsTransport {
  create: (ensure: boolean, id: string) => Promise<AppTab>;
  patch: (tab: AppTab, patch: AppTabPatch) => Promise<AppTab>;
  close: (tab: AppTab) => Promise<void>;
  move: (tab: AppTab, beforeId: string | null) => Promise<AppTab[]>;
}
export interface AppTabsSnapshot {
  tabs: AppTab[];
  activeId: string | null;
  busy: boolean;
  error: string | null;
  recovering: boolean;
}

/** One window's activation, departure guards, and ordered account mutations. */
export class AppTabsSession {
  private snapshot: AppTabsSnapshot = { tabs: [], activeId: null, busy: false, error: null, recovering: false };
  private listeners = new Set<() => void>();
  private guards = new Set<() => Promise<boolean>>();
  private queue: Promise<unknown> = Promise.resolve();
  private writes: Promise<unknown> = Promise.resolve();
  private creating = new Set<string>();
  private closing = new Map<string, AppTab>();
  private locations = new Map<string, string>();
  /** The newest server row seen per tab, so an unchanged location skips its write. */
  private confirmed = new Map<string, { href: string; revision: number }>();
  private order: { id: string; beforeId: string | null }[] = [];
  private orderBase: Map<string, number> | null = null;
  private closed = new Set<string>();
  private disposed = false;
  private observedHref: string | null = null;
  private activeHref: string | null = null;
  private target: string | null = null;
  private timer: ReturnType<typeof setTimeout> | undefined;
  private targetTimer: ReturnType<typeof setTimeout> | undefined;
  private localState = new Map<string, unknown>();
  private startup: { href: string; restored?: { id: string; href: string }; keepCurrentRoute: boolean } | null = null;
  getLocalState<T>(key: string): T | undefined { return this.localState.get(key) as T | undefined; }
  setLocalState(key: string, value: unknown) { if (!this.disposed) this.localState.set(key, value); }
  private forget(id: string) {
    this.closed.add(id);
    this.locations.delete(id);
    this.confirmed.delete(id);
    for (const key of this.localState.keys()) if (key.startsWith(`${id}:`)) this.localState.delete(key);
  }
  getActiveHref = () => this.activeHref;
  navigate: (href: string) => void = () => {};
  /** Owned by the mounted view host; only an exact retained destination qualifies. */
  isRetainedDestination: (tabId: string | null, href: string) => boolean = () => false;
  prefetch: (href: string) => void = () => {};
  remember: (id: string, href: string) => void = () => {};
  onDispose: () => void = () => {};

  constructor(private owner: string, private transport: AppTabsTransport) {}
  getSnapshot = () => this.snapshot;
  subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
  private emit(patch: Partial<AppTabsSnapshot>) {
    if (this.disposed) return;
    if (patch.tabs) for (const move of this.order) patch.tabs = moveAppTabBefore(patch.tabs, move.id, move.beforeId);
    if (patch.tabs) patch.tabs = sortAppTabs(patch.tabs);
    this.snapshot = { ...this.snapshot, ...patch };
    this.listeners.forEach((listener) => listener());
  }
  registerDeparture = (guard: () => Promise<boolean>) => {
    this.guards.add(guard);
    return () => { this.guards.delete(guard); };
  };
  private async saveEditors(): Promise<boolean> {
    for (const guard of this.guards) {
      if (!(await guard())) { this.emit({ error: "save_failed" }); return false; }
    }
    return !this.disposed;
  }
  private merge(tab: AppTab) {
    if (this.closed.has(tab.id) || this.disposed) return;
    if (this.closing.has(tab.id)) {
      const previous = this.closing.get(tab.id)!;
      if (tab.revision >= previous.revision) this.closing.set(tab.id, tab);
      return;
    }
    const previous = this.snapshot.tabs.find((row) => row.id === tab.id);
    if (previous && previous.revision > tab.revision) return;
    const href = this.locations.get(tab.id);
    this.emit({ tabs: sortAppTabs([...this.snapshot.tabs.filter((row) => row.id !== tab.id), href ? { ...tab, href } : tab]) });
  }
  private confirm(tab: AppTab) {
    const known = this.confirmed.get(tab.id);
    if (this.closed.has(tab.id) || (known && known.revision > tab.revision)) return;
    this.confirmed.set(tab.id, { href: normalizeAppTabLocation(tab.href) ?? "/home", revision: tab.revision });
  }
  /** Merges a row the server returned. */
  private adopt(tab: AppTab) {
    this.confirm(tab);
    this.merge(tab);
  }
  private report(error: unknown) {
    if (error instanceof AppTabRequestError && error.tab) this.adopt(error.tab);
    this.emit({ error: error instanceof AppTabRequestError ? error.code : "database" });
  }
  /** Network latency must not hold the window's navigation queue. */
  private persist(action: () => Promise<void>): Promise<void> {
    const next = this.writes.then(async () => {
      if (this.disposed) return;
      try { await action(); } catch (error) { this.report(error); }
    });
    this.writes = next;
    return next;
  }
  /** `departureOnly`: the action awaits nothing but departure guards. Without
   *  guards it settles within the current task, before any input can be
   *  dropped, so it skips the two busy renders. */
  private run(action: () => Promise<void>, departureOnly = false): Promise<void> {
    const next = this.queue.then(async () => {
      if (this.disposed) return;
      const busy = !departureOnly || this.guards.size > 0;
      if (busy) this.emit({ busy: true, error: null });
      else if (this.snapshot.error !== null) this.emit({ error: null });
      try { await action(); }
      catch (error) {
        this.report(error);
      } finally { if (busy) this.emit({ busy: false }); }
    });
    this.queue = next;
    return next;
  }
  private active() { return this.snapshot.tabs.find((tab) => tab.id === this.snapshot.activeId); }

  /** Remote edits update rows without changing this window's route. */
  receive(rows: AppTab[]) {
    if (this.orderBase) this.orderBase = new Map(rows.map((row) => [row.id, row.position]));
    const canonical = reconcileAppTabs(rows, this.owner);
    for (const tab of canonical) {
      this.confirm(tab);
      if (this.closing.has(tab.id)) this.merge(tab);
    }
    const incoming = canonical.filter((tab) => !this.closed.has(tab.id) && !this.closing.has(tab.id));
    for (const tab of this.snapshot.tabs) {
      if (this.creating.has(tab.id) && !incoming.some((row) => row.id === tab.id)) incoming.push(tab);
    }
    const active = this.active();
    const missing = active && !incoming.some((tab) => tab.id === active.id);
    const currentById = new Map(this.snapshot.tabs.map((tab) => [tab.id, tab]));
    const tabs = incoming.map((tab) => {
      const current = currentById.get(tab.id);
      const canonical = current && current.revision > tab.revision ? current : tab;
      const href = this.locations.get(tab.id);
      return href ? { ...canonical, href } : canonical;
    });
    if (missing) tabs.push(active);
    this.emit({ tabs: sortAppTabs(tabs), recovering: Boolean(missing) });
    if (missing) void this.recoverRemoteClose();
  }

  initialize(href: string, restored?: { id: string; href: string }, keepCurrentRoute = false): Promise<void> {
    if (this.snapshot.activeId) return Promise.resolve();
    if (keepCurrentRoute) restored = undefined;
    this.startup = { href, restored, keepCurrentRoute };
    return this.run(async () => {
      if (this.snapshot.activeId) return;
      if (!this.snapshot.tabs.length) this.adopt(await this.transport.create(true, crypto.randomUUID()));
      const normalized = normalizeAppTabLocation(href) ?? "/home";
      const explicit = keepCurrentRoute || href !== "/home";
      // The load destination claims the row that CONVENTIONALLY ALREADY shows
      // it: a live reload (the URL still points at the tab the previous
      // session left open) restores that tab, a typed or shared deep link
      // reuses a tab that already displays it. An address NO row displays —
      // the previous document died before it could observe it, or the memory
      // was lost — claims a FRESH row: defaulting to `tabs[0]` is what ground
      // the first pinned tab's location under the load URL — the home tab
      // turned into a replica of the current page.
      const restoredHref = restored ? normalizeAppTabLocation(restored.href) : null;
      let restoredTab = restored
        ? this.snapshot.tabs.find((tab) => tab.id === restored.id) ?? null
        : null;
      if (!restoredTab && restored && restoredHref && reopensRemembered(normalized, restoredHref)) {
        // The remembered row was closed elsewhere (another window, another
        // device): the row that still displays the remembered page stands in
        // for it, so a refresh restores THAT row instead of grinding the
        // first one under the load URL.
        restoredTab =
          this.snapshot.tabs.find((tab) => {
            const current = appTabDestinationKey(tab.href);
            return current !== null && current === destinationKey(restoredHref);
          }) ?? null;
      }
      // Several surfaces keep their selection out of the address and publish
      // the href that reconstructs them instead (/pull-requests hides `?pr=`).
      // A reload of such a page lands on a URL that
      // is a prefix of the remembered destination: when the remembered tab
      // opens the same page and the URL brings no selection of its own, the
      // load restores that tab — it is not a deep link free to claim another
      // row and grind its location under the load URL.
      const reloaded = Boolean(restoredTab && restoredHref && reopensRemembered(normalized, restoredHref));
      // The address bar is the freshest intent: when it carries everything the
      // memory has (same selection, maybe an anchor more), it wins — an anchor
      // the memory lacks must survive the reload. When the memory is the
      // richer one, it reconstructs the selection the URL keeps out of the
      // address (?pr=…).
      const loadWins = Boolean(restoredHref && reopensRemembered(restoredHref, normalized));
      const remembered = restoredTab && (!explicit || (restoredHref !== null && destinationKey(restoredHref) === destinationKey(normalized)) || reloaded || loadWins)
        ? restoredTab
        : null;
      const exact = explicit ? this.snapshot.tabs.find((tab) => destinationKey(tab.href) === destinationKey(normalized)) ?? null : null;
      // A refresh can precede sealing the active-window snapshot. Surfaces
      // publish richer selections than their bare URL, so a unique server row
      // is enough to recover identity without creating another tab. Explicit
      // selections, ambiguous rows and account-switch entry keep their rules.
      const richer = explicit && !keepCurrentRoute && !normalized.includes("?") && !normalized.includes("#")
        ? this.snapshot.tabs.filter((tab) => reopensRemembered(normalized, tab.href)) : [];
      const recovered = !remembered && !exact && richer.length === 1 ? richer[0] : null;
      const matched = exact ?? recovered;
      if (explicit && !remembered && !matched && (keepCurrentRoute || normalized !== "/home")) {
        // No row displays the load address: give it its own row. The document
        // is already there, so nothing navigates — the row joins the account
        // like a newly created tab, and every other row keeps its location.
        const position = this.snapshot.tabs.reduce((maximum, row) => Math.max(maximum, row.position), -1) + 1;
        const claimed: AppTab = { ...createHomeTab(this.owner, crypto.randomUUID(), position), href: normalized };
        this.creating.add(claimed.id);
        this.locations.set(claimed.id, normalized);
        this.merge(claimed);
        void this.persist(() => this.createOnServer(claimed.id));
        void this.flushLocation();
        this.emit({ activeId: claimed.id });
        this.activeHref = normalized;
        this.observedHref = normalized;
        this.remember(claimed.id, normalized);
        return;
      }
      const chosen = (remembered ?? matched) ?? this.snapshot.tabs[0];
      if (!chosen || this.disposed) return;
      const destination = explicit
        ? recovered
          ? recovered.href
          : reloaded
          ? loadWins ? normalized : restoredHref ?? normalized
          : normalized
        : restoredTab && restoredTab.id === chosen.id
          ? restoredHref ?? chosen.href
          : chosen.href;
      this.emit({ activeId: chosen.id });
      this.activeHref = destination;
      this.observedHref = destination;
      this.remember(chosen.id, destination);
      if (destination !== href && (!explicit || reloaded || recovered)) { this.setTarget(destination); this.navigate(destination); }
      else if (destination !== chosen.href) { this.locations.set(chosen.id, destination); await this.flushLocation(); }
    });
  }

  /** Called only for a committed route or view change, never for a refetch. */
  observe(href: string, published: string | null) {
    if (!this.snapshot.activeId || this.disposed) return;
    const activeId = this.snapshot.activeId;
    const location = normalizeAppTabLocation(href);
    const view = published ? normalizeAppTabLocation(published) : null;
    if (!location) return;
    if (this.target) {
      // A consumed selection acknowledges restoration by publishing its target.
      // If there is no publication, the URL itself is the acknowledgement.
      // Compared as destination keys: the target may hold full ids where the
      // address carries short codes.
      const targetUrl = new URL(appTabDestinationKey(this.target) ?? this.target, "https://minddy.invalid");
      const candidate = new URL(appTabDestinationKey(view ?? location) ?? location, "https://minddy.invalid");
      const matches = candidate.pathname === targetUrl.pathname &&
        [...targetUrl.searchParams].every(([key, value]) => candidate.searchParams.get(key) === value);
      const locationPath = (appTabDestinationKey(location) ?? location).split(/[?#]/)[0];
      if (!matches || locationPath !== targetUrl.pathname) return;
      const destination = this.target;
      this.target = null;
      clearTimeout(this.targetTimer);
      // The DESTINATION is the truth here, not the URL or publication on
      // hand: while a switch is settling, both still describe the OUTGOING
      // screen (the router commit and the incoming page's own publication
      // have not landed yet). Adopting them glued the outgoing family
      // board's `?family=` onto the plain tab that replaced it — and the
      // poisoned destination then kept that board on screen forever. The
      // incoming page republishes right after its mount, and that fresher,
      // tagged view refines the location in the next observation.
      // Activation already published this destination: only a change renders.
      const changed = this.observedHref !== destination || this.activeHref !== destination;
      this.observedHref = destination;
      this.activeHref = destination;
      if (changed) this.emit({});
      this.remember(activeId, destination);
      if (this.snapshot.recovering) return;
      this.locations.set(activeId, destination);
      const acknowledged = this.active();
      if (acknowledged && acknowledged.href !== destination) this.merge(acknowledged);
      clearTimeout(this.timer);
      this.timer = setTimeout(() => void this.flushLocation(), 250);
      return;
    }
    const next = view && view.split(/[?#]/)[0] === location.split(/[?#]/)[0] ? view : location;
    if (next === this.observedHref) return;
    this.observedHref = next;
    this.activeHref = next;
    this.emit({});
    this.remember(activeId, next);
    if (this.snapshot.recovering) return;
    this.locations.set(activeId, next);
    const tab = this.active();
    if (tab && tab.href !== next) this.merge(tab);
    clearTimeout(this.timer);
    this.timer = setTimeout(() => void this.flushLocation(), 250);
  }

  private async createOnServer(id: string) {
    if (!this.creating.has(id)) return;
    const tab = await this.transport.create(false, id);
    this.creating.delete(id);
    this.adopt(tab);
  }
  private flushLocation() {
    clearTimeout(this.timer);
    const ids = [...this.locations.keys()];
    return this.persist(async () => {
      for (const id of ids) {
        if (this.closing.has(id) || this.closed.has(id)) continue;
        const href = this.locations.get(id);
        if (!href) continue;
        await this.createOnServer(id);
        const tab = this.snapshot.tabs.find((row) => row.id === id);
        if (!tab) continue;
        // Activation, restoration and a route that briefly drops its published
        // selection all come back to the href the server row already holds.
        // Writing it again would only bump the revision and echo to every window.
        const known = this.confirmed.get(id);
        if (known?.revision === tab.revision && known.href === href) {
          if (this.locations.get(id) === href) this.locations.delete(id);
          continue;
        }
        const saved = await this.transport.patch(tab, { href });
        if (this.locations.get(id) === href) this.locations.delete(id);
        this.adopt(saved);
      }
    });
  }
  /** Pending location writes leave WITH the page (a refresh hides it first):
      the debounced flush would never fire, and the next load would restore a
      stale destination — or treat the fresh URL as an unmatched deep link and
      grind the first tab under it. The queue keeps the writes ordered; the
      fetch itself races the unload, which makes this best effort. */
  onPageHide = () => {
    clearTimeout(this.timer);
    void this.flushLocation();
  };
  private select(tab: AppTab) {
    if (this.disposed) return;
    const previous = this.snapshot.activeId;
    if (this.snapshot.recovering && previous && previous !== tab.id) {
      this.forget(previous);
      this.emit({ tabs: this.snapshot.tabs.filter((row) => row.id !== previous) });
    }
    this.setTarget(tab.href);
    this.activeHref = tab.href;
    this.observedHref = tab.href;
    this.emit({ activeId: tab.id, recovering: false });
    this.remember(tab.id, tab.href);
    this.navigate(tab.href);
  }
  private setTarget(href: string) {
    this.target = href;
    clearTimeout(this.targetTimer);
    this.targetTimer = setTimeout(() => {
      this.target = null;
      this.emit({ error: "destination_unavailable" });
    }, 15_000);
  }
  activate = (id: string) => this.run(async () => {
    if (id === this.snapshot.activeId) return;
    if (!(await this.saveEditors())) return;
    if (!this.snapshot.recovering) void this.flushLocation();
    const tab = this.snapshot.tabs.find((row) => row.id === id);
    if (tab) this.select(tab);
  }, true);
  /** Called only when opening a destination, never by route observation. */
  reuseDestination = (href: string): boolean => {
    if (this.disposed || !this.snapshot.activeId) return false;
    const matches = (tab: AppTab) => sameAppTabDestination(href, tab.href);
    const active = this.active();
    const tab = (active && matches(active) ? active : null) ?? this.snapshot.tabs.find(matches);
    if (!tab) return false;
    void this.activate(tab.id);
    return true;
  };
  /** Prepared same-tab navigation still saves editors and lets route observation persist the location. */
  navigateInCurrentTab = (href: string): boolean => {
    if (this.disposed || !this.snapshot.activeId || this.snapshot.recovering || !normalizeAppTabLocation(href)) return false;
    void this.run(async () => {
      if (!(await this.saveEditors())) return;
      void this.flushLocation();
      this.setTarget(href);
      this.activeHref = href;
      this.observedHref = null;
      this.emit({});
      this.navigate(href);
    }, true);
    return true;
  };
  create = (href: string = "/home") => {
    if (this.disposed) return Promise.resolve();
    const destination = normalizeAppTabLocation(href) ?? "/home";
    const position = this.snapshot.tabs.reduce((maximum, row) => Math.max(maximum, row.position), -1) + 1;
    const tab = { ...createHomeTab(this.owner, crypto.randomUUID(), position), href: destination };
    this.creating.add(tab.id);
    if (destination !== "/home") this.locations.set(tab.id, destination);
    this.merge(tab);
    void this.persist(() => this.createOnServer(tab.id));
    if (destination !== "/home") void this.flushLocation();
    return this.run(async () => {
      if (!(await this.saveEditors())) return;
      if (!this.snapshot.recovering) void this.flushLocation();
      this.select(tab);
    }, true);
  };
  goHome = () => this.run(async () => {
    if (!(await this.saveEditors())) return;
    const tab = this.active();
    if (!tab) return;
    if (this.snapshot.recovering) return;
    this.locations.set(tab.id, "/home");
    void this.flushLocation();
    this.select({ ...tab, href: "/home" });
  }, true);
  update = (id: string, patch: AppTabPatch) => {
    this.emit({ error: null });
    return this.persist(async () => {
      await this.createOnServer(id);
      const tab = this.snapshot.tabs.find((row) => row.id === id);
      if (tab) this.adopt(await this.transport.patch(tab, patch));
    });
  };
  move = (id: string, beforeId: string | null) => this.run(async () => {
    const previous = this.snapshot.tabs;
    const tab = previous.find((row) => row.id === id);
    if (!tab || id === beforeId || (beforeId && !previous.some((row) => row.id === beforeId && row.pinned === tab.pinned))) return;
    const move = { id, beforeId };
    this.orderBase ??= new Map(previous.map((row) => [row.id, row.position]));
    this.order.push(move);
    this.emit({ tabs: [...previous] });
    void this.persist(async () => {
      try {
        await this.createOnServer(id);
        if (beforeId) await this.createOnServer(beforeId);
        const current = this.snapshot.tabs.find((row) => row.id === id);
        if (!current) return;
        const rows = await this.transport.move(current, beforeId);
        this.order = this.order.filter((item) => item !== move);
        this.receive(rows);
      } catch (error) {
        this.order = this.order.filter((item) => item !== move);
        const positions = this.orderBase ?? new Map(previous.map((row) => [row.id, row.position]));
        this.emit({ tabs: this.snapshot.tabs.map((row) => positions.has(row.id) ? { ...row, position: positions.get(row.id)! } : row) });
        throw error;
      } finally {
        this.order = this.order.filter((item) => item !== move);
        if (!this.order.length) this.orderBase = null;
      }
    });
  });
  close = (id: string) => this.run(async () => {
    if (this.snapshot.tabs.length <= 1) return;
    if (id === this.snapshot.activeId) {
      if (!(await this.saveEditors())) return;
    }
    const tab = this.snapshot.tabs.find((row) => row.id === id);
    if (!tab) return;
    const nextId = selectTabAfterClose(this.snapshot.tabs, id, this.snapshot.activeId ?? id);
    this.closing.set(id, tab);
    this.emit({ tabs: this.snapshot.tabs.filter((row) => row.id !== id) });
    if (id === this.snapshot.activeId) {
      const next = this.snapshot.tabs.find((row) => row.id === nextId);
      if (next) this.select(next);
    }
    void this.persist(async () => {
      try {
        await this.createOnServer(id);
        await this.transport.close(this.closing.get(id) ?? tab);
        this.closing.delete(id);
        this.locations.delete(id);
        this.forget(id);
      } catch (error) {
        if (error instanceof AppTabRequestError && error.code === "not_found") {
          this.closing.delete(id); this.locations.delete(id); this.forget(id);
          return;
        }
        const restored = error instanceof AppTabRequestError && error.tab ? error.tab : this.closing.get(id) ?? tab;
        this.closing.delete(id);
        this.merge(restored);
        throw error;
      }
    });
  });
  recoverRemoteClose = () => this.run(async () => {
    if (!this.snapshot.recovering || !(await this.saveEditors())) return;
    const oldId = this.snapshot.activeId;
    let next = this.snapshot.tabs.find((row) => row.id !== oldId);
    if (!next) { next = await this.transport.create(true, crypto.randomUUID()); this.adopt(next); }
    if (oldId) this.forget(oldId);
    this.emit({ tabs: this.snapshot.tabs.filter((row) => row.id !== oldId) });
    this.select(next);
  });
  retry = () => {
    if (!this.snapshot.activeId && this.startup) return this.initialize(this.startup.href, this.startup.restored, this.startup.keepCurrentRoute);
    return this.snapshot.recovering ? this.recoverRemoteClose() : this.run(async () => {
      await this.persist(async () => { for (const id of this.creating) await this.createOnServer(id); });
      await this.flushLocation();
    });
  };
  dispose() { this.disposed = true; this.onDispose(); clearTimeout(this.timer); clearTimeout(this.targetTimer); this.guards.clear(); this.listeners.clear(); this.localState.clear(); }
}