diff --git a/quadratic-client/src/app/actions.ts b/quadratic-client/src/app/actions.ts index 13ed77464b..49f9f1cba6 100644 --- a/quadratic-client/src/app/actions.ts +++ b/quadratic-client/src/app/actions.ts @@ -1,4 +1,5 @@ import { EditorInteractionState } from '@/app/atoms/editorInteractionStateAtom'; +import { updateRecentFiles } from '@/app/ui/menus/TopBar/TopBarMenus/updateRecentFiles'; import { getActionFileDelete, getActionFileDuplicate } from '@/routes/api.files.$uuid'; import { GlobalSnackbar } from '@/shared/components/GlobalSnackbarProvider'; import { ROUTES } from '@/shared/constants/routes'; @@ -101,6 +102,7 @@ export const deleteFile = { try { const data = getActionFileDelete({ userEmail, redirect }); submit(data, { method: 'POST', action: ROUTES.API.FILE(uuid), encType: 'application/json' }); + updateRecentFiles(uuid, '', false); } catch (e) { addGlobalSnackbar('Failed to delete file. Try again.', { severity: 'error' }); } diff --git a/quadratic-client/src/app/events/events.ts b/quadratic-client/src/app/events/events.ts index 40bca28696..1a22fbe683 100644 --- a/quadratic-client/src/app/events/events.ts +++ b/quadratic-client/src/app/events/events.ts @@ -131,6 +131,7 @@ interface EventTypes { hashContentChanged: (sheetId: string, hashX: number, hashY: number) => void; + recentFiles: (url: string, name: string, loaded: boolean) => void; codeEditorCodeCell: (codeCell?: CodeCell) => void; } diff --git a/quadratic-client/src/app/ui/QuadraticUI.tsx b/quadratic-client/src/app/ui/QuadraticUI.tsx index 4624fdc5d9..4d2edc5cb0 100644 --- a/quadratic-client/src/app/ui/QuadraticUI.tsx +++ b/quadratic-client/src/app/ui/QuadraticUI.tsx @@ -23,6 +23,7 @@ import FeedbackMenu from '@/app/ui/menus/FeedbackMenu'; import SheetBar from '@/app/ui/menus/SheetBar'; import Toolbar from '@/app/ui/menus/Toolbar'; import { TopBar } from '@/app/ui/menus/TopBar/TopBar'; +import { updateRecentFiles } from '@/app/ui/menus/TopBar/TopBarMenus/updateRecentFiles'; import { ValidationPanel } from '@/app/ui/menus/Validations/ValidationPanel'; import { QuadraticSidebar } from '@/app/ui/QuadraticSidebar'; import { UpdateAlertVersion } from '@/app/ui/UpdateAlertVersion'; @@ -108,7 +109,10 @@ export default function QuadraticUI() { setShowRenameFileMenu(false)} - onSave={(newValue) => renameFile(newValue)} + onSave={(newValue) => { + updateRecentFiles(uuid, newValue, true); + renameFile(newValue); + }} value={name} /> )} diff --git a/quadratic-client/src/app/ui/components/FileProvider.tsx b/quadratic-client/src/app/ui/components/FileProvider.tsx index fc48202771..c9d63fab6f 100644 --- a/quadratic-client/src/app/ui/components/FileProvider.tsx +++ b/quadratic-client/src/app/ui/components/FileProvider.tsx @@ -1,5 +1,6 @@ import { hasPermissionToEditFile } from '@/app/actions'; import { editorInteractionStatePermissionsAtom } from '@/app/atoms/editorInteractionStateAtom'; +import { updateRecentFiles } from '@/app/ui/menus/TopBar/TopBarMenus/updateRecentFiles'; import { apiClient } from '@/shared/api/apiClient'; import { useFileRouteLoaderData } from '@/shared/hooks/useFileRouteLoaderData'; import mixpanel from 'mixpanel-browser'; @@ -42,8 +43,9 @@ export const FileProvider = ({ children }: { children: React.ReactElement }) => (newName) => { mixpanel.track('[Files].renameCurrentFile', { newFilename: newName }); setName(newName); + updateRecentFiles(uuid, newName, true); }, - [setName] + [setName, uuid] ); // Create and save the fn used by the sheetController to save the file diff --git a/quadratic-client/src/app/ui/menus/TopBar/TopBarMenus/FileMenubarMenu.tsx b/quadratic-client/src/app/ui/menus/TopBar/TopBarMenus/FileMenubarMenu.tsx index acd0e9aef8..978232c823 100644 --- a/quadratic-client/src/app/ui/menus/TopBar/TopBarMenus/FileMenubarMenu.tsx +++ b/quadratic-client/src/app/ui/menus/TopBar/TopBarMenus/FileMenubarMenu.tsx @@ -8,10 +8,22 @@ import { import { useFileContext } from '@/app/ui/components/FileProvider'; import { useIsAvailableArgs } from '@/app/ui/hooks/useIsAvailableArgs'; import { MenubarItemAction } from '@/app/ui/menus/TopBar/TopBarMenus/MenubarItemAction'; +import { clearRecentFiles, RECENT_FILES_KEY, RecentFile } from '@/app/ui/menus/TopBar/TopBarMenus/updateRecentFiles'; import { useRootRouteLoaderData } from '@/routes/_root'; import { useGlobalSnackbar } from '@/shared/components/GlobalSnackbarProvider'; -import { DeleteIcon, DraftIcon, FileCopyIcon } from '@/shared/components/Icons'; -import { MenubarContent, MenubarItem, MenubarMenu, MenubarSeparator, MenubarTrigger } from '@/shared/shadcn/ui/menubar'; +import { DeleteIcon, DraftIcon, FileCopyIcon, FileOpenIcon } from '@/shared/components/Icons'; +import useLocalStorage from '@/shared/hooks/useLocalStorage'; +import { + MenubarContent, + MenubarItem, + MenubarMenu, + MenubarSeparator, + MenubarSub, + MenubarSubContent, + MenubarSubTrigger, + MenubarTrigger, +} from '@/shared/shadcn/ui/menubar'; +import { useMemo } from 'react'; import { useSubmit } from 'react-router-dom'; import { useRecoilValue, useSetRecoilState } from 'recoil'; @@ -28,6 +40,38 @@ export const FileMenubarMenu = () => { const { addGlobalSnackbar } = useGlobalSnackbar(); const isAvailableArgs = useIsAvailableArgs(); + const [recentFiles] = useLocalStorage(RECENT_FILES_KEY, []); + const recentFilesMenuItems = useMemo(() => { + if (recentFiles.length <= 1) return null; + + return ( + <> + + + + Open recent + + + {recentFiles + .filter((file) => file.uuid !== uuid && file.name.trim().length > 0) + .map((file) => ( + { + window.location.href = `/file/${file.uuid}`; + }} + key={file.uuid} + > + {file.name} + + ))} + + Clear + + + + ); + }, [uuid, recentFiles]); + if (!isAuthenticated) return null; return ( @@ -46,6 +90,8 @@ export const FileMenubarMenu = () => { )} + {recentFilesMenuItems} + diff --git a/quadratic-client/src/app/ui/menus/TopBar/TopBarMenus/updateRecentFiles.ts b/quadratic-client/src/app/ui/menus/TopBar/TopBarMenus/updateRecentFiles.ts new file mode 100644 index 0000000000..9c45f6c5d6 --- /dev/null +++ b/quadratic-client/src/app/ui/menus/TopBar/TopBarMenus/updateRecentFiles.ts @@ -0,0 +1,48 @@ +//! This updates localStorage with a list of recently opened files on the user's +//! machine. This is called when a file is opened (successfully or not). The +//! file menu uses useLocalStorage to access this data (it cannot be done here +//! or in an Atom b/c of the timing of when the file is opened). + +export interface RecentFile { + uuid: string; + name: string; +} + +const MAX_RECENT_FILES = 10; +export const RECENT_FILES_KEY = 'recent_files'; + +// Updates the recent files list in localStorage. If loaded is false, then the +// file is deleted. If onlyIfExists = true, then the file is only added if it +// already exists in the list. +export const updateRecentFiles = (uuid: string, name: string, loaded: boolean, onlyIfExists = false) => { + try { + if (loaded) { + const existing = localStorage.getItem(RECENT_FILES_KEY); + const recentFiles = existing ? JSON.parse(existing) : []; + if (onlyIfExists && !recentFiles.find((file: RecentFile) => file.uuid === uuid)) { + return; + } + const newRecentFiles = [{ uuid, name }, ...recentFiles.filter((file: RecentFile) => file.uuid !== uuid)]; + while (newRecentFiles.length > MAX_RECENT_FILES) { + newRecentFiles.pop(); + } + localStorage.setItem(RECENT_FILES_KEY, JSON.stringify(newRecentFiles)); + } else { + const existing = localStorage.getItem(RECENT_FILES_KEY); + const recentFiles = existing ? JSON.parse(existing) : []; + localStorage.setItem( + RECENT_FILES_KEY, + JSON.stringify(recentFiles.filter((file: RecentFile) => file.uuid !== uuid)) + ); + window.dispatchEvent(new Event('local-storage')); + } + } catch (e) { + console.warn('Unable to update recent files', e); + } +}; + +// Clears the recent files list in localStorage +export const clearRecentFiles = () => { + localStorage.removeItem(RECENT_FILES_KEY); + window.dispatchEvent(new Event('local-storage')); +}; diff --git a/quadratic-client/src/dashboard/components/FilesListItem.tsx b/quadratic-client/src/dashboard/components/FilesListItem.tsx index 1c060ef746..a3984ad2bf 100644 --- a/quadratic-client/src/dashboard/components/FilesListItem.tsx +++ b/quadratic-client/src/dashboard/components/FilesListItem.tsx @@ -1,3 +1,4 @@ +import { updateRecentFiles } from '@/app/ui/menus/TopBar/TopBarMenus/updateRecentFiles'; import { useDashboardRouteLoaderData } from '@/routes/_dashboard'; import { useRootRouteLoaderData } from '@/routes/_root'; import { @@ -126,12 +127,14 @@ export function FilesListItemUserFile({ // Update on the server and optimistically in the UI const data: FileAction['request.rename'] = { action: 'rename', name: value }; fetcherRename.submit(data, fetcherSubmitOpts); + updateRecentFiles(uuid, value, true, true); }; const handleDelete = () => { if (window.confirm(`Confirm you want to delete the file: “${name}”`)) { const data = getActionFileDelete({ userEmail: loggedInUser?.email ?? '', redirect: false }); fetcherDelete.submit(data, fetcherSubmitOpts); + updateRecentFiles(uuid, '', false); } }; diff --git a/quadratic-client/src/routes/file.$uuid.tsx b/quadratic-client/src/routes/file.$uuid.tsx index 73e82ccc51..fe32ca58f6 100644 --- a/quadratic-client/src/routes/file.$uuid.tsx +++ b/quadratic-client/src/routes/file.$uuid.tsx @@ -5,6 +5,7 @@ import { thumbnail } from '@/app/gridGL/pixiApp/thumbnail'; import { isEmbed } from '@/app/helpers/isEmbed'; import initRustClient from '@/app/quadratic-rust-client/quadratic_rust_client'; import { VersionComparisonResult, compareVersions } from '@/app/schemas/compareVersions'; +import { updateRecentFiles } from '@/app/ui/menus/TopBar/TopBarMenus/updateRecentFiles'; import { QuadraticApp } from '@/app/ui/QuadraticApp'; import { quadraticCore } from '@/app/web-workers/quadraticCore/quadraticCore'; import { initWorkers } from '@/app/web-workers/workers'; @@ -43,6 +44,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs): Promise { return zoom_out; }; +export const FileOpenIcon: IconComponent = (props) => { + return file_open; +}; + export const ArrowRight: IconComponent = (props) => { return keyboard_arrow_right; };