From 759fb0a6ba5c6df258d5033fcd9de04495507d96 Mon Sep 17 00:00:00 2001 From: Adam Obuchowicz Date: Fri, 21 Feb 2025 09:11:41 +0100 Subject: [PATCH 1/5] Initialize Cloud File Browser with widget input. --- .../widgets/WidgetCloudBrowser.vue | 12 +++ .../components/widgets/FileBrowserWidget.vue | 85 ++++++++++++++----- .../FileBrowserWidget/FileBrowserEntry.vue | 7 +- 3 files changed, 83 insertions(+), 21 deletions(-) diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetCloudBrowser.vue b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetCloudBrowser.vue index 7576fa2b9e1d..fa8cd178e838 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetCloudBrowser.vue +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetCloudBrowser.vue @@ -15,6 +15,17 @@ const props = defineProps(widgetProps(widgetDefinition)) const writeMode = computed( () => props.input[ArgumentInfoKey]?.info?.reprType.includes(WRITABLE_FILE_TYPE) ?? false, ) + +const path = computed(() => { + if (props.input.value instanceof Ast.TextLiteral) { + return props.input.value.rawTextContent + } else if (typeof props.input.value === 'string') { + return Ast.TextLiteral.tryParse(props.input.value)?.rawTextContent ?? '' + } else { + return '' + } +}) + const item: CustomDropdownItem = { label: 'Choose file from cloud...', onClick: ({ setActivity, close }) => { @@ -22,6 +33,7 @@ const item: CustomDropdownItem = { computed(() => h(FileBrowserWidget, { writeMode: writeMode.value, + initialPath: path.value, onPathAccepted: (path: string) => { props.onUpdate({ portUpdate: { value: Ast.TextLiteral.new(path), origin: props.input.portId }, diff --git a/app/gui/src/project-view/components/widgets/FileBrowserWidget.vue b/app/gui/src/project-view/components/widgets/FileBrowserWidget.vue index 18061b7957db..56f754c04840 100644 --- a/app/gui/src/project-view/components/widgets/FileBrowserWidget.vue +++ b/app/gui/src/project-view/components/widgets/FileBrowserWidget.vue @@ -11,6 +11,7 @@ import SvgIcon from '@/components/SvgIcon.vue' import { useBackend } from '@/composables/backend' import { injectBackend } from '@/providers/backend' import { assert } from '@/util/assert' +import { findDifferenceIndex } from '@/util/data/array' import type { ToValue } from '@/util/reactivity' import { useToast } from '@/util/toast' import type { @@ -18,6 +19,7 @@ import type { DirectoryAsset, DirectoryId, FileAsset, + User, } from 'enso-common/src/services/Backend' import Backend, { assetIsDatalink, @@ -25,10 +27,13 @@ import Backend, { assetIsFile, } from 'enso-common/src/services/Backend' import { computed, onMounted, reactive, ref, toValue, watch } from 'vue' -import { Err, Ok, Result } from 'ydoc-shared/util/data/result' +import { Err, Ok, Result, unwrapOr, unwrapOrWithLog } from 'ydoc-shared/util/data/result' import FileBrowserEntry from './FileBrowserWidget/FileBrowserEntry.vue' -const { writeMode = false } = defineProps<{ writeMode?: boolean }>() +const { writeMode = false, initialPath = '' } = defineProps<{ + writeMode?: boolean + initialPath?: string +}>() const emit = defineEmits<{ pathAccepted: [path: string] @@ -50,6 +55,12 @@ let nextKeyForNewDir = 0 */ const keyOverride: Map = reactive(new Map()) +function pathToSegments(path: string) { + const withProtocol = path.split('/') + if (withProtocol[0] !== 'enso:') return Err(`${path} is not an enso path`) + return Ok(withProtocol.slice(1).filter((segment) => segment)) +} + // === Current Directory === interface Directory { @@ -63,11 +74,15 @@ const directoryStack = ref([]) const isDirectoryStackInitializing = computed(() => directoryStack.value.length === 0) const currentDirectory = computed(() => directoryStack.value[directoryStack.value.length - 1]) -const currentPath = computed(() => { +const rootSegments = computed(() => { if (!currentUser.data.value) return - let root = backend?.rootPath(currentUser.data.value) ?? 'enso://' - if (!root.endsWith('/')) root += '/' - return `${root}${directoryStack.value + const root = backend?.rootPath(currentUser.data.value) ?? 'enso://' + return pathToSegments(root) +}) + +const currentPath = computed(() => { + if (rootSegments.value == null || !rootSegments.value.ok) return + return `enso://${rootSegments.value.value.join('/')}/${directoryStack.value .slice(1) .map((dir) => `${dir.title}/`) .join('')}` @@ -125,11 +140,21 @@ function enterDir(dir: DirectoryAsset) { directoryStack.value.push(dir) } -class DirNotFoundError { - constructor(public dirName: string) {} +class CannotEnterDir { + constructor( + public reason: 'emptyStack' | 'notFound' | 'notDir', + public name: string, + ) {} toString() { - return `Directory "${this.dirName}" not found` + switch (this.reason) { + case 'emptyStack': + return 'Stack is empty' + case 'notFound': + return `Directory "${this.name}" not found` + case 'notDir': + return `"${this.name}" is not a directory` + } } } @@ -228,15 +253,28 @@ watch( // === Initialization === -async function enterDirByName(name: string, stack: Directory[]): Promise { +function dirsToEnterOnInit(user: User) { + const initialSegments = unwrapOr(pathToSegments(initialPath), ['Users', user.name]) + const rootSegs = unwrapOrWithLog(rootSegments.value ?? Err('cannot load root directory'), []) + const afterRootIndex = findDifferenceIndex(initialSegments, rootSegs) + if (afterRootIndex < rootSegs.length) { + return [] + } else { + return initialSegments.slice(afterRootIndex) + } +} + +async function enterDirByName( + name: string, + stack: Directory[], +): Promise> { const currentDir = stack[stack.length - 1] - if (currentDir == null) return Err('Stack is empty') + if (currentDir == null) return Err(new CannotEnterDir('emptyStack', name)) const content = await fetch('listDirectory', listDirectoryArgs(currentDir)) - const nextDir = content.find( - (asset): asset is DirectoryAsset => assetIsDirectory(asset) && asset.title === name, - ) - if (!nextDir) return Err(new DirNotFoundError(name)) - stack.push(nextDir) + const nextAsset = content.find((asset) => asset.title === name) + if (!nextAsset) return Err(new CannotEnterDir('notFound', name)) + if (!assetIsDirectory(nextAsset)) return Err(new CannotEnterDir('notDir', name)) + stack.push(nextAsset) return Ok() } @@ -249,11 +287,17 @@ onMounted(() => { } const rootDirectoryId = backend?.rootDirectoryId(user, organization, null) ?? user.rootDirectoryId + const stack = [{ id: rootDirectoryId, title: 'Cloud' }] - if (rootDirectoryId != user.rootDirectoryId) { - let result = await enterDirByName('Users', stack) - result = result.ok ? await enterDirByName(user.name, stack) : result - if (!result.ok) errorToast.reportError(result.error, 'Cannot enter home directory') + for (const name of dirsToEnterOnInit(user)) { + const result = await enterDirByName(name, stack) + if (result.ok) continue + if (result.error.payload.reason === 'notDir') { + fileName.value = name + } else if (result.error.payload.reason !== 'notFound') { + errorToast.reportError(result.error, 'Cannot enter home directory') + } + break } directoryStack.value = stack }, @@ -312,6 +356,7 @@ onMounted(() => { :key="entry.id" icon="text2" :title="entry.title" + :highlighted="entry.title === fileName" @click="chooseFile(entry)" /> diff --git a/app/gui/src/project-view/components/widgets/FileBrowserWidget/FileBrowserEntry.vue b/app/gui/src/project-view/components/widgets/FileBrowserWidget/FileBrowserEntry.vue index be537221af76..20b642442ce5 100644 --- a/app/gui/src/project-view/components/widgets/FileBrowserWidget/FileBrowserEntry.vue +++ b/app/gui/src/project-view/components/widgets/FileBrowserWidget/FileBrowserEntry.vue @@ -7,6 +7,7 @@ import { ref, watch } from 'vue' const props = defineProps<{ title: string icon: Icon + highlighted?: boolean editingState?: 'editing' | 'pending' | 'just created' | undefined }>() @@ -30,7 +31,7 @@ watch(input, (newInput) => {