Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Upload files by drag and drop #8214

Merged
merged 27 commits into from
Nov 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6cb9058
Implement mock drop handler
vitvakatu Oct 31, 2023
a6d23fe
Verifying if data directory exists
vitvakatu Oct 31, 2023
1c56f42
Preserve error code in the error message from RPC
vitvakatu Oct 31, 2023
781cb76
Use enum instead of namespace with constants
vitvakatu Oct 31, 2023
599f3e4
Expand error codes
vitvakatu Oct 31, 2023
1e02ef8
Use zod for parsing
vitvakatu Oct 31, 2023
94bb72e
Adjust after refactoring error codes
vitvakatu Oct 31, 2023
73d3c48
Refactor check for data directory existence
vitvakatu Oct 31, 2023
64bae30
Add ensureDataDirectoryExists
vitvakatu Oct 31, 2023
7c427ed
Picking unique name
vitvakatu Oct 31, 2023
b1a6365
Fix writeBytes and readBytes functions
vitvakatu Nov 1, 2023
bf4644f
Uploading file
vitvakatu Nov 1, 2023
f69ee47
Avoid crash when new directory created in project root
vitvakatu Nov 1, 2023
da808a9
Bail out if file is not src/**/*.enso
vitvakatu Nov 1, 2023
0709e46
Adding checksum calculation
vitvakatu Nov 1, 2023
a4b8f68
Add toString for Vec2
vitvakatu Nov 2, 2023
68cb95b
Fix node id calculation
vitvakatu Nov 2, 2023
d421f27
Use js-sha3 instead of sha3
vitvakatu Nov 2, 2023
e804226
Refactoring
vitvakatu Nov 2, 2023
576c928
Formatting
vitvakatu Nov 2, 2023
f13f170
Add abort handler
vitvakatu Nov 2, 2023
44c3681
Cleanup
vitvakatu Nov 2, 2023
e800aa9
Use @noble/hashes instead of js-sha3
vitvakatu Nov 2, 2023
e2f23e7
Fix directory creation
vitvakatu Nov 2, 2023
3ebb775
Change algorithm for assigning variable names
vitvakatu Nov 2, 2023
77f0d72
Merge branch 'develop' into wip/vitvakatu/uploading-files
vitvakatu Nov 2, 2023
e93e847
Fix lints
vitvakatu Nov 2, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/gui2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@fast-check/vitest": "^0.0.8",
"@lezer/common": "^1.1.0",
"@lezer/highlight": "^1.1.6",
"@noble/hashes": "^1.3.2",
"@open-rpc/client-js": "^1.8.1",
"@vueuse/core": "^10.4.1",
"codemirror": "^6.0.1",
Expand All @@ -53,7 +54,6 @@
"pinia": "^2.1.6",
"postcss-inline-svg": "^6.0.0",
"postcss-nesting": "^12.0.1",
"sha3": "^2.1.4",
"sucrase": "^3.34.0",
"vue": "^3.3.4",
"ws": "^8.13.0",
Expand Down
9 changes: 7 additions & 2 deletions app/gui2/shared/dataServer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ObservableV2 } from 'lib0/observable'
import * as random from 'lib0/random'
import type { Path as LSPath } from 'shared/languageServerTypes'
import {
Builder,
ByteBuffer,
Expand All @@ -15,6 +16,7 @@ import {
None,
OutboundMessage,
OutboundPayload,
Path,
ReadBytesCommand,
ReadBytesReply,
ReadFileCommand,
Expand Down Expand Up @@ -161,14 +163,17 @@ export class DataServer extends ObservableV2<DataServerEvents> {
}

async writeBytes(
path: string,
path: LSPath,
index: bigint,
overwriteExisting: boolean,
contents: string | ArrayBuffer | Uint8Array,
): Promise<WriteBytesReply> {
const builder = new Builder()
const bytesOffset = builder.createString(contents)
const pathOffset = builder.createString(path)
const segmentOffsets = [...path.segments].map((segment) => builder.createString(segment))
const segmentsOffset = Path.createSegmentsVector(builder, segmentOffsets)
const rootIdOffset = this.createUUID(builder, path.rootId)
const pathOffset = Path.createPath(builder, rootIdOffset, segmentsOffset)
const command = WriteBytesCommand.createWriteBytesCommand(
builder,
pathOffset,
Expand Down
5 changes: 3 additions & 2 deletions app/gui2/shared/languageServer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { sha3_224 as SHA3 } from '@noble/hashes/sha3'
import { bytesToHex } from '@noble/hashes/utils'
import { Client } from '@open-rpc/client-js'
import { ObservableV2 } from 'lib0/observable'
import { uuidv4 } from 'lib0/random'
import { SHA3 } from 'sha3'
import { z } from 'zod'
import type {
Checksum,
Expand Down Expand Up @@ -383,5 +384,5 @@ export class LanguageServer extends ObservableV2<Notifications> {
}

export function computeTextChecksum(text: string): Checksum {
return new SHA3(224).update(text).digest('hex') as Checksum
return bytesToHex(SHA3.create().update(text).digest()) as Checksum
}
7 changes: 5 additions & 2 deletions app/gui2/shared/yjsModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,11 @@ export class DistributedModule {
this.undoManager = new Y.UndoManager([this.doc.contents, this.doc.idMap, this.doc.metadata])
}

insertNewNode(offset: number, content: string, meta: NodeMetadata): ExprId {
const range = [offset, offset + content.length] as const
insertNewNode(offset: number, pattern: string, expression: string, meta: NodeMetadata): ExprId {
// Spaces at the beginning are needed to place the new node in scope of the `main` function with proper indentation.
const lhs = ` ${pattern} = `
const content = lhs + expression
const range = [offset + lhs.length, offset + content.length] as const
const newId = random.uuidv4() as ExprId
this.transact(() => {
this.doc.contents.insert(offset, content + '\n')
Expand Down
29 changes: 29 additions & 0 deletions app/gui2/src/components/GraphEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { codeEditorBindings, graphBindings, interactionBindings } from '@/bindings'
import CodeEditor from '@/components/CodeEditor.vue'
import ComponentBrowser from '@/components/ComponentBrowser.vue'
import { Uploader, uploadedExpression } from '@/components/GraphEditor/upload'
import SelectionBrush from '@/components/SelectionBrush.vue'
import TopBar from '@/components/TopBar.vue'
import { provideGraphNavigator } from '@/providers/graphNavigator'
Expand Down Expand Up @@ -180,6 +181,32 @@ watch(componentBrowserVisible, (visible) => {
interactionEnded(editingNode)
}
})

async function handleFileDrop(event: DragEvent) {
try {
if (event.dataTransfer && event.dataTransfer.items) {
;[...event.dataTransfer.items].forEach(async (item) => {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) {
const clientPos = new Vec2(event.clientX, event.clientY)
const pos = navigator.clientToScenePos(clientPos)
const uploader = await Uploader.create(
projectStore.lsRpcConnection,
projectStore.dataConnection,
projectStore.contentRoots,
file,
)
const name = await uploader.upload()
graphStore.createNode(pos, uploadedExpression(name))
}
}
})
}
} catch (err) {
console.error(`Uploading file failed. ${err}`)
}
}
</script>

<template>
Expand All @@ -191,6 +218,8 @@ watch(componentBrowserVisible, (visible) => {
@click="graphBindingsHandler"
v-on.="navigator.events"
v-on..="nodeSelection.events"
@dragover.prevent
@drop.prevent="handleFileDrop($event)"
>
<svg :viewBox="navigator.viewBox">
<GraphEdges @startInteraction="setCurrentInteraction" @endInteraction="interactionEnded" />
Expand Down
134 changes: 134 additions & 0 deletions app/gui2/src/components/GraphEditor/upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { Keccak, sha3_224 as SHA3 } from '@noble/hashes/sha3'
import type { Hash } from '@noble/hashes/utils'
import { bytesToHex } from '@noble/hashes/utils'
import type { DataServer } from 'shared/dataServer'
import type { LanguageServer } from 'shared/languageServer'
import { ErrorCode, RemoteRpcError } from 'shared/languageServer'
import type { ContentRoot, Path, Uuid } from 'shared/languageServerTypes'

export const uploadedExpression = (name: string) => `enso_project.data/"${name}" . read`
const DATA_DIR_NAME = 'data'

export class Uploader {
private rpc: LanguageServer
private binary: DataServer
private file: File
private projectRootId: Uuid
private checksum: Hash<Keccak>
private uploadedBytes: bigint

private constructor(rpc: LanguageServer, binary: DataServer, file: File, projectRootId: Uuid) {
this.rpc = rpc
this.binary = binary
this.file = file
this.projectRootId = projectRootId
this.checksum = SHA3.create()
this.uploadedBytes = BigInt(0)
}

static async create(
rpc: Promise<LanguageServer>,
binary: Promise<DataServer>,
contentRoots: Promise<ContentRoot[]>,
file: File,
): Promise<Uploader> {
const projectRootId = await contentRoots.then((roots) =>
roots.find((root) => root.type == 'Project'),
)
if (!projectRootId) throw new Error('Unable to find project root, uploading not possible.')
const instance = new Uploader(await rpc, await binary, file, projectRootId.id)
return instance
}

async upload(): Promise<string> {
await this.ensureDataDirExists()
const name = await this.pickUniqueName(this.file.name)
const remotePath: Path = { rootId: this.projectRootId, segments: [DATA_DIR_NAME, name] }
const uploader = this
const writableStream = new WritableStream<Uint8Array>({
async write(chunk: Uint8Array) {
await uploader.binary.writeBytes(remotePath, uploader.uploadedBytes, false, chunk)
uploader.checksum.update(chunk)
uploader.uploadedBytes += BigInt(chunk.length)
},
async close() {
// Disabled until https://github.com/enso-org/enso/issues/6691 is fixed.
// uploader.assertChecksum(remotePath)
},
async abort(reason: string) {
await uploader.rpc.deleteFile(remotePath)
throw new Error(`Uploading process aborted. ${reason}`)
},
})
await this.file.stream().pipeTo(writableStream)
return name
}

private async assertChecksum(path: Path) {
const engineChecksum = await this.rpc.fileChecksum(path)
const hexChecksum = bytesToHex(this.checksum.digest())
if (hexChecksum != engineChecksum.checksum) {
throw new Error(
`Uploading file failed, checksum does not match. ${hexChecksum} != ${engineChecksum.checksum}`,
)
}
}

private dataDirPath(): Path {
return { rootId: this.projectRootId, segments: [DATA_DIR_NAME] }
}

private async ensureDataDirExists() {
const exists = await this.dataDirExists()
if (!exists) {
await this.rpc.createFile({
type: 'Directory',
name: DATA_DIR_NAME,
path: { rootId: this.projectRootId, segments: [] },
})
}
}

private async dataDirExists(): Promise<boolean> {
try {
const info = await this.rpc.fileInfo(this.dataDirPath())
return info.attributes.kind.type == 'Directory'
} catch (err: any) {
if (err.cause && err.cause instanceof RemoteRpcError) {
if ([ErrorCode.FILE_NOT_FOUND, ErrorCode.CONTENT_ROOT_NOT_FOUND].includes(err.cause.code)) {
return false
}
}
throw err
}
}

private async pickUniqueName(suggestedName: string): Promise<string> {
const files = await this.rpc.listFiles(this.dataDirPath())
const existingNames = new Set(files.paths.map((path) => path.name))
const [stem, maybeExtension] = splitFilename(suggestedName)
const extension = maybeExtension ?? ''
let candidate = suggestedName
let num = 1
while (existingNames.has(candidate)) {
candidate = `${stem}_${num}.${extension}`
num += 1
}
return candidate
}
}

/**
* Split filename into stem and (optional) extension.
*/
function splitFilename(filename: string): [string, string | null] {
const dotIndex = filename.lastIndexOf('.')

if (dotIndex !== -1 && dotIndex !== 0) {
const stem = filename.substring(0, dotIndex)
const extension = filename.substring(dotIndex + 1)
return [stem, extension]
}

return [filename, null]
}
5 changes: 2 additions & 3 deletions app/gui2/src/stores/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,7 @@ export const useGraphStore = defineStore('graph', () => {
vis: null,
}
const ident = generateUniqueIdent()
const content = `${ident} = ${expression}`
return mod.insertNewNode(mod.doc.contents.length, content, meta)
return mod.insertNewNode(mod.doc.contents.length, ident, expression, meta)
}

function deleteNode(id: ExprId) {
Expand Down Expand Up @@ -358,7 +357,7 @@ export const useGraphStore = defineStore('graph', () => {
})

function randomString() {
return Math.random().toString(36).substring(2, 10)
return 'operator' + Math.round(Math.random() * 100000)
}

export interface Node {
Expand Down
1 change: 1 addition & 0 deletions app/gui2/src/util/navigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,5 +130,6 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
/** Use this transform instead, if the element should not be scaled. */
prescaledTransform,
sceneMousePos,
clientToScenePos,
})
}
4 changes: 4 additions & 0 deletions app/gui2/src/util/vec2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,8 @@ export class Vec2 {
lerp(to: Vec2, t: number): Vec2 {
return new Vec2(this.x + (to.x - this.x) * t, this.y + (to.y - this.y) * t)
}

toString(): string {
return `(${this.x}, ${this.y})`
}
}
42 changes: 12 additions & 30 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading