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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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