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
|
rm: typeof fs.rm
|
||||||
stat: (path: string) => ReturnType<fs.stat>
|
stat: (path: string) => ReturnType<fs.stat>
|
||||||
statIsDirectory: (path: string) => Promise<boolean>
|
statIsDirectory: (path: string) => Promise<boolean>
|
||||||
|
canReadWriteDirectory: (
|
||||||
|
path: string
|
||||||
|
) => Promise<{ value: boolean; error: unknown }>
|
||||||
path: typeof path
|
path: typeof path
|
||||||
mkdir: typeof fs.mkdir
|
mkdir: typeof fs.mkdir
|
||||||
join: typeof path.join
|
join: typeof path.join
|
||||||
|
@ -18,7 +18,9 @@ const config = defineConfig({
|
|||||||
environment: 'node',
|
environment: 'node',
|
||||||
reporters: process.env.GITHUB_ACTIONS
|
reporters: process.env.GITHUB_ACTIONS
|
||||||
? ['dot', '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,
|
testTimeout: 1000,
|
||||||
hookTimeout: 1000,
|
hookTimeout: 1000,
|
||||||
teardownTimeout: 1000,
|
teardownTimeout: 1000,
|
||||||
|
@ -86,8 +86,16 @@ function ProjectCard({
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
data-testid="project-link"
|
data-testid="project-link"
|
||||||
to={`${PATHS.FILE}/${encodeURIComponent(project.default_file)}`}
|
to={
|
||||||
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"
|
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">
|
<div className="h-36 relative overflow-hidden bg-gradient-to-b from-transparent to-primary/10 rounded-t-sm">
|
||||||
{imageUrl && (
|
{imageUrl && (
|
||||||
@ -116,19 +124,21 @@ function ProjectCard({
|
|||||||
{project.name?.replace(FILE_EXT, '')}
|
{project.name?.replace(FILE_EXT, '')}
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
<span className="px-2 text-chalkboard-60 text-xs">
|
{project.readWriteAccess && (
|
||||||
<span data-testid="project-file-count">{numberOfFiles}</span> file
|
<span className="px-2 text-chalkboard-60 text-xs">
|
||||||
{numberOfFiles === 1 ? '' : 's'}{' '}
|
<span data-testid="project-file-count">{numberOfFiles}</span> file
|
||||||
{numberOfFolders > 0 && (
|
{numberOfFiles === 1 ? '' : 's'}{' '}
|
||||||
<>
|
{numberOfFolders > 0 && (
|
||||||
{'/ '}
|
<>
|
||||||
<span data-testid="project-folder-count">
|
{'/ '}
|
||||||
{numberOfFolders}
|
<span data-testid="project-folder-count">
|
||||||
</span>{' '}
|
{numberOfFolders}
|
||||||
folder{numberOfFolders === 1 ? '' : 's'}
|
</span>{' '}
|
||||||
</>
|
folder{numberOfFolders === 1 ? '' : 's'}
|
||||||
)}
|
</>
|
||||||
</span>
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="px-2 text-chalkboard-60 text-xs">
|
<span className="px-2 text-chalkboard-60 text-xs">
|
||||||
Edited{' '}
|
Edited{' '}
|
||||||
<span data-testid="project-edit-date">
|
<span data-testid="project-edit-date">
|
||||||
@ -145,6 +155,7 @@ function ProjectCard({
|
|||||||
data-edit-buttons-for={project.name?.replace(FILE_EXT, '')}
|
data-edit-buttons-for={project.name?.replace(FILE_EXT, '')}
|
||||||
>
|
>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
disabled={!project.readWriteAccess}
|
||||||
Element="button"
|
Element="button"
|
||||||
iconStart={{
|
iconStart={{
|
||||||
icon: 'sketch',
|
icon: 'sketch',
|
||||||
@ -163,6 +174,7 @@ function ProjectCard({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
disabled={!project.readWriteAccess}
|
||||||
Element="button"
|
Element="button"
|
||||||
iconStart={{
|
iconStart={{
|
||||||
icon: 'trash',
|
icon: 'trash',
|
||||||
|
@ -14,6 +14,7 @@ const projectWellFormed = {
|
|||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
readWriteAccess: true,
|
||||||
metadata: {
|
metadata: {
|
||||||
created: now.toISOString(),
|
created: now.toISOString(),
|
||||||
modified: now.toISOString(),
|
modified: now.toISOString(),
|
||||||
|
@ -137,6 +137,7 @@ const ProjectsContextWeb = ({ children }: { children: React.ReactNode }) => {
|
|||||||
projects: [],
|
projects: [],
|
||||||
defaultProjectName: settings.projects.defaultProjectName.current,
|
defaultProjectName: settings.projects.defaultProjectName.current,
|
||||||
defaultDirectory: settings.app.projectDirectory.current,
|
defaultDirectory: settings.app.projectDirectory.current,
|
||||||
|
hasListedProjects: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -182,20 +183,11 @@ const ProjectsContextDesktop = ({
|
|||||||
}, [searchParams, setSearchParams])
|
}, [searchParams, setSearchParams])
|
||||||
const { onProjectOpen } = useLspContext()
|
const { onProjectOpen } = useLspContext()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0)
|
const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0)
|
||||||
const { projectPaths, projectsDir } = useProjectsLoader([
|
const { projectPaths, projectsDir } = useProjectsLoader([
|
||||||
projectsLoaderTrigger,
|
projectsLoaderTrigger,
|
||||||
])
|
])
|
||||||
|
|
||||||
// Re-read projects listing if the projectDir has any updates.
|
|
||||||
useFileSystemWatcher(
|
|
||||||
async () => {
|
|
||||||
return setProjectsLoaderTrigger(projectsLoaderTrigger + 1)
|
|
||||||
},
|
|
||||||
projectsDir ? [projectsDir] : []
|
|
||||||
)
|
|
||||||
|
|
||||||
const [state, send, actor] = useMachine(
|
const [state, send, actor] = useMachine(
|
||||||
projectsMachine.provide({
|
projectsMachine.provide({
|
||||||
actions: {
|
actions: {
|
||||||
@ -313,7 +305,9 @@ const ProjectsContextDesktop = ({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
actors: {
|
actors: {
|
||||||
readProjects: fromPromise(() => listProjects()),
|
readProjects: fromPromise(() => {
|
||||||
|
return listProjects()
|
||||||
|
}),
|
||||||
createProject: fromPromise(async ({ input }) => {
|
createProject: fromPromise(async ({ input }) => {
|
||||||
let name = (
|
let name = (
|
||||||
input && 'name' in input && input.name
|
input && 'name' in input && input.name
|
||||||
@ -427,13 +421,33 @@ const ProjectsContextDesktop = ({
|
|||||||
projects: projectPaths,
|
projects: projectPaths,
|
||||||
defaultProjectName: settings.projects.defaultProjectName.current,
|
defaultProjectName: settings.projects.defaultProjectName.current,
|
||||||
defaultDirectory: settings.app.projectDirectory.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(() => {
|
useEffect(() => {
|
||||||
send({ type: 'Read projects', data: {} })
|
send({ type: 'Read projects', data: {} })
|
||||||
}, [projectPaths])
|
}, [projectPaths, projectDirectory])
|
||||||
|
|
||||||
// register all project-related command palette commands
|
// register all project-related command palette commands
|
||||||
useStateMachineCommands({
|
useStateMachineCommands({
|
||||||
|
@ -5,6 +5,8 @@ import { loadAndValidateSettings } from 'lib/settings/settingsUtils'
|
|||||||
import { Project } from 'lib/project'
|
import { Project } from 'lib/project'
|
||||||
import { isDesktop } from 'lib/isDesktop'
|
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
|
// 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.
|
// dependency array, but is intended to only ever be used with 1 value.
|
||||||
export const useProjectsLoader = (deps?: [number]) => {
|
export const useProjectsLoader = (deps?: [number]) => {
|
||||||
|
@ -25,6 +25,7 @@ const mockElectron = {
|
|||||||
},
|
},
|
||||||
getPath: vi.fn(),
|
getPath: vi.fn(),
|
||||||
kittycad: vi.fn(),
|
kittycad: vi.fn(),
|
||||||
|
canReadWriteDirectory: vi.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.stubGlobal('window', { electron: mockElectron })
|
vi.stubGlobal('window', { electron: mockElectron })
|
||||||
@ -87,6 +88,12 @@ describe('desktop utilities', () => {
|
|||||||
return path in mockFileSystem
|
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
|
// Mock stat to always resolve with dummy metadata
|
||||||
mockElectron.stat.mockResolvedValue({
|
mockElectron.stat.mockResolvedValue({
|
||||||
mtimeMs: 123,
|
mtimeMs: 123,
|
||||||
|
@ -126,6 +126,8 @@ export async function createNewProjectDirectory(
|
|||||||
metadata,
|
metadata,
|
||||||
kcl_file_count: 1,
|
kcl_file_count: 1,
|
||||||
directory_count: 0,
|
directory_count: 0,
|
||||||
|
// If the mkdir did not crash you have readWriteAccess
|
||||||
|
readWriteAccess: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,7 +152,12 @@ export async function listProjects(
|
|||||||
const projects = []
|
const projects = []
|
||||||
if (!projectDir) return Promise.reject(new Error('projectDir was falsey'))
|
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 entries = await window.electron.readdir(projectDir)
|
||||||
|
|
||||||
|
const { value: canReadWriteProjectDirectory } =
|
||||||
|
await window.electron.canReadWriteDirectory(projectDir)
|
||||||
|
|
||||||
for (let entry of entries) {
|
for (let entry of entries) {
|
||||||
// Skip directories that start with a dot
|
// Skip directories that start with a dot
|
||||||
if (entry.startsWith('.')) {
|
if (entry.startsWith('.')) {
|
||||||
@ -158,19 +165,28 @@ export async function listProjects(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const projectPath = window.electron.path.join(projectDir, entry)
|
const projectPath = window.electron.path.join(projectDir, entry)
|
||||||
|
|
||||||
// if it's not a directory ignore.
|
// 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)
|
const isDirectory = await window.electron.statIsDirectory(projectPath)
|
||||||
if (!isDirectory) {
|
if (!isDirectory) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = await getProjectInfo(projectPath)
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push folders you cannot readWrite to show users the issue
|
||||||
projects.push(project)
|
projects.push(project)
|
||||||
}
|
}
|
||||||
|
|
||||||
return projects
|
return projects
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,7 +201,10 @@ const IMPORT_FILE_EXTENSIONS = [
|
|||||||
const isRelevantFile = (filename: string): boolean =>
|
const isRelevantFile = (filename: string): boolean =>
|
||||||
IMPORT_FILE_EXTENSIONS.some((ext) => filename.endsWith('.' + ext))
|
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.
|
// Make sure the filesystem object exists.
|
||||||
try {
|
try {
|
||||||
await window.electron.stat(path)
|
await window.electron.stat(path)
|
||||||
@ -202,12 +221,18 @@ const collectAllFilesRecursiveFrom = async (path: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const name = window.electron.path.basename(path)
|
const name = window.electron.path.basename(path)
|
||||||
|
|
||||||
let entry: FileEntry = {
|
let entry: FileEntry = {
|
||||||
name: name,
|
name: name,
|
||||||
path,
|
path,
|
||||||
children: [],
|
children: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If you cannot read/write this project path do not collect the files
|
||||||
|
if (!canReadWritePath) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
const children = []
|
const children = []
|
||||||
|
|
||||||
const entries = await window.electron.readdir(path)
|
const entries = await window.electron.readdir(path)
|
||||||
@ -234,7 +259,10 @@ const collectAllFilesRecursiveFrom = async (path: string) => {
|
|||||||
const isEDir = await window.electron.statIsDirectory(ePath)
|
const isEDir = await window.electron.statIsDirectory(ePath)
|
||||||
|
|
||||||
if (isEDir) {
|
if (isEDir) {
|
||||||
const subChildren = await collectAllFilesRecursiveFrom(ePath)
|
const subChildren = await collectAllFilesRecursiveFrom(
|
||||||
|
ePath,
|
||||||
|
canReadWritePath
|
||||||
|
)
|
||||||
children.push(subChildren)
|
children.push(subChildren)
|
||||||
} else {
|
} else {
|
||||||
if (!isRelevantFile(ePath)) {
|
if (!isRelevantFile(ePath)) {
|
||||||
@ -343,15 +371,31 @@ export async function getProjectInfo(projectPath: string): Promise<Project> {
|
|||||||
|
|
||||||
// Make sure it is a directory.
|
// Make sure it is a directory.
|
||||||
const projectPathIsDir = await window.electron.statIsDirectory(projectPath)
|
const projectPathIsDir = await window.electron.statIsDirectory(projectPath)
|
||||||
|
|
||||||
if (!projectPathIsDir) {
|
if (!projectPathIsDir) {
|
||||||
return Promise.reject(
|
return Promise.reject(
|
||||||
new Error(`Project path is not a directory: ${projectPath}`)
|
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)
|
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 = {
|
let project = {
|
||||||
...walked,
|
...walked,
|
||||||
// We need to map from node fs.Stats to FileMetadata
|
// 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,
|
kcl_file_count: 0,
|
||||||
directory_count: 0,
|
directory_count: 0,
|
||||||
default_file,
|
default_file,
|
||||||
|
readWriteAccess: canReadWriteProjectPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate the number of KCL files in the project.
|
// 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
|
path: string
|
||||||
name: string
|
name: string
|
||||||
children: Array<FileEntry> | null
|
children: Array<FileEntry> | null
|
||||||
|
readWriteAccess: boolean
|
||||||
}
|
}
|
||||||
|
@ -98,6 +98,7 @@ export const fileLoader: LoaderFunction = async (
|
|||||||
directory_count: 0,
|
directory_count: 0,
|
||||||
metadata: null,
|
metadata: null,
|
||||||
default_file: projectPath,
|
default_file: projectPath,
|
||||||
|
readWriteAccess: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
const maybeProjectInfo = isDesktop()
|
const maybeProjectInfo = isDesktop()
|
||||||
@ -143,6 +144,7 @@ export const fileLoader: LoaderFunction = async (
|
|||||||
directory_count: 0,
|
directory_count: 0,
|
||||||
kcl_file_count: 1,
|
kcl_file_count: 1,
|
||||||
metadata: null,
|
metadata: null,
|
||||||
|
readWriteAccess: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire off the event to load the project settings
|
// Fire off the event to load the project settings
|
||||||
|
@ -9,6 +9,7 @@ export const projectsMachine = setup({
|
|||||||
projects: Project[]
|
projects: Project[]
|
||||||
defaultProjectName: string
|
defaultProjectName: string
|
||||||
defaultDirectory: string
|
defaultDirectory: string
|
||||||
|
hasListedProjects: boolean
|
||||||
},
|
},
|
||||||
events: {} as
|
events: {} as
|
||||||
| { type: 'Read projects'; data: {} }
|
| { type: 'Read projects'; data: {} }
|
||||||
@ -55,6 +56,7 @@ export const projectsMachine = setup({
|
|||||||
projects: Project[]
|
projects: Project[]
|
||||||
defaultProjectName: string
|
defaultProjectName: string
|
||||||
defaultDirectory: string
|
defaultDirectory: string
|
||||||
|
hasListedProjects: boolean
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
@ -64,6 +66,9 @@ export const projectsMachine = setup({
|
|||||||
? event.output
|
? event.output
|
||||||
: context.projects,
|
: context.projects,
|
||||||
}),
|
}),
|
||||||
|
setHasListedProjects: assign({
|
||||||
|
hasListedProjects: () => true,
|
||||||
|
}),
|
||||||
toastSuccess: () => {},
|
toastSuccess: () => {},
|
||||||
toastError: () => {},
|
toastError: () => {},
|
||||||
navigateToProject: () => {},
|
navigateToProject: () => {},
|
||||||
@ -128,7 +133,6 @@ export const projectsMachine = setup({
|
|||||||
actions: assign(({ event }) => ({
|
actions: assign(({ event }) => ({
|
||||||
...event.data,
|
...event.data,
|
||||||
})),
|
})),
|
||||||
target: '.Reading projects',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
'Import file from URL': '.Creating file',
|
'Import file from URL': '.Creating file',
|
||||||
@ -281,11 +285,11 @@ export const projectsMachine = setup({
|
|||||||
{
|
{
|
||||||
guard: 'Has at least 1 project',
|
guard: 'Has at least 1 project',
|
||||||
target: 'Has projects',
|
target: 'Has projects',
|
||||||
actions: ['setProjects'],
|
actions: ['setProjects', 'setHasListedProjects'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: 'Has no projects',
|
target: 'Has no projects',
|
||||||
actions: ['setProjects'],
|
actions: ['setProjects', 'setHasListedProjects'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
onError: [
|
onError: [
|
||||||
|
@ -98,14 +98,40 @@ const rename = (prev: string, next: string) => fs.rename(prev, next)
|
|||||||
const writeFile = (path: string, data: string | Uint8Array) =>
|
const writeFile = (path: string, data: string | Uint8Array) =>
|
||||||
fs.writeFile(path, data, 'utf-8')
|
fs.writeFile(path, data, 'utf-8')
|
||||||
const readdir = (path: string) => fs.readdir(path, 'utf-8')
|
const readdir = (path: string) => fs.readdir(path, 'utf-8')
|
||||||
const stat = (path: string) =>
|
const stat = (path: string) => {
|
||||||
fs.stat(path).catch((e) => Promise.reject(e.code))
|
return fs.stat(path).catch((e) => Promise.reject(e.code))
|
||||||
|
}
|
||||||
|
|
||||||
// Electron has behavior where it doesn't clone the prototype chain over.
|
// Electron has behavior where it doesn't clone the prototype chain over.
|
||||||
// So we need to call stat.isDirectory on this side.
|
// So we need to call stat.isDirectory on this side.
|
||||||
const statIsDirectory = (path: string) =>
|
const statIsDirectory = (path: string) =>
|
||||||
stat(path).then((res) => res.isDirectory())
|
stat(path).then((res) => res.isDirectory())
|
||||||
const getPath = async (name: string) => ipcRenderer.invoke('app.getPath', name)
|
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 exposeProcessEnvs = (varNames: Array<string>) => {
|
||||||
const envs: Record<string, string> = {}
|
const envs: Record<string, string> = {}
|
||||||
varNames.forEach((varName) => {
|
varNames.forEach((varName) => {
|
||||||
@ -211,4 +237,5 @@ contextBridge.exposeInMainWorld('electron', {
|
|||||||
appCheckForUpdates,
|
appCheckForUpdates,
|
||||||
getArgvParsed,
|
getArgvParsed,
|
||||||
resizeWindow,
|
resizeWindow,
|
||||||
|
canReadWriteDirectory,
|
||||||
})
|
})
|
||||||
|
@ -19,19 +19,23 @@ import { LowerRightControls } from 'components/LowerRightControls'
|
|||||||
import { ProjectSearchBar, useProjectSearch } from 'components/ProjectSearchBar'
|
import { ProjectSearchBar, useProjectSearch } from 'components/ProjectSearchBar'
|
||||||
import { Project } from 'lib/project'
|
import { Project } from 'lib/project'
|
||||||
import { markOnce } from 'lib/performance'
|
import { markOnce } from 'lib/performance'
|
||||||
import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher'
|
|
||||||
import { useProjectsLoader } from 'hooks/useProjectsLoader'
|
|
||||||
import { useProjectsContext } from 'hooks/useProjectsContext'
|
import { useProjectsContext } from 'hooks/useProjectsContext'
|
||||||
import { commandBarActor } from 'machines/commandBarMachine'
|
import { commandBarActor } from 'machines/commandBarMachine'
|
||||||
import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher'
|
import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher'
|
||||||
import { useSettings } from 'machines/appMachine'
|
import { useSettings } from 'machines/appMachine'
|
||||||
|
import { reportRejection } from 'lib/trap'
|
||||||
|
|
||||||
// This route only opens in the desktop context for now,
|
// This route only opens in the desktop context for now,
|
||||||
// as defined in Router.tsx, so we can use the desktop APIs and types.
|
// as defined in Router.tsx, so we can use the desktop APIs and types.
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
const { state, send } = useProjectsContext()
|
const { state, send } = useProjectsContext()
|
||||||
const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0)
|
const [readWriteProjectDir, setReadWriteProjectDir] = useState<{
|
||||||
const { projectsDir } = useProjectsLoader([projectsLoaderTrigger])
|
value: boolean
|
||||||
|
error: unknown
|
||||||
|
}>({
|
||||||
|
value: true,
|
||||||
|
error: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
// Keep a lookout for a URL query string that invokes the 'import file from URL' command
|
// Keep a lookout for a URL query string that invokes the 'import file from URL' command
|
||||||
useCreateFileLinkQuery((argDefaultValues) => {
|
useCreateFileLinkQuery((argDefaultValues) => {
|
||||||
@ -66,14 +70,6 @@ const Home = () => {
|
|||||||
)
|
)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
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 projects = state?.context.projects ?? []
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const { searchResults, query, setQuery } = useProjectSearch(projects)
|
const { searchResults, query, setQuery } = useProjectSearch(projects)
|
||||||
@ -91,6 +87,16 @@ const Home = () => {
|
|||||||
defaultDirectory: settings.app.projectDirectory.current,
|
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.app.projectDirectory.current,
|
||||||
settings.projects.defaultProjectName.current,
|
settings.projects.defaultProjectName.current,
|
||||||
@ -124,6 +130,18 @@ const Home = () => {
|
|||||||
data: { name: project.name || '' },
|
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 (
|
return (
|
||||||
<div className="relative flex flex-col h-screen overflow-hidden" ref={ref}>
|
<div className="relative flex flex-col h-screen overflow-hidden" ref={ref}>
|
||||||
@ -219,6 +237,22 @@ const Home = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</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>
|
||||||
<section
|
<section
|
||||||
data-testid="home-section"
|
data-testid="home-section"
|
||||||
|
@ -48,7 +48,9 @@ const config = defineConfig({
|
|||||||
mockReset: true,
|
mockReset: true,
|
||||||
reporters: process.env.GITHUB_ACTIONS
|
reporters: process.env.GITHUB_ACTIONS
|
||||||
? ['dot', '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,
|
testTimeout: 1000,
|
||||||
hookTimeout: 1000,
|
hookTimeout: 1000,
|
||||||
teardownTimeout: 1000,
|
teardownTimeout: 1000,
|
||||||
|
Reference in New Issue
Block a user