Skip to content

Commit bf3d186

Browse files
authored
Maintain editor state in browser (#33)
1 parent 86cb9d8 commit bf3d186

File tree

6 files changed

+144
-25
lines changed

6 files changed

+144
-25
lines changed

src/App.svelte

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,44 @@
33
import Editor from '$lib/components/Editor.svelte';
44
import Output from '$lib/components/Output.svelte';
55
import BytecodeView from '$lib/components/BytecodeView.svelte';
6-
import { showBytecode } from '$lib/stores/settings';
6+
import { settings, showBytecode } from '$lib/stores/settings';
7+
import { files, activeFile } from '$lib/stores/playground';
78
import { isEmbed, embedTheme } from '$lib/stores/embed';
89
import { initTheme, setTheme } from '$lib/utils/theme';
910
import { loadLuauWasm } from '$lib/luau/wasm';
11+
import { parseStateFromHash } from '$lib/utils/decode';
12+
import { derived } from 'svelte/store';
13+
import { onMount } from 'svelte';
1014
1115
let mounted = $state(false);
1216
17+
function clearUrlHash(): void {
18+
if (!window.location.hash) return;
19+
const url = new URL(window.location.href);
20+
url.hash = '';
21+
window.history.replaceState(null, '', url.toString());
22+
}
23+
24+
onMount(() => {
25+
if (parseStateFromHash(window.location.hash) === null) return;
26+
27+
const stateChanges = derived([files, activeFile, settings, showBytecode], (values) => values);
28+
let isFirstEmission = true;
29+
let unsubscribe: () => void = () => {};
30+
31+
unsubscribe = stateChanges.subscribe(() => {
32+
if (isFirstEmission) {
33+
isFirstEmission = false;
34+
return;
35+
}
36+
37+
clearUrlHash();
38+
unsubscribe();
39+
});
40+
41+
return unsubscribe;
42+
});
43+
1344
// Initialize on mount
1445
$effect(() => {
1546
if (!mounted) {

src/app.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ body {
222222
opacity: 0.12;
223223
}
224224

225+
.cm-lineNumbers .cm-gutterElement {
226+
padding-left: 12px !important;
227+
}
228+
225229
/* CodeMirror lint gutter - custom minimal markers using SVG */
226230
.cm-gutterElement .cm-lint-marker,
227231
.cm-lint-marker {

src/lib/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ export const defaultSettings: PlaygroundSettings = {
1414
// Key for localStorage persistence of settings
1515
export const STORAGE_KEY = 'luau-playground-settings';
1616

17+
// Key for localStorage persistence of editor/files state
18+
export const PLAYGROUND_STORAGE_KEY = 'luau-playground-state';
19+
20+
// Key for localStorage persistence of small UI state (panels, etc)
21+
export const UI_STORAGE_KEY = 'luau-playground-ui';
22+
1723
// Filename used when working with a single default file
1824
export const DEFAULT_FILENAME = 'main.luau';
1925

src/lib/editor/setup.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,6 @@ function createExtensions(onChange: (content: string) => void): Extension[] {
9696
'.cm-content': {
9797
padding: '12px 0',
9898
},
99-
'.cm-gutters': {
100-
paddingLeft: '8px',
101-
},
10299
}),
103100

104101
// Update listener

src/lib/stores/playground.ts

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { writable, get } from 'svelte/store';
22
import { parseStateFromHash } from '$lib/utils/decode';
33
import type { OutputLine } from '$lib/utils/output';
4+
import { DEFAULT_FILENAME, PLAYGROUND_STORAGE_KEY } from '$lib/constants';
45

56
// Re-export for backwards compatibility
67
export type { OutputLine };
@@ -12,6 +13,11 @@ export interface PlaygroundState {
1213
isRunning: boolean;
1314
}
1415

16+
interface PersistedPlaygroundState {
17+
files: Record<string, string>;
18+
activeFile: string;
19+
}
20+
1521
// Default initial code
1622
const defaultCode = `-- Welcome to the Luau Playground!
1723
-- Write your code here and click Run
@@ -31,21 +37,40 @@ end
3137
print("Sum:", sum)
3238
`;
3339

40+
function loadFromStorage(): { files: Record<string, string>; activeFile: string } | null {
41+
if (typeof window === 'undefined') return null;
42+
43+
try {
44+
const stored = localStorage.getItem(PLAYGROUND_STORAGE_KEY);
45+
if (!stored) return null;
46+
47+
const parsed = JSON.parse(stored) as Partial<PersistedPlaygroundState>;
48+
if (!parsed.files || typeof parsed.files !== 'object') return null;
49+
if (!parsed.activeFile || typeof parsed.activeFile !== 'string') return null;
50+
51+
const fileNames = Object.keys(parsed.files);
52+
if (fileNames.length === 0) return null;
53+
if (!(parsed.activeFile in parsed.files)) return null;
54+
return { files: parsed.files as Record<string, string>, activeFile: parsed.activeFile };
55+
} catch {
56+
return null;
57+
}
58+
}
59+
3460
// Load initial state from URL if available
3561
function getInitialState(): { files: Record<string, string>; activeFile: string } {
36-
const defaultState = { files: { 'main.luau': defaultCode }, activeFile: 'main.luau' };
62+
const defaultState = { files: { [DEFAULT_FILENAME]: defaultCode }, activeFile: DEFAULT_FILENAME };
3763

3864
if (typeof window === 'undefined') {
3965
return defaultState;
4066
}
4167

4268
const state = parseStateFromHash(window.location.hash);
43-
if (!state || Object.keys(state.files).length === 0) {
44-
return defaultState;
69+
if (state && Object.keys(state.files).length > 0 && state.active in state.files) {
70+
return { files: state.files, activeFile: state.active };
4571
}
46-
47-
const active = state.active in state.files ? state.active : Object.keys(state.files)[0];
48-
return { files: state.files, activeFile: active };
72+
73+
return loadFromStorage() ?? defaultState;
4974
}
5075

5176
const initialState = getInitialState();
@@ -62,6 +87,45 @@ export function setExecutionTime(ms: number | null) {
6287
executionTime.set(ms);
6388
}
6489

90+
function debounce(fn: () => void, ms: number): () => void {
91+
let t: ReturnType<typeof setTimeout> | null = null;
92+
return () => {
93+
if (t) clearTimeout(t);
94+
t = setTimeout(() => {
95+
t = null;
96+
fn();
97+
}, ms);
98+
};
99+
}
100+
101+
function saveToStorage(): void {
102+
if (typeof window === 'undefined') return;
103+
104+
try {
105+
const f = get(files);
106+
const a = get(activeFile);
107+
const fileNames = Object.keys(f);
108+
if (fileNames.length === 0) return;
109+
if (!(a in f)) return;
110+
111+
const state: PersistedPlaygroundState = {
112+
files: f,
113+
activeFile: a,
114+
};
115+
116+
localStorage.setItem(PLAYGROUND_STORAGE_KEY, JSON.stringify(state));
117+
} catch {
118+
// Ignore storage errors
119+
}
120+
}
121+
122+
// Persist editor state (files + active file) between reloads
123+
if (typeof window !== 'undefined') {
124+
const scheduleSave = debounce(saveToStorage, 250);
125+
files.subscribe(() => scheduleSave());
126+
activeFile.subscribe(() => scheduleSave());
127+
}
128+
65129
// Actions
66130
export function addFile(name: string, content: string = '') {
67131
files.update((f) => ({ ...f, [name]: content }));

src/lib/stores/settings.ts

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { writable, get } from 'svelte/store';
22
import { parseStateFromHash } from '$lib/utils/decode';
3-
import { STORAGE_KEY, defaultSettings } from '$lib/constants';
3+
import { STORAGE_KEY, UI_STORAGE_KEY, defaultSettings } from '$lib/constants';
44

55
export type LuauMode = "strict" | "nonstrict" | "nocheck";
66
export type SolverMode = "new" | "old";
@@ -43,22 +43,34 @@ function loadSettingsFromStorage(): PlaygroundSettings {
4343

4444
try {
4545
const stored = localStorage.getItem(STORAGE_KEY);
46-
if (stored) {
47-
const parsed = JSON.parse(stored) as Partial<PlaygroundSettings>;
48-
return {
49-
mode: parsed.mode ?? defaultSettings.mode,
50-
solver: parsed.solver ?? defaultSettings.solver,
51-
optimizationLevel: parsed.optimizationLevel ?? defaultSettings.optimizationLevel,
52-
debugLevel: parsed.debugLevel ?? defaultSettings.debugLevel,
53-
outputFormat: parsed.outputFormat ?? defaultSettings.outputFormat,
54-
compilerRemarks: parsed.compilerRemarks ?? defaultSettings.compilerRemarks,
55-
};
56-
}
46+
if (!stored) return { ...defaultSettings };
47+
return mergeSettings(JSON.parse(stored) as Partial<PlaygroundSettings>);
5748
} catch {
5849
// Ignore parse errors
50+
return { ...defaultSettings };
51+
}
52+
}
53+
54+
function loadShowBytecodeFromStorage(): boolean {
55+
if (typeof window === 'undefined') {
56+
return false;
57+
}
58+
59+
try {
60+
return localStorage.getItem(UI_STORAGE_KEY) === '1';
61+
} catch {
62+
return false;
63+
}
64+
}
65+
66+
function saveShowBytecodeToStorage(value: boolean): void {
67+
if (typeof window === 'undefined') return;
68+
69+
try {
70+
localStorage.setItem(UI_STORAGE_KEY, value ? '1' : '0');
71+
} catch {
72+
// Ignore storage errors
5973
}
60-
61-
return { ...defaultSettings };
6274
}
6375

6476
function mergeSettings(partial: Partial<PlaygroundSettings>): PlaygroundSettings {
@@ -83,7 +95,7 @@ function loadSettings(): { settings: PlaygroundSettings; showBytecode: boolean }
8395
? mergeSettings(settingsFromUrl)
8496
: loadSettingsFromStorage();
8597

86-
const showBytecode = showFromUrl ?? false;
98+
const showBytecode = showFromUrl ?? loadShowBytecodeFromStorage();
8799

88100
return {
89101
settings,
@@ -113,6 +125,11 @@ settings.subscribe((value) => {
113125
saveSettings(value);
114126
});
115127

128+
// Auto-save UI state when it changes
129+
showBytecode.subscribe((value) => {
130+
saveShowBytecodeToStorage(value);
131+
});
132+
116133
export function setMode(mode: LuauMode): void {
117134
settings.update((s) => ({ ...s, mode }));
118135
}

0 commit comments

Comments
 (0)