Refactor awareness upload data into reactive store, add support for upload through file widget.

This commit is contained in:
Paweł Grabarz 2024-09-05 13:47:58 +02:00
parent d37b8f3786
commit 8f6921d7d2
8 changed files with 192 additions and 47 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: {},
}
}