Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open recent files from within a sheet #2054

Open
wants to merge 5 commits into
base: qa
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions quadratic-client/src/app/actions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should happen in quadratic-client/src/routes/api.files.$uuid.ts in the delete action

If you put it in this file, then if a user deletes a file from the dashboard you won't see that reflected in your files list.

} catch (e) {
addGlobalSnackbar('Failed to delete file. Try again.', { severity: 'error' });
}
Expand Down
1 change: 1 addition & 0 deletions quadratic-client/src/app/events/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
6 changes: 5 additions & 1 deletion quadratic-client/src/app/ui/QuadraticUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -108,7 +109,10 @@ export default function QuadraticUI() {
<DialogRenameItem
itemLabel="file"
onClose={() => setShowRenameFileMenu(false)}
onSave={(newValue) => renameFile(newValue)}
onSave={(newValue) => {
updateRecentFiles(uuid, newValue, true);
renameFile(newValue);
}}
value={name}
/>
)}
Expand Down
4 changes: 3 additions & 1 deletion quadratic-client/src/app/ui/components/FileProvider.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -28,6 +40,38 @@ export const FileMenubarMenu = () => {
const { addGlobalSnackbar } = useGlobalSnackbar();
const isAvailableArgs = useIsAvailableArgs();

const [recentFiles] = useLocalStorage<RecentFile[]>(RECENT_FILES_KEY, []);
const recentFilesMenuItems = useMemo(() => {
if (recentFiles.length <= 1) return null;

return (
<>
<MenubarSeparator />
<MenubarSub>
<MenubarSubTrigger>
<FileOpenIcon /> Open recent
</MenubarSubTrigger>
<MenubarSubContent>
{recentFiles
.filter((file) => file.uuid !== uuid && file.name.trim().length > 0)
.map((file) => (
<MenubarItem
onClick={() => {
window.location.href = `/file/${file.uuid}`;
}}
key={file.uuid}
>
{file.name}
</MenubarItem>
))}
<MenubarSeparator />
<MenubarItem onClick={clearRecentFiles}>Clear</MenubarItem>
</MenubarSubContent>
</MenubarSub>
</>
);
}, [uuid, recentFiles]);

if (!isAuthenticated) return null;

return (
Expand All @@ -46,6 +90,8 @@ export const FileMenubarMenu = () => {
</MenubarItem>
)}

{recentFilesMenuItems}

<MenubarSeparator />

<MenubarItemAction action={Action.FileShare} actionArgs={undefined} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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'));
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this file should be put in quadratic-client/src/shared somewhere, because its functionality that really is shared between the app and the dashboard, because if you delete a file on the dashboard you'll want this code to run (which is what this comment is about).

So this should live outside of src/app since it's not just specific to the app, but shared between the app and the dashboard.

3 changes: 3 additions & 0 deletions quadratic-client/src/dashboard/components/FilesListItem.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { updateRecentFiles } from '@/app/ui/menus/TopBar/TopBarMenus/updateRecentFiles';
import { useDashboardRouteLoaderData } from '@/routes/_dashboard';
import { useRootRouteLoaderData } from '@/routes/_root';
import {
Expand Down Expand Up @@ -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);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar feedback here, if you move these into the routes file, you won't have to find every place where you rename or delete a file across both the dashboard and the app.

};

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);
}
};

Expand Down
5 changes: 5 additions & 0 deletions quadratic-client/src/routes/file.$uuid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -43,6 +44,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs): Promise<F
if (error.status === 403 && !isLoggedIn) {
return redirect(ROUTES.SIGNUP_WITH_REDIRECT());
}
updateRecentFiles(uuid, '', false);
throw new Response('Failed to load file from server.', { status: error.status });
}
if (debugShowMultiplayer || debugShowFileIO)
Expand Down Expand Up @@ -70,6 +72,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs): Promise<F
error: result.error,
},
});
updateRecentFiles(uuid, data.file.name, false);
throw new Response('Failed to deserialize file from server.', { statusText: result.error });
} else if (result.version) {
// this should eventually be moved to Rust (too lazy now to find a Rust library that does the version string compare)
Expand All @@ -78,6 +81,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs): Promise<F
message: `User opened a file at version ${result.version} but the app is at version ${data.file.lastCheckpointVersion}. The app will automatically reload.`,
level: 'log',
});
updateRecentFiles(uuid, data.file.name, false);
// @ts-expect-error hard reload via `true` only works in some browsers
window.location.reload(true);
}
Expand All @@ -87,6 +91,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs): Promise<F
} else {
throw new Error('Expected quadraticCore.load to return either a version or an error');
}
updateRecentFiles(uuid, data.file.name, true);
return data;
};

Expand Down
4 changes: 4 additions & 0 deletions quadratic-client/src/shared/components/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,10 @@ export const ZoomOutIcon: IconComponent = (props) => {
return <Icon {...props}>zoom_out</Icon>;
};

export const FileOpenIcon: IconComponent = (props) => {
return <Icon {...props}>file_open</Icon>;
};

export const ArrowRight: IconComponent = (props) => {
return <Icon {...props}>keyboard_arrow_right</Icon>;
};
Expand Down