diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Sketch-on-face-with-none-z-up-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Sketch-on-face-with-none-z-up-1-Google-Chrome-linux.png index 9960b58cf43..0dfaf3b34e7 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Sketch-on-face-with-none-z-up-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Sketch-on-face-with-none-z-up-1-Google-Chrome-linux.png differ diff --git a/src/components/ConnectionStream.tsx b/src/components/ConnectionStream.tsx index b03914c76ac..ce1774ee8a5 100644 --- a/src/components/ConnectionStream.tsx +++ b/src/components/ConnectionStream.tsx @@ -1,5 +1,6 @@ import type { MouseEventHandler } from 'react' import { useRef, useState } from 'react' +import { NIL as uuidNIL } from 'uuid' import { ClientSideScene } from '@src/clientSideScene/ClientSideSceneComp' import { engineCommandManager, @@ -150,11 +151,21 @@ export const ConnectionStream = (props: { setShowManualConnect, }) .then(() => { - // Take a screen shot after the page mounts and zoom to fit runs - if (project && project.path) { + if ( + project && + project.path && + settings.meta.id.current && + settings.meta.id.current !== uuidNIL + ) { + // Take a screen shot after the page mounts and zoom to fit runs, and save to recent projects createThumbnailPNGOnDesktop({ + id: settings.meta.id.current, projectDirectoryWithoutEndingSlash: project.path, - }) + }).catch(reportRejection) + } else { + console.log( + 'Cannot save to recent projects or create thumbnail: missing project or settings meta id' + ) } }) .catch((e) => { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 90d75e3e747..77cb21e7b76 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -32,6 +32,7 @@ export const FILE_EXT = '.kcl' export const PROJECT_ENTRYPOINT = `main${FILE_EXT}` as const /** Thumbnail file name */ export const PROJECT_IMAGE_NAME = `thumbnail.png` +export const PROJECT_THUMBNAILS_DIR = `thumbnails` /** The default name given to new kcl files in a project */ export const DEFAULT_FILE_NAME = 'Untitled' /** The default name for a tutorial project */ @@ -294,3 +295,12 @@ export const LAYOUT_PERSIST_PREFIX = 'layout-' // Copilot input export const DEFAULT_ML_COPILOT_MODE: MlCopilotMode = 'fast' + +/** Recent projects */ +export const RECENT_PROJECTS_NAME = `recent-projects.json` +export const RECENT_PROJECTS_COUNT = 100 +export type RecentProject = { + path: string + id: string + lastOpened: Date +} diff --git a/src/lib/desktop.ts b/src/lib/desktop.ts index f95ed532c83..face2752d83 100644 --- a/src/lib/desktop.ts +++ b/src/lib/desktop.ts @@ -14,15 +14,19 @@ import { parseProjectSettings, } from '@src/lang/wasm' import { initPromise, relevantFileExtensions } from '@src/lang/wasmUtils' -import type { EnvironmentConfiguration } from '@src/lib/constants' +import type { + EnvironmentConfiguration, + RecentProject, +} from '@src/lib/constants' import { DEFAULT_DEFAULT_LENGTH_UNIT, ENVIRONMENT_CONFIGURATION_FOLDER, ENVIRONMENT_FILE_NAME, PROJECT_ENTRYPOINT, PROJECT_FOLDER, - PROJECT_IMAGE_NAME, PROJECT_SETTINGS_FILE_NAME, + PROJECT_THUMBNAILS_DIR, + RECENT_PROJECTS_NAME, SETTINGS_FILE_NAME, TELEMETRY_FILE_NAME, TELEMETRY_RAW_FILE_NAME, @@ -588,6 +592,78 @@ export const getEnvironmentConfigurationPath = async ( return electron.path.join(fullPath, environmentName + '.json') } +export const getProjectThumbnailsPath = async (electron: IElectronAPI) => { + const isTestEnv = electron.process.env.NODE_ENV === 'test' + const testSettingsPath = await electron.getAppTestProperty( + 'TEST_SETTINGS_FILE_KEY' + ) + const appConfig = await electron.getPath('appData') + const fullPath = isTestEnv + ? electron.path.resolve(testSettingsPath, '..') + : electron.path.join( + appConfig, + getAppFolderName(electron), + PROJECT_THUMBNAILS_DIR + ) + try { + await electron.stat(fullPath) + } catch (e) { + // File/path doesn't exist + if (e === 'ENOENT') { + await electron.mkdir(fullPath, { recursive: true }) + } + } + + return fullPath +} + +export const getRecentProjectsPath = async (electron: IElectronAPI) => { + const isTestEnv = electron.process.env.NODE_ENV === 'test' + const testSettingsPath = await electron.getAppTestProperty( + 'TEST_SETTINGS_FILE_KEY' + ) + const appConfig = await electron.getPath('appData') + const fullPath = isTestEnv + ? electron.path.resolve(testSettingsPath, '..') + : electron.path.join(appConfig, getAppFolderName(electron)) + try { + await electron.stat(fullPath) + } catch (e) { + // File/path doesn't exist + if (e === 'ENOENT') { + await electron.mkdir(fullPath, { recursive: true }) + } + } + + return electron.path.join(fullPath, RECENT_PROJECTS_NAME) +} + +export const readRecentProjectsFile = async ( + electron: IElectronAPI +): Promise => { + const path = await getRecentProjectsPath(electron) + if (electron.exists(path)) { + const content: string = await electron.readFile(path, { + encoding: 'utf-8', + }) + if (!content) return null + return JSON.parse(content) + } + return null +} + +export const writeRecentProjectsFile = async ( + electron: IElectronAPI, + recentProjects: RecentProject[] +) => { + const recentProjectsPath = await getRecentProjectsPath(electron) + const result = electron.writeFile( + recentProjectsPath, + JSON.stringify(recentProjects) + ) + return result +} + export const getEnvironmentFilePath = async (electron: IElectronAPI) => { const isTestEnv = electron.process.env.NODE_ENV === 'test' const testSettingsPath = await electron.getAppTestProperty( @@ -962,9 +1038,8 @@ export const getUser = async (token: string): Promise => { export const writeProjectThumbnailFile = async ( electron: IElectronAPI, dataUrl: string, - projectDirectoryPath: string + filePath: string ) => { - const filePath = electron.path.join(projectDirectoryPath, PROJECT_IMAGE_NAME) const data = atob(dataUrl.substring('data:image/png;base64,'.length)) const asArray = new Uint8Array(data.length) for (let i = 0, len = data.length; i < len; ++i) { diff --git a/src/lib/recentProjects.ts b/src/lib/recentProjects.ts new file mode 100644 index 00000000000..29eecea8599 --- /dev/null +++ b/src/lib/recentProjects.ts @@ -0,0 +1,42 @@ +import { + readRecentProjectsFile, + writeRecentProjectsFile, +} from '@src/lib/desktop' +import { RECENT_PROJECTS_COUNT, type RecentProject } from '@src/lib/constants' + +export async function saveToRecentProjects(path: string, id: string) { + if (!window.electron) { + console.warn( + 'Cannot save to recent projects: window.electron is not available' + ) + return + } + + const electron = window.electron + let recentProjects = await readRecentProjectsFile(electron) + if (!recentProjects) { + recentProjects = [] + } + + // Remove any existing entry for this projectPath + recentProjects = recentProjects.filter((proj) => proj.path !== path) + + // Add the new entry to the start of the list + const lastOpened = new Date() + recentProjects.unshift({ path, id, lastOpened }) + await writeRecentProjectsFile( + electron, + recentProjects.slice(0, RECENT_PROJECTS_COUNT) + ) +} + +export async function getRecentProjects(): Promise { + if (!window.electron) { + console.warn('Cannot get recent projects: window.electron is not available') + return [] + } + + const electron = window.electron + const recentProjects = await readRecentProjectsFile(electron) + return recentProjects || [] +} diff --git a/src/lib/screenshot.ts b/src/lib/screenshot.ts index 106929451b3..94724bec70c 100644 --- a/src/lib/screenshot.ts +++ b/src/lib/screenshot.ts @@ -1,4 +1,8 @@ -import { writeProjectThumbnailFile } from '@src/lib/desktop' +import { + getProjectThumbnailsPath, + writeProjectThumbnailFile, +} from '@src/lib/desktop' +import { PROJECT_IMAGE_NAME } from '@src/lib/constants' export function takeScreenshotOfVideoStreamCanvas() { const canvas = document.querySelector('[data-engine]') @@ -44,31 +48,39 @@ export default async function screenshot(): Promise { return takeScreenshotOfVideoStreamCanvas() } -export function createThumbnailPNGOnDesktop({ +export async function createThumbnailPNGOnDesktop({ + id, projectDirectoryWithoutEndingSlash, }: { + id: string projectDirectoryWithoutEndingSlash: string }) { - if (window.electron) { - const electron = window.electron - setTimeout(() => { - if (!projectDirectoryWithoutEndingSlash) { - return - } - const dataUrl: string = takeScreenshotOfVideoStreamCanvas() - // zoom to fit command does not wait, wait 500ms to see if zoom to fit finishes - writeProjectThumbnailFile( - electron, - dataUrl, - projectDirectoryWithoutEndingSlash - ) - .then(() => {}) - .catch((e) => { - console.error( - `Failed to generate thumbnail for ${projectDirectoryWithoutEndingSlash}` - ) - console.error(e) - }) - }, 500) + if (!window.electron || !id || !projectDirectoryWithoutEndingSlash) { + console.warn( + 'Cannot create thumbnail PNG: not desktop or missing parameters' + ) + return + } + + const electron = window.electron + try { + // zoom to fit command does not wait, wait 500ms to see if zoom to fit finishes + await new Promise((resolve) => setTimeout(resolve, 500)) + const dataUrl: string = takeScreenshotOfVideoStreamCanvas() + + const thumbnailsDir = await getProjectThumbnailsPath(electron) + const filePath = electron.path.join(thumbnailsDir, `${id}.png`) + console.log('Writing thumbnail to', filePath) + await writeProjectThumbnailFile(electron, dataUrl, filePath) + + // TODO: remove once we're retiring the old thumbnail.png + const oldThumbnailPath = electron.path.join( + projectDirectoryWithoutEndingSlash, + PROJECT_IMAGE_NAME + ) + console.log('Writing thumbnail to', oldThumbnailPath) + await writeProjectThumbnailFile(electron, dataUrl, oldThumbnailPath) + } catch (e) { + console.error(`Failed to generate thumbnail`, e) } } diff --git a/src/lib/settings/settingsUtils.ts b/src/lib/settings/settingsUtils.ts index a78edfe4458..8e76bc9977e 100644 --- a/src/lib/settings/settingsUtils.ts +++ b/src/lib/settings/settingsUtils.ts @@ -38,6 +38,7 @@ import type { import { appThemeToTheme } from '@src/lib/theme' import { err } from '@src/lib/trap' import type { DeepPartial } from '@src/lib/types' +import { saveToRecentProjects } from '@src/lib/recentProjects' type OmitNull = T extends null ? undefined : T const toUndefinedIfNull = (a: any): OmitNull => @@ -338,6 +339,7 @@ export interface AppSettings { export async function loadAndValidateSettings( projectPath?: string ): Promise { + console.log('loadAndValidateSettings begin', projectPath) // Make sure we have wasm initialized. await initPromise @@ -346,6 +348,7 @@ export async function loadAndValidateSettings( ? await readAppSettingsFile(window.electron) : readLocalStorageAppSettingsFile() + console.log('loadAndValidateSettings appSettingsPayload', appSettingsPayload) if (err(appSettingsPayload)) return Promise.reject(appSettingsPayload) let settingsNext = createSettings() @@ -380,6 +383,10 @@ export async function loadAndValidateSettings( } // Duplicated from settingsUtils.ts const projectTomlString = serializeProjectConfiguration(projectSettings) + console.log( + 'loadAndValidateSettings projectTomlString', + projectTomlString + ) if (err(projectTomlString)) return Promise.reject(new Error('Failed to serialize project settings')) if (window.electron) { @@ -404,6 +411,9 @@ export async function loadAndValidateSettings( !projectSettings.settings?.meta?.id || projectSettings.settings.meta.id === uuidNIL ) { + console.log( + 'An id was missing. Create one and write it to disk immediately.' + ) const projectSettingsNew = { meta: { id: v4(), @@ -482,6 +492,10 @@ export async function loadAndValidateSettings( 'project', projectConfigurationToSettingsPayload(projectSettingsPayload) ) + + if (isDesktop() && window.electron) { + await saveToRecentProjects(projectPath, settingsNext.meta.id.current) + } } // Return the settings object diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 60d4b5dc04f..2885bafe2b2 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -68,6 +68,7 @@ import { onDismissOnboardingInvite, } from '@src/routes/Onboarding/utils' import { useSelector } from '@xstate/react' +import { getRecentProjects } from '@src/lib/recentProjects' type ReadWriteProjectState = { value: boolean @@ -95,6 +96,11 @@ const Home = () => { setNativeFileMenuCreated(true) }) .catch(reportRejection) + + // TODO: remove, this is just for testing + getRecentProjects() + .then((rp) => console.log('Recent projects loaded on home:', rp)) + .catch(reportRejection) } billingActor.send({ type: BillingTransition.Update, apiToken }) // eslint-disable-next-line react-hooks/exhaustive-deps -- TODO: blanket-ignored fix me!