diff --git a/apps/web/src/lib/workspace-state.test.ts b/apps/web/src/lib/workspace-state.test.ts index 33154c9..6e3aaba 100644 --- a/apps/web/src/lib/workspace-state.test.ts +++ b/apps/web/src/lib/workspace-state.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { - loadEditorPercent, loadTreeOpen, storeEditorPercent, storeTreeOpen + loadEditorPercent, loadTreeOpen, loadWorkspaceSource, storeEditorPercent, storeTreeOpen, storeWorkspaceSource } from './workspace-state'; const values = new Map(); @@ -9,6 +9,41 @@ const storage = { setItem: (key: string, value: string) => { values.set(key, value); } }; +describe('workspace source persistence', () => { + beforeEach(() => values.clear()); + + it('defaults to no remembered source and stores both file and mode', () => { + expect(loadWorkspaceSource('writer', 'paper', storage)).toBeNull(); + const source = { fileId: 'chapter', mode: 'files' as const }; + storeWorkspaceSource('writer', 'paper', source, storage); + expect(loadWorkspaceSource('writer', 'paper', storage)).toEqual(source); + }); + + it('keeps accounts and projects separate', () => { + storeWorkspaceSource('writer', 'paper', { fileId: 'chapter', mode: 'files' }, storage); + storeWorkspaceSource('writer', 'other-paper', { fileId: 'appendix', mode: 'manuscript' }, storage); + storeWorkspaceSource('reader', 'paper', { fileId: 'intro', mode: 'manuscript' }, storage); + expect(loadWorkspaceSource('writer', 'paper', storage)).toEqual({ fileId: 'chapter', mode: 'files' }); + expect(loadWorkspaceSource('writer', 'other-paper', storage)).toEqual({ fileId: 'appendix', mode: 'manuscript' }); + expect(loadWorkspaceSource('reader', 'paper', storage)).toEqual({ fileId: 'intro', mode: 'manuscript' }); + }); + + it.each(['{', 'null', '[]', '42', '{"fileId":"chapter","mode":"invalid"}', '{"fileId":5,"mode":"files"}', '{"fileId":"","mode":"files"}'])('ignores invalid saved data: %s', (value) => { + storeWorkspaceSource('writer', 'paper', { fileId: 'chapter', mode: 'files' }, storage); + storage.setItem([...values.keys()][0]!, value); + expect(loadWorkspaceSource('writer', 'paper', storage)).toBeNull(); + }); + + it('allows navigation when browser storage is blocked or full', () => { + const blocked = { + getItem: () => { throw new DOMException('Blocked', 'SecurityError'); }, + setItem: () => { throw new DOMException('Full', 'QuotaExceededError'); } + }; + expect(loadWorkspaceSource('writer', 'paper', blocked)).toBeNull(); + expect(() => storeWorkspaceSource('writer', 'paper', { fileId: 'chapter', mode: 'files' }, blocked)).not.toThrow(); + }); +}); + describe('workspace layout persistence', () => { beforeEach(() => values.clear()); diff --git a/apps/web/src/lib/workspace-state.ts b/apps/web/src/lib/workspace-state.ts index 5f4b7df..575cd72 100644 --- a/apps/web/src/lib/workspace-state.ts +++ b/apps/web/src/lib/workspace-state.ts @@ -2,6 +2,32 @@ const TREE_OPEN_KEY = 'overroot.fileTreeOpen'; const EDITOR_PERCENT_KEY = 'overroot.editorSplit'; type StateStorage = Pick; +export interface WorkspaceSource { + fileId: string; + mode: 'manuscript' | 'files'; +} + +export function loadWorkspaceSource(userId: string, projectHash: string, storage?: StateStorage): WorkspaceSource | null { + try { + const value: unknown = JSON.parse((storage ?? window.localStorage).getItem(workspaceSourceKey(userId, projectHash)) ?? 'null'); + if (value && typeof value === 'object' && 'fileId' in value && typeof value.fileId === 'string' && value.fileId && + 'mode' in value && (value.mode === 'manuscript' || value.mode === 'files')) { + return { fileId: value.fileId, mode: value.mode }; + } + } catch { /* Browser storage can be unavailable or contain invalid JSON. */ } + return null; +} + +export function storeWorkspaceSource(userId: string, projectHash: string, source: WorkspaceSource, storage?: StateStorage): void { + try { + (storage ?? window.localStorage).setItem(workspaceSourceKey(userId, projectHash), JSON.stringify(source)); + } catch { /* A blocked or full store must not prevent opening a file. */ } +} + +function workspaceSourceKey(userId: string, projectHash: string): string { + return `overroot.workspaceSource.${JSON.stringify([userId, projectHash])}`; +} + export const MIN_EDITOR_PERCENT = 25; export const MAX_EDITOR_PERCENT = 75; diff --git a/apps/web/src/pages/WorkspacePage.test.tsx b/apps/web/src/pages/WorkspacePage.test.tsx index 981791b..29a24da 100644 --- a/apps/web/src/pages/WorkspacePage.test.tsx +++ b/apps/web/src/pages/WorkspacePage.test.tsx @@ -21,10 +21,10 @@ vi.mock('../components/WorkspaceDialogs', async (importOriginal) => { }); vi.mock('../components/ManuscriptPane', async () => { const { useEffect } = await import('react'); - return { ManuscriptPane: function Pane({ onFlushReady, onPresence }: { onFlushReady: (flush: (() => Promise) | null) => void; onPresence: NonNullable }) { + return { ManuscriptPane: function Pane({ onFlushReady, onPresence, entryFile }: { onFlushReady: (flush: (() => Promise) | null) => void; onPresence: NonNullable; entryFile: string }) { useEffect(() => { harness.onPresence = onPresence; }, [onPresence]); useEffect(() => { onFlushReady(() => harness.flush()); return () => onFlushReady(null); }, [onFlushReady]); - return
Manuscript test editor
; + return
Manuscript test editor{entryFile}
; } }; }); vi.mock('../components/EditorPane', async () => { @@ -45,6 +45,7 @@ function deferred() { } beforeEach(() => { vi.resetAllMocks(); + window.localStorage.clear(); harness.flush.mockImplementation(async () => {}); harness.api.getProject.mockResolvedValue({ project: { hash: 'project', name: 'Paper', entryFile: 'main.tex', role: 'owner', canWrite: true, canManage: true, memberCount: 1 } }); harness.api.listFiles.mockResolvedValue({ files: [main, result] }); @@ -54,7 +55,7 @@ beforeEach(() => { afterEach(cleanup); async function openWorkspace() { render(}/>); - await screen.findByText('Manuscript test editor'); + await screen.findByText(/(?:Manuscript|File) test editor/); } function startFileMutation(operation: string, path: string, content: string, renamePath?: string) { if (operation === 'create') { @@ -74,6 +75,65 @@ function startFileMutation(operation: string, path: string, content: string, ren } } +describe('source view persistence', () => { + it.each(['files', 'manuscript'])('restores the last opened file and %s mode after a remount', async (mode) => { + await openWorkspace(); + fireEvent.click(screen.getByRole('button', { name: 'result.tex' })); + await screen.findByText(result.content); + if (mode === 'manuscript') { + fireEvent.click(screen.getByRole('button', { name: 'Manuscript' })); + await screen.findByText('Manuscript test editor'); + } + cleanup(); + harness.api.getFile.mockClear(); + await openWorkspace(); + expect(harness.api.getFile).toHaveBeenCalledWith('project', result.id); + expect(screen.getByRole('button', { name: result.path }).closest('.tree-file')).toHaveClass('is-active'); + expect(screen.getByRole('button', { name: mode === 'files' ? 'Files' : 'Manuscript' })).toHaveAttribute('aria-pressed', 'true'); + if (mode === 'files') expect(screen.getByText(result.content)).toBeInTheDocument(); + else expect(screen.getByTestId('manuscript-entry')).toHaveTextContent(result.path); + }); + + it('restores a renamed file by its stable ID', async () => { + await openWorkspace(); + fireEvent.click(screen.getByRole('button', { name: result.path })); + await screen.findByText(result.content); + cleanup(); + const renamed = { ...result, path: 'renamed.tex' }; + harness.api.listFiles.mockResolvedValue({ files: [main, renamed] }); + harness.api.getFile.mockResolvedValue({ file: renamed }); + await openWorkspace(); + expect(screen.getByRole('button', { name: renamed.path }).closest('.tree-file')).toHaveClass('is-active'); + }); + + it('falls back to the project default if the remembered file was deleted', async () => { + await openWorkspace(); + fireEvent.click(screen.getByRole('button', { name: result.path })); + await screen.findByText(result.content); + cleanup(); + harness.api.listFiles.mockResolvedValue({ files: [main] }); + await openWorkspace(); + expect(screen.getByText(main.content)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: main.path }).closest('.tree-file')).toHaveClass('is-active'); + cleanup(); + harness.api.listFiles.mockResolvedValue({ files: [main, result] }); + await openWorkspace(); + expect(screen.getByText(main.content)).toBeInTheDocument(); + }); + + it('keeps the last opened file when a newer selection fails', async () => { + await openWorkspace(); + fireEvent.click(screen.getByRole('button', { name: result.path })); + await screen.findByText(result.content); + harness.api.getFile.mockRejectedValueOnce(new Error('Could not open file')); + fireEvent.click(screen.getByRole('button', { name: main.path })); + await screen.findByText('Could not open file'); + cleanup(); + await openWorkspace(); + expect(screen.getByText(result.content)).toBeInTheDocument(); + }); +}); + describe('online collaborators', () => { const people = ['Alice Smith', 'Alice Stone', 'Bo', 'Charlie', 'Dana', 'Eli'].map((name, clientId) => ({ clientId, name, color: '#2583e9' })); diff --git a/apps/web/src/pages/WorkspacePage.tsx b/apps/web/src/pages/WorkspacePage.tsx index 11ebefe..955150e 100644 --- a/apps/web/src/pages/WorkspacePage.tsx +++ b/apps/web/src/pages/WorkspacePage.tsx @@ -21,8 +21,8 @@ import { ApiError } from '../lib/api'; import { initials, relativeTime } from '../lib/format'; import { useSession } from '../lib/session'; import { - loadEditorPercent, loadTreeOpen, MAX_EDITOR_PERCENT, MIN_EDITOR_PERCENT, - storeEditorPercent, storeTreeOpen + loadEditorPercent, loadTreeOpen, loadWorkspaceSource, MAX_EDITOR_PERCENT, MIN_EDITOR_PERCENT, + storeEditorPercent, storeTreeOpen, storeWorkspaceSource } from '../lib/workspace-state'; import type { CompileJob, ProjectDetail, ProjectEvent, ProjectFile, SourceLocation } from '../types'; @@ -187,13 +187,17 @@ function ProjectWorkspace({ hash }: { hash: string }) { setProject(projectResponse.project); setFiles(fileResponse.files); const accepted = updateJob(compileResponse.job); - const initial = fileResponse.files.find((file) => file.path === projectResponse.project.entryFile) ?? fileResponse.files[0]; + const savedSource = user?.id ? loadWorkspaceSource(user.id, hash) : null; + const initial = fileResponse.files.find((file) => file.id === savedSource?.fileId) + ?? fileResponse.files.find((file) => file.path === projectResponse.project.entryFile) + ?? fileResponse.files[0]; if (initial) setSelected((await api.getFile(hash, initial.id)).file); + setEditorMode(savedSource?.mode ?? 'manuscript'); const previewJob = compileResponse.job?.status === 'succeeded' ? compileResponse.job : compileResponse.lastSuccessfulJob; if (previewJob?.hasPdf && (accepted || !currentJob.current?.hasPdf)) await loadPdf(previewJob); } catch (reason) { setError(reason instanceof Error ? reason.message : 'Could not open project'); } finally { setLoading(false); } - }, [api, hash, loadPdf, updateJob]); + }, [api, hash, loadPdf, updateJob, user?.id]); const reconcile = useCallback(async () => { try { @@ -212,6 +216,11 @@ function ProjectWorkspace({ hash }: { hash: string }) { }, [api, hash, loadFiles, loadPdf, navigate, updateJob]); useEffect(() => { void loadProject(); }, [loadProject]); + useEffect(() => { + if (!loading && user?.id && selected?.id) { + storeWorkspaceSource(user.id, hash, { fileId: selected.id, mode: editorMode }); + } + }, [loading, user?.id, hash, selected?.id, editorMode]); useEffect(() => () => { if (activityTimer.current != null) clearTimeout(activityTimer.current); }, []); const handleEvent = useCallback((event: ProjectEvent) => {