Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
2d2b973bd6 MUL-3147: preserve desktop tab query state
Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 09:45:21 +08:00
4 changed files with 126 additions and 4 deletions

View File

@@ -0,0 +1,97 @@
import { render, waitFor } from "@testing-library/react";
import type { DataRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
const createTabRouterMock = vi.hoisted(() =>
vi.fn(() => ({
dispose: vi.fn(),
state: { location: { pathname: "/" } },
navigate: vi.fn(),
subscribe: vi.fn(() => () => {}),
})),
);
vi.mock("../routes", () => ({
createTabRouter: createTabRouterMock,
}));
import { useTabStore } from "@/stores/tab-store";
import { useTabRouterSync } from "./use-tab-router-sync";
type RouterListener = (state: {
location: { pathname: string; search: string; hash: string };
historyAction: "PUSH" | "POP" | "REPLACE";
}) => void;
function makeRouter(
initial: { pathname: string; search?: string; hash?: string },
) {
let listener: RouterListener | null = null;
const router = {
state: {
location: {
pathname: initial.pathname,
search: initial.search ?? "",
hash: initial.hash ?? "",
},
},
subscribe: vi.fn((fn: RouterListener) => {
listener = fn;
return vi.fn();
}),
};
return {
router: router as unknown as DataRouter,
emit(
next: { pathname: string; search?: string; hash?: string },
historyAction: "PUSH" | "POP" | "REPLACE" = "REPLACE",
) {
router.state.location = {
pathname: next.pathname,
search: next.search ?? "",
hash: next.hash ?? "",
};
listener?.({ location: router.state.location, historyAction });
},
};
}
function Harness({ tabId, router }: { tabId: string; router: DataRouter }) {
useTabRouterSync(tabId, router);
return null;
}
function activeTab() {
const state = useTabStore.getState();
const group = state.byWorkspace.acme;
return group.tabs.find((tab) => tab.id === group.activeTabId)!;
}
beforeEach(() => {
createTabRouterMock.mockClear();
useTabStore.getState().reset();
});
describe("useTabRouterSync", () => {
it("keeps search params in the stored tab path for query-only navigation", async () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const tabId = activeTab().id;
const { router, emit } = makeRouter({
pathname: "/acme/inbox",
search: "?issue=issue-one",
});
render(<Harness tabId={tabId} router={router} />);
await waitFor(() => {
expect(activeTab().path).toBe("/acme/inbox?issue=issue-one");
});
expect(activeTab().icon).toBe("Inbox");
emit({ pathname: "/acme/inbox", search: "?issue=issue-two" });
expect(activeTab().path).toBe("/acme/inbox?issue=issue-two");
expect(activeTab().icon).toBe("Inbox");
});
});

View File

@@ -3,6 +3,14 @@ import type { DataRouter } from "react-router-dom";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
import { popDirectionHints } from "./use-tab-history";
function locationPath(location: {
pathname: string;
search?: string;
hash?: string;
}): string {
return `${location.pathname}${location.search ?? ""}${location.hash ?? ""}`;
}
/**
* Subscribe to a tab's memory router and sync path + history tracking
* back into the tab store.
@@ -15,12 +23,17 @@ export function useTabRouterSync(tabId: string, router: DataRouter) {
useEffect(() => {
// Sync initial state
const initialPath = router.state.location.pathname;
const initialLocation = router.state.location;
const initialPath = locationPath(initialLocation);
const store = useTabStore.getState();
store.updateTab(tabId, { path: initialPath, icon: resolveRouteIcon(initialPath) });
store.updateTab(tabId, {
path: initialPath,
icon: resolveRouteIcon(initialLocation.pathname),
});
const unsubscribe = router.subscribe((state) => {
const { pathname } = state.location;
const path = locationPath(state.location);
const action = state.historyAction;
if (action === "PUSH") {
@@ -40,7 +53,7 @@ export function useTabRouterSync(tabId: string, router: DataRouter) {
// REPLACE: index and length stay the same
const store = useTabStore.getState();
store.updateTab(tabId, { path: pathname, icon: resolveRouteIcon(pathname) });
store.updateTab(tabId, { path, icon: resolveRouteIcon(pathname) });
store.updateTabHistory(tabId, indexRef.current, lengthRef.current);
});

View File

@@ -15,6 +15,7 @@ vi.mock("../routes", () => ({
}));
import {
resolveRouteIcon,
sanitizeTabPath,
migrateV1ToV2,
migrateV2ToV3,
@@ -43,6 +44,9 @@ describe("sanitizeTabPath", () => {
it("passes through valid workspace-scoped paths", () => {
expect(sanitizeTabPath("/acme/issues")).toBe("/acme/issues");
expect(sanitizeTabPath("/my-team/projects/abc")).toBe("/my-team/projects/abc");
expect(sanitizeTabPath("/acme/inbox?issue=MUL-123#comment-1")).toBe(
"/acme/inbox?issue=MUL-123#comment-1",
);
});
it("rejects paths whose first segment is a reserved slug (missing workspace prefix)", () => {
@@ -59,6 +63,13 @@ describe("sanitizeTabPath", () => {
});
});
describe("resolveRouteIcon", () => {
it("uses the pathname route segment when a tab path includes search or hash", () => {
expect(resolveRouteIcon("/acme/inbox?issue=MUL-123#comment-1")).toBe("Inbox");
expect(resolveRouteIcon("/acme/issues/issue-1?view=detail")).toBe("ListTodo");
});
});
describe("migrateV1ToV2", () => {
it("groups v1 flat tabs by workspace slug", () => {
const v1 = {

View File

@@ -150,7 +150,8 @@ const ROUTE_ICONS: Record<string, string> = {
*
* Title is NOT determined here — it comes from document.title.
*/
export function resolveRouteIcon(pathname: string): string {
export function resolveRouteIcon(path: string): string {
const pathname = path.split("?")[0].split("#")[0];
const segments = pathname.split("/").filter(Boolean);
return ROUTE_ICONS[segments[1] ?? ""] ?? "ListTodo";
}