Dashboard support for renaming assets from Graph Editor (#10383)

- Close https://github.com/enso-org/cloud-v2/issues/1318
- Rename tab when project is renamed
- Update project name in GUI when renamed on Cloud backend (since it does not trigger a LS rename when on the cloud backend)

# Important Notes
None
This commit is contained in:
somebody1234 2024-07-18 14:49:55 +10:00 committed by GitHub
parent 836a7e1272
commit 27515c49d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 136 additions and 30 deletions

View File

@ -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)

View File

@ -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`.

View File

@ -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`.

View File

@ -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`.

View File

@ -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`.

View File

@ -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`.

View File

@ -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.

View File

@ -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<AssetEventType.del
readonly labelName: backend.LabelName
}
/** A signal to update the value of an item. */
export interface AssetSetItemEvent extends AssetBaseEvent<AssetEventType.setItem> {
readonly id: backend.AssetId
readonly valueOrUpdater: React.SetStateAction<backend.AnyAsset>
}
/** 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<AssetEventType.projectClosed> {
readonly id: backend.AssetId
}
/** Every possible type of asset event. */
export type AssetEvent = AssetEvents[keyof AssetEvents]

View File

@ -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),
}),
})
},
})
}

View File

@ -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. */

View File

@ -234,7 +234,7 @@ export function Tab(props: InternalTabProps) {
className={tailwindMerge.twMerge(onClose && 'group-hover:hidden focus-visible:hidden')}
/>
)}
{children}
{data?.name ?? children}
{onClose && (
<div className="flex">
<ariaComponents.CloseButton onPress={onClose} />

View File

@ -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

View File

@ -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<assetTable.AssetManagementApi | null>(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 }),
})
}
}}
/>
</aria.TabPanel>

View File

@ -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)

View File

@ -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'

View File

@ -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.

View File

@ -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()
</script>
@ -83,6 +85,7 @@ registerAutoBlurHandler()
v-bind="$attrs"
class="App"
:class="[...classSet.keys()]"
:projectId="props.projectId"
:renameProject="renameProject"
/>
<Teleport to="body">

View File

@ -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<GraphStoredState>({
// 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<GraphStoredState>({
rightDockWidth.value = restored.rwidth ?? undefined
} else {
await until(visibleAreasReady).toBe(true)
await until(visible).toBe(true)
if (!abort.aborted) zoomToAll(true)
}
},

View File

@ -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<Ref<boolean>>)

View File

@ -102,7 +102,8 @@ export type ProjectStore = ReturnType<typeof useProjectStore>
*/
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<string>()
@ -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,

View File

@ -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)
</script>
<template>