Skip to content

Commit f4bb7db

Browse files
committed
Revert "Revert "Move responsibility of disk watching for active editor into KclManager, don't watch while writing (#10305)""
This reverts commit a66c669.
1 parent eb4cea5 commit f4bb7db

7 files changed

Lines changed: 114 additions & 73 deletions

File tree

e2e/playwright/debug-pane.spec.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ test.describe('Debug pane', { tag: '@desktop' }, () => {
5252
await page.keyboard.press('ArrowDown')
5353
}
5454
})
55+
let lastSegmentText = await segment.innerText()
5556
// TODO: if you type all the code at once without delay (or paste it in)
5657
// the initial segment artifact ID is different. This appears to be niche bug
5758
// that is being sidestepped in this test until https://github.com/KittyCAD/modeling-app/issues/9609 is addressed.
@@ -60,20 +61,23 @@ test.describe('Debug pane', { tag: '@desktop' }, () => {
6061
// Wait for keyboard input debounce and updated artifact graph.
6162
await page.waitForTimeout(1000)
6263
})
63-
// Extract the artifact IDs from the debug artifact graph.
64-
const initialSegmentIds = await segment.innerText({ timeout: 5_000 })
64+
await expect(segment).not.toHaveText(lastSegmentText)
6565
// The artifact ID should include a UUID.
66-
expect(initialSegmentIds).toMatch(
66+
const uuidRegexp =
6767
/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/
68-
)
69-
await test.step('Enter a comment', async () => {
68+
await expect(segment).toHaveText(uuidRegexp)
69+
const uuid = (await segment.innerText()).match(uuidRegexp)!
70+
71+
await test.step('Enter another line', async () => {
72+
lastSegmentText = await segment.innerText()
7073
await page.keyboard.type('\n|> line(end = [2, 2])', { delay: 10 })
7174
// Wait for keyboard input debounce and updated artifact graph.
7275
await page.waitForTimeout(1000)
7376
})
74-
const newSegmentIds = await segment.innerText()
75-
// Strip off the closing bracket.
76-
const initialIds = initialSegmentIds.slice(0, initialSegmentIds.length - 1)
77-
expect(newSegmentIds.slice(0, initialIds.length)).toEqual(initialIds)
77+
78+
// Expect the artifact IDs to be changed (by adding another),
79+
await expect(segment).not.toHaveText(lastSegmentText)
80+
// but still contain the stable first ID.
81+
await expect(segment).toContainText(uuid)
7882
})
7983
})

src/components/CommandBar/CommandBarSelectionMixedInput.spec.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,11 @@ describe('CommandBarSelectionMixedInput', () => {
5555
describe('clearSelectionFirst behavior', () => {
5656
it('should send clear selection command when clearSelectionFirst is true', async () => {
5757
const app = App.getDefaultSystems()
58-
const executingEditor = new KclManager({
58+
const executingEditor = new KclManager('some-path', {
5959
commandBar: app.commands.actor,
6060
settings: app.settings.actor,
6161
wasmInstancePromise: app.wasmPromise,
62+
projectPath: 'some-project',
6263
})
6364
const mockModelingSend = vi.spyOn(
6465
executingEditor.engineCommandManager,
@@ -85,10 +86,11 @@ describe('CommandBarSelectionMixedInput', () => {
8586

8687
it('should NOT send clear selection command when clearSelectionFirst is false', async () => {
8788
const app = App.getDefaultSystems()
88-
const executingEditor = new KclManager({
89+
const executingEditor = new KclManager('some-path', {
8990
commandBar: app.commands.actor,
9091
settings: app.settings.actor,
9192
wasmInstancePromise: app.wasmPromise,
93+
projectPath: 'some-project',
9294
})
9395
const mockModelingSend = vi.spyOn(
9496
executingEditor.engineCommandManager,
@@ -112,10 +114,11 @@ describe('CommandBarSelectionMixedInput', () => {
112114

113115
it('should NOT send clear selection command when clearSelectionFirst is undefined', async () => {
114116
const app = App.getDefaultSystems()
115-
const executingEditor = new KclManager({
117+
const executingEditor = new KclManager('some-path', {
116118
commandBar: app.commands.actor,
117119
settings: app.settings.actor,
118120
wasmInstancePromise: app.wasmPromise,
121+
projectPath: 'some-project',
119122
})
120123
const mockModelingSend = vi.spyOn(
121124
executingEditor.engineCommandManager,
@@ -139,10 +142,11 @@ describe('CommandBarSelectionMixedInput', () => {
139142

140143
it('should send clear selection command only once on mount', async () => {
141144
const app = App.getDefaultSystems()
142-
const executingEditor = new KclManager({
145+
const executingEditor = new KclManager('some-path', {
143146
commandBar: app.commands.actor,
144147
settings: app.settings.actor,
145148
wasmInstancePromise: app.wasmPromise,
149+
projectPath: 'some-project',
146150
})
147151
const mockModelingSend = vi.spyOn(
148152
executingEditor.engineCommandManager,
@@ -181,10 +185,11 @@ describe('CommandBarSelectionMixedInput', () => {
181185

182186
it('should set hasClearedSelection state after clearing', async () => {
183187
const app = App.getDefaultSystems()
184-
const executingEditor = new KclManager({
188+
const executingEditor = new KclManager('some-path', {
185189
commandBar: app.commands.actor,
186190
settings: app.settings.actor,
187191
wasmInstancePromise: app.wasmPromise,
192+
projectPath: 'some-project',
188193
})
189194
const mockModelingSend = vi.spyOn(
190195
executingEditor.engineCommandManager,

src/components/RouteProvider.tsx

Lines changed: 4 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1-
import { isCodeTheSame } from '@src/lib/codeEditor'
21
import fsZds from '@src/lib/fs-zds'
32
import type { ReactNode } from 'react'
43
import { createContext, useEffect, useState } from 'react'
54
import { useLocation, useNavigate, useNavigation } from 'react-router-dom'
6-
import toast from 'react-hot-toast'
7-
85
import { useAuthNavigation } from '@src/hooks/useAuthNavigation'
96
import { useFileSystemWatcher } from '@src/hooks/useFileSystemWatcher'
107
import { getAppSettingsFilePath } from '@src/lib/desktop'
@@ -75,35 +72,10 @@ export function RouteProvider({ children }: { children: ReactNode }) {
7572
// is very high in the context tree, higher than mlEphant's.
7673
if (kclManager.mlEphantManagerMachineBulkManipulatingFileSystem) return
7774

78-
const isCurrentFile = loadedFile?.path === path
79-
if (isCurrentFile) {
80-
if (window.electron) {
81-
// Your current file is changed, read it from disk and write it into the code manager and execute the AST,
82-
// unless the change was initiated by us (the currently running instance).
83-
const code = await window.electron.readFile(path, {
84-
encoding: 'utf-8',
85-
})
86-
87-
const lastWrittenCode = kclManager.lastWrite?.code
88-
if (!lastWrittenCode || !isCodeTheSame(lastWrittenCode, code)) {
89-
const isInSketchMode =
90-
kclManager.modelingState?.matches('Sketch') ||
91-
kclManager.modelingState?.matches('sketchSolveMode')
92-
93-
// Nothing written out yet by ourselves, or it's not the same as the current file content
94-
// -> this must be an external change -> re-execute.
95-
kclManager.updateCodeEditor(code, {
96-
shouldExecute: !isInSketchMode,
97-
shouldResetCamera: !isInSketchMode,
98-
// We explicitly do not write to the file here since we are loading from
99-
// the file system and not the editor.
100-
shouldWriteToDisk: false,
101-
})
102-
103-
toast('Reloading file from disk', { icon: '📁' })
104-
}
105-
}
106-
} else {
75+
// We only react on files other than the currently-executing one here
76+
// because the currently-executing one is handled with its own watcher in
77+
// KclManager. In future, all files and folders will watch themselves.
78+
if (loadedFile?.path !== path) {
10779
const fileNameWithExtension = getStringAfterLastSeparator(path)
10880
// Is the file from the change event type imported into the currently opened file
10981
const isImportedInCurrentFile = kclManager.ast.body.some(

src/lang/KclManager.ts

Lines changed: 80 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import {
4242
} from '@src/lib/settings/settingsUtils'
4343

4444
import { err, reportRejection } from '@src/lib/trap'
45-
import { deferredCallback } from '@src/lib/utils'
45+
import { deferredCallback, uuidv4 } from '@src/lib/utils'
4646
import { ConnectionManager } from '@src/network/connectionManager'
4747
import { EngineDebugger } from '@src/lib/debugger'
4848
import type {
@@ -131,6 +131,7 @@ import type { FileEntry, Project } from '@src/lib/project'
131131
import { getStringAfterLastSeparator } from '@src/lib/paths'
132132
import type { SettingsActorType } from '@src/machines/settingsMachine'
133133
import type { CommandBarActorType } from '@src/machines/commandBarMachine'
134+
import { isCodeTheSame } from '@src/lib/codeEditor'
134135
import { getResolvedTheme } from '@src/lib/theme'
135136

136137
interface ExecuteArgs {
@@ -155,6 +156,7 @@ interface SystemDeps {
155156
wasmInstancePromise: Promise<ModuleType>
156157
settings: SettingsActorType
157158
commandBar: CommandBarActorType
159+
projectPath: string
158160
}
159161

160162
export enum KclManagerEvents {
@@ -234,42 +236,47 @@ export class ZDSProject {
234236
if (newPath === null) {
235237
return
236238
}
237-
const foundPathSignal = this.findEditorPathSignal(newPath)
239+
const foundPathSignal = this.findEditor(newPath)
238240
if (!foundPathSignal) {
239241
return
240242
}
241-
const found = this.editors.get(foundPathSignal)
243+
const found = foundPathSignal[1]
242244
if (found) {
243245
// TODO: Reconfigure the editor to be an executing one
244246
}
245-
this.#executingPath.value = foundPathSignal
247+
this.#executingPath.value = foundPathSignal[0]
246248
}
247-
findEditorPathSignal(path: string) {
248-
return this.editors.keys().find((p) => p.value === path)
249+
findEditor(path: string) {
250+
return this.editors.entries().find(([p]) => p.value === path)
249251
}
250252

251253
// Saving some keystrokes
252-
private get = this.editors.get.bind(this.editors)
253254
private set = this.editors.set.bind(this.editors)
254255

255256
// TODO: Remove providedEditor, replace with options about if the editor is the executing one
256257
// once the app can handle not having a KclManager.
257258
openEditor(path: string, providedEditor?: KclManager) {
258-
const foundPathSignal = this.findEditorPathSignal(path)
259-
const found = foundPathSignal ? this.get(foundPathSignal) : undefined
259+
const foundEditor = this.findEditor(path)
260+
const found = foundEditor?.[1]
260261
if (found) {
261262
console.warn(`Attempted to overwrite editor with path "${path}"`)
262263
return found
263264
}
264265

265266
const newEditor =
266267
providedEditor ??
267-
new KclManager({
268+
new KclManager(path, {
268269
wasmInstancePromise: this.app.wasmPromise,
269270
commandBar: this.app.commands.actor,
270271
settings: this.app.settings.actor,
272+
get projectPath() {
273+
return this.path
274+
},
271275
})
272276

277+
if (providedEditor) {
278+
providedEditor.path = path
279+
}
273280
// Initialize the editor theme
274281
// Subsequent changes are listened for within app.onSettingsUpdate()
275282
// TODO: Disassemble onSettingsUpdate, subscribe to changes from subsystems
@@ -285,15 +292,19 @@ export class ZDSProject {
285292
}
286293

287294
closeEditor(path: string) {
288-
const foundPathSignal = this.findEditorPathSignal(path)
295+
const foundPathSignal = this.findEditor(path)
289296
if (!foundPathSignal) {
290297
console.warn(`Attempted to close nonexistent editor with path "${path}"`)
291298
return
292299
}
293-
this.editors.delete(foundPathSignal)
300+
foundPathSignal[1].close()
301+
this.editors.delete(foundPathSignal[0])
294302
}
295303

296304
closeAllEditors() {
305+
for (const editor of this.editors.values()) {
306+
editor.close()
307+
}
297308
this.editors.clear()
298309
}
299310
}
@@ -428,6 +439,44 @@ export class KclManager extends EventTarget {
428439

429440
// INTERNAL BOOKKEEPING STATE
430441

442+
private fileWatcherKey = uuidv4()
443+
/**
444+
* Watching the file system for updates and reacting to them.
445+
* TODO: We don't watch for deletions here, should we?
446+
*/
447+
private onFileWatchEvent = (_eventType: string, path: string) => {
448+
// TODO: We can remove this once we make it impossible to have
449+
// a KclManager without a ZDSProject.
450+
if (path !== this.path || !this.systemDeps.projectPath) {
451+
return
452+
}
453+
// Your current file is changed, read it from disk and write it into the code manager and execute the AST,
454+
// unless the change was initiated by us (the currently running instance).
455+
window.electron
456+
?.readFile(path, {
457+
encoding: 'utf-8',
458+
})
459+
.then((code) => {
460+
const isInSketchMode =
461+
this.modelingState?.matches('Sketch') ||
462+
this.modelingState?.matches('sketchSolveMode')
463+
464+
if (!isCodeTheSame(code, this.code)) {
465+
// Nothing written out yet by ourselves, or it's not the same as the current file content
466+
// -> this must be an external change -> re-execute.
467+
this.updateCodeEditor(code, {
468+
shouldExecute: !isInSketchMode,
469+
shouldResetCamera: !isInSketchMode,
470+
// We explicitly do not write to the file here since we are loading from
471+
// the file system and not the editor.
472+
shouldWriteToDisk: false,
473+
})
474+
475+
toast('Reloading file from disk', { icon: '📁' })
476+
}
477+
})
478+
.catch(reportRejection)
479+
}
431480
private _wasmInitFailed = signal<boolean | undefined>(undefined)
432481
private _astParseFailed = false
433482
private _switchedFiles = false
@@ -450,11 +499,11 @@ export class KclManager extends EventTarget {
450499
undefined
451500
public writeCausedByAppCheckedInFileTreeFileSystemWatcher = false
452501
public mlEphantManagerMachineBulkManipulatingFileSystem = false
453-
// The last code written by the app, used to compare against external changes to the current file
454-
public lastWrite: {
455-
code: string // last code written by ZDS
456-
time: number // Unix epoch time in milliseconds
457-
} | null = null
502+
/**
503+
Indicator Promise that is pending while a live write is happening.
504+
If this value isn't `null`, don't watch for file system writes it was probably us!
505+
*/
506+
public writingPromise = signal<Promise<unknown> | null>(null)
458507
public isBufferMode = false
459508
sceneInfraBaseUnitMultiplierSetter: (unit: BaseUnit) => void = () => {}
460509
/** Values merged in from former EditorManager and CodeManager classes */
@@ -751,7 +800,7 @@ export class KclManager extends EventTarget {
751800
console.error('Error when updating Rust state after user edit:', error)
752801
}
753802
},
754-
300
803+
1000
755804
)
756805

757806
private createEditorExtensions() {
@@ -826,6 +875,18 @@ export class KclManager extends EventTarget {
826875
this._wasmInitFailed.value = true
827876
reportRejection(e)
828877
})
878+
879+
// Register a file watcher for this file.
880+
window.electron?.watchFileOn(
881+
path,
882+
this.fileWatcherKey,
883+
this.onFileWatchEvent
884+
)
885+
}
886+
887+
/** Clean up listeners, watchers, etc */
888+
public close() {
889+
window.electron?.watchFileOff(this.path, this.fileWatcherKey)
829890
}
830891

831892
clearAst() {
@@ -1854,7 +1915,6 @@ export class KclManager extends EventTarget {
18541915
updateCurrentFilePath(path: string) {
18551916
if (this._currentFilePath !== path) {
18561917
this._currentFilePath = path
1857-
this.lastWrite = null
18581918
}
18591919
}
18601920
get currentFileName() {
@@ -1954,17 +2014,14 @@ export class KclManager extends EventTarget {
19542014
// writes.
19552015
clearTimeout(this.timeoutWriter)
19562016
return new Promise((resolve, reject) => {
1957-
this.lastWrite = {
1958-
code: newCode ?? '',
1959-
time: Date.now(),
1960-
}
19612017
this.timeoutWriter = setTimeout(() => {
19622018
if (!path) {
19632019
return reject(new Error('currentFilePath not set'))
19642020
}
19652021
// Wait one event loop to give a chance for params to be set
19662022
// Save the file to disk
19672023
this.writeCausedByAppCheckedInFileTreeFileSystemWatcher = true
2024+
window.electron?.watchFileOff(this.path, this.fileWatcherKey)
19682025
fsZds
19692026
.writeFile(path, new TextEncoder().encode(newCode))
19702027
.then(resolve)

src/lib/app.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,10 +342,11 @@ export class App implements AppSubsystems {
342342
* Build the world!
343343
*/
344344
buildSingletons() {
345-
const kclManager = new KclManager({
345+
const kclManager = new KclManager('', {
346346
settings: this.settings.actor,
347347
wasmInstancePromise: this.wasmPromise,
348348
commandBar: this.commands.actor,
349+
projectPath: '',
349350
})
350351

351352
if (typeof window !== 'undefined') {

src/preload.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ const watchFileOn = (
7979
if (!watchers) {
8080
watchers = new Map()
8181
}
82-
const watcher = chokidar.watch(path, { depth: 1 })
82+
const watcher = chokidar.watch(path, { depth: 1, ignoreInitial: true })
8383
watcher.on('all', callback)
8484
watchers.set(key, { watcher, callback })
8585
fsWatchListeners.set(path, watchers)

0 commit comments

Comments
 (0)