Skip to content
Merged
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
37 changes: 36 additions & 1 deletion apps/web/src/lib/workspace-state.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
Expand All @@ -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());

Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/lib/workspace-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@ const TREE_OPEN_KEY = 'overroot.fileTreeOpen';
const EDITOR_PERCENT_KEY = 'overroot.editorSplit';
type StateStorage = Pick<Storage, 'getItem' | 'setItem'>;

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;

Expand Down
66 changes: 63 additions & 3 deletions apps/web/src/pages/WorkspacePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>) | null) => void; onPresence: NonNullable<typeof harness.onPresence> }) {
return { ManuscriptPane: function Pane({ onFlushReady, onPresence, entryFile }: { onFlushReady: (flush: (() => Promise<void>) | null) => void; onPresence: NonNullable<typeof harness.onPresence>; entryFile: string }) {
useEffect(() => { harness.onPresence = onPresence; }, [onPresence]);
useEffect(() => { onFlushReady(() => harness.flush()); return () => onFlushReady(null); }, [onFlushReady]);
return <div>Manuscript test editor</div>;
return <div>Manuscript test editor<span data-testid="manuscript-entry">{entryFile}</span></div>;
} };
});
vi.mock('../components/EditorPane', async () => {
Expand All @@ -45,6 +45,7 @@ function deferred<T>() {
}
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] });
Expand All @@ -54,7 +55,7 @@ beforeEach(() => {
afterEach(cleanup);
async function openWorkspace() {
render(<MemoryRouter initialEntries={['/project/project']}><Routes><Route path="/project/:hash" element={<WorkspacePage/>}/></Routes></MemoryRouter>);
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') {
Expand All @@ -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' }));

Expand Down
17 changes: 13 additions & 4 deletions apps/web/src/pages/WorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand All @@ -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) => {
Expand Down
Loading