| 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 +7,8 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 7 | 7 | ||
| 8 | import { readPreparedConnection } from "~/state/session"; | 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 | const parseIpv4Address = (host: string): readonly number[] | null => { | 13 | const parseIpv4Address = (host: string): readonly number[] | null => { |
| 13 | const parts = normalizeHostname(host).split(".").map(Number); | 14 | const parts = normalizeHostname(host).split(".").map(Number); |
| ... | @@ -17,7 +18,7 @@ const parseIpv4Address = (host: string): readonly number[] | null => { | ... | @@ -17,7 +18,7 @@ const parseIpv4Address = (host: string): readonly number[] | null => { |
| 17 | : null; | 18 | : null; |
| 18 | }; | 19 | }; |
| 19 | 20 | ||
| 20 | const isLocalLoopbackHost = (host: string): boolean => { | 21 | export const isLocalLoopbackHost = (host: string): boolean => { |
| 21 | const normalized = normalizeHostname(host); | 22 | const normalized = normalizeHostname(host); |
| 22 | if (normalized === "localhost" || normalized === "::1") return true; | 23 | if (normalized === "localhost" || normalized === "::1") return true; |
| 23 | return parseIpv4Address(normalized)?.[0] === 127; | 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,6 +52,7 @@ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsi |
| 52 | import { ScrollArea } from "./ui/scroll-area"; | 52 | import { ScrollArea } from "./ui/scroll-area"; |
| 53 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; | 53 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; |
| 54 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 54 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 55 | import { recordVisitForThread } from "../browserHistoryStore"; | ||
| 55 | import { useOpenInPreferredEditor } from "../editorPreferences"; | 56 | import { useOpenInPreferredEditor } from "../editorPreferences"; |
| 56 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; | 57 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 57 | import { fnv1a32 } from "../lib/diffRendering"; | 58 | import { fnv1a32 } from "../lib/diffRendering"; |
| ... | @@ -1336,7 +1337,10 @@ function ChatMarkdown({ | ... | @@ -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 | [openPreview, threadRef], | 1345 | [openPreview, threadRef], |
| 1342 | ); | 1346 | ); |
apps/web/src/components/ChatView.tsx+41-4| ... | @@ -172,9 +172,14 @@ import { | ... | @@ -172,9 +172,14 @@ import { |
| 172 | projectScriptIdFromCommand, | 172 | projectScriptIdFromCommand, |
| 173 | } from "~/projectScripts"; | 173 | } from "~/projectScripts"; |
| 174 | import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; | 174 | import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; |
| 175 | import { useBrowserHistoryStore } from "~/browserHistoryStore"; | ||
| 175 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; | 176 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; |
| 176 | import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; | 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 | import { useNowMinute } from "../hooks/useNowMinute"; | 183 | import { useNowMinute } from "../hooks/useNowMinute"; |
| 179 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; | 184 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; |
| 180 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; | 185 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| ... | @@ -182,9 +187,11 @@ import { getTerminalFocusOwner } from "../lib/terminalFocus"; | ... | @@ -182,9 +187,11 @@ import { getTerminalFocusOwner } from "../lib/terminalFocus"; |
| 182 | import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; | 187 | import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; |
| 183 | import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; | 188 | import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; |
| 184 | import { | 189 | import { |
| 190 | derivePhysicalProjectKey, | ||
| 185 | deriveLogicalProjectKeyFromSettings, | 191 | deriveLogicalProjectKeyFromSettings, |
| 186 | selectProjectGroupingSettings, | 192 | selectProjectGroupingSettings, |
| 187 | } from "../logicalProject"; | 193 | } from "../logicalProject"; |
| 194 | import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; | ||
| 188 | import { buildDraftThreadRouteParams } from "../threadRoutes"; | 195 | import { buildDraftThreadRouteParams } from "../threadRoutes"; |
| 189 | import { | 196 | import { |
| 190 | type ComposerImageAttachment, | 197 | type ComposerImageAttachment, |
| ... | @@ -1491,6 +1498,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1491,6 +1498,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 1491 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; | 1498 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; |
| 1492 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; | 1499 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; |
| 1493 | const activeThreadId = activeThread?.id ?? null; | 1500 | const activeThreadId = activeThread?.id ?? null; |
| 1501 | const activeThreadEnvironmentId = activeThread?.environmentId ?? null; | ||
| 1494 | const runningTerminalIds = useThreadRunningTerminalIds({ | 1502 | const runningTerminalIds = useThreadRunningTerminalIds({ |
| 1495 | environmentId: activeThread?.environmentId ?? null, | 1503 | environmentId: activeThread?.environmentId ?? null, |
| 1496 | threadId: activeThreadId, | 1504 | threadId: activeThreadId, |
| ... | @@ -1526,8 +1534,11 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1526,8 +1534,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 1526 | return labels; | 1534 | return labels; |
| 1527 | }, [activeThreadKnownSessions]); | 1535 | }, [activeThreadKnownSessions]); |
| 1528 | const activeThreadRef = useMemo( | 1536 | const activeThreadRef = useMemo( |
| 1529 | () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), | 1537 | () => |
| 1530 | [activeThread], | 1538 | activeThreadEnvironmentId && activeThreadId |
| 1539 | ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) | ||
| 1540 | : null, | ||
| 1541 | [activeThreadEnvironmentId, activeThreadId], | ||
| 1531 | ); | 1542 | ); |
| 1532 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; | 1543 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1533 | const [timelineAnchor, setTimelineAnchor] = useState<{ | 1544 | const [timelineAnchor, setTimelineAnchor] = useState<{ |
| ... | @@ -1652,6 +1663,8 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1652,6 +1663,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 1652 | const activeProjectKey = activeProject | 1663 | const activeProjectKey = activeProject |
| 1653 | ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` | 1664 | ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` |
| 1654 | : null; | 1665 | : null; |
| 1666 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); | ||
| 1667 | const clientSettingsHydrated = useClientSettingsHydrated(); | ||
| 1655 | const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< | 1668 | const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< |
| 1656 | ReadonlyMap<string, ReadonlySet<string>> | 1669 | ReadonlyMap<string, ReadonlySet<string>> |
| 1657 | >(() => new Map()); | 1670 | >(() => new Map()); |
| ... | @@ -1690,6 +1703,31 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1690,6 +1703,31 @@ function ChatViewContent(props: ChatViewProps) { |
| 1690 | // drive the environment picker in BranchToolbar. | 1703 | // drive the environment picker in BranchToolbar. |
| 1691 | const allProjects = useProjects(); | 1704 | const allProjects = useProjects(); |
| 1692 | const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; | 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 | const activeEnvironment = | 1731 | const activeEnvironment = |
| 1694 | activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); | 1732 | activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); |
| 1695 | const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; | 1733 | const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; |
| ... | @@ -1723,7 +1761,6 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1723,7 +1761,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 1723 | }, | 1761 | }, |
| 1724 | [retryEnvironment], | 1762 | [retryEnvironment], |
| 1725 | ); | 1763 | ); |
| 1726 | const projectGroupingSettings = selectProjectGroupingSettings(settings); | ||
| 1727 | const logicalProjectEnvironments = useMemo(() => { | 1764 | const logicalProjectEnvironments = useMemo(() => { |
| 1728 | if (!activeProject) return []; | 1765 | if (!activeProject) return []; |
| 1729 | const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); | 1766 | const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); |
apps/web/src/components/preview/BrowserMockup.tsx+1-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { cn } from "~/lib/utils"; | 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 | export function BrowserMockup({ className }: { className?: string }) { | 4 | export function BrowserMockup({ className }: { className?: string }) { |
| 5 | return ( | 5 | return ( |
| 6 | <div | 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,15 +1,19 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | 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 | import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty"; | 5 | import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty"; |
| 5 | 6 | ||
| 6 | import { PreviewLocalServerCard } from "./PreviewLocalServerCard"; | 7 | import { PreviewLocalServerCard } from "./PreviewLocalServerCard"; |
| 8 | import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard"; | ||
| 7 | import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers"; | 9 | import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers"; |
| 8 | 10 | ||
| 9 | interface Props { | 11 | interface Props { |
| 10 | environmentId: EnvironmentId; | 12 | environmentId: EnvironmentId; |
| 11 | configuredUrls?: ReadonlyArray<string> | undefined; | 13 | configuredUrls?: ReadonlyArray<string> | undefined; |
| 12 | recentlySeenUrls?: ReadonlyArray<string> | undefined; | 14 | recentlySeenUrls?: ReadonlyArray<string> | undefined; |
| 15 | recentEntries: ReadonlyArray<BrowserHistoryEntry>; | ||
| 16 | onRemoveRecent: (url: string) => void; | ||
| 13 | onOpenUrl: (url: string) => void; | 17 | onOpenUrl: (url: string) => void; |
| 14 | } | 18 | } |
| 15 | 19 | ||
| ... | @@ -17,6 +21,8 @@ export function PreviewEmptyState({ | ... | @@ -17,6 +21,8 @@ export function PreviewEmptyState({ |
| 17 | environmentId, | 21 | environmentId, |
| 18 | configuredUrls, | 22 | configuredUrls, |
| 19 | recentlySeenUrls, | 23 | recentlySeenUrls, |
| 24 | recentEntries, | ||
| 25 | onRemoveRecent, | ||
| 20 | onOpenUrl, | 26 | onOpenUrl, |
| 21 | }: Props) { | 27 | }: Props) { |
| 22 | const servers = useDiscoveredLocalServers({ | 28 | const servers = useDiscoveredLocalServers({ |
| ... | @@ -24,8 +30,9 @@ export function PreviewEmptyState({ | ... | @@ -24,8 +30,9 @@ export function PreviewEmptyState({ |
| 24 | configuredUrls, | 30 | configuredUrls, |
| 25 | recentlySeenUrls, | 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 | return ( | 36 | return ( |
| 30 | <Empty> | 37 | <Empty> |
| 31 | <EmptyMedia variant="icon"> | 38 | <EmptyMedia variant="icon"> |
| ... | @@ -42,23 +49,45 @@ export function PreviewEmptyState({ | ... | @@ -42,23 +49,45 @@ export function PreviewEmptyState({ |
| 42 | 49 | ||
| 43 | return ( | 50 | return ( |
| 44 | <div className="flex h-full min-h-0 overflow-y-auto px-5 py-8"> | 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"> | 52 | <div className="m-auto flex w-full max-w-xl flex-col gap-6"> |
| 46 | <div className="flex items-center gap-2 text-sm text-muted-foreground"> | 53 | {recents.length > 0 ? ( |
| 47 | <RadioTower className="size-4 shrink-0" /> | 54 | <div className="flex flex-col gap-3"> |
| 48 | <h2 className="font-medium">Local servers</h2> | 55 | <div className="flex items-center gap-2 text-sm text-muted-foreground"> |
| 49 | </div> | 56 | <History className="size-4 shrink-0" /> |
| 50 | <div className="flex flex-col divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70 bg-background"> | 57 | <h2 className="font-medium">Recently used</h2> |
| 51 | {servers.map((server) => ( | 58 | </div> |
| 52 | <PreviewLocalServerCard | 59 | <div className="flex flex-col divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70 bg-background"> |
| 53 | key={`${server.host}:${server.port}`} | 60 | {recents.map((entry) => ( |
| 54 | server={server} | 61 | <PreviewRecentUrlCard |
| 55 | onOpen={() => onOpenUrl(server.url)} | 62 | key={entry.url} |
| 56 | /> | 63 | entry={entry} |
| 57 | ))} | 64 | onOpen={() => onOpenUrl(entry.url)} |
| 58 | </div> | 65 | onRemove={() => onRemoveRecent(entry.url)} |
| 59 | <p className="px-1 text-xs text-muted-foreground"> | 66 | /> |
| 60 | Select a listening port to open it in this browser tab. | 67 | ))} |
| 61 | </p> | 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 | </div> | 91 | </div> |
| 63 | </div> | 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,6 +24,17 @@ const mocks = vi.hoisted(() => ({ |
| 24 | toggleAnnotation: null as (() => void) | null, | 24 | toggleAnnotation: null as (() => void) | null, |
| 25 | pictureInPicture: false, | 25 | pictureInPicture: false, |
| 26 | showEmptyState: false, | 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 | vi.mock("~/state/session", () => ({ | 40 | vi.mock("~/state/session", () => ({ |
| ... | @@ -232,6 +243,7 @@ describe("PreviewView navigation", () => { | ... | @@ -232,6 +243,7 @@ describe("PreviewView navigation", () => { |
| 232 | mocks.toggleAnnotation = null; | 243 | mocks.toggleAnnotation = null; |
| 233 | mocks.pictureInPicture = false; | 244 | mocks.pictureInPicture = false; |
| 234 | mocks.showEmptyState = false; | 245 | mocks.showEmptyState = false; |
| 246 | mocks.recordVisitForThread.mockClear(); | ||
| 235 | }); | 247 | }); |
| 236 | 248 | ||
| 237 | it.each([ | 249 | it.each([ |
| ... | @@ -267,6 +279,27 @@ describe("PreviewView navigation", () => { | ... | @@ -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 | it("maps an empty-state localhost server onto the WSL host", async () => { | 303 | it("maps an empty-state localhost server onto the WSL host", async () => { |
| 271 | mocks.showEmptyState = true; | 304 | mocks.showEmptyState = true; |
| 272 | renderToStaticMarkup( | 305 | renderToStaticMarkup( |
| ... | @@ -296,6 +329,12 @@ describe("PreviewView navigation", () => { | ... | @@ -296,6 +329,12 @@ describe("PreviewView navigation", () => { |
| 296 | }, | 329 | }, |
| 297 | "http://172.25.85.75:5173/app?mode=test#top", | 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 | it("opens and closes a thread-scoped floating preview for the active tab", async () => { | 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,6 +11,13 @@ import { |
| 11 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; | 11 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 12 | import { useCallback, useEffect, useRef, useState } from "react"; | 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 | import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore"; | 21 | import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore"; |
| 15 | import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; | 22 | import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; |
| 16 | import { ensureLocalApi } from "~/localApi"; | 23 | import { ensureLocalApi } from "~/localApi"; |
| ... | @@ -20,6 +27,7 @@ import { | ... | @@ -20,6 +27,7 @@ import { |
| 20 | useThreadPreviewState, | 27 | useThreadPreviewState, |
| 21 | } from "~/previewStateStore"; | 28 | } from "~/previewStateStore"; |
| 22 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; | 29 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 30 | import { useEnvironmentHttpBaseUrl } from "~/state/environments"; | ||
| 23 | import { previewEnvironment } from "~/state/preview"; | 31 | import { previewEnvironment } from "~/state/preview"; |
| 24 | import { useAtomCommand } from "~/state/use-atom-command"; | 32 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 25 | import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; | 33 | import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; |
| ... | @@ -83,12 +91,24 @@ export function PreviewView({ | ... | @@ -83,12 +91,24 @@ export function PreviewView({ |
| 83 | const activeRecordingTabIds = useActiveBrowserRecordingTabIds(); | 91 | const activeRecordingTabIds = useActiveBrowserRecordingTabIds(); |
| 84 | const pickActiveRef = useRef(false); | 92 | const pickActiveRef = useRef(false); |
| 85 | const isMountedRef = useRef(true); | 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 | const previewState = useThreadPreviewState(threadRef); | 98 | const previewState = useThreadPreviewState(threadRef); |
| 99 | const recentHistoryEntries = useThreadRecentHistory( | ||
| 100 | threadRef, | ||
| 101 | BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, | ||
| 102 | ); | ||
| 87 | const miniPlayer = usePreviewMiniPlayerStore((state) => | 103 | const miniPlayer = usePreviewMiniPlayerStore((state) => |
| 88 | selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), | 104 | selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), |
| 89 | ); | 105 | ); |
| 90 | const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); | 106 | const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); |
| 91 | const addImage = useComposerDraftStore((store) => store.addImage); | 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 | const open = useAtomCommand(previewEnvironment.open); | 112 | const open = useAtomCommand(previewEnvironment.open); |
| 93 | const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize"); | 113 | const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize"); |
| 94 | 114 | ||
| ... | @@ -128,20 +148,27 @@ export function PreviewView({ | ... | @@ -128,20 +148,27 @@ export function PreviewView({ |
| 128 | runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null, | 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 | const navigateToResolvedUrl = useCallback( | 162 | const navigateToResolvedUrl = useCallback( |
| 132 | async (resolvedUrl: string) => { | 163 | async (resolvedUrl: string) => { |
| 133 | if (runtimeTabId && previewBridge) { | 164 | if (runtimeTabId && previewBridge) { |
| 134 | // Drive the webview imperatively; `usePreviewBridge` mirrors the | 165 | // The bridge mirrors the resolved URL back to the server. |
| 135 | // resolved URL back to the server so other clients stay in sync. | ||
| 136 | await previewBridge.navigate(runtimeTabId, resolvedUrl); | 166 | await previewBridge.navigate(runtimeTabId, resolvedUrl); |
| 137 | rememberPreviewUrl(threadRef, resolvedUrl); | 167 | rememberPreviewUrl(threadRef, resolvedUrl); |
| 138 | } else { | 168 | return true; |
| 139 | await openPreviewSession({ | ||
| 140 | openPreview: open, | ||
| 141 | threadRef, | ||
| 142 | url: resolvedUrl, | ||
| 143 | }); | ||
| 144 | } | 169 | } |
| 170 | const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl }); | ||
| 171 | return result._tag === "Success"; | ||
| 145 | }, | 172 | }, |
| 146 | [open, runtimeTabId, threadRef], | 173 | [open, runtimeTabId, threadRef], |
| 147 | ); | 174 | ); |
| ... | @@ -149,23 +176,29 @@ export function PreviewView({ | ... | @@ -149,23 +176,29 @@ export function PreviewView({ |
| 149 | const handleSubmitUrl = useCallback( | 176 | const handleSubmitUrl = useCallback( |
| 150 | async (next: string) => { | 177 | async (next: string) => { |
| 151 | try { | 178 | try { |
| 152 | await navigateToResolvedUrl(normalizePreviewUrl(next)); | 179 | const normalized = normalizePreviewUrl(next); |
| 180 | if (await navigateToResolvedUrl(normalized)) { | ||
| 181 | recordVisitForThread(threadRef, normalized); | ||
| 182 | } | ||
| 153 | } catch { | 183 | } catch { |
| 154 | // Server-side `failed` event renders the unreachable view. | 184 | // Server-side `failed` event renders the unreachable view. |
| 155 | } | 185 | } |
| 156 | }, | 186 | }, |
| 157 | [navigateToResolvedUrl], | 187 | [navigateToResolvedUrl, threadRef], |
| 158 | ); | 188 | ); |
| 159 | 189 | ||
| 160 | const handleOpenServerUrl = useCallback( | 190 | const handleOpenServerUrl = useCallback( |
| 161 | async (next: string) => { | 191 | async (next: string) => { |
| 162 | try { | 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 | } catch { | 197 | } catch { |
| 165 | // Server-side `failed` event renders the unreachable view. | 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 | const handleRefresh = useCallback(() => { | 204 | const handleRefresh = useCallback(() => { |
| ... | @@ -680,6 +713,8 @@ export function PreviewView({ | ... | @@ -680,6 +713,8 @@ export function PreviewView({ |
| 680 | environmentId={threadRef.environmentId} | 713 | environmentId={threadRef.environmentId} |
| 681 | configuredUrls={configuredUrls} | 714 | configuredUrls={configuredUrls} |
| 682 | recentlySeenUrls={previewState.recentlySeenUrls} | 715 | recentlySeenUrls={previewState.recentlySeenUrls} |
| 716 | recentEntries={recentHistoryEntries} | ||
| 717 | onRemoveRecent={(url) => removeUrlForThread(threadRef, url)} | ||
| 683 | onOpenUrl={(next) => void handleOpenServerUrl(next)} | 718 | onOpenUrl={(next) => void handleOpenServerUrl(next)} |
| 684 | /> | 719 | /> |
| 685 | ) : null} | 720 | ) : null} |
apps/web/src/components/preview/openDiscoveredPort.ts+2| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | ||
| 7 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; | 7 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 8 | import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; | 8 | import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; |
| 9 | import { recordVisitForThread } from "~/browserHistoryStore"; | ||
| 9 | import { useRightPanelStore } from "~/rightPanelStore"; | 10 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 10 | import { openPreviewSession } from "./openPreviewSession"; | 11 | import { openPreviewSession } from "./openPreviewSession"; |
| 11 | 12 | ||
| ... | @@ -21,6 +22,7 @@ export async function openDiscoveredPort<E>(input: { | ... | @@ -21,6 +22,7 @@ export async function openDiscoveredPort<E>(input: { |
| 21 | url: resolvedUrl, | 22 | url: resolvedUrl, |
| 22 | }); | 23 | }); |
| 23 | return mapAtomCommandResult(result, (snapshot) => { | 24 | return mapAtomCommandResult(result, (snapshot) => { |
| 25 | recordVisitForThread(input.threadRef, input.port.url); | ||
| 24 | useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); | 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,6 +4,7 @@ import { isPreviewableUrl } from "@t3tools/shared/preview"; |
| 4 | import * as Schema from "effect/Schema"; | 4 | import * as Schema from "effect/Schema"; |
| 5 | 5 | ||
| 6 | import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; | 6 | import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; |
| 7 | import { recordVisitForThread } from "~/browserHistoryStore"; | ||
| 7 | import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; | 8 | import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; |
| 8 | import { useRightPanelStore } from "~/rightPanelStore"; | 9 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 9 | 10 | ||
| ... | @@ -98,6 +99,7 @@ export async function openTerminalLinkInPreview<E>( | ... | @@ -98,6 +99,7 @@ export async function openTerminalLinkInPreview<E>( |
| 98 | input.fallbackToBrowser(); | 99 | input.fallbackToBrowser(); |
| 99 | return; | 100 | return; |
| 100 | } | 101 | } |
| 102 | recordVisitForThread(input.threadRef, input.url); | ||
| 101 | applyPreviewServerSnapshot(input.threadRef, result.value); | 103 | applyPreviewServerSnapshot(input.threadRef, result.value); |
| 102 | useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId); | 104 | useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId); |
| 103 | return; | 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,10 +3,13 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers"; | 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 | host: "localhost", | 9 | host: "localhost", |
| 8 | port: 5173, | 10 | port: 5173, |
| 9 | url: "http://localhost:5173", | 11 | url: "http://localhost:5173", |
| 12 | requestedUrl: overrides.url ?? "http://localhost:5173", | ||
| 10 | processName: "vite", | 13 | processName: "vite", |
| 11 | pid: 1234, | 14 | pid: 1234, |
| 12 | terminal: null, | 15 | terminal: null, |
| ... | @@ -24,6 +27,7 @@ describe("mergeServers", () => { | ... | @@ -24,6 +27,7 @@ describe("mergeServers", () => { |
| 24 | expect(result[0]).toMatchObject({ | 27 | expect(result[0]).toMatchObject({ |
| 25 | host: "localhost", | 28 | host: "localhost", |
| 26 | port: 5173, | 29 | port: 5173, |
| 30 | requestedUrl: "http://localhost:5173", | ||
| 27 | source: "scanner", | 31 | source: "scanner", |
| 28 | listening: true, | 32 | listening: true, |
| 29 | processName: "vite", | 33 | processName: "vite", |
| ... | @@ -56,6 +60,7 @@ describe("mergeServers", () => { | ... | @@ -56,6 +60,7 @@ describe("mergeServers", () => { |
| 56 | expect(result[0]).toMatchObject({ | 60 | expect(result[0]).toMatchObject({ |
| 57 | source: "configured", | 61 | source: "configured", |
| 58 | listening: false, | 62 | listening: false, |
| 63 | requestedUrl: "http://localhost:5173/", | ||
| 59 | }); | 64 | }); |
| 60 | }); | 65 | }); |
| 61 | 66 | ||
| ... | @@ -68,6 +73,7 @@ describe("mergeServers", () => { | ... | @@ -68,6 +73,7 @@ describe("mergeServers", () => { |
| 68 | expect(result.map((s) => s.port)).toEqual([5173, 8080]); | 73 | expect(result.map((s) => s.port)).toEqual([5173, 8080]); |
| 69 | expect(result.find((s) => s.port === 5173)?.source).toBe("scanner"); | 74 | expect(result.find((s) => s.port === 5173)?.source).toBe("scanner"); |
| 70 | expect(result.find((s) => s.port === 8080)?.source).toBe("recent"); | 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 | it("ignores non-loopback URLs in configured/recent inputs", () => { | 79 | it("ignores non-loopback URLs in configured/recent inputs", () => { |
| ... | @@ -102,6 +108,22 @@ describe("mergeServers", () => { | ... | @@ -102,6 +108,22 @@ describe("mergeServers", () => { |
| 102 | }); | 108 | }); |
| 103 | expect(result).toHaveLength(1); | 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 | describe("PreviewableServer interface", () => { | 129 | describe("PreviewableServer interface", () => { |
apps/web/src/components/preview/useDiscoveredLocalServers.ts+9-1| ... | @@ -13,6 +13,11 @@ export interface PreviewableServer extends DiscoveredLocalServer { | ... | @@ -13,6 +13,11 @@ export interface PreviewableServer extends DiscoveredLocalServer { |
| 13 | * `configured` entry can also be `listening` when the scan enriched it. | 13 | * `configured` entry can also be `listening` when the scan enriched it. |
| 14 | */ | 14 | */ |
| 15 | listening: boolean; | 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 | interface UseDiscoveredLocalServersInput { | 23 | interface UseDiscoveredLocalServersInput { |
| ... | @@ -36,6 +41,7 @@ export function useDiscoveredLocalServers( | ... | @@ -36,6 +41,7 @@ export function useDiscoveredLocalServers( |
| 36 | scanner: scannerSnapshot.map((server) => ({ | 41 | scanner: scannerSnapshot.map((server) => ({ |
| 37 | ...server, | 42 | ...server, |
| 38 | url: resolveDiscoveredServerUrl(input.environmentId, server.url), | 43 | url: resolveDiscoveredServerUrl(input.environmentId, server.url), |
| 44 | requestedUrl: server.url, | ||
| 39 | })), | 45 | })), |
| 40 | configuredUrls: input.configuredUrls ?? [], | 46 | configuredUrls: input.configuredUrls ?? [], |
| 41 | recentlySeenUrls: input.recentlySeenUrls ?? [], | 47 | recentlySeenUrls: input.recentlySeenUrls ?? [], |
| ... | @@ -45,7 +51,7 @@ export function useDiscoveredLocalServers( | ... | @@ -45,7 +51,7 @@ export function useDiscoveredLocalServers( |
| 45 | } | 51 | } |
| 46 | 52 | ||
| 47 | export function mergeServers(input: { | 53 | export function mergeServers(input: { |
| 48 | scanner: ReadonlyArray<DiscoveredLocalServer>; | 54 | scanner: ReadonlyArray<DiscoveredLocalServer & { requestedUrl: string }>; |
| 49 | configuredUrls: ReadonlyArray<string>; | 55 | configuredUrls: ReadonlyArray<string>; |
| 50 | recentlySeenUrls: ReadonlyArray<string>; | 56 | recentlySeenUrls: ReadonlyArray<string>; |
| 51 | }): ReadonlyArray<PreviewableServer> { | 57 | }): ReadonlyArray<PreviewableServer> { |
| ... | @@ -60,6 +66,7 @@ export function mergeServers(input: { | ... | @@ -60,6 +66,7 @@ export function mergeServers(input: { |
| 60 | host: parsed.host, | 66 | host: parsed.host, |
| 61 | port: parsed.port, | 67 | port: parsed.port, |
| 62 | url: parsed.url, | 68 | url: parsed.url, |
| 69 | requestedUrl: parsed.url, | ||
| 63 | processName: null, | 70 | processName: null, |
| 64 | pid: null, | 71 | pid: null, |
| 65 | terminal: null, | 72 | terminal: null, |
| ... | @@ -95,6 +102,7 @@ export function mergeServers(input: { | ... | @@ -95,6 +102,7 @@ export function mergeServers(input: { |
| 95 | host: parsed.host, | 102 | host: parsed.host, |
| 96 | port: parsed.port, | 103 | port: parsed.port, |
| 97 | url: parsed.url, | 104 | url: parsed.url, |
| 105 | requestedUrl: parsed.url, | ||
| 98 | processName: null, | 106 | processName: null, |
| 99 | pid: null, | 107 | pid: null, |
| 100 | terminal: null, | 108 | terminal: null, |
apps/web/src/environmentGrouping.test.ts+5| ... | @@ -279,6 +279,11 @@ describe("environment grouping", () => { | ... | @@ -279,6 +279,11 @@ describe("environment grouping", () => { |
| 279 | expect(physicalToLogicalKey.get(derivePhysicalProjectKey(staleWithoutRepositoryIdentity))).toBe( | 279 | expect(physicalToLogicalKey.get(derivePhysicalProjectKey(staleWithoutRepositoryIdentity))).toBe( |
| 280 | repositoryIdentity.canonicalKey, | 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 | it("builds one picker entry per logical project and targets the preferred environment", () => { | 289 | it("builds one picker entry per logical project and targets the preferred environment", () => { |