@finos/legend-application-studio 28.21.5 → 28.21.6

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 (74) hide show
  1. package/lib/__lib__/LegendStudioEvent.d.ts +4 -1
  2. package/lib/__lib__/LegendStudioEvent.d.ts.map +1 -1
  3. package/lib/__lib__/LegendStudioEvent.js +3 -0
  4. package/lib/__lib__/LegendStudioEvent.js.map +1 -1
  5. package/lib/__lib__/LegendStudioTelemetryHelper.d.ts +2 -1
  6. package/lib/__lib__/LegendStudioTelemetryHelper.d.ts.map +1 -1
  7. package/lib/__lib__/LegendStudioTelemetryHelper.js +11 -3
  8. package/lib/__lib__/LegendStudioTelemetryHelper.js.map +1 -1
  9. package/lib/__lib__/LegendStudioUserDataHelper.d.ts +41 -1
  10. package/lib/__lib__/LegendStudioUserDataHelper.d.ts.map +1 -1
  11. package/lib/__lib__/LegendStudioUserDataHelper.js +120 -1
  12. package/lib/__lib__/LegendStudioUserDataHelper.js.map +1 -1
  13. package/lib/components/editor/editor-group/data-editor/EmbeddedDataEditor.d.ts.map +1 -1
  14. package/lib/components/editor/editor-group/data-editor/EmbeddedDataEditor.js +1 -1
  15. package/lib/components/editor/editor-group/data-editor/EmbeddedDataEditor.js.map +1 -1
  16. package/lib/components/editor/editor-group/data-editor/RelationElementsDataEditor.d.ts.map +1 -1
  17. package/lib/components/editor/editor-group/data-editor/RelationElementsDataEditor.js +2 -1
  18. package/lib/components/editor/editor-group/data-editor/RelationElementsDataEditor.js.map +1 -1
  19. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.d.ts.map +1 -1
  20. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js +1 -1
  21. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js.map +1 -1
  22. package/lib/components/editor/editor-group/testable/TestableSharedComponents.d.ts.map +1 -1
  23. package/lib/components/editor/editor-group/testable/TestableSharedComponents.js +1 -1
  24. package/lib/components/editor/editor-group/testable/TestableSharedComponents.js.map +1 -1
  25. package/lib/components/workspace-setup/RecentWorkspacesPanel.d.ts +22 -0
  26. package/lib/components/workspace-setup/RecentWorkspacesPanel.d.ts.map +1 -0
  27. package/lib/components/workspace-setup/RecentWorkspacesPanel.js +80 -0
  28. package/lib/components/workspace-setup/RecentWorkspacesPanel.js.map +1 -0
  29. package/lib/components/workspace-setup/WorkspaceSetup.d.ts.map +1 -1
  30. package/lib/components/workspace-setup/WorkspaceSetup.js +61 -6
  31. package/lib/components/workspace-setup/WorkspaceSetup.js.map +1 -1
  32. package/lib/index.css +2 -2
  33. package/lib/index.css.map +1 -1
  34. package/lib/package.json +1 -1
  35. package/lib/stores/editor/EditorStore.d.ts.map +1 -1
  36. package/lib/stores/editor/EditorStore.js +31 -0
  37. package/lib/stores/editor/EditorStore.js.map +1 -1
  38. package/lib/stores/editor/editor-state/element-editor-state/function-activator/testable/FunctionTestableState.d.ts.map +1 -1
  39. package/lib/stores/editor/editor-state/element-editor-state/function-activator/testable/FunctionTestableState.js +4 -2
  40. package/lib/stores/editor/editor-state/element-editor-state/function-activator/testable/FunctionTestableState.js.map +1 -1
  41. package/lib/stores/editor/sidebar-state/ProjectOverviewState.d.ts.map +1 -1
  42. package/lib/stores/editor/sidebar-state/ProjectOverviewState.js +11 -0
  43. package/lib/stores/editor/sidebar-state/ProjectOverviewState.js.map +1 -1
  44. package/lib/stores/editor/sidebar-state/WorkspaceReviewState.d.ts.map +1 -1
  45. package/lib/stores/editor/sidebar-state/WorkspaceReviewState.js +11 -0
  46. package/lib/stores/editor/sidebar-state/WorkspaceReviewState.js.map +1 -1
  47. package/lib/stores/editor/sidebar-state/dev-metadata/DevMetadataState.d.ts.map +1 -1
  48. package/lib/stores/editor/sidebar-state/dev-metadata/DevMetadataState.js +2 -1
  49. package/lib/stores/editor/sidebar-state/dev-metadata/DevMetadataState.js.map +1 -1
  50. package/lib/stores/project-reviewer/ProjectReviewerStore.d.ts.map +1 -1
  51. package/lib/stores/project-reviewer/ProjectReviewerStore.js +12 -0
  52. package/lib/stores/project-reviewer/ProjectReviewerStore.js.map +1 -1
  53. package/lib/stores/workspace-setup/WorkspaceSetupStore.d.ts +17 -0
  54. package/lib/stores/workspace-setup/WorkspaceSetupStore.d.ts.map +1 -1
  55. package/lib/stores/workspace-setup/WorkspaceSetupStore.js +61 -0
  56. package/lib/stores/workspace-setup/WorkspaceSetupStore.js.map +1 -1
  57. package/package.json +10 -10
  58. package/src/__lib__/LegendStudioEvent.ts +3 -0
  59. package/src/__lib__/LegendStudioTelemetryHelper.ts +35 -11
  60. package/src/__lib__/LegendStudioUserDataHelper.ts +204 -1
  61. package/src/components/editor/editor-group/data-editor/EmbeddedDataEditor.tsx +1 -0
  62. package/src/components/editor/editor-group/data-editor/RelationElementsDataEditor.tsx +13 -5
  63. package/src/components/editor/editor-group/dataProduct/DataProductEditor.tsx +1 -0
  64. package/src/components/editor/editor-group/testable/TestableSharedComponents.tsx +1 -0
  65. package/src/components/workspace-setup/RecentWorkspacesPanel.tsx +161 -0
  66. package/src/components/workspace-setup/WorkspaceSetup.tsx +97 -8
  67. package/src/stores/editor/EditorStore.ts +44 -0
  68. package/src/stores/editor/editor-state/element-editor-state/function-activator/testable/FunctionTestableState.ts +5 -1
  69. package/src/stores/editor/sidebar-state/ProjectOverviewState.ts +14 -0
  70. package/src/stores/editor/sidebar-state/WorkspaceReviewState.ts +14 -0
  71. package/src/stores/editor/sidebar-state/dev-metadata/DevMetadataState.ts +8 -1
  72. package/src/stores/project-reviewer/ProjectReviewerStore.ts +15 -0
  73. package/src/stores/workspace-setup/WorkspaceSetupStore.ts +93 -0
  74. package/tsconfig.json +1 -0
@@ -1,5 +1,12 @@
1
1
  import type { UserDataService } from '@finos/legend-application';
2
- import { returnUndefOnError } from '@finos/legend-shared';
2
+ import {
3
+ type PlainObject,
4
+ SerializationFactory,
5
+ returnUndefOnError,
6
+ usingModelSchema,
7
+ } from '@finos/legend-shared';
8
+ import { WorkspaceType } from '@finos/legend-server-sdlc';
9
+ import { createModelSchema, list, primitive } from 'serializr';
3
10
 
4
11
  /**
5
12
  * Copyright (c) 2020-present, Goldman Sachs
@@ -29,8 +36,104 @@ export enum LEGEND_STUDIO_USER_DATA_KEY {
29
36
  // drop the toggle button in the tab header, and retarget the SCSS
30
37
  // `.database-editor--light` block at the framework's color-theme tokens.
31
38
  DATABASE_EDITOR_THEME = 'studio-editor.database-editor.theme',
39
+ // Recently-opened projects and (non-patch) workspaces shown on the
40
+ // workspace setup screen to speed up re-opening common work.
41
+ WORKSPACE_SETUP_RECENTS = 'studio-editor.workspace-setup.recents',
32
42
  }
33
43
 
44
+ // --- Workspace setup recents -------------------------------------------------
45
+
46
+ const WORKSPACE_SETUP_RECENTS_VERSION = 1;
47
+ const MAX_RECENT_PROJECTS = 10;
48
+ const MAX_RECENT_WORKSPACES = 20;
49
+
50
+ export class RecentProjectEntry {
51
+ projectId!: string;
52
+ name!: string;
53
+ lastOpenedAt!: number;
54
+
55
+ static readonly serialization = new SerializationFactory(
56
+ createModelSchema(RecentProjectEntry, {
57
+ lastOpenedAt: primitive(),
58
+ name: primitive(),
59
+ projectId: primitive(),
60
+ }),
61
+ );
62
+ }
63
+
64
+ export class RecentWorkspaceEntry {
65
+ projectId!: string;
66
+ workspaceId!: string;
67
+ workspaceType!: WorkspaceType;
68
+ lastOpenedAt!: number;
69
+
70
+ static readonly serialization = new SerializationFactory(
71
+ createModelSchema(RecentWorkspaceEntry, {
72
+ lastOpenedAt: primitive(),
73
+ projectId: primitive(),
74
+ workspaceId: primitive(),
75
+ // WorkspaceType is a string enum; stored/loaded as a plain string and
76
+ // validated when the entry is consumed.
77
+ workspaceType: primitive(),
78
+ }),
79
+ );
80
+ }
81
+
82
+ export class WorkspaceSetupRecents {
83
+ version: number = WORKSPACE_SETUP_RECENTS_VERSION;
84
+ projects: RecentProjectEntry[] = [];
85
+ workspaces: RecentWorkspaceEntry[] = [];
86
+
87
+ static readonly serialization = new SerializationFactory(
88
+ createModelSchema(WorkspaceSetupRecents, {
89
+ projects: list(usingModelSchema(RecentProjectEntry.serialization.schema)),
90
+ version: primitive(),
91
+ workspaces: list(
92
+ usingModelSchema(RecentWorkspaceEntry.serialization.schema),
93
+ ),
94
+ }),
95
+ );
96
+ }
97
+
98
+ const isValidWorkspaceType = (v: unknown): v is WorkspaceType =>
99
+ v === WorkspaceType.USER || v === WorkspaceType.GROUP;
100
+
101
+ const emptyRecents = (): WorkspaceSetupRecents => new WorkspaceSetupRecents();
102
+
103
+ const readRecents = (service: UserDataService): WorkspaceSetupRecents => {
104
+ const raw = returnUndefOnError(() =>
105
+ service.getObjectValue(LEGEND_STUDIO_USER_DATA_KEY.WORKSPACE_SETUP_RECENTS),
106
+ );
107
+ if (!raw) {
108
+ return emptyRecents();
109
+ }
110
+ const parsed = returnUndefOnError(() =>
111
+ WorkspaceSetupRecents.serialization.fromJson(
112
+ raw as PlainObject<WorkspaceSetupRecents>,
113
+ ),
114
+ );
115
+ if (!parsed) {
116
+ return emptyRecents();
117
+ }
118
+ // Defensive post-checks: drop entries with invalid enum values and enforce
119
+ // the LRU caps in case the persisted blob was tampered with.
120
+ parsed.workspaces = parsed.workspaces
121
+ .filter((w) => isValidWorkspaceType(w.workspaceType))
122
+ .slice(0, MAX_RECENT_WORKSPACES);
123
+ parsed.projects = parsed.projects.slice(0, MAX_RECENT_PROJECTS);
124
+ return parsed;
125
+ };
126
+
127
+ const writeRecents = (
128
+ service: UserDataService,
129
+ recents: WorkspaceSetupRecents,
130
+ ): void => {
131
+ service.persistValue(
132
+ LEGEND_STUDIO_USER_DATA_KEY.WORKSPACE_SETUP_RECENTS,
133
+ WorkspaceSetupRecents.serialization.toJson(recents),
134
+ );
135
+ };
136
+
34
137
  export class LegendStudioUserDataHelper {
35
138
  static globalTestRunner_getShowDependencyPanel(
36
139
  service: UserDataService,
@@ -70,4 +173,104 @@ export class LegendStudioUserDataHelper {
70
173
  val,
71
174
  );
72
175
  }
176
+
177
+ // --- Workspace setup recents ---------------------------------------------
178
+
179
+ static workspaceSetup_getRecentProjects(
180
+ service: UserDataService,
181
+ ): RecentProjectEntry[] {
182
+ return readRecents(service).projects;
183
+ }
184
+
185
+ static workspaceSetup_getRecentWorkspaces(
186
+ service: UserDataService,
187
+ ): RecentWorkspaceEntry[] {
188
+ return readRecents(service).workspaces;
189
+ }
190
+
191
+ static workspaceSetup_recordRecentProject(
192
+ service: UserDataService,
193
+ entry: { projectId: string; name: string },
194
+ ): RecentProjectEntry[] {
195
+ const recents = readRecents(service);
196
+ const next = new RecentProjectEntry();
197
+ next.projectId = entry.projectId;
198
+ next.name = entry.name;
199
+ next.lastOpenedAt = Date.now();
200
+ recents.projects = [
201
+ next,
202
+ ...recents.projects.filter((p) => p.projectId !== entry.projectId),
203
+ ].slice(0, MAX_RECENT_PROJECTS);
204
+ writeRecents(service, recents);
205
+ return recents.projects;
206
+ }
207
+
208
+ static workspaceSetup_recordRecentWorkspace(
209
+ service: UserDataService,
210
+ entry: {
211
+ projectId: string;
212
+ workspaceId: string;
213
+ workspaceType: WorkspaceType;
214
+ },
215
+ ): RecentWorkspaceEntry[] {
216
+ const recents = readRecents(service);
217
+ const next = new RecentWorkspaceEntry();
218
+ next.projectId = entry.projectId;
219
+ next.workspaceId = entry.workspaceId;
220
+ next.workspaceType = entry.workspaceType;
221
+ next.lastOpenedAt = Date.now();
222
+ recents.workspaces = [
223
+ next,
224
+ ...recents.workspaces.filter(
225
+ (w) =>
226
+ !(
227
+ w.projectId === entry.projectId &&
228
+ w.workspaceId === entry.workspaceId &&
229
+ w.workspaceType === entry.workspaceType
230
+ ),
231
+ ),
232
+ ].slice(0, MAX_RECENT_WORKSPACES);
233
+ writeRecents(service, recents);
234
+ return recents.workspaces;
235
+ }
236
+
237
+ static workspaceSetup_removeRecentProject(
238
+ service: UserDataService,
239
+ projectId: string,
240
+ ): WorkspaceSetupRecents {
241
+ const recents = readRecents(service);
242
+ recents.projects = recents.projects.filter(
243
+ (p) => p.projectId !== projectId,
244
+ );
245
+ recents.workspaces = recents.workspaces.filter(
246
+ (w) => w.projectId !== projectId,
247
+ );
248
+ writeRecents(service, recents);
249
+ return recents;
250
+ }
251
+
252
+ static workspaceSetup_removeRecentWorkspace(
253
+ service: UserDataService,
254
+ entry: {
255
+ projectId: string;
256
+ workspaceId: string;
257
+ workspaceType: WorkspaceType;
258
+ },
259
+ ): RecentWorkspaceEntry[] {
260
+ const recents = readRecents(service);
261
+ recents.workspaces = recents.workspaces.filter(
262
+ (w) =>
263
+ !(
264
+ w.projectId === entry.projectId &&
265
+ w.workspaceId === entry.workspaceId &&
266
+ w.workspaceType === entry.workspaceType
267
+ ),
268
+ );
269
+ writeRecents(service, recents);
270
+ return recents.workspaces;
271
+ }
272
+
273
+ static workspaceSetup_clearRecents(service: UserDataService): void {
274
+ writeRecents(service, emptyRecents());
275
+ }
73
276
  }
@@ -400,6 +400,7 @@ export function renderEmbeddedDataEditor(
400
400
  dataState={embeddedDataState}
401
401
  isReadOnly={isReadOnly}
402
402
  isSharedData={isSharedData}
403
+ hideColumnDefinitions={true}
403
404
  />
404
405
  );
405
406
  } else if (embeddedDataState instanceof DataElementReferenceState) {
@@ -426,7 +426,8 @@ export const RelationElementEditor = observer(
426
426
  </div>
427
427
  </div>
428
428
  ) : null}
429
- {embeddedData.rows.length === 0 ? (
429
+ {embeddedData.rows.length === 0 &&
430
+ embeddedData.columns.length === 0 ? (
430
431
  <div className="relation-test-data-editor__empty-data">
431
432
  <div className="relation-test-data-editor__empty-text">
432
433
  No test data rows. Click &quot;+&quot; below to start entering
@@ -440,13 +441,20 @@ export const RelationElementEditor = observer(
440
441
  <tr>
441
442
  {embeddedData.columns.map((column, columnIndex) => (
442
443
  <th
443
- key={column}
444
+ key={`col-${guaranteeNonNullable(columnIndex)}`}
444
445
  className="relation-test-data-editor__th"
445
446
  >
446
447
  <div className="relation-test-data-editor__th__inner">
447
- <span className="relation-test-data-editor__th__label">
448
- {column}
449
- </span>
448
+ <input
449
+ className="relation-test-data-editor__th__label relation-test-data-editor__th__label--editable"
450
+ type="text"
451
+ value={column}
452
+ onChange={(e) =>
453
+ updateColumn(columnIndex, e.target.value)
454
+ }
455
+ disabled={!canEditColumns}
456
+ title={column}
457
+ />
450
458
  <button
451
459
  className="relation-test-data-editor__th__delete"
452
460
  onClick={() => removeColumn(columnIndex)}
@@ -661,6 +661,7 @@ const SampleValuesEditorModal = observer(
661
661
  accessPointState.relationElementState
662
662
  }
663
663
  isReadOnly={isReadOnly}
664
+ hideColumnDefinitions={true}
664
665
  />
665
666
  </div>
666
667
  </Tooltip>
@@ -595,6 +595,7 @@ const EqualToRelationAsssertionEditor = observer(
595
595
  equalToRelationAssertionState.expectedRelationElementState
596
596
  }
597
597
  isReadOnly={isReadOnly}
598
+ hideColumnDefinitions={true}
598
599
  />
599
600
  );
600
601
  },
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Copyright (c) 2020-present, Goldman Sachs
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import { observer } from 'mobx-react-lite';
18
+ import {
19
+ FolderIcon,
20
+ HistoryIcon,
21
+ TimesIcon,
22
+ UserIcon,
23
+ UsersIcon,
24
+ } from '@finos/legend-art';
25
+ import { WorkspaceType } from '@finos/legend-server-sdlc';
26
+ import type { WorkspaceSetupStore } from '../../stores/workspace-setup/WorkspaceSetupStore.js';
27
+ import { generateEditorRoute } from '../../__lib__/LegendStudioNavigation.js';
28
+
29
+ const MAX_TILES = 6;
30
+
31
+ const formatRelativeTime = (timestamp: number): string => {
32
+ const diffMs = Date.now() - timestamp;
33
+ if (diffMs < 0) {
34
+ return 'just now';
35
+ }
36
+ const minutes = Math.floor(diffMs / 60_000);
37
+ if (minutes < 1) {
38
+ return 'just now';
39
+ }
40
+ if (minutes < 60) {
41
+ return `${minutes}m ago`;
42
+ }
43
+ const hours = Math.floor(minutes / 60);
44
+ if (hours < 24) {
45
+ return `${hours}h ago`;
46
+ }
47
+ const days = Math.floor(hours / 24);
48
+ if (days < 7) {
49
+ return `${days}d ago`;
50
+ }
51
+ const weeks = Math.floor(days / 7);
52
+ if (weeks < 4) {
53
+ return `${weeks}w ago`;
54
+ }
55
+ const months = Math.floor(days / 30);
56
+ if (months < 12) {
57
+ return `${months}mo ago`;
58
+ }
59
+ return `${Math.floor(days / 365)}y ago`;
60
+ };
61
+
62
+ export const RecentWorkspacesPanel = observer(
63
+ (props: { setupStore: WorkspaceSetupStore }) => {
64
+ const { setupStore } = props;
65
+ const applicationStore = setupStore.applicationStore;
66
+
67
+ // Recents are stored in LRU order (most-recent first). Show the top N
68
+ // workspaces; map each to its project name (or fall back to projectId
69
+ // if the matching project entry was evicted independently).
70
+ const tiles = setupStore.recentWorkspaces.slice(0, MAX_TILES);
71
+ if (tiles.length === 0) {
72
+ return null;
73
+ }
74
+
75
+ const projectNameById = new Map(
76
+ setupStore.recentProjects.map((p) => [p.projectId, p.name]),
77
+ );
78
+
79
+ const openWorkspace = (
80
+ projectId: string,
81
+ workspaceId: string,
82
+ workspaceType: WorkspaceType,
83
+ ): void => {
84
+ applicationStore.navigationService.navigator.goToLocation(
85
+ generateEditorRoute(projectId, undefined, workspaceId, workspaceType),
86
+ );
87
+ };
88
+
89
+ return (
90
+ <div className="workspace-setup__recents">
91
+ <div className="workspace-setup__recents__header">
92
+ <div className="workspace-setup__recents__header__title">
93
+ <HistoryIcon />
94
+ <span>Recent workspaces</span>
95
+ </div>
96
+ </div>
97
+ <div className="workspace-setup__recents__grid">
98
+ {tiles.map((entry) => {
99
+ const projectName =
100
+ projectNameById.get(entry.projectId) ?? entry.projectId;
101
+ const key = `${entry.projectId}::${entry.workspaceType}::${entry.workspaceId}`;
102
+ const handleRemove = (
103
+ event: React.MouseEvent<HTMLButtonElement>,
104
+ ): void => {
105
+ event.stopPropagation();
106
+ setupStore.removeRecentWorkspace({
107
+ projectId: entry.projectId,
108
+ workspaceId: entry.workspaceId,
109
+ workspaceType: entry.workspaceType,
110
+ });
111
+ };
112
+ return (
113
+ <button
114
+ key={key}
115
+ type="button"
116
+ className="workspace-setup__recents__tile"
117
+ title={`Open ${projectName} / ${entry.workspaceId}`}
118
+ onClick={() =>
119
+ openWorkspace(
120
+ entry.projectId,
121
+ entry.workspaceId,
122
+ entry.workspaceType,
123
+ )
124
+ }
125
+ >
126
+ <button
127
+ type="button"
128
+ tabIndex={-1}
129
+ className="workspace-setup__recents__tile__remove"
130
+ title="Remove from recents"
131
+ onClick={handleRemove}
132
+ >
133
+ <TimesIcon />
134
+ </button>
135
+ <div className="workspace-setup__recents__tile__project">
136
+ <FolderIcon />
137
+ <span className="workspace-setup__recents__tile__project__name">
138
+ {projectName}
139
+ </span>
140
+ </div>
141
+ <div className="workspace-setup__recents__tile__workspace">
142
+ {entry.workspaceType === WorkspaceType.GROUP ? (
143
+ <UsersIcon />
144
+ ) : (
145
+ <UserIcon />
146
+ )}
147
+ <span className="workspace-setup__recents__tile__workspace__name">
148
+ {entry.workspaceId}
149
+ </span>
150
+ </div>
151
+ <div className="workspace-setup__recents__tile__time">
152
+ {formatRelativeTime(entry.lastOpenedAt)}
153
+ </div>
154
+ </button>
155
+ );
156
+ })}
157
+ </div>
158
+ </div>
159
+ );
160
+ },
161
+ );
@@ -52,6 +52,7 @@ import { CreateProjectModal } from './CreateProjectModal.js';
52
52
  import { ActivityBarMenu } from '../editor/ActivityBar.js';
53
53
  import { LEGEND_STUDIO_APPLICATION_NAVIGATION_CONTEXT_KEY } from '../../__lib__/LegendStudioApplicationNavigationContext.js';
54
54
  import { CreateWorkspaceModal } from './CreateWorkspaceModal.js';
55
+ import { RecentWorkspacesPanel } from './RecentWorkspacesPanel.js';
55
56
  import {
56
57
  useLegendStudioApplicationStore,
57
58
  useLegendStudioBaseStore,
@@ -66,7 +67,12 @@ import {
66
67
  buildWorkspaceOption,
67
68
  formatWorkspaceOptionLabel,
68
69
  } from './WorkspaceSelectorUtils.js';
69
- import { debounce, guaranteeNonNullable } from '@finos/legend-shared';
70
+ import {
71
+ debounce,
72
+ guaranteeNonNullable,
73
+ type PlainObject,
74
+ } from '@finos/legend-shared';
75
+ import { Project } from '@finos/legend-server-sdlc';
70
76
  import { WorkspaceSetupStore } from '../../stores/workspace-setup/WorkspaceSetupStore.js';
71
77
  import { openShowcaseManager } from '../../stores/ShowcaseManagerState.js';
72
78
 
@@ -360,18 +366,57 @@ export const WorkspaceSetup = withWorkspaceSetupStore(
360
366
  applicationStore.assistantService.toggleAssistant();
361
367
 
362
368
  // projects
363
- const projectOptions = setupStore.projects
369
+ // Build a unified option list: recent projects (that aren't already in the
370
+ // loaded set) are prepended so users can instantly re-open common work
371
+ // without waiting for the SDLC search to round-trip.
372
+ const loadedProjectOptions = setupStore.projects
364
373
  .map(buildProjectOption)
365
374
  .sort(compareLabelFn);
375
+ const loadedProjectIds = new Set(
376
+ setupStore.projects.map((p) => p.projectId),
377
+ );
378
+ const recentProjectOptions: ProjectOption[] = setupStore.recentProjects
379
+ .filter((r) => !loadedProjectIds.has(r.projectId))
380
+ .map((r) => {
381
+ // Construct a lightweight Project stand-in so the existing selector
382
+ // contract is preserved. The full project will be fetched on click
383
+ // via `selectRecentProject`.
384
+ const stub = Project.serialization.fromJson({
385
+ projectId: r.projectId,
386
+ name: r.name,
387
+ description: '',
388
+ webUrl: '',
389
+ tags: [],
390
+ } as PlainObject<Project>);
391
+ return { label: stub.name, value: stub };
392
+ });
393
+ const projectOptions: ProjectOption[] = [
394
+ ...recentProjectOptions,
395
+ ...loadedProjectOptions,
396
+ ];
397
+ const recentProjectIdSet = new Set(
398
+ setupStore.recentProjects.map((p) => p.projectId),
399
+ );
366
400
  const selectedProjectOption = setupStore.currentProject
367
401
  ? buildProjectOption(setupStore.currentProject)
368
402
  : null;
369
403
 
370
404
  const onProjectChange = (val: ProjectOption | null): void => {
371
405
  if (val) {
372
- flowResult(setupStore.changeProject(val.value)).catch(
373
- applicationStore.alertUnhandledError,
374
- );
406
+ // If the selection corresponds to a recent that hasn't been loaded
407
+ // from search yet, fetch the project before switching.
408
+ const isUnloadedRecent =
409
+ !loadedProjectIds.has(val.value.projectId) &&
410
+ recentProjectIdSet.has(val.value.projectId);
411
+ if (isUnloadedRecent) {
412
+ flowResult(setupStore.selectRecentProject(val.value.projectId)).catch(
413
+ applicationStore.alertUnhandledError,
414
+ );
415
+ } else {
416
+ flowResult(setupStore.changeProject(val.value)).catch(
417
+ applicationStore.alertUnhandledError,
418
+ );
419
+ }
375
420
  } else {
376
421
  setupStore.resetProject();
377
422
  }
@@ -404,9 +449,40 @@ export const WorkspaceSetup = withWorkspaceSetupStore(
404
449
  };
405
450
 
406
451
  // workspaces
407
- const workspaceOptions = setupStore.workspaces
408
- .map(buildWorkspaceOption)
452
+ // Recent (non-patch) workspaces for the currently selected project are
453
+ // floated to the top of the dropdown so users can re-enter their
454
+ // typical work without scanning the full list.
455
+ const projectIdForRecents = setupStore.currentProject?.projectId;
456
+ const recentWorkspaceKeys = new Set(
457
+ projectIdForRecents
458
+ ? setupStore.recentWorkspaces
459
+ .filter((w) => w.projectId === projectIdForRecents)
460
+ .map((w) => `${w.workspaceType}::${w.workspaceId}`)
461
+ : [],
462
+ );
463
+ const allWorkspaceOptions = setupStore.workspaces.map(buildWorkspaceOption);
464
+ const recentWorkspaceOptions = allWorkspaceOptions
465
+ .filter(
466
+ (o) =>
467
+ o.value.source === undefined &&
468
+ recentWorkspaceKeys.has(
469
+ `${o.value.workspaceType}::${o.value.workspaceId}`,
470
+ ),
471
+ )
472
+ .sort(compareLabelFn);
473
+ const otherWorkspaceOptions = allWorkspaceOptions
474
+ .filter(
475
+ (o) =>
476
+ o.value.source !== undefined ||
477
+ !recentWorkspaceKeys.has(
478
+ `${o.value.workspaceType}::${o.value.workspaceId}`,
479
+ ),
480
+ )
409
481
  .sort(compareLabelFn);
482
+ const workspaceOptions = [
483
+ ...recentWorkspaceOptions,
484
+ ...otherWorkspaceOptions,
485
+ ];
410
486
  const selectedWorkspaceOption = setupStore.currentWorkspace
411
487
  ? buildWorkspaceOption(setupStore.currentWorkspace)
412
488
  : null;
@@ -489,11 +565,24 @@ export const WorkspaceSetup = withWorkspaceSetupStore(
489
565
  Welcome to Legend Studio
490
566
  </div>
491
567
  </div>
568
+ <RecentWorkspacesPanel setupStore={setupStore} />
492
569
  <div className="workspace-setup__selectors">
493
570
  <div className="workspace-setup__selectors__container">
494
571
  <div className="workspace-setup__selector">
495
572
  <div className="workspace-setup__selector__header">
496
- Search for an existing project
573
+ <span>Search for an existing project</span>
574
+ {(setupStore.recentProjects.length > 0 ||
575
+ setupStore.recentWorkspaces.length > 0) && (
576
+ <button
577
+ type="button"
578
+ className="workspace-setup__selector__header__clear-recents"
579
+ tabIndex={-1}
580
+ onClick={() => setupStore.clearRecents()}
581
+ title="Clear recently-opened projects and workspaces"
582
+ >
583
+ Clear recents
584
+ </button>
585
+ )}
497
586
  </div>
498
587
  <div className="workspace-setup__selector__content">
499
588
  <div
@@ -93,6 +93,7 @@ import {
93
93
  DEFAULT_TAB_SIZE,
94
94
  } from '@finos/legend-application';
95
95
  import { LEGEND_STUDIO_APP_EVENT } from '../../__lib__/LegendStudioEvent.js';
96
+ import { LegendStudioUserDataHelper } from '../../__lib__/LegendStudioUserDataHelper.js';
96
97
  import type { EditorMode } from './EditorMode.js';
97
98
  import { StandardEditorMode } from './StandardEditorMode.js';
98
99
  import { WorkspaceUpdateConflictResolutionState } from './sidebar-state/WorkspaceUpdateConflictResolutionState.js';
@@ -691,6 +692,12 @@ export class EditorStore implements CommandRegistrar {
691
692
  }),
692
693
  );
693
694
  if (!this.sdlcState.currentProject) {
695
+ // The project the user navigated to doesn't exist (or isn't accessible).
696
+ // Drop it from the recents cache so we don't keep offering a dead link.
697
+ LegendStudioUserDataHelper.workspaceSetup_removeRecentProject(
698
+ this.applicationStore.userDataService,
699
+ projectId,
700
+ );
694
701
  // If the project is not found or the user does not have access to it,
695
702
  // we will not automatically redirect them to the setup page as they will lose the URL
696
703
  // instead, we give them the option to:
@@ -740,6 +747,15 @@ export class EditorStore implements CommandRegistrar {
740
747
  ),
741
748
  );
742
749
  if (!this.sdlcState.currentWorkspace) {
750
+ // The workspace the user navigated to doesn't exist anymore. Drop
751
+ // the matching entry from the recents cache (no-op for patch
752
+ // workspaces, which are never cached in the first place).
753
+ if (patchReleaseVersionId === undefined) {
754
+ LegendStudioUserDataHelper.workspaceSetup_removeRecentWorkspace(
755
+ this.applicationStore.userDataService,
756
+ { projectId, workspaceId, workspaceType },
757
+ );
758
+ }
743
759
  // If the workspace is not found,
744
760
  // we will not automatically redirect the user to the setup page as they will lose the URL
745
761
  // instead, we give them the option to:
@@ -810,6 +826,34 @@ export class EditorStore implements CommandRegistrar {
810
826
  onLeave(false);
811
827
  return;
812
828
  }
829
+ // At this point both the project and the workspace have been confirmed
830
+ // to exist on the server (the guards above bail out otherwise), so this
831
+ // is the authoritative "the user actually opened this workspace" moment.
832
+ // Record it in the recents cache so the workspace setup screen can offer
833
+ // it instantly next time.
834
+ // NOTE: patch-based workspaces are intentionally excluded from recents
835
+ // (they are not surfaced in the recents UI). Sandbox projects ARE
836
+ // included — opening one is still a meaningful "I worked here" signal,
837
+ // and surfacing it alongside other recents gives a faster one-click
838
+ // re-entry than waiting for the dedicated sandbox loader.
839
+ if (this.sdlcState.currentWorkspace.source === undefined) {
840
+ LegendStudioUserDataHelper.workspaceSetup_recordRecentProject(
841
+ this.applicationStore.userDataService,
842
+ {
843
+ projectId: this.sdlcState.currentProject.projectId,
844
+ name: this.sdlcState.currentProject.name,
845
+ },
846
+ );
847
+ LegendStudioUserDataHelper.workspaceSetup_recordRecentWorkspace(
848
+ this.applicationStore.userDataService,
849
+ {
850
+ projectId: this.sdlcState.currentProject.projectId,
851
+ workspaceId: this.sdlcState.currentWorkspace.workspaceId,
852
+ workspaceType: this.sdlcState.currentWorkspace.workspaceType,
853
+ },
854
+ );
855
+ }
856
+
813
857
  yield Promise.all([
814
858
  this.sdlcState.fetchCurrentRevision(
815
859
  projectId,