feat:移除了弹窗,服务器添加sls
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,245 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { ContentItem } from '@modrinth/ui'
|
||||
|
||||
import {
|
||||
buildDependencyGraph,
|
||||
dependencyGraphMetrics,
|
||||
getConnectedComponents,
|
||||
getDependencyTreeRows,
|
||||
getRelatedNodeIds,
|
||||
layoutDependencyGraph,
|
||||
} from './dependency-graph.ts'
|
||||
|
||||
type Ref = { provider: 'modrinth'; projectId: string; releaseId: string }
|
||||
|
||||
type ItemOptions = {
|
||||
title?: string
|
||||
requires?: Ref[]
|
||||
requiredBy?: Ref[]
|
||||
autoDependency?: boolean
|
||||
entryId?: string
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
function item(projectId: string, versionId: string, options: ItemOptions = {}): ContentItem {
|
||||
return {
|
||||
id: projectId,
|
||||
file_name: `${projectId}.jar`,
|
||||
file_path: options.filePath ?? `mods/${projectId}.jar`,
|
||||
size: 1,
|
||||
enabled: true,
|
||||
project_type: 'mod',
|
||||
project: { id: projectId, slug: projectId, title: options.title ?? projectId, icon_url: null },
|
||||
version: { id: versionId, version_number: versionId, file_name: `${projectId}.jar` },
|
||||
update: null,
|
||||
origin_provider: 'modrinth',
|
||||
provider_refs: [{ provider: 'modrinth', project_id: projectId, version_id: versionId }],
|
||||
instanceEntryId: options.entryId,
|
||||
dependency: {
|
||||
autoDependency: options.autoDependency ?? false,
|
||||
requires: options.requires ?? [],
|
||||
requiredBy: options.requiredBy ?? [],
|
||||
orphaned: false,
|
||||
},
|
||||
} as ContentItem
|
||||
}
|
||||
|
||||
const ref = (projectId: string, releaseId = '1') => ({
|
||||
provider: 'modrinth' as const,
|
||||
projectId,
|
||||
releaseId,
|
||||
})
|
||||
|
||||
function nodeId(projectId: string) {
|
||||
return `item:modrinth:${projectId}:1`
|
||||
}
|
||||
|
||||
test('builds dependency edges, roots, shared nodes, and relationship layout', () => {
|
||||
const graph = buildDependencyGraph([
|
||||
item('a', '1', { requires: [ref('b')] }),
|
||||
item('c', '1', { requires: [ref('b')] }),
|
||||
item('b', '1'),
|
||||
])
|
||||
|
||||
assert.equal(graph.edges.length, 2)
|
||||
assert.equal(graph.rootIds.length, 2)
|
||||
assert.equal(graph.nodeById.get(nodeId('b'))?.shared, true)
|
||||
assert.equal(layoutDependencyGraph(graph).edges.length, 2)
|
||||
})
|
||||
|
||||
test('keeps unresolved dependency targets visible', () => {
|
||||
const graph = buildDependencyGraph([item('a', '1', { requires: [ref('missing', '9')] })])
|
||||
assert.equal(graph.unresolvedIds.size, 1)
|
||||
assert.equal(graph.edges[0]?.resolved, false)
|
||||
assert.equal(graph.nodeById.get('missing:modrinth:missing:9')?.title, 'missing')
|
||||
})
|
||||
|
||||
test('deduplicates edges and stops tree traversal at cycles and shared references', () => {
|
||||
const graph = buildDependencyGraph([
|
||||
item('a', '1', { requires: [ref('b'), ref('b')] }),
|
||||
item('b', '1', { requires: [ref('a')] }),
|
||||
])
|
||||
assert.equal(graph.edges.length, 2)
|
||||
assert.equal(graph.cycleIds.size, 2)
|
||||
|
||||
const rows = getDependencyTreeRows(graph, new Set([nodeId('a'), nodeId('b')]))
|
||||
assert.ok(rows.some((row) => row.kind === 'cycle'))
|
||||
assert.ok(rows.length < 6)
|
||||
})
|
||||
|
||||
test('preserves isolated content as a root without dependency edges', () => {
|
||||
const graph = buildDependencyGraph([item('standalone', '1')])
|
||||
assert.deepEqual(graph.rootIds, [nodeId('standalone')])
|
||||
assert.deepEqual(getDependencyTreeRows(graph, new Set()), [
|
||||
{
|
||||
id: `node:${nodeId('standalone')}:0`,
|
||||
nodeId: nodeId('standalone'),
|
||||
depth: 0,
|
||||
kind: 'node',
|
||||
hasChildren: false,
|
||||
expanded: false,
|
||||
},
|
||||
])
|
||||
assert.equal(layoutDependencyGraph(graph).nodes.length, 0)
|
||||
})
|
||||
|
||||
test('keeps duplicate installed copies as distinct nodes and connects each matching copy', () => {
|
||||
const graph = buildDependencyGraph([
|
||||
item('parent', '1', { requires: [ref('library')] }),
|
||||
item('library', '1', { entryId: 'library-a', filePath: 'mods/library-a.jar' }),
|
||||
item('library', '1', { entryId: 'library-b', filePath: 'mods/library-b.jar' }),
|
||||
])
|
||||
|
||||
assert.equal(graph.nodes.filter((node) => node.projectId === 'library').length, 2)
|
||||
assert.equal(graph.edges.length, 2)
|
||||
assert.ok(graph.nodeById.has('item:entry:library-a'))
|
||||
assert.ok(graph.nodeById.has('item:entry:library-b'))
|
||||
})
|
||||
|
||||
test('partitions unrelated relationships into compact graph components', () => {
|
||||
const graph = buildDependencyGraph([
|
||||
item('a', '1', { requires: [ref('b')] }),
|
||||
item('b', '1'),
|
||||
item('c', '1', { requires: [ref('d')] }),
|
||||
item('d', '1'),
|
||||
item('isolated', '1'),
|
||||
])
|
||||
const relationshipIds = new Set(graph.edges.flatMap((edge) => [edge.source, edge.target]))
|
||||
const components = getConnectedComponents(graph, relationshipIds)
|
||||
const layout = layoutDependencyGraph(graph)
|
||||
|
||||
assert.equal(components.length, 2)
|
||||
assert.deepEqual(
|
||||
components.map((component) => component.nodeIds.length),
|
||||
[2, 2],
|
||||
)
|
||||
assert.equal(layout.components.length, 2)
|
||||
assert.equal(layout.nodes.length, 4)
|
||||
assert.equal(
|
||||
layout.nodes.some((node) => node.id === nodeId('isolated')),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('returns the complete relationship context for a filtered node', () => {
|
||||
const graph = buildDependencyGraph([
|
||||
item('a', '1', { requires: [ref('b')] }),
|
||||
item('b', '1', { requires: [ref('c')] }),
|
||||
item('c', '1'),
|
||||
item('isolated', '1'),
|
||||
])
|
||||
const related = getRelatedNodeIds(graph, new Set([nodeId('b')]))
|
||||
|
||||
assert.deepEqual(related, new Set([nodeId('a'), nodeId('b'), nodeId('c')]))
|
||||
})
|
||||
|
||||
test('connects each edge from a source output port to a target input port with an HTML connector', () => {
|
||||
const graph = buildDependencyGraph([item('a', '1', { requires: [ref('b')] }), item('b', '1')])
|
||||
const layout = layoutDependencyGraph(graph)
|
||||
const source = layout.nodes.find((node) => node.id === nodeId('a'))!
|
||||
const target = layout.nodes.find((node) => node.id === nodeId('b'))!
|
||||
const edge = layout.edges[0]!
|
||||
const expectedStartX =
|
||||
source.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.edgeClearance
|
||||
const expectedStartY = source.y + dependencyGraphMetrics.nodeHeight / 2
|
||||
const expectedEndX = target.x - dependencyGraphMetrics.edgeClearance
|
||||
const expectedEndY = target.y + dependencyGraphMetrics.nodeHeight / 2
|
||||
|
||||
assert.equal(edge.connector.x, expectedStartX)
|
||||
assert.equal(edge.connector.y, expectedStartY)
|
||||
assert.equal(
|
||||
edge.connector.length,
|
||||
Math.hypot(expectedEndX - expectedStartX, expectedEndY - expectedStartY),
|
||||
)
|
||||
assert.equal(
|
||||
edge.connector.rotation,
|
||||
(Math.atan2(expectedEndY - expectedStartY, expectedEndX - expectedStartX) * 180) / Math.PI,
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps output and input ports stable for reverse cycle connectors', () => {
|
||||
const graph = buildDependencyGraph([
|
||||
item('a', '1', { requires: [ref('b')] }),
|
||||
item('b', '1', { requires: [ref('a')] }),
|
||||
])
|
||||
const layout = layoutDependencyGraph(graph)
|
||||
const reverseEdge = layout.edges.find((edge) => edge.source === nodeId('b'))!
|
||||
const source = layout.nodes.find((node) => node.id === reverseEdge.source)!
|
||||
const target = layout.nodes.find((node) => node.id === reverseEdge.target)!
|
||||
|
||||
assert.equal(
|
||||
reverseEdge.connector.x,
|
||||
source.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.edgeClearance,
|
||||
)
|
||||
assert.equal(reverseEdge.connector.y, source.y + dependencyGraphMetrics.nodeHeight / 2)
|
||||
assert.equal(
|
||||
reverseEdge.connector.rotation,
|
||||
(Math.atan2(
|
||||
target.y + dependencyGraphMetrics.nodeHeight / 2 - reverseEdge.connector.y,
|
||||
target.x - dependencyGraphMetrics.edgeClearance - reverseEdge.connector.x,
|
||||
) *
|
||||
180) /
|
||||
Math.PI,
|
||||
)
|
||||
assert.ok(reverseEdge.connector.length > 0)
|
||||
assert.equal(
|
||||
reverseEdge.connector.length,
|
||||
Math.hypot(
|
||||
target.x - dependencyGraphMetrics.edgeClearance - reverseEdge.connector.x,
|
||||
target.y + dependencyGraphMetrics.nodeHeight / 2 - reverseEdge.connector.y,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test('uses dragged final coordinates for connectors and canvas bounds', () => {
|
||||
const graph = buildDependencyGraph([item('a', '1', { requires: [ref('b')] }), item('b', '1')])
|
||||
const offsets = new Map([[nodeId('b'), { x: 420, y: 180 }]])
|
||||
const layout = layoutDependencyGraph(graph, undefined, offsets)
|
||||
const source = layout.nodes.find((node) => node.id === nodeId('a'))!
|
||||
const target = layout.nodes.find((node) => node.id === nodeId('b'))!
|
||||
const edge = layout.edges[0]!
|
||||
|
||||
assert.ok(target.x >= dependencyGraphMetrics.canvasPadding + 420)
|
||||
assert.ok(target.y >= dependencyGraphMetrics.canvasPadding + 180)
|
||||
assert.equal(
|
||||
edge.connector.length,
|
||||
Math.hypot(
|
||||
target.x - dependencyGraphMetrics.edgeClearance - edge.connector.x,
|
||||
target.y + dependencyGraphMetrics.nodeHeight / 2 - edge.connector.y,
|
||||
),
|
||||
)
|
||||
assert.equal(
|
||||
edge.connector.x,
|
||||
source.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.edgeClearance,
|
||||
)
|
||||
assert.ok(
|
||||
layout.width >=
|
||||
target.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.canvasPadding,
|
||||
)
|
||||
assert.ok(
|
||||
layout.height >=
|
||||
target.y + dependencyGraphMetrics.nodeHeight + dependencyGraphMetrics.canvasPadding,
|
||||
)
|
||||
})
|
||||
@ -0,0 +1,658 @@
|
||||
import type { ContentItem } from '@modrinth/ui'
|
||||
|
||||
export type DependencyDirection = 'requires' | 'requiredBy'
|
||||
|
||||
export const dependencyGraphMetrics = {
|
||||
canvasPadding: 56,
|
||||
componentGap: 96,
|
||||
edgeClearance: 2,
|
||||
layerGap: 112,
|
||||
minHeight: 360,
|
||||
minWidth: 640,
|
||||
nodeHeight: 76,
|
||||
nodeWidth: 228,
|
||||
rowGap: 30,
|
||||
} as const
|
||||
|
||||
export type DependencyGraphNode = {
|
||||
id: string
|
||||
title: string
|
||||
iconUrl?: string
|
||||
projectId?: string
|
||||
versionId?: string
|
||||
versionNumber?: string
|
||||
fileName?: string
|
||||
projectType: string
|
||||
provider: string
|
||||
ownershipKind?: ContentItem['instanceOwnershipKind']
|
||||
enabled?: boolean
|
||||
materializationState?: ContentItem['instanceMaterializationState']
|
||||
dependency: NonNullable<ContentItem['dependency']>
|
||||
resolved: boolean
|
||||
item?: ContentItem
|
||||
cycle: boolean
|
||||
shared: boolean
|
||||
}
|
||||
|
||||
export type DependencyGraphEdge = {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
resolved: boolean
|
||||
}
|
||||
|
||||
export type DependencyGraph = {
|
||||
nodes: DependencyGraphNode[]
|
||||
edges: DependencyGraphEdge[]
|
||||
nodeById: Map<string, DependencyGraphNode>
|
||||
edgesBySource: Map<string, DependencyGraphEdge[]>
|
||||
edgesByTarget: Map<string, DependencyGraphEdge[]>
|
||||
rootIds: string[]
|
||||
cycleIds: Set<string>
|
||||
unresolvedIds: Set<string>
|
||||
}
|
||||
|
||||
export type DependencyTreeRow = {
|
||||
id: string
|
||||
nodeId?: string
|
||||
depth: number
|
||||
kind: 'node' | 'reference' | 'cycle'
|
||||
hasChildren: boolean
|
||||
expanded: boolean
|
||||
}
|
||||
|
||||
export type DependencyGraphConnector = {
|
||||
length: number
|
||||
rotation: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type DependencyGraphLayoutEdge = DependencyGraphEdge & {
|
||||
connector: DependencyGraphConnector
|
||||
}
|
||||
|
||||
export type DependencyGraphComponent = {
|
||||
edgeCount: number
|
||||
height: number
|
||||
id: string
|
||||
nodeIds: string[]
|
||||
width: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type DependencyGraphLayout = {
|
||||
components: DependencyGraphComponent[]
|
||||
edges: DependencyGraphLayoutEdge[]
|
||||
height: number
|
||||
nodes: Array<DependencyGraphNode & { x: number; y: number }>
|
||||
width: number
|
||||
}
|
||||
|
||||
type DependencyReference = {
|
||||
provider: string
|
||||
projectId: string
|
||||
releaseId: string
|
||||
}
|
||||
|
||||
type NodePosition = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type ComponentLayout = {
|
||||
edges: DependencyGraphLayoutEdge[]
|
||||
height: number
|
||||
nodeIds: string[]
|
||||
nodes: Array<DependencyGraphNode & NodePosition>
|
||||
width: number
|
||||
}
|
||||
|
||||
const emptyDependency = (): NonNullable<ContentItem['dependency']> => ({
|
||||
autoDependency: false,
|
||||
requiredBy: [],
|
||||
requires: [],
|
||||
orphaned: false,
|
||||
})
|
||||
|
||||
function normalizeProjectId(provider: string, projectId: string): string {
|
||||
return provider === 'curseforge' ? projectId.replace(/^curseforge:/, '') : projectId
|
||||
}
|
||||
|
||||
function referenceKey(reference: DependencyReference): string {
|
||||
return `${reference.provider}:${normalizeProjectId(reference.provider, reference.projectId)}:${reference.releaseId}`
|
||||
}
|
||||
|
||||
function itemReferenceKeys(item: ContentItem): Set<string> {
|
||||
const keys = new Set<string>()
|
||||
const projectId = item.project?.id
|
||||
const versionId = item.version?.id
|
||||
const provider = item.origin_provider ?? item.provider_refs[0]?.provider
|
||||
|
||||
if (projectId) keys.add(`project:${projectId}`)
|
||||
if (provider && projectId) {
|
||||
const normalizedProjectId = normalizeProjectId(provider, projectId)
|
||||
keys.add(`${provider}:${normalizedProjectId}:`)
|
||||
if (versionId) keys.add(`${provider}:${normalizedProjectId}:${versionId}`)
|
||||
}
|
||||
|
||||
for (const providerRef of item.provider_refs) {
|
||||
if (providerRef.provider === 'modrinth') {
|
||||
keys.add(`modrinth:${providerRef.project_id}:${providerRef.version_id ?? ''}`)
|
||||
keys.add(`modrinth:${providerRef.project_id}:`)
|
||||
} else {
|
||||
keys.add(`curseforge:${providerRef.project_id}:${providerRef.file_id ?? ''}`)
|
||||
keys.add(`curseforge:${providerRef.project_id}:`)
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
function nodeIdForItem(item: ContentItem): string {
|
||||
if (item.instanceEntryId) return `item:entry:${item.instanceEntryId}`
|
||||
if (item.instanceMemberId) return `item:member:${item.instanceMemberId}`
|
||||
if (item.instanceFileId) return `item:file:${item.instanceFileId}`
|
||||
|
||||
const provider = item.origin_provider ?? item.provider_refs[0]?.provider
|
||||
const projectId = item.project?.id
|
||||
if (provider && projectId) {
|
||||
return `item:${provider}:${normalizeProjectId(provider, projectId)}:${item.version?.id ?? ''}`
|
||||
}
|
||||
if (item.file_path) return `item:path:${item.file_path}`
|
||||
if (item.file_name) return `item:name:${item.file_name}`
|
||||
return `item:id:${item.id}`
|
||||
}
|
||||
|
||||
function nodeFromItem(item: ContentItem, id: string): DependencyGraphNode {
|
||||
const dependency = item.dependency ?? emptyDependency()
|
||||
const provider = item.origin_provider ?? item.provider_refs[0]?.provider ?? 'local'
|
||||
return {
|
||||
id,
|
||||
title: item.project?.title ?? item.file_name,
|
||||
iconUrl: item.project?.icon_url,
|
||||
projectId: item.project?.id,
|
||||
versionId: item.version?.id,
|
||||
versionNumber: item.version?.version_number,
|
||||
fileName: item.file_name,
|
||||
projectType: item.project_type,
|
||||
provider,
|
||||
ownershipKind: item.instanceOwnershipKind,
|
||||
enabled: item.enabled,
|
||||
materializationState: item.instanceMaterializationState,
|
||||
dependency,
|
||||
resolved: true,
|
||||
item,
|
||||
cycle: false,
|
||||
shared: false,
|
||||
}
|
||||
}
|
||||
|
||||
function unresolvedNode(reference: DependencyReference): DependencyGraphNode {
|
||||
return {
|
||||
id: `missing:${referenceKey(reference)}`,
|
||||
title: reference.projectId || reference.releaseId || 'Unresolved dependency',
|
||||
projectId: reference.projectId,
|
||||
versionId: reference.releaseId,
|
||||
projectType: 'unknown',
|
||||
provider: reference.provider,
|
||||
dependency: emptyDependency(),
|
||||
resolved: false,
|
||||
cycle: false,
|
||||
shared: false,
|
||||
}
|
||||
}
|
||||
|
||||
function addNodeForReference(
|
||||
nodesByItemKey: Map<string, DependencyGraphNode[]>,
|
||||
key: string,
|
||||
node: DependencyGraphNode,
|
||||
) {
|
||||
const matches = nodesByItemKey.get(key) ?? []
|
||||
if (!matches.some((candidate) => candidate.id === node.id)) matches.push(node)
|
||||
nodesByItemKey.set(key, matches)
|
||||
}
|
||||
|
||||
function findNodesForReference(
|
||||
reference: DependencyReference,
|
||||
nodesByItemKey: Map<string, DependencyGraphNode[]>,
|
||||
): DependencyGraphNode[] {
|
||||
const exact = nodesByItemKey.get(referenceKey(reference))
|
||||
if (exact?.length) return exact
|
||||
|
||||
const byProviderProject = nodesByItemKey.get(
|
||||
`${reference.provider}:${normalizeProjectId(reference.provider, reference.projectId)}:`,
|
||||
)
|
||||
if (byProviderProject?.length) return byProviderProject
|
||||
|
||||
return nodesByItemKey.get(`project:${reference.projectId}`) ?? []
|
||||
}
|
||||
|
||||
function markCycles(
|
||||
nodes: DependencyGraphNode[],
|
||||
edgesBySource: Map<string, DependencyGraphEdge[]>,
|
||||
): Set<string> {
|
||||
const state = new Map<string, 0 | 1 | 2>()
|
||||
const cycleIds = new Set<string>()
|
||||
|
||||
function visit(id: string, stack: string[]) {
|
||||
const currentState = state.get(id) ?? 0
|
||||
if (currentState === 2) return
|
||||
if (currentState === 1) {
|
||||
const cycleStart = stack.indexOf(id)
|
||||
for (const cycleId of stack.slice(cycleStart)) cycleIds.add(cycleId)
|
||||
return
|
||||
}
|
||||
|
||||
state.set(id, 1)
|
||||
for (const edge of edgesBySource.get(id) ?? []) visit(edge.target, [...stack, id])
|
||||
state.set(id, 2)
|
||||
}
|
||||
|
||||
for (const node of nodes) visit(node.id, [])
|
||||
return cycleIds
|
||||
}
|
||||
|
||||
export function buildDependencyGraph(items: ContentItem[]): DependencyGraph {
|
||||
const nodesByItemKey = new Map<string, DependencyGraphNode[]>()
|
||||
const nodes = new Map<string, DependencyGraphNode>()
|
||||
|
||||
for (const item of items) {
|
||||
const id = nodeIdForItem(item)
|
||||
const node = nodeFromItem(item, id)
|
||||
nodes.set(id, node)
|
||||
for (const key of itemReferenceKeys(item)) addNodeForReference(nodesByItemKey, key, node)
|
||||
}
|
||||
|
||||
const edges = new Map<string, DependencyGraphEdge>()
|
||||
const addEdge = (source: DependencyGraphNode, target: DependencyGraphNode) => {
|
||||
if (!nodes.has(source.id)) nodes.set(source.id, source)
|
||||
if (!nodes.has(target.id)) nodes.set(target.id, target)
|
||||
const id = `${source.id}->${target.id}`
|
||||
edges.set(id, { id, source: source.id, target: target.id, resolved: target.resolved })
|
||||
}
|
||||
for (const source of nodes.values()) {
|
||||
for (const reference of source.dependency.requires as DependencyReference[]) {
|
||||
const targets = findNodesForReference(reference, nodesByItemKey)
|
||||
if (targets.length) {
|
||||
for (const target of targets) addEdge(source, target)
|
||||
} else {
|
||||
addEdge(source, unresolvedNode(reference))
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const target of nodes.values()) {
|
||||
for (const reference of target.dependency.requiredBy as DependencyReference[]) {
|
||||
const sources = findNodesForReference(reference, nodesByItemKey)
|
||||
if (sources.length) {
|
||||
for (const source of sources) addEdge(source, target)
|
||||
} else {
|
||||
addEdge(unresolvedNode(reference), target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allNodes = [...nodes.values()]
|
||||
const allEdges = [...edges.values()]
|
||||
const edgesBySource = new Map<string, DependencyGraphEdge[]>()
|
||||
const edgesByTarget = new Map<string, DependencyGraphEdge[]>()
|
||||
for (const edge of allEdges) {
|
||||
const sourceEdges = edgesBySource.get(edge.source) ?? []
|
||||
sourceEdges.push(edge)
|
||||
edgesBySource.set(edge.source, sourceEdges)
|
||||
const targetEdges = edgesByTarget.get(edge.target) ?? []
|
||||
targetEdges.push(edge)
|
||||
edgesByTarget.set(edge.target, targetEdges)
|
||||
}
|
||||
|
||||
const cycleIds = markCycles(allNodes, edgesBySource)
|
||||
const unresolvedIds = new Set(allNodes.filter((node) => !node.resolved).map((node) => node.id))
|
||||
for (const node of allNodes) {
|
||||
node.cycle = cycleIds.has(node.id)
|
||||
node.shared = (edgesByTarget.get(node.id)?.length ?? 0) > 1
|
||||
}
|
||||
|
||||
const rootIds = allNodes
|
||||
.filter((node) => !(edgesByTarget.get(node.id)?.length ?? 0))
|
||||
.map((node) => node.id)
|
||||
.sort((a, b) => (nodes.get(a)!.title ?? '').localeCompare(nodes.get(b)!.title ?? ''))
|
||||
const covered = new Set<string>()
|
||||
const visitFromRoot = (id: string) => {
|
||||
if (covered.has(id)) return
|
||||
covered.add(id)
|
||||
for (const edge of edgesBySource.get(id) ?? []) visitFromRoot(edge.target)
|
||||
}
|
||||
for (const rootId of rootIds) visitFromRoot(rootId)
|
||||
for (const node of allNodes
|
||||
.filter((candidate) => !covered.has(candidate.id))
|
||||
.sort((a, b) => a.title.localeCompare(b.title))) {
|
||||
rootIds.push(node.id)
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: allNodes,
|
||||
edges: allEdges,
|
||||
nodeById: nodes,
|
||||
edgesBySource,
|
||||
edgesByTarget,
|
||||
rootIds,
|
||||
cycleIds,
|
||||
unresolvedIds,
|
||||
}
|
||||
}
|
||||
|
||||
export function getDependencyTreeRows(
|
||||
graph: DependencyGraph,
|
||||
expandedIds: Set<string>,
|
||||
direction: DependencyDirection = 'requires',
|
||||
): DependencyTreeRow[] {
|
||||
const rows: DependencyTreeRow[] = []
|
||||
const seen = new Set<string>()
|
||||
const roots =
|
||||
direction === 'requires'
|
||||
? graph.rootIds
|
||||
: graph.nodes
|
||||
.filter((node) => !(graph.edgesBySource.get(node.id)?.length ?? 0))
|
||||
.map((node) => node.id)
|
||||
.sort((a, b) => graph.nodeById.get(a)!.title.localeCompare(graph.nodeById.get(b)!.title))
|
||||
|
||||
function visit(nodeId: string, depth: number, stack: Set<string>) {
|
||||
const node = graph.nodeById.get(nodeId)
|
||||
if (!node) return
|
||||
const edges =
|
||||
direction === 'requires' ? graph.edgesBySource.get(nodeId) : graph.edgesByTarget.get(nodeId)
|
||||
const children = (edges ?? []).map((edge) =>
|
||||
direction === 'requires' ? edge.target : edge.source,
|
||||
)
|
||||
const expanded = expandedIds.has(nodeId)
|
||||
const firstVisit = !seen.has(nodeId)
|
||||
if (firstVisit) {
|
||||
seen.add(nodeId)
|
||||
rows.push({
|
||||
id: `node:${nodeId}:${depth}`,
|
||||
nodeId,
|
||||
depth,
|
||||
kind: 'node',
|
||||
hasChildren: children.length > 0,
|
||||
expanded,
|
||||
})
|
||||
} else {
|
||||
rows.push({
|
||||
id: `reference:${nodeId}:${depth}`,
|
||||
nodeId,
|
||||
depth,
|
||||
kind: 'reference',
|
||||
hasChildren: false,
|
||||
expanded: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!expanded) return
|
||||
for (const childId of children) {
|
||||
if (stack.has(childId)) {
|
||||
rows.push({
|
||||
id: `cycle:${childId}:${depth + 1}`,
|
||||
nodeId: childId,
|
||||
depth: depth + 1,
|
||||
kind: 'cycle',
|
||||
hasChildren: false,
|
||||
expanded: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
visit(childId, depth + 1, new Set([...stack, nodeId]))
|
||||
}
|
||||
}
|
||||
|
||||
for (const rootId of roots) visit(rootId, 0, new Set())
|
||||
return rows
|
||||
}
|
||||
|
||||
export function getRelatedNodeIds(
|
||||
graph: DependencyGraph,
|
||||
nodeIds: ReadonlySet<string>,
|
||||
): Set<string> {
|
||||
const related = new Set(nodeIds)
|
||||
const queue = [...nodeIds]
|
||||
while (queue.length) {
|
||||
const id = queue.shift()!
|
||||
for (const edge of [
|
||||
...(graph.edgesBySource.get(id) ?? []),
|
||||
...(graph.edgesByTarget.get(id) ?? []),
|
||||
]) {
|
||||
const next = edge.source === id ? edge.target : edge.source
|
||||
if (!related.has(next)) {
|
||||
related.add(next)
|
||||
queue.push(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
return related
|
||||
}
|
||||
|
||||
export function getConnectedComponents(
|
||||
graph: DependencyGraph,
|
||||
visibleIds: ReadonlySet<string>,
|
||||
): Array<{ edgeCount: number; nodeIds: string[] }> {
|
||||
const components: Array<{ edgeCount: number; nodeIds: string[] }> = []
|
||||
const remaining = new Set(visibleIds)
|
||||
|
||||
while (remaining.size) {
|
||||
const start = remaining.values().next().value as string
|
||||
const nodeIds = getRelatedNodeIds(graph, new Set([start]))
|
||||
const componentIds = [...nodeIds].filter((id) => visibleIds.has(id)).sort()
|
||||
for (const id of componentIds) remaining.delete(id)
|
||||
const edgeCount = graph.edges.filter(
|
||||
(edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target),
|
||||
).length
|
||||
components.push({ edgeCount, nodeIds: componentIds })
|
||||
}
|
||||
|
||||
return components.sort(
|
||||
(left, right) =>
|
||||
right.edgeCount - left.edgeCount ||
|
||||
right.nodeIds.length - left.nodeIds.length ||
|
||||
left.nodeIds[0]!.localeCompare(right.nodeIds[0]!),
|
||||
)
|
||||
}
|
||||
|
||||
function nodeDepths(graph: DependencyGraph, visibleIds: ReadonlySet<string>): Map<string, number> {
|
||||
const depths = new Map<string, number>()
|
||||
const visiting = new Set<string>()
|
||||
|
||||
function depth(id: string): number {
|
||||
const cached = depths.get(id)
|
||||
if (cached !== undefined) return cached
|
||||
if (visiting.has(id)) return 0
|
||||
visiting.add(id)
|
||||
const parents = (graph.edgesByTarget.get(id) ?? []).filter(
|
||||
(edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
|
||||
)
|
||||
const value =
|
||||
parents.length === 0 ? 0 : Math.max(...parents.map((edge) => depth(edge.source) + 1))
|
||||
visiting.delete(id)
|
||||
depths.set(id, value)
|
||||
return value
|
||||
}
|
||||
|
||||
for (const id of visibleIds) depth(id)
|
||||
return depths
|
||||
}
|
||||
|
||||
function edgeGeometry(
|
||||
source: NodePosition,
|
||||
target: NodePosition,
|
||||
): Pick<DependencyGraphLayoutEdge, 'connector'> {
|
||||
const x = source.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.edgeClearance
|
||||
const y = source.y + dependencyGraphMetrics.nodeHeight / 2
|
||||
const endX = target.x - dependencyGraphMetrics.edgeClearance
|
||||
const endY = target.y + dependencyGraphMetrics.nodeHeight / 2
|
||||
const dx = endX - x
|
||||
const dy = endY - y
|
||||
|
||||
return {
|
||||
connector: {
|
||||
length: Math.hypot(dx, dy),
|
||||
rotation: (Math.atan2(dy, dx) * 180) / Math.PI,
|
||||
x,
|
||||
y,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function layoutComponent(
|
||||
graph: DependencyGraph,
|
||||
nodeIds: string[],
|
||||
nodeOffsets: ReadonlyMap<string, NodePosition>,
|
||||
): ComponentLayout {
|
||||
const visibleIds = new Set(nodeIds)
|
||||
const nodesToLayout = graph.nodes.filter((node) => visibleIds.has(node.id))
|
||||
const depths = nodeDepths(graph, visibleIds)
|
||||
const groups = new Map<number, DependencyGraphNode[]>()
|
||||
for (const node of nodesToLayout) {
|
||||
const depth = depths.get(node.id) ?? 0
|
||||
const group = groups.get(depth) ?? []
|
||||
group.push(node)
|
||||
groups.set(depth, group)
|
||||
}
|
||||
|
||||
const ranks = new Map(
|
||||
[...groups.keys()].sort((a, b) => a - b).map((depth, rank) => [depth, rank]),
|
||||
)
|
||||
const positions = new Map<string, NodePosition>()
|
||||
for (const [depth, group] of groups) {
|
||||
group.sort((left, right) => left.title.localeCompare(right.title))
|
||||
const rank = ranks.get(depth) ?? 0
|
||||
group.forEach((node, index) => {
|
||||
const offset = nodeOffsets.get(node.id) ?? { x: 0, y: 0 }
|
||||
positions.set(node.id, {
|
||||
x: offset.x + rank * (dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.layerGap),
|
||||
y: offset.y + index * (dependencyGraphMetrics.nodeHeight + dependencyGraphMetrics.rowGap),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const rawPositions = [...positions.values()]
|
||||
const minX = Math.min(...rawPositions.map((position) => position.x))
|
||||
const minY = Math.min(...rawPositions.map((position) => position.y))
|
||||
const normalizedPositions = new Map<string, NodePosition>()
|
||||
for (const [id, position] of positions) {
|
||||
normalizedPositions.set(id, { x: position.x - minX, y: position.y - minY })
|
||||
}
|
||||
|
||||
const nodes = nodesToLayout.map((node) => ({ ...node, ...normalizedPositions.get(node.id)! }))
|
||||
const edges = graph.edges
|
||||
.filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target))
|
||||
.map((edge) => ({
|
||||
...edge,
|
||||
...edgeGeometry(normalizedPositions.get(edge.source)!, normalizedPositions.get(edge.target)!),
|
||||
}))
|
||||
|
||||
return {
|
||||
edges,
|
||||
height: Math.max(
|
||||
dependencyGraphMetrics.nodeHeight,
|
||||
...nodes.map((node) => node.y + dependencyGraphMetrics.nodeHeight),
|
||||
),
|
||||
nodeIds,
|
||||
nodes,
|
||||
width: Math.max(
|
||||
dependencyGraphMetrics.nodeWidth,
|
||||
...nodes.map((node) => node.x + dependencyGraphMetrics.nodeWidth),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function layoutDependencyGraph(
|
||||
graph: DependencyGraph,
|
||||
visibleIds: ReadonlySet<string> = new Set(graph.nodes.map((node) => node.id)),
|
||||
nodeOffsets: ReadonlyMap<string, NodePosition> = new Map(),
|
||||
): DependencyGraphLayout {
|
||||
const relationshipIds = new Set(
|
||||
graph.edges
|
||||
.filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target))
|
||||
.flatMap((edge) => [edge.source, edge.target]),
|
||||
)
|
||||
const components = getConnectedComponents(graph, relationshipIds)
|
||||
if (!components.length) {
|
||||
return {
|
||||
components: [],
|
||||
edges: [],
|
||||
height: dependencyGraphMetrics.minHeight,
|
||||
nodes: [],
|
||||
width: dependencyGraphMetrics.minWidth,
|
||||
}
|
||||
}
|
||||
|
||||
const layouts = components.map((component) => ({
|
||||
component,
|
||||
layout: layoutComponent(graph, component.nodeIds, nodeOffsets),
|
||||
}))
|
||||
const rowWidth = Math.max(
|
||||
dependencyGraphMetrics.minWidth - dependencyGraphMetrics.canvasPadding * 2,
|
||||
Math.max(...layouts.map(({ layout }) => layout.width)),
|
||||
)
|
||||
let cursorX = dependencyGraphMetrics.canvasPadding
|
||||
let cursorY = dependencyGraphMetrics.canvasPadding
|
||||
let rowHeight = 0
|
||||
const layoutComponents: DependencyGraphComponent[] = []
|
||||
const nodes: DependencyGraphLayout['nodes'] = []
|
||||
const edges: DependencyGraphLayout['edges'] = []
|
||||
|
||||
for (const { component, layout } of layouts) {
|
||||
if (cursorX > dependencyGraphMetrics.canvasPadding && cursorX + layout.width > rowWidth) {
|
||||
cursorX = dependencyGraphMetrics.canvasPadding
|
||||
cursorY += rowHeight + dependencyGraphMetrics.componentGap
|
||||
rowHeight = 0
|
||||
}
|
||||
const id = component.nodeIds.join('|')
|
||||
layoutComponents.push({
|
||||
edgeCount: component.edgeCount,
|
||||
height: layout.height,
|
||||
id,
|
||||
nodeIds: component.nodeIds,
|
||||
width: layout.width,
|
||||
x: cursorX,
|
||||
y: cursorY,
|
||||
})
|
||||
nodes.push(
|
||||
...layout.nodes.map((node) => ({ ...node, x: node.x + cursorX, y: node.y + cursorY })),
|
||||
)
|
||||
edges.push(
|
||||
...layout.edges.map((edge) => {
|
||||
const source = layout.nodes.find((node) => node.id === edge.source)!
|
||||
const target = layout.nodes.find((node) => node.id === edge.target)!
|
||||
return {
|
||||
...edge,
|
||||
...edgeGeometry(
|
||||
{ x: source.x + cursorX, y: source.y + cursorY },
|
||||
{ x: target.x + cursorX, y: target.y + cursorY },
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
cursorX += layout.width + dependencyGraphMetrics.componentGap
|
||||
rowHeight = Math.max(rowHeight, layout.height)
|
||||
}
|
||||
|
||||
return {
|
||||
components: layoutComponents,
|
||||
edges,
|
||||
height: Math.max(
|
||||
dependencyGraphMetrics.minHeight,
|
||||
cursorY + rowHeight + dependencyGraphMetrics.canvasPadding,
|
||||
),
|
||||
nodes,
|
||||
width: Math.max(
|
||||
dependencyGraphMetrics.minWidth,
|
||||
Math.max(
|
||||
...nodes.map(
|
||||
(node) =>
|
||||
node.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.canvasPadding,
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
275
apps/app-frontend/src/components/instance/studio/NbtEditor.vue
Normal file
275
apps/app-frontend/src/components/instance/studio/NbtEditor.vue
Normal file
@ -0,0 +1,275 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { NbtString, NbtTag } from 'deepslate/nbt'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import NbtTreeNode from './NbtTreeNode.vue'
|
||||
import StudioEditor from './StudioEditor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
filePath: string
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [content: string]
|
||||
'update:mode': [mode: 'tree' | 'snbt']
|
||||
format: []
|
||||
save: []
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
tree: { id: 'instance.files.studio.nbt.tree', defaultMessage: 'Tree' },
|
||||
snbt: { id: 'instance.files.studio.nbt.snbt', defaultMessage: 'SNBT' },
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const mode = ref<'tree' | 'snbt'>('tree')
|
||||
const root = ref<NbtTag | null>(null)
|
||||
const parseError = ref('')
|
||||
const draft = ref(props.content)
|
||||
const snbtEditor = ref<InstanceType<typeof StudioEditor> | null>(null)
|
||||
const history = ref([props.content])
|
||||
let historyIndex = 0
|
||||
let lastEmittedContent = props.content
|
||||
|
||||
function parseContent(content: string) {
|
||||
draft.value = content
|
||||
try {
|
||||
const parsed = NbtTag.fromString(content)
|
||||
if (!parsed.isCompound()) throw new Error('NBT root must be a compound')
|
||||
root.value = parsed
|
||||
parseError.value = ''
|
||||
} catch (error) {
|
||||
parseError.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
function updateSnbt(content: string) {
|
||||
draft.value = content
|
||||
parseContent(content)
|
||||
lastEmittedContent = content
|
||||
emit('update:content', content)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.content,
|
||||
(content) => {
|
||||
parseContent(content)
|
||||
if (content !== lastEmittedContent) {
|
||||
history.value = [content]
|
||||
historyIndex = 0
|
||||
lastEmittedContent = content
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const rootCompound = computed(() => (root.value?.isCompound() ? root.value : null))
|
||||
|
||||
function setMode(nextMode: 'tree' | 'snbt') {
|
||||
if (nextMode === 'tree' && parseError.value) return
|
||||
if (nextMode === 'tree') {
|
||||
history.value = [draft.value]
|
||||
historyIndex = 0
|
||||
}
|
||||
mode.value = nextMode
|
||||
emit('update:mode', nextMode)
|
||||
emit('save')
|
||||
}
|
||||
|
||||
function handleFocusout(event: FocusEvent) {
|
||||
const currentTarget = event.currentTarget
|
||||
const nextTarget = event.relatedTarget
|
||||
if (
|
||||
currentTarget instanceof HTMLElement &&
|
||||
nextTarget instanceof Node &&
|
||||
currentTarget.contains(nextTarget)
|
||||
)
|
||||
return
|
||||
emit('save')
|
||||
}
|
||||
|
||||
function resolve(path: (string | number)[]) {
|
||||
let current: NbtTag | undefined = root.value ?? undefined
|
||||
for (const segment of path) {
|
||||
if (!current) return undefined
|
||||
if (typeof segment === 'string' && current.isCompound()) current = current.get(segment)
|
||||
else if (typeof segment === 'number' && (current.isList() || current.isArray())) {
|
||||
current = current.get(segment)
|
||||
} else return undefined
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function parentOf(path: (string | number)[]) {
|
||||
return resolve(path.slice(0, -1))
|
||||
}
|
||||
|
||||
function updateContent() {
|
||||
if (!root.value) return
|
||||
const content = root.value.toPrettyString()
|
||||
draft.value = content
|
||||
if (history.value[historyIndex] !== content) {
|
||||
history.value = history.value.slice(0, historyIndex + 1)
|
||||
history.value.push(content)
|
||||
historyIndex += 1
|
||||
}
|
||||
lastEmittedContent = content
|
||||
emit('update:content', content)
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (mode.value !== 'tree' || historyIndex === 0) return
|
||||
historyIndex -= 1
|
||||
const content = history.value[historyIndex]
|
||||
parseContent(content)
|
||||
lastEmittedContent = content
|
||||
emit('update:content', content)
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (mode.value !== 'tree' || historyIndex >= history.value.length - 1) return
|
||||
historyIndex += 1
|
||||
const content = history.value[historyIndex]
|
||||
parseContent(content)
|
||||
lastEmittedContent = content
|
||||
emit('update:content', content)
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!(event.ctrlKey || event.metaKey)) return
|
||||
if (event.key.toLowerCase() === 'z') {
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) redo()
|
||||
else undo()
|
||||
} else if (event.key.toLowerCase() === 'y') {
|
||||
event.preventDefault()
|
||||
redo()
|
||||
}
|
||||
}
|
||||
|
||||
function editValue(path: (string | number)[], value: string) {
|
||||
const target = resolve(path)
|
||||
if (!target) return
|
||||
try {
|
||||
const parsed = target.isString() ? new NbtString(value) : NbtTag.fromString(value)
|
||||
if (parsed.getId() !== target.getId()) throw new Error('Value type cannot be changed')
|
||||
const parent = parentOf(path)
|
||||
const last = path.at(-1)
|
||||
if (parent?.isCompound() && typeof last === 'string') parent.set(last, parsed)
|
||||
else if (parent?.isList() && typeof last === 'number') parent.set(last, parsed)
|
||||
else if (parent?.isByteArray() && typeof last === 'number' && parsed.isByte())
|
||||
parent.set(last, parsed)
|
||||
else if (parent?.isIntArray() && typeof last === 'number' && parsed.isInt())
|
||||
parent.set(last, parsed)
|
||||
else if (parent?.isLongArray() && typeof last === 'number' && parsed.isLong())
|
||||
parent.set(last, parsed)
|
||||
else throw new Error('Value cannot be changed')
|
||||
parseError.value = ''
|
||||
updateContent()
|
||||
} catch (error) {
|
||||
parseError.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
function removeValue(path: (string | number)[]) {
|
||||
const parent = parentOf(path)
|
||||
const last = path.at(-1)
|
||||
if (parent?.isCompound() && typeof last === 'string') parent.delete(last)
|
||||
else if (parent?.isListOrArray() && typeof last === 'number') parent.delete(last)
|
||||
parseError.value = ''
|
||||
updateContent()
|
||||
}
|
||||
|
||||
function renameValue(path: (string | number)[], name: string) {
|
||||
const parent = parentOf(path)
|
||||
const oldName = path.at(-1)
|
||||
if (!parent?.isCompound() || typeof oldName !== 'string' || parent.has(name)) return
|
||||
const value = parent.get(oldName)
|
||||
if (!value) return
|
||||
parent.delete(oldName)
|
||||
parent.set(name, value)
|
||||
parseError.value = ''
|
||||
updateContent()
|
||||
}
|
||||
|
||||
function addValue(path: (string | number)[], input: string) {
|
||||
const parent = resolve(path)
|
||||
if (!parent) return
|
||||
try {
|
||||
const separator = parent.isCompound() ? input.indexOf(':') : -1
|
||||
const name = separator === -1 ? undefined : input.slice(0, separator).trim()
|
||||
const valueText = separator === -1 ? input.trim() : input.slice(separator + 1).trim()
|
||||
const value = NbtTag.fromString(valueText)
|
||||
if (parent.isCompound() && name && !parent.has(name)) parent.set(name, value)
|
||||
else if (parent.isList() && value.getId() === parent.getType()) parent.add(value)
|
||||
else if (parent.isByteArray() && value.isByte()) parent.add(value)
|
||||
else if (parent.isIntArray() && value.isInt()) parent.add(value)
|
||||
else if (parent.isLongArray() && value.isLong()) parent.add(value)
|
||||
else throw new Error('Value type does not match the container')
|
||||
parseError.value = ''
|
||||
updateContent()
|
||||
} catch (error) {
|
||||
parseError.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function formatDocument() {
|
||||
await snbtEditor.value?.formatDocument()
|
||||
}
|
||||
|
||||
defineExpose({ formatDocument })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex size-full min-h-0 min-w-0 flex-col bg-surface-2" @focusout="handleFocusout">
|
||||
<div
|
||||
class="flex h-10 shrink-0 items-center gap-1 border-0 border-b border-solid border-surface-4 px-3"
|
||||
>
|
||||
<button
|
||||
v-for="candidate in ['tree', 'snbt'] as const"
|
||||
:key="candidate"
|
||||
type="button"
|
||||
class="rounded border-0 px-3 py-1 text-xs font-semibold capitalize"
|
||||
:class="
|
||||
mode === candidate
|
||||
? 'bg-brand text-contrast'
|
||||
: 'bg-transparent text-secondary hover:bg-surface-3'
|
||||
"
|
||||
@click="setMode(candidate)"
|
||||
>
|
||||
{{ formatMessage(messages[candidate]) }}
|
||||
</button>
|
||||
<span v-if="parseError" class="ml-2 truncate text-xs text-red">{{ parseError }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="mode === 'tree'"
|
||||
class="min-h-0 flex-1 overflow-auto p-2"
|
||||
tabindex="0"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<NbtTreeNode
|
||||
v-if="rootCompound"
|
||||
:tag="rootCompound"
|
||||
:path="[]"
|
||||
:depth="0"
|
||||
:read-only="readOnly"
|
||||
@edit="editValue"
|
||||
@remove="removeValue"
|
||||
@rename="renameValue"
|
||||
@add="addValue"
|
||||
/>
|
||||
</div>
|
||||
<StudioEditor
|
||||
v-else
|
||||
ref="snbtEditor"
|
||||
:file-path="filePath"
|
||||
:content="draft"
|
||||
:read-only="readOnly"
|
||||
:language="'snbt'"
|
||||
@update:content="updateSnbt"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
222
apps/app-frontend/src/components/instance/studio/NbtTreeNode.vue
Normal file
222
apps/app-frontend/src/components/instance/studio/NbtTreeNode.vue
Normal file
@ -0,0 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { type NbtTag, NbtType } from 'deepslate/nbt'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import NbtTypeIcon from './NbtTypeIcon.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name?: string
|
||||
tag: NbtTag
|
||||
path: (string | number)[]
|
||||
depth: number
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [path: (string | number)[], value: string]
|
||||
remove: [path: (string | number)[]]
|
||||
rename: [path: (string | number)[], value: string]
|
||||
add: [path: (string | number)[], value: string]
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
collapse: { id: 'instance.files.studio.nbt.collapse', defaultMessage: 'Collapse node' },
|
||||
expand: { id: 'instance.files.studio.nbt.expand', defaultMessage: 'Expand node' },
|
||||
add: { id: 'instance.files.studio.nbt.add', defaultMessage: 'Add child' },
|
||||
remove: { id: 'instance.files.studio.nbt.remove', defaultMessage: 'Remove node' },
|
||||
addPlaceholder: {
|
||||
id: 'instance.files.studio.nbt.add-placeholder',
|
||||
defaultMessage: 'name:value',
|
||||
},
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const expanded = ref(props.depth < 1)
|
||||
const editing = ref(false)
|
||||
const renaming = ref(false)
|
||||
const draft = ref('')
|
||||
const renameDraft = ref(props.name ?? '')
|
||||
const adding = ref(false)
|
||||
const addDraft = ref('')
|
||||
|
||||
const expandable = computed(
|
||||
() =>
|
||||
props.tag.isCompound() ||
|
||||
props.tag.isList() ||
|
||||
props.tag.isByteArray() ||
|
||||
props.tag.isIntArray() ||
|
||||
props.tag.isLongArray(),
|
||||
)
|
||||
const typeName = computed(() => NbtType[props.tag.getId()])
|
||||
const displayValue = computed(() => {
|
||||
if (props.tag.isCompound()) return `${props.tag.size} entries`
|
||||
if (props.tag.isList()) return `${props.tag.length} ${NbtType[props.tag.getType()]} values`
|
||||
if (props.tag.isArray()) return `${props.tag.length} values`
|
||||
return props.tag.toString()
|
||||
})
|
||||
|
||||
function children(): Array<{ name?: string; tag: NbtTag; path: (string | number)[] }> {
|
||||
if (props.tag.isCompound()) {
|
||||
return [...props.tag.keys()].map((name) => ({
|
||||
name,
|
||||
tag: props.tag.get(name)!,
|
||||
path: [...props.path, name],
|
||||
}))
|
||||
}
|
||||
if (props.tag.isList()) {
|
||||
return Array.from({ length: props.tag.length }, (_, index) => ({
|
||||
tag: props.tag.get(index),
|
||||
path: [...props.path, index],
|
||||
}))
|
||||
}
|
||||
if (props.tag.isArray()) {
|
||||
return Array.from({ length: props.tag.length }, (_, index) => ({
|
||||
tag: props.tag.get(index),
|
||||
path: [...props.path, index],
|
||||
}))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function beginEdit() {
|
||||
if (props.readOnly || expandable.value) return
|
||||
draft.value = props.tag.isString() ? props.tag.getAsString() : props.tag.toString()
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function commitEdit() {
|
||||
if (draft.value.trim()) emit('edit', props.path, draft.value)
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
function commitRename() {
|
||||
if (renameDraft.value.trim() && renameDraft.value !== props.name) {
|
||||
emit('rename', props.path, renameDraft.value.trim())
|
||||
}
|
||||
renaming.value = false
|
||||
}
|
||||
|
||||
function commitAdd() {
|
||||
if (addDraft.value.trim()) emit('add', props.path, addDraft.value)
|
||||
addDraft.value = ''
|
||||
adding.value = false
|
||||
}
|
||||
|
||||
function forwardEdit(path: (string | number)[], value: string) {
|
||||
emit('edit', path, value)
|
||||
}
|
||||
|
||||
function forwardRename(path: (string | number)[], value: string) {
|
||||
emit('rename', path, value)
|
||||
}
|
||||
|
||||
function forwardAdd(path: (string | number)[], value: string) {
|
||||
emit('add', path, value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="group flex min-h-8 items-center gap-2 rounded px-2 text-sm hover:bg-surface-3"
|
||||
:style="{ paddingLeft: `${depth * 1.25 + 0.5}rem` }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-secondary"
|
||||
:class="expandable ? 'cursor-pointer' : 'cursor-default'"
|
||||
:aria-label="formatMessage(expanded ? messages.collapse : messages.expand)"
|
||||
@click="expandable && (expanded = !expanded)"
|
||||
>
|
||||
<span v-if="expandable">{{ expanded ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<NbtTypeIcon :type="tag.getId()" />
|
||||
<template v-if="name !== undefined">
|
||||
<input
|
||||
v-if="renaming"
|
||||
v-model="renameDraft"
|
||||
class="min-w-0 flex-1 rounded border border-surface-5 bg-surface-1 px-1 text-sm text-contrast"
|
||||
@blur="commitRename"
|
||||
@keydown.enter.prevent="commitRename"
|
||||
/>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="shrink-0 border-0 bg-transparent p-0 text-secondary"
|
||||
:class="{ 'cursor-text': !readOnly }"
|
||||
@dblclick="!readOnly && (renaming = true)"
|
||||
>
|
||||
{{ name }}:
|
||||
</button>
|
||||
</template>
|
||||
<span class="text-xs text-secondary">{{ typeName }}</span>
|
||||
<input
|
||||
v-if="editing"
|
||||
v-model="draft"
|
||||
autofocus
|
||||
class="min-w-0 flex-1 rounded border border-brand bg-surface-1 px-2 py-0.5 font-mono text-xs text-contrast"
|
||||
@blur="commitEdit"
|
||||
@keydown.enter.prevent="commitEdit"
|
||||
@keydown.escape="editing = false"
|
||||
/>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="min-w-0 truncate border-0 bg-transparent p-0 text-left font-mono text-xs text-primary"
|
||||
:class="{ 'cursor-text': !expandable && !readOnly }"
|
||||
@dblclick="beginEdit"
|
||||
>
|
||||
{{ displayValue }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!readOnly && expandable"
|
||||
type="button"
|
||||
class="ml-auto hidden rounded border-0 bg-transparent px-1 text-xs text-secondary group-hover:inline-flex hover:text-contrast"
|
||||
:aria-label="formatMessage(messages.add)"
|
||||
@click="adding = !adding"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
v-if="!readOnly && path.length > 0"
|
||||
type="button"
|
||||
class="hidden rounded border-0 bg-transparent px-1 text-xs text-secondary group-hover:inline-flex hover:text-red"
|
||||
:aria-label="formatMessage(messages.remove)"
|
||||
@click="emit('remove', path)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="adding"
|
||||
class="flex items-center gap-2 px-3 py-1"
|
||||
:style="{ paddingLeft: `${(depth + 1) * 1.25 + 2.25}rem` }"
|
||||
>
|
||||
<input
|
||||
v-model="addDraft"
|
||||
autofocus
|
||||
class="min-w-0 flex-1 rounded border border-surface-5 bg-surface-1 px-2 py-1 font-mono text-xs text-contrast"
|
||||
:placeholder="formatMessage(messages.addPlaceholder)"
|
||||
@keydown.enter="commitAdd"
|
||||
@blur="commitAdd"
|
||||
@keydown.escape="adding = false"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="expanded && expandable">
|
||||
<NbtTreeNode
|
||||
v-for="child in children()"
|
||||
:key="child.path.join('.')"
|
||||
:name="child.name"
|
||||
:tag="child.tag"
|
||||
:path="child.path"
|
||||
:depth="depth + 1"
|
||||
:read-only="readOnly"
|
||||
@edit="forwardEdit"
|
||||
@remove="emit('remove', $event)"
|
||||
@rename="forwardRename"
|
||||
@add="forwardAdd"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { NbtType } from 'deepslate/nbt'
|
||||
|
||||
defineProps<{
|
||||
type: NbtType
|
||||
}>()
|
||||
|
||||
const colors: Record<NbtType, string> = {
|
||||
[NbtType.End]: 'text-secondary',
|
||||
[NbtType.Byte]: 'text-orange',
|
||||
[NbtType.Short]: 'text-yellow',
|
||||
[NbtType.Int]: 'text-green',
|
||||
[NbtType.Long]: 'text-blue',
|
||||
[NbtType.Float]: 'text-purple',
|
||||
[NbtType.Double]: 'text-pink',
|
||||
[NbtType.ByteArray]: 'text-orange',
|
||||
[NbtType.String]: 'text-brand',
|
||||
[NbtType.List]: 'text-cyan',
|
||||
[NbtType.Compound]: 'text-contrast',
|
||||
[NbtType.IntArray]: 'text-green',
|
||||
[NbtType.LongArray]: 'text-blue',
|
||||
}
|
||||
|
||||
const labels: Partial<Record<NbtType, string>> = {
|
||||
[NbtType.Byte]: 'B',
|
||||
[NbtType.Short]: 'S',
|
||||
[NbtType.Int]: 'I',
|
||||
[NbtType.Long]: 'L',
|
||||
[NbtType.Float]: 'F',
|
||||
[NbtType.Double]: 'D',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="size-4 shrink-0"
|
||||
:class="colors[type]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path v-if="type === NbtType.Compound" d="M3 2.5h7l3 3v8H3zM10 2.5v3h3" />
|
||||
<path v-else-if="type === NbtType.List" d="M3 3h10M3 8h10M3 13h10M5 3v10" />
|
||||
<path
|
||||
v-else-if="type === NbtType.String"
|
||||
d="M4 3h8M4 13h8M5 3c-3 2-3 8 0 10M11 3c3 2 3 8 0 10"
|
||||
/>
|
||||
<path
|
||||
v-else-if="
|
||||
type === NbtType.ByteArray || type === NbtType.IntArray || type === NbtType.LongArray
|
||||
"
|
||||
d="M4 2.5h8v11H4zM6.5 5h3M6.5 8h3M6.5 11h3"
|
||||
/>
|
||||
<path v-else d="M3 3h10v10H3z" />
|
||||
<text
|
||||
v-if="labels[type]"
|
||||
x="8"
|
||||
y="11"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
text-anchor="middle"
|
||||
font-size="7"
|
||||
font-weight="700"
|
||||
>
|
||||
{{ labels[type] }}
|
||||
</text>
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,279 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import type * as Monaco from 'monaco-editor'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__axolotlMonacoRuntime?: Promise<typeof Monaco>
|
||||
require?: {
|
||||
config(config: Record<string, unknown>): void
|
||||
(dependencies: string[], callback: (monaco: typeof Monaco) => void): void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
filePath: string
|
||||
language: string
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [content: string]
|
||||
save: []
|
||||
blur: []
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
loading: {
|
||||
id: 'instance.files.studio.editor-loading',
|
||||
defaultMessage: 'Loading editor...',
|
||||
},
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const editorElement = ref<HTMLElement | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
let monaco: typeof Monaco | null = null
|
||||
let editor: Monaco.editor.IStandaloneCodeEditor | null = null
|
||||
let model: Monaco.editor.ITextModel | null = null
|
||||
let contentSubscription: Monaco.IDisposable | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let themeObserver: MutationObserver | null = null
|
||||
let applyingExternalContent = false
|
||||
let disposed = false
|
||||
|
||||
function loadCodiconStyles() {
|
||||
if (document.querySelector('link[data-monaco-codicons]')) return
|
||||
const stylesheet = document.createElement('link')
|
||||
stylesheet.rel = 'stylesheet'
|
||||
stylesheet.href = '/monaco/codicon/codicon.css'
|
||||
stylesheet.dataset.monacoCodicons = 'true'
|
||||
document.head.append(stylesheet)
|
||||
}
|
||||
|
||||
function loadMonaco(): Promise<typeof Monaco> {
|
||||
if (window.__axolotlMonacoRuntime) return window.__axolotlMonacoRuntime
|
||||
|
||||
window.__axolotlMonacoRuntime = new Promise((resolve, reject) => {
|
||||
loadCodiconStyles()
|
||||
const initialize = () => {
|
||||
const require = window.require
|
||||
if (!require) {
|
||||
reject(new Error('Monaco loader did not initialize'))
|
||||
return
|
||||
}
|
||||
require.config({ paths: { vs: '/monaco/vs' } })
|
||||
require(['vs/editor/editor.main'], (loadedMonaco: typeof Monaco) => resolve(loadedMonaco))
|
||||
}
|
||||
|
||||
if (window.require) {
|
||||
initialize()
|
||||
return
|
||||
}
|
||||
|
||||
const existingLoader = document.querySelector<HTMLScriptElement>('script[data-monaco-loader]')
|
||||
if (existingLoader) {
|
||||
existingLoader.addEventListener('load', initialize, { once: true })
|
||||
existingLoader.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error('Failed to load Monaco editor')),
|
||||
{ once: true },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const loader = document.createElement('script')
|
||||
loader.src = '/monaco/vs/loader.js'
|
||||
loader.dataset.monacoLoader = 'true'
|
||||
loader.onload = initialize
|
||||
loader.onerror = () => reject(new Error('Failed to load Monaco editor'))
|
||||
document.head.append(loader)
|
||||
})
|
||||
|
||||
return window.__axolotlMonacoRuntime
|
||||
}
|
||||
|
||||
function cssVariable(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
if (!monaco) return
|
||||
const isLight = document.documentElement.classList.contains('light-mode')
|
||||
monaco.editor.defineTheme('axolotl-studio', {
|
||||
base: isLight ? 'vs' : 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': cssVariable('--surface-2'),
|
||||
'editor.foreground': cssVariable('--color-base'),
|
||||
'editorGutter.background': cssVariable('--surface-2'),
|
||||
'editorLineNumber.foreground': cssVariable('--color-secondary'),
|
||||
'editor.lineHighlightBackground': cssVariable('--surface-3'),
|
||||
'editorCursor.foreground': cssVariable('--color-brand'),
|
||||
},
|
||||
})
|
||||
monaco.editor.setTheme('axolotl-studio')
|
||||
}
|
||||
|
||||
function registerStudioLanguages() {
|
||||
if (!monaco) return
|
||||
|
||||
if (!monaco.languages.getLanguages().some(({ id }) => id === 'toml')) {
|
||||
monaco.languages.register({ id: 'toml', extensions: ['.toml'] })
|
||||
monaco.languages.setMonarchTokensProvider('toml', {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/#.*/, 'comment'],
|
||||
[/\[\[?.*?\]\]?/, 'type.identifier'],
|
||||
[/^[\w.-]+(?=\s*=)/, 'key'],
|
||||
[/"([^"\\]|\\.)*"/, 'string'],
|
||||
[/'[^']*'/, 'string'],
|
||||
[/\b(true|false)\b/, 'keyword'],
|
||||
[/[-+]?\b\d+(\.\d+)?\b/, 'number'],
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!monaco.languages.getLanguages().some(({ id }) => id === 'properties')) {
|
||||
monaco.languages.register({ id: 'properties', extensions: ['.properties'] })
|
||||
monaco.languages.setMonarchTokensProvider('properties', {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/^[#!].*$/, 'comment'],
|
||||
[/^[^\s:=]+(?=\s*[:=])/, 'key'],
|
||||
[/[:=]/, 'delimiter'],
|
||||
[/\\./, 'string.escape'],
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!monaco.languages.getLanguages().some(({ id }) => id === 'snbt')) {
|
||||
monaco.languages.register({ id: 'snbt' })
|
||||
monaco.languages.setMonarchTokensProvider('snbt', {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/\/\/.*$/, 'comment'],
|
||||
[/[{}[\],:]/, 'delimiter'],
|
||||
[/(?:true|false)\b/, 'keyword'],
|
||||
[/-?(?:\d+\.?\d*|\.\d+)(?:[bBsSlLfFdD])?\b/, 'number'],
|
||||
[/'(?:[^'\\]|\\.)*'/, 'string'],
|
||||
[/"(?:[^"\\]|\\.)*"/, 'string'],
|
||||
[/[A-Za-z0-9_.+-]+(?=\s*:)/, 'key'],
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function createModel() {
|
||||
if (!monaco || !editor) return
|
||||
contentSubscription?.dispose()
|
||||
model?.dispose()
|
||||
model = monaco.editor.createModel(
|
||||
props.content,
|
||||
props.language,
|
||||
monaco.Uri.parse(
|
||||
`axolotl-instance://studio/${props.filePath.split('/').map(encodeURIComponent).join('/')}`,
|
||||
),
|
||||
)
|
||||
editor.setModel(model)
|
||||
contentSubscription = model.onDidChangeContent(() => {
|
||||
if (!applyingExternalContent) emit('update:content', model?.getValue() ?? '')
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
monaco = await loadMonaco()
|
||||
} catch (error) {
|
||||
loading.value = false
|
||||
console.error('Failed to load Monaco editor', error)
|
||||
return
|
||||
}
|
||||
if (disposed) return
|
||||
registerStudioLanguages()
|
||||
applyTheme()
|
||||
|
||||
if (!editorElement.value) return
|
||||
editor = monaco.editor.create(editorElement.value, {
|
||||
automaticLayout: false,
|
||||
fontSize: 14,
|
||||
fontLigatures: false,
|
||||
minimap: { enabled: true },
|
||||
padding: { top: 12 },
|
||||
readOnly: props.readOnly,
|
||||
renderWhitespace: 'selection',
|
||||
scrollBeyondLastLine: false,
|
||||
theme: 'axolotl-studio',
|
||||
})
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => emit('save'))
|
||||
editor.onDidBlurEditorWidget(() => emit('blur'))
|
||||
createModel()
|
||||
|
||||
resizeObserver = new ResizeObserver(() => editor?.layout())
|
||||
resizeObserver.observe(editorElement.value)
|
||||
themeObserver = new MutationObserver(applyTheme)
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
loading.value = false
|
||||
})
|
||||
watch(
|
||||
() => props.filePath,
|
||||
() => createModel(),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.content,
|
||||
(content) => {
|
||||
if (!model || model.getValue() === content) return
|
||||
applyingExternalContent = true
|
||||
model.setValue(content)
|
||||
applyingExternalContent = false
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.language,
|
||||
(language) => {
|
||||
if (monaco && model) monaco.editor.setModelLanguage(model, language)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.readOnly,
|
||||
(readOnly) => editor?.updateOptions({ readOnly }),
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
contentSubscription?.dispose()
|
||||
resizeObserver?.disconnect()
|
||||
themeObserver?.disconnect()
|
||||
editor?.dispose()
|
||||
model?.dispose()
|
||||
})
|
||||
|
||||
async function formatDocument() {
|
||||
await editor?.getAction('editor.action.formatDocument')?.run()
|
||||
}
|
||||
|
||||
defineExpose({ formatDocument })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative size-full min-h-0 min-w-0 bg-surface-2">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="absolute inset-0 z-[1] flex items-center justify-center text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</div>
|
||||
<div ref="editorElement" class="size-full min-h-0 min-w-0" />
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { FileCodeIcon, XIcon } from '@modrinth/assets'
|
||||
import { commonMessages, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import type { StudioDocument } from './useStudioDocuments'
|
||||
|
||||
defineProps<{
|
||||
documents: StudioDocument[]
|
||||
activePath: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
activate: [path: string]
|
||||
close: [path: string]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
let middleClickPath: string | null = null
|
||||
|
||||
function handleWheel(event: WheelEvent) {
|
||||
const container = event.currentTarget as HTMLElement
|
||||
if (container.scrollWidth <= container.clientWidth) return
|
||||
event.preventDefault()
|
||||
container.scrollLeft += event.deltaY || event.deltaX
|
||||
}
|
||||
|
||||
function handleAuxClick(event: MouseEvent, path: string) {
|
||||
if (event.button !== 1) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (middleClickPath === path) {
|
||||
middleClickPath = null
|
||||
return
|
||||
}
|
||||
emit('close', path)
|
||||
}
|
||||
|
||||
function handleMouseDown(event: MouseEvent, path: string) {
|
||||
if (event.button !== 1) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
middleClickPath = path
|
||||
emit('close', path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-w-0 flex-1 overflow-x-auto" @wheel="handleWheel">
|
||||
<div
|
||||
v-for="document in documents"
|
||||
:key="document.path"
|
||||
role="tab"
|
||||
tabindex="0"
|
||||
:aria-selected="document.path === activePath"
|
||||
class="flex h-full max-w-[14rem] min-w-[8rem] shrink-0 select-none items-center gap-2 border-0 border-r border-solid border-surface-4 px-3 text-left text-sm text-secondary hover:bg-surface-2"
|
||||
:class="{ 'bg-surface-2 !text-contrast': document.path === activePath }"
|
||||
@click="emit('activate', document.path)"
|
||||
@mousedown="handleMouseDown($event, document.path)"
|
||||
@auxclick="handleAuxClick($event, document.path)"
|
||||
@keydown.enter="emit('activate', document.path)"
|
||||
@keydown.space.prevent="emit('activate', document.path)"
|
||||
>
|
||||
<XIcon v-if="document.kind === 'unsupported'" class="size-4 shrink-0 text-red" />
|
||||
<FileCodeIcon v-else class="size-4 shrink-0 text-secondary" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ document.name }}</span>
|
||||
<span
|
||||
v-if="document.content !== document.savedContent"
|
||||
class="size-2 shrink-0 rounded-full bg-brand"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="formatMessage(commonMessages.closeButton)"
|
||||
class="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-secondary hover:bg-surface-4 hover:text-contrast"
|
||||
@pointerdown.stop
|
||||
@click.stop.prevent="emit('close', document.path)"
|
||||
>
|
||||
<XIcon class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,137 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export interface StudioDocument {
|
||||
kind: 'text' | 'nbt' | 'image' | 'video' | 'unsupported'
|
||||
path: string
|
||||
name: string
|
||||
content: string
|
||||
savedContent: string
|
||||
saving: boolean
|
||||
}
|
||||
|
||||
export function useStudioDocuments(
|
||||
writeDocument: (document: StudioDocument, content: string) => Promise<void>,
|
||||
onSaveError: (error: unknown) => void,
|
||||
) {
|
||||
const documents = ref<StudioDocument[]>([])
|
||||
const activeIndex = ref(-1)
|
||||
const savePromises = new Map<string, Promise<boolean>>()
|
||||
|
||||
const activeDocument = computed(() => documents.value[activeIndex.value] ?? null)
|
||||
const activePath = computed(() => activeDocument.value?.path ?? '')
|
||||
const hasUnsavedChanges = computed(
|
||||
() =>
|
||||
activeDocument.value !== null &&
|
||||
activeDocument.value.content !== activeDocument.value.savedContent,
|
||||
)
|
||||
const hasAnyUnsavedChanges = computed(() =>
|
||||
documents.value.some((document) => document.content !== document.savedContent),
|
||||
)
|
||||
|
||||
function saveDocument(document: StudioDocument | null): Promise<boolean> {
|
||||
if (
|
||||
!document ||
|
||||
(document.kind !== 'text' && document.kind !== 'nbt') ||
|
||||
document.content === document.savedContent
|
||||
) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
|
||||
const existingPromise = savePromises.get(document.path)
|
||||
if (existingPromise) return existingPromise
|
||||
|
||||
document.saving = true
|
||||
const contentToSave = document.content
|
||||
const savePromise = writeDocument(document, contentToSave)
|
||||
.then(() => {
|
||||
document.savedContent = contentToSave
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
onSaveError(error)
|
||||
return false
|
||||
})
|
||||
.finally(() => {
|
||||
document.saving = false
|
||||
savePromises.delete(document.path)
|
||||
})
|
||||
|
||||
savePromises.set(document.path, savePromise)
|
||||
return savePromise
|
||||
}
|
||||
|
||||
async function activate(path: string) {
|
||||
if (path === activePath.value) return true
|
||||
if (!(await saveDocument(activeDocument.value))) return false
|
||||
const nextIndex = documents.value.findIndex((document) => document.path === path)
|
||||
if (nextIndex === -1) return false
|
||||
activeIndex.value = nextIndex
|
||||
return true
|
||||
}
|
||||
|
||||
async function open(document: StudioDocument) {
|
||||
const existing = documents.value.find((candidate) => candidate.path === document.path)
|
||||
if (existing) return activate(existing.path)
|
||||
if (!(await saveDocument(activeDocument.value))) return false
|
||||
documents.value.push(document)
|
||||
activeIndex.value = documents.value.length - 1
|
||||
return true
|
||||
}
|
||||
|
||||
async function close(path: string) {
|
||||
const index = documents.value.findIndex((document) => document.path === path)
|
||||
if (index === -1) return false
|
||||
if (!(await saveDocument(documents.value[index]))) return false
|
||||
|
||||
const wasActive = activeIndex.value === index
|
||||
documents.value.splice(index, 1)
|
||||
if (documents.value.length === 0) {
|
||||
activeIndex.value = -1
|
||||
} else if (wasActive) {
|
||||
activeIndex.value = Math.min(index, documents.value.length - 1)
|
||||
} else if (index < activeIndex.value) {
|
||||
activeIndex.value -= 1
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function updateActiveContent(content: string) {
|
||||
if (activeDocument.value) activeDocument.value.content = content
|
||||
}
|
||||
|
||||
function discardActiveChanges() {
|
||||
if (activeDocument.value) activeDocument.value.content = activeDocument.value.savedContent
|
||||
}
|
||||
|
||||
async function saveActive() {
|
||||
return saveDocument(activeDocument.value)
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
const results = await Promise.all(documents.value.map((document) => saveDocument(document)))
|
||||
return results.every(Boolean)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
documents.value = []
|
||||
activeIndex.value = -1
|
||||
savePromises.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
documents,
|
||||
activeDocument,
|
||||
activePath,
|
||||
hasUnsavedChanges,
|
||||
hasAnyUnsavedChanges,
|
||||
activate,
|
||||
open,
|
||||
close,
|
||||
saveDocument,
|
||||
saveActive,
|
||||
saveAll,
|
||||
updateActiveContent,
|
||||
discardActiveChanges,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user