Add Open in New Window and reexecution on import change (#6379)
* Quick prototype: open in new window in file tree * WIP: refresh on imported file change * Fix up reexecution * Clean up * Add test 'Assembly gets reexecuted when imported models are updated externally' * Clean up
This commit is contained in:
@ -6,6 +6,7 @@ import type { ToolbarFixture } from '@e2e/playwright/fixtures/toolbarFixture'
|
||||
import {
|
||||
executorInputPath,
|
||||
getUtils,
|
||||
kclSamplesPath,
|
||||
testsInputPath,
|
||||
} from '@e2e/playwright/test-utils'
|
||||
import { expect, test } from '@e2e/playwright/zoo-test'
|
||||
@ -472,4 +473,94 @@ test.describe('Point-and-click assemblies tests', () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Assembly gets reexecuted when imported models are updated externally',
|
||||
{ tag: ['@electron'] },
|
||||
async ({ context, page, homePage, scene, toolbar, cmdBar, tronApp }) => {
|
||||
if (!tronApp) {
|
||||
fail()
|
||||
}
|
||||
|
||||
const midPoint = { x: 500, y: 250 }
|
||||
const washerPoint = { x: 645, y: 250 }
|
||||
const partColor: [number, number, number] = [120, 120, 120]
|
||||
const redPartColor: [number, number, number] = [200, 0, 0]
|
||||
const bgColor: [number, number, number] = [30, 30, 30]
|
||||
const tolerance = 50
|
||||
const projectName = 'assembly'
|
||||
|
||||
await test.step('Setup parts and expect imported model', async () => {
|
||||
await context.folderSetupFn(async (dir) => {
|
||||
const projectDir = path.join(dir, projectName)
|
||||
await fsp.mkdir(projectDir, { recursive: true })
|
||||
await Promise.all([
|
||||
fsp.copyFile(
|
||||
executorInputPath('cube.kcl'),
|
||||
path.join(projectDir, 'cube.kcl')
|
||||
),
|
||||
fsp.copyFile(
|
||||
kclSamplesPath(
|
||||
path.join(
|
||||
'pipe-flange-assembly',
|
||||
'mcmaster-parts',
|
||||
'98017a257-washer.step'
|
||||
)
|
||||
),
|
||||
path.join(projectDir, 'foreign.step')
|
||||
),
|
||||
fsp.writeFile(
|
||||
path.join(projectDir, 'main.kcl'),
|
||||
`
|
||||
import "cube.kcl" as cube
|
||||
import "foreign.step" as foreign
|
||||
cube
|
||||
foreign
|
||||
|> translate(x = 40, z = 10)`
|
||||
),
|
||||
])
|
||||
})
|
||||
await page.setBodyDimensions({ width: 1000, height: 500 })
|
||||
await homePage.openProject(projectName)
|
||||
await scene.settled(cmdBar)
|
||||
await toolbar.closePane('code')
|
||||
await scene.expectPixelColor(partColor, midPoint, tolerance)
|
||||
})
|
||||
|
||||
await test.step('Change imported kcl file and expect change', async () => {
|
||||
await context.folderSetupFn(async (dir) => {
|
||||
// Append appearance to the cube.kcl file
|
||||
await fsp.appendFile(
|
||||
path.join(dir, projectName, 'cube.kcl'),
|
||||
`\n |> appearance(color = "#ff0000")`
|
||||
)
|
||||
})
|
||||
await scene.settled(cmdBar)
|
||||
await toolbar.closePane('code')
|
||||
await scene.expectPixelColor(redPartColor, midPoint, tolerance)
|
||||
await scene.expectPixelColor(partColor, washerPoint, tolerance)
|
||||
})
|
||||
|
||||
await test.step('Change imported step file and expect change', async () => {
|
||||
await context.folderSetupFn(async (dir) => {
|
||||
// Replace the washer with a pipe
|
||||
await fsp.copyFile(
|
||||
kclSamplesPath(
|
||||
path.join(
|
||||
'pipe-flange-assembly',
|
||||
'mcmaster-parts',
|
||||
'1120t74-pipe.step'
|
||||
)
|
||||
),
|
||||
path.join(dir, projectName, 'foreign.step')
|
||||
)
|
||||
})
|
||||
await scene.settled(cmdBar)
|
||||
await toolbar.closePane('code')
|
||||
// Expect pipe to take over the red cube but leave some space where the washer was
|
||||
await scene.expectPixelColor(partColor, midPoint, tolerance)
|
||||
await scene.expectPixelColor(bgColor, washerPoint, tolerance)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
@ -1024,6 +1024,10 @@ export function testsInputPath(fileName: string): string {
|
||||
return path.join('rust', 'kcl-lib', 'tests', 'inputs', fileName)
|
||||
}
|
||||
|
||||
export function kclSamplesPath(fileName: string): string {
|
||||
return path.join('public', 'kcl-samples', fileName)
|
||||
}
|
||||
|
||||
export async function doAndWaitForImageDiff(
|
||||
page: Page,
|
||||
fn: () => Promise<unknown>,
|
||||
|
1
interface.d.ts
vendored
1
interface.d.ts
vendored
@ -20,6 +20,7 @@ export interface IElectronAPI {
|
||||
open: typeof dialog.showOpenDialog
|
||||
save: typeof dialog.showSaveDialog
|
||||
openExternal: typeof shell.openExternal
|
||||
openInNewWindow: (name: string) => void
|
||||
takeElectronWindowScreenshot: ({
|
||||
width,
|
||||
height,
|
||||
|
@ -194,6 +194,14 @@ export const FileMachineProvider = ({
|
||||
navigate(`..${PATHS.FILE}/${encodeURIComponent(event.output.path)}`)
|
||||
}
|
||||
},
|
||||
openFileInNewWindow: ({ event }) => {
|
||||
if (event.type !== 'Open file in new window') {
|
||||
return
|
||||
}
|
||||
|
||||
commandBarActor.send({ type: 'Close' })
|
||||
window.electron.openInNewWindow(event.data.name)
|
||||
},
|
||||
},
|
||||
actors: {
|
||||
readFiles: fromPromise(async ({ input }) => {
|
||||
|
@ -24,7 +24,7 @@ import { sortFilesAndDirectories } from '@src/lib/desktopFS'
|
||||
import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
|
||||
import { PATHS } from '@src/lib/paths'
|
||||
import type { FileEntry } from '@src/lib/project'
|
||||
import { codeManager, kclManager } from '@src/lib/singletons'
|
||||
import { codeManager, kclManager, rustContext } from '@src/lib/singletons'
|
||||
import { reportRejection } from '@src/lib/trap'
|
||||
import type { IndexLoaderData } from '@src/lib/types'
|
||||
|
||||
@ -32,6 +32,7 @@ import { ToastInsert } from '@src/components/ToastInsert'
|
||||
import { commandBarActor } from '@src/lib/singletons'
|
||||
import toast from 'react-hot-toast'
|
||||
import styles from './FileTree.module.css'
|
||||
import { jsAppSettings } from '@src/lib/settings/settingsUtils'
|
||||
|
||||
function getIndentationCSS(level: number) {
|
||||
return `calc(1rem * ${level + 1})`
|
||||
@ -163,6 +164,7 @@ const FileTreeItem = ({
|
||||
onCreateFile,
|
||||
onCreateFolder,
|
||||
onCloneFileOrFolder,
|
||||
onOpenInNewWindow,
|
||||
newTreeEntry,
|
||||
level = 0,
|
||||
treeSelection,
|
||||
@ -183,6 +185,7 @@ const FileTreeItem = ({
|
||||
onCreateFile: (name: string) => void
|
||||
onCreateFolder: (name: string) => void
|
||||
onCloneFileOrFolder: (path: string) => void
|
||||
onOpenInNewWindow: (path: string) => void
|
||||
newTreeEntry: TreeEntry
|
||||
level?: number
|
||||
treeSelection: FileEntry | undefined
|
||||
@ -212,10 +215,25 @@ const FileTreeItem = ({
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: make this not just name based but sub path instead
|
||||
const isImportedInCurrentFile = kclManager.ast.body.some(
|
||||
(n) =>
|
||||
n.type === 'ImportStatement' &&
|
||||
((n.path.type === 'Kcl' &&
|
||||
n.path.filename.includes(fileOrDir.name)) ||
|
||||
(n.path.type === 'Foreign' && n.path.path.includes(fileOrDir.name)))
|
||||
)
|
||||
|
||||
if (isCurrentFile && eventType === 'change') {
|
||||
let code = await window.electron.readFile(path, { encoding: 'utf-8' })
|
||||
code = normalizeLineEndings(code)
|
||||
codeManager.updateCodeStateEditor(code)
|
||||
} else if (isImportedInCurrentFile && eventType === 'change') {
|
||||
await rustContext.clearSceneAndBustCache(
|
||||
{ settings: await jsAppSettings() },
|
||||
codeManager?.currentFilePath || undefined
|
||||
)
|
||||
await kclManager.executeAst()
|
||||
}
|
||||
fileSend({ type: 'Refresh' })
|
||||
},
|
||||
@ -439,6 +457,7 @@ const FileTreeItem = ({
|
||||
onCreateFile={onCreateFile}
|
||||
onCreateFolder={onCreateFolder}
|
||||
onCloneFileOrFolder={onCloneFileOrFolder}
|
||||
onOpenInNewWindow={onOpenInNewWindow}
|
||||
newTreeEntry={newTreeEntry}
|
||||
lastDirectoryClicked={lastDirectoryClicked}
|
||||
onClickDirectory={onClickDirectory}
|
||||
@ -479,6 +498,7 @@ const FileTreeItem = ({
|
||||
onRename={addCurrentItemToRenaming}
|
||||
onDelete={() => setIsConfirmingDelete(true)}
|
||||
onClone={() => onCloneFileOrFolder(fileOrDir.path)}
|
||||
onOpenInNewWindow={() => onOpenInNewWindow(fileOrDir.path)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@ -489,6 +509,7 @@ interface FileTreeContextMenuProps {
|
||||
onRename: () => void
|
||||
onDelete: () => void
|
||||
onClone: () => void
|
||||
onOpenInNewWindow: () => void
|
||||
}
|
||||
|
||||
function FileTreeContextMenu({
|
||||
@ -496,6 +517,7 @@ function FileTreeContextMenu({
|
||||
onRename,
|
||||
onDelete,
|
||||
onClone,
|
||||
onOpenInNewWindow,
|
||||
}: FileTreeContextMenuProps) {
|
||||
const platform = usePlatform()
|
||||
const metaKey = platform === 'macos' ? '⌘' : 'Ctrl'
|
||||
@ -525,6 +547,12 @@ function FileTreeContextMenu({
|
||||
>
|
||||
Clone
|
||||
</ContextMenuItem>,
|
||||
<ContextMenuItem
|
||||
data-testid="context-menu-open-in-new-window"
|
||||
onClick={onOpenInNewWindow}
|
||||
>
|
||||
Open in new window
|
||||
</ContextMenuItem>,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
@ -636,11 +664,21 @@ export const useFileTreeOperations = () => {
|
||||
})
|
||||
}
|
||||
|
||||
function openInNewWindow(args: { path: string }) {
|
||||
send({
|
||||
type: 'Open file in new window',
|
||||
data: {
|
||||
name: args.path,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
createFile,
|
||||
createFolder,
|
||||
cloneFileOrDir,
|
||||
newTreeEntry,
|
||||
openInNewWindow,
|
||||
}
|
||||
}
|
||||
|
||||
@ -648,8 +686,13 @@ export const FileTree = ({
|
||||
className = '',
|
||||
onNavigateToFile: closePanel,
|
||||
}: FileTreeProps) => {
|
||||
const { createFile, createFolder, cloneFileOrDir, newTreeEntry } =
|
||||
useFileTreeOperations()
|
||||
const {
|
||||
createFile,
|
||||
createFolder,
|
||||
cloneFileOrDir,
|
||||
openInNewWindow,
|
||||
newTreeEntry,
|
||||
} = useFileTreeOperations()
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
@ -666,6 +709,7 @@ export const FileTree = ({
|
||||
onCreateFile={(name: string) => createFile({ dryRun: false, name })}
|
||||
onCreateFolder={(name: string) => createFolder({ dryRun: false, name })}
|
||||
onCloneFileOrFolder={(path: string) => cloneFileOrDir({ path })}
|
||||
onOpenInNewWindow={(path: string) => openInNewWindow({ path })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@ -676,11 +720,13 @@ export const FileTreeInner = ({
|
||||
onCreateFile,
|
||||
onCreateFolder,
|
||||
onCloneFileOrFolder,
|
||||
onOpenInNewWindow,
|
||||
newTreeEntry,
|
||||
}: {
|
||||
onCreateFile: (name: string) => void
|
||||
onCreateFolder: (name: string) => void
|
||||
onCloneFileOrFolder: (path: string) => void
|
||||
onOpenInNewWindow: (path: string) => void
|
||||
newTreeEntry: TreeEntry
|
||||
onNavigateToFile?: () => void
|
||||
}) => {
|
||||
@ -792,6 +838,7 @@ export const FileTreeInner = ({
|
||||
onCreateFile={onCreateFile}
|
||||
onCreateFolder={onCreateFolder}
|
||||
onCloneFileOrFolder={onCloneFileOrFolder}
|
||||
onOpenInNewWindow={onOpenInNewWindow}
|
||||
newTreeEntry={newTreeEntry}
|
||||
onClickDirectory={onClickDirectory}
|
||||
onNavigateToFile={onNavigateToFile_}
|
||||
|
@ -132,8 +132,13 @@ export const sidebarPanes: SidebarPane[] = [
|
||||
icon: 'folder',
|
||||
sidebarName: 'Project Files',
|
||||
Content: (props: { id: SidebarType; onClose: () => void }) => {
|
||||
const { createFile, createFolder, cloneFileOrDir, newTreeEntry } =
|
||||
useFileTreeOperations()
|
||||
const {
|
||||
createFile,
|
||||
createFolder,
|
||||
cloneFileOrDir,
|
||||
openInNewWindow,
|
||||
newTreeEntry,
|
||||
} = useFileTreeOperations()
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -155,6 +160,7 @@ export const sidebarPanes: SidebarPane[] = [
|
||||
createFolder({ dryRun: false, name })
|
||||
}
|
||||
onCloneFileOrFolder={(path: string) => cloneFileOrDir({ path })}
|
||||
onOpenInNewWindow={(path: string) => openInNewWindow({ path })}
|
||||
newTreeEntry={newTreeEntry}
|
||||
/>
|
||||
</>
|
||||
|
@ -10,6 +10,7 @@ type FileMachineContext = {
|
||||
|
||||
type FileMachineEvents =
|
||||
| { type: 'Open file'; data: { name: string } }
|
||||
| { type: 'Open file in new window'; data: { name: string } }
|
||||
| {
|
||||
type: 'Rename file'
|
||||
data: { oldName: string; newName: string; isDir: boolean }
|
||||
@ -95,6 +96,7 @@ export const fileMachine = setup({
|
||||
},
|
||||
}),
|
||||
navigateToFile: () => {},
|
||||
openFileInNewWindow: () => {},
|
||||
renameToastSuccess: () => {},
|
||||
createToastSuccess: () => {},
|
||||
toastSuccess: () => {},
|
||||
@ -213,6 +215,10 @@ export const fileMachine = setup({
|
||||
target: 'Opening file',
|
||||
},
|
||||
|
||||
'Open file in new window': {
|
||||
target: 'Opening file in new window',
|
||||
},
|
||||
|
||||
'Set selected directory': {
|
||||
target: 'Has files',
|
||||
actions: ['setSelectedDirectory'],
|
||||
@ -400,6 +406,10 @@ export const fileMachine = setup({
|
||||
entry: ['navigateToFile'],
|
||||
},
|
||||
|
||||
'Opening file in new window': {
|
||||
entry: ['openFileInNewWindow'],
|
||||
},
|
||||
|
||||
'Creating file': {
|
||||
invoke: {
|
||||
src: 'createFile',
|
||||
|
@ -283,6 +283,10 @@ ipcMain.handle('shell.openExternal', (event, data) => {
|
||||
return shell.openExternal(data)
|
||||
})
|
||||
|
||||
ipcMain.handle('openInNewWindow', (event, data) => {
|
||||
return createWindow(data)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'take.screenshot',
|
||||
async (event, data: { width: number; height: number }) => {
|
||||
|
@ -21,6 +21,7 @@ const resizeWindow = (width: number, height: number) =>
|
||||
const open = (args: any) => ipcRenderer.invoke('dialog.showOpenDialog', args)
|
||||
const save = (args: any) => ipcRenderer.invoke('dialog.showSaveDialog', args)
|
||||
const openExternal = (url: any) => ipcRenderer.invoke('shell.openExternal', url)
|
||||
const openInNewWindow = (url: any) => ipcRenderer.invoke('openInNewWindow', url)
|
||||
const takeElectronWindowScreenshot = ({
|
||||
width,
|
||||
height,
|
||||
@ -248,6 +249,7 @@ contextBridge.exposeInMainWorld('electron', {
|
||||
save,
|
||||
// opens the URL
|
||||
openExternal,
|
||||
openInNewWindow,
|
||||
showInFolder,
|
||||
getPath,
|
||||
packageJson,
|
||||
|
Reference in New Issue
Block a user