建立前端与 Tauri 桌面端的首个版本提交,包含核心编辑器、项目文件读写、测试与构建配置。 补充 Git 忽略规则和换行规范,排除依赖、构建产物、本地运行日志与临时验证文件,方便在其他电脑继续开发。
1004 lines
28 KiB
TypeScript
1004 lines
28 KiB
TypeScript
import { create } from "zustand"
|
|
import { demoSnapshot } from "../demo/demoProject"
|
|
import { buildReferenceIndex, renderTemplate } from "../../core"
|
|
import type {
|
|
BlockNode,
|
|
DataSetDocument,
|
|
FragmentDocument,
|
|
InlineNode,
|
|
ProjectDiagnostic,
|
|
ProjectResourceDirectory,
|
|
ProjectResourceFile,
|
|
ProjectResourceNode,
|
|
ProjectSnapshot,
|
|
RenderResult,
|
|
TemplateDocument
|
|
} from "../../core/types"
|
|
import { createBlockId } from "../../utils/id"
|
|
|
|
export type RightPanelTab = "properties" | "data" | "preview" | "debug" | "references"
|
|
export type SaveStatus = "saved" | "dirty" | "saving" | "error"
|
|
export type DataEditorStatus = "valid" | "invalid"
|
|
export type ProjectOpenStatus = "demo" | "opening" | "open" | "error"
|
|
export type OpenDocument = TemplateDocument | FragmentDocument
|
|
|
|
export type ProjectPersistence = {
|
|
isAvailable: () => boolean
|
|
openProjectFromDisk: () => Promise<ProjectSnapshot | null>
|
|
saveProjectChanges: (snapshot: ProjectSnapshot, dirtyDocumentIds: string[]) => Promise<ProjectSnapshot>
|
|
}
|
|
|
|
export type WorkbenchState = {
|
|
snapshot: ProjectSnapshot
|
|
selectedDocumentId: string
|
|
selectedResourcePath: string
|
|
currentDatasetId: string
|
|
documentRevision: number
|
|
dataEditorText: string
|
|
dataEditorStatus: DataEditorStatus
|
|
dataEditorError: string | null
|
|
selectedBlockId: string | null
|
|
rightPanelTab: RightPanelTab
|
|
saveStatus: SaveStatus
|
|
projectOpenStatus: ProjectOpenStatus
|
|
dirtyDocumentIds: string[]
|
|
lastSaveError: string | null
|
|
isProjectPersistenceAvailable: boolean
|
|
openProjectFromDisk: () => Promise<void>
|
|
saveProjectChanges: () => Promise<void>
|
|
createFragment: () => void
|
|
selectResource: (resource: ProjectResourceFile) => void
|
|
openDocumentById: (documentId: string) => void
|
|
selectDocument: (documentId: string, resourcePath?: string) => void
|
|
setCurrentDataset: (datasetId: string) => void
|
|
setDataEditorText: (text: string) => void
|
|
updateCurrentDatasetData: (data: Record<string, unknown>) => void
|
|
setSelectedBlockId: (blockId: string | null) => void
|
|
setRightPanelTab: (tab: RightPanelTab) => void
|
|
updateOpenDocumentChildren: (children: BlockNode[]) => void
|
|
expandSelectedFragmentReference: () => void
|
|
resetDemo: () => void
|
|
}
|
|
|
|
export const defaultDocumentId = demoSnapshot.project.entryTemplateId ?? Object.keys(demoSnapshot.templates)[0]!
|
|
export const defaultDatasetId = Object.keys(demoSnapshot.datasets)[0]!
|
|
export const defaultResourcePath = findResourcePathByDocumentId(
|
|
demoSnapshot.resourceTree.children,
|
|
defaultDocumentId
|
|
) ?? "templates/main.json"
|
|
const fallbackDataset: DataSetDocument = {
|
|
kind: "dataset",
|
|
id: "dataset_runtime_empty",
|
|
name: "empty",
|
|
data: {}
|
|
}
|
|
|
|
let projectPersistenceOverride: ProjectPersistence | null = null
|
|
|
|
export function setProjectPersistenceForTests(persistence: ProjectPersistence | null): void {
|
|
projectPersistenceOverride = persistence
|
|
useWorkbenchStore.setState({
|
|
isProjectPersistenceAvailable: getProjectPersistence().isAvailable()
|
|
})
|
|
}
|
|
|
|
export const useWorkbenchStore = create<WorkbenchState>((set) => ({
|
|
snapshot: demoSnapshot,
|
|
selectedDocumentId: defaultDocumentId,
|
|
selectedResourcePath: defaultResourcePath,
|
|
currentDatasetId: defaultDatasetId,
|
|
documentRevision: 0,
|
|
dataEditorText: formatJson(demoSnapshot.datasets[defaultDatasetId]?.data ?? {}),
|
|
dataEditorStatus: "valid",
|
|
dataEditorError: null,
|
|
selectedBlockId: null,
|
|
rightPanelTab: "properties",
|
|
saveStatus: "saved",
|
|
projectOpenStatus: "demo",
|
|
dirtyDocumentIds: [],
|
|
lastSaveError: null,
|
|
isProjectPersistenceAvailable: getProjectPersistence().isAvailable(),
|
|
|
|
async openProjectFromDisk() {
|
|
const persistence = getProjectPersistence()
|
|
if (!persistence.isAvailable()) {
|
|
set({
|
|
projectOpenStatus: "error",
|
|
saveStatus: "error",
|
|
lastSaveError: "Desktop file access is only available in the Tauri app."
|
|
})
|
|
return
|
|
}
|
|
|
|
set({ projectOpenStatus: "opening", lastSaveError: null })
|
|
|
|
try {
|
|
const snapshot = await persistence.openProjectFromDisk()
|
|
if (snapshot === null) {
|
|
set((state) => ({
|
|
projectOpenStatus: state.snapshot === demoSnapshot ? "demo" : "open"
|
|
}))
|
|
return
|
|
}
|
|
|
|
set((state) => ({
|
|
...state,
|
|
...stateForSnapshot(snapshot, state),
|
|
projectOpenStatus: "open",
|
|
dirtyDocumentIds: [],
|
|
lastSaveError: null,
|
|
saveStatus: "saved",
|
|
documentRevision: state.documentRevision + 1
|
|
}))
|
|
} catch (error) {
|
|
set({
|
|
projectOpenStatus: "error",
|
|
saveStatus: "error",
|
|
lastSaveError: formatError(error)
|
|
})
|
|
}
|
|
},
|
|
|
|
async saveProjectChanges() {
|
|
const persistence = getProjectPersistence()
|
|
if (!persistence.isAvailable()) {
|
|
set({
|
|
saveStatus: "error",
|
|
lastSaveError: "Desktop file access is only available in the Tauri app."
|
|
})
|
|
return
|
|
}
|
|
|
|
set((state) => {
|
|
if (state.dirtyDocumentIds.length === 0) {
|
|
return state
|
|
}
|
|
|
|
return {
|
|
saveStatus: "saving",
|
|
lastSaveError: null
|
|
}
|
|
})
|
|
|
|
const state = useWorkbenchStore.getState()
|
|
if (state.dirtyDocumentIds.length === 0) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const snapshot = await persistence.saveProjectChanges(state.snapshot, state.dirtyDocumentIds)
|
|
set((current) => ({
|
|
...current,
|
|
...stateForSnapshot(snapshot, current),
|
|
projectOpenStatus: "open",
|
|
dirtyDocumentIds: [],
|
|
lastSaveError: null,
|
|
saveStatus: "saved",
|
|
documentRevision: current.documentRevision + 1
|
|
}))
|
|
} catch (error) {
|
|
set({
|
|
saveStatus: "error",
|
|
lastSaveError: formatError(error)
|
|
})
|
|
}
|
|
},
|
|
|
|
createFragment() {
|
|
set((state) => {
|
|
const next = nextFragmentIdentity(state.snapshot)
|
|
const now = new Date().toISOString()
|
|
const fragment: FragmentDocument = {
|
|
kind: "fragment",
|
|
id: next.id,
|
|
name: next.name,
|
|
filePath: next.path,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
children: [
|
|
{
|
|
id: createBlockId("paragraph"),
|
|
type: "paragraph",
|
|
inlines: []
|
|
}
|
|
]
|
|
}
|
|
const fragments = {
|
|
...state.snapshot.fragments,
|
|
[fragment.id]: fragment
|
|
}
|
|
const referenceIndex = buildReferenceIndex([
|
|
...Object.values(state.snapshot.templates),
|
|
...Object.values(fragments)
|
|
])
|
|
const diagnostics = rebuildReferenceDiagnostics(
|
|
state.snapshot.diagnostics,
|
|
referenceIndex.missingFragments
|
|
)
|
|
const resourceFile: ProjectResourceFile = {
|
|
type: "file",
|
|
name: next.fileName,
|
|
path: next.path,
|
|
relativePath: next.relativePath,
|
|
resourceKind: "fragment",
|
|
documentId: fragment.id
|
|
}
|
|
|
|
return {
|
|
snapshot: {
|
|
...state.snapshot,
|
|
fragments,
|
|
resourceTree: {
|
|
...state.snapshot.resourceTree,
|
|
children: addResourceToDirectory(
|
|
state.snapshot.resourceTree.children,
|
|
"fragments",
|
|
resourceFile,
|
|
state.snapshot.project.paths.fragmentsDir
|
|
)
|
|
},
|
|
referenceIndex,
|
|
diagnostics
|
|
},
|
|
selectedDocumentId: fragment.id,
|
|
selectedResourcePath: next.relativePath,
|
|
selectedBlockId: null,
|
|
rightPanelTab: "properties",
|
|
documentRevision: state.documentRevision + 1,
|
|
dirtyDocumentIds: addDirtyDocumentId(state.dirtyDocumentIds, fragment.id),
|
|
lastSaveError: null,
|
|
saveStatus: "dirty"
|
|
}
|
|
})
|
|
},
|
|
|
|
selectResource(resource) {
|
|
set((state) => {
|
|
const nextState: Partial<WorkbenchState> = {
|
|
selectedResourcePath: resource.relativePath
|
|
}
|
|
|
|
if (
|
|
resource.documentId !== undefined &&
|
|
(resource.resourceKind === "template" || resource.resourceKind === "fragment")
|
|
) {
|
|
nextState.selectedDocumentId = resource.documentId
|
|
}
|
|
|
|
if (resource.documentId !== undefined && resource.resourceKind === "dataset") {
|
|
nextState.currentDatasetId = resource.documentId
|
|
nextState.dataEditorText = datasetDataTextFor(state.snapshot, resource.documentId)
|
|
nextState.dataEditorStatus = "valid"
|
|
nextState.dataEditorError = null
|
|
nextState.rightPanelTab = "data"
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
...nextState
|
|
}
|
|
})
|
|
},
|
|
|
|
openDocumentById(documentId) {
|
|
set((state) => {
|
|
const document = state.snapshot.templates[documentId] ?? state.snapshot.fragments[documentId]
|
|
if (document === undefined) {
|
|
return state
|
|
}
|
|
|
|
return {
|
|
selectedDocumentId: documentId,
|
|
selectedResourcePath:
|
|
findResourcePathByDocumentId(state.snapshot.resourceTree.children, documentId) ??
|
|
state.selectedResourcePath,
|
|
selectedBlockId: null,
|
|
rightPanelTab: document.kind === "fragment" ? "properties" : state.rightPanelTab
|
|
}
|
|
})
|
|
},
|
|
|
|
selectDocument(documentId, resourcePath) {
|
|
set((state) => ({
|
|
selectedDocumentId: documentId,
|
|
selectedResourcePath: resourcePath ?? state.selectedResourcePath
|
|
}))
|
|
},
|
|
|
|
setCurrentDataset(datasetId) {
|
|
set((state) => ({
|
|
currentDatasetId: datasetId,
|
|
dataEditorText: datasetDataTextFor(state.snapshot, datasetId),
|
|
dataEditorStatus: "valid",
|
|
dataEditorError: null
|
|
}))
|
|
},
|
|
|
|
setDataEditorText(text) {
|
|
const parsed = parseDatasetJson(text)
|
|
if (!parsed.ok) {
|
|
set({
|
|
dataEditorText: text,
|
|
dataEditorStatus: "invalid",
|
|
dataEditorError: parsed.error
|
|
})
|
|
return
|
|
}
|
|
|
|
set((state) => applyDatasetDataUpdate(state, parsed.data, text))
|
|
},
|
|
|
|
updateCurrentDatasetData(data) {
|
|
set((state) => applyDatasetDataUpdate(state, data, formatJson(data)))
|
|
},
|
|
|
|
setSelectedBlockId(blockId) {
|
|
set({ selectedBlockId: blockId })
|
|
},
|
|
|
|
setRightPanelTab(tab) {
|
|
set({ rightPanelTab: tab })
|
|
},
|
|
|
|
updateOpenDocumentChildren(children) {
|
|
set((state) => {
|
|
const template = state.snapshot.templates[state.selectedDocumentId]
|
|
const fragment = state.snapshot.fragments[state.selectedDocumentId]
|
|
|
|
if (template === undefined && fragment === undefined) {
|
|
return state
|
|
}
|
|
|
|
const templates = { ...state.snapshot.templates }
|
|
const fragments = { ...state.snapshot.fragments }
|
|
|
|
if (template !== undefined) {
|
|
templates[template.id] = { ...template, children }
|
|
} else if (fragment !== undefined) {
|
|
fragments[fragment.id] = { ...fragment, children }
|
|
}
|
|
|
|
const referenceIndex = buildReferenceIndex([
|
|
...Object.values(templates),
|
|
...Object.values(fragments)
|
|
])
|
|
const diagnostics = rebuildReferenceDiagnostics(
|
|
state.snapshot.diagnostics,
|
|
referenceIndex.missingFragments
|
|
)
|
|
|
|
return {
|
|
snapshot: {
|
|
...state.snapshot,
|
|
templates,
|
|
fragments,
|
|
referenceIndex,
|
|
diagnostics
|
|
},
|
|
selectedBlockId: blockListContainsId(children, state.selectedBlockId) ? state.selectedBlockId : null,
|
|
dirtyDocumentIds: addDirtyDocumentId(state.dirtyDocumentIds, state.selectedDocumentId),
|
|
lastSaveError: null,
|
|
saveStatus: "dirty"
|
|
}
|
|
})
|
|
},
|
|
|
|
expandSelectedFragmentReference() {
|
|
set((state) => {
|
|
if (state.selectedBlockId === null) {
|
|
return state
|
|
}
|
|
|
|
const document = getCurrentDocument(state)
|
|
const selectedBlock = findBlockById(document.children, state.selectedBlockId)
|
|
if (selectedBlock?.type !== "fragmentRef") {
|
|
return state
|
|
}
|
|
|
|
const fragment = state.snapshot.fragments[selectedBlock.fragmentId]
|
|
if (fragment === undefined) {
|
|
return state
|
|
}
|
|
|
|
const replacement = cloneBlocks(fragment.children)
|
|
const replaced = replaceBlockById(document.children, selectedBlock.id, replacement)
|
|
if (!replaced.replaced) {
|
|
return state
|
|
}
|
|
|
|
const templates = { ...state.snapshot.templates }
|
|
const fragments = { ...state.snapshot.fragments }
|
|
if (document.kind === "template") {
|
|
templates[document.id] = { ...document, children: replaced.blocks }
|
|
} else {
|
|
fragments[document.id] = { ...document, children: replaced.blocks }
|
|
}
|
|
|
|
const referenceIndex = buildReferenceIndex([
|
|
...Object.values(templates),
|
|
...Object.values(fragments)
|
|
])
|
|
const diagnostics = rebuildReferenceDiagnostics(
|
|
state.snapshot.diagnostics,
|
|
referenceIndex.missingFragments
|
|
)
|
|
|
|
return {
|
|
snapshot: {
|
|
...state.snapshot,
|
|
templates,
|
|
fragments,
|
|
referenceIndex,
|
|
diagnostics
|
|
},
|
|
selectedBlockId: replacement[0]?.id ?? null,
|
|
documentRevision: state.documentRevision + 1,
|
|
dirtyDocumentIds: addDirtyDocumentId(state.dirtyDocumentIds, document.id),
|
|
lastSaveError: null,
|
|
saveStatus: "dirty"
|
|
}
|
|
})
|
|
},
|
|
|
|
resetDemo() {
|
|
set({
|
|
snapshot: demoSnapshot,
|
|
selectedDocumentId: defaultDocumentId,
|
|
selectedResourcePath: defaultResourcePath,
|
|
currentDatasetId: defaultDatasetId,
|
|
documentRevision: 0,
|
|
dataEditorText: formatJson(demoSnapshot.datasets[defaultDatasetId]?.data ?? {}),
|
|
dataEditorStatus: "valid",
|
|
dataEditorError: null,
|
|
selectedBlockId: null,
|
|
rightPanelTab: "properties",
|
|
saveStatus: "saved",
|
|
projectOpenStatus: "demo",
|
|
dirtyDocumentIds: [],
|
|
lastSaveError: null,
|
|
isProjectPersistenceAvailable: getProjectPersistence().isAvailable()
|
|
})
|
|
}
|
|
}))
|
|
|
|
export function getCurrentDocument(state: Pick<WorkbenchState, "snapshot" | "selectedDocumentId">): OpenDocument {
|
|
const template = state.snapshot.templates[state.selectedDocumentId]
|
|
if (template !== undefined) {
|
|
return template
|
|
}
|
|
|
|
const fragment = state.snapshot.fragments[state.selectedDocumentId]
|
|
if (fragment !== undefined) {
|
|
return fragment
|
|
}
|
|
|
|
return state.snapshot.templates[defaultDocumentId]!
|
|
}
|
|
|
|
export function getCurrentDataset(state: Pick<WorkbenchState, "snapshot" | "currentDatasetId">): DataSetDocument {
|
|
return state.snapshot.datasets[state.currentDatasetId] ?? state.snapshot.datasets[defaultDatasetId] ?? fallbackDataset
|
|
}
|
|
|
|
export function getCurrentRenderResult(
|
|
state: Pick<WorkbenchState, "snapshot" | "selectedDocumentId" | "currentDatasetId">,
|
|
options: { targetBlockId?: string | null } = {}
|
|
): RenderResult {
|
|
const document = getCurrentDocument(state)
|
|
const dataset = getCurrentDataset(state)
|
|
const renderOptions = options.targetBlockId == null ? undefined : { targetBlockId: options.targetBlockId }
|
|
|
|
if (document.kind === "fragment") {
|
|
return renderTemplate({
|
|
template: {
|
|
kind: "template",
|
|
id: `preview_${document.id}`,
|
|
name: document.name,
|
|
children: document.children
|
|
},
|
|
fragments: state.snapshot.fragments,
|
|
dataset,
|
|
...(renderOptions === undefined ? {} : { options: renderOptions })
|
|
})
|
|
}
|
|
|
|
return renderTemplate({
|
|
template: document,
|
|
fragments: state.snapshot.fragments,
|
|
dataset,
|
|
...(renderOptions === undefined ? {} : { options: renderOptions })
|
|
})
|
|
}
|
|
|
|
export function getCurrentBlocks(state: Pick<WorkbenchState, "snapshot" | "selectedDocumentId">): BlockNode[] {
|
|
return getCurrentDocument(state).children
|
|
}
|
|
|
|
export function getSelectedBlock(
|
|
state: Pick<WorkbenchState, "snapshot" | "selectedDocumentId" | "selectedBlockId">
|
|
): BlockNode | undefined {
|
|
if (state.selectedBlockId === null) {
|
|
return undefined
|
|
}
|
|
|
|
return findBlockById(getCurrentDocument(state).children, state.selectedBlockId)
|
|
}
|
|
|
|
function findResourcePathByDocumentId(
|
|
nodes: ProjectResourceNode[],
|
|
documentId: string
|
|
): string | undefined {
|
|
for (const node of nodes) {
|
|
if (node.type === "file" && node.documentId === documentId) {
|
|
return node.relativePath
|
|
}
|
|
|
|
if (node.type === "directory") {
|
|
const found = findResourcePathByDocumentId(node.children, documentId)
|
|
if (found !== undefined) {
|
|
return found
|
|
}
|
|
}
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
function getProjectPersistence(): ProjectPersistence {
|
|
if (projectPersistenceOverride !== null) {
|
|
return projectPersistenceOverride
|
|
}
|
|
|
|
return {
|
|
isAvailable: isTauriRuntime,
|
|
|
|
async openProjectFromDisk() {
|
|
const { createTauriProjectPersistence } = await import("../desktop/projectPersistence")
|
|
return createTauriProjectPersistence().openProjectFromDisk()
|
|
},
|
|
|
|
async saveProjectChanges(snapshot, dirtyDocumentIds) {
|
|
const { createTauriProjectPersistence } = await import("../desktop/projectPersistence")
|
|
return createTauriProjectPersistence().saveProjectChanges(snapshot, dirtyDocumentIds)
|
|
}
|
|
}
|
|
}
|
|
|
|
function isTauriRuntime(): boolean {
|
|
if (typeof window === "undefined") {
|
|
return false
|
|
}
|
|
|
|
const runtime = window as Window & {
|
|
__TAURI__?: unknown
|
|
__TAURI_INTERNALS__?: unknown
|
|
}
|
|
|
|
return runtime.__TAURI__ !== undefined || runtime.__TAURI_INTERNALS__ !== undefined
|
|
}
|
|
|
|
function stateForSnapshot(
|
|
snapshot: ProjectSnapshot,
|
|
previous: Pick<WorkbenchState, "selectedDocumentId" | "currentDatasetId">
|
|
): Pick<
|
|
WorkbenchState,
|
|
| "snapshot"
|
|
| "selectedDocumentId"
|
|
| "selectedResourcePath"
|
|
| "currentDatasetId"
|
|
| "dataEditorText"
|
|
| "dataEditorStatus"
|
|
| "dataEditorError"
|
|
| "selectedBlockId"
|
|
| "rightPanelTab"
|
|
> {
|
|
const selectedDocumentId = documentExists(snapshot, previous.selectedDocumentId)
|
|
? previous.selectedDocumentId
|
|
: defaultDocumentIdForSnapshot(snapshot)
|
|
const currentDatasetId = snapshot.datasets[previous.currentDatasetId] !== undefined
|
|
? previous.currentDatasetId
|
|
: defaultDatasetIdForSnapshot(snapshot)
|
|
|
|
return {
|
|
snapshot,
|
|
selectedDocumentId,
|
|
selectedResourcePath:
|
|
findResourcePathByDocumentId(snapshot.resourceTree.children, selectedDocumentId) ?? defaultResourcePath,
|
|
currentDatasetId,
|
|
dataEditorText: datasetDataTextFor(snapshot, currentDatasetId),
|
|
dataEditorStatus: "valid",
|
|
dataEditorError: null,
|
|
selectedBlockId: null,
|
|
rightPanelTab: "properties"
|
|
}
|
|
}
|
|
|
|
function documentExists(snapshot: ProjectSnapshot, documentId: string): boolean {
|
|
return snapshot.templates[documentId] !== undefined || snapshot.fragments[documentId] !== undefined
|
|
}
|
|
|
|
function defaultDocumentIdForSnapshot(snapshot: ProjectSnapshot): string {
|
|
if (
|
|
snapshot.project.entryTemplateId !== undefined &&
|
|
snapshot.templates[snapshot.project.entryTemplateId] !== undefined
|
|
) {
|
|
return snapshot.project.entryTemplateId
|
|
}
|
|
|
|
return Object.keys(snapshot.templates)[0] ?? Object.keys(snapshot.fragments)[0] ?? defaultDocumentId
|
|
}
|
|
|
|
function defaultDatasetIdForSnapshot(snapshot: ProjectSnapshot): string {
|
|
return Object.keys(snapshot.datasets)[0] ?? fallbackDataset.id
|
|
}
|
|
|
|
function addDirtyDocumentId(dirtyDocumentIds: string[], documentId: string): string[] {
|
|
return dirtyDocumentIds.includes(documentId) ? dirtyDocumentIds : [...dirtyDocumentIds, documentId]
|
|
}
|
|
|
|
function formatError(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error)
|
|
}
|
|
|
|
function nextFragmentIdentity(snapshot: ProjectSnapshot): {
|
|
id: string
|
|
name: string
|
|
fileName: string
|
|
relativePath: string
|
|
path: string
|
|
} {
|
|
let index = Object.keys(snapshot.fragments).length + 1
|
|
let id = `fragment_${index}`
|
|
while (snapshot.fragments[id] !== undefined) {
|
|
index += 1
|
|
id = `fragment_${index}`
|
|
}
|
|
|
|
const fileName = `fragment-${index}.json`
|
|
const relativePath = `fragments/${fileName}`
|
|
const fragmentsDir = snapshot.project.paths.fragmentsDir.replace(/\\/g, "/")
|
|
|
|
return {
|
|
id,
|
|
name: `Fragment ${index}`,
|
|
fileName,
|
|
relativePath,
|
|
path: `${fragmentsDir}/${fileName}`
|
|
}
|
|
}
|
|
|
|
function addResourceToDirectory(
|
|
nodes: ProjectResourceNode[],
|
|
directoryRelativePath: string,
|
|
file: ProjectResourceFile,
|
|
directoryPath: string
|
|
): ProjectResourceNode[] {
|
|
const result = addResourceToDirectoryRecursive(nodes, directoryRelativePath, file)
|
|
if (result.inserted) {
|
|
return result.nodes
|
|
}
|
|
|
|
const directory: ProjectResourceDirectory = {
|
|
type: "directory",
|
|
name: directoryRelativePath,
|
|
path: directoryPath,
|
|
relativePath: directoryRelativePath,
|
|
children: [file]
|
|
}
|
|
|
|
return [...result.nodes, directory]
|
|
}
|
|
|
|
function addResourceToDirectoryRecursive(
|
|
nodes: ProjectResourceNode[],
|
|
directoryRelativePath: string,
|
|
file: ProjectResourceFile
|
|
): { nodes: ProjectResourceNode[]; inserted: boolean } {
|
|
let inserted = false
|
|
const nextNodes = nodes.map((node): ProjectResourceNode => {
|
|
if (node.type === "directory" && node.relativePath === directoryRelativePath) {
|
|
inserted = true
|
|
return {
|
|
...node,
|
|
children: [...node.children.filter((child) => child.relativePath !== file.relativePath), file]
|
|
}
|
|
}
|
|
|
|
if (node.type === "directory") {
|
|
const result = addResourceToDirectoryRecursive(node.children, directoryRelativePath, file)
|
|
inserted ||= result.inserted
|
|
return {
|
|
...node,
|
|
children: result.nodes
|
|
}
|
|
}
|
|
|
|
return node
|
|
})
|
|
|
|
return { nodes: nextNodes, inserted }
|
|
}
|
|
|
|
function rebuildReferenceDiagnostics(
|
|
existing: ProjectDiagnostic[],
|
|
missingFragments: ProjectSnapshot["referenceIndex"]["missingFragments"]
|
|
): ProjectDiagnostic[] {
|
|
const nonReferenceDiagnostics = existing.filter((diagnostic) => diagnostic.code !== "MISSING_FRAGMENT")
|
|
const missingDiagnostics = missingFragments.map<ProjectDiagnostic>((missing) => {
|
|
const diagnostic: ProjectDiagnostic = {
|
|
code: "MISSING_FRAGMENT",
|
|
severity: "error",
|
|
message: `Missing fragment reference: ${missing.fragmentId}`,
|
|
documentId: missing.fromId,
|
|
fragmentId: missing.fragmentId
|
|
}
|
|
|
|
if (missing.fromPath !== undefined) {
|
|
diagnostic.filePath = missing.fromPath
|
|
}
|
|
|
|
return diagnostic
|
|
})
|
|
|
|
return [...nonReferenceDiagnostics, ...missingDiagnostics]
|
|
}
|
|
|
|
function applyDatasetDataUpdate(
|
|
state: WorkbenchState,
|
|
data: Record<string, unknown>,
|
|
dataEditorText: string
|
|
): Partial<WorkbenchState> {
|
|
const dataset = getCurrentDataset(state)
|
|
|
|
return {
|
|
snapshot: {
|
|
...state.snapshot,
|
|
datasets: {
|
|
...state.snapshot.datasets,
|
|
[dataset.id]: {
|
|
...dataset,
|
|
data
|
|
}
|
|
}
|
|
},
|
|
dataEditorText,
|
|
dataEditorStatus: "valid",
|
|
dataEditorError: null,
|
|
dirtyDocumentIds: addDirtyDocumentId(state.dirtyDocumentIds, dataset.id),
|
|
lastSaveError: null,
|
|
saveStatus: "dirty"
|
|
}
|
|
}
|
|
|
|
function datasetDataTextFor(snapshot: ProjectSnapshot, datasetId: string): string {
|
|
const dataset = snapshot.datasets[datasetId] ?? snapshot.datasets[defaultDatasetId]
|
|
return formatJson(dataset?.data ?? {})
|
|
}
|
|
|
|
function parseDatasetJson(text: string): { ok: true; data: Record<string, unknown> } | { ok: false; error: string } {
|
|
try {
|
|
const parsed = JSON.parse(text) as unknown
|
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
return { ok: false, error: "Dataset JSON must be an object." }
|
|
}
|
|
|
|
return { ok: true, data: parsed as Record<string, unknown> }
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
error: error instanceof Error ? error.message : "Invalid JSON."
|
|
}
|
|
}
|
|
}
|
|
|
|
function formatJson(value: unknown): string {
|
|
return `${JSON.stringify(value, null, 2)}\n`
|
|
}
|
|
|
|
function blockListContainsId(blocks: BlockNode[], blockId: string | null): boolean {
|
|
if (blockId === null) {
|
|
return false
|
|
}
|
|
|
|
return findBlockById(blocks, blockId) !== undefined
|
|
}
|
|
|
|
function findBlockById(blocks: BlockNode[], blockId: string): BlockNode | undefined {
|
|
for (const block of blocks) {
|
|
if (block.id === blockId) {
|
|
return block
|
|
}
|
|
|
|
const children = getNestedBlocks(block)
|
|
if (children.length > 0) {
|
|
const found = findBlockById(children, blockId)
|
|
if (found !== undefined) {
|
|
return found
|
|
}
|
|
}
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
function getNestedBlocks(block: BlockNode): BlockNode[] {
|
|
switch (block.type) {
|
|
case "container":
|
|
return block.children
|
|
|
|
case "condition":
|
|
return [...block.children, ...(block.elseChildren ?? [])]
|
|
|
|
case "loop":
|
|
return [...block.children, ...(block.emptyChildren ?? [])]
|
|
|
|
case "slot":
|
|
return block.fallbackChildren ?? []
|
|
|
|
default:
|
|
return []
|
|
}
|
|
}
|
|
|
|
function cloneBlocks(blocks: BlockNode[]): BlockNode[] {
|
|
return blocks.map(cloneBlock)
|
|
}
|
|
|
|
function cloneBlock(block: BlockNode): BlockNode {
|
|
switch (block.type) {
|
|
case "paragraph":
|
|
return {
|
|
...block,
|
|
id: createBlockId("paragraph"),
|
|
inlines: cloneInlines(block.inlines)
|
|
}
|
|
|
|
case "container":
|
|
return {
|
|
...block,
|
|
id: createBlockId("container"),
|
|
children: cloneBlocks(block.children)
|
|
}
|
|
|
|
case "condition": {
|
|
const cloned: BlockNode = {
|
|
...block,
|
|
id: createBlockId("condition"),
|
|
children: cloneBlocks(block.children)
|
|
}
|
|
if (block.elseChildren !== undefined) {
|
|
cloned.elseChildren = cloneBlocks(block.elseChildren)
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
case "loop": {
|
|
const cloned: BlockNode = {
|
|
...block,
|
|
id: createBlockId("loop"),
|
|
children: cloneBlocks(block.children)
|
|
}
|
|
if (block.emptyChildren !== undefined) {
|
|
cloned.emptyChildren = cloneBlocks(block.emptyChildren)
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
case "fragmentRef":
|
|
return {
|
|
...block,
|
|
id: createBlockId("fragment")
|
|
}
|
|
|
|
case "slot": {
|
|
const cloned: BlockNode = {
|
|
...block,
|
|
id: createBlockId("slot")
|
|
}
|
|
if (block.fallbackChildren !== undefined) {
|
|
cloned.fallbackChildren = cloneBlocks(block.fallbackChildren)
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
case "comment":
|
|
return {
|
|
...block,
|
|
id: createBlockId("comment")
|
|
}
|
|
|
|
case "output":
|
|
return {
|
|
...block,
|
|
id: createBlockId("output"),
|
|
config: { ...block.config }
|
|
}
|
|
}
|
|
}
|
|
|
|
function cloneInlines(inlines: InlineNode[]): InlineNode[] {
|
|
return inlines.map((inline) => ({ ...inline }))
|
|
}
|
|
|
|
function replaceBlockById(
|
|
blocks: BlockNode[],
|
|
blockId: string,
|
|
replacement: BlockNode[]
|
|
): { blocks: BlockNode[]; replaced: boolean } {
|
|
let replaced = false
|
|
const nextBlocks: BlockNode[] = []
|
|
|
|
for (const block of blocks) {
|
|
if (block.id === blockId) {
|
|
nextBlocks.push(...replacement)
|
|
replaced = true
|
|
continue
|
|
}
|
|
|
|
const nested = replaceNestedBlockById(block, blockId, replacement)
|
|
nextBlocks.push(nested.block)
|
|
replaced ||= nested.replaced
|
|
}
|
|
|
|
return { blocks: nextBlocks, replaced }
|
|
}
|
|
|
|
function replaceNestedBlockById(
|
|
block: BlockNode,
|
|
blockId: string,
|
|
replacement: BlockNode[]
|
|
): { block: BlockNode; replaced: boolean } {
|
|
switch (block.type) {
|
|
case "container": {
|
|
const result = replaceBlockById(block.children, blockId, replacement)
|
|
return result.replaced
|
|
? { block: { ...block, children: result.blocks }, replaced: true }
|
|
: { block, replaced: false }
|
|
}
|
|
|
|
case "condition": {
|
|
const children = replaceBlockById(block.children, blockId, replacement)
|
|
const elseChildren =
|
|
block.elseChildren === undefined
|
|
? { blocks: undefined, replaced: false }
|
|
: replaceBlockById(block.elseChildren, blockId, replacement)
|
|
if (!children.replaced && !elseChildren.replaced) {
|
|
return { block, replaced: false }
|
|
}
|
|
const next: BlockNode = { ...block, children: children.blocks }
|
|
if (elseChildren.blocks !== undefined) {
|
|
next.elseChildren = elseChildren.blocks
|
|
}
|
|
return { block: next, replaced: true }
|
|
}
|
|
|
|
case "loop": {
|
|
const children = replaceBlockById(block.children, blockId, replacement)
|
|
const emptyChildren =
|
|
block.emptyChildren === undefined
|
|
? { blocks: undefined, replaced: false }
|
|
: replaceBlockById(block.emptyChildren, blockId, replacement)
|
|
if (!children.replaced && !emptyChildren.replaced) {
|
|
return { block, replaced: false }
|
|
}
|
|
const next: BlockNode = { ...block, children: children.blocks }
|
|
if (emptyChildren.blocks !== undefined) {
|
|
next.emptyChildren = emptyChildren.blocks
|
|
}
|
|
return { block: next, replaced: true }
|
|
}
|
|
|
|
case "slot": {
|
|
if (block.fallbackChildren === undefined) {
|
|
return { block, replaced: false }
|
|
}
|
|
const fallbackChildren = replaceBlockById(block.fallbackChildren, blockId, replacement)
|
|
return fallbackChildren.replaced
|
|
? { block: { ...block, fallbackChildren: fallbackChildren.blocks }, replaced: true }
|
|
: { block, replaced: false }
|
|
}
|
|
|
|
default:
|
|
return { block, replaced: false }
|
|
}
|
|
}
|