Skip to content

Commit 2ac0da1

Browse files
committed
Add HistoryView class for global history effect tracking
1 parent 44e47d7 commit 2ac0da1

File tree

5 files changed

+196
-33
lines changed

5 files changed

+196
-33
lines changed

src/components/Explorer/ProjectExplorer.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,9 @@ export const ProjectExplorer = ({
328328
requestedProjectName: project.name,
329329
},
330330
})
331-
kclManager.dispatch(fsArchiveFile({ src, target }))
331+
kclManager.addGlobalHistoryEvent(
332+
fsArchiveFile({ src, target })
333+
)
332334
})
333335
.catch((e) => {
334336
console.error(e)
@@ -348,7 +350,9 @@ export const ProjectExplorer = ({
348350
successMessage: 'Archived successfully',
349351
},
350352
})
351-
kclManager.dispatch(fsArchiveFile({ src, target }))
353+
kclManager.addGlobalHistoryEvent(
354+
fsArchiveFile({ src, target })
355+
)
352356
})
353357
.catch((e) => {
354358
console.error(e)

src/editor/HistoryView.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import {
2+
history,
3+
invertedEffects,
4+
isolateHistory,
5+
redo,
6+
undo,
7+
} from '@codemirror/commands'
8+
import {
9+
Compartment,
10+
StateEffect,
11+
Transaction,
12+
type Extension,
13+
type TransactionSpec,
14+
} from '@codemirror/state'
15+
import { EditorView } from 'codemirror'
16+
17+
/** Any EditorView that wants to be a local history target must use this extension compartment */
18+
export const localHistoryTarget = new Compartment()
19+
/**
20+
* Extensions which want to dispatch global history transactions
21+
* must use this limited transaction type. We don't want any developers
22+
* mistaking the globalHistoryView for a normal document-oriented EditorView.
23+
*/
24+
export type TransactionSpecNoChanges = TransactionSpec & {
25+
changes?: undefined
26+
selection?: undefined
27+
}
28+
29+
const globalHistory = new Compartment()
30+
type HistoryRequest = {
31+
historySource: EditorView
32+
request: 'new' | 'undo' | 'redo'
33+
}
34+
const globalHistoryRequest = StateEffect.define<HistoryRequest>()
35+
36+
/**
37+
* A locked-down CodeMirror EditorView that is meant for
38+
* global application effects that we want to be un/redoable.
39+
*
40+
* It can be created without a "local target history" EditorView registered,
41+
* and receive transactions that are un/redoable. If it has a registered "local target history",
42+
* any transactions the HistoryView receives will be interleaved into the local target's history
43+
* as "global history request" StateEffects.
44+
*
45+
* The HistoryView is then intended to be used as a fallback for "local history" un/redo commands:
46+
* - If there is un/redo available, the local history only fires
47+
* - If this is a forwarded "global history request", then yay we un/redo the global history.
48+
* - If there is no local history, we try out the global history.
49+
*
50+
* This allows us to do keep around this global history for longer than the life of any given
51+
* "local history" EditorView, but still get to interleave them with normal local history!
52+
*/
53+
export class HistoryView {
54+
private editorView: EditorView
55+
56+
constructor(extensions: Extension[]) {
57+
this.editorView = new EditorView({
58+
extensions: [history(), globalHistory.of([]), ...extensions],
59+
})
60+
}
61+
62+
registerLocalHistoryTarget(target: EditorView) {
63+
this.editorView.dispatch({
64+
effects: [
65+
globalHistory.reconfigure([this.buildForwardingExtension(target)]),
66+
],
67+
})
68+
target.dispatch({
69+
effects: [localHistoryTarget.reconfigure(this.localHistoryExtension())],
70+
annotations: [Transaction.addToHistory.of(false)],
71+
})
72+
}
73+
74+
// Used by `undo()` function
75+
get state() {
76+
return this.editorView.state
77+
}
78+
// Used by `undo()` function
79+
dispatch = (tr: Transaction | TransactionSpec) => this.editorView.dispatch(tr)
80+
81+
/**
82+
* Type errors will occur if a developer tries to include `changes` or `selection`.
83+
* The HistoryView should really only contain effectful, reversible transactions
84+
*/
85+
limitedDispatch = (spec: TransactionSpecNoChanges) => {
86+
this.editorView.dispatch(spec)
87+
}
88+
89+
/** Extensions attached to a local history target */
90+
private localHistoryExtension(): Extension {
91+
/**
92+
* Extension attached to a local history source to turn effects pointing
93+
* to "global history requests" into actual history commands on the global history.
94+
*/
95+
const localHistoryEffect = EditorView.updateListener.of((vu) => {
96+
for (const tr of vu.transactions) {
97+
for (const e of tr.effects) {
98+
if (e.is(globalHistoryRequest)) {
99+
e.value.request === 'undo'
100+
? undo(e.value.historySource)
101+
: redo(e.value.historySource)
102+
}
103+
}
104+
}
105+
})
106+
107+
/**
108+
* Extension attached to local history source to make "global history request"
109+
* effects undoable. The value simply changes whether an undo or redo is called
110+
* on the global history source.
111+
*/
112+
const localHistoryInvertedEffect = invertedEffects.of((tr) => {
113+
const found: StateEffect<unknown>[] = []
114+
for (const e of tr.effects) {
115+
if (e.is(globalHistoryRequest)) {
116+
found.push(
117+
globalHistoryRequest.of({
118+
historySource: e.value.historySource,
119+
request: e.value.request === 'undo' ? 'redo' : 'undo',
120+
})
121+
)
122+
}
123+
}
124+
return found
125+
})
126+
127+
return [localHistoryEffect, localHistoryInvertedEffect]
128+
}
129+
130+
/**
131+
* Extension attached to global history source that will
132+
* forward any updates it receives to a `targetView` with local history.
133+
*/
134+
private buildForwardingExtension(targetView: EditorView): Extension {
135+
return EditorView.updateListener.of((vu) => {
136+
for (const tr of vu.transactions) {
137+
// We don't want to forward undo and redo events because then
138+
// we'll just be in a loop with our target local history.
139+
if (tr.isUserEvent('undo') || tr.isUserEvent('redo')) {
140+
continue
141+
}
142+
143+
targetView.dispatch({
144+
effects: [
145+
globalHistoryRequest.of({
146+
historySource: this.editorView,
147+
request: 'redo',
148+
}),
149+
],
150+
annotations: [
151+
// These "global history request" transactions should never be grouped.
152+
isolateHistory.of('full'),
153+
],
154+
})
155+
}
156+
})
157+
}
158+
}

src/editor/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { lineHighlightField } from '@src/editor/highlightextension'
4040
import { onMouseDragRegex, onMouseDragMakeANewNumber } from '@src/lib/utils'
4141
import { themeCompartment } from '@src/editor/plugins/theme'
4242
import { kclAstExtension } from '@src/editor/plugins/ast'
43-
import { fsEffectExtension } from '@src/editor/plugins/fs'
43+
import { localHistoryTarget } from '@src/editor/HistoryView'
4444

4545
export const lineWrappingCompartment = new Compartment()
4646
export const cursorBlinkingCompartment = new Compartment()
@@ -55,14 +55,14 @@ export function baseEditorExtensions() {
5555
kclLspCompartment.of([]),
5656
kclAutocompleteCompartment.of([]),
5757
lineWrappingCompartment.of([]),
58-
fsEffectExtension(),
5958
cursorBlinkingCompartment.of(
6059
drawSelection({
6160
cursorBlinkRate: 1200,
6261
})
6362
),
6463
lineHighlightField,
6564
historyCompartment.of(history()),
65+
localHistoryTarget.of([]),
6666
kclAstExtension(),
6767
closeBrackets(),
6868
codeFolding(),

src/editor/plugins/fs/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { invertedEffects } from '@codemirror/commands'
2-
import type { Extension, TransactionSpec } from '@codemirror/state'
2+
import type { Extension } from '@codemirror/state'
33
import { Annotation, Compartment, StateEffect } from '@codemirror/state'
4+
import type { TransactionSpecNoChanges } from '@src/editor/HistoryView'
45
import type { KclManager } from '@src/lang/KclManager'
56
import type { systemIOMachine } from '@src/machines/systemIO/systemIOMachine'
67
import { SystemIOMachineEvents } from '@src/machines/systemIO/utils'
@@ -13,7 +14,7 @@ export const fsIgnoreAnnotationType = Annotation.define<true>()
1314
type FSEffectProps = { src: string; target: string }
1415
const restoreFile = StateEffect.define<FSEffectProps>()
1516
const archiveFile = StateEffect.define<FSEffectProps>()
16-
const h = <T>(e: StateEffect<T>): TransactionSpec => ({
17+
const h = <T>(e: StateEffect<T>): TransactionSpecNoChanges => ({
1718
effects: e,
1819
// Hopefully, makes initial transactions ignored and undo/redo not ignored.
1920
annotations: [fsIgnoreAnnotationType.of(true)],
@@ -46,12 +47,12 @@ export function buildFSEffectExtension(
4647
}
4748
})
4849

49-
kclManager.editorView.dispatch({
50+
kclManager.globalHistoryView.dispatch({
5051
effects: [fsEffectCompartment.reconfigure(fsWiredListener)],
5152
})
5253
// Teardown
5354
return () => {
54-
kclManager.editorView.dispatch({
55+
kclManager.globalHistoryView.dispatch({
5556
effects: [fsEffectCompartment.reconfigure([])],
5657
})
5758
}

src/lang/KclManager.ts

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ import type {
114114
SourceDelta,
115115
} from '@rust/kcl-lib/bindings/FrontendApi'
116116
import { resetCameraPosition } from '@src/lib/resetCameraPosition'
117+
import {
118+
HistoryView,
119+
type TransactionSpecNoChanges,
120+
} from '@src/editor/HistoryView'
121+
import { fsEffectExtension } from '@src/editor/plugins/fs'
117122

118123
interface ExecuteArgs {
119124
ast?: Node<Program>
@@ -198,7 +203,8 @@ export class KclManager extends EventTarget {
198203

199204
// CORE STATE
200205

201-
private _editorView: EditorView
206+
private readonly _editorView: EditorView
207+
private readonly _globalHistoryView: HistoryView
202208

203209
/**
204210
* The core state in KclManager are the code and the selection.
@@ -253,8 +259,10 @@ export class KclManager extends EventTarget {
253259
undoDepth = signal(0)
254260
redoDepth = signal(0)
255261
undoListenerEffect = EditorView.updateListener.of((vu) => {
256-
this.undoDepth.value = undoDepth(vu.state)
257-
this.redoDepth.value = redoDepth(vu.state)
262+
this.undoDepth.value =
263+
undoDepth(vu.state) || undoDepth(this._globalHistoryView.state)
264+
this.redoDepth.value =
265+
redoDepth(vu.state) || redoDepth(this._globalHistoryView.state)
258266
})
259267
/**
260268
* A client-side representation of the commands that have been sent,
@@ -607,8 +615,9 @@ export class KclManager extends EventTarget {
607615
this._wasmInstancePromise = wasmInstance
608616
this.singletons = singletons
609617

610-
/** Merged code from EditorManager and CodeManager classes */
618+
this._globalHistoryView = new HistoryView([fsEffectExtension()])
611619
this._editorView = this.createEditorView()
620+
this._globalHistoryView.registerLocalHistoryTarget(this._editorView)
612621

613622
if (isDesktop()) {
614623
this.code = ''
@@ -1219,6 +1228,9 @@ export class KclManager extends EventTarget {
12191228
get state() {
12201229
return this.editorState
12211230
}
1231+
get globalHistoryView() {
1232+
return this._globalHistoryView
1233+
}
12221234
setCopilotEnabled(enabled: boolean) {
12231235
this._copilotEnabled = enabled
12241236
}
@@ -1457,31 +1469,19 @@ export class KclManager extends EventTarget {
14571469
],
14581470
})
14591471
}
1472+
addGlobalHistoryEvent(spec: TransactionSpecNoChanges) {
1473+
this._globalHistoryView.limitedDispatch(spec)
1474+
}
14601475
undo() {
1461-
if (this._editorView) {
1462-
undo(this._editorView)
1463-
} else if (this.editorState) {
1464-
const undoPerformed = undo(this) // invokes dispatch which updates this._editorState
1465-
if (undoPerformed) {
1466-
const newState = this.editorState
1467-
// Update the code, this is similar to kcl/index.ts / update, updateDoc,
1468-
// needed to update the code, so sketch segments can update themselves.
1469-
// In the editorView case this happens within the kcl plugin's update method being called during updates.
1470-
this.code = newState.doc.toString()
1471-
void this.executeCode()
1472-
}
1476+
const result = undo(this._editorView)
1477+
if (!result) {
1478+
undo(this._globalHistoryView)
14731479
}
14741480
}
14751481
redo() {
1476-
if (this._editorView) {
1477-
redo(this._editorView)
1478-
} else if (this.editorState) {
1479-
const redoPerformed = redo(this)
1480-
if (redoPerformed) {
1481-
const newState = this.editorState
1482-
this.code = newState.doc.toString()
1483-
void this.executeCode()
1484-
}
1482+
const result = redo(this._editorView)
1483+
if (!result) {
1484+
redo(this._globalHistoryView)
14851485
}
14861486
}
14871487
// Invoked by codeMirror during undo/redo.

0 commit comments

Comments
 (0)