@finos/legend-application-studio 28.21.4 → 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 (100) 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 +1 -1
  14. package/lib/components/editor/editor-group/data-editor/EmbeddedDataEditor.d.ts.map +1 -1
  15. package/lib/components/editor/editor-group/data-editor/EmbeddedDataEditor.js +3 -3
  16. package/lib/components/editor/editor-group/data-editor/EmbeddedDataEditor.js.map +1 -1
  17. package/lib/components/editor/editor-group/data-editor/RelationElementsDataEditor.d.ts +3 -0
  18. package/lib/components/editor/editor-group/data-editor/RelationElementsDataEditor.d.ts.map +1 -1
  19. package/lib/components/editor/editor-group/data-editor/RelationElementsDataEditor.js +13 -35
  20. package/lib/components/editor/editor-group/data-editor/RelationElementsDataEditor.js.map +1 -1
  21. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.d.ts.map +1 -1
  22. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js +20 -7
  23. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js.map +1 -1
  24. package/lib/components/editor/editor-group/dataProduct/testable/DataProductTestableEditor.d.ts.map +1 -1
  25. package/lib/components/editor/editor-group/dataProduct/testable/DataProductTestableEditor.js +59 -22
  26. package/lib/components/editor/editor-group/dataProduct/testable/DataProductTestableEditor.js.map +1 -1
  27. package/lib/components/editor/editor-group/function-activator/testable/FunctionTestableEditor.d.ts.map +1 -1
  28. package/lib/components/editor/editor-group/function-activator/testable/FunctionTestableEditor.js +113 -75
  29. package/lib/components/editor/editor-group/function-activator/testable/FunctionTestableEditor.js.map +1 -1
  30. package/lib/components/editor/editor-group/testable/TestableSharedComponents.d.ts.map +1 -1
  31. package/lib/components/editor/editor-group/testable/TestableSharedComponents.js +2 -2
  32. package/lib/components/editor/editor-group/testable/TestableSharedComponents.js.map +1 -1
  33. package/lib/components/editor/side-bar/DevMetadataPanel.d.ts.map +1 -1
  34. package/lib/components/editor/side-bar/DevMetadataPanel.js +37 -6
  35. package/lib/components/editor/side-bar/DevMetadataPanel.js.map +1 -1
  36. package/lib/components/workspace-setup/RecentWorkspacesPanel.d.ts +22 -0
  37. package/lib/components/workspace-setup/RecentWorkspacesPanel.d.ts.map +1 -0
  38. package/lib/components/workspace-setup/RecentWorkspacesPanel.js +80 -0
  39. package/lib/components/workspace-setup/RecentWorkspacesPanel.js.map +1 -0
  40. package/lib/components/workspace-setup/WorkspaceSetup.d.ts.map +1 -1
  41. package/lib/components/workspace-setup/WorkspaceSetup.js +61 -6
  42. package/lib/components/workspace-setup/WorkspaceSetup.js.map +1 -1
  43. package/lib/index.css +2 -2
  44. package/lib/index.css.map +1 -1
  45. package/lib/package.json +1 -1
  46. package/lib/stores/editor/EditorStore.d.ts.map +1 -1
  47. package/lib/stores/editor/EditorStore.js +31 -0
  48. package/lib/stores/editor/EditorStore.js.map +1 -1
  49. package/lib/stores/editor/editor-state/element-editor-state/data/EmbeddedDataState.d.ts +1 -1
  50. package/lib/stores/editor/editor-state/element-editor-state/data/EmbeddedDataState.d.ts.map +1 -1
  51. package/lib/stores/editor/editor-state/element-editor-state/data/EmbeddedDataState.js +20 -48
  52. package/lib/stores/editor/editor-state/element-editor-state/data/EmbeddedDataState.js.map +1 -1
  53. package/lib/stores/editor/editor-state/element-editor-state/dataProduct/testable/DataProductTestableState.d.ts +9 -14
  54. package/lib/stores/editor/editor-state/element-editor-state/dataProduct/testable/DataProductTestableState.d.ts.map +1 -1
  55. package/lib/stores/editor/editor-state/element-editor-state/dataProduct/testable/DataProductTestableState.js +125 -78
  56. package/lib/stores/editor/editor-state/element-editor-state/dataProduct/testable/DataProductTestableState.js.map +1 -1
  57. package/lib/stores/editor/editor-state/element-editor-state/function-activator/testable/FunctionTestableState.d.ts +18 -4
  58. package/lib/stores/editor/editor-state/element-editor-state/function-activator/testable/FunctionTestableState.d.ts.map +1 -1
  59. package/lib/stores/editor/editor-state/element-editor-state/function-activator/testable/FunctionTestableState.js +216 -53
  60. package/lib/stores/editor/editor-state/element-editor-state/function-activator/testable/FunctionTestableState.js.map +1 -1
  61. package/lib/stores/editor/sidebar-state/ProjectOverviewState.d.ts.map +1 -1
  62. package/lib/stores/editor/sidebar-state/ProjectOverviewState.js +11 -0
  63. package/lib/stores/editor/sidebar-state/ProjectOverviewState.js.map +1 -1
  64. package/lib/stores/editor/sidebar-state/WorkspaceReviewState.d.ts.map +1 -1
  65. package/lib/stores/editor/sidebar-state/WorkspaceReviewState.js +11 -0
  66. package/lib/stores/editor/sidebar-state/WorkspaceReviewState.js.map +1 -1
  67. package/lib/stores/editor/sidebar-state/dev-metadata/DevMetadataState.d.ts +9 -0
  68. package/lib/stores/editor/sidebar-state/dev-metadata/DevMetadataState.d.ts.map +1 -1
  69. package/lib/stores/editor/sidebar-state/dev-metadata/DevMetadataState.js +57 -1
  70. package/lib/stores/editor/sidebar-state/dev-metadata/DevMetadataState.js.map +1 -1
  71. package/lib/stores/project-reviewer/ProjectReviewerStore.d.ts.map +1 -1
  72. package/lib/stores/project-reviewer/ProjectReviewerStore.js +12 -0
  73. package/lib/stores/project-reviewer/ProjectReviewerStore.js.map +1 -1
  74. package/lib/stores/workspace-setup/WorkspaceSetupStore.d.ts +17 -0
  75. package/lib/stores/workspace-setup/WorkspaceSetupStore.d.ts.map +1 -1
  76. package/lib/stores/workspace-setup/WorkspaceSetupStore.js +61 -0
  77. package/lib/stores/workspace-setup/WorkspaceSetupStore.js.map +1 -1
  78. package/package.json +16 -16
  79. package/src/__lib__/LegendStudioEvent.ts +3 -0
  80. package/src/__lib__/LegendStudioTelemetryHelper.ts +35 -11
  81. package/src/__lib__/LegendStudioUserDataHelper.ts +204 -1
  82. package/src/components/editor/editor-group/data-editor/EmbeddedDataEditor.tsx +4 -0
  83. package/src/components/editor/editor-group/data-editor/RelationElementsDataEditor.tsx +209 -187
  84. package/src/components/editor/editor-group/dataProduct/DataProductEditor.tsx +26 -7
  85. package/src/components/editor/editor-group/dataProduct/testable/DataProductTestableEditor.tsx +149 -86
  86. package/src/components/editor/editor-group/function-activator/testable/FunctionTestableEditor.tsx +425 -308
  87. package/src/components/editor/editor-group/testable/TestableSharedComponents.tsx +3 -11
  88. package/src/components/editor/side-bar/DevMetadataPanel.tsx +194 -10
  89. package/src/components/workspace-setup/RecentWorkspacesPanel.tsx +161 -0
  90. package/src/components/workspace-setup/WorkspaceSetup.tsx +97 -8
  91. package/src/stores/editor/EditorStore.ts +44 -0
  92. package/src/stores/editor/editor-state/element-editor-state/data/EmbeddedDataState.ts +28 -50
  93. package/src/stores/editor/editor-state/element-editor-state/dataProduct/testable/DataProductTestableState.ts +164 -100
  94. package/src/stores/editor/editor-state/element-editor-state/function-activator/testable/FunctionTestableState.ts +307 -72
  95. package/src/stores/editor/sidebar-state/ProjectOverviewState.ts +14 -0
  96. package/src/stores/editor/sidebar-state/WorkspaceReviewState.ts +14 -0
  97. package/src/stores/editor/sidebar-state/dev-metadata/DevMetadataState.ts +84 -1
  98. package/src/stores/project-reviewer/ProjectReviewerStore.ts +15 -0
  99. package/src/stores/workspace-setup/WorkspaceSetupStore.ts +93 -0
  100. package/tsconfig.json +1 -0
@@ -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
  },
@@ -610,25 +611,16 @@ const DataProductEqualToRelationAssertionEditor = observer(
610
611
 
611
612
  return (
612
613
  <div className="service-test-data-editor panel">
613
- <div className="function-testable-editor__header">
614
- <div className="function-testable-editor__header__title">
615
- <div className="function-testable-editor__header__title__label">
616
- expected
617
- </div>
618
- </div>
619
- </div>
620
614
  <div className="panel__content__form__section">
621
615
  <div className="panel__content__form__section__header__label">
622
- Access Point
623
- </div>
624
- <div className="panel__content__form__section__header__prompt">
625
- {testState.accessPointLabel}
616
+ Access Point: {testState.accessPointLabel}
626
617
  </div>
627
618
  </div>
628
619
  {relationElementState ? (
629
620
  <RelationElementEditor
630
621
  relationElementState={relationElementState}
631
622
  isReadOnly={isReadOnly}
623
+ hideColumnDefinitions={true}
632
624
  />
633
625
  ) : (
634
626
  <BlankPanelPlaceholder
@@ -46,9 +46,13 @@ import {
46
46
  TrashIcon,
47
47
  PlusIcon,
48
48
  CogIcon,
49
+ InfoCircleIcon,
50
+ CompareIcon,
51
+ ChevronDownIcon,
52
+ ChevronRightIcon,
49
53
  } from '@finos/legend-art';
50
54
  import { CODE_EDITOR_LANGUAGE } from '@finos/legend-code-editor';
51
- import { CodeEditor } from '@finos/legend-lego/code-editor';
55
+ import { CodeDiffView, CodeEditor } from '@finos/legend-lego/code-editor';
52
56
  import {
53
57
  type BuildLog,
54
58
  type BuildPhaseActionState,
@@ -338,6 +342,103 @@ const getPhaseStatusIcon = (status: BuildPhaseStatus): ReactNode => {
338
342
  }
339
343
  };
340
344
 
345
+ const DevMetadataCompareModal = observer(() => {
346
+ const editorStore = useEditorStore();
347
+ const applicationStore = editorStore.applicationStore;
348
+ const devMetadataState = editorStore.devMetadataState;
349
+ const isOpen = devMetadataState.isCompareModalOpen;
350
+ const isLoading = devMetadataState.compareState.isInProgress;
351
+
352
+ if (!isOpen) {
353
+ return null;
354
+ }
355
+
356
+ return (
357
+ <Dialog
358
+ open={isOpen}
359
+ onClose={() => devMetadataState.closeCompareModal()}
360
+ classes={{
361
+ root: 'editor-modal__root-container',
362
+ container: 'editor-modal__container',
363
+ paper: 'editor-modal__content',
364
+ }}
365
+ >
366
+ <Modal
367
+ darkMode={
368
+ !applicationStore.layoutService.TEMPORARY__isLightColorThemeEnabled
369
+ }
370
+ className="editor-modal dev-metadata-compare-modal"
371
+ >
372
+ <ModalHeader>
373
+ <ModalTitle title="Compare Workspace with Dev Snapshot" />
374
+ </ModalHeader>
375
+ <ModalBody className="dev-metadata-compare-modal__body">
376
+ {isLoading && (
377
+ <div className="dev-metadata-compare-modal__loading">
378
+ <CircleNotchIcon className="dev-metadata-compare-modal__loading__spinner" />
379
+ <span>Loading diff…</span>
380
+ </div>
381
+ )}
382
+ {!isLoading && devMetadataState.snapshotNotAvailable && (
383
+ <div className="dev-metadata-compare-modal__empty">
384
+ <InfoCircleIcon />
385
+ <div>
386
+ <div className="dev-metadata-compare-modal__empty__title">
387
+ Dev snapshot not available
388
+ </div>
389
+ <div className="dev-metadata-compare-modal__empty__body">
390
+ No deployed metadata was found for this project at version{' '}
391
+ <code>1.0.0-SNAPSHOT</code>. Push to dev first to create a
392
+ snapshot to compare against.
393
+ </div>
394
+ </div>
395
+ </div>
396
+ )}
397
+ {!isLoading && !devMetadataState.snapshotNotAvailable && (
398
+ <div className="dev-metadata-compare-modal__diff">
399
+ <div className="dev-metadata-compare-modal__diff__legend">
400
+ <div className="dev-metadata-compare-modal__diff__legend__side dev-metadata-compare-modal__diff__legend__side--from">
401
+ <span className="dev-metadata-compare-modal__diff__legend__badge dev-metadata-compare-modal__diff__legend__badge--from">
402
+ Deployed
403
+ </span>
404
+ <span className="dev-metadata-compare-modal__diff__legend__label">
405
+ Dev Snapshot
406
+ </span>
407
+ <code className="dev-metadata-compare-modal__diff__legend__version">
408
+ 1.0.0-SNAPSHOT
409
+ </code>
410
+ </div>
411
+ <div className="dev-metadata-compare-modal__diff__legend__side dev-metadata-compare-modal__diff__legend__side--to">
412
+ <span className="dev-metadata-compare-modal__diff__legend__badge dev-metadata-compare-modal__diff__legend__badge--to">
413
+ Local
414
+ </span>
415
+ <span className="dev-metadata-compare-modal__diff__legend__label">
416
+ Current Workspace
417
+ </span>
418
+ </div>
419
+ </div>
420
+ <div className="dev-metadata-compare-modal__diff__view">
421
+ <CodeDiffView
422
+ language={CODE_EDITOR_LANGUAGE.PURE}
423
+ from={devMetadataState.snapshotCode ?? ''}
424
+ to={devMetadataState.currentWorkspaceCode ?? ''}
425
+ />
426
+ </div>
427
+ </div>
428
+ )}
429
+ </ModalBody>
430
+ <ModalFooter>
431
+ <ModalFooterButton
432
+ text="Close"
433
+ onClick={() => devMetadataState.closeCompareModal()}
434
+ type="secondary"
435
+ />
436
+ </ModalFooter>
437
+ </Modal>
438
+ </Dialog>
439
+ );
440
+ });
441
+
341
442
  const PhaseLogsViewer = observer(
342
443
  (props: { phase: BuildPhaseActionState; onClose: () => void }) => {
343
444
  const { phase, onClose } = props;
@@ -356,6 +457,17 @@ const PhaseLogsViewer = observer(
356
457
 
357
458
  const logs = phase.logs ? formatLogs(phase.logs) : 'No logs available';
358
459
 
460
+ const handleCopyLogs = (): void => {
461
+ applicationStore.clipboardService
462
+ .copyTextToClipboard(logs)
463
+ .then(() =>
464
+ applicationStore.notificationService.notifySuccess(
465
+ 'Logs copied to clipboard',
466
+ ),
467
+ )
468
+ .catch(applicationStore.alertUnhandledError);
469
+ };
470
+
359
471
  return (
360
472
  <Dialog
361
473
  open={true}
@@ -386,6 +498,11 @@ const PhaseLogsViewer = observer(
386
498
  />
387
499
  </ModalBody>
388
500
  <ModalFooter>
501
+ <ModalFooterButton
502
+ text="Copy Logs"
503
+ onClick={handleCopyLogs}
504
+ type="secondary"
505
+ />
389
506
  <ModalFooterButton
390
507
  text="Close"
391
508
  onClick={onClose}
@@ -444,7 +561,13 @@ const DeploymentPhaseNode = observer(
444
561
  }
445
562
  menuProps={{ elevation: 7 }}
446
563
  >
447
- <div className="deployment-phase__node">
564
+ <div
565
+ className={clsx('deployment-phase__node', {
566
+ 'deployment-phase__node--clickable': hasLogs,
567
+ })}
568
+ onClick={hasLogs ? () => onViewLogs(phase) : undefined}
569
+ title={hasLogs ? 'Click to view logs' : undefined}
570
+ >
448
571
  <div className="deployment-phase__node__icon">{statusIcon}</div>
449
572
  <div className="deployment-phase__node__content">
450
573
  <div className="deployment-phase__node__title">{phase.phase}</div>
@@ -578,6 +701,7 @@ export const DevMetadataPanel = observer(() => {
578
701
  const editorStore = useEditorStore();
579
702
  const devMetadataState = editorStore.devMetadataState;
580
703
  const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
704
+ const [isInfoExpanded, setIsInfoExpanded] = useState(false);
581
705
 
582
706
  const handlePush = (): void => {
583
707
  flowResult(devMetadataState.push()).catch(
@@ -589,7 +713,15 @@ export const DevMetadataPanel = observer(() => {
589
713
  devMetadataState.setOptions(newOptions);
590
714
  };
591
715
 
716
+ const handleCompare = (): void => {
717
+ devMetadataState.openCompareModal();
718
+ flowResult(devMetadataState.compareWithSnapshot()).catch(
719
+ editorStore.applicationStore.alertUnhandledError,
720
+ );
721
+ };
722
+
592
723
  const isPushing = devMetadataState.pushState.isInProgress;
724
+ const isComparing = devMetadataState.compareState.isInProgress;
593
725
 
594
726
  return (
595
727
  <Panel>
@@ -628,6 +760,46 @@ export const DevMetadataPanel = observer(() => {
628
760
  </PanelFormSection>
629
761
 
630
762
  <PanelDivider />
763
+ <PanelFormSection>
764
+ <div
765
+ className={clsx('dev-metadata-panel__info-callout', {
766
+ 'dev-metadata-panel__info-callout--collapsed': !isInfoExpanded,
767
+ })}
768
+ >
769
+ <button
770
+ type="button"
771
+ className="dev-metadata-panel__info-callout__header"
772
+ onClick={() => setIsInfoExpanded(!isInfoExpanded)}
773
+ title={isInfoExpanded ? 'Hide details' : 'Show details'}
774
+ >
775
+ <span className="dev-metadata-panel__info-callout__header__chevron">
776
+ {isInfoExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}
777
+ </span>
778
+ <InfoCircleIcon />
779
+ <span className="dev-metadata-panel__info-callout__header__title">
780
+ Heads up — you&apos;re using Dev Mode
781
+ </span>
782
+ </button>
783
+ {isInfoExpanded && (
784
+ <div className="dev-metadata-panel__info-callout__content">
785
+ <div className="dev-metadata-panel__info-callout__body">
786
+ This pushes your current workspace straight to your dev branch
787
+ (
788
+ <code className="dev-metadata-panel__info-callout__code">
789
+ 1.0.0-SNAPSHOT
790
+ </code>
791
+ ), bypassing the GitLab build pipeline so you can iterate and
792
+ test changes faster.
793
+ </div>
794
+ <div className="dev-metadata-panel__info-callout__body">
795
+ Any Lakehouse elements in your workspace — including ingests,
796
+ materialized views, and data products — will be deployed by
797
+ default.
798
+ </div>
799
+ </div>
800
+ )}
801
+ </div>
802
+ </PanelFormSection>
631
803
  <PanelFormSection>
632
804
  <div className="dev-metadata-panel__push-section">
633
805
  <div className="dev-metadata-panel__push-header">
@@ -635,14 +807,25 @@ export const DevMetadataPanel = observer(() => {
635
807
  <div className="panel__content__form__section__header__label">
636
808
  Deploy Metadata
637
809
  </div>
638
- <button
639
- className="dev-metadata-panel__settings-btn"
640
- onClick={() => setIsOptionsModalOpen(true)}
641
- title="Configure deployment options"
642
- disabled={isPushing}
643
- >
644
- <CogIcon />
645
- </button>
810
+ <div className="dev-metadata-panel__push-title-row__actions">
811
+ <button
812
+ className="dev-metadata-panel__compare-btn"
813
+ onClick={handleCompare}
814
+ title="Compare current workspace with the deployed dev snapshot"
815
+ disabled={isPushing || isComparing}
816
+ >
817
+ <CompareIcon />
818
+ <span>Compare with Dev</span>
819
+ </button>
820
+ <button
821
+ className="dev-metadata-panel__settings-btn"
822
+ onClick={() => setIsOptionsModalOpen(true)}
823
+ title="Configure deployment options"
824
+ disabled={isPushing}
825
+ >
826
+ <CogIcon />
827
+ </button>
828
+ </div>
646
829
  </div>
647
830
  <div className="dev-metadata-panel__push-description">
648
831
  {isPushing
@@ -702,6 +885,7 @@ export const DevMetadataPanel = observer(() => {
702
885
  options={devMetadataState.options}
703
886
  onSave={handleSaveOptions}
704
887
  />
888
+ <DevMetadataCompareModal />
705
889
  </PanelContent>
706
890
  </Panel>
707
891
  );
@@ -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