New context menu (#7431)

Closes https://github.com/enso-org/cloud-v2/issues/560
- New context menu
- Global keyboard shortcut handler
- Moves the existing "escape" keybindings (close modal, cancel editing names) to global keybind handlers

# Important Notes
- The "Upload To Cloud" action is not present in the Figma design. As such:
- Its current icon is an edit of the "cloud_from" icon, with the arrow upside down
- It does not have a corresponding keyboard shortcut
This commit is contained in:
somebody1234 2023-08-11 01:31:53 +10:00 committed by GitHub
parent af0e738dec
commit f1c224e62e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
79 changed files with 1894 additions and 1052 deletions

View File

@ -23,17 +23,19 @@ declare global {
}
}
Object.defineProperty(Object.prototype, '$d$', {
/** Log self and return self. */
value: function <T>(this: T, message?: string) {
if (message != null) {
console.log(message, this)
} else {
console.log(this)
}
return this
},
enumerable: false,
writable: false,
configurable: false,
})
if (!('$d$' in Object.prototype)) {
Object.defineProperty(Object.prototype, '$d$', {
/** Log self and return self. */
value: function <T>(this: T, message?: string) {
if (message != null) {
console.log(message, this)
} else {
console.log(this)
}
return this
},
enumerable: false,
writable: false,
configurable: false,
})
}

View File

@ -1,11 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.5">
<rect x="4" y="4" width="3" height="3" fill="black" fill-opacity="0.6" />
<rect x="9" y="4" width="3" height="3" fill="black" fill-opacity="0.6" />
<rect x="4" y="4" width="3" height="3" fill="black" />
<rect x="9" y="4" width="3" height="3" fill="black" />
</g>
<g opacity="0.5">
<rect x="4" y="9" width="3" height="3" fill="black" fill-opacity="0.6" />
<rect x="9" y="9" width="3" height="3" fill="black" fill-opacity="0.6" />
<rect x="4" y="9" width="3" height="3" fill="black" />
<rect x="9" y="9" width="3" height="3" fill="black" />
</g>
<rect x="1" y="1" width="14" height="14" rx="1" stroke="black" stroke-opacity="0.6" stroke-width="2" />
<rect x="1" y="1" width="14" height="14" rx="1" stroke="black" stroke-width="2" />
</svg>

Before

Width:  |  Height:  |  Size: 600 B

After

Width:  |  Height:  |  Size: 503 B

View File

@ -1,7 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="4" width="3" height="3" fill="black" fill-opacity="0.6" />
<rect x="9" y="4" width="3" height="3" fill="black" fill-opacity="0.6" />
<rect x="4" y="9" width="3" height="3" fill="black" fill-opacity="0.6" />
<rect x="9" y="9" width="3" height="3" fill="black" fill-opacity="0.6" />
<rect opacity="0.3" width="16" height="16" rx="2" fill="black" fill-opacity="0.6" />
<rect x="4" y="4" width="3" height="3" fill="black" />
<rect x="9" y="4" width="3" height="3" fill="black" />
<rect x="4" y="9" width="3" height="3" fill="black" />
<rect x="9" y="9" width="3" height="3" fill="black" />
<rect opacity="0.3" width="16" height="16" rx="2" fill="black" />
</svg>

Before

Width:  |  Height:  |  Size: 503 B

After

Width:  |  Height:  |  Size: 408 B

View File

@ -1,7 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0 8C0 5.23858 2.23858 3 5 3H7V7L11 7C11.5523 7 12 7.44772 12 8C12 8.03411 11.9983 8.06782 11.995 8.10105C11.2578 8.25147 10.5797 8.56409 9.99951 9H7V13H5C2.23858 13 0 10.7614 0 8ZM15.9126 8.93548C15.97 8.63244 16 8.31972 16 8C16 5.23858 13.7614 3 11 3H9V5L11 5C12.6569 5 14 6.34315 14 8C14 8.03336 13.9995 8.06659 13.9984 8.09969C14.7012 8.24211 15.3505 8.53192 15.9126 8.93548ZM6.99998 4.99999V7H5C4.44772 7 4 7.44771 4 8C4 8.55228 4.44772 9 5 9H6.99998V11H4.99998C3.34313 11 1.99998 9.65685 1.99998 7.99999C1.99998 6.34314 3.34313 4.99999 4.99998 4.99999H6.99998Z"
fill="black" fill-opacity="0.3" />
fill="black" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M14 14V15V16H12V15V14H10V12L12 12V10H14V12L16 12V14H14Z"
fill="black" fill-opacity="0.3" />
fill="black" />
</svg>

Before

Width:  |  Height:  |  Size: 927 B

After

Width:  |  Height:  |  Size: 889 B

View File

@ -2,7 +2,7 @@
<g clip-path="url(#clip0_20908_12316)">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0 3C0 1.89543 0.895431 1 2 1H6C7.10457 1 8 1.89543 8 3H14C15.1046 3 16 3.89543 16 5V5.67363C18.3649 6.79709 20 9.2076 20 12C20 15.866 16.866 19 13 19C10.2076 19 7.79709 17.3649 6.67363 15H2C0.89543 15 0 14.1046 0 13V7H8.10102C8.49 6.61882 8.92328 6.28268 9.39241 6H0V3ZM13 17C11.3642 17 9.91184 16.2144 8.99963 15C8.37194 14.1643 8 13.1256 8 12C8 9.23858 10.2386 7 13 7C14.1256 7 15.1643 7.37194 16 7.99963C17.2144 8.91184 18 10.3642 18 12C18 14.7614 15.7614 17 13 17ZM14 14V13H16V11H14V9H12V11H10V13H12V14V15H14V14Z"
fill="black" fill-opacity="0.3" />
fill="black" />
</g>
<defs>
<clipPath id="clip0_20908_12316">

Before

Width:  |  Height:  |  Size: 932 B

After

Width:  |  Height:  |  Size: 913 B

View File

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M16 0H0V4H2.5V6H0V10H2.5V12H0V16H8.99963C8.37194 15.1643 8 14.1256 8 13C8 12.6575 8.03443 12.3231 8.10002 12H4.5V10H7V6H4.5V4H11.5V6H9V9.99951C9.91223 8.78534 11.3644 8 13 8C14.1256 8 15.1643 8.37194 16 8.99963V6H13.5V4H16V0Z"
fill="black" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M14 14V15V16H12V15V14H10V12L12 12V10H14V12L16 12V14H14Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 548 B

View File

@ -0,0 +1,2 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
</svg>

After

Width:  |  Height:  |  Size: 102 B

View File

@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.70711 1.29289C5.89464 1.10536 6.149 1 6.41421 1H9.58579C9.851 1 10.1054 1.10536 10.2929 1.29289L12 3H4L5.70711 1.29289Z"
fill="black" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M3 3C1.34315 3 0 4.34315 0 6V12C0 13.6569 1.34315 15 3 15H13C14.6569 15 16 13.6569 16 12V6C16 4.34315 14.6569 3 13 3H3ZM8 12.7C10.2091 12.7 12 10.9091 12 8.7C12 6.49086 10.2091 4.7 8 4.7C5.79086 4.7 4 6.49086 4 8.7C4 10.9091 5.79086 12.7 8 12.7Z"
fill="black" />
<circle cx="8" cy="8.70001" r="2" fill="black" />
<circle cx="8" cy="8.70001" r="2.25" stroke="black" stroke-width="0.5" />
</svg>

After

Width:  |  Height:  |  Size: 735 B

View File

@ -2,6 +2,6 @@
<g opacity="0.9">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M8 15C12.4183 15 16 11.6421 16 7.5C16 3.35786 12.4183 0 8 0C3.58172 0 0 3.35786 0 7.5C0 9.08162 0.52221 10.5489 1.4138 11.7585L1.12093e-05 16L4.18897 14.0959C5.32198 14.6725 6.6202 15 8 15Z"
fill="black" fill-opacity="0.6" />
fill="black" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 440 B

After

Width:  |  Height:  |  Size: 421 B

View File

@ -0,0 +1,9 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.3">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M2.03823 7.31894C2.37639 4.32591 4.91657 2 8 2C10.7676 2 13.0976 3.87386 13.7904 6.42207C15.1006 7.07898 16 8.43446 16 10C16 12.2091 14.2091 14 12 14H8H3.5C1.567 14 0 12.433 0 10.5C0 9.08881 0.835182 7.87268 2.03823 7.31894Z"
fill="black" />
</g>
<path d="M11 9L8 5L5 9L11 9Z" fill="black" />
<path d="M7 11C7 11.55228 7.44772 12 8 12C8.55228 12 9 11.55228 9 11V9H7V7Z" fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 603 B

View File

@ -0,0 +1,5 @@
<svg width="13" height="13" viewBox="0 1 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M4.46875 4.96875V4.15625C4.46875 3.70752 4.10498 3.34375 3.65625 3.34375C3.20752 3.34375 2.84375 3.70752 2.84375 4.15625C2.84375 4.60498 3.20752 4.96875 3.65625 4.96875H4.46875ZM4.46875 6.1875H3.65625C2.53442 6.1875 1.625 5.27808 1.625 4.15625C1.625 3.03442 2.53442 2.125 3.65625 2.125C4.77808 2.125 5.6875 3.03442 5.6875 4.15625V4.96875H7.3125V4.15625C7.3125 3.03442 8.22192 2.125 9.34375 2.125C10.4656 2.125 11.375 3.03442 11.375 4.15625C11.375 5.27808 10.4656 6.1875 9.34375 6.1875H8.53125V7.8125H9.34375C10.4656 7.8125 11.375 8.72192 11.375 9.84375C11.375 10.9656 10.4656 11.875 9.34375 11.875C8.22192 11.875 7.3125 10.9656 7.3125 9.84375V9.03125H5.6875V9.84375C5.6875 10.9656 4.77808 11.875 3.65625 11.875C2.53442 11.875 1.625 10.9656 1.625 9.84375C1.625 8.72192 2.53442 7.8125 3.65625 7.8125H4.46875V6.1875ZM4.46875 9.03125H3.65625C3.20752 9.03125 2.84375 9.39502 2.84375 9.84375C2.84375 10.2925 3.20752 10.6562 3.65625 10.6562C4.10498 10.6562 4.46875 10.2925 4.46875 9.84375V9.03125ZM5.6875 7.8125V7.40625V6.59375V6.1875H6.09375H6.90625H7.3125V6.59375V7.40625V7.8125H6.90625H6.09375H5.6875ZM8.53125 9.03125V9.84375C8.53125 10.2925 8.89502 10.6562 9.34375 10.6562C9.79248 10.6562 10.1562 10.2925 10.1562 9.84375C10.1562 9.39502 9.79248 9.03125 9.34375 9.03125H8.53125ZM8.53125 4.96875H9.34375C9.79248 4.96875 10.1562 4.60498 10.1562 4.15625C10.1562 3.70752 9.79248 3.34375 9.34375 3.34375C8.89502 3.34375 8.53125 3.70752 8.53125 4.15625V4.96875Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.3" y="4" width="12" height="12" rx="2" fill="black" />
<rect x="5" y="1" width="10" height="10" rx="1" stroke="black" stroke-width="2" stroke-dasharray="5.57 4"
stroke-dashoffset="3.57" />
</svg>

After

Width:  |  Height:  |  Size: 324 B

View File

@ -0,0 +1,5 @@
<svg width="13" height="13" viewBox="1.5 1.5 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M9.41421 6.41421L8 5L6.58579 6.41421L3.75736 9.24264L5.17157 10.6569L8 7.82843L10.8284 10.6569L12.2426 9.24264L9.41421 6.41421Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 320 B

View File

@ -1,12 +1,9 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.25" x="14" width="10" height="12" rx="2" transform="rotate(90 14 0)" fill="black"
fill-opacity="0.3" />
<rect opacity="0.25" x="14" width="10" height="12" rx="2" transform="rotate(90 14 0)" fill="black" />
<g opacity="0.25" clip-path="url(#clip0_20908_12355)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 16L11 12L9 12L9 5L7 5L7 12L5 12L8 16Z" fill="black"
fill-opacity="0.3" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 16L11 12L9 12L9 5L7 5L7 12L5 12L8 16Z" fill="black" />
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 16L11 12L9 12L9 5L7 5L7 12L5 12L8 16Z" fill="black"
fill-opacity="0.3" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 16L11 12L9 12L9 5L7 5L7 12L5 12L8 16Z" fill="black" />
<defs>
<clipPath id="clip0_20908_12355">
<rect width="6" height="6" fill="white" transform="translate(5 10)" />

Before

Width:  |  Height:  |  Size: 755 B

After

Width:  |  Height:  |  Size: 670 B

View File

@ -1,8 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.25" x="2" y="11" width="11" height="12" rx="2" transform="rotate(-90 2 11)" fill="black"
fill-opacity="0.3" />
<rect opacity="0.25" x="7" y="15" width="4" height="2" transform="rotate(-90 7 15)" fill="black"
fill-opacity="0.3" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 4L5 8L7 8L7 15L9 15L9 8L11 8L8 4Z" fill="black"
fill-opacity="0.3" />
<rect opacity="0.25" x="2" y="11" width="11" height="12" rx="2" transform="rotate(-90 2 11)" fill="black" />
<rect opacity="0.25" x="7" y="15" width="4" height="2" transform="rotate(-90 7 15)" fill="black" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 4L5 8L7 8L7 15L9 15L9 8L11 8L8 4Z" fill="black" />
</svg>

Before

Width:  |  Height:  |  Size: 507 B

After

Width:  |  Height:  |  Size: 426 B

View File

@ -1,6 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 11.5H20V17.5C20 18.6046 19.1046 19.5 18 19.5H6C4.89543 19.5 4 18.6046 4 17.5V11.5Z" fill="black"
fill-opacity="0.6" />
<path d="M4 7.5H18C19.1046 7.5 20 8.39543 20 9.5V10.5H4V7.5Z" fill="black" fill-opacity="0.6" />
<path d="M4 7.5C4 6.39543 4.89543 5.5 6 5.5H10C11.1046 5.5 12 6.39543 12 7.5H4Z" fill="black" fill-opacity="0.6" />
</svg>

Before

Width:  |  Height:  |  Size: 466 B

View File

@ -1,6 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="1" width="12" height="2" fill="black" fill-opacity="0.6" />
<rect x="2" y="5" width="8" height="2" fill="black" fill-opacity="0.6" />
<rect x="4" y="9" width="8" height="2" fill="black" fill-opacity="0.6" />
<rect x="2" y="13" width="12" height="2" fill="black" fill-opacity="0.6" />
<rect x="2" y="1" width="12" height="2" fill="black" />
<rect x="2" y="5" width="8" height="2" fill="black" />
<rect x="4" y="9" width="8" height="2" fill="black" />
<rect x="2" y="13" width="12" height="2" fill="black" />
</svg>

Before

Width:  |  Height:  |  Size: 417 B

After

Width:  |  Height:  |  Size: 341 B

View File

@ -1,9 +1,9 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0 9.5V13.5C0 14.8807 3.58172 16 8 16C12.4183 16 16 14.8807 16 13.5V9.5C16 10.8807 12.4183 12 8 12C3.58172 12 0 10.8807 0 9.5Z"
fill="black" fill-opacity="0.6" />
fill="black" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0 4V8C0 9.38071 3.58172 10.5 8 10.5C12.4183 10.5 16 9.38071 16 8V4C16 5.38071 12.4183 6.5 8 6.5C3.58172 6.5 0 5.38071 0 4Z"
fill="black" fill-opacity="0.6" />
<ellipse cx="8" cy="2.5" rx="8" ry="2.5" fill="black" fill-opacity="0.6" />
fill="black" />
<ellipse cx="8" cy="2.5" rx="8" ry="2.5" fill="black" />
</svg>

Before

Width:  |  Height:  |  Size: 643 B

After

Width:  |  Height:  |  Size: 586 B

View File

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.3" fill-rule="evenodd" clip-rule="evenodd"
d="M4 4H2C0.895431 4 0 4.89543 0 6V14C0 15.1046 0.895431 16 2 16H10C11.1046 16 12 15.1046 12 14V12H6C4.89543 12 4 11.1046 4 10V4Z"
fill="black" />
<rect x="4" width="12" height="12" rx="2" fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -1,4 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="black" fill-opacity="0.6">
<path
d="M6.5 3h8v2a2 2 0 0 0 2 2h2v13a1 1 0 0 1 -1 1h-11a1 1 0 0 1 -1 -1v-16a1 1 0 0 1 1 -1ZM15 3v2a1.5 1.5 0 0 0 1.5 1.5h2" />
</svg>

Before

Width:  |  Height:  |  Size: 263 B

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M2 1C0.895431 1 0 1.89543 0 3V6H16V5C16 3.89543 15.1046 3 14 3H8C8 1.89543 7.10457 1 6 1H2ZM0 13V7H16V13C16 14.1046 15.1046 15 14 15H2C0.89543 15 0 14.1046 0 13Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 350 B

View File

@ -1,4 +1,4 @@
<svg width="13" height="16" viewBox="0 0 13 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13 5H0V16H5V10H8V16H13V5Z" fill="black" fill-opacity="0.6" />
<path d="M6.5 0L13 5H0L6.5 0Z" fill="black" fill-opacity="0.6" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M13 5H0V16H5V10H8V16H13V5Z" fill="black" />
<path d="M6.5 0L13 5H0L6.5 0Z" fill="black" />
</svg>

Before

Width:  |  Height:  |  Size: 288 B

After

Width:  |  Height:  |  Size: 250 B

View File

@ -1,5 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0 0H16V4H13.5V6H16V10H9V6H11.5V4H4.5V6H7V10H4.5V12H16V16H0V12H2.5V10H0V6H2.5V4H0V0Z" fill="black"
fill-opacity="0.6" />
d="M0 0H16V4H13.5V6H16V10H9V6H11.5V4H4.5V6H7V10H4.5V12H16V16H0V12H2.5V10H0V6H2.5V4H0V0Z" fill="black" />
</svg>

Before

Width:  |  Height:  |  Size: 292 B

After

Width:  |  Height:  |  Size: 265 B

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M10 0V2H12.5858L8.00197 6.58385C7.61144 6.97437 7.61144 7.60754 8.00197 7.99806C8.39249 8.38859 9.02566 8.38859 9.41618 7.99806L14 3.41425V6H16L16 2L16 0H14H10ZM7 0V2H3C2.44772 2 2 2.44772 2 3V13C2 13.5523 2.44772 14 3 14H13C13.5523 14 14 13.5523 14 13V9H16L16 13C16 14.6569 14.6569 16 13 16H3C1.34315 16 0 14.6569 0 13V3C0 1.34315 1.34315 0 3 0H7Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 537 B

View File

@ -0,0 +1,4 @@
<svg width="13" height="13" viewBox="-0.5 -1.5 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1H4.5L7.5 8.5H11" stroke="black" stroke-width="1.2" />
<path d="M6.5 0.5L11 0.5V2H7.1L6.5 0.5Z" fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 240 B

View File

@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="2 2 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="10.7845" y="3.98547" width="5" height="10" transform="rotate(45 10.7845 3.98547)" fill="black" />
<path
d="M12.1987 2.57126C12.9798 1.79021 14.2461 1.79021 15.0271 2.57126L15.7342 3.27836C16.5153 4.05941 16.5153 5.32574 15.7342 6.10679L15.0271 6.8139L11.4916 3.27836L12.1987 2.57126Z"
fill="black" />
<path d="M3.71341 11.0565L7.24894 14.5921L3.71341 14.5921L3.71341 11.0565Z" fill="black" />
<rect x="2" y="16" width="16" height="2" fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 593 B

View File

@ -1,9 +1,9 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd"
d="M5.64584 8C4.93328 8.79613 4.5 9.84747 4.5 11V15H1C0.447715 15 0 14.5523 0 14V11C0 9.34315 1.34315 8 3 8H5.64584ZM7.06188 1.82086C6.70386 2.46649 6.5 3.20944 6.5 4C6.5 4.79056 6.70386 5.53351 7.06188 6.17914C6.52432 6.68796 5.7986 7 5 7C3.34315 7 2 5.65685 2 4C2 2.34315 3.34315 1 5 1C5.7986 1 6.52432 1.31204 7.06188 1.82086Z"
fill="black" fill-opacity="0.6" />
fill="black" />
<path
d="M6 11C6 9.34315 7.34315 8 9 8H13C14.6569 8 16 9.34315 16 11V14C16 14.5523 15.5523 15 15 15H7C6.44772 15 6 14.5523 6 14V11Z"
fill="black" fill-opacity="0.6" />
<circle cx="11" cy="4" r="3" fill="black" fill-opacity="0.6" />
fill="black" />
<circle cx="11" cy="4" r="3" fill="black" />
</svg>

Before

Width:  |  Height:  |  Size: 804 B

After

Width:  |  Height:  |  Size: 747 B

View File

@ -1,6 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="m10.04 7.34 6 3.85a1 1 0 0 1 0 1.68l-6 3.85a1 1 0 0 1-1.54-.84v-7.7a1 1 0 0 1 1.54-.84Z" fill="#000000"
fill-opacity="0.6" />
<rect opacity="0.3" x="1.5" y="1.5" width="21" height="21" rx="10.5" stroke="#000000" stroke-opacity="0.6"
stroke-width="3" />
<path d="m10.04 7.34 6 3.85a1 1 0 0 1 0 1.68l-6 3.85a1 1 0 0 1-1.54-.84v-7.7a1 1 0 0 1 1.54-.84Z" fill="black" />
<rect opacity="0.3" x="1.5" y="1.5" width="21" height="21" rx="10.5" stroke="black" stroke-width="3" />
</svg>

Before

Width:  |  Height:  |  Size: 388 B

After

Width:  |  Height:  |  Size: 328 B

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M13.7699 12.8559C14.5352 13.2977 15.5137 13.0355 15.9555 12.2703C16.066 12.0789 16.0004 11.8343 15.8091 11.7238L15.4786 11.533L10.3136 8.55098L8.31355 9.70568L13.4394 12.6651L13.444 12.6677L13.7699 12.8559ZM5.76182 10.0243L4.96309 10.4855C5.07499 10.6039 5.17484 10.7358 5.26033 10.8807C5.96158 12.0689 5.43125 13.6806 4.07579 14.4806C2.72033 15.2805 1.05303 14.9658 0.35178 13.7776C-0.349474 12.5894 0.180864 10.9777 1.53632 10.1777C1.55931 10.1641 1.58238 10.1509 1.60553 10.138L1.57328 10.1332L2.86874 9.38524L5.31364 7.97368L2.82654 6.53775L1.53108 5.7898L1.56333 5.78501C1.54017 5.77208 1.5171 5.75884 1.49412 5.74528C0.138658 4.94531 -0.39168 3.33358 0.309574 2.14538C1.01083 0.957183 2.67812 0.642459 4.03358 1.44243C5.38904 2.24239 5.91938 3.85412 5.21812 5.04232C5.13263 5.18718 5.03278 5.31905 4.92089 5.43751L7.31365 6.81897L13.4816 3.25791L13.4862 3.25527L13.8121 3.0671C14.5774 2.62527 15.5559 2.88747 15.9977 3.65274C16.1082 3.84405 16.0427 4.08869 15.8513 4.19915L15.5208 4.38996L11.9577 6.44716L11.9576 6.44708L5.76177 10.0243L5.76182 10.0243ZM3.92632 4.27992C4.11529 3.95974 4.07827 3.21056 3.27118 2.73423C2.46409 2.2579 1.79034 2.5876 1.60138 2.90778C1.41241 3.22796 1.44942 3.97714 2.25651 4.45348C3.0636 4.92981 3.73736 4.6001 3.92632 4.27992ZM3.96853 11.6431C4.15749 11.9632 4.12048 12.7124 3.31339 13.1888C2.5063 13.6651 1.83255 13.3354 1.64358 13.0152C1.45462 12.695 1.49163 11.9458 2.29872 11.4695C3.10581 10.9932 3.77956 11.3229 3.96853 11.6431Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M7.23515 0C6.79777 0 6.41114 0.284249 6.28067 0.701725L5.73703 2.4414C5.22538 2.64991 4.74898 2.92701 4.31925 3.26128L2.5392 2.86189C2.11243 2.76613 1.67295 2.95884 1.45425 3.33763L0.689444 4.66232C0.470751 5.0411 0.523601 5.51806 0.819915 5.83978L2.05539 7.18119C2.01886 7.44891 1.99999 7.72225 1.99999 8C1.99999 8.27774 2.01886 8.55107 2.05539 8.81878L0.819912 10.1602C0.523598 10.4819 0.470747 10.9589 0.68944 11.3376L1.45425 12.6623C1.67294 13.0411 2.11243 13.2338 2.5392 13.1381L4.31922 12.7387C4.74897 13.073 5.2254 13.3501 5.73708 13.5586L6.28072 15.2983C6.41118 15.7157 6.79781 16 7.2352 16H8.76481C9.2022 16 9.58883 15.7157 9.71929 15.2983L10.2629 13.5586C10.7746 13.3501 11.251 13.073 11.6808 12.7387L13.4609 13.1381C13.8876 13.2338 14.3271 13.0411 14.5458 12.6623L15.3106 11.3376C15.5293 10.9589 15.4765 10.4819 15.1801 10.1602L13.9446 8.8187C13.9811 8.55101 14 8.27771 14 8C14 7.72228 13.9811 7.44897 13.9446 7.18128L15.1801 5.83981C15.4764 5.51809 15.5293 5.04113 15.3106 4.66234L14.5458 3.33766C14.3271 2.95887 13.8876 2.76616 13.4608 2.86191L11.6808 3.26131C11.251 2.92702 10.7746 2.6499 10.2629 2.44138L9.71925 0.701725C9.58879 0.28425 9.20216 0 8.76477 0H7.23515ZM7.99999 10C9.10456 10 9.99999 9.10457 9.99999 8C9.99999 6.89543 9.10456 6 7.99999 6C6.89542 6 5.99999 6.89543 5.99999 8C5.99999 9.10457 6.89542 10 7.99999 10Z"
fill="black" fill-opacity="0.6" />
fill="black" />
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,9 @@
<svg width="13" height="13" viewBox="0 1 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="path-1-inside-1_21234_4818" fill="white">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M1.40203 7.95125C1.17219 8.21392 1.35873 8.62502 1.70776 8.62502H4.06248V11.875H8.93748V8.62502H11.2922C11.6412 8.62502 11.8278 8.21392 11.5979 7.95125L6.80571 2.47443C6.64386 2.28945 6.3561 2.28945 6.19425 2.47443L1.40203 7.95125Z" />
</mask>
<path
d="M1.40203 7.95125L0.484824 7.14869L1.40203 7.95125ZM4.06248 8.62502H5.28123V7.40627H4.06248V8.62502ZM4.06248 11.875H2.84373V13.0938H4.06248V11.875ZM8.93748 11.875V13.0938H10.1562V11.875H8.93748ZM8.93748 8.62502V7.40627H7.71873V8.62502H8.93748ZM11.5979 7.95125L12.5151 7.1487L12.5151 7.14869L11.5979 7.95125ZM6.80571 2.47443L5.88851 3.27698L5.88851 3.27698L6.80571 2.47443ZM6.19425 2.47443L5.27704 1.67187L5.27704 1.67187L6.19425 2.47443ZM1.70776 7.40627C2.40583 7.40627 2.77891 8.22845 2.31923 8.7538L0.484824 7.14869C-0.434537 8.19939 0.311629 9.84377 1.70776 9.84377V7.40627ZM4.06248 7.40627H1.70776V9.84377H4.06248V7.40627ZM5.28123 11.875V8.62502H2.84373V11.875H5.28123ZM8.93748 10.6563H4.06248V13.0938H8.93748V10.6563ZM7.71873 8.62502V11.875H10.1562V8.62502H7.71873ZM11.2922 7.40627H8.93748V9.84377H11.2922V7.40627ZM10.6807 8.7538C10.2211 8.22845 10.5941 7.40627 11.2922 7.40627V9.84377C12.6883 9.84377 13.4345 8.19939 12.5151 7.1487L10.6807 8.7538ZM5.88851 3.27698L10.6807 8.7538L12.5151 7.14869L7.72292 1.67187L5.88851 3.27698ZM7.11145 3.27698C6.78774 3.64693 6.21222 3.64693 5.88851 3.27698L7.72292 1.67187C7.0755 0.931964 5.92446 0.931969 5.27704 1.67187L7.11145 3.27698ZM2.31923 8.7538L7.11145 3.27698L5.27704 1.67187L0.484824 7.14869L2.31923 8.7538Z"
fill="black" mask="url(#path-1-inside-1_21234_4818)" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -1,6 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="m9 8L15 8a1 1 0 0 1 1 1L16 15a1 1 0 0 1 -1 1L9 16a1 1 0 0 1 -1 -1L8 9a1 1 0 0 1 1 -1" fill="#000000"
fill-opacity="0.6" />
<rect x="1.5" y="1.5" width="21" height="21" rx="10.5" opacity="0.3" stroke="#000000" stroke-opacity="0.6"
stroke-width="3" />
<path d="m9 8L15 8a1 1 0 0 1 1 1L16 15a1 1 0 0 1 -1 1L9 16a1 1 0 0 1 -1 -1L8 9a1 1 0 0 1 1 -1" fill="black" />
<rect x="1.5" y="1.5" width="21" height="21" rx="10.5" opacity="0.3" stroke="black" stroke-width="3" />
</svg>

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 325 B

View File

@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M16 4C16 2.89543 15.1046 2 14 2H5.8798C5.31919 2 4.78431 2.23529 4.40549 2.64855L0.738826 6.64855C0.0379039 7.41319 0.0379044 8.58681 0.738827 9.35145L4.40549 13.3514C4.78431 13.7647 5.31919 14 5.8798 14H14C15.1046 14 16 13.1046 16 12V4ZM5.50002 9.5C6.32845 9.5 7.00002 8.82843 7.00002 8C7.00002 7.17157 6.32845 6.5 5.50002 6.5C4.67159 6.5 4.00002 7.17157 4.00002 8C4.00002 8.82843 4.67159 9.5 5.50002 9.5Z"
fill="black" fill-opacity="0.6" />
fill="black" />
</svg>

Before

Width:  |  Height:  |  Size: 614 B

After

Width:  |  Height:  |  Size: 595 B

View File

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.45" x="2" width="12" height="16" rx="2" fill="black" />
<rect x="4" y="2" width="5" height="2" fill="black" />
<rect x="4" y="5" width="8" height="2" fill="black" />
<rect x="4" y="8" width="8" height="2" fill="black" />
<rect x="4" y="11" width="6" height="2" fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

View File

@ -1,6 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle opacity="0.2" cx="8" cy="8" r="8" fill="black" fill-opacity="0.6" />
<circle opacity="0.2" cx="8" cy="8" r="8" fill="black" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M8 2C7.44772 2 7 2.44772 7 3V8C7 8.377 7.20862 8.70528 7.51672 8.87568L10.4957 10.5956C10.974 10.8717 11.5856 10.7079 11.8617 10.2296C12.1379 9.75127 11.974 9.13968 11.4957 8.86354L9 7.42265V3C9 2.44772 8.55228 2 8 2Z"
fill="black" fill-opacity="0.6" />
fill="black" />
</svg>

Before

Width:  |  Height:  |  Size: 506 B

After

Width:  |  Height:  |  Size: 470 B

View File

@ -0,0 +1,9 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.72361 0.552786C5.893 0.214002 6.23926 0 6.61803 0H9.38197C9.76074 0 10.107 0.214002 10.2764 0.552786L10.5 1H5.5L5.72361 0.552786Z"
fill="black" />
<path d="M2 2C2 1.44772 2.44772 1 3 1H13C13.5523 1 14 1.44772 14 2V3H2V2Z" fill="black" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M14 4H2V14C2 15.1046 2.89543 16 4 16H12C13.1046 16 14 15.1046 14 14V4ZM4.5 6H5.5V14H4.5V6ZM8.5 6H7.5V14H8.5V6ZM11.5 6H10.5V14H11.5V6Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 596 B

View File

@ -0,0 +1,11 @@
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path clip-path="url(#clip_windows)" d="M2 3.3l9-1.3v9l-9-1.3" fill="black" />
<defs>
<clipPath id="clip_windows">
<rect width="5.8" height="6.3" fill="white" />
<rect x="6.2" width="6" height="6.3" fill="white" />
<rect y="6.7" width="5.8" height="6.3" fill="white" />
<rect x="6.2" y="6.7" width="6" height="6.3" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 529 B

View File

@ -9,22 +9,33 @@ import * as React from 'react'
export interface SvgMaskProps {
/** The URL of the SVG to use as the mask. */
src: string
title?: string
style?: React.CSSProperties
// Allowing `undefined` is fine here as this prop is being transparently passed through to the
// underlying `div`.
// eslint-disable-next-line no-restricted-syntax
className?: string | undefined
onClick?: (event: React.MouseEvent) => void
}
/** Use an SVG as a mask. This lets the SVG use the text color (`currentColor`). */
export default function SvgMask(props: SvgMaskProps) {
const { src } = props
const { src, title, style, className, onClick } = props
const urlSrc = `url(${JSON.stringify(src)})`
return (
<div
title={title}
style={{
...(style ?? {}),
backgroundColor: 'currentcolor',
mask: urlSrc,
// The names come from a third-party API and cannot be changed.
// eslint-disable-next-line @typescript-eslint/naming-convention
WebkitMask: urlSrc,
}}
className={`inline-block w-max h-max ${className ?? ''}`}
onClick={onClick}
>
{/* This is required for this component to have the right size. */}
<img src={src} className="opacity-0" />

View File

@ -44,12 +44,14 @@ import * as authServiceModule from '../authentication/service'
import * as backend from '../dashboard/backend'
import * as hooks from '../hooks'
import * as localBackend from '../dashboard/localBackend'
import * as shortcutsModule from '../dashboard/shortcuts'
import * as authProvider from '../authentication/providers/auth'
import * as backendProvider from '../providers/backend'
import * as loggerProvider from '../providers/logger'
import * as modalProvider from '../providers/modal'
import * as sessionProvider from '../authentication/providers/session'
import * as shortcutsProvider from '../providers/shortcuts'
import ConfirmRegistration from '../authentication/components/confirmRegistration'
import Dashboard from '../dashboard/components/dashboard'
@ -169,6 +171,18 @@ function AppRouter(props: AppProps) {
// @ts-expect-error This is used exclusively for debugging.
window.navigate = navigate
}
const [shortcuts] = React.useState(() => shortcutsModule.ShortcutRegistry.createWithDefaults())
React.useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (shortcuts.handleKeyboardEvent(event)) {
event.preventDefault()
}
}
document.body.addEventListener('keydown', onKeyDown)
return () => {
document.body.removeEventListener('keydown', onKeyDown)
}
}, [shortcuts])
const mainPageUrl = getMainPageUrl()
const authService = React.useMemo(() => {
const authConfig = { navigate, ...props }
@ -222,7 +236,11 @@ function AppRouter(props: AppProps) {
authService={authService}
onAuthenticated={onAuthenticated}
>
<modalProvider.ModalProvider>{routes}</modalProvider.ModalProvider>
<modalProvider.ModalProvider>
<shortcutsProvider.ShortcutsProvider shortcuts={shortcuts}>
{routes}
</shortcutsProvider.ShortcutsProvider>
</modalProvider.ModalProvider>
</authProvider.AuthProvider>
</backendProvider.BackendProvider>
</sessionProvider.SessionProvider>

View File

@ -19,38 +19,31 @@ export enum BackendType {
/** Unique identifier for a user/organization. */
export type UserOrOrganizationId = newtype.Newtype<string, 'UserOrOrganizationId'>
/** Create a {@link UserOrOrganizationId}. */
export const UserOrOrganizationId = newtype.newtypeConstructor<UserOrOrganizationId>()
/** Unique identifier for a directory. */
export type DirectoryId = newtype.Newtype<string, 'DirectoryId'>
/** Create a {@link DirectoryId}. */
export const DirectoryId = newtype.newtypeConstructor<DirectoryId>()
/** Unique identifier for an asset representing the items inside a directory for which the
* request to retrive the items has not yet completed. */
export type LoadingAssetId = newtype.Newtype<string, 'LoadingAssetId'>
/** Create a {@link LoadingAssetId}. */
export const LoadingAssetId = newtype.newtypeConstructor<LoadingAssetId>()
/** Unique identifier for an asset representing the nonexistent children of an empty directory. */
export type EmptyAssetId = newtype.Newtype<string, 'EmptyAssetId'>
/** Create a {@link EmptyAssetId}. */
export const EmptyAssetId = newtype.newtypeConstructor<EmptyAssetId>()
/** Unique identifier for a user's project. */
export type ProjectId = newtype.Newtype<string, 'ProjectId'>
/** Create a {@link ProjectId}. */
export const ProjectId = newtype.newtypeConstructor<ProjectId>()
/** Unique identifier for an uploaded file. */
export type FileId = newtype.Newtype<string, 'FileId'>
/** Create a {@link FileId}. */
export const FileId = newtype.newtypeConstructor<FileId>()
/** Unique identifier for a secret environment variable. */
export type SecretId = newtype.Newtype<string, 'SecretId'>
/** Create a {@link SecretId}. */
export const SecretId = newtype.newtypeConstructor<SecretId>()
/** Unique identifier for an arbitrary asset. */
@ -58,32 +51,26 @@ export type AssetId = IdType[keyof IdType]
/** Unique identifier for a file tag or project tag. */
export type TagId = newtype.Newtype<string, 'TagId'>
/** Create a {@link TagId}. */
export const TagId = newtype.newtypeConstructor<TagId>()
/** A URL. */
export type Address = newtype.Newtype<string, 'Address'>
/** Create an {@link Address}. */
export const Address = newtype.newtypeConstructor<Address>()
/** An email address. */
export type EmailAddress = newtype.Newtype<string, 'EmailAddress'>
/** Create an {@link EmailAddress}. */
export const EmailAddress = newtype.newtypeConstructor<EmailAddress>()
/** An AWS S3 file path. */
export type S3FilePath = newtype.Newtype<string, 'S3FilePath'>
/** Create an {@link S3FilePath}. */
export const S3FilePath = newtype.newtypeConstructor<S3FilePath>()
/** An AWS machine configuration. */
export type Ami = newtype.Newtype<string, 'Ami'>
/** Create an {@link Ami}. */
export const Ami = newtype.newtypeConstructor<Ami>()
/** An AWS user ID. */
export type Subject = newtype.Newtype<string, 'Subject'>
/** Create a {@link Subject}. */
export const Subject = newtype.newtypeConstructor<Subject>()
/* eslint-enable @typescript-eslint/no-redeclare */
@ -585,13 +572,17 @@ export function detectVersionLifecycle(version: string) {
}
}
// =======================
// === rootDirectoryId ===
// =======================
// =====================
// === compareAssets ===
// =====================
/** Return the id of the root directory for a user or organization. */
export function rootDirectoryId(userOrOrganizationId: UserOrOrganizationId) {
return DirectoryId(userOrOrganizationId.replace(/^organization-/, `${AssetType.directory}-`))
/** Return a positive number if `a > b`, a negative number if `a < b`, and zero if `a === b`. */
export function compareAssets(a: AnyAsset, b: AnyAsset) {
const relativeTypeOrder = ASSET_TYPE_ORDER[a.type] - ASSET_TYPE_ORDER[b.type]
if (relativeTypeOrder !== 0) {
return relativeTypeOrder
}
return a.title > b.title ? 1 : a.title < b.title ? COMPARE_LESS_THAN : 0
}
// ==================
@ -673,6 +664,8 @@ export abstract class Backend {
}
}
}
/** Return the root directory id for the given user. */
abstract rootDirectoryId(user: UserOrOrganization | null): DirectoryId
/** Return a list of all users in the same organization. */
abstract listUsers(): Promise<SimpleUser[]>
/** Set the username of the current user. */

View File

@ -21,6 +21,7 @@ import * as assetsTable from './components/assetsTable'
import AssetNameColumn from './components/assetNameColumn'
import ManagePermissionsModal from './components/managePermissionsModal'
import PermissionDisplay from './components/permissionDisplay'
import SvgMask from '../authentication/components/svgMask'
// =============
// === Types ===
@ -240,33 +241,33 @@ export const COLUMN_HEADING: Record<
> = {
[Column.name]: () => <>{COLUMN_NAME[Column.name]}</>,
[Column.modified]: () => (
<div className="flex gap-2">
<img src={TimeIcon} /> {COLUMN_NAME[Column.modified]}
<div className="flex items-center gap-2">
<SvgMask src={TimeIcon} /> {COLUMN_NAME[Column.modified]}
</div>
),
[Column.sharedWith]: () => (
<div className="flex gap-2">
<img src={PeopleIcon} /> {COLUMN_NAME[Column.sharedWith]}
<div className="flex items-center gap-2">
<SvgMask src={PeopleIcon} /> {COLUMN_NAME[Column.sharedWith]}
</div>
),
[Column.tags]: () => (
<div className="flex gap-2">
<img src={TagIcon} /> {COLUMN_NAME[Column.tags]}
<div className="flex items-center gap-2">
<SvgMask src={TagIcon} /> {COLUMN_NAME[Column.tags]}
</div>
),
[Column.accessedByProjects]: () => (
<div className="flex gap-2">
<img src={AccessedByProjectsIcon} /> {COLUMN_NAME[Column.accessedByProjects]}
<div className="flex items-center gap-2">
<SvgMask src={AccessedByProjectsIcon} /> {COLUMN_NAME[Column.accessedByProjects]}
</div>
),
[Column.accessedData]: () => (
<div className="flex gap-2">
<img src={AccessedDataIcon} /> {COLUMN_NAME[Column.accessedData]}
<div className="flex items-center gap-2">
<SvgMask src={AccessedDataIcon} /> {COLUMN_NAME[Column.accessedData]}
</div>
),
[Column.docs]: () => (
<div className="flex gap-2">
<img src={DocsIcon} /> {COLUMN_NAME[Column.docs]}
<div className="flex items-center gap-2">
<SvgMask src={DocsIcon} /> {COLUMN_NAME[Column.docs]}
</div>
),
}

View File

@ -1,59 +1,247 @@
/** @file The context menu for an arbitrary {@link backendModule.Asset}. */
import * as React from 'react'
import * as toast from 'react-toastify'
import * as assetEventModule from '../events/assetEvent'
import * as backendModule from '../backend'
import * as hooks from '../../hooks'
import * as http from '../../http'
import * as remoteBackendModule from '../remoteBackend'
import * as shortcuts from '../shortcuts'
import * as authProvider from '../../authentication/providers/auth'
import * as backendProvider from '../../providers/backend'
import * as loggerProvider from '../../providers/logger'
import * as modalProvider from '../../providers/modal'
import * as assetsTable from './assetsTable'
import * as tableRow from './tableRow'
import DirectoryContextMenu from './directoryContextMenu'
import FileContextMenu from './fileContextMenu'
import ProjectContextMenu from './projectContextMenu'
import SecretContextMenu from './secretContextMenu'
import ConfirmDeleteModal from './confirmDeleteModal'
import ContextMenu from './contextMenu'
import ContextMenuEntry from './contextMenuEntry'
import ContextMenuSeparator from './contextMenuSeparator'
import ContextMenus from './contextMenus'
import GlobalContextMenu from './globalContextMenu'
import ManagePermissionsModal from './managePermissionsModal'
// ========================
// === AssetContextMenu ===
// ========================
/** Props for a {@link AssetContextMenu}. */
export interface AssetContextMenuProps<T extends backendModule.AnyAsset> {
innerProps: tableRow.TableRowInnerProps<T, assetsTable.AssetRowState, T['id']>
event: React.MouseEvent
dispatchAssetEvent: (assetEvent: assetEventModule.AssetEvent) => void
hidden?: boolean
innerProps: tableRow.TableRowInnerProps<
T,
assetsTable.AssetsTableState,
assetsTable.AssetRowState,
T['id']
>
event: Pick<React.MouseEvent, 'pageX' | 'pageY'>
eventTarget: HTMLElement | null
doDelete: () => Promise<void>
}
/** The context menu for an arbitrary {@link backendModule.Asset}. */
export default function AssetContextMenu(props: AssetContextMenuProps<backendModule.AnyAsset>) {
switch (props.innerProps.item.type) {
// The type assertions are SAFE, as the `item.type` matches.
/* eslint-disable no-restricted-syntax */
case backendModule.AssetType.directory: {
return (
<DirectoryContextMenu
{...(props as AssetContextMenuProps<backendModule.DirectoryAsset>)}
const {
hidden = false,
innerProps: {
key,
item,
setItem,
state: { dispatchAssetEvent, dispatchAssetListEvent },
setRowState,
},
event,
eventTarget,
doDelete,
} = props
const logger = loggerProvider.useLogger()
const { organization, accessToken } = authProvider.useNonPartialUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { backend } = backendProvider.useBackend()
const toastAndLog = hooks.useToastAndLog()
const self = item.permissions?.find(
permission => permission.user.user_email === organization?.email
)
const managesThisAsset =
self?.permission === backendModule.PermissionAction.own ||
self?.permission === backendModule.PermissionAction.admin
return (
<ContextMenus hidden={hidden} key={props.innerProps.item.id} event={event}>
<ContextMenu hidden={hidden}>
{item.type === backendModule.AssetType.project && (
<ContextMenuEntry
hidden={hidden}
action={shortcuts.KeyboardAction.open}
doAction={() => {
unsetModal()
dispatchAssetEvent({
type: assetEventModule.AssetEventType.openProject,
id: item.id,
})
}}
/>
)}
{item.type === backendModule.AssetType.project &&
backend.type === backendModule.BackendType.local && (
<ContextMenuEntry
hidden={hidden}
action={shortcuts.KeyboardAction.uploadToCloud}
doAction={async () => {
unsetModal()
if (accessToken == null) {
toastAndLog('Cannot upload to cloud in offline mode')
} else {
try {
const headers = new Headers([
['Authorization', `Bearer ${accessToken}`],
])
const client = new http.Client(headers)
const remoteBackend = new remoteBackendModule.RemoteBackend(
client,
logger
)
const projectResponse = await fetch(
`./api/project-manager/projects/${item.id}/enso-project`
)
// This DOES NOT update the cloud assets list when it
// completes, as the current backend is not the remote
// (cloud) backend. The user may change to the cloud backend
// while this request is in progress, however this is
// uncommon enough that it is not worth the added complexity.
await remoteBackend.uploadFile(
{
fileName: `${item.title}.enso-project`,
fileId: null,
parentDirectoryId: null,
},
await projectResponse.blob()
)
toast.toast.success(
'Successfully uploaded local project to cloud!'
)
} catch (error) {
toastAndLog(
'Could not upload local project to cloud',
error
)
}
}
}}
/>
)}
<ContextMenuEntry
hidden={hidden}
disabled={
item.type !== backendModule.AssetType.project &&
item.type !== backendModule.AssetType.directory
}
action={shortcuts.KeyboardAction.rename}
doAction={() => {
setRowState(oldRowState => ({
...oldRowState,
isEditingName: true,
}))
unsetModal()
}}
/>
)
}
case backendModule.AssetType.project: {
return (
<ProjectContextMenu
{...(props as AssetContextMenuProps<backendModule.ProjectAsset>)}
<ContextMenuEntry
hidden={hidden}
disabled
action={shortcuts.KeyboardAction.snapshot}
doAction={() => {
// No backend support yet.
}}
/>
)
}
case backendModule.AssetType.file: {
return (
<FileContextMenu {...(props as AssetContextMenuProps<backendModule.FileAsset>)} />
)
}
case backendModule.AssetType.secret: {
return (
<SecretContextMenu
{...(props as AssetContextMenuProps<backendModule.SecretAsset>)}
<ContextMenuEntry
hidden={hidden}
action={shortcuts.KeyboardAction.moveToTrash}
doAction={() => {
setModal(
<ConfirmDeleteModal
description={`the ${item.type} '${item.title}'`}
doDelete={doDelete}
/>
)
}}
/>
)
}
case backendModule.AssetType.specialLoading:
case backendModule.AssetType.specialEmpty: {
return null
}
/* eslint-enable no-restricted-syntax */
}
<ContextMenuSeparator hidden={hidden} />
{managesThisAsset && (
<ContextMenuEntry
hidden={hidden}
action={shortcuts.KeyboardAction.share}
doAction={() => {
setModal(
<ManagePermissionsModal
item={item}
setItem={setItem}
self={self}
eventTarget={eventTarget}
doRemoveSelf={() => {
dispatchAssetEvent({
type: assetEventModule.AssetEventType.removeSelf,
id: item.id,
})
}}
/>
)
}}
/>
)}
<ContextMenuEntry
hidden={hidden}
disabled
action={shortcuts.KeyboardAction.label}
doAction={() => {
// No backend support yet.
}}
/>
<ContextMenuSeparator hidden={hidden} />
<ContextMenuEntry
hidden={hidden}
disabled
action={shortcuts.KeyboardAction.duplicate}
doAction={() => {
// No backend support yet.
}}
/>
<ContextMenuEntry
hidden={hidden}
disabled
action={shortcuts.KeyboardAction.copy}
doAction={() => {
// No backend support yet.
}}
/>
<ContextMenuEntry
hidden={hidden}
disabled
action={shortcuts.KeyboardAction.cut}
doAction={() => {
// No backend support yet.
}}
/>
<ContextMenuEntry
hidden={hidden}
disabled
action={shortcuts.KeyboardAction.download}
doAction={() => {
// No backend support yet.
}}
/>
</ContextMenu>
{item.type === backendModule.AssetType.directory ? (
<GlobalContextMenu
hidden={hidden}
// This is SAFE, as this only exists when the item is a directory.
// eslint-disable-next-line no-restricted-syntax
directoryKey={key as backendModule.DirectoryId}
directoryId={item.id}
dispatchAssetListEvent={dispatchAssetListEvent}
/>
) : null}
</ContextMenus>
)
}

View File

@ -19,6 +19,7 @@ import * as indent from '../indent'
import * as modalProvider from '../../providers/modal'
import * as permissions from '../permissions'
import * as presenceModule from '../presence'
import * as shortcuts from '../shortcuts'
import * as string from '../../string'
import * as uniqueString from '../../uniqueString'
@ -29,6 +30,8 @@ import Button from './button'
import ConfirmDeleteModal from './confirmDeleteModal'
import ContextMenu from './contextMenu'
import ContextMenuEntry from './contextMenuEntry'
import ContextMenus from './contextMenus'
import GlobalContextMenu from './globalContextMenu'
import Table from './table'
// =================
@ -39,6 +42,8 @@ import Table from './table'
const EXTRA_COLUMNS_KEY =
common.PRODUCT_NAME.toLowerCase() + '-dashboard-directory-list-extra-columns'
/** The value returned when {@link Array.findIndex} fails. */
const NOT_FOUND = -1
/** The user-facing name of this asset type. */
const ASSET_TYPE_NAME = 'item'
/** The user-facing plural name of this asset type. */
@ -61,18 +66,32 @@ const DIRECTORY_NAME_REGEX = /^New_Folder_(?<directoryIndex>\d+)$/
/** The default prefix of an automatically generated directory. */
const DIRECTORY_NAME_DEFAULT_PREFIX = 'New_Folder_'
// =============
// === Types ===
// =============
// =====================
// === splicedAssets ===
// =====================
/** An interface containing only the depth of an item (an integer). */
interface Depth {
depth: number
/** Insert assets into the assets list at the correct position, removing a "This folder is empty"
* placeholder asset, if one exists. */
function splicedAssets(
oldAssets: backendModule.AnyAsset[],
assetsToInsert: backendModule.AnyAsset[],
parentKey: backendModule.DirectoryId | null,
predicate: (asset: backendModule.AnyAsset) => boolean
) {
const newAssets = Array.from(oldAssets)
const insertIndex = oldAssets.findIndex(predicate)
const firstChild = oldAssets[insertIndex]
const numberOfItemsToRemove = firstChild?.type === backendModule.AssetType.specialEmpty ? 1 : 0
newAssets.splice(
insertIndex === NOT_FOUND
? oldAssets.findIndex(asset => asset.id === parentKey) + 1
: insertIndex,
numberOfItemsToRemove,
...assetsToInsert
)
return newAssets
}
/** The type of items in an {@link AssetsTable}. */
export type AssetTableItem<T extends backendModule.AnyAsset = backendModule.AnyAsset> = Depth & T
// ================
// === AssetRow ===
// ================
@ -88,22 +107,29 @@ function AssetRow(props: AssetRowProps<backendModule.AnyAsset>) {
keyProp: key,
item: rawItem,
initialRowState,
columns,
selected,
state: { assetEvents, dispatchAssetEvent, dispatchAssetListEvent, getDepth },
allowContextMenu,
onContextMenu,
state,
columns,
} = props
const { assetEvents, dispatchAssetListEvent, getDepth } = state
const { backend } = backendProvider.useBackend()
const { setModal } = modalProvider.useSetModal()
const { user } = authProvider.useNonPartialUserSession()
const toastAndLog = hooks.useToastAndLog()
const [item, setItem] = React.useState(rawItem)
const [presence, setPresence] = React.useState(presenceModule.Presence.present)
const [rowState, setRowState] = React.useState<AssetRowState>(() => ({
...initialRowState,
setPresence,
}))
React.useEffect(() => {
setItem(rawItem)
}, [rawItem])
const doDelete = async () => {
const doDelete = React.useCallback(async () => {
setPresence(presenceModule.Presence.deleting)
try {
if (
@ -131,15 +157,15 @@ function AssetRow(props: AssetRowProps<backendModule.AnyAsset>) {
setPresence(presenceModule.Presence.present)
toastAndLog('Unable to delete project', error)
}
}
}, [backend, dispatchAssetListEvent, item, key, toastAndLog])
hooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
// These events are handled in the specific NameColumn files.
case assetEventModule.AssetEventType.createProject:
case assetEventModule.AssetEventType.createDirectory:
case assetEventModule.AssetEventType.newProject:
case assetEventModule.AssetEventType.newFolder:
case assetEventModule.AssetEventType.uploadFiles:
case assetEventModule.AssetEventType.createSecret:
case assetEventModule.AssetEventType.newSecret:
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects: {
break
@ -191,25 +217,52 @@ function AssetRow(props: AssetRowProps<backendModule.AnyAsset>) {
return presence === presenceModule.Presence.deleting ? (
<></>
) : (
<TableRow
className={presenceModule.CLASS_NAME[presence]}
{...props}
onContextMenu={(innerProps, event) => {
event.preventDefault()
event.stopPropagation()
setModal(
<AssetContextMenu
innerProps={innerProps}
event={event}
dispatchAssetEvent={dispatchAssetEvent}
doDelete={doDelete}
/>
)
}}
item={item}
setItem={setItem}
initialRowState={{ ...initialRowState, setPresence }}
/>
<>
<TableRow
className={presenceModule.CLASS_NAME[presence]}
{...props}
onContextMenu={(innerProps, event) => {
if (allowContextMenu) {
event.preventDefault()
event.stopPropagation()
onContextMenu?.(innerProps, event)
setModal(
<AssetContextMenu
innerProps={innerProps}
event={event}
eventTarget={event.currentTarget}
doDelete={doDelete}
/>
)
} else {
onContextMenu?.(innerProps, event)
}
}}
item={item}
setItem={setItem}
initialRowState={rowState}
setRowState={setRowState}
/>
{selected && allowContextMenu && (
// This is a copy of the context menu, since the context menu registers keyboard
// shortcut handlers. This is a bit of a hack, however it is preferable to duplicating
// the entire context menu (once for the keyboard actions, once for the JSX).
<AssetContextMenu
hidden
innerProps={{
key,
item,
setItem,
state,
rowState,
setRowState,
}}
event={{ pageX: 0, pageY: 0 }}
eventTarget={null}
doDelete={doDelete}
/>
)}
</>
)
}
case backendModule.AssetType.specialLoading: {
@ -261,8 +314,9 @@ export interface AssetsTableState {
dispatchAssetListEvent: (event: assetListEventModule.AssetListEvent) => void
getDepth: (id: backendModule.AssetId) => number
doToggleDirectoryExpansion: (
directory: backendModule.DirectoryAsset,
key: backendModule.AssetId
directoryId: backendModule.DirectoryId,
key: backendModule.DirectoryId,
title?: string
) => void
/** Called when the project is opened via the {@link ProjectActionButton}. */
doOpenManually: (projectId: backendModule.ProjectId) => void
@ -320,6 +374,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const [extraColumns, setExtraColumns] = React.useState(
() => new Set<columnModule.ExtraColumn>()
)
const [selectedKeys, setSelectedKeys] = React.useState(() => new Set<backendModule.AssetId>())
// Items in the root directory have a depth of 0.
const itemDepthsRef = React.useRef(new Map<backendModule.AssetId, number>())
@ -337,6 +392,11 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}, [])
React.useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
itemDepthsRef.current.set(backend.rootDirectoryId(organization), -1)
}, [backend, organization])
React.useEffect(() => {
if (initialized) {
localStorage.setItem(EXTRA_COLUMNS_KEY, JSON.stringify(Array.from(extraColumns)))
@ -366,15 +426,29 @@ export default function AssetsTable(props: AssetsTableProps) {
return depth != null ? [[key, depth]] : []
})
)
}, [items])
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
itemDepthsRef.current.set(backend.rootDirectoryId(organization), -1)
}, [items, backend, organization])
const expandedDirectoriesRef = React.useRef(new Set<backendModule.DirectoryId>())
const directoryListAbortControllersRef = React.useRef(
new Map<backendModule.DirectoryId, AbortController>()
)
const doToggleDirectoryExpansion = React.useCallback(
(directory: backendModule.DirectoryAsset, key: backendModule.AssetId) => {
(
directoryId: backendModule.DirectoryId,
key: backendModule.DirectoryId,
title?: string
) => {
const set = expandedDirectoriesRef.current
if (set.has(directory.id)) {
set.delete(directory.id)
const foldersToCollapse = new Set([directory.id])
if (set.has(directoryId)) {
const abortController = directoryListAbortControllersRef.current.get(directoryId)
if (abortController != null) {
abortController.abort()
directoryListAbortControllersRef.current.delete(directoryId)
}
set.delete(directoryId)
const foldersToCollapse = new Set([directoryId])
setItems(
items.filter(item => {
const shouldKeep = !foldersToCollapse.has(item.parentId)
@ -386,7 +460,7 @@ export default function AssetsTable(props: AssetsTableProps) {
)
} else {
const childDepth = getDepth(key) + 1
set.add(directory.id)
set.add(directoryId)
const loadingAssetId = backendModule.LoadingAssetId(uniqueString.uniqueString())
itemDepthsRef.current.set(loadingAssetId, childDepth)
setItems(
@ -398,43 +472,71 @@ export default function AssetsTable(props: AssetsTableProps) {
title: '',
id: loadingAssetId,
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: directory.id,
parentId: directoryId,
permissions: [],
projectState: null,
},
],
item => item.id === directory.id
item => item.id === key
)
)
void (async () => {
const abortController = new AbortController()
directoryListAbortControllersRef.current.set(directoryId, abortController)
const returnedItems = await backend.listDirectory(
{ parentId: directory.id },
directory.title
{ parentId: directoryId },
title ?? null
)
const childItems: backendModule.AnyAsset[] =
returnedItems.length !== 0
? returnedItems
: [
{
type: backendModule.AssetType.specialEmpty,
title: '',
id: backendModule.EmptyAssetId(uniqueString.uniqueString()),
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: directory.id,
permissions: [],
projectState: null,
},
]
for (const childItem of childItems) {
itemDepthsRef.current.set(childItem.id, childDepth)
if (!abortController.signal.aborted) {
const childItems: backendModule.AnyAsset[] =
returnedItems.length !== 0
? returnedItems
: [
{
type: backendModule.AssetType.specialEmpty,
title: '',
id: backendModule.EmptyAssetId(
uniqueString.uniqueString()
),
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: directoryId,
permissions: [],
projectState: null,
},
]
for (const childItem of childItems) {
itemDepthsRef.current.set(childItem.id, childDepth)
}
setItems(oldItems => {
let firstChildIndex = oldItems.findIndex(
item => item.parentId === directoryId
)
if (firstChildIndex === NOT_FOUND) {
firstChildIndex = oldItems.findIndex(item => item.id === key) + 1
}
let numberOfChildren = 1
while (
oldItems[firstChildIndex + numberOfChildren]?.parentId ===
directoryId
) {
numberOfChildren += 1
}
const oldChildren = oldItems.slice(
firstChildIndex,
// Subtract one extra, to exclude the placeholder "loading" asset.
firstChildIndex + numberOfChildren - 1
)
const newChildren =
oldChildren.length === 0
? childItems
: [...oldChildren, ...returnedItems].sort(
backendModule.compareAssets
)
const newItems = Array.from(oldItems)
newItems.splice(firstChildIndex, numberOfChildren, ...newChildren)
return newItems
})
}
setItems(oldItems =>
array.splicedReplacing(
oldItems,
childItems,
item => item.id === loadingAssetId
)
)
})()
}
},
@ -442,21 +544,25 @@ export default function AssetsTable(props: AssetsTableProps) {
)
const getNewProjectName = React.useCallback(
(templateId?: string | null) => {
(templateId: string | null, parentId: backendModule.DirectoryId | null) => {
const prefix = `${templateId ?? 'New_Project'}_`
const projectNameTemplate = new RegExp(`^${prefix}(?<projectIndex>\\d+)$`)
const actualParentId = parentId ?? backend.rootDirectoryId(organization)
const projectIndices = items
.filter(item => item.parentId === actualParentId)
.map(project => projectNameTemplate.exec(project.title)?.groups?.projectIndex)
.map(maybeIndex => (maybeIndex != null ? parseInt(maybeIndex, 10) : 0))
return `${prefix}${Math.max(0, ...projectIndices) + 1}`
},
[items]
[items, backend, organization]
)
hooks.useEventHandler(assetListEvents, event => {
switch (event.type) {
case assetListEventModule.AssetListEventType.createDirectory: {
case assetListEventModule.AssetListEventType.newFolder: {
const parentId = event.parentId ?? backend.rootDirectoryId(organization)
const directoryIndices = items
.filter(item => item.parentId === parentId)
.map(item => DIRECTORY_NAME_REGEX.exec(item.title))
.map(match => match?.groups?.directoryIndex)
.map(maybeIndex => (maybeIndex != null ? parseInt(maybeIndex, 10) : 0))
@ -467,51 +573,75 @@ export default function AssetsTable(props: AssetsTableProps) {
id: backendModule.DirectoryId(uniqueString.uniqueString()),
title,
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: event.parentId ?? backendModule.DirectoryId(''),
parentId: event.parentId ?? backend.rootDirectoryId(organization),
permissions: permissions.tryGetSingletonOwnerPermission(organization, user),
projectState: null,
type: backendModule.AssetType.directory,
}
const typeOrder = backendModule.ASSET_TYPE_ORDER[placeholderItem.type]
if (
event.parentId != null &&
event.parentKey != null &&
!expandedDirectoriesRef.current.has(event.parentId)
) {
doToggleDirectoryExpansion(event.parentId, event.parentKey)
}
setItems(oldItems =>
array.splicedBefore(
splicedAssets(
oldItems,
[placeholderItem],
event.parentKey,
item =>
item.parentId === event.parentId &&
item.parentId === placeholderItem.parentId &&
backendModule.ASSET_TYPE_ORDER[item.type] >= typeOrder
)
)
itemDepthsRef.current.set(
placeholderItem.id,
(itemDepthsRef.current.get(placeholderItem.parentId) ?? 0) + 1
)
dispatchAssetEvent({
type: assetEventModule.AssetEventType.createDirectory,
type: assetEventModule.AssetEventType.newFolder,
placeholderId: placeholderItem.id,
})
break
}
case assetListEventModule.AssetListEventType.createProject: {
const projectName = getNewProjectName(event.templateId)
case assetListEventModule.AssetListEventType.newProject: {
const projectName = getNewProjectName(event.templateId, event.parentId)
const dummyId = backendModule.ProjectId(uniqueString.uniqueString())
const placeholderItem: backendModule.ProjectAsset = {
id: dummyId,
title: projectName,
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: event.parentId ?? backendModule.DirectoryId(''),
parentId: event.parentId ?? backend.rootDirectoryId(organization),
permissions: permissions.tryGetSingletonOwnerPermission(organization, user),
projectState: { type: backendModule.ProjectState.placeholder },
type: backendModule.AssetType.project,
}
const typeOrder = backendModule.ASSET_TYPE_ORDER[placeholderItem.type]
if (
event.parentId != null &&
event.parentKey != null &&
!expandedDirectoriesRef.current.has(event.parentId)
) {
doToggleDirectoryExpansion(event.parentId, event.parentKey)
}
setItems(oldItems =>
array.splicedBefore(
splicedAssets(
oldItems,
[placeholderItem],
event.parentKey,
item =>
item.parentId === event.parentId &&
item.parentId === placeholderItem.parentId &&
backendModule.ASSET_TYPE_ORDER[item.type] >= typeOrder
)
)
itemDepthsRef.current.set(
placeholderItem.id,
(itemDepthsRef.current.get(placeholderItem.parentId) ?? 0) + 1
)
dispatchAssetEvent({
type: assetEventModule.AssetEventType.createProject,
type: assetEventModule.AssetEventType.newProject,
placeholderId: dummyId,
templateId: event.templateId,
onSpinnerStateChange: event.onSpinnerStateChange,
@ -520,13 +650,14 @@ export default function AssetsTable(props: AssetsTableProps) {
}
case assetListEventModule.AssetListEventType.uploadFiles: {
const reversedFiles = Array.from(event.files).reverse()
const parentId = event.parentId ?? backend.rootDirectoryId(organization)
const placeholderFiles: backendModule.FileAsset[] = reversedFiles
.filter(backendModule.fileIsNotProject)
.map(file => ({
type: backendModule.AssetType.file,
id: backendModule.FileId(uniqueString.uniqueString()),
title: file.name,
parentId: event.parentId ?? backendModule.DirectoryId(''),
parentId,
permissions: permissions.tryGetSingletonOwnerPermission(organization, user),
modifiedAt: dateTime.toRfc3339(new Date()),
projectState: null,
@ -537,7 +668,7 @@ export default function AssetsTable(props: AssetsTableProps) {
type: backendModule.AssetType.project,
id: backendModule.ProjectId(uniqueString.uniqueString()),
title: file.name,
parentId: event.parentId ?? backendModule.DirectoryId(''),
parentId,
permissions: permissions.tryGetSingletonOwnerPermission(organization, user),
modifiedAt: dateTime.toRfc3339(new Date()),
projectState: {
@ -547,22 +678,39 @@ export default function AssetsTable(props: AssetsTableProps) {
const fileTypeOrder = backendModule.ASSET_TYPE_ORDER[backendModule.AssetType.file]
const projectTypeOrder =
backendModule.ASSET_TYPE_ORDER[backendModule.AssetType.project]
setItems(oldItems => {
const ret = array.spliceBefore(
array.splicedBefore(
if (
event.parentId != null &&
event.parentKey != null &&
!expandedDirectoriesRef.current.has(event.parentId)
) {
doToggleDirectoryExpansion(event.parentId, event.parentKey)
}
setItems(oldItems =>
array.spliceBefore(
splicedAssets(
oldItems,
placeholderFiles,
event.parentKey,
item =>
item.parentId === event.parentId &&
item.parentId === parentId &&
backendModule.ASSET_TYPE_ORDER[item.type] >= fileTypeOrder
),
placeholderProjects,
item =>
item.parentId === event.parentId &&
item.parentId === parentId &&
backendModule.ASSET_TYPE_ORDER[item.type] >= projectTypeOrder
)
return ret
})
)
const depth =
event.parentId != null
? (itemDepthsRef.current.get(event.parentId) ?? 0) + 1
: 0
for (const file of placeholderFiles) {
itemDepthsRef.current.set(file.id, depth)
}
for (const project of placeholderProjects) {
itemDepthsRef.current.set(project.id, depth)
}
dispatchAssetEvent({
type: assetEventModule.AssetEventType.uploadFiles,
files: new Map(
@ -577,28 +725,40 @@ export default function AssetsTable(props: AssetsTableProps) {
})
break
}
case assetListEventModule.AssetListEventType.createSecret: {
case assetListEventModule.AssetListEventType.newSecret: {
const placeholderItem: backendModule.SecretAsset = {
id: backendModule.SecretId(uniqueString.uniqueString()),
title: event.name,
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: event.parentId ?? backendModule.DirectoryId(''),
parentId: event.parentId ?? backend.rootDirectoryId(organization),
permissions: permissions.tryGetSingletonOwnerPermission(organization, user),
projectState: null,
type: backendModule.AssetType.secret,
}
const typeOrder = backendModule.ASSET_TYPE_ORDER[placeholderItem.type]
if (
event.parentId != null &&
event.parentKey != null &&
!expandedDirectoriesRef.current.has(event.parentId)
) {
doToggleDirectoryExpansion(event.parentId, event.parentKey)
}
setItems(oldItems =>
array.splicedBefore(
splicedAssets(
oldItems,
[placeholderItem],
event.parentKey,
item =>
item.parentId === event.parentId &&
item.parentId === placeholderItem.parentId &&
backendModule.ASSET_TYPE_ORDER[item.type] >= typeOrder
)
)
itemDepthsRef.current.set(
placeholderItem.id,
(itemDepthsRef.current.get(placeholderItem.parentId) ?? 0) + 1
)
dispatchAssetEvent({
type: assetEventModule.AssetEventType.createSecret,
type: assetEventModule.AssetEventType.newSecret,
placeholderId: placeholderItem.id,
value: event.value,
})
@ -606,6 +766,7 @@ export default function AssetsTable(props: AssetsTableProps) {
}
case assetListEventModule.AssetListEventType.delete: {
setItems(oldItems => oldItems.filter(item => item.id !== event.id))
itemDepthsRef.current.delete(event.id)
break
}
}
@ -656,9 +817,9 @@ export default function AssetsTable(props: AssetsTableProps) {
return (
<div className="flex-1 overflow-auto">
<div className="flex flex-col w-min min-w-full">
<div className="flex flex-col w-min min-w-full h-full">
<div className="h-0">
<div className="block sticky right-0 px-2 py-1 ml-auto mt-3 w-29 z-10">
<div className="block sticky right-0 px-2 py-1 ml-auto mt-3.5 w-29 z-10">
<div className="inline-flex gap-3">
{columnModule.EXTRA_COLUMNS.map(column => (
<Button
@ -685,12 +846,15 @@ export default function AssetsTable(props: AssetsTableProps) {
AssetRowState,
backendModule.AssetId
>
footer={<tfoot className="h-full"></tfoot>}
rowComponent={AssetRow}
items={visibleItems}
isLoading={isLoading}
state={state}
initialRowState={INITIAL_ROW_STATE}
getKey={backendModule.getAssetId}
selectedKeys={selectedKeys}
setSelectedKeys={setSelectedKeys}
placeholder={PLACEHOLDER}
columns={columnModule.getColumnList(backend.type, extraColumns).map(column => ({
id: column,
@ -698,21 +862,21 @@ export default function AssetsTable(props: AssetsTableProps) {
heading: columnModule.COLUMN_HEADING[column],
render: columnModule.COLUMN_RENDERER[column],
}))}
onContextMenu={(selectedKeys, event, setSelectedKeys) => {
onContextMenu={(innerSelectedKeys, event, innerSetSelectedKeys) => {
event.preventDefault()
event.stopPropagation()
const pluralized = pluralize(selectedKeys.size)
const pluralized = pluralize(innerSelectedKeys.size)
// This is not a React component even though it contains JSX.
// eslint-disable-next-line no-restricted-syntax
const doDeleteAll = () => {
setModal(
<ConfirmDeleteModal
description={`${selectedKeys.size} selected ${pluralized}`}
description={`${innerSelectedKeys.size} selected ${pluralized}`}
doDelete={() => {
setSelectedKeys(new Set())
innerSetSelectedKeys(new Set())
dispatchAssetEvent({
type: assetEventModule.AssetEventType.deleteMultiple,
ids: selectedKeys,
ids: innerSelectedKeys,
})
return Promise.resolve()
}}
@ -720,13 +884,21 @@ export default function AssetsTable(props: AssetsTableProps) {
)
}
setModal(
<ContextMenu key={uniqueString.uniqueString()} event={event}>
<ContextMenuEntry onClick={doDeleteAll}>
<span className="text-red-700">
Delete {selectedKeys.size} {pluralized}
</span>
</ContextMenuEntry>
</ContextMenu>
<ContextMenus key={uniqueString.uniqueString()} event={event}>
{innerSelectedKeys.size !== 0 && (
<ContextMenu>
<ContextMenuEntry
action={shortcuts.KeyboardAction.moveAllToTrash}
doAction={doDeleteAll}
/>
</ContextMenu>
)}
<GlobalContextMenu
directoryKey={null}
directoryId={null}
dispatchAssetListEvent={dispatchAssetListEvent}
/>
</ContextMenus>
)
}}
/>

View File

@ -1,10 +1,12 @@
/** @file A styled button. */
import * as React from 'react'
import SvgMask from '../../authentication/components/svgMask'
/** Props for a {@link Button}. */
export interface ButtonProps {
active?: boolean
disabled?: boolean
disabledOpacityClassName?: string
image: string
/** A title that is only shown when `disabled` is true. */
error?: string | null
@ -14,18 +16,24 @@ export interface ButtonProps {
/** A styled button. */
export default function Button(props: ButtonProps) {
const { active = false, disabled = false, image, error, className, onClick } = props
const {
active = false,
disabled = false,
disabledOpacityClassName,
image,
error,
className,
onClick,
} = props
return (
<button
disabled={disabled}
<SvgMask
src={image}
{...(disabled && error != null ? { title: error } : {})}
className={`cursor-pointer disabled:cursor-default disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-100 ${
active ? '' : 'opacity-50'
className={`${active && !disabled ? '' : disabledOpacityClassName ?? 'opacity-50'} ${
!disabled ? 'cursor-pointer hover:opacity-100 cursor-pointer' : 'cursor-not-allowed'
} ${className ?? ''}`}
onClick={onClick}
>
<img src={image} />
</button>
{...(disabled ? {} : { onClick })}
/>
)
}

View File

@ -2,8 +2,6 @@
import * as React from 'react'
import * as toastify from 'react-toastify'
import CloseIcon from 'enso-assets/close.svg'
import * as errorModule from '../../error'
import * as loggerProvider from '../../providers/logger'
import * as modalProvider from '../../providers/modal'
@ -39,47 +37,39 @@ export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
}
return (
<Modal centered className="bg-opacity-90">
<form
onClick={event => {
event.stopPropagation()
}}
onSubmit={event => {
event.preventDefault()
// Consider not calling `onSubmit()` here to make it harder to accidentally
// delete an important asset.
onSubmit()
}}
className="relative bg-white shadow-soft rounded-lg w-96 p-2"
>
<div className="flex">
{/* Padding. */}
<div className="grow" />
<button
type="button"
className="absolute right-0 top-0 m-2"
onClick={unsetModal}
>
<img src={CloseIcon} />
</button>
</div>
<div className="m-2">Are you sure you want to delete {description}?</div>
<div className="m-1">
<button
type="submit"
className="hover:cursor-pointer inline-block text-white bg-red-500 rounded-full px-4 py-1 m-1"
>
Delete
</button>
<button
type="button"
className="hover:cursor-pointer inline-block bg-gray-200 rounded-full px-4 py-1 m-1"
onClick={unsetModal}
>
Cancel
</button>
</div>
</form>
<Modal centered className="bg-dim">
<div className="relative rounded-2xl pointer-events-auto">
<div className="absolute rounded-2xl bg-frame-selected backdrop-blur-3xl w-full h-full" />
<form
onClick={event => {
event.stopPropagation()
}}
onSubmit={event => {
event.preventDefault()
// Consider not calling `onSubmit()` here to make it harder to accidentally
// delete an important asset.
onSubmit()
}}
className="relative shadow-soft rounded-2xl w-96 px-4 py-2"
>
<div className="m-2">Are you sure you want to delete {description}?</div>
<div className="m-1">
<button
type="submit"
className="hover:cursor-pointer inline-block text-white bg-delete rounded-full px-4 py-1 m-1"
>
Delete
</button>
<button
type="button"
className="hover:cursor-pointer inline-block bg-frame-selected rounded-full px-4 py-1 m-1"
onClick={unsetModal}
>
Cancel
</button>
</div>
</form>
</div>
</Modal>
)
}

View File

@ -1,57 +1,32 @@
/** @file A context menu. */
import * as React from 'react'
// =================
// === Constants ===
// =================
/** The margin around the context menu, so that it is not at the edge of the screen. */
const SCROLL_MARGIN = 12
// ===================
// === ContextMenu ===
// ===================
/** Props for a {@link ContextMenu}. */
export interface ContextMenuProps extends React.PropsWithChildren {
key: string
// `left: number` and `top: number` may be more correct,
// however passing an event eliminates the chance
// of passing the wrong coordinates from the event.
event: React.MouseEvent
hidden?: boolean
}
/** A context menu that opens at the current mouse position. */
export default function ContextMenu(props: ContextMenuProps) {
const { children, event } = props
const contextMenuRef = React.useRef<HTMLDivElement>(null)
const [top, setTop] = React.useState(event.pageY)
// This must be the original height before the returned element affects the `scrollHeight`.
const [bodyHeight] = React.useState(document.body.scrollHeight)
const { hidden = false, children } = props
React.useEffect(() => {
if (contextMenuRef.current != null) {
setTop(Math.min(top, bodyHeight - contextMenuRef.current.clientHeight))
const boundingBox = contextMenuRef.current.getBoundingClientRect()
const scrollBy = boundingBox.bottom - innerHeight + SCROLL_MARGIN
if (scrollBy > 0) {
scroll(scrollX, scrollY + scrollBy)
}
}
}, [bodyHeight, children, top])
return (
<div
ref={contextMenuRef}
// The location must be offset by -0.5rem to balance out the `m-2`.
style={{ left: `calc(${event.pageX}px - 0.5rem)`, top: `calc(${top}px - 0.5rem)` }}
className="absolute bg-white rounded-lg shadow-soft flex flex-col flex-nowrap m-2"
onClick={clickEvent => {
clickEvent.stopPropagation()
}}
>
{children}
return hidden ? (
<>{children}</>
) : (
<div className="relative rounded-2xl pointer-events-auto">
<div className="absolute rounded-2xl bg-frame-selected backdrop-blur-3xl w-full h-full" />
<div
className="relative flex flex-col rounded-2xl w-57.5 p-2"
onClick={clickEvent => {
clickEvent.stopPropagation()
}}
>
{children}
</div>
</div>
)
}

View File

@ -1,33 +1,62 @@
/** @file An entry in a context menu. */
import * as React from 'react'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
import KeyboardShortcut from './keyboardShortcut'
import SvgMask from '../../authentication/components/svgMask'
// ========================
// === ContextMenuEntry ===
// ========================
/** Props for a {@link ContextMenuEntry}. */
export interface ContextMenuEntryProps {
hidden?: boolean
action: shortcutsModule.KeyboardAction
disabled?: boolean
title?: string
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
doAction: () => void
}
/** An item in a `ContextMenu`. */
export default function ContextMenuEntry(props: React.PropsWithChildren<ContextMenuEntryProps>) {
const { children, disabled = false, title, onClick } = props
return (
export default function ContextMenuEntry(props: ContextMenuEntryProps) {
const { hidden = false, action, disabled = false, title, doAction } = props
const { shortcuts } = shortcutsProvider.useShortcuts()
const info = shortcuts.keyboardShortcutInfo[action]
React.useEffect(() => {
// This is slower than registering every shortcut in the context menu at once.
if (!disabled) {
return shortcuts.registerKeyboardHandlers({
[action]: doAction,
})
} else {
return
}
}, [disabled, shortcuts, action, doAction])
return hidden ? null : (
<button
disabled={disabled}
title={title}
className={`${
disabled ? 'opacity-50' : ''
} p-1 hover:bg-gray-200 first:rounded-t-lg last:rounded-b-lg text-left`}
className="flex items-center place-content-between h-8 px-3 py-1 hover:bg-black-a10 disabled:bg-transparent rounded-lg text-left disabled:opacity-50"
onClick={event => {
event.stopPropagation()
onClick(event)
doAction()
}}
>
{children}
<div className="flex items-center gap-3">
<SvgMask
style={{
width: shortcutsModule.ICON_SIZE_PX,
height: shortcutsModule.ICON_SIZE_PX,
}}
src={info.icon}
className={info.colorClass}
/>
{info.name}
</div>
<KeyboardShortcut action={action} />
</button>
)
}

View File

@ -0,0 +1,21 @@
/** @file A horizontal line dividing two sections in the context menu. */
import * as React from 'react'
// ============================
// === ContextMenuSeparator ===
// ============================
/** Props for a {@link ContextMenuSeparator}. */
export interface ContextMenuSeparatorProps {
hidden?: boolean
}
/** A horizontal line dividing two sections in the context menu. */
export default function ContextMenuSeparator(props: ContextMenuSeparatorProps) {
const { hidden = false } = props
return hidden ? null : (
<div className="py-0.5">
<div className="border-t-0.5 border-black-a16" />
</div>
)
}

View File

@ -0,0 +1,61 @@
/** @file A context menu. */
import * as React from 'react'
import Modal from './modal'
// =================
// === Constants ===
// =================
/** The margin around the context menu, so that it is not at the edge of the screen. */
const SCROLL_MARGIN = 12
// ===================
// === ContextMenu ===
// ===================
/** Props for a {@link ContextMenus}. */
export interface ContextMenusProps extends React.PropsWithChildren {
hidden?: boolean
key: string
event: Pick<React.MouseEvent, 'pageX' | 'pageY'>
}
/** A context menu that opens at the current mouse position. */
export default function ContextMenus(props: ContextMenusProps) {
const { hidden = false, children, event } = props
const contextMenuRef = React.useRef<HTMLDivElement>(null)
const [left, setLeft] = React.useState(event.pageX)
const [top, setTop] = React.useState(event.pageY)
// This must be the original height before the returned element affects the `scrollHeight`.
const [bodyHeight] = React.useState(document.body.scrollHeight)
React.useLayoutEffect(() => {
if (contextMenuRef.current != null) {
setTop(Math.min(top, bodyHeight - contextMenuRef.current.clientHeight))
const boundingBox = contextMenuRef.current.getBoundingClientRect()
setLeft(event.pageX - boundingBox.width / 2)
const scrollBy = boundingBox.bottom - innerHeight + SCROLL_MARGIN
if (scrollBy > 0) {
scroll(scrollX, scrollY + scrollBy)
}
}
}, [bodyHeight, children, top, event.pageX])
return hidden ? (
<>{children}</>
) : (
<Modal className="absolute overflow-hidden bg-dim w-full h-full z-10">
<div
ref={contextMenuRef}
style={{ left, top }}
className="sticky flex pointer-events-none items-start gap-0.5 w-min"
onClick={clickEvent => {
clickEvent.stopPropagation()
}}
>
{children}
</div>
</Modal>
)
}

View File

@ -11,12 +11,13 @@ import * as http from '../../http'
import * as localBackend from '../localBackend'
import * as projectManager from '../projectManager'
import * as remoteBackendModule from '../remoteBackend'
import * as shortcuts from '../shortcuts'
import * as shortcutsModule from '../shortcuts'
import * as authProvider from '../../authentication/providers/auth'
import * as backendProvider from '../../providers/backend'
import * as loggerProvider from '../../providers/logger'
import * as modalProvider from '../../providers/modal'
import * as shortcutsProvider from '../../providers/shortcuts'
import * as app from '../../components/app'
import * as pageSwitcher from './pageSwitcher'
@ -49,12 +50,7 @@ export default function Dashboard(props: DashboardProps) {
const { backend } = backendProvider.useBackend()
const { setBackend } = backendProvider.useSetBackend()
const { unsetModal } = modalProvider.useSetModal()
const [directoryId, setDirectoryId] = React.useState(
session.organization != null
? backendModule.rootDirectoryId(session.organization.id)
: // The local backend uses the empty string as the sole directory ID.
backendModule.DirectoryId('')
)
const { shortcuts } = shortcutsProvider.useShortcuts()
const [query, setQuery] = React.useState('')
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
const [isHelpChatVisible, setIsHelpChatVisible] = React.useState(false)
@ -97,7 +93,6 @@ export default function Dashboard(props: DashboardProps) {
backendModule.BackendType.remote
) {
setBackend(new localBackend.LocalBackend())
setDirectoryId(backendModule.DirectoryId(''))
}
// This hook MUST only run once, on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -136,22 +131,10 @@ export default function Dashboard(props: DashboardProps) {
}, [])
React.useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (
shortcuts.SHORTCUT_REGISTRY.matchesKeyboardAction(
shortcuts.KeyboardAction.closeModal,
event
)
) {
event.preventDefault()
unsetModal()
}
}
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
}
}, [unsetModal])
return shortcuts.registerKeyboardHandlers({
[shortcutsModule.KeyboardAction.closeModal]: unsetModal,
})
}, [shortcuts, unsetModal])
const setBackendType = React.useCallback(
(newBackendType: backendModule.BackendType) => {
@ -159,24 +142,18 @@ export default function Dashboard(props: DashboardProps) {
switch (newBackendType) {
case backendModule.BackendType.local:
setBackend(new localBackend.LocalBackend())
setDirectoryId(backendModule.DirectoryId(''))
break
case backendModule.BackendType.remote: {
const headers = new Headers()
headers.append('Authorization', `Bearer ${session.accessToken ?? ''}`)
const client = new http.Client(headers)
setBackend(new remoteBackendModule.RemoteBackend(client, logger))
setDirectoryId(
session.organization != null
? backendModule.rootDirectoryId(session.organization.id)
: backendModule.DirectoryId('')
)
break
}
}
}
},
[backend.type, logger, session.accessToken, session.organization, setBackend]
[backend.type, logger, session.accessToken, setBackend]
)
const doCreateProject = React.useCallback(
@ -185,13 +162,14 @@ export default function Dashboard(props: DashboardProps) {
onSpinnerStateChange?: (state: spinner.SpinnerState) => void
) => {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.createProject,
parentId: directoryId,
type: assetListEventModule.AssetListEventType.newProject,
parentKey: null,
parentId: null,
templateId: templateId ?? null,
onSpinnerStateChange: onSpinnerStateChange ?? null,
})
},
[directoryId, /* should never change */ dispatchAssetListEvent]
[/* should never change */ dispatchAssetListEvent]
)
const openEditor = React.useCallback(
@ -277,8 +255,6 @@ export default function Dashboard(props: DashboardProps) {
hidden={page !== pageSwitcher.Page.drive}
page={page}
initialProjectName={initialProjectName}
directoryId={directoryId}
setDirectoryId={setDirectoryId}
assetListEvents={assetListEvents}
dispatchAssetListEvent={dispatchAssetListEvent}
query={query}

View File

@ -1,60 +0,0 @@
/** @file The context menu for a {@link backendModule.DirectoryAsset}. */
import * as React from 'react'
import * as backendModule from '../backend'
import * as modalProvider from '../../providers/modal'
import ConfirmDeleteModal from './confirmDeleteModal'
import ContextMenu from './contextMenu'
import ContextMenuEntry from './contextMenuEntry'
import * as assetContextMenu from './assetContextMenu'
// =================
// === Constants ===
// =================
/** The user-facing name of this asset type. */
const ASSET_TYPE_NAME = 'folder'
// ============================
// === DirectoryContextMenu ===
// ============================
/** Props for a {@link DirectoryContextMenu}. */
export interface DirectoryContextMenuProps
extends assetContextMenu.AssetContextMenuProps<backendModule.DirectoryAsset> {}
/** The context menu for a {@link backendModule.DirectoryAsset}. */
export default function DirectoryContextMenu(props: DirectoryContextMenuProps) {
const {
innerProps: { item, setRowState },
event,
doDelete,
} = props
const { setModal, unsetModal } = modalProvider.useSetModal()
const doRename = () => {
setRowState(oldRowState => ({
...oldRowState,
isEditingName: true,
}))
unsetModal()
}
return (
<ContextMenu key={item.id} event={event}>
<ContextMenuEntry onClick={doRename}>Rename</ContextMenuEntry>
<ContextMenuEntry
onClick={() => {
setModal(
<ConfirmDeleteModal
description={`the ${ASSET_TYPE_NAME} '${item.title}'`}
doDelete={doDelete}
/>
)
}}
>
<span className="text-red-700">Delete</span>
</ContextMenuEntry>
</ContextMenu>
)
}

View File

@ -1,21 +1,22 @@
/** @file The icon and name of a {@link backendModule.DirectoryAsset}. */
import * as React from 'react'
import DirectoryIcon from 'enso-assets/directory.svg'
import DirectoryIcon from 'enso-assets/folder.svg'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as column from '../column'
import * as eventModule from '../event'
import * as hooks from '../../hooks'
import * as indent from '../indent'
import * as presence from '../presence'
import * as shortcuts from '../shortcuts'
import * as backendProvider from '../../providers/backend'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
import EditableSpan from './editableSpan'
import SvgMask from '../../authentication/components/svgMask'
// =====================
// === DirectoryName ===
@ -37,8 +38,9 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
rowState,
setRowState,
} = props
const { backend } = backendProvider.useBackend()
const toastAndLog = hooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
const { shortcuts } = shortcutsProvider.useShortcuts()
const doRename = async (newName: string) => {
if (backend.type !== backendModule.BackendType.local) {
@ -54,9 +56,9 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
hooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
case assetEventModule.AssetEventType.createProject:
case assetEventModule.AssetEventType.newProject:
case assetEventModule.AssetEventType.uploadFiles:
case assetEventModule.AssetEventType.createSecret:
case assetEventModule.AssetEventType.newSecret:
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.deleteMultiple:
@ -66,7 +68,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
// `deleteMultiple` and `downloadSelected` are handled by `AssetRow`.
break
}
case assetEventModule.AssetEventType.createDirectory: {
case assetEventModule.AssetEventType.newFolder: {
if (key === event.placeholderId) {
if (backend.type !== backendModule.BackendType.remote) {
toastAndLog('Folders cannot be created on the local backend')
@ -106,10 +108,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
if (
eventModule.isSingleClick(event) &&
(selected ||
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
shortcuts.MouseAction.editName,
event
))
shortcuts.matchesMouseAction(shortcutsModule.MouseAction.editName, event))
) {
setRowState(oldRowState => ({
...oldRowState,
@ -122,12 +121,12 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
window.setTimeout(() => {
setSelected(false)
}, 0)
doToggleDirectoryExpansion(item, key)
doToggleDirectoryExpansion(item.id, key, item.title)
}
}
}}
>
<img src={DirectoryIcon} />
<SvgMask src={DirectoryIcon} className="m-1" />
<EditableSpan
editable={rowState.isEditingName}
onSubmit={async newTitle => {

View File

@ -11,6 +11,9 @@ import * as assetEventModule from '../events/assetEvent'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as modalProvider from '../../providers/modal'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
import Button from './button'
// ================
@ -31,8 +34,27 @@ export default function DriveBar(props: DriveBarProps) {
const { doCreateProject, doCreateDirectory, doUploadFiles, dispatchAssetEvent } = props
const { backend } = backendProvider.useBackend()
const { unsetModal } = modalProvider.useSetModal()
const { shortcuts } = shortcutsProvider.useShortcuts()
const uploadFilesRef = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
return shortcuts.registerKeyboardHandlers({
...(backend.type !== backendModule.BackendType.local
? {
[shortcutsModule.KeyboardAction.newFolder]: () => {
doCreateDirectory()
},
}
: {}),
[shortcutsModule.KeyboardAction.newProject]: () => {
doCreateProject(null)
},
[shortcutsModule.KeyboardAction.uploadFiles]: () => {
uploadFilesRef.current?.click()
},
})
}, [backend.type, doCreateDirectory, doCreateProject, /* should never change */ shortcuts])
return (
<div className="flex py-0.5">
<div className="flex gap-2.5">
@ -45,12 +67,13 @@ export default function DriveBar(props: DriveBarProps) {
>
<span className="font-semibold leading-5 h-6 py-px">New Project</span>
</button>
<div className="flex items-center bg-frame rounded-full gap-3 h-8 px-3">
<div className="flex items-center text-black-a50 bg-frame rounded-full gap-3 h-8 px-3">
{backend.type !== backendModule.BackendType.local && (
<>
<Button
active
image={AddFolderIcon}
disabledOpacityClassName="opacity-20"
onClick={() => {
unsetModal()
doCreateDirectory()
@ -61,6 +84,7 @@ export default function DriveBar(props: DriveBarProps) {
disabled
image={AddConnectorIcon}
error="Not implemented yet."
disabledOpacityClassName="opacity-20"
onClick={() => {
// No backend support yet.
}}
@ -89,6 +113,7 @@ export default function DriveBar(props: DriveBarProps) {
<Button
active
image={DataUploadIcon}
disabledOpacityClassName="opacity-20"
onClick={() => {
unsetModal()
uploadFilesRef.current?.click()
@ -99,6 +124,7 @@ export default function DriveBar(props: DriveBarProps) {
disabled={backend.type !== backendModule.BackendType.local}
image={DataDownloadIcon}
error="Not implemented yet."
disabledOpacityClassName="opacity-20"
onClick={event => {
event.stopPropagation()
unsetModal()

View File

@ -2,8 +2,6 @@
import * as React from 'react'
import * as toastify from 'react-toastify'
import * as common from 'enso-common'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import * as authProvider from '../../authentication/providers/auth'
@ -17,13 +15,6 @@ import * as pageSwitcher from './pageSwitcher'
import AssetsTable from './assetsTable'
import DriveBar from './driveBar'
// =================
// === Constants ===
// =================
/** The `localStorage` key under which the ID of the current directory is stored. */
const DIRECTORY_STACK_KEY = `${common.PRODUCT_NAME.toLowerCase()}-dashboard-directory-stack`
// =================
// === DriveView ===
// =================
@ -33,8 +24,6 @@ export interface DriveViewProps {
page: pageSwitcher.Page
hidden: boolean
initialProjectName: string | null
directoryId: backendModule.DirectoryId | null
setDirectoryId: (directoryId: backendModule.DirectoryId) => void
assetListEvents: assetListEventModule.AssetListEvent[]
dispatchAssetListEvent: (directoryEvent: assetListEventModule.AssetListEvent) => void
query: string
@ -54,8 +43,6 @@ export default function DriveView(props: DriveViewProps) {
page,
hidden,
initialProjectName,
directoryId,
setDirectoryId,
query,
assetListEvents,
dispatchAssetListEvent,
@ -75,7 +62,6 @@ export default function DriveView(props: DriveViewProps) {
const [initialized, setInitialized] = React.useState(false)
const [assets, rawSetAssets] = React.useState<backendModule.AnyAsset[]>([])
const [isLoadingAssets, setIsLoadingAssets] = React.useState(true)
const [directoryStack, setDirectoryStack] = React.useState<backendModule.DirectoryAsset[]>([])
const [isFileBeingDragged, setIsFileBeingDragged] = React.useState(false)
const [assetEvents, dispatchAssetEvent] = hooks.useEvent<assetEventModule.AssetEvent>()
const [nameOfProjectToImmediatelyOpen, setNameOfProjectToImmediatelyOpen] =
@ -102,7 +88,7 @@ export default function DriveView(props: DriveViewProps) {
React.useEffect(() => {
setIsLoadingAssets(true)
}, [backend, directoryId])
}, [backend])
React.useEffect(() => {
if (backend.type === backendModule.BackendType.local && loadingProjectManagerDidFail) {
@ -110,39 +96,13 @@ export default function DriveView(props: DriveViewProps) {
}
}, [loadingProjectManagerDidFail, backend.type])
React.useEffect(() => {
const cachedDirectoryStackJson = localStorage.getItem(DIRECTORY_STACK_KEY)
if (cachedDirectoryStackJson != null) {
// The JSON was inserted by the code below, so it will always have the right type.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const cachedDirectoryStack: backendModule.DirectoryAsset[] =
JSON.parse(cachedDirectoryStackJson)
setDirectoryStack(cachedDirectoryStack)
const cachedDirectoryId = cachedDirectoryStack[cachedDirectoryStack.length - 1]?.id
if (cachedDirectoryId) {
setDirectoryId(cachedDirectoryId)
}
}
}, [setDirectoryId])
React.useEffect(() => {
if (
organization != null &&
directoryId === backendModule.rootDirectoryId(organization.id)
) {
localStorage.removeItem(DIRECTORY_STACK_KEY)
} else {
localStorage.setItem(DIRECTORY_STACK_KEY, JSON.stringify(directoryStack))
}
}, [directoryStack, directoryId, organization])
const setAssets = React.useCallback(
(newAssets: backendModule.AnyAsset[]) => {
rawSetAssets(newAssets)
if (nameOfProjectToImmediatelyOpen != null) {
const projectToLoad = newAssets.find(
projectAsset => projectAsset.title === nameOfProjectToImmediatelyOpen
)
const projectToLoad = newAssets
.filter(backendModule.assetIsProject)
.find(projectAsset => projectAsset.title === nameOfProjectToImmediatelyOpen)
if (projectToLoad != null) {
dispatchAssetEvent({
type: assetEventModule.AssetEventType.openProject,
@ -193,13 +153,9 @@ export default function DriveView(props: DriveViewProps) {
case backendModule.BackendType.remote: {
if (
!isListingRemoteDirectoryAndWillFail &&
!isListingRemoteDirectoryWhileOffline &&
directoryId != null
!isListingRemoteDirectoryWhileOffline
) {
const newAssets = await backend.listDirectory(
{ parentId: directoryId },
directoryStack[0]?.title ?? null
)
const newAssets = await backend.listDirectory({ parentId: null }, null)
if (!signal.aborted) {
setIsLoadingAssets(false)
setAssets(newAssets)
@ -211,31 +167,33 @@ export default function DriveView(props: DriveViewProps) {
}
}
},
[accessToken, directoryId, backend]
[accessToken, organization, backend]
)
const doUploadFiles = React.useCallback(
(files: File[]) => {
if (backend.type !== backendModule.BackendType.local && directoryId == null) {
if (backend.type !== backendModule.BackendType.local && organization == null) {
// This should never happen, however display a nice error message in case it does.
toastAndLog('Files cannot be uploaded while offline')
} else {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.uploadFiles,
parentId: directoryId,
parentKey: null,
parentId: null,
files,
})
}
},
[backend.type, directoryId, toastAndLog, /* should never change */ dispatchAssetListEvent]
[backend, organization, toastAndLog, /* should never change */ dispatchAssetListEvent]
)
const doCreateDirectory = React.useCallback(() => {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.createDirectory,
parentId: directoryId,
type: assetListEventModule.AssetListEventType.newFolder,
parentKey: null,
parentId: null,
})
}, [directoryId, /* should never change */ dispatchAssetListEvent])
}, [/* should never change */ dispatchAssetListEvent])
React.useEffect(() => {
const onDragEnter = (event: DragEvent) => {
@ -284,7 +242,7 @@ export default function DriveView(props: DriveViewProps) {
doCloseIde={doCloseEditor}
/>
{isFileBeingDragged &&
directoryId != null &&
organization != null &&
backend.type === backendModule.BackendType.remote ? (
<div
className="text-white text-lg fixed w-screen h-screen inset-0 opacity-0 hover:opacity-100 bg-primary bg-opacity-75 backdrop-blur-none hover:backdrop-blur-xs transition-all grid place-items-center"
@ -299,7 +257,8 @@ export default function DriveView(props: DriveViewProps) {
setIsFileBeingDragged(false)
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.uploadFiles,
parentId: directoryId,
parentKey: null,
parentId: null,
files: Array.from(event.dataTransfer.files),
})
}}

View File

@ -4,7 +4,8 @@ import * as React from 'react'
import CrossIcon from 'enso-assets/cross.svg'
import TickIcon from 'enso-assets/tick.svg'
import * as shortcuts from '../shortcuts'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
// ====================
// === EditableSpan ===
@ -34,10 +35,22 @@ export default function EditableSpan(props: EditableSpanProps) {
inputTitle,
...passthroughProps
} = props
const { shortcuts } = shortcutsProvider.useShortcuts()
// This is incorrect, but SAFE, as the value is always set by the time it is used.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const inputRef = React.useRef<HTMLInputElement>(null!)
const inputRef = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
if (editable) {
return shortcuts.registerKeyboardHandlers({
[shortcutsModule.KeyboardAction.cancelEditName]: () => {
onCancel()
inputRef.current?.blur()
},
})
} else {
return
}
}, [editable, shortcuts, onCancel])
if (editable) {
return (
@ -45,7 +58,9 @@ export default function EditableSpan(props: EditableSpanProps) {
className="flex grow"
onSubmit={event => {
event.preventDefault()
onSubmit(inputRef.current.value)
if (inputRef.current != null) {
onSubmit(inputRef.current.value)
}
}}
>
<input
@ -55,16 +70,6 @@ export default function EditableSpan(props: EditableSpanProps) {
size={1}
defaultValue={children}
onBlur={event => event.currentTarget.form?.requestSubmit()}
onKeyUp={event => {
if (
shortcuts.SHORTCUT_REGISTRY.matchesKeyboardAction(
shortcuts.KeyboardAction.cancelEditName,
event
)
) {
onCancel()
}
}}
{...(inputPattern != null ? { pattern: inputPattern } : {})}
{...(inputTitle != null ? { title: inputTitle } : {})}
{...passthroughProps}

View File

@ -1,60 +0,0 @@
/** @file The context menu for a {@link backendModule.FileAsset}. */
import * as React from 'react'
import * as backendModule from '../backend'
import * as modalProvider from '../../providers/modal'
import * as assetContextMenu from './assetContextMenu'
import ConfirmDeleteModal from './confirmDeleteModal'
import ContextMenu from './contextMenu'
import ContextMenuEntry from './contextMenuEntry'
// =================
// === Constants ===
// =================
const ASSET_TYPE_NAME = 'file'
// =======================
// === FileContextMenu ===
// =======================
/** Props for a {@link FileContextMenu}. */
export interface FileContextMenuProps
extends assetContextMenu.AssetContextMenuProps<backendModule.FileAsset> {}
/** The context menu for a {@link backendModule.FileAsset}. */
export default function FileContextMenu(props: FileContextMenuProps) {
const {
innerProps: { item },
event,
doDelete,
} = props
const { setModal } = modalProvider.useSetModal()
return (
<ContextMenu key={item.id} event={event}>
{/*<ContextMenuEntry disabled onClick={doCopy}>
Copy
</ContextMenuEntry>
<ContextMenuEntry disabled onClick={doCut}>
Cut
</ContextMenuEntry>*/}
<ContextMenuEntry
onClick={() => {
setModal(
<ConfirmDeleteModal
description={`the ${ASSET_TYPE_NAME} '${item.title}'`}
doDelete={doDelete}
/>
)
}}
>
<span className="text-red-700">Delete</span>
</ContextMenuEntry>
{/*<ContextMenuEntry disabled onClick={doDownload}>
Download
</ContextMenuEntry>*/}
</ContextMenu>
)
}

View File

@ -10,10 +10,12 @@ import * as fileInfo from '../../fileInfo'
import * as hooks from '../../hooks'
import * as indent from '../indent'
import * as presence from '../presence'
import * as shortcuts from '../shortcuts'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
import * as column from '../column'
import EditableSpan from './editableSpan'
import SvgMask from '../../authentication/components/svgMask'
// ================
// === FileName ===
@ -33,8 +35,9 @@ export default function FileNameColumn(props: FileNameColumnProps) {
rowState,
setRowState,
} = props
const { backend } = backendProvider.useBackend()
const toastAndLog = hooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
const { shortcuts } = shortcutsProvider.useShortcuts()
// TODO[sb]: Wait for backend implementation. `editable` should also be re-enabled, and the
// context menu entry should be re-added.
@ -45,9 +48,9 @@ export default function FileNameColumn(props: FileNameColumnProps) {
hooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
case assetEventModule.AssetEventType.createProject:
case assetEventModule.AssetEventType.createDirectory:
case assetEventModule.AssetEventType.createSecret:
case assetEventModule.AssetEventType.newProject:
case assetEventModule.AssetEventType.newFolder:
case assetEventModule.AssetEventType.newSecret:
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.deleteMultiple:
@ -98,10 +101,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
if (
eventModule.isSingleClick(event) &&
(selected ||
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
shortcuts.MouseAction.editName,
event
))
shortcuts.matchesMouseAction(shortcutsModule.MouseAction.editName, event))
) {
setRowState(oldRowState => ({
...oldRowState,
@ -110,7 +110,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
}
}}
>
<img src={fileInfo.fileIcon()} />
<SvgMask src={fileInfo.fileIcon()} className="m-1" />
<EditableSpan
editable={false}
onSubmit={async newTitle => {

View File

@ -0,0 +1,119 @@
/** @file A context menu available everywhere in the directory. */
import * as React from 'react'
import * as assetListEventModule from '../events/assetListEvent'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as modalProvider from '../../providers/modal'
import * as shortcuts from '../shortcuts'
import ContextMenu from './contextMenu'
import ContextMenuEntry from './contextMenuEntry'
/** Props for a {@link GlobalContextMenu}. */
export interface GlobalContextMenuProps {
hidden?: boolean
directoryKey: backendModule.DirectoryId | null
directoryId: backendModule.DirectoryId | null
dispatchAssetListEvent: (event: assetListEventModule.AssetListEvent) => void
}
/** A context menu available everywhere in the directory. */
export default function GlobalContextMenu(props: GlobalContextMenuProps) {
const { hidden = false, directoryKey, directoryId, dispatchAssetListEvent } = props
const { backend } = backendProvider.useBackend()
const { unsetModal } = modalProvider.useSetModal()
const filesInputRef = React.useRef<HTMLInputElement>(null)
return (
<ContextMenu hidden={hidden}>
{!hidden && (
<input
ref={filesInputRef}
multiple
type="file"
id="context_menu_file_input"
{...(backend.type !== backendModule.BackendType.local
? {}
: { accept: '.enso-project' })}
className="hidden"
onInput={event => {
if (event.currentTarget.files != null) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.uploadFiles,
parentKey: directoryKey,
parentId: directoryId,
files: Array.from(event.currentTarget.files),
})
unsetModal()
}
}}
></input>
)}
<ContextMenuEntry
hidden={hidden}
action={shortcuts.KeyboardAction.uploadFiles}
doAction={() => {
if (filesInputRef.current?.isConnected === true) {
filesInputRef.current.click()
} else {
const input = document.createElement('input')
input.type = 'file'
input.style.display = 'none'
document.body.appendChild(input)
input.addEventListener('input', () => {
if (input.files != null) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.uploadFiles,
parentKey: directoryKey,
parentId: directoryId,
files: Array.from(input.files),
})
unsetModal()
}
})
input.click()
input.remove()
}
}}
/>
<ContextMenuEntry
hidden={hidden}
action={shortcuts.KeyboardAction.newProject}
doAction={() => {
unsetModal()
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.newProject,
parentKey: directoryKey,
parentId: directoryId,
templateId: null,
onSpinnerStateChange: null,
})
}}
/>
{backend.type !== backendModule.BackendType.local && (
<ContextMenuEntry
hidden={hidden}
action={shortcuts.KeyboardAction.newFolder}
doAction={() => {
unsetModal()
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.newFolder,
parentKey: directoryKey,
parentId: directoryId,
})
}}
/>
)}
{backend.type !== backendModule.BackendType.local && (
<ContextMenuEntry
hidden={hidden}
disabled
action={shortcuts.KeyboardAction.newDataConnector}
doAction={() => {
// No backend support yet.
}}
/>
)}
</ContextMenu>
)
}

View File

@ -0,0 +1,73 @@
/** @file A visual representation of a keyboard shortcut. */
import * as React from 'react'
import CommandKeyIcon from 'enso-assets/command_key.svg'
import CtrlKeyIcon from 'enso-assets/ctrl_key.svg'
import OptionKeyIcon from 'enso-assets/option_key.svg'
import ShiftKeyIcon from 'enso-assets/shift_key.svg'
import WindowsKeyIcon from 'enso-assets/windows_key.svg'
import * as detect from 'enso-common/src/detect'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
import SvgMask from '../../authentication/components/svgMask'
// ========================
// === KeyboardShortcut ===
// ========================
/** The size (both width and height) of key icons. */
const ICON_SIZE_PX = 13
const ICON_STYLE = { width: ICON_SIZE_PX, height: ICON_SIZE_PX }
/** Icons for modifier keys (if they exist). */
const MODIFIER_MAPPINGS: Partial<Record<shortcutsModule.ModifierKey, React.ReactNode>> =
detect.platform() === detect.Platform.macOS
? // The names are intentionally not in `camelCase`.
/* eslint-disable @typescript-eslint/naming-convention */
{
Meta: <SvgMask style={ICON_STYLE} key="Meta" src={CommandKeyIcon} />,
Shift: <SvgMask style={ICON_STYLE} key="Shift" src={ShiftKeyIcon} />,
Alt: <SvgMask style={ICON_STYLE} key="Alt" src={OptionKeyIcon} />,
Ctrl: <SvgMask style={ICON_STYLE} key="Ctrl" src={CtrlKeyIcon} />,
}
: {
// TODO[sb]: These are required, otherwise the entry for "New Data Connector" will
// span across two lines. These should be replaced with proper Windows equivalents.
Meta: <SvgMask style={ICON_STYLE} key="Meta" src={WindowsKeyIcon} />,
Shift: <SvgMask style={ICON_STYLE} key="Shift" src={ShiftKeyIcon} />,
Alt: <SvgMask style={ICON_STYLE} key="Alt" src={OptionKeyIcon} />,
Ctrl: <SvgMask style={ICON_STYLE} key="Ctrl" src={CommandKeyIcon} />,
}
/* eslint-enable @typescript-eslint/naming-convention */
/** Props for a {@link KeyboardShortcut} */
export interface KeyboardShortcutProps {
action: shortcutsModule.KeyboardAction
}
/** A visual representation of a keyboard shortcut. */
export default function KeyboardShortcut(props: KeyboardShortcutProps) {
const { action } = props
const { shortcuts } = shortcutsProvider.useShortcuts()
const shortcut = shortcuts.keyboardShortcuts[action][0]
if (shortcut == null) {
return null
} else {
return (
<div className="flex items-center h-6 gap-0.5">
{shortcutsModule.getModifierKeysOfShortcut(shortcut).map(
modifier =>
MODIFIER_MAPPINGS[modifier] ?? (
<span key={modifier} className="leading-170 h-6 py-px">
{modifier}
</span>
)
)}
<span className="leading-170 h-6 py-px">{shortcut.key}</span>
</div>
)
}
}

View File

@ -33,7 +33,7 @@ export interface ManagePermissionsModalProps {
/** Remove the current user's permissions from this asset. This MUST be a prop because it should
* change the assets list. */
doRemoveSelf: () => void
eventTarget: HTMLElement
eventTarget: HTMLElement | null
}
/** A modal with inputs for user email and permission level.
@ -50,7 +50,7 @@ export default function ManagePermissionsModal(props: ManagePermissionsModalProp
const [email, setEmail] = React.useState<string | null>(null)
const [action, setAction] = React.useState(backendModule.PermissionAction.view)
const emailValidityRef = React.useRef<HTMLInputElement>(null)
const position = React.useMemo(() => eventTarget.getBoundingClientRect(), [eventTarget])
const position = React.useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget])
const editablePermissions = React.useMemo(
() =>
self.permission === backendModule.PermissionAction.own
@ -227,12 +227,19 @@ export default function ManagePermissionsModal(props: ManagePermissionsModalProp
}
return (
<Modal className="absolute overflow-hidden bg-dim w-full h-full top-0 left-0 z-10">
<Modal
centered={eventTarget == null}
className="absolute overflow-hidden bg-dim w-full h-full top-0 left-0 z-10"
>
<div
style={{
left: position.left + window.scrollX,
top: position.top + window.scrollY,
}}
style={
position != null
? {
left: position.left + window.scrollX,
top: position.top + window.scrollY,
}
: {}
}
className="sticky w-115.25"
onClick={mouseEvent => {
mouseEvent.stopPropagation()

View File

@ -25,7 +25,7 @@ export default function Modal(props: ModalProps) {
return (
<div
style={style}
className={`inset-0 bg-primary z-10 ${
className={`inset-0 z-10 ${
centered ? 'fixed w-screen h-screen grid place-items-center ' : ''
}${className ?? ''}`}
onClick={

View File

@ -48,7 +48,7 @@ export interface PageSwitcherProps {
export default function PageSwitcher(props: PageSwitcherProps) {
const { page, setPage, isEditorDisabled } = props
return (
<div className="flex shrink-0 gap-4">
<div className="flex items-center shrink-0 gap-4">
{PAGE_DATA.map(pageData => {
const isDisabled =
pageData.page === Page.home ||

View File

@ -1,110 +0,0 @@
/** @file The context menu for a {@link backendModule.ProjectAsset}. */
import * as React from 'react'
import * as toast from 'react-toastify'
import * as assetEventModule from '../events/assetEvent'
import * as authProvider from '../../authentication/providers/auth'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as hooks from '../../hooks'
import * as http from '../../http'
import * as loggerProvider from '../../providers/logger'
import * as modalProvider from '../../providers/modal'
import * as remoteBackendModule from '../remoteBackend'
import * as assetContextMenu from './assetContextMenu'
import ConfirmDeleteModal from './confirmDeleteModal'
import ContextMenu from './contextMenu'
import ContextMenuEntry from './contextMenuEntry'
// =================
// === Constants ===
// =================
/** The user-facing name of this asset type. */
const ASSET_TYPE_NAME = 'project'
// ==========================
// === ProjectContextMenu ===
// ==========================
/** Props for a {@link ProjectContextMenu}. */
export interface ProjectContextMenuProps
extends assetContextMenu.AssetContextMenuProps<backendModule.ProjectAsset> {}
/** The context menu for a {@link backendModule.ProjectAsset}. */
export default function ProjectContextMenu(props: ProjectContextMenuProps) {
const {
innerProps: { item, setRowState },
event,
dispatchAssetEvent,
doDelete,
} = props
const logger = loggerProvider.useLogger()
const { backend } = backendProvider.useBackend()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { accessToken } = authProvider.useNonPartialUserSession()
const toastAndLog = hooks.useToastAndLog()
const doOpenForEditing = () => {
unsetModal()
dispatchAssetEvent({
type: assetEventModule.AssetEventType.openProject,
id: item.id,
})
}
const doUploadToCloud = async () => {
unsetModal()
if (accessToken == null) {
toastAndLog('Cannot upload to cloud in offline mode')
} else {
try {
const headers = new Headers([['Authorization', `Bearer ${accessToken}`]])
const client = new http.Client(headers)
const remoteBackend = new remoteBackendModule.RemoteBackend(client, logger)
const projectResponse = await fetch(
`./api/project-manager/projects/${item.id}/enso-project`
)
await remoteBackend.uploadFile(
{
fileName: `${item.title}.enso-project`,
fileId: null,
parentDirectoryId: null,
},
await projectResponse.blob()
)
toast.toast.success('Successfully uploaded local project to cloud!')
} catch (error) {
toastAndLog('Could not upload local project to cloud', error)
}
}
}
const doRename = () => {
setRowState(oldRowState => ({
...oldRowState,
isEditingName: true,
}))
unsetModal()
}
return (
<ContextMenu key={item.id} event={event}>
<ContextMenuEntry onClick={doOpenForEditing}>Open for editing</ContextMenuEntry>
{backend.type === backendModule.BackendType.local && (
<ContextMenuEntry onClick={doUploadToCloud}>Upload to cloud</ContextMenuEntry>
)}
<ContextMenuEntry onClick={doRename}>Rename</ContextMenuEntry>
<ContextMenuEntry
onClick={() => {
setModal(
<ConfirmDeleteModal
description={`the ${ASSET_TYPE_NAME} '${item.title}'`}
doDelete={doDelete}
/>
)
}}
>
<span className="text-red-700">Delete</span>
</ContextMenuEntry>
</ContextMenu>
)
}

View File

@ -13,6 +13,7 @@ import * as hooks from '../../hooks'
import * as modalProvider from '../../providers/modal'
import Spinner, * as spinner from './spinner'
import SvgMask from '../../authentication/components/svgMask'
// =================
// === Constants ===
@ -204,9 +205,9 @@ export default function ProjectIcon(props: ProjectIconProps) {
hooks.useEventHandler(assetEvents, event => {
switch (event.type) {
case assetEventModule.AssetEventType.createDirectory:
case assetEventModule.AssetEventType.newFolder:
case assetEventModule.AssetEventType.uploadFiles:
case assetEventModule.AssetEventType.createSecret:
case assetEventModule.AssetEventType.newSecret:
case assetEventModule.AssetEventType.deleteMultiple:
case assetEventModule.AssetEventType.downloadSelected:
case assetEventModule.AssetEventType.removeSelf: {
@ -233,7 +234,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
void closeProject(false)
break
}
case assetEventModule.AssetEventType.createProject: {
case assetEventModule.AssetEventType.newProject: {
if (event.placeholderId === key) {
setOnSpinnerStateChange(() => event.onSpinnerStateChange)
} else if (event.onSpinnerStateChange === onSpinnerStateChange) {
@ -385,7 +386,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
doOpenManually(item.id)
}}
>
<img src={PlayIcon} />
<SvgMask src={PlayIcon} />
</button>
)
case backendModule.ProjectState.openInProgress:
@ -402,7 +403,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
<div className="relative h-0">
<Spinner size={24} state={spinnerState} />
</div>
<img src={StopIcon} />
<SvgMask src={StopIcon} />
</button>
)
case backendModule.ProjectState.opened:
@ -419,7 +420,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
<div className="relative h-0">
<Spinner size={24} state={spinnerState} />
</div>
<img src={StopIcon} />
<SvgMask src={StopIcon} />
</button>
<button
className="w-6"
@ -429,7 +430,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
openIde()
}}
>
<img src={ArrowUpIcon} />
<SvgMask src={ArrowUpIcon} />
</button>
</>
)

View File

@ -9,7 +9,8 @@ import * as eventModule from '../event'
import * as hooks from '../../hooks'
import * as indent from '../indent'
import * as presence from '../presence'
import * as shortcuts from '../shortcuts'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
import * as validation from '../validation'
import * as column from '../column'
@ -44,8 +45,9 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
getDepth,
},
} = props
const { backend } = backendProvider.useBackend()
const toastAndLog = hooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
const { shortcuts } = shortcutsProvider.useShortcuts()
const doRename = async (newName: string) => {
try {
@ -67,8 +69,8 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
hooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
case assetEventModule.AssetEventType.createDirectory:
case assetEventModule.AssetEventType.createSecret:
case assetEventModule.AssetEventType.newFolder:
case assetEventModule.AssetEventType.newSecret:
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.deleteMultiple:
@ -78,7 +80,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
// `deleteMultiple` and `downloadSelected` are handled by `AssetRow`.
break
}
case assetEventModule.AssetEventType.createProject: {
case assetEventModule.AssetEventType.newProject: {
// This should only run before this project gets replaced with the actual project
// by this event handler. In both cases `key` will match, so using `key` here
// is a mistake.
@ -205,10 +207,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
} else if (
eventModule.isSingleClick(event) &&
(selected ||
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
shortcuts.MouseAction.editName,
event
))
shortcuts.matchesMouseAction(shortcutsModule.MouseAction.editName, event))
) {
setRowState(oldRowState => ({
...oldRowState,

View File

@ -1,52 +0,0 @@
/** @file The context menu for a {@link backendModule.SecretAsset}. */
import * as React from 'react'
import * as backendModule from '../backend'
import * as modalProvider from '../../providers/modal'
import * as assetContextMenu from './assetContextMenu'
import ConfirmDeleteModal from './confirmDeleteModal'
import ContextMenu from './contextMenu'
import ContextMenuEntry from './contextMenuEntry'
// =================
// === Constants ===
// =================
/** The user-facing name of this asset type. */
const ASSET_TYPE_NAME = 'secret'
// =========================
// === SecretContextMenu ===
// =========================
/** Props for a {@link SecretContextMenu}. */
export interface SecretContextMenuProps
extends assetContextMenu.AssetContextMenuProps<backendModule.SecretAsset> {}
/** The context menu for a {@link backendModule.SecretAsset}. */
export default function SecretContextMenu(props: SecretContextMenuProps) {
const {
innerProps: { item },
event,
doDelete,
} = props
const { setModal } = modalProvider.useSetModal()
return (
<ContextMenu key={item.id} event={event}>
<ContextMenuEntry
onClick={() => {
setModal(
<ConfirmDeleteModal
description={`the ${ASSET_TYPE_NAME} '${item.title}'`}
doDelete={doDelete}
/>
)
}}
>
<span className="text-red-700">Delete</span>
</ContextMenuEntry>
</ContextMenu>
)
}

View File

@ -11,10 +11,12 @@ import * as eventModule from '../event'
import * as hooks from '../../hooks'
import * as indent from '../indent'
import * as presence from '../presence'
import * as shortcuts from '../shortcuts'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
import * as column from '../column'
import EditableSpan from './editableSpan'
import SvgMask from '../../authentication/components/svgMask'
// ==================
// === SecretName ===
@ -36,6 +38,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
} = props
const toastAndLog = hooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
const { shortcuts } = shortcutsProvider.useShortcuts()
// TODO[sb]: Wait for backend implementation. `editable` should also be re-enabled, and the
// context menu entry should be re-added.
@ -46,8 +49,8 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
hooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
case assetEventModule.AssetEventType.createProject:
case assetEventModule.AssetEventType.createDirectory:
case assetEventModule.AssetEventType.newProject:
case assetEventModule.AssetEventType.newFolder:
case assetEventModule.AssetEventType.uploadFiles:
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
@ -58,7 +61,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
// `deleteMultiple` and `downloadSelected` are handled by `AssetRow`.
break
}
case assetEventModule.AssetEventType.createSecret: {
case assetEventModule.AssetEventType.newSecret: {
if (key === event.placeholderId) {
if (backend.type !== backendModule.BackendType.remote) {
toastAndLog('Secrets cannot be created on the local backend')
@ -99,10 +102,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
if (
eventModule.isSingleClick(event) &&
(selected ||
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
shortcuts.MouseAction.editName,
event
))
shortcuts.matchesMouseAction(shortcutsModule.MouseAction.editName, event))
) {
setRowState(oldRowState => ({
...oldRowState,
@ -111,7 +111,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
}
}}
>
<img src={SecretIcon} />{' '}
<SvgMask src={SecretIcon} />{' '}
<EditableSpan
editable={false}
onSubmit={async newTitle => {

View File

@ -4,7 +4,8 @@
import * as React from 'react'
import * as set from '../../set'
import * as shortcuts from '../shortcuts'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
import * as tableColumn from './tableColumn'
import Spinner, * as spinner from './spinner'
@ -31,17 +32,32 @@ interface InitialRowStateProp<RowState> {
initialRowState: RowState
}
/** `selectedKeys` and `setSelectedKeys` when they are present. */
interface InternalSelectedKeysProps<Key> {
selectedKeys: Set<Key>
setSelectedKeys: React.Dispatch<React.SetStateAction<Set<Key>>>
}
/** The absence of `selectedKeys` and `setSelectedKeys`. */
interface InternalNoSelectedKeysProps {
selectedKeys?: never
setSelectedKeys?: never
}
// =============
// === Table ===
// =============
/** Props for a {@link Table}. */
interface InternalTableProps<T, State = never, RowState = never, Key extends string = string> {
footer?: JSX.Element
rowComponent?: (props: tableRow.TableRowProps<T, State, RowState, Key>) => JSX.Element
items: T[]
state?: State
initialRowState?: RowState
getKey: (item: T) => Key
selectedKeys?: Set<Key>
setSelectedKeys?: React.Dispatch<React.SetStateAction<Set<Key>>>
columns: tableColumn.TableColumn<T, State, RowState, Key>[]
isLoading: boolean
placeholder?: JSX.Element
@ -61,39 +77,47 @@ export type TableProps<
Key extends string = string
> = InternalTableProps<T, State, RowState, Key> &
([RowState] extends [never] ? unknown : InitialRowStateProp<RowState>) &
([State] extends [never] ? unknown : StateProp<State>)
([State] extends [never] ? unknown : StateProp<State>) &
(InternalNoSelectedKeysProps | InternalSelectedKeysProps<Key>)
/** Table that projects an object into each column. */
export default function Table<T, State = never, RowState = never, Key extends string = string>(
props: TableProps<T, State, RowState, Key>
) {
const {
footer,
rowComponent: RowComponent = TableRow,
items,
getKey,
selectedKeys: rawSelectedKeys,
setSelectedKeys: rawSetSelectedKeys,
columns,
isLoading,
placeholder,
onContextMenu,
...rowProps
} = props
const { shortcuts } = shortcutsProvider.useShortcuts()
const [spinnerState, setSpinnerState] = React.useState(spinner.SpinnerState.initial)
// This should not be made mutable for the sake of optimization, otherwise its value may
// be different after `await`ing an I/O operation. Also, a change in its value should trigger
// a re-render.
const [selectedKeys, setSelectedKeys] = React.useState(() => new Set<Key>())
const [fallbackSelectedKeys, fallbackSetSelectedKeys] = React.useState(() => new Set<Key>())
const [selectedKeys, setSelectedKeys] =
rawSelectedKeys != null
? [rawSelectedKeys, rawSetSelectedKeys]
: [fallbackSelectedKeys, fallbackSetSelectedKeys]
const [previouslySelectedKey, setPreviouslySelectedKey] = React.useState<Key | null>(null)
React.useEffect(() => {
const onDocumentClick = (event: MouseEvent) => {
if (
!shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
shortcuts.MouseAction.selectAdditional,
!shortcuts.matchesMouseAction(
shortcutsModule.MouseAction.selectAdditional,
event
) &&
!shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
shortcuts.MouseAction.selectAdditionalRange,
!shortcuts.matchesMouseAction(
shortcutsModule.MouseAction.selectAdditionalRange,
event
) &&
selectedKeys.size !== 0
@ -105,7 +129,7 @@ export default function Table<T, State = never, RowState = never, Key extends st
return () => {
document.removeEventListener('click', onDocumentClick)
}
}, [selectedKeys])
}, [selectedKeys, /* should never change */ setSelectedKeys, shortcuts])
React.useEffect(() => {
if (isLoading) {
@ -120,7 +144,10 @@ export default function Table<T, State = never, RowState = never, Key extends st
}, [isLoading])
const onRowClick = React.useCallback(
(innerRowProps: tableRow.TableRowInnerProps<T, RowState, Key>, event: React.MouseEvent) => {
(
innerRowProps: tableRow.TableRowInnerProps<T, State, RowState, Key>,
event: React.MouseEvent
) => {
const { key } = innerRowProps
event.stopPropagation()
const getNewlySelectedKeys = () => {
@ -138,16 +165,11 @@ export default function Table<T, State = never, RowState = never, Key extends st
return selectedItems.map(getKey)
}
}
if (
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
shortcuts.MouseAction.selectRange,
event
)
) {
if (shortcuts.matchesMouseAction(shortcutsModule.MouseAction.selectRange, event)) {
setSelectedKeys(new Set(getNewlySelectedKeys()))
} else if (
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
shortcuts.MouseAction.selectAdditionalRange,
shortcuts.matchesMouseAction(
shortcutsModule.MouseAction.selectAdditionalRange,
event
)
) {
@ -155,10 +177,7 @@ export default function Table<T, State = never, RowState = never, Key extends st
oldSelectedItems => new Set([...oldSelectedItems, ...getNewlySelectedKeys()])
)
} else if (
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
shortcuts.MouseAction.selectAdditional,
event
)
shortcuts.matchesMouseAction(shortcutsModule.MouseAction.selectAdditional, event)
) {
setSelectedKeys(oldSelectedItems => {
const newItems = new Set(oldSelectedItems)
@ -174,7 +193,13 @@ export default function Table<T, State = never, RowState = never, Key extends st
}
setPreviouslySelectedKey(key)
},
[items, previouslySelectedKey, /* should never change */ getKey]
[
items,
previouslySelectedKey,
shortcuts,
/* should never change */ setSelectedKeys,
/* should never change */ getKey,
]
)
const headerRow = (
@ -231,9 +256,18 @@ export default function Table<T, State = never, RowState = never, Key extends st
}}
allowContextMenu={
selectedKeys.size === 0 ||
!selectedKeys.has(key) ||
(selectedKeys.size === 1 && selectedKeys.has(key))
}
onClick={onRowClick}
onContextMenu={(_innerProps, event) => {
if (!selectedKeys.has(key)) {
event.preventDefault()
event.stopPropagation()
setPreviouslySelectedKey(key)
setSelectedKeys(new Set([key]))
}
}}
/>
)
})
@ -241,7 +275,7 @@ export default function Table<T, State = never, RowState = never, Key extends st
return (
<table
className="rounded-rows self-start table-fixed border-collapse mt-2"
className="grow rounded-rows self-start table-fixed border-collapse mt-2"
onContextMenu={event => {
onContextMenu(selectedKeys, event, setSelectedKeys)
}}
@ -257,6 +291,7 @@ export default function Table<T, State = never, RowState = never, Key extends st
</tr>
)}
</tbody>
{footer}
</table>
)
}

View File

@ -33,36 +33,38 @@ interface InitialRowStateProp<RowState> {
interface InternalTableRowInnerProps<T, Key extends string = string> {
key: Key
item: T
setItem: (newItem: T) => void
setItem: React.Dispatch<React.SetStateAction<T>>
}
/** State and setters passed to event handlers on a {@link TableRow}. */
export type TableRowInnerProps<
T,
TableRowState = never,
State = never,
RowState = never,
Key extends string = string
> = InternalTableRowInnerProps<T, Key> &
([TableRowState] extends never ? unknown : InternalTableRowStateProps<TableRowState>)
([RowState] extends never ? unknown : InternalTableRowStateProps<RowState>) &
([State] extends never ? unknown : StateProp<State>)
/** Props for a {@link TableRow}. */
interface InternalBaseTableRowProps<
T,
State = never,
TableRowState = never,
Key extends string = string
> extends Omit<JSX.IntrinsicElements['tr'], 'onClick' | 'onContextMenu'> {
interface InternalBaseTableRowProps<T, State = never, RowState = never, Key extends string = string>
extends Omit<JSX.IntrinsicElements['tr'], 'onClick' | 'onContextMenu'> {
keyProp: Key
tableRowRef?: React.RefObject<HTMLTableRowElement>
item: T
/** Pass this in only if `item` also needs to be updated in the parent component. */
setItem?: React.Dispatch<React.SetStateAction<T>>
state?: State
initialRowState?: TableRowState
columns: tableColumn.TableColumn<T, State, TableRowState, Key>[]
initialRowState?: RowState
/** Pass this in only if `rowState` also needs to be updated in the parent component. */
setRowState?: React.Dispatch<React.SetStateAction<RowState>>
columns: tableColumn.TableColumn<T, State, RowState, Key>[]
selected: boolean
setSelected: (selected: boolean) => void
allowContextMenu: boolean
onClick: (props: TableRowInnerProps<T, TableRowState, Key>, event: React.MouseEvent) => void
onClick: (props: TableRowInnerProps<T, State, RowState, Key>, event: React.MouseEvent) => void
onContextMenu?: (
props: TableRowInnerProps<T, TableRowState, Key>,
props: TableRowInnerProps<T, State, RowState, Key>,
event: React.MouseEvent<HTMLTableRowElement>
) => void
}
@ -83,13 +85,17 @@ export default function TableRow<T, State = never, RowState = never, Key extends
) {
const {
keyProp: key,
tableRowRef,
item: rawItem,
setItem: rawSetItem,
state,
initialRowState,
setRowState: rawSetRowState,
columns,
selected,
setSelected,
// This prop is unused here, but is useful for components wrapping this component.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
allowContextMenu,
onClick,
onContextMenu,
@ -98,15 +104,20 @@ export default function TableRow<T, State = never, RowState = never, Key extends
} = props
const { unsetModal } = modalProvider.useSetModal()
/** The internal state for this row. This may change as backend requests are sent. */
// This hook is not called conditionally. `setItem` either always exists, or never exists.
// eslint-disable-next-line react-hooks/rules-of-hooks
const [item, setItem] = rawSetItem != null ? [rawItem, rawSetItem] : React.useState(rawItem)
const [fallbackItem, fallbackSetItem] = React.useState(rawItem)
/** The item represented by this row. This may change as backend requests are sent. */
const [item, setItem] =
rawSetItem != null ? [rawItem, rawSetItem] : [fallbackItem, fallbackSetItem]
/** This is SAFE, as the type is defined such that they MUST be present when `RowState` is not
* `never`.
* See the type definitions of {@link TableRowProps} and `TableProps`. */
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const [rowState, setRowState] = React.useState<RowState>(initialRowState!)
const [fallbackRowState, fallbackSetRowState] = React.useState<RowState>(initialRowState!)
/** The internal state for this row. This may change as backend requests are sent. */
const [rowState, setRowState] =
initialRowState != null && rawSetRowState != null
? [initialRowState, rawSetRowState]
: [fallbackRowState, fallbackSetRowState]
React.useEffect(() => {
if (rawSetItem == null) {
@ -114,25 +125,28 @@ export default function TableRow<T, State = never, RowState = never, Key extends
}
}, [rawItem, /* should never change */ setItem, /* should never change */ rawSetItem])
const innerProps: TableRowInnerProps<T, RowState, Key> = {
const innerProps: TableRowInnerProps<T, State, RowState, Key> = {
key,
item,
setItem,
// This is SAFE, as the type is defined such that they MUST be present when `State` is not
//`never`.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
state: state!,
rowState,
setRowState,
}
return (
<tr
ref={tableRowRef}
tabIndex={-1}
onClick={event => {
unsetModal()
onClick(innerProps, event)
}}
onContextMenu={event => {
if (allowContextMenu) {
onContextMenu?.(innerProps, event)
}
onContextMenu?.(innerProps, event)
}}
className={`h-10 transition duration-300 ease-in-out ${className ?? ''} ${
selected ? 'selected' : ''

View File

@ -18,10 +18,10 @@ declare module '../../hooks' {
/** Possible types of asset state change. */
export enum AssetEventType {
createProject = 'create-project',
createDirectory = 'create-directory',
newProject = 'new-project',
newFolder = 'new-folder',
uploadFiles = 'upload-files',
createSecret = 'create-secret',
newSecret = 'new-secret',
openProject = 'open-project',
cancelOpeningAllProjects = 'cancel-opening-all-projects',
deleteMultiple = 'delete-multiple',
@ -36,10 +36,10 @@ interface AssetBaseEvent<Type extends AssetEventType> {
/** All possible events. */
interface AssetEvents {
createProject: AssetCreateProjectEvent
createDirectory: AssetCreateDirectoryEvent
newProject: AssetNewProjectEvent
newFolder: AssetNewFolderEvent
uploadFiles: AssetUploadFilesEvent
createSecret: AssetCreateSecretEvent
newSecret: AssetNewSecretEvent
openProject: AssetOpenProjectEvent
cancelOpeningAllProjects: AssetCancelOpeningAllProjectsEvent
deleteMultiple: AssetDeleteMultipleEvent
@ -58,14 +58,14 @@ type SanityCheck<
> = T
/** A signal to create a project. */
export interface AssetCreateProjectEvent extends AssetBaseEvent<AssetEventType.createProject> {
export interface AssetNewProjectEvent extends AssetBaseEvent<AssetEventType.newProject> {
placeholderId: backendModule.ProjectId
templateId: string | null
onSpinnerStateChange: ((state: spinner.SpinnerState) => void) | null
}
/** A signal to create a directory. */
export interface AssetCreateDirectoryEvent extends AssetBaseEvent<AssetEventType.createDirectory> {
export interface AssetNewFolderEvent extends AssetBaseEvent<AssetEventType.newFolder> {
placeholderId: backendModule.DirectoryId
}
@ -75,14 +75,14 @@ export interface AssetUploadFilesEvent extends AssetBaseEvent<AssetEventType.upl
}
/** A signal to create a secret. */
export interface AssetCreateSecretEvent extends AssetBaseEvent<AssetEventType.createSecret> {
export interface AssetNewSecretEvent extends AssetBaseEvent<AssetEventType.newSecret> {
placeholderId: backendModule.SecretId
value: string
}
/** A signal to open the specified project. */
export interface AssetOpenProjectEvent extends AssetBaseEvent<AssetEventType.openProject> {
id: backendModule.AssetId
id: backendModule.ProjectId
}
/** A signal to cancel automatically opening any project that is currently opening. */

View File

@ -14,10 +14,10 @@ declare module '../../hooks' {
/** Possible changes to the file list. */
export enum AssetListEventType {
createDirectory = 'create-directory',
createProject = 'create-project',
newFolder = 'new-folder',
newProject = 'new-project',
uploadFiles = 'upload-files',
createSecret = 'create-secret',
newSecret = 'new-secret',
delete = 'delete',
}
@ -28,10 +28,10 @@ interface AssetListBaseEvent<Type extends AssetListEventType> {
/** All possible events. */
interface AssetListEvents {
createDirectory: AssetListCreateDirectoryEvent
createProject: AssetListCreateProjectEvent
newFolder: AssetListNewFolderEvent
newProject: AssetListNewProjectEvent
uploadFiles: AssetListUploadFilesEvent
createSecret: AssetListCreateSecretEvent
newSecret: AssetListNewSecretEvent
delete: AssetListDeleteEvent
}
@ -48,13 +48,14 @@ type SanityCheck<
> = T
/** A signal to create a new directory. */
interface AssetListCreateDirectoryEvent
extends AssetListBaseEvent<AssetListEventType.createDirectory> {
interface AssetListNewFolderEvent extends AssetListBaseEvent<AssetListEventType.newFolder> {
parentKey: backend.DirectoryId | null
parentId: backend.DirectoryId | null
}
/** A signal to create a new project. */
interface AssetListCreateProjectEvent extends AssetListBaseEvent<AssetListEventType.createProject> {
interface AssetListNewProjectEvent extends AssetListBaseEvent<AssetListEventType.newProject> {
parentKey: backend.DirectoryId | null
parentId: backend.DirectoryId | null
templateId: string | null
onSpinnerStateChange: ((state: spinner.SpinnerState) => void) | null
@ -62,18 +63,21 @@ interface AssetListCreateProjectEvent extends AssetListBaseEvent<AssetListEventT
/** A signal to upload files. */
interface AssetListUploadFilesEvent extends AssetListBaseEvent<AssetListEventType.uploadFiles> {
parentKey: backend.DirectoryId | null
parentId: backend.DirectoryId | null
files: File[]
}
/** A signal to create a new secret. */
interface AssetListCreateSecretEvent extends AssetListBaseEvent<AssetListEventType.createSecret> {
interface AssetListNewSecretEvent extends AssetListBaseEvent<AssetListEventType.newSecret> {
parentKey: backend.DirectoryId | null
parentId: backend.DirectoryId | null
name: string
value: string
}
/** A signal to delete a file. */
/** A signal that a file has been deleted. This must not be called before the request is
* finished. */
interface AssetListDeleteEvent extends AssetListBaseEvent<AssetListEventType.delete> {
id: backend.AssetId
}

View File

@ -39,10 +39,15 @@ export class LocalBackend extends backend.Backend {
}
}
/** Return the root directory id for the given user. */
override rootDirectoryId(): backend.DirectoryId {
return backend.DirectoryId('')
}
/** Return a list of assets in a directory.
*
* @throws An error if the JSON-RPC call fails. */
async listDirectory(): Promise<backend.AnyAsset[]> {
override async listDirectory(): Promise<backend.AnyAsset[]> {
const result = await this.projectManager.listProjects({})
return result.projects.map(project => ({
type: backend.AssetType.project,
@ -64,7 +69,7 @@ export class LocalBackend extends backend.Backend {
/** Return a list of projects belonging to the current user.
*
* @throws An error if the JSON-RPC call fails. */
async listProjects(): Promise<backend.ListedProject[]> {
override async listProjects(): Promise<backend.ListedProject[]> {
const result = await this.projectManager.listProjects({})
return result.projects.map(project => ({
name: project.name,
@ -82,7 +87,9 @@ export class LocalBackend extends backend.Backend {
/** Create a project.
*
* @throws An error if the JSON-RPC call fails. */
async createProject(body: backend.CreateProjectRequestBody): Promise<backend.CreatedProject> {
override async createProject(
body: backend.CreateProjectRequestBody
): Promise<backend.CreatedProject> {
const project = await this.projectManager.createProject({
name: projectManager.ProjectName(body.projectName),
...(body.projectTemplateName != null
@ -104,7 +111,7 @@ export class LocalBackend extends backend.Backend {
/** Close the project identified by the given project ID.
*
* @throws An error if the JSON-RPC call fails. */
async closeProject(projectId: backend.ProjectId, title: string | null): Promise<void> {
override async closeProject(projectId: backend.ProjectId, title: string | null): Promise<void> {
if (LocalBackend.currentlyOpeningProjectId === projectId) {
LocalBackend.currentlyOpeningProjectId = null
}
@ -124,7 +131,7 @@ export class LocalBackend extends backend.Backend {
/** Close the project identified by the given project ID.
*
* @throws An error if the JSON-RPC call fails. */
async getProjectDetails(projectId: backend.ProjectId): Promise<backend.Project> {
override async getProjectDetails(projectId: backend.ProjectId): Promise<backend.Project> {
const cachedProject = LocalBackend.currentlyOpenProjects.get(projectId)
if (cachedProject == null) {
const result = await this.projectManager.listProjects({})
@ -186,7 +193,7 @@ export class LocalBackend extends backend.Backend {
/** Prepare a project for execution.
*
* @throws An error if the JSON-RPC call fails. */
async openProject(
override async openProject(
projectId: backend.ProjectId,
_body: backend.OpenProjectRequestBody | null,
title: string | null
@ -215,7 +222,7 @@ export class LocalBackend extends backend.Backend {
/** Change the name of a project.
*
* @throws An error if the JSON-RPC call fails. */
async projectUpdate(
override async projectUpdate(
projectId: backend.ProjectId,
body: backend.ProjectUpdateRequestBody
): Promise<backend.UpdatedProject> {
@ -257,7 +264,10 @@ export class LocalBackend extends backend.Backend {
/** Delete a project.
*
* @throws An error if the JSON-RPC call fails. */
async deleteProject(projectId: backend.ProjectId, title: string | null): Promise<void> {
override async deleteProject(
projectId: backend.ProjectId,
title: string | null
): Promise<void> {
if (LocalBackend.currentlyOpeningProjectId === projectId) {
LocalBackend.currentlyOpeningProjectId = null
}

View File

@ -195,6 +195,15 @@ export class RemoteBackend extends backend.Backend {
throw new Error(message)
}
/** Return the root directory id for the given user. */
override rootDirectoryId(user: backend.UserOrOrganization | null): backend.DirectoryId {
return backend.DirectoryId(
// `user` is only null when the user is offline, in which case the remote backend cannot
// be accessed anyway.
user != null ? user.id.replace(/^organization-/, `${backend.AssetType.directory}-`) : ''
)
}
/** Return a list of all users in the same organization. */
async listUsers(): Promise<backend.SimpleUser[]> {
const response = await this.get<ListUsersResponseBody>(LIST_USERS_PATH)

View File

@ -1,152 +0,0 @@
/** @file A registry for keyboard and mouse shortcuts. */
import * as React from 'react'
import * as detect from 'enso-common/src/detect'
// =============
// === Types ===
// =============
/** All possible mouse actions for which shortcuts can be registered. */
export enum MouseAction {
editName = 'edit-name',
selectAdditional = 'select-additional',
selectRange = 'select-range',
selectAdditionalRange = 'select-additional-range',
}
/** All possible keyboard actions for which shortcuts can be registered. */
export enum KeyboardAction {
closeModal = 'close-modal',
cancelEditName = 'cancel-edit-name',
}
/** Valid mouse buttons. The values of each enum member is its corresponding value of
* `MouseEvent.button`. */
export enum MouseButton {
left = 0,
middle = 1,
right = 2,
back = 3,
forward = 4,
}
/** Restrictions on modifier keys that can trigger a shortcut.
*
* If a key is omitted, the shortcut will be triggered regardless of its value in the event. */
interface Modifiers {
ctrl?: boolean
alt?: boolean
shift?: boolean
meta?: boolean
}
/** A keyboard shortcut. */
export interface KeyboardShortcut extends Modifiers {
// Every printable character is a valid value for `key`, so unions and enums are both
// not an option here.
key: string
}
/** A mouse shortcut. If a key is omitted, that means its value does not matter. */
export interface MouseShortcut extends Modifiers {
button: MouseButton
}
/** All possible modifier keys. */
export type ModifierKey = 'Alt' | 'Ctrl' | 'Meta' | 'Shift'
// ===========================
// === modifiersMatchEvent ===
// ===========================
/** Return `true` if and only if the modifiers match the evenet's modifier key states. */
function modifiersMatchEvent(
modifiers: Modifiers,
event: KeyboardEvent | MouseEvent | React.KeyboardEvent | React.MouseEvent
) {
return (
('ctrl' in modifiers ? event.ctrlKey === modifiers.ctrl : true) &&
('alt' in modifiers ? event.altKey === modifiers.alt : true) &&
('shift' in modifiers ? event.shiftKey === modifiers.shift : true) &&
('meta' in modifiers ? event.metaKey === modifiers.meta : true)
)
}
// ========================
// === ShortcutRegistry ===
// ========================
/** Holds all keyboard and mouse shortcuts, and provides functions to detect them. */
export class ShortcutRegistry {
/** Create a {@link ShortcutRegistry}. */
constructor(
public keyboardShortcuts: Record<KeyboardAction, KeyboardShortcut[]>,
public mouseShortcuts: Record<MouseAction, MouseShortcut[]>
) {}
/** Return `true` if the specified action is being triggered by the given event. */
matchesKeyboardAction(action: KeyboardAction, event: KeyboardEvent | React.KeyboardEvent) {
return this.keyboardShortcuts[action].some(
shortcut => shortcut.key === event.key && modifiersMatchEvent(shortcut, event)
)
}
/** Return `true` if the specified action is being triggered by the given event. */
matchesMouseAction(action: MouseAction, event: MouseEvent | React.MouseEvent) {
return this.mouseShortcuts[action].some(
shortcut => shortcut.button === event.button && modifiersMatchEvent(shortcut, event)
)
}
}
/** A shorthand for creating a {@link KeyboardShortcut}. Should only be used in
* {@link DEFAULT_KEYBOARD_SHORTCUTS}. */
function keybind(modifiers: ModifierKey[], key: string): KeyboardShortcut {
return {
key: key,
ctrl: modifiers.includes('Ctrl'),
alt: modifiers.includes('Alt'),
shift: modifiers.includes('Shift'),
meta: modifiers.includes('Meta'),
}
}
/** A shorthand for creating a {@link MouseShortcut}. Should only be used in
* {@link DEFAULT_MOUSE_SHORTCUTS}. */
function mousebind(modifiers: ModifierKey[], button: MouseButton): MouseShortcut {
return {
button: button,
ctrl: modifiers.includes('Ctrl'),
alt: modifiers.includes('Alt'),
shift: modifiers.includes('Shift'),
meta: modifiers.includes('Meta'),
}
}
// =================
// === Constants ===
// =================
/** The equivalent of the `Control` key for the current platform. */
const CTRL = detect.platform() === detect.Platform.macOS ? 'Meta' : 'Ctrl'
/** The default keyboard shortcuts. */
const DEFAULT_KEYBOARD_SHORTCUTS: Record<KeyboardAction, KeyboardShortcut[]> = {
[KeyboardAction.closeModal]: [keybind([], 'Escape')],
[KeyboardAction.cancelEditName]: [keybind([], 'Escape')],
}
/** The default mouse shortcuts. */
const DEFAULT_MOUSE_SHORTCUTS: Record<MouseAction, MouseShortcut[]> = {
[MouseAction.editName]: [mousebind([CTRL], MouseButton.left)],
[MouseAction.selectAdditional]: [mousebind([CTRL], MouseButton.left)],
[MouseAction.selectRange]: [mousebind(['Shift'], MouseButton.left)],
[MouseAction.selectAdditionalRange]: [mousebind([CTRL, 'Shift'], MouseButton.left)],
}
/** The global instance of the shortcut registry. */
export const SHORTCUT_REGISTRY = new ShortcutRegistry(
DEFAULT_KEYBOARD_SHORTCUTS,
DEFAULT_MOUSE_SHORTCUTS
)

View File

@ -0,0 +1,435 @@
/** @file A registry for keyboard and mouse shortcuts. */
import * as React from 'react'
import AddConnectorIcon from 'enso-assets/add_connector.svg'
import AddFolderIcon from 'enso-assets/add_folder.svg'
import AddNetworkIcon from 'enso-assets/add_network.svg'
import BlankIcon from 'enso-assets/blank_16.svg'
import CameraIcon from 'enso-assets/camera.svg'
import CloudToIcon from 'enso-assets/cloud_to.svg'
import CopyIcon from 'enso-assets/copy.svg'
import DataDownloadIcon from 'enso-assets/data_download.svg'
import DataUploadIcon from 'enso-assets/data_upload.svg'
import DuplicateIcon from 'enso-assets/duplicate.svg'
import OpenIcon from 'enso-assets/open.svg'
import PenIcon from 'enso-assets/pen.svg'
import PeopleIcon from 'enso-assets/people.svg'
import ScissorsIcon from 'enso-assets/scissors.svg'
import TagIcon from 'enso-assets/tag.svg'
import TrashIcon from 'enso-assets/trash.svg'
import * as detect from 'enso-common/src/detect'
// This file MUST be a `.tsx` file so that Tailwind includes the CSS classes used here.
// =================
// === Constants ===
// =================
/** The size (both width and height) of icons. */
export const ICON_SIZE_PX = 16
// =============
// === Types ===
// =============
/** All possible mouse actions for which shortcuts can be registered. */
export enum MouseAction {
editName = 'edit-name',
selectAdditional = 'select-additional',
selectRange = 'select-range',
selectAdditionalRange = 'select-additional-range',
}
/** All possible keyboard actions for which shortcuts can be registered. */
export enum KeyboardAction {
open = 'open',
uploadToCloud = 'upload-to-cloud',
rename = 'rename',
snapshot = 'snapshot',
moveToTrash = 'move-to-trash',
moveAllToTrash = 'move-all-to-trash',
share = 'share',
label = 'label',
duplicate = 'duplicate',
copy = 'copy',
cut = 'cut',
download = 'download',
uploadFiles = 'upload-files',
newProject = 'new-project',
newFolder = 'new-folder',
newDataConnector = 'new-data-connector',
closeModal = 'close-modal',
cancelEditName = 'cancel-edit-name',
}
/** Valid mouse buttons. The values of each enum member is its corresponding value of
* `MouseEvent.button`. */
export enum MouseButton {
left = 0,
middle = 1,
right = 2,
back = 3,
forward = 4,
}
/** Restrictions on modifier keys that can trigger a shortcut.
*
* If a key is omitted, the shortcut will be triggered regardless of its value in the event. */
interface Modifiers {
ctrl?: boolean
alt?: boolean
shift?: boolean
meta?: boolean
}
/** A keyboard shortcut. */
export interface KeyboardShortcut extends Modifiers {
// Every printable character is a valid value for `key`, so unions and enums are both
// not an option here.
key: string
action: KeyboardAction
}
/** A mouse shortcut. If a key is omitted, that means its value does not matter. */
export interface MouseShortcut extends Modifiers {
button: MouseButton
action: MouseAction
}
/** All possible modifier keys. */
export type ModifierKey = (typeof MODIFIERS)[number]
/** A list of all possible modifier keys, in order. */
export const MODIFIERS =
detect.platform() === detect.Platform.macOS
? // This is required to derive the `ModifierKey` type above.
// eslint-disable-next-line no-restricted-syntax
(['Meta', 'Shift', 'Alt', 'Ctrl'] as const)
: // eslint-disable-next-line no-restricted-syntax
(['Ctrl', 'Shift', 'Alt', 'Meta'] as const)
// =============================
// === makeKeyboardActionMap ===
// =============================
/** Create a mapping from {@link KeyboardAction} to `T`. */
function makeKeyboardActionMap<T>(make: () => T): Record<KeyboardAction, T> {
return {
[KeyboardAction.open]: make(),
[KeyboardAction.uploadToCloud]: make(),
[KeyboardAction.rename]: make(),
[KeyboardAction.snapshot]: make(),
[KeyboardAction.moveToTrash]: make(),
[KeyboardAction.moveAllToTrash]: make(),
[KeyboardAction.share]: make(),
[KeyboardAction.label]: make(),
[KeyboardAction.duplicate]: make(),
[KeyboardAction.copy]: make(),
[KeyboardAction.cut]: make(),
[KeyboardAction.download]: make(),
[KeyboardAction.uploadFiles]: make(),
[KeyboardAction.newProject]: make(),
[KeyboardAction.newFolder]: make(),
[KeyboardAction.newDataConnector]: make(),
[KeyboardAction.closeModal]: make(),
[KeyboardAction.cancelEditName]: make(),
}
}
// ====================
// === ShortcutInfo ===
// ====================
/** Data needed to render a keyboard shortcut in a context menu. */
export interface ShortcutInfo {
name: string
/** A URL to the image representing this shortcut. */
icon: string
/** A Tailwind class for the desired color of the icon. It should be in the form `text-<color>`,
* where `<color>` is replaced with the actual color. */
colorClass?: string
}
// ===============================
// === getModifierKeyssOfEvent ===
// ===============================
/** Extracts the list of active {@link ModifierKey}s in an event.
* This is useful for displaying the modifier keys in the UI. */
export function getModifierKeysOfShortcut(event: KeyboardShortcut | MouseShortcut): ModifierKey[] {
return [
...(event.meta === true ? (['Meta'] satisfies ModifierKey[]) : []),
...(event.shift === true ? (['Shift'] satisfies ModifierKey[]) : []),
...(event.alt === true ? (['Alt'] satisfies ModifierKey[]) : []),
...(event.ctrl === true ? (['Ctrl'] satisfies ModifierKey[]) : []),
]
}
// ===========================
// === modifiersMatchEvent ===
// ===========================
/** Return `true` if and only if the modifiers match the event's modifier key states. */
function modifiersMatchEvent(
modifiers: Modifiers,
event: KeyboardEvent | MouseEvent | React.KeyboardEvent | React.MouseEvent
) {
return (
('ctrl' in modifiers ? event.ctrlKey === modifiers.ctrl : true) &&
('alt' in modifiers ? event.altKey === modifiers.alt : true) &&
('shift' in modifiers ? event.shiftKey === modifiers.shift : true) &&
('meta' in modifiers ? event.metaKey === modifiers.meta : true)
)
}
// ========================
// === ShortcutRegistry ===
// ========================
/** Holds all keyboard and mouse shortcuts, and provides functions to detect them. */
export class ShortcutRegistry {
keyboardShortcutsByKey: Record<string, KeyboardShortcut[]> = {}
allKeyboardHandlers: Record<
KeyboardAction,
((event: KeyboardEvent | React.KeyboardEvent) => void)[]
> = makeKeyboardActionMap(() => [])
/** The last handler (if any) for each action in
* {@link ShortcutRegistry.allKeyboardHandlers}. */
activeKeyboardHandlers: Record<
KeyboardAction,
((event: KeyboardEvent | React.KeyboardEvent) => void) | null
> = makeKeyboardActionMap(() => null)
/** Create a {@link ShortcutRegistry}. */
constructor(
public keyboardShortcuts: Record<KeyboardAction, KeyboardShortcut[]>,
public mouseShortcuts: Record<MouseAction, MouseShortcut[]>,
public keyboardShortcutInfo: Record<KeyboardAction, ShortcutInfo>
) {
this.updateKeyboardShortcutsByKey()
}
/** Create a new {@link ShortcutRegistry} with default values. */
static createWithDefaults() {
return new this(
{ ...DEFAULT_KEYBOARD_SHORTCUTS },
{ ...DEFAULT_MOUSE_SHORTCUTS },
{ ...DEFAULT_KEYBOARD_SHORTCUT_INFO }
)
}
/** Return `true` if the shortcut is being triggered by the keyboard event. */
matchesKeyboardShortcut(
this: void,
shortcut: KeyboardShortcut,
event: KeyboardEvent | React.KeyboardEvent
) {
return (
shortcut.key.toUpperCase() === event.key.toUpperCase() &&
modifiersMatchEvent(shortcut, event)
)
}
/** Return `true` if the shortcut is being triggered by the mouse event. */
matchesMouseShortcut(
this: void,
shortcut: MouseShortcut,
event: MouseEvent | React.MouseEvent
) {
return shortcut.button === event.button && modifiersMatchEvent(shortcut, event)
}
/** Return `true` if the action is being triggered by the keyboard event. */
matchesKeyboardAction(action: KeyboardAction, event: KeyboardEvent | React.KeyboardEvent) {
return this.keyboardShortcuts[action].some(shortcut =>
this.matchesKeyboardShortcut(shortcut, event)
)
}
/** Return `true` if the action is being triggered by the mouse event. */
matchesMouseAction(action: MouseAction, event: MouseEvent | React.MouseEvent) {
return this.mouseShortcuts[action].some(shortcut =>
this.matchesMouseShortcut(shortcut, event)
)
}
/** Trigger the appropriate handler for the action matching the currently pressed shortcut
* (if any). Return `true` if a matching action was found, otherwise return `false`. */
handleKeyboardEvent(event: KeyboardEvent | React.KeyboardEvent) {
// `event` is missing `.key` on a `keydown` event that fires after signing out.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (event.key != null) {
for (const shortcut of this.keyboardShortcutsByKey[event.key.toUpperCase()] ?? []) {
if (this.matchesKeyboardShortcut(shortcut, event)) {
const handler = this.activeKeyboardHandlers[shortcut.action]
if (handler != null) {
handler(event)
// The matching `false` return is immediately after this loop.
// eslint-disable-next-line no-restricted-syntax
return true
}
}
}
}
return false
}
/** Regenerate {@link ShortcutRegistry.keyboardShortcutsByKey}. */
updateKeyboardShortcutsByKey() {
this.keyboardShortcutsByKey = {}
for (const shortcuts of Object.values(this.keyboardShortcuts)) {
for (const shortcut of shortcuts) {
const byKey = this.keyboardShortcutsByKey[shortcut.key.toUpperCase()]
if (byKey != null) {
byKey.unshift(shortcut)
} else {
this.keyboardShortcutsByKey[shortcut.key.toUpperCase()] = [shortcut]
}
}
}
}
/** Regenerate {@link ShortcutRegistry.activeKeyboardHandlers}. */
updateActiveKeyboardHandlers() {
for (const action of Object.values(KeyboardAction)) {
const handlers = this.allKeyboardHandlers[action]
this.activeKeyboardHandlers[action] = handlers[handlers.length - 1] ?? null
}
}
/** Update the currently active handler for each action, and return a function to unregister
* these handlers. */
registerKeyboardHandlers(
handlers: Partial<
Record<KeyboardAction, (event: KeyboardEvent | React.KeyboardEvent) => void>
>
) {
for (const action of Object.values(KeyboardAction)) {
const handler = handlers[action]
if (handler != null) {
this.allKeyboardHandlers[action].push(handler)
this.activeKeyboardHandlers[action] = handler
}
}
const allNewHandlers = new Set(Object.values(handlers))
return () => {
for (const handlersForCurrentAction of Object.values(this.allKeyboardHandlers)) {
// Remove in-place the handlers that were added.
handlersForCurrentAction.splice(
0,
handlersForCurrentAction.length,
...handlersForCurrentAction.filter(handler => !allNewHandlers.has(handler))
)
}
this.updateActiveKeyboardHandlers()
}
}
}
/** A shorthand for creating a {@link KeyboardShortcut}. Should only be used in
* {@link DEFAULT_KEYBOARD_SHORTCUTS}. */
function keybind(action: KeyboardAction, modifiers: ModifierKey[], key: string): KeyboardShortcut {
return {
key,
action,
ctrl: modifiers.includes('Ctrl'),
alt: modifiers.includes('Alt'),
shift: modifiers.includes('Shift'),
meta: modifiers.includes('Meta'),
}
}
/** A shorthand for creating a {@link MouseShortcut}. Should only be used in
* {@link DEFAULT_MOUSE_SHORTCUTS}. */
function mousebind(
action: MouseAction,
modifiers: ModifierKey[],
button: MouseButton
): MouseShortcut {
return {
button,
action,
ctrl: modifiers.includes('Ctrl'),
alt: modifiers.includes('Alt'),
shift: modifiers.includes('Shift'),
meta: modifiers.includes('Meta'),
}
}
// =================
// === Constants ===
// =================
/** The equivalent of the `Control` key for the current platform. */
const CTRL = (detect.platform() === detect.Platform.macOS ? 'Meta' : 'Ctrl') satisfies ModifierKey
/** The key known as the `Delete` key for the current platform. */
const DELETE = detect.platform() === detect.Platform.macOS ? 'Backspace' : 'Delete'
/** The default keyboard shortcuts. */
const DEFAULT_KEYBOARD_SHORTCUTS: Record<KeyboardAction, KeyboardShortcut[]> = {
[KeyboardAction.open]: [keybind(KeyboardAction.open, [], 'Enter')],
[KeyboardAction.uploadToCloud]: [],
[KeyboardAction.rename]: [keybind(KeyboardAction.rename, [CTRL], 'R')],
[KeyboardAction.snapshot]: [keybind(KeyboardAction.snapshot, [CTRL], 'S')],
[KeyboardAction.moveToTrash]: [keybind(KeyboardAction.moveToTrash, [], DELETE)],
[KeyboardAction.moveAllToTrash]: [keybind(KeyboardAction.moveAllToTrash, [], DELETE)],
[KeyboardAction.share]: [keybind(KeyboardAction.share, [CTRL], 'Enter')],
[KeyboardAction.label]: [keybind(KeyboardAction.label, [CTRL], 'L')],
[KeyboardAction.duplicate]: [keybind(KeyboardAction.duplicate, [CTRL], 'D')],
[KeyboardAction.copy]: [keybind(KeyboardAction.copy, [CTRL], 'C')],
[KeyboardAction.cut]: [keybind(KeyboardAction.cut, [CTRL], 'X')],
[KeyboardAction.download]: [keybind(KeyboardAction.download, [CTRL, 'Shift'], 'S')],
[KeyboardAction.uploadFiles]: [keybind(KeyboardAction.uploadFiles, [CTRL], 'U')],
[KeyboardAction.newProject]: [keybind(KeyboardAction.newProject, [CTRL], 'N')],
[KeyboardAction.newFolder]: [keybind(KeyboardAction.newFolder, [CTRL, 'Shift'], 'N')],
[KeyboardAction.newDataConnector]: [
keybind(KeyboardAction.newDataConnector, [CTRL, 'Alt'], 'N'),
],
[KeyboardAction.closeModal]: [keybind(KeyboardAction.closeModal, [], 'Escape')],
[KeyboardAction.cancelEditName]: [keybind(KeyboardAction.cancelEditName, [], 'Escape')],
}
/** The default UI data for every keyboard shortcut. */
const DEFAULT_KEYBOARD_SHORTCUT_INFO: Record<KeyboardAction, ShortcutInfo> = {
[KeyboardAction.open]: { name: 'Open', icon: OpenIcon },
[KeyboardAction.uploadToCloud]: { name: 'Upload To Cloud', icon: CloudToIcon },
[KeyboardAction.rename]: { name: 'Rename', icon: PenIcon },
[KeyboardAction.snapshot]: { name: 'Snapshot', icon: CameraIcon },
[KeyboardAction.moveToTrash]: {
name: 'Move To Trash',
icon: TrashIcon,
colorClass: 'text-delete',
},
[KeyboardAction.moveAllToTrash]: {
name: 'Move All To Trash',
icon: TrashIcon,
colorClass: 'text-delete',
},
[KeyboardAction.share]: { name: 'Share', icon: PeopleIcon },
[KeyboardAction.label]: { name: 'Label', icon: TagIcon },
[KeyboardAction.duplicate]: { name: 'Duplicate', icon: DuplicateIcon },
[KeyboardAction.copy]: { name: 'Copy', icon: CopyIcon },
[KeyboardAction.cut]: { name: 'Cut', icon: ScissorsIcon },
[KeyboardAction.download]: { name: 'Download', icon: DataDownloadIcon },
[KeyboardAction.uploadFiles]: { name: 'Upload Files', icon: DataUploadIcon },
[KeyboardAction.newProject]: { name: 'New Project', icon: AddNetworkIcon },
[KeyboardAction.newFolder]: { name: 'New Folder', icon: AddFolderIcon },
[KeyboardAction.newDataConnector]: { name: 'New Data Connector', icon: AddConnectorIcon },
// These should not appear in any context menus.
[KeyboardAction.closeModal]: { name: 'Close', icon: BlankIcon },
[KeyboardAction.cancelEditName]: { name: 'Cancel Editing', icon: BlankIcon },
}
/** The default mouse shortcuts. */
const DEFAULT_MOUSE_SHORTCUTS: Record<MouseAction, MouseShortcut[]> = {
[MouseAction.editName]: [mousebind(MouseAction.editName, [CTRL], MouseButton.left)],
[MouseAction.selectAdditional]: [
mousebind(MouseAction.selectAdditional, [CTRL], MouseButton.left),
],
[MouseAction.selectRange]: [mousebind(MouseAction.selectRange, ['Shift'], MouseButton.left)],
[MouseAction.selectAdditionalRange]: [
mousebind(MouseAction.selectAdditionalRange, [CTRL, 'Shift'], MouseButton.left),
],
}

View File

@ -1,5 +1,5 @@
/** @file Utility functions for extracting and manipulating file information. */
import FileIcon from 'enso-assets/file.svg'
import TextIcon from 'enso-assets/text.svg'
// ================================
// === Extract file information ===
@ -17,7 +17,7 @@ export function fileExtension(fileName: string) {
/** Returns the appropriate icon for a specific file extension. */
export function fileIcon() {
return FileIcon
return TextIcon
}
// ===================================

View File

@ -11,7 +11,7 @@ export type Modal = JSX.Element
/** State contained in a `SetModalContext`. */
interface SetModalContextType {
setModal: (modal: React.SetStateAction<Modal | null>) => void
setModal: React.Dispatch<React.SetStateAction<Modal | null>>
}
/** State contained in a `ModalContext`. */
@ -25,8 +25,8 @@ const ModalContext = React.createContext<ModalContextType>({
const SetModalContext = React.createContext<SetModalContextType>({
setModal: () => {
// Ignored. This default value will never be used
// as `ModalProvider` always provides its own value.
// Ignored. This default value will never be used as `ModalProvider` always provides
// its own value.
},
})
@ -48,7 +48,7 @@ export function ModalProvider(props: ModalProviderProps) {
/** Props for a {@link ModalProvider}. */
interface InternalSetModalProviderProps extends React.PropsWithChildren {
setModal: (modal: React.SetStateAction<Modal | null>) => void
setModal: React.Dispatch<React.SetStateAction<Modal | null>>
}
/** A React provider containing a function to set the currently active modal. */

View File

@ -0,0 +1,42 @@
/** @file The React provider for keyboard and mouse shortcuts, along with hooks to use the provider
* via the shared React context. */
import * as React from 'react'
import * as shortcutsModule from '../dashboard/shortcuts'
// ========================
// === ShortcutsContext ===
// ========================
/** State contained in a `ShortcutsContext`. */
export interface ShortcutsContextType {
shortcuts: shortcutsModule.ShortcutRegistry
}
// @ts-expect-error The default value will never be exposed as using this without a `Provider`
// is a mistake.
const ShortcutsContext = React.createContext<ShortcutsContextType>(null)
/** Props for a {@link ShortcutsProvider}. */
export interface ShortcutsProviderProps extends React.PropsWithChildren<object> {
shortcuts?: shortcutsModule.ShortcutRegistry
}
// =========================
// === ShortcutsProvider ===
// =========================
/** A React Provider that lets components get the shortcut registry. */
export function ShortcutsProvider(props: ShortcutsProviderProps) {
const { shortcuts: rawShortcuts, children } = props
const [shortcuts] = React.useState(
() => rawShortcuts ?? shortcutsModule.ShortcutRegistry.createWithDefaults()
)
return <ShortcutsContext.Provider value={{ shortcuts }}>{children}</ShortcutsContext.Provider>
}
/** Exposes a property to get the shortcut registry. */
export function useShortcuts() {
return React.useContext(ShortcutsContext)
}

View File

@ -33,8 +33,6 @@ export const theme = {
dim: 'rgba(0, 0, 0, 0.25)',
frame: 'rgba(255, 255, 255, 0.40)',
'frame-selected': 'rgba(255, 255, 255, 0.70)',
'black-a5': 'rgba(0, 0, 0, 0.05)',
'black-a10': 'rgba(0, 0, 0, 0.10)',
'tag-text': 'rgba(255, 255, 255, 0.90)',
'tag-text-2': 'rgba(0, 0, 0, 0.60)',
'permission-owner': 'rgba(236, 2, 2, 0.70)',
@ -45,20 +43,26 @@ export const theme = {
'permission-exec': 'rgba(236, 2, 2, 0.70)',
'permission-view': 'rgba(0, 0, 0, 0.10)',
'call-to-action': '#fa6c08',
'black-a5': 'rgba(0, 0, 0, 0.05)',
'black-a10': 'rgba(0, 0, 0, 0.10)',
'black-a16': 'rgba(0, 0, 0, 0.16)',
'black-a30': 'rgba(0, 0, 0, 0.30)',
'black-a50': 'rgba(0, 0, 0, 0.50)',
'gray-350': '#b7bcc5',
},
flexGrow: {
2: '2',
},
fontSize: {
xs: '0.71875rem',
sm: '0.8125rem',
},
lineHeight: {
'170': '170%',
},
spacing: {
'0.75': '0.1875rem',
'1.75': '0.4375rem',
'2.25': '0.5625rem',
'3.25': '0.8125rem',
'3.5': '0.875rem',
'4.75': '1.1875rem',
'5.5': '1.375rem',
'6.5': '1.625rem',
@ -70,6 +74,7 @@ export const theme = {
'30.25': '7.5625rem',
'42': '10.5rem',
'54': '13.5rem',
'57.5': '14.375rem',
'70': '17.5rem',
'83.5': '20.875rem',
'98.25': '24.5625rem',
@ -92,9 +97,7 @@ export const theme = {
backdropBlur: {
xs: '2px',
},
lineHeight: {
'170': '170%',
},
borderWidth: { '0.5': '0.5px' },
boxShadow: {
soft: `0 0.5px 2.2px 0px #00000008, 0 1.2px 5.3px 0px #0000000b, \
0 2.3px 10px 0 #0000000e, 0 4px 18px 0 #00000011, 0 7.5px 33.4px 0 #00000014, \