350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
import { create } from 'zustand';
|
|
|
|
export interface WorkspaceFile {
|
|
id: string;
|
|
name: string;
|
|
content: string;
|
|
modified: boolean;
|
|
}
|
|
|
|
const MAIN_ID = 'main-sketch';
|
|
|
|
const DEFAULT_INO_CONTENT = `// Arduino Blink Example
|
|
void setup() {
|
|
pinMode(LED_BUILTIN, OUTPUT);
|
|
}
|
|
|
|
void loop() {
|
|
digitalWrite(LED_BUILTIN, HIGH);
|
|
delay(1000);
|
|
digitalWrite(LED_BUILTIN, LOW);
|
|
delay(1000);
|
|
}`;
|
|
|
|
const DEFAULT_PY_CONTENT = `import RPi.GPIO as GPIO
|
|
import time
|
|
|
|
LED_PIN = 17
|
|
|
|
GPIO.setmode(GPIO.BCM)
|
|
GPIO.setup(LED_PIN, GPIO.OUT)
|
|
|
|
try:
|
|
while True:
|
|
GPIO.output(LED_PIN, GPIO.HIGH)
|
|
time.sleep(1)
|
|
GPIO.output(LED_PIN, GPIO.LOW)
|
|
time.sleep(1)
|
|
except KeyboardInterrupt:
|
|
GPIO.cleanup()
|
|
`;
|
|
|
|
const DEFAULT_FILE: WorkspaceFile = {
|
|
id: MAIN_ID,
|
|
name: 'sketch.ino',
|
|
content: DEFAULT_INO_CONTENT,
|
|
modified: false,
|
|
};
|
|
|
|
/** Default file group for the initial Arduino Uno board */
|
|
const DEFAULT_GROUP_ID = 'group-arduino-uno';
|
|
|
|
interface EditorState {
|
|
files: WorkspaceFile[];
|
|
activeFileId: string;
|
|
openFileIds: string[];
|
|
theme: 'vs-dark' | 'light';
|
|
fontSize: number;
|
|
|
|
// ── File groups (one per board) ──────────────────────────────────────────
|
|
/** Map of groupId → WorkspaceFile[]. Stored as plain object for Zustand. */
|
|
fileGroups: Record<string, WorkspaceFile[]>;
|
|
/** Active group (determines which board's files are shown in the editor). */
|
|
activeGroupId: string;
|
|
/** Active file within the active group */
|
|
activeGroupFileId: Record<string, string>;
|
|
/** Open file IDs within each group */
|
|
openGroupFileIds: Record<string, string[]>;
|
|
|
|
// File operations (operate on active group)
|
|
createFile: (name: string) => string;
|
|
deleteFile: (id: string) => void;
|
|
renameFile: (id: string, newName: string) => void;
|
|
setFileContent: (id: string, content: string) => void;
|
|
markFileSaved: (id: string) => void;
|
|
openFile: (id: string) => void;
|
|
closeFile: (id: string) => void;
|
|
setActiveFile: (id: string) => void;
|
|
/** Load a full set of files (e.g. when loading a saved project) */
|
|
loadFiles: (files: { name: string; content: string }[]) => void;
|
|
|
|
// File group management
|
|
createFileGroup: (groupId: string, initialFiles?: { name: string; content: string }[]) => void;
|
|
deleteFileGroup: (groupId: string) => void;
|
|
setActiveGroup: (groupId: string) => void;
|
|
getGroupFiles: (groupId: string) => WorkspaceFile[];
|
|
updateGroupFile: (groupId: string, fileId: string, content: string) => void;
|
|
|
|
// Settings
|
|
setTheme: (theme: 'vs-dark' | 'light') => void;
|
|
setFontSize: (size: number) => void;
|
|
|
|
// Legacy compat — sets content of the active file
|
|
setCode: (code: string) => void;
|
|
}
|
|
|
|
export const useEditorStore = create<EditorState>((set, get) => ({
|
|
files: [DEFAULT_FILE],
|
|
activeFileId: MAIN_ID,
|
|
openFileIds: [MAIN_ID],
|
|
theme: 'vs-dark',
|
|
fontSize: 14,
|
|
|
|
// File groups — initial state has one group for the default Arduino Uno board
|
|
fileGroups: {
|
|
[DEFAULT_GROUP_ID]: [DEFAULT_FILE],
|
|
},
|
|
activeGroupId: DEFAULT_GROUP_ID,
|
|
activeGroupFileId: { [DEFAULT_GROUP_ID]: MAIN_ID },
|
|
openGroupFileIds: { [DEFAULT_GROUP_ID]: [MAIN_ID] },
|
|
|
|
// ── File operations (legacy API — operate on active group) ──────────────
|
|
|
|
createFile: (name: string) => {
|
|
const id = crypto.randomUUID();
|
|
const newFile: WorkspaceFile = { id, name, content: '', modified: false };
|
|
set((s) => {
|
|
const groupId = s.activeGroupId;
|
|
const groupFiles = [...(s.fileGroups[groupId] ?? []), newFile];
|
|
return {
|
|
// Legacy flat list (mirrors active group)
|
|
files: [...s.files, newFile],
|
|
openFileIds: [...s.openFileIds, id],
|
|
activeFileId: id,
|
|
// Group-aware state
|
|
fileGroups: { ...s.fileGroups, [groupId]: groupFiles },
|
|
openGroupFileIds: { ...s.openGroupFileIds, [groupId]: [...(s.openGroupFileIds[groupId] ?? []), id] },
|
|
activeGroupFileId: { ...s.activeGroupFileId, [groupId]: id },
|
|
};
|
|
});
|
|
return id;
|
|
},
|
|
|
|
deleteFile: (id: string) => {
|
|
set((s) => {
|
|
const groupId = s.activeGroupId;
|
|
const files = s.files.filter((f) => f.id !== id);
|
|
const openFileIds = s.openFileIds.filter((fid) => fid !== id);
|
|
let activeFileId = s.activeFileId;
|
|
if (activeFileId === id) {
|
|
const idx = s.openFileIds.indexOf(id);
|
|
activeFileId =
|
|
openFileIds[idx] ??
|
|
openFileIds[idx - 1] ??
|
|
openFileIds[0] ??
|
|
files[0]?.id ??
|
|
'';
|
|
}
|
|
const groupFiles = (s.fileGroups[groupId] ?? []).filter((f) => f.id !== id);
|
|
const groupOpenIds = (s.openGroupFileIds[groupId] ?? []).filter((fid) => fid !== id);
|
|
return {
|
|
files,
|
|
openFileIds,
|
|
activeFileId,
|
|
fileGroups: { ...s.fileGroups, [groupId]: groupFiles },
|
|
openGroupFileIds: { ...s.openGroupFileIds, [groupId]: groupOpenIds },
|
|
activeGroupFileId: { ...s.activeGroupFileId, [groupId]: activeFileId },
|
|
};
|
|
});
|
|
},
|
|
|
|
renameFile: (id: string, newName: string) => {
|
|
set((s) => {
|
|
const groupId = s.activeGroupId;
|
|
const mapper = (f: WorkspaceFile) =>
|
|
f.id === id ? { ...f, name: newName, modified: true } : f;
|
|
return {
|
|
files: s.files.map(mapper),
|
|
fileGroups: { ...s.fileGroups, [groupId]: (s.fileGroups[groupId] ?? []).map(mapper) },
|
|
};
|
|
});
|
|
},
|
|
|
|
setFileContent: (id: string, content: string) => {
|
|
set((s) => {
|
|
const groupId = s.activeGroupId;
|
|
const mapper = (f: WorkspaceFile) =>
|
|
f.id === id ? { ...f, content, modified: true } : f;
|
|
return {
|
|
files: s.files.map(mapper),
|
|
fileGroups: { ...s.fileGroups, [groupId]: (s.fileGroups[groupId] ?? []).map(mapper) },
|
|
};
|
|
});
|
|
},
|
|
|
|
markFileSaved: (id: string) => {
|
|
set((s) => {
|
|
const groupId = s.activeGroupId;
|
|
const mapper = (f: WorkspaceFile) =>
|
|
f.id === id ? { ...f, modified: false } : f;
|
|
return {
|
|
files: s.files.map(mapper),
|
|
fileGroups: { ...s.fileGroups, [groupId]: (s.fileGroups[groupId] ?? []).map(mapper) },
|
|
};
|
|
});
|
|
},
|
|
|
|
openFile: (id: string) => {
|
|
set((s) => {
|
|
const groupId = s.activeGroupId;
|
|
const groupOpenIds = s.openGroupFileIds[groupId] ?? [];
|
|
return {
|
|
openFileIds: s.openFileIds.includes(id) ? s.openFileIds : [...s.openFileIds, id],
|
|
activeFileId: id,
|
|
openGroupFileIds: {
|
|
...s.openGroupFileIds,
|
|
[groupId]: groupOpenIds.includes(id) ? groupOpenIds : [...groupOpenIds, id],
|
|
},
|
|
activeGroupFileId: { ...s.activeGroupFileId, [groupId]: id },
|
|
};
|
|
});
|
|
},
|
|
|
|
closeFile: (id: string) => {
|
|
set((s) => {
|
|
const groupId = s.activeGroupId;
|
|
const openFileIds = s.openFileIds.filter((fid) => fid !== id);
|
|
let activeFileId = s.activeFileId;
|
|
if (activeFileId === id) {
|
|
const idx = s.openFileIds.indexOf(id);
|
|
activeFileId =
|
|
openFileIds[idx] ?? openFileIds[idx - 1] ?? openFileIds[0] ?? '';
|
|
}
|
|
const groupOpenIds = (s.openGroupFileIds[groupId] ?? []).filter((fid) => fid !== id);
|
|
return {
|
|
openFileIds,
|
|
activeFileId,
|
|
openGroupFileIds: { ...s.openGroupFileIds, [groupId]: groupOpenIds },
|
|
activeGroupFileId: { ...s.activeGroupFileId, [groupId]: activeFileId },
|
|
};
|
|
});
|
|
},
|
|
|
|
setActiveFile: (id: string) => {
|
|
set((s) => {
|
|
const groupId = s.activeGroupId;
|
|
return {
|
|
activeFileId: id,
|
|
activeGroupFileId: { ...s.activeGroupFileId, [groupId]: id },
|
|
};
|
|
});
|
|
},
|
|
|
|
loadFiles: (incoming: { name: string; content: string }[]) => {
|
|
const files: WorkspaceFile[] = incoming.map((f, i) => ({
|
|
id: i === 0 ? MAIN_ID : crypto.randomUUID(),
|
|
name: f.name,
|
|
content: f.content,
|
|
modified: false,
|
|
}));
|
|
const firstId = files[0]?.id ?? MAIN_ID;
|
|
set((s) => {
|
|
const groupId = s.activeGroupId;
|
|
return {
|
|
files,
|
|
activeFileId: firstId,
|
|
openFileIds: [firstId],
|
|
fileGroups: { ...s.fileGroups, [groupId]: files },
|
|
activeGroupFileId: { ...s.activeGroupFileId, [groupId]: firstId },
|
|
openGroupFileIds: { ...s.openGroupFileIds, [groupId]: [firstId] },
|
|
};
|
|
});
|
|
},
|
|
|
|
// ── File group management ─────────────────────────────────────────────────
|
|
|
|
createFileGroup: (groupId: string, initialFiles?: { name: string; content: string }[]) => {
|
|
set((s) => {
|
|
if (s.fileGroups[groupId]) return s; // already exists
|
|
|
|
let files: WorkspaceFile[];
|
|
if (initialFiles && initialFiles.length > 0) {
|
|
files = initialFiles.map((f, i) => ({
|
|
id: i === 0 ? `${groupId}-main` : crypto.randomUUID(),
|
|
name: f.name,
|
|
content: f.content,
|
|
modified: false,
|
|
}));
|
|
} else {
|
|
// Determine default file by group name convention
|
|
const isPi = groupId.includes('raspberry-pi-3');
|
|
const mainId = `${groupId}-main`;
|
|
files = [{
|
|
id: mainId,
|
|
name: isPi ? 'script.py' : 'sketch.ino',
|
|
content: isPi ? DEFAULT_PY_CONTENT : DEFAULT_INO_CONTENT,
|
|
modified: false,
|
|
}];
|
|
}
|
|
|
|
const firstId = files[0]?.id ?? `${groupId}-main`;
|
|
return {
|
|
fileGroups: { ...s.fileGroups, [groupId]: files },
|
|
activeGroupFileId: { ...s.activeGroupFileId, [groupId]: firstId },
|
|
openGroupFileIds: { ...s.openGroupFileIds, [groupId]: [firstId] },
|
|
};
|
|
});
|
|
},
|
|
|
|
deleteFileGroup: (groupId: string) => {
|
|
set((s) => {
|
|
const { [groupId]: _removed, ...rest } = s.fileGroups;
|
|
const { [groupId]: _a, ...restActive } = s.activeGroupFileId;
|
|
const { [groupId]: _o, ...restOpen } = s.openGroupFileIds;
|
|
return {
|
|
fileGroups: rest,
|
|
activeGroupFileId: restActive,
|
|
openGroupFileIds: restOpen,
|
|
};
|
|
});
|
|
},
|
|
|
|
setActiveGroup: (groupId: string) => {
|
|
set((s) => {
|
|
const groupFiles = s.fileGroups[groupId] ?? [];
|
|
const activeFileId = s.activeGroupFileId[groupId] ?? groupFiles[0]?.id ?? '';
|
|
const openFileIds = s.openGroupFileIds[groupId] ?? (groupFiles[0] ? [groupFiles[0].id] : []);
|
|
return {
|
|
activeGroupId: groupId,
|
|
files: groupFiles,
|
|
activeFileId,
|
|
openFileIds,
|
|
};
|
|
});
|
|
},
|
|
|
|
getGroupFiles: (groupId: string) => {
|
|
return get().fileGroups[groupId] ?? [];
|
|
},
|
|
|
|
updateGroupFile: (groupId: string, fileId: string, content: string) => {
|
|
set((s) => {
|
|
const groupFiles = (s.fileGroups[groupId] ?? []).map((f) =>
|
|
f.id === fileId ? { ...f, content, modified: true } : f
|
|
);
|
|
return { fileGroups: { ...s.fileGroups, [groupId]: groupFiles } };
|
|
});
|
|
},
|
|
|
|
// ── Settings ──────────────────────────────────────────────────────────────
|
|
|
|
setTheme: (theme) => set({ theme }),
|
|
setFontSize: (fontSize) => set({ fontSize }),
|
|
|
|
// Legacy: sets content of active file
|
|
setCode: (code: string) => {
|
|
const { activeFileId, setFileContent } = get();
|
|
if (activeFileId) setFileContent(activeFileId, code);
|
|
},
|
|
}));
|