| author | |
| committer | |
| log | 72d673a855c730536f0cf3bb964ba523e0af9e2e |
| tree | 444b65704afba8d2e2fabb7579212763dff0b730 |
| parent | e2cd2383cec150ea674030d408f2a99a411c94fc |
| signature | Signed by PGP key B5690EEEBB952194 |
16 files changed, 1207 insertions(+), 41 deletions(-)
apps/web/src/browser/browserTargetResolver.ts+3-2| ... | ... | @@ -7,7 +7,8 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 7 | 7 | |
| 8 | 8 | import { readPreparedConnection } from "~/state/session"; |
| 9 | 9 | |
| 10 | const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, ""); | |
| 10 | export const normalizeHostname = (host: string): string => | |
| 11 | host.toLowerCase().replace(/^\[|\]$/g, ""); | |
| 11 | 12 | |
| 12 | 13 | const parseIpv4Address = (host: string): readonly number[] | null => { |
| 13 | 14 | const parts = normalizeHostname(host).split(".").map(Number); |
| ... | ... | @@ -17,7 +18,7 @@ const parseIpv4Address = (host: string): readonly number[] | null => { |
| 17 | 18 | : null; |
| 18 | 19 | }; |
| 19 | 20 | |
| 20 | const isLocalLoopbackHost = (host: string): boolean => { | |
| 21 | export const isLocalLoopbackHost = (host: string): boolean => { | |
| 21 | 22 | const normalized = normalizeHostname(host); |
| 22 | 23 | if (normalized === "localhost" || normalized === "::1") return true; |
| 23 | 24 | return parseIpv4Address(normalized)?.[0] === 127; |
apps/web/src/browserHistoryStore.test.ts created+444| ... | ... | @@ -0,0 +1,444 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 4 | ||
| 5 | const { readPreparedConnection } = vi.hoisted(() => ({ | |
| 6 | readPreparedConnection: vi.fn<() => { httpBaseUrl: string } | null>(() => null), | |
| 7 | })); | |
| 8 | ||
| 9 | vi.mock("~/state/session", () => ({ readPreparedConnection })); | |
| 10 | ||
| 11 | import { | |
| 12 | BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, | |
| 13 | BROWSER_HISTORY_MAX_PROJECTS, | |
| 14 | BROWSER_HISTORY_MAX_TITLE_LENGTH, | |
| 15 | type BrowserHistoryEntry, | |
| 16 | evictExcessProjects, | |
| 17 | mergeBrowserHistoryState, | |
| 18 | migratePersistedBrowserHistoryState, | |
| 19 | normalizeHistoryUrl, | |
| 20 | recordVisitForThread, | |
| 21 | removeUrlForThread, | |
| 22 | resetBrowserHistoryForTests, | |
| 23 | setTitleForThreadUrl, | |
| 24 | upsertHistoryEntry, | |
| 25 | useBrowserHistoryStore, | |
| 26 | } from "./browserHistoryStore"; | |
| 27 | ||
| 28 | function entry(overrides: Partial<BrowserHistoryEntry> = {}): BrowserHistoryEntry { | |
| 29 | return { url: "http://localhost:3000/", lastVisitedAt: 1000, ...overrides }; | |
| 30 | } | |
| 31 | ||
| 32 | beforeEach(() => readPreparedConnection.mockReturnValue(null)); | |
| 33 | afterEach(() => vi.restoreAllMocks()); | |
| 34 | ||
| 35 | function spyOnPersistWrites() { | |
| 36 | const storage = useBrowserHistoryStore.persist.getOptions().storage; | |
| 37 | if (!storage) throw new Error("Browser history persistence storage is unavailable."); | |
| 38 | return vi.spyOn(storage, "setItem"); | |
| 39 | } | |
| 40 | ||
| 41 | describe("normalizeHistoryUrl", () => { | |
| 42 | it("normalizes bare loopback hosts to http and keeps path/query", () => { | |
| 43 | expect(normalizeHistoryUrl("localhost:3000/admin?tab=1")).toBe( | |
| 44 | "http://localhost:3000/admin?tab=1", | |
| 45 | ); | |
| 46 | }); | |
| 47 | ||
| 48 | it("normalizes bare public hosts to https", () => { | |
| 49 | expect(normalizeHistoryUrl("myapp.test")).toBe("https://myapp.test/"); | |
| 50 | }); | |
| 51 | ||
| 52 | it("preserves hash routes and strips credentials", () => { | |
| 53 | expect(normalizeHistoryUrl("http://localhost:3000/app#/route")).toBe( | |
| 54 | "http://localhost:3000/app#/route", | |
| 55 | ); | |
| 56 | expect(normalizeHistoryUrl("https://user:secret@example.com/")).toBe("https://example.com/"); | |
| 57 | }); | |
| 58 | ||
| 59 | it("rejects non-http(s), unparseable, and oversized urls", () => { | |
| 60 | expect(normalizeHistoryUrl("ftp://example.com")).toBeNull(); | |
| 61 | expect(normalizeHistoryUrl("")).toBeNull(); | |
| 62 | expect(normalizeHistoryUrl(`http://localhost/${"a".repeat(2048)}`)).toBeNull(); | |
| 63 | }); | |
| 64 | }); | |
| 65 | ||
| 66 | describe("upsertHistoryEntry", () => { | |
| 67 | it("prepends new urls", () => { | |
| 68 | const next = upsertHistoryEntry([entry()], "http://localhost:5173/", 2000); | |
| 69 | expect(next.map((e) => e.url)).toEqual(["http://localhost:5173/", "http://localhost:3000/"]); | |
| 70 | expect(next[0]).toEqual({ url: "http://localhost:5173/", lastVisitedAt: 2000 }); | |
| 71 | }); | |
| 72 | ||
| 73 | it("moves revisits to front, updates the timestamp, and keeps the title", () => { | |
| 74 | const existing = [ | |
| 75 | entry({ url: "http://a.test/", lastVisitedAt: 500, title: "A" }), | |
| 76 | entry({ url: "http://b.test/", lastVisitedAt: 400 }), | |
| 77 | ]; | |
| 78 | const next = upsertHistoryEntry(existing, "http://b.test/", 3000); | |
| 79 | expect(next.map((e) => e.url)).toEqual(["http://b.test/", "http://a.test/"]); | |
| 80 | expect(next[0]?.lastVisitedAt).toBe(3000); | |
| 81 | expect(next[1]?.title).toBe("A"); | |
| 82 | }); | |
| 83 | ||
| 84 | it("caps the list at the per-project limit", () => { | |
| 85 | const full = Array.from({ length: BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT }, (_, i) => | |
| 86 | entry({ url: `http://localhost:${3000 + i}/`, lastVisitedAt: i }), | |
| 87 | ); | |
| 88 | const next = upsertHistoryEntry(full, "http://new.test/", 9999); | |
| 89 | expect(next).toHaveLength(BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); | |
| 90 | expect(next[0]?.url).toBe("http://new.test/"); | |
| 91 | const lastPort = 3000 + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT - 1; | |
| 92 | expect(next.some((e) => e.url === `http://localhost:${lastPort}/`)).toBe(false); | |
| 93 | expect(next.some((e) => e.url === "http://localhost:3000/")).toBe(true); | |
| 94 | }); | |
| 95 | ||
| 96 | it("with insertOrdered, slots an older entry below a newer one instead of prepending", () => { | |
| 97 | const existing = [entry({ url: "http://newer.test/", lastVisitedAt: 2000 })]; | |
| 98 | const next = upsertHistoryEntry(existing, "http://older.test/", 1000, { | |
| 99 | insertOrdered: true, | |
| 100 | }); | |
| 101 | expect(next.map((e) => e.url)).toEqual(["http://newer.test/", "http://older.test/"]); | |
| 102 | }); | |
| 103 | ||
| 104 | it("with insertOrdered, replaying an older visit for an existing entry keeps its newer timestamp", () => { | |
| 105 | const existing = [entry({ url: "http://a.test/", lastVisitedAt: 2000 })]; | |
| 106 | const next = upsertHistoryEntry(existing, "http://a.test/", 1000, { insertOrdered: true }); | |
| 107 | expect(next).toEqual([{ url: "http://a.test/", lastVisitedAt: 2000 }]); | |
| 108 | }); | |
| 109 | }); | |
| 110 | ||
| 111 | describe("evictExcessProjects", () => { | |
| 112 | it("keeps the most recently visited projects when over the cap", () => { | |
| 113 | const byProjectKey = Object.fromEntries( | |
| 114 | Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 2 }, (_, i) => [ | |
| 115 | `project-${i}`, | |
| 116 | [entry({ lastVisitedAt: i })], | |
| 117 | ]), | |
| 118 | ); | |
| 119 | const next = evictExcessProjects(byProjectKey); | |
| 120 | expect(Object.keys(next)).toHaveLength(BROWSER_HISTORY_MAX_PROJECTS); | |
| 121 | expect(next["project-0"]).toBeUndefined(); | |
| 122 | expect(next["project-1"]).toBeUndefined(); | |
| 123 | expect(next[`project-${BROWSER_HISTORY_MAX_PROJECTS + 1}`]).toBeDefined(); | |
| 124 | }); | |
| 125 | }); | |
| 126 | ||
| 127 | describe("migratePersistedBrowserHistoryState", () => { | |
| 128 | it("drops malformed state and invalid entries", () => { | |
| 129 | expect(migratePersistedBrowserHistoryState(null)).toEqual({ byProjectKey: {} }); | |
| 130 | expect(migratePersistedBrowserHistoryState({ byProjectKey: 42 })).toEqual({ byProjectKey: {} }); | |
| 131 | const migrated = migratePersistedBrowserHistoryState({ | |
| 132 | byProjectKey: { | |
| 133 | good: [ | |
| 134 | { url: "http://a.test/", lastVisitedAt: 100, title: "A" }, | |
| 135 | { url: "", lastVisitedAt: 100 }, | |
| 136 | { url: "ftp://ghost.test/", lastVisitedAt: 100 }, | |
| 137 | { url: "http://b.test/", lastVisitedAt: Number.NaN }, | |
| 138 | "junk", | |
| 139 | ], | |
| 140 | bad: "junk", | |
| 141 | }, | |
| 142 | }); | |
| 143 | expect(migrated.byProjectKey["good"]).toEqual([ | |
| 144 | { url: "http://a.test/", lastVisitedAt: 100, title: "A" }, | |
| 145 | ]); | |
| 146 | expect(migrated.byProjectKey["bad"]).toBeUndefined(); | |
| 147 | }); | |
| 148 | ||
| 149 | it("normalizes persisted urls with the same rules as live writes", () => { | |
| 150 | const migrated = migratePersistedBrowserHistoryState({ | |
| 151 | byProjectKey: { | |
| 152 | good: [{ url: "a.test/path#section", lastVisitedAt: 100 }], | |
| 153 | }, | |
| 154 | }); | |
| 155 | expect(migrated.byProjectKey["good"]).toEqual([ | |
| 156 | { url: "https://a.test/path#section", lastVisitedAt: 100 }, | |
| 157 | ]); | |
| 158 | }); | |
| 159 | ||
| 160 | it("restores MRU ordering, deduplicates normalized urls, and enforces project bounds", () => { | |
| 161 | const byProjectKey = Object.fromEntries( | |
| 162 | Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 1 }, (_, index) => [ | |
| 163 | `project-${index}`, | |
| 164 | [{ url: `http://project-${index}.test/`, lastVisitedAt: index }], | |
| 165 | ]), | |
| 166 | ); | |
| 167 | byProjectKey["project-1"] = [ | |
| 168 | { url: "a.test/", lastVisitedAt: 1 }, | |
| 169 | { url: "http://newer.test/", lastVisitedAt: 3 }, | |
| 170 | { url: "https://a.test/", lastVisitedAt: 2 }, | |
| 171 | ]; | |
| 172 | ||
| 173 | const migrated = migratePersistedBrowserHistoryState({ byProjectKey }); | |
| 174 | ||
| 175 | expect(Object.keys(migrated.byProjectKey)).toHaveLength(BROWSER_HISTORY_MAX_PROJECTS); | |
| 176 | expect(migrated.byProjectKey["project-0"]).toBeUndefined(); | |
| 177 | expect(migrated.byProjectKey["project-1"]).toEqual([ | |
| 178 | { url: "http://newer.test/", lastVisitedAt: 3 }, | |
| 179 | { url: "https://a.test/", lastVisitedAt: 2 }, | |
| 180 | ]); | |
| 181 | }); | |
| 182 | ||
| 183 | it("rejects a lastVisitedAt outside Date's valid range", () => { | |
| 184 | const migrated = migratePersistedBrowserHistoryState({ | |
| 185 | byProjectKey: { | |
| 186 | good: [ | |
| 187 | { url: "http://a.test/", lastVisitedAt: 100 }, | |
| 188 | { url: "http://b.test/", lastVisitedAt: 1e20 }, | |
| 189 | ], | |
| 190 | }, | |
| 191 | }); | |
| 192 | expect(migrated.byProjectKey["good"]).toEqual([{ url: "http://a.test/", lastVisitedAt: 100 }]); | |
| 193 | }); | |
| 194 | ||
| 195 | it("truncates oversized persisted titles to the contract bound", () => { | |
| 196 | const oversized = "x".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 100); | |
| 197 | const migrated = migratePersistedBrowserHistoryState({ | |
| 198 | byProjectKey: { | |
| 199 | good: [{ url: "http://a.test/", lastVisitedAt: 100, title: oversized }], | |
| 200 | }, | |
| 201 | }); | |
| 202 | expect(migrated.byProjectKey["good"]?.[0]?.title).toHaveLength( | |
| 203 | BROWSER_HISTORY_MAX_TITLE_LENGTH, | |
| 204 | ); | |
| 205 | expect(migrated.byProjectKey["good"]?.[0]?.title).toBe( | |
| 206 | oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH), | |
| 207 | ); | |
| 208 | }); | |
| 209 | }); | |
| 210 | ||
| 211 | const threadRef = { | |
| 212 | environmentId: EnvironmentId.make("env-1"), | |
| 213 | threadId: ThreadId.make("thread-1"), | |
| 214 | }; | |
| 215 | ||
| 216 | describe("useBrowserHistoryStore", () => { | |
| 217 | beforeEach(() => { | |
| 218 | resetBrowserHistoryForTests(); | |
| 219 | }); | |
| 220 | ||
| 221 | it("records visits for registered threads under the project key", () => { | |
| 222 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 223 | recordVisitForThread(threadRef, "myapp.test/admin#section", 1234); | |
| 224 | expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([ | |
| 225 | { url: "https://myapp.test/admin#section", lastVisitedAt: 1234 }, | |
| 226 | ]); | |
| 227 | }); | |
| 228 | ||
| 229 | it("does not persist when a thread is already registered to the same project", () => { | |
| 230 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 231 | const persist = spyOnPersistWrites(); | |
| 232 | ||
| 233 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 234 | ||
| 235 | expect(persist).not.toHaveBeenCalled(); | |
| 236 | }); | |
| 237 | ||
| 238 | it("ignores invalid urls whether queued pending or recorded post-registration", () => { | |
| 239 | recordVisitForThread(threadRef, "ftp://a.test/", 1); | |
| 240 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 241 | recordVisitForThread(threadRef, "ftp://a.test/", 2); | |
| 242 | expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); | |
| 243 | }); | |
| 244 | ||
| 245 | it("sets titles update-only via the thread helper", () => { | |
| 246 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 247 | setTitleForThreadUrl(threadRef, "http://a.test/", "Should not create"); | |
| 248 | expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); | |
| 249 | recordVisitForThread(threadRef, "http://a.test/#/settings", 1); | |
| 250 | setTitleForThreadUrl(threadRef, "http://a.test/#/settings", "My App"); | |
| 251 | expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("My App"); | |
| 252 | }); | |
| 253 | ||
| 254 | it("does not persist when the title is already set", () => { | |
| 255 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 256 | recordVisitForThread(threadRef, "http://a.test/", 1); | |
| 257 | setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); | |
| 258 | const persist = spyOnPersistWrites(); | |
| 259 | const byProjectKey = useBrowserHistoryStore.getState().byProjectKey; | |
| 260 | ||
| 261 | setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); | |
| 262 | ||
| 263 | expect(useBrowserHistoryStore.getState().byProjectKey).toBe(byProjectKey); | |
| 264 | expect(persist).not.toHaveBeenCalled(); | |
| 265 | }); | |
| 266 | ||
| 267 | it("sets a title against a settled url that differs from the stored one only by a trailing slash", () => { | |
| 268 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 269 | recordVisitForThread(threadRef, "http://a.test/community", 1); | |
| 270 | setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community"); | |
| 271 | expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({ | |
| 272 | url: "http://a.test/community", | |
| 273 | title: "Community", | |
| 274 | }); | |
| 275 | ||
| 276 | useBrowserHistoryStore.setState({ byProjectKey: {} }); | |
| 277 | recordVisitForThread(threadRef, "http://a.test/community/", 1); | |
| 278 | setTitleForThreadUrl(threadRef, "http://a.test/community", "Community"); | |
| 279 | expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({ | |
| 280 | url: "http://a.test/community/", | |
| 281 | title: "Community", | |
| 282 | }); | |
| 283 | }); | |
| 284 | ||
| 285 | it("matches a requested localhost URL to the resolved environment host", () => { | |
| 286 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 287 | recordVisitForThread(threadRef, "http://localhost:5173/app", 1); | |
| 288 | setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2"); | |
| 289 | expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App"); | |
| 290 | }); | |
| 291 | ||
| 292 | it("deduplicates loopback aliases and the resolved environment host", () => { | |
| 293 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.64.2:3773" }); | |
| 294 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 295 | recordVisitForThread(threadRef, "http://localhost:5173/app", 1); | |
| 296 | recordVisitForThread(threadRef, "http://127.0.0.1:5173/app", 2); | |
| 297 | recordVisitForThread(threadRef, "http://192.168.64.2:5173/app", 3); | |
| 298 | expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([ | |
| 299 | { url: "http://localhost:5173/app", lastVisitedAt: 3 }, | |
| 300 | ]); | |
| 301 | ||
| 302 | useBrowserHistoryStore.setState({ byProjectKey: {} }); | |
| 303 | recordVisitForThread(threadRef, "http://192.168.64.2:5173/app", 4); | |
| 304 | recordVisitForThread(threadRef, "http://localhost:5173/app", 5); | |
| 305 | expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([ | |
| 306 | { url: "http://localhost:5173/app", lastVisitedAt: 5 }, | |
| 307 | ]); | |
| 308 | }); | |
| 309 | ||
| 310 | it("does not match a genuinely different path via the trailing-slash comparison", () => { | |
| 311 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 312 | recordVisitForThread(threadRef, "http://a.test/community", 1); | |
| 313 | setTitleForThreadUrl(threadRef, "http://a.test/community/foo", "Foo"); | |
| 314 | expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBeUndefined(); | |
| 315 | }); | |
| 316 | ||
| 317 | it("updates only the most recent entry when several share a trailing-slash comparison key", () => { | |
| 318 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 319 | recordVisitForThread(threadRef, "http://a.test/community/", 1); | |
| 320 | recordVisitForThread(threadRef, "http://a.test/community", 2); | |
| 321 | setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community"); | |
| 322 | const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; | |
| 323 | expect(entries?.[0]).toMatchObject({ url: "http://a.test/community", title: "Community" }); | |
| 324 | expect(entries?.[1]).toMatchObject({ url: "http://a.test/community/" }); | |
| 325 | expect(entries?.[1]?.title).toBeUndefined(); | |
| 326 | }); | |
| 327 | ||
| 328 | it("truncates oversized titles to the contract bound", () => { | |
| 329 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 330 | recordVisitForThread(threadRef, "http://a.test/", 1); | |
| 331 | const oversized = "y".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 50); | |
| 332 | setTitleForThreadUrl(threadRef, "http://a.test/", oversized); | |
| 333 | const title = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title; | |
| 334 | expect(title).toHaveLength(BROWSER_HISTORY_MAX_TITLE_LENGTH); | |
| 335 | expect(title).toBe(oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH)); | |
| 336 | }); | |
| 337 | ||
| 338 | it("removes entries", () => { | |
| 339 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 340 | recordVisitForThread(threadRef, "http://a.test/", 1); | |
| 341 | recordVisitForThread(threadRef, "http://b.test/", 2); | |
| 342 | removeUrlForThread(threadRef, "http://a.test/"); | |
| 343 | expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url)).toEqual([ | |
| 344 | "http://b.test/", | |
| 345 | ]); | |
| 346 | }); | |
| 347 | }); | |
| 348 | ||
| 349 | describe("pendingVisitsByThreadKey", () => { | |
| 350 | beforeEach(() => { | |
| 351 | resetBrowserHistoryForTests(); | |
| 352 | }); | |
| 353 | ||
| 354 | it("queues a visit recorded before registration and drains it in order on registration", () => { | |
| 355 | recordVisitForThread(threadRef, "http://a.test/", 1); | |
| 356 | recordVisitForThread(threadRef, "http://b.test/", 2); | |
| 357 | expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); | |
| 358 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 359 | expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url)).toEqual([ | |
| 360 | "http://b.test/", | |
| 361 | "http://a.test/", | |
| 362 | ]); | |
| 363 | expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.lastVisitedAt).toBe(2); | |
| 364 | expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[1]?.lastVisitedAt).toBe(1); | |
| 365 | expect(useBrowserHistoryStore.getState().pendingVisitsByThreadKey).toEqual({}); | |
| 366 | }); | |
| 367 | ||
| 368 | it("caps the per-thread pending list at 10, dropping the oldest", () => { | |
| 369 | for (let i = 0; i < 12; i++) { | |
| 370 | recordVisitForThread(threadRef, `http://a.test/${i}`, i); | |
| 371 | } | |
| 372 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 373 | const urls = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url); | |
| 374 | expect(urls).toHaveLength(10); | |
| 375 | expect(urls).not.toContain("http://a.test/0"); | |
| 376 | expect(urls).not.toContain("http://a.test/1"); | |
| 377 | expect(urls?.[0]).toBe("http://a.test/11"); | |
| 378 | }); | |
| 379 | ||
| 380 | it("slots a replayed visit by timestamp instead of hoisting it above a newer live visit", () => { | |
| 381 | const otherThreadRef = { | |
| 382 | environmentId: EnvironmentId.make("env-1"), | |
| 383 | threadId: ThreadId.make("thread-2"), | |
| 384 | }; | |
| 385 | useBrowserHistoryStore.getState().registerThreadProject(otherThreadRef, "proj-a"); | |
| 386 | recordVisitForThread(otherThreadRef, "http://newer.test/", 2000); | |
| 387 | recordVisitForThread(threadRef, "http://older.test/", 1000); | |
| 388 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 389 | ||
| 390 | const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; | |
| 391 | expect(entries?.map((e) => e.url)).toEqual(["http://newer.test/", "http://older.test/"]); | |
| 392 | // `entries[0]` being the most recent is the invariant `evictExcessProjects` relies on. | |
| 393 | expect(entries?.[0]?.lastVisitedAt).toBe(2000); | |
| 394 | }); | |
| 395 | }); | |
| 396 | ||
| 397 | describe("pendingTitlesByThreadKey", () => { | |
| 398 | beforeEach(() => { | |
| 399 | resetBrowserHistoryForTests(); | |
| 400 | }); | |
| 401 | ||
| 402 | it("buffers a title set before registration and applies it once the matching visit drains", () => { | |
| 403 | recordVisitForThread(threadRef, "http://a.test/", 1); | |
| 404 | setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); | |
| 405 | expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); | |
| 406 | ||
| 407 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 408 | ||
| 409 | const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; | |
| 410 | expect(entries?.[0]).toMatchObject({ url: "http://a.test/", title: "My App" }); | |
| 411 | expect(useBrowserHistoryStore.getState().pendingTitlesByThreadKey).toEqual({}); | |
| 412 | }); | |
| 413 | ||
| 414 | it("preserves environment host matching while a title is pending", () => { | |
| 415 | recordVisitForThread(threadRef, "http://localhost:5173/app", 1); | |
| 416 | setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2"); | |
| 417 | useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); | |
| 418 | expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App"); | |
| 419 | }); | |
| 420 | }); | |
| 421 | ||
| 422 | describe("mergeBrowserHistoryState", () => { | |
| 423 | it("sanitizes same-version corrupt persisted data and preserves actions", () => { | |
| 424 | // `migrate` only runs when versions differ; `merge` runs on every rehydrate. | |
| 425 | const current = useBrowserHistoryStore.getState(); | |
| 426 | const merged = mergeBrowserHistoryState( | |
| 427 | { | |
| 428 | byProjectKey: { | |
| 429 | a: [{ url: "ftp://bad.test/", lastVisitedAt: 1 }], | |
| 430 | b: [{ url: "http://ok.test/", lastVisitedAt: 5 }], | |
| 431 | }, | |
| 432 | projectKeyByThreadKey: { good: "b", stale: "a", malformed: 42 }, | |
| 433 | }, | |
| 434 | current, | |
| 435 | ); | |
| 436 | expect(merged.byProjectKey).toEqual({ | |
| 437 | b: [{ url: "http://ok.test/", lastVisitedAt: 5 }], | |
| 438 | }); | |
| 439 | expect(typeof merged.recordVisit).toBe("function"); | |
| 440 | expect(merged.projectKeyByThreadKey).toEqual({ good: "b" }); | |
| 441 | expect(merged.pendingVisitsByThreadKey).toEqual({}); | |
| 442 | expect(merged.pendingTitlesByThreadKey).toEqual({}); | |
| 443 | }); | |
| 444 | }); |
apps/web/src/browserHistoryStore.ts created+398| ... | ... | @@ -0,0 +1,398 @@ |
| 1 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | |
| 2 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 3 | import { create } from "zustand"; | |
| 4 | import { createJSONStorage, persist } from "zustand/middleware"; | |
| 5 | import { useShallow } from "zustand/react/shallow"; | |
| 6 | ||
| 7 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; | |
| 8 | import { readPreparedConnection } from "~/state/session"; | |
| 9 | ||
| 10 | import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver"; | |
| 11 | import { resolveStorage } from "./lib/storage"; | |
| 12 | ||
| 13 | export type BrowserHistoryEntry = { url: string; lastVisitedAt: number; title?: string }; | |
| 14 | ||
| 15 | export const BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT = 50; | |
| 16 | export const BROWSER_HISTORY_MAX_PROJECTS = 20; | |
| 17 | export const BROWSER_HISTORY_MAX_URL_LENGTH = 2048; | |
| 18 | export const BROWSER_HISTORY_MAX_TITLE_LENGTH = 512; | |
| 19 | const MAX_VALID_DATE_MS = 8_640_000_000_000_000; | |
| 20 | ||
| 21 | export function isValidHistoryTimestamp(value: unknown): value is number { | |
| 22 | return ( | |
| 23 | typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= MAX_VALID_DATE_MS | |
| 24 | ); | |
| 25 | } | |
| 26 | ||
| 27 | export function normalizeHistoryUrl(raw: string): string | null { | |
| 28 | let parsed: URL; | |
| 29 | try { | |
| 30 | parsed = new URL(normalizePreviewUrl(raw)); | |
| 31 | } catch { | |
| 32 | return null; | |
| 33 | } | |
| 34 | parsed.username = parsed.password = ""; | |
| 35 | return parsed.href.length > BROWSER_HISTORY_MAX_URL_LENGTH ? null : parsed.href; | |
| 36 | } | |
| 37 | ||
| 38 | export function titleLookupKey(normalized: string, environmentHostname?: string | null): string { | |
| 39 | const parsed = new URL(visitLookupKey(normalized, environmentHostname)); | |
| 40 | if (parsed.pathname !== "/" && parsed.pathname.endsWith("/")) | |
| 41 | parsed.pathname = parsed.pathname.slice(0, -1); | |
| 42 | return parsed.href; | |
| 43 | } | |
| 44 | ||
| 45 | function visitLookupKey(normalized: string, environmentHostname?: string | null): string { | |
| 46 | const parsed = new URL(normalized); | |
| 47 | const host = normalizeHostname(parsed.hostname); | |
| 48 | const environmentHost = environmentHostname && normalizeHostname(environmentHostname); | |
| 49 | if (isLocalLoopbackHost(host) || host === "0.0.0.0" || host === environmentHost) | |
| 50 | parsed.hostname = "local"; | |
| 51 | return parsed.href; | |
| 52 | } | |
| 53 | ||
| 54 | function isStableLocalUrl(normalized: string): boolean { | |
| 55 | const host = normalizeHostname(new URL(normalized).hostname); | |
| 56 | return isLocalLoopbackHost(host) || host === "0.0.0.0"; | |
| 57 | } | |
| 58 | ||
| 59 | export function upsertHistoryEntry( | |
| 60 | entries: ReadonlyArray<BrowserHistoryEntry>, | |
| 61 | url: string, | |
| 62 | at: number, | |
| 63 | options?: { insertOrdered?: boolean; environmentHostname?: string | null }, | |
| 64 | ): BrowserHistoryEntry[] { | |
| 65 | const key = visitLookupKey(url, options?.environmentHostname); | |
| 66 | const existing = entries.find( | |
| 67 | (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) === key, | |
| 68 | ); | |
| 69 | const rest = entries.filter( | |
| 70 | (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) !== key, | |
| 71 | ); | |
| 72 | const visitedAt = | |
| 73 | options?.insertOrdered && existing && existing.lastVisitedAt > at ? existing.lastVisitedAt : at; | |
| 74 | const storedUrl = | |
| 75 | existing && (isStableLocalUrl(existing.url) || !isStableLocalUrl(url)) ? existing.url : url; | |
| 76 | const entry: BrowserHistoryEntry = existing | |
| 77 | ? { ...existing, url: storedUrl, lastVisitedAt: visitedAt } | |
| 78 | : { url, lastVisitedAt: visitedAt }; | |
| 79 | if (!options?.insertOrdered) | |
| 80 | return [entry, ...rest].slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); | |
| 81 | const index = rest.findIndex((candidate) => candidate.lastVisitedAt < entry.lastVisitedAt); | |
| 82 | const next = index === -1 ? [...rest, entry] : rest.toSpliced(index, 0, entry); | |
| 83 | return next.slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); | |
| 84 | } | |
| 85 | ||
| 86 | export function evictExcessProjects( | |
| 87 | byProjectKey: Record<string, BrowserHistoryEntry[]>, | |
| 88 | ): Record<string, BrowserHistoryEntry[]> { | |
| 89 | const keys = Object.keys(byProjectKey); | |
| 90 | if (keys.length <= BROWSER_HISTORY_MAX_PROJECTS) return byProjectKey; | |
| 91 | const kept = keys | |
| 92 | .toSorted( | |
| 93 | (a, b) => | |
| 94 | (byProjectKey[b]?.[0]?.lastVisitedAt ?? 0) - (byProjectKey[a]?.[0]?.lastVisitedAt ?? 0), | |
| 95 | ) | |
| 96 | .slice(0, BROWSER_HISTORY_MAX_PROJECTS); | |
| 97 | return Object.fromEntries(kept.map((key) => [key, byProjectKey[key] ?? []])); | |
| 98 | } | |
| 99 | ||
| 100 | export function migratePersistedBrowserHistoryState(persistedState: unknown): { | |
| 101 | byProjectKey: Record<string, BrowserHistoryEntry[]>; | |
| 102 | } { | |
| 103 | if (!persistedState || typeof persistedState !== "object") return { byProjectKey: {} }; | |
| 104 | const raw = (persistedState as { byProjectKey?: unknown }).byProjectKey; | |
| 105 | if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byProjectKey: {} }; | |
| 106 | const byProjectKey: Record<string, BrowserHistoryEntry[]> = {}; | |
| 107 | for (const [projectKey, value] of Object.entries(raw as Record<string, unknown>)) { | |
| 108 | if (!Array.isArray(value)) continue; | |
| 109 | const seenUrls = new Set<string>(); | |
| 110 | const entries = value | |
| 111 | .flatMap<BrowserHistoryEntry>((candidate) => { | |
| 112 | if (!candidate || typeof candidate !== "object") return []; | |
| 113 | const { url, lastVisitedAt, title } = candidate as Record<string, unknown>; | |
| 114 | if (typeof url !== "string") return []; | |
| 115 | const normalizedUrl = normalizeHistoryUrl(url); | |
| 116 | if (!normalizedUrl) return []; | |
| 117 | if (!isValidHistoryTimestamp(lastVisitedAt)) return []; | |
| 118 | return [ | |
| 119 | { | |
| 120 | url: normalizedUrl, | |
| 121 | lastVisitedAt, | |
| 122 | ...(typeof title === "string" && title.length > 0 | |
| 123 | ? { title: title.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH) } | |
| 124 | : {}), | |
| 125 | }, | |
| 126 | ]; | |
| 127 | }) | |
| 128 | .toSorted((a, b) => b.lastVisitedAt - a.lastVisitedAt) | |
| 129 | .filter((entry) => { | |
| 130 | const key = visitLookupKey(entry.url); | |
| 131 | if (seenUrls.has(key)) return false; | |
| 132 | seenUrls.add(key); | |
| 133 | return true; | |
| 134 | }) | |
| 135 | .slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); | |
| 136 | if (entries.length > 0) byProjectKey[projectKey] = entries; | |
| 137 | } | |
| 138 | return { byProjectKey: evictExcessProjects(byProjectKey) }; | |
| 139 | } | |
| 140 | ||
| 141 | const BROWSER_HISTORY_STORAGE_KEY = "t3code:browser-history:v1"; | |
| 142 | ||
| 143 | const PENDING_MAX_PER_THREAD = 10; | |
| 144 | const PENDING_MAX_THREADS = 20; | |
| 145 | ||
| 146 | type PendingVisit = { url: string; at: number; environmentHostname: string | null }; | |
| 147 | type PendingTitle = { url: string; title: string; environmentHostname: string | null | undefined }; | |
| 148 | ||
| 149 | interface BrowserHistoryStoreState { | |
| 150 | byProjectKey: Record<string, BrowserHistoryEntry[]>; | |
| 151 | projectKeyByThreadKey: Record<string, string>; | |
| 152 | pendingVisitsByThreadKey: Record<string, PendingVisit[]>; | |
| 153 | pendingTitlesByThreadKey: Record<string, PendingTitle[]>; | |
| 154 | recordVisit: ( | |
| 155 | projectKey: string, | |
| 156 | url: string, | |
| 157 | at: number, | |
| 158 | options?: { insertOrdered?: boolean; environmentHostname?: string | null }, | |
| 159 | ) => void; | |
| 160 | setTitleForUrl: ( | |
| 161 | projectKey: string, | |
| 162 | url: string, | |
| 163 | title: string, | |
| 164 | environmentHostname?: string | null, | |
| 165 | ) => void; | |
| 166 | removeUrl: (projectKey: string, url: string) => void; | |
| 167 | registerThreadProject: (ref: ScopedThreadRef, projectKey: string) => void; | |
| 168 | } | |
| 169 | ||
| 170 | function addPendingByThread<T>( | |
| 171 | pendingByThreadKey: Record<string, T[]>, | |
| 172 | threadKey: string, | |
| 173 | item: T, | |
| 174 | ): Record<string, T[]> { | |
| 175 | const existing = pendingByThreadKey[threadKey] ?? []; | |
| 176 | const next = { ...pendingByThreadKey }; | |
| 177 | next[threadKey] = [...existing, item].slice(-PENDING_MAX_PER_THREAD); | |
| 178 | const keys = Object.keys(next); | |
| 179 | if (keys.length > PENDING_MAX_THREADS) { | |
| 180 | const oldestKey = keys[0]; | |
| 181 | if (oldestKey !== undefined && oldestKey !== threadKey) delete next[oldestKey]; | |
| 182 | } | |
| 183 | return next; | |
| 184 | } | |
| 185 | ||
| 186 | export const useBrowserHistoryStore = create<BrowserHistoryStoreState>()( | |
| 187 | persist( | |
| 188 | (set, get) => ({ | |
| 189 | byProjectKey: {}, | |
| 190 | projectKeyByThreadKey: {}, | |
| 191 | pendingVisitsByThreadKey: {}, | |
| 192 | pendingTitlesByThreadKey: {}, | |
| 193 | recordVisit: (projectKey, url, at, options) => { | |
| 194 | const normalized = normalizeHistoryUrl(url); | |
| 195 | if (!normalized) return; | |
| 196 | set((state) => { | |
| 197 | return { | |
| 198 | byProjectKey: evictExcessProjects({ | |
| 199 | ...state.byProjectKey, | |
| 200 | [projectKey]: upsertHistoryEntry( | |
| 201 | state.byProjectKey[projectKey] ?? [], | |
| 202 | normalized, | |
| 203 | at, | |
| 204 | options, | |
| 205 | ), | |
| 206 | }), | |
| 207 | }; | |
| 208 | }); | |
| 209 | }, | |
| 210 | setTitleForUrl: (projectKey, url, title, environmentHostname) => { | |
| 211 | const normalized = normalizeHistoryUrl(url); | |
| 212 | const state = get(); | |
| 213 | const entries = state.byProjectKey[projectKey]; | |
| 214 | const trimmed = title.trim().slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH); | |
| 215 | if (!normalized || !entries || trimmed.length === 0) return; | |
| 216 | const key = titleLookupKey(normalized, environmentHostname); | |
| 217 | const index = entries.findIndex( | |
| 218 | (candidate) => titleLookupKey(candidate.url, environmentHostname) === key, | |
| 219 | ); | |
| 220 | if (index === -1 || entries[index]?.title === trimmed) return; | |
| 221 | set({ | |
| 222 | byProjectKey: { | |
| 223 | ...state.byProjectKey, | |
| 224 | [projectKey]: entries.map((candidate, candidateIndex) => | |
| 225 | candidateIndex === index ? { ...candidate, title: trimmed } : candidate, | |
| 226 | ), | |
| 227 | }, | |
| 228 | }); | |
| 229 | }, | |
| 230 | removeUrl: (projectKey, url) => { | |
| 231 | const normalized = normalizeHistoryUrl(url); | |
| 232 | const state = get(); | |
| 233 | const entries = state.byProjectKey[projectKey]; | |
| 234 | if (!normalized || !entries) return; | |
| 235 | const next = entries.filter((candidate) => candidate.url !== normalized); | |
| 236 | if (next.length === entries.length) return; | |
| 237 | if (next.length === 0) { | |
| 238 | const { [projectKey]: _removed, ...rest } = state.byProjectKey; | |
| 239 | set({ byProjectKey: rest }); | |
| 240 | return; | |
| 241 | } | |
| 242 | set({ byProjectKey: { ...state.byProjectKey, [projectKey]: next } }); | |
| 243 | }, | |
| 244 | registerThreadProject: (ref, projectKey) => { | |
| 245 | const threadKey = scopedThreadKey(ref); | |
| 246 | const state = get(); | |
| 247 | const pendingVisits = state.pendingVisitsByThreadKey[threadKey]; | |
| 248 | const pendingTitles = state.pendingTitlesByThreadKey[threadKey]; | |
| 249 | if ( | |
| 250 | state.projectKeyByThreadKey[threadKey] === projectKey && | |
| 251 | !pendingVisits && | |
| 252 | !pendingTitles | |
| 253 | ) { | |
| 254 | return; | |
| 255 | } | |
| 256 | const nextPendingVisits = { ...state.pendingVisitsByThreadKey }; | |
| 257 | const nextPendingTitles = { ...state.pendingTitlesByThreadKey }; | |
| 258 | delete nextPendingVisits[threadKey]; | |
| 259 | delete nextPendingTitles[threadKey]; | |
| 260 | set({ | |
| 261 | projectKeyByThreadKey: { ...state.projectKeyByThreadKey, [threadKey]: projectKey }, | |
| 262 | pendingVisitsByThreadKey: nextPendingVisits, | |
| 263 | pendingTitlesByThreadKey: nextPendingTitles, | |
| 264 | }); | |
| 265 | for (const visit of pendingVisits ?? []) | |
| 266 | get().recordVisit(projectKey, visit.url, visit.at, { | |
| 267 | insertOrdered: true, | |
| 268 | environmentHostname: visit.environmentHostname, | |
| 269 | }); | |
| 270 | for (const pendingTitle of pendingTitles ?? []) | |
| 271 | get().setTitleForUrl( | |
| 272 | projectKey, | |
| 273 | pendingTitle.url, | |
| 274 | pendingTitle.title, | |
| 275 | pendingTitle.environmentHostname, | |
| 276 | ); | |
| 277 | }, | |
| 278 | }), | |
| 279 | { | |
| 280 | name: BROWSER_HISTORY_STORAGE_KEY, | |
| 281 | version: 1, | |
| 282 | storage: createJSONStorage(() => | |
| 283 | resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), | |
| 284 | ), | |
| 285 | partialize: (state) => ({ | |
| 286 | byProjectKey: state.byProjectKey, | |
| 287 | projectKeyByThreadKey: state.projectKeyByThreadKey, | |
| 288 | }), | |
| 289 | migrate: migratePersistedBrowserHistoryState, | |
| 290 | merge: mergeBrowserHistoryState, | |
| 291 | }, | |
| 292 | ), | |
| 293 | ); | |
| 294 | ||
| 295 | export function mergeBrowserHistoryState( | |
| 296 | persistedState: unknown, | |
| 297 | currentState: BrowserHistoryStoreState, | |
| 298 | ): BrowserHistoryStoreState { | |
| 299 | const migrated = migratePersistedBrowserHistoryState(persistedState); | |
| 300 | return { | |
| 301 | ...currentState, | |
| 302 | ...migrated, | |
| 303 | projectKeyByThreadKey: migratePersistedThreadProjectKeys(persistedState, migrated.byProjectKey), | |
| 304 | pendingVisitsByThreadKey: {}, | |
| 305 | pendingTitlesByThreadKey: {}, | |
| 306 | }; | |
| 307 | } | |
| 308 | ||
| 309 | function migratePersistedThreadProjectKeys( | |
| 310 | persistedState: unknown, | |
| 311 | byProjectKey: Record<string, BrowserHistoryEntry[]>, | |
| 312 | ): Record<string, string> { | |
| 313 | if (!persistedState || typeof persistedState !== "object") return {}; | |
| 314 | const raw = (persistedState as { projectKeyByThreadKey?: unknown }).projectKeyByThreadKey; | |
| 315 | if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; | |
| 316 | return Object.fromEntries( | |
| 317 | Object.entries(raw as Record<string, unknown>) | |
| 318 | .filter( | |
| 319 | (entry): entry is [string, string] => | |
| 320 | typeof entry[1] === "string" && entry[1] in byProjectKey, | |
| 321 | ) | |
| 322 | .slice(-100), | |
| 323 | ); | |
| 324 | } | |
| 325 | ||
| 326 | export function recordVisitForThread(ref: ScopedThreadRef, url: string, at?: number): void { | |
| 327 | const threadKey = scopedThreadKey(ref); | |
| 328 | const state = useBrowserHistoryStore.getState(); | |
| 329 | const projectKey = state.projectKeyByThreadKey[threadKey]; | |
| 330 | const visitAt = at ?? Date.now(); | |
| 331 | const connection = readPreparedConnection(ref.environmentId); | |
| 332 | const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null; | |
| 333 | if (!projectKey) { | |
| 334 | useBrowserHistoryStore.setState({ | |
| 335 | pendingVisitsByThreadKey: addPendingByThread(state.pendingVisitsByThreadKey, threadKey, { | |
| 336 | url, | |
| 337 | at: visitAt, | |
| 338 | environmentHostname, | |
| 339 | }), | |
| 340 | }); | |
| 341 | return; | |
| 342 | } | |
| 343 | state.recordVisit(projectKey, url, visitAt, { environmentHostname }); | |
| 344 | } | |
| 345 | ||
| 346 | export function setTitleForThreadUrl( | |
| 347 | ref: ScopedThreadRef, | |
| 348 | url: string, | |
| 349 | title: string, | |
| 350 | environmentHostname?: string | null, | |
| 351 | ): void { | |
| 352 | const threadKey = scopedThreadKey(ref); | |
| 353 | const state = useBrowserHistoryStore.getState(); | |
| 354 | const projectKey = state.projectKeyByThreadKey[threadKey]; | |
| 355 | if (!projectKey) { | |
| 356 | useBrowserHistoryStore.setState({ | |
| 357 | pendingTitlesByThreadKey: addPendingByThread(state.pendingTitlesByThreadKey, threadKey, { | |
| 358 | url, | |
| 359 | title, | |
| 360 | environmentHostname, | |
| 361 | }), | |
| 362 | }); | |
| 363 | return; | |
| 364 | } | |
| 365 | state.setTitleForUrl(projectKey, url, title, environmentHostname); | |
| 366 | } | |
| 367 | ||
| 368 | export function removeUrlForThread(ref: ScopedThreadRef, url: string): void { | |
| 369 | const state = useBrowserHistoryStore.getState(); | |
| 370 | const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; | |
| 371 | if (!projectKey) return; | |
| 372 | state.removeUrl(projectKey, url); | |
| 373 | } | |
| 374 | ||
| 375 | const EMPTY_HISTORY: ReadonlyArray<BrowserHistoryEntry> = []; | |
| 376 | ||
| 377 | export function useThreadRecentHistory( | |
| 378 | ref: ScopedThreadRef, | |
| 379 | limit: number, | |
| 380 | ): ReadonlyArray<BrowserHistoryEntry> { | |
| 381 | return useBrowserHistoryStore( | |
| 382 | useShallow((state) => { | |
| 383 | const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; | |
| 384 | const entries = projectKey ? state.byProjectKey[projectKey] : undefined; | |
| 385 | return entries && entries.length > 0 ? entries.slice(0, limit) : EMPTY_HISTORY; | |
| 386 | }), | |
| 387 | ); | |
| 388 | } | |
| 389 | ||
| 390 | export function resetBrowserHistoryForTests(): void { | |
| 391 | useBrowserHistoryStore.setState({ | |
| 392 | byProjectKey: {}, | |
| 393 | projectKeyByThreadKey: {}, | |
| 394 | pendingVisitsByThreadKey: {}, | |
| 395 | pendingTitlesByThreadKey: {}, | |
| 396 | }); | |
| 397 | useBrowserHistoryStore.persist.clearStorage(); | |
| 398 | } |
apps/web/src/components/ChatMarkdown.tsx+5-1| ... | ... | @@ -52,6 +52,7 @@ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsi |
| 52 | 52 | import { ScrollArea } from "./ui/scroll-area"; |
| 53 | 53 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; |
| 54 | 54 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 55 | import { recordVisitForThread } from "../browserHistoryStore"; | |
| 55 | 56 | import { useOpenInPreferredEditor } from "../editorPreferences"; |
| 56 | 57 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 57 | 58 | import { fnv1a32 } from "../lib/diffRendering"; |
| ... | ... | @@ -1336,7 +1337,10 @@ function ChatMarkdown({ |
| 1336 | 1337 | ), |
| 1337 | 1338 | ); |
| 1338 | 1339 | } |
| 1339 | return openUrlInPreview({ threadRef, url, openPreview }); | |
| 1340 | return openUrlInPreview({ threadRef, url, openPreview }).then((result) => { | |
| 1341 | if (result._tag === "Success") recordVisitForThread(threadRef, url); | |
| 1342 | return result; | |
| 1343 | }); | |
| 1340 | 1344 | }, |
| 1341 | 1345 | [openPreview, threadRef], |
| 1342 | 1346 | ); |
apps/web/src/components/ChatView.tsx+41-4| ... | ... | @@ -172,9 +172,14 @@ import { |
| 172 | 172 | projectScriptIdFromCommand, |
| 173 | 173 | } from "~/projectScripts"; |
| 174 | 174 | import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; |
| 175 | import { useBrowserHistoryStore } from "~/browserHistoryStore"; | |
| 175 | 176 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; |
| 176 | 177 | import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; |
| 177 | import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; | |
| 178 | import { | |
| 179 | useClientSettings, | |
| 180 | useClientSettingsHydrated, | |
| 181 | useEnvironmentSettings, | |
| 182 | } from "../hooks/useSettings"; | |
| 178 | 183 | import { useNowMinute } from "../hooks/useNowMinute"; |
| 179 | 184 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; |
| 180 | 185 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| ... | ... | @@ -182,9 +187,11 @@ import { getTerminalFocusOwner } from "../lib/terminalFocus"; |
| 182 | 187 | import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; |
| 183 | 188 | import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; |
| 184 | 189 | import { |
| 190 | derivePhysicalProjectKey, | |
| 185 | 191 | deriveLogicalProjectKeyFromSettings, |
| 186 | 192 | selectProjectGroupingSettings, |
| 187 | 193 | } from "../logicalProject"; |
| 194 | import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; | |
| 188 | 195 | import { buildDraftThreadRouteParams } from "../threadRoutes"; |
| 189 | 196 | import { |
| 190 | 197 | type ComposerImageAttachment, |
| ... | ... | @@ -1491,6 +1498,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 1491 | 1498 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; |
| 1492 | 1499 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; |
| 1493 | 1500 | const activeThreadId = activeThread?.id ?? null; |
| 1501 | const activeThreadEnvironmentId = activeThread?.environmentId ?? null; | |
| 1494 | 1502 | const runningTerminalIds = useThreadRunningTerminalIds({ |
| 1495 | 1503 | environmentId: activeThread?.environmentId ?? null, |
| 1496 | 1504 | threadId: activeThreadId, |
| ... | ... | @@ -1526,8 +1534,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 1526 | 1534 | return labels; |
| 1527 | 1535 | }, [activeThreadKnownSessions]); |
| 1528 | 1536 | const activeThreadRef = useMemo( |
| 1529 | () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), | |
| 1530 | [activeThread], | |
| 1537 | () => | |
| 1538 | activeThreadEnvironmentId && activeThreadId | |
| 1539 | ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) | |
| 1540 | : null, | |
| 1541 | [activeThreadEnvironmentId, activeThreadId], | |
| 1531 | 1542 | ); |
| 1532 | 1543 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1533 | 1544 | const [timelineAnchor, setTimelineAnchor] = useState<{ |
| ... | ... | @@ -1652,6 +1663,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 1652 | 1663 | const activeProjectKey = activeProject |
| 1653 | 1664 | ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` |
| 1654 | 1665 | : null; |
| 1666 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); | |
| 1667 | const clientSettingsHydrated = useClientSettingsHydrated(); | |
| 1655 | 1668 | const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< |
| 1656 | 1669 | ReadonlyMap<string, ReadonlySet<string>> |
| 1657 | 1670 | >(() => new Map()); |
| ... | ... | @@ -1690,6 +1703,31 @@ function ChatViewContent(props: ChatViewProps) { |
| 1690 | 1703 | // drive the environment picker in BranchToolbar. |
| 1691 | 1704 | const allProjects = useProjects(); |
| 1692 | 1705 | const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; |
| 1706 | useEffect(() => { | |
| 1707 | if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return; | |
| 1708 | // Reuse the sidebar's grouping so history follows the project rows the user | |
| 1709 | // sees. Deriving the key from the active project alone would miss the | |
| 1710 | // identity a duplicate row borrows from its siblings. | |
| 1711 | const logicalKeyByPhysicalKey = buildPhysicalToLogicalProjectKeyMap({ | |
| 1712 | projects: allProjects, | |
| 1713 | settings: projectGroupingSettings, | |
| 1714 | primaryEnvironmentId, | |
| 1715 | }); | |
| 1716 | useBrowserHistoryStore | |
| 1717 | .getState() | |
| 1718 | .registerThreadProject( | |
| 1719 | activeThreadRef, | |
| 1720 | logicalKeyByPhysicalKey.get(derivePhysicalProjectKey(activeProject)) ?? | |
| 1721 | deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings), | |
| 1722 | ); | |
| 1723 | }, [ | |
| 1724 | activeProject, | |
| 1725 | activeThreadRef, | |
| 1726 | allProjects, | |
| 1727 | clientSettingsHydrated, | |
| 1728 | primaryEnvironmentId, | |
| 1729 | projectGroupingSettings, | |
| 1730 | ]); | |
| 1693 | 1731 | const activeEnvironment = |
| 1694 | 1732 | activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); |
| 1695 | 1733 | const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; |
| ... | ... | @@ -1723,7 +1761,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 1723 | 1761 | }, |
| 1724 | 1762 | [retryEnvironment], |
| 1725 | 1763 | ); |
| 1726 | const projectGroupingSettings = selectProjectGroupingSettings(settings); | |
| 1727 | 1764 | const logicalProjectEnvironments = useMemo(() => { |
| 1728 | 1765 | if (!activeProject) return []; |
| 1729 | 1766 | const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); |
apps/web/src/components/preview/BrowserMockup.tsx+1-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { cn } from "~/lib/utils"; |
| 2 | 2 | |
| 3 | /** Browser-window thumbnail glyph for the "Local" recommendation cards. */ | |
| 3 | /** Browser-window thumbnail glyph for preview recommendation cards. */ | |
| 4 | 4 | export function BrowserMockup({ className }: { className?: string }) { |
| 5 | 5 | return ( |
| 6 | 6 | <div |
apps/web/src/components/preview/PreviewEmptyState.test.tsx created+89| ... | ... | @@ -0,0 +1,89 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 3 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | const mocks = vi.hoisted(() => ({ | |
| 6 | servers: [] as Array<{ | |
| 7 | host: string; | |
| 8 | port: number; | |
| 9 | url: string; | |
| 10 | requestedUrl: string; | |
| 11 | processName: string | null; | |
| 12 | pid: number | null; | |
| 13 | terminal: null; | |
| 14 | source: "scanner"; | |
| 15 | listening: boolean; | |
| 16 | }>, | |
| 17 | })); | |
| 18 | ||
| 19 | vi.mock("./useDiscoveredLocalServers", () => ({ | |
| 20 | useDiscoveredLocalServers: () => mocks.servers, | |
| 21 | })); | |
| 22 | ||
| 23 | import { PreviewEmptyState } from "./PreviewEmptyState"; | |
| 24 | ||
| 25 | const environmentId = EnvironmentId.make("env-1"); | |
| 26 | ||
| 27 | function server(port: number) { | |
| 28 | return { | |
| 29 | host: "localhost", | |
| 30 | port, | |
| 31 | url: `http://localhost:${port}`, | |
| 32 | requestedUrl: `http://localhost:${port}`, | |
| 33 | processName: "node", | |
| 34 | pid: 1, | |
| 35 | terminal: null, | |
| 36 | source: "scanner" as const, | |
| 37 | listening: true, | |
| 38 | }; | |
| 39 | } | |
| 40 | ||
| 41 | function render(recentEntries: Array<{ url: string; lastVisitedAt: number; title?: string }>) { | |
| 42 | return renderToStaticMarkup( | |
| 43 | <PreviewEmptyState | |
| 44 | environmentId={environmentId} | |
| 45 | recentEntries={recentEntries} | |
| 46 | onRemoveRecent={() => undefined} | |
| 47 | onOpenUrl={() => undefined} | |
| 48 | />, | |
| 49 | ); | |
| 50 | } | |
| 51 | ||
| 52 | describe("PreviewEmptyState", () => { | |
| 53 | it("renders a history entry in both groups when its host:port matches a live server", () => { | |
| 54 | mocks.servers = [server(5173)]; | |
| 55 | const html = render([ | |
| 56 | { url: "https://myapp.test/admin#users", lastVisitedAt: Date.now(), title: "Admin" }, | |
| 57 | { url: "http://localhost:5173/", lastVisitedAt: Date.now(), title: "Recent Local" }, | |
| 58 | ]); | |
| 59 | expect(html).toContain("Recently used"); | |
| 60 | expect(html).toContain("Local servers"); | |
| 61 | expect(html).toContain("myapp.test/admin#users"); | |
| 62 | expect(html).toContain("Admin"); | |
| 63 | expect(html).toContain("Recent Local"); | |
| 64 | expect(html).toContain("node"); | |
| 65 | }); | |
| 66 | ||
| 67 | it("renders only the recents group when no servers are found", () => { | |
| 68 | mocks.servers = []; | |
| 69 | const html = render([{ url: "https://myapp.test/", lastVisitedAt: 0 }]); | |
| 70 | expect(html).toContain("Recently used"); | |
| 71 | expect(html).not.toContain("Local servers"); | |
| 72 | }); | |
| 73 | ||
| 74 | it("keeps the original empty state when both groups are empty", () => { | |
| 75 | mocks.servers = []; | |
| 76 | const html = render([]); | |
| 77 | expect(html).toContain("No preview yet"); | |
| 78 | }); | |
| 79 | ||
| 80 | it("renders an out-of-range lastVisitedAt entry without throwing", () => { | |
| 81 | mocks.servers = []; | |
| 82 | let html = ""; | |
| 83 | expect(() => { | |
| 84 | html = render([{ url: "https://myapp.test/", lastVisitedAt: 1e20 }]); | |
| 85 | }).not.toThrow(); | |
| 86 | expect(html).toContain("myapp.test"); | |
| 87 | expect(html).toContain("Remove"); | |
| 88 | }); | |
| 89 | }); |
apps/web/src/components/preview/PreviewEmptyState.tsx+48-19| ... | ... | @@ -1,15 +1,19 @@ |
| 1 | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { Globe, RadioTower } from "lucide-react"; | |
| 2 | import { Globe, History, RadioTower } from "lucide-react"; | |
| 3 | 3 | |
| 4 | import type { BrowserHistoryEntry } from "~/browserHistoryStore"; | |
| 4 | 5 | import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty"; |
| 5 | 6 | |
| 6 | 7 | import { PreviewLocalServerCard } from "./PreviewLocalServerCard"; |
| 8 | import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard"; | |
| 7 | 9 | import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers"; |
| 8 | 10 | |
| 9 | 11 | interface Props { |
| 10 | 12 | environmentId: EnvironmentId; |
| 11 | 13 | configuredUrls?: ReadonlyArray<string> | undefined; |
| 12 | 14 | recentlySeenUrls?: ReadonlyArray<string> | undefined; |
| 15 | recentEntries: ReadonlyArray<BrowserHistoryEntry>; | |
| 16 | onRemoveRecent: (url: string) => void; | |
| 13 | 17 | onOpenUrl: (url: string) => void; |
| 14 | 18 | } |
| 15 | 19 | |
| ... | ... | @@ -17,6 +21,8 @@ export function PreviewEmptyState({ |
| 17 | 21 | environmentId, |
| 18 | 22 | configuredUrls, |
| 19 | 23 | recentlySeenUrls, |
| 24 | recentEntries, | |
| 25 | onRemoveRecent, | |
| 20 | 26 | onOpenUrl, |
| 21 | 27 | }: Props) { |
| 22 | 28 | const servers = useDiscoveredLocalServers({ |
| ... | ... | @@ -24,8 +30,9 @@ export function PreviewEmptyState({ |
| 24 | 30 | configuredUrls, |
| 25 | 31 | recentlySeenUrls, |
| 26 | 32 | }); |
| 33 | const recents = recentEntries.filter((entry) => URL.canParse(entry.url)).slice(0, 8); | |
| 27 | 34 | |
| 28 | if (servers.length === 0) { | |
| 35 | if (servers.length === 0 && recents.length === 0) { | |
| 29 | 36 | return ( |
| 30 | 37 | <Empty> |
| 31 | 38 | <EmptyMedia variant="icon"> |
| ... | ... | @@ -42,23 +49,45 @@ export function PreviewEmptyState({ |
| 42 | 49 | |
| 43 | 50 | return ( |
| 44 | 51 | <div className="flex h-full min-h-0 overflow-y-auto px-5 py-8"> |
| 45 | <div className="m-auto flex w-full max-w-xl flex-col gap-3"> | |
| 46 | <div className="flex items-center gap-2 text-sm text-muted-foreground"> | |
| 47 | <RadioTower className="size-4 shrink-0" /> | |
| 48 | <h2 className="font-medium">Local servers</h2> | |
| 49 | </div> | |
| 50 | <div className="flex flex-col divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70 bg-background"> | |
| 51 | {servers.map((server) => ( | |
| 52 | <PreviewLocalServerCard | |
| 53 | key={`${server.host}:${server.port}`} | |
| 54 | server={server} | |
| 55 | onOpen={() => onOpenUrl(server.url)} | |
| 56 | /> | |
| 57 | ))} | |
| 58 | </div> | |
| 59 | <p className="px-1 text-xs text-muted-foreground"> | |
| 60 | Select a listening port to open it in this browser tab. | |
| 61 | </p> | |
| 52 | <div className="m-auto flex w-full max-w-xl flex-col gap-6"> | |
| 53 | {recents.length > 0 ? ( | |
| 54 | <div className="flex flex-col gap-3"> | |
| 55 | <div className="flex items-center gap-2 text-sm text-muted-foreground"> | |
| 56 | <History className="size-4 shrink-0" /> | |
| 57 | <h2 className="font-medium">Recently used</h2> | |
| 58 | </div> | |
| 59 | <div className="flex flex-col divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70 bg-background"> | |
| 60 | {recents.map((entry) => ( | |
| 61 | <PreviewRecentUrlCard | |
| 62 | key={entry.url} | |
| 63 | entry={entry} | |
| 64 | onOpen={() => onOpenUrl(entry.url)} | |
| 65 | onRemove={() => onRemoveRecent(entry.url)} | |
| 66 | /> | |
| 67 | ))} | |
| 68 | </div> | |
| 69 | </div> | |
| 70 | ) : null} | |
| 71 | {servers.length > 0 ? ( | |
| 72 | <div className="flex flex-col gap-3"> | |
| 73 | <div className="flex items-center gap-2 text-sm text-muted-foreground"> | |
| 74 | <RadioTower className="size-4 shrink-0" /> | |
| 75 | <h2 className="font-medium">Local servers</h2> | |
| 76 | </div> | |
| 77 | <div className="flex flex-col divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70 bg-background"> | |
| 78 | {servers.map((server) => ( | |
| 79 | <PreviewLocalServerCard | |
| 80 | key={`${server.host}:${server.port}`} | |
| 81 | server={server} | |
| 82 | onOpen={() => onOpenUrl(server.requestedUrl)} | |
| 83 | /> | |
| 84 | ))} | |
| 85 | </div> | |
| 86 | <p className="px-1 text-xs text-muted-foreground"> | |
| 87 | Select a listening port to open it in this browser tab. | |
| 88 | </p> | |
| 89 | </div> | |
| 90 | ) : null} | |
| 62 | 91 | </div> |
| 63 | 92 | </div> |
| 64 | 93 | ); |
apps/web/src/components/preview/PreviewRecentUrlCard.tsx created+51| ... | ... | @@ -0,0 +1,51 @@ |
| 1 | import { X } from "lucide-react"; | |
| 2 | ||
| 3 | import { isValidHistoryTimestamp, type BrowserHistoryEntry } from "~/browserHistoryStore"; | |
| 4 | import { useNowMinute } from "~/hooks/useNowMinute"; | |
| 5 | import { formatRelativeTimeLabel } from "~/timestampFormat"; | |
| 6 | ||
| 7 | import { BrowserMockup } from "./BrowserMockup"; | |
| 8 | ||
| 9 | interface Props { | |
| 10 | entry: BrowserHistoryEntry; | |
| 11 | onOpen: () => void; | |
| 12 | onRemove: () => void; | |
| 13 | } | |
| 14 | ||
| 15 | export function PreviewRecentUrlCard({ entry, onOpen, onRemove }: Props) { | |
| 16 | const parsed = new URL(entry.url); | |
| 17 | const path = parsed.pathname === "/" ? "" : parsed.pathname; | |
| 18 | const label = `${parsed.host}${path}${parsed.search}${parsed.hash}`; | |
| 19 | const visitedAt = isValidHistoryTimestamp(entry.lastVisitedAt) | |
| 20 | ? formatRelativeTimeLabel(new Date(entry.lastVisitedAt).toISOString()) | |
| 21 | : ""; | |
| 22 | useNowMinute(); | |
| 23 | return ( | |
| 24 | <div className="group relative flex w-full items-center"> | |
| 25 | <button | |
| 26 | type="button" | |
| 27 | onClick={onOpen} | |
| 28 | className="flex w-full items-center gap-3 px-3 py-3 pr-10 text-left hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" | |
| 29 | > | |
| 30 | <BrowserMockup className="size-7 shrink-0" /> | |
| 31 | <div className="flex min-w-0 flex-1 flex-col"> | |
| 32 | <span className="truncate text-sm font-medium text-foreground"> | |
| 33 | {entry.title ?? label} | |
| 34 | </span> | |
| 35 | <span className="truncate text-xs text-muted-foreground"> | |
| 36 | {entry.title ? `${label} · ` : ""} | |
| 37 | {visitedAt} | |
| 38 | </span> | |
| 39 | </div> | |
| 40 | </button> | |
| 41 | <button | |
| 42 | type="button" | |
| 43 | aria-label={`Remove ${label} from history`} | |
| 44 | onClick={onRemove} | |
| 45 | className="absolute right-3 rounded p-1 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100" | |
| 46 | > | |
| 47 | <X className="size-3.5" /> | |
| 48 | </button> | |
| 49 | </div> | |
| 50 | ); | |
| 51 | } |
apps/web/src/components/preview/PreviewView.test.tsx+39| ... | ... | @@ -24,6 +24,17 @@ const mocks = vi.hoisted(() => ({ |
| 24 | 24 | toggleAnnotation: null as (() => void) | null, |
| 25 | 25 | pictureInPicture: false, |
| 26 | 26 | showEmptyState: false, |
| 27 | recordVisitForThread: vi.fn(), | |
| 28 | })); | |
| 29 | ||
| 30 | const EMPTY_HISTORY: never[] = []; | |
| 31 | ||
| 32 | vi.mock("~/browserHistoryStore", () => ({ | |
| 33 | recordVisitForThread: mocks.recordVisitForThread, | |
| 34 | setTitleForThreadUrl: vi.fn(), | |
| 35 | removeUrlForThread: vi.fn(), | |
| 36 | BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT: 50, | |
| 37 | useThreadRecentHistory: () => EMPTY_HISTORY, | |
| 27 | 38 | })); |
| 28 | 39 | |
| 29 | 40 | vi.mock("~/state/session", () => ({ |
| ... | ... | @@ -232,6 +243,7 @@ describe("PreviewView navigation", () => { |
| 232 | 243 | mocks.toggleAnnotation = null; |
| 233 | 244 | mocks.pictureInPicture = false; |
| 234 | 245 | mocks.showEmptyState = false; |
| 246 | mocks.recordVisitForThread.mockClear(); | |
| 235 | 247 | }); |
| 236 | 248 | |
| 237 | 249 | it.each([ |
| ... | ... | @@ -267,6 +279,27 @@ describe("PreviewView navigation", () => { |
| 267 | 279 | ); |
| 268 | 280 | }); |
| 269 | 281 | |
| 282 | it("records a history visit with the normalized requested url on submit", async () => { | |
| 283 | renderToStaticMarkup( | |
| 284 | <PreviewView | |
| 285 | threadRef={{ | |
| 286 | environmentId: EnvironmentId.make("environment-1"), | |
| 287 | threadId: ThreadId.make("thread-1"), | |
| 288 | }} | |
| 289 | tabId="tab-1" | |
| 290 | visible | |
| 291 | />, | |
| 292 | ); | |
| 293 | ||
| 294 | mocks.submittedUrl?.("localhost:3000/admin"); | |
| 295 | await vi.waitFor(() => { | |
| 296 | expect(mocks.recordVisitForThread).toHaveBeenCalledWith( | |
| 297 | expect.objectContaining({ threadId: expect.anything() }), | |
| 298 | "http://localhost:3000/admin", | |
| 299 | ); | |
| 300 | }); | |
| 301 | }); | |
| 302 | ||
| 270 | 303 | it("maps an empty-state localhost server onto the WSL host", async () => { |
| 271 | 304 | mocks.showEmptyState = true; |
| 272 | 305 | renderToStaticMarkup( |
| ... | ... | @@ -296,6 +329,12 @@ describe("PreviewView navigation", () => { |
| 296 | 329 | }, |
| 297 | 330 | "http://172.25.85.75:5173/app?mode=test#top", |
| 298 | 331 | ); |
| 332 | await vi.waitFor(() => | |
| 333 | expect(mocks.recordVisitForThread).toHaveBeenCalledWith( | |
| 334 | expect.objectContaining({ threadId: expect.anything() }), | |
| 335 | "http://localhost:5173/app?mode=test#top", | |
| 336 | ), | |
| 337 | ); | |
| 299 | 338 | }); |
| 300 | 339 | |
| 301 | 340 | it("opens and closes a thread-scoped floating preview for the active tab", async () => { |
apps/web/src/components/preview/PreviewView.tsx+47-12| ... | ... | @@ -11,6 +11,13 @@ import { |
| 11 | 11 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 12 | 12 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 13 | 13 | |
| 14 | import { | |
| 15 | BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, | |
| 16 | recordVisitForThread, | |
| 17 | removeUrlForThread, | |
| 18 | setTitleForThreadUrl, | |
| 19 | useThreadRecentHistory, | |
| 20 | } from "~/browserHistoryStore"; | |
| 14 | 21 | import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore"; |
| 15 | 22 | import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; |
| 16 | 23 | import { ensureLocalApi } from "~/localApi"; |
| ... | ... | @@ -20,6 +27,7 @@ import { |
| 20 | 27 | useThreadPreviewState, |
| 21 | 28 | } from "~/previewStateStore"; |
| 22 | 29 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 30 | import { useEnvironmentHttpBaseUrl } from "~/state/environments"; | |
| 23 | 31 | import { previewEnvironment } from "~/state/preview"; |
| 24 | 32 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 25 | 33 | import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; |
| ... | ... | @@ -83,12 +91,24 @@ export function PreviewView({ |
| 83 | 91 | const activeRecordingTabIds = useActiveBrowserRecordingTabIds(); |
| 84 | 92 | const pickActiveRef = useRef(false); |
| 85 | 93 | const isMountedRef = useRef(true); |
| 94 | // Kept in sync so the title effect can depend on the stable thread key | |
| 95 | // instead of the thread object, which is recreated on every update. | |
| 96 | const threadRefRef = useRef(threadRef); | |
| 97 | threadRefRef.current = threadRef; | |
| 86 | 98 | const previewState = useThreadPreviewState(threadRef); |
| 99 | const recentHistoryEntries = useThreadRecentHistory( | |
| 100 | threadRef, | |
| 101 | BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, | |
| 102 | ); | |
| 87 | 103 | const miniPlayer = usePreviewMiniPlayerStore((state) => |
| 88 | 104 | selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), |
| 89 | 105 | ); |
| 90 | 106 | const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); |
| 91 | 107 | const addImage = useComposerDraftStore((store) => store.addImage); |
| 108 | const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); | |
| 109 | const environmentHostname = environmentHttpBaseUrl | |
| 110 | ? new URL(environmentHttpBaseUrl).hostname | |
| 111 | : null; | |
| 92 | 112 | const open = useAtomCommand(previewEnvironment.open); |
| 93 | 113 | const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize"); |
| 94 | 114 | |
| ... | ... | @@ -128,20 +148,27 @@ export function PreviewView({ |
| 128 | 148 | runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null, |
| 129 | 149 | ); |
| 130 | 150 | |
| 151 | const navUrl = navStatus._tag === "Success" ? navStatus.url : null; | |
| 152 | const navTitle = navStatus._tag === "Success" ? navStatus.title : null; | |
| 153 | const latestHistoryUrl = recentHistoryEntries[0]?.url; | |
| 154 | const threadKey = scopedThreadKey(threadRef); | |
| 155 | useEffect(() => { | |
| 156 | if (!navUrl || !navTitle || !latestHistoryUrl) return; | |
| 157 | // Agent-driven pages only enrich an existing requested URL. | |
| 158 | setTitleForThreadUrl(threadRefRef.current, navUrl, navTitle, environmentHostname); | |
| 159 | // threadKey stands in for threadRef, whose identity churns on every thread update. | |
| 160 | }, [environmentHostname, latestHistoryUrl, navTitle, navUrl, threadKey]); | |
| 161 | ||
| 131 | 162 | const navigateToResolvedUrl = useCallback( |
| 132 | 163 | async (resolvedUrl: string) => { |
| 133 | 164 | if (runtimeTabId && previewBridge) { |
| 134 | // Drive the webview imperatively; `usePreviewBridge` mirrors the | |
| 135 | // resolved URL back to the server so other clients stay in sync. | |
| 165 | // The bridge mirrors the resolved URL back to the server. | |
| 136 | 166 | await previewBridge.navigate(runtimeTabId, resolvedUrl); |
| 137 | 167 | rememberPreviewUrl(threadRef, resolvedUrl); |
| 138 | } else { | |
| 139 | await openPreviewSession({ | |
| 140 | openPreview: open, | |
| 141 | threadRef, | |
| 142 | url: resolvedUrl, | |
| 143 | }); | |
| 168 | return true; | |
| 144 | 169 | } |
| 170 | const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl }); | |
| 171 | return result._tag === "Success"; | |
| 145 | 172 | }, |
| 146 | 173 | [open, runtimeTabId, threadRef], |
| 147 | 174 | ); |
| ... | ... | @@ -149,23 +176,29 @@ export function PreviewView({ |
| 149 | 176 | const handleSubmitUrl = useCallback( |
| 150 | 177 | async (next: string) => { |
| 151 | 178 | try { |
| 152 | await navigateToResolvedUrl(normalizePreviewUrl(next)); | |
| 179 | const normalized = normalizePreviewUrl(next); | |
| 180 | if (await navigateToResolvedUrl(normalized)) { | |
| 181 | recordVisitForThread(threadRef, normalized); | |
| 182 | } | |
| 153 | 183 | } catch { |
| 154 | 184 | // Server-side `failed` event renders the unreachable view. |
| 155 | 185 | } |
| 156 | 186 | }, |
| 157 | [navigateToResolvedUrl], | |
| 187 | [navigateToResolvedUrl, threadRef], | |
| 158 | 188 | ); |
| 159 | 189 | |
| 160 | 190 | const handleOpenServerUrl = useCallback( |
| 161 | 191 | async (next: string) => { |
| 162 | 192 | try { |
| 163 | await navigateToResolvedUrl(resolveDiscoveredServerUrl(threadRef.environmentId, next)); | |
| 193 | const resolved = resolveDiscoveredServerUrl(threadRef.environmentId, next); | |
| 194 | if (await navigateToResolvedUrl(resolved)) { | |
| 195 | recordVisitForThread(threadRef, next); | |
| 196 | } | |
| 164 | 197 | } catch { |
| 165 | 198 | // Server-side `failed` event renders the unreachable view. |
| 166 | 199 | } |
| 167 | 200 | }, |
| 168 | [navigateToResolvedUrl, threadRef.environmentId], | |
| 201 | [navigateToResolvedUrl, threadRef], | |
| 169 | 202 | ); |
| 170 | 203 | |
| 171 | 204 | const handleRefresh = useCallback(() => { |
| ... | ... | @@ -680,6 +713,8 @@ export function PreviewView({ |
| 680 | 713 | environmentId={threadRef.environmentId} |
| 681 | 714 | configuredUrls={configuredUrls} |
| 682 | 715 | recentlySeenUrls={previewState.recentlySeenUrls} |
| 716 | recentEntries={recentHistoryEntries} | |
| 717 | onRemoveRecent={(url) => removeUrlForThread(threadRef, url)} | |
| 683 | 718 | onOpenUrl={(next) => void handleOpenServerUrl(next)} |
| 684 | 719 | /> |
| 685 | 720 | ) : null} |
apps/web/src/components/preview/openDiscoveredPort.ts+2| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | |
| 7 | 7 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 8 | 8 | import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; |
| 9 | import { recordVisitForThread } from "~/browserHistoryStore"; | |
| 9 | 10 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 10 | 11 | import { openPreviewSession } from "./openPreviewSession"; |
| 11 | 12 | |
| ... | ... | @@ -21,6 +22,7 @@ export async function openDiscoveredPort<E>(input: { |
| 21 | 22 | url: resolvedUrl, |
| 22 | 23 | }); |
| 23 | 24 | return mapAtomCommandResult(result, (snapshot) => { |
| 25 | recordVisitForThread(input.threadRef, input.port.url); | |
| 24 | 26 | useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); |
| 25 | 27 | }); |
| 26 | 28 | } |
apps/web/src/components/preview/openTerminalLinkInPreview.ts+2| ... | ... | @@ -4,6 +4,7 @@ import { isPreviewableUrl } from "@t3tools/shared/preview"; |
| 4 | 4 | import * as Schema from "effect/Schema"; |
| 5 | 5 | |
| 6 | 6 | import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; |
| 7 | import { recordVisitForThread } from "~/browserHistoryStore"; | |
| 7 | 8 | import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; |
| 8 | 9 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 9 | 10 | |
| ... | ... | @@ -98,6 +99,7 @@ export async function openTerminalLinkInPreview<E>( |
| 98 | 99 | input.fallbackToBrowser(); |
| 99 | 100 | return; |
| 100 | 101 | } |
| 102 | recordVisitForThread(input.threadRef, input.url); | |
| 101 | 103 | applyPreviewServerSnapshot(input.threadRef, result.value); |
| 102 | 104 | useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId); |
| 103 | 105 | return; |
apps/web/src/components/preview/useDiscoveredLocalServers.test.ts+23-1| ... | ... | @@ -3,10 +3,13 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | 4 | import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers"; |
| 5 | 5 | |
| 6 | const scannerServer = (overrides: Partial<DiscoveredLocalServer>): DiscoveredLocalServer => ({ | |
| 6 | const scannerServer = ( | |
| 7 | overrides: Partial<DiscoveredLocalServer & { requestedUrl: string }>, | |
| 8 | ): DiscoveredLocalServer & { requestedUrl: string } => ({ | |
| 7 | 9 | host: "localhost", |
| 8 | 10 | port: 5173, |
| 9 | 11 | url: "http://localhost:5173", |
| 12 | requestedUrl: overrides.url ?? "http://localhost:5173", | |
| 10 | 13 | processName: "vite", |
| 11 | 14 | pid: 1234, |
| 12 | 15 | terminal: null, |
| ... | ... | @@ -24,6 +27,7 @@ describe("mergeServers", () => { |
| 24 | 27 | expect(result[0]).toMatchObject({ |
| 25 | 28 | host: "localhost", |
| 26 | 29 | port: 5173, |
| 30 | requestedUrl: "http://localhost:5173", | |
| 27 | 31 | source: "scanner", |
| 28 | 32 | listening: true, |
| 29 | 33 | processName: "vite", |
| ... | ... | @@ -56,6 +60,7 @@ describe("mergeServers", () => { |
| 56 | 60 | expect(result[0]).toMatchObject({ |
| 57 | 61 | source: "configured", |
| 58 | 62 | listening: false, |
| 63 | requestedUrl: "http://localhost:5173/", | |
| 59 | 64 | }); |
| 60 | 65 | }); |
| 61 | 66 | |
| ... | ... | @@ -68,6 +73,7 @@ describe("mergeServers", () => { |
| 68 | 73 | expect(result.map((s) => s.port)).toEqual([5173, 8080]); |
| 69 | 74 | expect(result.find((s) => s.port === 5173)?.source).toBe("scanner"); |
| 70 | 75 | expect(result.find((s) => s.port === 8080)?.source).toBe("recent"); |
| 76 | expect(result.find((s) => s.port === 8080)?.requestedUrl).toBe("http://localhost:8080/"); | |
| 71 | 77 | }); |
| 72 | 78 | |
| 73 | 79 | it("ignores non-loopback URLs in configured/recent inputs", () => { |
| ... | ... | @@ -102,6 +108,22 @@ describe("mergeServers", () => { |
| 102 | 108 | }); |
| 103 | 109 | expect(result).toHaveLength(1); |
| 104 | 110 | }); |
| 111 | ||
| 112 | it("keeps a scanner entry's pre-resolution requestedUrl distinct from a resolved url", () => { | |
| 113 | const result = mergeServers({ | |
| 114 | scanner: [ | |
| 115 | scannerServer({ | |
| 116 | port: 5173, | |
| 117 | url: "https://env-42.example.dev:5173/", | |
| 118 | requestedUrl: "http://localhost:5173/", | |
| 119 | }), | |
| 120 | ], | |
| 121 | configuredUrls: [], | |
| 122 | recentlySeenUrls: [], | |
| 123 | }); | |
| 124 | expect(result[0]?.url).toBe("https://env-42.example.dev:5173/"); | |
| 125 | expect(result[0]?.requestedUrl).toBe("http://localhost:5173/"); | |
| 126 | }); | |
| 105 | 127 | }); |
| 106 | 128 | |
| 107 | 129 | describe("PreviewableServer interface", () => { |
apps/web/src/components/preview/useDiscoveredLocalServers.ts+9-1| ... | ... | @@ -13,6 +13,11 @@ export interface PreviewableServer extends DiscoveredLocalServer { |
| 13 | 13 | * `configured` entry can also be `listening` when the scan enriched it. |
| 14 | 14 | */ |
| 15 | 15 | listening: boolean; |
| 16 | /** | |
| 17 | * Pre-resolution loopback url. `url` is the resolved navigation target | |
| 18 | * (volatile on a remote environment); history must key off this instead. | |
| 19 | */ | |
| 20 | requestedUrl: string; | |
| 16 | 21 | } |
| 17 | 22 | |
| 18 | 23 | interface UseDiscoveredLocalServersInput { |
| ... | ... | @@ -36,6 +41,7 @@ export function useDiscoveredLocalServers( |
| 36 | 41 | scanner: scannerSnapshot.map((server) => ({ |
| 37 | 42 | ...server, |
| 38 | 43 | url: resolveDiscoveredServerUrl(input.environmentId, server.url), |
| 44 | requestedUrl: server.url, | |
| 39 | 45 | })), |
| 40 | 46 | configuredUrls: input.configuredUrls ?? [], |
| 41 | 47 | recentlySeenUrls: input.recentlySeenUrls ?? [], |
| ... | ... | @@ -45,7 +51,7 @@ export function useDiscoveredLocalServers( |
| 45 | 51 | } |
| 46 | 52 | |
| 47 | 53 | export function mergeServers(input: { |
| 48 | scanner: ReadonlyArray<DiscoveredLocalServer>; | |
| 54 | scanner: ReadonlyArray<DiscoveredLocalServer & { requestedUrl: string }>; | |
| 49 | 55 | configuredUrls: ReadonlyArray<string>; |
| 50 | 56 | recentlySeenUrls: ReadonlyArray<string>; |
| 51 | 57 | }): ReadonlyArray<PreviewableServer> { |
| ... | ... | @@ -60,6 +66,7 @@ export function mergeServers(input: { |
| 60 | 66 | host: parsed.host, |
| 61 | 67 | port: parsed.port, |
| 62 | 68 | url: parsed.url, |
| 69 | requestedUrl: parsed.url, | |
| 63 | 70 | processName: null, |
| 64 | 71 | pid: null, |
| 65 | 72 | terminal: null, |
| ... | ... | @@ -95,6 +102,7 @@ export function mergeServers(input: { |
| 95 | 102 | host: parsed.host, |
| 96 | 103 | port: parsed.port, |
| 97 | 104 | url: parsed.url, |
| 105 | requestedUrl: parsed.url, | |
| 98 | 106 | processName: null, |
| 99 | 107 | pid: null, |
| 100 | 108 | terminal: null, |
apps/web/src/environmentGrouping.test.ts+5| ... | ... | @@ -279,6 +279,11 @@ describe("environment grouping", () => { |
| 279 | 279 | expect(physicalToLogicalKey.get(derivePhysicalProjectKey(staleWithoutRepositoryIdentity))).toBe( |
| 280 | 280 | repositoryIdentity.canonicalKey, |
| 281 | 281 | ); |
| 282 | // Deriving from the stale project alone misses the identity its sibling | |
| 283 | // carries, so consumers must go through the map to match the sidebar. | |
| 284 | expect( | |
| 285 | deriveLogicalProjectKeyFromSettings(staleWithoutRepositoryIdentity, defaultGroupingSettings), | |
| 286 | ).not.toBe(repositoryIdentity.canonicalKey); | |
| 282 | 287 | }); |
| 283 | 288 | |
| 284 | 289 | it("builds one picker entry per logical project and targets the preferred environment", () => { |