Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
cd59718
feat(view): read an edited draw.io file back as DiagramLayout edits
devin-ai-integration[bot] Oct 29, 2026
732216a
test(view): read the compressed draw.io desktop save and malformed files
devin-ai-integration[bot] Oct 29, 2026
3530143
fix(view): pair draw.io cells by id before name, place a start by its…
devin-ai-integration[bot] Oct 29, 2026
bc3611b
feat(edit): write Note and Picture annotations, and the edits a draw.…
devin-ai-integration[bot] Oct 29, 2026
f8bd8a3
fix(semantics): keep no annotations read while the about index is bei…
devin-ai-integration[bot] Oct 29, 2026
02681cd
fix(view): emit draw.io import edits in the file's cell order
devin-ai-integration[bot] Oct 29, 2026
f267c90
fix(semantics): keep no filter verdict or import enumeration read whi…
devin-ai-integration[bot] Oct 29, 2026
05670eb
fix(view): round trip the imported draw.io edits through the model an…
devin-ai-integration[bot] Oct 29, 2026
b86bd1d
Merge remote-tracking branch 'origin/develop' into feat/drawio-import
devin-ai-integration[bot] Oct 29, 2026
1f2722b
test(view): re-save the draw.io desktop fixture against the routed ex…
devin-ai-integration[bot] Oct 29, 2026
a9ecd01
fix(view): name nested-view elements by their own path and keep typin…
devin-ai-integration[bot] Oct 29, 2026
333744d
fix(view): keep the children of a pinned container where the file sho…
devin-ai-integration[bot] Oct 29, 2026
26780b9
style(view): keep the pin, fitted and pinning-test comments to two lines
devin-ai-integration[bot] Oct 29, 2026
c63fea5
feat(cli,lsp,mcp): import an edited draw.io file back into the model
devin-ai-integration[bot] Oct 29, 2026
4690034
feat(vscode): Import layout from draw.io… applies an edited diagram a…
devin-ai-integration[bot] Oct 29, 2026
d0f87bd
docs(view): document the draw.io round trip across the CLI, LSP, MCP …
devin-ai-integration[bot] Oct 29, 2026
701b88a
fix(cli,lsp,mcp): make the draw.io import safe to apply
devin-ai-integration[bot] Oct 29, 2026
97c17c1
Merge origin/develop into feat/drawio-import
devin-ai-integration[bot] Oct 29, 2026
78dc4cd
test(cli,mcp): refuse an empty -import-diagram and a draw.io file out…
devin-ai-integration[bot] Oct 29, 2026
4bf41fe
docs(self-model): list the opensysml.importDiagram command
devin-ai-integration[bot] Oct 29, 2026
5ff3aa9
fix(cli): write imported layouts through model symlinks and leave unc…
devin-ai-integration[bot] Oct 29, 2026
c6853cb
fix(cli,view): name ports in a draw.io export as the import does
devin-ai-integration[bot] Oct 29, 2026
3c4611c
fix(view): place a port by its fraction of its owner when comparing i…
devin-ai-integration[bot] Oct 29, 2026
46720b7
fix(view): notice a port dragged onto another cell in a draw.io import
devin-ai-integration[bot] Oct 29, 2026
fc13fde
fix(lsp): hand the draw.io writer the declaration sites on every export
devin-ai-integration[bot] Oct 29, 2026
eb03991
Merge origin/develop into feat/drawio-import
devin-ai-integration[bot] Oct 29, 2026
750c143
Merge remote-tracking branch 'origin/develop' into feat/drawio-import
devin-ai-integration[bot] Oct 29, 2026
eef08d6
test(cli): derive the re-routed connection's expected Route from the …
devin-ai-integration[bot] Oct 29, 2026
5f5a2a8
Merge remote-tracking branch 'origin/develop' into feat/drawio-import
devin-ai-integration[bot] Oct 29, 2026
1d8066d
chore(referee): re-record the api-json and pilot baselines for the ex…
devin-ai-integration[bot] Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/unreleased/drawio-import.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- A draw.io file the `drawio` rendering form wrote and a user edited in draw.io is imported back as `DiagramLayout` metadata (`Layout`, `Route`, `Style`, `Canvas`, `Note`, `Picture`) through the model editor, by `sysml -import-diagram <file> [-view <name>] [-dry-run]`, the LSP request `opensysml/importDiagram`, the MCP tool `import_diagram` and the VS Code command *Import layout from draw.io…*; only cells that differ from a fresh export are written, and cells the model cannot carry are reported. The LSP model edit and MCP `apply_model_edit` gain `setNote` and `setPicture`.
210 changes: 210 additions & 0 deletions cmd/sysml/import_diagram.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package main

import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"

modeledit "github.com/Open-MBEE/OpenSysML/internal/check/edit"
"github.com/Open-MBEE/OpenSysML/internal/ir/view"
"github.com/Open-MBEE/OpenSysML/internal/workspace/model"
)

// runImportDiagram reads a draw.io file the drawio form wrote and the user
// edited, and writes what moved back into the model as DiagramLayout metadata.
func runImportDiagram(files []string) error {
if len(files) == 0 {
return errors.New("no model to import into; name the files the view is declared in, as `sysml model.sysml -import-diagram view.drawio`")
}
data, err := os.ReadFile(importDiagramPath)
if err != nil {
return err
}
file, err := view.ReadDrawio(data, importView)
if err != nil {
return fmt.Errorf("%s: %w", importDiagramPath, err)
}
sess, err := loadArtifactModel(files, "nothing was imported")
if err != nil {
return err
}
rendering, err := sess.OverlaidViewRendering(file.View, "", 0)
if err != nil {
return err
}
sites, err := sess.ViewSites()
if err != nil {
return err
}
imp, err := rendering.ImportDrawio(file, view.Options{Links: view.Links{Sites: sites}})
if err != nil {
return fmt.Errorf("%s: %w", importDiagramPath, err)
}
reportDrawioImport(imp)
if len(imp.Edits) == 0 {
fmt.Fprintf(os.Stderr, "%s: nothing differs from the rendering of %s; nothing to import\n", importDiagramPath, file.View)
return nil
}
ops := modeledit.DrawioOperations(imp, false)
doc := drawioImportDocument(imp, files)
result, err := sess.ApplyModelEdit(doc, ops)
if err != nil {
var refusal *modeledit.Error
if errors.As(err, &refusal) {
if i := refusal.OperationIndex; i >= 0 && i < len(imp.Edits) {
return fmt.Errorf("%s: %s is not imported: %s", importDiagramPath, drawioEditTarget(imp.Edits[i]), refusal.Message)
}
return fmt.Errorf("%s: not imported: %s", importDiagramPath, refusal.Message)
}
return err
}
if dryRun {
for _, edited := range result.Documents {
fmt.Fprint(os.Stdout, model.UnifiedDiff(edited.Name, edited.Original, edited.Content))
}
fmt.Fprintf(os.Stderr, "dry run: %d edits planned, nothing written\n", len(imp.Edits))
return nil
}
if err := commitEditedDocuments(result.Documents); err != nil {
return err
}
for _, edited := range result.Documents {
if !bytes.Equal(edited.Original, edited.Content) {
fmt.Fprintf(os.Stderr, "%s: written\n", edited.Name)
}
}
return nil
}

// commitEditedDocuments rewrites the model files as a set: each is staged
// beside its file and swapped in; a failure puts every file back as it was.
func commitEditedDocuments(documents []model.DocumentEdit) (err error) {
type swap struct {
target, backup string
movedAside bool
}
var done []swap
defer func() {
if err == nil {
return
}
for i := len(done) - 1; i >= 0; i-- {
restoreBackup(done[i].target, done[i].backup, done[i].movedAside)
}
}()
staged := make([]string, 0, len(documents))
defer func() {
for _, name := range staged {
_ = os.Remove(name)
}
}()
targets := map[string]string{}
for _, edited := range documents {
if bytes.Equal(edited.Original, edited.Content) {
continue
}
target, statErr := filepath.EvalSymlinks(edited.Name)
if statErr != nil {
return statErr
}
if other, dup := targets[target]; dup {
return fmt.Errorf("write %s: %s is the same file", edited.Name, other)
}
targets[target] = edited.Name
info, statErr := os.Stat(target)
if statErr != nil {
return statErr
}
name, stageErr := stageEdited(target, edited.Content, info.Mode().Perm())
if name != "" {
staged = append(staged, name)
}
if stageErr != nil {
return stageErr
}
backup, movedAside, backupErr := backUp(target)
if backupErr != nil {
return fmt.Errorf("write %s: %w", edited.Name, backupErr)
}
done = append(done, swap{target, backup, movedAside})
if renameErr := os.Rename(name, target); renameErr != nil {
return fmt.Errorf("write %s: %w", edited.Name, renameErr)
}
}
for _, s := range done {
_ = os.Remove(s.backup)
}
done = nil
return nil
}

// stageEdited writes the edited document to a temporary file beside its own,
// with the same permissions, and names it.
func stageEdited(target string, content []byte, perm fs.FileMode) (string, error) {
file, err := os.CreateTemp(filepath.Dir(target), ".sysml-staged-*")
if err != nil {
return "", fmt.Errorf("stage %s: %w", target, err)
}
name := file.Name()
if _, err := file.Write(content); err != nil {
_ = file.Close()
return name, fmt.Errorf("stage %s: %w", target, err)
}
if err := file.Sync(); err != nil {
_ = file.Close()
return name, fmt.Errorf("stage %s: %w", target, err)
}
if err := file.Close(); err != nil {
return name, fmt.Errorf("stage %s: %w", target, err)
}
if err := os.Chmod(name, perm); err != nil {
return name, fmt.Errorf("stage %s: %w", target, err)
}
return name, nil
}

// reportDrawioImport lists the edits an import plans and what it leaves alone.
func reportDrawioImport(imp *view.DrawioImport) {
for _, e := range imp.Edits {
fmt.Fprintf(os.Stdout, "%s about %s\n", e.Kind, drawioEditTarget(e))
}
for _, name := range imp.Unmatched {
fmt.Fprintf(os.Stderr, "%s: %s is not in the rendering; its cell is not imported\n", importDiagramPath, name)
}
for _, cell := range imp.Unknown {
fmt.Fprintf(os.Stderr, "%s: cell %s names no element; it is not imported\n", importDiagramPath, cell.ID)
}
for _, name := range imp.Deleted {
fmt.Fprintf(os.Stderr, "%s: %s was deleted from the file; the element stays in the model\n", importDiagramPath, name)
}
for _, notice := range imp.Notices {
fmt.Fprintf(os.Stderr, "%s: %s\n", importDiagramPath, notice)
}
}

func drawioEditTarget(e view.DrawioEdit) string {
switch {
case e.Kind == view.DrawioEditCanvas:
return "the page"
case e.Edge != nil:
return e.Edge.String()
case e.FQN != "":
return e.FQN
}
return strings.ReplaceAll(e.Label, "\n", " ")
}

// drawioImportDocument is the session document the edits are applied through:
// the one declaring the first edited element, else the first file named.
func drawioImportDocument(imp *view.DrawioImport, files []string) string {
for _, e := range imp.Edits {
if e.Origin.Located() {
return e.Origin.Doc
}
}
return files[0]
}
Loading
Loading