mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 16:01:30 +03:00
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:
parent
836a7e1272
commit
27515c49d4
@ -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)
|
||||
|
@ -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`.
|
||||
|
@ -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`.
|
||||
|
@ -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`.
|
||||
|
@ -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`.
|
||||
|
@ -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`.
|
||||
|
@ -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.
|
||||
|
@ -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]
|
||||
|
@ -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),
|
||||
}),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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. */
|
||||
|
@ -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} />
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
|
@ -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'
|
||||
|
@ -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.
|
||||
|
@ -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">
|
||||
|
@ -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)
|
||||
}
|
||||
},
|
||||
|
6
app/gui2/src/providers/visibility.ts
Normal file
6
app/gui2/src/providers/visibility.ts
Normal 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>>)
|
@ -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,
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user