Split dashboard.tsx into smaller components (#6546)

* Consistent order for statements in `dashboard.tsx`; change functions back to lambdas

* Create convenience aliases for each asset type

* Remove obsolete FIXME

* Refactor out column renderers into components

* Enable `prefer-const` lint

* Remove hardcoded product name

* Add fixme

* Enable `react-hooks` lints (not working for some reason)

* Consistent messages for naming-convention lint overrides

* Enable `react` lints

* Extract out tables for each asset type

* Refactor out column display mode switcher to a file

* Switch VM check state to use an enum

* Fix lint errors

* Minor section change

* Fix position of create forms

* Fix bugs; improve debugging QoL

* Add documentation for new components

* Refactor out drive bar

* Refactor out event handlers to variables

* Minor clarifications

* Refactor out directory view; some fixes; improve React DX

There are still many issues when switching backends

* Add `assert`

* Use `backend.platform` instead of checking for properties

* Fix errors when switching backend

* Minor style changes; fix lint errors

* Fix assert behavior

* Change `Rows` to `Table`

* Fixes

* Fix lint errors

* Fix "show dashboard" button

* Implement click to rename

* Fix lint errors

* Fix lint errors (along with a bug in `devServiceWorker`)

* Enable dev-mode on `ide watch`

* Fix bug in `useAsyncEffect` introduced during merge

* More fixes; new debug hooks; fix infinite loop in `auth.tsx`

* Inline Cognito methods

* Remove redundant `Promise.resolve`s

* Fix column display

* Fixes

* Simplify modal type

* Fix bug when opening IDE

* Shift+click to select a range of table items

* Implement delete multiple

* Fixes

* Tick and cross for rename input; fixes

* Implement rename and delete directory and multi-delete directory; fixes

* Optimize modal re-rendering

* Make some internal `Props` private

* Remove old asset selection code

* Eliminate re-renders when clicking document body

* Fix name flickering when renaming

* Use static placeholders

* Avoid refreshing entire directory on rename

* Use asset name instead of ID in error messages

* QoL improvements

* Enable react lints and `strict-boolean-expressions`

* Extract dashboard feature flags to its own module

* Feature flag to show more toasts; minimize calls to `listDirectory`

* Deselect selection on delete; hide unused features; add exception to PascalCase lint

* Fix projects disappearing after being created

* Fix name of `projectEvent` module imports

* Re-disable delete when project is being closed

* Fix assets refreshing when adding new projects

* Refactor row state into `Table`; fix delete not being disabled again

* Address review

* Implement shortcut registry

* Fix stop icon spinning when switching backends (ported from #6919)

* Give columns names

* Immediately show project as opening

* Replace `asNewtype` with constructor functions

* Address review

* Minor bugfixes

* Prepare for optimistically updated tables

* wip 2

* Fix type errors

* Remove indirect usages of `doRefresh`

Updating the lists of items will need to be re-added later

* Remove `toastPromise`

* Fix `New_Directory_-Infinity` bug

* wip

* WIP: Begin restoring functionality to rows

* Fix most issues with DirectoriesTable

* Port optimistic UI from `DirectoriesTable` to all other asset tables

* Fix bugs in item list events for asset tables

* Merge `projectActionButton` into `projectsTable`

* Remove `RenameModal`; minor context menu bugfixes

* Fix bugs

* Remove small default user icon

* Fix more bugs

* Fix bugs

* Fix type error

* Address review and QA

* Fix optimistic UI for "manage permissions" modal

* Fix "share with" modal

* Fix template spinner disappearing

* Allow multiple projects to be opened on local backend; fix version lifecycle returned by local backend

* Fix minor bug when closing local project

---------

Co-authored-by: Paweł Buchowski <pawel.buchowski@enso.org>
This commit is contained in:
somebody1234 2023-07-19 19:48:39 +10:00 committed by GitHub
parent a5ec6a9e51
commit ce33af82f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
83 changed files with 6262 additions and 3080 deletions

View File

@ -38,7 +38,7 @@ const RELATIVE_MODULES =
'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|file-associations|index|ipc|log|naming|paths|preload|security|url-associations'
const STRING_LITERAL = ':matches(Literal[raw=/^["\']/], TemplateLiteral)'
const JSX = ':matches(JSXElement, JSXFragment)'
const NOT_PASCAL_CASE = '/^(?!_?([A-Z][a-z0-9]*)+$)/'
const NOT_PASCAL_CASE = '/^(?!do[A-Z])(?!_?([A-Z][a-z0-9]*)+$)/'
const NOT_CAMEL_CASE = '/^(?!_?[a-z][a-z0-9*]*([A-Z0-9][a-z0-9]*)*$)(?!React$)/'
const WHITELISTED_CONSTANTS = 'logger|.+Context|interpolationFunction.+'
const NOT_CONSTANT_CASE = `/^(?!${WHITELISTED_CONSTANTS}$|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$)/`
@ -103,7 +103,8 @@ const RESTRICTED_SYNTAXES = [
message: 'Use `for (const x of xs)`, not `for (let x of xs)`',
},
{
selector: 'TSTypeAliasDeclaration > TSTypeReference > Identifier',
selector:
'TSTypeAliasDeclaration > TSTypeReference:not(:has(.typeParameters)) > Identifier',
message: 'No renamed types',
},
{
@ -133,7 +134,7 @@ const RESTRICTED_SYNTAXES = [
},
{
// Matches non-functions.
selector: `:matches(Program, ExportNamedDeclaration, TSModuleBlock) > VariableDeclaration[kind=const] > VariableDeclarator[id.name=${NOT_CONSTANT_CASE}]:not(:has(:matches(ArrowFunctionExpression)))`,
selector: `:matches(Program, ExportNamedDeclaration, TSModuleBlock) > VariableDeclaration[kind=const] > VariableDeclarator[id.name=${NOT_CONSTANT_CASE}]:not(:matches(:has(ArrowFunctionExpression), :has(CallExpression[callee.object.name=newtype][callee.property.name=newtypeConstructor])))`,
message: 'Use `CONSTANT_CASE` for top-level constants that are not functions',
},
{
@ -225,6 +226,10 @@ const RESTRICTED_SYNTAXES = [
selector: 'IfStatement > ExpressionStatement',
message: 'Wrap `if` branches in `{}`',
},
{
selector: ':matches(ForStatement[test=null], ForStatement[test.value=true])',
message: 'Use `while (true)` instead of `for (;;)`',
},
{
selector: 'VariableDeclarator[id.name=ENVIRONMENT][init.value!=production]',
message: "Environment must be 'production' when committing",
@ -290,6 +295,7 @@ export default [
},
],
'sort-imports': ['error', { allowSeparatedGroups: true }],
'no-constant-condition': ['error', { checkLoops: false }],
'no-restricted-properties': [
'error',
{
@ -357,7 +363,10 @@ export default [
{ checksVoidReturn: { attributes: false } },
],
'@typescript-eslint/no-redundant-type-constituents': 'error',
'@typescript-eslint/no-unnecessary-condition': 'error',
'@typescript-eslint/no-unnecessary-condition': [
'error',
{ allowConstantLoopConditions: true },
],
'@typescript-eslint/no-useless-empty-export': 'error',
'@typescript-eslint/parameter-properties': ['error', { prefer: 'parameter-property' }],
'@typescript-eslint/prefer-enum-initializers': 'error',

View File

@ -0,0 +1,6 @@
<svg height="16" width="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="8" fill="#3e515fe5" fill-opacity="0.1" />
<path
d="M4.8 6.2L6.6 8 4.8 9.8A.5.5 0 1 0 6.2 11.2L8 9.4 9.8 11.2A.5.5 0 1 0 11.2 9.8L9.4 8 11.2 6.2A.5.5 0 1 0 9.8 4.8L8 6.6 6.2 4.8A.5.5 0 1 0 4.8 6.2Z"
fill="#3e515fe5" />
</svg>

After

Width:  |  Height:  |  Size: 357 B

View File

@ -0,0 +1,5 @@
<svg height="16" width="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="8" fill="#3e515fe5" fill-opacity="0.1" />
<path d="M3.4 9.2L5.7 11.5A1 1 0 0 0 7.3 11.5L12.6 6.2A.5.5 0 1 0 11.2 4.8L6.5 9.5 4.8 7.8A.5.5 0 1 0 3.4 9.2"
fill="#3e515fe5" />
</svg>

After

Width:  |  Height:  |  Size: 304 B

View File

@ -77,6 +77,8 @@ electron.contextBridge.exposeInMainWorld('enso_console', {
// === Authentication API ===
// ==========================
let currentDeepLinkHandler: ((event: Electron.IpcRendererEvent, url: string) => void) | null = null
/** Object exposed on the Electron main window; provides proxy functions to:
* - open OAuth flows in the system browser, and
* - handle deep links from the system browser or email client to the dashboard.
@ -102,10 +104,15 @@ const AUTHENTICATION_API = {
* `enso://authentication/register?code=...&state=...` from external sources like the user's
* system browser or email client. Handling the links involves resuming whatever flow was in
* progress when the link was opened (e.g., an OAuth registration flow). */
setDeepLinkHandler: (callback: (url: string) => void) =>
electron.ipcRenderer.on(ipc.Channel.openDeepLink, (_event, url: string) => {
setDeepLinkHandler: (callback: (url: string) => void) => {
if (currentDeepLinkHandler != null) {
electron.ipcRenderer.off(ipc.Channel.openDeepLink, currentDeepLinkHandler)
}
currentDeepLinkHandler = (_event, url: string) => {
callback(url)
}),
}
electron.ipcRenderer.on(ipc.Channel.openDeepLink, currentDeepLinkHandler)
},
/** Save the access token to a credentials file.
*
* The backend doesn't have access to Electron's `localStorage` so we need to save access token

View File

@ -231,7 +231,8 @@ export function generateDirectoryName(name: string): string {
// If the name already consists a suffix, reuse it.
const matches = name.match(/^(.*)_(\d+)$/)
let suffix = 0
const initialSuffix = -1
let suffix = initialSuffix
// Matches start with the whole match, so we need to skip it. Then come our two capture groups.
const [matchedName, matchedSuffix] = matches?.slice(1) ?? []
if (typeof matchedName !== 'undefined' && typeof matchedSuffix !== 'undefined') {
@ -240,7 +241,8 @@ export function generateDirectoryName(name: string): string {
}
const projectsDirectory = getProjectsDirectory()
for (; ; suffix++) {
while (true) {
suffix++
const candidatePath = pathModule.join(
projectsDirectory,
`${name}${suffix === 0 ? '' : `_${suffix}`}`

View File

@ -142,7 +142,7 @@ process.on('SIGINT', () => {
process.exit(0)
})
for (;;) {
while (true) {
console.log('Spawning Electron process.')
const electronProcess = childProcess.spawn('electron', ELECTRON_ARGS, {
stdio: 'inherit',

View File

@ -11,3 +11,29 @@
export function isRunningInElectron() {
return /electron/i.test(navigator.userAgent)
}
// ================
// === Platform ===
// ================
/** Possible platforms that the app may run on. */
export enum Platform {
unknown = 'Unknown platform',
windows = 'Windows',
macOS = 'macOS',
linux = 'Linux',
}
/** Returns the platform the app is currently running on.
* This is used to determine whether `metaKey` or `ctrlKey` is used in shortcuts. */
export function platform(): Platform {
if (/windows/i.test(navigator.userAgent)) {
return Platform.windows
} else if (/mac os/i.test(navigator.userAgent)) {
return Platform.macOS
} else if (/linux/i.test(navigator.userAgent)) {
return Platform.linux
} else {
return Platform.unknown
}
}

View File

@ -33,11 +33,11 @@
user-scalable = no"
/>
<title>Enso</title>
<link rel="stylesheet" href="/tailwind.css" />
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="/docsStyle.css" />
<script type="module" src="/index.js" defer></script>
<script type="module" src="/run.js" defer></script>
<link rel="stylesheet" href="./tailwind.css" />
<link rel="stylesheet" href="./style.css" />
<link rel="stylesheet" href="./docsStyle.css" />
<script type="module" src="./index.js" defer></script>
<script type="module" src="./run.js" defer></script>
<script
src="https://cdn.jsdelivr.net/npm/@twemoji/api@14.1.2/dist/twemoji.min.js"
integrity="sha384-D6GSzpW7fMH86ilu73eB95ipkfeXcMPoOGVst/L04yqSSe+RTUY0jXcuEIZk0wrT"

View File

@ -22,14 +22,14 @@ const logger = app.log.logger
/** The name of the `localStorage` key storing the initial URL of the app. */
const INITIAL_URL_KEY = `${common.PRODUCT_NAME.toLowerCase()}-initial-url`
/** Path to the SSE endpoint over which esbuild sends events. */
const ESBUILD_PATH = '/esbuild'
const ESBUILD_PATH = './esbuild'
/** SSE event indicating a build has finished. */
const ESBUILD_EVENT_NAME = 'change'
/** Path to the serice worker that caches assets for offline usage.
* In development, it also resolves all extensionless paths to `/index.html`.
* This is required for client-side routing to work when doing `./run gui watch`.
*/
const SERVICE_WORKER_PATH = '/serviceWorker.js'
const SERVICE_WORKER_PATH = './serviceWorker.js'
/** One second in milliseconds. */
const SECOND = 1000
/** Time in seconds after which a `fetchTimeout` ends. */

View File

@ -191,8 +191,10 @@ export class Cognito {
/** Return the current {@link UserSession}, or `None` if the user is not logged in.
*
* Will refresh the {@link UserSession} if it has expired. */
userSession() {
return userSession()
async userSession() {
const currentSession = await results.Result.wrapAsync(() => amplify.Auth.currentSession())
const amplifySession = currentSession.mapErr(intoCurrentSessionErrorKind)
return amplifySession.map(parseUserSession).unwrapOr(null)
}
/** Returns the associated organization ID of the current user, which is passed during signup,
@ -207,8 +209,17 @@ export class Cognito {
/** Sign up with username and password.
*
* Does not rely on federated identity providers (e.g., Google or GitHub). */
signUp(username: string, password: string, organizationId: string | null) {
return signUp(this.supportsDeepLinks, username, password, organizationId)
async signUp(username: string, password: string, organizationId: string | null) {
const result = await results.Result.wrapAsync(async () => {
const params = intoSignUpParams(
this.supportsDeepLinks,
username,
password,
organizationId
)
await amplify.Auth.signUp(params)
})
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignUpErrorOrThrow)
}
/** Send the email address verification code.
@ -217,8 +228,11 @@ export class Cognito {
* verification page. The email verification page will parse the verification code from the URL.
* If the verification code matches, the email address is marked as verified. Once the email
* address is verified, the user can sign in. */
confirmSignUp(email: string, code: string) {
return confirmSignUp(email, code)
async confirmSignUp(email: string, code: string) {
const result = await results.Result.wrapAsync(async () => {
await amplify.Auth.confirmSignUp(email, code)
})
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoConfirmSignUpErrorOrThrow)
}
/** Sign in via the Google federated identity provider.
@ -226,8 +240,13 @@ export class Cognito {
* This function will open the Google authentication page in the user's browser. The user will
* be asked to log in to their Google account, and then to grant access to the application.
* After the user has granted access, the browser will be redirected to the application. */
signInWithGoogle() {
return signInWithGoogle(this.customState())
async signInWithGoogle() {
const customState = this.customState()
const provider = amplify.CognitoHostedUIIdentityProvider.Google
await amplify.Auth.federatedSignIn({
provider,
...(customState != null ? { customState } : {}),
})
}
/** Sign in via the GitHub federated identity provider.
@ -235,20 +254,40 @@ export class Cognito {
* This function will open the GitHub authentication page in the user's browser. The user will
* be asked to log in to their GitHub account, and then to grant access to the application.
* After the user has granted access, the browser will be redirected to the application. */
signInWithGitHub() {
return signInWithGitHub()
async signInWithGitHub() {
await amplify.Auth.federatedSignIn({
customProvider: GITHUB_PROVIDER,
})
}
/** Sign in with the given username and password.
*
* Does not rely on external identity providers (e.g., Google or GitHub). */
signInWithPassword(username: string, password: string) {
return signInWithPassword(username, password)
async signInWithPassword(username: string, password: string) {
const result = await results.Result.wrapAsync(async () => {
await amplify.Auth.signIn(username, password)
})
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignInWithPasswordErrorOrThrow)
}
/** Sign out the current user. */
signOut() {
return signOut(this.logger)
async signOut() {
// FIXME [NP]: https://github.com/enso-org/cloud-v2/issues/341
// For some reason, the redirect back to the IDE from the browser doesn't work correctly so this
// `await` throws a timeout error. As a workaround, we catch this error and force a refresh of
// the session manually by running the `signOut` again. This works because Amplify will see that
// we've already signed out and clear the cache accordingly. Ideally we should figure out how
// to fix the redirect and remove this `catch`. This has the unintended consequence of catching
// any other errors that might occur during sign out, that we really shouldn't be catching. This
// also has the unintended consequence of delaying the sign out process by a few seconds (until
// the timeout occurs).
try {
await amplify.Auth.signOut()
} catch (error) {
this.logger.error('Sign out failed', error)
} finally {
await amplify.Auth.signOut()
}
}
/** Send a password reset email.
@ -256,8 +295,11 @@ export class Cognito {
* The user will be able to reset their password by following the link in the email, which takes
* them to the "reset password" page of the application. The verification code will be filled in
* automatically. */
forgotPassword(email: string) {
return forgotPassword(email)
async forgotPassword(email: string) {
const result = await results.Result.wrapAsync(async () => {
await amplify.Auth.forgotPassword(email)
})
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoForgotPasswordErrorOrThrow)
}
/** Submit a new password for the given email address.
@ -265,8 +307,11 @@ export class Cognito {
* The user will have received a verification code in an email, which they will have entered on
* the "reset password" page of the application. This function will submit the new password
* along with the verification code, changing the user's password. */
forgotPasswordSubmit(email: string, code: string, password: string) {
return forgotPasswordSubmit(email, code, password)
async forgotPasswordSubmit(email: string, code: string, password: string) {
const result = await results.Result.wrapAsync(async () => {
await amplify.Auth.forgotPasswordSubmit(email, code, password)
})
return result.mapErr(intoForgotPasswordSubmitErrorOrThrow)
}
/** Change a password for current authenticated user.
@ -275,8 +320,17 @@ export class Cognito {
* password, new password, and repeat new password to change their old password to the new
* one. The validation of the repeated new password is handled by the `changePasswordModel`
* component. */
changePassword(oldPassword: string, newPassword: string) {
return changePassword(oldPassword, newPassword)
async changePassword(oldPassword: string, newPassword: string) {
const cognitoUserResult = await currentAuthenticatedUser()
if (cognitoUserResult.ok) {
const cognitoUser = cognitoUserResult.unwrap()
const result = await results.Result.wrapAsync(async () => {
await amplify.Auth.changePassword(cognitoUser, oldPassword, newPassword)
})
return result.mapErr(intoAmplifyErrorOrThrow)
} else {
return results.Err(cognitoUserResult.val)
}
}
/** We want to signal to Amplify to fire a "custom state change" event when the user is
@ -320,21 +374,6 @@ export interface UserSession {
accessToken: string
}
/** Return the current `CognitoUserSession`, if one exists. */
async function userSession() {
const amplifySession = await getAmplifyCurrentSession()
return amplifySession.map(parseUserSession).toOption()
}
/** Return the current `CognitoUserSession` if the user is logged in, or `CurrentSessionErrorKind`
* otherwise.
*
* Will refresh the session if it has expired. */
async function getAmplifyCurrentSession() {
const currentSession = await results.Result.wrapAsync(() => amplify.Auth.currentSession())
return currentSession.mapErr(intoCurrentSessionErrorKind)
}
/** Parse a `CognitoUserSession` into a {@link UserSession}.
* @throws If the `email` field of the payload is not a string. */
function parseUserSession(session: cognito.CognitoUserSession): UserSession {
@ -372,21 +411,6 @@ function intoCurrentSessionErrorKind(error: unknown): CurrentSessionErrorKind {
// === SignUp ===
// ==============
/** A wrapper around the Amplify "sign up" endpoint that converts known errors
* to {@link SignUpError}s. */
async function signUp(
supportsDeepLinks: boolean,
username: string,
password: string,
organizationId: string | null
) {
const result = await results.Result.wrapAsync(async () => {
const params = intoSignUpParams(supportsDeepLinks, username, password, organizationId)
await amplify.Auth.signUp(params)
})
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignUpErrorOrThrow)
}
/** Format a username and password as an {@link amplify.SignUpParams}. */
function intoSignUpParams(
supportsDeepLinks: boolean,
@ -471,14 +495,6 @@ function intoSignUpErrorOrThrow(error: AmplifyError): SignUpError {
// === ConfirmSignUp ===
// =====================
/** A wrapper around the Amplify "confirm sign up" endpoint that converts known errors
* to {@link ConfirmSignUpError}s. */
async function confirmSignUp(email: string, code: string) {
return results.Result.wrapAsync(async () => {
await amplify.Auth.confirmSignUp(email, code)
}).then(result => result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoConfirmSignUpErrorOrThrow))
}
const CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR = {
internalCode: 'NotAuthorizedException',
internalMessage: 'User cannot be confirmed. Current status is CONFIRMED',
@ -514,44 +530,10 @@ function intoConfirmSignUpErrorOrThrow(error: AmplifyError): ConfirmSignUpError
}
}
// ========================
// === SignInWithGoogle ===
// ========================
/** A wrapper around the Amplify "sign in with Google" endpoint. */
async function signInWithGoogle(customState: string | null) {
const provider = amplify.CognitoHostedUIIdentityProvider.Google
const options = {
provider,
...(customState != null ? { customState } : {}),
}
await amplify.Auth.federatedSignIn(options)
}
// ========================
// === SignInWithGoogle ===
// ========================
/** A wrapper around the Amplify confirm "sign in with GitHub" endpoint. */
async function signInWithGitHub() {
await amplify.Auth.federatedSignIn({
customProvider: GITHUB_PROVIDER,
})
}
// ==========================
// === SignInWithPassword ===
// ==========================
/** A wrapper around the Amplify "sign in with password" endpoint that converts known errors
* to {@link SignInWithPasswordError}s. */
async function signInWithPassword(username: string, password: string) {
const result = await results.Result.wrapAsync(async () => {
await amplify.Auth.signIn(username, password)
})
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignInWithPasswordErrorOrThrow)
}
/** Internal IDs of errors that may occur when signing in with a password. */
type SignInWithPasswordErrorKind = 'NotAuthorized' | 'UserNotConfirmed' | 'UserNotFound'
@ -589,11 +571,12 @@ function intoSignInWithPasswordErrorOrThrow(error: AmplifyError): SignInWithPass
// ======================
// === ForgotPassword ===
// ======================
const FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR = {
internalCode: 'InvalidParameterException',
kind: 'UserNotConfirmed',
message: `Cannot reset password for the user as there is no registered/verified email or \
phone_number`,
message: `Cannot reset password for the user as there is no registered/verified email \
or phone_number`,
} as const
const FORGOT_PASSWORD_USER_NOT_FOUND_ERROR = {
@ -601,14 +584,6 @@ const FORGOT_PASSWORD_USER_NOT_FOUND_ERROR = {
kind: 'UserNotFound',
} as const
/** A wrapper around the Amplify "forgot password" endpoint that converts known errors
* to {@link ForgotPasswordError}s. */
async function forgotPassword(email: string) {
return results.Result.wrapAsync(async () => {
await amplify.Auth.forgotPassword(email)
}).then(result => result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoForgotPasswordErrorOrThrow))
}
/** Internal IDs of errors that may occur when requesting a password reset. */
type ForgotPasswordErrorKind =
| (typeof FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR)['kind']
@ -646,15 +621,6 @@ function intoForgotPasswordErrorOrThrow(error: AmplifyError): ForgotPasswordErro
// === ForgotPasswordSubmit ===
// ============================
/** A wrapper around the Amplify "forgot password submit" endpoint that converts known errors
* to {@link ForgotPasswordSubmitError}s. */
async function forgotPasswordSubmit(email: string, code: string, password: string) {
const result = await results.Result.wrapAsync(async () => {
await amplify.Auth.forgotPasswordSubmit(email, code, password)
})
return result.mapErr(intoForgotPasswordSubmitErrorOrThrow)
}
/** Internal IDs of errors that may occur when resetting a password. */
type ForgotPasswordSubmitErrorKind = 'AmplifyError' | 'AuthError'
@ -683,30 +649,6 @@ function intoForgotPasswordSubmitErrorOrThrow(error: unknown): ForgotPasswordSub
}
}
// ===============
// === SignOut ===
// ===============
/** A wrapper around the Amplify "sign out" endpoint. */
async function signOut(logger: loggerProvider.Logger) {
// FIXME [NP]: https://github.com/enso-org/cloud-v2/issues/341
// For some reason, the redirect back to the IDE from the browser doesn't work correctly so this
// `await` throws a timeout error. As a workaround, we catch this error and force a refresh of
// the session manually by running the `signOut` again. This works because Amplify will see that
// we've already signed out and clear the cache accordingly. Ideally we should figure out how
// to fix the redirect and remove this `catch`. This has the unintended consequence of catching
// any other errors that might occur during sign out, that we really shouldn't be catching. This
// also has the unintended consequence of delaying the sign out process by a few seconds (until
// the timeout occurs).
try {
await amplify.Auth.signOut()
} catch (error) {
logger.error('Sign out failed', error)
} finally {
await amplify.Auth.signOut()
}
}
// ======================
// === ChangePassword ===
// ======================
@ -724,18 +666,3 @@ async function currentAuthenticatedUser() {
)
return result.mapErr(intoAmplifyErrorOrThrow)
}
/** A wrapper around the Amplify "change password submit" endpoint that converts known errors
* to {@link AmplifyError}s. */
async function changePassword(oldPassword: string, newPassword: string) {
const cognitoUserResult = await currentAuthenticatedUser()
if (cognitoUserResult.ok) {
const cognitoUser = cognitoUserResult.unwrap()
const result = await results.Result.wrapAsync(async () => {
await amplify.Auth.changePassword(cognitoUser, oldPassword, newPassword)
})
return result.mapErr(intoAmplifyErrorOrThrow)
} else {
return results.Err(cognitoUserResult.val)
}
}

View File

@ -31,8 +31,8 @@ function ConfirmRegistration() {
const { verificationCode, email } = parseUrlSearchParams(location.search)
// No dependencies means this runs on every render, however this component should immediately
// navigate away so it shouldn't exist for more than a few renders.
// No dependencies means this runs on every render, however this component immediately
// navigates away so it should not exist for more than a few renders.
React.useEffect(() => {
if (email == null || verificationCode == null) {
navigate(app.LOGIN_PATH)

View File

@ -12,54 +12,58 @@
import * as newtype from '../newtype'
// =================
// === Constants ===
// =================
/** AWS region in which our Cognito pool is located. */
export const AWS_REGION = newtype.asNewtype<AwsRegion>('eu-west-1')
/** Complete list of OAuth scopes used by the app. */
export const OAUTH_SCOPES = [
newtype.asNewtype<OAuthScope>('email'),
newtype.asNewtype<OAuthScope>('openid'),
]
/** OAuth response type used in the OAuth flows. */
export const OAUTH_RESPONSE_TYPE = newtype.asNewtype<OAuthResponseType>('code')
// =============
// === Types ===
// =============
// These are constructor functions that construct values of the type they are named after.
/* eslint-disable @typescript-eslint/no-redeclare */
/** The AWS region in which our Cognito pool is located. This is always set to `eu-west-1` because
* that is the only region in which our Cognito pools are currently available in. */
type AwsRegion = newtype.Newtype<string, 'AwsRegion'>
/** Create an {@link AwsRegion}. */
const AwsRegion = newtype.newtypeConstructor<AwsRegion>()
/** ID of the "Cognito user pool" that contains authentication & identity data of our users.
*
* This is created automatically by our Terraform scripts when the backend infrastructure is
* created. Look in the `enso-org/cloud-v2` repo for details. */
export type UserPoolId = newtype.Newtype<string, 'UserPoolId'>
/** Create a {@link UserPoolId}. */
export const UserPoolId = newtype.newtypeConstructor<UserPoolId>()
/** ID of an OAuth client authorized to interact with the Cognito user pool specified by the
* {@link UserPoolId}.
*
* This is created automatically by our Terraform scripts when the backend infrastructure is
* created. Look in the `enso-org/cloud-v2` repo for details. */
export type UserPoolWebClientId = newtype.Newtype<string, 'UserPoolWebClientId'>
/** Create a {@link UserPoolWebClientId}. */
export const UserPoolWebClientId = newtype.newtypeConstructor<UserPoolWebClientId>()
/** Domain of the Cognito user pool used for authenticating/identifying the user.
*
* This must correspond to the public-facing domain name of the Cognito pool identified by the
* {@link UserPoolId}, and must not contain an HTTP scheme, or a pathname. */
export type OAuthDomain = newtype.Newtype<string, 'OAuthDomain'>
/** Create a {@link OAuthDomain}. */
export const OAuthDomain = newtype.newtypeConstructor<OAuthDomain>()
/** Possible OAuth scopes to request from the federated identity provider during OAuth sign-in. */
type OAuthScope = newtype.Newtype<string, 'OAuthScope'>
/** Create an {@link OAuthScope}. */
const OAuthScope = newtype.newtypeConstructor<OAuthScope>()
/** The response type used to complete the OAuth flow. "code" means that the federated identity
* provider will return an authorization code that can be exchanged for an access token. The
* authorization code will be provided as a query parameter of the redirect URL. */
type OAuthResponseType = newtype.Newtype<string, 'OAuthResponseType'>
/** Create an {@link OAuthResponseType}. */
const OAuthResponseType = newtype.newtypeConstructor<OAuthResponseType>()
/** The URL used as a redirect (minus query parameters like `code` which get appended later), once
* an OAuth flow (e.g., sign-in or sign-out) has completed. These must match the values set in the
* Cognito pool and during the creation of the OAuth client. See the `enso-org/cloud-v2` repo for
* details. */
export type OAuthRedirect = newtype.Newtype<string, 'OAuthRedirect'>
/** Create a {@link OAuthRedirect}. */
export const OAuthRedirect = newtype.newtypeConstructor<OAuthRedirect>()
/* eslint-enable @typescript-eslint/no-redeclare */
/** Callback used to open URLs for the OAuth flow. This is only used in the desktop app (i.e. not in
* the cloud). This is because in the cloud we just keep the user in their browser, but in the app
* we want to open OAuth URLs in the system browser. This is because the user can't be expected to
@ -73,6 +77,17 @@ export type AccessTokenSaver = (accessToken: string) => void
* user is redirected back to the app from the system browser, after completing an OAuth flow. */
export type RegisterOpenAuthenticationUrlCallbackFn = () => void
// =================
// === Constants ===
// =================
/** AWS region in which our Cognito pool is located. */
export const AWS_REGION = AwsRegion('eu-west-1')
/** Complete list of OAuth scopes used by the app. */
export const OAUTH_SCOPES = [OAuthScope('email'), OAuthScope('openid')]
/** OAuth response type used in the OAuth flows. */
export const OAUTH_RESPONSE_TYPE = OAuthResponseType('code')
// =====================
// === AmplifyConfig ===
// =====================

View File

@ -15,7 +15,6 @@ import * as errorModule from '../../error'
import * as http from '../../http'
import * as localBackend from '../../dashboard/localBackend'
import * as loggerProvider from '../../providers/logger'
import * as newtype from '../../newtype'
import * as remoteBackend from '../../dashboard/remoteBackend'
import * as sessionProvider from './session'
@ -37,7 +36,7 @@ const MESSAGES = {
resetPasswordSuccess: 'Successfully reset password!',
signOutLoading: 'Logging out...',
signOutSuccess: 'Successfully logged out!',
signOutError: 'Error logging out, please try again.',
signOutError: 'Could not log out, please try again.',
pleaseWait: 'Please wait...',
} as const
@ -194,6 +193,7 @@ export function AuthProvider(props: AuthProviderProps) {
const goOfflineInternal = React.useCallback(() => {
setInitialized(true)
setUserSession(OFFLINE_USER_SESSION)
setBackendWithoutSavingType(new localBackend.LocalBackend())
if (supportsLocalBackend) {
setBackendWithoutSavingType(new localBackend.LocalBackend())
} else {
@ -218,8 +218,9 @@ export function AuthProvider(props: AuthProviderProps) {
// This is identical to `hooks.useOnlineCheck`, however it is inline here to avoid any possible
// circular dependency.
React.useEffect(() => {
// `navigator.onLine` is not a dependency so that the app doesn't make the remote backend
// completely unusable on unreliable connections.
// `navigator.onLine` is not a dependency of this `useEffect` (so this effect is not called
// when `navigator.onLine` changes) - the internet being down should not immediately disable
// the remote backend.
if (!navigator.onLine) {
void goOffline()
}
@ -235,12 +236,11 @@ export function AuthProvider(props: AuthProviderProps) {
if (!navigator.onLine || forceOfflineMode) {
goOfflineInternal()
setForceOfflineMode(false)
} else if (session.none) {
} else if (session == null) {
setInitialized(true)
setUserSession(null)
} else {
const { accessToken, email } = session.val
const headers = new Headers([['Authorization', `Bearer ${accessToken}`]])
const headers = new Headers([['Authorization', `Bearer ${session.accessToken}`]])
const client = new http.Client(headers)
const backend = new remoteBackend.RemoteBackend(client, logger)
// The backend MUST be the remote backend before login is finished.
@ -252,11 +252,11 @@ export function AuthProvider(props: AuthProviderProps) {
) {
setBackendWithoutSavingType(backend)
}
let organization
// eslint-disable-next-line no-restricted-syntax
while (organization === undefined) {
let organization: backendModule.UserOrOrganization | null
while (true) {
try {
organization = await backend.usersMe()
break
} catch {
// The value may have changed after the `await`.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@ -277,25 +277,24 @@ export function AuthProvider(props: AuthProviderProps) {
history.replaceState(null, '', url.toString())
}
let newUserSession: UserSession
const sharedSessionData = { email, accessToken }
if (!organization) {
if (organization == null) {
newUserSession = {
type: UserSessionType.partial,
...sharedSessionData,
...session,
}
} else {
newUserSession = {
type: UserSessionType.full,
...sharedSessionData,
...session,
organization,
}
/** Save access token so can be reused by Enso backend. */
cognito.saveAccessToken(accessToken)
cognito.saveAccessToken(session.accessToken)
/** Execute the callback that should inform the Electron app that the user has logged in.
* This is done to transition the app from the authentication/dashboard view to the IDE. */
onAuthenticated(accessToken)
onAuthenticated(session.accessToken)
}
setUserSession(newUserSession)
@ -306,12 +305,14 @@ export function AuthProvider(props: AuthProviderProps) {
fetchSession().catch(error => {
if (isUserFacingError(error)) {
toast.error(error.message)
logger.error(error.message)
} else {
logger.error(error)
}
})
// `userSession` MUST NOT be a dependency as this effect always does a `setUserSession`,
// which would result in an infinite loop.
// `userSession` MUST NOT be a dependency as `setUserSession` is called every time
// by this effect. Because it is an object literal, it will never be equal to the previous
// value.
// `initialized` MUST NOT be a dependency as it breaks offline mode.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
@ -390,15 +391,15 @@ export function AuthProvider(props: AuthProviderProps) {
} else {
try {
const organizationId = await authService.cognito.organizationId()
// This should not omit success and error toasts as it is not possible
// to render this optimistically.
await toast.promise(
backend.createUser({
userName: username,
userEmail: newtype.asNewtype<backendModule.EmailAddress>(email),
userEmail: backendModule.EmailAddress(email),
organizationId:
organizationId != null
? newtype.asNewtype<backendModule.UserOrOrganizationId>(
organizationId
)
? backendModule.UserOrOrganizationId(organizationId)
: null,
}),
{
@ -451,6 +452,8 @@ export function AuthProvider(props: AuthProviderProps) {
deinitializeSession()
setInitialized(false)
setUserSession(null)
// This should not omit success and error toasts as it is not possible
// to render this optimistically.
await toast.promise(cognito.signOut(), {
success: MESSAGES.signOutSuccess,
error: MESSAGES.signOutError,
@ -535,7 +538,7 @@ export function ProtectedLayout() {
const { session } = useAuth()
const shouldPreventNavigation = getShouldPreventNavigation()
if (!shouldPreventNavigation && !session) {
if (!shouldPreventNavigation && session == null) {
return <router.Navigate to={app.LOGIN_PATH} />
} else if (!shouldPreventNavigation && session?.type === UserSessionType.partial) {
return <router.Navigate to={app.SET_USERNAME_PATH} />

View File

@ -2,8 +2,6 @@
* currently authenticated user's session. */
import * as React from 'react'
import * as results from 'ts-results'
import * as cognito from '../cognito'
import * as error from '../../error'
import * as hooks from '../../hooks'
@ -15,12 +13,12 @@ import * as listen from '../listen'
/** State contained in a {@link SessionContext}. */
interface SessionContextType {
session: results.Option<cognito.UserSession>
session: cognito.UserSession | null
/** Set `initialized` to false. Must be called when logging out. */
deinitializeSession: () => void
}
/** See `AuthContext` for safety details. */
/** See {@link AuthContext} for safety details. */
const SessionContext = React.createContext<SessionContextType>(
// eslint-disable-next-line no-restricted-syntax
{} as SessionContextType
@ -45,7 +43,7 @@ export interface SessionProviderProps {
* is initially served. */
mainPageUrl: URL
registerAuthEventListener: listen.ListenFunction
userSession: () => Promise<results.Option<cognito.UserSession>>
userSession: () => Promise<cognito.UserSession | null>
children: React.ReactNode
}
@ -63,13 +61,13 @@ export function SessionProvider(props: SessionProviderProps) {
* set. This is useful when a user has just logged in (as their cached credentials are
* out of date, so this will update them). */
const session = hooks.useAsyncEffect(
results.None,
null,
async () => {
const innerSession = await userSession()
setInitialized(true)
return innerSession
},
[userSession, refresh]
[refresh]
)
/** Register an effect that will listen for authentication events. When the event occurs, we
@ -79,8 +77,6 @@ export function SessionProvider(props: SessionProviderProps) {
* For example, if a user clicks the signout button, this will clear the user's session, which
* means we want the login screen to render (which is a child of this provider). */
React.useEffect(() => {
/** Handle Cognito authentication events
* @throws {error.UnreachableCaseError} Never. */
const listener: listen.ListenerCallback = event => {
switch (event) {
case listen.AuthEvent.signIn:
@ -112,7 +108,7 @@ export function SessionProvider(props: SessionProviderProps) {
* cleaned up between renders. This must be done because the `useEffect` will be called
* multiple times during the lifetime of the component. */
return cancel
}, [doRefresh, mainPageUrl, registerAuthEventListener])
}, [doRefresh, registerAuthEventListener, mainPageUrl])
const deinitializeSession = () => {
setInitialized(false)

View File

@ -11,7 +11,6 @@ import * as cognito from './cognito'
import * as config from '../config'
import * as listen from './listen'
import * as loggerProvider from '../providers/logger'
import * as newtype from '../newtype'
// =============
// === Types ===
@ -40,9 +39,7 @@ const LOGIN_PATHNAME = '//auth/login'
const REGISTRATION_PATHNAME = '//auth/registration'
/** URI used as the OAuth redirect when deep links are supported. */
const DEEP_LINK_REDIRECT = newtype.asNewtype<auth.OAuthRedirect>(
`${common.DEEP_LINK_SCHEME}://auth`
)
const DEEP_LINK_REDIRECT = auth.OAuthRedirect(`${common.DEEP_LINK_SCHEME}://auth`)
/** OAuth redirect URLs for the electron app. */
const DEEP_LINK_REDIRECTS: AmplifyRedirects = {
redirectSignIn: DEEP_LINK_REDIRECT,
@ -64,35 +61,23 @@ const BASE_AMPLIFY_CONFIG = {
const AMPLIFY_CONFIGS = {
/** Configuration for @indiv0's Cognito user pool. */
npekin: {
userPoolId: newtype.asNewtype<auth.UserPoolId>('eu-west-1_AXX1gMvpx'),
userPoolWebClientId: newtype.asNewtype<auth.UserPoolWebClientId>(
'1rpnb2n1ijn6o5529a7ob017o'
),
domain: newtype.asNewtype<auth.OAuthDomain>(
'npekin-enso-domain.auth.eu-west-1.amazoncognito.com'
),
userPoolId: auth.UserPoolId('eu-west-1_AXX1gMvpx'),
userPoolWebClientId: auth.UserPoolWebClientId('1rpnb2n1ijn6o5529a7ob017o'),
domain: auth.OAuthDomain('npekin-enso-domain.auth.eu-west-1.amazoncognito.com'),
...BASE_AMPLIFY_CONFIG,
} satisfies Partial<auth.AmplifyConfig>,
/** Configuration for @pbuchu's Cognito user pool. */
pbuchu: {
userPoolId: newtype.asNewtype<auth.UserPoolId>('eu-west-1_jSF1RbgPK'),
userPoolWebClientId: newtype.asNewtype<auth.UserPoolWebClientId>(
'1bnib0jfon3aqc5g3lkia2infr'
),
domain: newtype.asNewtype<auth.OAuthDomain>(
'pb-enso-domain.auth.eu-west-1.amazoncognito.com'
),
userPoolId: auth.UserPoolId('eu-west-1_jSF1RbgPK'),
userPoolWebClientId: auth.UserPoolWebClientId('1bnib0jfon3aqc5g3lkia2infr'),
domain: auth.OAuthDomain('pb-enso-domain.auth.eu-west-1.amazoncognito.com'),
...BASE_AMPLIFY_CONFIG,
} satisfies Partial<auth.AmplifyConfig>,
/** Configuration for the production Cognito user pool. */
production: {
userPoolId: newtype.asNewtype<auth.UserPoolId>('eu-west-1_9Kycu2SbD'),
userPoolWebClientId: newtype.asNewtype<auth.UserPoolWebClientId>(
'4j9bfs8e7415erf82l129v0qhe'
),
domain: newtype.asNewtype<auth.OAuthDomain>(
'production-enso-domain.auth.eu-west-1.amazoncognito.com'
),
userPoolId: auth.UserPoolId('eu-west-1_9Kycu2SbD'),
userPoolWebClientId: auth.UserPoolWebClientId('4j9bfs8e7415erf82l129v0qhe'),
domain: auth.OAuthDomain('production-enso-domain.auth.eu-west-1.amazoncognito.com'),
...BASE_AMPLIFY_CONFIG,
} satisfies Partial<auth.AmplifyConfig>,
}

View File

@ -76,6 +76,22 @@ export const FORGOT_PASSWORD_PATH = '/forgot-password'
export const RESET_PASSWORD_PATH = '/password-reset'
/** Path to the set username page. */
export const SET_USERNAME_PATH = '/set-username'
/** A {@link RegExp} matching all paths. */
export const ALL_PATHS_REGEX = new RegExp(
`(?:${DASHBOARD_PATH}|${LOGIN_PATH}|${REGISTRATION_PATH}|${CONFIRM_REGISTRATION_PATH}|` +
`${FORGOT_PASSWORD_PATH}|${RESET_PASSWORD_PATH}|${SET_USERNAME_PATH})$`
)
// ======================
// === getMainPageUrl ===
// ======================
/** Returns the URL to the main page. This is the current URL, with the current route removed. */
function getMainPageUrl() {
const mainPageUrl = new URL(window.location.href)
mainPageUrl.pathname = mainPageUrl.pathname.replace(ALL_PATHS_REGEX, '')
return mainPageUrl
}
// ===========
// === App ===
@ -112,12 +128,8 @@ function App(props: AppProps) {
* will redirect the user between the login/register pages and the dashboard. */
return (
<>
<toast.Toaster
toastOptions={{ style: { maxWidth: '100%' } }}
position="top-center"
reverseOrder={false}
/>
<Router>
<toast.Toaster toastOptions={{ style: { maxWidth: '100%' } }} position="top-center" />
<Router basename={getMainPageUrl().pathname}>
<AppRouter {...props} />
</Router>
</>
@ -142,13 +154,11 @@ function AppRouter(props: AppProps) {
onAuthenticated,
} = props
const navigate = hooks.useNavigate()
// FIXME[sb]: After platform detection for Electron is merged in, `IS_DEV_MODE` should be
// set to true on `ide watch`.
if (IS_DEV_MODE) {
// @ts-expect-error This is used exclusively for debugging.
window.navigate = navigate
}
const mainPageUrl = new URL(window.location.href)
const mainPageUrl = getMainPageUrl()
const authService = React.useMemo(() => {
const authConfig = { navigate, ...props }
return authServiceModule.initAuthService(authConfig)

View File

@ -3,6 +3,24 @@
import * as auth from './authentication/config'
import * as newtype from './newtype'
// =============
// === Types ===
// =============
/** Base URL for requests to our Cloud API backend. */
type ApiUrl = newtype.Newtype<`http://${string}` | `https://${string}`, 'ApiUrl'>
/** Create an {@link ApiUrl}. */
// This is a constructor function that constructs values of the type after which it is named.
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const ApiUrl = newtype.newtypeConstructor<ApiUrl>()
/** URL to the websocket endpoint of the Help Chat. */
type ChatUrl = newtype.Newtype<`ws://${string}` | `wss://${string}`, 'ChatUrl'>
// This is a constructor function that constructs values of the type after which it is named.
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const ChatUrl = newtype.newtypeConstructor<ChatUrl>()
// =================
// === Constants ===
// =================
@ -19,15 +37,15 @@ const CLOUD_REDIRECTS = {
* The redirect URL must be known ahead of time because it is registered with the OAuth provider
* when it is created. In the native app, the port is unpredictable, but this is not a problem
* because the native app does not use port-based redirects, but deep links. */
development: newtype.asNewtype<auth.OAuthRedirect>('http://localhost:8080'),
production: newtype.asNewtype<auth.OAuthRedirect>(REDIRECT_OVERRIDE ?? CLOUD_DOMAIN),
development: auth.OAuthRedirect('http://localhost:8080'),
production: auth.OAuthRedirect(REDIRECT_OVERRIDE ?? CLOUD_DOMAIN),
}
/** All possible API URLs, sorted by environment. */
const API_URLS = {
pbuchu: newtype.asNewtype<ApiUrl>('https://xw0g8j3tsb.execute-api.eu-west-1.amazonaws.com'),
npekin: newtype.asNewtype<ApiUrl>('https://s02ejyepk1.execute-api.eu-west-1.amazonaws.com'),
production: newtype.asNewtype<ApiUrl>('https://7aqkn3tnbc.execute-api.eu-west-1.amazonaws.com'),
pbuchu: ApiUrl('https://xw0g8j3tsb.execute-api.eu-west-1.amazonaws.com'),
npekin: ApiUrl('https://s02ejyepk1.execute-api.eu-west-1.amazonaws.com'),
production: ApiUrl('https://7aqkn3tnbc.execute-api.eu-west-1.amazonaws.com'),
}
/**
@ -36,9 +54,8 @@ const API_URLS = {
* In development mode, the chat bot will need to be run locally:
* https://github.com/enso-org/enso-bot */
const CHAT_URLS = {
development: newtype.asNewtype<ChatUrl>('ws://localhost:8082'),
// TODO[sb]: Insert the actual URL of the production chat bot here.
production: newtype.asNewtype<ChatUrl>('wss://chat.cloud.enso.org'),
development: ChatUrl('ws://localhost:8082'),
production: ChatUrl('wss://chat.cloud.enso.org'),
}
/** All possible configuration options, sorted by environment. */
@ -89,9 +106,3 @@ export type Environment = 'npekin' | 'pbuchu' | 'production'
// ===========
// === API ===
// ===========
/** Base URL for requests to our Cloud API backend. */
type ApiUrl = newtype.Newtype<`http://${string}` | `https://${string}`, 'ApiUrl'>
/** URL to the websocket endpoint of the Help Chat. */
type ChatUrl = newtype.Newtype<`ws://${string}` | `wss://${string}`, 'ChatUrl'>

View File

@ -13,41 +13,68 @@ export enum BackendType {
remote = 'remote',
}
// These are constructor functions that construct values of the type they are named after.
/* eslint-disable @typescript-eslint/no-redeclare */
/** 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 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 */
export type AssetId = DirectoryId | FileId | ProjectId | SecretId
/** 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 */
/** A user/organization in the application. These are the primary owners of a project. */
export interface UserOrOrganization {
@ -59,6 +86,13 @@ export interface UserOrOrganization {
isEnabled: boolean
}
/** A `Directory` returned by `createDirectory`. */
export interface CreatedDirectory {
id: DirectoryId
parentId: DirectoryId
title: string
}
/** Possible states that a project can be in. */
export enum ProjectState {
created = 'Created',
@ -261,13 +295,22 @@ export interface UserPermissions {
permissions: PermissionAction[]
}
/** The type returned from the "update directory" endpoint. */
export interface UpdatedDirectory {
id: DirectoryId
parentId: DirectoryId
title: string
}
/** Metadata uniquely identifying a directory entry.
* These can be Projects, Files, Secrets, or other directories. */
export interface BaseAsset {
id: AssetId
title: string
modifiedAt: dateTime.Rfc3339DateTime | null
parentId: AssetId
/** This is defined as a generic {@link AssetId} in the backend, however it is more convenient
* (and currently safe) to assume it is always a {@link DirectoryId}. */
parentId: DirectoryId
permissions: UserPermission[] | null
}
@ -295,8 +338,20 @@ export interface Asset<Type extends AssetType = AssetType> extends BaseAsset {
projectState: Type extends AssetType.project ? ProjectStateType : null
}
/** A convenience alias for {@link Asset}<{@link AssetType.project}>. */
export interface ProjectAsset extends Asset<AssetType.project> {}
/** A convenience alias for {@link Asset}<{@link AssetType.directory}>. */
export interface DirectoryAsset extends Asset<AssetType.directory> {}
/** A convenience alias for {@link Asset}<{@link AssetType.secret}>. */
export interface SecretAsset extends Asset<AssetType.secret> {}
/** A convenience alias for {@link Asset}<{@link AssetType.file}>. */
export interface FileAsset extends Asset<AssetType.file> {}
/** The type returned from the "create directory" endpoint. */
export interface Directory extends Asset<AssetType.directory> {}
export interface Directory extends DirectoryAsset {}
// =================
// === Constants ===
@ -309,6 +364,23 @@ export const ASSET_TYPE_NAME: Record<AssetType, string> = {
[AssetType.file]: 'file',
} as const
// ==============================
// === detectVersionLifecycle ===
// ==============================
/** Extract the {@link VersionLifecycle} from a version string. */
export function detectVersionLifecycle(version: string) {
if (/rc/i.test(version)) {
return VersionLifecycle.releaseCandidate
} else if (/\bnightly\b/i.test(version)) {
return VersionLifecycle.nightly
} else if (/\bdev\b|\balpha\b/i.test(version)) {
return VersionLifecycle.development
} else {
return VersionLifecycle.stable
}
}
// =================
// === Endpoints ===
// =================
@ -339,6 +411,11 @@ export interface CreateDirectoryRequestBody {
parentId: DirectoryId | null
}
/** HTTP request body for the "update directory" endpoint. */
export interface UpdateDirectoryRequestBody {
title: string
}
/** HTTP request body for the "create project" endpoint. */
export interface CreateProjectRequestBody {
projectName: string
@ -376,14 +453,14 @@ export interface CreateTagRequestBody {
/** URL query string parameters for the "list directory" endpoint. */
export interface ListDirectoryRequestParams {
parentId?: string
parentId: string | null
}
/** URL query string parameters for the "upload file" endpoint. */
export interface UploadFileRequestParams {
fileId?: string
fileName?: string
parentDirectoryId?: DirectoryId
fileId: string | null
fileName: string | null
parentDirectoryId: DirectoryId | null
}
/** URL query string parameters for the "list tags" endpoint. */
@ -406,6 +483,37 @@ export function assetIsType<Type extends AssetType>(type: Type) {
return (asset: Asset): asset is Asset<Type> => asset.type === type
}
// These are functions, and so their names should be camelCase.
/* eslint-disable no-restricted-syntax */
/** A type guard that returns whether an {@link Asset} is a {@link ProjectAsset}. */
export const assetIsProject = assetIsType(AssetType.project)
/** A type guard that returns whether an {@link Asset} is a {@link DirectoryAsset}. */
export const assetIsDirectory = assetIsType(AssetType.directory)
/** A type guard that returns whether an {@link Asset} is a {@link SecretAsset}. */
export const assetIsSecret = assetIsType(AssetType.secret)
/** A type guard that returns whether an {@link Asset} is a {@link FileAsset}. */
export const assetIsFile = assetIsType(AssetType.file)
/* eslint-disable no-restricted-syntax */
// =======================
// === rootDirectoryId ===
// =======================
/** Return the id of the root directory for a user or organization. */
export function rootDirectoryId(userOrOrganizationId: UserOrOrganizationId) {
return DirectoryId(userOrOrganizationId.replace(/^organization-/, `${AssetType.directory}-`))
}
// ==================
// === getAssetId ===
// ==================
/** A convenience function to get the `id` of an {@link Asset}.
* This is useful to avoid React re-renders as it is not re-created on each function call. */
export function getAssetId<Type extends AssetType>(asset: Asset<Type>) {
return asset.id
}
// ==============================
// === groupPermissionsByUser ===
// ==============================
@ -449,38 +557,54 @@ export interface Backend {
/** Return user details for the current user. */
usersMe: () => Promise<UserOrOrganization | null>
/** Return a list of assets in a directory. */
listDirectory: (query: ListDirectoryRequestParams) => Promise<Asset[]>
listDirectory: (query: ListDirectoryRequestParams, title: string | null) => Promise<Asset[]>
/** Create a directory. */
createDirectory: (body: CreateDirectoryRequestBody) => Promise<Directory>
createDirectory: (body: CreateDirectoryRequestBody) => Promise<CreatedDirectory>
/** Change the name of a directory. */
updateDirectory: (
directoryId: DirectoryId,
body: UpdateDirectoryRequestBody,
title: string | null
) => Promise<UpdatedDirectory>
/** Delete a directory. */
deleteDirectory: (directoryId: DirectoryId, title: string | null) => Promise<void>
/** Return a list of projects belonging to the current user. */
listProjects: () => Promise<ListedProject[]>
/** Create a project for the current user. */
createProject: (body: CreateProjectRequestBody) => Promise<CreatedProject>
/** Close the project identified by the given project ID. */
closeProject: (projectId: ProjectId) => Promise<void>
closeProject: (projectId: ProjectId, title: string | null) => Promise<void>
/** Return project details for the specified project ID. */
getProjectDetails: (projectId: ProjectId) => Promise<Project>
getProjectDetails: (projectId: ProjectId, title: string | null) => Promise<Project>
/** Set a project to an open state. */
openProject: (projectId: ProjectId, body: OpenProjectRequestBody) => Promise<void>
projectUpdate: (projectId: ProjectId, body: ProjectUpdateRequestBody) => Promise<UpdatedProject>
openProject: (
projectId: ProjectId,
body: OpenProjectRequestBody | null,
title: string | null
) => Promise<void>
projectUpdate: (
projectId: ProjectId,
body: ProjectUpdateRequestBody,
title: string | null
) => Promise<UpdatedProject>
/** Delete a project. */
deleteProject: (projectId: ProjectId) => Promise<void>
deleteProject: (projectId: ProjectId, title: string | null) => Promise<void>
/** Return project memory, processor and storage usage. */
checkResources: (projectId: ProjectId) => Promise<ResourceUsage>
checkResources: (projectId: ProjectId, title: string | null) => Promise<ResourceUsage>
/** Return a list of files accessible by the current user. */
listFiles: () => Promise<File[]>
/** Upload a file. */
uploadFile: (params: UploadFileRequestParams, body: Blob) => Promise<FileInfo>
/** Delete a file. */
deleteFile: (fileId: FileId) => Promise<void>
deleteFile: (fileId: FileId, title: string | null) => Promise<void>
/** Create a secret environment variable. */
createSecret: (body: CreateSecretRequestBody) => Promise<SecretAndInfo>
/** Return a secret environment variable. */
getSecret: (secretId: SecretId) => Promise<Secret>
getSecret: (secretId: SecretId, title: string | null) => Promise<Secret>
/** Return the secret environment variables accessible by the user. */
listSecrets: () => Promise<SecretInfo[]>
/** Delete a secret environment variable. */
deleteSecret: (secretId: SecretId) => Promise<void>
deleteSecret: (secretId: SecretId, title: string | null) => Promise<void>
/** Create a file tag or project tag. */
createTag: (body: CreateTagRequestBody) => Promise<TagInfo>
/** Return file tags or project tags accessible by the user. */

View File

@ -0,0 +1,344 @@
/** @file Column types and column display modes. */
import * as React from 'react'
import DefaultUserIcon from 'enso-assets/default_user.svg'
import PlusIcon from 'enso-assets/plus.svg'
import * as authProvider from '../authentication/providers/auth'
import * as backend from './backend'
import * as dateTime from './dateTime'
import * as modalProvider from '../providers/modal'
import * as tableColumn from './components/tableColumn'
import PermissionDisplay, * as permissionDisplay from './components/permissionDisplay'
import ManagePermissionsModal from './components/managePermissionsModal'
// =============
// === Types ===
// =============
/** Determines which columns are visible. */
export enum ColumnDisplayMode {
/** Show only columns which are ready for release. */
release = 'release',
/** Show all columns. */
all = 'all',
/** Show only name and metadata. */
compact = 'compact',
/** Show only columns relevant to documentation editors. */
docs = 'docs',
/** Show only name, metadata, and configuration options. */
settings = 'settings',
}
/** Column type. */
export enum Column {
name = 'name',
lastModified = 'last-modified',
sharedWith = 'shared-with',
docs = 'docs',
labels = 'labels',
dataAccess = 'data-access',
usagePlan = 'usage-plan',
engine = 'engine',
ide = 'ide',
}
// =================
// === Constants ===
// =================
/** An immutable empty array, useful as a React prop. */
const EMPTY_ARRAY: never[] = []
/** English names for every column except for the name column. */
export const COLUMN_NAME: Record<Exclude<Column, Column.name>, string> = {
[Column.lastModified]: 'Last modified',
[Column.sharedWith]: 'Shared with',
[Column.docs]: 'Docs',
[Column.labels]: 'Labels',
[Column.dataAccess]: 'Data access',
[Column.usagePlan]: 'Usage plan',
[Column.engine]: 'Engine',
[Column.ide]: 'IDE',
} as const
/** CSS classes for every column. Currently only used to set the widths. */
export const COLUMN_CSS_CLASS: Record<Column, string> = {
[Column.name]: 'w-60',
[Column.lastModified]: 'w-40',
[Column.sharedWith]: 'w-36',
[Column.docs]: 'w-96',
[Column.labels]: 'w-80',
[Column.dataAccess]: 'w-96',
[Column.usagePlan]: 'w-40',
[Column.engine]: 'w-20',
[Column.ide]: 'w-20',
} as const
/** A list of column display modes and names, in order. */
export const COLUMN_DISPLAY_MODES_AND_NAMES: [ColumnDisplayMode, string][] = [
[ColumnDisplayMode.all, 'All'],
[ColumnDisplayMode.compact, 'Compact'],
[ColumnDisplayMode.docs, 'Docs'],
[ColumnDisplayMode.settings, 'Settings'],
]
/** {@link table.ColumnProps} for an unknown variant of {@link backend.Asset}. */
type AnyAssetColumnProps = Omit<
tableColumn.TableColumnProps<backend.Asset>,
'rowState' | 'setItem' | 'setRowState' | 'state'
>
/** A column displaying the time at which the asset was last modified. */
function LastModifiedColumn(props: AnyAssetColumnProps) {
return <>{props.item.modifiedAt && dateTime.formatDateTime(new Date(props.item.modifiedAt))}</>
}
/** Props for a {@link UserPermissionDisplay}. */
interface InternalUserPermissionDisplayProps {
user: backend.UserPermissions
item: backend.Asset
emailsOfUsersWithPermission: Set<backend.EmailAddress>
ownsThisAsset: boolean
onDelete: () => void
onPermissionsChange: (permissions: backend.PermissionAction[]) => void
}
/** Displays permissions for a user on a specific asset. */
function UserPermissionDisplay(props: InternalUserPermissionDisplayProps) {
const {
user,
item,
emailsOfUsersWithPermission,
ownsThisAsset,
onDelete,
onPermissionsChange,
} = props
const { setModal } = modalProvider.useSetModal()
const [permissions, setPermissions] = React.useState(user.permissions)
const [oldPermissions, setOldPermissions] = React.useState(user.permissions)
const [isHovered, setIsHovered] = React.useState(false)
const [isDeleting, setIsDeleting] = React.useState(false)
React.useEffect(() => {
setPermissions(user.permissions)
}, [user.permissions])
return isDeleting ? null : (
<PermissionDisplay
key={user.user.pk}
permissions={permissionDisplay.permissionActionsToPermissions(permissions)}
className={`border-2 rounded-full -ml-5 first:ml-0 ${
ownsThisAsset ? 'cursor-pointer hover:shadow-soft hover:z-10' : ''
}`}
onClick={event => {
event.stopPropagation()
if (ownsThisAsset) {
setModal(
<ManagePermissionsModal
key={Number(new Date())}
user={user.user}
initialPermissions={user.permissions}
asset={item}
emailsOfUsersWithPermission={emailsOfUsersWithPermission}
eventTarget={event.currentTarget}
onSubmit={(_users, newPermissions) => {
if (newPermissions.length === 0) {
setIsDeleting(true)
} else {
setOldPermissions(permissions)
setPermissions(newPermissions)
onPermissionsChange(newPermissions)
}
}}
onSuccess={(_users, newPermissions) => {
if (newPermissions.length === 0) {
onDelete()
}
}}
onFailure={() => {
setIsDeleting(false)
setPermissions(oldPermissions)
onPermissionsChange(oldPermissions)
}}
/>
)
}
}}
onMouseEnter={() => {
setIsHovered(true)
}}
onMouseLeave={() => {
setIsHovered(false)
}}
>
{isHovered && (
<div className="relative">
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full shadow-soft bg-white px-2 py-1">
{user.user.user_email}
</div>
</div>
)}
<img src={DefaultUserIcon} height={24} width={24} />
</PermissionDisplay>
)
}
/** A column listing the users with which this asset is shared. */
function SharedWithColumn(props: AnyAssetColumnProps) {
const { item } = props
const session = authProvider.useNonPartialUserSession()
const { setModal } = modalProvider.useSetModal()
const [permissions, setPermissions] = React.useState(() =>
backend.groupPermissionsByUser(item.permissions ?? [])
)
const [oldPermissions, setOldPermissions] = React.useState(permissions)
const emailsOfUsersWithPermission = React.useMemo(
() => new Set(permissions.map(permission => permission.user.user_email)),
[permissions]
)
const selfPermission = item.permissions?.find(
permission => permission.user.user_email === session.organization?.email
)?.permission
const ownsThisAsset = selfPermission === backend.PermissionAction.own
return (
<div className="flex">
{permissions.map(user => (
<UserPermissionDisplay
key={user.user.user_email}
user={user}
item={item}
emailsOfUsersWithPermission={emailsOfUsersWithPermission}
ownsThisAsset={ownsThisAsset}
onDelete={() => {
setPermissions(
permissions.filter(
permission => permission.user.user_email !== user.user.user_email
)
)
}}
onPermissionsChange={newPermissions => {
setPermissions(
permissions.map(permission =>
permission.user.user_email === user.user.user_email
? { user: user.user, permissions: newPermissions }
: permission
)
)
}}
/>
))}
{ownsThisAsset && (
<button
onClick={event => {
event.stopPropagation()
setModal(
<ManagePermissionsModal
key={Number(new Date())}
asset={item}
initialPermissions={EMPTY_ARRAY}
emailsOfUsersWithPermission={emailsOfUsersWithPermission}
eventTarget={event.currentTarget}
onSubmit={(users, newPermissions) => {
setOldPermissions(permissions)
setPermissions([
...permissions,
...users.map(user => {
const userPermissions: backend.UserPermissions = {
user: {
pk: user.id,
// The names come from a third-party API
// and cannot be changed.
/* eslint-disable @typescript-eslint/naming-convention */
user_name: user.name,
user_email: user.email,
/** {@link SharedWithColumn} is only accessible
* if `session.organization` is not `null`. */
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
organization_id: session.organization!.id,
/* eslint-enable @typescript-eslint/naming-convention */
},
permissions: newPermissions,
}
return userPermissions
}),
])
}}
onFailure={() => {
setPermissions(oldPermissions)
}}
/>
)
}}
>
<img src={PlusIcon} />
</button>
)}
</div>
)
}
/** A placeholder component for columns which do not yet have corresponding data to display. */
function PlaceholderColumn() {
return <></>
}
/** React components for every column except for the name column. */
// This is not a React component even though it contains JSX.
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
export const COLUMN_RENDERER: Record<
Exclude<Column, Column.name>,
(props: AnyAssetColumnProps) => JSX.Element
> = {
[Column.lastModified]: LastModifiedColumn,
[Column.sharedWith]: SharedWithColumn,
[Column.docs]: PlaceholderColumn,
[Column.labels]: PlaceholderColumn,
[Column.dataAccess]: PlaceholderColumn,
[Column.usagePlan]: PlaceholderColumn,
[Column.engine]: PlaceholderColumn,
[Column.ide]: PlaceholderColumn,
}
// ========================
// === Helper functions ===
// ========================
/** The list of columns displayed on each `ColumnDisplayMode`. */
const COLUMNS_FOR: Record<ColumnDisplayMode, Column[]> = {
[ColumnDisplayMode.release]: [Column.name, Column.lastModified, Column.sharedWith],
[ColumnDisplayMode.all]: [
Column.name,
Column.lastModified,
Column.sharedWith,
Column.labels,
Column.dataAccess,
Column.usagePlan,
Column.engine,
Column.ide,
],
[ColumnDisplayMode.compact]: [
Column.name,
Column.lastModified,
Column.sharedWith,
Column.labels,
Column.dataAccess,
],
[ColumnDisplayMode.docs]: [Column.name, Column.lastModified, Column.docs],
[ColumnDisplayMode.settings]: [
Column.name,
Column.lastModified,
Column.usagePlan,
Column.engine,
Column.ide,
],
}
/** Returns the list of columns to be displayed. */
export function columnsFor(displayMode: ColumnDisplayMode, backendType: backend.BackendType) {
const columns = COLUMNS_FOR[displayMode]
return backendType === backend.BackendType.local
? columns.filter(column => column !== Column.sharedWith)
: columns
}

View File

@ -212,6 +212,7 @@ function Autocomplete(props: InternalMultipleAutocompleteProps | InternalSingleA
ref={inputRef}
autoFocus={autoFocus}
disabled={disabled}
size={1}
className={`grow bg-gray-200 rounded-xl px-2 py-1 ${
disabled ? 'pointer-events-none opacity-70' : ''
} ${className ?? ''}`}

View File

@ -18,6 +18,13 @@ import * as newtype from '../../newtype'
import Twemoji from './twemoji'
// ================
// === Newtypes ===
// ================
/** Create a {@link chat.MessageId} */
const MessageId = newtype.newtypeConstructor<chat.MessageId>()
// =================
// === Constants ===
// =================
@ -628,7 +635,7 @@ function Chat(props: ChatProps) {
element.style.height = `${element.scrollHeight}px`
const newMessage: ChatDisplayMessage = {
// This MUST be unique.
id: newtype.asNewtype<chat.MessageId>(String(Number(new Date()))),
id: MessageId(String(Number(new Date()))),
isStaffMessage: false,
avatar: null,
name: 'Me',

View File

@ -0,0 +1,42 @@
/** @file Switcher for choosing which columns are shown. */
import * as React from 'react'
import * as column from '../column'
// =================================
// === ColumnDisplayModeSwitcher ===
// =================================
/** Props for a {@link ColumnDisplayModeSwitcher}. */
export interface ColumnDisplayModeSwitcherProps {
columnDisplayMode: column.ColumnDisplayMode
setColumnDisplayMode: (columnDisplayMode: column.ColumnDisplayMode) => void
}
/** A selector that lets the user choose between pre-defined sets of visible columns. */
function ColumnDisplayModeSwitcher(props: ColumnDisplayModeSwitcherProps) {
const { columnDisplayMode, setColumnDisplayMode } = props
return (
<>
{column.COLUMN_DISPLAY_MODES_AND_NAMES.map(modeAndName => {
const [mode, name] = modeAndName
return (
<button
key={mode}
className={`${
columnDisplayMode === mode ? 'bg-white shadow-soft' : 'opacity-50'
} rounded-full px-1.5`}
onClick={() => {
setColumnDisplayMode(mode)
}}
>
{name}
</button>
)
})}
</>
)
}
export default ColumnDisplayModeSwitcher

View File

@ -4,6 +4,8 @@ import toast from 'react-hot-toast'
import CloseIcon from 'enso-assets/close.svg'
import * as errorModule from '../../error'
import * as loggerProvider from '../../providers/logger'
import * as modalProvider from '../../providers/modal'
import Modal from './modal'
@ -14,29 +16,27 @@ import Modal from './modal'
/** Props for a {@link ConfirmDeleteModal}. */
export interface ConfirmDeleteModalProps {
assetType: string
name: string
doDelete: () => Promise<void>
onComplete: () => void
/** Must fit in the sentence "Are you sure you want to delete <description>"? */
description: string
doDelete: () => void
}
/** A modal for confirming the deletion of an asset. */
function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
const { assetType, name, doDelete, onComplete } = props
const { description, doDelete } = props
const logger = loggerProvider.useLogger()
const { unsetModal } = modalProvider.useSetModal()
const onSubmit = async () => {
const onSubmit = () => {
unsetModal()
try {
await toast.promise(doDelete(), {
loading: `Deleting ${assetType} '${name}'...`,
success: `Deleted ${assetType} '${name}'.`,
// This is UNSAFE, as the original function's parameter is of type `any`.
error: (promiseError: Error) =>
`Error deleting ${assetType} '${name}': ${promiseError.message}`,
})
} finally {
onComplete()
doDelete()
} catch (error) {
const message = `Could not delete ${description}: ${
errorModule.tryGetMessage(error) ?? 'unknown error.'
}`
toast.error(message)
logger.error(message)
}
}
@ -46,24 +46,26 @@ function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
onClick={event => {
event.stopPropagation()
}}
onSubmit={async event => {
onSubmit={event => {
event.preventDefault()
// Consider not calling `onSubmit()` here to make it harder to accidentally
// delete an important asset.
await onSubmit()
onSubmit()
}}
className="relative bg-white shadow-soft rounded-lg w-96 p-2"
>
<div className="flex">
{/* Padding. */}
<div className="grow" />
<button type="button" onClick={unsetModal}>
<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 the {assetType} &lsquo;{name}&rsquo;?
</div>
<div className="m-2">Are you sure you want to delete {description}?</div>
<div className="m-1">
<button
type="submit"

View File

@ -14,7 +14,8 @@ const SCROLL_MARGIN = 12
// ===================
/** Props for a {@link ContextMenu}. */
export interface ContextMenuProps {
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.
@ -22,7 +23,7 @@ export interface ContextMenuProps {
}
/** A context menu that opens at the current mouse position. */
function ContextMenu(props: React.PropsWithChildren<ContextMenuProps>) {
function ContextMenu(props: ContextMenuProps) {
const { children, event } = props
const contextMenuRef = React.useRef<HTMLDivElement>(null)
const [top, setTop] = React.useState(event.pageY)
@ -46,6 +47,9 @@ function ContextMenu(props: React.PropsWithChildren<ContextMenuProps>) {
// 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}
</div>

View File

@ -12,7 +12,6 @@ export interface ContextMenuEntryProps {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
}
// This component MUST NOT use `useState` because it is not rendered directly.
/** An item in a `ContextMenu`. */
function ContextMenuEntry(props: React.PropsWithChildren<ContextMenuEntryProps>) {
const { children, disabled = false, title, onClick } = props

View File

@ -23,7 +23,7 @@ export interface CreateFormPassthroughProps {
/** `CreateFormPassthroughProps`, plus props that should be defined in the wrapper component. */
export interface CreateFormProps extends CreateFormPassthroughProps, React.PropsWithChildren {
title: string
onSubmit: (event: React.FormEvent) => Promise<void>
onSubmit: (event: React.FormEvent) => void
}
/** A form to create an element. */
@ -31,9 +31,9 @@ function CreateForm(props: CreateFormProps) {
const { title, left, top, children, onSubmit: innerOnSubmit } = props
const { unsetModal } = modalProvider.useSetModal()
const onSubmit = async (event: React.FormEvent) => {
const onSubmit = (event: React.FormEvent) => {
event.preventDefault()
await innerOnSubmit(event)
innerOnSubmit(event)
}
return (

View File

@ -0,0 +1,578 @@
/** @file Table displaying a list of directories. */
import * as React from 'react'
import toast from 'react-hot-toast'
import DirectoryIcon from 'enso-assets/directory.svg'
import PlusIcon from 'enso-assets/plus.svg'
import * as backendModule from '../backend'
import * as columnModule from '../column'
import * as dateTime from '../dateTime'
import * as directoryEventModule from '../events/directoryEvent'
import * as directoryListEventModule from '../events/directoryListEvent'
import * as errorModule from '../../error'
import * as eventModule from '../event'
import * as hooks from '../../hooks'
import * as permissions from '../permissions'
import * as presence from '../presence'
import * as shortcuts from '../shortcuts'
import * as string from '../../string'
import * as uniqueString from '../../uniqueString'
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 tableColumn from './tableColumn'
import TableRow, * as tableRow from './tableRow'
import ConfirmDeleteModal from './confirmDeleteModal'
import ContextMenu from './contextMenu'
import ContextMenuEntry from './contextMenuEntry'
import EditableSpan from './editableSpan'
import Table from './table'
// =================
// === Constants ===
// =================
/** The {@link RegExp} matching a directory name following the default naming convention. */
const DIRECTORY_NAME_REGEX = /^New_Folder_(?<directoryIndex>\d+)$/
/** The default prefix of an automatically generated directory. */
const DIRECTORY_NAME_DEFAULT_PREFIX = 'New_Folder_'
/** The user-facing name of this asset type. */
const ASSET_TYPE_NAME = 'folder'
/** The user-facing plural name of this asset type. */
const ASSET_TYPE_NAME_PLURAL = 'folders'
// This is a function, even though it is not syntactically a function.
// eslint-disable-next-line no-restricted-syntax
const pluralize = string.makePluralize(ASSET_TYPE_NAME, ASSET_TYPE_NAME_PLURAL)
/** Placeholder row when the search query is not empty. */
const PLACEHOLDER_WITH_FILTER = (
<span className="opacity-75">
This folder does not contain any sub{ASSET_TYPE_NAME_PLURAL} matching your query.
</span>
)
/** Placeholder row when the search query is empty. */
const PLACEHOLDER_WITHOUT_FILTER = (
<span className="opacity-75">
This folder does not contain any sub{ASSET_TYPE_NAME_PLURAL}.
</span>
)
// ============================
// === DirectoryNameHeading ===
// ============================
/** Props for a {@link DirectoryNameHeading}. */
interface InternalDirectoryNameHeadingProps {
doCreateDirectory: () => void
}
/** The column header for the "name" column for the table of directory assets. */
function DirectoryNameHeading(props: InternalDirectoryNameHeadingProps) {
const { doCreateDirectory } = props
return (
<div className="inline-flex">
{string.capitalizeFirst(ASSET_TYPE_NAME_PLURAL)}
<button
className="mx-1"
onClick={event => {
event.stopPropagation()
doCreateDirectory()
}}
>
<img src={PlusIcon} />
</button>
</div>
)
}
// =====================
// === DirectoryName ===
// =====================
/** Props for a {@link DirectoryName}. */
interface InternalDirectoryNameProps
extends tableColumn.TableColumnProps<
backendModule.DirectoryAsset,
DirectoriesTableState,
DirectoryRowState
> {}
/** The icon and name of a specific directory asset. */
function DirectoryName(props: InternalDirectoryNameProps) {
const {
item,
setItem,
selected,
state: { enterDirectory },
rowState,
setRowState,
} = props
const logger = loggerProvider.useLogger()
const { backend } = backendProvider.useBackend()
const doRename = async (newName: string) => {
if (backend.type !== backendModule.BackendType.local) {
try {
await backend.updateDirectory(item.id, { title: newName }, item.title)
return
} catch (error) {
const message = `Error renaming folder: ${
errorModule.tryGetMessage(error) ?? 'unknown error'
}`
toast.error(message)
logger.error(message)
throw error
}
}
}
return (
<div
className="flex text-left items-center align-middle whitespace-nowrap"
onClick={event => {
if (
eventModule.isSingleClick(event) &&
(selected ||
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
shortcuts.MouseAction.editName,
event
))
) {
setRowState(oldRowState => ({
...oldRowState,
isEditingName: true,
}))
} else if (eventModule.isDoubleClick(event)) {
enterDirectory(item)
}
}}
>
<img src={DirectoryIcon} />
<EditableSpan
editable={rowState.isEditingName}
onSubmit={async newTitle => {
setRowState(oldRowState => ({
...oldRowState,
isEditingName: false,
}))
if (newTitle !== item.title) {
const oldTitle = item.title
setItem(oldItem => ({ ...oldItem, title: newTitle }))
try {
await doRename(newTitle)
} catch {
setItem(oldItem => ({ ...oldItem, title: oldTitle }))
}
}
}}
onCancel={() => {
setRowState(oldRowState => ({
...oldRowState,
isEditingName: false,
}))
}}
className="cursor-pointer bg-transparent grow px-2"
>
{item.title}
</EditableSpan>
</div>
)
}
// ===============================
// === DirectoryRowContextMenu ===
// ===============================
/** Props for a {@link DirectoryRowContextMenu}. */
interface InternalDirectoryRowContextMenuProps {
innerProps: tableRow.TableRowInnerProps<
backendModule.DirectoryAsset,
backendModule.DirectoryId,
DirectoryRowState
>
event: React.MouseEvent
doDelete: () => Promise<void>
}
/** The context menu for a row of a {@link DirectorysTable}. */
function DirectoryRowContextMenu(props: InternalDirectoryRowContextMenuProps) {
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>
)
}
// ====================
// === DirectoryRow ===
// ====================
/** A row in a {@link DirectoriesTable}. */
function DirectoryRow(
props: tableRow.TableRowProps<
backendModule.DirectoryAsset,
backendModule.DirectoryId,
DirectoriesTableState,
DirectoryRowState
>
) {
const {
keyProp: key,
item: rawItem,
state: { directoryEvent, dispatchDirectoryListEvent, markItemAsHidden, markItemAsVisible },
} = props
const logger = loggerProvider.useLogger()
const { backend } = backendProvider.useBackend()
const { setModal } = modalProvider.useSetModal()
const [item, setItem] = React.useState(rawItem)
const [status, setStatus] = React.useState(presence.Presence.present)
React.useEffect(() => {
setItem(rawItem)
}, [rawItem])
const doDelete = async () => {
if (backend.type !== backendModule.BackendType.local) {
setStatus(presence.Presence.deleting)
markItemAsHidden(key)
try {
await backend.deleteDirectory(item.id, item.title)
dispatchDirectoryListEvent({
type: directoryListEventModule.DirectoryListEventType.delete,
directoryId: key,
})
} catch (error) {
setStatus(presence.Presence.present)
markItemAsVisible(key)
const message = `Unable to delete directory: ${
errorModule.tryGetMessage(error) ?? 'unknown error.'
}`
toast.error(message)
logger.error(message)
}
}
}
hooks.useEventHandler(directoryEvent, async event => {
switch (event.type) {
case directoryEventModule.DirectoryEventType.create: {
if (key === event.placeholderId) {
if (backend.type !== backendModule.BackendType.remote) {
const message = 'Folders cannot be created on the local backend.'
toast.error(message)
logger.error(message)
} else {
setStatus(presence.Presence.inserting)
try {
const createdDirectory = await backend.createDirectory({
parentId: item.parentId,
title: item.title,
})
setStatus(presence.Presence.present)
const newItem: backendModule.DirectoryAsset = {
...item,
...createdDirectory,
}
setItem(newItem)
} catch (error) {
dispatchDirectoryListEvent({
type: directoryListEventModule.DirectoryListEventType.delete,
directoryId: key,
})
const message = `Error creating new folder: ${
errorModule.tryGetMessage(error) ?? 'unknown error.'
}`
toast.error(message)
logger.error(message)
}
}
}
break
}
case directoryEventModule.DirectoryEventType.deleteMultiple: {
if (event.directoryIds.has(key)) {
await doDelete()
}
break
}
}
})
return (
<TableRow
className={presence.CLASS_NAME[status]}
{...props}
onContextMenu={(innerProps, event) => {
event.preventDefault()
event.stopPropagation()
setModal(
<DirectoryRowContextMenu
innerProps={innerProps}
event={event}
doDelete={doDelete}
/>
)
}}
item={item}
/>
)
}
// ========================
// === DirectoriesTable ===
// ========================
/** State passed through from a {@link DirectoriesTable} to every cell. */
interface DirectoriesTableState {
directoryEvent: directoryEventModule.DirectoryEvent | null
enterDirectory: (directory: backendModule.DirectoryAsset) => void
dispatchDirectoryListEvent: (event: directoryListEventModule.DirectoryListEvent) => void
markItemAsHidden: (key: string) => void
markItemAsVisible: (key: string) => void
}
/** Data associated with a {@link DirectoryRow}, used for rendering. */
export interface DirectoryRowState {
isEditingName: boolean
}
/** The default {@link DirectoryRowState} associated with a {@link DirectoryRow}. */
export const INITIAL_ROW_STATE: DirectoryRowState = Object.freeze({
isEditingName: false,
})
/** Props for a {@link DirectoriesTable}. */
export interface DirectoriesTableProps {
directoryId: backendModule.DirectoryId | null
items: backendModule.DirectoryAsset[]
filter: ((item: backendModule.DirectoryAsset) => boolean) | null
isLoading: boolean
columnDisplayMode: columnModule.ColumnDisplayMode
enterDirectory: (directory: backendModule.DirectoryAsset) => void
}
/** The table of directory assets. */
function DirectoriesTable(props: DirectoriesTableProps) {
const {
directoryId,
items: rawItems,
filter,
isLoading,
columnDisplayMode,
enterDirectory,
} = props
const { organization } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const { setModal } = modalProvider.useSetModal()
const [items, setItems] = React.useState(rawItems)
const [directoryEvent, dispatchDirectoryEvent] =
hooks.useEvent<directoryEventModule.DirectoryEvent>()
const [directoryListEvent, dispatchDirectoryListEvent] =
hooks.useEvent<directoryListEventModule.DirectoryListEvent>()
React.useEffect(() => {
setItems(rawItems)
}, [rawItems])
const visibleItems = React.useMemo(
() => (filter != null ? items.filter(filter) : items),
[items, filter]
)
// === Tracking number of visually hidden items ===
const [shouldForceShowPlaceholder, setShouldForceShowPlaceholder] = React.useState(false)
const keysOfHiddenItemsRef = React.useRef(new Set<string>())
const updateShouldForceShowPlaceholder = React.useCallback(() => {
setShouldForceShowPlaceholder(
visibleItems.every(item => keysOfHiddenItemsRef.current.has(item.id))
)
}, [visibleItems])
React.useEffect(updateShouldForceShowPlaceholder, [updateShouldForceShowPlaceholder])
React.useEffect(() => {
const oldKeys = keysOfHiddenItemsRef.current
keysOfHiddenItemsRef.current = new Set(
items.map(backendModule.getAssetId).filter(key => oldKeys.has(key))
)
}, [items])
const markItemAsHidden = React.useCallback(
(key: string) => {
keysOfHiddenItemsRef.current.add(key)
updateShouldForceShowPlaceholder()
},
[updateShouldForceShowPlaceholder]
)
const markItemAsVisible = React.useCallback(
(key: string) => {
keysOfHiddenItemsRef.current.delete(key)
updateShouldForceShowPlaceholder()
},
[updateShouldForceShowPlaceholder]
)
// === End tracking number of visually hidden items ===
const createNewDirectory = React.useCallback(() => {
dispatchDirectoryListEvent({
type: directoryListEventModule.DirectoryListEventType.create,
})
}, [/* should never change */ dispatchDirectoryListEvent])
hooks.useEventHandler(directoryListEvent, event => {
switch (event.type) {
case directoryListEventModule.DirectoryListEventType.create: {
const directoryIndices = items
.map(item => DIRECTORY_NAME_REGEX.exec(item.title))
.map(match => match?.groups?.directoryIndex)
.map(maybeIndex => (maybeIndex != null ? parseInt(maybeIndex, 10) : 0))
const title = `${DIRECTORY_NAME_DEFAULT_PREFIX}${
Math.max(0, ...directoryIndices) + 1
}`
const placeholderItem: backendModule.DirectoryAsset = {
id: backendModule.DirectoryId(uniqueString.uniqueString()),
title,
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: directoryId ?? backendModule.DirectoryId(''),
permissions: permissions.tryGetSingletonOwnerPermission(organization),
projectState: null,
type: backendModule.AssetType.directory,
}
setItems(oldItems => [placeholderItem, ...oldItems])
dispatchDirectoryEvent({
type: directoryEventModule.DirectoryEventType.create,
placeholderId: placeholderItem.id,
})
break
}
case directoryListEventModule.DirectoryListEventType.delete: {
setItems(oldItems => oldItems.filter(item => item.id !== event.directoryId))
break
}
}
})
const state = React.useMemo(
// The type MUST be here to trigger excess property errors at typecheck time.
(): DirectoriesTableState => ({
directoryEvent,
enterDirectory,
dispatchDirectoryListEvent,
markItemAsHidden,
markItemAsVisible,
}),
[
directoryEvent,
enterDirectory,
dispatchDirectoryListEvent,
markItemAsHidden,
markItemAsVisible,
]
)
if (backend.type === backendModule.BackendType.local) {
return <></>
} else {
return (
<Table<
backendModule.DirectoryAsset,
backendModule.DirectoryId,
DirectoriesTableState,
DirectoryRowState
>
rowComponent={DirectoryRow}
items={visibleItems}
isLoading={isLoading}
state={state}
initialRowState={INITIAL_ROW_STATE}
getKey={backendModule.getAssetId}
placeholder={filter != null ? PLACEHOLDER_WITH_FILTER : PLACEHOLDER_WITHOUT_FILTER}
forceShowPlaceholder={shouldForceShowPlaceholder}
columns={columnModule.columnsFor(columnDisplayMode, backend.type).map(column =>
column === columnModule.Column.name
? {
id: column,
className: columnModule.COLUMN_CSS_CLASS[column],
heading: (
<DirectoryNameHeading doCreateDirectory={createNewDirectory} />
),
render: DirectoryName,
}
: {
id: column,
className: columnModule.COLUMN_CSS_CLASS[column],
heading: <>{columnModule.COLUMN_NAME[column]}</>,
render: columnModule.COLUMN_RENDERER[column],
}
)}
onContextMenu={(selectedKeys, event, setSelectedKeys) => {
event.preventDefault()
event.stopPropagation()
const doDeleteAll = () => {
setModal(
<ConfirmDeleteModal
description={
`${selectedKeys.size} selected ` + ASSET_TYPE_NAME_PLURAL
}
doDelete={() => {
dispatchDirectoryEvent({
type: directoryEventModule.DirectoryEventType
.deleteMultiple,
directoryIds: selectedKeys,
})
setSelectedKeys(new Set())
}}
/>
)
}
const pluralized = pluralize(selectedKeys.size)
setModal(
<ContextMenu key={uniqueString.uniqueString()} event={event}>
<ContextMenuEntry onClick={doDeleteAll}>
<span className="text-red-700">
Delete {selectedKeys.size} {pluralized}
</span>
</ContextMenuEntry>
</ContextMenu>
)
}}
/>
)
}
}
export default DirectoriesTable

View File

@ -0,0 +1,375 @@
/** @file The directory header bar and directory item listing. */
import * as React from 'react'
import toast from 'react-hot-toast'
import * as common from 'enso-common'
import * as authProvider from '../../authentication/providers/auth'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as columnModule from '../column'
import * as hooks from '../../hooks'
import * as loggerProvider from '../../providers/logger'
import * as tabModule from '../tab'
import * as fileListEventModule from '../events/fileListEvent'
import * as projectEventModule from '../events/projectEvent'
import * as projectListEventModule from '../events/projectListEvent'
import DirectoriesTable from './directoriesTable'
import DriveBar from './driveBar'
import FilesTable from './filesTable'
import ProjectsTable from './projectsTable'
import SecretsTable from './secretsTable'
// =================
// === 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`
// ========================
// === Helper functions ===
// ========================
/** Sanitizes a string for use as a regex. */
function regexEscape(string: string) {
return string.replace(/[\\^$.|?*+()[{]/g, '\\$&')
}
// =====================
// === DirectoryView ===
// =====================
/** Props for a {@link DirectoryView}. */
export interface DirectoryViewProps {
tab: tabModule.Tab
initialProjectName: string | null
nameOfProjectToImmediatelyOpen: string | null
setNameOfProjectToImmediatelyOpen: (nameOfProjectToImmediatelyOpen: string | null) => void
directoryId: backendModule.DirectoryId | null
setDirectoryId: (directoryId: backendModule.DirectoryId | null) => void
projectListEvent: projectListEventModule.ProjectListEvent | null
dispatchProjectListEvent: (directoryEvent: projectListEventModule.ProjectListEvent) => void
query: string
onOpenIde: (project: backendModule.ProjectAsset) => void
onCloseIde: () => void
appRunner: AppRunner | null
loadingProjectManagerDidFail: boolean
isListingRemoteDirectoryWhileOffline: boolean
isListingLocalDirectoryAndWillFail: boolean
isListingRemoteDirectoryAndWillFail: boolean
}
/** Contains directory path and directory contents (projects, folders, secrets and files). */
function DirectoryView(props: DirectoryViewProps) {
const {
tab,
initialProjectName,
nameOfProjectToImmediatelyOpen,
setNameOfProjectToImmediatelyOpen,
directoryId,
setDirectoryId,
query,
projectListEvent,
dispatchProjectListEvent,
onOpenIde,
onCloseIde,
appRunner,
loadingProjectManagerDidFail,
isListingRemoteDirectoryWhileOffline,
isListingLocalDirectoryAndWillFail,
isListingRemoteDirectoryAndWillFail,
} = props
const logger = loggerProvider.useLogger()
const { organization, accessToken } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const [initialized, setInitialized] = React.useState(false)
const [isLoadingAssets, setIsLoadingAssets] = React.useState(true)
const [directoryStack, setDirectoryStack] = React.useState<backendModule.DirectoryAsset[]>([])
// Defined by the spec as `compact` by default, however some columns lack an implementation
// in the remote (cloud) backend and will therefore be empty.
const [columnDisplayMode, setColumnDisplayMode] = React.useState(
columnModule.ColumnDisplayMode.release
)
const [isFileBeingDragged, setIsFileBeingDragged] = React.useState(false)
const [projectAssets, setProjectAssets] = React.useState<backendModule.ProjectAsset[]>([])
const [directoryAssets, setDirectoryAssets] = React.useState<backendModule.DirectoryAsset[]>([])
const [secretAssets, setSecretAssets] = React.useState<backendModule.SecretAsset[]>([])
const [fileAssets, setFileAssets] = React.useState<backendModule.FileAsset[]>([])
const [projectEvent, dispatchProjectEvent] =
React.useState<projectEventModule.ProjectEvent | null>(null)
const [fileListEvent, dispatchFileListEvent] =
React.useState<fileListEventModule.FileListEvent | null>(null)
const assetFilter = React.useMemo(() => {
if (query === '') {
return null
} else {
const regex = new RegExp(regexEscape(query), 'i')
return (asset: backendModule.Asset) => regex.test(asset.title)
}
}, [query])
const directory = directoryStack[directoryStack.length - 1] ?? null
const parentDirectory = directoryStack[directoryStack.length - 2] ?? null
React.useEffect(() => {
const onBlur = () => {
setIsFileBeingDragged(false)
}
window.addEventListener('blur', onBlur)
return () => {
window.removeEventListener('blur', onBlur)
}
}, [])
React.useEffect(() => {
setIsLoadingAssets(true)
setProjectAssets([])
setDirectoryAssets([])
setSecretAssets([])
setFileAssets([])
}, [backend, directoryId])
React.useEffect(() => {
if (backend.type === backendModule.BackendType.local && loadingProjectManagerDidFail) {
setIsLoadingAssets(false)
}
}, [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 assets = hooks.useAsyncEffect(
[],
async signal => {
switch (backend.type) {
case backendModule.BackendType.local: {
if (!isListingLocalDirectoryAndWillFail) {
const newAssets = await backend.listDirectory()
if (!signal.aborted) {
setIsLoadingAssets(false)
}
return newAssets
} else {
return []
}
}
case backendModule.BackendType.remote: {
if (
!isListingRemoteDirectoryAndWillFail &&
!isListingRemoteDirectoryWhileOffline &&
directoryId != null
) {
const newAssets = await backend.listDirectory(
{ parentId: directoryId },
directory?.title ?? null
)
if (!signal.aborted) {
setIsLoadingAssets(false)
}
return newAssets
} else {
setIsLoadingAssets(false)
return []
}
}
}
},
[accessToken, directoryId, backend]
)
React.useEffect(() => {
const newProjectAssets = assets.filter(backendModule.assetIsProject)
setProjectAssets(newProjectAssets)
setDirectoryAssets(assets.filter(backendModule.assetIsDirectory))
setSecretAssets(assets.filter(backendModule.assetIsSecret))
setFileAssets(assets.filter(backendModule.assetIsFile))
if (nameOfProjectToImmediatelyOpen != null) {
const projectToLoad = newProjectAssets.find(
projectAsset => projectAsset.title === nameOfProjectToImmediatelyOpen
)
if (projectToLoad != null) {
dispatchProjectEvent({
type: projectEventModule.ProjectEventType.open,
projectId: projectToLoad.id,
})
}
setNameOfProjectToImmediatelyOpen(null)
}
if (!initialized && initialProjectName != null) {
setInitialized(true)
if (!newProjectAssets.some(asset => asset.title === initialProjectName)) {
const errorMessage = `No project named '${initialProjectName}' was found.`
toast.error(errorMessage)
logger.error(`Error opening project on startup: ${errorMessage}`)
}
}
// `nameOfProjectToImmediatelyOpen` must NOT trigger this effect.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
assets,
initialized,
initialProjectName,
logger,
/* should never change */ setNameOfProjectToImmediatelyOpen,
/* should never change */ dispatchProjectEvent,
])
const doCreateProject = React.useCallback(() => {
dispatchProjectListEvent({
type: projectListEventModule.ProjectListEventType.create,
templateId: null,
onSpinnerStateChange: null,
})
}, [/* should never change */ dispatchProjectListEvent])
const enterDirectory = React.useCallback(
(directoryAsset: backendModule.DirectoryAsset) => {
setDirectoryId(directoryAsset.id)
setDirectoryStack([...directoryStack, directoryAsset])
},
[directoryStack, setDirectoryId]
)
const exitDirectory = React.useCallback(() => {
setDirectoryId(
parentDirectory?.id ??
(organization != null ? backendModule.rootDirectoryId(organization.id) : null)
)
setDirectoryStack(
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
directoryStack.slice(0, -1)
)
}, [directoryStack, organization, parentDirectory?.id, setDirectoryId])
React.useEffect(() => {
const onDragEnter = (event: DragEvent) => {
if (
tab === tabModule.Tab.dashboard &&
event.dataTransfer?.types.includes('Files') === true
) {
setIsFileBeingDragged(true)
}
}
document.body.addEventListener('dragenter', onDragEnter)
return () => {
document.body.removeEventListener('dragenter', onDragEnter)
}
}, [tab])
return (
<>
<DriveBar
directoryId={directoryId}
directory={directory}
parentDirectory={parentDirectory}
columnDisplayMode={columnDisplayMode}
setColumnDisplayMode={setColumnDisplayMode}
exitDirectory={exitDirectory}
dispatchFileListEvent={dispatchFileListEvent}
/>
{/* Padding. */}
<div className="h-6 mx-2" />
<div className="flex-1 overflow-auto mx-2">
<ProjectsTable
appRunner={appRunner}
directoryId={directoryId}
items={projectAssets}
filter={assetFilter}
isLoading={isLoadingAssets}
columnDisplayMode={columnDisplayMode}
projectEvent={projectEvent}
dispatchProjectEvent={dispatchProjectEvent}
projectListEvent={projectListEvent}
dispatchProjectListEvent={dispatchProjectListEvent}
doCreateProject={doCreateProject}
doOpenIde={onOpenIde}
doCloseIde={onCloseIde}
/>
{/* Padding. */}
<div className="h-10" />
<DirectoriesTable
directoryId={directoryId}
items={directoryAssets}
filter={assetFilter}
isLoading={isLoadingAssets}
columnDisplayMode={columnDisplayMode}
enterDirectory={enterDirectory}
/>
{/* Padding. */}
<div className="h-10" />
<SecretsTable
directoryId={directoryId}
items={secretAssets}
filter={assetFilter}
isLoading={isLoadingAssets}
columnDisplayMode={columnDisplayMode}
/>
{/* Padding. */}
<div className="h-10" />
<FilesTable
directoryId={directoryId}
items={fileAssets}
filter={assetFilter}
isLoading={isLoadingAssets}
columnDisplayMode={columnDisplayMode}
fileListEvent={fileListEvent}
dispatchFileListEvent={dispatchFileListEvent}
/>
</div>
{isFileBeingDragged &&
directoryId != null &&
backend.type === backendModule.BackendType.remote ? (
<div
className="text-white text-lg fixed w-screen h-screen inset-0 bg-primary grid place-items-center"
onDragLeave={() => {
setIsFileBeingDragged(false)
}}
onDragOver={event => {
event.preventDefault()
}}
onDrop={event => {
event.preventDefault()
setIsFileBeingDragged(false)
dispatchFileListEvent({
type: fileListEventModule.FileListEventType.uploadMultiple,
files: event.dataTransfer.files,
})
}}
>
Drop to upload files.
</div>
) : null}
</>
)
}
export default DirectoryView

View File

@ -0,0 +1,144 @@
/** @file Header menubar for the directory listing, containing information about
* the current directory and some configuration options. */
import * as React from 'react'
import toast from 'react-hot-toast'
import ArrowRightSmallIcon from 'enso-assets/arrow_right_small.svg'
import DownloadIcon from 'enso-assets/download.svg'
import UploadIcon from 'enso-assets/upload.svg'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as column from '../column'
import * as featureFlags from '../featureFlags'
import * as fileListEventModule from '../events/fileListEvent'
import * as loggerProvider from '../../providers/logger'
import ColumnDisplayModeSwitcher from './columnDisplayModeSwitcher'
// ================
// === DriveBar ===
// ================
/** Props for a {@link DriveBar}. */
export interface DriveBarProps {
directoryId: backendModule.DirectoryId | null
directory: backendModule.DirectoryAsset | null
parentDirectory: backendModule.DirectoryAsset | null
columnDisplayMode: column.ColumnDisplayMode
setColumnDisplayMode: (columnDisplayMode: column.ColumnDisplayMode) => void
dispatchFileListEvent: (fileListEvent: fileListEventModule.FileListEvent) => void
exitDirectory: () => void
}
/** Displays the current directory path and permissions, upload and download buttons,
* and a column display mode switcher. */
function DriveBar(props: DriveBarProps) {
const {
directoryId,
directory,
parentDirectory,
columnDisplayMode,
setColumnDisplayMode,
dispatchFileListEvent,
exitDirectory,
} = props
const logger = loggerProvider.useLogger()
const { backend } = backendProvider.useBackend()
const uploadFiles = React.useCallback(
(event: React.FormEvent<HTMLInputElement>) => {
if (backend.type === backendModule.BackendType.local) {
// TODO[sb]: Allow uploading `.enso-project`s
// https://github.com/enso-org/cloud-v2/issues/510
const message = 'Files cannot be uploaded to the local backend.'
toast.error(message)
logger.error(message)
} else if (
event.currentTarget.files == null ||
event.currentTarget.files.length === 0
) {
toast.success('No files selected to upload.')
} else if (directoryId == null) {
// This should never happen, however display a nice error message in case
// it somehow does.
const message = 'Files cannot be uploaded while offline.'
toast.error(message)
logger.error(message)
} else {
dispatchFileListEvent({
type: fileListEventModule.FileListEventType.uploadMultiple,
files: event.currentTarget.files,
})
}
},
[
backend.type,
directoryId,
/* should not change */ logger,
/* should never change */ dispatchFileListEvent,
]
)
return (
<div className="flex flex-row flex-nowrap my-2">
<h1 className="text-xl font-bold mx-4 self-center">Drive</h1>
<div className="flex flex-row flex-nowrap mx-4">
<div className="bg-gray-100 rounded-l-full flex flex-row flex-nowrap items-center p-1 mx-0.5">
{directory && (
<>
<button className="mx-2" onClick={exitDirectory}>
{parentDirectory?.title ?? '/'}
</button>
<img src={ArrowRightSmallIcon} />
</>
)}
<span className="mx-2">{directory?.title ?? '/'}</span>
</div>
<div className="bg-gray-100 rounded-r-full flex flex-row flex-nowrap items-center mx-0.5">
<div className="m-2">Shared with</div>
<div></div>
</div>
<div className="bg-gray-100 rounded-full flex flex-row flex-nowrap px-1.5 py-1 mx-4">
<input
type="file"
multiple
disabled={backend.type === backendModule.BackendType.local}
id="upload_files_input"
name="upload_files_input"
className="w-0 h-0"
onInput={uploadFiles}
/>
<label
htmlFor="upload_files_input"
className={`mx-1 ${
backend.type === backendModule.BackendType.local
? 'opacity-50'
: 'cursor-pointer'
}`}
>
<img src={UploadIcon} />
</label>
<button
className={`mx-1 opacity-50`}
disabled={true}
onClick={event => {
event.stopPropagation()
/* TODO */
}}
>
<img src={DownloadIcon} />
</button>
</div>
{featureFlags.FEATURE_FLAGS.columnDisplayModeSwitcher && (
<ColumnDisplayModeSwitcher
columnDisplayMode={columnDisplayMode}
setColumnDisplayMode={setColumnDisplayMode}
/>
)}
</div>
</div>
)
}
export default DriveBar

View File

@ -0,0 +1,91 @@
/** @file A text `<span>` which turns into an `input` when desired. */
import * as React from 'react'
import CrossIcon from 'enso-assets/cross.svg'
import TickIcon from 'enso-assets/tick.svg'
import * as shortcuts from '../shortcuts'
// ====================
// === EditableSpan ===
// ====================
/** Props of an {@link EditableSpan} that are passed through to the base element. */
type EditableSpanPassthroughProps = JSX.IntrinsicElements['input'] & JSX.IntrinsicElements['span']
/** Props for an {@link EditableSpan}. */
export interface EditableSpanProps extends Omit<EditableSpanPassthroughProps, 'onSubmit'> {
editable?: boolean
onSubmit: (value: string) => void
onCancel: () => void
inputPattern?: string
inputTitle?: string
children: string
}
/** A `<span>` that can turn into an `<input type="text">`. */
function EditableSpan(props: EditableSpanProps) {
const {
editable = false,
children,
onSubmit,
onCancel,
inputPattern,
inputTitle,
...passthroughProps
} = props
// 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!)
if (editable) {
return (
<form
className="flex grow"
onSubmit={event => {
event.preventDefault()
onSubmit(inputRef.current.value)
}}
>
<input
ref={inputRef}
autoFocus
type="text"
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}
/>
<button type="submit" className="mx-0.5">
<img src={TickIcon} />
</button>
<button
className="mx-0.5"
onClick={event => {
event.stopPropagation()
onCancel()
}}
>
<img src={CrossIcon} />
</button>
</form>
)
} else {
return <span {...passthroughProps}>{children}</span>
}
}
export default EditableSpan

View File

@ -1,101 +0,0 @@
/** @file Form to create a project. */
import * as React from 'react'
import toast from 'react-hot-toast'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as error from '../../error'
import * as modalProvider from '../../providers/modal'
import CreateForm, * as createForm from './createForm'
// ======================
// === FileCreateForm ===
// ======================
/** Props for a {@link FileCreateForm}. */
export interface FileCreateFormProps extends createForm.CreateFormPassthroughProps {
directoryId: backendModule.DirectoryId
onSuccess: () => void
}
/** A form to create a file. */
function FileCreateForm(props: FileCreateFormProps) {
const { directoryId, onSuccess, ...passThrough } = props
const { backend } = backendProvider.useBackend()
const { unsetModal } = modalProvider.useSetModal()
const [name, setName] = React.useState<string | null>(null)
const [file, setFile] = React.useState<File | null>(null)
if (backend.type === backendModule.BackendType.local) {
return <></>
} else {
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (file == null) {
// TODO[sb]: Uploading a file may be a mistake when creating a new file.
toast.error('Please select a file to upload.')
} else {
unsetModal()
await toast
.promise(
backend.uploadFile(
{
parentDirectoryId: directoryId,
fileName: name ?? file.name,
},
file
),
{
loading: 'Uploading file...',
success: 'Sucessfully uploaded file.',
error: error.unsafeIntoErrorMessage,
}
)
.then(onSuccess)
}
}
return (
<CreateForm title="New File" onSubmit={onSubmit} {...passThrough}>
<div className="flex flex-row flex-nowrap m-1">
<label className="inline-block flex-1 grow m-1" htmlFor="file_name">
Name
</label>
<input
id="file_name"
type="text"
size={1}
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
onChange={event => {
setName(event.target.value)
}}
defaultValue={name ?? file?.name ?? ''}
/>
</div>
<div className="flex flex-row flex-nowrap m-1">
<div className="inline-block flex-1 grow m-1">File</div>
<div className="inline-block bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1">
<label className="bg-transparent rounded-full w-full" htmlFor="file_file">
<div className="inline-block bg-gray-300 hover:bg-gray-400 rounded-l-full px-2 -ml-2">
<u>𐌣</u>
</div>
<div className="inline-block px-2 -mr-2">
{file?.name ?? 'No file chosen'}
</div>
</label>
<input
id="file_file"
type="file"
className="hidden"
onChange={event => {
setFile(event.target.files?.[0] ?? null)
}}
/>
</div>
</div>
</CreateForm>
)
}
}
export default FileCreateForm

View File

@ -0,0 +1,580 @@
/** @file Table displaying a list of files. */
import * as React from 'react'
import toast from 'react-hot-toast'
import PlusIcon from 'enso-assets/plus.svg'
import * as backendModule from '../backend'
import * as columnModule from '../column'
import * as dateTime from '../dateTime'
import * as errorModule from '../../error'
import * as eventModule from '../event'
import * as fileEventModule from '../events/fileEvent'
import * as fileInfo from '../../fileInfo'
import * as fileListEventModule from '../events/fileListEvent'
import * as hooks from '../../hooks'
import * as permissions from '../permissions'
import * as presence from '../presence'
import * as shortcuts from '../shortcuts'
import * as string from '../../string'
import * as uniqueString from '../../uniqueString'
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 tableColumn from './tableColumn'
import TableRow, * as tableRow from './tableRow'
import ConfirmDeleteModal from './confirmDeleteModal'
import ContextMenu from './contextMenu'
import ContextMenuEntry from './contextMenuEntry'
import EditableSpan from './editableSpan'
import Table from './table'
// =================
// === Constants ===
// =================
/** The user-facing name of this asset type. */
const ASSET_TYPE_NAME = 'file'
/** The user-facing plural name of this asset type. */
const ASSET_TYPE_NAME_PLURAL = 'files'
// This is a function, even though it is not syntactically a function.
// eslint-disable-next-line no-restricted-syntax
const pluralize = string.makePluralize(ASSET_TYPE_NAME, ASSET_TYPE_NAME_PLURAL)
/** Placeholder row when the search query is not empty. */
const PLACEHOLDER_WITH_QUERY = (
<span className="opacity-75">
This folder does not contain any {ASSET_TYPE_NAME_PLURAL} matching your query.
</span>
)
/** Placeholder row when the search query is empty. */
const PLACEHOLDER_WITHOUT_QUERY = (
<span className="opacity-75">This folder does not contain any {ASSET_TYPE_NAME_PLURAL}.</span>
)
// =======================
// === FileNameHeading ===
// =======================
/** Props for a {@link FileNameHeading}. */
interface InternalFileNameHeadingProps {
directoryId: backendModule.DirectoryId | null
dispatchFileListEvent: (fileListEvent: fileListEventModule.FileListEvent) => void
}
/** The column header for the "name" column for the table of file assets. */
function FileNameHeading(props: InternalFileNameHeadingProps) {
const { directoryId, dispatchFileListEvent } = props
const logger = loggerProvider.useLogger()
const { backend } = backendProvider.useBackend()
const uploadFiles = React.useCallback(
(event: React.FormEvent<HTMLInputElement>) => {
if (backend.type === backendModule.BackendType.local) {
// TODO[sb]: Allow uploading `.enso-project`s
// https://github.com/enso-org/cloud-v2/issues/510
const message = 'Files cannot be uploaded to the local backend.'
toast.error(message)
logger.error(message)
} else if (
event.currentTarget.files == null ||
event.currentTarget.files.length === 0
) {
toast.success('No files selected to upload.')
} else if (directoryId == null) {
// This should never happen, however display a nice error message in case
// it somehow does.
const message = 'Files cannot be uploaded while offline.'
toast.error(message)
logger.error(message)
} else {
dispatchFileListEvent({
type: fileListEventModule.FileListEventType.uploadMultiple,
files: event.currentTarget.files,
})
}
},
[
backend.type,
directoryId,
/* should not change */ logger,
/* should never change */ dispatchFileListEvent,
]
)
return (
<div className="inline-flex">
{string.capitalizeFirst(ASSET_TYPE_NAME_PLURAL)}
<input
type="file"
id="files_table_upload_files_input"
name="files_table_upload_files_input"
multiple
className="w-0 h-0"
onInput={uploadFiles}
/>
<label htmlFor="files_table_upload_files_input" className="cursor-pointer mx-1">
<img src={PlusIcon} />
</label>
</div>
)
}
// ================
// === FileName ===
// ================
/** Props for a {@link FileName}. */
interface InternalFileNameProps
extends tableColumn.TableColumnProps<backendModule.FileAsset, FilesTableState, FileRowState> {}
/** The icon and name of a specific file asset. */
function FileName(props: InternalFileNameProps) {
const { item, setItem, selected, setRowState } = props
// TODO[sb]: Wait for backend implementation. `editable` should also be re-enabled, and the
// context menu entry should be re-added.
// Backend implementation is tracked here: https://github.com/enso-org/cloud-v2/issues/505.
const doRename = async () => {
return await Promise.resolve(null)
}
return (
<div
className="flex text-left items-center align-middle whitespace-nowrap"
onClick={event => {
if (
eventModule.isSingleClick(event) &&
(selected ||
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
shortcuts.MouseAction.editName,
event
))
) {
setRowState(oldRowState => ({
...oldRowState,
isEditingName: true,
}))
}
}}
>
<img src={fileInfo.fileIcon()} />
<EditableSpan
editable={false}
onSubmit={async newTitle => {
setRowState(oldRowState => ({
...oldRowState,
isEditingName: false,
}))
if (newTitle !== item.title) {
const oldTitle = item.title
setItem(oldItem => ({ ...oldItem, title: newTitle }))
try {
await doRename(/* newTitle */)
} catch {
setItem(oldItem => ({ ...oldItem, title: oldTitle }))
}
}
}}
onCancel={() => {
setRowState(oldRowState => ({
...oldRowState,
isEditingName: false,
}))
}}
className="bg-transparent grow px-2"
>
{item.title}
</EditableSpan>
</div>
)
}
// ==========================
// === FileRowContextMenu ===
// ==========================
/** Props for a {@link FileRowContextMenu}. */
interface InternalDirectoryRowContextMenuProps {
innerProps: tableRow.TableRowInnerProps<
backendModule.FileAsset,
backendModule.FileId,
FileRowState
>
event: React.MouseEvent
doDelete: () => Promise<void>
}
/** The context menu for a row of a {@link FilesTable}. */
function FileRowContextMenu(props: InternalDirectoryRowContextMenuProps) {
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>
)
}
// ===============
// === FileRow ===
// ===============
/** A row in a {@link FilesTable}. */
function FileRow(
props: tableRow.TableRowProps<
backendModule.FileAsset,
backendModule.FileId,
FilesTableState,
FileRowState
>
) {
const {
keyProp: key,
item: rawItem,
state: { fileEvent, dispatchFileListEvent, markItemAsHidden, markItemAsVisible },
} = props
const logger = loggerProvider.useLogger()
const { backend } = backendProvider.useBackend()
const { setModal } = modalProvider.useSetModal()
const [item, setItem] = React.useState(rawItem)
const [status, setStatus] = React.useState(presence.Presence.present)
React.useEffect(() => {
setItem(rawItem)
}, [rawItem])
const doDelete = async () => {
if (backend.type !== backendModule.BackendType.local) {
setStatus(presence.Presence.deleting)
markItemAsHidden(key)
try {
await backend.deleteFile(item.id, item.title)
dispatchFileListEvent({
type: fileListEventModule.FileListEventType.delete,
fileId: key,
})
} catch (error) {
setStatus(presence.Presence.present)
markItemAsVisible(key)
const message = `Unable to delete file: ${
errorModule.tryGetMessage(error) ?? 'unknown error.'
}`
toast.error(message)
logger.error(message)
}
}
}
hooks.useEventHandler(fileEvent, async event => {
switch (event.type) {
case fileEventModule.FileEventType.createMultiple: {
const file = event.files.get(key)
if (file != null) {
if (backend.type !== backendModule.BackendType.remote) {
const message = 'Files cannot be uploaded on the local backend.'
toast.error(message)
logger.error(message)
} else {
setStatus(presence.Presence.inserting)
try {
const createdFile = await backend.uploadFile(
{
fileId: null,
fileName: item.title,
parentDirectoryId: item.parentId,
},
file
)
setStatus(presence.Presence.present)
const newItem: backendModule.FileAsset = {
...item,
...createdFile,
}
setItem(newItem)
} catch (error) {
dispatchFileListEvent({
type: fileListEventModule.FileListEventType.delete,
fileId: key,
})
const message = `Error creating new file: ${
errorModule.tryGetMessage(error) ?? 'unknown error.'
}`
toast.error(message)
logger.error(message)
}
}
}
break
}
case fileEventModule.FileEventType.deleteMultiple: {
if (event.fileIds.has(key)) {
await doDelete()
}
break
}
}
})
return (
<TableRow
className={presence.CLASS_NAME[status]}
{...props}
onContextMenu={(innerProps, event) => {
event.preventDefault()
event.stopPropagation()
setModal(
<FileRowContextMenu innerProps={innerProps} event={event} doDelete={doDelete} />
)
}}
item={item}
/>
)
}
// ==================
// === FilesTable ===
// ==================
/** State passed through from a {@link FilesTable} to every cell. */
interface FilesTableState {
fileEvent: fileEventModule.FileEvent | null
dispatchFileListEvent: (event: fileListEventModule.FileListEvent) => void
markItemAsHidden: (key: string) => void
markItemAsVisible: (key: string) => void
}
/** Data associated with a {@link FileRow}, used for rendering. */
interface FileRowState {
isEditingName: boolean
}
/** The default {@link FileRowState} associated with a {@link FileRow}. */
const INITIAL_ROW_STATE: FileRowState = Object.freeze({
isEditingName: false,
})
/** Props for a {@link FilesTable}. */
export interface FilesTableProps {
directoryId: backendModule.DirectoryId | null
items: backendModule.FileAsset[]
filter: ((item: backendModule.FileAsset) => boolean) | null
isLoading: boolean
columnDisplayMode: columnModule.ColumnDisplayMode
fileListEvent: fileListEventModule.FileListEvent | null
dispatchFileListEvent: (projectListEvent: fileListEventModule.FileListEvent) => void
}
/** The table of file assets. */
function FilesTable(props: FilesTableProps) {
const {
directoryId,
items: rawItems,
filter,
isLoading,
columnDisplayMode,
fileListEvent,
dispatchFileListEvent,
} = props
const { organization } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const { setModal } = modalProvider.useSetModal()
const [items, setItems] = React.useState(rawItems)
const [fileEvent, dispatchFileEvent] = hooks.useEvent<fileEventModule.FileEvent>()
React.useEffect(() => {
setItems(rawItems)
}, [rawItems])
const visibleItems = React.useMemo(
() => (filter != null ? items.filter(filter) : items),
[items, filter]
)
// === Tracking number of visually hidden items ===
const [shouldForceShowPlaceholder, setShouldForceShowPlaceholder] = React.useState(false)
const keysOfHiddenItemsRef = React.useRef(new Set<string>())
const updateShouldForceShowPlaceholder = React.useCallback(() => {
setShouldForceShowPlaceholder(
visibleItems.every(item => keysOfHiddenItemsRef.current.has(item.id))
)
}, [visibleItems])
React.useEffect(updateShouldForceShowPlaceholder, [updateShouldForceShowPlaceholder])
React.useEffect(() => {
const oldKeys = keysOfHiddenItemsRef.current
keysOfHiddenItemsRef.current = new Set(
items.map(backendModule.getAssetId).filter(key => oldKeys.has(key))
)
}, [items])
const markItemAsHidden = React.useCallback(
(key: string) => {
keysOfHiddenItemsRef.current.add(key)
updateShouldForceShowPlaceholder()
},
[updateShouldForceShowPlaceholder]
)
const markItemAsVisible = React.useCallback(
(key: string) => {
keysOfHiddenItemsRef.current.delete(key)
updateShouldForceShowPlaceholder()
},
[updateShouldForceShowPlaceholder]
)
// === End tracking number of visually hidden items ===
hooks.useEventHandler(fileListEvent, event => {
switch (event.type) {
case fileListEventModule.FileListEventType.uploadMultiple: {
const placeholderItems: backendModule.FileAsset[] = Array.from(event.files)
.reverse()
.map(file => ({
type: backendModule.AssetType.file,
id: backendModule.FileId(uniqueString.uniqueString()),
title: file.name,
parentId: directoryId ?? backendModule.DirectoryId(''),
permissions: permissions.tryGetSingletonOwnerPermission(organization),
modifiedAt: dateTime.toRfc3339(new Date()),
projectState: null,
}))
setItems(oldItems => [...placeholderItems, ...oldItems])
dispatchFileEvent({
type: fileEventModule.FileEventType.createMultiple,
files: new Map(
placeholderItems.map((placeholderItem, i) => [
placeholderItem.id,
// This is SAFE, as `placeholderItems` is created using a map on
// `event.files`.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
event.files[i]!,
])
),
})
break
}
case fileListEventModule.FileListEventType.delete: {
setItems(oldItems => oldItems.filter(item => item.id !== event.fileId))
break
}
}
})
const state = React.useMemo(
// The type MUST be here to trigger excess property errors at typecheck time.
(): FilesTableState => ({
fileEvent,
dispatchFileListEvent,
markItemAsHidden,
markItemAsVisible,
}),
[fileEvent, dispatchFileListEvent, markItemAsHidden, markItemAsVisible]
)
if (backend.type === backendModule.BackendType.local) {
return <></>
} else {
return (
<Table<backendModule.FileAsset, backendModule.FileId, FilesTableState, FileRowState>
rowComponent={FileRow}
items={visibleItems}
isLoading={isLoading}
state={state}
initialRowState={INITIAL_ROW_STATE}
getKey={backendModule.getAssetId}
placeholder={filter != null ? PLACEHOLDER_WITH_QUERY : PLACEHOLDER_WITHOUT_QUERY}
forceShowPlaceholder={shouldForceShowPlaceholder}
columns={columnModule.columnsFor(columnDisplayMode, backend.type).map(column =>
column === columnModule.Column.name
? {
id: column,
className: columnModule.COLUMN_CSS_CLASS[column],
heading: (
<FileNameHeading
directoryId={directoryId}
dispatchFileListEvent={dispatchFileListEvent}
/>
),
render: FileName,
}
: {
id: column,
className: columnModule.COLUMN_CSS_CLASS[column],
heading: <>{columnModule.COLUMN_NAME[column]}</>,
render: columnModule.COLUMN_RENDERER[column],
}
)}
onContextMenu={(selectedKeys, event, setSelectedKeys) => {
event.preventDefault()
event.stopPropagation()
const doDeleteAll = () => {
setModal(
<ConfirmDeleteModal
description={
`${selectedKeys.size} selected ` + ASSET_TYPE_NAME_PLURAL
}
doDelete={() => {
dispatchFileEvent({
type: fileEventModule.FileEventType.deleteMultiple,
fileIds: selectedKeys,
})
setSelectedKeys(new Set())
}}
/>
)
}
const pluralized = pluralize(selectedKeys.size)
setModal(
<ContextMenu key={uniqueString.uniqueString()} event={event}>
{/*<ContextMenuEntry disabled onClick={doCopyAll}>
Copy {files.size} {pluralized}
</ContextMenuEntry>
<ContextMenuEntry disabled onClick={doCutAll}>
Cut {files.size} {pluralized}
</ContextMenuEntry>*/}
<ContextMenuEntry onClick={doDeleteAll}>
<span className="text-red-700">
Delete {selectedKeys.size} {pluralized}
</span>
</ContextMenuEntry>
</ContextMenu>
)
}}
/>
)
}
}
export default FilesTable

View File

@ -33,11 +33,22 @@ function Ide(props: IdeProps) {
const { backend } = backendProvider.useBackend()
const { accessToken } = auth.useNonPartialUserSession()
let hasEffectRun = false
React.useEffect(() => {
// This is a hack to work around the IDE WASM not playing nicely with React Strict Mode.
// This is unavoidable as the WASM must fully set up to be able to properly drop its assets,
// but React re-executes this side-effect faster tha the WASM can do so.
if (hasEffectRun) {
// eslint-disable-next-line no-restricted-syntax
return
}
// eslint-disable-next-line react-hooks/exhaustive-deps
hasEffectRun = true
void (async () => {
const ideVersion =
project.ideVersion?.value ??
('listVersions' in backend
(backend.type === backendModule.BackendType.remote
? await backend.listVersions({
versionType: backendModule.VersionType.ide,
default: true,
@ -45,7 +56,7 @@ function Ide(props: IdeProps) {
: null)?.[0].number.value
const engineVersion =
project.engineVersion?.value ??
('listVersions' in backend
(backend.type === backendModule.BackendType.remote
? await backend.listVersions({
versionType: backendModule.VersionType.backend,
default: true,
@ -62,14 +73,17 @@ function Ide(props: IdeProps) {
} else if (binaryAddress == null) {
throw new Error("Could not get the address of the project's binary endpoint.")
} else {
const assetsRoot = (() => {
switch (backend.type) {
case backendModule.BackendType.remote:
return `${IDE_CDN_URL}/${ideVersion}/`
case backendModule.BackendType.local:
return ''
let assetsRoot: string
switch (backend.type) {
case backendModule.BackendType.remote: {
assetsRoot = `${IDE_CDN_URL}/${ideVersion}/`
break
}
})()
case backendModule.BackendType.local: {
assetsRoot = ''
break
}
}
const runNewProject = async () => {
const engineConfig =
backend.type === backendModule.BackendType.remote
@ -130,9 +144,9 @@ function Ide(props: IdeProps) {
}
})()
// The backend MUST NOT be a dependency, since the IDE should only be recreated when a new
// project is opened.
// project is opened, and a local project does not exist on the cloud and vice versa.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appRunner, project])
}, [project, /* should never change */ appRunner])
return <></>
}

View File

@ -5,17 +5,20 @@ import * as React from 'react'
// === Input ===
// =============
/** Props for an `<input>` HTML element/ */
type InputAttributes = JSX.IntrinsicElements['input']
/** Props for an {@link Input}. */
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
export interface InputProps extends InputAttributes {
setValue: (value: string) => void
}
/** A component for authentication from inputs, with preset styles. */
function Input(props: InputProps) {
const { setValue, ...passThrough } = props
const { setValue, ...passthroughProps } = props
return (
<input
{...passThrough}
{...passthroughProps}
onChange={event => {
setValue(event.target.value)
}}

View File

@ -9,7 +9,6 @@ import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as errorModule from '../../error'
import * as modalProvider from '../../providers/modal'
import * as newtype from '../../newtype'
import Autocomplete from './autocomplete'
import Modal from './modal'
@ -62,9 +61,22 @@ const ACTION_CSS_CLASS: Record<ManagePermissionsAction, string> = {
/** Props for a {@link ManagePermissionsModal}. */
export interface ManagePermissionsModalProps {
asset: backendModule.Asset
initialPermissions: backendModule.PermissionAction[]
emailsOfUsersWithPermission: Set<backendModule.EmailAddress>
/* If present, the user cannot be changed. */
user?: backendModule.User
onSuccess: () => void
onSubmit: (
users: backendModule.SimpleUser[],
permissions: backendModule.PermissionAction[]
) => void
onSuccess?: (
users: backendModule.SimpleUser[],
permissions: backendModule.PermissionAction[]
) => void
onFailure?: (
users: backendModule.SimpleUser[],
permissions: backendModule.PermissionAction[]
) => void
eventTarget: HTMLElement
}
@ -72,7 +84,16 @@ export interface ManagePermissionsModalProps {
* @throws {Error} when the current backend is the local backend, or when the user is offline.
* This should never happen, as this modal should not be accessible in either case. */
export function ManagePermissionsModal(props: ManagePermissionsModalProps) {
const { asset, user: rawUser, onSuccess, eventTarget } = props
const {
asset,
initialPermissions,
emailsOfUsersWithPermission,
user: rawUser,
onSubmit: rawOnSubmit,
onSuccess,
onFailure,
eventTarget,
} = props
const { organization } = auth.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const { unsetModal } = modalProvider.useSetModal()
@ -90,12 +111,13 @@ export function ManagePermissionsModal(props: ManagePermissionsModalProps) {
const user = React.useMemo(() => {
const firstEmail = emails[0]
if (firstEmail == null || emails.length !== 1) {
if (rawUser != null) {
return rawUser
} else if (firstEmail != null && emails.length === 1) {
return asset.permissions?.find(permission => permission.user.user_email === firstEmail)
?.user
} else {
return (asset.permissions ?? []).find(
permission => permission.user.user_email === firstEmail
)?.user
return null
}
}, [rawUser, emails, /* should never change */ asset.permissions])
@ -108,32 +130,6 @@ export function ManagePermissionsModal(props: ManagePermissionsModalProps) {
? ManagePermissionsAction.inviteToOrganization
: ManagePermissionsAction.share
/** Overridden by the user's permissions only if it is not empty. */
const initialPermissions = React.useMemo(
() =>
permissions.size !== 0
? null
: user != null
? new Set(
(asset.permissions ?? [])
.filter(
assetPermission => assetPermission.user.user_email === user.user_email
)
.map(userPermission => userPermission.permission)
)
: null,
// `permissions` is NOT a dependency; this is an expensive computation so it is only used
// to determine whether the computation should be avoided completely.
// eslint-disable-next-line react-hooks/exhaustive-deps
[user, /* should never change */ asset.permissions]
)
const emailsOfUsersWithPermission = React.useMemo(
() =>
new Set(asset.permissions?.map(userPermission => userPermission.user.user_email) ?? []),
[/* should never change */ asset.permissions]
)
const userEmailRef = React.useRef<HTMLInputElement>(null)
if (backend.type === backendModule.BackendType.local || organization == null) {
@ -176,7 +172,7 @@ export function ManagePermissionsModal(props: ManagePermissionsModalProps) {
setEmails(newEmails)
const lowercaseEmail =
newEmails[0] != null
? newtype.asNewtype<backendModule.EmailAddress>(newEmails[0].toLowerCase())
? backendModule.EmailAddress(newEmails[0].toLowerCase())
: null
if (
userEmailRef.current?.validity.valid === true &&
@ -235,21 +231,24 @@ export function ManagePermissionsModal(props: ManagePermissionsModalProps) {
try {
await backend.inviteUser({
organizationId: organization.id,
userEmail: newtype.asNewtype<backendModule.EmailAddress>(firstEmail),
userEmail: backendModule.EmailAddress(firstEmail),
})
} catch (error) {
toast.error(errorModule.tryGetMessage(error) ?? 'Unknown error.')
}
} else if (finalUsers.length !== 0) {
unsetModal()
const permissionsArray = [...permissions]
try {
rawOnSubmit(finalUsers, permissionsArray)
await backend.createPermission({
userSubjects: finalUsers.map(finalUser => finalUser.id),
resourceId: asset.id,
actions: [...permissions],
actions: permissionsArray,
})
onSuccess()
onSuccess?.(finalUsers, permissionsArray)
} catch {
onFailure?.(finalUsers, permissionsArray)
const finalUserEmails = finalUsers.map(finalUser => `'${finalUser.email}'`)
toast.error(`Unable to set permissions of ${finalUserEmails.join(', ')}.`)
}
@ -263,7 +262,9 @@ export function ManagePermissionsModal(props: ManagePermissionsModalProps) {
organization.id,
users,
user,
rawOnSubmit,
onSuccess,
onFailure,
/* should never change */ unsetModal,
/* should never change */ backend,
/* should never change */ rawUser,

View File

@ -30,7 +30,7 @@ const PERMISSIONS = [
/** Props for a {@link PermissionSelector}. */
export interface PermissionSelectorProps {
/** If this prop changes, the internal state will be updated too. */
initialPermissions?: Set<backend.PermissionAction> | null
initialPermissions?: backend.PermissionAction[] | null
className?: string
permissionClassName?: string
onChange: (permissions: Set<backend.PermissionAction>) => void
@ -38,22 +38,18 @@ export interface PermissionSelectorProps {
/** A horizontal selector for all possible permissions. */
function PermissionSelector(props: PermissionSelectorProps) {
const {
initialPermissions: rawInitialPermissions,
className,
permissionClassName,
onChange,
} = props
const { initialPermissions, className, permissionClassName, onChange } = props
const [permissions, setPermissions] = React.useState(() => new Set<backend.PermissionAction>())
React.useEffect(() => {
if (rawInitialPermissions != null) {
setPermissions(rawInitialPermissions)
onChange(rawInitialPermissions)
if (initialPermissions != null) {
const initialPermissionsSet = new Set(initialPermissions)
setPermissions(initialPermissionsSet)
onChange(initialPermissionsSet)
}
// `onChange` is NOT a dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rawInitialPermissions])
}, [initialPermissions])
return (
<div className={`flex justify-items-center ${className ?? ''}`}>

View File

@ -1,424 +0,0 @@
/** @file An interactive button displaying the status of a project. */
import * as React from 'react'
import toast from 'react-hot-toast'
import ArrowUpIcon from 'enso-assets/arrow_up.svg'
import PlayIcon from 'enso-assets/play.svg'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as localBackend from '../localBackend'
import * as modalProvider from '../../providers/modal'
import * as svg from '../../components/svg'
import * as spinner from './spinner'
// =============
// === Types ===
// =============
/** Data associated with a project, used for rendering.
* FIXME[sb]: This is a hack that is required because each row does not carry its own extra state.
* It will be obsoleted by the implementation in https://github.com/enso-org/enso/pull/6546. */
export interface ProjectData {
isRunning: boolean
}
/** Possible types of project state change. */
export enum ProjectEventType {
open = 'open',
cancelOpeningAll = 'cancelOpeningAll',
}
/** Properties common to all project state change events. */
interface ProjectBaseEvent<Type extends ProjectEventType> {
type: Type
}
/** Requests the specified project to be opened. */
export interface ProjectOpenEvent extends ProjectBaseEvent<ProjectEventType.open> {
/** This must be a name because it may be specified by name on the command line.
* Note that this will not work properly with the cloud backend if there are multiple projects
* with the same name. */
projectId: backendModule.ProjectId
onSpinnerStateChange: ((state: spinner.SpinnerState) => void) | null
}
/** Requests the specified project to be opened. */
export interface ProjectCancelOpeningAllEvent
extends ProjectBaseEvent<ProjectEventType.cancelOpeningAll> {}
/** Every possible type of project event. */
export type ProjectEvent = ProjectCancelOpeningAllEvent | ProjectOpenEvent
// =================
// === Constants ===
// =================
/** The default {@link ProjectData} associated with a {@link backendModule.Project}. */
export const DEFAULT_PROJECT_DATA: ProjectData = Object.freeze({
isRunning: false,
})
const LOADING_MESSAGE =
'Your environment is being created. It will take some time, please be patient.'
/** The interval between requests checking whether the IDE is ready. */
const CHECK_STATUS_INTERVAL_MS = 5000
/** The interval between requests checking whether the VM is ready. */
const CHECK_RESOURCES_INTERVAL_MS = 1000
/** The corresponding {@link SpinnerState} for each {@link backendModule.ProjectState},
* when using the remote backend. */
const REMOTE_SPINNER_STATE: Record<backendModule.ProjectState, spinner.SpinnerState> = {
[backendModule.ProjectState.closed]: spinner.SpinnerState.initial,
[backendModule.ProjectState.created]: spinner.SpinnerState.initial,
[backendModule.ProjectState.new]: spinner.SpinnerState.initial,
[backendModule.ProjectState.openInProgress]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.opened]: spinner.SpinnerState.done,
}
/** The corresponding {@link SpinnerState} for each {@link backendModule.ProjectState},
* when using the local backend. */
const LOCAL_SPINNER_STATE: Record<backendModule.ProjectState, spinner.SpinnerState> = {
[backendModule.ProjectState.closed]: spinner.SpinnerState.initial,
[backendModule.ProjectState.created]: spinner.SpinnerState.initial,
[backendModule.ProjectState.new]: spinner.SpinnerState.initial,
[backendModule.ProjectState.openInProgress]: spinner.SpinnerState.loadingMedium,
[backendModule.ProjectState.opened]: spinner.SpinnerState.done,
}
// =================
// === Component ===
// =================
/** Props for a {@link ProjectActionButton}. */
export interface ProjectActionButtonProps {
project: backendModule.Asset<backendModule.AssetType.project>
projectData: ProjectData
setProjectData: React.Dispatch<React.SetStateAction<ProjectData>>
appRunner: AppRunner | null
event: ProjectEvent | null
/** Called when the project is opened via the {@link ProjectActionButton}. */
doOpenManually: () => void
onClose: () => void
openIde: () => void
doRefresh: () => void
}
/** An interactive button displaying the status of a project. */
function ProjectActionButton(props: ProjectActionButtonProps) {
const {
project,
setProjectData,
event,
appRunner,
doOpenManually,
onClose,
openIde,
doRefresh,
} = props
const { backend } = backendProvider.useBackend()
const { unsetModal } = modalProvider.useSetModal()
const shouldCheckIfActuallyOpen =
backend.type === backendModule.BackendType.remote &&
(project.projectState.type === backendModule.ProjectState.opened ||
project.projectState.type === backendModule.ProjectState.openInProgress)
const [state, setState] = React.useState(() => {
if (shouldCheckIfActuallyOpen) {
return backendModule.ProjectState.created
} else {
return project.projectState.type
}
})
const [isCheckingStatus, setIsCheckingStatus] = React.useState(false)
const [isCheckingResources, setIsCheckingResources] = React.useState(false)
const [spinnerState, setSpinnerState] = React.useState(REMOTE_SPINNER_STATE[state])
const [onSpinnerStateChange, setOnSpinnerStateChange] = React.useState<
((state: spinner.SpinnerState | null) => void) | null
>(null)
const [shouldOpenWhenReady, setShouldOpenWhenReady] = React.useState(false)
const [toastId, setToastId] = React.useState<string | null>(null)
React.useEffect(() => {
if (toastId != null) {
return () => {
toast.dismiss(toastId)
}
} else {
return
}
}, [toastId])
React.useEffect(() => {
// Ensure that the previous spinner state is visible for at least one frame.
requestAnimationFrame(() => {
const newSpinnerState =
backend.type === backendModule.BackendType.remote
? REMOTE_SPINNER_STATE[state]
: LOCAL_SPINNER_STATE[state]
setSpinnerState(newSpinnerState)
onSpinnerStateChange?.(
state === backendModule.ProjectState.closed ? null : newSpinnerState
)
})
}, [state, onSpinnerStateChange, backend.type])
React.useEffect(() => {
onSpinnerStateChange?.(spinner.SpinnerState.initial)
}, [onSpinnerStateChange])
React.useEffect(() => {
if (toastId != null && state !== backendModule.ProjectState.openInProgress) {
toast.dismiss(toastId)
}
}, [state, toastId])
React.useEffect(() => {
if (shouldCheckIfActuallyOpen) {
setState(backendModule.ProjectState.openInProgress)
setIsCheckingResources(true)
}
}, [shouldCheckIfActuallyOpen])
const openProject = React.useCallback(async () => {
setState(backendModule.ProjectState.openInProgress)
try {
switch (backend.type) {
case backendModule.BackendType.remote:
setToastId(toast.loading(LOADING_MESSAGE))
await backend.openProject(project.id)
setProjectData(oldProjectData => ({ ...oldProjectData, isRunning: true }))
doRefresh()
setIsCheckingStatus(true)
break
case backendModule.BackendType.local:
await backend.openProject(project.id)
setProjectData(oldProjectData => ({ ...oldProjectData, isRunning: true }))
setState(oldState => {
if (oldState === backendModule.ProjectState.openInProgress) {
setTimeout(() => {
doRefresh()
}, 0)
return backendModule.ProjectState.opened
} else {
return oldState
}
})
break
}
} catch {
setIsCheckingStatus(false)
setIsCheckingResources(false)
toast.error(`Error opening project '${project.title}'.`)
setState(backendModule.ProjectState.closed)
}
}, [backend, doRefresh, project.id, project.title, setProjectData])
React.useEffect(() => {
if (event != null) {
switch (event.type) {
case ProjectEventType.open: {
if (event.projectId !== project.id) {
setShouldOpenWhenReady(false)
if (onSpinnerStateChange === event.onSpinnerStateChange) {
setOnSpinnerStateChange(null)
}
} else {
setShouldOpenWhenReady(true)
setOnSpinnerStateChange(() => event.onSpinnerStateChange)
void openProject()
}
break
}
case ProjectEventType.cancelOpeningAll: {
setShouldOpenWhenReady(false)
onSpinnerStateChange?.(null)
setOnSpinnerStateChange(null)
break
}
}
}
// This effect MUST run if and only if `event` changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [event])
React.useEffect(() => {
if (shouldOpenWhenReady && state === backendModule.ProjectState.opened) {
openIde()
setShouldOpenWhenReady(false)
}
}, [openIde, shouldOpenWhenReady, state])
React.useEffect(() => {
if (
backend.type === backendModule.BackendType.local &&
project.id !== localBackend.LocalBackend.currentlyOpeningProjectId
) {
setState(backendModule.ProjectState.closed)
}
// `localBackend.LocalBackend.currentlyOpeningProjectId` is a mutable outer scope value.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project, state, backend.type, localBackend.LocalBackend.currentlyOpeningProjectId])
React.useEffect(() => {
if (!isCheckingStatus) {
return
} else {
let handle: number | null = null
let continuePolling = true
let previousTimestamp = 0
const checkProjectStatus = async () => {
try {
const response = await backend.getProjectDetails(project.id)
handle = null
if (
continuePolling &&
response.state.type === backendModule.ProjectState.opened
) {
continuePolling = false
setIsCheckingStatus(false)
setIsCheckingResources(true)
}
} finally {
if (continuePolling) {
const nowTimestamp = Number(new Date())
const delay = CHECK_STATUS_INTERVAL_MS - (nowTimestamp - previousTimestamp)
previousTimestamp = nowTimestamp
handle = window.setTimeout(
() => void checkProjectStatus(),
Math.max(0, delay)
)
}
}
}
void checkProjectStatus()
return () => {
continuePolling = false
if (handle != null) {
clearTimeout(handle)
}
}
}
}, [backend, isCheckingStatus, project.id])
React.useEffect(() => {
if (!isCheckingResources) {
return
} else {
let handle: number | null = null
let continuePolling = true
let previousTimestamp = 0
const checkProjectResources = async () => {
if (backend.type === backendModule.BackendType.local) {
setState(backendModule.ProjectState.opened)
setIsCheckingResources(false)
} else {
try {
// This call will error if the VM is not ready yet.
await backend.checkResources(project.id)
handle = null
if (continuePolling) {
continuePolling = false
setState(backendModule.ProjectState.opened)
setIsCheckingResources(false)
}
} catch {
if (continuePolling) {
const nowTimestamp = Number(new Date())
const delay =
CHECK_RESOURCES_INTERVAL_MS - (nowTimestamp - previousTimestamp)
previousTimestamp = nowTimestamp
handle = window.setTimeout(
() => void checkProjectResources(),
Math.max(0, delay)
)
}
}
}
}
void checkProjectResources()
return () => {
continuePolling = false
if (handle != null) {
clearTimeout(handle)
}
}
}
}, [backend, isCheckingResources, project.id])
const closeProject = async () => {
onClose()
setShouldOpenWhenReady(false)
setState(backendModule.ProjectState.closed)
onSpinnerStateChange?.(null)
setOnSpinnerStateChange(null)
appRunner?.stopApp()
setIsCheckingStatus(false)
setIsCheckingResources(false)
try {
await backend.closeProject(project.id)
} finally {
// This is not 100% correct, but it is better than never setting `isRunning` to `false`,
// which would prevent the project from ever being deleted.
setProjectData(oldProjectData => ({ ...oldProjectData, isRunning: false }))
}
}
switch (state) {
case null:
case backendModule.ProjectState.created:
case backendModule.ProjectState.new:
case backendModule.ProjectState.closed:
return (
<button
className="w-6"
onClick={clickEvent => {
clickEvent.stopPropagation()
unsetModal()
doOpenManually()
}}
>
<img src={PlayIcon} />
</button>
)
case backendModule.ProjectState.openInProgress:
return (
<button
className="w-6"
onClick={async clickEvent => {
clickEvent.stopPropagation()
unsetModal()
await closeProject()
}}
>
<svg.StopIcon className={spinner.SPINNER_CSS_CLASSES[spinnerState]} />
</button>
)
case backendModule.ProjectState.opened:
return (
<>
<button
className="w-6"
onClick={async clickEvent => {
clickEvent.stopPropagation()
unsetModal()
await closeProject()
}}
>
<svg.StopIcon className={spinner.SPINNER_CSS_CLASSES[spinnerState]} />
</button>
<button
className="w-6"
onClick={clickEvent => {
clickEvent.stopPropagation()
unsetModal()
openIde()
}}
>
<img src={ArrowUpIcon} />
</button>
</>
)
}
}
export default ProjectActionButton

View File

@ -1,106 +0,0 @@
/** @file Modal for confirming delete of any type of asset. */
import * as React from 'react'
import toast from 'react-hot-toast'
import CloseIcon from 'enso-assets/close.svg'
import * as modalProvider from '../../providers/modal'
import Input from './input'
import Modal from './modal'
// ===================
// === RenameModal ===
// ===================
/** Props for a {@link RenameModal}. */
export interface RenameModalProps {
assetType: string
name: string
namePattern?: string
title?: string
doRename: (newName: string) => Promise<void>
onComplete: () => void
}
/** A modal for renaming an asset. */
function RenameModal(props: RenameModalProps) {
const { assetType, name, namePattern, title, doRename, onComplete } = props
const { unsetModal } = modalProvider.useSetModal()
const [newName, setNewName] = React.useState<string | null>(null)
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (newName == null) {
toast.error('Please provide a new name.')
} else {
unsetModal()
try {
await toast.promise(doRename(newName), {
loading: `Renaming ${assetType} '${name}' to '${newName}'...`,
success: `Renamed ${assetType} '${name}' to '${newName}'.`,
// This is UNSAFE, as the original function's parameter is of type `any`.
error: (promiseError: Error) =>
`Error renaming ${assetType} '${name}' to '${newName}': ${promiseError.message}`,
})
} finally {
onComplete()
}
}
}
return (
<Modal centered className="bg-opacity-90">
<form
onClick={event => {
event.stopPropagation()
}}
onSubmit={onSubmit}
className="relative bg-white shadow-soft rounded-lg w-96 p-2"
>
<div className="flex">
{/* Padding. */}
<div className="grow" />
<button type="button" onClick={unsetModal}>
<img src={CloseIcon} />
</button>
</div>
<div className="m-2">
What do you want to rename the {assetType} &lsquo;{name}&rsquo; to?
</div>
<div className="m-2">
<Input
autoFocus
required
// Never disabled, as disabling unfocuses the input.
id="renamed_asset_name"
type="text"
pattern={namePattern}
title={title}
className="border-primary bg-gray-200 rounded-full w-full px-2"
defaultValue={newName ?? name}
setValue={setNewName}
/>
</div>
<div className="m-1">
<button
type="submit"
className="hover:cursor-pointer inline-block text-white bg-blue-600 rounded-full px-4 py-1 m-1"
>
Rename
</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>
)
}
export default RenameModal

View File

@ -1,109 +0,0 @@
/** @file Table that projects an object into each column. */
import * as React from 'react'
import Spinner, * as spinner from './spinner'
// =================
// === Constants ===
// =================
/** The size of the loading spinner. */
const LOADING_SPINNER_SIZE = 36
// =============
// === Types ===
// =============
/** Metadata describing how to render a column of the table. */
export interface Column<T> {
id: string
heading: JSX.Element
render: (item: T, index: number) => React.ReactNode
}
// =================
// === Component ===
// =================
/** Props for a {@link Rows}. */
export interface RowsProps<T> {
items: T[]
getKey: (item: T) => string
isLoading: boolean
placeholder: JSX.Element
columns: Column<T>[]
onClick: (item: T, event: React.MouseEvent<HTMLTableRowElement>) => void
onContextMenu: (item: T, event: React.MouseEvent<HTMLTableRowElement>) => void
}
/** Table that projects an object into each column. */
function Rows<T>(props: RowsProps<T>) {
const { columns, items, isLoading, getKey, placeholder, onClick, onContextMenu } = props
const [spinnerState, setSpinnerState] = React.useState(spinner.SpinnerState.initial)
const headerRow = (
<tr>
{columns.map(column => (
<th
key={column.id}
className="text-vs px-4 align-middle py-1 border-0 border-r whitespace-nowrap font-semibold text-left"
>
{column.heading}
</th>
))}
</tr>
)
React.useEffect(() => {
if (isLoading) {
// Ensure the spinner stays in the "initial" state for at least one frame.
requestAnimationFrame(() => {
setSpinnerState(spinner.SpinnerState.loadingFast)
})
} else {
setSpinnerState(spinner.SpinnerState.initial)
}
}, [isLoading])
const itemRows = isLoading ? (
<tr className="h-10">
<td colSpan={columns.length}>
<div className="grid justify-around w-full">
<Spinner size={LOADING_SPINNER_SIZE} state={spinnerState} />
</div>
</td>
</tr>
) : items.length === 0 ? (
<tr className="h-10">
<td colSpan={columns.length}>{placeholder}</td>
</tr>
) : (
items.map((item, index) => (
<tr
key={getKey(item)}
tabIndex={-1}
onClick={event => {
onClick(item, event)
}}
onContextMenu={event => {
onContextMenu(item, event)
}}
className="h-10 transition duration-300 ease-in-out hover:bg-gray-100 focus:bg-gray-200"
>
{columns.map(column => (
<td key={column.id} className="px-4 border-0 border-r vertical-align-middle">
{column.render(item, index)}
</td>
))}
</tr>
))
)
return (
<>
{headerRow}
{itemRows}
</>
)
}
export default Rows

View File

@ -1,94 +0,0 @@
/** @file Form to create a project. */
import * as React from 'react'
import toast from 'react-hot-toast'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as error from '../../error'
import * as modalProvider from '../../providers/modal'
import CreateForm, * as createForm from './createForm'
// ========================
// === SecretCreateForm ===
// ========================
/** Props for a {@link SecretCreateForm}. */
export interface SecretCreateFormProps extends createForm.CreateFormPassthroughProps {
directoryId: backendModule.DirectoryId
onSuccess: () => void
}
/** A form to create a secret. */
function SecretCreateForm(props: SecretCreateFormProps) {
const { directoryId, onSuccess, ...passThrough } = props
const { backend } = backendProvider.useBackend()
const { unsetModal } = modalProvider.useSetModal()
const [name, setName] = React.useState<string | null>(null)
const [value, setValue] = React.useState<string | null>(null)
if (backend.type === backendModule.BackendType.local) {
return <></>
} else {
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (name == null || name === '') {
toast.error('Please provide a secret name.')
} else if (value == null) {
// Secret value explicitly can be empty.
toast.error('Please provide a secret value.')
} else {
unsetModal()
await toast
.promise(
backend.createSecret({
parentDirectoryId: directoryId,
secretName: name,
secretValue: value,
}),
{
loading: 'Creating secret...',
success: 'Sucessfully created secret.',
error: error.unsafeIntoErrorMessage,
}
)
.then(onSuccess)
}
}
return (
<CreateForm title="New Secret" onSubmit={onSubmit} {...passThrough}>
<div className="flex flex-row flex-nowrap m-1">
<label className="inline-block flex-1 grow m-1" htmlFor="project_name">
Name
</label>
<input
id="project_name"
type="text"
size={1}
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
onChange={event => {
setName(event.target.value)
}}
/>
</div>
<div className="flex flex-row flex-nowrap m-1">
<label className="inline-block flex-1 grow m-1" htmlFor="secret_value">
Value
</label>
<input
id="secret_value"
type="text"
size={1}
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
onChange={event => {
setValue(event.target.value)
}}
/>
</div>
</CreateForm>
)
}
}
export default SecretCreateForm

View File

@ -0,0 +1,606 @@
/** @file Table displaying a list of secrets. */
import * as React from 'react'
import toast from 'react-hot-toast'
import PlusIcon from 'enso-assets/plus.svg'
import SecretIcon from 'enso-assets/secret.svg'
import * as backendModule from '../backend'
import * as columnModule from '../column'
import * as dateTime from '../dateTime'
import * as errorModule from '../../error'
import * as eventModule from '../event'
import * as hooks from '../../hooks'
import * as permissions from '../permissions'
import * as presence from '../presence'
import * as secretEventModule from '../events/secretEvent'
import * as secretListEventModule from '../events/secretListEvent'
import * as shortcuts from '../shortcuts'
import * as string from '../../string'
import * as uniqueString from '../../uniqueString'
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 tableColumn from './tableColumn'
import CreateForm, * as createForm from './createForm'
import TableRow, * as tableRow from './tableRow'
import ConfirmDeleteModal from './confirmDeleteModal'
import ContextMenu from './contextMenu'
import ContextMenuEntry from './contextMenuEntry'
import EditableSpan from './editableSpan'
import Table from './table'
// =================
// === Constants ===
// =================
/** The user-facing name of this asset type. */
const ASSET_TYPE_NAME = 'secret'
/** The user-facing plural name of this asset type. */
const ASSET_TYPE_NAME_PLURAL = 'secrets'
// This is a function, even though it is not syntactically a function.
// eslint-disable-next-line no-restricted-syntax
const pluralize = string.makePluralize(ASSET_TYPE_NAME, ASSET_TYPE_NAME_PLURAL)
/** Placeholder row when the search query is not empty. */
const PLACEHOLDER_WITH_QUERY = (
<span className="opacity-75">
This folder does not contain any secrets matching your query.
</span>
)
/** Placeholder row when the search query is empty. */
const PLACEHOLDER_WITHOUT_QUERY = (
<span className="opacity-75">This folder does not contain any secrets.</span>
)
// ========================
// === SecretCreateForm ===
// ========================
/** Props for a {@link SecretCreateForm}. */
interface InternalSecretCreateFormProps extends createForm.CreateFormPassthroughProps {
dispatchSecretListEvent: (event: secretListEventModule.SecretListEvent) => void
}
/** A form to create a new secret asset. */
function SecretCreateForm(props: InternalSecretCreateFormProps) {
const { dispatchSecretListEvent, ...passThrough } = props
const { backend } = backendProvider.useBackend()
const { unsetModal } = modalProvider.useSetModal()
const [name, setName] = React.useState<string | null>(null)
const [value, setValue] = React.useState<string | null>(null)
if (backend.type === backendModule.BackendType.local) {
return <></>
} else {
const onSubmit = (event: React.FormEvent) => {
event.preventDefault()
if (name == null) {
toast.error('Please provide a secret name.')
} else if (value == null) {
// Secret value explicitly can be empty.
toast.error('Please provide a secret value.')
} else {
unsetModal()
dispatchSecretListEvent({
type: secretListEventModule.SecretListEventType.create,
name: name,
value: value,
})
}
}
return (
<CreateForm title="New Secret" onSubmit={onSubmit} {...passThrough}>
<div className="flex flex-row flex-nowrap m-1">
<label className="inline-block flex-1 grow m-1" htmlFor="project_name">
Name
</label>
<input
id="project_name"
type="text"
size={1}
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
onChange={event => {
setName(event.target.value)
}}
/>
</div>
<div className="flex flex-row flex-nowrap m-1">
<label className="inline-block flex-1 grow m-1" htmlFor="secret_value">
Value
</label>
<input
id="secret_value"
type="text"
size={1}
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
onChange={event => {
setValue(event.target.value)
}}
/>
</div>
</CreateForm>
)
}
}
// =========================
// === SecretNameHeading ===
// =========================
/** Props for a {@link SecretNameHeading}. */
interface InternalSecretNameHeadingProps {
dispatchSecretListEvent: (event: secretListEventModule.SecretListEvent) => void
}
/** The column header for the "name" column for the table of secret assets. */
function SecretNameHeading(props: InternalSecretNameHeadingProps) {
const { dispatchSecretListEvent } = props
const { setModal } = modalProvider.useSetModal()
return (
<div className="inline-flex">
{string.capitalizeFirst(ASSET_TYPE_NAME_PLURAL)}
<button
className="mx-1"
onClick={event => {
event.stopPropagation()
const buttonPosition = event.currentTarget.getBoundingClientRect()
setModal(
<SecretCreateForm
left={buttonPosition.left + window.scrollX}
top={buttonPosition.top + window.scrollY}
dispatchSecretListEvent={dispatchSecretListEvent}
/>
)
}}
>
<img src={PlusIcon} />
</button>
</div>
)
}
// ==================
// === SecretName ===
// ==================
/** Props for a {@link SecretName}. */
interface InternalSecretNameProps
extends tableColumn.TableColumnProps<
backendModule.SecretAsset,
SecretsTableState,
SecretRowState
> {}
/** The icon and name of a specific secret asset. */
function SecretName(props: InternalSecretNameProps) {
const { item, setItem, selected, setRowState } = props
// TODO[sb]: Wait for backend implementation. `editable` should also be re-enabled, and the
// context menu entry should be re-added.
// Backend implementation is tracked here: https://github.com/enso-org/cloud-v2/issues/505.
const doRename = async (/* _newName: string */) => {
await Promise.resolve(null)
}
return (
<div
className="flex text-left items-center align-middle whitespace-nowrap"
onClick={event => {
if (
eventModule.isSingleClick(event) &&
(selected ||
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
shortcuts.MouseAction.editName,
event
))
) {
setRowState(oldRowState => ({
...oldRowState,
isEditingName: true,
}))
}
}}
>
<img src={SecretIcon} />{' '}
<EditableSpan
editable={false}
onSubmit={async newTitle => {
setRowState(oldRowState => ({
...oldRowState,
isEditingName: false,
}))
if (newTitle !== item.title) {
const oldTitle = item.title
setItem(oldItem => ({ ...oldItem, title: newTitle }))
try {
await doRename(/* newTitle */)
} catch {
setItem(oldItem => ({ ...oldItem, title: oldTitle }))
}
}
}}
onCancel={() => {
setRowState(oldRowState => ({
...oldRowState,
isEditingName: false,
}))
}}
className="bg-transparent grow px-2"
>
{item.title}
</EditableSpan>
</div>
)
}
// ============================
// === SecretRowContextMenu ===
// ============================
/** Props for a {@link SecretRowContextMenu}. */
interface InternalDirectoryRowContextMenuProps {
innerProps: tableRow.TableRowInnerProps<
backendModule.SecretAsset,
backendModule.SecretId,
SecretRowState
>
event: React.MouseEvent
doDelete: () => Promise<void>
}
/** The context menu for a row of a {@link SecretsTable}. */
function SecretRowContextMenu(props: InternalDirectoryRowContextMenuProps) {
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>
)
}
// =================
// === SecretRow ===
// =================
/** A row in a {@link SecretsTable}. */
function SecretRow(
props: tableRow.TableRowProps<
backendModule.SecretAsset,
backendModule.SecretId,
SecretsTableState,
SecretRowState
>
) {
const {
keyProp: key,
item: rawItem,
state: { secretEvent, dispatchSecretListEvent, markItemAsHidden, markItemAsVisible },
} = props
const logger = loggerProvider.useLogger()
const { backend } = backendProvider.useBackend()
const { setModal } = modalProvider.useSetModal()
const [item, setItem] = React.useState(rawItem)
const [status, setStatus] = React.useState(presence.Presence.present)
React.useEffect(() => {
setItem(rawItem)
}, [rawItem])
const doDelete = async () => {
if (backend.type !== backendModule.BackendType.local) {
setStatus(presence.Presence.deleting)
markItemAsHidden(key)
try {
await backend.deleteSecret(item.id, item.title)
dispatchSecretListEvent({
type: secretListEventModule.SecretListEventType.delete,
secretId: key,
})
} catch (error) {
setStatus(presence.Presence.present)
markItemAsVisible(key)
const message = `Unable to delete secret: ${
errorModule.tryGetMessage(error) ?? 'unknown error.'
}`
toast.error(message)
logger.error(message)
}
}
}
hooks.useEventHandler(secretEvent, async event => {
switch (event.type) {
case secretEventModule.SecretEventType.create: {
if (key === event.placeholderId) {
if (backend.type !== backendModule.BackendType.remote) {
const message = 'Secrets cannot be created on the local backend.'
toast.error(message)
logger.error(message)
} else {
setStatus(presence.Presence.inserting)
try {
const createdSecret = await backend.createSecret({
parentDirectoryId: item.parentId,
secretName: item.title,
secretValue: event.value,
})
setStatus(presence.Presence.present)
const newItem: backendModule.SecretAsset = {
...item,
...createdSecret,
}
setItem(newItem)
} catch (error) {
dispatchSecretListEvent({
type: secretListEventModule.SecretListEventType.delete,
secretId: key,
})
const message = `Error creating new secret: ${
errorModule.tryGetMessage(error) ?? 'unknown error.'
}`
toast.error(message)
logger.error(message)
}
}
}
break
}
case secretEventModule.SecretEventType.deleteMultiple: {
if (event.secretIds.has(key)) {
await doDelete()
}
break
}
}
})
return (
<TableRow
className={presence.CLASS_NAME[status]}
{...props}
onContextMenu={(innerProps, event) => {
event.preventDefault()
event.stopPropagation()
setModal(
<SecretRowContextMenu
innerProps={innerProps}
event={event}
doDelete={doDelete}
/>
)
}}
item={item}
/>
)
}
// ====================
// === SecretsTable ===
// ====================
/** State passed through from a {@link SecretsTable} to every cell. */
interface SecretsTableState {
secretEvent: secretEventModule.SecretEvent | null
dispatchSecretListEvent: (event: secretListEventModule.SecretListEvent) => void
markItemAsHidden: (key: string) => void
markItemAsVisible: (key: string) => void
}
/** Data associated with a {@link SecretRow}, used for rendering. */
export interface SecretRowState {
isEditingName: boolean
}
/** The default {@link SecretRowState} associated with a {@link SecretRow}. */
export const INITIAL_ROW_STATE: SecretRowState = Object.freeze({
isEditingName: false,
})
/** Props for a {@link SecretsTable}. */
export interface SecretsTableProps {
directoryId: backendModule.DirectoryId | null
items: backendModule.SecretAsset[]
filter: ((item: backendModule.SecretAsset) => boolean) | null
isLoading: boolean
columnDisplayMode: columnModule.ColumnDisplayMode
}
/** The table of secret assets. */
function SecretsTable(props: SecretsTableProps) {
const { directoryId, items: rawItems, filter, isLoading, columnDisplayMode } = props
const logger = loggerProvider.useLogger()
const { organization } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const { setModal } = modalProvider.useSetModal()
const [items, setItems] = React.useState(rawItems)
const [secretEvent, dispatchSecretEvent] = hooks.useEvent<secretEventModule.SecretEvent>()
const [secretListEvent, dispatchSecretListEvent] =
hooks.useEvent<secretListEventModule.SecretListEvent>()
React.useEffect(() => {
setItems(rawItems)
}, [rawItems])
const visibleItems = React.useMemo(
() => (filter != null ? items.filter(filter) : items),
[items, filter]
)
// === Tracking number of visually hidden items ===
const [shouldForceShowPlaceholder, setShouldForceShowPlaceholder] = React.useState(false)
const keysOfHiddenItemsRef = React.useRef(new Set<string>())
const updateShouldForceShowPlaceholder = React.useCallback(() => {
setShouldForceShowPlaceholder(
visibleItems.every(item => keysOfHiddenItemsRef.current.has(item.id))
)
}, [visibleItems])
React.useEffect(updateShouldForceShowPlaceholder, [updateShouldForceShowPlaceholder])
React.useEffect(() => {
const oldKeys = keysOfHiddenItemsRef.current
keysOfHiddenItemsRef.current = new Set(
items.map(backendModule.getAssetId).filter(key => oldKeys.has(key))
)
}, [items])
const markItemAsHidden = React.useCallback(
(key: string) => {
keysOfHiddenItemsRef.current.add(key)
updateShouldForceShowPlaceholder()
},
[updateShouldForceShowPlaceholder]
)
const markItemAsVisible = React.useCallback(
(key: string) => {
keysOfHiddenItemsRef.current.delete(key)
updateShouldForceShowPlaceholder()
},
[updateShouldForceShowPlaceholder]
)
// === End tracking number of visually hidden items ===
hooks.useEventHandler(secretListEvent, event => {
switch (event.type) {
case secretListEventModule.SecretListEventType.create: {
if (backend.type !== backendModule.BackendType.remote) {
const message = 'Secrets cannot be created on the local backend.'
toast.error(message)
logger.error(message)
} else {
const placeholderItem: backendModule.SecretAsset = {
id: backendModule.SecretId(uniqueString.uniqueString()),
title: event.name,
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: directoryId ?? backendModule.DirectoryId(''),
permissions: permissions.tryGetSingletonOwnerPermission(organization),
projectState: null,
type: backendModule.AssetType.secret,
}
setItems(oldItems => [placeholderItem, ...oldItems])
dispatchSecretEvent({
type: secretEventModule.SecretEventType.create,
placeholderId: placeholderItem.id,
value: event.value,
})
}
break
}
case secretListEventModule.SecretListEventType.delete: {
setItems(oldItems => oldItems.filter(item => item.id !== event.secretId))
break
}
}
})
const state = React.useMemo(
// The type MUST be here to trigger excess property errors at typecheck time.
(): SecretsTableState => ({
secretEvent,
dispatchSecretListEvent,
markItemAsHidden,
markItemAsVisible,
}),
[secretEvent, dispatchSecretListEvent, markItemAsHidden, markItemAsVisible]
)
if (backend.type === backendModule.BackendType.local) {
return <></>
} else {
return (
<Table<
backendModule.SecretAsset,
backendModule.SecretId,
SecretsTableState,
SecretRowState
>
rowComponent={SecretRow}
items={visibleItems}
isLoading={isLoading}
state={state}
initialRowState={INITIAL_ROW_STATE}
getKey={backendModule.getAssetId}
placeholder={filter != null ? PLACEHOLDER_WITH_QUERY : PLACEHOLDER_WITHOUT_QUERY}
forceShowPlaceholder={shouldForceShowPlaceholder}
columns={columnModule.columnsFor(columnDisplayMode, backend.type).map(column =>
column === columnModule.Column.name
? {
id: column,
className: columnModule.COLUMN_CSS_CLASS[column],
heading: (
<SecretNameHeading
dispatchSecretListEvent={dispatchSecretListEvent}
/>
),
render: SecretName,
}
: {
id: column,
className: columnModule.COLUMN_CSS_CLASS[column],
heading: <>{columnModule.COLUMN_NAME[column]}</>,
render: columnModule.COLUMN_RENDERER[column],
}
)}
onContextMenu={(selectedKeys, event, setSelectedKeys) => {
event.preventDefault()
event.stopPropagation()
const doDeleteAll = () => {
setModal(
<ConfirmDeleteModal
description={
`${selectedKeys.size} selected ` + ASSET_TYPE_NAME_PLURAL
}
doDelete={() => {
dispatchSecretEvent({
type: secretEventModule.SecretEventType.deleteMultiple,
secretIds: selectedKeys,
})
setSelectedKeys(new Set())
}}
/>
)
}
const pluralized = pluralize(selectedKeys.size)
setModal(
<ContextMenu key={uniqueString.uniqueString()} event={event}>
<ContextMenuEntry onClick={doDeleteAll}>
<span className="text-red-700">
Delete {selectedKeys.size} {pluralized}
</span>
</ContextMenuEntry>
</ContextMenu>
)
}}
/>
)
}
}
export default SecretsTable

View File

@ -0,0 +1,246 @@
/** @file A table that projects an object into each column.
* This is intended to be specialized into components for specific item types, rather than
* being used directly. */
import * as React from 'react'
import * as shortcuts from '../shortcuts'
import * as tableColumn from './tableColumn'
import Spinner, * as spinner from './spinner'
import TableRow, * as tableRow from './tableRow'
// =================
// === Constants ===
// =================
/** The size of the loading spinner. */
const LOADING_SPINNER_SIZE = 36
// =============================
// === Partial `Props` types ===
// =============================
/** `state: State`. */
interface StateProp<State> {
state: State
}
/** `initialRowState: RowState`. */
interface InitialRowStateProp<RowState> {
initialRowState: RowState
}
// =============
// === Table ===
// =============
/** Props for a {@link Table}. */
interface InternalTableProps<T, Key extends string = string, State = never, RowState = never> {
rowComponent?: (props: tableRow.TableRowProps<T, Key, State, RowState>) => JSX.Element
items: T[]
state?: State
initialRowState?: RowState
getKey: (item: T) => Key
columns: tableColumn.TableColumn<T, State, RowState>[]
isLoading: boolean
placeholder: JSX.Element
forceShowPlaceholder?: boolean
onContextMenu: (
selectedKeys: Set<Key>,
event: React.MouseEvent<HTMLTableElement>,
setSelectedKeys: (items: Set<Key>) => void
) => void
}
/** Props for a {@link Table}. */
export type TableProps<
T,
Key extends string = string,
State = never,
RowState = never
> = InternalTableProps<T, Key, State, RowState> &
([RowState] extends [never] ? unknown : InitialRowStateProp<RowState>) &
([State] extends [never] ? unknown : StateProp<State>)
/** Table that projects an object into each column. */
function Table<T, Key extends string = string, State = never, RowState = never>(
props: TableProps<T, Key, State, RowState>
) {
const {
rowComponent: RowComponent = TableRow,
items,
getKey,
columns,
isLoading,
placeholder,
forceShowPlaceholder = false,
onContextMenu,
...rowProps
} = props
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.
const [selectedKeys, setSelectedKeys] = React.useState(() => new Set<Key>())
const [previouslySelectedKey, setPreviouslySelectedKey] = React.useState<Key | null>(null)
React.useEffect(() => {
const onDocumentClick = (event: MouseEvent) => {
if (
!shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
shortcuts.MouseAction.selectAdditional,
event
) &&
!shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
shortcuts.MouseAction.selectAdditionalRange,
event
) &&
selectedKeys.size !== 0
) {
setSelectedKeys(new Set())
}
}
document.addEventListener('click', onDocumentClick)
return () => {
document.removeEventListener('click', onDocumentClick)
}
}, [selectedKeys])
React.useEffect(() => {
if (isLoading) {
// Ensure the spinner stays in the "initial" state for at least one frame,
// to ensure the CSS animation begins at the initial state.
requestAnimationFrame(() => {
setSpinnerState(spinner.SpinnerState.loadingFast)
})
} else {
setSpinnerState(spinner.SpinnerState.initial)
}
}, [isLoading])
const onRowClick = React.useCallback(
(innerRowProps: tableRow.TableRowInnerProps<T, Key, RowState>, event: React.MouseEvent) => {
const { key } = innerRowProps
event.stopPropagation()
const getNewlySelectedKeys = () => {
if (previouslySelectedKey == null) {
return [key]
} else {
const index1 = items.findIndex(
innerItem => getKey(innerItem) === previouslySelectedKey
)
const index2 = items.findIndex(innerItem => getKey(innerItem) === key)
const selectedItems =
index1 <= index2
? items.slice(index1, index2 + 1)
: items.slice(index2, index1 + 1)
return selectedItems.map(getKey)
}
}
if (
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
shortcuts.MouseAction.selectRange,
event
)
) {
setSelectedKeys(new Set(getNewlySelectedKeys()))
} else if (
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
shortcuts.MouseAction.selectAdditionalRange,
event
)
) {
setSelectedKeys(
oldSelectedItems => new Set([...oldSelectedItems, ...getNewlySelectedKeys()])
)
} else if (
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
shortcuts.MouseAction.selectAdditional,
event
)
) {
setSelectedKeys(oldSelectedItems => {
const newItems = new Set(oldSelectedItems)
if (oldSelectedItems.has(key)) {
newItems.delete(key)
} else {
newItems.add(key)
}
return newItems
})
} else {
setSelectedKeys(new Set([key]))
}
setPreviouslySelectedKey(key)
},
[items, previouslySelectedKey, /* should never change */ getKey]
)
const headerRow = (
<tr>
{columns.map(column => (
<th
key={column.id}
className={`text-vs px-4 align-middle py-1 border-0 border-r whitespace-nowrap font-semibold text-left ${
column.className ?? ''
}`}
>
{column.heading}
</th>
))}
</tr>
)
const itemRows = isLoading ? (
<tr className="h-10">
<td colSpan={columns.length}>
<div className="grid justify-around w-full">
<Spinner size={LOADING_SPINNER_SIZE} state={spinnerState} />
</div>
</td>
</tr>
) : items.length === 0 || forceShowPlaceholder ? (
<tr className="h-10">
<td colSpan={columns.length}>{placeholder}</td>
</tr>
) : (
items.map(item => {
const key = getKey(item)
return (
<RowComponent
{...rowProps}
columns={columns}
// The following two lines are safe; the type error occurs because a property
// with a conditional type is being destructured.
// eslint-disable-next-line no-restricted-syntax
state={rowProps.state as never}
// eslint-disable-next-line no-restricted-syntax
initialRowState={rowProps.initialRowState as never}
key={key}
keyProp={key}
item={item}
selected={selectedKeys.has(key)}
allowContextMenu={
selectedKeys.size === 0 ||
(selectedKeys.size === 1 && selectedKeys.has(key))
}
onClick={onRowClick}
/>
)
})
)
return (
<table
className="table-fixed items-center border-collapse w-0 mt-2"
onContextMenu={event => {
onContextMenu(selectedKeys, event, setSelectedKeys)
}}
>
<thead>{headerRow}</thead>
<tbody>{itemRows}</tbody>
</table>
)
}
export default Table

View File

@ -0,0 +1,23 @@
/** @file Types for columns in a `Table`. */
// =============
// === Types ===
// =============
/** Props for a {@link Column}. */
export interface TableColumnProps<T, State = never, RowState = never> {
item: T
setItem: React.Dispatch<React.SetStateAction<T>>
selected: boolean
state: State
rowState: RowState
setRowState: React.Dispatch<React.SetStateAction<RowState>>
}
/** Metadata describing how to render a column of the table. */
export interface TableColumn<T, State = never, RowState = never> {
id: string
className?: string
heading: JSX.Element
render: (props: TableColumnProps<T, State, RowState>) => JSX.Element
}

View File

@ -0,0 +1,165 @@
/** @file A row in a `Table`. */
import * as React from 'react'
import * as modalProvider from '../../providers/modal'
import * as tableColumn from './tableColumn'
// =============================
// === Partial `Props` types ===
// =============================
/** `state: State`. */
interface StateProp<State> {
state: State
}
/** `tablerowState` and `setTableRowState` */
interface InternalTableRowStateProps<TableRowState> {
rowState: TableRowState
setRowState: React.Dispatch<React.SetStateAction<TableRowState>>
}
/** `initialRowState: RowState`. */
interface InitialRowStateProp<RowState> {
initialRowState: RowState
}
// ================
// === TableRow ===
// ================
/** Common properties for state and setters passed to event handlers on a {@link TableRow}. */
interface InternalTableRowInnerProps<T, Key extends string = string> {
key: Key
item: T
setItem: (newItem: T) => void
}
/** State and setters passed to event handlers on a {@link TableRow}. */
export type TableRowInnerProps<
T,
Key extends string = string,
TableRowState = never
> = InternalTableRowInnerProps<T, Key> &
([TableRowState] extends never ? unknown : InternalTableRowStateProps<TableRowState>)
/** Props for a {@link TableRow}. */
interface InternalBaseTableRowProps<
T,
Key extends string = string,
State = never,
TableRowState = never
> extends Omit<JSX.IntrinsicElements['tr'], 'onClick' | 'onContextMenu'> {
keyProp: Key
item: T
state?: State
initialRowState?: TableRowState
columns: tableColumn.TableColumn<T, State, TableRowState>[]
selected: boolean
allowContextMenu: boolean
onClick: (props: TableRowInnerProps<T, Key, TableRowState>, event: React.MouseEvent) => void
onContextMenu?: (
props: TableRowInnerProps<T, Key, TableRowState>,
event: React.MouseEvent<HTMLTableRowElement>
) => void
}
/** Props for a {@link TableRow}. */
export type TableRowProps<
T,
Key extends string = string,
State = never,
TableRowState = never
> = InternalBaseTableRowProps<T, Key, State, TableRowState> &
([State] extends [never] ? unknown : StateProp<State>) &
([TableRowState] extends [never] ? unknown : InitialRowStateProp<TableRowState>)
/** A row of a table. This is required because each row may store its own state. */
function TableRow<T, Key extends string = string, State = never, RowState = never>(
props: TableRowProps<T, Key, State, RowState>
) {
const {
keyProp: key,
item: rawItem,
state,
initialRowState,
columns,
selected,
allowContextMenu,
onClick,
onContextMenu,
className,
...passthrough
} = props
const { unsetModal } = modalProvider.useSetModal()
/** The internal state for this row. This may change as backend requests are sent. */
const [item, setItem] = React.useState(rawItem)
/** 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!)
React.useEffect(() => {
setItem(rawItem)
}, [rawItem])
const innerProps: TableRowInnerProps<T, Key, RowState> = {
key,
item,
setItem,
rowState,
setRowState,
}
return (
<tr
tabIndex={-1}
onClick={event => {
unsetModal()
onClick(innerProps, event)
}}
onContextMenu={event => {
if (allowContextMenu) {
onContextMenu?.(innerProps, event)
}
}}
className={`h-10 transition duration-300 ease-in-out hover:bg-gray-100 ${
className ?? ''
} ${selected ? 'bg-gray-200' : ''}`}
{...passthrough}
>
{columns.map(column => {
// This is a React component even though it does not contain JSX.
// eslint-disable-next-line no-restricted-syntax
const Render = column.render
return (
<td
key={column.id}
className={`px-4 border-0 border-r vertical-align-middle ${
column.className ?? ''
}`}
>
<Render
item={item}
setItem={setItem}
selected={selected}
/** This is SAFE, as the type is defined such that they MUST be
* present if it is specified as a generic parameter.
* See the type definitions of {@link TableRowProps} and {@link TableProps}.
*/
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
state={state!}
rowState={rowState}
setRowState={setRowState}
/>
</td>
)
})}
</tr>
)
}
export default TableRow

View File

@ -37,9 +37,9 @@ enum ShadowClass {
both = 'shadow-inset-v-lg',
}
// =================
// === Templates ===
// =================
// =============
// === Types ===
// =============
/** Template metadata. */
export interface Template {
@ -49,6 +49,10 @@ export interface Template {
background: string
}
// =================
// === Constants ===
// =================
/** The full list of templates. */
export const TEMPLATES: [Template, ...Template[]] = [
{
@ -73,19 +77,19 @@ export const TEMPLATES: [Template, ...Template[]] = [
title: 'Combine spreadsheets',
id: 'Orders',
description: 'Glue multiple spreadsheets together to analyse all your data at once.',
background: 'url("/spreadsheets.png") 50% 20% / 80% no-repeat, #479366',
background: 'url("./spreadsheets.png") 50% 20% / 80% no-repeat, #479366',
},
{
title: 'Geospatial analysis',
id: 'Restaurants',
description: 'Learn where to open a coffee shop to maximize your income.',
background: 'url("/geo.png") center / cover',
background: 'url("./geo.png") center / cover, #6b7280',
},
{
title: 'Analyze GitHub stars',
id: 'Stargazers',
description: "Find out which of Enso's repositories are most popular over time.",
background: 'url("/visualize.png") center / cover',
background: 'url("./visualize.png") center / cover, #6b7280',
},
]
@ -154,6 +158,17 @@ interface InternalTemplateButtonProps {
function TemplateButton(props: InternalTemplateButtonProps) {
const { template, onTemplateClick } = props
const [spinnerState, setSpinnerState] = React.useState<spinner.SpinnerState | null>(null)
const onSpinnerStateChange = React.useCallback(
(newSpinnerState: spinner.SpinnerState | null) => {
setSpinnerState(newSpinnerState)
if (newSpinnerState === spinner.SpinnerState.done) {
setTimeout(() => {
setSpinnerState(null)
}, SPINNER_DONE_DURATION_MS)
}
},
[]
)
return (
<button
@ -161,14 +176,7 @@ function TemplateButton(props: InternalTemplateButtonProps) {
className="h-40 cursor-pointer"
onClick={() => {
setSpinnerState(spinner.SpinnerState.initial)
onTemplateClick(template.id, newSpinnerState => {
setSpinnerState(newSpinnerState)
if (newSpinnerState === spinner.SpinnerState.done) {
setTimeout(() => {
setSpinnerState(null)
}, SPINNER_DONE_DURATION_MS)
}
})
onTemplateClick(template.id, onSpinnerStateChange)
}}
>
<div

View File

@ -0,0 +1,17 @@
/** @file A component that renders the modal instance from the modal React Context. */
import * as React from 'react'
import * as modalProvider from '../../providers/modal'
// ================
// === TheModal ===
// ================
/** Renders the modal instance from the modal React Context (if any). */
function TheModal() {
const { modal } = modalProvider.useModal()
return <>{modal}</>
}
export default TheModal

View File

@ -9,7 +9,7 @@ import MagnifyingGlassIcon from 'enso-assets/magnifying_glass.svg'
import SpeechBubbleIcon from 'enso-assets/speech_bubble.svg'
import * as backendModule from '../backend'
import * as dashboard from './dashboard'
import * as tabModule from '../tab'
import * as backendProvider from '../../providers/backend'
import * as modalProvider from '../../providers/modal'
@ -25,7 +25,7 @@ export interface TopBarProps {
/** Whether the application may have the local backend running. */
supportsLocalBackend: boolean
projectName: string | null
tab: dashboard.Tab
tab: tabModule.Tab
toggleTab: () => void
setBackendType: (backendType: backendModule.BackendType) => void
isHelpChatOpen: boolean
@ -48,24 +48,8 @@ function TopBar(props: TopBarProps) {
query,
setQuery,
} = props
const [isUserMenuVisible, setIsUserMenuVisible] = React.useState(false)
const { modal } = modalProvider.useModal()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { backend } = backendProvider.useBackend()
React.useEffect(() => {
if (!modal) {
setIsUserMenuVisible(false)
}
}, [modal])
React.useEffect(() => {
if (isUserMenuVisible) {
setModal(() => <UserMenu />)
} else {
unsetModal()
}
}, [isUserMenuVisible, setModal, unsetModal])
const { updateModal } = modalProvider.useSetModal()
return (
<div className="flex mx-2 h-8">
@ -105,7 +89,7 @@ function TopBar(props: TopBarProps) {
>
<span
className={`opacity-50 overflow-hidden transition-width nowrap ${
tab === dashboard.Tab.dashboard ? 'm-2 w-16' : 'w-0'
tab === tabModule.Tab.dashboard ? 'm-2 w-16' : 'w-0'
}`}
>
{projectName ?? 'Dashboard'}
@ -115,7 +99,7 @@ function TopBar(props: TopBarProps) {
</div>
<span
className={`opacity-50 overflow-hidden transition-width nowrap ${
tab === dashboard.Tab.ide ? 'm-2 w-16' : 'w-0'
tab === tabModule.Tab.ide ? 'm-2 w-16' : 'w-0'
}`}
>
{projectName ?? 'No project open'}
@ -155,7 +139,7 @@ function TopBar(props: TopBarProps) {
<div
onClick={event => {
event.stopPropagation()
setIsUserMenuVisible(!isUserMenuVisible)
updateModal(oldModal => (oldModal?.type === UserMenu ? null : <UserMenu />))
}}
className="rounded-full w-8 h-8 bg-cover cursor-pointer"
>

View File

@ -27,7 +27,7 @@ function UserMenuItem(props: React.PropsWithChildren<UserMenuItemProps>) {
<div
className={`whitespace-nowrap px-4 py-2 ${disabled ? 'opacity-50' : ''} ${
onClick ? 'hover:bg-blue-500 hover:text-white' : ''
} ${onClick && !disabled ? 'cursor-pointer' : ''}`}
} ${onClick != null && !disabled ? 'cursor-pointer' : ''}`}
onClick={onClick}
>
{children}
@ -76,7 +76,7 @@ function UserMenu() {
{canChangePassword && (
<UserMenuItem
onClick={() => {
setModal(() => <ChangePasswordModal />)
setModal(<ChangePasswordModal />)
}}
>
Change your password

View File

@ -14,6 +14,10 @@ const HALF_DAY_HOURS = 12
/** A string with date and time, following the RFC3339 specification. */
export type Rfc3339DateTime = newtype.Newtype<string, 'Rfc3339DateTime'>
/** Create a {@link Rfc3339DateTime}. */
// This is a constructor function that constructs values of the type it is named after.
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const Rfc3339DateTime = newtype.newtypeConstructor<Rfc3339DateTime>()
/** Formats date time into the preferred format: `YYYY-MM-DD, hh:mm`. */
export function formatDateTime(date: Date) {
@ -44,5 +48,5 @@ export function formatDateTimeChatFriendly(date: Date) {
/** Formats a {@link Date} as a {@link Rfc3339DateTime} */
export function toRfc3339(date: Date) {
return newtype.asNewtype<Rfc3339DateTime>(date.toISOString())
return Rfc3339DateTime(date.toISOString())
}

View File

@ -0,0 +1,17 @@
/** @file Utility functions related to event handling. */
import * as React from 'react'
// =============================
// === Mouse event utilities ===
// =============================
/** Returns `true` if and only if the event is a single click event. */
export function isSingleClick(event: React.MouseEvent) {
return event.detail === 1
}
/** Returns `true` if and only if the event is a double click event. */
export function isDoubleClick(event: React.MouseEvent) {
return event.detail === 2
}

View File

@ -0,0 +1,40 @@
/** @file Events related to changes in directory state. */
import * as backendModule from '../backend'
// This is required, to whitelist this event.
// eslint-disable-next-line no-restricted-syntax
declare module '../../hooks' {
/** A map containing all known event types. */
export interface KnownEventsMap {
directoryEvent: DirectoryEvent
}
}
// ======================
// === DirectoryEvent ===
// ======================
/** Possible types of directory state change. */
export enum DirectoryEventType {
create = 'create',
deleteMultiple = 'delete-multiple',
}
/** Properties common to all directory state change events. */
interface DirectoryBaseEvent<Type extends DirectoryEventType> {
type: Type
}
/** A signal to create a directory. */
export interface DirectoryCreateEvent extends DirectoryBaseEvent<DirectoryEventType.create> {
placeholderId: backendModule.DirectoryId
}
/** A signal to delete multiple directories. */
export interface DirectoryDeleteMultipleEvent
extends DirectoryBaseEvent<DirectoryEventType.deleteMultiple> {
directoryIds: Set<backendModule.DirectoryId>
}
/** Every possible type of directory event. */
export type DirectoryEvent = DirectoryCreateEvent | DirectoryDeleteMultipleEvent

View File

@ -0,0 +1,36 @@
/** @file Events related to changes in the directory list. */
// This is required, to whitelist this event.
// eslint-disable-next-line no-restricted-syntax
declare module '../../hooks' {
/** A map containing all known event types. */
export interface KnownEventsMap {
directoryListEvent: DirectoryListEvent
}
}
// ==========================
// === DirectoryListEvent ===
// ==========================
/** Possible changes to the directory list. */
export enum DirectoryListEventType {
create = 'create',
delete = 'delete',
}
/** Properties common to all directory list change events. */
interface DirectoryListBaseEvent<Type extends DirectoryListEventType> {
type: Type
}
/** A signal to create a new directory. */
interface DirectoryListCreateEvent extends DirectoryListBaseEvent<DirectoryListEventType.create> {}
/** A signal to delete a directory. */
interface DirectoryListDeleteEvent extends DirectoryListBaseEvent<DirectoryListEventType.delete> {
directoryId: string
}
/** Every possible type of directory list event. */
export type DirectoryListEvent = DirectoryListCreateEvent | DirectoryListDeleteEvent

View File

@ -0,0 +1,39 @@
/** @file Events related to changes in file state. */
import * as backendModule from '../backend'
// This is required, to whitelist this event.
// eslint-disable-next-line no-restricted-syntax
declare module '../../hooks' {
/** A map containing all known event types. */
export interface KnownEventsMap {
fileEvent: FileEvent
}
}
// =================
// === FileEvent ===
// =================
/** Possible types of file state change. */
export enum FileEventType {
createMultiple = 'create-multiple',
deleteMultiple = 'delete-multiple',
}
/** Properties common to all file state change events. */
interface FileBaseEvent<Type extends FileEventType> {
type: Type
}
/** A signal to create multiple files. */
export interface FileCreateMultipleEvent extends FileBaseEvent<FileEventType.createMultiple> {
files: Map<backendModule.FileId, File>
}
/** A signal to delete multiple files. */
export interface FileDeleteMultipleEvent extends FileBaseEvent<FileEventType.deleteMultiple> {
fileIds: Set<backendModule.FileId>
}
/** Every possible type of file event. */
export type FileEvent = FileCreateMultipleEvent | FileDeleteMultipleEvent

View File

@ -0,0 +1,38 @@
/** @file Events related to changes in the file list. */
// This is required, to whitelist this event.
// eslint-disable-next-line no-restricted-syntax
declare module '../../hooks' {
/** A map containing all known event types. */
export interface KnownEventsMap {
fileListEvent: FileListEvent
}
}
// =====================
// === FileListEvent ===
// =====================
/** Possible changes to the file list. */
export enum FileListEventType {
uploadMultiple = 'upload-multiple',
delete = 'delete',
}
/** Properties common to all file list change events. */
interface FileListBaseEvent<Type extends FileListEventType> {
type: Type
}
/** A signal to upload multiple files. */
interface FileListUploadMultipleEvent extends FileListBaseEvent<FileListEventType.uploadMultiple> {
files: FileList
}
/** A signal to delete a file. */
interface FileListDeleteEvent extends FileListBaseEvent<FileListEventType.delete> {
fileId: string
}
/** Every possible type of file list event. */
export type FileListEvent = FileListDeleteEvent | FileListUploadMultipleEvent

View File

@ -0,0 +1,67 @@
/** @file Events related to changes in project state. */
import * as backendModule from '../backend'
import * as spinner from '../components/spinner'
// This is required, to whitelist this event.
// eslint-disable-next-line no-restricted-syntax
declare module '../../hooks' {
/** A map containing all known event types. */
export interface KnownEventsMap {
projectEvent: ProjectEvent
}
}
// ====================
// === ProjectEvent ===
// ====================
/** Possible types of project state change. */
export enum ProjectEventType {
create = 'create',
open = 'open',
showAsOpening = 'show-as-opening',
cancelOpeningAll = 'cancel-opening-all',
deleteMultiple = 'delete-multiple',
}
/** Properties common to all project state change events. */
interface ProjectBaseEvent<Type extends ProjectEventType> {
type: Type
}
/** A signal to create a project. */
export interface ProjectCreateEvent extends ProjectBaseEvent<ProjectEventType.create> {
placeholderId: backendModule.ProjectId
templateId: string | null
onSpinnerStateChange: ((state: spinner.SpinnerState) => void) | null
}
/** A signal to open the specified project. */
export interface ProjectOpenEvent extends ProjectBaseEvent<ProjectEventType.open> {
projectId: backendModule.ProjectId
}
/** A signal to display the specified project as opening, but not actually send the call to the
* backend. */
export interface ProjectShowAsOpeningEvent
extends ProjectBaseEvent<ProjectEventType.showAsOpening> {
projectId: backendModule.ProjectId
}
/** A signal to stop automatically opening any project that is currently opening. */
export interface ProjectCancelOpeningAllEvent
extends ProjectBaseEvent<ProjectEventType.cancelOpeningAll> {}
/** A signal to delete multiple projects. */
export interface ProjectDeleteMultipleEvent
extends ProjectBaseEvent<ProjectEventType.deleteMultiple> {
projectIds: Set<backendModule.ProjectId>
}
/** Every possible type of project event. */
export type ProjectEvent =
| ProjectCancelOpeningAllEvent
| ProjectCreateEvent
| ProjectDeleteMultipleEvent
| ProjectOpenEvent
| ProjectShowAsOpeningEvent

View File

@ -0,0 +1,40 @@
/** @file Events related to changes in the project list. */
import * as spinner from '../components/spinner'
// This is required, to whitelist this event.
// eslint-disable-next-line no-restricted-syntax
declare module '../../hooks' {
/** A map containing all known event types. */
export interface KnownEventsMap {
projectListEvent: ProjectListEvent
}
}
// ========================
// === ProjectListEvent ===
// ========================
/** Possible changes to the project list. */
export enum ProjectListEventType {
create = 'create',
delete = 'delete',
}
/** Properties common to all project list events. */
interface ProjectListBaseEvent<Type extends ProjectListEventType> {
type: Type
}
/** A signal to create a new project. */
interface ProjectListCreateEvent extends ProjectListBaseEvent<ProjectListEventType.create> {
templateId: string | null
onSpinnerStateChange: ((state: spinner.SpinnerState) => void) | null
}
/** A signal to delete a project. */
interface ProjectListDeleteEvent extends ProjectListBaseEvent<ProjectListEventType.delete> {
projectId: string
}
/** Every possible type of project list event. */
export type ProjectListEvent = ProjectListCreateEvent | ProjectListDeleteEvent

View File

@ -0,0 +1,40 @@
/** @file Events related to changes in secret state. */
import * as backendModule from '../backend'
// This is required, to whitelist this event.
// eslint-disable-next-line no-restricted-syntax
declare module '../../hooks' {
/** A map containing all known event types. */
export interface KnownEventsMap {
secretEvent: SecretEvent
}
}
// ===================
// === SecretEvent ===
// ===================
/** Possible types of secret state change. */
export enum SecretEventType {
create = 'create',
deleteMultiple = 'delete-multiple',
}
/** Properties common to all secret state change events. */
interface SecretBaseEvent<Type extends SecretEventType> {
type: Type
}
/** A signal to create a secret. */
export interface SecretCreateEvent extends SecretBaseEvent<SecretEventType.create> {
placeholderId: backendModule.SecretId
value: string
}
/** A signal to delete multiple secrets. */
export interface SecretDeleteMultipleEvent extends SecretBaseEvent<SecretEventType.deleteMultiple> {
secretIds: Set<backendModule.SecretId>
}
/** Every possible type of secret event. */
export type SecretEvent = SecretCreateEvent | SecretDeleteMultipleEvent

View File

@ -0,0 +1,39 @@
/** @file Events related to changes in the secret list. */
// This is required, to whitelist this event.
// eslint-disable-next-line no-restricted-syntax
declare module '../../hooks' {
/** A map containing all known event types. */
export interface KnownEventsMap {
secretListEvent: SecretListEvent
}
}
// =======================
// === SecretListEvent ===
// =======================
/** Possible changes to the secret list. */
export enum SecretListEventType {
create = 'create',
delete = 'delete',
}
/** Properties common to all secret list change events. */
interface SecretListBaseEvent<Type extends SecretListEventType> {
type: Type
}
/** A signal to create a new secret. */
interface SecretListCreateEvent extends SecretListBaseEvent<SecretListEventType.create> {
name: string
value: string
}
/** A signal to delete a secret. */
interface SecretListDeleteEvent extends SecretListBaseEvent<SecretListEventType.delete> {
secretId: string
}
/** Every possible type of secret list event. */
export type SecretListEvent = SecretListCreateEvent | SecretListDeleteEvent

View File

@ -0,0 +1,12 @@
/** @file Flags for features that are implemented, but disabled either because
* they are not production-ready, or because it is unknown whether they are still needed. */
export const FEATURE_FLAGS = {
/** A selector that lets the user choose between pre-defined sets of visible columns. */
columnDisplayModeSwitcher: false,
}
if (IS_DEV_MODE) {
// @ts-expect-error This is exposed for development purposes only.
window.featureFlags = FEATURE_FLAGS
}

View File

@ -4,7 +4,7 @@
* The functions are asynchronous and return a {@link Promise} that resolves to the response from
* the API. */
import * as backend from './backend'
import * as newtype from '../newtype'
import * as errorModule from '../error'
import * as projectManager from './projectManager'
// ========================
@ -13,24 +13,18 @@ import * as projectManager from './projectManager'
/** Convert a {@link projectManager.IpWithSocket} to a {@link backend.Address}. */
function ipWithSocketToAddress(ipWithSocket: projectManager.IpWithSocket) {
return newtype.asNewtype<backend.Address>(`ws://${ipWithSocket.host}:${ipWithSocket.port}`)
return backend.Address(`ws://${ipWithSocket.host}:${ipWithSocket.port}`)
}
// ====================
// === LocalBackend ===
// ====================
/** The currently open project and its ID. */
interface CurrentlyOpenProjectInfo {
id: projectManager.ProjectId
project: projectManager.OpenProject
}
/** Class for sending requests to the Project Manager API endpoints.
* This is used instead of the cloud backend API when managing local projects from the dashboard. */
export class LocalBackend implements Partial<backend.Backend> {
static currentlyOpeningProjectId: backend.ProjectId | null = null
static currentlyOpenProject: CurrentlyOpenProjectInfo | null = null
static currentlyOpenProjects = new Map<projectManager.ProjectId, projectManager.OpenProject>()
readonly type = backend.BackendType.local
private readonly projectManager = projectManager.ProjectManager.default()
@ -53,17 +47,16 @@ export class LocalBackend implements Partial<backend.Backend> {
id: project.id,
title: project.name,
modifiedAt: project.lastOpened ?? project.created,
parentId: newtype.asNewtype<backend.AssetId>(''),
parentId: backend.DirectoryId(''),
permissions: [],
projectState: {
type:
project.id === LocalBackend.currentlyOpenProject?.id
? backend.ProjectState.opened
: project.id === LocalBackend.currentlyOpeningProjectId
? backend.ProjectState.openInProgress
: project.lastOpened != null
? backend.ProjectState.closed
: backend.ProjectState.created,
type: LocalBackend.currentlyOpenProjects.has(project.id)
? backend.ProjectState.opened
: project.id === LocalBackend.currentlyOpeningProjectId
? backend.ProjectState.openInProgress
: project.lastOpened != null
? backend.ProjectState.closed
: backend.ProjectState.created,
},
}))
}
@ -91,7 +84,7 @@ export class LocalBackend implements Partial<backend.Backend> {
* @throws An error if the JSON-RPC call fails. */
async createProject(body: backend.CreateProjectRequestBody): Promise<backend.CreatedProject> {
const project = await this.projectManager.createProject({
name: newtype.asNewtype<projectManager.ProjectName>(body.projectName),
name: projectManager.ProjectName(body.projectName),
...(body.projectTemplateName != null
? { projectTemplate: body.projectTemplateName }
: {}),
@ -111,37 +104,45 @@ export class LocalBackend implements Partial<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): Promise<void> {
async closeProject(projectId: backend.ProjectId, title: string | null): Promise<void> {
if (LocalBackend.currentlyOpeningProjectId === projectId) {
LocalBackend.currentlyOpeningProjectId = null
}
if (LocalBackend.currentlyOpenProject?.id === projectId) {
LocalBackend.currentlyOpenProject = null
LocalBackend.currentlyOpenProjects.delete(projectId)
try {
await this.projectManager.closeProject({ projectId })
return
} catch (error) {
throw new Error(
`Unable to close project ${
title != null ? `'${title}'` : `with ID '${projectId}'`
}: ${errorModule.tryGetMessage(error) ?? 'unknown error'}.`
)
}
await this.projectManager.closeProject({ projectId })
}
/** 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> {
if (projectId !== LocalBackend.currentlyOpenProject?.id) {
const cachedProject = LocalBackend.currentlyOpenProjects.get(projectId)
if (cachedProject == null) {
const result = await this.projectManager.listProjects({})
const project = result.projects.find(listedProject => listedProject.id === projectId)
const engineVersion = project?.engineVersion
if (project == null) {
throw new Error(`The project ID '${projectId}' is invalid.`)
} else if (engineVersion == null) {
throw new Error(`The project '${projectId}' does not have an engine version.`)
throw new Error(`The project '${project.name}' does not have an engine version.`)
} else {
return Promise.resolve<backend.Project>({
return {
name: project.name,
engineVersion: {
lifecycle: backend.VersionLifecycle.stable,
lifecycle: backend.detectVersionLifecycle(engineVersion),
value: engineVersion,
},
ideVersion: {
lifecycle: backend.VersionLifecycle.stable,
lifecycle: backend.detectVersionLifecycle(engineVersion),
value: engineVersion,
},
jsonAddress: null,
@ -157,43 +158,55 @@ export class LocalBackend implements Partial<backend.Backend> {
? backend.ProjectState.closed
: backend.ProjectState.created,
},
})
}
}
} else {
const project = LocalBackend.currentlyOpenProject.project
return Promise.resolve<backend.Project>({
name: project.projectName,
return {
name: cachedProject.projectName,
engineVersion: {
lifecycle: backend.VersionLifecycle.stable,
value: project.engineVersion,
lifecycle: backend.detectVersionLifecycle(cachedProject.engineVersion),
value: cachedProject.engineVersion,
},
ideVersion: {
lifecycle: backend.VersionLifecycle.stable,
value: project.engineVersion,
lifecycle: backend.detectVersionLifecycle(cachedProject.engineVersion),
value: cachedProject.engineVersion,
},
jsonAddress: ipWithSocketToAddress(project.languageServerJsonAddress),
binaryAddress: ipWithSocketToAddress(project.languageServerBinaryAddress),
jsonAddress: ipWithSocketToAddress(cachedProject.languageServerJsonAddress),
binaryAddress: ipWithSocketToAddress(cachedProject.languageServerBinaryAddress),
organizationId: '',
packageName: project.projectName,
packageName: cachedProject.projectName,
projectId,
state: {
type: backend.ProjectState.opened,
},
})
}
}
}
/** Prepare a project for execution.
*
* @throws An error if the JSON-RPC call fails. */
async openProject(projectId: backend.ProjectId): Promise<void> {
async openProject(
projectId: backend.ProjectId,
_body: backend.OpenProjectRequestBody | null,
title: string | null
): Promise<void> {
LocalBackend.currentlyOpeningProjectId = projectId
const project = await this.projectManager.openProject({
projectId,
missingComponentAction: projectManager.MissingComponentAction.install,
})
if (LocalBackend.currentlyOpeningProjectId === projectId) {
LocalBackend.currentlyOpenProject = { id: projectId, project }
if (!LocalBackend.currentlyOpenProjects.has(projectId)) {
try {
const project = await this.projectManager.openProject({
projectId,
missingComponentAction: projectManager.MissingComponentAction.install,
})
LocalBackend.currentlyOpenProjects.set(projectId, project)
return
} catch (error) {
throw new Error(
`Unable to open project ${
title != null ? `'${title}'` : `with ID '${projectId}'`
}: ${errorModule.tryGetMessage(error) ?? 'unknown error'}.`
)
}
}
}
@ -210,7 +223,7 @@ export class LocalBackend implements Partial<backend.Backend> {
if (body.projectName != null) {
await this.projectManager.renameProject({
projectId,
name: newtype.asNewtype<projectManager.ProjectName>(body.projectName),
name: projectManager.ProjectName(body.projectName),
})
}
const result = await this.projectManager.listProjects({})
@ -219,7 +232,7 @@ export class LocalBackend implements Partial<backend.Backend> {
if (project == null) {
throw new Error(`The project ID '${projectId}' is invalid.`)
} else if (engineVersion == null) {
throw new Error(`The project '${projectId}' does not have an engine version.`)
throw new Error(`The project '${project.name}' does not have an engine version.`)
} else {
return {
ami: null,
@ -242,13 +255,20 @@ export class LocalBackend implements Partial<backend.Backend> {
/** Delete a project.
*
* @throws An error if the JSON-RPC call fails. */
async deleteProject(projectId: backend.ProjectId): Promise<void> {
async deleteProject(projectId: backend.ProjectId, title: string | null): Promise<void> {
if (LocalBackend.currentlyOpeningProjectId === projectId) {
LocalBackend.currentlyOpeningProjectId = null
}
if (LocalBackend.currentlyOpenProject?.id === projectId) {
LocalBackend.currentlyOpenProject = null
LocalBackend.currentlyOpenProjects.delete(projectId)
try {
await this.projectManager.deleteProject({ projectId })
return
} catch (error) {
throw new Error(
`Unable to delete project ${
title != null ? `'${title}'` : `with ID '${projectId}'`
}: ${errorModule.tryGetMessage(error) ?? 'unknown error'}.`
)
}
await this.projectManager.deleteProject({ projectId })
}
}

View File

@ -0,0 +1,23 @@
/** @file Utilities for working with permissions. */
import * as backend from './backend'
/** Returns an array containing the owner permission if `owner` is not `null`;
* else returns an empty array (`[]`). */
export function tryGetSingletonOwnerPermission(owner: backend.UserOrOrganization | null) {
return owner != null
? [
{
user: {
// The names are defined by the backend and cannot be changed.
/* eslint-disable @typescript-eslint/naming-convention */
pk: backend.Subject(''),
organization_id: owner.id,
user_email: owner.email,
user_name: owner.name,
/* eslint-enable @typescript-eslint/naming-convention */
},
permission: backend.PermissionAction.own,
},
]
: []
}

View File

@ -0,0 +1,29 @@
/** @file Types and functions for representing optimistic state.
* Optimistic UI is when UI updates are applied immediately with the expected result, instead of
* waiting for a server response, based on the assumption that virtually all requests succeed.
* In our case it is MANDATORY because immediate user feedback is important. */
// ================
// === Presence ===
// ================
/** The state of an item being synced to a remote server. */
export enum Presence {
/** The item is present. */
present = 'present',
/** The item will be inserted, but the backend request has not yet finished. */
inserting = 'inserting',
/** The item will be deleted, but the backend request has not yet finished. */
deleting = 'deleting',
}
// =================
// === Constants ===
// =================
/** The corresponding CSS classes for table rows, for each {@link Presence}. */
export const CLASS_NAME: Record<Presence, string> = {
[Presence.present]: '',
[Presence.deleting]: 'hidden',
[Presence.inserting]: 'opacity-50 pointer-events-none-recursive',
} as const

View File

@ -53,14 +53,25 @@ interface JSONRPCErrorResponse extends JSONRPCBaseResponse {
/** The return value of a JSON-RPC call. */
type JSONRPCResponse<T> = JSONRPCErrorResponse | JSONRPCSuccessResponse<T>
// These are constructor functions that construct values of the type they are named after.
/* eslint-disable @typescript-eslint/no-redeclare */
// This intentionally has the same brand as in the cloud backend API.
/** An ID of a project. */
export type ProjectId = newtype.Newtype<string, 'ProjectId'>
/** Create a {@link ProjectId}. */
export const ProjectId = newtype.newtypeConstructor<ProjectId>()
/** A name of a project. */
export type ProjectName = newtype.Newtype<string, 'ProjectName'>
/** Create a {@link ProjectName}. */
export const ProjectName = newtype.newtypeConstructor<ProjectName>()
/** The newtype's `TypeName` is intentionally different from the name of this type alias,
* to match the backend's newtype. */
export type UTCDateTime = dateTime.Rfc3339DateTime
/** Create a {@link UTCDateTime}. */
export const UTCDateTime = newtype.newtypeConstructor<UTCDateTime>()
/* eslint-enable @typescript-eslint/no-redeclare */
/** Details for a project. */
export interface ProjectMetadata {

View File

@ -7,7 +7,6 @@ import * as backend from './backend'
import * as config from '../config'
import * as http from '../http'
import * as loggerProvider from '../providers/logger'
import * as newtype from '../newtype'
// =================
// === Constants ===
@ -70,6 +69,14 @@ const CREATE_TAG_PATH = 'tags'
const LIST_TAGS_PATH = 'tags'
/** Relative HTTP path to the "list versions" endpoint of the Cloud backend API. */
const LIST_VERSIONS_PATH = 'versions'
/** Relative HTTP path to the "update directory" endpoint of the Cloud backend API. */
function updateDirectoryPath(directoryId: backend.DirectoryId) {
return `directories/${directoryId}`
}
/** Relative HTTP path to the "delete directory" endpoint of the Cloud backend API. */
function deleteDirectoryPath(directoryId: backend.DirectoryId) {
return `directories/${directoryId}`
}
/** Relative HTTP path to the "close project" endpoint of the Cloud backend API. */
function closeProjectPath(projectId: backend.ProjectId) {
return `projects/${projectId}/close`
@ -228,7 +235,7 @@ export class RemoteBackend implements backend.Backend {
/** Return organization info for the current user.
*
* @returns `null` if any status code other than 200 OK was received. */
* @returns `null` if a non-successful status code (not 200-299) was received. */
async usersMe(): Promise<backend.UserOrOrganization | null> {
const response = await this.get<backend.UserOrOrganization>(USERS_ME_PATH)
if (!responseIsSuccessful(response)) {
@ -240,8 +247,11 @@ export class RemoteBackend implements backend.Backend {
/** Return a list of assets in a directory.
*
* @throws An error if any status code other than 200 OK was received. */
async listDirectory(query: backend.ListDirectoryRequestParams): Promise<backend.Asset[]> {
* @throws An error if a non-successful status code (not 200-299) was received. */
async listDirectory(
query: backend.ListDirectoryRequestParams,
title: string | null
): Promise<backend.Asset[]> {
const response = await this.get<ListDirectoryResponseBody>(
LIST_DIRECTORY_PATH +
'?' +
@ -255,7 +265,11 @@ export class RemoteBackend implements backend.Backend {
// The directory is probably empty.
return []
} else if (query.parentId != null) {
return this.throw(`Unable to list directory with ID '${query.parentId}'.`)
return this.throw(
`Unable to list directory ${
title != null ? `'${title}'` : `with ID '${query.parentId}'`
}.`
)
} else {
return this.throw('Unable to list root directory.')
}
@ -270,9 +284,11 @@ export class RemoteBackend implements backend.Backend {
/** Create a directory.
*
* @throws An error if any status code other than 200 OK was received. */
async createDirectory(body: backend.CreateDirectoryRequestBody): Promise<backend.Directory> {
const response = await this.post<backend.Directory>(CREATE_DIRECTORY_PATH, body)
* @throws An error if a non-successful status code (not 200-299) was received. */
async createDirectory(
body: backend.CreateDirectoryRequestBody
): Promise<backend.CreatedDirectory> {
const response = await this.post<backend.CreatedDirectory>(CREATE_DIRECTORY_PATH, body)
if (!responseIsSuccessful(response)) {
return this.throw(`Unable to create directory with name '${body.title}'.`)
} else {
@ -280,9 +296,48 @@ export class RemoteBackend implements backend.Backend {
}
}
/** Change the name of a directory.
*
* @throws An error if a non-successful status code (not 200-299) was received. */
async updateDirectory(
directoryId: backend.DirectoryId,
body: backend.UpdateDirectoryRequestBody,
title: string | null
) {
const response = await this.put<backend.UpdatedDirectory>(
updateDirectoryPath(directoryId),
body
)
if (!responseIsSuccessful(response)) {
return this.throw(
`Unable to update directory ${
title != null ? `'${title}'` : `with ID '${directoryId}'`
}.`
)
} else {
return await response.json()
}
}
/** Change the name of a directory.
*
* @throws An error if a non-successful status code (not 200-299) was received. */
async deleteDirectory(directoryId: backend.DirectoryId, title: string | null) {
const response = await this.delete(deleteDirectoryPath(directoryId))
if (!responseIsSuccessful(response)) {
return this.throw(
`Unable to delete directory ${
title != null ? `'${title}'` : `with ID '${directoryId}'`
}.`
)
} else {
return
}
}
/** Return a list of projects belonging to the current user.
*
* @throws An error if any status code other than 200 OK was received. */
* @throws An error if a non-successful status code (not 200-299) was received. */
async listProjects(): Promise<backend.ListedProject[]> {
const response = await this.get<ListProjectsResponseBody>(LIST_PROJECTS_PATH)
if (!responseIsSuccessful(response)) {
@ -291,20 +346,16 @@ export class RemoteBackend implements backend.Backend {
return (await response.json()).projects.map(project => ({
...project,
jsonAddress:
project.address != null
? newtype.asNewtype<backend.Address>(`${project.address}json`)
: null,
project.address != null ? backend.Address(`${project.address}json`) : null,
binaryAddress:
project.address != null
? newtype.asNewtype<backend.Address>(`${project.address}binary`)
: null,
project.address != null ? backend.Address(`${project.address}binary`) : null,
}))
}
}
/** Create a project.
*
* @throws An error if any status code other than 200 OK was received. */
* @throws An error if a non-successful status code (not 200-299) was received. */
async createProject(body: backend.CreateProjectRequestBody): Promise<backend.CreatedProject> {
const response = await this.post<backend.CreatedProject>(CREATE_PROJECT_PATH, body)
if (!responseIsSuccessful(response)) {
@ -316,11 +367,15 @@ export class RemoteBackend implements backend.Backend {
/** Close a project.
*
* @throws An error if any status code other than 200 OK was received. */
async closeProject(projectId: backend.ProjectId): Promise<void> {
* @throws An error if a non-successful status code (not 200-299) was received. */
async closeProject(projectId: backend.ProjectId, title: string | null): Promise<void> {
const response = await this.post(closeProjectPath(projectId), {})
if (!responseIsSuccessful(response)) {
return this.throw(`Unable to close project with ID '${projectId}'.`)
return this.throw(
`Unable to close project ${
title != null ? `'${title}'` : `with ID '${projectId}'`
}.`
)
} else {
return
}
@ -328,37 +383,46 @@ export class RemoteBackend implements backend.Backend {
/** Return details for a project.
*
* @throws An error if any status code other than 200 OK was received. */
async getProjectDetails(projectId: backend.ProjectId): Promise<backend.Project> {
* @throws An error if a non-successful status code (not 200-299) was received. */
async getProjectDetails(
projectId: backend.ProjectId,
title: string | null
): Promise<backend.Project> {
const response = await this.get<backend.ProjectRaw>(getProjectDetailsPath(projectId))
if (!responseIsSuccessful(response)) {
return this.throw(`Unable to get details of project with ID '${projectId}'.`)
return this.throw(
`Unable to get details of project ${
title != null ? `'${title}'` : `with ID '${projectId}'`
}.`
)
} else {
const project = await response.json()
return {
...project,
jsonAddress:
project.address != null
? newtype.asNewtype<backend.Address>(`${project.address}json`)
: null,
project.address != null ? backend.Address(`${project.address}json`) : null,
binaryAddress:
project.address != null
? newtype.asNewtype<backend.Address>(`${project.address}binary`)
: null,
project.address != null ? backend.Address(`${project.address}binary`) : null,
}
}
}
/** Prepare a project for execution.
*
* @throws An error if any status code other than 200 OK was received. */
* @throws An error if a non-successful status code (not 200-299) was received. */
async openProject(
projectId: backend.ProjectId,
body: backend.OpenProjectRequestBody = DEFAULT_OPEN_PROJECT_BODY
body: backend.OpenProjectRequestBody | null,
title: string | null
): Promise<void> {
const response = await this.post(openProjectPath(projectId), body)
const response = await this.post(
openProjectPath(projectId),
body ?? DEFAULT_OPEN_PROJECT_BODY
)
if (!responseIsSuccessful(response)) {
return this.throw(`Unable to open project with ID '${projectId}'.`)
return this.throw(
`Unable to open project ${title != null ? `'${title}'` : `with ID '${projectId}'`}.`
)
} else {
return
}
@ -366,14 +430,19 @@ export class RemoteBackend implements backend.Backend {
/** Update the name or AMI of a project.
*
* @throws An error if any status code other than 200 OK was received. */
* @throws An error if a non-successful status code (not 200-299) was received. */
async projectUpdate(
projectId: backend.ProjectId,
body: backend.ProjectUpdateRequestBody
body: backend.ProjectUpdateRequestBody,
title: string | null
): Promise<backend.UpdatedProject> {
const response = await this.put<backend.UpdatedProject>(projectUpdatePath(projectId), body)
if (!responseIsSuccessful(response)) {
return this.throw(`Unable to update project with ID '${projectId}'.`)
return this.throw(
`Unable to update project ${
title != null ? `'${title}'` : `with ID '${projectId}'`
}.`
)
} else {
return await response.json()
}
@ -381,11 +450,15 @@ export class RemoteBackend implements backend.Backend {
/** Delete a project.
*
* @throws An error if any status code other than 200 OK was received. */
async deleteProject(projectId: backend.ProjectId): Promise<void> {
* @throws An error if a non-successful status code (not 200-299) was received. */
async deleteProject(projectId: backend.ProjectId, title: string | null): Promise<void> {
const response = await this.delete(deleteProjectPath(projectId))
if (!responseIsSuccessful(response)) {
return this.throw(`Unable to delete project with ID '${projectId}'.`)
return this.throw(
`Unable to delete project ${
title != null ? `'${title}'` : `with ID '${projectId}'`
}.`
)
} else {
return
}
@ -393,11 +466,18 @@ export class RemoteBackend implements backend.Backend {
/** Return the resource usage of a project.
*
* @throws An error if any status code other than 200 OK was received. */
async checkResources(projectId: backend.ProjectId): Promise<backend.ResourceUsage> {
* @throws An error if a non-successful status code (not 200-299) was received. */
async checkResources(
projectId: backend.ProjectId,
title: string | null
): Promise<backend.ResourceUsage> {
const response = await this.get<backend.ResourceUsage>(checkResourcesPath(projectId))
if (!responseIsSuccessful(response)) {
return this.throw(`Unable to get resource usage for project with ID '${projectId}'.`)
return this.throw(
`Unable to get resource usage for project ${
title != null ? `'${title}'` : `with ID '${projectId}'`
}.`
)
} else {
return await response.json()
}
@ -405,7 +485,7 @@ export class RemoteBackend implements backend.Backend {
/** Return a list of files accessible by the current user.
*
* @throws An error if any status code other than 200 OK was received. */
* @throws An error if a non-successful status code (not 200-299) was received. */
async listFiles(): Promise<backend.File[]> {
const response = await this.get<ListFilesResponseBody>(LIST_FILES_PATH)
if (!responseIsSuccessful(response)) {
@ -417,7 +497,7 @@ export class RemoteBackend implements backend.Backend {
/** Upload a file.
*
* @throws An error if any status code other than 200 OK was received. */
* @throws An error if a non-successful status code (not 200-299) was received. */
async uploadFile(
params: backend.UploadFileRequestParams,
body: Blob
@ -451,11 +531,13 @@ export class RemoteBackend implements backend.Backend {
/** Delete a file.
*
* @throws An error if any status code other than 200 OK was received. */
async deleteFile(fileId: backend.FileId): Promise<void> {
* @throws An error if a non-successful status code (not 200-299) was received. */
async deleteFile(fileId: backend.FileId, title: string | null): Promise<void> {
const response = await this.delete(deleteFilePath(fileId))
if (!responseIsSuccessful(response)) {
return this.throw(`Unable to delete file with ID '${fileId}'.`)
return this.throw(
`Unable to delete file ${title != null ? `'${title}'` : `with ID '${fileId}'`}.`
)
} else {
return
}
@ -463,7 +545,7 @@ export class RemoteBackend implements backend.Backend {
/** Create a secret environment variable.
*
* @throws An error if any status code other than 200 OK was received. */
* @throws An error if a non-successful status code (not 200-299) was received. */
async createSecret(body: backend.CreateSecretRequestBody): Promise<backend.SecretAndInfo> {
const response = await this.post<backend.SecretAndInfo>(CREATE_SECRET_PATH, body)
if (!responseIsSuccessful(response)) {
@ -475,11 +557,13 @@ export class RemoteBackend implements backend.Backend {
/** Return a secret environment variable.
*
* @throws An error if any status code other than 200 OK was received. */
async getSecret(secretId: backend.SecretId): Promise<backend.Secret> {
* @throws An error if a non-successful status code (not 200-299) was received. */
async getSecret(secretId: backend.SecretId, title: string | null): Promise<backend.Secret> {
const response = await this.get<backend.Secret>(getSecretPath(secretId))
if (!responseIsSuccessful(response)) {
return this.throw(`Unable to get secret with ID '${secretId}'.`)
return this.throw(
`Unable to get secret ${title != null ? `'${title}'` : `with ID '${secretId}'`}.`
)
} else {
return await response.json()
}
@ -487,7 +571,7 @@ export class RemoteBackend implements backend.Backend {
/** Return the secret environment variables accessible by the user.
*
* @throws An error if any status code other than 200 OK was received. */
* @throws An error if a non-successful status code (not 200-299) was received. */
async listSecrets(): Promise<backend.SecretInfo[]> {
const response = await this.get<ListSecretsResponseBody>(LIST_SECRETS_PATH)
if (!responseIsSuccessful(response)) {
@ -499,11 +583,13 @@ export class RemoteBackend implements backend.Backend {
/** Delete a secret environment variable.
*
* @throws An error if any status code other than 200 OK was received. */
async deleteSecret(secretId: backend.SecretId): Promise<void> {
* @throws An error if a non-successful status code (not 200-299) was received. */
async deleteSecret(secretId: backend.SecretId, title: string | null): Promise<void> {
const response = await this.delete(deleteSecretPath(secretId))
if (!responseIsSuccessful(response)) {
return this.throw(`Unable to delete secret with ID '${secretId}'.`)
return this.throw(
`Unable to delete secret ${title != null ? `'${title}'` : `with ID '${secretId}'`}.`
)
} else {
return
}
@ -511,7 +597,7 @@ export class RemoteBackend implements backend.Backend {
/** Create a file tag or project tag.
*
* @throws An error if any status code other than 200 OK was received. */
* @throws An error if a non-successful status code (not 200-299) was received. */
async createTag(body: backend.CreateTagRequestBody): Promise<backend.TagInfo> {
const response = await this.post<backend.TagInfo>(CREATE_TAG_PATH, {
/* eslint-disable @typescript-eslint/naming-convention */
@ -530,7 +616,7 @@ export class RemoteBackend implements backend.Backend {
/** Return file tags or project tags accessible by the user.
*
* @throws An error if any status code other than 200 OK was received. */
* @throws An error if a non-successful status code (not 200-299) was received. */
async listTags(params: backend.ListTagsRequestParams): Promise<backend.Tag[]> {
const response = await this.get<ListTagsResponseBody>(
LIST_TAGS_PATH +
@ -549,7 +635,7 @@ export class RemoteBackend implements backend.Backend {
/** Delete a secret environment variable.
*
* @throws An error if any status code other than 200 OK was received. */
* @throws An error if a non-successful status code (not 200-299) was received. */
async deleteTag(tagId: backend.TagId): Promise<void> {
const response = await this.delete(deleteTagPath(tagId))
if (!responseIsSuccessful(response)) {
@ -561,7 +647,7 @@ export class RemoteBackend implements backend.Backend {
/** Return list of backend or IDE versions.
*
* @throws An error if any status code other than 200 OK was received. */
* @throws An error if a non-successful status code (not 200-299) was received. */
async listVersions(
params: backend.ListVersionsRequestParams
): Promise<[backend.Version, ...backend.Version[]]> {

View File

@ -0,0 +1,152 @@
/** @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,12 @@
/** @file An enum specifying the main content of the screen. Only one should be visible
* at a time. */
// ===========
// === Tab ===
// ===========
/** Main content of the screen. Only one should be visible at a time. */
export enum Tab {
dashboard = 'dashboard',
ide = 'ide',
}

View File

@ -16,3 +16,12 @@ export const PREVIOUS_PASSWORD_PATTERN = '^[\\S]+.*[\\S]+$'
export const PREVIOUS_PASSWORD_TITLE =
'Your password must neither start nor end with whitespace, and must contain ' +
'at least two characters.'
// The Project Manager has restrictions on names of projects.
/** Regex pattern for valid names for local projects.
* @see https://github.com/enso-org/enso/blob/72ec775d8cf46b1862884fe7905477354943f0a5/lib/scala/pkg/src/main/scala/org/enso/pkg/validation/NameValidation.scala#L37
*/
export const LOCAL_PROJECT_NAME_PATTERN = '[A-Z]+[a-z]*(?:_\\d+|_[A-Z]+[a-z]*)*'
/** Human readable explanation of project name restrictions for local projects. */
export const LOCAL_PROJECT_NAME_TITLE =
'Project names must be in Upper_Snake_Case. (Numbers (_0, _1) are also allowed.)'

View File

@ -29,24 +29,6 @@ export function tryGetMessage(error: unknown): string | null {
: null
}
// ================================
// === Type assertions (unsafe) ===
// ================================
/** Assumes an unknown value is an {@link Error}. */
export function unsafeAsError<T>(error: MustBeAny<T>) {
// This is UNSAFE - errors can be any value.
// Usually they *do* extend `Error`,
// however great care must be taken when deciding to use this.
// eslint-disable-next-line no-restricted-syntax
return error as Error
}
/** Extracts the `message` property of a value, by first assuming it is an {@link Error}. */
export function unsafeIntoErrorMessage<T>(error: MustBeAny<T>) {
return unsafeAsError(error).message
}
// ============================
// === UnreachableCaseError ===
// ============================
@ -64,3 +46,10 @@ export class UnreachableCaseError extends Error {
super(`Unreachable case: ${JSON.stringify(value)}`)
}
}
/** A function that throws an {@link UnreachableCaseError} so that it can be used
* in an expresison.
* @throws {UnreachableCaseError} Always. */
export function unreachable(value: never): never {
throw new UnreachableCaseError(value)
}

View File

@ -1,5 +1,6 @@
/** @file Module containing common custom React hooks used throughout out Dashboard. */
import * as React from 'react'
import * as reactDom from 'react-dom'
import * as router from 'react-router'
import * as app from './components/app'
@ -100,21 +101,61 @@ export function useNavigate() {
return navigate
}
// ================
// === useEvent ===
// ================
// =======================
// === Reactive Events ===
// =======================
/** A wrapper around `useState` that sets its value to `null` after the current render. */
export function useEvent<T>(): [event: T | null, dispatchEvent: (value: T | null) => void] {
const [event, setEvent] = React.useState<T | null>(null)
/** A map containing all known event types. Names MUST be chosen carefully to avoid conflicts.
* The simplest way to achieve this is by namespacing names using a prefix. */
export interface KnownEventsMap {}
React.useEffect(() => {
/** A union of all known events. */
type KnownEvent = KnownEventsMap[keyof KnownEventsMap]
/** A wrapper around `useState` that calls `flushSync` after every `setState`.
* This is required so that no events are dropped. */
export function useEvent<T extends KnownEvent>(): [
event: T | null,
dispatchEvent: (event: T) => void
] {
const [event, rawDispatchEvent] = React.useState<T | null>(null)
const dispatchEvent = React.useCallback(
(innerEvent: T) => {
setTimeout(() => {
reactDom.flushSync(() => {
rawDispatchEvent(innerEvent)
})
}, 0)
},
[rawDispatchEvent]
)
return [event, dispatchEvent]
}
/** A wrapper around `useEffect` that has `event` as its sole dependency. */
export function useEventHandler<T extends KnownEvent>(
event: T | null,
effect: (event: T) => Promise<void> | void
) {
let hasEffectRun = false
React.useLayoutEffect(() => {
if (IS_DEV_MODE) {
if (hasEffectRun) {
// This is the second time this event is being run in React Strict Mode.
// Event handlers are not supposed to be idempotent, so it is a mistake to execute it
// a second time.
// eslint-disable-next-line no-restricted-syntax
return
} else {
// eslint-disable-next-line react-hooks/exhaustive-deps
hasEffectRun = true
}
}
if (event != null) {
setEvent(null)
void effect(event)
}
}, [event])
return [event, setEvent]
}
// =========================================

View File

@ -45,9 +45,15 @@ function run(props: app.AppProps) {
// `supportsDeepLinks` will be incorrect when accessing the installed Electron app's pages
// via the browser.
const actuallySupportsDeepLinks = supportsDeepLinks && detect.isRunningInElectron()
reactDOM
.createRoot(root)
.render(<App {...props} supportsDeepLinks={actuallySupportsDeepLinks} />)
reactDOM.createRoot(root).render(
IS_DEV_MODE ? (
<React.StrictMode>
<App {...props} />
</React.StrictMode>
) : (
<App {...props} supportsDeepLinks={actuallySupportsDeepLinks} />
)
)
}
}

View File

@ -37,8 +37,10 @@ interface NotNewtype {
_$type?: never
}
/** Converts a value that is not a newtype, to a value that is a newtype. */
export function asNewtype<T extends Newtype<unknown, string>>(s: UnNewtype<T>): T {
/** Converts a value that is not a newtype, to a value that is a newtype.
* This function intentionally returns another function, to ensure that each function instance
* is only used for one type, avoiding the de-optimization caused by polymorphic functions. */
export function newtypeConstructor<T extends Newtype<unknown, string>>() {
// This cast is unsafe.
// `T` has an extra property `_$type` which is used purely for typechecking
// and does not exist at runtime.
@ -46,5 +48,5 @@ export function asNewtype<T extends Newtype<unknown, string>>(s: UnNewtype<T>):
// The property name is specifically chosen to trigger eslint's `naming-convention` lint,
// so it should not be possible to accidentally create a value with such a type.
// eslint-disable-next-line no-restricted-syntax
return s as unknown as T
return (s: NotNewtype & UnNewtype<T>) => s as unknown as T
}

View File

@ -1,4 +1,4 @@
/** @file Defines the React provider for the project manager `Backend`, along with hooks to use the
/** @file The React provider for the project manager `Backend`, along with hooks to use the
* provider via the shared React context. */
import * as React from 'react'
@ -47,14 +47,10 @@ export interface BackendProviderProps extends React.PropsWithChildren<object> {
/** A React Provider that lets components get and set the current backend. */
export function BackendProvider(props: BackendProviderProps) {
const { children } = props
const { initialBackend, children } = props
const [backend, setBackendWithoutSavingType] = React.useState<
localBackend.LocalBackend | remoteBackend.RemoteBackend
// This default value is UNSAFE, but must neither be `LocalBackend`, which may not be
// available, not `RemoteBackend`, which does not work when not yet logged in.
// Care must be taken to initialize the backend before its first usage.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
>(null!)
>(initialBackend)
const setBackend = React.useCallback((newBackend: AnyBackendAPI) => {
setBackendWithoutSavingType(newBackend)
localStorage.setItem(BACKEND_TYPE_KEY, newBackend.type)

View File

@ -1,4 +1,4 @@
/** @file Defines the React provider for the {@link Logger} interface, along with a hook to use the
/** @file The React provider for the {@link Logger} interface, along with a hook to use the
* provider via the shared React context. */
import * as React from 'react'

View File

@ -1,4 +1,5 @@
/** @file */
/** @file The React provider for modals, along with hooks to use the provider via
* the shared React context. */
import * as React from 'react'
// =============
@ -6,16 +7,23 @@ import * as React from 'react'
// =============
/** The type of a modal. */
export type Modal = () => JSX.Element
export type Modal = JSX.Element
/** State contained in a `SetModalContext`. */
interface SetModalContextType {
setModal: (modal: React.SetStateAction<Modal | null>) => void
}
/** State contained in a `ModalContext`. */
interface ModalContextType {
modal: Modal | null
setModal: (modal: Modal | null) => void
}
const ModalContext = React.createContext<ModalContextType>({
modal: null,
})
const SetModalContext = React.createContext<SetModalContextType>({
setModal: () => {
// Ignored. This default value will never be used
// as `ModalProvider` always provides its own value.
@ -23,13 +31,31 @@ const ModalContext = React.createContext<ModalContextType>({
})
/** Props for a {@link ModalProvider}. */
export interface ModalProviderProps extends React.PropsWithChildren<object> {}
export interface ModalProviderProps extends React.PropsWithChildren {}
/** A React provider containing the currently active modal. */
export function ModalProvider(props: ModalProviderProps) {
const { children } = props
const [modal, setModal] = React.useState<Modal | null>(null)
return <ModalContext.Provider value={{ modal, setModal }}>{children}</ModalContext.Provider>
// This is NOT for optimization purposes - this is for debugging purposes,
// so that a change of `modal` does not trigger VDOM changes everywhere in the page.
const setModalProvider = React.useMemo(
() => <SetModalProvider setModal={setModal}>{children}</SetModalProvider>,
[children]
)
return <ModalContext.Provider value={{ modal }}>{setModalProvider}</ModalContext.Provider>
}
/** Props for a {@link ModalProvider}. */
interface InternalSetModalProviderProps extends React.PropsWithChildren {
setModal: (modal: React.SetStateAction<Modal | null>) => void
}
/** A React provider containing a function to set the currently active modal. */
function SetModalProvider(props: InternalSetModalProviderProps) {
const { setModal, children } = props
return <SetModalContext.Provider value={{ setModal }}>{children}</SetModalContext.Provider>
}
/** A React context hook exposing the currently active modal, if one is currently visible. */
@ -40,10 +66,11 @@ export function useModal() {
/** A React context hook exposing functions to set and unset the currently active modal. */
export function useSetModal() {
const { setModal: setModalRaw } = React.useContext(ModalContext)
const { setModal: setModalRaw } = React.useContext(SetModalContext)
const setModal: (modal: Modal) => void = setModalRaw
const updateModal: (updater: (modal: Modal | null) => Modal | null) => void = setModalRaw
const unsetModal = React.useCallback(() => {
setModalRaw(null)
}, [/* should never change */ setModalRaw])
return { setModal, unsetModal }
return { setModal, updateModal, unsetModal }
}

View File

@ -0,0 +1,12 @@
/** @file Utilities for manipulating strings. */
/** Return a function returning the singular or plural form of a word depending on the count of
* items. */
export function makePluralize(singular: string, plural: string) {
return (count: number) => (count === 1 ? singular : plural)
}
/** Return the given string, but with the first letter uppercased. */
export function capitalizeFirst(string: string) {
return string.replace(/^./, match => match.toUpperCase())
}

View File

@ -0,0 +1,14 @@
/** @file A function that generates a unique string. */
// ====================
// === uniqueString ===
// ====================
// This is initialized to an unusual number, to minimize the chances of collision.
let counter = Number(new Date()) >> 2
/** Returns a new, mostly unique string. */
export function uniqueString(): string {
counter += 1
return counter.toString()
}

View File

@ -1,62 +0,0 @@
/** @file Helper function to upload multiple files,
* with progress being reported by a continually updating toast notification. */
import toast from 'react-hot-toast'
import * as backend from './dashboard/backend'
import * as remoteBackend from './dashboard/remoteBackend'
// ===========================
// === uploadMultipleFiles ===
// ===========================
/** Uploads multiple files to the backend, showing a continuously updated toast notification. */
export async function uploadMultipleFiles(
backendService: remoteBackend.RemoteBackend,
directoryId: backend.DirectoryId,
files: File[]
) {
const fileCount = files.length
if (fileCount === 0) {
toast.error('No files were dropped.')
return []
} else {
let successfulUploadCount = 0
let completedUploads = 0
/** "file" or "files", whicheven is appropriate. */
const filesWord = fileCount === 1 ? 'file' : 'files'
const toastId = toast.loading(`Uploading ${fileCount} ${filesWord}.`)
return await Promise.allSettled(
files.map(file =>
backendService
.uploadFile(
{
fileName: file.name,
parentDirectoryId: directoryId,
},
file
)
.then(() => {
successfulUploadCount += 1
})
.catch(() => {
toast.error(`Could not upload file '${file.name}'.`)
})
.finally(() => {
completedUploads += 1
if (completedUploads === fileCount) {
const progress =
successfulUploadCount === fileCount
? fileCount
: `${successfulUploadCount}/${fileCount}`
toast.success(`${progress} ${filesWord} uploaded.`, { id: toastId })
} else {
toast.loading(
`${successfulUploadCount}/${fileCount} ${filesWord} uploaded.`,
{ id: toastId }
)
}
})
)
)
}
}

View File

@ -6,12 +6,12 @@ import * as authentication from 'enso-authentication'
// =================
/** Path to the SSE endpoint over which esbuild sends events. */
const ESBUILD_PATH = '/esbuild'
const ESBUILD_PATH = './esbuild'
/** SSE event indicating a build has finished. */
const ESBUILD_EVENT_NAME = 'change'
/** Path to the service worker that resolves all extensionless paths to `/index.html`.
* This service worker is required for client-side routing to work when doing local development. */
const SERVICE_WORKER_PATH = '/serviceWorker.js'
const SERVICE_WORKER_PATH = './serviceWorker.js'
// ===================
// === Live reload ===

View File

@ -153,4 +153,9 @@ body {
}
}
}
.pointer-events-none-recursive,
.pointer-events-none-recursive * {
pointer-events: none;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -31,7 +31,9 @@
"@typescript-eslint/parser": "^5.55.0",
"cross-env": "^7.0.3",
"eslint": "^8.36.0",
"eslint-plugin-jsdoc": "^40.0.2"
"eslint-plugin-jsdoc": "^40.0.2",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0"
},
"scripts": {
"watch": "npm run watch --workspace enso-content",