import { scopedThreadKey } from "@t3tools/client-runtime/environment";import type { ScopedThreadRef } from "@t3tools/contracts";import { create } from "zustand";import { createJSONStorage, persist } from "zustand/middleware";import { useShallow } from "zustand/react/shallow"; import { normalizePreviewUrl } from "@t3tools/shared/preview";import { readPreparedConnection } from "~/state/session"; import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver";import { resolveStorage } from "./lib/storage"; export type BrowserHistoryEntry = { url: string; lastVisitedAt: number; title?: string }; export const BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT = 50;export const BROWSER_HISTORY_MAX_PROJECTS = 20;const BROWSER_HISTORY_MAX_URL_LENGTH = 2048;export const BROWSER_HISTORY_MAX_TITLE_LENGTH = 512;const MAX_VALID_DATE_MS = 8_640_000_000_000_000; export function isValidHistoryTimestamp(value: unknown): value is number { return ( typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= MAX_VALID_DATE_MS );} export function normalizeHistoryUrl(raw: string): string | null { let parsed: URL; try { parsed = new URL(normalizePreviewUrl(raw)); } catch { return null; } parsed.username = parsed.password = ""; return parsed.href.length > BROWSER_HISTORY_MAX_URL_LENGTH ? null : parsed.href;} function titleLookupKey(normalized: string, environmentHostname?: string | null): string { const parsed = new URL(visitLookupKey(normalized, environmentHostname)); if (parsed.pathname !== "/" && parsed.pathname.endsWith("/")) parsed.pathname = parsed.pathname.slice(0, -1); return parsed.href;} function visitLookupKey(normalized: string, environmentHostname?: string | null): string { const parsed = new URL(normalized); const host = normalizeHostname(parsed.hostname); const environmentHost = environmentHostname && normalizeHostname(environmentHostname); if (isLocalLoopbackHost(host) || host === "0.0.0.0" || host === environmentHost) parsed.hostname = "local"; return parsed.href;} function isStableLocalUrl(normalized: string): boolean { const host = normalizeHostname(new URL(normalized).hostname); return isLocalLoopbackHost(host) || host === "0.0.0.0";} export function upsertHistoryEntry( entries: ReadonlyArray<BrowserHistoryEntry>, url: string, at: number, options?: { insertOrdered?: boolean; environmentHostname?: string | null },): BrowserHistoryEntry[] { const key = visitLookupKey(url, options?.environmentHostname); const existing = entries.find( (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) === key, ); const rest = entries.filter( (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) !== key, ); const visitedAt = options?.insertOrdered && existing && existing.lastVisitedAt > at ? existing.lastVisitedAt : at; const storedUrl = existing && (isStableLocalUrl(existing.url) || !isStableLocalUrl(url)) ? existing.url : url; const entry: BrowserHistoryEntry = existing ? { ...existing, url: storedUrl, lastVisitedAt: visitedAt } : { url, lastVisitedAt: visitedAt }; if (!options?.insertOrdered) return [entry, ...rest].slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); const index = rest.findIndex((candidate) => candidate.lastVisitedAt < entry.lastVisitedAt); const next = index === -1 ? [...rest, entry] : rest.toSpliced(index, 0, entry); return next.slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT);} export function evictExcessProjects( byProjectKey: Record<string, BrowserHistoryEntry[]>,): Record<string, BrowserHistoryEntry[]> { const keys = Object.keys(byProjectKey); if (keys.length <= BROWSER_HISTORY_MAX_PROJECTS) return byProjectKey; const kept = keys .toSorted( (a, b) => (byProjectKey[b]?.[0]?.lastVisitedAt ?? 0) - (byProjectKey[a]?.[0]?.lastVisitedAt ?? 0), ) .slice(0, BROWSER_HISTORY_MAX_PROJECTS); return Object.fromEntries(kept.map((key) => [key, byProjectKey[key] ?? []]));} export function migratePersistedBrowserHistoryState(persistedState: unknown): { byProjectKey: Record<string, BrowserHistoryEntry[]>;} { if (!persistedState || typeof persistedState !== "object") return { byProjectKey: {} }; const raw = (persistedState as { byProjectKey?: unknown }).byProjectKey; if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byProjectKey: {} }; const byProjectKey: Record<string, BrowserHistoryEntry[]> = {}; for (const [projectKey, value] of Object.entries(raw as Record<string, unknown>)) { if (!Array.isArray(value)) continue; const seenUrls = new Set<string>(); const entries = value .flatMap<BrowserHistoryEntry>((candidate) => { if (!candidate || typeof candidate !== "object") return []; const { url, lastVisitedAt, title } = candidate as Record<string, unknown>; if (typeof url !== "string") return []; const normalizedUrl = normalizeHistoryUrl(url); if (!normalizedUrl) return []; if (!isValidHistoryTimestamp(lastVisitedAt)) return []; return [ { url: normalizedUrl, lastVisitedAt, ...(typeof title === "string" && title.length > 0 ? { title: title.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH) } : {}), }, ]; }) .toSorted((a, b) => b.lastVisitedAt - a.lastVisitedAt) .filter((entry) => { const key = visitLookupKey(entry.url); if (seenUrls.has(key)) return false; seenUrls.add(key); return true; }) .slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); if (entries.length > 0) byProjectKey[projectKey] = entries; } return { byProjectKey: evictExcessProjects(byProjectKey) };} const BROWSER_HISTORY_STORAGE_KEY = "t3code:browser-history:v1"; const PENDING_MAX_PER_THREAD = 10;const PENDING_MAX_THREADS = 20; type PendingVisit = { url: string; at: number; environmentHostname: string | null };type PendingTitle = { url: string; title: string; environmentHostname: string | null | undefined }; interface BrowserHistoryStoreState { byProjectKey: Record<string, BrowserHistoryEntry[]>; projectKeyByThreadKey: Record<string, string>; pendingVisitsByThreadKey: Record<string, PendingVisit[]>; pendingTitlesByThreadKey: Record<string, PendingTitle[]>; recordVisit: ( projectKey: string, url: string, at: number, options?: { insertOrdered?: boolean; environmentHostname?: string | null }, ) => void; setTitleForUrl: ( projectKey: string, url: string, title: string, environmentHostname?: string | null, ) => void; removeUrl: (projectKey: string, url: string) => void; registerThreadProject: (ref: ScopedThreadRef, projectKey: string) => void;} function addPendingByThread<T>( pendingByThreadKey: Record<string, T[]>, threadKey: string, item: T,): Record<string, T[]> { const existing = pendingByThreadKey[threadKey] ?? []; const next = { ...pendingByThreadKey }; next[threadKey] = [...existing, item].slice(-PENDING_MAX_PER_THREAD); const keys = Object.keys(next); if (keys.length > PENDING_MAX_THREADS) { const oldestKey = keys[0]; if (oldestKey !== undefined && oldestKey !== threadKey) delete next[oldestKey]; } return next;} export const useBrowserHistoryStore = create<BrowserHistoryStoreState>()( persist( (set, get) => ({ byProjectKey: {}, projectKeyByThreadKey: {}, pendingVisitsByThreadKey: {}, pendingTitlesByThreadKey: {}, recordVisit: (projectKey, url, at, options) => { const normalized = normalizeHistoryUrl(url); if (!normalized) return; set((state) => { return { byProjectKey: evictExcessProjects({ ...state.byProjectKey, [projectKey]: upsertHistoryEntry( state.byProjectKey[projectKey] ?? [], normalized, at, options, ), }), }; }); }, setTitleForUrl: (projectKey, url, title, environmentHostname) => { const normalized = normalizeHistoryUrl(url); const state = get(); const entries = state.byProjectKey[projectKey]; const trimmed = title.trim().slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH); if (!normalized || !entries || trimmed.length === 0) return; const key = titleLookupKey(normalized, environmentHostname); const index = entries.findIndex( (candidate) => titleLookupKey(candidate.url, environmentHostname) === key, ); if (index === -1 || entries[index]?.title === trimmed) return; set({ byProjectKey: { ...state.byProjectKey, [projectKey]: entries.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, title: trimmed } : candidate, ), }, }); }, removeUrl: (projectKey, url) => { const normalized = normalizeHistoryUrl(url); const state = get(); const entries = state.byProjectKey[projectKey]; if (!normalized || !entries) return; const next = entries.filter((candidate) => candidate.url !== normalized); if (next.length === entries.length) return; if (next.length === 0) { const { [projectKey]: _removed, ...rest } = state.byProjectKey; set({ byProjectKey: rest }); return; } set({ byProjectKey: { ...state.byProjectKey, [projectKey]: next } }); }, registerThreadProject: (ref, projectKey) => { const threadKey = scopedThreadKey(ref); const state = get(); const pendingVisits = state.pendingVisitsByThreadKey[threadKey]; const pendingTitles = state.pendingTitlesByThreadKey[threadKey]; if ( state.projectKeyByThreadKey[threadKey] === projectKey && !pendingVisits && !pendingTitles ) { return; } const nextPendingVisits = { ...state.pendingVisitsByThreadKey }; const nextPendingTitles = { ...state.pendingTitlesByThreadKey }; delete nextPendingVisits[threadKey]; delete nextPendingTitles[threadKey]; set({ projectKeyByThreadKey: { ...state.projectKeyByThreadKey, [threadKey]: projectKey }, pendingVisitsByThreadKey: nextPendingVisits, pendingTitlesByThreadKey: nextPendingTitles, }); for (const visit of pendingVisits ?? []) get().recordVisit(projectKey, visit.url, visit.at, { insertOrdered: true, environmentHostname: visit.environmentHostname, }); for (const pendingTitle of pendingTitles ?? []) get().setTitleForUrl( projectKey, pendingTitle.url, pendingTitle.title, pendingTitle.environmentHostname, ); }, }), { name: BROWSER_HISTORY_STORAGE_KEY, version: 1, storage: createJSONStorage(() => resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), ), partialize: (state) => ({ byProjectKey: state.byProjectKey, projectKeyByThreadKey: state.projectKeyByThreadKey, }), migrate: migratePersistedBrowserHistoryState, merge: mergeBrowserHistoryState, }, ),); export function mergeBrowserHistoryState( persistedState: unknown, currentState: BrowserHistoryStoreState,): BrowserHistoryStoreState { const migrated = migratePersistedBrowserHistoryState(persistedState); return { ...currentState, ...migrated, projectKeyByThreadKey: migratePersistedThreadProjectKeys(persistedState, migrated.byProjectKey), pendingVisitsByThreadKey: {}, pendingTitlesByThreadKey: {}, };} function migratePersistedThreadProjectKeys( persistedState: unknown, byProjectKey: Record<string, BrowserHistoryEntry[]>,): Record<string, string> { if (!persistedState || typeof persistedState !== "object") return {}; const raw = (persistedState as { projectKeyByThreadKey?: unknown }).projectKeyByThreadKey; if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; return Object.fromEntries( Object.entries(raw as Record<string, unknown>) .filter( (entry): entry is [string, string] => typeof entry[1] === "string" && entry[1] in byProjectKey, ) .slice(-100), );} export function recordVisitForThread(ref: ScopedThreadRef, url: string, at?: number): void { const threadKey = scopedThreadKey(ref); const state = useBrowserHistoryStore.getState(); const projectKey = state.projectKeyByThreadKey[threadKey]; const visitAt = at ?? Date.now(); const connection = readPreparedConnection(ref.environmentId); const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null; if (!projectKey) { useBrowserHistoryStore.setState({ pendingVisitsByThreadKey: addPendingByThread(state.pendingVisitsByThreadKey, threadKey, { url, at: visitAt, environmentHostname, }), }); return; } state.recordVisit(projectKey, url, visitAt, { environmentHostname });} export function setTitleForThreadUrl( ref: ScopedThreadRef, url: string, title: string, environmentHostname?: string | null,): void { const threadKey = scopedThreadKey(ref); const state = useBrowserHistoryStore.getState(); const projectKey = state.projectKeyByThreadKey[threadKey]; if (!projectKey) { useBrowserHistoryStore.setState({ pendingTitlesByThreadKey: addPendingByThread(state.pendingTitlesByThreadKey, threadKey, { url, title, environmentHostname, }), }); return; } state.setTitleForUrl(projectKey, url, title, environmentHostname);} export function removeUrlForThread(ref: ScopedThreadRef, url: string): void { const state = useBrowserHistoryStore.getState(); const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; if (!projectKey) return; state.removeUrl(projectKey, url);} const EMPTY_HISTORY: ReadonlyArray<BrowserHistoryEntry> = []; export function useThreadRecentHistory( ref: ScopedThreadRef, limit: number,): ReadonlyArray<BrowserHistoryEntry> { return useBrowserHistoryStore( useShallow((state) => { const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; const entries = projectKey ? state.byProjectKey[projectKey] : undefined; return entries && entries.length > 0 ? entries.slice(0, limit) : EMPTY_HISTORY; }), );} export function resetBrowserHistoryForTests(): void { useBrowserHistoryStore.setState({ byProjectKey: {}, projectKeyByThreadKey: {}, pendingVisitsByThreadKey: {}, pendingTitlesByThreadKey: {}, }); useBrowserHistoryStore.persist.clearStorage();}
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
import type { ScopedThreadRef } from "@t3tools/contracts";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { useShallow } from "zustand/react/shallow";
import { normalizePreviewUrl } from "@t3tools/shared/preview";
import { readPreparedConnection } from "~/state/session";
import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver";
import { resolveStorage } from "./lib/storage";
export type BrowserHistoryEntry = { url: string; lastVisitedAt: number; title?: string };
export const BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT = 50;
export const BROWSER_HISTORY_MAX_PROJECTS = 20;
const BROWSER_HISTORY_MAX_URL_LENGTH = 2048;
export const BROWSER_HISTORY_MAX_TITLE_LENGTH = 512;
const MAX_VALID_DATE_MS = 8_640_000_000_000_000;
export function isValidHistoryTimestamp(value: unknown): value is number {
return (
typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= MAX_VALID_DATE_MS
);
}
export function normalizeHistoryUrl(raw: string): string | null {
let parsed: URL;
try {
parsed = new URL(normalizePreviewUrl(raw));
} catch {
return null;
}
parsed.username = parsed.password = "";
return parsed.href.length > BROWSER_HISTORY_MAX_URL_LENGTH ? null : parsed.href;
}
function titleLookupKey(normalized: string, environmentHostname?: string | null): string {
const parsed = new URL(visitLookupKey(normalized, environmentHostname));
if (parsed.pathname !== "/" && parsed.pathname.endsWith("/"))
parsed.pathname = parsed.pathname.slice(0, -1);
return parsed.href;
}
function visitLookupKey(normalized: string, environmentHostname?: string | null): string {
const parsed = new URL(normalized);
const host = normalizeHostname(parsed.hostname);
const environmentHost = environmentHostname && normalizeHostname(environmentHostname);
if (isLocalLoopbackHost(host) || host === "0.0.0.0" || host === environmentHost)
parsed.hostname = "local";
return parsed.href;
}
function isStableLocalUrl(normalized: string): boolean {
const host = normalizeHostname(new URL(normalized).hostname);
return isLocalLoopbackHost(host) || host === "0.0.0.0";
}
export function upsertHistoryEntry(
entries: ReadonlyArray<BrowserHistoryEntry>,
url: string,
at: number,
options?: { insertOrdered?: boolean; environmentHostname?: string | null },
): BrowserHistoryEntry[] {
const key = visitLookupKey(url, options?.environmentHostname);
const existing = entries.find(
(candidate) => visitLookupKey(candidate.url, options?.environmentHostname) === key,
);
const rest = entries.filter(
(candidate) => visitLookupKey(candidate.url, options?.environmentHostname) !== key,
);
const visitedAt =
options?.insertOrdered && existing && existing.lastVisitedAt > at ? existing.lastVisitedAt : at;
const storedUrl =
existing && (isStableLocalUrl(existing.url) || !isStableLocalUrl(url)) ? existing.url : url;
const entry: BrowserHistoryEntry = existing
? { ...existing, url: storedUrl, lastVisitedAt: visitedAt }
: { url, lastVisitedAt: visitedAt };
if (!options?.insertOrdered)
return [entry, ...rest].slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT);
const index = rest.findIndex((candidate) => candidate.lastVisitedAt < entry.lastVisitedAt);
const next = index === -1 ? [...rest, entry] : rest.toSpliced(index, 0, entry);
return next.slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT);
}
export function evictExcessProjects(
byProjectKey: Record<string, BrowserHistoryEntry[]>,
): Record<string, BrowserHistoryEntry[]> {
const keys = Object.keys(byProjectKey);
if (keys.length <= BROWSER_HISTORY_MAX_PROJECTS) return byProjectKey;
const kept = keys
.toSorted(
(a, b) =>
(byProjectKey[b]?.[0]?.lastVisitedAt ?? 0) - (byProjectKey[a]?.[0]?.lastVisitedAt ?? 0),
)
.slice(0, BROWSER_HISTORY_MAX_PROJECTS);
return Object.fromEntries(kept.map((key) => [key, byProjectKey[key] ?? []]));
}
export function migratePersistedBrowserHistoryState(persistedState: unknown): {
byProjectKey: Record<string, BrowserHistoryEntry[]>;
} {
if (!persistedState || typeof persistedState !== "object") return { byProjectKey: {} };
const raw = (persistedState as { byProjectKey?: unknown }).byProjectKey;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byProjectKey: {} };
const byProjectKey: Record<string, BrowserHistoryEntry[]> = {};
for (const [projectKey, value] of Object.entries(raw as Record<string, unknown>)) {
if (!Array.isArray(value)) continue;
const seenUrls = new Set<string>();
const entries = value
.flatMap<BrowserHistoryEntry>((candidate) => {
if (!candidate || typeof candidate !== "object") return [];
const { url, lastVisitedAt, title } = candidate as Record<string, unknown>;
if (typeof url !== "string") return [];
const normalizedUrl = normalizeHistoryUrl(url);
if (!normalizedUrl) return [];
if (!isValidHistoryTimestamp(lastVisitedAt)) return [];
return [
{
url: normalizedUrl,
lastVisitedAt,
...(typeof title === "string" && title.length > 0
? { title: title.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH) }
: {}),
},
];
})
.toSorted((a, b) => b.lastVisitedAt - a.lastVisitedAt)
.filter((entry) => {
const key = visitLookupKey(entry.url);
if (seenUrls.has(key)) return false;
seenUrls.add(key);
return true;
})
.slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT);
if (entries.length > 0) byProjectKey[projectKey] = entries;
}
return { byProjectKey: evictExcessProjects(byProjectKey) };
}
const BROWSER_HISTORY_STORAGE_KEY = "t3code:browser-history:v1";
const PENDING_MAX_PER_THREAD = 10;
const PENDING_MAX_THREADS = 20;
type PendingVisit = { url: string; at: number; environmentHostname: string | null };
type PendingTitle = { url: string; title: string; environmentHostname: string | null | undefined };
interface BrowserHistoryStoreState {
byProjectKey: Record<string, BrowserHistoryEntry[]>;
projectKeyByThreadKey: Record<string, string>;
pendingVisitsByThreadKey: Record<string, PendingVisit[]>;
pendingTitlesByThreadKey: Record<string, PendingTitle[]>;
recordVisit: (
projectKey: string,
url: string,
at: number,
options?: { insertOrdered?: boolean; environmentHostname?: string | null },
) => void;
setTitleForUrl: (
projectKey: string,
url: string,
title: string,
environmentHostname?: string | null,
) => void;
removeUrl: (projectKey: string, url: string) => void;
registerThreadProject: (ref: ScopedThreadRef, projectKey: string) => void;
}
function addPendingByThread<T>(
pendingByThreadKey: Record<string, T[]>,
threadKey: string,
item: T,
): Record<string, T[]> {
const existing = pendingByThreadKey[threadKey] ?? [];
const next = { ...pendingByThreadKey };
next[threadKey] = [...existing, item].slice(-PENDING_MAX_PER_THREAD);
const keys = Object.keys(next);
if (keys.length > PENDING_MAX_THREADS) {
const oldestKey = keys[0];
if (oldestKey !== undefined && oldestKey !== threadKey) delete next[oldestKey];
}
return next;
}
export const useBrowserHistoryStore = create<BrowserHistoryStoreState>()(
persist(
(set, get) => ({
byProjectKey: {},
projectKeyByThreadKey: {},
pendingVisitsByThreadKey: {},
pendingTitlesByThreadKey: {},
recordVisit: (projectKey, url, at, options) => {
const normalized = normalizeHistoryUrl(url);
if (!normalized) return;
set((state) => {
return {
byProjectKey: evictExcessProjects({
...state.byProjectKey,
[projectKey]: upsertHistoryEntry(
state.byProjectKey[projectKey] ?? [],
normalized,
at,
options,
),
}),
};
});
},
setTitleForUrl: (projectKey, url, title, environmentHostname) => {
const normalized = normalizeHistoryUrl(url);
const state = get();
const entries = state.byProjectKey[projectKey];
const trimmed = title.trim().slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH);
if (!normalized || !entries || trimmed.length === 0) return;
const key = titleLookupKey(normalized, environmentHostname);
const index = entries.findIndex(
(candidate) => titleLookupKey(candidate.url, environmentHostname) === key,
);
if (index === -1 || entries[index]?.title === trimmed) return;
set({
byProjectKey: {
...state.byProjectKey,
[projectKey]: entries.map((candidate, candidateIndex) =>
candidateIndex === index ? { ...candidate, title: trimmed } : candidate,
),
},
});
},
removeUrl: (projectKey, url) => {
const normalized = normalizeHistoryUrl(url);
const state = get();
const entries = state.byProjectKey[projectKey];
if (!normalized || !entries) return;
const next = entries.filter((candidate) => candidate.url !== normalized);
if (next.length === entries.length) return;
if (next.length === 0) {
const { [projectKey]: _removed, ...rest } = state.byProjectKey;
set({ byProjectKey: rest });
return;
}
set({ byProjectKey: { ...state.byProjectKey, [projectKey]: next } });
},
registerThreadProject: (ref, projectKey) => {
const threadKey = scopedThreadKey(ref);
const state = get();
const pendingVisits = state.pendingVisitsByThreadKey[threadKey];
const pendingTitles = state.pendingTitlesByThreadKey[threadKey];
if (
state.projectKeyByThreadKey[threadKey] === projectKey &&
!pendingVisits &&
!pendingTitles
) {
return;
}
const nextPendingVisits = { ...state.pendingVisitsByThreadKey };
const nextPendingTitles = { ...state.pendingTitlesByThreadKey };
delete nextPendingVisits[threadKey];
delete nextPendingTitles[threadKey];
set({
projectKeyByThreadKey: { ...state.projectKeyByThreadKey, [threadKey]: projectKey },
pendingVisitsByThreadKey: nextPendingVisits,
pendingTitlesByThreadKey: nextPendingTitles,
});
for (const visit of pendingVisits ?? [])
get().recordVisit(projectKey, visit.url, visit.at, {
insertOrdered: true,
environmentHostname: visit.environmentHostname,
});
for (const pendingTitle of pendingTitles ?? [])
get().setTitleForUrl(
projectKey,
pendingTitle.url,
pendingTitle.title,
pendingTitle.environmentHostname,
);
},
}),
{
name: BROWSER_HISTORY_STORAGE_KEY,
version: 1,
storage: createJSONStorage(() =>
resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
),
partialize: (state) => ({
byProjectKey: state.byProjectKey,
projectKeyByThreadKey: state.projectKeyByThreadKey,
}),
migrate: migratePersistedBrowserHistoryState,
merge: mergeBrowserHistoryState,
},
),
);
export function mergeBrowserHistoryState(
persistedState: unknown,
currentState: BrowserHistoryStoreState,
): BrowserHistoryStoreState {
const migrated = migratePersistedBrowserHistoryState(persistedState);
return {
...currentState,
...migrated,
projectKeyByThreadKey: migratePersistedThreadProjectKeys(persistedState, migrated.byProjectKey),
pendingVisitsByThreadKey: {},
pendingTitlesByThreadKey: {},
};
}
function migratePersistedThreadProjectKeys(
persistedState: unknown,
byProjectKey: Record<string, BrowserHistoryEntry[]>,
): Record<string, string> {
if (!persistedState || typeof persistedState !== "object") return {};
const raw = (persistedState as { projectKeyByThreadKey?: unknown }).projectKeyByThreadKey;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
return Object.fromEntries(
Object.entries(raw as Record<string, unknown>)
.filter(
(entry): entry is [string, string] =>
typeof entry[1] === "string" && entry[1] in byProjectKey,
)
.slice(-100),
);
}
export function recordVisitForThread(ref: ScopedThreadRef, url: string, at?: number): void {
const threadKey = scopedThreadKey(ref);
const state = useBrowserHistoryStore.getState();
const projectKey = state.projectKeyByThreadKey[threadKey];
const visitAt = at ?? Date.now();
const connection = readPreparedConnection(ref.environmentId);
const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null;
if (!projectKey) {
useBrowserHistoryStore.setState({
pendingVisitsByThreadKey: addPendingByThread(state.pendingVisitsByThreadKey, threadKey, {
url,
at: visitAt,
environmentHostname,
}),
});
return;
}
state.recordVisit(projectKey, url, visitAt, { environmentHostname });
}
export function setTitleForThreadUrl(
ref: ScopedThreadRef,
url: string,
title: string,
environmentHostname?: string | null,
): void {
const threadKey = scopedThreadKey(ref);
const state = useBrowserHistoryStore.getState();
const projectKey = state.projectKeyByThreadKey[threadKey];
if (!projectKey) {
useBrowserHistoryStore.setState({
pendingTitlesByThreadKey: addPendingByThread(state.pendingTitlesByThreadKey, threadKey, {
url,
title,
environmentHostname,
}),
});
return;
}
state.setTitleForUrl(projectKey, url, title, environmentHostname);
}
export function removeUrlForThread(ref: ScopedThreadRef, url: string): void {
const state = useBrowserHistoryStore.getState();
const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)];
if (!projectKey) return;
state.removeUrl(projectKey, url);
}
const EMPTY_HISTORY: ReadonlyArray<BrowserHistoryEntry> = [];
export function useThreadRecentHistory(
ref: ScopedThreadRef,
limit: number,
): ReadonlyArray<BrowserHistoryEntry> {
return useBrowserHistoryStore(
useShallow((state) => {
const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)];
const entries = projectKey ? state.byProjectKey[projectKey] : undefined;
return entries && entries.length > 0 ? entries.slice(0, limit) : EMPTY_HISTORY;
}),
);
}
export function resetBrowserHistoryForTests(): void {
useBrowserHistoryStore.setState({
byProjectKey: {},
projectKeyByThreadKey: {},
pendingVisitsByThreadKey: {},
pendingTitlesByThreadKey: {},
});
useBrowserHistoryStore.persist.clearStorage();
}