Skip to content
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 14 additions & 3 deletions src/components/ConnectionStream.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
10 changes: 10 additions & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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
}
83 changes: 79 additions & 4 deletions src/lib/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<RecentProject[] | null> => {
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(
Expand Down Expand Up @@ -962,9 +1038,8 @@ export const getUser = async (token: string): Promise<User> => {
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) {
Expand Down
42 changes: 42 additions & 0 deletions src/lib/recentProjects.ts
Original file line number Diff line number Diff line change
@@ -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<RecentProject[]> {
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 || []
}
58 changes: 35 additions & 23 deletions src/lib/screenshot.ts
Original file line number Diff line number Diff line change
@@ -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]')
Expand Down Expand Up @@ -44,31 +48,39 @@ export default async function screenshot(): Promise<string> {
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)
}
}
14 changes: 14 additions & 0 deletions src/lib/settings/settingsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = T extends null ? undefined : T
const toUndefinedIfNull = (a: any): OmitNull<any> =>
Expand Down Expand Up @@ -338,6 +339,7 @@ export interface AppSettings {
export async function loadAndValidateSettings(
projectPath?: string
): Promise<AppSettings> {
console.log('loadAndValidateSettings begin', projectPath)
// Make sure we have wasm initialized.
await initPromise

Expand All @@ -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()
Expand Down Expand Up @@ -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) {
Expand All @@ -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(),
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/routes/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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!
Expand Down
Loading