diff --git a/src/contexts/workspace-context.test.tsx b/src/contexts/workspace-context.test.tsx index c0a588a40..f33a74192 100644 --- a/src/contexts/workspace-context.test.tsx +++ b/src/contexts/workspace-context.test.tsx @@ -1,5 +1,5 @@ import { act, render, screen } from "@testing-library/react" -import { beforeEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { WorkspaceProvider, useWorkspaceActions, @@ -327,6 +327,120 @@ describe("WorkspaceProvider mode", () => { }) }) +describe("WorkspaceProvider file history guard", () => { + beforeEach(() => { + window.history.replaceState({}, "", "/workspace") + vi.spyOn(window.history, "back").mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("pushes one history entry when files open and closes all files on back", () => { + const pushState = vi.spyOn(window.history, "pushState") + renderWorkspace() + + act(() => { + screen.getByRole("button", { name: "Open diff" }).click() + screen.getByRole("button", { name: "Open diff 2" }).click() + }) + + expect(screen.getByTestId("file-tab-count")).toHaveTextContent("2") + expect(pushState).toHaveBeenCalledTimes(1) + expect(pushState.mock.calls[0]?.[0]).toEqual({ + codegFileWorkspace: true, + }) + + act(() => { + window.dispatchEvent(new PopStateEvent("popstate")) + }) + + expect(screen.getByTestId("mode")).toHaveTextContent("conversation") + expect(screen.getByTestId("file-tab-count")).toHaveTextContent("0") + }) + + function DirtyFileProbe() { + const { + openFilePreview, + updateActiveFileContent, + closeAllFileTabs, + mode, + fileTabs, + } = useWorkspaceContext() + + return ( +
+ {mode} + {fileTabs.length} + + {fileTabs.filter((tab) => tab.isDirty).length} + + + + +
+ ) + } + + it("keeps a dirty file workspace when back-close is refused", async () => { + mockedApi.readFileForEdit.mockResolvedValue({ + path: "a.ts", + content: "saved", + etag: "e1", + mtime_ms: 1, + readonly: false, + line_ending: "lf", + }) + mockedApi.gitIsTracked.mockResolvedValue(false) + vi.spyOn(window.history, "pushState") + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false) + + render( + + + + ) + + await act(async () => { + screen.getByText("open file").click() + }) + await act(async () => { + screen.getByText("edit").click() + }) + expect(screen.getByTestId("dirty-count")).toHaveTextContent("1") + + await act(async () => { + window.dispatchEvent(new PopStateEvent("popstate")) + }) + + expect(confirm).toHaveBeenCalledTimes(1) + expect(screen.getByTestId("mode")).toHaveTextContent("fusion") + expect(screen.getByTestId("file-tab-count")).toHaveTextContent("1") + expect(screen.getByTestId("dirty-count")).toHaveTextContent("1") + }) + + it("consumes the history entry when the user closes all files manually", () => { + vi.spyOn(window.history, "pushState") + const back = vi.spyOn(window.history, "back") + renderWorkspace() + + act(() => { + screen.getByRole("button", { name: "Open diff" }).click() + }) + expect(screen.getByTestId("file-tab-count")).toHaveTextContent("1") + + act(() => { + screen.getByRole("button", { name: "Close all" }).click() + }) + + expect(screen.getByTestId("file-tab-count")).toHaveTextContent("0") + expect(back).toHaveBeenCalledTimes(1) + }) +}) + describe("WorkspaceProvider files-maximized", () => { it("toggles filesMaximized only while files are open", () => { renderWorkspace() diff --git a/src/contexts/workspace-context.tsx b/src/contexts/workspace-context.tsx index d312fdb8a..0c613a629 100644 --- a/src/contexts/workspace-context.tsx +++ b/src/contexts/workspace-context.tsx @@ -50,6 +50,7 @@ import { HIDDEN_TAB_CONTENT_BUDGET_CHARS, selectTabsToUnload, } from "@/lib/file-tab-memory" +import { isDesktop } from "@/lib/transport" import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store" import { useOpenFileTabsWatch, @@ -331,6 +332,20 @@ function loadingTab( type LoadDecision = { kind: "skip" } | { kind: "fetch"; gen: number } +const FILE_WORKSPACE_HISTORY_KEY = "codegFileWorkspace" + +function canUseFileWorkspaceHistory(): boolean { + return typeof window !== "undefined" && !isDesktop() +} + +function hasFileWorkspaceHistoryState(): boolean { + return Boolean( + (window.history.state as Record | null)?.[ + FILE_WORKSPACE_HISTORY_KEY + ] + ) +} + async function withTimeout( promise: Promise, timeoutMs: number, @@ -421,6 +436,21 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { fileTabsRef.current = fileTabs }, [fileTabs]) + // Browser back is a mobile affordance for leaving the file workspace, not + // for leaving /workspace itself. One synthetic entry guards the whole file + // layer; popstate closes all tabs and manual close consumes the entry. + const fileHistoryDepthRef = useRef(0) + const ignoreNextFileHistoryPopRef = useRef(false) + + const pushFileWorkspaceHistory = useCallback(() => { + window.history.pushState( + { [FILE_WORKSPACE_HISTORY_KEY]: true }, + "", + window.location.href + ) + fileHistoryDepthRef.current += 1 + }, []) + useEffect(() => { activeFileTabIdRef.current = activeFileTabId }, [activeFileTabId]) @@ -2266,6 +2296,44 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { const reorderFileTabs = useCallback((tabs: FileWorkspaceTab[]) => { setFileTabs(tabs) }, []) + useEffect(() => { + if (!canUseFileWorkspaceHistory()) return + + if (fileTabs.length > 0) { + if (fileHistoryDepthRef.current === 0) pushFileWorkspaceHistory() + return + } + + if (fileHistoryDepthRef.current === 0) return + fileHistoryDepthRef.current = 0 + if (hasFileWorkspaceHistoryState()) { + ignoreNextFileHistoryPopRef.current = true + window.history.back() + } + }, [fileTabs.length, pushFileWorkspaceHistory]) + + useEffect(() => { + if (!canUseFileWorkspaceHistory()) return + + const handlePopState = () => { + if (ignoreNextFileHistoryPopRef.current) { + ignoreNextFileHistoryPopRef.current = false + return + } + if (fileHistoryDepthRef.current === 0) return + + fileHistoryDepthRef.current = 0 + if (fileTabsRef.current.length === 0) return + + closeAllFileTabs() + // If close was refused for a dirty tab, restore the synthetic entry. + // If it succeeded, the empty-tabs effect above consumes it after commit. + pushFileWorkspaceHistory() + } + + window.addEventListener("popstate", handlePopState) + return () => window.removeEventListener("popstate", handlePopState) + }, [closeAllFileTabs, pushFileWorkspaceHistory]) const activeFileTab = useMemo( () => fileTabs.find((tab) => tab.id === activeFileTabId) ?? null,