diff --git a/app/dashboard/src/components/dashboard/AssetRow.tsx b/app/dashboard/src/components/dashboard/AssetRow.tsx index 183a5314df..d059c90727 100644 --- a/app/dashboard/src/components/dashboard/AssetRow.tsx +++ b/app/dashboard/src/components/dashboard/AssetRow.tsx @@ -428,7 +428,8 @@ export default function AssetRow(props: AssetRowProps) { case AssetEventType.newSecret: case AssetEventType.updateFiles: case AssetEventType.openProject: - case AssetEventType.closeProject: { + case AssetEventType.closeProject: + case AssetEventType.projectClosed: { break } case AssetEventType.copy: { @@ -661,6 +662,12 @@ export default function AssetRow(props: AssetRowProps) { }) break } + case AssetEventType.setItem: { + if (asset.id === event.id) { + setAsset(event.valueOrUpdater) + } + break + } } } }, item.initialAssetEvents) diff --git a/app/dashboard/src/components/dashboard/DatalinkNameColumn.tsx b/app/dashboard/src/components/dashboard/DatalinkNameColumn.tsx index 27f481d1fd..9a63d03d74 100644 --- a/app/dashboard/src/components/dashboard/DatalinkNameColumn.tsx +++ b/app/dashboard/src/components/dashboard/DatalinkNameColumn.tsx @@ -87,7 +87,9 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) { case AssetEventType.temporarilyRemoveLabels: case AssetEventType.addLabels: case AssetEventType.removeLabels: - case AssetEventType.deleteLabel: { + case AssetEventType.deleteLabel: + case AssetEventType.setItem: + case AssetEventType.projectClosed: { // Ignored. These events should all be unrelated to secrets. // `delete`, `deleteForever`, `restoreMultiple`, `download`, and `downloadSelected` // are handled by `AssetRow`. diff --git a/app/dashboard/src/components/dashboard/DirectoryNameColumn.tsx b/app/dashboard/src/components/dashboard/DirectoryNameColumn.tsx index 256877423a..637000038c 100644 --- a/app/dashboard/src/components/dashboard/DirectoryNameColumn.tsx +++ b/app/dashboard/src/components/dashboard/DirectoryNameColumn.tsx @@ -113,7 +113,9 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { case AssetEventType.temporarilyRemoveLabels: case AssetEventType.addLabels: case AssetEventType.removeLabels: - case AssetEventType.deleteLabel: { + case AssetEventType.deleteLabel: + case AssetEventType.setItem: + case AssetEventType.projectClosed: { // Ignored. These events should all be unrelated to directories. // `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected` // are handled by`AssetRow`. diff --git a/app/dashboard/src/components/dashboard/FileNameColumn.tsx b/app/dashboard/src/components/dashboard/FileNameColumn.tsx index ff109a492d..d603d42070 100644 --- a/app/dashboard/src/components/dashboard/FileNameColumn.tsx +++ b/app/dashboard/src/components/dashboard/FileNameColumn.tsx @@ -108,7 +108,9 @@ export default function FileNameColumn(props: FileNameColumnProps) { case AssetEventType.temporarilyRemoveLabels: case AssetEventType.addLabels: case AssetEventType.removeLabels: - case AssetEventType.deleteLabel: { + case AssetEventType.deleteLabel: + case AssetEventType.setItem: + case AssetEventType.projectClosed: { // Ignored. These events should all be unrelated to projects. // `delete`, `deleteForever`, `restoreMultiple`, `download`, and `downloadSelected` // are handled by `AssetRow`. diff --git a/app/dashboard/src/components/dashboard/ProjectNameColumn.tsx b/app/dashboard/src/components/dashboard/ProjectNameColumn.tsx index 25c460f9b8..b30223e2df 100644 --- a/app/dashboard/src/components/dashboard/ProjectNameColumn.tsx +++ b/app/dashboard/src/components/dashboard/ProjectNameColumn.tsx @@ -1,6 +1,8 @@ /** @file The icon and name of a {@link backendModule.ProjectAsset}. */ import * as React from 'react' +import * as reactQuery from '@tanstack/react-query' + import NetworkIcon from '#/assets/network.svg' import * as backendHooks from '#/hooks/backendHooks' @@ -58,6 +60,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { isOpened, } = props const { backend, selectedKeys, nodeMap } = state + const client = reactQuery.useQueryClient() const toastAndLog = toastAndLogHooks.useToastAndLog() const { user } = authProvider.useNonPartialUserSession() const { getText } = textProvider.useText() @@ -122,6 +125,9 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { { ami: null, ideVersion: null, projectName: newTitle }, asset.title, ]) + await client.invalidateQueries({ + queryKey: projectHooks.createGetProjectDetailsQuery.getQueryKey(asset.id), + }) } catch (error) { toastAndLog('renameProjectError', error) setAsset(object.merger({ title: oldTitle })) @@ -151,7 +157,9 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { case AssetEventType.temporarilyRemoveLabels: case AssetEventType.addLabels: case AssetEventType.removeLabels: - case AssetEventType.deleteLabel: { + case AssetEventType.deleteLabel: + case AssetEventType.setItem: + case AssetEventType.projectClosed: { // Ignored. Any missing project-related events should be handled by `ProjectIcon`. // `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected` // are handled by`AssetRow`. diff --git a/app/dashboard/src/components/dashboard/SecretNameColumn.tsx b/app/dashboard/src/components/dashboard/SecretNameColumn.tsx index 5943ebb760..198908b990 100644 --- a/app/dashboard/src/components/dashboard/SecretNameColumn.tsx +++ b/app/dashboard/src/components/dashboard/SecretNameColumn.tsx @@ -87,7 +87,9 @@ export default function SecretNameColumn(props: SecretNameColumnProps) { case AssetEventType.temporarilyRemoveLabels: case AssetEventType.addLabels: case AssetEventType.removeLabels: - case AssetEventType.deleteLabel: { + case AssetEventType.deleteLabel: + case AssetEventType.setItem: + case AssetEventType.projectClosed: { // Ignored. These events should all be unrelated to secrets. // `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected` // are handled by`AssetRow`. diff --git a/app/dashboard/src/events/AssetEventType.ts b/app/dashboard/src/events/AssetEventType.ts index 2883d30798..d3b1621ed8 100644 --- a/app/dashboard/src/events/AssetEventType.ts +++ b/app/dashboard/src/events/AssetEventType.ts @@ -29,6 +29,8 @@ enum AssetEventType { addLabels = 'add-labels', removeLabels = 'remove-labels', deleteLabel = 'delete-label', + setItem = 'set-item', + projectClosed = 'project-closed', } // This is REQUIRED, as `export default enum` is invalid syntax. diff --git a/app/dashboard/src/events/assetEvent.ts b/app/dashboard/src/events/assetEvent.ts index c66479c2dd..815d55de12 100644 --- a/app/dashboard/src/events/assetEvent.ts +++ b/app/dashboard/src/events/assetEvent.ts @@ -37,6 +37,8 @@ interface AssetEvents { readonly addLabels: AssetAddLabelsEvent readonly removeLabels: AssetRemoveLabelsEvent readonly deleteLabel: AssetDeleteLabelEvent + readonly setItem: AssetSetItemEvent + readonly projectClosed: AssetProjectClosedEvent } /** A type to ensure that {@link AssetEvents} contains every {@link AssetEventType}. */ @@ -186,5 +188,17 @@ export interface AssetDeleteLabelEvent extends AssetBaseEvent { + readonly id: backend.AssetId + readonly valueOrUpdater: React.SetStateAction +} + +/** A signal that a project was closed. In this case, the consumer should not fire a + * "close project" request to the backend. */ +export interface AssetProjectClosedEvent extends AssetBaseEvent { + readonly id: backend.AssetId +} + /** Every possible type of asset event. */ export type AssetEvent = AssetEvents[keyof AssetEvents] diff --git a/app/dashboard/src/hooks/projectHooks.ts b/app/dashboard/src/hooks/projectHooks.ts index cd8850188c..2bdeb1afd8 100644 --- a/app/dashboard/src/hooks/projectHooks.ts +++ b/app/dashboard/src/hooks/projectHooks.ts @@ -5,6 +5,8 @@ import * as reactQuery from '@tanstack/react-query' import invariant from 'tiny-invariant' import * as z from 'zod' +import { merge } from 'enso-common/src/utilities/data/object' + import * as eventCallbacks from '#/hooks/eventCallbackHooks' import * as authProvider from '#/providers/AuthProvider' @@ -220,6 +222,7 @@ export function useRenameProjectMutation() { const client = reactQuery.useQueryClient() const remoteBackend = backendProvider.useRemoteBackendStrict() const localBackend = backendProvider.useLocalBackend() + const updateLaunchedProjects = projectsProvider.useUpdateLaunchedProjects() return reactQuery.useMutation({ mutationKey: ['renameProject'], @@ -231,10 +234,16 @@ export function useRenameProjectMutation() { return backend.updateProject(id, { projectName: newName, ami: null, ideVersion: null }, title) }, - onSuccess: (_, { project }) => - client.invalidateQueries({ + onSuccess: (_, { newName, project }) => { + updateLaunchedProjects(projects => + projects.map(otherProject => + project.id !== otherProject.id ? otherProject : merge(otherProject, { title: newName }) + ) + ) + return client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(project.id), - }), + }) + }, }) } diff --git a/app/dashboard/src/layouts/Editor.tsx b/app/dashboard/src/layouts/Editor.tsx index 6bd319bf14..156905de8e 100644 --- a/app/dashboard/src/layouts/Editor.tsx +++ b/app/dashboard/src/layouts/Editor.tsx @@ -72,7 +72,7 @@ export interface EditorProps { readonly ydocUrl: string | null readonly appRunner: GraphEditorRunner | null readonly renameProject: (newName: string) => void - readonly projectId: backendModule.ProjectAsset['id'] + readonly projectId: backendModule.ProjectId } /** The container that launches the IDE. */ diff --git a/app/dashboard/src/layouts/TabBar.tsx b/app/dashboard/src/layouts/TabBar.tsx index 916abbb451..056de24f82 100644 --- a/app/dashboard/src/layouts/TabBar.tsx +++ b/app/dashboard/src/layouts/TabBar.tsx @@ -234,7 +234,7 @@ export function Tab(props: InternalTabProps) { className={tailwindMerge.twMerge(onClose && 'group-hover:hidden focus-visible:hidden')} /> )} - {children} + {data?.name ?? children} {onClose && (
diff --git a/app/dashboard/src/layouts/UserBar.tsx b/app/dashboard/src/layouts/UserBar.tsx index 2ded4e37a7..55b94024e7 100644 --- a/app/dashboard/src/layouts/UserBar.tsx +++ b/app/dashboard/src/layouts/UserBar.tsx @@ -51,7 +51,6 @@ export default function UserBar(props: UserBarProps) { const { setModal } = modalProvider.useSetModal() const { getText } = textProvider.useText() const { isFeatureUnderPaywall } = billing.usePaywall({ plan: user.plan }) - const shouldShowUpgradeButton = isFeatureUnderPaywall('inviteUser') const shouldShowShareButton = onShareClick != null const shouldShowInviteButton = !shouldShowShareButton && !shouldShowUpgradeButton diff --git a/app/dashboard/src/pages/dashboard/Dashboard.tsx b/app/dashboard/src/pages/dashboard/Dashboard.tsx index 8433672e3e..ec97de1492 100644 --- a/app/dashboard/src/pages/dashboard/Dashboard.tsx +++ b/app/dashboard/src/pages/dashboard/Dashboard.tsx @@ -50,6 +50,7 @@ import * as projectManager from '#/services/ProjectManager' import * as array from '#/utilities/array' import LocalStorage from '#/utilities/LocalStorage' +import * as object from '#/utilities/object' import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets' // ============================ @@ -101,6 +102,7 @@ function DashboardInner(props: DashboardProps) { const inputBindings = inputBindingsProvider.useInputBindings() const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false) + const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent() const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() const assetManagementApiRef = React.useRef(null) @@ -359,8 +361,21 @@ function DashboardInner(props: DashboardProps) { isOpeningFailed={openProjectMutation.isError} openingError={openProjectMutation.error} startProject={openProjectMutation.mutate} - renameProject={newName => { - renameProjectMutation.mutate({ newName, project }) + renameProject={async newName => { + try { + await renameProjectMutation.mutateAsync({ newName, project }) + dispatchAssetEvent({ + type: AssetEventType.setItem, + id: project.id, + valueOrUpdater: object.merger({ title: newName }), + }) + } catch { + dispatchAssetEvent({ + type: AssetEventType.setItem, + id: project.id, + valueOrUpdater: object.merger({ title: project.title }), + }) + } }} /> diff --git a/app/dashboard/src/providers/ProjectsProvider.tsx b/app/dashboard/src/providers/ProjectsProvider.tsx index 149edadeef..c7d86bc02b 100644 --- a/app/dashboard/src/providers/ProjectsProvider.tsx +++ b/app/dashboard/src/providers/ProjectsProvider.tsx @@ -51,6 +51,9 @@ interface ProjectsStore { readonly page: projectHooks.ProjectId | TabType readonly setPage: (page: projectHooks.ProjectId | TabType) => void readonly launchedProjects: readonly projectHooks.Project[] + readonly updateLaunchedProjects: ( + update: (projects: readonly projectHooks.Project[]) => readonly projectHooks.Project[] + ) => void readonly addLaunchedProject: (project: projectHooks.Project) => void readonly removeLaunchedProject: (projectId: projectHooks.ProjectId) => void readonly clearLaunchedProjects: () => void @@ -84,6 +87,9 @@ export default function ProjectsProvider(props: ProjectsProviderProps) { set({ page }) }, launchedProjects: localStorage.get('launchedProjects') ?? [], + updateLaunchedProjects: update => { + set(({ launchedProjects }) => ({ launchedProjects: update(launchedProjects) })) + }, addLaunchedProject: project => { set(({ launchedProjects }) => ({ launchedProjects: [...launchedProjects, project] })) }, @@ -168,11 +174,28 @@ export function useLaunchedProjects() { return zustand.useStore(store, state => state.launchedProjects) } +// ================================= +// === useUpdateLaunchedProjects === +// ================================= + +/** A function to update launched projects. */ +export function useUpdateLaunchedProjects() { + const store = useProjectsStore() + const updateLaunchedProjects = zustand.useStore(store, state => state.updateLaunchedProjects) + return eventCallbacks.useEventCallback( + (update: (projects: readonly projectHooks.Project[]) => readonly projectHooks.Project[]) => { + React.startTransition(() => { + updateLaunchedProjects(update) + }) + } + ) +} + // ============================= // === useAddLaunchedProject === // ============================= -/** A function to add a new launched projoect. */ +/** A function to add a new launched project. */ export function useAddLaunchedProject() { const store = useProjectsStore() const addLaunchedProject = zustand.useStore(store, state => state.addLaunchedProject) diff --git a/app/dashboard/src/services/Backend.ts b/app/dashboard/src/services/Backend.ts index a2b3a6afee..0d85882d19 100644 --- a/app/dashboard/src/services/Backend.ts +++ b/app/dashboard/src/services/Backend.ts @@ -1,4 +1,3 @@ /** @file Type definitions common between all backends. */ - export * from 'enso-common/src/services/Backend' export { default } from 'enso-common/src/services/Backend' diff --git a/app/dashboard/src/services/ProjectManager.ts b/app/dashboard/src/services/ProjectManager.ts index ac22010867..ec59501538 100644 --- a/app/dashboard/src/services/ProjectManager.ts +++ b/app/dashboard/src/services/ProjectManager.ts @@ -447,6 +447,13 @@ export default class ProjectManager { path == null ? this.rootDirectory : getDirectoryAndName(path).directoryPath const fullParams: RenameProjectParams = { ...params, projectsDirectory: directoryPath } await this.sendRequest('project/rename', fullParams) + const state = this.internalProjects.get(params.projectId) + if (state?.state === backend.ProjectState.opened) { + this.internalProjects.set(params.projectId, { + state: state.state, + data: { ...state.data, projectName: params.name }, + }) + } // Update `internalDirectories` by listing the project's parent directory, because the new // directory name of the project is unknown. Deleting the directory is not an option because // that will prevent ALL descendants of the parent directory from being updated. diff --git a/app/gui2/src/App.vue b/app/gui2/src/App.vue index a4a7d096c5..30c3e6cfcd 100644 --- a/app/gui2/src/App.vue +++ b/app/gui2/src/App.vue @@ -18,6 +18,7 @@ import type Backend from 'enso-common/src/services/Backend' import { computed, markRaw, toRaw, toRef, watch } from 'vue' import TooltipDisplayer from './components/TooltipDisplayer.vue' import { provideTooltipRegistry } from './providers/tooltipState' +import { provideVisibility } from './providers/visibility' import { initializePrefixes } from './util/ast/node' import { urlParams } from './util/urlParams' @@ -66,6 +67,7 @@ const appConfig = computed(() => { }) provideGuiConfig(computed((): ApplicationConfigValue => configValue(appConfig.value.config))) +provideVisibility(computed(() => !props.hidden)) registerAutoBlurHandler() @@ -83,6 +85,7 @@ registerAutoBlurHandler() v-bind="$attrs" class="App" :class="[...classSet.keys()]" + :projectId="props.projectId" :renameProject="renameProject" /> diff --git a/app/gui2/src/components/GraphEditor.vue b/app/gui2/src/components/GraphEditor.vue index 8d4fa8f717..264495f6a9 100644 --- a/app/gui2/src/components/GraphEditor.vue +++ b/app/gui2/src/components/GraphEditor.vue @@ -70,6 +70,7 @@ import { } from 'vue' import { builtinWidgets } from '@/components/widgets' +import { injectVisibility } from '@/providers/visibility' const keyboard = provideKeyboard() const projectStore = useProjectStore() @@ -77,6 +78,7 @@ const suggestionDb = provideSuggestionDbStore(projectStore) const graphStore = provideGraphStore(projectStore, suggestionDb) const widgetRegistry = provideWidgetRegistry(graphStore.db) const _visualizationStore = provideVisualizationStore(projectStore) +const visible = injectVisibility() onMounted(() => { widgetRegistry.loadWidgets(Object.entries(builtinWidgets)) @@ -132,7 +134,7 @@ useSyncLocalStorage({ // Client graph state needs to be stored separately for: // - each project // - each function within the project - encoding.writeVarString(enc, projectStore.name) + encoding.writeVarString(enc, projectStore.id) const methodPtr = graphStore.currentMethodPointer() if (methodPtr != null) encodeMethodPointer(enc, methodPtr) }, @@ -155,6 +157,7 @@ useSyncLocalStorage({ rightDockWidth.value = restored.rwidth ?? undefined } else { await until(visibleAreasReady).toBe(true) + await until(visible).toBe(true) if (!abort.aborted) zoomToAll(true) } }, diff --git a/app/gui2/src/providers/visibility.ts b/app/gui2/src/providers/visibility.ts new file mode 100644 index 0000000000..8431f1eb12 --- /dev/null +++ b/app/gui2/src/providers/visibility.ts @@ -0,0 +1,6 @@ +import { createContextStore } from '@/providers' +import { identity } from '@vueuse/core' +import { type Ref } from 'vue' + +export { injectFn as injectVisibility, provideFn as provideVisibility } +const { provideFn, injectFn } = createContextStore('Visibility', identity>) diff --git a/app/gui2/src/stores/project/index.ts b/app/gui2/src/stores/project/index.ts index cce6e8d8cb..31e7d6a97a 100644 --- a/app/gui2/src/stores/project/index.ts +++ b/app/gui2/src/stores/project/index.ts @@ -102,7 +102,8 @@ export type ProjectStore = ReturnType */ export const { provideFn: provideProjectStore, injectFn: useProjectStore } = createContextStore( 'project', - (renameProjectBackend: (newName: string) => void) => { + (props: { projectId: string; renameProject: (newName: string) => void }) => { + const { projectId, renameProject: renameProjectBackend } = props const abort = useAbortScope() const observedFileName = ref() @@ -114,7 +115,12 @@ export const { provideFn: provideProjectStore, injectFn: useProjectStore } = cre const projectNameFromCfg = config.value.startup?.project if (projectNameFromCfg == null) throw new Error('Missing project name.') const projectName = ref(projectNameFromCfg) - const projectDisplayName = ref(config.value.startup?.displayedProjectName ?? projectName) + // Note that `config` is not deeply reactive. This is fine as the config is an immutable object + // passed in from the dashboard, so the entire object will change if any of its nested + // properties change. + const projectDisplayName = computed( + () => config.value.startup?.displayedProjectName ?? projectName, + ) const clientId = random.uuidv4() as Uuid const lsUrls = resolveLsUrl(config.value) @@ -355,15 +361,11 @@ export const { provideFn: provideProjectStore, injectFn: useProjectStore } = cre return Err(err) } } - lsRpcConnection.on( - 'refactoring/projectRenamed', - ({ oldNormalizedName, newNormalizedName, newName }) => { - if (oldNormalizedName === projectName.value) { - projectName.value = newNormalizedName - projectDisplayName.value = newName - } - }, - ) + lsRpcConnection.on('refactoring/projectRenamed', ({ oldNormalizedName, newNormalizedName }) => { + if (oldNormalizedName === projectName.value) { + projectName.value = newNormalizedName + } + }) const projectRootId = contentRoots.then( (roots) => roots.find((root) => root.type === 'Project')?.id, @@ -388,6 +390,7 @@ export const { provideFn: provideProjectStore, injectFn: useProjectStore } = cre get observedFileName() { return observedFileName.value }, + id: projectId, name: readonly(projectName), displayName: readonly(projectDisplayName), isOnLocalBackend, diff --git a/app/gui2/src/views/ProjectView.vue b/app/gui2/src/views/ProjectView.vue index 720efe8a05..3469c75a89 100644 --- a/app/gui2/src/views/ProjectView.vue +++ b/app/gui2/src/views/ProjectView.vue @@ -2,9 +2,9 @@ import GraphEditor from '@/components/GraphEditor.vue' import { provideProjectStore } from '@/stores/project' -const props = defineProps<{ renameProject: (newName: string) => void }>() +const props = defineProps<{ projectId: string; renameProject: (newName: string) => void }>() -provideProjectStore(props.renameProject) +provideProjectStore(props)