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:
Pierre Jacquier
2025-04-24 19:02:18 -04:00
committed by GitHub
parent bd1e68a4c8
commit 6001b71f06
9 changed files with 178 additions and 5 deletions

View File

@ -6,6 +6,7 @@ import type { ToolbarFixture } from '@e2e/playwright/fixtures/toolbarFixture'
import { import {
executorInputPath, executorInputPath,
getUtils, getUtils,
kclSamplesPath,
testsInputPath, testsInputPath,
} from '@e2e/playwright/test-utils' } from '@e2e/playwright/test-utils'
import { expect, test } from '@e2e/playwright/zoo-test' 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)
})
}
)
}) })

View File

@ -1024,6 +1024,10 @@ export function testsInputPath(fileName: string): string {
return path.join('rust', 'kcl-lib', 'tests', 'inputs', fileName) 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( export async function doAndWaitForImageDiff(
page: Page, page: Page,
fn: () => Promise<unknown>, fn: () => Promise<unknown>,

1
interface.d.ts vendored
View File

@ -20,6 +20,7 @@ export interface IElectronAPI {
open: typeof dialog.showOpenDialog open: typeof dialog.showOpenDialog
save: typeof dialog.showSaveDialog save: typeof dialog.showSaveDialog
openExternal: typeof shell.openExternal openExternal: typeof shell.openExternal
openInNewWindow: (name: string) => void
takeElectronWindowScreenshot: ({ takeElectronWindowScreenshot: ({
width, width,
height, height,

View File

@ -194,6 +194,14 @@ export const FileMachineProvider = ({
navigate(`..${PATHS.FILE}/${encodeURIComponent(event.output.path)}`) 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: { actors: {
readFiles: fromPromise(async ({ input }) => { readFiles: fromPromise(async ({ input }) => {

View File

@ -24,7 +24,7 @@ import { sortFilesAndDirectories } from '@src/lib/desktopFS'
import useHotkeyWrapper from '@src/lib/hotkeyWrapper' import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
import { PATHS } from '@src/lib/paths' import { PATHS } from '@src/lib/paths'
import type { FileEntry } from '@src/lib/project' 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 { reportRejection } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types' import type { IndexLoaderData } from '@src/lib/types'
@ -32,6 +32,7 @@ import { ToastInsert } from '@src/components/ToastInsert'
import { commandBarActor } from '@src/lib/singletons' import { commandBarActor } from '@src/lib/singletons'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import styles from './FileTree.module.css' import styles from './FileTree.module.css'
import { jsAppSettings } from '@src/lib/settings/settingsUtils'
function getIndentationCSS(level: number) { function getIndentationCSS(level: number) {
return `calc(1rem * ${level + 1})` return `calc(1rem * ${level + 1})`
@ -163,6 +164,7 @@ const FileTreeItem = ({
onCreateFile, onCreateFile,
onCreateFolder, onCreateFolder,
onCloneFileOrFolder, onCloneFileOrFolder,
onOpenInNewWindow,
newTreeEntry, newTreeEntry,
level = 0, level = 0,
treeSelection, treeSelection,
@ -183,6 +185,7 @@ const FileTreeItem = ({
onCreateFile: (name: string) => void onCreateFile: (name: string) => void
onCreateFolder: (name: string) => void onCreateFolder: (name: string) => void
onCloneFileOrFolder: (path: string) => void onCloneFileOrFolder: (path: string) => void
onOpenInNewWindow: (path: string) => void
newTreeEntry: TreeEntry newTreeEntry: TreeEntry
level?: number level?: number
treeSelection: FileEntry | undefined treeSelection: FileEntry | undefined
@ -212,10 +215,25 @@ const FileTreeItem = ({
return 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') { if (isCurrentFile && eventType === 'change') {
let code = await window.electron.readFile(path, { encoding: 'utf-8' }) let code = await window.electron.readFile(path, { encoding: 'utf-8' })
code = normalizeLineEndings(code) code = normalizeLineEndings(code)
codeManager.updateCodeStateEditor(code) codeManager.updateCodeStateEditor(code)
} else if (isImportedInCurrentFile && eventType === 'change') {
await rustContext.clearSceneAndBustCache(
{ settings: await jsAppSettings() },
codeManager?.currentFilePath || undefined
)
await kclManager.executeAst()
} }
fileSend({ type: 'Refresh' }) fileSend({ type: 'Refresh' })
}, },
@ -439,6 +457,7 @@ const FileTreeItem = ({
onCreateFile={onCreateFile} onCreateFile={onCreateFile}
onCreateFolder={onCreateFolder} onCreateFolder={onCreateFolder}
onCloneFileOrFolder={onCloneFileOrFolder} onCloneFileOrFolder={onCloneFileOrFolder}
onOpenInNewWindow={onOpenInNewWindow}
newTreeEntry={newTreeEntry} newTreeEntry={newTreeEntry}
lastDirectoryClicked={lastDirectoryClicked} lastDirectoryClicked={lastDirectoryClicked}
onClickDirectory={onClickDirectory} onClickDirectory={onClickDirectory}
@ -479,6 +498,7 @@ const FileTreeItem = ({
onRename={addCurrentItemToRenaming} onRename={addCurrentItemToRenaming}
onDelete={() => setIsConfirmingDelete(true)} onDelete={() => setIsConfirmingDelete(true)}
onClone={() => onCloneFileOrFolder(fileOrDir.path)} onClone={() => onCloneFileOrFolder(fileOrDir.path)}
onOpenInNewWindow={() => onOpenInNewWindow(fileOrDir.path)}
/> />
</div> </div>
) )
@ -489,6 +509,7 @@ interface FileTreeContextMenuProps {
onRename: () => void onRename: () => void
onDelete: () => void onDelete: () => void
onClone: () => void onClone: () => void
onOpenInNewWindow: () => void
} }
function FileTreeContextMenu({ function FileTreeContextMenu({
@ -496,6 +517,7 @@ function FileTreeContextMenu({
onRename, onRename,
onDelete, onDelete,
onClone, onClone,
onOpenInNewWindow,
}: FileTreeContextMenuProps) { }: FileTreeContextMenuProps) {
const platform = usePlatform() const platform = usePlatform()
const metaKey = platform === 'macos' ? '⌘' : 'Ctrl' const metaKey = platform === 'macos' ? '⌘' : 'Ctrl'
@ -525,6 +547,12 @@ function FileTreeContextMenu({
> >
Clone Clone
</ContextMenuItem>, </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 { return {
createFile, createFile,
createFolder, createFolder,
cloneFileOrDir, cloneFileOrDir,
newTreeEntry, newTreeEntry,
openInNewWindow,
} }
} }
@ -648,8 +686,13 @@ export const FileTree = ({
className = '', className = '',
onNavigateToFile: closePanel, onNavigateToFile: closePanel,
}: FileTreeProps) => { }: FileTreeProps) => {
const { createFile, createFolder, cloneFileOrDir, newTreeEntry } = const {
useFileTreeOperations() createFile,
createFolder,
cloneFileOrDir,
openInNewWindow,
newTreeEntry,
} = useFileTreeOperations()
return ( return (
<div className={className}> <div className={className}>
@ -666,6 +709,7 @@ export const FileTree = ({
onCreateFile={(name: string) => createFile({ dryRun: false, name })} onCreateFile={(name: string) => createFile({ dryRun: false, name })}
onCreateFolder={(name: string) => createFolder({ dryRun: false, name })} onCreateFolder={(name: string) => createFolder({ dryRun: false, name })}
onCloneFileOrFolder={(path: string) => cloneFileOrDir({ path })} onCloneFileOrFolder={(path: string) => cloneFileOrDir({ path })}
onOpenInNewWindow={(path: string) => openInNewWindow({ path })}
/> />
</div> </div>
) )
@ -676,11 +720,13 @@ export const FileTreeInner = ({
onCreateFile, onCreateFile,
onCreateFolder, onCreateFolder,
onCloneFileOrFolder, onCloneFileOrFolder,
onOpenInNewWindow,
newTreeEntry, newTreeEntry,
}: { }: {
onCreateFile: (name: string) => void onCreateFile: (name: string) => void
onCreateFolder: (name: string) => void onCreateFolder: (name: string) => void
onCloneFileOrFolder: (path: string) => void onCloneFileOrFolder: (path: string) => void
onOpenInNewWindow: (path: string) => void
newTreeEntry: TreeEntry newTreeEntry: TreeEntry
onNavigateToFile?: () => void onNavigateToFile?: () => void
}) => { }) => {
@ -792,6 +838,7 @@ export const FileTreeInner = ({
onCreateFile={onCreateFile} onCreateFile={onCreateFile}
onCreateFolder={onCreateFolder} onCreateFolder={onCreateFolder}
onCloneFileOrFolder={onCloneFileOrFolder} onCloneFileOrFolder={onCloneFileOrFolder}
onOpenInNewWindow={onOpenInNewWindow}
newTreeEntry={newTreeEntry} newTreeEntry={newTreeEntry}
onClickDirectory={onClickDirectory} onClickDirectory={onClickDirectory}
onNavigateToFile={onNavigateToFile_} onNavigateToFile={onNavigateToFile_}

View File

@ -132,8 +132,13 @@ export const sidebarPanes: SidebarPane[] = [
icon: 'folder', icon: 'folder',
sidebarName: 'Project Files', sidebarName: 'Project Files',
Content: (props: { id: SidebarType; onClose: () => void }) => { Content: (props: { id: SidebarType; onClose: () => void }) => {
const { createFile, createFolder, cloneFileOrDir, newTreeEntry } = const {
useFileTreeOperations() createFile,
createFolder,
cloneFileOrDir,
openInNewWindow,
newTreeEntry,
} = useFileTreeOperations()
return ( return (
<> <>
@ -155,6 +160,7 @@ export const sidebarPanes: SidebarPane[] = [
createFolder({ dryRun: false, name }) createFolder({ dryRun: false, name })
} }
onCloneFileOrFolder={(path: string) => cloneFileOrDir({ path })} onCloneFileOrFolder={(path: string) => cloneFileOrDir({ path })}
onOpenInNewWindow={(path: string) => openInNewWindow({ path })}
newTreeEntry={newTreeEntry} newTreeEntry={newTreeEntry}
/> />
</> </>

View File

@ -10,6 +10,7 @@ type FileMachineContext = {
type FileMachineEvents = type FileMachineEvents =
| { type: 'Open file'; data: { name: string } } | { type: 'Open file'; data: { name: string } }
| { type: 'Open file in new window'; data: { name: string } }
| { | {
type: 'Rename file' type: 'Rename file'
data: { oldName: string; newName: string; isDir: boolean } data: { oldName: string; newName: string; isDir: boolean }
@ -95,6 +96,7 @@ export const fileMachine = setup({
}, },
}), }),
navigateToFile: () => {}, navigateToFile: () => {},
openFileInNewWindow: () => {},
renameToastSuccess: () => {}, renameToastSuccess: () => {},
createToastSuccess: () => {}, createToastSuccess: () => {},
toastSuccess: () => {}, toastSuccess: () => {},
@ -213,6 +215,10 @@ export const fileMachine = setup({
target: 'Opening file', target: 'Opening file',
}, },
'Open file in new window': {
target: 'Opening file in new window',
},
'Set selected directory': { 'Set selected directory': {
target: 'Has files', target: 'Has files',
actions: ['setSelectedDirectory'], actions: ['setSelectedDirectory'],
@ -400,6 +406,10 @@ export const fileMachine = setup({
entry: ['navigateToFile'], entry: ['navigateToFile'],
}, },
'Opening file in new window': {
entry: ['openFileInNewWindow'],
},
'Creating file': { 'Creating file': {
invoke: { invoke: {
src: 'createFile', src: 'createFile',

View File

@ -283,6 +283,10 @@ ipcMain.handle('shell.openExternal', (event, data) => {
return shell.openExternal(data) return shell.openExternal(data)
}) })
ipcMain.handle('openInNewWindow', (event, data) => {
return createWindow(data)
})
ipcMain.handle( ipcMain.handle(
'take.screenshot', 'take.screenshot',
async (event, data: { width: number; height: number }) => { async (event, data: { width: number; height: number }) => {

View File

@ -21,6 +21,7 @@ const resizeWindow = (width: number, height: number) =>
const open = (args: any) => ipcRenderer.invoke('dialog.showOpenDialog', args) const open = (args: any) => ipcRenderer.invoke('dialog.showOpenDialog', args)
const save = (args: any) => ipcRenderer.invoke('dialog.showSaveDialog', args) const save = (args: any) => ipcRenderer.invoke('dialog.showSaveDialog', args)
const openExternal = (url: any) => ipcRenderer.invoke('shell.openExternal', url) const openExternal = (url: any) => ipcRenderer.invoke('shell.openExternal', url)
const openInNewWindow = (url: any) => ipcRenderer.invoke('openInNewWindow', url)
const takeElectronWindowScreenshot = ({ const takeElectronWindowScreenshot = ({
width, width,
height, height,
@ -248,6 +249,7 @@ contextBridge.exposeInMainWorld('electron', {
save, save,
// opens the URL // opens the URL
openExternal, openExternal,
openInNewWindow,
showInFolder, showInFolder,
getPath, getPath,
packageJson, packageJson,