Skip to content

Commit 05b1688

Browse files
feat(store): fix current document and add onDocumentChanged (#74)
1 parent cd4ecc7 commit 05b1688

File tree

5 files changed

+126
-55
lines changed

5 files changed

+126
-55
lines changed

packages/components/react/src/Panels/EditorPanel.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import type { Lesson } from '@tutorialkit/types';
2-
import { useCallback, useEffect, useRef, useState } from 'react';
1+
import { useEffect, useRef } from 'react';
32
import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels';
43
import {
54
CodeMirrorEditor,

packages/components/react/src/Panels/TerminalPanel.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { lazy, Suspense, useEffect, useState } from 'react';
1+
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
22
import { useStore } from '@nanostores/react';
33
import type { TutorialStore } from '@tutorialkit/runtime';
44
import type { TerminalPanelType } from '@tutorialkit/types';
55
import { classNames } from '../utils/classnames.js';
6+
import type { TerminalRef } from '../Terminal/index.js';
67

78
const Terminal = lazy(() => import('../Terminal/index.js'));
89

@@ -19,6 +20,8 @@ const ICON_MAP = new Map<TerminalPanelType, string>([
1920
export function TerminalPanel({ theme, tutorialStore }: TerminalPanelProps) {
2021
const terminalConfig = useStore(tutorialStore.terminalConfig);
2122

23+
const terminalRefs = useRef<Record<number, TerminalRef>>({});
24+
2225
const [domLoaded, setDomLoaded] = useState(false);
2326

2427
// select the terminal tab by default
@@ -32,6 +35,14 @@ export function TerminalPanel({ theme, tutorialStore }: TerminalPanelProps) {
3235
setTabIndex(terminalConfig.activePanel);
3336
}, [terminalConfig]);
3437

38+
useEffect(() => {
39+
return tutorialStore.themeRef.subscribe(() => {
40+
for (const ref of Object.values(terminalRefs.current)) {
41+
ref.reloadStyles();
42+
}
43+
});
44+
}, []);
45+
3546
return (
3647
<div className="panel-container bg-tk-elements-app-backgroundColor">
3748
<div className="panel-tabs-header overflow-x-hidden">
@@ -85,6 +96,7 @@ export function TerminalPanel({ theme, tutorialStore }: TerminalPanelProps) {
8596
className={tabIndex !== index ? 'hidden' : ''}
8697
theme={theme}
8798
readonly={type === 'output'}
99+
ref={(ref) => (terminalRefs.current[index] = ref!)}
88100
onTerminalReady={(terminal) => {
89101
tutorialStore.attachTerminal(id, terminal);
90102
}}
Lines changed: 55 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { FitAddon } from '@xterm/addon-fit';
22
import { WebLinksAddon } from '@xterm/addon-web-links';
3-
import { Terminal as XTerm, type ITheme } from '@xterm/xterm';
3+
import { Terminal as XTerm } from '@xterm/xterm';
44
import '@xterm/xterm/css/xterm.css';
5-
import { useEffect, useRef } from 'react';
5+
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
66
import { getTerminalTheme } from './theme.js';
77

8+
export interface TerminalRef {
9+
reloadStyles: () => void;
10+
}
11+
812
export interface Props {
913
theme: 'dark' | 'light';
1014
className?: string;
@@ -13,66 +17,69 @@ export interface Props {
1317
onTerminalResize?: (cols: number, rows: number) => void;
1418
}
1519

16-
export function Terminal({ theme, className, readonly = true, onTerminalReady, onTerminalResize }: Props) {
17-
const divRef = useRef<HTMLDivElement>(null);
18-
const terminalRef = useRef<XTerm>();
20+
const Terminal = forwardRef<TerminalRef, Props>(
21+
({ theme, className, readonly = true, onTerminalReady, onTerminalResize }, ref) => {
22+
const divRef = useRef<HTMLDivElement>(null);
23+
const terminalRef = useRef<XTerm>();
1924

20-
useEffect(() => {
21-
if (!divRef.current) {
22-
console.error('Terminal reference undefined');
23-
return;
24-
}
25+
useEffect(() => {
26+
const element = divRef.current!;
2527

26-
const element = divRef.current;
28+
const fitAddon = new FitAddon();
29+
const webLinksAddon = new WebLinksAddon();
2730

28-
const fitAddon = new FitAddon();
29-
const webLinksAddon = new WebLinksAddon();
31+
const terminal = new XTerm({
32+
cursorBlink: true,
33+
convertEol: true,
34+
disableStdin: readonly,
35+
theme: getTerminalTheme(readonly ? { cursor: '#00000000' } : {}),
36+
fontSize: 13,
37+
fontFamily: 'Menlo, courier-new, courier, monospace',
38+
});
3039

31-
const terminal = new XTerm({
32-
cursorBlink: true,
33-
convertEol: true,
34-
disableStdin: readonly,
35-
theme: getTerminalTheme(readonly ? { cursor: '#00000000' } : {}),
36-
fontSize: 13,
37-
fontFamily: 'Menlo, courier-new, courier, monospace',
38-
});
40+
terminalRef.current = terminal;
3941

40-
terminalRef.current = terminal;
42+
terminal.loadAddon(fitAddon);
43+
terminal.loadAddon(webLinksAddon);
44+
terminal.open(element);
4145

42-
terminal.loadAddon(fitAddon);
43-
terminal.loadAddon(webLinksAddon);
44-
terminal.open(element);
46+
fitAddon.fit();
4547

46-
fitAddon.fit();
48+
const resizeObserver = new ResizeObserver(() => {
49+
fitAddon.fit();
50+
onTerminalResize?.(terminal.cols, terminal.rows);
51+
});
4752

48-
const resizeObserver = new ResizeObserver(() => {
49-
fitAddon.fit();
50-
onTerminalResize?.(terminal.cols, terminal.rows);
51-
});
53+
resizeObserver.observe(element);
5254

53-
resizeObserver.observe(element);
55+
onTerminalReady?.(terminal);
5456

55-
onTerminalReady?.(terminal);
57+
return () => {
58+
resizeObserver.disconnect();
59+
terminal.dispose();
60+
};
61+
}, []);
5662

57-
return () => {
58-
resizeObserver.disconnect();
59-
terminal.dispose();
60-
};
61-
}, []);
63+
useEffect(() => {
64+
const terminal = terminalRef.current!;
6265

63-
useEffect(() => {
64-
if (!terminalRef.current) {
65-
return;
66-
}
66+
// we render a transparent cursor in case the terminal is readonly
67+
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
68+
terminal.options.disableStdin = readonly;
69+
}, [theme, readonly]);
6770

68-
const terminal = terminalRef.current;
71+
useImperativeHandle(ref, () => {
72+
return {
73+
reloadStyles: () => {
74+
const terminal = terminalRef.current!;
6975

70-
// we render a transparent cursor in case the terminal is readonly
71-
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
72-
terminal.options.disableStdin = readonly;
73-
}, [theme, readonly]);
76+
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
77+
},
78+
};
79+
}, []);
7480

75-
return <div className={`h-full ${className}`} ref={divRef} />;
76-
}
81+
return <div className={`h-full ${className}`} ref={divRef} />;
82+
},
83+
);
7784

7885
export default Terminal;

packages/runtime/src/store/editor.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { FilesRefList, Files } from '@tutorialkit/types';
2-
import { atom, computed } from 'nanostores';
2+
import { atom, map, computed } from 'nanostores';
33

44
export interface EditorDocument {
55
value: string | Uint8Array;
@@ -17,7 +17,7 @@ export type EditorDocuments = Record<string, EditorDocument>;
1717

1818
export class EditorStore {
1919
selectedFile = atom<string | undefined>();
20-
documents = atom<EditorDocuments>({});
20+
documents = map<EditorDocuments>({});
2121

2222
currentDocument = computed([this.documents, this.selectedFile], (documents, selectedFile) => {
2323
if (!selectedFile) {
@@ -76,7 +76,10 @@ export class EditorStore {
7676
return;
7777
}
7878

79-
documentState.scroll = position;
79+
this.documents.setKey(filePath, {
80+
...documentState,
81+
scroll: position,
82+
});
8083
}
8184

8285
updateFile(filePath: string, content: string): boolean {
@@ -90,9 +93,46 @@ export class EditorStore {
9093
const contentChanged = currentContent !== content;
9194

9295
if (contentChanged) {
93-
documentState.value = content;
96+
this.documents.setKey(filePath, {
97+
...documentState,
98+
value: content,
99+
});
94100
}
95101

96102
return contentChanged;
97103
}
104+
105+
onDocumentChanged(filePath: string, callback: (document: Readonly<EditorDocument>) => void) {
106+
const unsubscribeFromCurrentDocument = this.currentDocument.subscribe((document) => {
107+
if (document?.filePath === filePath) {
108+
callback(document);
109+
}
110+
});
111+
112+
const unsubscribeFromDocuments = this.documents.subscribe((documents) => {
113+
const document = documents[filePath];
114+
115+
/**
116+
* We grab the document from the store, but only call the callback if it is not loading anymore which means
117+
* the content is loaded.
118+
*/
119+
if (document && !document.loading) {
120+
/**
121+
* call this in a `queueMicrotask` because the subscribe callback is called synchronoulsy, which causes
122+
* the `unsubscribeFromDocuments` to not exist yet.
123+
*/
124+
queueMicrotask(() => {
125+
callback(document);
126+
127+
unsubscribeFromDocuments();
128+
});
129+
}
130+
});
131+
132+
return () => {
133+
unsubscribeFromDocuments();
134+
135+
unsubscribeFromCurrentDocument();
136+
};
137+
}
98138
}

packages/runtime/src/store/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export class TutorialStore {
3030
private _lessonTask: Task<unknown> | undefined;
3131
private _lesson: Lesson | undefined;
3232
private _ref: number = 1;
33+
private _themeRef = atom(1);
3334

3435
private _lessonFiles: Files | undefined;
3536
private _lessonSolution: Files | undefined;
@@ -203,6 +204,10 @@ export class TutorialStore {
203204
return this._ref;
204205
}
205206

207+
get themeRef(): ReadableAtom<unknown> {
208+
return this._themeRef;
209+
}
210+
206211
/**
207212
* Steps that the runner is or will be executing.
208213
*/
@@ -318,4 +323,12 @@ export class TutorialStore {
318323
this._runner.onTerminalResize(cols, rows);
319324
}
320325
}
326+
327+
onDocumentChanged(filePath: string, callback: (document: Readonly<EditorDocument>) => void) {
328+
return this._editorStore.onDocumentChanged(filePath, callback);
329+
}
330+
331+
refreshStyles() {
332+
this._themeRef.set(this._themeRef.get() + 1);
333+
}
321334
}

0 commit comments

Comments
 (0)