mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 03:32:23 +03:00
Refactor awareness upload data into reactive store, add support for upload through file widget.
This commit is contained in:
parent
d37b8f3786
commit
8f6921d7d2
@ -623,12 +623,11 @@ async function handleFileDrop(event: DragEvent) {
|
||||
projectRootId,
|
||||
projectStore.awareness,
|
||||
file,
|
||||
pos,
|
||||
projectStore.isOnLocalBackend,
|
||||
event.shiftKey,
|
||||
projectStore.executionContext.getStackTop(),
|
||||
)
|
||||
const uploadResult = await uploader.upload()
|
||||
const uploadResult = await uploader.upload({ position: pos })
|
||||
if (uploadResult.ok) {
|
||||
createNode({
|
||||
placement: { type: 'mouseEvent', position: pos },
|
||||
|
@ -45,10 +45,12 @@ const displacingWithArrows = useArrows(
|
||||
useEvent(window, 'keydown', displacingWithArrows.events.keydown)
|
||||
|
||||
const uploadingFiles = computed<[FileName, File][]>(() => {
|
||||
let uploads = [...projectStore.awareness.allUploads()]
|
||||
let uploads = projectStore.awareness.allUploads()
|
||||
if (uploads.length == 0) return []
|
||||
const currentStackItem = toRaw(projectStore.executionContext.getStackTop())
|
||||
return uploads.filter(([, file]) => stackItemsEqual(file.stackItem, currentStackItem))
|
||||
return uploads.filter(
|
||||
([, file]) => file.position != null && stackItemsEqual(file.stackItem, currentStackItem),
|
||||
)
|
||||
})
|
||||
|
||||
const graphNodeSelections = shallowRef<HTMLElement>()
|
||||
|
@ -9,7 +9,7 @@ const props = defineProps<{
|
||||
|
||||
const transform = computed(() => {
|
||||
let pos = props.file.position
|
||||
return `translate(${pos.x}px, ${pos.y}px)`
|
||||
return pos ? `translate(${pos.x}px, ${pos.y}px)` : ''
|
||||
})
|
||||
|
||||
const backgroundOffset = computed(() => 200 - props.file.sizePercentage)
|
||||
|
@ -15,6 +15,17 @@ import { Err, Ok, withContext, type Result } from 'ydoc-shared/util/data/result'
|
||||
|
||||
const DATA_DIR_NAME = 'data'
|
||||
|
||||
export function uploadedExpressionPath(result: UploadResult) {
|
||||
switch (result.source) {
|
||||
case 'Project': {
|
||||
return `enso_project.data/'${escapeTextLiteral(result.name)}'`
|
||||
}
|
||||
case 'FileSystemRoot': {
|
||||
return `'${escapeTextLiteral(result.name)}'`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function uploadedExpression(result: UploadResult) {
|
||||
switch (result.source) {
|
||||
case 'Project': {
|
||||
@ -44,7 +55,6 @@ export class Uploader {
|
||||
private awareness: Awareness,
|
||||
private file: File,
|
||||
private projectRootId: Uuid,
|
||||
private position: Vec2,
|
||||
private isOnLocalBackend: boolean,
|
||||
private disableDirectRead: boolean,
|
||||
stackItem: StackItem,
|
||||
@ -60,7 +70,6 @@ export class Uploader {
|
||||
projectRootId: Uuid,
|
||||
awareness: Awareness,
|
||||
file: File,
|
||||
position: Vec2,
|
||||
isOnLocalBackend: boolean,
|
||||
disableDirectRead: boolean,
|
||||
stackItem: StackItem,
|
||||
@ -71,14 +80,13 @@ export class Uploader {
|
||||
awareness,
|
||||
file,
|
||||
projectRootId,
|
||||
position,
|
||||
isOnLocalBackend,
|
||||
disableDirectRead,
|
||||
stackItem,
|
||||
)
|
||||
}
|
||||
|
||||
async upload(): Promise<Result<UploadResult>> {
|
||||
async upload(awarenessData: { position?: Vec2; portId?: string }): Promise<Result<UploadResult>> {
|
||||
// This non-standard property is defined in Electron.
|
||||
if (
|
||||
this.isOnLocalBackend &&
|
||||
@ -93,8 +101,8 @@ export class Uploader {
|
||||
const name = await this.pickUniqueName(this.file.name)
|
||||
if (!name.ok) return name
|
||||
this.awareness.addOrUpdateUpload(name.value, {
|
||||
...awarenessData,
|
||||
sizePercentage: 0,
|
||||
position: this.position,
|
||||
stackItem: this.stackItem,
|
||||
})
|
||||
const remotePath: Path = { rootId: this.projectRootId, segments: [DATA_DIR_NAME, name.value] }
|
||||
@ -108,8 +116,8 @@ export class Uploader {
|
||||
const bytes = Number(uploader.uploadedBytes)
|
||||
const sizePercentage = Math.round((bytes / uploader.file.size) * 100)
|
||||
uploader.awareness.addOrUpdateUpload(name.value, {
|
||||
...awarenessData,
|
||||
sizePercentage,
|
||||
position: uploader.position,
|
||||
stackItem: uploader.stackItem,
|
||||
})
|
||||
},
|
||||
|
@ -1,20 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
|
||||
import { FileUploadKey } from '@/components/GraphEditor/widgets/WidgetFileUploadProgress.vue'
|
||||
import {
|
||||
CustomDropdownItemsKey,
|
||||
type CustomDropdownItem,
|
||||
} from '@/components/GraphEditor/widgets/WidgetSelection.vue'
|
||||
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
||||
import { injectKeyboard } from '@/providers/keyboard'
|
||||
import { defineWidget, Score, WidgetInput, widgetProps } from '@/providers/widgetRegistry'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import type { RequiredImport } from '@/stores/graph/imports'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { Pattern } from '@/util/ast/match'
|
||||
import { ArgumentInfoKey } from '@/util/callTree'
|
||||
import { computed } from 'vue'
|
||||
import { TextLiteral } from 'ydoc-shared/ast'
|
||||
import { assertDefined } from 'ydoc-shared/util/assert'
|
||||
import { uploadedExpressionPath, Uploader } from '../upload'
|
||||
|
||||
const props = defineProps(widgetProps(widgetDefinition))
|
||||
const graph = useGraphStore()
|
||||
const projectStore = useProjectStore()
|
||||
const keyboard = injectKeyboard(true)
|
||||
|
||||
const insertAsFileConstructor = computed(() => {
|
||||
const reprType = props.input[ArgumentInfoKey]?.info?.reprType
|
||||
@ -86,9 +93,52 @@ function makeValue(edit: Ast.MutableModule, useFileConstructor: boolean, path: s
|
||||
}
|
||||
}
|
||||
|
||||
const uploadStatus = computed(() => {
|
||||
let uploads = projectStore.awareness.allUploads()
|
||||
if (uploads.length == 0) return undefined
|
||||
return uploads.find(([, file]) => file.portId === props.input.portId)
|
||||
})
|
||||
|
||||
const style = computed(() => {
|
||||
const status = uploadStatus.value
|
||||
if (status) {
|
||||
return {
|
||||
'--upload-progress': `${status[1].sizePercentage}%`,
|
||||
}
|
||||
} else {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const onClick = async () => {
|
||||
if (!window.fileBrowserApi) {
|
||||
console.error('File browser not supported!')
|
||||
const selected = await openFileDialog(dialogKind.value, false)
|
||||
const rootId = await projectStore.projectRootId
|
||||
assertDefined(rootId)
|
||||
if (selected != null && selected[0] != null) {
|
||||
const uploader = Uploader.Create(
|
||||
projectStore.lsRpcConnection,
|
||||
projectStore.dataConnection,
|
||||
rootId,
|
||||
projectStore.awareness,
|
||||
selected[0],
|
||||
projectStore.isOnLocalBackend,
|
||||
keyboard?.shift ?? false,
|
||||
projectStore.executionContext.getStackTop(),
|
||||
)
|
||||
|
||||
const uploadResult = await uploader.upload({ portId: props.input.portId })
|
||||
if (uploadResult.ok) {
|
||||
const edit = graph.startEdit()
|
||||
props.onUpdate({
|
||||
edit,
|
||||
portUpdate: {
|
||||
value: uploadedExpressionPath(uploadResult.value),
|
||||
origin: props.input.portId,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const selected = await window.fileBrowserApi.openFileBrowser(
|
||||
dialogKind.value,
|
||||
@ -108,6 +158,24 @@ const onClick = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open "file open" system dialog using a temporary file input DOM node.
|
||||
*
|
||||
* his function must be called from a user activation event (ie an onclick event),
|
||||
* otherwise the dispatchEvent will have no effect.
|
||||
*/
|
||||
|
||||
function openFileDialog(dialogKind: string, multiple: boolean): Promise<FileList | null> {
|
||||
return new Promise((resolve) => {
|
||||
var inputElement = document.createElement('input')
|
||||
inputElement.type = 'file'
|
||||
inputElement.multiple = multiple
|
||||
if (dialogKind === 'directory') inputElement.webkitdirectory = true
|
||||
inputElement.addEventListener('change', () => resolve(inputElement.files))
|
||||
inputElement.dispatchEvent(new MouseEvent('click'))
|
||||
})
|
||||
}
|
||||
|
||||
const item = computed<CustomDropdownItem>(() => ({
|
||||
label: label.value,
|
||||
onClick,
|
||||
@ -115,9 +183,18 @@ const item = computed<CustomDropdownItem>(() => ({
|
||||
|
||||
const innerWidgetInput = computed(() => {
|
||||
const existingItems = props.input[CustomDropdownItemsKey] ?? []
|
||||
const upload = uploadStatus.value
|
||||
return {
|
||||
...props.input,
|
||||
[CustomDropdownItemsKey]: [...existingItems, item.value],
|
||||
...(upload ?
|
||||
{
|
||||
[FileUploadKey]: {
|
||||
name: upload[0],
|
||||
file: upload[1],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@ -150,7 +227,7 @@ export const widgetDefinition = defineWidget(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="WidgetFileBrowser">
|
||||
<div class="WidgetFileBrowser" :style="style">
|
||||
<NodeWidget :input="innerWidgetInput" />
|
||||
</div>
|
||||
</template>
|
||||
|
@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { defineWidget, Score, WidgetInput, widgetProps } from '@/providers/widgetRegistry'
|
||||
import { UploadingFile } from '@/stores/awareness'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps(widgetProps(widgetDefinition))
|
||||
const upload = computed(() => props.input[FileUploadKey])
|
||||
const style = computed(() => {
|
||||
return {
|
||||
'background-position': `${200 - upload.value.file.sizePercentage}% 0`,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export const FileUploadKey: unique symbol = Symbol.for('WidgetInput:FileUploadProgress')
|
||||
|
||||
export interface FileUploadInfo {
|
||||
name: string
|
||||
file: UploadingFile
|
||||
}
|
||||
|
||||
function hasFileUpload(input: WidgetInput): input is WidgetInput & {
|
||||
[FileUploadKey]: FileUploadInfo
|
||||
} {
|
||||
return input[FileUploadKey] != null
|
||||
}
|
||||
|
||||
export const widgetDefinition = defineWidget(
|
||||
hasFileUpload,
|
||||
{
|
||||
priority: 101,
|
||||
score: Score.Perfect,
|
||||
},
|
||||
import.meta.hot,
|
||||
)
|
||||
|
||||
declare module '@/providers/widgetRegistry' {
|
||||
export interface WidgetInput {
|
||||
[FileUploadKey]?: FileUploadInfo
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="WidgetFileUploadProgress" :style="style">
|
||||
<span>{{ `Uploading ${upload.name} (${upload.file.sizePercentage}%)` }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.WidgetFileUploadProgress {
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding: 2px 8px;
|
||||
outline: 0px solid transparent;
|
||||
|
||||
--progress-color: color-mix(in oklab, var(--node-color-port) 85%, white 15%);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--node-color-port) 0%,
|
||||
var(--node-color-port) 50%,
|
||||
var(--progress-color) 50%,
|
||||
var(--progress-color) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
</style>
|
@ -31,7 +31,7 @@ const MISSING = Symbol('MISSING')
|
||||
* [Context API]: https://vuejs.org/guide/components/provide-inject.html#provide-inject
|
||||
*/
|
||||
export function createContextStore<F extends (...args: any[]) => any>(name: string, factory: F) {
|
||||
const provideKey = Symbol(name) as InjectionKey<ReturnType<F>>
|
||||
const provideKey = Symbol.for(`contextStore-${name}`) as InjectionKey<ReturnType<F>>
|
||||
|
||||
/**
|
||||
* Create the instance of a store and store it in the current component's context. All child
|
||||
|
@ -11,7 +11,8 @@ export type FileName = string
|
||||
export interface UploadingFile {
|
||||
sizePercentage: number
|
||||
stackItem: StackItem
|
||||
position: Vec2
|
||||
position?: Vec2
|
||||
portId?: string
|
||||
}
|
||||
|
||||
// === Awareness wrapper ===
|
||||
@ -21,61 +22,48 @@ export interface UploadingFile {
|
||||
*/
|
||||
export class Awareness {
|
||||
public internal: YjsAwareness
|
||||
private uploadingFiles: Map<ClientId, Uploads>
|
||||
|
||||
constructor(doc: Y.Doc) {
|
||||
this.internal = new YjsAwareness(doc)
|
||||
this.internal.setLocalState(initialState())
|
||||
this.uploadingFiles = reactive(new Map())
|
||||
|
||||
this.internal.on('update', (updates: AwarenessUpdates) => {
|
||||
updates.removed.forEach((id) => this.uploadingFiles.delete(id))
|
||||
for (const id of [...updates.added, ...updates.updated]) {
|
||||
const uploads = this.internal.getStates().get(id)?.uploads
|
||||
if (uploads) {
|
||||
this.uploadingFiles.set(id, structuredClone(uploads))
|
||||
}
|
||||
}
|
||||
})
|
||||
this.internal.states = reactive(this.internal.states)
|
||||
}
|
||||
|
||||
public addOrUpdateUpload(name: FileName, file: UploadingFile) {
|
||||
this.withUploads((uploads) => {
|
||||
uploads[name] = file
|
||||
this.mutateLocalState((state) => {
|
||||
state.uploads[name] = { ...(state.uploads[name] ?? {}), ...file }
|
||||
})
|
||||
}
|
||||
|
||||
public removeUpload(name: FileName) {
|
||||
this.withUploads((uploads) => {
|
||||
delete uploads[name]
|
||||
this.mutateLocalState((state) => {
|
||||
delete state.uploads[name]
|
||||
})
|
||||
}
|
||||
|
||||
public allUploads(): Iterable<[FileName, UploadingFile]> {
|
||||
return [...this.uploadingFiles.values()].flatMap((uploads) => [...Object.entries(uploads)])
|
||||
public allUploads(): [FileName, UploadingFile][] {
|
||||
return [...this.states.values()].flatMap((state) => [...Object.entries(state.uploads)])
|
||||
}
|
||||
|
||||
private withUploads(f: (uploads: Uploads) => void) {
|
||||
private get states(): Map<number, State> {
|
||||
return this.internal.states as Map<number, State>
|
||||
}
|
||||
|
||||
private mutateLocalState(f: (state: State) => void) {
|
||||
const state = this.internal.getLocalState() as State
|
||||
f(state.uploads)
|
||||
f(state)
|
||||
this.internal.setLocalState(state)
|
||||
}
|
||||
}
|
||||
|
||||
// === Private types ===
|
||||
|
||||
type ClientId = number
|
||||
|
||||
interface State {
|
||||
uploads: Uploads
|
||||
uploads: Record<FileName, UploadingFile>
|
||||
}
|
||||
|
||||
type Uploads = Record<FileName, UploadingFile>
|
||||
|
||||
const initialState: () => State = () => ({ uploads: {} })
|
||||
|
||||
interface AwarenessUpdates {
|
||||
added: ClientId[]
|
||||
removed: ClientId[]
|
||||
updated: ClientId[]
|
||||
function initialState(): State {
|
||||
return {
|
||||
uploads: {},
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user