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:
Kevin Nadro
2025-03-24 14:57:01 -05:00
committed by GitHub
parent 65c455ae7c
commit fdeb2b3f49
17 changed files with 207 additions and 51 deletions

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
View File

@ -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

View File

@ -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,

View File

@ -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,6 +124,7 @@ function ProjectCard({
{project.name?.replace(FILE_EXT, '')}
</h3>
)}
{project.readWriteAccess && (
<span className="px-2 text-chalkboard-60 text-xs">
<span data-testid="project-file-count">{numberOfFiles}</span> file
{numberOfFiles === 1 ? '' : 's'}{' '}
@ -129,6 +138,7 @@ function ProjectCard({
</>
)}
</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',

View File

@ -14,6 +14,7 @@ const projectWellFormed = {
children: [],
},
],
readWriteAccess: true,
metadata: {
created: now.toISOString(),
modified: now.toISOString(),

View File

@ -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({

View File

@ -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]) => {

View File

@ -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,

View File

@ -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.

View File

@ -43,4 +43,5 @@ export type Project = {
path: string
name: string
children: Array<FileEntry> | null
readWriteAccess: boolean
}

View File

@ -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

View File

@ -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: [

View File

@ -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,
})

View File

@ -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"

View File

@ -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,