Feature: Implement read write access checking on Project Directory and report any issues in home page (#5676)
* chore: skeleton to detect read write directories and if we have access to notify user * chore: adding buttont to easily change project directory * chore: cleaning up home page error bar layout and button * fix: adding clearer comments * fix: ugly console debugging but I need to save off progress * fix: removing project dir check on empty string * fix: debug progress to save off listProjects once. Still bugged... * fix: more hard coded debugging to get project loading optimizted * fix: yarp, we got another one bois * fix: cleaning up code * fix: massive bug comment to warn devs about chokidar bugs * fix: returning error instead of throwing * fix: cleaning up PR * fix: fixed loading the projects when the project directory changes * fix: remove testing code * fix: only skip directories if you can access the project directory since we don't need to view them * fix: unit tests, turning off noisey localhost vitest garbage * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores) * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores) * fix: deleted testing state --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com> Co-authored-by: Pierre Jacquier <pierre@zoo.dev>
This commit is contained in:
		
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB  | 
							
								
								
									
										3
									
								
								interface.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								interface.d.ts
									
									
									
									
										vendored
									
									
								
							@ -44,6 +44,9 @@ export interface IElectronAPI {
 | 
			
		||||
  rm: typeof fs.rm
 | 
			
		||||
  stat: (path: string) => ReturnType<fs.stat>
 | 
			
		||||
  statIsDirectory: (path: string) => Promise<boolean>
 | 
			
		||||
  canReadWriteDirectory: (
 | 
			
		||||
    path: string
 | 
			
		||||
  ) => Promise<{ value: boolean; error: unknown }>
 | 
			
		||||
  path: typeof path
 | 
			
		||||
  mkdir: typeof fs.mkdir
 | 
			
		||||
  join: typeof path.join
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
 | 
			
		||||
@ -86,8 +86,16 @@ function ProjectCard({
 | 
			
		||||
    >
 | 
			
		||||
      <Link
 | 
			
		||||
        data-testid="project-link"
 | 
			
		||||
        to={`${PATHS.FILE}/${encodeURIComponent(project.default_file)}`}
 | 
			
		||||
        className="flex flex-col flex-1 !no-underline !text-chalkboard-110 dark:!text-chalkboard-10 group-hover:!hue-rotate-0 min-h-[5em] divide-y divide-primary/40 dark:divide-chalkboard-80 group-hover:!divide-primary"
 | 
			
		||||
        to={
 | 
			
		||||
          project.readWriteAccess
 | 
			
		||||
            ? `${PATHS.FILE}/${encodeURIComponent(project.default_file)}`
 | 
			
		||||
            : ''
 | 
			
		||||
        }
 | 
			
		||||
        className={`flex flex-col flex-1 !no-underline !text-chalkboard-110 dark:!text-chalkboard-10 min-h-[5em] divide-y divide-primary/40 dark:divide-chalkboard-80  ${
 | 
			
		||||
          project.readWriteAccess
 | 
			
		||||
            ? 'group-hover:!divide-primary group-hover:!hue-rotate-0'
 | 
			
		||||
            : 'cursor-not-allowed'
 | 
			
		||||
        }`}
 | 
			
		||||
      >
 | 
			
		||||
        <div className="h-36 relative overflow-hidden bg-gradient-to-b from-transparent to-primary/10 rounded-t-sm">
 | 
			
		||||
          {imageUrl && (
 | 
			
		||||
@ -116,19 +124,21 @@ function ProjectCard({
 | 
			
		||||
              {project.name?.replace(FILE_EXT, '')}
 | 
			
		||||
            </h3>
 | 
			
		||||
          )}
 | 
			
		||||
          <span className="px-2 text-chalkboard-60 text-xs">
 | 
			
		||||
            <span data-testid="project-file-count">{numberOfFiles}</span> file
 | 
			
		||||
            {numberOfFiles === 1 ? '' : 's'}{' '}
 | 
			
		||||
            {numberOfFolders > 0 && (
 | 
			
		||||
              <>
 | 
			
		||||
                {'/ '}
 | 
			
		||||
                <span data-testid="project-folder-count">
 | 
			
		||||
                  {numberOfFolders}
 | 
			
		||||
                </span>{' '}
 | 
			
		||||
                folder{numberOfFolders === 1 ? '' : 's'}
 | 
			
		||||
              </>
 | 
			
		||||
            )}
 | 
			
		||||
          </span>
 | 
			
		||||
          {project.readWriteAccess && (
 | 
			
		||||
            <span className="px-2 text-chalkboard-60 text-xs">
 | 
			
		||||
              <span data-testid="project-file-count">{numberOfFiles}</span> file
 | 
			
		||||
              {numberOfFiles === 1 ? '' : 's'}{' '}
 | 
			
		||||
              {numberOfFolders > 0 && (
 | 
			
		||||
                <>
 | 
			
		||||
                  {'/ '}
 | 
			
		||||
                  <span data-testid="project-folder-count">
 | 
			
		||||
                    {numberOfFolders}
 | 
			
		||||
                  </span>{' '}
 | 
			
		||||
                  folder{numberOfFolders === 1 ? '' : 's'}
 | 
			
		||||
                </>
 | 
			
		||||
              )}
 | 
			
		||||
            </span>
 | 
			
		||||
          )}
 | 
			
		||||
          <span className="px-2 text-chalkboard-60 text-xs">
 | 
			
		||||
            Edited{' '}
 | 
			
		||||
            <span data-testid="project-edit-date">
 | 
			
		||||
@ -145,6 +155,7 @@ function ProjectCard({
 | 
			
		||||
          data-edit-buttons-for={project.name?.replace(FILE_EXT, '')}
 | 
			
		||||
        >
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            disabled={!project.readWriteAccess}
 | 
			
		||||
            Element="button"
 | 
			
		||||
            iconStart={{
 | 
			
		||||
              icon: 'sketch',
 | 
			
		||||
@ -163,6 +174,7 @@ function ProjectCard({
 | 
			
		||||
            </Tooltip>
 | 
			
		||||
          </ActionButton>
 | 
			
		||||
          <ActionButton
 | 
			
		||||
            disabled={!project.readWriteAccess}
 | 
			
		||||
            Element="button"
 | 
			
		||||
            iconStart={{
 | 
			
		||||
              icon: 'trash',
 | 
			
		||||
 | 
			
		||||
@ -14,6 +14,7 @@ const projectWellFormed = {
 | 
			
		||||
      children: [],
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
  readWriteAccess: true,
 | 
			
		||||
  metadata: {
 | 
			
		||||
    created: now.toISOString(),
 | 
			
		||||
    modified: now.toISOString(),
 | 
			
		||||
 | 
			
		||||
@ -137,6 +137,7 @@ const ProjectsContextWeb = ({ children }: { children: React.ReactNode }) => {
 | 
			
		||||
        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({
 | 
			
		||||
 | 
			
		||||
@ -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]) => {
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
 | 
			
		||||
@ -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<Project> {
 | 
			
		||||
 | 
			
		||||
  // 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<Project> {
 | 
			
		||||
    kcl_file_count: 0,
 | 
			
		||||
    directory_count: 0,
 | 
			
		||||
    default_file,
 | 
			
		||||
    readWriteAccess: canReadWriteProjectPath,
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Populate the number of KCL files in the project.
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								src/lib/project.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								src/lib/project.d.ts
									
									
									
									
										vendored
									
									
								
							@ -43,4 +43,5 @@ export type Project = {
 | 
			
		||||
  path: string
 | 
			
		||||
  name: string
 | 
			
		||||
  children: Array<FileEntry> | null
 | 
			
		||||
  readWriteAccess: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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: [
 | 
			
		||||
 | 
			
		||||
@ -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<string>) => {
 | 
			
		||||
  const envs: Record<string, string> = {}
 | 
			
		||||
  varNames.forEach((varName) => {
 | 
			
		||||
@ -211,4 +237,5 @@ contextBridge.exposeInMainWorld('electron', {
 | 
			
		||||
  appCheckForUpdates,
 | 
			
		||||
  getArgvParsed,
 | 
			
		||||
  resizeWindow,
 | 
			
		||||
  canReadWriteDirectory,
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
@ -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<HTMLDivElement>(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 (
 | 
			
		||||
    <div className="relative flex flex-col h-screen overflow-hidden" ref={ref}>
 | 
			
		||||
@ -219,6 +237,22 @@ const Home = () => {
 | 
			
		||||
            </Link>
 | 
			
		||||
            .
 | 
			
		||||
          </p>
 | 
			
		||||
          {!readWriteProjectDir.value && (
 | 
			
		||||
            <section>
 | 
			
		||||
              <div className="flex items-center select-none">
 | 
			
		||||
                <div className="flex gap-8 items-center justify-between grow bg-destroy-80 text-white py-1 px-4 my-2 rounded-sm grow">
 | 
			
		||||
                  <p className="">{errorMessage(readWriteProjectDir.error)}</p>
 | 
			
		||||
                  <Link
 | 
			
		||||
                    data-testid="project-directory-settings-link"
 | 
			
		||||
                    to={`${PATHS.HOME + PATHS.SETTINGS_USER}#projectDirectory`}
 | 
			
		||||
                    className="py-1 text-white underline underline-offset-2 text-sm"
 | 
			
		||||
                  >
 | 
			
		||||
                    Change Project Directory
 | 
			
		||||
                  </Link>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </section>
 | 
			
		||||
          )}
 | 
			
		||||
        </section>
 | 
			
		||||
        <section
 | 
			
		||||
          data-testid="home-section"
 | 
			
		||||
 | 
			
		||||
@ -48,7 +48,9 @@ const config = defineConfig({
 | 
			
		||||
    mockReset: true,
 | 
			
		||||
    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,
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user