Fix path splitting issues on windows (#3565)

* Fix path splitting issues on windows

* Fix path splitting issue on routeLoaders

* Enable some e2e tests

* Swap enabled e2e tests

* Working bare-min project parse

* Make tsc happy

* Clean up & enable more tests

* Fix paths in browser

* Fix tests for windows

fmt

* Clean up wasm side

* Make build:wasm windows compatible

* More paths cleanup & some tests

* Remove sleep

* Use new config sturcture in parseroute

* Clean up debugger

* Fix: on settings close go back to the same file (#3549)

* Fix: on settings close go back to the same file

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest)

* shit aint working yo

* Get that page a-loading

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Frank Noirot <frank@kittycad.io>
Co-authored-by: Jess Frazelle <jessfraz@users.noreply.github.com>

* Fmt

* Comment out currently failing win32 tests

* Ignore tsc for electron monkey-patch

* Force line-endings to only

* Fix tsc

* Enable more tests

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)

* Avoid modifying global for tests

---------

Co-authored-by: 49fl <ircsurfer33@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Frank Noirot <frank@kittycad.io>
Co-authored-by: Jess Frazelle <jessfraz@users.noreply.github.com>
This commit is contained in:
Adam Sunderland
2024-08-22 13:38:53 -04:00
committed by GitHub
parent 0bb4586e6d
commit a2d8c5a714
14 changed files with 378 additions and 347 deletions

View File

@ -45,17 +45,20 @@ test(
'click help/keybindings from project page', 'click help/keybindings from project page',
{ tag: '@electron' }, { tag: '@electron' },
async ({ browserName }, testInfo) => { async ({ browserName }, testInfo) => {
test.skip(
process.platform === 'win32',
'TODO: remove this skip https://github.com/KittyCAD/modeling-app/issues/3557'
)
const { electronApp, page } = await setupElectron({ const { electronApp, page } = await setupElectron({
testInfo, testInfo,
folderSetupFn: async (dir) => { folderSetupFn: async (dir) => {
await fsp.mkdir(`${dir}/bracket`, { recursive: true }) await fsp.mkdir(join(dir, 'bracket'), { recursive: true })
await fsp.copyFile( await fsp.copyFile(
'src/wasm-lib/tests/executor/inputs/focusrite_scarlett_mounting_braket.kcl', join(
`${dir}/bracket/main.kcl` 'src',
'wasm-lib',
'tests',
'executor',
'inputs',
'focusrite_scarlett_mounting_braket.kcl'
),
join(dir, 'bracket', 'main.kcl')
) )
}, },
}) })
@ -64,8 +67,6 @@ test(
page.on('console', console.log) page.on('console', console.log)
page.on('console', console.log)
// expect to see the text bracket // expect to see the text bracket
await expect(page.getByText('bracket')).toBeVisible() await expect(page.getByText('bracket')).toBeVisible()
@ -92,17 +93,20 @@ test(
'when code with error first loads you get errors in console', 'when code with error first loads you get errors in console',
{ tag: '@electron' }, { tag: '@electron' },
async ({ browserName }, testInfo) => { async ({ browserName }, testInfo) => {
test.skip(
process.platform === 'win32',
'TODO: remove this skip https://github.com/KittyCAD/modeling-app/issues/3557'
)
const { electronApp, page } = await setupElectron({ const { electronApp, page } = await setupElectron({
testInfo, testInfo,
folderSetupFn: async (dir) => { folderSetupFn: async (dir) => {
await fsp.mkdir(`${dir}/broken-code`, { recursive: true }) await fsp.mkdir(join(dir, 'broken-code'), { recursive: true })
await fsp.copyFile( await fsp.copyFile(
'src/wasm-lib/tests/executor/inputs/broken-code-test.kcl', join(
`${dir}/broken-code/main.kcl` 'src',
'wasm-lib',
'tests',
'executor',
'inputs',
'broken-code-test.kcl'
),
join(dir, 'broken-code', 'main.kcl')
) )
}, },
}) })
@ -232,10 +236,6 @@ test(
'Rename and delete projects, also spam arrow keys when renaming', 'Rename and delete projects, also spam arrow keys when renaming',
{ tag: '@electron' }, { tag: '@electron' },
async ({ browserName }, testInfo) => { async ({ browserName }, testInfo) => {
test.skip(
process.platform === 'win32',
'TODO: remove this skip https://github.com/KittyCAD/modeling-app/issues/3557'
)
const { electronApp, page } = await setupElectron({ const { electronApp, page } = await setupElectron({
testInfo, testInfo,
folderSetupFn: async (dir) => { folderSetupFn: async (dir) => {
@ -524,10 +524,6 @@ test(
'Deleting projects, can delete individual project, can still create projects after deleting all', 'Deleting projects, can delete individual project, can still create projects after deleting all',
{ tag: '@electron' }, { tag: '@electron' },
async ({ browserName }, testInfo) => { async ({ browserName }, testInfo) => {
test.skip(
process.platform === 'win32',
'TODO: remove this skip https://github.com/KittyCAD/modeling-app/issues/3557'
)
const { electronApp, page } = await setupElectron({ const { electronApp, page } = await setupElectron({
testInfo, testInfo,
}) })
@ -622,10 +618,6 @@ test(
'Can sort projects on home page', 'Can sort projects on home page',
{ tag: '@electron' }, { tag: '@electron' },
async ({ browserName }, testInfo) => { async ({ browserName }, testInfo) => {
test.skip(
process.platform === 'win32',
'TODO: remove this skip https://github.com/KittyCAD/modeling-app/issues/3557'
)
const { electronApp, page } = await setupElectron({ const { electronApp, page } = await setupElectron({
testInfo, testInfo,
}) })
@ -748,10 +740,6 @@ test(
'When the project folder is empty, user can create new project and open it.', 'When the project folder is empty, user can create new project and open it.',
{ tag: '@electron' }, { tag: '@electron' },
async ({ browserName }, testInfo) => { async ({ browserName }, testInfo) => {
test.skip(
process.platform === 'win32',
'TODO: remove this skip https://github.com/KittyCAD/modeling-app/issues/3557'
)
const { electronApp, page } = await setupElectron({ testInfo }) const { electronApp, page } = await setupElectron({ testInfo })
const u = await getUtils(page) const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 }) await page.setViewportSize({ width: 1200, height: 500 })
@ -836,25 +824,35 @@ test(
'Opening a project should successfully load the stream, (regression test that this also works when switching between projects)', 'Opening a project should successfully load the stream, (regression test that this also works when switching between projects)',
{ tag: '@electron' }, { tag: '@electron' },
async ({ browserName }, testInfo) => { async ({ browserName }, testInfo) => {
test.skip(
process.platform === 'win32',
'TODO: remove this skip https://github.com/KittyCAD/modeling-app/issues/3557'
)
const { electronApp, page } = await setupElectron({ const { electronApp, page } = await setupElectron({
testInfo, testInfo,
folderSetupFn: async (dir) => { folderSetupFn: async (dir) => {
await Promise.all([ await Promise.all([
fsp.mkdir(`${dir}/router-template-slate`, { recursive: true }), fsp.mkdir(join(dir, 'router-template-slate'), { recursive: true }),
fsp.mkdir(`${dir}/bracket`, { recursive: true }), fsp.mkdir(join(dir, 'bracket'), { recursive: true }),
]) ])
await Promise.all([ await Promise.all([
fsp.copyFile( fsp.copyFile(
'src/wasm-lib/tests/executor/inputs/router-template-slate.kcl', join(
`${dir}/router-template-slate/main.kcl` 'src',
'wasm-lib',
'tests',
'executor',
'inputs',
'router-template-slate.kcl'
),
join(dir, 'router-template-slate', 'main.kcl')
), ),
fsp.copyFile( fsp.copyFile(
'src/wasm-lib/tests/executor/inputs/focusrite_scarlett_mounting_braket.kcl', join(
`${dir}/bracket/main.kcl` 'src',
'wasm-lib',
'tests',
'executor',
'inputs',
'focusrite_scarlett_mounting_braket.kcl'
),
join(dir, 'bracket', 'main.kcl')
), ),
]) ])
}, },
@ -1302,10 +1300,6 @@ test(
'Settings persist across restarts', 'Settings persist across restarts',
{ tag: '@electron' }, { tag: '@electron' },
async ({ browserName }, testInfo) => { async ({ browserName }, testInfo) => {
test.skip(
process.platform === 'win32',
'TODO: remove this skip https://github.com/KittyCAD/modeling-app/issues/3557'
)
await test.step('We can change a user setting like theme', async () => { await test.step('We can change a user setting like theme', async () => {
const { electronApp, page } = await setupElectron({ const { electronApp, page } = await setupElectron({
testInfo, testInfo,
@ -1682,6 +1676,7 @@ test.describe('Renaming in the file tree', () => {
}) })
await test.step('Rename the folder', async () => { await test.step('Rename the folder', async () => {
await page.waitForTimeout(60000)
await folderToRename.click({ button: 'right' }) await folderToRename.click({ button: 'right' })
await expect(renameMenuItem).toBeVisible() await expect(renameMenuItem).toBeVisible()
await renameMenuItem.click() await renameMenuItem.click()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

@ -2,10 +2,11 @@ import {
expect, expect,
Page, Page,
Download, Download,
TestInfo,
BrowserContext, BrowserContext,
TestInfo,
_electron as electron, _electron as electron,
Locator, Locator,
test,
} from '@playwright/test' } from '@playwright/test'
import { EngineCommand } from 'lang/std/artifactGraph' import { EngineCommand } from 'lang/std/artifactGraph'
import os from 'os' import os from 'os'
@ -44,6 +45,9 @@ export const commonPoints = {
num2: 14.44, num2: 14.44,
} }
export const editorSelector = '[role="textbox"][data-language="kcl"]'
type PaneId = 'variables' | 'code' | 'files' | 'logs'
async function waitForPageLoadWithRetry(page: Page) { async function waitForPageLoadWithRetry(page: Page) {
await expect(async () => { await expect(async () => {
await page.goto('/') await page.goto('/')
@ -311,13 +315,19 @@ export function normaliseKclNumbers(code: string, ignoreZero = true): string {
return replaceNumbers(code) return replaceNumbers(code)
} }
export async function getUtils(page: Page) { export async function getUtils(page: Page, test_?: typeof test) {
if (!test) {
console.warn(
'Some methods in getUtils requires test object as second argument'
)
}
// Chrome devtools protocol session only works in Chromium // Chrome devtools protocol session only works in Chromium
const browserType = page.context().browser()?.browserType().name() const browserType = page.context().browser()?.browserType().name()
const cdpSession = const cdpSession =
browserType !== 'chromium' ? null : await page.context().newCDPSession(page) browserType !== 'chromium' ? null : await page.context().newCDPSession(page)
return { const util = {
waitForAuthSkipAppStart: () => waitForAuthAndLsp(page), waitForAuthSkipAppStart: () => waitForAuthAndLsp(page),
waitForPageLoad: () => waitForPageLoad(page), waitForPageLoad: () => waitForPageLoad(page),
waitForPageLoadWithRetry: () => waitForPageLoadWithRetry(page), waitForPageLoadWithRetry: () => waitForPageLoadWithRetry(page),
@ -484,7 +494,74 @@ export async function getUtils(page: Page) {
networkOptions networkOptions
) )
}, },
toNormalizedCode: (text: string) => {
return text.replace(/\s+/g, '')
},
createAndSelectProject: async (hasText: string) => {
return test_?.step(
`Create and select project with text "${hasText}"`,
async () => {
await page.getByTestId('home-new-file').click()
const projectLinksPost = page.getByTestId('project-link')
await projectLinksPost.filter({ hasText }).click()
}
)
},
editorTextMatches: async (code: string) => {
const editor = page.locator(editorSelector)
const editorText = await editor.textContent()
return expect(util.toNormalizedCode(editorText || '')).toBe(
util.toNormalizedCode(code)
)
},
pasteCodeInEditor: async (code: string) => {
return test?.step('Paste in KCL code', async () => {
const editor = page.locator(editorSelector)
await editor.fill(code)
await util.editorTextMatches(code)
})
},
clickPane: async (paneId: PaneId) => {
return test?.step(`Open ${paneId} pane`, async () => {
await page.getByTestId(paneId + '-pane-button').click()
await expect(page.locator('#' + paneId + '-pane')).toBeVisible()
})
},
createNewFileAndSelect: async (name: string) => {
return test?.step(`Create a file named ${name}, select it`, async () => {
await page.getByTestId('create-file-button').click()
await page.getByTestId('file-rename-field').fill(name)
await page.keyboard.press('Enter')
await page
.getByTestId('file-pane-scroll-container')
.filter({ hasText: name })
.click()
})
},
panesOpen: async (paneIds: PaneId[]) => {
return test?.step(`Setting ${paneIds} panes to be open`, async () => {
await page.addInitScript(
({ PERSIST_MODELING_CONTEXT, paneIds }) => {
localStorage.setItem(
PERSIST_MODELING_CONTEXT,
JSON.stringify({ openPanes: paneIds })
)
},
{ PERSIST_MODELING_CONTEXT, paneIds }
)
await page.reload()
})
},
} }
return util
} }
type TemplateOptions = Array<number | Array<number>> type TemplateOptions = Array<number | Array<number>>
@ -733,6 +810,7 @@ export async function setup(context: BrowserContext, page: Page) {
// kill animations, speeds up tests and reduced flakiness // kill animations, speeds up tests and reduced flakiness
await page.emulateMedia({ reducedMotion: 'reduce' }) await page.emulateMedia({ reducedMotion: 'reduce' })
// Trigger a navigation, since loading file:// doesn't.
await page.reload() await page.reload()
} }

View File

@ -264,4 +264,69 @@ test.describe('Testing settings', () => {
await electronApp.close() await electronApp.close()
} }
) )
test(
`Closing settings modal should go back to the original file being viewed`,
{ tag: '@electron' },
async ({ browser: _ }, testInfo) => {
const { electronApp, page } = await setupElectron({
testInfo,
folderSetupFn: async () => {},
})
const {
panesOpen,
createAndSelectProject,
pasteCodeInEditor,
clickPane,
createNewFileAndSelect,
editorTextMatches,
} = await getUtils(page, test)
await page.setViewportSize({ width: 1200, height: 500 })
page.on('console', console.log)
await panesOpen([])
await test.step('Precondition: No projects exist', async () => {
await expect(page.getByTestId('home-section')).toBeVisible()
const projectLinksPre = page.getByTestId('project-link')
await expect(projectLinksPre).toHaveCount(0)
})
await createAndSelectProject('project-000')
await clickPane('code')
const kclCube = await fsp.readFile(
'src/wasm-lib/tests/executor/inputs/cube.kcl',
'utf-8'
)
await pasteCodeInEditor(kclCube)
await clickPane('files')
await createNewFileAndSelect('2.kcl')
const kclCylinder = await fsp.readFile(
'src/wasm-lib/tests/executor/inputs/cylinder.kcl',
'utf-8'
)
await pasteCodeInEditor(kclCylinder)
const settingsOpenButton = page.getByRole('link', {
name: 'settings Settings',
})
const settingsCloseButton = page.getByTestId('settings-close-button')
await test.step('Open and close settings', async () => {
await settingsOpenButton.click()
await settingsCloseButton.click()
})
await test.step('Postcondition: Same file content is in editor as before settings opened', async () => {
await editorTextMatches(kclCylinder)
})
await electronApp.close()
}
)
}) })

View File

@ -83,7 +83,7 @@
"fetch:wasm": "./get-latest-wasm-bundle.sh", "fetch:wasm": "./get-latest-wasm-bundle.sh",
"isomorphic-copy-wasm": "(copy src/wasm-lib/pkg/wasm_lib_bg.wasm public || cp src/wasm-lib/pkg/wasm_lib_bg.wasm public)", "isomorphic-copy-wasm": "(copy src/wasm-lib/pkg/wasm_lib_bg.wasm public || cp src/wasm-lib/pkg/wasm_lib_bg.wasm public)",
"build:wasm-dev": "(cd src/wasm-lib && wasm-pack build --dev --target web --out-dir pkg && cargo test -p kcl-lib export_bindings) && yarn isomorphic-copy-wasm && yarn fmt", "build:wasm-dev": "(cd src/wasm-lib && wasm-pack build --dev --target web --out-dir pkg && cargo test -p kcl-lib export_bindings) && yarn isomorphic-copy-wasm && yarn fmt",
"build:wasm": "(cd src/wasm-lib && wasm-pack build --release --target web --out-dir pkg && cargo test -p kcl-lib export_bindings) && yarn isomorphic-copy-wasm && yarn fmt", "build:wasm": "cd src/wasm-lib && wasm-pack build --release --target web --out-dir pkg && cargo test -p kcl-lib export_bindings && cd ../.. && yarn isomorphic-copy-wasm && yarn fmt",
"build:wasm-clean": "yarn wasm-prep && yarn build:wasm", "build:wasm-clean": "yarn wasm-prep && yarn build:wasm",
"remove-importmeta": "sed -i 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\"; sed -i '' 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\" || echo \"sed for both mac and linux\"", "remove-importmeta": "sed -i 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\"; sed -i '' 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\" || echo \"sed for both mac and linux\"",
"wasm-prep": "rm -rf src/wasm-lib/pkg && mkdir src/wasm-lib/pkg && rm -rf src/wasm-lib/kcl/bindings", "wasm-prep": "rm -rf src/wasm-lib/pkg && mkdir src/wasm-lib/pkg && rm -rf src/wasm-lib/kcl/bindings",

View File

@ -1,4 +1,4 @@
import { defineConfig } from '@playwright/test' import { defineConfig, devices } from '@playwright/test'
/** /**
* See https://playwright.dev/docs/test-configuration. * See https://playwright.dev/docs/test-configuration.
@ -30,4 +30,24 @@ export default defineConfig({
actionTimeout: 15_000, actionTimeout: 15_000,
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
}, },
projects: [
{
name: 'Google Chrome',
use: {
...devices['Desktop Chrome'],
channel: 'chrome',
contextOptions: {
/* Chromium is the only one with these permission types */
permissions: ['clipboard-write', 'clipboard-read'],
},
launchOptions: {
...(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
? {
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
}
: {}),
},
}, // or 'chrome-beta'
},
],
}) })

View File

@ -61,6 +61,7 @@ function RenameForm({
<label> <label>
<span className="sr-only">Rename file</span> <span className="sr-only">Rename file</span>
<input <input
data-testid="file-rename-field"
ref={inputRef} ref={inputRef}
type="text" type="text"
autoFocus autoFocus
@ -402,6 +403,7 @@ export const FileTreeMenu = () => {
<> <>
<ActionButton <ActionButton
Element="button" Element="button"
data-testid="create-file-button"
iconStart={{ iconStart={{
icon: 'filePlus', icon: 'filePlus',
iconClassName: '!text-current', iconClassName: '!text-current',
@ -417,6 +419,7 @@ export const FileTreeMenu = () => {
<ActionButton <ActionButton
Element="button" Element="button"
data-testid="create-folder-button"
iconStart={{ iconStart={{
icon: 'folderPlus', icon: 'folderPlus',
iconClassName: '!text-current', iconClassName: '!text-current',

View File

@ -16,7 +16,6 @@ import init, {
parse_app_settings, parse_app_settings,
parse_project_settings, parse_project_settings,
default_project_settings, default_project_settings,
parse_project_route,
base64_decode, base64_decode,
} from '../wasm-lib/pkg/wasm_lib' } from '../wasm-lib/pkg/wasm_lib'
import { KCLError } from './errors' import { KCLError } from './errors'
@ -33,7 +32,6 @@ import { CoreDumpManager } from 'lib/coredump'
import openWindow from 'lib/openWindow' import openWindow from 'lib/openWindow'
import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes' import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes'
import { TEST } from 'env' import { TEST } from 'env'
import { ProjectRoute } from 'wasm-lib/kcl/bindings/ProjectRoute'
import { err } from 'lib/trap' import { err } from 'lib/trap'
import { Configuration } from 'wasm-lib/kcl/bindings/Configuration' import { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
import { DeepPartial } from 'lib/types' import { DeepPartial } from 'lib/types'
@ -611,13 +609,6 @@ export function parseProjectSettings(
return parse_project_settings(toml) return parse_project_settings(toml)
} }
export function parseProjectRoute(
configuration: DeepPartial<Configuration>,
route_str: string
): ProjectRoute | Error {
return parse_project_route(JSON.stringify(configuration), route_str)
}
export function base64Decode(base64: string): ArrayBuffer | Error { export function base64Decode(base64: string): ArrayBuffer | Error {
try { try {
const decoded = base64_decode(base64) const decoded = base64_decode(base64)

View File

@ -19,7 +19,6 @@ import {
import { DeepPartial } from './types' import { DeepPartial } from './types'
import { ProjectConfiguration } from 'wasm-lib/kcl/bindings/ProjectConfiguration' import { ProjectConfiguration } from 'wasm-lib/kcl/bindings/ProjectConfiguration'
import { Configuration } from 'wasm-lib/kcl/bindings/Configuration' import { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
export { parseProjectRoute } from 'lang/wasm'
export async function renameProjectDirectory( export async function renameProjectDirectory(
projectPath: string, projectPath: string,
@ -39,7 +38,7 @@ export async function renameProjectDirectory(
// Make sure the new name does not exist. // Make sure the new name does not exist.
const newPath = window.electron.path.join( const newPath = window.electron.path.join(
projectPath.split('/').slice(0, -1).join('/'), window.electron.path.dirname(projectPath),
newName newName
) )
try { try {
@ -186,9 +185,9 @@ const collectAllFilesRecursiveFrom = async (path: string) => {
return Promise.reject(new Error(`Path ${path} is not a directory`)) return Promise.reject(new Error(`Path ${path} is not a directory`))
} }
const pathParts = path.split(window.electron.path.sep) const name = window.electron.path.basename(path)
let entry: FileEntry = { let entry: FileEntry = {
name: pathParts.slice(-1)[0], name: name,
path, path,
children: [], children: [],
} }
@ -330,7 +329,6 @@ export async function getProjectInfo(projectPath: string): Promise<Project> {
new Error(`Project path is not a directory: ${projectPath}`) new Error(`Project path is not a directory: ${projectPath}`)
) )
} }
let walked = await collectAllFilesRecursiveFrom(projectPath) let walked = await collectAllFilesRecursiveFrom(projectPath)
let default_file = await getDefaultKclFileForDir(projectPath, walked) let default_file = await getDefaultKclFileForDir(projectPath, walked)
const metadata = await window.electron.stat(projectPath) const metadata = await window.electron.stat(projectPath)

79
src/lib/paths.test.ts Normal file
View File

@ -0,0 +1,79 @@
import { parseProjectRoute } from './paths'
import * as path from 'path'
describe('testing parseProjectRoute', () => {
it('should parse a project as a subpath of project dir', async () => {
let config = {
settings: {
project: {
directory: '/home/somebody/projects',
},
},
}
const route = '/home/somebody/projects/project'
expect(await parseProjectRoute(config, route, path)).toEqual({
projectName: 'project',
projectPath: route,
currentFileName: null,
currentFilePath: null,
})
})
it('should parse a project as the project dir', async () => {
let config = {
settings: {
project: {
directory: '/home/somebody/projects',
},
},
}
const route = '/home/somebody/projects'
expect(await parseProjectRoute(config, route, path)).toEqual({
projectName: null,
projectPath: route,
currentFileName: null,
currentFilePath: null,
})
})
it('should parse a project with file in the project dir', async () => {
let config = {
settings: {
project: {
directory: '/home/somebody/projects',
},
},
}
const route = '/home/somebody/projects/assembly/main.kcl'
expect(await parseProjectRoute(config, route, path)).toEqual({
projectName: 'assembly',
projectPath: '/home/somebody/projects/assembly',
currentFileName: 'main.kcl',
currentFilePath: route,
})
})
it('should parse a project with file in a subdir in the project dir', async () => {
let config = {
settings: {
project: {
directory: '/home/somebody/projects',
},
},
}
const route = '/home/somebody/projects/assembly/subdir/main.kcl'
expect(await parseProjectRoute(config, route, path)).toEqual({
projectName: 'assembly',
projectPath: '/home/somebody/projects/assembly',
currentFileName: 'main.kcl',
currentFilePath: route,
})
})
it('should work in the browser context', async () => {
let config = {}
const route = '/browser/main.kcl'
expect(await parseProjectRoute(config, route, undefined)).toEqual({
projectName: 'browser',
projectPath: '/browser',
currentFileName: 'main.kcl',
currentFilePath: route,
})
})
})

View File

@ -1,13 +1,13 @@
import { onboardingPaths } from 'routes/Onboarding/paths' import { onboardingPaths } from 'routes/Onboarding/paths'
import { BROWSER_FILE_NAME, BROWSER_PROJECT_NAME, FILE_EXT } from './constants' import { BROWSER_FILE_NAME, BROWSER_PROJECT_NAME, FILE_EXT } from './constants'
import { isDesktop } from './isDesktop' import { isDesktop } from './isDesktop'
import { ProjectRoute } from 'wasm-lib/kcl/bindings/ProjectRoute' import { readAppSettingsFile } from './desktop'
import { parseProjectRoute, readAppSettingsFile } from './desktop'
import { readLocalStorageAppSettingsFile } from './settings/settingsUtils' import { readLocalStorageAppSettingsFile } from './settings/settingsUtils'
import { err } from 'lib/trap' import { err } from 'lib/trap'
import { IS_PLAYWRIGHT_KEY } from '../../e2e/playwright/storageStates' import { IS_PLAYWRIGHT_KEY } from '../../e2e/playwright/storageStates'
import { DeepPartial } from './types' import { DeepPartial } from './types'
import { Configuration } from 'wasm-lib/kcl/bindings/Configuration' import { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
import { PlatformPath } from 'path'
const prependRoutes = const prependRoutes =
(routesObject: Record<string, string>) => (prepend: string) => { (routesObject: Record<string, string>) => (prepend: string) => {
@ -25,6 +25,13 @@ type OnboardingPaths = {
const SETTINGS = '/settings' as const const SETTINGS = '/settings' as const
export type ProjectRoute = {
projectName: string | null
projectPath: string
currentFileName: string | null
currentFilePath: string | null
}
export const PATHS = { export const PATHS = {
INDEX: '/', INDEX: '/',
HOME: '/home', HOME: '/home',
@ -60,9 +67,64 @@ export async function getProjectMetaByRouteId(
return Promise.reject(new Error('No configuration found')) return Promise.reject(new Error('No configuration found'))
} }
const route = parseProjectRoute(configuration, id) const route = parseProjectRoute(configuration, id, window?.electron?.path)
if (err(route)) return Promise.reject(route) if (err(route)) return Promise.reject(route)
return route return route
} }
export async function parseProjectRoute(
configuration: DeepPartial<Configuration>,
id: string,
pathlib: PlatformPath | undefined
): Promise<ProjectRoute> {
let projectName = null
let projectPath = ''
let currentFileName = null
let currentFilePath = null
if (
pathlib &&
configuration.settings?.project?.directory &&
id.startsWith(configuration.settings.project.directory)
) {
const relativeToRoot = pathlib.relative(
configuration.settings.project.directory,
id
)
projectName = relativeToRoot.split(pathlib.sep)[0]
projectPath = pathlib.join(
configuration.settings.project.directory,
projectName
)
projectName = projectName === '' ? null : projectName
} else {
projectPath = id
if (pathlib) {
if (pathlib.extname(id) === '.kcl') {
projectPath = pathlib.dirname(id)
}
projectName = pathlib.basename(projectPath)
} else {
if (id.endsWith('.kcl')) {
projectPath = '/browser'
projectName = 'browser'
}
}
}
if (pathlib) {
if (projectPath !== id) {
currentFileName = pathlib.basename(id)
currentFilePath = id
}
} else {
currentFileName = 'main.kcl'
currentFilePath = id
}
return {
projectName: projectName,
projectPath: projectPath,
currentFileName: currentFileName,
currentFilePath: currentFilePath,
}
}

View File

@ -36,9 +36,9 @@ export const settingsLoader: LoaderFunction = async ({
configuration configuration
) )
if (projectPathData) { if (projectPathData) {
const { project_path } = projectPathData const { projectPath } = projectPathData
const { settings: s } = await loadAndValidateSettings( const { settings: s } = await loadAndValidateSettings(
project_path || undefined projectPath || undefined
) )
return s return s
} }
@ -83,48 +83,49 @@ export const fileLoader: LoaderFunction = async (
const isBrowserProject = params.id === decodeURIComponent(BROWSER_PATH) const isBrowserProject = params.id === decodeURIComponent(BROWSER_PATH)
if (!isBrowserProject && projectPathData) { if (!isBrowserProject && projectPathData) {
const { project_name, project_path, current_file_name, current_file_path } = const { projectName, projectPath, currentFileName, currentFilePath } =
projectPathData projectPathData
const urlObj = new URL(routerData.request.url) const urlObj = new URL(routerData.request.url)
let code = '' let code = ''
if (!urlObj.pathname.endsWith('/settings')) { if (!urlObj.pathname.endsWith('/settings')) {
if (!current_file_name || !current_file_path || !project_name) { if (!currentFileName || !currentFilePath || !projectName) {
return redirect( return redirect(
`${PATHS.FILE}/${encodeURIComponent( `${PATHS.FILE}/${encodeURIComponent(
isDesktop() isDesktop()
? (await getProjectInfo(project_path)).default_file ? (await getProjectInfo(projectPath)).default_file
: params.id + '/' + PROJECT_ENTRYPOINT : params.id + '/' + PROJECT_ENTRYPOINT
)}` )}`
) )
} }
code = await window.electron.readFile(current_file_path) code = await window.electron.readFile(currentFilePath)
code = normalizeLineEndings(code)
// Update both the state and the editor's code. // Update both the state and the editor's code.
// We explicitly do not write to the file here since we are loading from // We explicitly do not write to the file here since we are loading from
// the file system and not the editor. // the file system and not the editor.
codeManager.updateCurrentFilePath(current_file_path) codeManager.updateCurrentFilePath(currentFilePath)
codeManager.updateCodeStateEditor(code) codeManager.updateCodeStateEditor(code)
} }
// Set the file system manager to the project path // Set the file system manager to the project path
// So that WASM gets an updated path for operations // So that WASM gets an updated path for operations
fileSystemManager.dir = project_path fileSystemManager.dir = projectPath
const defaultProjectData = { const defaultProjectData = {
name: project_name || 'unnamed', name: projectName || 'unnamed',
path: project_path, path: projectPath,
children: [], children: [],
kcl_file_count: 0, kcl_file_count: 0,
directory_count: 0, directory_count: 0,
metadata: null, metadata: null,
default_file: project_path, default_file: projectPath,
} }
const maybeProjectInfo = isDesktop() const maybeProjectInfo = isDesktop()
? await getProjectInfo(project_path) ? await getProjectInfo(projectPath)
: null : null
console.log('maybeProjectInfo', { console.log('maybeProjectInfo', {
@ -137,8 +138,8 @@ export const fileLoader: LoaderFunction = async (
code, code,
project: maybeProjectInfo ?? defaultProjectData, project: maybeProjectInfo ?? defaultProjectData,
file: { file: {
name: current_file_name || '', name: currentFileName || '',
path: current_file_path || '', path: currentFilePath || '',
children: [], children: [],
}, },
} }
@ -187,3 +188,7 @@ export const homeLoader: LoaderFunction = async (): Promise<
} }
} }
} }
const normalizeLineEndings = (str: string, normalized = '\n') => {
return str.replace(/\r?\n/g, normalized)
}

View File

@ -8,8 +8,6 @@ use parse_display::{Display, FromStr};
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::settings::types::Configuration;
/// State management for the application. /// State management for the application.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)] #[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
#[ts(export)] #[ts(export)]
@ -148,99 +146,6 @@ const model = import("{}")"#,
} }
} }
/// Project route information.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct ProjectRoute {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_name: Option<String>,
pub project_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_file_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_file_path: Option<String>,
}
impl ProjectRoute {
/// Get the project state from the url in the route.
pub fn from_route(configuration: &Configuration, route: &str) -> Result<Self> {
let path = std::path::Path::new(route);
// Check if the default project path is in the route.
let (project_path, project_name) = if path.starts_with(&configuration.settings.project.directory)
&& configuration.settings.project.directory != std::path::PathBuf::default()
{
// Get the project name.
if let Some(project_name) = path
.strip_prefix(&configuration.settings.project.directory)?
.iter()
.next()
{
(
configuration
.settings
.project
.directory
.join(project_name)
.display()
.to_string(),
Some(project_name.to_string_lossy().to_string()),
)
} else {
(configuration.settings.project.directory.display().to_string(), None)
}
} else {
// Assume the project path is the parent directory of the file.
let project_dir = if path.display().to_string().ends_with(".kcl") {
path.parent()
.ok_or_else(|| anyhow::anyhow!("Parent directory not found: {}", path.display()))?
} else {
path
};
if project_dir == std::path::Path::new("/") {
(
path.display().to_string(),
Some(
path.file_name()
.ok_or_else(|| anyhow::anyhow!("File name not found: {}", path.display()))?
.to_string_lossy()
.to_string(),
),
)
} else if let Some(project_name) = project_dir.file_name() {
(
project_dir.display().to_string(),
Some(project_name.to_string_lossy().to_string()),
)
} else {
(project_dir.display().to_string(), None)
}
};
let (current_file_name, current_file_path) = if path.display().to_string() == project_path {
(None, None)
} else {
(
Some(
path.file_name()
.ok_or_else(|| anyhow::anyhow!("File name not found: {}", path.display()))?
.to_string_lossy()
.to_string(),
),
Some(path.display().to_string()),
)
};
Ok(Self {
project_name,
project_path,
current_file_name,
current_file_path,
})
}
}
/// Information about project. /// Information about project.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)] #[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
#[ts(export)] #[ts(export)]
@ -535,160 +440,6 @@ impl From<std::fs::Metadata> for FileMetadata {
mod tests { mod tests {
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
#[test]
fn test_project_route_from_route_std_path() {
let mut configuration = crate::settings::types::Configuration::default();
configuration.settings.project.directory =
std::path::PathBuf::from("/Users/macinatormax/Documents/kittycad-modeling-projects");
let route = "/Users/macinatormax/Documents/kittycad-modeling-projects/assembly/main.kcl";
let state = super::ProjectRoute::from_route(&configuration, route).unwrap();
assert_eq!(
state,
super::ProjectRoute {
project_name: Some("assembly".to_string()),
project_path: "/Users/macinatormax/Documents/kittycad-modeling-projects/assembly".to_string(),
current_file_name: Some("main.kcl".to_string()),
current_file_path: Some(
"/Users/macinatormax/Documents/kittycad-modeling-projects/assembly/main.kcl".to_string()
),
}
);
}
#[test]
fn test_project_route_from_route_std_path_dir() {
let mut configuration = crate::settings::types::Configuration::default();
configuration.settings.project.directory =
std::path::PathBuf::from("/Users/macinatormax/Documents/kittycad-modeling-projects");
let route = "/Users/macinatormax/Documents/kittycad-modeling-projects/assembly";
let state = super::ProjectRoute::from_route(&configuration, route).unwrap();
assert_eq!(
state,
super::ProjectRoute {
project_name: Some("assembly".to_string()),
project_path: "/Users/macinatormax/Documents/kittycad-modeling-projects/assembly".to_string(),
current_file_name: None,
current_file_path: None
}
);
}
#[test]
fn test_project_route_from_route_std_path_dir_empty() {
let mut configuration = crate::settings::types::Configuration::default();
configuration.settings.project.directory =
std::path::PathBuf::from("/Users/macinatormax/Documents/kittycad-modeling-projects");
let route = "/Users/macinatormax/Documents/kittycad-modeling-projects";
let state = super::ProjectRoute::from_route(&configuration, route).unwrap();
assert_eq!(
state,
super::ProjectRoute {
project_name: None,
project_path: "/Users/macinatormax/Documents/kittycad-modeling-projects".to_string(),
current_file_name: None,
current_file_path: None
}
);
}
#[test]
fn test_project_route_from_route_outside_std_path() {
let mut configuration = crate::settings::types::Configuration::default();
configuration.settings.project.directory =
std::path::PathBuf::from("/Users/macinatormax/Documents/kittycad-modeling-projects");
let route = "/Users/macinatormax/kittycad/modeling-app/main.kcl";
let state = super::ProjectRoute::from_route(&configuration, route).unwrap();
assert_eq!(
state,
super::ProjectRoute {
project_name: Some("modeling-app".to_string()),
project_path: "/Users/macinatormax/kittycad/modeling-app".to_string(),
current_file_name: Some("main.kcl".to_string()),
current_file_path: Some("/Users/macinatormax/kittycad/modeling-app/main.kcl".to_string()),
}
);
}
#[test]
fn test_project_route_from_route_outside_std_path_dir() {
let mut configuration = crate::settings::types::Configuration::default();
configuration.settings.project.directory =
std::path::PathBuf::from("/Users/macinatormax/Documents/kittycad-modeling-projects");
let route = "/Users/macinatormax/kittycad/modeling-app";
let state = super::ProjectRoute::from_route(&configuration, route).unwrap();
assert_eq!(
state,
super::ProjectRoute {
project_name: Some("modeling-app".to_string()),
project_path: "/Users/macinatormax/kittycad/modeling-app".to_string(),
current_file_name: None,
current_file_path: None
}
);
}
#[test]
fn test_project_route_from_route_browser() {
let mut configuration = crate::settings::types::Configuration::default();
configuration.settings.project.directory = std::path::PathBuf::default();
let route = "/browser/main.kcl";
let state = super::ProjectRoute::from_route(&configuration, route).unwrap();
assert_eq!(
state,
super::ProjectRoute {
project_name: Some("browser".to_string()),
project_path: "/browser".to_string(),
current_file_name: Some("main.kcl".to_string()),
current_file_path: Some("/browser/main.kcl".to_string())
}
);
}
#[test]
fn test_project_route_from_route_browser_no_path() {
let mut configuration = crate::settings::types::Configuration::default();
configuration.settings.project.directory = std::path::PathBuf::default();
let route = "/browser";
let state = super::ProjectRoute::from_route(&configuration, route).unwrap();
assert_eq!(
state,
super::ProjectRoute {
project_name: Some("browser".to_string()),
project_path: "/browser".to_string(),
current_file_name: None,
current_file_path: None
}
);
}
#[test]
fn test_project_route_from_route_non_main_file() {
let mut configuration = crate::settings::types::Configuration::default();
configuration.settings.project.directory =
std::path::PathBuf::from("/Users/macinatormax/Documents/kittycad-modeling-projects");
let route = "/Users/macinatormax/Documents/kittycad-modeling-projects/assembly/thing.kcl";
let state = super::ProjectRoute::from_route(&configuration, route).unwrap();
assert_eq!(
state,
super::ProjectRoute {
project_name: Some("assembly".to_string()),
project_path: "/Users/macinatormax/Documents/kittycad-modeling-projects/assembly".to_string(),
current_file_name: Some("thing.kcl".to_string()),
current_file_path: Some(
"/Users/macinatormax/Documents/kittycad-modeling-projects/assembly/thing.kcl".to_string()
),
}
);
}
#[tokio::test] #[tokio::test]
async fn test_default_kcl_file_for_dir_non_exist() { async fn test_default_kcl_file_for_dir_non_exist() {
let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4()); let name = format!("kittycad-modeling-projects-{}", uuid::Uuid::new_v4());

View File

@ -566,22 +566,6 @@ pub fn serialize_project_settings(val: JsValue) -> Result<JsValue, String> {
Ok(JsValue::from_str(&toml_str)) Ok(JsValue::from_str(&toml_str))
} }
/// Parse the project route.
#[wasm_bindgen]
pub fn parse_project_route(configuration: &str, route: &str) -> Result<JsValue, String> {
console_error_panic_hook::set_once();
let configuration: kcl_lib::settings::types::Configuration =
serde_json::from_str(configuration).map_err(|e| e.to_string())?;
let route =
kcl_lib::settings::types::file::ProjectRoute::from_route(&configuration, route).map_err(|e| e.to_string())?;
// The serde-wasm-bindgen does not work here because of weird HashMap issues so we use the
// gloo-serialize crate instead.
JsValue::from_serde(&route).map_err(|e| e.to_string())
}
static ALLOWED_DECODING_FORMATS: &[data_encoding::Encoding] = &[ static ALLOWED_DECODING_FORMATS: &[data_encoding::Encoding] = &[
data_encoding::BASE64, data_encoding::BASE64,
data_encoding::BASE64URL, data_encoding::BASE64URL,