Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 115 additions & 1 deletion src/contexts/workspace-context.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 (
<div>
<output data-testid="mode">{mode}</output>
<output data-testid="file-tab-count">{fileTabs.length}</output>
<output data-testid="dirty-count">
{fileTabs.filter((tab) => tab.isDirty).length}
</output>
<button onClick={() => void openFilePreview("a.ts")}>open file</button>
<button onClick={() => updateActiveFileContent("local changes")}>
edit
</button>
<button onClick={closeAllFileTabs}>close all</button>
</div>
)
}

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(
<WorkspaceProvider>
<DirtyFileProbe />
</WorkspaceProvider>
)

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()
Expand Down
68 changes: 68 additions & 0 deletions src/contexts/workspace-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> | null)?.[
FILE_WORKSPACE_HISTORY_KEY
]
)
}

async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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,
Expand Down
Loading