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 ab57f9878..4c342ee87 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/e2e/playwright/snapshot-tests.spec.ts-snapshots/Zoom-to-fit-on-load---solid-3d-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Zoom-to-fit-on-load---solid-3d-1-Google-Chrome-linux.png index b5d4021ed..73f7d5b9e 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Zoom-to-fit-on-load---solid-3d-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Zoom-to-fit-on-load---solid-3d-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-XY-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-XY-1-Google-Chrome-linux.png index fd2aac645..9b7e43ef2 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-XY-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-XY-1-Google-Chrome-linux.png differ diff --git a/interface.d.ts b/interface.d.ts index 8e0be5262..b4ebeb511 100644 --- a/interface.d.ts +++ b/interface.d.ts @@ -44,6 +44,9 @@ export interface IElectronAPI { rm: typeof fs.rm stat: (path: string) => ReturnType statIsDirectory: (path: string) => Promise + canReadWriteDirectory: ( + path: string + ) => Promise<{ value: boolean; error: unknown }> path: typeof path mkdir: typeof fs.mkdir join: typeof path.join diff --git a/packages/codemirror-lang-kcl/vitest.main.config.ts b/packages/codemirror-lang-kcl/vitest.main.config.ts index 8664aa768..814142a18 100644 --- a/packages/codemirror-lang-kcl/vitest.main.config.ts +++ b/packages/codemirror-lang-kcl/vitest.main.config.ts @@ -18,7 +18,9 @@ const config = defineConfig({ environment: 'node', reporters: process.env.GITHUB_ACTIONS ? ['dot', 'github-actions'] - : ['verbose', 'hanging-process'], + : // Gotcha: 'hanging-process' is very noisey, turn off by default on localhost + // : ['verbose', 'hanging-process'], + ['verbose'], testTimeout: 1000, hookTimeout: 1000, teardownTimeout: 1000, diff --git a/src/components/ProjectCard/ProjectCard.tsx b/src/components/ProjectCard/ProjectCard.tsx index c408aa5b8..2256af092 100644 --- a/src/components/ProjectCard/ProjectCard.tsx +++ b/src/components/ProjectCard/ProjectCard.tsx @@ -86,8 +86,16 @@ function ProjectCard({ >
{imageUrl && ( @@ -116,19 +124,21 @@ function ProjectCard({ {project.name?.replace(FILE_EXT, '')} )} - - {numberOfFiles} file - {numberOfFiles === 1 ? '' : 's'}{' '} - {numberOfFolders > 0 && ( - <> - {'/ '} - - {numberOfFolders} - {' '} - folder{numberOfFolders === 1 ? '' : 's'} - - )} - + {project.readWriteAccess && ( + + {numberOfFiles} file + {numberOfFiles === 1 ? '' : 's'}{' '} + {numberOfFolders > 0 && ( + <> + {'/ '} + + {numberOfFolders} + {' '} + folder{numberOfFolders === 1 ? '' : 's'} + + )} + + )} Edited{' '} @@ -145,6 +155,7 @@ function ProjectCard({ data-edit-buttons-for={project.name?.replace(FILE_EXT, '')} > { projects: [], defaultProjectName: settings.projects.defaultProjectName.current, defaultDirectory: settings.app.projectDirectory.current, + hasListedProjects: false, }, } ) @@ -182,20 +183,11 @@ const ProjectsContextDesktop = ({ }, [searchParams, setSearchParams]) const { onProjectOpen } = useLspContext() const settings = useSettings() - const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0) const { projectPaths, projectsDir } = useProjectsLoader([ projectsLoaderTrigger, ]) - // Re-read projects listing if the projectDir has any updates. - useFileSystemWatcher( - async () => { - return setProjectsLoaderTrigger(projectsLoaderTrigger + 1) - }, - projectsDir ? [projectsDir] : [] - ) - const [state, send, actor] = useMachine( projectsMachine.provide({ actions: { @@ -313,7 +305,9 @@ const ProjectsContextDesktop = ({ ), }, actors: { - readProjects: fromPromise(() => listProjects()), + readProjects: fromPromise(() => { + return listProjects() + }), createProject: fromPromise(async ({ input }) => { let name = ( input && 'name' in input && input.name @@ -427,13 +421,33 @@ const ProjectsContextDesktop = ({ projects: projectPaths, defaultProjectName: settings.projects.defaultProjectName.current, defaultDirectory: settings.app.projectDirectory.current, + hasListedProjects: false, }, } ) + useFileSystemWatcher( + async () => { + // Gotcha: Chokidar is buggy. It will emit addDir or add on files that did not get created. + // This means while the application initialize and Chokidar initializes you cannot tell if + // a directory or file is actually created or they are buggy signals. This means you must + // ignore all signals during initialization because it is ambiguous. Once those signals settle + // you can actually start listening to real signals. + // If someone creates folders or files during initialization we ignore those events! + if (!actor.getSnapshot().context.hasListedProjects) { + return + } + return setProjectsLoaderTrigger(projectsLoaderTrigger + 1) + }, + projectsDir ? [projectsDir] : [] + ) + + // Gotcha: Triggers listProjects() on chokidar changes + // Gotcha: Load the projects when the projectDirectory changes. + const projectDirectory = settings.app.projectDirectory.current useEffect(() => { send({ type: 'Read projects', data: {} }) - }, [projectPaths]) + }, [projectPaths, projectDirectory]) // register all project-related command palette commands useStateMachineCommands({ diff --git a/src/hooks/useProjectsLoader.tsx b/src/hooks/useProjectsLoader.tsx index aff8edaa8..8df3bab8a 100644 --- a/src/hooks/useProjectsLoader.tsx +++ b/src/hooks/useProjectsLoader.tsx @@ -5,6 +5,8 @@ import { loadAndValidateSettings } from 'lib/settings/settingsUtils' import { Project } from 'lib/project' import { isDesktop } from 'lib/isDesktop' +// Gotcha: This should be ported to the ProjectMachine and keep track of +// projectDirs and projectPaths in the context when it internally calls listProjects // Hook uses [number] to give users familiarity. It is meant to mimic a // dependency array, but is intended to only ever be used with 1 value. export const useProjectsLoader = (deps?: [number]) => { diff --git a/src/lib/desktop.test.ts b/src/lib/desktop.test.ts index 18ef57b2f..2868ceb10 100644 --- a/src/lib/desktop.test.ts +++ b/src/lib/desktop.test.ts @@ -25,6 +25,7 @@ const mockElectron = { }, getPath: vi.fn(), kittycad: vi.fn(), + canReadWriteDirectory: vi.fn(), } vi.stubGlobal('window', { electron: mockElectron }) @@ -87,6 +88,12 @@ describe('desktop utilities', () => { return path in mockFileSystem }) + mockElectron.canReadWriteDirectory.mockImplementation( + async (path: string) => { + return { value: path in mockFileSystem, error: undefined } + } + ) + // Mock stat to always resolve with dummy metadata mockElectron.stat.mockResolvedValue({ mtimeMs: 123, diff --git a/src/lib/desktop.ts b/src/lib/desktop.ts index 5e252aaa8..f154b9308 100644 --- a/src/lib/desktop.ts +++ b/src/lib/desktop.ts @@ -126,6 +126,8 @@ export async function createNewProjectDirectory( metadata, kcl_file_count: 1, directory_count: 0, + // If the mkdir did not crash you have readWriteAccess + readWriteAccess: true, } } @@ -150,7 +152,12 @@ export async function listProjects( const projects = [] if (!projectDir) return Promise.reject(new Error('projectDir was falsey')) + // Gotcha: readdir will list all folders at this project directory even if you do not have readwrite access on the directory path const entries = await window.electron.readdir(projectDir) + + const { value: canReadWriteProjectDirectory } = + await window.electron.canReadWriteDirectory(projectDir) + for (let entry of entries) { // Skip directories that start with a dot if (entry.startsWith('.')) { @@ -158,19 +165,28 @@ export async function listProjects( } const projectPath = window.electron.path.join(projectDir, entry) + // if it's not a directory ignore. + // Gotcha: statIsDirectory will work even if you do not have read write permissions on the project path const isDirectory = await window.electron.statIsDirectory(projectPath) if (!isDirectory) { continue } const project = await getProjectInfo(projectPath) - // Needs at least one file to be added to the projects list - if (project.kcl_file_count === 0) { + + if ( + project.kcl_file_count === 0 && + project.readWriteAccess && + canReadWriteProjectDirectory + ) { continue } + + // Push folders you cannot readWrite to show users the issue projects.push(project) } + return projects } @@ -185,7 +201,10 @@ const IMPORT_FILE_EXTENSIONS = [ const isRelevantFile = (filename: string): boolean => IMPORT_FILE_EXTENSIONS.some((ext) => filename.endsWith('.' + ext)) -const collectAllFilesRecursiveFrom = async (path: string) => { +const collectAllFilesRecursiveFrom = async ( + path: string, + canReadWritePath: boolean +) => { // Make sure the filesystem object exists. try { await window.electron.stat(path) @@ -202,12 +221,18 @@ const collectAllFilesRecursiveFrom = async (path: string) => { } const name = window.electron.path.basename(path) + let entry: FileEntry = { name: name, path, children: [], } + // If you cannot read/write this project path do not collect the files + if (!canReadWritePath) { + return entry + } + const children = [] const entries = await window.electron.readdir(path) @@ -234,7 +259,10 @@ const collectAllFilesRecursiveFrom = async (path: string) => { const isEDir = await window.electron.statIsDirectory(ePath) if (isEDir) { - const subChildren = await collectAllFilesRecursiveFrom(ePath) + const subChildren = await collectAllFilesRecursiveFrom( + ePath, + canReadWritePath + ) children.push(subChildren) } else { if (!isRelevantFile(ePath)) { @@ -343,15 +371,31 @@ export async function getProjectInfo(projectPath: string): Promise { // Make sure it is a directory. const projectPathIsDir = await window.electron.statIsDirectory(projectPath) + if (!projectPathIsDir) { return Promise.reject( new Error(`Project path is not a directory: ${projectPath}`) ) } - let walked = await collectAllFilesRecursiveFrom(projectPath) - let default_file = await getDefaultKclFileForDir(projectPath, walked) + + // Detect the projectPath has read write permission + const { value: canReadWriteProjectPath } = + await window.electron.canReadWriteDirectory(projectPath) const metadata = await window.electron.stat(projectPath) + // Return walked early if canReadWriteProjectPath is false + let walked = await collectAllFilesRecursiveFrom( + projectPath, + canReadWriteProjectPath + ) + + // If the projectPath does not have read write permissions, the default_file is empty string + let default_file = '' + if (canReadWriteProjectPath) { + // Create the default main.kcl file only if the project path has read write permissions + default_file = await getDefaultKclFileForDir(projectPath, walked) + } + let project = { ...walked, // We need to map from node fs.Stats to FileMetadata @@ -368,6 +412,7 @@ export async function getProjectInfo(projectPath: string): Promise { kcl_file_count: 0, directory_count: 0, default_file, + readWriteAccess: canReadWriteProjectPath, } // Populate the number of KCL files in the project. diff --git a/src/lib/project.d.ts b/src/lib/project.d.ts index 987f23164..876db8b3f 100644 --- a/src/lib/project.d.ts +++ b/src/lib/project.d.ts @@ -43,4 +43,5 @@ export type Project = { path: string name: string children: Array | null + readWriteAccess: boolean } diff --git a/src/lib/routeLoaders.ts b/src/lib/routeLoaders.ts index 15d60bf83..7da9a221a 100644 --- a/src/lib/routeLoaders.ts +++ b/src/lib/routeLoaders.ts @@ -98,6 +98,7 @@ export const fileLoader: LoaderFunction = async ( directory_count: 0, metadata: null, default_file: projectPath, + readWriteAccess: true, } const maybeProjectInfo = isDesktop() @@ -143,6 +144,7 @@ export const fileLoader: LoaderFunction = async ( directory_count: 0, kcl_file_count: 1, metadata: null, + readWriteAccess: true, } // Fire off the event to load the project settings diff --git a/src/machines/projectsMachine.ts b/src/machines/projectsMachine.ts index f15f1490e..408360870 100644 --- a/src/machines/projectsMachine.ts +++ b/src/machines/projectsMachine.ts @@ -9,6 +9,7 @@ export const projectsMachine = setup({ projects: Project[] defaultProjectName: string defaultDirectory: string + hasListedProjects: boolean }, events: {} as | { type: 'Read projects'; data: {} } @@ -55,6 +56,7 @@ export const projectsMachine = setup({ projects: Project[] defaultProjectName: string defaultDirectory: string + hasListedProjects: boolean }, }, actions: { @@ -64,6 +66,9 @@ export const projectsMachine = setup({ ? event.output : context.projects, }), + setHasListedProjects: assign({ + hasListedProjects: () => true, + }), toastSuccess: () => {}, toastError: () => {}, navigateToProject: () => {}, @@ -128,7 +133,6 @@ export const projectsMachine = setup({ actions: assign(({ event }) => ({ ...event.data, })), - target: '.Reading projects', }, 'Import file from URL': '.Creating file', @@ -281,11 +285,11 @@ export const projectsMachine = setup({ { guard: 'Has at least 1 project', target: 'Has projects', - actions: ['setProjects'], + actions: ['setProjects', 'setHasListedProjects'], }, { target: 'Has no projects', - actions: ['setProjects'], + actions: ['setProjects', 'setHasListedProjects'], }, ], onError: [ diff --git a/src/preload.ts b/src/preload.ts index b9346826f..57726a809 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -98,14 +98,40 @@ const rename = (prev: string, next: string) => fs.rename(prev, next) const writeFile = (path: string, data: string | Uint8Array) => fs.writeFile(path, data, 'utf-8') const readdir = (path: string) => fs.readdir(path, 'utf-8') -const stat = (path: string) => - fs.stat(path).catch((e) => Promise.reject(e.code)) +const stat = (path: string) => { + return fs.stat(path).catch((e) => Promise.reject(e.code)) +} + // Electron has behavior where it doesn't clone the prototype chain over. // So we need to call stat.isDirectory on this side. const statIsDirectory = (path: string) => stat(path).then((res) => res.isDirectory()) const getPath = async (name: string) => ipcRenderer.invoke('app.getPath', name) +const canReadWriteDirectory = async ( + path: string +): Promise<{ value: boolean; error: unknown } | Error> => { + const isDirectory = await statIsDirectory(path) + if (!isDirectory) { + return new Error('path is not a directory. Do not send a file path.') + } + + // bitwise OR to check read and write permissions + try { + const canReadWrite = await fs.access( + path, + fs.constants.R_OK | fs.constants.W_OK + ) + // This function returns undefined. If it cannot access the path it will throw an error + return canReadWrite === undefined + ? { value: true, error: undefined } + : { value: false, error: undefined } + } catch (e) { + console.error(e) + return { value: false, error: e } + } +} + const exposeProcessEnvs = (varNames: Array) => { const envs: Record = {} varNames.forEach((varName) => { @@ -211,4 +237,5 @@ contextBridge.exposeInMainWorld('electron', { appCheckForUpdates, getArgvParsed, resizeWindow, + canReadWriteDirectory, }) diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index e50ae1486..aa9255ed5 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -19,19 +19,23 @@ import { LowerRightControls } from 'components/LowerRightControls' import { ProjectSearchBar, useProjectSearch } from 'components/ProjectSearchBar' import { Project } from 'lib/project' import { markOnce } from 'lib/performance' -import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher' -import { useProjectsLoader } from 'hooks/useProjectsLoader' import { useProjectsContext } from 'hooks/useProjectsContext' import { commandBarActor } from 'machines/commandBarMachine' import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher' import { useSettings } from 'machines/appMachine' +import { reportRejection } from 'lib/trap' // This route only opens in the desktop context for now, // as defined in Router.tsx, so we can use the desktop APIs and types. const Home = () => { const { state, send } = useProjectsContext() - const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0) - const { projectsDir } = useProjectsLoader([projectsLoaderTrigger]) + const [readWriteProjectDir, setReadWriteProjectDir] = useState<{ + value: boolean + error: unknown + }>({ + value: true, + error: undefined, + }) // Keep a lookout for a URL query string that invokes the 'import file from URL' command useCreateFileLinkQuery((argDefaultValues) => { @@ -66,14 +70,6 @@ const Home = () => { ) const ref = useRef(null) - // Re-read projects listing if the projectDir has any updates. - useFileSystemWatcher( - async () => { - setProjectsLoaderTrigger(projectsLoaderTrigger + 1) - }, - projectsDir ? [projectsDir] : [] - ) - const projects = state?.context.projects ?? [] const [searchParams, setSearchParams] = useSearchParams() const { searchResults, query, setQuery } = useProjectSearch(projects) @@ -91,6 +87,16 @@ const Home = () => { defaultDirectory: settings.app.projectDirectory.current, }, }) + + // Must be a truthy string, not '' or null or undefined + if (settings.app.projectDirectory.current) { + window.electron + .canReadWriteDirectory(settings.app.projectDirectory.current) + .then((res) => { + setReadWriteProjectDir(res) + }) + .catch(reportRejection) + } }, [ settings.app.projectDirectory.current, settings.projects.defaultProjectName.current, @@ -124,6 +130,18 @@ const Home = () => { data: { name: project.name || '' }, }) } + /** Type narrowing function of unknown error to a string */ + function errorMessage(error: unknown): string { + if (error != undefined && error instanceof Error) { + return error.message + } else if (error && typeof error === 'object') { + return JSON.stringify(error) + } else if (typeof error === 'string') { + return error + } else { + return 'Unknown error' + } + } return (
@@ -219,6 +237,22 @@ const Home = () => { .

+ {!readWriteProjectDir.value && ( +
+
+
+

{errorMessage(readWriteProjectDir.error)}

+ + Change Project Directory + +
+
+
+ )}