Skip to content

Commit

Permalink
Copy/Duplicate/Copy-paste assets (#8700)
Browse files Browse the repository at this point in the history
- Closes enso-org/cloud-v2#822
- Implements the following actions and their associated keyboard shortcuts
- "Duplicate"
- "Copy" and "Copy All" (= copy all selected items)
- "Paste" and "Paste All" (with copied assets - cut-and-paste already worked previously)

# Important Notes
None
  • Loading branch information
somebody1234 authored Jan 9, 2024
1 parent 8ad8a33 commit 9ba676b
Show file tree
Hide file tree
Showing 35 changed files with 1,656 additions and 1,046 deletions.
3 changes: 2 additions & 1 deletion app/ide-desktop/lib/dashboard/playwright-e2e.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default test.defineConfig({
testDir: './test-e2e',
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
timeout: 10000,
...(process.env.CI ? { workers: 1 } : {}),
expect: {
toHaveScreenshot: { threshold: 0 },
Expand Down Expand Up @@ -43,6 +44,6 @@ export default test.defineConfig({
webServer: {
command: 'npx tsx test-server.ts',
port: 8080,
reuseExistingServer: !process.env.CI,
reuseExistingServer: false,
},
})
Original file line number Diff line number Diff line change
Expand Up @@ -7,128 +7,136 @@ import * as backendModule from './backend'
// === AssetTreeNode ===
// =====================

/** An {@link AssetTreeNode}, but excluding its methods. */
export interface AssetTreeNodeData
extends Pick<
AssetTreeNode,
'children' | 'depth' | 'directoryId' | 'directoryKey' | 'item' | 'key'
> {}

/** A node in the drive's item tree. */
export interface AssetTreeNode {
/** The id of the asset (or the placeholder id for new assets). This must never change. */
key: backendModule.AssetId
/** The actual asset. This MAY change if this is initially a placeholder item, but rows MAY
* keep updated values within the row itself as well. */
item: backendModule.AnyAsset
/** The id of the asset's parent directory (or the placeholder id for new assets).
* This must never change. */
directoryKey: backendModule.AssetId | null
/** The actual id of the asset's parent directory (or the placeholder id for new assets). */
directoryId: backendModule.DirectoryId | null
/** This is `null` if the asset is not a directory asset, OR if it is a collapsed directory
* asset. */
children: AssetTreeNode[] | null
depth: number
}
export class AssetTreeNode {
/** Create a {@link AssetTreeNode}. */
constructor(
/** The id of the asset (or the placeholder id for new assets). This must never change. */
public readonly key: backendModule.AssetId,
/** The actual asset. This MAY change if this is initially a placeholder item, but rows MAY
* keep updated values within the row itself as well. */
public item: backendModule.AnyAsset,
/** The id of the asset's parent directory (or the placeholder id for new assets).
* This must never change. */
public readonly directoryKey: backendModule.AssetId,
/** The actual id of the asset's parent directory (or the placeholder id for new assets). */
public readonly directoryId: backendModule.DirectoryId,
/** This is `null` if the asset is not a directory asset, OR if it is a collapsed directory
* asset. */
public readonly children: AssetTreeNode[] | null,
public readonly depth: number
) {}

/** Get an {@link AssetTreeNode.key} from an {@link AssetTreeNode}. Useful for React, references
* of global functions do not change. */
export function getAssetTreeNodeKey(node: AssetTreeNode) {
return node.key
}
/** Get an {@link AssetTreeNode.key} from an {@link AssetTreeNode}. Useful for React,
* becausse references of static functions do not change. */
static getKey(this: void, node: AssetTreeNode) {
return node.key
}

/** Return a positive number if `a > b`, a negative number if `a < b`, and zero if `a === b`.
* Uses {@link backendModule.compareAssets} internally. */
export function compareAssetTreeNodes(a: AssetTreeNode, b: AssetTreeNode) {
return backendModule.compareAssets(a.item, b.item)
}
/** Return a positive number if `a > b`, a negative number if `a < b`, and zero if `a === b`.
* Uses {@link backendModule.compareAssets} internally. */
static compare(this: void, a: AssetTreeNode, b: AssetTreeNode) {
return backendModule.compareAssets(a.item, b.item)
}

/** Return a new {@link AssetTreeNode} array if any children would be changed by the transformation
* function, otherwise return the original {@link AssetTreeNode} array. */
export function assetTreeMap(
tree: AssetTreeNode[],
transform: (node: AssetTreeNode) => AssetTreeNode
) {
let result: AssetTreeNode[] | null = null
for (let i = 0; i < tree.length; i += 1) {
const node = tree[i]
if (node == null) {
break
}
const intermediateNode = transform(node)
let newNode: AssetTreeNode = intermediateNode
if (intermediateNode.children != null) {
const newChildren = assetTreeMap(intermediateNode.children, transform)
if (newChildren !== intermediateNode.children) {
newNode = { ...intermediateNode, children: newChildren }
/** Creates an {@link AssetTreeNode} from a {@link backendModule.AnyAsset}. */
static fromAsset(
this: void,
asset: backendModule.AnyAsset,
directoryKey: backendModule.AssetId,
directoryId: backendModule.DirectoryId,
depth: number,
getKey: ((asset: backendModule.AnyAsset) => backendModule.AssetId) | null = null
): AssetTreeNode {
getKey ??= oldAsset => oldAsset.id
return new AssetTreeNode(getKey(asset), asset, directoryKey, directoryId, null, depth)
}

/** Create a new {@link AssetTreeNode} with the specified properties updated. */
with(update: Partial<AssetTreeNodeData>) {
return new AssetTreeNode(
update.key ?? this.key,
update.item ?? this.item,
update.directoryKey ?? this.directoryKey,
update.directoryId ?? this.directoryId,
// `null` MUST be special-cases in the following line.
// eslint-disable-next-line eqeqeq
update.children === null ? update.children : update.children ?? this.children,
update.depth ?? this.depth
)
}

/** Return a new {@link AssetTreeNode} array if any children would be changed by the transformation
* function, otherwise return the original {@link AssetTreeNode} array. */
map(transform: (node: AssetTreeNode) => AssetTreeNode) {
const children = this.children ?? []
let result: AssetTreeNode = transform(this)
for (let i = 0; i < children.length; i += 1) {
const node = children[i]
if (node == null) {
break
}
const newNode = node.map(transform)
if (newNode !== node) {
if (result === this) {
result = this.with({ children: [...children] })
}
// This is SAFE, as `result` is always created with a non-`null` children.
// (See the line above.)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
result.children![i] = newNode
}
}
if (newNode !== node) {
result ??= Array.from(tree)
result[i] = newNode
}
return result
}
return result ?? tree
}

/** Return a new {@link AssetTreeNode} array if any children would be changed by the transformation
* function, otherwise return the original {@link AssetTreeNode} array. The predicate is applied to
* a parent node before it is applied to its children. */
export function assetTreeFilter(
tree: AssetTreeNode[],
predicate: (node: AssetTreeNode) => boolean
) {
let result: AssetTreeNode[] | null = null
for (let i = 0; i < tree.length; i += 1) {
const node = tree[i]
if (node == null) {
break
}
if (!predicate(node)) {
result = tree.slice(0, i)
} else {
if (node.children != null) {
const newChildren = assetTreeFilter(node.children, predicate)
if (newChildren !== node.children) {
result ??= tree.slice(0, i)
const newNode = {
...node,
children: newChildren.length === 0 ? null : newChildren,
/** Return a new {@link AssetTreeNode} array if any children would be changed by the transformation
* function, otherwise return the original {@link AssetTreeNode} array. The predicate is applied to
* a parent node before it is applied to its children. The root node is never removed. */
filter(predicate: (node: AssetTreeNode) => boolean) {
const children = this.children ?? []
let result: AssetTreeNode | null = null
for (let i = 0; i < children.length; i += 1) {
const node = children[i]
if (node == null) {
break
}
if (!predicate(node)) {
result ??= this.with({ children: i === 0 ? null : children.slice(0, i) })
} else {
let newNode = node
if (node.children != null) {
newNode = node.filter(predicate)
if (newNode !== node) {
result ??= this.with({ children: children.slice(0, i) })
}
}
if (result) {
if (!result.children) {
result = result.with({ children: [newNode] })
} else {
result.children.push(newNode)
}
result.push(newNode)
} else if (result != null) {
result.push(node)
}
} else if (result != null) {
result.push(node)
}
}
return result?.children?.length === 0 ? result.with({ children: null }) : result ?? this
}
return result ?? tree
}

/** Returns all items in the tree, flattened into an array using pre-order traversal. */
export function assetTreePreorderTraversal(
tree: AssetTreeNode[],
preprocess?: ((tree: AssetTreeNode[]) => AssetTreeNode[]) | null
): AssetTreeNode[] {
return (preprocess?.(tree) ?? tree).flatMap(node => {
if (node.children != null) {
return [node, ...assetTreePreorderTraversal(node.children, preprocess ?? null)]
} else {
return [node]
}
})
}

/** Creates an {@link AssetTreeNode} from a {@link backendModule.AnyAsset}. */
export function assetTreeNodeFromAsset(
asset: backendModule.AnyAsset,
directoryKey: backendModule.AssetId | null,
directoryId: backendModule.DirectoryId | null,
depth: number
): AssetTreeNode {
return {
key: asset.id,
item: asset,
directoryKey,
directoryId,
children: null,
depth,
/** Returns all items in the tree, flattened into an array using pre-order traversal. */
preorderTraversal(
preprocess: ((tree: AssetTreeNode[]) => AssetTreeNode[]) | null = null
): AssetTreeNode[] {
return (preprocess?.(this.children ?? []) ?? this.children ?? []).flatMap(node =>
node.children == null ? [node] : [node, ...node.preorderTraversal(preprocess)]
)
}
}

Expand All @@ -154,7 +162,7 @@ export function useSetAsset<T extends backendModule.AnyAsset>(
// eslint-disable-next-line no-restricted-syntax
valueOrUpdater(oldNode.item as T)
: valueOrUpdater
return { ...oldNode, item }
return oldNode.with({ item })
})
},
[/* should never change */ setNode]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,18 @@ export interface UpdatedDirectory {
/** The type returned from the "create directory" endpoint. */
export interface Directory extends DirectoryAsset {}

/** The subset of asset fields returned by the "copy asset" endpoint. */
export interface CopiedAsset {
id: AssetId
parentId: DirectoryId
title: string
}

/** The type returned from the "copy asset" endpoint. */
export interface CopyAssetResponse {
asset: CopiedAsset
}

/** Possible filters for the "list directory" endpoint. */
export enum FilterBy {
all = 'All',
Expand Down Expand Up @@ -490,6 +502,25 @@ export interface SecretAsset extends Asset<AssetType.secret> {}
/** A convenience alias for {@link Asset}<{@link AssetType.specialLoading}>. */
export interface SpecialLoadingAsset extends Asset<AssetType.specialLoading> {}

/** A convenience alias for {@link Asset}<{@link AssetType.specialEmpty}>. */
export interface SpecialEmptyAsset extends Asset<AssetType.specialEmpty> {}

/** Creates a {@link DirectoryAsset} representing the root directory for the organization,
* with all irrelevant fields initialized to default values. */
export function createRootDirectoryAsset(directoryId: DirectoryId): DirectoryAsset {
return {
type: AssetType.directory,
title: '(root)',
id: directoryId,
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: DirectoryId(''),
permissions: [],
projectState: null,
labels: [],
description: null,
}
}

/** Creates a {@link SpecialLoadingAsset}, with all irrelevant fields initialized to default
* values. */
export function createSpecialLoadingAsset(directoryId: DirectoryId): SpecialLoadingAsset {
Expand All @@ -506,9 +537,6 @@ export function createSpecialLoadingAsset(directoryId: DirectoryId): SpecialLoad
}
}

/** A convenience alias for {@link Asset}<{@link AssetType.specialEmpty}>. */
export interface SpecialEmptyAsset extends Asset<AssetType.specialEmpty> {}

/** Creates a {@link SpecialEmptyAsset}, with all irrelevant fields initialized to default
* values. */
export function createSpecialEmptyAsset(directoryId: DirectoryId): SpecialEmptyAsset {
Expand Down Expand Up @@ -539,6 +567,46 @@ export function assetIsType<Type extends AssetType>(type: Type) {
return (asset: AnyAsset): asset is Extract<AnyAsset, Asset<Type>> => asset.type === type
}

/** Creates a new placeholder asset id for the given asset type. */
export function createPlaceholderAssetId<Type extends AssetType>(
type: Type,
id?: string
): IdType[Type] {
// This is required so that TypeScript can check the `switch` for exhaustiveness.
const assetType: AssetType = type
id ??= uniqueString.uniqueString()
let result: AssetId
switch (assetType) {
case AssetType.directory: {
result = DirectoryId(id)
break
}
case AssetType.project: {
result = ProjectId(id)
break
}
case AssetType.file: {
result = FileId(id)
break
}
case AssetType.secret: {
result = SecretId(id)
break
}
case AssetType.specialLoading: {
result = LoadingAssetId(id)
break
}
case AssetType.specialEmpty: {
result = EmptyAssetId(id)
break
}
}
// This is SAFE, just too complex for TypeScript to correctly typecheck.
// eslint-disable-next-line no-restricted-syntax
return result as IdType[Type]
}

// These are functions, and so their names should be camelCase.
/* eslint-disable no-restricted-syntax */
/** A type guard that returns whether an {@link Asset} is a {@link ProjectAsset}. */
Expand Down Expand Up @@ -797,6 +865,13 @@ export abstract class Backend {
abstract deleteAsset(assetId: AssetId, title: string | null): Promise<void>
/** Restore an arbitrary asset from the trash. */
abstract undoDeleteAsset(assetId: AssetId, title: string | null): Promise<void>
/** Copy an arbitrary asset to another directory. */
abstract copyAsset(
assetId: AssetId,
parentDirectoryId: DirectoryId,
title: string | null,
parentDirectoryTitle: string | null
): Promise<CopyAssetResponse>
/** Return a list of projects belonging to the current user. */
abstract listProjects(): Promise<ListedProject[]>
/** Create a project for the current user. */
Expand Down
Loading

0 comments on commit 9ba676b

Please sign in to comment.