1import {
2 scopedProjectKey,
3 scopedThreadKey,
4 scopeProjectRef,
5} from "@t3tools/client-runtime/environment";
6import {
7 type EnvironmentId,
8 INCOGNITO_BROWSER_PROFILE_ID,
9 type PreviewSessionSnapshot,
10 type ScopedProjectRef,
11 type ScopedThreadRef,
12} from "@t3tools/contracts";
13import { create } from "zustand";
14import { createJSONStorage, persist } from "zustand/middleware";
15import { useShallow } from "zustand/react/shallow";
16
17import { normalizePreviewUrl } from "@t3tools/shared/preview";
18import {
19 previewStateAtom,
20 previewThreadRefsAtom,
21 readThreadPreviewState,
22 type ThreadPreviewState,
23} from "~/previewStateStore";
24import { appAtomRegistry } from "~/rpc/atomRegistry";
25import {
26 readProject,
27 readProjects,
28 readThreadShell,
29 useProjects,
30 useQuietThreadShell,
31} from "~/state/entities";
32import { readPrimaryEnvironmentId, usePrimaryEnvironmentId } from "~/state/environments";
33import { readPreparedConnection } from "~/state/session";
34
35import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver";
36import { useComposerDraftStore } from "./composerDraftStore";
37import {
38 ensureClientSettingsHydrated,
39 getClientSettings,
40 isClientSettingsHydrated,
41 useClientSettings,
42} from "./hooks/useSettings";
43import { resolveStorage } from "./lib/storage";
44import {
45 deriveLogicalProjectKeyFromSettings,
46 derivePhysicalProjectKey,
47 selectProjectGroupingSettings,
48 type ProjectGroupingSettings,
49} from "@t3tools/client-runtime/state/project-grouping";
50import { buildPhysicalToLogicalProjectKeyMap } from "./sidebarProjectGrouping";
51import type { Project } from "./types";
52
53export type BrowserHistoryEntry = { url: string; lastVisitedAt: number; title?: string };
54
55export const BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT = 50;
56export const BROWSER_HISTORY_MAX_PROJECTS = 20;
57const BROWSER_HISTORY_MAX_URL_LENGTH = 2048;
58const BROWSER_HISTORY_MAX_TITLE_LENGTH = 512;
59const MAX_VALID_DATE_MS = 8_640_000_000_000_000;
60
61export function isValidHistoryTimestamp(value: unknown): value is number {
62 return (
63 typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= MAX_VALID_DATE_MS
64 );
65}
66
67export function normalizeHistoryUrl(raw: string): string | null {
68 let parsed: URL;
69 try {
70 parsed = new URL(normalizePreviewUrl(raw));
71 } catch {
72 return null;
73 }
74 parsed.username = parsed.password = "";
75 return parsed.href.length > BROWSER_HISTORY_MAX_URL_LENGTH ? null : parsed.href;
76}
77
78function titleLookupKey(normalized: string, environmentHostname?: string | null): string {
79 const parsed = new URL(visitLookupKey(normalized, environmentHostname));
80 if (parsed.pathname !== "/" && parsed.pathname.endsWith("/"))
81 parsed.pathname = parsed.pathname.slice(0, -1);
82 return parsed.href;
83}
84
85function visitLookupKey(normalized: string, environmentHostname?: string | null): string {
86 const parsed = new URL(normalized);
87 const host = normalizeHostname(parsed.hostname);
88 const environmentHost = environmentHostname && normalizeHostname(environmentHostname);
89 if (isLocalLoopbackHost(host) || host === "0.0.0.0" || host === environmentHost)
90 parsed.hostname = "local";
91 return parsed.href;
92}
93
94function isStableLocalUrl(normalized: string): boolean {
95 const host = normalizeHostname(new URL(normalized).hostname);
96 return isLocalLoopbackHost(host) || host === "0.0.0.0";
97}
98
99export function upsertHistoryEntry(
100 entries: ReadonlyArray<BrowserHistoryEntry>,
101 url: string,
102 at: number,
103 options?: { insertOrdered?: boolean; environmentHostname?: string | null },
104): BrowserHistoryEntry[] {
105 const key = visitLookupKey(url, options?.environmentHostname);
106 const existing = entries.find(
107 (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) === key,
108 );
109 const rest = entries.filter(
110 (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) !== key,
111 );
112 const visitedAt =
113 options?.insertOrdered && existing && existing.lastVisitedAt > at ? existing.lastVisitedAt : at;
114 const storedUrl =
115 existing && (isStableLocalUrl(existing.url) || !isStableLocalUrl(url)) ? existing.url : url;
116 const entry: BrowserHistoryEntry = existing
117 ? { ...existing, url: storedUrl, lastVisitedAt: visitedAt }
118 : { url, lastVisitedAt: visitedAt };
119 if (!options?.insertOrdered)
120 return [entry, ...rest].slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT);
121 const index = rest.findIndex((candidate) => candidate.lastVisitedAt < entry.lastVisitedAt);
122 const next = index === -1 ? [...rest, entry] : rest.toSpliced(index, 0, entry);
123 return next.slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT);
124}
125
126function evictExcessProjects(
127 byProjectKey: Record<string, BrowserHistoryEntry[]>,
128): Record<string, BrowserHistoryEntry[]> {
129 const keys = Object.keys(byProjectKey);
130 if (keys.length <= BROWSER_HISTORY_MAX_PROJECTS) return byProjectKey;
131 const kept = keys
132 .toSorted(
133 (a, b) =>
134 (byProjectKey[b]?.[0]?.lastVisitedAt ?? 0) - (byProjectKey[a]?.[0]?.lastVisitedAt ?? 0),
135 )
136 .slice(0, BROWSER_HISTORY_MAX_PROJECTS);
137 return Object.fromEntries(kept.map((key) => [key, byProjectKey[key] ?? []]));
138}
139
140export function migratePersistedBrowserHistoryState(persistedState: unknown): {
141 byProjectKey: Record<string, BrowserHistoryEntry[]>;
142} {
143 if (!persistedState || typeof persistedState !== "object") return { byProjectKey: {} };
144 const raw = (persistedState as { byProjectKey?: unknown }).byProjectKey;
145 if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byProjectKey: {} };
146 const byProjectKey: Record<string, BrowserHistoryEntry[]> = {};
147 for (const [projectKey, value] of Object.entries(raw as Record<string, unknown>)) {
148 if (!Array.isArray(value)) continue;
149 const seenUrls = new Set<string>();
150 const entries = value
151 .flatMap<BrowserHistoryEntry>((candidate) => {
152 if (!candidate || typeof candidate !== "object") return [];
153 const { url, lastVisitedAt, title } = candidate as Record<string, unknown>;
154 if (typeof url !== "string") return [];
155 const normalizedUrl = normalizeHistoryUrl(url);
156 if (!normalizedUrl) return [];
157 if (!isValidHistoryTimestamp(lastVisitedAt)) return [];
158 return [
159 {
160 url: normalizedUrl,
161 lastVisitedAt,
162 ...(typeof title === "string" && title.length > 0
163 ? { title: title.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH) }
164 : {}),
165 },
166 ];
167 })
168 .toSorted((a, b) => b.lastVisitedAt - a.lastVisitedAt)
169 .filter((entry) => {
170 const key = visitLookupKey(entry.url);
171 if (seenUrls.has(key)) return false;
172 seenUrls.add(key);
173 return true;
174 })
175 .slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT);
176 if (entries.length > 0) byProjectKey[projectKey] = entries;
177 }
178 return { byProjectKey: evictExcessProjects(byProjectKey) };
179}
180
181const BROWSER_HISTORY_STORAGE_KEY = "t3code:browser-history:v1";
182
183const PENDING_MAX_PER_THREAD = 10;
184const PENDING_MAX_THREADS = 20;
185
186type PendingVisit = { url: string; at: number; environmentHostname: string | null };
187
188interface BrowserHistoryStoreState {
189 byProjectKey: Record<string, BrowserHistoryEntry[]>;
190 projectKeyByThreadKey: Record<string, string>;
191 pendingVisitsByThreadKey: Record<string, PendingVisit[]>;
192 recordVisit: (
193 projectKey: string,
194 url: string,
195 at: number,
196 options?: { insertOrdered?: boolean; environmentHostname?: string | null },
197 ) => void;
198 setTitleForUrl: (
199 projectKey: string,
200 url: string,
201 title: string,
202 environmentHostname?: string | null,
203 ) => void;
204 removeUrl: (projectKey: string, url: string) => void;
205 registerThreadProject: (ref: ScopedThreadRef, projectKey: string) => void;
206}
207
208function addPendingByThread<T>(
209 pendingByThreadKey: Record<string, T[]>,
210 threadKey: string,
211 item: T,
212): Record<string, T[]> {
213 const existing = pendingByThreadKey[threadKey] ?? [];
214 const next = { ...pendingByThreadKey };
215 next[threadKey] = [...existing, item].slice(-PENDING_MAX_PER_THREAD);
216 const keys = Object.keys(next);
217 if (keys.length > PENDING_MAX_THREADS) {
218 const oldestKey = keys[0];
219 if (oldestKey !== undefined && oldestKey !== threadKey) delete next[oldestKey];
220 }
221 return next;
222}
223
224export const useBrowserHistoryStore = create<BrowserHistoryStoreState>()(
225 persist(
226 (set, get) => ({
227 byProjectKey: {},
228 projectKeyByThreadKey: {},
229 pendingVisitsByThreadKey: {},
230 recordVisit: (projectKey, url, at, options) => {
231 const normalized = normalizeHistoryUrl(url);
232 if (!normalized) return;
233 set((state) => {
234 return {
235 byProjectKey: evictExcessProjects({
236 ...state.byProjectKey,
237 [projectKey]: upsertHistoryEntry(
238 state.byProjectKey[projectKey] ?? [],
239 normalized,
240 at,
241 options,
242 ),
243 }),
244 };
245 });
246 },
247 setTitleForUrl: (projectKey, url, title, environmentHostname) => {
248 const normalized = normalizeHistoryUrl(url);
249 const state = get();
250 const entries = state.byProjectKey[projectKey];
251 const trimmed = title.trim().slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH);
252 if (!normalized || !entries || trimmed.length === 0) return;
253 const key = titleLookupKey(normalized, environmentHostname);
254 const index = entries.findIndex(
255 (candidate) => titleLookupKey(candidate.url, environmentHostname) === key,
256 );
257 if (index === -1 || entries[index]?.title === trimmed) return;
258 set({
259 byProjectKey: {
260 ...state.byProjectKey,
261 [projectKey]: entries.map((candidate, candidateIndex) =>
262 candidateIndex === index ? { ...candidate, title: trimmed } : candidate,
263 ),
264 },
265 });
266 },
267 removeUrl: (projectKey, url) => {
268 const normalized = normalizeHistoryUrl(url);
269 const state = get();
270 const entries = state.byProjectKey[projectKey];
271 if (!normalized || !entries) return;
272 const next = entries.filter((candidate) => candidate.url !== normalized);
273 if (next.length === entries.length) return;
274 if (next.length === 0) {
275 const { [projectKey]: _removed, ...rest } = state.byProjectKey;
276 set({ byProjectKey: rest });
277 return;
278 }
279 set({ byProjectKey: { ...state.byProjectKey, [projectKey]: next } });
280 },
281 registerThreadProject: (ref, projectKey) => {
282 const threadKey = scopedThreadKey(ref);
283 const state = get();
284 const pendingVisits = state.pendingVisitsByThreadKey[threadKey];
285 if (state.projectKeyByThreadKey[threadKey] === projectKey && !pendingVisits) return;
286 const nextPendingVisits = { ...state.pendingVisitsByThreadKey };
287 delete nextPendingVisits[threadKey];
288 set({
289 projectKeyByThreadKey: { ...state.projectKeyByThreadKey, [threadKey]: projectKey },
290 pendingVisitsByThreadKey: nextPendingVisits,
291 });
292 for (const visit of pendingVisits ?? [])
293 get().recordVisit(projectKey, visit.url, visit.at, {
294 insertOrdered: true,
295 environmentHostname: visit.environmentHostname,
296 });
297 if (pendingVisits)
298 recordPreviewTitles(ref, readThreadPreviewState(ref), {
299 urls: pendingVisits.map((visit) => visit.url),
300 });
301 },
302 }),
303 {
304 name: BROWSER_HISTORY_STORAGE_KEY,
305 version: 1,
306 storage: createJSONStorage(() =>
307 resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
308 ),
309 partialize: (state) => ({
310 byProjectKey: state.byProjectKey,
311 projectKeyByThreadKey: state.projectKeyByThreadKey,
312 }),
313 migrate: migratePersistedBrowserHistoryState,
314 merge: mergeBrowserHistoryState,
315 },
316 ),
317);
318
319export function mergeBrowserHistoryState(
320 persistedState: unknown,
321 currentState: BrowserHistoryStoreState,
322): BrowserHistoryStoreState {
323 const migrated = migratePersistedBrowserHistoryState(persistedState);
324 return {
325 ...currentState,
326 ...migrated,
327 projectKeyByThreadKey: migratePersistedThreadProjectKeys(persistedState, migrated.byProjectKey),
328 pendingVisitsByThreadKey: {},
329 };
330}
331
332function migratePersistedThreadProjectKeys(
333 persistedState: unknown,
334 byProjectKey: Record<string, BrowserHistoryEntry[]>,
335): Record<string, string> {
336 if (!persistedState || typeof persistedState !== "object") return {};
337 const raw = (persistedState as { projectKeyByThreadKey?: unknown }).projectKeyByThreadKey;
338 if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
339 return Object.fromEntries(
340 Object.entries(raw as Record<string, unknown>)
341 .filter(
342 (entry): entry is [string, string] =>
343 typeof entry[1] === "string" && entry[1] in byProjectKey,
344 )
345 .slice(-100),
346 );
347}
348
349/**
350 * History follows the sidebar's project rows, so a duplicate row shares the
351 * history of the group it is listed under rather than keying by its own path.
352 */
353function deriveHistoryProjectKey(
354 project: Project,
355 projects: ReadonlyArray<Project>,
356 settings: ProjectGroupingSettings,
357 primaryEnvironmentId: EnvironmentId | null,
358): string {
359 return (
360 buildPhysicalToLogicalProjectKeyMap({ projects, settings, primaryEnvironmentId }).get(
361 derivePhysicalProjectKey(project),
362 ) ?? deriveLogicalProjectKeyFromSettings(project, settings)
363 );
364}
365
366/** Server threads name their project in the shell; drafts in the composer draft store. */
367function readThreadProjectRef(ref: ScopedThreadRef): ScopedProjectRef | null {
368 const projectId = readThreadShell(ref)?.projectId;
369 if (projectId) return scopeProjectRef(ref.environmentId, projectId);
370 const draft = useComposerDraftStore.getState().getDraftSessionByRef(ref);
371 return draft ? scopeProjectRef(draft.environmentId, draft.projectId) : null;
372}
373
374/**
375 * Registers the thread under its current project so queued visits and titles
376 * land. Until the project loads, or client settings hydrate (the grouping is
377 * unknown before then), the last registration stands and writes stay queued.
378 */
379function resolveThreadProjectKey(ref: ScopedThreadRef): string | undefined {
380 const state = useBrowserHistoryStore.getState();
381 const registered = state.projectKeyByThreadKey[scopedThreadKey(ref)];
382 if (!isClientSettingsHydrated()) {
383 void ensureClientSettingsHydrated().then(
384 () => resolveThreadProjectKey(ref),
385 () => undefined,
386 );
387 return registered;
388 }
389 const projectRef = readThreadProjectRef(ref);
390 const project = projectRef ? readProject(projectRef) : null;
391 if (!project) return registered;
392 const projectKey = deriveHistoryProjectKey(
393 project,
394 readProjects(),
395 selectProjectGroupingSettings(getClientSettings()),
396 readPrimaryEnvironmentId(),
397 );
398 state.registerThreadProject(ref, projectKey);
399 return projectKey;
400}
401
402export function recordVisitForThread(ref: ScopedThreadRef, url: string, at?: number): void {
403 const threadKey = scopedThreadKey(ref);
404 const projectKey = resolveThreadProjectKey(ref);
405 const state = useBrowserHistoryStore.getState();
406 const visitAt = at ?? Date.now();
407 const connection = readPreparedConnection(ref.environmentId);
408 const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null;
409 if (!projectKey) {
410 useBrowserHistoryStore.setState({
411 pendingVisitsByThreadKey: addPendingByThread(state.pendingVisitsByThreadKey, threadKey, {
412 url,
413 at: visitAt,
414 environmentHostname,
415 }),
416 });
417 return;
418 }
419 state.recordVisit(projectKey, url, visitAt, { environmentHostname });
420 // A page can settle before the address bar records its visit.
421 recordPreviewTitles(ref, readThreadPreviewState(ref), { urls: [url] });
422}
423
424/**
425 * History is shared across a project's threads and settled sessions are retained,
426 * so replaying an old settle would clobber another thread's newer title. Visits
427 * only fill untitled entries; settles apply only pages that loaded or retitled.
428 */
429function recordPreviewTitles(
430 ref: ScopedThreadRef,
431 preview: ThreadPreviewState,
432 only:
433 | { readonly urls: ReadonlyArray<string> }
434 | { readonly changedSince: ThreadPreviewState["sessions"] },
435): void {
436 const state = useBrowserHistoryStore.getState();
437 const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)];
438 if (!projectKey || !state.byProjectKey[projectKey]?.length) return;
439 const connection = readPreparedConnection(ref.environmentId);
440 const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null;
441 const titleKey = (url: string) => {
442 const normalized = normalizeHistoryUrl(url);
443 return normalized ? titleLookupKey(normalized, environmentHostname) : null;
444 };
445 const titled = new Set(
446 "urls" in only
447 ? state.byProjectKey[projectKey]?.flatMap((entry) =>
448 entry.title ? [titleLookupKey(entry.url, environmentHostname)] : [],
449 )
450 : [],
451 );
452 const visitKeys =
453 "urls" in only
454 ? new Set(only.urls.map(titleKey).filter((key) => key !== null && !titled.has(key)))
455 : null;
456 // Resizes and list reconciles replace a session without loading a new page.
457 const settledAgain = (session: PreviewSessionSnapshot, previous?: PreviewSessionSnapshot) =>
458 previous?.navStatus._tag === "Success" &&
459 session.navStatus._tag === "Success" &&
460 previous.navStatus.url === session.navStatus.url &&
461 previous.navStatus.title === session.navStatus.title;
462 const include = (session: PreviewSessionSnapshot, url: string) =>
463 "changedSince" in only
464 ? !settledAgain(session, only.changedSince[session.tabId])
465 : (visitKeys?.has(titleKey(url)) ?? false);
466 // Tabs can share a URL; applying oldest first lets the newest settle win.
467 const sessions = Object.values(preview.sessions).toSorted((a, b) =>
468 a.updatedAt.localeCompare(b.updatedAt),
469 );
470 for (const session of sessions) {
471 const status = session.navStatus;
472 if (session.profileId === INCOGNITO_BROWSER_PROFILE_ID) continue;
473 if (status._tag !== "Success" || !status.title || !include(session, status.url)) continue;
474 state.setTitleForUrl(projectKey, status.url, status.title, environmentHostname);
475 }
476}
477
478/** Records settled page titles for every thread with previews, for the life of the page. */
479export function startPreviewTitleRecording(): () => void {
480 const followed = new Map<string, () => void>();
481 const followNewThreads = (refs: ReadonlyMap<string, ScopedThreadRef>) => {
482 for (const [threadKey, threadRef] of refs) {
483 if (followed.has(threadKey)) continue;
484 const atom = previewStateAtom(threadKey);
485 let previousSessions = appAtomRegistry.get(atom).sessions;
486 followed.set(
487 threadKey,
488 appAtomRegistry.subscribe(atom, (next) => {
489 if (next.sessions === previousSessions) return;
490 const changedSince = previousSessions;
491 previousSessions = next.sessions;
492 recordPreviewTitles(threadRef, next, { changedSince });
493 }),
494 );
495 }
496 };
497 const stopIndex = appAtomRegistry.subscribe(previewThreadRefsAtom, followNewThreads, {
498 immediate: true,
499 });
500 return () => {
501 stopIndex();
502 for (const stop of followed.values()) stop();
503 };
504}
505
506export function removeUrlForThread(ref: ScopedThreadRef, url: string): void {
507 const projectKey = resolveThreadProjectKey(ref);
508 if (!projectKey) return;
509 useBrowserHistoryStore.getState().removeUrl(projectKey, url);
510}
511
512const EMPTY_HISTORY: ReadonlyArray<BrowserHistoryEntry> = [];
513
514function useThreadHistoryProjectKey(ref: ScopedThreadRef): string | null {
515 const threadKey = scopedThreadKey(ref);
516 const shellProjectId = useQuietThreadShell(ref)?.projectId ?? null;
517 const draftProjectKey = useComposerDraftStore((state) => {
518 const draft = state.getDraftSessionByRef(ref);
519 return draft ? scopedProjectKey(scopeProjectRef(draft.environmentId, draft.projectId)) : null;
520 });
521 const projectKey = shellProjectId
522 ? scopedProjectKey(scopeProjectRef(ref.environmentId, shellProjectId))
523 : draftProjectKey;
524 const projects = useProjects();
525 const settings = useClientSettings(selectProjectGroupingSettings);
526 const primaryEnvironmentId = usePrimaryEnvironmentId();
527 const registered = useBrowserHistoryStore(
528 (state) => state.projectKeyByThreadKey[threadKey] ?? null,
529 );
530 const project = projects.find(
531 (candidate) =>
532 scopedProjectKey(scopeProjectRef(candidate.environmentId, candidate.id)) === projectKey,
533 );
534 return project
535 ? deriveHistoryProjectKey(project, projects, settings, primaryEnvironmentId)
536 : registered;
537}
538
539export function useThreadRecentHistory(
540 ref: ScopedThreadRef,
541 limit: number,
542): ReadonlyArray<BrowserHistoryEntry> {
543 const projectKey = useThreadHistoryProjectKey(ref);
544 return useBrowserHistoryStore(
545 useShallow((state) => {
546 const entries = projectKey ? state.byProjectKey[projectKey] : undefined;
547 return entries && entries.length > 0 ? entries.slice(0, limit) : EMPTY_HISTORY;
548 }),
549 );
550}
551
552export function resetBrowserHistoryForTests(): void {
553 useBrowserHistoryStore.setState({
554 byProjectKey: {},
555 projectKeyByThreadKey: {},
556 pendingVisitsByThreadKey: {},
557 });
558 useBrowserHistoryStore.persist.clearStorage();
559}