Renaming project in GUI (#10243)

Fixes #10073

[Screencast from 2024-06-11 13-01-10.webm](https://github.com/enso-org/enso/assets/3919101/2917ad41-e080-482b-9a69-690e80087132)
This commit is contained in:
Adam Obuchowicz 2024-06-21 13:28:30 +02:00 committed by GitHub
parent 5fd0e0c30d
commit 4164277d22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 247 additions and 71 deletions

View File

@ -9,6 +9,7 @@
- [Copy-pasting multiple nodes][10194]. - [Copy-pasting multiple nodes][10194].
- The documentation editor has [formatting toolbars][10064]. - The documentation editor has [formatting toolbars][10064].
- The documentation editor supports [rendering images][10205]. - The documentation editor supports [rendering images][10205].
- [Project may be renamed in Project View][10243]
- [Fixed a bug where drop-down were not displayed for some arguments][10297]. - [Fixed a bug where drop-down were not displayed for some arguments][10297].
For example, `locale` parameter of `Equal_Ignore_Case` kind in join component. For example, `locale` parameter of `Equal_Ignore_Case` kind in join component.
- [Node previews][10310]: Node may be previewed by hovering output port while - [Node previews][10310]: Node may be previewed by hovering output port while
@ -19,6 +20,7 @@
[10194]: https://github.com/enso-org/enso/pull/10194 [10194]: https://github.com/enso-org/enso/pull/10194
[10198]: https://github.com/enso-org/enso/pull/10198 [10198]: https://github.com/enso-org/enso/pull/10198
[10205]: https://github.com/enso-org/enso/pull/10205 [10205]: https://github.com/enso-org/enso/pull/10205
[10243]: https://github.com/enso-org/enso/pull/10243
[10297]: https://github.com/enso-org/enso/pull/10297 [10297]: https://github.com/enso-org/enso/pull/10297
[10310]: https://github.com/enso-org/enso/pull/10310 [10310]: https://github.com/enso-org/enso/pull/10310

View File

@ -479,6 +479,11 @@ export class LanguageServer extends ObservableV2<Notifications & TransportEvents
return this.request('refactoring/renameSymbol', { module, expressionId, newName }) return this.request('refactoring/renameSymbol', { module, expressionId, newName })
} }
/** [Documentation](https://github.com/enso-org/enso/blob/develop/docs/language-server/protocol-language-server.md#refactoringrenameproject) */
renameProject(namespace: string, oldName: string, newName: string): Promise<LsRpcResult<void>> {
return this.request('refactoring/renameProject', { namespace, oldName, newName })
}
/** [Documentation](https://github.com/enso-org/enso/blob/develop/docs/language-server/protocol-language-server.md#profilingstart) */ /** [Documentation](https://github.com/enso-org/enso/blob/develop/docs/language-server/protocol-language-server.md#profilingstart) */
profilingStart(memorySnapshot?: boolean): Promise<LsRpcResult<void>> { profilingStart(memorySnapshot?: boolean): Promise<LsRpcResult<void>> {
return this.request('profiling/start', { memorySnapshot }) return this.request('profiling/start', { memorySnapshot })

View File

@ -343,7 +343,11 @@ export type Notifications = {
'file/event': (param: { path: Path; kind: FileEventKind }) => void 'file/event': (param: { path: Path; kind: FileEventKind }) => void
'file/rootAdded': (param: {}) => void 'file/rootAdded': (param: {}) => void
'file/rootRemoved': (param: {}) => void 'file/rootRemoved': (param: {}) => void
'refactoring/projectRenamed': (param: {}) => void 'refactoring/projectRenamed': (param: {
oldNormalizedName: string
newNormalizedName: string
newName: string
}) => void
} }
export type Event<T extends keyof Notifications> = Parameters<Notifications[T]>[0] export type Event<T extends keyof Notifications> = Parameters<Notifications[T]>[0]

View File

@ -25,6 +25,7 @@ const props = defineProps<{
logEvent: LogEvent logEvent: LogEvent
hidden: boolean hidden: boolean
ignoreParamsRegex?: RegExp ignoreParamsRegex?: RegExp
renameProject: (newName: string) => void
}>() }>()
const classSet = provideAppClassSet() const classSet = provideAppClassSet()
@ -71,7 +72,13 @@ registerAutoBlurHandler()
:unrecognizedOptions="appConfig.unrecognizedOptions" :unrecognizedOptions="appConfig.unrecognizedOptions"
:config="appConfig.config" :config="appConfig.config"
/> />
<ProjectView v-else v-show="!props.hidden" class="App" :class="[...classSet.keys()]" /> <ProjectView
v-else
v-show="!props.hidden"
class="App"
:class="[...classSet.keys()]"
:renameProject="renameProject"
/>
<Teleport to="body"> <Teleport to="body">
<TooltipDisplayer :registry="appTooltips" /> <TooltipDisplayer :registry="appTooltips" />
</Teleport> </Teleport>

View File

@ -28,19 +28,19 @@ import { useDoubleClick } from '@/composables/doubleClick'
import { keyboardBusy, keyboardBusyExceptIn, unrefElement, useEvent } from '@/composables/events' import { keyboardBusy, keyboardBusyExceptIn, unrefElement, useEvent } from '@/composables/events'
import { groupColorVar } from '@/composables/nodeColors' import { groupColorVar } from '@/composables/nodeColors'
import type { PlacementStrategy } from '@/composables/nodeCreation' import type { PlacementStrategy } from '@/composables/nodeCreation'
import { useStackNavigator } from '@/composables/stackNavigator'
import { useSyncLocalStorage } from '@/composables/syncLocalStorage' import { useSyncLocalStorage } from '@/composables/syncLocalStorage'
import { provideGraphNavigator, type GraphNavigator } from '@/providers/graphNavigator' import { provideGraphNavigator, type GraphNavigator } from '@/providers/graphNavigator'
import { provideNodeColors } from '@/providers/graphNodeColors' import { provideNodeColors } from '@/providers/graphNodeColors'
import { provideNodeCreation } from '@/providers/graphNodeCreation' import { provideNodeCreation } from '@/providers/graphNodeCreation'
import { provideGraphSelection } from '@/providers/graphSelection' import { provideGraphSelection } from '@/providers/graphSelection'
import { provideStackNavigator } from '@/providers/graphStackNavigator'
import { provideInteractionHandler } from '@/providers/interactionHandler' import { provideInteractionHandler } from '@/providers/interactionHandler'
import { provideKeyboard } from '@/providers/keyboard' import { provideKeyboard } from '@/providers/keyboard'
import { provideWidgetRegistry } from '@/providers/widgetRegistry' import { provideWidgetRegistry } from '@/providers/widgetRegistry'
import { provideGraphStore, type NodeId } from '@/stores/graph' import { provideGraphStore, type NodeId } from '@/stores/graph'
import { asNodeId } from '@/stores/graph/graphDatabase' import { asNodeId } from '@/stores/graph/graphDatabase'
import type { RequiredImport } from '@/stores/graph/imports' import type { RequiredImport } from '@/stores/graph/imports'
import { provideProjectStore } from '@/stores/project' import { useProjectStore } from '@/stores/project'
import { provideSuggestionDbStore } from '@/stores/suggestionDatabase' import { provideSuggestionDbStore } from '@/stores/suggestionDatabase'
import type { Typename } from '@/stores/suggestionDatabase/entry' import type { Typename } from '@/stores/suggestionDatabase/entry'
import { provideVisualizationStore } from '@/stores/visualization' import { provideVisualizationStore } from '@/stores/visualization'
@ -70,7 +70,7 @@ import {
} from 'vue' } from 'vue'
const keyboard = provideKeyboard() const keyboard = provideKeyboard()
const projectStore = provideProjectStore() const projectStore = useProjectStore()
const suggestionDb = provideSuggestionDbStore(projectStore) const suggestionDb = provideSuggestionDbStore(projectStore)
const graphStore = provideGraphStore(projectStore, suggestionDb) const graphStore = provideGraphStore(projectStore, suggestionDb)
const widgetRegistry = provideWidgetRegistry(graphStore.db) const widgetRegistry = provideWidgetRegistry(graphStore.db)
@ -194,7 +194,7 @@ function panToSelected() {
// == Breadcrumbs == // == Breadcrumbs ==
const stackNavigator = useStackNavigator(projectStore, graphStore) const stackNavigator = provideStackNavigator(projectStore, graphStore)
// === Toasts === // === Toasts ===
@ -706,14 +706,8 @@ const groupColors = computed(() => {
v-model:showColorPicker="showColorPicker" v-model:showColorPicker="showColorPicker"
v-model:showCodeEditor="showCodeEditor" v-model:showCodeEditor="showCodeEditor"
v-model:showDocumentationEditor="showDocumentationEditor" v-model:showDocumentationEditor="showDocumentationEditor"
:breadcrumbs="stackNavigator.breadcrumbLabels.value"
:allowNavigationLeft="stackNavigator.allowNavigationLeft.value"
:allowNavigationRight="stackNavigator.allowNavigationRight.value"
:zoomLevel="100.0 * graphNavigator.targetScale" :zoomLevel="100.0 * graphNavigator.targetScale"
:componentsSelected="nodeSelection.selected.size" :componentsSelected="nodeSelection.selected.size"
@breadcrumbClick="stackNavigator.handleBreadcrumbClick"
@back="stackNavigator.exitNode"
@forward="stackNavigator.enterNextNodeFromHistory"
@recordOnce="onRecordOnceButtonPress()" @recordOnce="onRecordOnceButtonPress()"
@fitToAllClicked="zoomToSelected" @fitToAllClicked="zoomToSelected"
@zoomIn="graphNavigator.stepZoom(+1)" @zoomIn="graphNavigator.stepZoom(+1)"

View File

@ -1,14 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import NavBreadcrumbs, { type BreadcrumbItem } from '@/components/NavBreadcrumbs.vue' import NavBreadcrumbs from '@/components/NavBreadcrumbs.vue'
import SvgIcon from '@/components/SvgIcon.vue' import SvgIcon from '@/components/SvgIcon.vue'
import { injectStackNavigator } from '@/providers/graphStackNavigator'
import SvgButton from './SvgButton.vue' import SvgButton from './SvgButton.vue'
const props = defineProps<{ const stackNavigator = injectStackNavigator()
breadcrumbs: BreadcrumbItem[]
allowNavigationLeft: boolean
allowNavigationRight: boolean
}>()
const emit = defineEmits<{ back: []; forward: []; breadcrumbClick: [index: number] }>()
</script> </script>
<template> <template>
@ -17,18 +13,18 @@ const emit = defineEmits<{ back: []; forward: []; breadcrumbClick: [index: numbe
<div class="breadcrumbs-controls"> <div class="breadcrumbs-controls">
<SvgButton <SvgButton
name="arrow_left" name="arrow_left"
:disabled="!props.allowNavigationLeft" :disabled="!stackNavigator.allowNavigationLeft"
title="Back" title="Back"
@click.stop="emit('back')" @click.stop="stackNavigator.exitNode"
/> />
<SvgButton <SvgButton
name="arrow_right" name="arrow_right"
:disabled="!props.allowNavigationRight" :disabled="!stackNavigator.allowNavigationRight"
title="Forward" title="Forward"
@click.stop="emit('forward')" @click.stop="stackNavigator.enterNextNodeFromHistory"
/> />
</div> </div>
<NavBreadcrumbs :breadcrumbs="props.breadcrumbs" @selected="emit('breadcrumbClick', $event)" /> <NavBreadcrumbs />
</div> </div>
</template> </template>

View File

@ -1,10 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ text: string; active: boolean }>() import { ref, watch, type ComponentInstance } from 'vue'
import AutoSizedInput from './widgets/AutoSizedInput.vue'
const model = defineModel<string>({ required: true })
const _props = defineProps<{ active: boolean; editing: boolean }>()
const input = ref<ComponentInstance<typeof AutoSizedInput>>()
watch(input, (input, old) => {
if (old == null && input != null) input.focus()
})
</script> </script>
<template> <template>
<div :class="['NavBreadcrumb', { inactive: !props.active }]"> <div :class="['NavBreadcrumb', { inactive: !active }]">
<span v-text="props.text"></span> <AutoSizedInput v-if="editing" ref="input" v-model.lazy="model" :autoSelect="true" />
<span v-else v-text="model"></span>
</div> </div>
</template> </template>

View File

@ -1,31 +1,50 @@
<script setup lang="ts"> <script setup lang="ts">
import NavBreadcrumb from '@/components/NavBreadcrumb.vue' import NavBreadcrumb from '@/components/NavBreadcrumb.vue'
import SvgButton from '@/components/SvgButton.vue' import SvgButton from '@/components/SvgButton.vue'
import { injectStackNavigator } from '@/providers/graphStackNavigator'
import { useProjectStore } from '@/stores/project'
import { useToast } from '@/util/toast'
import { ref } from 'vue'
export interface BreadcrumbItem { export interface BreadcrumbItem {
label: string label: string
active: boolean active: boolean
} }
const renameError = useToast.error()
const projectNameEdited = ref(false)
const props = defineProps<{ breadcrumbs: BreadcrumbItem[] }>() const stackNavigator = injectStackNavigator()
const emit = defineEmits<{ selected: [index: number] }>() const project = useProjectStore()
async function renameBreadcrumb(index: number, newName: string) {
if (index === 0) {
const result = await project.renameProject(newName)
if (!result.ok) {
renameError.reportError(result.error)
}
projectNameEdited.value = false
}
}
</script> </script>
<template> <template>
<div class="NavBreadcrumbs"> <div class="NavBreadcrumbs">
<template v-for="(breadcrumb, index) in props.breadcrumbs" :key="index"> <SvgButton name="edit" title="Edit Project Name" @click.stop="projectNameEdited = true" />
<template v-for="(breadcrumb, index) in stackNavigator.breadcrumbLabels.value" :key="index">
<SvgButton <SvgButton
v-if="index > 0" v-if="index > 0"
name="arrow_right_head_only" name="arrow_right_head_only"
:disabled="breadcrumb.active" :disabled="!breadcrumb.active"
class="arrow" class="arrow"
/> />
<NavBreadcrumb <NavBreadcrumb
:text="breadcrumb.label" :modelValue="breadcrumb.label"
:active="breadcrumb.active" :active="breadcrumb.active"
:editing="index === 0 && projectNameEdited"
:title="index === 0 ? 'Project Name' : ''" :title="index === 0 ? 'Project Name' : ''"
class="clickable" class="clickable"
@click.stop="emit('selected', index)" @click.stop="stackNavigator.handleBreadcrumbClick(index)"
@update:modelValue="renameBreadcrumb(index, $event)"
/> />
</template> </template>
</div> </div>

View File

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import ExtendedMenu from '@/components/ExtendedMenu.vue' import ExtendedMenu from '@/components/ExtendedMenu.vue'
import NavBar from '@/components/NavBar.vue' import NavBar from '@/components/NavBar.vue'
import type { BreadcrumbItem } from '@/components/NavBreadcrumbs.vue'
import RecordControl from '@/components/RecordControl.vue' import RecordControl from '@/components/RecordControl.vue'
import SelectionMenu from '@/components/SelectionMenu.vue' import SelectionMenu from '@/components/SelectionMenu.vue'
import { injectGuiConfig } from '@/providers/guiConfig' import { injectGuiConfig } from '@/providers/guiConfig'
@ -11,18 +10,12 @@ const showColorPicker = defineModel<boolean>('showColorPicker', { required: true
const showCodeEditor = defineModel<boolean>('showCodeEditor', { required: true }) const showCodeEditor = defineModel<boolean>('showCodeEditor', { required: true })
const showDocumentationEditor = defineModel<boolean>('showDocumentationEditor', { required: true }) const showDocumentationEditor = defineModel<boolean>('showDocumentationEditor', { required: true })
const props = defineProps<{ const props = defineProps<{
breadcrumbs: BreadcrumbItem[]
recordMode: boolean recordMode: boolean
allowNavigationLeft: boolean
allowNavigationRight: boolean
zoomLevel: number zoomLevel: number
componentsSelected: number componentsSelected: number
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
recordOnce: [] recordOnce: []
back: []
forward: []
breadcrumbClick: [index: number]
'update:recordMode': [enabled: boolean] 'update:recordMode': [enabled: boolean]
fitToAllClicked: [] fitToAllClicked: []
zoomIn: [] zoomIn: []
@ -50,14 +43,7 @@ const barStyle = computed(() => {
@update:recordMode="emit('update:recordMode', $event)" @update:recordMode="emit('update:recordMode', $event)"
@recordOnce="emit('recordOnce')" @recordOnce="emit('recordOnce')"
/> />
<NavBar <NavBar />
:breadcrumbs="props.breadcrumbs"
:allowNavigationLeft="props.allowNavigationLeft"
:allowNavigationRight="props.allowNavigationRight"
@back="emit('back')"
@forward="emit('forward')"
@breadcrumbClick="emit('breadcrumbClick', $event)"
/>
<Transition name="selection-menu"> <Transition name="selection-menu">
<SelectionMenu <SelectionMenu
v-if="componentsSelected > 1" v-if="componentsSelected > 1"

View File

@ -0,0 +1,5 @@
import { useStackNavigator } from '@/composables/stackNavigator'
import { createContextStore } from '@/providers'
export { injectFn as injectStackNavigator, provideFn as provideStackNavigator }
const { provideFn, injectFn } = createContextStore('graph stack navigator', useStackNavigator)

View File

@ -2,6 +2,12 @@ import { findIndexOpt } from '@/util/data/array'
import { isSome, type Opt } from '@/util/data/opt' import { isSome, type Opt } from '@/util/data/opt'
import { Err, Ok, type Result } from '@/util/data/result' import { Err, Ok, type Result } from '@/util/data/result'
import { AsyncQueue, type AbortScope } from '@/util/net' import { AsyncQueue, type AbortScope } from '@/util/net'
import {
qnReplaceProjectName,
tryIdentifier,
tryQualifiedName,
type Identifier,
} from '@/util/qualifiedName'
import * as array from 'lib0/array' import * as array from 'lib0/array'
import * as object from 'lib0/object' import * as object from 'lib0/object'
import { ObservableV2 } from 'lib0/observable' import { ObservableV2 } from 'lib0/observable'
@ -55,7 +61,7 @@ type ExecutionContextState =
visualizations: Map<Uuid, NodeVisualizationConfiguration> visualizations: Map<Uuid, NodeVisualizationConfiguration>
stack: StackItem[] stack: StackItem[]
environment?: ExecutionEnvironment environment?: ExecutionEnvironment
} // | { status: 'broken'} TODO[ao] think about it }
type EntryPoint = Omit<ExplicitCall, 'type'> type EntryPoint = Omit<ExplicitCall, 'type'>
@ -74,6 +80,13 @@ type ExecutionContextNotification = {
'visualizationsConfigured'(configs: Set<Uuid>): void 'visualizationsConfigured'(configs: Set<Uuid>): void
} }
enum SyncStatus {
NOT_SYNCED,
QUEUED,
SYNCING,
SYNCED,
}
/** /**
* Execution Context * Execution Context
* *
@ -86,7 +99,7 @@ type ExecutionContextNotification = {
export class ExecutionContext extends ObservableV2<ExecutionContextNotification> { export class ExecutionContext extends ObservableV2<ExecutionContextNotification> {
readonly id: ContextId = random.uuidv4() as ContextId readonly id: ContextId = random.uuidv4() as ContextId
private queue: AsyncQueue<ExecutionContextState> private queue: AsyncQueue<ExecutionContextState>
private syncScheduled = false private syncStatus = SyncStatus.NOT_SYNCED
private clearScheduled = false private clearScheduled = false
private _desiredStack: StackItem[] = reactive([]) private _desiredStack: StackItem[] = reactive([])
private visualizationConfigs: Map<Uuid, NodeVisualizationConfiguration> = new Map() private visualizationConfigs: Map<Uuid, NodeVisualizationConfiguration> = new Map()
@ -135,7 +148,7 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
// Connection closed: the created execution context is no longer available // Connection closed: the created execution context is no longer available
// There is no point in any scheduled action until resynchronization // There is no point in any scheduled action until resynchronization
this.queue.clear() this.queue.clear()
this.syncScheduled = false this.syncStatus = SyncStatus.NOT_SYNCED
this.queue.pushTask(() => { this.queue.pushTask(() => {
this.clearScheduled = false this.clearScheduled = false
this.sync() this.sync()
@ -143,6 +156,32 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
}) })
this.clearScheduled = true this.clearScheduled = true
}) })
this.lsRpc.on('refactoring/projectRenamed', ({ oldNormalizedName, newNormalizedName }) => {
const newIdent = tryIdentifier(newNormalizedName)
if (!newIdent.ok) {
console.error(
`Cannot update project name in execution stack: new name ${newNormalizedName} is not a valid identifier!`,
)
return
}
ExecutionContext.replaceProjectNameInStack(
this._desiredStack,
oldNormalizedName,
newIdent.value,
)
if (this.syncStatus === SyncStatus.SYNCED) {
this.queue.pushTask((state) => {
if (state.status !== 'created') return Promise.resolve(state)
ExecutionContext.replaceProjectNameInStack(state.stack, oldNormalizedName, newIdent.value)
return Promise.resolve(state)
})
} else {
// Engine updates project name in its execution context frames by itself. But if we are out
// of sync, we have no guarantee if the stack wasn't set with old name after project rename.
// It's safer to just re-sync the stack.
this.sync()
}
})
} }
private pushItem(item: StackItem) { private pushItem(item: StackItem) {
@ -150,6 +189,28 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
this.sync() this.sync()
} }
private static replaceProjectNameInStack(
stack: StackItem[],
oldName: string,
newName: Identifier,
) {
const updatedField = (value: string) => {
const qn = tryQualifiedName(value)
if (qn.ok) {
return qnReplaceProjectName(qn.value, oldName, newName)
} else {
console.warn(`Invalid qualified name in execution context stack: ${value}`)
return value
}
}
for (const item of stack) {
if (item.type === 'ExplicitCall') {
item.methodPointer.module = updatedField(item.methodPointer.module)
item.methodPointer.definedOnType = updatedField(item.methodPointer.definedOnType)
}
}
}
get desiredStack() { get desiredStack() {
return this._desiredStack return this._desiredStack
} }
@ -229,8 +290,8 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
} }
private sync() { private sync() {
if (this.syncScheduled || this.abort.signal.aborted) return if (this.syncStatus === SyncStatus.QUEUED || this.abort.signal.aborted) return
this.syncScheduled = true this.syncStatus = SyncStatus.QUEUED
this.queue.pushTask(this.syncTask()) this.queue.pushTask(this.syncTask())
} }
@ -248,7 +309,7 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
private syncTask() { private syncTask() {
return async (state: ExecutionContextState) => { return async (state: ExecutionContextState) => {
this.syncScheduled = false this.syncStatus = SyncStatus.SYNCING
if (this.abort.signal.aborted) return state if (this.abort.signal.aborted) return state
let newState = { ...state } let newState = { ...state }
@ -399,6 +460,9 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
this.emit('visualizationsConfigured', [ this.emit('visualizationsConfigured', [
new Set(state.status === 'created' ? state.visualizations.keys() : []), new Set(state.status === 'created' ? state.visualizations.keys() : []),
]) ])
if (this.syncStatus === SyncStatus.SYNCING) {
this.syncStatus = SyncStatus.SYNCED
}
return newState return newState
} }
} }

View File

@ -32,6 +32,7 @@ import {
markRaw, markRaw,
onScopeDispose, onScopeDispose,
proxyRefs, proxyRefs,
readonly,
ref, ref,
shallowRef, shallowRef,
watch, watch,
@ -99,7 +100,7 @@ export type ProjectStore = ReturnType<typeof useProjectStore>
*/ */
export const { provideFn: provideProjectStore, injectFn: useProjectStore } = createContextStore( export const { provideFn: provideProjectStore, injectFn: useProjectStore } = createContextStore(
'project', 'project',
() => { (renameProjectBackend: (newName: string) => void) => {
const abort = useAbortScope() const abort = useAbortScope()
const observedFileName = ref<string>() const observedFileName = ref<string>()
@ -108,9 +109,10 @@ export const { provideFn: provideProjectStore, injectFn: useProjectStore } = cre
const awareness = new Awareness(doc) const awareness = new Awareness(doc)
const config = injectGuiConfig() const config = injectGuiConfig()
const projectName = config.value.startup?.project const projectNameFromCfg = config.value.startup?.project
if (projectName == null) throw new Error('Missing project name.') if (projectNameFromCfg == null) throw new Error('Missing project name.')
const projectDisplayName = config.value.startup?.displayedProjectName ?? projectName const projectName = ref(projectNameFromCfg)
const projectDisplayName = ref(config.value.startup?.displayedProjectName ?? projectName)
const clientId = random.uuidv4() as Uuid const clientId = random.uuidv4() as Uuid
const lsUrls = resolveLsUrl(config.value) const lsUrls = resolveLsUrl(config.value)
@ -126,7 +128,6 @@ export const { provideFn: provideProjectStore, injectFn: useProjectStore } = cre
rpcUrl.hostname === '[::1]' || rpcUrl.hostname === '[::1]' ||
rpcUrl.hostname === '0:0:0:0:0:0:0:1' rpcUrl.hostname === '0:0:0:0:0:0:0:1'
const name = computed(() => config.value.startup?.project)
const namespace = computed(() => config.value.engine?.namespace) const namespace = computed(() => config.value.engine?.namespace)
const fullName = computed(() => { const fullName = computed(() => {
const ns = namespace.value const ns = namespace.value
@ -135,14 +136,7 @@ export const { provideFn: provideProjectStore, injectFn: useProjectStore } = cre
'Unknown project\'s namespace. Assuming "local", however it likely won\'t work in cloud', 'Unknown project\'s namespace. Assuming "local", however it likely won\'t work in cloud',
) )
} }
const projectName = name.value return `${ns ?? 'local'}.${projectName.value}`
if (projectName == null) {
console.error(
"Unknown project's name. Cannot specify opened module's qualified path; many things may not work",
)
return null
}
return `${ns ?? 'local'}.${projectName}`
}) })
const modulePath = computed(() => { const modulePath = computed(() => {
const filePath = observedFileName.value const filePath = observedFileName.value
@ -347,6 +341,24 @@ export const { provideFn: provideProjectStore, injectFn: useProjectStore } = cre
}, },
}) })
function renameProject(newDisplayedName: string) {
try {
renameProjectBackend(newDisplayedName)
return Ok()
} catch (err) {
return Err(err)
}
}
lsRpcConnection.on(
'refactoring/projectRenamed',
({ oldNormalizedName, newNormalizedName, newName }) => {
if (oldNormalizedName === projectName.value) {
projectName.value = newNormalizedName
projectDisplayName.value = newName
}
},
)
const projectRootId = contentRoots.then( const projectRootId = contentRoots.then(
(roots) => roots.find((root) => root.type === 'Project')?.id, (roots) => roots.find((root) => root.type === 'Project')?.id,
) )
@ -370,8 +382,8 @@ export const { provideFn: provideProjectStore, injectFn: useProjectStore } = cre
get observedFileName() { get observedFileName() {
return observedFileName.value return observedFileName.value
}, },
name: projectName, name: readonly(projectName),
displayName: projectDisplayName, displayName: readonly(projectDisplayName),
isOnLocalBackend, isOnLocalBackend,
executionContext, executionContext,
firstExecution, firstExecution,
@ -394,6 +406,7 @@ export const { provideFn: provideProjectStore, injectFn: useProjectStore } = cre
dataflowErrors, dataflowErrors,
executeExpression, executeExpression,
disposeYDocsProvider, disposeYDocsProvider,
renameProject,
}) })
}, },
) )

View File

@ -5,7 +5,9 @@ import {
qnJoin, qnJoin,
qnLastSegment, qnLastSegment,
qnParent, qnParent,
qnReplaceProjectName,
qnSplit, qnSplit,
tryIdentifier,
tryIdentifierOrOperatorIdentifier, tryIdentifierOrOperatorIdentifier,
tryQualifiedName, tryQualifiedName,
type IdentifierOrOperatorIdentifier, type IdentifierOrOperatorIdentifier,
@ -96,3 +98,15 @@ test.each([
const qn = unwrap(tryQualifiedName(name)) const qn = unwrap(tryQualifiedName(name))
expect(normalizeQualifiedName(qn)).toEqual(unwrap(tryQualifiedName(expected))) expect(normalizeQualifiedName(qn)).toEqual(unwrap(tryQualifiedName(expected)))
}) })
test.each([
['local.Project.Main', 'Project', 'NewProject', 'local.NewProject.Main'],
['local.Project.Main', 'Project2', 'NewProject', 'local.Project.Main'],
['local.Project', 'Project', 'NewProject', 'local.NewProject'],
['Project', 'Project', 'NewProject', 'Project'],
['local.Project2.Project', 'Project', 'NewProject', 'local.Project2.Project'],
])('Replacing project name in %s from %s to %s', (qname, oldName, newName, expected) => {
const qn = unwrap(tryQualifiedName(qname))
const newIdent = unwrap(tryIdentifier(newName))
expect(qnReplaceProjectName(qn, oldName, newIdent)).toBe(expected)
})

View File

@ -97,3 +97,19 @@ export function qnSlice(
export function qnIsTopElement(name: QualifiedName): boolean { export function qnIsTopElement(name: QualifiedName): boolean {
return !/[.].*?[.].*?[.]/.test(name) return !/[.].*?[.].*?[.]/.test(name)
} }
/**
* Replace the project name in this qualified name if equal to `oldProject`, otherwise return `qn`.
*
* The namespace will be unchanged.
*/
export function qnReplaceProjectName(
qn: QualifiedName,
oldProject: string,
newProject: Identifier,
): QualifiedName {
return qn.replace(
new RegExp(`^(${identifierRegexPart}\\.)${oldProject}(?=\\.|$)`),
`$1${newProject}`,
) as QualifiedName
}

View File

@ -1,5 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import GraphEditor from '@/components/GraphEditor.vue' import GraphEditor from '@/components/GraphEditor.vue'
import { provideProjectStore } from '@/stores/project'
const props = defineProps<{ renameProject: (newName: string) => void }>()
provideProjectStore(props.renameProject)
</script> </script>
<template> <template>

View File

@ -8,7 +8,10 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as backendProvider from '#/providers/BackendProvider' import * as backendProvider from '#/providers/BackendProvider'
import type * as backendModule from '#/services/Backend' import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import * as object from '#/utilities/object'
import type * as types from '../../../types/types' import type * as types from '../../../types/types'
@ -32,6 +35,7 @@ export default function Editor(props: EditorProps) {
const gtagEventRef = React.useRef(gtagEvent) const gtagEventRef = React.useRef(gtagEvent)
gtagEventRef.current = gtagEvent gtagEventRef.current = gtagEvent
const remoteBackend = backendProvider.useRemoteBackend() const remoteBackend = backendProvider.useRemoteBackend()
const localBackend = backendProvider.useLocalBackend()
const logEvent = React.useCallback( const logEvent = React.useCallback(
(message: string, projectId?: string | null, metadata?: object | null) => { (message: string, projectId?: string | null, metadata?: object | null) => {
@ -42,6 +46,36 @@ export default function Editor(props: EditorProps) {
[remoteBackend] [remoteBackend]
) )
const renameProject = React.useCallback(
(newName: string) => {
if (projectStartupInfo != null) {
let backend: Backend | null
switch (projectStartupInfo.backendType) {
case backendModule.BackendType.local:
backend = localBackend
break
case backendModule.BackendType.remote:
backend = remoteBackend
break
}
const { id: projectId, parentId, title } = projectStartupInfo.projectAsset
backend
?.updateProject(
projectId,
{ projectName: newName, ami: null, ideVersion: null, parentId },
title
)
.then(
() => {
projectStartupInfo.setProjectAsset?.(object.merger({ title: newName }))
},
e => toastAndLog('renameProjectError', e)
)
}
},
[remoteBackend, localBackend, projectStartupInfo, toastAndLog]
)
React.useEffect(() => { React.useEffect(() => {
if (hidden) { if (hidden) {
return return
@ -84,9 +118,10 @@ export default function Editor(props: EditorProps) {
hidden, hidden,
ignoreParamsRegex: new RegExp(`^${appUtils.SEARCH_PARAMS_PREFIX}(.+)$`), ignoreParamsRegex: new RegExp(`^${appUtils.SEARCH_PARAMS_PREFIX}(.+)$`),
logEvent, logEvent,
renameProject,
} }
} }
}, [projectStartupInfo, toastAndLog, hidden, logEvent, ydocUrl]) }, [projectStartupInfo, toastAndLog, hidden, logEvent, ydocUrl, renameProject])
if (projectStartupInfo == null || AppRunner == null || appProps == null) { if (projectStartupInfo == null || AppRunner == null || appProps == null) {
return <></> return <></>

View File

@ -21,6 +21,7 @@ interface EditorProps {
projectId?: string | null, projectId?: string | null,
metadata?: object | null metadata?: object | null
) => void ) => void
readonly renameProject: (newName: string) => void
} }
/** The value passed from the entrypoint to the dashboard, which enables the dashboard to /** The value passed from the entrypoint to the dashboard, which enables the dashboard to