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