Files
modeling-app/src/lib/desktopFS.ts
Pierre Jacquier add1b21503 Assemblies: Load outside files into project via point-and-click (#6217)
* WIP: Add point-and-click Import for geometry
Will eventually fix #6120
Right now the whole loop is there but the codemod doesn't work yet

* Better pathToNOde, log on non-working cm dispatch call

* Add workaround to updateModelingState not working

* Back to updateModelingState with a skip flag

* Better todo

* Change working from Import to Insert, cleanups

* Sister command in kclCommands to populate file options

* Improve path selector

* Unsure: move importAstMod to kclCommands onSubmit 😶

* Add e2e test

* Clean up for review

* Add native file menu entry and test

* No await yo lint said so

* WIP: UX improvements around foreign file imports
Fixes #6152

* @lrev-Dev's suggestion to remove a comment

Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch>

* Update to scene.settled(cmdBar)

* Add partNNN default name for alias

* Lint

* Lint

* Fix unit tests

* Add sad path insert test
Thanks @Irev-Dev for the suggestion

* Add step insert test

* Lint

* Add test for second foreign import thru file tree click

* WIP: Add point-and-click Load to copy files from outside the project into the project
Towards #6210

* Move Insert button to modeling toolbar, update menus and toolbars

* Add default value for local name alias

* Aligning tests

* Fix tests

* Add padding for filenames starting with a digit

* Lint

* Lint

* Update snapshots

* Merge branch 'main' into pierremtb/issue6210-Add-point-and-click-Load-to-copy-files-from-outside-the-project-into-the-project

* Add disabled transform subbutton

* Merge kcl-samples and local disk load into one 'Load external model' command

* Fix em tests

* Fix test

* Add test for file pick import, better input

* Fix non .kcl loading

* Lint

* Update snapshots

* Fix issue leading to test failure

* Fix clone test

* Add note

* Fix nested clone issue

* Clean up for review

* Add valueSummary for path

* Fix test after path change

* Clean up for review

* Update src/lib/kclCommands.ts

Thanks @franknoirot!

Co-authored-by: Frank Noirot <frank@zoo.dev>

* Improve path input arg

* Fix tests

* Merge branch 'main' into pierremtb/issue6210-Add-point-and-click-Load-to-copy-files-from-outside-the-project-into-the-project

* Fix path header not showing and improve tests

* Clean up

---------

Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch>
Co-authored-by: Frank Noirot <frank@zoo.dev>
2025-04-14 18:53:01 +00:00

250 lines
6.9 KiB
TypeScript

import { relevantFileExtensions } from '@src/lang/wasmUtils'
import {
FILE_EXT,
INDEX_IDENTIFIER,
MAX_PADDING,
ONBOARDING_PROJECT_NAME,
} from '@src/lib/constants'
import {
createNewProjectDirectory,
listProjects,
readAppSettingsFile,
} from '@src/lib/desktop'
import { bracket } from '@src/lib/exampleKcl'
import { isDesktop } from '@src/lib/isDesktop'
import { PATHS } from '@src/lib/paths'
import type { FileEntry } from '@src/lib/project'
export const isHidden = (fileOrDir: FileEntry) =>
!!fileOrDir.name?.startsWith('.')
export const isDir = (fileOrDir: FileEntry) =>
'children' in fileOrDir && fileOrDir.children !== undefined
// Shallow sort the files and directories
// Files and directories are sorted alphabetically
export function sortFilesAndDirectories(files: FileEntry[]): FileEntry[] {
return files.sort((a, b) => {
if (a.children === null && b.children !== null) {
return 1
} else if (a.children !== null && b.children === null) {
return -1
} else if (a.name && b.name) {
return a.name.localeCompare(b.name)
} else {
return 0
}
})
}
// create a regex to match the project name
// replacing any instances of "$n" with a regex to match any number
function interpolateProjectName(projectName: string) {
const regex = new RegExp(
projectName.replace(getPaddedIdentifierRegExp(), '([0-9]+)')
)
return regex
}
// Returns the next available index for a project name
export function getNextProjectIndex(
projectName: string,
projects: FileEntry[]
) {
const regex = interpolateProjectName(projectName)
const matches = projects.map((project) => project.name?.match(regex))
const indices = matches
.filter(Boolean)
.map((match) => (match !== null ? match[1] : '-1'))
.map((maybeMatchIndex) => {
return parseInt(maybeMatchIndex || '0', 10)
})
const maxIndex = Math.max(...indices, -1)
return maxIndex + 1
}
// Interpolates the project name with the next available index,
// padding the index with 0s if necessary
export function interpolateProjectNameWithIndex(
projectName: string,
index: number
) {
const regex = getPaddedIdentifierRegExp()
const matches = projectName.match(regex)
const padStartLength = Math.min(
matches !== null ? matches[1]?.length || 0 : 0,
MAX_PADDING
)
return projectName.replace(
regex,
index.toString().padStart(padStartLength + 1, '0')
)
}
export function doesProjectNameNeedInterpolated(projectName: string) {
return projectName.includes(INDEX_IDENTIFIER)
}
/**
* Given a target name, which may include our magic index interpolation string,
* and a list of projects, return a unique name that doesn't conflict with any
* of the existing projects, incrementing any ending number if necessary.
* @param name
* @param projects
* @returns
*/
export function getUniqueProjectName(name: string, projects: FileEntry[]) {
// The name may have our magic index interpolation string in it
const needsInterpolation = doesProjectNameNeedInterpolated(name)
if (needsInterpolation) {
const nextIndex = getNextProjectIndex(name, projects)
return interpolateProjectNameWithIndex(name, nextIndex)
} else {
let newName = name
while (projects.some((project) => project.name === newName)) {
const nameEndsWithNumber = newName.match(/\d+$/)
newName = nameEndsWithNumber
? newName.replace(/\d+$/, (num) => `${parseInt(num, 10) + 1}`)
: `${name}-1`
}
return newName
}
}
function escapeRegExpChars(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function getPaddedIdentifierRegExp() {
const escapedIdentifier = escapeRegExpChars(INDEX_IDENTIFIER)
return new RegExp(`${escapedIdentifier}(${escapedIdentifier.slice(-1)}*)`)
}
export async function getSettingsFolderPaths(projectPath?: string) {
const user = isDesktop() ? await window.electron.getPath('appData') : '/'
const project = projectPath !== undefined ? projectPath : undefined
return {
user,
project,
}
}
export async function createAndOpenNewTutorialProject({
onProjectOpen,
navigate,
}: {
onProjectOpen: (
project: {
name: string | null
path: string | null
} | null,
file: FileEntry | null
) => void
navigate: (path: string) => void
}) {
// Create a new project with the onboarding project name
const configuration = await readAppSettingsFile()
const projects = await listProjects(configuration)
const nextIndex = getNextProjectIndex(ONBOARDING_PROJECT_NAME, projects)
const name = interpolateProjectNameWithIndex(
ONBOARDING_PROJECT_NAME,
nextIndex
)
// Delete the tutorial project if it already exists.
if (isDesktop()) {
if (configuration.settings?.project?.directory === undefined) {
return Promise.reject(new Error('configuration settings are undefined'))
}
const fullPath = window.electron.join(
configuration.settings.project.directory,
name
)
if (window.electron.exists(fullPath)) {
await window.electron.rm(fullPath)
}
}
const newProject = await createNewProjectDirectory(
name,
bracket,
configuration
)
// Prep the LSP and navigate to the onboarding start
onProjectOpen(
{
name: newProject.name,
path: newProject.path,
},
null
)
navigate(
`${PATHS.FILE}/${encodeURIComponent(newProject.default_file)}${
PATHS.ONBOARDING.INDEX
}`
)
return newProject
}
/**
* Get the next available file name by appending a hyphen and number to the end of the name
*/
export function getNextFileName({
entryName,
baseDir,
}: {
entryName: string
baseDir: string
}) {
// Preserve the extension in case of a relevant but foreign file
let extension = window.electron.path.extname(entryName)
if (!relevantFileExtensions().includes(extension.replace('.', ''))) {
extension = FILE_EXT
}
// Remove any existing index from the name before adding a new one
let createdName = entryName.replace(extension, '') + extension
let createdPath = window.electron.path.join(baseDir, createdName)
let i = 1
while (window.electron.exists(createdPath)) {
const matchOnIndexAndExtension = new RegExp(`(-\\d+)?(${extension})?$`)
createdName =
entryName.replace(matchOnIndexAndExtension, '') + `-${i}` + extension
createdPath = window.electron.path.join(baseDir, createdName)
i++
}
return {
name: createdName,
path: createdPath,
}
}
/**
* Get the next available directory name by appending a hyphen and number to the end of the name
*/
export function getNextDirName({
entryName,
baseDir,
}: {
entryName: string
baseDir: string
}) {
let createdName = entryName
let createdPath = window.electron.path.join(baseDir, createdName)
let i = 1
while (window.electron.exists(createdPath)) {
createdName = entryName.replace(/-\d+$/, '') + `-${i}`
createdPath = window.electron.path.join(baseDir, createdName)
i++
}
return {
name: createdName,
path: createdPath,
}
}