@likec4/language-server 1.44.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/LikeC4LanguageServices.d.ts +1 -15
  2. package/dist/LikeC4LanguageServices.js +2 -32
  3. package/dist/Rpc.js +32 -20
  4. package/dist/ast.js +6 -2
  5. package/dist/browser.js +2 -2
  6. package/dist/bundled.js +2 -0
  7. package/dist/bundled.mjs +3184 -3162
  8. package/dist/filesystem/ChokidarWatcher.d.ts +2 -0
  9. package/dist/filesystem/ChokidarWatcher.js +27 -16
  10. package/dist/filesystem/LikeC4FileSystem.js +1 -1
  11. package/dist/index.d.ts +3 -1
  12. package/dist/index.js +5 -3
  13. package/dist/mcp/server/StdioLikeC4MCPServer.js +10 -6
  14. package/dist/mcp/server/StreamableLikeC4MCPServer.js +97 -97
  15. package/dist/mcp/server/WithMCPServer.js +5 -5
  16. package/dist/mcp/tools/search-element.js +5 -5
  17. package/dist/mcp/utils.js +1 -1
  18. package/dist/model/deployments-index.js +2 -2
  19. package/dist/model/fqn-index.d.ts +1 -2
  20. package/dist/model/fqn-index.js +13 -16
  21. package/dist/model/model-builder.js +0 -2
  22. package/dist/model/model-parser.js +34 -27
  23. package/dist/model/parser/SpecificationParser.js +4 -0
  24. package/dist/model/parser/ViewsParser.js +3 -1
  25. package/dist/model-change/ModelChanges.d.ts +2 -2
  26. package/dist/model-change/ModelChanges.js +36 -9
  27. package/dist/protocol.d.ts +33 -10
  28. package/dist/protocol.js +13 -4
  29. package/dist/view-utils/manual-layout.js +2 -4
  30. package/dist/views/LikeC4ManualLayouts.d.ts +16 -2
  31. package/dist/views/LikeC4ManualLayouts.js +99 -22
  32. package/dist/views/LikeC4Views.d.ts +26 -5
  33. package/dist/views/LikeC4Views.js +49 -33
  34. package/dist/workspace/AstNodeDescriptionProvider.js +6 -3
  35. package/dist/workspace/IndexManager.js +1 -1
  36. package/dist/workspace/LangiumDocuments.d.ts +3 -2
  37. package/dist/workspace/LangiumDocuments.js +29 -15
  38. package/dist/workspace/ProjectsManager.d.ts +19 -15
  39. package/dist/workspace/ProjectsManager.js +137 -41
  40. package/dist/workspace/WorkspaceManager.js +5 -0
  41. package/package.json +16 -15
@@ -1,11 +1,9 @@
1
1
  import { DefaultWeakMap, invariant, MultiMap } from '@likec4/core/utils';
2
- import { loggable } from '@likec4/log';
3
2
  import { DocumentState, UriUtils } from 'langium';
4
3
  import { pipe } from 'remeda';
5
4
  import { DiagnosticSeverity } from 'vscode-languageserver-types';
6
5
  import { isLikeC4LangiumDocument } from '../ast';
7
- import { isLikeC4Builtin } from '../likec4lib';
8
- import { logger as rootLogger } from '../logger';
6
+ import { logger as rootLogger, logWarnError } from '../logger';
9
7
  import { BaseParser } from './parser/Base';
10
8
  import { DeploymentModelParser } from './parser/DeploymentModelParser';
11
9
  import { DeploymentViewParser } from './parser/DeploymentViewParser';
@@ -25,24 +23,34 @@ export class LikeC4ModelParser {
25
23
  cachedParsers = new DefaultWeakMap((doc) => this.createParser(doc));
26
24
  constructor(services) {
27
25
  this.services = services;
28
- services.shared.workspace.DocumentBuilder.onDocumentPhase(DocumentState.Linked, doc => {
29
- try {
26
+ services.shared.workspace.DocumentBuilder.onDocumentPhase(DocumentState.Linked, async (doc) => {
27
+ if (this.cachedParsers.has(doc)) {
28
+ logger.trace('Linked: clear cached parser {projectId} document {doc}', {
29
+ projectId: doc.likec4ProjectId,
30
+ doc: UriUtils.basename(doc.uri),
31
+ });
32
+ this.cachedParsers.delete(doc);
33
+ }
34
+ });
35
+ services.shared.workspace.DocumentBuilder.onBuildPhase(DocumentState.Linked, async (docs) => {
36
+ for (const doc of docs) {
30
37
  if (services.shared.workspace.ProjectsManager.isExcluded(doc)) {
31
- return;
38
+ continue;
32
39
  }
33
- if (!isLikeC4Builtin(doc.uri)) {
34
- this.cachedParsers.set(doc, this.createParser(doc));
40
+ try {
41
+ // Force create parser for linked document (if not yet created)
42
+ this.parse(doc);
43
+ }
44
+ catch (error) {
45
+ logWarnError(error);
35
46
  }
36
- }
37
- catch (e) {
38
- logger.warn(loggable(e));
39
47
  }
40
48
  });
41
49
  // We need to clean up cached parser when document is validated and has errors
42
50
  // Because after that parser takes into account validation results
43
- services.shared.workspace.DocumentBuilder.onDocumentPhase(DocumentState.Validated, doc => {
51
+ services.shared.workspace.DocumentBuilder.onDocumentPhase(DocumentState.Validated, async (doc) => {
44
52
  if (doc.diagnostics?.some(d => d.severity === DiagnosticSeverity.Error) && this.cachedParsers.has(doc)) {
45
- logger.debug('clear cached parser {projectId} document {doc}', {
53
+ logger.trace('Validated: clear cached parser {projectId} document {doc} because of errors', {
46
54
  projectId: doc.likec4ProjectId,
47
55
  doc: UriUtils.basename(doc.uri),
48
56
  });
@@ -54,31 +62,30 @@ export class LikeC4ModelParser {
54
62
  return this.services.shared.workspace.LangiumDocuments.projectDocuments(projectId).map(d => this.parse(d));
55
63
  }
56
64
  parse(doc) {
57
- try {
58
- const parser = this.forDocument(doc);
59
- return parser.doc;
60
- }
61
- catch (cause) {
62
- throw new Error(`Error parsing document ${doc.uri.toString()}`, { cause });
63
- }
65
+ const parser = this.forDocument(doc);
66
+ return parser.doc;
64
67
  }
65
68
  forDocument(doc) {
66
- if (doc.state < DocumentState.Linked) {
67
- logger.warn(`Document {doc} is not linked`, { doc: doc.uri.toString() });
68
- }
69
69
  return this.cachedParsers.get(doc);
70
70
  }
71
71
  createParser(doc) {
72
72
  invariant(isLikeC4LangiumDocument(doc), `Document ${doc.uri.toString()} is not a LikeC4 document`);
73
+ const docbasename = UriUtils.basename(doc.uri);
73
74
  if (doc.likec4ProjectId) {
74
- logger.debug(`create parser {projectId} document {doc}`, {
75
+ logger.trace(`create parser {projectId} document {doc}`, {
75
76
  projectId: doc.likec4ProjectId,
76
- doc: UriUtils.basename(doc.uri),
77
+ doc: docbasename,
77
78
  });
78
79
  }
79
80
  else {
80
81
  logger.warn(`create parser for document without project {doc}`, { doc: doc.uri.fsPath });
81
82
  }
83
+ if (doc.state < DocumentState.Linked) {
84
+ logger.warn(`Document {doc} is not linked, state is {state}`, {
85
+ doc: docbasename,
86
+ state: doc.state,
87
+ });
88
+ }
82
89
  const props = {
83
90
  c4Specification: {
84
91
  tags: {},
@@ -111,8 +118,8 @@ export class LikeC4ModelParser {
111
118
  parser.parseDeployment();
112
119
  parser.parseViews();
113
120
  }
114
- catch (e) {
115
- logger.error(`Error parsing document {doc}`, { doc: doc.uri.toString(), error: e });
121
+ catch (error) {
122
+ throw new Error(`Error parsing document ${doc.uri.fsPath}`, { cause: error });
116
123
  }
117
124
  return parser;
118
125
  }
@@ -55,6 +55,10 @@ export function SpecificationParser(B) {
55
55
  const tag = tagSpec.tag.name;
56
56
  const astPath = this.getAstNodePath(tagSpec.tag);
57
57
  const color = tagSpec.color && this.parseColorLiteral(tagSpec.color);
58
+ if (tag in c4Specification.tags) {
59
+ logger.warn(`Tag {tag} is already defined, skipping duplicate`, { tag });
60
+ continue;
61
+ }
58
62
  if (isTruthy(tag)) {
59
63
  c4Specification.tags[tag] = {
60
64
  astPath,
@@ -63,7 +63,9 @@ export function ViewsParser(B) {
63
63
  const viewOfEl = elementRef(astNode.viewOf);
64
64
  const _viewOf = viewOfEl && this.resolveFqn(viewOfEl);
65
65
  if (!_viewOf) {
66
- logger.warn('viewOf is not resolved: ' + astNode.$cstNode?.text);
66
+ const viewId = astNode.name ?? 'unnamed';
67
+ const msg = astNode.viewOf.$cstNode?.text ?? '<unknown>';
68
+ logger.warn(`viewOf {viewId} not resolved {msg}`, { msg, viewId });
67
69
  }
68
70
  else {
69
71
  viewOf = _viewOf;
@@ -1,5 +1,5 @@
1
1
  import { type ViewChange } from '@likec4/core';
2
- import { Location, Range, TextEdit } from 'vscode-languageserver-types';
2
+ import { Range, TextEdit } from 'vscode-languageserver-types';
3
3
  import type { ViewLocateResult } from '../model';
4
4
  import type { LikeC4Services } from '../module';
5
5
  import type { ChangeView } from '../protocol';
@@ -7,7 +7,7 @@ export declare class LikeC4ModelChanges {
7
7
  private services;
8
8
  private locator;
9
9
  constructor(services: LikeC4Services);
10
- applyChange(changeView: ChangeView.Params): Promise<Location | null>;
10
+ applyChange(changeView: ChangeView.Params): Promise<ChangeView.Res>;
11
11
  protected convertToTextEdit({ lookup, change }: {
12
12
  lookup: ViewLocateResult;
13
13
  change: Exclude<ViewChange, ViewChange.SaveViewSnapshot | ViewChange.ResetManualLayout>;
@@ -1,5 +1,6 @@
1
1
  import { invariant, nonexhaustive } from '@likec4/core';
2
- import { Location, Range, TextEdit } from 'vscode-languageserver-types';
2
+ import { loggable, wrapError } from '@likec4/log';
3
+ import { Range, TextEdit } from 'vscode-languageserver-types';
3
4
  import { logger as mainLogger } from '../logger';
4
5
  import { changeElementStyle } from './changeElementStyle';
5
6
  import { changeViewLayout } from './changeViewLayout';
@@ -23,7 +24,7 @@ export class LikeC4ModelChanges {
23
24
  logger.debug `Applying model change ${change.op} to view ${viewId} in project ${project.id}`;
24
25
  const lookup = this.locator.locateViewAst(viewId, project.id);
25
26
  if (!lookup) {
26
- throw new Error(`LikeC4ModelChanges: view not found: ${viewId}`);
27
+ throw new Error(`View ${viewId} not found in project ${project.id}`);
27
28
  }
28
29
  const textDocument = {
29
30
  uri: lookup.doc.textDocument.uri,
@@ -39,11 +40,26 @@ export class LikeC4ModelChanges {
39
40
  logger.warn(`Failed to remove manual layout v1 for view ${viewId} in project ${project.id}`, { err });
40
41
  });
41
42
  }
42
- result = await this.services.likec4.ManualLayouts.write(project, change.layout);
43
+ const location = await this.services.likec4.ManualLayouts.write(project, change.layout);
44
+ result = {
45
+ success: true,
46
+ location,
47
+ };
43
48
  return;
44
49
  }
45
50
  if (change.op === 'reset-manual-layout') {
46
- result = await this.services.likec4.ManualLayouts.remove(project, viewId);
51
+ // If there is an existing manual layout v1
52
+ if (lookup.view.manualLayout) {
53
+ // We clean it up
54
+ await removeManualLayoutV1(this.services, { lookup }).catch(err => {
55
+ logger.warn(`Failed to remove manual layout v1 for view ${viewId} in project ${project.id}`, { err });
56
+ });
57
+ }
58
+ const location = await this.services.likec4.ManualLayouts.remove(project, viewId);
59
+ result = {
60
+ success: true,
61
+ location,
62
+ };
47
63
  return;
48
64
  }
49
65
  const { edits, modifiedRange } = this.convertToTextEdit({
@@ -66,15 +82,26 @@ export class LikeC4ModelChanges {
66
82
  return;
67
83
  }
68
84
  result = {
69
- uri: textDocument.uri,
70
- range: modifiedRange,
85
+ success: true,
86
+ location: {
87
+ uri: textDocument.uri,
88
+ range: modifiedRange,
89
+ },
71
90
  };
72
91
  });
73
92
  }
74
- catch (error) {
75
- logger.error(`Failed to apply change ${changeView.change.op} ${changeView.viewId}`, { error });
93
+ catch (err) {
94
+ const error = loggable(wrapError(err, `Failed to apply change ${changeView.change.op} ${changeView.viewId}:\n`));
95
+ logger.error(error);
96
+ result = {
97
+ success: false,
98
+ error,
99
+ };
76
100
  }
77
- return result;
101
+ return result ?? {
102
+ success: false,
103
+ error: 'Unknown error applying model change',
104
+ };
78
105
  }
79
106
  convertToTextEdit({ lookup, change }) {
80
107
  switch (change.op) {
@@ -6,6 +6,18 @@ export declare namespace DidChangeModelNotification {
6
6
  const type: NotificationType<string>;
7
7
  type Type = typeof type;
8
8
  }
9
+ /**
10
+ * When the snapshot of a manual layout changes
11
+ * Send by the editor to the language server
12
+ */
13
+ export declare namespace DidChangeSnapshotNotification {
14
+ type Params = {
15
+ snapshotUri: DocumentUri;
16
+ };
17
+ const Method: "likec4/onDidChangeSnapshot";
18
+ const type: NotificationType<Params>;
19
+ type Type = typeof type;
20
+ }
9
21
  /**
10
22
  * When server requests to open a likec4 preview panel
11
23
  * (available only in the editor).
@@ -72,6 +84,7 @@ export declare namespace LayoutView {
72
84
  type Params = {
73
85
  viewId: ViewId;
74
86
  projectId?: string | undefined;
87
+ layoutType?: 'auto' | 'manual' | undefined;
75
88
  };
76
89
  type Res = {
77
90
  result: {
@@ -102,8 +115,8 @@ export declare namespace ValidateLayout {
102
115
  };
103
116
  }[] | null;
104
117
  };
105
- const Req: RequestType<Params, Res, void>;
106
- type Req = typeof Req;
118
+ const req: RequestType<Params, Res, void>;
119
+ type Req = typeof req;
107
120
  }
108
121
  /**
109
122
  * Request to reload projects.
@@ -123,7 +136,10 @@ export declare namespace FetchProjects {
123
136
  projects: {
124
137
  [projectId: ProjectId]: {
125
138
  folder: URI;
126
- config: LikeC4ProjectJsonConfig;
139
+ config: {
140
+ name: string;
141
+ title?: string | undefined;
142
+ };
127
143
  docs: NonEmptyArray<DocumentUri>;
128
144
  };
129
145
  };
@@ -152,8 +168,8 @@ export declare namespace BuildDocuments {
152
168
  type Params = {
153
169
  docs: DocumentUri[];
154
170
  };
155
- const Req: RequestType<Params, void, void>;
156
- type Req = typeof Req;
171
+ const req: RequestType<Params, void, void>;
172
+ type Req = typeof req;
157
173
  }
158
174
  /**
159
175
  * Request to locate an element, relation, deployment or view.
@@ -200,8 +216,8 @@ export declare namespace Locate {
200
216
  projectId?: string | undefined;
201
217
  };
202
218
  type Res = Location | null;
203
- const Req: RequestType<Params, Res, void>;
204
- type Req = typeof Req;
219
+ const req: RequestType<Params, Res, void>;
220
+ type Req = typeof req;
205
221
  }
206
222
  /**
207
223
  * Request to change the view
@@ -213,9 +229,16 @@ export declare namespace ChangeView {
213
229
  change: ViewChange;
214
230
  projectId?: string | undefined;
215
231
  };
216
- type Res = Location | null;
217
- const Req: RequestType<Params, Res, void>;
218
- type Req = typeof Req;
232
+ type Res = {
233
+ success: true;
234
+ location: Location | null;
235
+ } | {
236
+ success: false;
237
+ location?: Location | null;
238
+ error: string;
239
+ };
240
+ const req: RequestType<Params, Res, void>;
241
+ type Req = typeof req;
219
242
  }
220
243
  /**
221
244
  * Request to fetch telemetry metrics
package/dist/protocol.js CHANGED
@@ -3,6 +3,15 @@ export var DidChangeModelNotification;
3
3
  (function (DidChangeModelNotification) {
4
4
  DidChangeModelNotification.type = new NotificationType('likec4/onDidChangeModel');
5
5
  })(DidChangeModelNotification || (DidChangeModelNotification = {}));
6
+ /**
7
+ * When the snapshot of a manual layout changes
8
+ * Send by the editor to the language server
9
+ */
10
+ export var DidChangeSnapshotNotification;
11
+ (function (DidChangeSnapshotNotification) {
12
+ DidChangeSnapshotNotification.Method = 'likec4/onDidChangeSnapshot';
13
+ DidChangeSnapshotNotification.type = new NotificationType(DidChangeSnapshotNotification.Method);
14
+ })(DidChangeSnapshotNotification || (DidChangeSnapshotNotification = {}));
6
15
  /**
7
16
  * When server requests to open a likec4 preview panel
8
17
  * (available only in the editor).
@@ -51,7 +60,7 @@ export var LayoutView;
51
60
  */
52
61
  export var ValidateLayout;
53
62
  (function (ValidateLayout) {
54
- ValidateLayout.Req = new RequestType('likec4/validate-layout');
63
+ ValidateLayout.req = new RequestType('likec4/validate-layout');
55
64
  })(ValidateLayout || (ValidateLayout = {}));
56
65
  /**
57
66
  * Request to reload projects.
@@ -79,7 +88,7 @@ export var RegisterProject;
79
88
  */
80
89
  export var BuildDocuments;
81
90
  (function (BuildDocuments) {
82
- BuildDocuments.Req = new RequestType('likec4/build');
91
+ BuildDocuments.req = new RequestType('likec4/build');
83
92
  })(BuildDocuments || (BuildDocuments = {}));
84
93
  /**
85
94
  * Request to locate an element, relation, deployment or view.
@@ -87,7 +96,7 @@ export var BuildDocuments;
87
96
  */
88
97
  export var Locate;
89
98
  (function (Locate) {
90
- Locate.Req = new RequestType('likec4/locate');
99
+ Locate.req = new RequestType('likec4/locate');
91
100
  })(Locate || (Locate = {}));
92
101
  // #endregion
93
102
  /**
@@ -96,7 +105,7 @@ export var Locate;
96
105
  */
97
106
  export var ChangeView;
98
107
  (function (ChangeView) {
99
- ChangeView.Req = new RequestType('likec4/change-view');
108
+ ChangeView.req = new RequestType('likec4/change-view');
100
109
  })(ChangeView || (ChangeView = {}));
101
110
  /**
102
111
  * Request to fetch telemetry metrics
@@ -14,10 +14,9 @@ function pack({ nodes, edges, ...rest }) {
14
14
  b: [x, y, width, height],
15
15
  c: isCompound,
16
16
  })),
17
- edges: mapValues(edges, ({ points, controlPoints, labelBBox, dotpos, ...e }) => ({
17
+ edges: mapValues(edges, ({ points, controlPoints, labelBBox, ...e }) => ({
18
18
  ...!!controlPoints && { cp: controlPoints },
19
19
  ...!!labelBBox && { l: labelBBox },
20
- ...!!dotpos && { dp: dotpos },
21
20
  ...e,
22
21
  p: points,
23
22
  })),
@@ -36,10 +35,9 @@ function unpack({ nodes, edges, autoLayout, ...rest }) {
36
35
  isCompound: c,
37
36
  ...n,
38
37
  })),
39
- edges: mapValues(edges, ({ p, cp, l, dp, ...e }) => ({
38
+ edges: mapValues(edges, ({ p, cp, l, ...e }) => ({
40
39
  ...!!cp && { controlPoints: cp },
41
40
  ...!!l && { labelBBox: l },
42
- ...!!dp && { dotpos: dp },
43
41
  ...e,
44
42
  points: p,
45
43
  })),
@@ -1,4 +1,5 @@
1
- import type { LayoutedView, ViewId } from '@likec4/core';
1
+ import type { LayoutedView, ProjectId, ViewId } from '@likec4/core';
2
+ import { URI, WorkspaceCache } from 'langium';
2
3
  import { type Location } from 'vscode-languageserver-types';
3
4
  import type { LikeC4Services } from '../module';
4
5
  import type { Project } from '../workspace/ProjectsManager';
@@ -19,10 +20,23 @@ export interface LikeC4ManualLayoutsModuleContext {
19
20
  export declare const WithLikeC4ManualLayouts: LikeC4ManualLayoutsModuleContext;
20
21
  export declare class DefaultLikeC4ManualLayouts implements LikeC4ManualLayouts {
21
22
  private services;
22
- private manualLayouts;
23
+ protected cache: WorkspaceCache<ProjectId, Promise<Record<ViewId, LayoutedView> | null>>;
23
24
  constructor(services: LikeC4Services);
24
25
  read(project: Project): Promise<Record<ViewId, LayoutedView> | null>;
25
26
  write(project: Project, layouted: LayoutedView): Promise<Location>;
26
27
  remove(project: Project, view: ViewId): Promise<Location | null>;
27
28
  clearCaches(): void;
29
+ /**
30
+ * When we save snapshot - it may contain fullpath to icons on the machine it was created,
31
+ * that is wrong when opened on another.
32
+ *
33
+ * Prepares a snapshot for writing by converting absolute icon paths to relative paths.
34
+ * Absolute paths starting with 'file://' are converted to relative paths prefixed with 'file://./'
35
+ */
36
+ protected normalizeIconPathsForWrite(layouted: LayoutedView, projectUri: URI): LayoutedView;
37
+ /**
38
+ * Postprocesses a snapshot after reading by converting relative icon paths back to absolute paths.
39
+ * Relative paths prefixed with 'file://./' are converted to absolute paths based on project folder.
40
+ */
41
+ protected resolveIconPathsAfterRead(layouted: LayoutedView, projectUri: URI): LayoutedView;
28
42
  }
@@ -1,10 +1,9 @@
1
- import { getOrCreate } from '@likec4/core/utils';
2
1
  import JSON5 from 'json5';
3
- import { UriUtils } from 'langium';
2
+ import { DocumentState, URI, UriUtils, WorkspaceCache } from 'langium';
4
3
  import { indexBy, prop } from 'remeda';
5
4
  import { Position, Range, } from 'vscode-languageserver-types';
6
5
  import { logger as rootLogger } from '../logger';
7
- const logger = rootLogger.getChild('manual-layouts');
6
+ const layoutsLogger = rootLogger.getChild('manual-layouts');
8
7
  /**
9
8
  * @todo sync with vscode extension watchers
10
9
  * (search for ".likec4.snap" references)
@@ -19,14 +18,17 @@ function getManualLayoutsOutDir(project) {
19
18
  export const WithLikeC4ManualLayouts = {
20
19
  manualLayouts: (services) => new DefaultLikeC4ManualLayouts(services),
21
20
  };
21
+ const RELATIVE_PATH_PREFIX = 'file://./';
22
22
  export class DefaultLikeC4ManualLayouts {
23
23
  services;
24
- manualLayouts = new WeakMap();
24
+ cache;
25
25
  constructor(services) {
26
26
  this.services = services;
27
+ this.cache = new WorkspaceCache(services.shared, DocumentState.Validated);
27
28
  }
28
29
  async read(project) {
29
- return await getOrCreate(this.manualLayouts, project.folderUri, async (_) => {
30
+ return await this.cache.get(project.id, async () => {
31
+ const logger = layoutsLogger.getChild(project.id);
30
32
  const fs = this.services.shared.workspace.FileSystemProvider;
31
33
  const outDir = getManualLayoutsOutDir(project);
32
34
  const manualLayouts = [];
@@ -39,8 +41,10 @@ export class DefaultLikeC4ManualLayouts {
39
41
  if (file.isFile) {
40
42
  try {
41
43
  const content = await fs.readFile(file.uri);
44
+ const parsed = JSON5.parse(content);
45
+ const resolved = this.resolveIconPathsAfterRead(parsed, project.folderUri);
42
46
  manualLayouts.push({
43
- ...JSON5.parse(content),
47
+ ...resolved,
44
48
  _layout: 'manual',
45
49
  });
46
50
  }
@@ -63,6 +67,7 @@ export class DefaultLikeC4ManualLayouts {
63
67
  });
64
68
  }
65
69
  async write(project, layouted) {
70
+ const logger = layoutsLogger.getChild(project.id);
66
71
  const outDir = getManualLayoutsOutDir(project);
67
72
  const file = UriUtils.joinPath(outDir, fileName(layouted.id));
68
73
  // Ensure the manualLayout field is omitted (may exist in migration)
@@ -70,6 +75,8 @@ export class DefaultLikeC4ManualLayouts {
70
75
  const { manualLayout: _, ...rest } = layouted;
71
76
  layouted = rest;
72
77
  }
78
+ // Normalize icon paths before writing
79
+ layouted = this.normalizeIconPathsForWrite(layouted, project.folderUri);
73
80
  const content = JSON5.stringify(layouted, {
74
81
  space: 2,
75
82
  quote: '\'',
@@ -81,23 +88,29 @@ export class DefaultLikeC4ManualLayouts {
81
88
  logger.debug `write snapshot of ${layouted.id} in project ${project.id} to ${file.fsPath}`;
82
89
  const fs = this.services.shared.workspace.FileSystemProvider;
83
90
  try {
84
- await fs.writeFile(file, content);
91
+ await fs.writeFile(file, content + '\n');
85
92
  }
86
93
  catch (err) {
87
94
  logger.warn(`Failed to write snapshot ${layouted.id} to ${file.fsPath}`, { err });
88
95
  }
89
- const projectCaches = await this.read(project);
90
- if (projectCaches) {
91
- logger.debug `update snapshot cache of ${layouted.id} in project ${project.id}`;
92
- projectCaches[layouted.id] = layouted;
93
- }
94
- else {
95
- this.manualLayouts.delete(project.folderUri);
96
+ const projectCachesPromise = this.cache.get(project.id);
97
+ if (projectCachesPromise) {
98
+ const projectCaches = await projectCachesPromise;
99
+ if (projectCaches) {
100
+ logger.debug `update snapshot cache of ${layouted.id} in project ${project.id}`;
101
+ projectCaches[layouted.id] = layouted;
102
+ }
103
+ else {
104
+ logger.debug `clean cache of project ${project.id}`;
105
+ // Cache was null, remove it entirely
106
+ this.cache.delete(project.id);
107
+ }
96
108
  }
97
109
  this.services.likec4.ModelBuilder.clearCache();
98
110
  return location;
99
111
  }
100
112
  async remove(project, view) {
113
+ const logger = layoutsLogger.getChild(project.id);
101
114
  const outDir = getManualLayoutsOutDir(project);
102
115
  const file = UriUtils.joinPath(outDir, fileName(view));
103
116
  logger.debug `delete snapshot of ${view} in project ${project.id}. File: ${file.fsPath}`;
@@ -115,18 +128,82 @@ export class DefaultLikeC4ManualLayouts {
115
128
  catch (err) {
116
129
  logger.warn(`Failed to delete snapshot ${view} from ${file.fsPath}`, { err });
117
130
  }
118
- const projectCaches = await this.read(project);
119
- if (projectCaches) {
120
- logger.debug `clean cache of ${view} in project ${project.id}`;
121
- delete projectCaches[view];
122
- }
123
- else {
124
- this.manualLayouts.delete(project.folderUri);
131
+ const projectCachesPromise = this.cache.get(project.id);
132
+ if (projectCachesPromise) {
133
+ const projectCaches = await projectCachesPromise;
134
+ if (projectCaches) {
135
+ logger.debug `clean cached view ${view} in project ${project.id}`;
136
+ delete projectCaches[view];
137
+ }
138
+ else {
139
+ logger.debug `reset empty cache of project ${project.id}`;
140
+ // Cache was null, remove it entirely
141
+ this.cache.delete(project.id);
142
+ }
125
143
  }
126
144
  this.services.likec4.ModelBuilder.clearCache();
127
145
  return location;
128
146
  }
129
147
  clearCaches() {
130
- this.manualLayouts = new WeakMap();
148
+ this.cache.clear();
149
+ }
150
+ /**
151
+ * When we save snapshot - it may contain fullpath to icons on the machine it was created,
152
+ * that is wrong when opened on another.
153
+ *
154
+ * Prepares a snapshot for writing by converting absolute icon paths to relative paths.
155
+ * Absolute paths starting with 'file://' are converted to relative paths prefixed with 'file://./'
156
+ */
157
+ normalizeIconPathsForWrite(layouted, projectUri) {
158
+ const nodes = layouted.nodes.map((node) => {
159
+ if (!node.icon || typeof node.icon !== 'string') {
160
+ return node;
161
+ }
162
+ // Check if icon is an absolute file path
163
+ if (node.icon.startsWith('file://')) {
164
+ const iconUri = URI.parse(node.icon);
165
+ // Get relative path from project folder to icon
166
+ const relativePath = UriUtils.relative(projectUri, iconUri);
167
+ // If icon is outside of project folder - leave it as is,
168
+ // to avoid security issues on reading snapshots on another machine
169
+ if (relativePath.startsWith('..')) {
170
+ return node;
171
+ }
172
+ return {
173
+ ...node,
174
+ icon: `${RELATIVE_PATH_PREFIX}${relativePath}`,
175
+ };
176
+ }
177
+ return node;
178
+ });
179
+ return {
180
+ ...layouted,
181
+ nodes: nodes,
182
+ };
183
+ }
184
+ /**
185
+ * Postprocesses a snapshot after reading by converting relative icon paths back to absolute paths.
186
+ * Relative paths prefixed with 'file://./' are converted to absolute paths based on project folder.
187
+ */
188
+ resolveIconPathsAfterRead(layouted, projectUri) {
189
+ const nodes = layouted.nodes.map((node) => {
190
+ if (!node.icon || typeof node.icon !== 'string') {
191
+ return node;
192
+ }
193
+ // Check if icon is a relative file path
194
+ if (node.icon.startsWith(RELATIVE_PATH_PREFIX)) {
195
+ const relativePath = node.icon.substring(RELATIVE_PATH_PREFIX.length);
196
+ const absoluteUri = UriUtils.joinPath(projectUri, relativePath);
197
+ return {
198
+ ...node,
199
+ icon: absoluteUri.toString(),
200
+ };
201
+ }
202
+ return node;
203
+ });
204
+ return {
205
+ ...layouted,
206
+ nodes: nodes,
207
+ };
131
208
  }
132
209
  }