Skip to content

Commit 9fa72ad

Browse files
committed
Add HistoryView class for global history effect tracking
1 parent d4fd6f7 commit 9fa72ad

File tree

5 files changed

+242
-15
lines changed

5 files changed

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

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.globalOnlyDispatch({
5051
effects: [fsEffectCompartment.reconfigure(fsWiredListener)],
5152
})
5253
// Teardown
5354
return () => {
54-
kclManager.editorView.dispatch({
55+
kclManager.globalHistoryView.globalOnlyDispatch({
5556
effects: [fsEffectCompartment.reconfigure([])],
5657
})
5758
}

src/lang/KclManager.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,12 @@ import {
6060
setSelectionFilter,
6161
setSelectionFilterToDefault,
6262
} from '@src/lib/selectionFilterUtils'
63-
import { history, redo, redoDepth, undo, undoDepth } from '@codemirror/commands'
63+
import {
64+
history,
65+
historyField,
66+
redoDepth,
67+
undoDepth,
68+
} from '@codemirror/commands'
6469
import { syntaxTree } from '@codemirror/language'
6570
import type { Diagnostic } from '@codemirror/lint'
6671
import { forEachDiagnostic, setDiagnosticsEffect } from '@codemirror/lint'
@@ -114,6 +119,11 @@ import type {
114119
SourceDelta,
115120
} from '@rust/kcl-lib/bindings/FrontendApi'
116121
import { resetCameraPosition } from '@src/lib/resetCameraPosition'
122+
import {
123+
HistoryView,
124+
type TransactionSpecNoChanges,
125+
} from '@src/editor/HistoryView'
126+
import { fsEffectExtension } from '@src/editor/plugins/fs'
117127

118128
interface ExecuteArgs {
119129
ast?: Node<Program>
@@ -210,7 +220,8 @@ export class KclManager extends EventTarget {
210220

211221
// CORE STATE
212222

213-
private _editorView: EditorView
223+
private readonly _editorView: EditorView
224+
private readonly _globalHistoryView: HistoryView
214225

215226
/**
216227
* The core state in KclManager are the code and the selection.
@@ -262,8 +273,13 @@ export class KclManager extends EventTarget {
262273
undoDepth = signal(0)
263274
redoDepth = signal(0)
264275
undoListenerEffect = EditorView.updateListener.of((vu) => {
265-
this.undoDepth.value = undoDepth(vu.state)
266-
this.redoDepth.value = redoDepth(vu.state)
276+
if (undoDepth(vu.state) === 1 && this.undoDepth.value === 0) {
277+
debugger
278+
}
279+
this.undoDepth.value =
280+
undoDepth(vu.state) || undoDepth(this._globalHistoryView.state)
281+
this.redoDepth.value =
282+
redoDepth(vu.state) || redoDepth(this._globalHistoryView.state)
267283
})
268284
/**
269285
* A client-side representation of the commands that have been sent,
@@ -644,8 +660,9 @@ export class KclManager extends EventTarget {
644660
this._wasmInstancePromise = wasmInstance
645661
this.singletons = singletons
646662

647-
/** Merged code from EditorManager and CodeManager classes */
663+
this._globalHistoryView = new HistoryView([fsEffectExtension()])
648664
this._editorView = this.createEditorView()
665+
this._globalHistoryView.registerLocalHistoryTarget(this._editorView)
649666

650667
if (isDesktop()) {
651668
this._code.value = ''
@@ -1253,6 +1270,9 @@ export class KclManager extends EventTarget {
12531270
get state() {
12541271
return this.editorState
12551272
}
1273+
get globalHistoryView() {
1274+
return this._globalHistoryView
1275+
}
12561276
setCopilotEnabled(enabled: boolean) {
12571277
this._copilotEnabled = enabled
12581278
}
@@ -1491,11 +1511,14 @@ export class KclManager extends EventTarget {
14911511
],
14921512
})
14931513
}
1514+
addGlobalHistoryEvent(spec: TransactionSpecNoChanges) {
1515+
this._globalHistoryView.limitedDispatch(spec)
1516+
}
14941517
undo() {
1495-
undo(this._editorView)
1518+
this._globalHistoryView.undo(this._editorView)
14961519
}
14971520
redo() {
1498-
redo(this._editorView)
1521+
this._globalHistoryView.redo(this._editorView)
14991522
}
15001523
clearLocalHistory() {
15011524
// Clear history

0 commit comments

Comments
 (0)