From 58bb813b33a10d1102d46bd9b29a21da91dd90b1 Mon Sep 17 00:00:00 2001 From: RheeseyB <1044774+Rheeseyb@users.noreply.github.com> Date: Tue, 13 Aug 2024 15:43:40 +0100 Subject: [PATCH] Revert "Update VS Code and enable Intellisense" (#6228) Reverts concrete-utopia/utopia#6222 --- .../code-editor/code-editor-container.tsx | 6 +- .../components/editor/store/vscode-changes.ts | 14 +- .../navigator-item-clickable-wrapper.tsx | 5 +- editor/src/core/vscode/vscode-bridge.ts | 50 +- .../templates/vscode-editor-iframe/index.html | 8 +- release.nix | 6 +- server/src/Utopia/Web/Endpoints.hs | 39 +- server/src/Utopia/Web/Types.hs | 14 +- shell.nix | 2 +- utopia-vscode-common/src/fs/fs-core.ts | 158 ++ utopia-vscode-common/src/fs/fs-types.ts | 101 + utopia-vscode-common/src/fs/fs-utils.ts | 638 +++++++ utopia-vscode-common/src/index.ts | 8 +- utopia-vscode-common/src/lite-either.ts | 65 + utopia-vscode-common/src/mailbox.ts | 253 +++ .../src/messages-to-utopia.ts | 162 -- .../src/messages-to-vscode.ts | 328 ---- utopia-vscode-common/src/messages.ts | 360 ++++ utopia-vscode-common/src/path-utils.ts | 8 +- .../src/vscode-communication.ts | 191 ++ utopia-vscode-common/src/window-messages.ts | 252 +++ utopia-vscode-extension/package.json | 20 +- utopia-vscode-extension/pnpm-lock.yaml | 74 +- utopia-vscode-extension/src/extension.ts | 286 ++- utopia-vscode-extension/src/in-mem-fs.ts | 400 ---- utopia-vscode-extension/src/path-utils.ts | 34 +- utopia-vscode-extension/src/utopia-fs.ts | 362 ++-- vscode-build/build.js | 15 +- vscode-build/package.json | 5 +- vscode-build/pull-utopia-extension.js | 2 +- vscode-build/shell.nix | 4 +- vscode-build/vscode.patch | 1691 +++++++++-------- vscode-build/yarn.lock | 17 +- website-next/components/common/env-vars.ts | 1 + 34 files changed, 3354 insertions(+), 2225 deletions(-) create mode 100644 utopia-vscode-common/src/fs/fs-core.ts create mode 100644 utopia-vscode-common/src/fs/fs-types.ts create mode 100644 utopia-vscode-common/src/fs/fs-utils.ts create mode 100644 utopia-vscode-common/src/lite-either.ts create mode 100644 utopia-vscode-common/src/mailbox.ts delete mode 100644 utopia-vscode-common/src/messages-to-utopia.ts delete mode 100644 utopia-vscode-common/src/messages-to-vscode.ts create mode 100644 utopia-vscode-common/src/messages.ts create mode 100644 utopia-vscode-common/src/vscode-communication.ts create mode 100644 utopia-vscode-common/src/window-messages.ts delete mode 100644 utopia-vscode-extension/src/in-mem-fs.ts diff --git a/editor/src/components/code-editor/code-editor-container.tsx b/editor/src/components/code-editor/code-editor-container.tsx index 85d5d4495e88..9fd6556af0a1 100644 --- a/editor/src/components/code-editor/code-editor-container.tsx +++ b/editor/src/components/code-editor/code-editor-container.tsx @@ -1,17 +1,17 @@ import React from 'react' import { Substores, useEditorState } from '../editor/store/store-hook' +import { MONACO_EDITOR_IFRAME_BASE_URL } from '../../common/env-vars' import { createIframeUrl } from '../../core/shared/utils' import { getUnderlyingVSCodeBridgeID } from '../editor/store/editor-state' import { VSCodeLoadingScreen } from './vscode-editor-loading-screen' -import { setBranchNameFromURL } from '../../utils/branches' +import { getEditorBranchNameFromURL, setBranchNameFromURL } from '../../utils/branches' import { VSCODE_EDITOR_IFRAME_ID } from '../../core/vscode/vscode-bridge' const VSCodeIframeContainer = React.memo((props: { vsCodeSessionID: string }) => { const vsCodeSessionID = props.vsCodeSessionID - const baseIframeSrc = createIframeUrl(window.location.origin, 'vscode-editor-iframe/') + const baseIframeSrc = createIframeUrl(MONACO_EDITOR_IFRAME_BASE_URL, 'vscode-editor-iframe/') const url = new URL(baseIframeSrc) url.searchParams.append('vs_code_session_id', vsCodeSessionID) - url.searchParams.append('vscode-coi', '') // Required to enable intellisense setBranchNameFromURL(url.searchParams) diff --git a/editor/src/components/editor/store/vscode-changes.ts b/editor/src/components/editor/store/vscode-changes.ts index 43c1385493f6..c509fc658103 100644 --- a/editor/src/components/editor/store/vscode-changes.ts +++ b/editor/src/components/editor/store/vscode-changes.ts @@ -21,8 +21,10 @@ import { import type { UpdateDecorationsMessage, SelectedElementChanged, - FromUtopiaToVSCodeMessage, + AccumulatedToVSCodeMessage, + ToVSCodeMessageNoAccumulated, } from 'utopia-vscode-common' +import { accumulatedToVSCodeMessage, toVSCodeExtensionMessage } from 'utopia-vscode-common' import type { EditorState } from './editor-state' import { getHighlightBoundsForElementPaths } from './editor-state' import { shallowEqual } from '../../../core/shared/equality-utils' @@ -300,15 +302,15 @@ export const emptyProjectChanges: ProjectChanges = { selectedChanged: null, } -function projectChangesToVSCodeMessages(local: ProjectChanges): Array { - let messages: Array = [] +export function projectChangesToVSCodeMessages(local: ProjectChanges): AccumulatedToVSCodeMessage { + let messages: Array = [] if (local.updateDecorations != null) { messages.push(local.updateDecorations) } if (local.selectedChanged != null) { messages.push(local.selectedChanged) } - return messages + return accumulatedToVSCodeMessage(messages) } export function getProjectChanges( @@ -332,5 +334,7 @@ export function getProjectChanges( export function sendVSCodeChanges(changes: ProjectChanges) { applyProjectChanges(changes.fileChanges.changesForVSCode) const toVSCodeAccumulated = projectChangesToVSCodeMessages(changes) - toVSCodeAccumulated.forEach((message) => sendMessage(message)) + if (toVSCodeAccumulated.messages.length > 0) { + sendMessage(toVSCodeExtensionMessage(toVSCodeAccumulated)) + } } diff --git a/editor/src/components/navigator/navigator-item/navigator-item-clickable-wrapper.tsx b/editor/src/components/navigator/navigator-item/navigator-item-clickable-wrapper.tsx index 8b1deca408b7..a42ba01e2b34 100644 --- a/editor/src/components/navigator/navigator-item/navigator-item-clickable-wrapper.tsx +++ b/editor/src/components/navigator/navigator-item/navigator-item-clickable-wrapper.tsx @@ -21,6 +21,7 @@ import { selectedElementChangedMessageFromHighlightBounds, sendMessage, } from '../../../core/vscode/vscode-bridge' +import { toVSCodeExtensionMessage } from 'utopia-vscode-common' import { isRegulaNavigatorRow, type NavigatorRow } from '../navigator-row' import { useDispatch } from '../../editor/store/dispatch-context' import { isRight } from '../../../core/shared/either' @@ -99,7 +100,9 @@ export function useGetNavigatorClickActions( // when we click on an already selected item we should force vscode to navigate there if (selected && highlightBounds != null) { sendMessage( - selectedElementChangedMessageFromHighlightBounds(highlightBounds, 'force-navigation'), + toVSCodeExtensionMessage( + selectedElementChangedMessageFromHighlightBounds(highlightBounds, 'force-navigation'), + ), ) } return actionsForSingleSelection(targetPath, row, conditionalOverrideUpdate) diff --git a/editor/src/core/vscode/vscode-bridge.ts b/editor/src/core/vscode/vscode-bridge.ts index da9ff01d9153..b03135cb0fa9 100644 --- a/editor/src/core/vscode/vscode-bridge.ts +++ b/editor/src/core/vscode/vscode-bridge.ts @@ -14,20 +14,19 @@ import { deletePathChange, ensureDirectoryExistsChange, initProject, - isClearLoadingScreen, - isEditorCursorPositionChanged, + isFromVSCodeExtensionMessage, + isIndexedDBFailure, isMessageListenersReady, - isUtopiaVSCodeConfigValues, isVSCodeBridgeReady, isVSCodeFileChange, isVSCodeFileDelete, - isVSCodeReady, openFileMessage, projectDirectory, projectTextFile, selectedElementChanged, setFollowSelectionConfig, setVSCodeTheme, + toVSCodeExtensionMessage, updateDecorationsMessage, writeProjectFileChange, } from 'utopia-vscode-common' @@ -132,16 +131,27 @@ export function initVSCodeBridge( // Store the source vscodeIFrame = messageEvent.source dispatch([markVSCodeBridgeReady(true)]) - } else if (isEditorCursorPositionChanged(data)) { - dispatch([selectFromFileAndPosition(data.filePath, data.line, data.column)]) - } else if (isUtopiaVSCodeConfigValues(data)) { - dispatch([updateConfigFromVSCode(data.config)]) - } else if (isVSCodeReady(data)) { - dispatch([sendCodeEditorInitialisation()]) - } else if (isClearLoadingScreen(data)) { - if (!loadingScreenHidden) { - loadingScreenHidden = true - dispatch([hideVSCodeLoadingScreen()]) + } else if (isFromVSCodeExtensionMessage(data)) { + const message = data.message + switch (message.type) { + case 'EDITOR_CURSOR_POSITION_CHANGED': + dispatch([selectFromFileAndPosition(message.filePath, message.line, message.column)]) + break + case 'UTOPIA_VSCODE_CONFIG_VALUES': + dispatch([updateConfigFromVSCode(message.config)]) + break + case 'VSCODE_READY': + dispatch([sendCodeEditorInitialisation()]) + break + case 'CLEAR_LOADING_SCREEN': + if (!loadingScreenHidden) { + loadingScreenHidden = true + dispatch([hideVSCodeLoadingScreen()]) + } + break + default: + const _exhaustiveCheck: never = message + throw new Error(`Unhandled message type${JSON.stringify(message)}`) } } else if (isVSCodeFileChange(data)) { const { filePath, fileContent } = data @@ -159,6 +169,8 @@ export function initVSCodeBridge( dispatch(actionsToDispatch) } else if (isVSCodeFileDelete(data)) { dispatch([deleteFileFromVSCode(data.filePath)]) + } else if (isIndexedDBFailure(data)) { + dispatch([setIndexedDBFailed(true)]) } } @@ -170,11 +182,11 @@ export function sendMessage(message: FromUtopiaToVSCodeMessage) { } export function sendOpenFileMessage(filePath: string, bounds: Bounds | null) { - sendMessage(openFileMessage(filePath, bounds)) + sendMessage(toVSCodeExtensionMessage(openFileMessage(filePath, bounds))) } export function sendSetFollowSelectionEnabledMessage(enabled: boolean) { - sendMessage(setFollowSelectionConfig(enabled)) + sendMessage(toVSCodeExtensionMessage(setFollowSelectionConfig(enabled))) } export function applyProjectChanges(changes: Array) { @@ -231,7 +243,7 @@ export function getCodeEditorDecorations(editorState: EditorState): UpdateDecora export function sendCodeEditorDecorations(editorState: EditorState) { const decorationsMessage = getCodeEditorDecorations(editorState) - sendMessage(decorationsMessage) + sendMessage(toVSCodeExtensionMessage(decorationsMessage)) } export function getSelectedElementChangedMessage( @@ -272,7 +284,7 @@ export function sendSelectedElement(newEditorState: EditorState) { 'do-not-force-navigation', ) if (selectedElementChangedMessage != null) { - sendMessage(selectedElementChangedMessage) + sendMessage(toVSCodeExtensionMessage(selectedElementChangedMessage)) } } @@ -290,5 +302,5 @@ function vsCodeThemeForTheme(theme: Theme): string { export function sendSetVSCodeTheme(theme: Theme) { const vsCodeTheme = vsCodeThemeForTheme(theme) - sendMessage(setVSCodeTheme(vsCodeTheme)) + sendMessage(toVSCodeExtensionMessage(setVSCodeTheme(vsCodeTheme))) } diff --git a/editor/src/templates/vscode-editor-iframe/index.html b/editor/src/templates/vscode-editor-iframe/index.html index ac95ec7e08b7..f3af22f2caca 100644 --- a/editor/src/templates/vscode-editor-iframe/index.html +++ b/editor/src/templates/vscode-editor-iframe/index.html @@ -10,9 +10,9 @@ @@ -36,8 +36,8 @@ - - + + diff --git a/release.nix b/release.nix index 8430c16f1fd4..87ac2f60e35f 100644 --- a/release.nix +++ b/release.nix @@ -13,10 +13,10 @@ let }) { inherit config; }; recentPkgs = import (builtins.fetchTarball { - name = "nixos-24.05"; - url = https://github.com/NixOS/nixpkgs/archive/24.05.tar.gz; + name = "nixos-23.11"; + url = https://github.com/NixOS/nixpkgs/archive/23.11.tar.gz; # Hash obtained using `nix-prefetch-url --unpack ` - sha256 = "1lr1h35prqkd1mkmzriwlpvxcb34kmhc9dnr48gkm8hh089hifmx"; + sha256 = "1ndiv385w1qyb3b18vw13991fzb9wg4cl21wglk89grsfsnra41k"; }) { inherit config; }; in diff --git a/server/src/Utopia/Web/Endpoints.hs b/server/src/Utopia/Web/Endpoints.hs index 3932a65d51e1..2b67ff818df3 100644 --- a/server/src/Utopia/Web/Endpoints.hs +++ b/server/src/Utopia/Web/Endpoints.hs @@ -277,7 +277,7 @@ injectIntoPage toInject@(_, _, _, _, editorScriptTags) (TagComment "editorScript injectIntoPage toInject (firstTag : remainder) = firstTag : injectIntoPage toInject remainder injectIntoPage _ [] = [] -renderPageWithMetadata :: Maybe ProjectIdWithSuffix -> Maybe ProjectMetadata -> Maybe DB.DecodedProject -> Maybe Text -> Text -> ServerMonad ProjectPageResponse +renderPageWithMetadata :: Maybe ProjectIdWithSuffix -> Maybe ProjectMetadata -> Maybe DB.DecodedProject -> Maybe Text -> Text -> ServerMonad H.Html renderPageWithMetadata possibleProjectID possibleMetadata possibleProject branchName pagePath = do indexHtml <- getEditorTextContent branchName pagePath siteRoot <- getSiteRoot @@ -294,38 +294,38 @@ renderPageWithMetadata possibleProjectID possibleMetadata possibleProject branch let reversedEditorScriptTags = partitionOutScriptDefer False $ reverse parsedTags let editorScriptPreloads = preloadsForScripts $ reverse reversedEditorScriptTags let updatedContent = injectIntoPage (ogTags, projectIDScriptTags, dependenciesTags, vscodePreloadTags, editorScriptPreloads) parsedTags - return $ addHeader "cross-origin" $ addHeader "same-origin" $ addHeader "credentialless" $ H.preEscapedToHtml $ renderTags updatedContent + return $ H.preEscapedToHtml $ renderTags updatedContent -innerProjectPage :: Maybe ProjectIdWithSuffix -> ProjectDetails -> Maybe DB.DecodedProject -> Maybe Text -> ServerMonad ProjectPageResponse +innerProjectPage :: Maybe ProjectIdWithSuffix -> ProjectDetails -> Maybe DB.DecodedProject -> Maybe Text -> ServerMonad H.Html innerProjectPage (Just _) UnknownProject _ branchName = do projectNotFoundHtml <- getEditorTextContent branchName "project-not-found/index.html" - return $ addHeader "cross-origin" $ addHeader "same-origin" $ addHeader "credentialless" $ H.preEscapedToHtml projectNotFoundHtml + return $ H.preEscapedToHtml projectNotFoundHtml innerProjectPage possibleProjectID details possibleProject branchName = renderPageWithMetadata possibleProjectID (projectDetailsToPossibleMetadata details) possibleProject branchName "index.html" -projectPage :: ProjectIdWithSuffix -> Maybe Text -> ServerMonad ProjectPageResponse +projectPage :: ProjectIdWithSuffix -> Maybe Text -> ServerMonad H.Html projectPage projectIDWithSuffix@(ProjectIdWithSuffix projectID _) branchName = do possibleMetadata <- getProjectMetadata projectID possibleProject <- loadProject projectID innerProjectPage (Just projectIDWithSuffix) possibleMetadata possibleProject branchName -emptyProjectPage :: Maybe Text -> ServerMonad ProjectPageResponse +emptyProjectPage :: Maybe Text -> ServerMonad H.Html emptyProjectPage = innerProjectPage Nothing UnknownProject Nothing -innerPreviewPage :: Maybe ProjectIdWithSuffix -> ProjectDetails -> Maybe DB.DecodedProject -> Maybe Text -> ServerMonad ProjectPageResponse +innerPreviewPage :: Maybe ProjectIdWithSuffix -> ProjectDetails -> Maybe DB.DecodedProject -> Maybe Text -> ServerMonad H.Html innerPreviewPage (Just _) UnknownProject _ branchName = do projectNotFoundHtml <- getEditorTextContent branchName "project-not-found/index.html" - return $ addHeader "cross-origin" $ addHeader "same-origin" $ addHeader "credentialless" $ H.preEscapedToHtml projectNotFoundHtml + return $ H.preEscapedToHtml projectNotFoundHtml innerPreviewPage possibleProjectID details possibleProject branchName = renderPageWithMetadata possibleProjectID (projectDetailsToPossibleMetadata details) possibleProject branchName "preview/index.html" -previewPage :: ProjectIdWithSuffix -> Maybe Text -> ServerMonad ProjectPageResponse +previewPage :: ProjectIdWithSuffix -> Maybe Text -> ServerMonad H.Html previewPage projectIDWithSuffix@(ProjectIdWithSuffix projectID _) branchName = do possibleMetadata <- getProjectMetadata projectID possibleProject <- loadProject projectID innerPreviewPage (Just projectIDWithSuffix) possibleMetadata possibleProject branchName -emptyPreviewPage :: Maybe Text -> ServerMonad ProjectPageResponse +emptyPreviewPage :: Maybe Text -> ServerMonad H.Html emptyPreviewPage = innerPreviewPage Nothing UnknownProject Nothing getUserEndpoint :: Maybe Text -> ServerMonad UserResponse @@ -529,27 +529,12 @@ addCacheControl = addMiddlewareHeader "Cache-Control" "public, immutable, max-ag addCacheControlRevalidate :: Middleware addCacheControlRevalidate = addMiddlewareHeader "Cache-Control" "public, must-revalidate, proxy-revalidate, max-age=0" -addCrossOriginResourcePolicy :: Middleware -addCrossOriginResourcePolicy = addMiddlewareHeader "Cross-Origin-Resource-Policy" "cross-origin" - -addCrossOriginOpenerPolicy :: Middleware -addCrossOriginOpenerPolicy = addMiddlewareHeader "Cross-Origin-Opener-Policy" "same-origin" - -addCrossOriginEmbedderPolicy :: Middleware -addCrossOriginEmbedderPolicy = addMiddlewareHeader "Cross-Origin-Embedder-Policy" "require-corp" - addCDNHeaders :: Middleware addCDNHeaders = addCacheControl . addAccessControlAllowOrigin addCDNHeadersCacheRevalidate :: Middleware addCDNHeadersCacheRevalidate = addCacheControlRevalidate . addAccessControlAllowOrigin -addEditorAssetsHeaders :: Middleware -addEditorAssetsHeaders = addCDNHeaders . addCrossOriginResourcePolicy . addCrossOriginOpenerPolicy . addCrossOriginEmbedderPolicy - -addVSCodeHeaders :: Middleware -addVSCodeHeaders = addCDNHeadersCacheRevalidate . addCrossOriginResourcePolicy . addCrossOriginOpenerPolicy . addCrossOriginEmbedderPolicy - fallbackOn404 :: Application -> Application -> Application fallbackOn404 firstApplication secondApplication request sendResponse = firstApplication request $ \firstAppResponse -> do @@ -572,7 +557,7 @@ editorAssetsEndpoint notProxiedPath possibleBranchName = do mainApp <- case possibleBranchName of Just _ -> pure loadLocally Nothing -> maybe (pure loadLocally) loadFromProxy possibleProxyManager - pure $ addEditorAssetsHeaders $ downloadWithFallbacks mainApp + pure $ addCDNHeaders $ downloadWithFallbacks mainApp downloadGithubProjectEndpoint :: Maybe Text -> Text -> Text -> ServerMonad BL.ByteString downloadGithubProjectEndpoint cookie owner repo = requireUser cookie $ \_ -> do @@ -595,7 +580,7 @@ websiteAssetsEndpoint notProxiedPath = do vsCodeAssetsEndpoint :: ServerMonad Application vsCodeAssetsEndpoint = do pathToServeFrom <- getVSCodeAssetRoot - addVSCodeHeaders <$> servePath pathToServeFrom Nothing + addCDNHeadersCacheRevalidate <$> servePath pathToServeFrom Nothing wrappedWebAppLookup :: (Pieces -> IO LookupResult) -> Pieces -> IO LookupResult wrappedWebAppLookup defaultLookup _ = diff --git a/server/src/Utopia/Web/Types.hs b/server/src/Utopia/Web/Types.hs index 9501b4e2bf27..cf7fba0b6561 100644 --- a/server/src/Utopia/Web/Types.hs +++ b/server/src/Utopia/Web/Types.hs @@ -76,17 +76,15 @@ type LogoutAPI = "logout" :> Get '[HTML] (SetSessionCookies H.Html) type GetUserAPI = "v1" :> "user" :> Get '[JSON] UserResponse -type ProjectPageResponse = Headers '[Header "Cross-Origin-Resource-Policy" Text, Header "Cross-Origin-Opener-Policy" Text, Header "Cross-Origin-Embedder-Policy" Text] (H.Html) +type EmptyProjectPageAPI = "p" :> BranchNameParam :> Get '[HTML] H.Html -type EmptyProjectPageAPI = "p" :> BranchNameParam :> Get '[HTML] ProjectPageResponse - -type ProjectPageAPI = "p" :> Capture "project_id" ProjectIdWithSuffix :> BranchNameParam :> Get '[HTML] ProjectPageResponse +type ProjectPageAPI = "p" :> Capture "project_id" ProjectIdWithSuffix :> BranchNameParam :> Get '[HTML] H.Html type LoadProjectFileAPI = "p" :> Capture "project_id" ProjectIdWithSuffix :> Header "If-None-Match" Text :> CaptureAll "file_path" Text :> RawM -type EmptyPreviewPageAPI = "share" :> BranchNameParam :> Get '[HTML] ProjectPageResponse +type EmptyPreviewPageAPI = "share" :> BranchNameParam :> Get '[HTML] H.Html -type PreviewPageAPI = "share" :> Capture "project_id" ProjectIdWithSuffix :> BranchNameParam :> Get '[HTML] ProjectPageResponse +type PreviewPageAPI = "share" :> Capture "project_id" ProjectIdWithSuffix :> BranchNameParam :> Get '[HTML] H.Html type DownloadProjectResponse = Headers '[Header "Access-Control-Allow-Origin" Text] Value @@ -263,4 +261,6 @@ packagerAPI = Proxy packagerLink :: Text -> Text -> Text packagerLink jsPackageName jsPackageVersion = let versionedName = jsPackageName <> "@" <> jsPackageVersion - in toUrlPiece $ safeLink apiProxy packagerAPI versionedName \ No newline at end of file + in toUrlPiece $ safeLink apiProxy packagerAPI versionedName + + diff --git a/shell.nix b/shell.nix index 4a47f0bead5f..c4c630a9c99a 100644 --- a/shell.nix +++ b/shell.nix @@ -785,7 +785,7 @@ let pythonAndPackages = pkgs.python3.withPackages(ps: with ps; [ pyusb tkinter pkgconfig ]); - basePackages = [ node pkgs.libsecret pkgs.libkrb5 pythonAndPackages pkgs.pkg-config pkgs.tmux pkgs.git pkgs.wget ] ++ nodePackages ++ linuxOnlyPackages ++ macOSOnlyPackages; + basePackages = [ node pkgs.libsecret pythonAndPackages pkgs.pkg-config pkgs.tmux pkgs.git pkgs.wget ] ++ nodePackages ++ linuxOnlyPackages ++ macOSOnlyPackages; withServerBasePackages = basePackages ++ (lib.optionals includeServerBuildSupport baseServerPackages); withServerRunPackages = withServerBasePackages ++ (lib.optionals includeRunLocallySupport serverRunPackages); withReleasePackages = withServerRunPackages ++ (lib.optionals includeReleaseSupport releasePackages); diff --git a/utopia-vscode-common/src/fs/fs-core.ts b/utopia-vscode-common/src/fs/fs-core.ts new file mode 100644 index 000000000000..940211be00ae --- /dev/null +++ b/utopia-vscode-common/src/fs/fs-core.ts @@ -0,0 +1,158 @@ +import * as localforage from 'localforage' +import type { Either } from '../lite-either' +import { left, mapEither, right } from '../lite-either' +import { stripTrailingSlash } from '../path-utils' +import type { FSNode } from './fs-types' +import { defer } from './fs-utils' + +let dbHeartbeatsStore: LocalForage // There is no way to request a list of existing stores, so we have to explicitly track them + +let store: LocalForage | null +let thisDBName: string + +const StoreExistsKey = '.store-exists' + +const firstInitialize = defer() +let initializeStoreChain: Promise = Promise.resolve() + +export async function initializeStore( + storeName: string, + driver: string = localforage.INDEXEDDB, +): Promise { + async function innerInitialize(): Promise { + thisDBName = `utopia-project-${storeName}` + + store = localforage.createInstance({ + name: thisDBName, + driver: driver, + }) + + await store.ready() + await store.setItem(StoreExistsKey, true) + + dbHeartbeatsStore = localforage.createInstance({ + name: 'utopia-all-store-heartbeats', + driver: localforage.INDEXEDDB, + }) + + await dbHeartbeatsStore.ready() + + triggerHeartbeat().then(dropOldStores) + } + initializeStoreChain = initializeStoreChain.then(innerInitialize) + firstInitialize.resolve() + return initializeStoreChain +} + +export interface StoreDoesNotExist { + type: 'StoreDoesNotExist' +} + +const StoreDoesNotExistConst: StoreDoesNotExist = { + type: 'StoreDoesNotExist', +} + +export function isStoreDoesNotExist(t: unknown): t is StoreDoesNotExist { + return (t as any)?.type === 'StoreDoesNotExist' +} + +export type AsyncFSResult = Promise> + +const StoreExistsKeyInterval = 1000 + +interface StoreKeyExistsCheck { + lastCheckedTime: number + exists: boolean +} + +let lastCheckedForStoreKeyExists: StoreKeyExistsCheck | null = null + +async function checkStoreKeyExists(): Promise { + if (store == null) { + return false + } else { + const now = Date.now() + if ( + lastCheckedForStoreKeyExists == null || + lastCheckedForStoreKeyExists.lastCheckedTime + StoreExistsKeyInterval < now + ) { + const exists = (await store.getItem(StoreExistsKey)) ?? false + lastCheckedForStoreKeyExists = { + lastCheckedTime: now, + exists: exists, + } + return exists + } else { + return lastCheckedForStoreKeyExists.exists + } + } +} + +async function withSanityCheckedStore( + withStore: (sanityCheckedStore: LocalForage) => Promise, +): AsyncFSResult { + await firstInitialize + await initializeStoreChain + const storeExists = await checkStoreKeyExists() + if (store != null && storeExists) { + const result = await withStore(store) + return right(result) + } else { + store = null + return left(StoreDoesNotExistConst) + } +} + +export async function keys(): AsyncFSResult { + return withSanityCheckedStore((sanityCheckedStore: LocalForage) => sanityCheckedStore.keys()) +} + +export async function getItem(path: string): AsyncFSResult { + return withSanityCheckedStore((sanityCheckedStore: LocalForage) => + sanityCheckedStore.getItem(stripTrailingSlash(path)), + ) +} + +export async function setItem(path: string, value: FSNode): AsyncFSResult { + return withSanityCheckedStore((sanityCheckedStore: LocalForage) => + sanityCheckedStore.setItem(stripTrailingSlash(path), value), + ) +} + +export async function removeItem(path: string): AsyncFSResult { + return withSanityCheckedStore((sanityCheckedStore: LocalForage) => + sanityCheckedStore.removeItem(stripTrailingSlash(path)), + ) +} + +const ONE_HOUR = 1000 * 60 * 60 +const ONE_DAY = ONE_HOUR * 24 + +async function triggerHeartbeat(): Promise { + await dbHeartbeatsStore.setItem(thisDBName, Date.now()) + setTimeout(triggerHeartbeat, ONE_HOUR) +} + +async function dropOldStores(): Promise { + const now = Date.now() + const allStores = await dbHeartbeatsStore.keys() + const allDBsWithLastHeartbeatTS = await Promise.all( + allStores.map(async (k) => { + const ts = await dbHeartbeatsStore.getItem(k) + return { + dbName: k, + ts: ts ?? now, + } + }), + ) + const dbsToDrop = allDBsWithLastHeartbeatTS.filter((v) => now - v.ts > ONE_DAY) + if (dbsToDrop.length > 0) { + const dbNamesToDrop = dbsToDrop.map((v) => v.dbName) + dbNamesToDrop.forEach((dbName) => { + dbHeartbeatsStore.removeItem(dbName) + localforage.dropInstance({ + name: dbName, + }) + }) + } +} diff --git a/utopia-vscode-common/src/fs/fs-types.ts b/utopia-vscode-common/src/fs/fs-types.ts new file mode 100644 index 000000000000..5cb0a99a426d --- /dev/null +++ b/utopia-vscode-common/src/fs/fs-types.ts @@ -0,0 +1,101 @@ +type FSNodeType = 'FILE' | 'DIRECTORY' +export type FSUser = 'UTOPIA' | 'VSCODE' + +export interface FSNode { + type: FSNodeType + ctime: number + mtime: number + lastSavedTime: number + sourceOfLastChange: FSUser +} + +export interface FSNodeWithPath { + path: string + node: FSNode +} + +export interface FSStat extends FSNode { + size: number +} + +export interface FileContent { + content: Uint8Array + unsavedContent: Uint8Array | null +} + +export interface FSFile extends FSNode, FileContent { + type: 'FILE' +} + +export function fsFile( + content: Uint8Array, + unsavedContent: Uint8Array | null, + ctime: number, + mtime: number, + lastSavedTime: number, + sourceOfLastChange: FSUser, +): FSFile { + return { + type: 'FILE', + ctime: ctime, + mtime: mtime, + lastSavedTime: lastSavedTime, + content: content, + unsavedContent: unsavedContent, + sourceOfLastChange: sourceOfLastChange, + } +} + +export interface FSDirectory extends FSNode { + type: 'DIRECTORY' +} + +export function fsDirectory(ctime: number, mtime: number, sourceOfLastChange: FSUser): FSDirectory { + return { + type: 'DIRECTORY', + ctime: ctime, + mtime: mtime, + lastSavedTime: mtime, + sourceOfLastChange: sourceOfLastChange, + } +} + +export function newFSDirectory(sourceOfLastChange: FSUser): FSDirectory { + const now = Date.now() + return { + type: 'DIRECTORY', + ctime: now, + mtime: now, + lastSavedTime: now, + sourceOfLastChange: sourceOfLastChange, + } +} + +export function isFile(node: FSNode): node is FSFile { + return node.type === 'FILE' +} + +export function isDirectory(node: FSNode): node is FSDirectory { + return node.type === 'DIRECTORY' +} + +export type FSErrorCode = 'ENOENT' | 'EEXIST' | 'EISDIR' | 'ENOTDIR' | 'FS_UNAVAILABLE' +export interface FSError { + code: FSErrorCode + path: string +} + +export type FSErrorHandler = (e: FSError) => Error + +function fsError(code: FSErrorCode, path: string): FSError { + return { + code: code, + path: path, + } +} + +export const enoent = (path: string) => fsError('ENOENT', path) +export const eexist = (path: string) => fsError('EEXIST', path) +export const eisdir = (path: string) => fsError('EISDIR', path) +export const enotdir = (path: string) => fsError('ENOTDIR', path) +export const fsUnavailable = (path: string) => fsError('FS_UNAVAILABLE', path) diff --git a/utopia-vscode-common/src/fs/fs-utils.ts b/utopia-vscode-common/src/fs/fs-utils.ts new file mode 100644 index 000000000000..d72231b81e25 --- /dev/null +++ b/utopia-vscode-common/src/fs/fs-utils.ts @@ -0,0 +1,638 @@ +import { INDEXEDDB } from 'localforage' +import { isRight } from '../lite-either' +import { appendToPath, stripLeadingSlash, stripTrailingSlash } from '../path-utils' +import type { AsyncFSResult } from './fs-core' +import { + getItem as getItemCore, + initializeStore, + keys as keysCore, + removeItem as removeItemCore, + setItem as setItemCore, +} from './fs-core' +import type { + FSError, + FSErrorHandler, + FSNode, + FSStat, + FSDirectory, + FSNodeWithPath, + FSFile, + FileContent, + FSUser, +} from './fs-types' +import { + isDirectory, + isFile, + enoent, + eexist, + eisdir, + enotdir, + fsFile, + newFSDirectory, + fsDirectory, + fsUnavailable, +} from './fs-types' + +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +let fsUser: FSUser // Used to determine if changes came from this user or another + +const SanityCheckFolder = '/SanityCheckFolder' + +let handleError: FSErrorHandler = (e: FSError) => { + let error = Error(`FS Error: ${JSON.stringify(e)}`) + error.name = e.code + return error +} + +export function setErrorHandler(handler: FSErrorHandler): void { + handleError = handler +} + +const missingFileError = (path: string) => handleError(enoent(path)) +const existingFileError = (path: string) => handleError(eexist(path)) +const isDirectoryError = (path: string) => handleError(eisdir(path)) +const isNotDirectoryError = (path: string) => handleError(enotdir(path)) +const isUnavailableError = (path: string) => handleError(fsUnavailable(path)) + +export async function initializeFS( + storeName: string, + user: FSUser, + driver: string = INDEXEDDB, +): Promise { + fsUser = user + await initializeStore(storeName, driver) + await simpleCreateDirectoryIfMissing('/') +} + +async function withAvailableFS( + path: string, + fn: (path: string) => AsyncFSResult, +): Promise { + const result = await fn(path) + if (isRight(result)) { + return result.value + } else { + return Promise.reject(isUnavailableError(path)) + } +} + +const getItem = (path: string) => withAvailableFS(path, getItemCore) +const keys = () => withAvailableFS('', (_path: string) => keysCore()) +const removeItem = (path: string) => withAvailableFS(path, removeItemCore) +const setItem = (path: string, v: FSNode) => withAvailableFS(path, (p) => setItemCore(p, v)) + +export async function exists(path: string): Promise { + const value = await getItem(path) + return value != null +} + +export async function pathIsDirectory(path: string): Promise { + const node = await getItem(path) + return node != null && isDirectory(node) +} + +export async function pathIsFile(path: string): Promise { + const node = await getItem(path) + return node != null && isFile(node) +} + +export async function pathIsFileWithUnsavedContent(path: string): Promise { + const node = await getItem(path) + return node != null && isFile(node) && node.unsavedContent != null +} + +async function getNode(path: string): Promise { + const node = await getItem(path) + if (node == null) { + return Promise.reject(missingFileError(path)) + } else { + return node + } +} + +async function getFile(path: string): Promise { + const node = await getNode(path) + if (isFile(node)) { + return node + } else { + return Promise.reject(isDirectoryError(path)) + } +} + +export async function readFile(path: string): Promise { + return getFile(path) +} + +export async function readFileSavedContent(path: string): Promise { + const fileNode = await getFile(path) + return fileNode.content +} + +export async function readFileUnsavedContent(path: string): Promise { + const fileNode = await getFile(path) + return fileNode.unsavedContent +} + +export interface StoredFile { + content: string + unsavedContent: string | null +} + +export async function readFileAsUTF8(path: string): Promise { + const { content, unsavedContent } = await getFile(path) + return { + content: decoder.decode(content), + unsavedContent: unsavedContent == null ? null : decoder.decode(unsavedContent), + } +} + +export async function readFileSavedContentAsUTF8(path: string): Promise { + const { content } = await readFileAsUTF8(path) + return content +} + +export async function readFileUnsavedContentAsUTF8(path: string): Promise { + const { unsavedContent } = await readFileAsUTF8(path) + return unsavedContent +} + +function fsStatForNode(node: FSNode): FSStat { + return { + type: node.type, + ctime: node.ctime, + mtime: node.mtime, + lastSavedTime: node.lastSavedTime, + size: isFile(node) ? node.content.length : 0, + sourceOfLastChange: node.sourceOfLastChange, + } +} + +export async function stat(path: string): Promise { + const node = await getNode(path) + return fsStatForNode(node) +} + +export function getDescendentPathsWithAllPaths( + path: string, + allPaths: Array, +): Array { + return allPaths.filter((k) => k != path && k.startsWith(path)) +} + +export async function getDescendentPaths(path: string): Promise { + const allPaths = await keys() + return getDescendentPathsWithAllPaths(path, allPaths) +} + +async function targetsForOperation(path: string, recursive: boolean): Promise { + if (recursive) { + const allDescendents = await getDescendentPaths(path) + let result = [path, ...allDescendents] + result.sort() + result.reverse() + return result + } else { + return [path] + } +} + +function getParentPath(path: string): string | null { + const withoutLeadingOrTrailingSlash = stripLeadingSlash(stripTrailingSlash(path)) + const pathElems = withoutLeadingOrTrailingSlash.split('/') + if (pathElems.length <= 1) { + return null + } else { + return `/${pathElems.slice(0, -1).join('/')}` + } +} + +function filenameOfPath(path: string): string { + const target = path.endsWith('/') ? path.slice(0, -1) : path + const lastSlashIndex = target.lastIndexOf('/') + return lastSlashIndex >= 0 ? path.slice(lastSlashIndex + 1) : path +} + +export function childPathsWithAllPaths(path: string, allPaths: Array): Array { + const allDescendents = getDescendentPathsWithAllPaths(path, allPaths) + const pathAsDir = stripTrailingSlash(path) + return allDescendents.filter((k) => getParentPath(k) === pathAsDir) +} + +export async function childPaths(path: string): Promise { + const allDescendents = await getDescendentPaths(path) + return childPathsWithAllPaths(path, allDescendents) +} + +async function getDirectory(path: string): Promise { + const node = await getNode(path) + if (isDirectory(node)) { + return node + } else { + return Promise.reject(isNotDirectoryError(path)) + } +} + +async function getParent(path: string): Promise { + // null signifies we're already at the root + const parentPath = getParentPath(path) + if (parentPath == null) { + return null + } else { + const parentDir = await getDirectory(parentPath) + return { + path: parentPath, + node: parentDir, + } + } +} + +export async function readDirectory(path: string): Promise { + await getDirectory(path) // Ensure the path exists and is a directory + const children = await childPaths(path) + return children.map(filenameOfPath) +} + +export async function createDirectory(path: string): Promise { + const parent = await getParent(path) + const pathExists = await getItem(path) + if (pathExists != null) { + return Promise.reject(existingFileError(path)) + } + + await setItem(path, newFSDirectory(fsUser)) + if (parent != null) { + await markModified(parent) + } +} + +function allPathsUpToPath(path: string): string[] { + const directories = path.split('/') + const { paths: allPaths } = directories.reduce( + ({ paths, workingPath }, next) => { + const nextPath = appendToPath(workingPath, next) + return { + paths: paths.concat(nextPath), + workingPath: nextPath, + } + }, + { paths: ['/'], workingPath: '/' }, + ) + return allPaths +} + +async function simpleCreateDirectoryIfMissing(path: string): Promise { + const existingNode = await getItem(path) + if (existingNode == null) { + await setItem(path, newFSDirectory(fsUser)) + + // Attempt to mark the parent as modified, but don't fail if it doesn't exist + // since it might not have been created yet + const parentPath = getParentPath(path) + if (parentPath != null) { + const parentNode = await getItem(parentPath) + if (parentNode != null) { + await markModified({ path: parentPath, node: parentNode }) + } + } + } else if (isFile(existingNode)) { + return Promise.reject(isNotDirectoryError(path)) + } +} + +export async function ensureDirectoryExists(path: string): Promise { + const allPaths = allPathsUpToPath(path) + for (const pathToCreate of allPaths) { + await simpleCreateDirectoryIfMissing(pathToCreate) + } +} + +export async function writeFile( + path: string, + content: Uint8Array, + unsavedContent: Uint8Array | null, +): Promise { + const parent = await getParent(path) + const maybeExistingFile = await getItem(path) + if (maybeExistingFile != null && isDirectory(maybeExistingFile)) { + return Promise.reject(isDirectoryError(path)) + } + + const now = Date.now() + const fileCTime = maybeExistingFile == null ? now : maybeExistingFile.ctime + const lastSavedTime = + unsavedContent == null || maybeExistingFile == null ? now : maybeExistingFile.lastSavedTime + const fileToWrite = fsFile(content, unsavedContent, fileCTime, now, lastSavedTime, fsUser) + await setItem(path, fileToWrite) + if (parent != null) { + await markModified(parent) + } +} + +export async function writeFileSavedContent(path: string, content: Uint8Array): Promise { + return writeFile(path, content, null) +} + +export async function writeFileUnsavedContent( + path: string, + unsavedContent: Uint8Array, +): Promise { + const savedContent = await readFileSavedContent(path) + return writeFile(path, savedContent, unsavedContent) +} + +export async function writeFileAsUTF8( + path: string, + content: string, + unsavedContent: string | null, +): Promise { + return writeFile( + path, + encoder.encode(content), + unsavedContent == null ? null : encoder.encode(unsavedContent), + ) +} + +export async function writeFileSavedContentAsUTF8( + path: string, + savedContent: string, +): Promise { + return writeFileAsUTF8(path, savedContent, null) +} + +export async function writeFileUnsavedContentAsUTF8( + path: string, + unsavedContent: string, +): Promise { + return writeFileUnsavedContent(path, encoder.encode(unsavedContent)) +} + +export async function clearFileUnsavedContent(path: string): Promise { + const savedContent = await readFileSavedContent(path) + return writeFileSavedContent(path, savedContent) +} + +function updateMTime(node: FSNode): FSNode { + const now = Date.now() + if (isFile(node)) { + const lastSavedTime = node.unsavedContent == null ? now : node.lastSavedTime + return fsFile(node.content, node.unsavedContent, node.ctime, now, lastSavedTime, fsUser) + } else { + return fsDirectory(node.ctime, now, fsUser) + } +} + +async function markModified(nodeWithPath: FSNodeWithPath): Promise { + await setItem(nodeWithPath.path, updateMTime(nodeWithPath.node)) + resetPollingFrequency() +} + +async function uncheckedMove(oldPath: string, newPath: string): Promise { + const node = await getNode(oldPath) + await setItem(newPath, updateMTime(node)) + await removeItem(oldPath) +} + +export async function rename(oldPath: string, newPath: string): Promise { + const oldParent = await getParent(oldPath) + const newParent = await getParent(newPath) + + const pathsToMove = await targetsForOperation(oldPath, true) + const toNewPath = (p: string) => `${newPath}${p.slice(0, oldPath.length)}` + await Promise.all( + pathsToMove.map((pathToMove) => uncheckedMove(pathToMove, toNewPath(pathToMove))), + ) + if (oldParent != null) { + await markModified(oldParent) + } + if (newParent != null) { + await markModified(newParent) + } +} + +export async function deletePath(path: string, recursive: boolean): Promise { + const parent = await getParent(path) + const targets = await targetsForOperation(path, recursive) + + // Really this should fail if recursive isn't set to true when trying to delete a + // non-empty directory, but for some reason VSCode doesn't provide an error suitable for that + for (const target of targets) { + await removeItem(target) + } + + if (parent != null) { + await markModified(parent) + } + return targets +} + +interface WatchConfig { + recursive: boolean + onCreated: (path: string) => void + onModified: (path: string, modifiedBySelf: boolean) => void + onDeleted: (path: string) => void +} + +let watchTimeout: number | null = null +let watchedPaths: Map = new Map() +let lastModifiedTSs: Map = new Map() + +const MIN_POLLING_TIMEOUT = 128 +const MAX_POLLING_TIMEOUT = MIN_POLLING_TIMEOUT * Math.pow(2, 2) // Max out at 512ms +let POLLING_TIMEOUT = MIN_POLLING_TIMEOUT + +let reducePollingAttemptsCount = 0 + +function reducePollingFrequency() { + if (POLLING_TIMEOUT < MAX_POLLING_TIMEOUT) { + reducePollingAttemptsCount++ + if (reducePollingAttemptsCount >= 5) { + reducePollingAttemptsCount = 0 + POLLING_TIMEOUT = POLLING_TIMEOUT + MIN_POLLING_TIMEOUT + } + } +} + +function resetPollingFrequency() { + reducePollingAttemptsCount = 0 + POLLING_TIMEOUT = MIN_POLLING_TIMEOUT +} + +function watchPath(path: string, config: WatchConfig) { + watchedPaths.set(path, config) + lastModifiedTSs.set(path, Date.now()) +} + +function isFSUnavailableError(e: unknown): boolean { + return (e as any)?.name === 'FS_UNAVAILABLE' +} + +type FileModifiedStatus = 'modified' | 'not-modified' | 'unknown' + +async function onPolledWatch(paths: Map): Promise> { + const allKeys = await keys() + const results = Array.from(paths).map(async ([path, config]) => { + const { recursive, onCreated, onModified, onDeleted } = config + + try { + const node = await getItem(path) + if (node == null) { + watchedPaths.delete(path) + lastModifiedTSs.delete(path) + onDeleted(path) + return 'modified' + } else { + const stats = fsStatForNode(node) + + const modifiedTS = stats.mtime + const wasModified = modifiedTS > (lastModifiedTSs.get(path) ?? 0) + const modifiedBySelf = stats.sourceOfLastChange === fsUser + + if (isDirectory(node)) { + if (recursive) { + const children = childPathsWithAllPaths(path, allKeys) + const unsupervisedChildren = children.filter((p) => !watchedPaths.has(p)) + unsupervisedChildren.forEach((childPath) => { + watchPath(childPath, config) + onCreated(childPath) + }) + if (unsupervisedChildren.length > 0) { + onModified(path, modifiedBySelf) + lastModifiedTSs.set(path, modifiedTS) + return 'modified' + } + } + } else { + if (wasModified) { + onModified(path, modifiedBySelf) + lastModifiedTSs.set(path, modifiedTS) + return 'modified' + } + } + + return 'not-modified' + } + } catch (e) { + if (isFSUnavailableError(e)) { + // Explicitly handle unavailable errors here by removing the watchers, then re-throw + watchedPaths.delete(path) + lastModifiedTSs.delete(path) + throw e + } + // Something was changed mid-poll, likely the file or its parent was deleted. We'll catch it on the next poll. + return 'unknown' + } + }) + return Promise.all(results) +} + +async function polledWatch(): Promise { + let promises: Array>> = [] + promises.push(onPolledWatch(watchedPaths)) + + const results = await Promise.all(promises).then((nestedResults) => nestedResults.flat()) + + let shouldReducePollingFrequency = true + for (var i = 0, len = results.length; i < len; i++) { + if (i in results) { + const fileModifiedStatus = results[i] + if (fileModifiedStatus === 'modified') { + resetPollingFrequency() + shouldReducePollingFrequency = false + return + } else if (fileModifiedStatus === 'unknown') { + shouldReducePollingFrequency = false + } + } + } + + if (shouldReducePollingFrequency) { + reducePollingFrequency() + } +} + +export async function watch( + target: string, + recursive: boolean, + onCreated: (path: string) => void, + onModified: (path: string, modifiedBySelf: boolean) => void, + onDeleted: (path: string) => void, + onIndexedDBFailure: () => void, +): Promise { + try { + await simpleCreateDirectoryIfMissing(SanityCheckFolder) + const fileExists = await exists(target) + if (fileExists) { + // This has the limitation that calling `watch` on a path will replace any existing subscriber + const startWatchingPath = (path: string) => + watchPath(path, { + recursive: recursive, + onCreated: onCreated, + onModified: onModified, + onDeleted: onDeleted, + }) + + const targets = await targetsForOperation(target, recursive) + targets.forEach(startWatchingPath) + + if (watchTimeout == null) { + async function pollThenFireAgain(): Promise { + try { + await polledWatch() + } catch (e) { + if (isFSUnavailableError(e)) { + onIndexedDBFailure() + } else { + throw e + } + } + + watchTimeout = setTimeout(pollThenFireAgain, POLLING_TIMEOUT) as any + } + + watchTimeout = setTimeout(pollThenFireAgain, POLLING_TIMEOUT) as any + } + } + } catch (e) { + if (isFSUnavailableError(e)) { + onIndexedDBFailure() + } else { + throw e + } + } +} + +export async function stopWatching(target: string, recursive: boolean) { + const stopWatchingPath = (path: string) => { + watchedPaths.delete(path) + } + + const targets = await targetsForOperation(target, recursive) + targets.forEach(stopWatchingPath) +} + +export function stopWatchingAll() { + if (watchTimeout != null) { + clearTimeout(watchTimeout) + watchTimeout = null + } + watchedPaths = new Map() + lastModifiedTSs = new Map() +} + +export function defer(): Promise & { + resolve: (value?: T) => void + reject: (reason?: any) => void +} { + var res, rej + + var promise = new Promise((resolve, reject) => { + res = resolve + rej = reject + }) + Object.defineProperty(promise, 'resolve', { value: res }) + Object.defineProperty(promise, 'reject', { value: rej }) + + return promise as any +} diff --git a/utopia-vscode-common/src/index.ts b/utopia-vscode-common/src/index.ts index 67e5855a1424..8d78399fe43e 100644 --- a/utopia-vscode-common/src/index.ts +++ b/utopia-vscode-common/src/index.ts @@ -1,7 +1,11 @@ export * from './path-utils' +export * from './mailbox' +export * from './messages' +export * from './fs/fs-types' +export * from './fs/fs-utils' export * from './prettier-utils' -export * from './messages-to-utopia' -export * from './messages-to-vscode' export * from './utopia-vscode-config' +export * from './vscode-communication' +export * from './window-messages' export const ProjectIDPlaceholderPrefix = 'PLACEHOLDER_DURING_LOADING' diff --git a/utopia-vscode-common/src/lite-either.ts b/utopia-vscode-common/src/lite-either.ts new file mode 100644 index 000000000000..3feb989cf214 --- /dev/null +++ b/utopia-vscode-common/src/lite-either.ts @@ -0,0 +1,65 @@ +// TODO Move either.ts here? + +// Often treated as the "failure" case. +export interface Left { + type: 'LEFT' + value: L +} + +// Often treated as the "success" case. +export interface Right { + type: 'RIGHT' + value: R +} + +// Usually treated as having bias to the right. +export type Either = Left | Right + +export function left(value: L): Either { + return { + type: 'LEFT', + value: value, + } +} + +export function right(value: R): Either { + return { + type: 'RIGHT', + value: value, + } +} + +// http://hackage.haskell.org/package/base-4.12.0.0/docs/Data-Either.html#v:isLeft +export function isLeft(either: Either): either is Left { + return either.type === 'LEFT' +} + +// http://hackage.haskell.org/package/base-4.12.0.0/docs/Data-Either.html#v:isRight +export function isRight(either: Either): either is Right { + return either.type === 'RIGHT' +} + +// http://hackage.haskell.org/package/base-4.12.0.0/docs/Data-Either.html#v:either +export function foldEither( + foldLeft: (l: L) => X, + foldRight: (r: R) => X, + either: Either, +): X { + if (isLeft(either)) { + return foldLeft(either.value) + } else { + return foldRight(either.value) + } +} + +// http://hackage.haskell.org/package/base-4.12.0.0/docs/Data-Functor.html#v:fmap +export function mapEither( + transform: (r: R1) => R2, + either: Either, +): Either { + if (isLeft(either)) { + return either + } else { + return right(transform(either.value)) + } +} diff --git a/utopia-vscode-common/src/mailbox.ts b/utopia-vscode-common/src/mailbox.ts new file mode 100644 index 000000000000..79bd92ab4c8f --- /dev/null +++ b/utopia-vscode-common/src/mailbox.ts @@ -0,0 +1,253 @@ +import { + childPaths, + deletePath, + ensureDirectoryExists, + exists, + readDirectory, + readFileSavedContentAsUTF8, + writeFileSavedContentAsUTF8, +} from './fs/fs-utils' +import type { FromVSCodeMessage, ToVSCodeMessage } from './messages' +import { appendToPath } from './path-utils' + +type Mailbox = 'VSCODE_MAILBOX' | 'UTOPIA_MAILBOX' +export const VSCodeInbox: Mailbox = 'VSCODE_MAILBOX' +export const UtopiaInbox: Mailbox = 'UTOPIA_MAILBOX' + +let inbox: Mailbox +let outbox: Mailbox +let onMessageCallback: (message: any) => void +let lastSentMessage: number = 0 +let lastConsumedMessage: number = -1 +let mailboxLastClearedTimestamp: number = Date.now() +let queuedMessages: Array = [] +const MIN_POLLING_TIMEOUT = 8 +const MAX_POLLING_TIMEOUT = MIN_POLLING_TIMEOUT * Math.pow(2, 4) // Max out at 128ms +let POLLING_TIMEOUT = MIN_POLLING_TIMEOUT +let pollTimeout: any | null = null + +let reducePollingAttemptsCount = 0 + +function reducePollingFrequency() { + if (POLLING_TIMEOUT < MAX_POLLING_TIMEOUT) { + reducePollingAttemptsCount++ + if (reducePollingAttemptsCount >= 5) { + reducePollingAttemptsCount = 0 + POLLING_TIMEOUT = POLLING_TIMEOUT * 2 + } + } +} + +function resetPollingFrequency() { + reducePollingAttemptsCount = 0 + POLLING_TIMEOUT = MIN_POLLING_TIMEOUT +} + +function lastConsumedMessageKey(mailbox: Mailbox): string { + return `/${mailbox}_LAST_CONSUMED` +} + +function mailboxClearedAtTimestampKey(mailbox: Mailbox): string { + return `/${mailbox}_CLEARED` +} + +function pathForMailbox(mailbox: Mailbox): string { + return `/${mailbox}` +} + +function pathForMessage(messageName: string, mailbox: Mailbox): string { + return appendToPath(pathForMailbox(mailbox), messageName) +} + +const pathForInboxMessage = (messageName: string) => pathForMessage(messageName, inbox) +const pathForOutboxMessage = (messageName: string) => pathForMessage(messageName, outbox) + +function generateMessageName(): string { + return `${lastSentMessage++}` +} + +export async function sendMessage(message: ToVSCodeMessage | FromVSCodeMessage): Promise { + resetPollingFrequency() + + if (outbox == null) { + queuedMessages.push(message) + } else { + return sendNamedMessage(generateMessageName(), JSON.stringify(message)) + } +} + +async function sendNamedMessage(messageName: string, content: string): Promise { + return writeFileSavedContentAsUTF8(pathForOutboxMessage(messageName), content) +} + +function maxMessageNumber(messageNames: Array, minValue: number = 0): number { + return Math.max(minValue, ...messageNames.map((messageName) => Number.parseInt(messageName))) +} + +async function initOutbox(outboxToUse: Mailbox): Promise { + await ensureMailboxExists(outboxToUse) + const previouslySentMessages = await readDirectory(pathForMailbox(outboxToUse)) + lastSentMessage = maxMessageNumber(previouslySentMessages) + + outbox = outboxToUse + if (queuedMessages.length > 0) { + queuedMessages.forEach(sendMessage) + queuedMessages = [] + } +} + +async function receiveMessage( + messageName: string, + parseMessage: (msg: string) => T, +): Promise { + const messagePath = pathForInboxMessage(messageName) + const content = await readFileSavedContentAsUTF8(messagePath) + return parseMessage(content) +} + +async function waitForPathToExist(path: string, maxWaitTime: number = 5000): Promise { + if (maxWaitTime >= 0) { + const doesItExist: boolean = await exists(path) + if (!doesItExist) { + return waitForPathToExist(path, maxWaitTime - 100) + } else { + return Promise.resolve() + } + } else { + return Promise.reject(`Waited too long for ${path} to exist.`) + } +} + +async function checkAndResetIfMailboxCleared(mailbox: Mailbox): Promise { + const mailboxClearedAtTimestamp = await getMailboxClearedAtTimestamp(mailbox) + if (mailboxClearedAtTimestamp > mailboxLastClearedTimestamp) { + // The mailbox was cleared since we last polled it, meaning our last consumed message + // count is now invalid, and we need to start consuming messages from the beginning again. + lastConsumedMessage = -1 + mailboxLastClearedTimestamp = mailboxClearedAtTimestamp + } +} + +async function pollInbox(parseMessage: (msg: string) => T): Promise { + await checkAndResetIfMailboxCleared(inbox) + + const mailboxPath = pathForMailbox(inbox) + waitForPathToExist(mailboxPath) + const allMessages = await readDirectory(mailboxPath) + + // Filter messages to only those that haven't been processed yet. We do this rather than deleting processed + // messages so that multiple instances in different browser tabs won't drive over eachother. + const messagesToProcess = allMessages.filter( + (messageName) => Number.parseInt(messageName) > lastConsumedMessage, + ) + if (messagesToProcess.length > 0) { + try { + const messages = await Promise.all( + messagesToProcess.map((m) => receiveMessage(m, parseMessage)), + ) + lastConsumedMessage = maxMessageNumber(messagesToProcess, lastConsumedMessage) + await updateLastConsumedMessageFile(inbox, lastConsumedMessage) + messages.forEach(onMessageCallback) + } catch (e) { + // It's possible that the mailbox was cleared whilst something was trying to read the messages. + // If that happens, we bail out of this poll, and the call `checkAndResetIfMailboxCleared` will + // correct things on the next poll + } + resetPollingFrequency() + } else { + reducePollingFrequency() + } + pollTimeout = setTimeout(() => pollInbox(parseMessage), POLLING_TIMEOUT) +} + +async function initInbox( + inboxToUse: Mailbox, + parseMessage: (msg: string) => T, + onMessage: (message: T) => void, +): Promise { + inbox = inboxToUse + await ensureMailboxExists(inboxToUse) + mailboxLastClearedTimestamp = await getMailboxClearedAtTimestamp(inbox) + lastConsumedMessage = await getLastConsumedMessageNumber(inbox) + onMessageCallback = onMessage + pollInbox(parseMessage) +} + +async function ensureMailboxExists(mailbox: Mailbox): Promise { + await ensureDirectoryExists(pathForMailbox(mailbox)) +} + +async function clearMailbox(mailbox: Mailbox): Promise { + const messagePaths = await childPaths(pathForMailbox(mailbox)) + await Promise.all(messagePaths.map((messagePath) => deletePath(messagePath, false))) +} + +async function clearLastConsumedMessageFile(mailbox: Mailbox): Promise { + await deletePath(lastConsumedMessageKey(mailbox), false) +} + +async function updateLastConsumedMessageFile(mailbox: Mailbox, value: number): Promise { + await writeFileSavedContentAsUTF8(lastConsumedMessageKey(mailbox), `${value}`) +} + +async function getLastConsumedMessageNumber(mailbox: Mailbox): Promise { + const lastConsumedMessageValueExists = await exists(lastConsumedMessageKey(mailbox)) + if (lastConsumedMessageValueExists) { + try { + const lastConsumedMessageName = await readFileSavedContentAsUTF8( + lastConsumedMessageKey(mailbox), + ) + return Number.parseInt(lastConsumedMessageName) + } catch (e) { + // This can be cleared by the VSCode Bridge in between the above line and now, in which case we want to consume all messages from the start + return -1 + } + } else { + return -1 + } +} + +async function updateMailboxClearedAtTimestamp(mailbox: Mailbox, timestamp: number): Promise { + await writeFileSavedContentAsUTF8(mailboxClearedAtTimestampKey(mailbox), `${timestamp}`) +} + +async function getMailboxClearedAtTimestamp(mailbox: Mailbox): Promise { + const mailboxClearedAtTimestampExists = await exists(mailboxClearedAtTimestampKey(mailbox)) + if (mailboxClearedAtTimestampExists) { + const mailboxClearedAtTimestamp = await readFileSavedContentAsUTF8( + mailboxClearedAtTimestampKey(mailbox), + ) + return Number.parseInt(mailboxClearedAtTimestamp) + } else { + return -1 + } +} + +export async function clearBothMailboxes(): Promise { + await ensureMailboxExists(UtopiaInbox) + await clearMailbox(UtopiaInbox) + await ensureMailboxExists(VSCodeInbox) + await clearMailbox(VSCodeInbox) + await clearLastConsumedMessageFile(UtopiaInbox) + await clearLastConsumedMessageFile(VSCodeInbox) + await updateMailboxClearedAtTimestamp(UtopiaInbox, Date.now()) + await updateMailboxClearedAtTimestamp(VSCodeInbox, Date.now()) +} + +export function stopPollingMailbox(): void { + if (pollTimeout != null) { + clearTimeout(pollTimeout) + pollTimeout = null + lastConsumedMessage = -1 + lastSentMessage = 0 + } +} + +export async function initMailbox( + inboxToUse: Mailbox, + parseMessage: (msg: string) => T, + onMessage: (message: T) => void, +): Promise { + await initOutbox(inboxToUse === VSCodeInbox ? UtopiaInbox : VSCodeInbox) + await initInbox(inboxToUse, parseMessage, onMessage) +} diff --git a/utopia-vscode-common/src/messages-to-utopia.ts b/utopia-vscode-common/src/messages-to-utopia.ts deleted file mode 100644 index b63d6e5c2109..000000000000 --- a/utopia-vscode-common/src/messages-to-utopia.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { UtopiaVSCodeConfig } from './utopia-vscode-config' - -export interface MessageListenersReady { - type: 'MESSAGE_LISTENERS_READY' -} - -export function messageListenersReady(): MessageListenersReady { - return { - type: 'MESSAGE_LISTENERS_READY', - } -} - -export function isMessageListenersReady( - messageData: unknown, -): messageData is MessageListenersReady { - return ( - typeof messageData === 'object' && (messageData as any)?.['type'] === 'MESSAGE_LISTENERS_READY' - ) -} - -interface StoredFile { - content: string - unsavedContent: string | null -} - -export interface VSCodeFileChange { - type: 'VSCODE_FILE_CHANGE' - filePath: string - fileContent: StoredFile -} - -export function vsCodeFileChange(filePath: string, fileContent: StoredFile): VSCodeFileChange { - return { - type: 'VSCODE_FILE_CHANGE', - filePath: filePath, - fileContent: fileContent, - } -} - -export function isVSCodeFileChange(messageData: unknown): messageData is VSCodeFileChange { - return typeof messageData === 'object' && (messageData as any)?.['type'] === 'VSCODE_FILE_CHANGE' -} - -export interface VSCodeFileDelete { - type: 'VSCODE_FILE_DELETE' - filePath: string -} - -export function vsCodeFileDelete(filePath: string): VSCodeFileDelete { - return { - type: 'VSCODE_FILE_DELETE', - filePath: filePath, - } -} - -export function isVSCodeFileDelete(messageData: unknown): messageData is VSCodeFileDelete { - return typeof messageData === 'object' && (messageData as any)?.['type'] === 'VSCODE_FILE_DELETE' -} - -export interface VSCodeBridgeReady { - type: 'VSCODE_BRIDGE_READY' -} - -export function vsCodeBridgeReady(): VSCodeBridgeReady { - return { - type: 'VSCODE_BRIDGE_READY', - } -} - -export function isVSCodeBridgeReady(messageData: unknown): messageData is VSCodeBridgeReady { - return typeof messageData === 'object' && (messageData as any)?.['type'] === 'VSCODE_BRIDGE_READY' -} - -export interface EditorCursorPositionChanged { - type: 'EDITOR_CURSOR_POSITION_CHANGED' - filePath: string - line: number - column: number -} - -export function editorCursorPositionChanged( - filePath: string, - line: number, - column: number, -): EditorCursorPositionChanged { - return { - type: 'EDITOR_CURSOR_POSITION_CHANGED', - filePath: filePath, - line: line, - column: column, - } -} - -export function isEditorCursorPositionChanged( - messageData: unknown, -): messageData is EditorCursorPositionChanged { - return ( - typeof messageData === 'object' && - (messageData as any)?.['type'] === 'EDITOR_CURSOR_POSITION_CHANGED' - ) -} - -export interface UtopiaVSCodeConfigValues { - type: 'UTOPIA_VSCODE_CONFIG_VALUES' - config: UtopiaVSCodeConfig -} - -export function utopiaVSCodeConfigValues(config: UtopiaVSCodeConfig): UtopiaVSCodeConfigValues { - return { - type: 'UTOPIA_VSCODE_CONFIG_VALUES', - config: config, - } -} - -export function isUtopiaVSCodeConfigValues( - messageData: unknown, -): messageData is UtopiaVSCodeConfigValues { - return ( - typeof messageData === 'object' && - (messageData as any)?.['type'] === 'UTOPIA_VSCODE_CONFIG_VALUES' - ) -} - -export interface VSCodeReady { - type: 'VSCODE_READY' -} - -export function vsCodeReady(): VSCodeReady { - return { - type: 'VSCODE_READY', - } -} - -export function isVSCodeReady(messageData: unknown): messageData is VSCodeReady { - return typeof messageData === 'object' && (messageData as any)?.['type'] === 'VSCODE_READY' -} - -export interface ClearLoadingScreen { - type: 'CLEAR_LOADING_SCREEN' -} - -export function clearLoadingScreen(): ClearLoadingScreen { - return { - type: 'CLEAR_LOADING_SCREEN', - } -} - -export function isClearLoadingScreen(messageData: unknown): messageData is ClearLoadingScreen { - return ( - typeof messageData === 'object' && (messageData as any)?.['type'] === 'CLEAR_LOADING_SCREEN' - ) -} - -export type FromVSCodeToUtopiaMessage = - | MessageListenersReady - | VSCodeFileChange - | VSCodeFileDelete - | VSCodeBridgeReady - | EditorCursorPositionChanged - | UtopiaVSCodeConfigValues - | VSCodeReady - | ClearLoadingScreen diff --git a/utopia-vscode-common/src/messages-to-vscode.ts b/utopia-vscode-common/src/messages-to-vscode.ts deleted file mode 100644 index 2b31ee0b8a22..000000000000 --- a/utopia-vscode-common/src/messages-to-vscode.ts +++ /dev/null @@ -1,328 +0,0 @@ -import type { UtopiaVSCodeConfig } from './utopia-vscode-config' - -export interface ProjectDirectory { - type: 'PROJECT_DIRECTORY' - filePath: string -} - -export function projectDirectory(filePath: string): ProjectDirectory { - return { - type: 'PROJECT_DIRECTORY', - filePath: filePath, - } -} - -export interface ProjectTextFile { - type: 'PROJECT_TEXT_FILE' - filePath: string - savedContent: string - unsavedContent: string | null -} - -export function projectTextFile( - filePath: string, - savedContent: string, - unsavedContent: string | null, -): ProjectTextFile { - return { - type: 'PROJECT_TEXT_FILE', - filePath: filePath, - savedContent: savedContent, - unsavedContent: unsavedContent, - } -} - -export type ProjectFile = ProjectDirectory | ProjectTextFile - -export interface InitProject { - type: 'INIT_PROJECT' - projectContents: Array - openFilePath: string | null -} - -export function initProject( - projectContents: Array, - openFilePath: string | null, -): InitProject { - return { - type: 'INIT_PROJECT', - projectContents: projectContents, - openFilePath: openFilePath, - } -} - -export function isInitProject(messageData: unknown): messageData is InitProject { - return typeof messageData === 'object' && (messageData as any)?.['type'] === 'INIT_PROJECT' -} - -export interface WriteProjectFileChange { - type: 'WRITE_PROJECT_FILE' - projectFile: ProjectFile -} - -export function writeProjectFileChange(projectFile: ProjectFile): WriteProjectFileChange { - return { - type: 'WRITE_PROJECT_FILE', - projectFile: projectFile, - } -} - -export function isWriteProjectFileChange( - messageData: unknown, -): messageData is WriteProjectFileChange { - return typeof messageData === 'object' && (messageData as any)?.['type'] === 'WRITE_PROJECT_FILE' -} - -export interface DeletePathChange { - type: 'DELETE_PATH' - fullPath: string - recursive: boolean -} - -export function deletePathChange(fullPath: string, recursive: boolean): DeletePathChange { - return { - type: 'DELETE_PATH', - fullPath: fullPath, - recursive: recursive, - } -} - -export function isDeletePathChange(messageData: unknown): messageData is DeletePathChange { - return typeof messageData === 'object' && (messageData as any)?.['type'] === 'DELETE_PATH' -} - -export interface EnsureDirectoryExistsChange { - type: 'ENSURE_DIRECTORY_EXISTS' - fullPath: string -} - -export function ensureDirectoryExistsChange(fullPath: string): EnsureDirectoryExistsChange { - return { - type: 'ENSURE_DIRECTORY_EXISTS', - fullPath: fullPath, - } -} - -export function isEnsureDirectoryExistsChange( - messageData: unknown, -): messageData is EnsureDirectoryExistsChange { - return ( - typeof messageData === 'object' && (messageData as any)?.['type'] === 'ENSURE_DIRECTORY_EXISTS' - ) -} - -export interface OpenFileMessage { - type: 'OPEN_FILE' - filePath: string - bounds: Bounds | null -} - -export function openFileMessage(filePath: string, bounds: Bounds | null): OpenFileMessage { - return { - type: 'OPEN_FILE', - filePath: filePath, - bounds: bounds, - } -} - -export function isOpenFileMessage(messageData: unknown): messageData is OpenFileMessage { - return typeof messageData === 'object' && (messageData as any)?.['type'] === 'OPEN_FILE' -} - -export type DecorationRangeType = 'selection' | 'highlight' - -export interface Bounds { - startLine: number - startCol: number - endLine: number - endCol: number -} - -export interface BoundsInFile extends Bounds { - filePath: string -} - -export function boundsInFile( - filePath: string, - startLine: number, - startCol: number, - endLine: number, - endCol: number, -): BoundsInFile { - return { - filePath: filePath, - startLine: startLine, - startCol: startCol, - endLine: endLine, - endCol: endCol, - } -} - -export interface DecorationRange extends BoundsInFile { - rangeType: DecorationRangeType -} - -export function decorationRange( - rangeType: DecorationRangeType, - filePath: string, - startLine: number, - startCol: number, - endLine: number, - endCol: number, -): DecorationRange { - return { - rangeType: rangeType, - filePath: filePath, - startLine: startLine, - startCol: startCol, - endLine: endLine, - endCol: endCol, - } -} - -export interface UpdateDecorationsMessage { - type: 'UPDATE_DECORATIONS' - decorations: Array -} - -export function updateDecorationsMessage( - decorations: Array, -): UpdateDecorationsMessage { - return { - type: 'UPDATE_DECORATIONS', - decorations: decorations, - } -} - -export function isUpdateDecorationsMessage( - messageData: unknown, -): messageData is UpdateDecorationsMessage { - return typeof messageData === 'object' && (messageData as any)?.['type'] === 'UPDATE_DECORATIONS' -} - -export type ForceNavigation = 'do-not-force-navigation' | 'force-navigation' - -export interface SelectedElementChanged { - type: 'SELECTED_ELEMENT_CHANGED' - boundsInFile: BoundsInFile - forceNavigation: ForceNavigation -} - -export function selectedElementChanged( - bounds: BoundsInFile, - forceNavigation: ForceNavigation, -): SelectedElementChanged { - return { - type: 'SELECTED_ELEMENT_CHANGED', - boundsInFile: bounds, - forceNavigation: forceNavigation, - } -} - -export function isSelectedElementChanged( - messageData: unknown, -): messageData is SelectedElementChanged { - return ( - typeof messageData === 'object' && (messageData as any)?.['type'] === 'SELECTED_ELEMENT_CHANGED' - ) -} - -export interface GetUtopiaVSCodeConfig { - type: 'GET_UTOPIA_VSCODE_CONFIG' -} - -export function getUtopiaVSCodeConfig(): GetUtopiaVSCodeConfig { - return { - type: 'GET_UTOPIA_VSCODE_CONFIG', - } -} - -export function isGetUtopiaVSCodeConfig( - messageData: unknown, -): messageData is GetUtopiaVSCodeConfig { - return ( - typeof messageData === 'object' && (messageData as any)?.['type'] === 'GET_UTOPIA_VSCODE_CONFIG' - ) -} - -export interface SetFollowSelectionConfig { - type: 'SET_FOLLOW_SELECTION_CONFIG' - enabled: boolean -} - -export function setFollowSelectionConfig(enabled: boolean): SetFollowSelectionConfig { - return { - type: 'SET_FOLLOW_SELECTION_CONFIG', - enabled: enabled, - } -} - -export function isSetFollowSelectionConfig( - messageData: unknown, -): messageData is SetFollowSelectionConfig { - return ( - typeof messageData === 'object' && - (messageData as any)?.['type'] === 'SET_FOLLOW_SELECTION_CONFIG' - ) -} - -export interface SetVSCodeTheme { - type: 'SET_VSCODE_THEME' - theme: string -} - -export function setVSCodeTheme(theme: string): SetVSCodeTheme { - return { - type: 'SET_VSCODE_THEME', - theme: theme, - } -} - -export function isSetVSCodeTheme(messageData: unknown): messageData is SetVSCodeTheme { - return typeof messageData === 'object' && (messageData as any)?.['type'] === 'SET_VSCODE_THEME' -} - -export interface UtopiaReady { - type: 'UTOPIA_READY' -} - -export function utopiaReady(): UtopiaReady { - return { - type: 'UTOPIA_READY', - } -} - -export function isUtopiaReady(messageData: unknown): messageData is UtopiaReady { - return typeof messageData === 'object' && (messageData as any)?.['type'] === 'UTOPIA_READY' -} - -export function isFromUtopiaToVSCodeMessage( - messageData: unknown, -): messageData is FromUtopiaToVSCodeMessage { - return ( - isInitProject(messageData) || - isWriteProjectFileChange(messageData) || - isDeletePathChange(messageData) || - isEnsureDirectoryExistsChange(messageData) || - isOpenFileMessage(messageData) || - isUpdateDecorationsMessage(messageData) || - isSelectedElementChanged(messageData) || - isGetUtopiaVSCodeConfig(messageData) || - isSetFollowSelectionConfig(messageData) || - isSetVSCodeTheme(messageData) || - isUtopiaReady(messageData) - ) -} - -export type FromUtopiaToVSCodeMessage = - | InitProject - | WriteProjectFileChange - | DeletePathChange - | EnsureDirectoryExistsChange - | OpenFileMessage - | UpdateDecorationsMessage - | SelectedElementChanged - | GetUtopiaVSCodeConfig - | SetFollowSelectionConfig - | SetVSCodeTheme - | UtopiaReady diff --git a/utopia-vscode-common/src/messages.ts b/utopia-vscode-common/src/messages.ts new file mode 100644 index 000000000000..7d5528c07287 --- /dev/null +++ b/utopia-vscode-common/src/messages.ts @@ -0,0 +1,360 @@ +import type { UtopiaVSCodeConfig } from './utopia-vscode-config' + +export interface OpenFileMessage { + type: 'OPEN_FILE' + filePath: string + bounds: Bounds | null +} + +export function openFileMessage(filePath: string, bounds: Bounds | null): OpenFileMessage { + return { + type: 'OPEN_FILE', + filePath: filePath, + bounds: bounds, + } +} + +export type DecorationRangeType = 'selection' | 'highlight' + +export interface Bounds { + startLine: number + startCol: number + endLine: number + endCol: number +} + +export interface BoundsInFile extends Bounds { + filePath: string +} + +export function boundsInFile( + filePath: string, + startLine: number, + startCol: number, + endLine: number, + endCol: number, +): BoundsInFile { + return { + filePath: filePath, + startLine: startLine, + startCol: startCol, + endLine: endLine, + endCol: endCol, + } +} + +export interface DecorationRange extends BoundsInFile { + rangeType: DecorationRangeType +} + +export function decorationRange( + rangeType: DecorationRangeType, + filePath: string, + startLine: number, + startCol: number, + endLine: number, + endCol: number, +): DecorationRange { + return { + rangeType: rangeType, + filePath: filePath, + startLine: startLine, + startCol: startCol, + endLine: endLine, + endCol: endCol, + } +} + +export interface UpdateDecorationsMessage { + type: 'UPDATE_DECORATIONS' + decorations: Array +} + +export function updateDecorationsMessage( + decorations: Array, +): UpdateDecorationsMessage { + return { + type: 'UPDATE_DECORATIONS', + decorations: decorations, + } +} + +export type ForceNavigation = 'do-not-force-navigation' | 'force-navigation' + +export interface SelectedElementChanged { + type: 'SELECTED_ELEMENT_CHANGED' + boundsInFile: BoundsInFile + forceNavigation: ForceNavigation +} + +export function selectedElementChanged( + bounds: BoundsInFile, + forceNavigation: ForceNavigation, +): SelectedElementChanged { + return { + type: 'SELECTED_ELEMENT_CHANGED', + boundsInFile: bounds, + forceNavigation: forceNavigation, + } +} + +export interface GetUtopiaVSCodeConfig { + type: 'GET_UTOPIA_VSCODE_CONFIG' +} + +export function getUtopiaVSCodeConfig(): GetUtopiaVSCodeConfig { + return { + type: 'GET_UTOPIA_VSCODE_CONFIG', + } +} + +export interface SetFollowSelectionConfig { + type: 'SET_FOLLOW_SELECTION_CONFIG' + enabled: boolean +} + +export function setFollowSelectionConfig(enabled: boolean): SetFollowSelectionConfig { + return { + type: 'SET_FOLLOW_SELECTION_CONFIG', + enabled: enabled, + } +} + +export interface SetVSCodeTheme { + type: 'SET_VSCODE_THEME' + theme: string +} + +export function setVSCodeTheme(theme: string): SetVSCodeTheme { + return { + type: 'SET_VSCODE_THEME', + theme: theme, + } +} + +export interface UtopiaReady { + type: 'UTOPIA_READY' +} + +export function utopiaReady(): UtopiaReady { + return { + type: 'UTOPIA_READY', + } +} + +export type ToVSCodeMessageNoAccumulated = + | OpenFileMessage + | UpdateDecorationsMessage + | SelectedElementChanged + | GetUtopiaVSCodeConfig + | SetFollowSelectionConfig + | SetVSCodeTheme + | UtopiaReady + +export interface AccumulatedToVSCodeMessage { + type: 'ACCUMULATED_TO_VSCODE_MESSAGE' + messages: Array +} + +export function accumulatedToVSCodeMessage( + messages: Array, +): AccumulatedToVSCodeMessage { + return { + type: 'ACCUMULATED_TO_VSCODE_MESSAGE', + messages: messages, + } +} + +export type ToVSCodeMessage = ToVSCodeMessageNoAccumulated | AccumulatedToVSCodeMessage + +export function isOpenFileMessage(message: unknown): message is OpenFileMessage { + return ( + typeof message === 'object' && + !Array.isArray(message) && + (message as OpenFileMessage).type === 'OPEN_FILE' + ) +} + +export function isUpdateDecorationsMessage(message: unknown): message is UpdateDecorationsMessage { + return ( + typeof message === 'object' && + !Array.isArray(message) && + (message as UpdateDecorationsMessage).type === 'UPDATE_DECORATIONS' + ) +} + +export function isSelectedElementChanged(message: unknown): message is SelectedElementChanged { + return ( + typeof message === 'object' && + !Array.isArray(message) && + (message as SelectedElementChanged).type === 'SELECTED_ELEMENT_CHANGED' + ) +} + +export function isGetUtopiaVSCodeConfig(message: unknown): message is GetUtopiaVSCodeConfig { + return ( + typeof message === 'object' && + !Array.isArray(message) && + (message as GetUtopiaVSCodeConfig).type === 'GET_UTOPIA_VSCODE_CONFIG' + ) +} + +export function isSetFollowSelectionConfig(message: unknown): message is SetFollowSelectionConfig { + return ( + typeof message === 'object' && + !Array.isArray(message) && + (message as SetFollowSelectionConfig).type === 'SET_FOLLOW_SELECTION_CONFIG' + ) +} + +export function isSetVSCodeTheme(message: unknown): message is SetVSCodeTheme { + return ( + typeof message === 'object' && + !Array.isArray(message) && + (message as SetVSCodeTheme).type === 'SET_VSCODE_THEME' + ) +} + +export function isUtopiaReadyMessage(message: unknown): message is UtopiaReady { + return ( + typeof message === 'object' && + !Array.isArray(message) && + (message as UtopiaReady).type === 'UTOPIA_READY' + ) +} + +export function isAccumulatedToVSCodeMessage( + message: unknown, +): message is AccumulatedToVSCodeMessage { + return ( + typeof message === 'object' && + !Array.isArray(message) && + (message as AccumulatedToVSCodeMessage).type === 'ACCUMULATED_TO_VSCODE_MESSAGE' + ) +} + +export function parseToVSCodeMessage(unparsed: string): ToVSCodeMessage { + const message = JSON.parse(unparsed) + if ( + isOpenFileMessage(message) || + isUpdateDecorationsMessage(message) || + isSelectedElementChanged(message) || + isGetUtopiaVSCodeConfig(message) || + isSetFollowSelectionConfig(message) || + isSetVSCodeTheme(message) || + isUtopiaReadyMessage(message) || + isAccumulatedToVSCodeMessage(message) + ) { + return message + } else { + // FIXME This should return an Either + throw new Error(`Invalid message type ${JSON.stringify(message)}`) + } +} + +export interface EditorCursorPositionChanged { + type: 'EDITOR_CURSOR_POSITION_CHANGED' + filePath: string + line: number + column: number +} + +export function editorCursorPositionChanged( + filePath: string, + line: number, + column: number, +): EditorCursorPositionChanged { + return { + type: 'EDITOR_CURSOR_POSITION_CHANGED', + filePath: filePath, + line: line, + column: column, + } +} + +export interface UtopiaVSCodeConfigValues { + type: 'UTOPIA_VSCODE_CONFIG_VALUES' + config: UtopiaVSCodeConfig +} + +export function utopiaVSCodeConfigValues(config: UtopiaVSCodeConfig): UtopiaVSCodeConfigValues { + return { + type: 'UTOPIA_VSCODE_CONFIG_VALUES', + config: config, + } +} + +export interface VSCodeReady { + type: 'VSCODE_READY' +} + +export function vsCodeReady(): VSCodeReady { + return { + type: 'VSCODE_READY', + } +} + +export interface ClearLoadingScreen { + type: 'CLEAR_LOADING_SCREEN' +} + +export function clearLoadingScreen(): ClearLoadingScreen { + return { + type: 'CLEAR_LOADING_SCREEN', + } +} + +export type FromVSCodeMessage = + | EditorCursorPositionChanged + | UtopiaVSCodeConfigValues + | VSCodeReady + | ClearLoadingScreen + +export function isEditorCursorPositionChanged( + message: unknown, +): message is EditorCursorPositionChanged { + return ( + typeof message === 'object' && + !Array.isArray(message) && + (message as EditorCursorPositionChanged).type === 'EDITOR_CURSOR_POSITION_CHANGED' + ) +} + +export function isUtopiaVSCodeConfigValues(message: unknown): message is UtopiaVSCodeConfigValues { + return ( + typeof message === 'object' && + !Array.isArray(message) && + (message as UtopiaVSCodeConfigValues).type === 'UTOPIA_VSCODE_CONFIG_VALUES' + ) +} + +export function isVSCodeReady(message: unknown): message is VSCodeReady { + return ( + typeof message === 'object' && + !Array.isArray(message) && + (message as VSCodeReady).type === 'VSCODE_READY' + ) +} + +export function isClearLoadingScreen(message: unknown): message is ClearLoadingScreen { + return ( + typeof message === 'object' && + !Array.isArray(message) && + (message as ClearLoadingScreen).type === 'CLEAR_LOADING_SCREEN' + ) +} + +export function parseFromVSCodeMessage(unparsed: string): FromVSCodeMessage { + const message = JSON.parse(unparsed) + if ( + isEditorCursorPositionChanged(message) || + isUtopiaVSCodeConfigValues(message) || + isVSCodeReady(message) || + isClearLoadingScreen(message) + ) { + return message + } else { + // FIXME This should return an Either + throw new Error(`Invalid message type ${JSON.stringify(message)}`) + } +} diff --git a/utopia-vscode-common/src/path-utils.ts b/utopia-vscode-common/src/path-utils.ts index 9fee9c07cd83..9a9451dc42b3 100644 --- a/utopia-vscode-common/src/path-utils.ts +++ b/utopia-vscode-common/src/path-utils.ts @@ -1,3 +1,5 @@ +export const RootDir = `/utopia` + export function stripTrailingSlash(path: string): string { return path.endsWith('/') ? path.slice(0, -1) : path } @@ -12,8 +14,12 @@ export function appendToPath(path: string, elem: string): string { return `${left}/${right}` } +export function stripRootPrefix(path: string): string { + return path.startsWith(RootDir) ? path.slice(RootDir.length + 1) : path +} + export function toUtopiaPath(projectID: string, path: string): string { - const result = appendToPath(`${projectID}:/`, path) + const result = appendToPath(`${projectID}:/`, stripRootPrefix(path)) return result } diff --git a/utopia-vscode-common/src/vscode-communication.ts b/utopia-vscode-common/src/vscode-communication.ts new file mode 100644 index 000000000000..d836a93b90f6 --- /dev/null +++ b/utopia-vscode-common/src/vscode-communication.ts @@ -0,0 +1,191 @@ +// This file exists so that the extension can communicate with the Utopia editor + +import type { FSUser } from './fs/fs-types' +import { + deletePath, + ensureDirectoryExists, + initializeFS, + readFileAsUTF8, + stat, + stopWatchingAll, + watch, + writeFileAsUTF8, +} from './fs/fs-utils' +import { + clearBothMailboxes, + initMailbox, + sendMessage, + stopPollingMailbox, + UtopiaInbox, +} from './mailbox' +import type { FromVSCodeMessage } from './messages' +import { + clearLoadingScreen, + getUtopiaVSCodeConfig, + openFileMessage, + parseFromVSCodeMessage, + utopiaReady, +} from './messages' +import { appendToPath } from './path-utils' +import type { ProjectFile } from './window-messages' +import { + fromVSCodeExtensionMessage, + indexedDBFailure, + isDeletePathChange, + isEnsureDirectoryExistsChange, + isInitProject, + isToVSCodeExtensionMessage, + isWriteProjectFileChange, + messageListenersReady, + vsCodeBridgeReady, + vsCodeFileChange, + vsCodeFileDelete, +} from './window-messages' + +const Scheme = 'utopia' +const RootDir = `/${Scheme}` +const UtopiaFSUser: FSUser = 'UTOPIA' + +function toFSPath(projectPath: string): string { + const fsPath = appendToPath(RootDir, projectPath) + return fsPath +} + +function fromFSPath(fsPath: string): string { + const prefix = RootDir + const prefixIndex = fsPath.indexOf(prefix) + if (prefixIndex === 0) { + const projectPath = fsPath.slice(prefix.length) + return projectPath + } else { + throw new Error(`Invalid FS path: ${fsPath}`) + } +} + +async function writeProjectFile(projectFile: ProjectFile): Promise { + switch (projectFile.type) { + case 'PROJECT_DIRECTORY': { + const { filePath: projectPath } = projectFile + return ensureDirectoryExists(toFSPath(projectPath)) + } + case 'PROJECT_TEXT_FILE': { + const { filePath: projectPath, savedContent, unsavedContent } = projectFile + const filePath = toFSPath(projectPath) + const alreadyExistingFile = await readFileAsUTF8(filePath).catch((_) => null) + const fileDiffers = + alreadyExistingFile == null || + alreadyExistingFile.content !== savedContent || + alreadyExistingFile.unsavedContent !== unsavedContent + if (fileDiffers) { + // Avoid pushing a file to the file system if the content hasn't changed. + return writeFileAsUTF8(filePath, savedContent, unsavedContent) + } + return + } + default: + const _exhaustiveCheck: never = projectFile + throw new Error(`Invalid file projectFile type ${projectFile}`) + } +} + +function watchForChanges(): void { + function onCreated(fsPath: string): void { + void stat(fsPath).then((fsStat) => { + if (fsStat.type === 'FILE' && fsStat.sourceOfLastChange !== UtopiaFSUser) { + void readFileAsUTF8(fsPath).then((fileContent) => { + const filePath = fromFSPath(fsPath) + window.top?.postMessage(vsCodeFileChange(filePath, fileContent), '*') + }) + } + }) + } + function onModified(fsPath: string, modifiedBySelf: boolean): void { + if (!modifiedBySelf) { + onCreated(fsPath) + } + } + function onDeleted(fsPath: string): void { + const filePath = fromFSPath(fsPath) + window.top?.postMessage(vsCodeFileDelete(filePath), '*') + } + function onIndexedDBFailure(): void { + window.top?.postMessage(indexedDBFailure(), '*') + } + + void watch(toFSPath('/'), true, onCreated, onModified, onDeleted, onIndexedDBFailure) +} + +let currentInit: Promise = Promise.resolve() + +async function initIndexedDBBridge( + vsCodeSessionID: string, + projectContents: Array, + openFilePath: string | null, +): Promise { + async function innerInit(): Promise { + stopWatchingAll() + stopPollingMailbox() + await initializeFS(vsCodeSessionID, UtopiaFSUser) + await ensureDirectoryExists(RootDir) + await clearBothMailboxes() + for (const projectFile of projectContents) { + await writeProjectFile(projectFile) + } + await initMailbox(UtopiaInbox, parseFromVSCodeMessage, (message: FromVSCodeMessage) => { + window.top?.postMessage(fromVSCodeExtensionMessage(message), '*') + }) + await sendMessage(utopiaReady()) + await sendMessage(getUtopiaVSCodeConfig()) + watchForChanges() + if (openFilePath != null) { + await sendMessage(openFileMessage(openFilePath, null)) + } else { + window.top?.postMessage(fromVSCodeExtensionMessage(clearLoadingScreen()), '*') + } + + window.top?.postMessage(vsCodeBridgeReady(), '*') + } + + // Prevent multiple initialisations from driving over each other. + currentInit = currentInit.then(innerInit) +} + +// Chain off of the previous one to ensure the ordering of changes is maintained. +let applyProjectChangesCoordinator: Promise = Promise.resolve() + +export function setupVSCodeEventListenersForProject(vsCodeSessionID: string) { + let intervalID: number | null = null + window.addEventListener('message', (messageEvent: MessageEvent) => { + const { data } = messageEvent + if (isInitProject(data)) { + if (intervalID != null) { + window.clearInterval(intervalID) + } + initIndexedDBBridge(vsCodeSessionID, data.projectContents, data.openFilePath) + } else if (isDeletePathChange(data)) { + applyProjectChangesCoordinator = applyProjectChangesCoordinator.then(async () => { + await deletePath(toFSPath(data.fullPath), data.recursive) + }) + } else if (isWriteProjectFileChange(data)) { + applyProjectChangesCoordinator = applyProjectChangesCoordinator.then(async () => { + await writeProjectFile(data.projectFile) + }) + } else if (isEnsureDirectoryExistsChange(data)) { + applyProjectChangesCoordinator = applyProjectChangesCoordinator.then(async () => { + await ensureDirectoryExists(toFSPath(data.fullPath)) + }) + } else if (isToVSCodeExtensionMessage(data)) { + applyProjectChangesCoordinator = applyProjectChangesCoordinator.then(async () => { + await sendMessage(data.message) + }) + } + }) + + intervalID = window.setInterval(() => { + try { + window.top?.postMessage(messageListenersReady(), '*') + } catch (error) { + console.error('Error posting messageListenersReady', error) + } + }, 500) +} diff --git a/utopia-vscode-common/src/window-messages.ts b/utopia-vscode-common/src/window-messages.ts new file mode 100644 index 000000000000..a23e0c657f21 --- /dev/null +++ b/utopia-vscode-common/src/window-messages.ts @@ -0,0 +1,252 @@ +import type { StoredFile } from './fs/fs-utils' +import type { FromVSCodeMessage, ToVSCodeMessage } from './messages' + +export interface ProjectDirectory { + type: 'PROJECT_DIRECTORY' + filePath: string +} + +export function projectDirectory(filePath: string): ProjectDirectory { + return { + type: 'PROJECT_DIRECTORY', + filePath: filePath, + } +} + +export interface ProjectTextFile { + type: 'PROJECT_TEXT_FILE' + filePath: string + savedContent: string + unsavedContent: string | null +} + +export function projectTextFile( + filePath: string, + savedContent: string, + unsavedContent: string | null, +): ProjectTextFile { + return { + type: 'PROJECT_TEXT_FILE', + filePath: filePath, + savedContent: savedContent, + unsavedContent: unsavedContent, + } +} + +export type ProjectFile = ProjectDirectory | ProjectTextFile + +// Message Types To VS Code +export interface InitProject { + type: 'INIT_PROJECT' + projectContents: Array + openFilePath: string | null +} + +export function initProject( + projectContents: Array, + openFilePath: string | null, +): InitProject { + return { + type: 'INIT_PROJECT', + projectContents: projectContents, + openFilePath: openFilePath, + } +} + +export function isInitProject(messageData: unknown): messageData is InitProject { + return typeof messageData === 'object' && (messageData as any)?.['type'] === 'INIT_PROJECT' +} + +export interface ToVSCodeExtensionMessage { + type: 'TO_VSCODE_EXTENSION_MESSAGE' + message: ToVSCodeMessage +} + +export function toVSCodeExtensionMessage(message: ToVSCodeMessage): ToVSCodeExtensionMessage { + return { + type: 'TO_VSCODE_EXTENSION_MESSAGE', + message: message, + } +} + +export function isToVSCodeExtensionMessage( + messageData: unknown, +): messageData is ToVSCodeExtensionMessage { + return ( + typeof messageData === 'object' && + (messageData as any)?.['type'] === 'TO_VSCODE_EXTENSION_MESSAGE' + ) +} + +export interface WriteProjectFileChange { + type: 'WRITE_PROJECT_FILE' + projectFile: ProjectFile +} + +export function writeProjectFileChange(projectFile: ProjectFile): WriteProjectFileChange { + return { + type: 'WRITE_PROJECT_FILE', + projectFile: projectFile, + } +} + +export function isWriteProjectFileChange( + messageData: unknown, +): messageData is WriteProjectFileChange { + return typeof messageData === 'object' && (messageData as any)?.['type'] === 'WRITE_PROJECT_FILE' +} + +export interface DeletePathChange { + type: 'DELETE_PATH' + fullPath: string + recursive: boolean +} + +export function deletePathChange(fullPath: string, recursive: boolean): DeletePathChange { + return { + type: 'DELETE_PATH', + fullPath: fullPath, + recursive: recursive, + } +} + +export function isDeletePathChange(messageData: unknown): messageData is DeletePathChange { + return typeof messageData === 'object' && (messageData as any)?.['type'] === 'DELETE_PATH' +} + +export interface EnsureDirectoryExistsChange { + type: 'ENSURE_DIRECTORY_EXISTS' + fullPath: string +} + +export function ensureDirectoryExistsChange(fullPath: string): EnsureDirectoryExistsChange { + return { + type: 'ENSURE_DIRECTORY_EXISTS', + fullPath: fullPath, + } +} + +export function isEnsureDirectoryExistsChange( + messageData: unknown, +): messageData is EnsureDirectoryExistsChange { + return ( + typeof messageData === 'object' && (messageData as any)?.['type'] === 'ENSURE_DIRECTORY_EXISTS' + ) +} + +export type FromUtopiaToVSCodeMessage = + | InitProject + | ToVSCodeExtensionMessage + | WriteProjectFileChange + | DeletePathChange + | EnsureDirectoryExistsChange + +// Message Types To Utopia +export interface MessageListenersReady { + type: 'MESSAGE_LISTENERS_READY' +} + +export function messageListenersReady(): MessageListenersReady { + return { + type: 'MESSAGE_LISTENERS_READY', + } +} + +export function isMessageListenersReady( + messageData: unknown, +): messageData is MessageListenersReady { + return ( + typeof messageData === 'object' && (messageData as any)?.['type'] === 'MESSAGE_LISTENERS_READY' + ) +} + +export interface FromVSCodeExtensionMessage { + type: 'FROM_VSCODE_EXTENSION_MESSAGE' + message: FromVSCodeMessage +} + +export function fromVSCodeExtensionMessage(message: FromVSCodeMessage): FromVSCodeExtensionMessage { + return { + type: 'FROM_VSCODE_EXTENSION_MESSAGE', + message: message, + } +} + +export function isFromVSCodeExtensionMessage( + messageData: unknown, +): messageData is FromVSCodeExtensionMessage { + return ( + typeof messageData === 'object' && + (messageData as any)?.['type'] === 'FROM_VSCODE_EXTENSION_MESSAGE' + ) +} + +export interface VSCodeFileChange { + type: 'VSCODE_FILE_CHANGE' + filePath: string + fileContent: StoredFile +} + +export function vsCodeFileChange(filePath: string, fileContent: StoredFile): VSCodeFileChange { + return { + type: 'VSCODE_FILE_CHANGE', + filePath: filePath, + fileContent: fileContent, + } +} + +export function isVSCodeFileChange(messageData: unknown): messageData is VSCodeFileChange { + return typeof messageData === 'object' && (messageData as any)?.['type'] === 'VSCODE_FILE_CHANGE' +} + +export interface VSCodeFileDelete { + type: 'VSCODE_FILE_DELETE' + filePath: string +} + +export function vsCodeFileDelete(filePath: string): VSCodeFileDelete { + return { + type: 'VSCODE_FILE_DELETE', + filePath: filePath, + } +} + +export function isVSCodeFileDelete(messageData: unknown): messageData is VSCodeFileDelete { + return typeof messageData === 'object' && (messageData as any)?.['type'] === 'VSCODE_FILE_DELETE' +} + +export interface IndexedDBFailure { + type: 'INDEXED_DB_FAILURE' +} + +export function indexedDBFailure(): IndexedDBFailure { + return { + type: 'INDEXED_DB_FAILURE', + } +} + +export function isIndexedDBFailure(messageData: unknown): messageData is IndexedDBFailure { + return typeof messageData === 'object' && (messageData as any)?.['type'] === 'INDEXED_DB_FAILURE' +} + +export interface VSCodeBridgeReady { + type: 'VSCODE_BRIDGE_READY' +} + +export function vsCodeBridgeReady(): VSCodeBridgeReady { + return { + type: 'VSCODE_BRIDGE_READY', + } +} + +export function isVSCodeBridgeReady(messageData: unknown): messageData is VSCodeBridgeReady { + return typeof messageData === 'object' && (messageData as any)?.['type'] === 'VSCODE_BRIDGE_READY' +} + +export type FromVSCodeToUtopiaMessage = + | MessageListenersReady + | FromVSCodeExtensionMessage + | VSCodeFileChange + | VSCodeFileDelete + | IndexedDBFailure + | VSCodeBridgeReady diff --git a/utopia-vscode-extension/package.json b/utopia-vscode-extension/package.json index 2c7f077ddfd2..cdf03d3787b4 100644 --- a/utopia-vscode-extension/package.json +++ b/utopia-vscode-extension/package.json @@ -5,10 +5,7 @@ "description": "For providing communication between Utopia and VS Code", "version": "0.0.2", "license": "MIT", - "enabledApiProposals": [ - "fileSearchProvider", - "textSearchProvider" - ], + "enableProposedApi": true, "private": true, "activationEvents": [ "onFileSystem:utopia", @@ -17,15 +14,15 @@ ], "browser": "./dist/browser/extension", "engines": { - "vscode": "^1.74.0" + "vscode": "^1.61.2" }, "scripts": { "build": "NODE_OPTIONS=$NODE_OPENSSL_OPTION yarn webpack-cli --config extension-browser.webpack.config", "production": "NODE_OPTIONS=$NODE_OPENSSL_OPTION yarn webpack-cli --config extension-browser.webpack.config --mode production", "watch": "NODE_OPTIONS=$NODE_OPENSSL_OPTION yarn webpack-cli --config extension-browser.webpack.config --mode production --watch --info-verbosity verbose", "watch-dev": "NODE_OPTIONS=$NODE_OPENSSL_OPTION yarn webpack-cli --config extension-browser.webpack.config --watch --info-verbosity verbose", - "download-api": "mkdir -p src/vscode-types && cd src/vscode-types && npx @vscode/dts dev 1.91.1", - "postdownload-api": "mkdir -p src/vscode-types && cd src/vscode-types && npx @vscode/dts 1.91.1", + "download-api": "mkdir -p src/vscode-types && cd src/vscode-types && npx vscode-dts dev 1.61.2", + "postdownload-api": "mkdir -p src/vscode-types && cd src/vscode-types && npx vscode-dts 1.61.2", "preinstall": "npx only-allow pnpm", "postinstall": "npm run download-api" }, @@ -47,13 +44,6 @@ "language": "javascript", "path": "./snippets.json" } - ], - "commands": [ - { - "command": "utopia.toVSCodeMessage", - "title": "Utopia Message to VS Code", - "category": "Utopia" - } ] }, "dependencies": { @@ -64,6 +54,6 @@ "typescript": "4.0.5", "webpack": "4.42.0", "webpack-cli": "3.3.11", - "@vscode/dts": "0.4.1" + "vscode-dts": "0.3.1" } } diff --git a/utopia-vscode-extension/pnpm-lock.yaml b/utopia-vscode-extension/pnpm-lock.yaml index a1d049e4140e..715fdea02a81 100644 --- a/utopia-vscode-extension/pnpm-lock.yaml +++ b/utopia-vscode-extension/pnpm-lock.yaml @@ -1,10 +1,10 @@ lockfileVersion: 5.4 specifiers: - '@vscode/dts': 0.4.1 ts-loader: 5.3.3 typescript: 4.0.5 utopia-vscode-common: link:../utopia-vscode-common + vscode-dts: 0.3.1 webpack: 4.42.0 webpack-cli: 3.3.11 @@ -12,25 +12,14 @@ dependencies: utopia-vscode-common: link:../utopia-vscode-common devDependencies: - '@vscode/dts': 0.4.1 ts-loader: 5.3.3_typescript@4.0.5 typescript: 4.0.5 + vscode-dts: 0.3.1 webpack: 4.42.0_webpack-cli@3.3.11 webpack-cli: 3.3.11_webpack@4.42.0 packages: - /@vscode/dts/0.4.1: - resolution: {integrity: sha512-o8cI5Vqt6S6Y5mCI7yCkSQdiLQaLG5DMUpciJV3zReZwE+dA5KERxSVX8H3cPEhyKw21XwKGmIrg6YmN6M5uZA==} - hasBin: true - dependencies: - https-proxy-agent: 7.0.5 - minimist: 1.2.8 - prompts: 2.4.2 - transitivePeerDependencies: - - supports-color - dev: true - /@webassemblyjs/ast/1.8.5: resolution: {integrity: sha512-aJMfngIZ65+t71C3y2nBBg5FFG0Okt9m0XEgWZ7Ywgn1oMAT8cNwx00Uv1cQyHtidq0Xn94R4TAywO+LCQ+ZAQ==} dependencies: @@ -179,15 +168,6 @@ packages: hasBin: true dev: true - /agent-base/7.1.1: - resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} - engines: {node: '>= 14'} - dependencies: - debug: 4.3.6 - transitivePeerDependencies: - - supports-color - dev: true - /ajv-errors/1.0.1_ajv@6.12.6: resolution: {integrity: sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==} peerDependencies: @@ -762,18 +742,6 @@ packages: supports-color: 6.1.0 dev: true - /debug/4.3.6: - resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - dependencies: - ms: 2.1.2 - dev: true - /decamelize/1.2.0: resolution: {integrity: sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=} engines: {node: '>=0.10.0'} @@ -1313,16 +1281,6 @@ packages: resolution: {integrity: sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=} dev: true - /https-proxy-agent/7.0.5: - resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==} - engines: {node: '>= 14'} - dependencies: - agent-base: 7.1.1 - debug: 4.3.6 - transitivePeerDependencies: - - supports-color - dev: true - /ieee754/1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} dev: true @@ -1764,10 +1722,6 @@ packages: resolution: {integrity: sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==} dev: true - /minimist/1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: true - /mississippi/3.0.0: resolution: {integrity: sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==} engines: {node: '>=4.0.0'} @@ -1814,10 +1768,6 @@ packages: resolution: {integrity: sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=} dev: true - /ms/2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - dev: true - /nan/2.15.0: resolution: {integrity: sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==} dev: true @@ -2109,8 +2059,8 @@ packages: bluebird: 3.7.2 dev: true - /prompts/2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + /prompts/2.4.1: + resolution: {integrity: sha512-EQyfIuO2hPDsX1L/blblV+H7I0knhgAd82cVneCwcdND9B8AuCDuRcBH6yIcG4dFzlOUqbazQqwGjx5xmsNLuQ==} engines: {node: '>= 6'} dependencies: kleur: 3.0.3 @@ -2301,6 +2251,13 @@ packages: glob: 7.2.0 dev: true + /rimraf/3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + hasBin: true + dependencies: + glob: 7.2.0 + dev: true + /ripemd160/2.0.2: resolution: {integrity: sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==} dependencies: @@ -2775,6 +2732,15 @@ packages: resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==} dev: true + /vscode-dts/0.3.1: + resolution: {integrity: sha512-8XZ+M7IQV5MnPXEhHLemGOk5FRBfT7HCBEughfDhn2i6wwPXlpv4OuQQdhs6XZVmF3GFdKqt+fXOgfsNBKP+fw==} + hasBin: true + dependencies: + minimist: 1.2.5 + prompts: 2.4.1 + rimraf: 3.0.2 + dev: true + /watchpack-chokidar2/2.0.1: resolution: {integrity: sha512-nCFfBIPKr5Sh61s4LPpy1Wtfi0HE8isJ3d2Yb5/Ppw2P2B/3eVSEBjKfN0fmHJSK14+31KwMKmcrzs2GM4P0Ww==} requiresBuild: true diff --git a/utopia-vscode-extension/src/extension.ts b/utopia-vscode-extension/src/extension.ts index 8474ce85146b..e64fe6b70836 100644 --- a/utopia-vscode-extension/src/extension.ts +++ b/utopia-vscode-extension/src/extension.ts @@ -2,37 +2,40 @@ import * as vscode from 'vscode' import type { DecorationRange, DecorationRangeType, + FSError, BoundsInFile, Bounds, + ToVSCodeMessage, UtopiaVSCodeConfig, - FromUtopiaToVSCodeMessage, - FromVSCodeToUtopiaMessage, } from 'utopia-vscode-common' import { + ensureDirectoryExists, + RootDir, + initMailbox, + VSCodeInbox, + setErrorHandler, toUtopiaPath, + initializeFS, + parseToVSCodeMessage, + sendMessage, editorCursorPositionChanged, + readFileAsUTF8, + exists, + writeFileUnsavedContentAsUTF8, + clearFileUnsavedContent, applyPrettier, utopiaVSCodeConfigValues, vsCodeReady, clearLoadingScreen, ProjectIDPlaceholderPrefix, - vsCodeBridgeReady, - vsCodeFileChange, } from 'utopia-vscode-common' import { UtopiaFSExtension } from './utopia-fs' +import { fromUtopiaURI } from './path-utils' import type { TextDocumentChangeEvent, TextDocumentWillSaveEvent, Uri } from 'vscode' -import type { FSError } from './in-mem-fs' -import { - clearFileUnsavedContent, - exists, - readFileAsUTF8, - setErrorHandler, - writeFileUnsavedContentAsUTF8, -} from './in-mem-fs' const FollowSelectionConfigKey = 'utopia.editor.followSelection.enabled' -export function activate(context: vscode.ExtensionContext) { +export async function activate(context: vscode.ExtensionContext): Promise { const workspaceRootUri = vscode.workspace.workspaceFolders[0].uri const projectID = workspaceRootUri.scheme if (projectID.startsWith(ProjectIDPlaceholderPrefix)) { @@ -40,23 +43,23 @@ export function activate(context: vscode.ExtensionContext) { return } - setErrorHandler((e) => toFileSystemProviderError(projectID, e)) - - const utopiaFS = new UtopiaFSExtension(projectID) - context.subscriptions.push(utopiaFS) + /* eslint-disable-next-line react-hooks/rules-of-hooks */ + useFileSystemProviderErrors(projectID) - initMessaging(context, workspaceRootUri, utopiaFS) + await initFS(projectID) + const utopiaFS = initUtopiaFSProvider(projectID, context) + initMessaging(context, workspaceRootUri) + watchForUnsavedContentChangesFromFS(utopiaFS) watchForChangesFromVSCode(context, projectID) // Send a VSCodeReady message on activation as this might be triggered by an iframe reload, // meaning no new UtopiaReady message will have been sent - sendMessageToUtopia(vsCodeReady()) + await sendMessage(vsCodeReady()) watchForFileDeletions() } -// FIXME This isn't actually closing the document function watchForFileDeletions() { let fileWatcherChain: Promise = Promise.resolve() const fileWatcher = vscode.workspace.createFileSystemWatcher('**/*') @@ -82,6 +85,33 @@ async function wait(timeoutms: number): Promise { return new Promise((resolve) => setTimeout(() => resolve(), timeoutms)) } +async function initFS(projectID: string): Promise { + await initializeFS(projectID, 'VSCODE') + await ensureDirectoryExists(RootDir) +} + +function initUtopiaFSProvider( + projectID: string, + context: vscode.ExtensionContext, +): UtopiaFSExtension { + const utopiaFS = new UtopiaFSExtension(projectID) + context.subscriptions.push(utopiaFS) + return utopiaFS +} + +function watchForUnsavedContentChangesFromFS(utopiaFS: UtopiaFSExtension) { + utopiaFS.onUtopiaDidChangeUnsavedContent((uris) => { + uris.forEach((uri) => { + updateDirtyContent(uri) + }) + }) + utopiaFS.onUtopiaDidChangeSavedContent((uris) => { + uris.forEach((uri) => { + clearDirtyFlags(uri) + }) + }) +} + let dirtyFiles: Set = new Set() let incomingFileChanges: Set = new Set() @@ -164,7 +194,7 @@ function minimisePendingWork(): void { pendingWork = newPendingWork } -function doSubscriptionWork(work: SubscriptionWork) { +async function doSubscriptionWork(work: SubscriptionWork): Promise { switch (work.type) { case 'DID_CHANGE_TEXT': { const { path, event } = work @@ -178,9 +208,7 @@ function doSubscriptionWork(work: SubscriptionWork) { incomingFileChanges.delete(path) } else { const fullText = event.document.getText() - writeFileUnsavedContentAsUTF8(path, fullText) - const updatedFile = readFileAsUTF8(path) - sendMessageToUtopia(vsCodeFileChange(path, updatedFile)) + await writeFileUnsavedContentAsUTF8(path, fullText) } } break @@ -188,12 +216,12 @@ function doSubscriptionWork(work: SubscriptionWork) { case 'UPDATE_DIRTY_CONTENT': { const { path, uri } = work if (!incomingFileChanges.has(path)) { - updateDirtyContent(uri) + await updateDirtyContent(uri) } break } case 'WILL_SAVE_TEXT': { - const { path } = work + const { path, event } = work dirtyFiles.delete(path) break @@ -204,9 +232,6 @@ function doSubscriptionWork(work: SubscriptionWork) { // User decided to bin unsaved changes when closing the document clearFileUnsavedContent(path) dirtyFiles.delete(path) - - const updatedFile = readFileAsUTF8(path) - sendMessageToUtopia(vsCodeFileChange(path, updatedFile)) } break @@ -219,15 +244,13 @@ function doSubscriptionWork(work: SubscriptionWork) { const SUBSCRIPTION_POLLING_TIMEOUT = 100 -function runPendingSubscriptionChanges() { +async function runPendingSubscriptionChanges(): Promise { minimisePendingWork() for (const work of pendingWork) { - doSubscriptionWork(work) + await doSubscriptionWork(work) } pendingWork = [] - - // TODO should we still do it like this, or instead follow the pattern used by queueEvents? setTimeout(runPendingSubscriptionChanges, SUBSCRIPTION_POLLING_TIMEOUT) } @@ -242,13 +265,16 @@ function watchForChangesFromVSCode(context: vscode.ExtensionContext, projectID: vscode.workspace.onDidChangeTextDocument((event) => { if (isUtopiaDocument(event.document)) { const resource = event.document.uri - const path = resource.path - pendingWork.push(didChangeTextChange(path, event)) + if (resource.scheme === projectID) { + // Don't act on changes to other documents + const path = fromUtopiaURI(resource) + pendingWork.push(didChangeTextChange(path, event)) + } } }), vscode.workspace.onWillSaveTextDocument((event) => { if (isUtopiaDocument(event.document)) { - const path = event.document.uri.path + const path = fromUtopiaURI(event.document.uri) pendingWork.push(willSaveText(path, event)) if (event.reason === vscode.TextDocumentSaveReason.Manual) { const formattedCode = applyPrettier(event.document.getText(), false).formatted @@ -258,13 +284,13 @@ function watchForChangesFromVSCode(context: vscode.ExtensionContext, projectID: }), vscode.workspace.onDidCloseTextDocument((document) => { if (isUtopiaDocument(document)) { - const path = document.uri.path + const path = fromUtopiaURI(document.uri) pendingWork.push(didClose(path)) } }), vscode.workspace.onDidOpenTextDocument((document) => { if (isUtopiaDocument(document)) { - const path = document.uri.path + const path = fromUtopiaURI(document.uri) pendingWork.push(updateDirtyContentChange(path, document.uri)) } }), @@ -308,110 +334,56 @@ function getFullConfig(): UtopiaVSCodeConfig { let currentDecorations: Array = [] let currentSelection: BoundsInFile | null = null -// This part is the crux of the communication system. We have this extension and VS Code register -// a pair of new commands `utopia.toUtopiaMessage` and `utopia.toVSCodeMessage` for passing messages -// between themselves, with the VS Code side then forwarding those messages straight onto Utopia via -// a window.postMessage (since that isn't possible from the extension) -function sendMessageToUtopia(message: FromVSCodeToUtopiaMessage): void { - vscode.commands.executeCommand('utopia.toUtopiaMessage', message) -} - -function initMessaging( - context: vscode.ExtensionContext, - workspaceRootUri: vscode.Uri, - utopiaFS: UtopiaFSExtension, -): void { - context.subscriptions.push( - vscode.commands.registerCommand( - 'utopia.toVSCodeMessage', - (message: FromUtopiaToVSCodeMessage) => { - switch (message.type) { - case 'INIT_PROJECT': - const { projectContents, openFilePath } = message - for (const projectFile of projectContents) { - utopiaFS.writeProjectFile(projectFile) - if (projectFile.type === 'PROJECT_TEXT_FILE' && projectFile.unsavedContent != null) { - updateDirtyContent(vscode.Uri.joinPath(workspaceRootUri, projectFile.filePath)) - } - } - if (openFilePath != null) { - openFile(vscode.Uri.joinPath(workspaceRootUri, openFilePath)) - } else { - sendMessageToUtopia(clearLoadingScreen()) - } - - sendMessageToUtopia(vsCodeReady()) // FIXME do we need both? - sendFullConfigToUtopia() - sendMessageToUtopia(vsCodeBridgeReady()) - break - case 'WRITE_PROJECT_FILE': - const { projectFile } = message - utopiaFS.writeProjectFile(projectFile) - if (projectFile.type === 'PROJECT_TEXT_FILE') { - const fileUri = vscode.Uri.joinPath(workspaceRootUri, projectFile.filePath) - if (projectFile.unsavedContent == null) { - clearDirtyFlags(fileUri) - } else { - updateDirtyContent(fileUri) - } - } - break - case 'DELETE_PATH': - utopiaFS.silentDelete(message.fullPath, { recursive: message.recursive }) - break - case 'ENSURE_DIRECTORY_EXISTS': - utopiaFS.ensureDirectoryExists(message.fullPath) - break - case 'OPEN_FILE': - if (message.bounds != null) { - revealRangeIfPossible(workspaceRootUri, { - ...message.bounds, - filePath: message.filePath, - }) - } else { - openFile(vscode.Uri.joinPath(workspaceRootUri, message.filePath)) - } - break - case 'UPDATE_DECORATIONS': - currentDecorations = message.decorations - updateDecorations(currentDecorations) - break - case 'SELECTED_ELEMENT_CHANGED': - const followSelectionEnabled = getFollowSelectionEnabledConfig() - const shouldFollowSelection = - followSelectionEnabled && - (shouldFollowSelectionWithActiveFile() || - message.forceNavigation === 'force-navigation') - if (shouldFollowSelection) { - currentSelection = message.boundsInFile - revealRangeIfPossible(workspaceRootUri, message.boundsInFile) - } - break - case 'GET_UTOPIA_VSCODE_CONFIG': - sendFullConfigToUtopia() - break - case 'SET_FOLLOW_SELECTION_CONFIG': - vscode.workspace - .getConfiguration() - .update( - FollowSelectionConfigKey, - message.enabled, - vscode.ConfigurationTarget.Workspace, - ) - break - case 'SET_VSCODE_THEME': - vscode.workspace.getConfiguration().update('workbench.colorTheme', message.theme, true) - break - case 'UTOPIA_READY': - sendMessageToUtopia(vsCodeReady()) - break - default: - const _exhaustiveCheck: never = message - console.error(`Unhandled message type ${JSON.stringify(message)}`) +function initMessaging(context: vscode.ExtensionContext, workspaceRootUri: vscode.Uri): void { + function handleMessage(message: ToVSCodeMessage): void { + switch (message.type) { + case 'OPEN_FILE': + if (message.bounds != null) { + revealRangeIfPossible(workspaceRootUri, { ...message.bounds, filePath: message.filePath }) + } else { + openFile(vscode.Uri.joinPath(workspaceRootUri, message.filePath)) } - }, - ), - ) + break + case 'UPDATE_DECORATIONS': + currentDecorations = message.decorations + updateDecorations(currentDecorations) + break + case 'SELECTED_ELEMENT_CHANGED': + const followSelectionEnabled = getFollowSelectionEnabledConfig() + const shouldFollowSelection = + followSelectionEnabled && + (shouldFollowSelectionWithActiveFile() || message.forceNavigation === 'force-navigation') + if (shouldFollowSelection) { + currentSelection = message.boundsInFile + revealRangeIfPossible(workspaceRootUri, message.boundsInFile) + } + break + case 'GET_UTOPIA_VSCODE_CONFIG': + sendFullConfigToUtopia() + break + case 'SET_FOLLOW_SELECTION_CONFIG': + vscode.workspace + .getConfiguration() + .update(FollowSelectionConfigKey, message.enabled, vscode.ConfigurationTarget.Workspace) + break + case 'SET_VSCODE_THEME': + vscode.workspace.getConfiguration().update('workbench.colorTheme', message.theme, true) + break + case 'ACCUMULATED_TO_VSCODE_MESSAGE': + for (const innerMessage of message.messages) { + handleMessage(innerMessage) + } + break + case 'UTOPIA_READY': + sendMessage(vsCodeReady()) + break + default: + const _exhaustiveCheck: never = message + console.error(`Unhandled message type ${JSON.stringify(message)}`) + } + } + + initMailbox(VSCodeInbox, parseToVSCodeMessage, handleMessage) context.subscriptions.push( vscode.window.onDidChangeVisibleTextEditors(() => { @@ -430,9 +402,9 @@ function initMessaging( ) } -function sendFullConfigToUtopia() { +function sendFullConfigToUtopia(): Promise { const fullConfig = getFullConfig() - sendMessageToUtopia(utopiaVSCodeConfigValues(fullConfig)) + return sendMessage(utopiaVSCodeConfigValues(fullConfig)) } function entireDocRange() { @@ -449,8 +421,8 @@ async function clearDirtyFlags(resource: vscode.Uri): Promise { } async function updateDirtyContent(resource: vscode.Uri): Promise { - const filePath = resource.path - const { unsavedContent } = readFileAsUTF8(filePath) + const filePath = fromUtopiaURI(resource) + const { unsavedContent } = await readFileAsUTF8(filePath) if (unsavedContent != null) { incomingFileChanges.add(filePath) const workspaceEdit = new vscode.WorkspaceEdit() @@ -470,20 +442,19 @@ async function updateDirtyContent(resource: vscode.Uri): Promise { } async function openFile(fileUri: vscode.Uri, retries: number = 5): Promise { - const filePath = fileUri.path - const fileExists = exists(filePath) + const filePath = fromUtopiaURI(fileUri) + const fileExists = await exists(filePath) if (fileExists) { await vscode.commands.executeCommand('vscode.open', fileUri, { preserveFocus: true }) - sendMessageToUtopia(clearLoadingScreen()) + sendMessage(clearLoadingScreen()) return true } else { - // FIXME We shouldn't need this // Just in case the message is processed before the file has been written to the FS if (retries > 0) { await wait(100) return openFile(fileUri, retries - 1) } else { - sendMessageToUtopia(clearLoadingScreen()) + sendMessage(clearLoadingScreen()) return false } } @@ -494,18 +465,21 @@ function cursorPositionChanged(event: vscode.TextEditorSelectionChangeEvent): vo const editor = event.textEditor const filename = editor.document.uri.path const position = editor.selection.active - sendMessageToUtopia(editorCursorPositionChanged(filename, position.line, position.character)) + sendMessage(editorCursorPositionChanged(filename, position.line, position.character)) } catch (error) { console.error('cursorPositionChanged failure.', error) } } -function revealRangeIfPossible(workspaceRootUri: vscode.Uri, boundsInFile: BoundsInFile) { +async function revealRangeIfPossible( + workspaceRootUri: vscode.Uri, + boundsInFile: BoundsInFile, +): Promise { const visibleEditor = vscode.window.visibleTextEditors.find( (editor) => editor.document.uri.path === boundsInFile.filePath, ) if (visibleEditor == null) { - const opened = openFile(vscode.Uri.joinPath(workspaceRootUri, boundsInFile.filePath)) + const opened = await openFile(vscode.Uri.joinPath(workspaceRootUri, boundsInFile.filePath)) if (opened) { revealRangeIfPossibleInVisibleEditor(boundsInFile) } @@ -614,6 +588,10 @@ function updateDecorations(decorations: Array): void { } } +function useFileSystemProviderErrors(projectID: string): void { + setErrorHandler((e) => toFileSystemProviderError(projectID, e)) +} + function toFileSystemProviderError(projectID: string, error: FSError): vscode.FileSystemError { const { path: unadjustedPath, code } = error const path = toUtopiaPath(projectID, unadjustedPath) @@ -626,6 +604,8 @@ function toFileSystemProviderError(projectID: string, error: FSError): vscode.Fi return vscode.FileSystemError.FileNotADirectory(path) case 'EEXIST': return vscode.FileSystemError.FileExists(path) + case 'FS_UNAVAILABLE': + return vscode.FileSystemError.Unavailable(path) default: const _exhaustiveCheck: never = code throw new Error(`Unhandled FS Error ${JSON.stringify(error)}`) diff --git a/utopia-vscode-extension/src/in-mem-fs.ts b/utopia-vscode-extension/src/in-mem-fs.ts deleted file mode 100644 index 5932dc8cc94d..000000000000 --- a/utopia-vscode-extension/src/in-mem-fs.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { stripTrailingSlash } from 'utopia-vscode-common' -import { getParentPath } from './path-utils' - -type FSNodeType = 'FILE' | 'DIRECTORY' - -interface FSNode { - type: FSNodeType - ctime: number - mtime: number - lastSavedTime: number -} - -interface FSNodeWithPath { - path: string - node: FSNode -} - -interface FSStat extends FSNode { - size: number -} - -interface FileContent { - content: Uint8Array - unsavedContent: Uint8Array | null -} - -interface FSFile extends FSNode, FileContent { - type: 'FILE' -} - -function fsFile( - content: Uint8Array, - unsavedContent: Uint8Array | null, - ctime: number, - mtime: number, - lastSavedTime: number, -): FSFile { - return { - type: 'FILE', - ctime: ctime, - mtime: mtime, - lastSavedTime: lastSavedTime, - content: content, - unsavedContent: unsavedContent, - } -} - -interface FSDirectory extends FSNode { - type: 'DIRECTORY' -} - -function fsDirectory(ctime: number, mtime: number): FSDirectory { - return { - type: 'DIRECTORY', - ctime: ctime, - mtime: mtime, - lastSavedTime: mtime, - } -} - -export function newFSDirectory(): FSDirectory { - const now = Date.now() - return { - type: 'DIRECTORY', - ctime: now, - mtime: now, - lastSavedTime: now, - } -} - -export function isFile(node: FSNode): node is FSFile { - return node.type === 'FILE' -} - -export function isDirectory(node: FSNode): node is FSDirectory { - return node.type === 'DIRECTORY' -} - -type FSErrorCode = 'ENOENT' | 'EEXIST' | 'EISDIR' | 'ENOTDIR' -export interface FSError { - code: FSErrorCode - path: string -} - -type FSErrorHandler = (e: FSError) => Error - -function fsError(code: FSErrorCode, path: string): FSError { - return { - code: code, - path: path, - } -} - -const encoder = new TextEncoder() -const decoder = new TextDecoder() - -const Store = new Map() -Store.set('', newFSDirectory()) - -export function keys(): IterableIterator { - return Store.keys() -} - -export function getItem(path: string): FSNode | undefined { - return Store.get(stripTrailingSlash(path)) -} - -export function setItem(path: string, value: FSNode) { - Store.set(stripTrailingSlash(path), value) -} - -export function removeItem(path: string) { - Store.delete(stripTrailingSlash(path)) -} - -let handleError: FSErrorHandler = (e: FSError) => { - let error = Error(`FS Error: ${JSON.stringify(e)}`) - error.name = e.code - return error -} - -export function setErrorHandler(handler: FSErrorHandler): void { - handleError = handler -} - -const missingFileError = (path: string) => handleError(fsError('ENOENT', path)) -const existingFileError = (path: string) => handleError(fsError('EEXIST', path)) -const isDirectoryError = (path: string) => handleError(fsError('EISDIR', path)) -export const isNotDirectoryError = (path: string) => handleError(fsError('ENOTDIR', path)) - -export function exists(path: string): boolean { - const value = getItem(path) - return value != null -} - -export function pathIsDirectory(path: string): boolean { - const node = getItem(path) - return node != null && isDirectory(node) -} - -export function pathIsFile(path: string): boolean { - const node = getItem(path) - return node != null && isFile(node) -} - -export function pathIsFileWithUnsavedContent(path: string): boolean { - const node = getItem(path) - return node != null && isFile(node) && node.unsavedContent != null -} - -function getNode(path: string): FSNode { - const node = getItem(path) - if (node == null) { - throw missingFileError(path) - } else { - return node - } -} - -function getFile(path: string): FSFile { - const node = getNode(path) - if (isFile(node)) { - return node - } else { - throw isDirectoryError(path) - } -} - -export function readFile(path: string): FileContent { - return getFile(path) -} - -export function readFileSavedContent(path: string): Uint8Array { - const fileNode = getFile(path) - return fileNode.content -} - -export function readFileUnsavedContent(path: string): Uint8Array | null { - const fileNode = getFile(path) - return fileNode.unsavedContent -} - -export interface StoredFile { - content: string - unsavedContent: string | null -} - -export function readFileAsUTF8(path: string): StoredFile { - const { content, unsavedContent } = getFile(path) - return { - content: decoder.decode(content), - unsavedContent: unsavedContent == null ? null : decoder.decode(unsavedContent), - } -} - -export function readFileSavedContentAsUTF8(path: string): string { - const { content } = readFileAsUTF8(path) - return content -} - -export function readFileUnsavedContentAsUTF8(path: string): string | null { - const { unsavedContent } = readFileAsUTF8(path) - return unsavedContent -} - -function fsStatForNode(node: FSNode): FSStat { - return { - type: node.type, - ctime: node.ctime, - mtime: node.mtime, - lastSavedTime: node.lastSavedTime, - size: isFile(node) ? node.content.length : 0, - } -} - -export function stat(path: string): FSStat { - const node = getNode(path) - return fsStatForNode(node) -} - -export function getDescendentPathsWithAllPaths( - path: string, - allPaths: Array, -): Array { - return allPaths.filter((k) => k != path && k.startsWith(path)) -} - -export function getDescendentPaths(path: string): string[] { - const allPaths = keys() - return getDescendentPathsWithAllPaths(path, Array.from(allPaths)) -} - -function targetsForOperation(path: string, recursive: boolean): string[] { - if (recursive) { - const allDescendents = getDescendentPaths(path) - let result = [path, ...allDescendents] - result.sort() - result.reverse() - return result - } else { - return [path] - } -} - -function filenameOfPath(path: string): string { - const target = path.endsWith('/') ? path.slice(0, -1) : path - const lastSlashIndex = target.lastIndexOf('/') - return lastSlashIndex >= 0 ? path.slice(lastSlashIndex + 1) : path -} - -export function childPaths(path: string): Array { - const allDescendents = getDescendentPaths(path) - const pathAsDir = stripTrailingSlash(path) - return allDescendents.filter((k) => getParentPath(k) === pathAsDir) -} - -function getDirectory(path: string): FSDirectory { - const node = getNode(path) - if (isDirectory(node)) { - return node - } else { - throw isNotDirectoryError(path) - } -} - -function getParent(path: string): FSNodeWithPath | null { - // null signifies we're already at the root - const parentPath = getParentPath(path) - if (parentPath == null) { - return null - } else { - const parentDir = getDirectory(parentPath) - return { - path: parentPath, - node: parentDir, - } - } -} - -export function readDirectory(path: string): Array { - getDirectory(path) // Ensure the path exists and is a directory - const children = childPaths(path) - return children.map(filenameOfPath) -} - -export function createDirectory(path: string) { - if (exists(path)) { - throw existingFileError(path) - } - - createDirectoryWithoutError(path) -} - -export function createDirectoryWithoutError(path: string) { - setItem(path, newFSDirectory()) - - const parent = getParent(path) - if (parent != null) { - markModified(parent) - } -} - -export function writeFile(path: string, content: Uint8Array, unsavedContent: Uint8Array | null) { - const parent = getParent(path) - const maybeExistingFile = getItem(path) - if (maybeExistingFile != null && isDirectory(maybeExistingFile)) { - throw isDirectoryError(path) - } - - const now = Date.now() - const fileCTime = maybeExistingFile == null ? now : maybeExistingFile.ctime - const lastSavedTime = - unsavedContent == null || maybeExistingFile == null ? now : maybeExistingFile.lastSavedTime - const fileToWrite = fsFile(content, unsavedContent, fileCTime, now, lastSavedTime) - setItem(path, fileToWrite) - if (parent != null) { - markModified(parent) - } -} - -export function writeFileSavedContent(path: string, content: Uint8Array) { - writeFile(path, content, null) -} - -export function writeFileUnsavedContent(path: string, unsavedContent: Uint8Array) { - const savedContent = readFileSavedContent(path) - writeFile(path, savedContent, unsavedContent) -} - -export function writeFileAsUTF8(path: string, content: string, unsavedContent: string | null) { - writeFile( - path, - encoder.encode(content), - unsavedContent == null ? null : encoder.encode(unsavedContent), - ) -} - -export function writeFileSavedContentAsUTF8(path: string, savedContent: string) { - writeFileAsUTF8(path, savedContent, null) -} - -export function writeFileUnsavedContentAsUTF8(path: string, unsavedContent: string) { - writeFileUnsavedContent(path, encoder.encode(unsavedContent)) -} - -export function clearFileUnsavedContent(path: string) { - const savedContent = readFileSavedContent(path) - writeFileSavedContent(path, savedContent) -} - -function updateMTime(node: FSNode): FSNode { - const now = Date.now() - if (isFile(node)) { - const lastSavedTime = node.unsavedContent == null ? now : node.lastSavedTime - return fsFile(node.content, node.unsavedContent, node.ctime, now, lastSavedTime) - } else { - return fsDirectory(node.ctime, now) - } -} - -function markModified(nodeWithPath: FSNodeWithPath) { - setItem(nodeWithPath.path, updateMTime(nodeWithPath.node)) -} - -function uncheckedMove(oldPath: string, newPath: string) { - const node = getNode(oldPath) - setItem(newPath, updateMTime(node)) - removeItem(oldPath) -} - -export function rename(oldPath: string, newPath: string) { - const oldParent = getParent(oldPath) - const newParent = getParent(newPath) - - const pathsToMove = targetsForOperation(oldPath, true) - const toNewPath = (p: string) => `${newPath}${p.slice(0, oldPath.length)}` - pathsToMove.forEach((pathToMove) => uncheckedMove(pathToMove, toNewPath(pathToMove))) - if (oldParent != null) { - markModified(oldParent) - } - if (newParent != null) { - markModified(newParent) - } -} - -export function deletePath(path: string, recursive: boolean) { - const parent = getParent(path) - const targets = targetsForOperation(path, recursive) - - // Really this should fail if recursive isn't set to true when trying to delete a - // non-empty directory, but for some reason VSCode doesn't provide an error suitable for that - for (const target of targets) { - removeItem(target) - } - - if (parent != null) { - markModified(parent) - } - return targets -} diff --git a/utopia-vscode-extension/src/path-utils.ts b/utopia-vscode-extension/src/path-utils.ts index 0a93330174ac..961a590af516 100644 --- a/utopia-vscode-extension/src/path-utils.ts +++ b/utopia-vscode-extension/src/path-utils.ts @@ -1,36 +1,10 @@ import { Uri } from 'vscode' -import { - appendToPath, - stripLeadingSlash, - stripTrailingSlash, - toUtopiaPath, -} from 'utopia-vscode-common' +import { appendToPath, RootDir, toUtopiaPath } from 'utopia-vscode-common' -export function addSchemeToPath(projectID: string, path: string): Uri { +export function toUtopiaURI(projectID: string, path: string): Uri { return Uri.parse(toUtopiaPath(projectID, path)) } -export function allPathsUpToPath(path: string): Array { - const directories = path.split('/') - const { paths: allPaths } = directories.reduce( - ({ paths, workingPath }, next) => { - const nextPath = appendToPath(workingPath, next) - return { - paths: paths.concat(nextPath), - workingPath: nextPath, - } - }, - { paths: ['/'], workingPath: '/' }, - ) - return allPaths -} - -export function getParentPath(path: string): string | null { - const withoutLeadingOrTrailingSlash = stripLeadingSlash(stripTrailingSlash(path)) - const pathElems = withoutLeadingOrTrailingSlash.split('/') - if (pathElems.length <= 1) { - return path === '/' || path === '' ? null : '' - } else { - return `/${pathElems.slice(0, -1).join('/')}` - } +export function fromUtopiaURI(uri: Uri): string { + return appendToPath(RootDir, uri.path) } diff --git a/utopia-vscode-extension/src/utopia-fs.ts b/utopia-vscode-extension/src/utopia-fs.ts index c03971029194..54440ab1ee38 100644 --- a/utopia-vscode-extension/src/utopia-fs.ts +++ b/utopia-vscode-extension/src/utopia-fs.ts @@ -24,35 +24,31 @@ import { Position, Range, workspace, - commands, } from 'vscode' import { + watch, + stopWatching, stat, pathIsDirectory, createDirectory, readFile, exists, writeFile, + appendToPath, + dirname, + stripRootPrefix, deletePath, rename, getDescendentPaths, isDirectory, readDirectory, + RootDir, readFileSavedContent, writeFileSavedContent, readFileSavedContentAsUTF8, pathIsFileWithUnsavedContent, - readFileAsUTF8, - writeFileAsUTF8, - getItem, - createDirectoryWithoutError, - isFile, - isNotDirectoryError, - pathIsFile, -} from './in-mem-fs' -import { appendToPath, dirname, vsCodeFileDelete } from 'utopia-vscode-common' -import type { ProjectFile } from 'utopia-vscode-common' -import { addSchemeToPath, allPathsUpToPath } from './path-utils' +} from 'utopia-vscode-common' +import { fromUtopiaURI, toUtopiaURI } from './path-utils' interface EventQueue { queue: T[] @@ -73,8 +69,9 @@ export class UtopiaFSExtension { private disposable: Disposable - // This is the event queue for notifying VS Code of file changes private fileChangeEventQueue = newEventQueue() + private utopiaSavedChangeEventQueue = newEventQueue() + private utopiaUnsavedChangeEventQueue = newEventQueue() private allFilePaths: string[] | null = null @@ -92,9 +89,13 @@ export class UtopiaFSExtension // FileSystemProvider readonly onDidChangeFile: Event = this.fileChangeEventQueue.emitter.event + readonly onUtopiaDidChangeSavedContent: Event = + this.utopiaSavedChangeEventQueue.emitter.event + readonly onUtopiaDidChangeUnsavedContent: Event = + this.utopiaUnsavedChangeEventQueue.emitter.event - private queueEvents(events: Array, eventQueue: EventQueue): void { - eventQueue.queue.push(...events) + private queueEvent(event: T, eventQueue: EventQueue): void { + eventQueue.queue.push(event) if (eventQueue.handle != null) { clearTimeout(eventQueue.handle) @@ -106,96 +107,82 @@ export class UtopiaFSExtension }, 5) } - private queueFileChangeEvents(events: Array): void { + private queueFileChangeEvent(event: FileChangeEvent): void { this.clearCachedFiles() - this.queueEvents(events, this.fileChangeEventQueue) + this.queueEvent(event, this.fileChangeEventQueue) } - private notifyFileChanged(path: string) { - const uri = addSchemeToPath(this.projectID, path) - const hasUnsavedContent = pathIsFileWithUnsavedContent(path) + private queueUtopiaSavedChangeEvent(resource: Uri): void { + this.queueEvent(resource, this.utopiaSavedChangeEventQueue) + } + + private queueUtopiaUnsavedChangeEvent(resource: Uri): void { + this.queueEvent(resource, this.utopiaUnsavedChangeEventQueue) + } + + private async notifyFileChanged(path: string, modifiedBySelf: boolean): Promise { + const uri = toUtopiaURI(this.projectID, path) + const hasUnsavedContent = await pathIsFileWithUnsavedContent(path) const fileWasSaved = !hasUnsavedContent if (fileWasSaved) { // Notify VS Code of updates to the saved content - this.queueFileChangeEvents([ - { - type: FileChangeType.Changed, - uri: uri, - }, - ]) + this.queueFileChangeEvent({ + type: FileChangeType.Changed, + uri: uri, + }) + } + + if (!modifiedBySelf) { + // Notify our extension of changes coming from Utopia only + if (fileWasSaved) { + this.queueUtopiaSavedChangeEvent(uri) + } else { + this.queueUtopiaUnsavedChangeEvent(uri) + } } } - private notifyFileCreated(path: string) { - const parentDirectory = dirname(path) - this.queueFileChangeEvents([ - { - type: FileChangeType.Created, - uri: addSchemeToPath(this.projectID, path), - }, - { - type: FileChangeType.Changed, - uri: addSchemeToPath(this.projectID, parentDirectory), - }, - ]) + private notifyFileCreated(path: string): void { + this.queueFileChangeEvent({ + type: FileChangeType.Created, + uri: toUtopiaURI(this.projectID, path), + }) } - private notifyFileDeleted(path: string) { - const parentDirectory = dirname(path) - this.queueFileChangeEvents([ - { - type: FileChangeType.Deleted, - uri: addSchemeToPath(this.projectID, path), - }, - { - type: FileChangeType.Changed, - uri: addSchemeToPath(this.projectID, parentDirectory), - }, - ]) + private notifyFileDeleted(path: string): void { + this.queueFileChangeEvent({ + type: FileChangeType.Deleted, + uri: toUtopiaURI(this.projectID, path), + }) } - private notifyFileRenamed(oldPath: string, newPath: string) { - const oldParentDirectory = dirname(oldPath) - const newParentDirectory = dirname(newPath) - const parentChanged = oldParentDirectory !== newParentDirectory - this.queueFileChangeEvents([ - { - type: FileChangeType.Deleted, - uri: addSchemeToPath(this.projectID, oldPath), - }, - { - type: FileChangeType.Created, - uri: addSchemeToPath(this.projectID, newPath), + watch(uri: Uri, options: { recursive: boolean; excludes: string[] }): Disposable { + const path = fromUtopiaURI(uri) + watch( + path, + options.recursive, + this.notifyFileCreated.bind(this), + this.notifyFileChanged.bind(this), + this.notifyFileDeleted.bind(this), + () => { + /* no op */ }, - { - type: FileChangeType.Changed, - uri: addSchemeToPath(this.projectID, oldParentDirectory), - }, - ...(parentChanged - ? [ - { - type: FileChangeType.Changed, - uri: addSchemeToPath(this.projectID, newParentDirectory), - }, - ] - : []), - ]) - } + ) - watch(): Disposable { - // No need for this since all events are manually fired - return new Disposable(() => {}) + return new Disposable(() => { + stopWatching(path, options.recursive) + }) } - exists(uri: Uri): boolean { - const path = uri.path + async exists(uri: Uri): Promise { + const path = fromUtopiaURI(uri) return exists(path) } - stat(uri: Uri): FileStat { - const path = uri.path - const stats = stat(path) + async stat(uri: Uri): Promise { + const path = fromUtopiaURI(uri) + const stats = await stat(path) const fileType = isDirectory(stats) ? FileType.Directory : FileType.File return { @@ -206,30 +193,36 @@ export class UtopiaFSExtension } } - readDirectory(uri: Uri): Array<[string, FileType]> { - const path = uri.path - const children = readDirectory(path) - return children.map((childName) => { - const resultIsDirectory = pathIsDirectory(appendToPath(path, childName)) - return [childName, resultIsDirectory ? FileType.Directory : FileType.File] - }) + async readDirectory(uri: Uri): Promise<[string, FileType][]> { + const path = fromUtopiaURI(uri) + const children = await readDirectory(path) + const result: Promise<[string, FileType]>[] = children.map((childName) => + pathIsDirectory(appendToPath(path, childName)).then((resultIsDirectory) => [ + childName, + resultIsDirectory ? FileType.Directory : FileType.File, + ]), + ) + return Promise.all(result) } - createDirectory(uri: Uri) { - const path = uri.path - createDirectory(path) - this.notifyFileCreated(path) + async createDirectory(uri: Uri): Promise { + const path = fromUtopiaURI(uri) + await createDirectory(path) } - readFile(uri: Uri): Uint8Array { - const path = uri.path + async readFile(uri: Uri): Promise { + const path = fromUtopiaURI(uri) return readFileSavedContent(path) } - writeFile(uri: Uri, content: Uint8Array, options: { create: boolean; overwrite: boolean }) { - const path = uri.path - const fileExists = exists(path) + async writeFile( + uri: Uri, + content: Uint8Array, + options: { create: boolean; overwrite: boolean }, + ): Promise { + const path = fromUtopiaURI(uri) if (!options.create || !options.overwrite) { + const fileExists = await exists(path) if (!fileExists && !options.create) { throw FileSystemError.FileNotFound(uri) } else if (fileExists && !options.overwrite) { @@ -237,159 +230,90 @@ export class UtopiaFSExtension } } - writeFileSavedContent(path, content) - if (fileExists) { - this.notifyFileChanged(path) - } else { - this.notifyFileCreated(path) - } - } - - ensureDirectoryExists(pathToEnsure: string) { - const allPaths = allPathsUpToPath(pathToEnsure) - let createdDirectories: Array = [] - for (const pathToCreate of allPaths) { - const existingNode = getItem(pathToCreate) - if (existingNode == null) { - createDirectoryWithoutError(pathToCreate) - createdDirectories.push(pathToCreate) - } else if (isFile(existingNode)) { - throw isNotDirectoryError(pathToCreate) - } - } - - createdDirectories.forEach((createdDirectory) => this.notifyFileCreated(createdDirectory)) - } - - writeProjectFile(projectFile: ProjectFile) { - switch (projectFile.type) { - case 'PROJECT_DIRECTORY': { - const { filePath } = projectFile - this.ensureDirectoryExists(filePath) - break - } - case 'PROJECT_TEXT_FILE': { - const { filePath, savedContent, unsavedContent } = projectFile - const fileExists = exists(filePath) - const alreadyExistingFile = fileExists ? readFileAsUTF8(filePath) : null - const fileDiffers = - alreadyExistingFile == null || - alreadyExistingFile.content !== savedContent || - alreadyExistingFile.unsavedContent !== unsavedContent - if (fileDiffers) { - // Avoid pushing a file to the file system if the content hasn't changed. - writeFileAsUTF8(filePath, savedContent, unsavedContent) - - if (fileExists) { - this.notifyFileChanged(filePath) - } else { - this.notifyFileCreated(filePath) - } - } - break - } - default: - const _exhaustiveCheck: never = projectFile - throw new Error(`Invalid file projectFile type ${projectFile}`) - } - } - - delete(uri: Uri, options: { recursive: boolean }) { - this.silentDelete(uri.path, options) - commands.executeCommand('utopia.toUtopiaMessage', vsCodeFileDelete(uri.path)) + await writeFileSavedContent(path, content) } - // "silent" because it doesn't send the message to Utopia. It still emits the event. - silentDelete(path: string, options: { recursive: boolean }) { - deletePath(path, options.recursive) - this.notifyFileDeleted(path) + async delete(uri: Uri, options: { recursive: boolean }): Promise { + const path = fromUtopiaURI(uri) + await deletePath(path, options.recursive) } - rename(oldUri: Uri, newUri: Uri, options: { overwrite: boolean }) { - const oldPath = oldUri.path - const newPath = newUri.path + async rename(oldUri: Uri, newUri: Uri, options: { overwrite: boolean }): Promise { + const oldPath = fromUtopiaURI(oldUri) + const newPath = fromUtopiaURI(newUri) if (!options.overwrite) { - const fileExists = exists(newPath) + const fileExists = await exists(newPath) if (fileExists) { throw FileSystemError.FileExists(newUri) } } - rename(oldPath, newPath) - this.notifyFileRenamed(oldPath, newPath) + await rename(oldPath, newPath) } - copy(source: Uri, destination: Uri, options: { overwrite: boolean }) { + async copy(source: Uri, destination: Uri, options: { overwrite: boolean }): Promise { // It's not clear where this will ever be called from, but it seems to be from the side bar // that isn't available in Utopia, so this implementation is "just in case" - const sourcePath = source.path - const destinationPath = destination.path + const sourcePath = fromUtopiaURI(source) + const destinationPath = fromUtopiaURI(destination) const destinationParentDir = dirname(destinationPath) - const destinationParentDirExists = exists(destinationParentDir) + const destinationParentDirExists = await exists(destinationParentDir) if (!destinationParentDirExists) { - throw FileSystemError.FileNotFound(addSchemeToPath(this.projectID, destinationParentDir)) + throw FileSystemError.FileNotFound(toUtopiaURI(this.projectID, destinationParentDir)) } if (!options.overwrite) { - const destinationExists = exists(destinationPath) + const destinationExists = await exists(destinationPath) if (destinationExists && !options.overwrite) { throw FileSystemError.FileExists(destination) } } - const { content, unsavedContent } = readFile(sourcePath) - writeFile(destinationPath, content, unsavedContent) - this.notifyFileCreated(destinationPath) + const { content, unsavedContent } = await readFile(sourcePath) + await writeFile(destinationPath, content, unsavedContent) } // FileSearchProvider - provideFileSearchResults( + async provideFileSearchResults( query: FileSearchQuery, options: FileSearchOptions, _token: CancellationToken, - ): Array { + ): Promise { // TODO Support all search options - const lowerCaseQuery = query.pattern.toLocaleLowerCase() - const filePaths = this.getAllFilePaths() - const foundPaths = filePaths.filter((p) => p.toLocaleLowerCase().includes(lowerCaseQuery)) - return foundPaths.map((p) => addSchemeToPath(this.projectID, p)) + const { result: foundPaths } = await this.filterFilePaths(query.pattern, options.maxResults) + return foundPaths.map((p) => toUtopiaURI(this.projectID, p)) } // TextSearchProvider - provideTextSearchResults( + async provideTextSearchResults( query: TextSearchQuery, options: TextSearchOptions, progress: Progress, token: CancellationToken, - ): TextSearchComplete { + ): Promise { // This appears to only be callable from the side bar that isn't available in Utopia // TODO Support all search options - const filePaths = this.filterFilePaths(options.includes[0]) + const { result: filePaths, limitHit } = await this.filterFilePaths(options.includes[0]) if (filePaths.length > 0) { - const isCaseSensitive = query.isCaseSensitive ?? false - const lowerCaseQuery = query.pattern.toLocaleLowerCase() - for (const filePath of filePaths) { if (token.isCancellationRequested) { break } - const content = readFileSavedContentAsUTF8(filePath) + const content = await readFileSavedContentAsUTF8(filePath) const lines = splitIntoLines(content) for (let i = 0; i < lines.length; i++) { const line = lines[i] - const index = isCaseSensitive - ? line.indexOf(query.pattern) - : line.toLocaleLowerCase().indexOf(lowerCaseQuery) + const index = line.indexOf(query.pattern) if (index !== -1) { progress.report({ - uri: addSchemeToPath(this.projectID, filePath), + uri: toUtopiaURI(this.projectID, filePath), ranges: new Range( new Position(i, index), new Position(i, index + query.pattern.length), @@ -407,35 +331,49 @@ export class UtopiaFSExtension } } - return { limitHit: false } + return { limitHit: limitHit } } // Common - private filterFilePaths(query: string | undefined): Array { - const filePaths = this.getAllFilePaths() - if (query == null) { - return filePaths - } - + private async filterFilePaths( + query: string | undefined, + maxResults?: number, + ): Promise<{ result: string[]; limitHit: boolean }> { + const filePaths = await this.getAllPaths() let result: string[] = [] - const pattern = new RegExp(convertSimple2RegExpPattern(query)) + let limitHit = false + let remainingCount = maxResults == null ? Infinity : maxResults + + const pattern = query ? new RegExp(convertSimple2RegExpPattern(query)) : null for (const path of filePaths) { - if (!pattern || pattern.exec(path)) { - result.push(path) + if (remainingCount < 0) { + break + } + + if (!pattern || pattern.exec(stripRootPrefix(path))) { + if (remainingCount === 0) { + // We've already found the max number of results, but we want to flag that there are more + limitHit = true + } else { + result.push(path) + } + remainingCount-- } } - return result + return { + result: result, + limitHit: limitHit, + } } - getAllFilePaths(): Array { + async getAllPaths(): Promise { if (this.allFilePaths == null) { - const allPaths = getDescendentPaths('') - const allFilePaths = allPaths.filter((p) => pathIsFile(p)) - this.allFilePaths = allFilePaths - return allFilePaths + const result = await getDescendentPaths(RootDir) + this.allFilePaths = result + return result } else { return this.allFilePaths } diff --git a/vscode-build/build.js b/vscode-build/build.js index e0224f11e95e..48c15cc5fb40 100644 --- a/vscode-build/build.js +++ b/vscode-build/build.js @@ -5,7 +5,7 @@ const fse = require('fs-extra') const glob = require('glob') const rmdir = require('rimraf') -const vscodeVersion = '1.91.1' +const vscodeVersion = '1.61.2' if (fs.existsSync('vscode')) { process.chdir('vscode') @@ -35,7 +35,9 @@ child_process.execSync(`git apply ../vscode.patch`, { child_process.execSync('yarn', { stdio: 'inherit' }) // Compile -child_process.execSync('yarn gulp vscode-web-min', { stdio: 'inherit' }) +child_process.execSync('yarn gulp compile-build', { stdio: 'inherit' }) +child_process.execSync('yarn gulp minify-vscode', { stdio: 'inherit' }) +child_process.execSync('yarn compile-web', { stdio: 'inherit' }) // Remove maps const mapFiles = glob.sync('out-vscode-min/**/*.js.map', {}) @@ -48,12 +50,13 @@ if (fs.existsSync('../dist')) { fs.rmdirSync('../dist', { recursive: true }) } -fse.moveSync('../vscode-web', '../dist') +fs.mkdirSync('../dist') fs.mkdirSync('../dist/lib') -fse.copySync('resources', '../dist/vscode/resources') +fse.copySync('out-vscode-min', '../dist/vscode') fse.copySync('product.json', '../dist/product.json') -fse.copySync('node_modules/vscode-oniguruma', '../dist/lib/vscode-oniguruma') -fse.copySync('node_modules/vscode-textmate', '../dist/lib/vscode-textmate') +fse.copySync('../node_modules/semver-umd', '../dist/lib/semver-umd') +fse.copySync('../node_modules/vscode-oniguruma', '../dist/lib/vscode-oniguruma') +fse.copySync('../node_modules/vscode-textmate', '../dist/lib/vscode-textmate') fse.copySync('../node_modules/utopia-vscode-common', '../dist/lib/utopia-vscode-common') const extensionNM = glob.sync('extensions/**/node_modules', {}) diff --git a/vscode-build/package.json b/vscode-build/package.json index 61ac2bb57d8a..bb6eaff8b244 100644 --- a/vscode-build/package.json +++ b/vscode-build/package.json @@ -16,7 +16,10 @@ "fs-extra": "9.0.1", "glob": "7.1.6", "rimraf": "3.0.2", - "utopia-vscode-common": "file:../utopia-vscode-common" + "semver-umd": "5.5.7", + "utopia-vscode-common": "file:../utopia-vscode-common", + "vscode-oniguruma": "1.4.0", + "vscode-textmate": "5.2.0" }, "dependencies": {} } diff --git a/vscode-build/pull-utopia-extension.js b/vscode-build/pull-utopia-extension.js index 7535a0bcb0d9..a5f0b8e026b9 100644 --- a/vscode-build/pull-utopia-extension.js +++ b/vscode-build/pull-utopia-extension.js @@ -36,7 +36,7 @@ for (const extension of extensionsContent) { extensions.push({ packageJSON, extensionPath: extension, - ...(packageNLS == null ? {} : { packageNLS }), + packageNLS, }) } } diff --git a/vscode-build/shell.nix b/vscode-build/shell.nix index 0f904326cabc..8acb32e59ef8 100644 --- a/vscode-build/shell.nix +++ b/vscode-build/shell.nix @@ -1,7 +1,7 @@ let release = (import ../release.nix {}); - pkgs = release.recentPkgs; - node = pkgs.nodejs_20; + pkgs = release.pkgs; + node = pkgs.nodejs-16_x; stdenv = pkgs.stdenv; pnpm = node.pkgs.pnpm; yarn = pkgs.yarn.override { nodejs = node; }; diff --git a/vscode-build/vscode.patch b/vscode-build/vscode.patch index 0d851e68d257..b1508679304c 100644 --- a/vscode-build/vscode.patch +++ b/vscode-build/vscode.patch @@ -1,31 +1,35 @@ -diff --git a/build/gulpfile.compile.js b/build/gulpfile.compile.js -index c4947e76cbf..c2dd4189229 100644 ---- a/build/gulpfile.compile.js -+++ b/build/gulpfile.compile.js -@@ -22,7 +22,9 @@ function makeCompileBuildTask(disableMangle) { - } - - // Full compile, including nls and inline sources in sourcemaps, mangling, minification, for build --const compileBuildTask = task.define('compile-build', makeCompileBuildTask(false)); -+// The `true` here is to disable mangling. Minification still happens. -+// For some reason the mangling was causing the build to completely hang on my machine. -+const compileBuildTask = task.define('compile-build', makeCompileBuildTask(true)); - gulp.task(compileBuildTask); - exports.compileBuildTask = compileBuildTask; - -diff --git a/build/gulpfile.vscode.web.js b/build/gulpfile.vscode.web.js -index 50c7e6fb631..629a119108b 100644 ---- a/build/gulpfile.vscode.web.js -+++ b/build/gulpfile.vscode.web.js -@@ -186,7 +186,7 @@ function packageTask(sourceFolderName, destinationFolderName) { - const json = require('gulp-json-editor'); +diff --git a/build/gulpfile.vscode.js b/build/gulpfile.vscode.js +index 13c20bed989..2a8452a08ab 100644 +--- a/build/gulpfile.vscode.js ++++ b/build/gulpfile.vscode.js +@@ -35,13 +35,14 @@ const { compileExtensionsBuildTask } = require('./gulpfile.extensions'); - const src = gulp.src(sourceFolderName + '/**', { base: '.' }) -- .pipe(rename(function (path) { path.dirname = path.dirname.replace(new RegExp('^' + sourceFolderName), 'out'); })); -+ .pipe(rename(function (path) { path.dirname = path.dirname.replace(new RegExp('^' + sourceFolderName), 'vscode'); })); + // Build + const vscodeEntryPoints = _.flatten([ +- buildfile.entrypoint('vs/workbench/workbench.desktop.main'), ++ buildfile.entrypoint('vs/workbench/workbench.web.api'), + buildfile.base, + buildfile.workerExtensionHost, + buildfile.workerNotebook, + buildfile.workerLanguageDetection, + buildfile.workerLocalFileSearch, +- buildfile.workbenchDesktop, ++ buildfile.workbenchWeb, ++ buildfile.keyboardMaps, + buildfile.code + ]); - const extensions = gulp.src('.build/web/extensions/**', { base: '.build/web', dot: true }); +@@ -157,8 +158,8 @@ function packageTask(platform, arch, sourceFolderName, destinationFolderName, op + const checksums = computeChecksums(out, [ + 'vs/base/parts/sandbox/electron-browser/preload.js', +- 'vs/workbench/workbench.desktop.main.js', +- 'vs/workbench/workbench.desktop.main.css', ++ 'vs/workbench/workbench.web.api.js', ++ 'vs/workbench/workbench.web.api.css', + 'vs/workbench/services/extensions/node/extensionHostProcess.js', + 'vs/code/electron-browser/workbench/workbench.html', + 'vs/code/electron-browser/workbench/workbench.js' diff --git a/extensions/css-language-features/server/test/linksTestFixtures/node_modules/foo/package.json b/extensions/css-language-features/server/test/linksTestFixtures/node_modules/foo/package.json deleted file mode 100644 index 9e26dfeeb6e..00000000000 @@ -34,27 +38,59 @@ index 9e26dfeeb6e..00000000000 @@ -1 +0,0 @@ -{} \ No newline at end of file +diff --git a/extensions/simple-browser/src/simpleBrowserView.ts b/extensions/simple-browser/src/simpleBrowserView.ts +index 2d7da1aecf8..052f52383f0 100644 +--- a/extensions/simple-browser/src/simpleBrowserView.ts ++++ b/extensions/simple-browser/src/simpleBrowserView.ts +@@ -139,7 +139,7 @@ export class SimpleBrowserView extends Disposable { + +
+
${localize('view.iframe-focused', "Focus Lock")}
+- ++ +
+ + diff --git a/package.json b/package.json -index 2103fe1fe1a..92954953615 100644 +index 2bca5115cac..53884a9bb17 100644 --- a/package.json +++ b/package.json -@@ -96,10 +96,10 @@ - "kerberos": "^2.0.1", - "minimist": "^1.2.6", - "native-is-elevated": "0.7.0", -- "native-keymap": "^3.3.5", - "native-watchdog": "^1.4.1", - "node-pty": "1.1.0-beta11", - "tas-client-umd": "0.2.0", +@@ -44,15 +44,15 @@ + "valid-layers-check": "node build/lib/layersChecker.js", + "update-distro": "node build/npm/update-distro.js", + "web": "node resources/web/code-web.js", +- "compile-web": "node --max_old_space_size=4095 ./node_modules/gulp/bin/gulp.js compile-web", +- "watch-web": "node --max_old_space_size=4095 ./node_modules/gulp/bin/gulp.js watch-web", ++ "compile-web": "node --max_old_space_size=4095 $NODE_OPENSSL_OPTION ./node_modules/gulp/bin/gulp.js compile-web", ++ "watch-web": "node --max_old_space_size=4095 $NODE_OPENSSL_OPTION ./node_modules/gulp/bin/gulp.js watch-web", + "eslint": "node build/eslint", + "playwright-install": "node build/azure-pipelines/common/installPlaywright.js", +- "compile-build": "node --max_old_space_size=4095 ./node_modules/gulp/bin/gulp.js compile-build", +- "compile-extensions-build": "node --max_old_space_size=4095 ./node_modules/gulp/bin/gulp.js compile-extensions-build", +- "minify-vscode": "node --max_old_space_size=4095 ./node_modules/gulp/bin/gulp.js minify-vscode", +- "minify-vscode-reh": "node --max_old_space_size=4095 ./node_modules/gulp/bin/gulp.js minify-vscode-reh", +- "minify-vscode-reh-web": "node --max_old_space_size=4095 ./node_modules/gulp/bin/gulp.js minify-vscode-reh-web", ++ "compile-build": "node --max_old_space_size=4095 $NODE_OPENSSL_OPTION ./node_modules/gulp/bin/gulp.js compile-build", ++ "compile-extensions-build": "node --max_old_space_size=4095 $NODE_OPENSSL_OPTION ./node_modules/gulp/bin/gulp.js compile-extensions-build", ++ "minify-vscode": "node --max_old_space_size=4095 $NODE_OPENSSL_OPTION ./node_modules/gulp/bin/gulp.js minify-vscode", ++ "minify-vscode-reh": "node --max_old_space_size=4095 $NODE_OPENSSL_OPTION ./node_modules/gulp/bin/gulp.js minify-vscode-reh", ++ "minify-vscode-reh-web": "node --max_old_space_size=4095 $NODE_OPENSSL_OPTION ./node_modules/gulp/bin/gulp.js minify-vscode-reh-web", + "hygiene": "node --max_old_space_size=4095 ./node_modules/gulp/bin/gulp.js hygiene", + "core-ci": "node --max_old_space_size=4095 ./node_modules/gulp/bin/gulp.js core-ci", + "extensions-ci": "node --max_old_space_size=4095 ./node_modules/gulp/bin/gulp.js extensions-ci" +@@ -77,6 +77,7 @@ + "spdlog": "^0.13.0", + "sudo-prompt": "9.2.1", + "tas-client-umd": "0.1.4", + "utopia-vscode-common": "file:../../utopia-vscode-common", - "v8-inspect-profiler": "^0.1.1", - "vscode-oniguruma": "1.7.0", - "vscode-regexpp": "^3.1.0", + "v8-inspect-profiler": "^0.0.22", + "vscode-nsfw": "2.1.8", + "vscode-oniguruma": "1.5.1", diff --git a/product.json b/product.json -index 27ae53fe16b..83637043bdc 100644 +index 7b60eac641d..b37ebcd9f73 100644 --- a/product.json +++ b/product.json -@@ -1,84 +1,9 @@ +@@ -1,96 +1,9 @@ { - "nameShort": "Code - OSS", - "nameLong": "Code - OSS", @@ -63,36 +99,50 @@ index 27ae53fe16b..83637043bdc 100644 - "win32MutexName": "vscodeoss", - "licenseName": "MIT", - "licenseUrl": "https://github.com/microsoft/vscode/blob/main/LICENSE.txt", -- "serverLicenseUrl": "https://github.com/microsoft/vscode/blob/main/LICENSE.txt", -- "serverGreeting": [], -- "serverLicense": [], -- "serverLicensePrompt": "", -- "serverApplicationName": "code-server-oss", -- "serverDataFolderName": ".vscode-server-oss", -- "tunnelApplicationName": "code-tunnel-oss", - "win32DirName": "Microsoft Code OSS", - "win32NameVersion": "Microsoft Code OSS", - "win32RegValueName": "CodeOSS", +- "win32AppId": "{{E34003BB-9E10-4501-8C11-BE3FAA83F23F}", - "win32x64AppId": "{{D77B7E06-80BA-4137-BCF4-654B95CCEBC5}", - "win32arm64AppId": "{{D1ACE434-89C5-48D1-88D3-E2991DF85475}", +- "win32UserAppId": "{{C6065F05-9603-4FC4-8101-B9781A25D88E}", - "win32x64UserAppId": "{{CC6B787D-37A0-49E8-AE24-8559A032BE0C}", - "win32arm64UserAppId": "{{3AEBF0C8-F733-4AD4-BADE-FDB816D53D7B}", - "win32AppUserModelId": "Microsoft.CodeOSS", - "win32ShellNameShort": "C&ode - OSS", -- "win32TunnelServiceMutex": "vscodeoss-tunnelservice", -- "win32TunnelMutex": "vscodeoss-tunnel", - "darwinBundleIdentifier": "com.visualstudio.code.oss", -- "linuxIconName": "code-oss", +- "linuxIconName": "com.visualstudio.code.oss", - "licenseFileName": "LICENSE.txt", - "reportIssueUrl": "https://github.com/microsoft/vscode/issues/new", -- "nodejsRepository": "https://nodejs.org", - "urlProtocol": "code-oss", -- "webviewContentExternalBaseUrlTemplate": "https://{{uuid}}.vscode-cdn.net/insider/ef65ac1ba57f57f2a3961bfe94aa20481caca4c6/out/vs/workbench/contrib/webview/browser/pre/", +- "webviewContentExternalBaseUrlTemplate": "https://{{uuid}}.vscode-webview.net/{{quality}}/{{commit}}/out/vs/workbench/contrib/webview/browser/pre/", +- "extensionAllowedProposedApi": [ +- "ms-vscode.vscode-js-profile-flame", +- "ms-vscode.vscode-js-profile-table", +- "ms-vscode.remotehub", +- "ms-vscode.remotehub-insiders", +- "GitHub.remotehub", +- "GitHub.remotehub-insiders" +- ], - "builtInExtensions": [ - { +- "name": "ms-vscode.references-view", +- "version": "0.0.81", +- "repo": "https://github.com/microsoft/vscode-references-view", +- "metadata": { +- "id": "dc489f46-520d-4556-ae85-1f9eab3c412d", +- "publisherId": { +- "publisherId": "5f5636e7-69ed-4afe-b5d6-8d231fb3d3ee", +- "publisherName": "ms-vscode", +- "displayName": "Microsoft", +- "flags": "verified" +- }, +- "publisherDisplayName": "Microsoft" +- } +- }, +- { - "name": "ms-vscode.js-debug-companion", -- "version": "1.1.2", -- "sha256": "e034b8b41beb4e97e02c70f7175bd88abe66048374c2bd629f54bb33354bc2aa", +- "version": "1.0.15", - "repo": "https://github.com/microsoft/vscode-js-debug-companion", - "metadata": { - "id": "99cb0b7f-7354-4278-b8da-6cc79972169d", @@ -107,8 +157,7 @@ index 27ae53fe16b..83637043bdc 100644 - }, - { - "name": "ms-vscode.js-debug", -- "version": "1.91.0", -- "sha256": "53b99146c7fa280f00c74414e09721530c622bf3e5eac2c967ddfb9906b51c80", +- "version": "1.61.0", - "repo": "https://github.com/microsoft/vscode-js-debug", - "metadata": { - "id": "25629058-ddac-4e17-abba-74678e126c5d", @@ -123,8 +172,7 @@ index 27ae53fe16b..83637043bdc 100644 - }, - { - "name": "ms-vscode.vscode-js-profile-table", -- "version": "1.0.9", -- "sha256": "3b62ee4276a2bbea3fe230f94b1d5edd915b05966090ea56f882e1e0ab53e1a6", +- "version": "0.0.18", - "repo": "https://github.com/microsoft/vscode-js-profile-visualizer", - "metadata": { - "id": "7e52b41b-71ad-457b-ab7e-0620f1fc4feb", @@ -138,529 +186,349 @@ index 27ae53fe16b..83637043bdc 100644 - } - } - ] --} + "productConfiguration": { + "nameShort": "Code Web", + "nameLong": "Code Web", + "applicationName": "code-web", + "dataFolderName": ".vscode-web", -+ "version": "1.91.1" ++ "version": "1.62.0" + } -+} -\ No newline at end of file + } diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts -index 66d30c3aca3..25f12f879e4 100644 +index 6148eb30092..6948ededa4a 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts -@@ -495,8 +495,11 @@ export function getClientArea(element: HTMLElement, fallback?: HTMLElement): Dim - if (fallback) { - return getClientArea(fallback); +@@ -365,7 +365,11 @@ export function getClientArea(element: HTMLElement): Dimension { + return new Dimension(document.documentElement.clientWidth, document.documentElement.clientHeight); } -- + - throw new Error('Unable to figure out browser width and height'); + // this Error would prevent VSCode from loading inside Utopia if the browser tab is not in the foreground + // throw new Error('Unable to figure out browser width and height'); + -+ // Instead, we just return 1 x 1, as a non-zero value is required for laying out the editor correctly -+ return new Dimension(1, 1); ++ // Instead, we just return 0 x 0, it seems to be fine ++ return new Dimension(0, 0); } class SizeUtils { diff --git a/src/vs/code/browser/workbench/workbench.ts b/src/vs/code/browser/workbench/workbench.ts -index f8875029a8a..3bdfd1bfa64 100644 +index 534fe232636..b41446430f0 100644 --- a/src/vs/code/browser/workbench/workbench.ts +++ b/src/vs/code/browser/workbench/workbench.ts -@@ -3,586 +3,41 @@ - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - +@@ -1,536 +1,101 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation. All rights reserved. +- * Licensed under the MIT License. See License.txt in the project root for license information. +- *--------------------------------------------------------------------------------------------*/ +- -import { isStandalone } from 'vs/base/browser/browser'; - import { mainWindow } from 'vs/base/browser/window'; --import { VSBuffer, decodeBase64, encodeBase64 } from 'vs/base/common/buffer'; --import { Emitter } from 'vs/base/common/event'; --import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; --import { parse } from 'vs/base/common/marshalling'; +-import { streamToBuffer } from 'vs/base/common/buffer'; +-import { CancellationToken } from 'vs/base/common/cancellation'; +-import { Emitter, Event } from 'vs/base/common/event'; +-import { Disposable } from 'vs/base/common/lifecycle'; -import { Schemas } from 'vs/base/common/network'; --import { posix } from 'vs/base/common/path'; -import { isEqual } from 'vs/base/common/resources'; --import { ltrim } from 'vs/base/common/strings'; -import { URI, UriComponents } from 'vs/base/common/uri'; +-import { generateUuid } from 'vs/base/common/uuid'; +-import { request } from 'vs/base/parts/request/browser/request'; +-import { localize } from 'vs/nls'; +-import { parseLogLevel } from 'vs/platform/log/common/log'; -import product from 'vs/platform/product/common/product'; --import { ISecretStorageProvider } from 'vs/platform/secrets/common/secrets'; --import { isFolderToOpen, isWorkspaceToOpen } from 'vs/platform/window/common/window'; -+import { URI } from 'vs/base/common/uri'; - import type { IWorkbenchConstructionOptions, IWorkspace, IWorkspaceProvider } from 'vs/workbench/browser/web.api'; --import { AuthenticationSessionInfo } from 'vs/workbench/services/authentication/browser/authenticationService'; --import type { IURLCallbackProvider } from 'vs/workbench/services/url/browser/urlService'; - import { create } from 'vs/workbench/workbench.web.main'; - --interface ISecretStorageCrypto { -- seal(data: string): Promise; -- unseal(data: string): Promise; --} -+(async function () { -+ // create workbench -+ const result = await fetch('/vscode/product.json') -+ const loadedConfig: IWorkbenchConstructionOptions = await result.json() - --class TransparentCrypto implements ISecretStorageCrypto { -- async seal(data: string): Promise { -- return data; -- } -- -- async unseal(data: string): Promise { -- return data; -- } --} +-import { isFolderToOpen, isWorkspaceToOpen } from 'vs/platform/windows/common/windows'; +-import { create, ICredentialsProvider, IHomeIndicator, IProductQualityChangeHandler, ISettingsSyncOptions, IURLCallbackProvider, IWelcomeBanner, IWindowIndicator, IWorkbenchConstructionOptions, IWorkspace, IWorkspaceProvider } from 'vs/workbench/workbench.web.api'; - --const enum AESConstants { -- ALGORITHM = 'AES-GCM', -- KEY_LENGTH = 256, -- IV_LENGTH = 12, --} +-function doCreateUri(path: string, queryValues: Map): URI { +- let query: string | undefined = undefined; - --class ServerKeyedAESCrypto implements ISecretStorageCrypto { -- private _serverKey: Uint8Array | undefined; +- if (queryValues) { +- let index = 0; +- queryValues.forEach((value, key) => { +- if (!query) { +- query = ''; +- } - -- /** Gets whether the algorithm is supported; requires a secure context */ -- public static supported() { -- return !!crypto.subtle; +- const prefix = (index++ === 0) ? '' : '&'; +- query += `${prefix}${key}=${encodeURIComponent(value)}`; +- }); - } - -- constructor(private readonly authEndpoint: string) { } -- -- async seal(data: string): Promise { -- // Get a new key and IV on every change, to avoid the risk of reusing the same key and IV pair with AES-GCM -- // (see also: https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams#properties) -- const iv = mainWindow.crypto.getRandomValues(new Uint8Array(AESConstants.IV_LENGTH)); -- // crypto.getRandomValues isn't a good-enough PRNG to generate crypto keys, so we need to use crypto.subtle.generateKey and export the key instead -- const clientKeyObj = await mainWindow.crypto.subtle.generateKey( -- { name: AESConstants.ALGORITHM as const, length: AESConstants.KEY_LENGTH as const }, -- true, -- ['encrypt', 'decrypt'] -- ); -- -- const clientKey = new Uint8Array(await mainWindow.crypto.subtle.exportKey('raw', clientKeyObj)); -- const key = await this.getKey(clientKey); -- const dataUint8Array = new TextEncoder().encode(data); -- const cipherText: ArrayBuffer = await mainWindow.crypto.subtle.encrypt( -- { name: AESConstants.ALGORITHM as const, iv }, -- key, -- dataUint8Array -- ); -- -- // Base64 encode the result and store the ciphertext, the key, and the IV in localStorage -- // Note that the clientKey and IV don't need to be secret -- const result = new Uint8Array([...clientKey, ...iv, ...new Uint8Array(cipherText)]); -- return encodeBase64(VSBuffer.wrap(result)); -- } +- return URI.parse(window.location.href).with({ path, query }); +-} - -- async unseal(data: string): Promise { -- // encrypted should contain, in order: the key (32-byte), the IV for AES-GCM (12-byte) and the ciphertext (which has the GCM auth tag at the end) -- // Minimum length must be 44 (key+IV length) + 16 bytes (1 block encrypted with AES - regardless of key size) -- const dataUint8Array = decodeBase64(data); +-interface ICredential { +- service: string; +- account: string; +- password: string; +-} - -- if (dataUint8Array.byteLength < 60) { -- throw Error('Invalid length for the value for credentials.crypto'); -- } +-class LocalStorageCredentialsProvider implements ICredentialsProvider { - -- const keyLength = AESConstants.KEY_LENGTH / 8; -- const clientKey = dataUint8Array.slice(0, keyLength); -- const iv = dataUint8Array.slice(keyLength, keyLength + AESConstants.IV_LENGTH); -- const cipherText = dataUint8Array.slice(keyLength + AESConstants.IV_LENGTH); +- static readonly CREDENTIALS_OPENED_KEY = 'credentials.provider'; - -- // Do the decryption and parse the result as JSON -- const key = await this.getKey(clientKey.buffer); -- const decrypted = await mainWindow.crypto.subtle.decrypt( -- { name: AESConstants.ALGORITHM as const, iv: iv.buffer }, -- key, -- cipherText.buffer -- ); +- private readonly authService: string | undefined; - -- return new TextDecoder().decode(new Uint8Array(decrypted)); -- } +- constructor() { +- let authSessionInfo: { readonly id: string, readonly accessToken: string, readonly providerId: string, readonly canSignOut?: boolean, readonly scopes: string[][] } | undefined; +- const authSessionElement = document.getElementById('vscode-workbench-auth-session'); +- const authSessionElementAttribute = authSessionElement ? authSessionElement.getAttribute('data-settings') : undefined; +- if (authSessionElementAttribute) { +- try { +- authSessionInfo = JSON.parse(authSessionElementAttribute); +- } catch (error) { /* Invalid session is passed. Ignore. */ } +- } - -- /** -- * Given a clientKey, returns the CryptoKey object that is used to encrypt/decrypt the data. -- * The actual key is (clientKey XOR serverKey) -- */ -- private async getKey(clientKey: Uint8Array): Promise { -- if (!clientKey || clientKey.byteLength !== AESConstants.KEY_LENGTH / 8) { -- throw Error('Invalid length for clientKey'); +- if (authSessionInfo) { +- // Settings Sync Entry +- this.setPassword(`${product.urlProtocol}.login`, 'account', JSON.stringify(authSessionInfo)); +- +- // Auth extension Entry +- this.authService = `${product.urlProtocol}-${authSessionInfo.providerId}.login`; +- this.setPassword(this.authService, 'account', JSON.stringify(authSessionInfo.scopes.map(scopes => ({ +- id: authSessionInfo!.id, +- scopes, +- accessToken: authSessionInfo!.accessToken +- })))); - } +- } - -- const serverKey = await this.getServerKeyPart(); -- const keyData = new Uint8Array(AESConstants.KEY_LENGTH / 8); +- private _credentials: ICredential[] | undefined; +- private get credentials(): ICredential[] { +- if (!this._credentials) { +- try { +- const serializedCredentials = window.localStorage.getItem(LocalStorageCredentialsProvider.CREDENTIALS_OPENED_KEY); +- if (serializedCredentials) { +- this._credentials = JSON.parse(serializedCredentials); +- } +- } catch (error) { +- // ignore +- } - -- for (let i = 0; i < keyData.byteLength; i++) { -- keyData[i] = clientKey[i]! ^ serverKey[i]!; +- if (!Array.isArray(this._credentials)) { +- this._credentials = []; +- } - } - -- return mainWindow.crypto.subtle.importKey( -- 'raw', -- keyData, -- { -- name: AESConstants.ALGORITHM as const, -- length: AESConstants.KEY_LENGTH as const, -- }, -- true, -- ['encrypt', 'decrypt'] -- ); +- return this._credentials; - } - -- private async getServerKeyPart(): Promise { -- if (this._serverKey) { -- return this._serverKey; -- } +- private save(): void { +- window.localStorage.setItem(LocalStorageCredentialsProvider.CREDENTIALS_OPENED_KEY, JSON.stringify(this.credentials)); +- } - -- let attempt = 0; -- let lastError: unknown | undefined; +- async getPassword(service: string, account: string): Promise { +- return this.doGetPassword(service, account); +- } - -- while (attempt <= 3) { -- try { -- const res = await fetch(this.authEndpoint, { credentials: 'include', method: 'POST' }); -- if (!res.ok) { -- throw new Error(res.statusText); +- private async doGetPassword(service: string, account?: string): Promise { +- for (const credential of this.credentials) { +- if (credential.service === service) { +- if (typeof account !== 'string' || account === credential.account) { +- return credential.password; - } -- const serverKey = new Uint8Array(await await res.arrayBuffer()); -- if (serverKey.byteLength !== AESConstants.KEY_LENGTH / 8) { -- throw Error(`The key retrieved by the server is not ${AESConstants.KEY_LENGTH} bit long.`); -- } -- this._serverKey = serverKey; -- return this._serverKey; -- } catch (e) { -- lastError = e; -- attempt++; -- -- // exponential backoff -- await new Promise(resolve => setTimeout(resolve, attempt * attempt * 100)); - } - } - -- throw lastError; +- return null; - } --} - --export class LocalStorageSecretStorageProvider implements ISecretStorageProvider { -- private readonly _storageKey = 'secrets.provider'; +- async setPassword(service: string, account: string, password: string): Promise { +- this.doDeletePassword(service, account); - -- private _secretsPromise: Promise> = this.load(); +- this.credentials.push({ service, account, password }); - -- type: 'in-memory' | 'persisted' | 'unknown' = 'persisted'; +- this.save(); - -- constructor( -- private readonly crypto: ISecretStorageCrypto, -- ) { } +- try { +- if (password && service === this.authService) { +- const value = JSON.parse(password); +- if (Array.isArray(value) && value.length === 0) { +- await this.logout(service); +- } +- } +- } catch (error) { +- console.log(error); +- } +- } - -- private async load(): Promise> { -- const record = this.loadAuthSessionFromElement(); -- // Get the secrets from localStorage -- const encrypted = localStorage.getItem(this._storageKey); -- if (encrypted) { +- async deletePassword(service: string, account: string): Promise { +- const result = await this.doDeletePassword(service, account); +- +- if (result && service === this.authService) { - try { -- const decrypted = JSON.parse(await this.crypto.unseal(encrypted)); -- return { ...record, ...decrypted }; -- } catch (err) { -- // TODO: send telemetry -- console.error('Failed to decrypt secrets from localStorage', err); -- localStorage.removeItem(this._storageKey); +- await this.logout(service); +- } catch (error) { +- console.log(error); - } - } - -- return record; +- return result; - } - -- private loadAuthSessionFromElement(): Record { -- let authSessionInfo: (AuthenticationSessionInfo & { scopes: string[][] }) | undefined; -- const authSessionElement = mainWindow.document.getElementById('vscode-workbench-auth-session'); -- const authSessionElementAttribute = authSessionElement ? authSessionElement.getAttribute('data-settings') : undefined; -- if (authSessionElementAttribute) { -- try { -- authSessionInfo = JSON.parse(authSessionElementAttribute); -- } catch (error) { /* Invalid session is passed. Ignore. */ } -- } +- private async doDeletePassword(service: string, account: string): Promise { +- let found = false; - -- if (!authSessionInfo) { -- return {}; -- } +- this._credentials = this.credentials.filter(credential => { +- if (credential.service === service && credential.account === account) { +- found = true; - -- const record: Record = {}; +- return false; +- } - -- // Settings Sync Entry -- record[`${product.urlProtocol}.loginAccount`] = JSON.stringify(authSessionInfo); +- return true; +- }); - -- // Auth extension Entry -- if (authSessionInfo.providerId !== 'github') { -- console.error(`Unexpected auth provider: ${authSessionInfo.providerId}. Expected 'github'.`); -- return record; +- if (found) { +- this.save(); - } - -- const authAccount = JSON.stringify({ extensionId: 'vscode.github-authentication', key: 'github.auth' }); -- record[authAccount] = JSON.stringify(authSessionInfo.scopes.map(scopes => ({ -- id: authSessionInfo.id, -- scopes, -- accessToken: authSessionInfo.accessToken -- }))); -- -- return record; +- return found; - } - -- async get(key: string): Promise { -- const secrets = await this._secretsPromise; -- return secrets[key]; +- async findPassword(service: string): Promise { +- return this.doGetPassword(service); - } -- async set(key: string, value: string): Promise { -- const secrets = await this._secretsPromise; -- secrets[key] = value; -- this._secretsPromise = Promise.resolve(secrets); -- this.save(); +- +- async findCredentials(service: string): Promise> { +- return this.credentials +- .filter(credential => credential.service === service) +- .map(({ account, password }) => ({ account, password })); - } -- async delete(key: string): Promise { -- const secrets = await this._secretsPromise; -- delete secrets[key]; -- this._secretsPromise = Promise.resolve(secrets); -- this.save(); +- +- private async logout(service: string): Promise { +- const queryValues: Map = new Map(); +- queryValues.set('logout', String(true)); +- queryValues.set('service', service); +- +- await request({ +- url: doCreateUri('/auth/logout', queryValues).toString(true) +- }, CancellationToken.None); - } - -- private async save(): Promise { -- try { -- const encrypted = await this.crypto.seal(JSON.stringify(await this._secretsPromise)); -- localStorage.setItem(this._storageKey, encrypted); -- } catch (err) { -- console.error(err); -- } +- async clear(): Promise { +- window.localStorage.removeItem(LocalStorageCredentialsProvider.CREDENTIALS_OPENED_KEY); - } -} - +-class PollingURLCallbackProvider extends Disposable implements IURLCallbackProvider { - --class LocalStorageURLCallbackProvider extends Disposable implements IURLCallbackProvider { -- -- private static REQUEST_ID = 0; +- static readonly FETCH_INTERVAL = 500; // fetch every 500ms +- static readonly FETCH_TIMEOUT = 5 * 60 * 1000; // ...but stop after 5min - -- private static QUERY_KEYS: ('scheme' | 'authority' | 'path' | 'query' | 'fragment')[] = [ -- 'scheme', -- 'authority', -- 'path', -- 'query', -- 'fragment' -- ]; +- static readonly QUERY_KEYS = { +- REQUEST_ID: 'vscode-requestId', +- SCHEME: 'vscode-scheme', +- AUTHORITY: 'vscode-authority', +- PATH: 'vscode-path', +- QUERY: 'vscode-query', +- FRAGMENT: 'vscode-fragment' +- }; - - private readonly _onCallback = this._register(new Emitter()); - readonly onCallback = this._onCallback.event; - -- private pendingCallbacks = new Set(); -- private lastTimeChecked = Date.now(); -- private checkCallbacksTimeout: unknown | undefined = undefined; -- private onDidChangeLocalStorageDisposable: IDisposable | undefined; -- -- constructor(private readonly _callbackRoute: string) { -- super(); -- } +- create(options?: Partial): URI { +- const queryValues: Map = new Map(); - -- create(options: Partial = {}): URI { -- const id = ++LocalStorageURLCallbackProvider.REQUEST_ID; -- const queryParams: string[] = [`vscode-reqid=${id}`]; +- const requestId = generateUuid(); +- queryValues.set(PollingURLCallbackProvider.QUERY_KEYS.REQUEST_ID, requestId); - -- for (const key of LocalStorageURLCallbackProvider.QUERY_KEYS) { -- const value = options[key]; +- const { scheme, authority, path, query, fragment } = options ? options : { scheme: undefined, authority: undefined, path: undefined, query: undefined, fragment: undefined }; - -- if (value) { -- queryParams.push(`vscode-${key}=${encodeURIComponent(value)}`); -- } +- if (scheme) { +- queryValues.set(PollingURLCallbackProvider.QUERY_KEYS.SCHEME, scheme); - } - -- // TODO@joao remove eventually -- // https://github.com/microsoft/vscode-dev/issues/62 -- // https://github.com/microsoft/vscode/blob/159479eb5ae451a66b5dac3c12d564f32f454796/extensions/github-authentication/src/githubServer.ts#L50-L50 -- if (!(options.authority === 'vscode.github-authentication' && options.path === '/dummy')) { -- const key = `vscode-web.url-callbacks[${id}]`; -- localStorage.removeItem(key); -- -- this.pendingCallbacks.add(id); -- this.startListening(); +- if (authority) { +- queryValues.set(PollingURLCallbackProvider.QUERY_KEYS.AUTHORITY, authority); - } - -- return URI.parse(mainWindow.location.href).with({ path: this._callbackRoute, query: queryParams.join('&') }); -- } +- if (path) { +- queryValues.set(PollingURLCallbackProvider.QUERY_KEYS.PATH, path); +- } - -- private startListening(): void { -- if (this.onDidChangeLocalStorageDisposable) { -- return; +- if (query) { +- queryValues.set(PollingURLCallbackProvider.QUERY_KEYS.QUERY, query); - } - -- const fn = () => this.onDidChangeLocalStorage(); -- mainWindow.addEventListener('storage', fn); -- this.onDidChangeLocalStorageDisposable = { dispose: () => mainWindow.removeEventListener('storage', fn) }; -- } +- if (fragment) { +- queryValues.set(PollingURLCallbackProvider.QUERY_KEYS.FRAGMENT, fragment); +- } - -- private stopListening(): void { -- this.onDidChangeLocalStorageDisposable?.dispose(); -- this.onDidChangeLocalStorageDisposable = undefined; -- } +- // Start to poll on the callback being fired +- this.periodicFetchCallback(requestId, Date.now()); - -- // this fires every time local storage changes, but we -- // don't want to check more often than once a second -- private async onDidChangeLocalStorage(): Promise { -- const ellapsed = Date.now() - this.lastTimeChecked; -- -- if (ellapsed > 1000) { -- this.checkCallbacks(); -- } else if (this.checkCallbacksTimeout === undefined) { -- this.checkCallbacksTimeout = setTimeout(() => { -- this.checkCallbacksTimeout = undefined; -- this.checkCallbacks(); -- }, 1000 - ellapsed); -- } +- return doCreateUri('/callback', queryValues); - } - -- private checkCallbacks(): void { -- let pendingCallbacks: Set | undefined; +- private async periodicFetchCallback(requestId: string, startTime: number): Promise { - -- for (const id of this.pendingCallbacks) { -- const key = `vscode-web.url-callbacks[${id}]`; -- const result = localStorage.getItem(key); +- // Ask server for callback results +- const queryValues: Map = new Map(); +- queryValues.set(PollingURLCallbackProvider.QUERY_KEYS.REQUEST_ID, requestId); - -- if (result !== null) { -- try { -- this._onCallback.fire(URI.revive(JSON.parse(result))); -- } catch (error) { -- console.error(error); -- } +- const result = await request({ +- url: doCreateUri('/fetch-callback', queryValues).toString(true) +- }, CancellationToken.None); - -- pendingCallbacks = pendingCallbacks ?? new Set(this.pendingCallbacks); -- pendingCallbacks.delete(id); -- localStorage.removeItem(key); +- // Check for callback results +- const content = await streamToBuffer(result.stream); +- if (content.byteLength > 0) { +- try { +- this._onCallback.fire(URI.revive(JSON.parse(content.toString()))); +- } catch (error) { +- console.error(error); - } -- } -- -- if (pendingCallbacks) { -- this.pendingCallbacks = pendingCallbacks; - -- if (this.pendingCallbacks.size === 0) { -- this.stopListening(); -- } +- return; // done - } - -- this.lastTimeChecked = Date.now(); +- // Continue fetching unless we hit the timeout +- if (Date.now() - startTime < PollingURLCallbackProvider.FETCH_TIMEOUT) { +- setTimeout(() => this.periodicFetchCallback(requestId, startTime), PollingURLCallbackProvider.FETCH_INTERVAL); +- } - } -} - -class WorkspaceProvider implements IWorkspaceProvider { - -- private static QUERY_PARAM_EMPTY_WINDOW = 'ew'; -- private static QUERY_PARAM_FOLDER = 'folder'; -- private static QUERY_PARAM_WORKSPACE = 'workspace'; +- static QUERY_PARAM_EMPTY_WINDOW = 'ew'; +- static QUERY_PARAM_FOLDER = 'folder'; +- static QUERY_PARAM_WORKSPACE = 'workspace'; - -- private static QUERY_PARAM_PAYLOAD = 'payload'; -- -- static create(config: IWorkbenchConstructionOptions & { folderUri?: UriComponents; workspaceUri?: UriComponents }) { -- let foundWorkspace = false; -- let workspace: IWorkspace; -- let payload = Object.create(null); -- -- const query = new URL(document.location.href).searchParams; -- query.forEach((value, key) => { -- switch (key) { -- -- // Folder -- case WorkspaceProvider.QUERY_PARAM_FOLDER: -- if (config.remoteAuthority && value.startsWith(posix.sep)) { -- // when connected to a remote and having a value -- // that is a path (begins with a `/`), assume this -- // is a vscode-remote resource as simplified URL. -- workspace = { folderUri: URI.from({ scheme: Schemas.vscodeRemote, path: value, authority: config.remoteAuthority }) }; -- } else { -- workspace = { folderUri: URI.parse(value) }; -- } -- foundWorkspace = true; -- break; -- -- // Workspace -- case WorkspaceProvider.QUERY_PARAM_WORKSPACE: -- if (config.remoteAuthority && value.startsWith(posix.sep)) { -- // when connected to a remote and having a value -- // that is a path (begins with a `/`), assume this -- // is a vscode-remote resource as simplified URL. -- workspace = { workspaceUri: URI.from({ scheme: Schemas.vscodeRemote, path: value, authority: config.remoteAuthority }) }; -- } else { -- workspace = { workspaceUri: URI.parse(value) }; -- } -- foundWorkspace = true; -- break; -- -- // Empty -- case WorkspaceProvider.QUERY_PARAM_EMPTY_WINDOW: -- workspace = undefined; -- foundWorkspace = true; -- break; -- -- // Payload -- case WorkspaceProvider.QUERY_PARAM_PAYLOAD: -- try { -- payload = parse(value); // use marshalling#parse() to revive potential URIs -- } catch (error) { -- console.error(error); // possible invalid JSON -- } -- break; -- } -- }); -- -- // If no workspace is provided through the URL, check for config -- // attribute from server -- if (!foundWorkspace) { -- if (config.folderUri) { -- workspace = { folderUri: URI.revive(config.folderUri) }; -- } else if (config.workspaceUri) { -- workspace = { workspaceUri: URI.revive(config.workspaceUri) }; -- } -- } -- -- return new WorkspaceProvider(workspace, payload, config); -- } +- static QUERY_PARAM_PAYLOAD = 'payload'; - - readonly trusted = true; -+ // Inject project specific utopia config into the product.json -+ const urlParams = new URLSearchParams(window.location.search) -+ const vsCodeSessionID = urlParams.get('vs_code_session_id')! - -- private constructor( +- +- constructor( - readonly workspace: IWorkspace, -- readonly payload: object, -- private readonly config: IWorkbenchConstructionOptions -- ) { -- } +- readonly payload: object +- ) { } - -- async open(workspace: IWorkspace, options?: { reuse?: boolean; payload?: object }): Promise { +- async open(workspace: IWorkspace, options?: { reuse?: boolean, payload?: object }): Promise { - if (options?.reuse && !options.payload && this.isSame(this.workspace, workspace)) { - return true; // return early if workspace and environment is not changing and we are reusing window - } -+ // Use this instance as the webview provider rather than hitting MS servers -+ const webviewEndpoint = `${window.location.origin}/vscode/vscode/vs/workbench/contrib/webview/browser/pre` - +- - const targetHref = this.createTargetUrl(workspace, options); - if (targetHref) { - if (options?.reuse) { -- mainWindow.location.href = targetHref; +- window.location.href = targetHref; - return true; - } else { - let result; -- if (isStandalone()) { -- result = mainWindow.open(targetHref, '_blank', 'toolbar=no'); // ensures to open another 'standalone' window! +- if (isStandalone) { +- result = window.open(targetHref, '_blank', 'toolbar=no'); // ensures to open another 'standalone' window! - } else { -- result = mainWindow.open(targetHref); +- result = window.open(targetHref); - } - - return !!result; - } - } - return false; -+ let config = { -+ ...loadedConfig, -+ webviewEndpoint: webviewEndpoint, -+ editSessionId: vsCodeSessionID - } - -- private createTargetUrl(workspace: IWorkspace, options?: { reuse?: boolean; payload?: object }): string | undefined { +- } +- +- private createTargetUrl(workspace: IWorkspace, options?: { reuse?: boolean, payload?: object }): string | undefined { - - // Empty - let targetHref: string | undefined = undefined; @@ -670,14 +538,12 @@ index f8875029a8a..3bdfd1bfa64 100644 - - // Folder - else if (isFolderToOpen(workspace)) { -- const queryParamFolder = this.encodeWorkspacePath(workspace.folderUri); -- targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_FOLDER}=${queryParamFolder}`; +- targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_FOLDER}=${encodeURIComponent(workspace.folderUri.toString())}`; - } - - // Workspace - else if (isWorkspaceToOpen(workspace)) { -- const queryParamWorkspace = this.encodeWorkspacePath(workspace.workspaceUri); -- targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_WORKSPACE}=${queryParamWorkspace}`; +- targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_WORKSPACE}=${encodeURIComponent(workspace.workspaceUri.toString())}`; - } - - // Append payload if any @@ -686,29 +552,6 @@ index f8875029a8a..3bdfd1bfa64 100644 - } - - return targetHref; -+ const workspace = { -+ folderUri: URI.parse(`${vsCodeSessionID}:/`) - } - -- private encodeWorkspacePath(uri: URI): string { -- if (this.config.remoteAuthority && uri.scheme === Schemas.vscodeRemote) { -- -- // when connected to a remote and having a folder -- // or workspace for that remote, only use the path -- // as query value to form shorter, nicer URLs. -- // however, we still need to `encodeURIComponent` -- // to ensure to preserve special characters, such -- // as `+` in the path. -- -- return encodeURIComponent(`${posix.sep}${ltrim(uri.path, posix.sep)}`).replaceAll('%2F', '/'); -+ if (workspace) { -+ const workspaceProvider: IWorkspaceProvider = { -+ workspace, -+ open: async (_workspace: IWorkspace, _options?: { reuse?: boolean, payload?: object }) => true, -+ trusted: true - } -- -- return encodeURIComponent(uri.toString(true)); - } - - private isSame(workspaceA: IWorkspace, workspaceB: IWorkspace): boolean { @@ -742,49 +585,280 @@ index f8875029a8a..3bdfd1bfa64 100644 - } -} - --function readCookie(name: string): string | undefined { -- const cookies = document.cookie.split('; '); -- for (const cookie of cookies) { -- if (cookie.startsWith(name + '=')) { -- return cookie.substring(name.length + 1); +-class WindowIndicator implements IWindowIndicator { +- +- readonly onDidChange = Event.None; +- +- readonly label: string; +- readonly tooltip: string; +- readonly command: string | undefined; +- +- constructor(workspace: IWorkspace) { +- let repositoryOwner: string | undefined = undefined; +- let repositoryName: string | undefined = undefined; +- +- if (workspace) { +- let uri: URI | undefined = undefined; +- if (isFolderToOpen(workspace)) { +- uri = workspace.folderUri; +- } else if (isWorkspaceToOpen(workspace)) { +- uri = workspace.workspaceUri; +- } +- +- if (uri?.scheme === 'github' || uri?.scheme === 'codespace') { +- [repositoryOwner, repositoryName] = uri.authority.split('+'); +- } +- } +- +- // Repo +- if (repositoryName && repositoryOwner) { +- this.label = localize('playgroundLabelRepository', "$(remote) Visual Studio Code Playground: {0}/{1}", repositoryOwner, repositoryName); +- this.tooltip = localize('playgroundRepositoryTooltip', "Visual Studio Code Playground: {0}/{1}", repositoryOwner, repositoryName); - } -- } - -- return undefined; +- // No Repo +- else { +- this.label = localize('playgroundLabel', "$(remote) Visual Studio Code Playground"); +- this.tooltip = localize('playgroundTooltip', "Visual Studio Code Playground"); +- } +- } -} - -(function () { - - // Find config by checking for DOM -- const configElement = mainWindow.document.getElementById('vscode-workbench-web-configuration'); +- const configElement = document.getElementById('vscode-workbench-web-configuration'); - const configElementAttribute = configElement ? configElement.getAttribute('data-settings') : undefined; - if (!configElement || !configElementAttribute) { - throw new Error('Missing web configuration element'); -+ config = { ...config, workspaceProvider } - } -- const config: IWorkbenchConstructionOptions & { folderUri?: UriComponents; workspaceUri?: UriComponents; callbackRoute: string } = JSON.parse(configElementAttribute); -- const secretStorageKeyPath = readCookie('vscode-secret-key-path'); -- const secretStorageCrypto = secretStorageKeyPath && ServerKeyedAESCrypto.supported() -- ? new ServerKeyedAESCrypto(secretStorageKeyPath) : new TransparentCrypto(); - -- // Create workbench -- create(mainWindow.document.body, { +- } +- +- const config: IWorkbenchConstructionOptions & { folderUri?: UriComponents, workspaceUri?: UriComponents } = JSON.parse(configElementAttribute); +- +- // Find workspace to open and payload +- let foundWorkspace = false; +- let workspace: IWorkspace; +- let payload = Object.create(null); +- let logLevel: string | undefined = undefined; +- +- const query = new URL(document.location.href).searchParams; +- query.forEach((value, key) => { +- switch (key) { +- +- // Folder +- case WorkspaceProvider.QUERY_PARAM_FOLDER: +- workspace = { folderUri: URI.parse(value) }; +- foundWorkspace = true; +- break; +- +- // Workspace +- case WorkspaceProvider.QUERY_PARAM_WORKSPACE: +- workspace = { workspaceUri: URI.parse(value) }; +- foundWorkspace = true; +- break; +- +- // Empty +- case WorkspaceProvider.QUERY_PARAM_EMPTY_WINDOW: +- workspace = undefined; +- foundWorkspace = true; +- break; +- +- // Payload +- case WorkspaceProvider.QUERY_PARAM_PAYLOAD: +- try { +- payload = JSON.parse(value); +- } catch (error) { +- console.error(error); // possible invalid JSON +- } +- break; +- +- // Log level +- case 'logLevel': +- logLevel = value; +- break; +- } +- }); +- +- // If no workspace is provided through the URL, check for config attribute from server +- if (!foundWorkspace) { +- if (config.folderUri) { +- workspace = { folderUri: URI.revive(config.folderUri) }; +- } else if (config.workspaceUri) { +- workspace = { workspaceUri: URI.revive(config.workspaceUri) }; +- } else { +- workspace = undefined; +- } +- } +- +- // Workspace Provider +- const workspaceProvider = new WorkspaceProvider(workspace, payload); +- +- // Home Indicator +- const homeIndicator: IHomeIndicator = { +- href: 'https://github.com/microsoft/vscode', +- icon: 'code', +- title: localize('home', "Home") +- }; +- +- // Welcome Banner +- const welcomeBanner: IWelcomeBanner = { +- message: localize('welcomeBannerMessage', "{0} Web. Browser based playground for testing.", product.nameShort), +- actions: [{ +- href: 'https://github.com/microsoft/vscode', +- label: localize('learnMore', "Learn More") +- }] +- }; +- +- // Window indicator (unless connected to a remote) +- let windowIndicator: WindowIndicator | undefined = undefined; +- if (!workspaceProvider.hasRemote()) { +- windowIndicator = new WindowIndicator(workspace); +- } +- +- // Product Quality Change Handler +- const productQualityChangeHandler: IProductQualityChangeHandler = (quality) => { +- let queryString = `quality=${quality}`; +- +- // Save all other query params we might have +- const query = new URL(document.location.href).searchParams; +- query.forEach((value, key) => { +- if (key !== 'quality') { +- queryString += `&${key}=${value}`; +- } +- }); +- +- window.location.href = `${window.location.origin}?${queryString}`; +- }; +- +- // settings sync options +- const settingsSyncOptions: ISettingsSyncOptions | undefined = config.settingsSyncOptions ? { +- enabled: config.settingsSyncOptions.enabled, +- } : undefined; +- +- // Finally create workbench +- create(document.body, { - ...config, -- windowIndicator: config.windowIndicator ?? { label: '$(remote)', tooltip: `${product.nameShort} Web` }, -- settingsSyncOptions: config.settingsSyncOptions ? { enabled: config.settingsSyncOptions.enabled, } : undefined, -- workspaceProvider: WorkspaceProvider.create(config), -- urlCallbackProvider: new LocalStorageURLCallbackProvider(config.callbackRoute), -- secretStorageProvider: config.remoteAuthority && !secretStorageKeyPath -- ? undefined /* with a remote without embedder-preferred storage, store on the remote */ -- : new LocalStorageSecretStorageProvider(secretStorageCrypto), +- developmentOptions: { +- logLevel: logLevel ? parseLogLevel(logLevel) : undefined, +- ...config.developmentOptions +- }, +- settingsSyncOptions, +- homeIndicator, +- windowIndicator, +- welcomeBanner, +- productQualityChangeHandler, +- workspaceProvider, +- urlCallbackProvider: new PollingURLCallbackProvider(), +- credentialsProvider: new LocalStorageCredentialsProvider() - }); -+ create(mainWindow.document.body, config) - })(); +-})(); ++import { ++ create, ++ IWorkspace, ++ IWorkbenchConstructionOptions, ++ IWorkspaceProvider, ++} from 'vs/workbench/workbench.web.api' ++import { URI } from 'vs/base/common/uri' ++import { setupVSCodeEventListenersForProject } from 'utopia-vscode-common' ++ ++// TODO revisit these dummy parts so that they can return something rather than nothing ++// import { ++// getSingletonServiceDescriptors, ++// registerSingleton, ++// } from 'vs/platform/instantiation/common/extensions' ++// import { BrandedService, ServiceIdentifier } from 'vs/platform/instantiation/common/instantiation' ++// import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors' ++ ++// const _registry = getSingletonServiceDescriptors() ++ ++// function replaceRegisteredSingleton( ++// id: ServiceIdentifier, ++// ctor: new (...services: Services) => T, ++// supportsDelayedInstantiation?: boolean, ++// ): void { ++// const index = _registry.findIndex((tuple) => tuple[0] === id) ++// if (index > 0) { ++// _registry[index] = [ ++// id, ++// new SyncDescriptor(ctor as new (...args: any[]) => T, [], supportsDelayedInstantiation), ++// ] ++// } else { ++// registerSingleton(id, ctor) ++// } ++// } ++ ++// // Replace services for the parts we don't want to use - ++// // We have to import the original part first to ensure it isn't registered later ++// import 'vs/workbench/browser/parts/panel/panelPart' ++// import { PanelPart } from 'vs/workbench/browser/parts/dummies/panelPart' ++// import { IPanelService } from 'vs/workbench/services/panel/common/panelService' ++// replaceRegisteredSingleton(IPanelService, PanelPart) ++ ++// import 'vs/workbench/browser/parts/sidebar/sidebarPart' ++// import { SidebarPart } from 'vs/workbench/browser/parts/dummies/sidebarPart' ++// import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet' ++// replaceRegisteredSingleton(IViewletService, SidebarPart) ++ ++// import 'vs/workbench/browser/parts/activitybar/activitybarPart' ++// import { ActivitybarPart } from 'vs/workbench/browser/parts/dummies/activitybarPart' ++// import { IActivityBarService } from 'vs/workbench/services/activityBar/browser/activityBarService' ++// replaceRegisteredSingleton(IActivityBarService, ActivitybarPart) ++ ++// import 'vs/workbench/browser/parts/titlebar/titlebarPart' ++// import { TitlebarPart } from 'vs/workbench/browser/parts/dummies/titlebarPart' ++// import { ITitleService } from 'vs/workbench/services/title/common/titleService' ++// replaceRegisteredSingleton(ITitleService, TitlebarPart) ++ ++// import 'vs/workbench/browser/parts/statusbar/statusbarPart' ++// import { StatusbarPart } from 'vs/workbench/browser/parts/dummies/statusbarPart' ++// import { IStatusbarService } from 'vs/workbench/services/statusbar/common/statusbar' ++// replaceRegisteredSingleton(IStatusbarService, StatusbarPart) ++ ++;(async function () { ++ // create workbench ++ const result = await fetch('/vscode/product.json') ++ const loadedConfig: IWorkbenchConstructionOptions = await result.json() ++ ++ // Inject project specific utopia config into the product.json ++ const urlParams = new URLSearchParams(window.location.search) ++ const vsCodeSessionID = urlParams.get('vs_code_session_id')! ++ ++ // Use this instance as the webview provider rather than hitting MS servers ++ const webviewEndpoint = `${window.location.origin}/vscode/vscode/vs/workbench/contrib/webview/browser/pre` ++ ++ let config = { ++ ...loadedConfig, ++ folderUri: { ++ scheme: vsCodeSessionID, ++ authority: '', ++ path: `/`, ++ query: '', ++ fragment: '', ++ }, ++ webviewEndpoint: webviewEndpoint, ++ } ++ ++ const workspace = { folderUri: URI.revive(config.folderUri) } ++ ++ if (workspace) { ++ const workspaceProvider: IWorkspaceProvider = { ++ workspace, ++ open: async (workspace: IWorkspace, options?: { reuse?: boolean, payload?: object }) => true, ++ trusted: true ++ } ++ config = { ...config, workspaceProvider } ++ } ++ ++ setupVSCodeEventListenersForProject(vsCodeSessionID) ++ ++ create(document.body, config) ++})() +\ No newline at end of file diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts -index 294e7030695..46730d6d518 100644 +index 7d8189c50be..125b6c89d0b 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts -@@ -3093,7 +3093,7 @@ class EditorMinimap extends BaseEditorOption { - assert.ok(typeof result === 'boolean', testErrorMessage('native-is-elevated')); - }); +diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts +index 6b59c1e9faa..a9192e938ef 100644 +--- a/src/vs/workbench/browser/layout.ts ++++ b/src/vs/workbench/browser/layout.ts +@@ -54,6 +54,7 @@ import { AuxiliaryBarPart } from 'vs/workbench/browser/parts/auxiliarybar/auxili + export enum Settings { + ACTIVITYBAR_VISIBLE = 'workbench.activityBar.visible', + STATUSBAR_VISIBLE = 'workbench.statusBar.visible', ++ SIDEBAR_VISIBLE = 'workbench.sideBar.visible', -- test('native-keymap', async () => { -- const keyMap = await import('native-keymap'); -- assert.ok(typeof keyMap.getCurrentKeyboardLayout === 'function', testErrorMessage('native-keymap')); -- -- const result = keyMap.getCurrentKeyboardLayout(); -- assert.ok(result, testErrorMessage('native-keymap')); -- }); -+ // I removed everything involving native-keymap as that was causing issues with the build involving Electron (which we obviously don't care about) + SIDEBAR_POSITION = 'workbench.sideBar.location', + PANEL_POSITION = 'workbench.panel.defaultLocation', +@@ -190,16 +191,16 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi + windowBorder: false, + + menuBar: { +- visibility: 'classic' as MenuBarVisibility, ++ visibility: 'hidden' as MenuBarVisibility, + toggled: false + }, - test('native-watchdog', async () => { - const watchDog = await import('native-watchdog'); -diff --git a/src/vs/platform/keyboardLayout/electron-main/keyboardLayoutMainService.ts b/src/vs/platform/keyboardLayout/electron-main/keyboardLayoutMainService.ts -index 1d17e4c709e..00109563f5c 100644 ---- a/src/vs/platform/keyboardLayout/electron-main/keyboardLayoutMainService.ts -+++ b/src/vs/platform/keyboardLayout/electron-main/keyboardLayoutMainService.ts -@@ -3,8 +3,6 @@ - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ + activityBar: { +- hidden: false ++ hidden: true + }, --import type * as nativeKeymap from 'native-keymap'; --import * as platform from 'vs/base/common/platform'; - import { Emitter } from 'vs/base/common/event'; - import { Disposable } from 'vs/base/common/lifecycle'; - import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -@@ -45,29 +43,10 @@ export class KeyboardLayoutMainService extends Disposable implements INativeKeyb - return this._initPromise; - } + sideBar: { +- hidden: false, ++ hidden: true, + position: Position.LEFT, + width: 300, + viewletToRestore: undefined as string | undefined +@@ -214,7 +215,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi + }, -- private async _doInitialize(): Promise { -- const nativeKeymapMod = await import('native-keymap'); -- -- this._keyboardLayoutData = readKeyboardLayoutData(nativeKeymapMod); -- if (!platform.isCI) { -- // See https://github.com/microsoft/vscode/issues/152840 -- // Do not register the keyboard layout change listener in CI because it doesn't work -- // on the build machines and it just adds noise to the build logs. -- nativeKeymapMod.onDidChangeKeyboardLayout(() => { -- this._keyboardLayoutData = readKeyboardLayoutData(nativeKeymapMod); -- this._onDidChangeKeyboardLayout.fire(this._keyboardLayoutData); -- }); -- } -- } -+ private async _doInitialize(): Promise {} + panel: { +- hidden: false, ++ hidden: true, + position: Position.BOTTOM, + lastNonMaximizedWidth: 300, + lastNonMaximizedHeight: 300, +@@ -228,7 +229,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi + }, + + statusBar: { +- hidden: false ++ hidden: true + }, + + views: { +@@ -513,13 +514,13 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi + this.state.fullscreen = isFullscreen(); + + // Menubar visibility +- this.state.menuBar.visibility = getMenuBarVisibility(this.configurationService); ++ this.state.menuBar.visibility = 'hidden'; + + // Activity bar visibility +- this.state.activityBar.hidden = !this.configurationService.getValue(Settings.ACTIVITYBAR_VISIBLE); ++ this.state.activityBar.hidden = true; + + // Sidebar visibility +- this.state.sideBar.hidden = this.storageService.getBoolean(Storage.SIDEBAR_HIDDEN, StorageScope.WORKSPACE, this.contextService.getWorkbenchState() === WorkbenchState.EMPTY); ++ this.state.sideBar.hidden = true; + + // Sidebar position + this.state.sideBar.position = (this.configurationService.getValue(Settings.SIDEBAR_POSITION) === 'right') ? Position.RIGHT : Position.LEFT; +@@ -552,7 +553,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi + this.state.editor.editorsToOpen = this.resolveEditorsToOpen(fileService, this.contextService); + + // Panel visibility +- this.state.panel.hidden = this.storageService.getBoolean(Storage.PANEL_HIDDEN, StorageScope.WORKSPACE, true); ++ this.state.panel.hidden = true; + + // Whether or not the panel was last maximized + this.state.panel.wasLastMaximized = this.storageService.getBoolean(Storage.PANEL_LAST_IS_MAXIMIZED, StorageScope.WORKSPACE, false); +@@ -590,7 +591,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi + this.state.panel.lastNonMaximizedWidth = this.storageService.getNumber(Storage.PANEL_LAST_NON_MAXIMIZED_WIDTH, StorageScope.GLOBAL, 300); + + // Statusbar visibility +- this.state.statusBar.hidden = !this.configurationService.getValue(Settings.STATUSBAR_VISIBLE); ++ this.state.statusBar.hidden = true; - public async getKeyboardLayoutData(): Promise { - await this._initialize(); - return this._keyboardLayoutData!; + // Zen mode enablement + this.state.zenMode.restore = this.storageService.getBoolean(Storage.ZEN_MODE_ENABLED, StorageScope.WORKSPACE, false) && this.configurationService.getValue(Settings.ZEN_MODE_RESTORE); +@@ -1227,7 +1228,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi + } } - } -- --function readKeyboardLayoutData(nativeKeymapMod: typeof nativeKeymap): IKeyboardLayoutData { -- const keyboardMapping = nativeKeymapMod.getKeyMap(); -- const keyboardLayoutInfo = nativeKeymapMod.getCurrentKeyboardLayout(); -- return { keyboardMapping, keyboardLayoutInfo }; --} -diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts -index b8232cb8490..0ce6a8e7002 100644 ---- a/src/vs/workbench/browser/layout.ts -+++ b/src/vs/workbench/browser/layout.ts -@@ -1203,40 +1203,40 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi - if (this.initialized) { - switch (part) { -- case Parts.TITLEBAR_PART: -- return this.workbenchGrid.isViewVisible(this.titleBarPartView); -- case Parts.SIDEBAR_PART: -- return !this.stateModel.getRuntimeValue(LayoutStateKeys.SIDEBAR_HIDDEN); -- case Parts.PANEL_PART: -- return !this.stateModel.getRuntimeValue(LayoutStateKeys.PANEL_HIDDEN); -- case Parts.AUXILIARYBAR_PART: -- return !this.stateModel.getRuntimeValue(LayoutStateKeys.AUXILIARYBAR_HIDDEN); -- case Parts.STATUSBAR_PART: -- return !this.stateModel.getRuntimeValue(LayoutStateKeys.STATUSBAR_HIDDEN); -- case Parts.ACTIVITYBAR_PART: -- return !this.stateModel.getRuntimeValue(LayoutStateKeys.ACTIVITYBAR_HIDDEN); -+ // case Parts.TITLEBAR_PART: -+ // return this.workbenchGrid.isViewVisible(this.titleBarPartView); -+ // case Parts.SIDEBAR_PART: -+ // return !this.stateModel.getRuntimeValue(LayoutStateKeys.SIDEBAR_HIDDEN); -+ // case Parts.PANEL_PART: -+ // return !this.stateModel.getRuntimeValue(LayoutStateKeys.PANEL_HIDDEN); -+ // case Parts.AUXILIARYBAR_PART: -+ // return !this.stateModel.getRuntimeValue(LayoutStateKeys.AUXILIARYBAR_HIDDEN); -+ // case Parts.STATUSBAR_PART: -+ // return !this.stateModel.getRuntimeValue(LayoutStateKeys.STATUSBAR_HIDDEN); -+ // case Parts.ACTIVITYBAR_PART: -+ // return !this.stateModel.getRuntimeValue(LayoutStateKeys.ACTIVITYBAR_HIDDEN); - case Parts.EDITOR_PART: - return !this.stateModel.getRuntimeValue(LayoutStateKeys.EDITOR_HIDDEN); -- case Parts.BANNER_PART: -- return this.workbenchGrid.isViewVisible(this.bannerPartView); -+ // case Parts.BANNER_PART: -+ // return this.workbenchGrid.isViewVisible(this.bannerPartView); - default: - return false; // any other part cannot be hidden - } +- private setStatusBarHidden(hidden: boolean, skipLayout?: boolean): void { ++ private setStatusBarHidden(_hidden: boolean, skipLayout?: boolean): void { ++ const hidden = true; + this.state.statusBar.hidden = hidden; + + // Adjust CSS +@@ -1453,7 +1455,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } + } + +- private setActivityBarHidden(hidden: boolean, skipLayout?: boolean): void { ++ private setActivityBarHidden(_hidden: boolean, skipLayout?: boolean): void { ++ const hidden = true; + this.state.activityBar.hidden = hidden; + + // Propagate to grid +@@ -1500,7 +1503,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi + ]); + } - switch (part) { -- case Parts.TITLEBAR_PART: -- return shouldShowCustomTitleBar(this.configurationService, mainWindow, this.state.runtime.menuBar.toggled, this.isZenModeActive()); -- case Parts.SIDEBAR_PART: -- return !this.stateModel.getRuntimeValue(LayoutStateKeys.SIDEBAR_HIDDEN); -- case Parts.PANEL_PART: -- return !this.stateModel.getRuntimeValue(LayoutStateKeys.PANEL_HIDDEN); -- case Parts.AUXILIARYBAR_PART: -- return !this.stateModel.getRuntimeValue(LayoutStateKeys.AUXILIARYBAR_HIDDEN); -- case Parts.STATUSBAR_PART: -- return !this.stateModel.getRuntimeValue(LayoutStateKeys.STATUSBAR_HIDDEN); -- case Parts.ACTIVITYBAR_PART: -- return !this.stateModel.getRuntimeValue(LayoutStateKeys.ACTIVITYBAR_HIDDEN); -+ // case Parts.TITLEBAR_PART: -+ // return shouldShowCustomTitleBar(this.configurationService, mainWindow, this.state.runtime.menuBar.toggled, this.isZenModeActive()); -+ // case Parts.SIDEBAR_PART: -+ // return !this.stateModel.getRuntimeValue(LayoutStateKeys.SIDEBAR_HIDDEN); -+ // case Parts.PANEL_PART: -+ // return !this.stateModel.getRuntimeValue(LayoutStateKeys.PANEL_HIDDEN); -+ // case Parts.AUXILIARYBAR_PART: -+ // return !this.stateModel.getRuntimeValue(LayoutStateKeys.AUXILIARYBAR_HIDDEN); -+ // case Parts.STATUSBAR_PART: -+ // return !this.stateModel.getRuntimeValue(LayoutStateKeys.STATUSBAR_HIDDEN); -+ // case Parts.ACTIVITYBAR_PART: -+ // return !this.stateModel.getRuntimeValue(LayoutStateKeys.ACTIVITYBAR_HIDDEN); - case Parts.EDITOR_PART: - return !this.stateModel.getRuntimeValue(LayoutStateKeys.EDITOR_HIDDEN); - default: -@@ -1293,7 +1293,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi +- private setSideBarHidden(hidden: boolean, skipLayout?: boolean): void { ++ private setSideBarHidden(_hidden: boolean, skipLayout?: boolean): void { ++ const hidden = true + this.state.sideBar.hidden = hidden; + + // Adjust CSS +@@ -1560,7 +1564,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi + return viewContainerModel.activeViewDescriptors.length >= 1; } - private isZenModeActive(): boolean { -- return this.stateModel.getRuntimeValue(LayoutStateKeys.ZEN_MODE_ACTIVE); -+ return true; // Always true to remove all of the other UI +- private setPanelHidden(hidden: boolean, skipLayout?: boolean): void { ++ private setPanelHidden(_hidden: boolean, skipLayout?: boolean): void { ++ const hidden = true; + const wasHidden = this.state.panel.hidden; + this.state.panel.hidden = hidden; + +@@ -1771,7 +1776,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi + return this.state.sideBar.position; } - private setZenModeActive(active: boolean) { +- setMenubarVisibility(visibility: MenuBarVisibility, skipLayout: boolean): void { ++ setMenubarVisibility(_visibility: MenuBarVisibility, skipLayout: boolean): void { ++ const visibility = 'hidden'; + if (this.state.menuBar.visibility !== visibility) { + this.state.menuBar.visibility = visibility; + diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts -index ae4d22c9eaa..e342fb223d8 100644 +index 000fd151ee2..d21a981e220 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts -@@ -784,12 +784,12 @@ const registry = Registry.as(ConfigurationExtensions.Con - 'properties': { - 'zenMode.fullScreen': { +@@ -7,7 +7,7 @@ import product from 'vs/platform/product/common/product'; + import { Registry } from 'vs/platform/registry/common/platform'; + import { localize } from 'vs/nls'; + import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from 'vs/platform/configuration/common/configurationRegistry'; +-import { isMacintosh, isWindows, isLinux, isWeb, isNative } from 'vs/base/common/platform'; ++import { isMacintosh, isWeb, isNative } from 'vs/base/common/platform'; + import { workbenchConfigurationNodeBase } from 'vs/workbench/common/configuration'; + import { isStandalone } from 'vs/base/browser/browser'; + +@@ -278,6 +278,12 @@ const registry = Registry.as(ConfigurationExtensions.Con + 'default': 'left', + 'description': localize('sideBarLocation', "Controls the location of the sidebar and activity bar. They can either show on the left or right of the workbench.") + }, ++ 'workbench.sideBar.visible': { ++ 'type': 'boolean', ++ 'default': false, ++ 'description': localize('sideBarVisibility', "Controls the visibility of the side bar at the side of the workbench."), ++ 'included': false ++ }, + 'workbench.panel.defaultLocation': { + 'type': 'string', + 'enum': ['left', 'bottom', 'right'], +@@ -297,13 +303,15 @@ const registry = Registry.as(ConfigurationExtensions.Con + }, + 'workbench.statusBar.visible': { 'type': 'boolean', - 'default': true, +- 'description': localize('statusBarVisibility', "Controls the visibility of the status bar at the bottom of the workbench.") + 'default': false, - 'description': localize('zenMode.fullScreen', "Controls whether turning on Zen Mode also puts the workbench into full screen mode.") ++ 'description': localize('statusBarVisibility', "Controls the visibility of the status bar at the bottom of the workbench."), ++ 'included': false }, - 'zenMode.centerLayout': { + 'workbench.activityBar.visible': { 'type': 'boolean', - 'default': true, +- 'description': localize('activityBarVisibility', "Controls the visibility of the activity bar in the workbench.") + 'default': false, - 'description': localize('zenMode.centerLayout', "Controls whether turning on Zen Mode also centers the layout.") ++ 'description': localize('activityBarVisibility', "Controls the visibility of the activity bar in the workbench."), ++ 'included': false }, - 'zenMode.showTabs': { -@@ -815,7 +815,7 @@ const registry = Registry.as(ConfigurationExtensions.Con + 'workbench.activityBar.iconClickBehavior': { + 'type': 'string', +@@ -420,26 +428,26 @@ const registry = Registry.as(ConfigurationExtensions.Con + localize('window.menuBarVisibility.hidden', "Menu is always hidden."), + localize('window.menuBarVisibility.compact', "Menu is displayed as a compact button in the sidebar. This value is ignored when `#window.titleBarStyle#` is `native`.") + ], +- 'default': isWeb ? 'compact' : 'classic', ++ 'default': 'hidden', + 'scope': ConfigurationScope.APPLICATION, + 'markdownDescription': isMacintosh ? + localize('menuBarVisibility.mac', "Control the visibility of the menu bar. A setting of 'toggle' means that the menu bar is hidden and executing `Focus Application Menu` will show it. A setting of 'compact' will move the menu into the sidebar.") : + localize('menuBarVisibility', "Control the visibility of the menu bar. A setting of 'toggle' means that the menu bar is hidden and a single press of the Alt key will show it. A setting of 'compact' will move the menu into the sidebar."), +- 'included': isWindows || isLinux || isWeb ++ 'included': false }, - 'zenMode.hideLineNumbers': { + 'window.enableMenuBarMnemonics': { 'type': 'boolean', - 'default': true, + 'default': false, - 'description': localize('zenMode.hideLineNumbers', "Controls whether turning on Zen Mode also hides the editor line numbers.") + 'scope': ConfigurationScope.APPLICATION, + 'description': localize('enableMenuBarMnemonics', "Controls whether the main menus can be opened via Alt-key shortcuts. Disabling mnemonics allows to bind these Alt-key shortcuts to editor commands instead."), +- 'included': isWindows || isLinux ++ 'included': false }, - 'zenMode.restore': { -diff --git a/src/vs/workbench/browser/workbench.ts b/src/vs/workbench/browser/workbench.ts -index b0688133537..4353093fd5b 100644 ---- a/src/vs/workbench/browser/workbench.ts -+++ b/src/vs/workbench/browser/workbench.ts -@@ -50,6 +50,8 @@ import { AccessibilityProgressSignalScheduler } from 'vs/platform/accessibilityS - import { setProgressAcccessibilitySignalScheduler } from 'vs/base/browser/ui/progressbar/progressAccessibilitySignal'; - import { AccessibleViewRegistry } from 'vs/platform/accessibility/browser/accessibleViewRegistry'; - import { NotificationAccessibleView } from 'vs/workbench/browser/parts/notifications/notificationAccessibleView'; -+import { ICommandService } from '../../platform/commands/common/commands'; -+import { isFromUtopiaToVSCodeMessage, messageListenersReady } from 'utopia-vscode-common'; - - export interface IWorkbenchOptions { - -@@ -193,6 +195,37 @@ export class Workbench extends Layout { - this.restore(lifecycleService); - }); - -+ // Chain off of the previous one to ensure the ordering of changes is maintained. -+ // FIXME Do we still need this? -+ let applyProjectChangesCoordinator: Promise = Promise.resolve() -+ -+ let intervalID: number | null = null -+ -+ mainWindow.addEventListener('message', (messageEvent: MessageEvent) => { -+ const { data } = messageEvent; -+ if (isFromUtopiaToVSCodeMessage(data)) { -+ const commandService = this.serviceCollection.get(ICommandService); -+ if (commandService == null) { -+ console.error(`There is no command service`); -+ } else { -+ if (intervalID != null) { -+ window.clearInterval(intervalID) -+ } -+ applyProjectChangesCoordinator = applyProjectChangesCoordinator.then(async () => { -+ (commandService as ICommandService).executeCommand('utopia.toVSCodeMessage', data); -+ }) -+ } -+ } -+ }); -+ -+ intervalID = window.setInterval(() => { -+ try { -+ window.top?.postMessage(messageListenersReady(), '*') -+ } catch (error) { -+ console.error('Error posting messageListenersReady', error) -+ } -+ }, 500) -+ - return instantiationService; - } catch (error) { - onUnexpectedError(error); + 'window.customMenuBarAltFocus': { + 'type': 'boolean', +- 'default': true, ++ 'default': false, + 'scope': ConfigurationScope.APPLICATION, + 'markdownDescription': localize('customMenuBarAltFocus', "Controls whether the menu bar will be focused by pressing the Alt-key. This setting has no effect on toggling the menu bar with the Alt-key."), +- 'included': isWindows || isLinux ++ 'included': false + }, + 'window.openFilesInNewWindow': { + 'type': 'string', diff --git a/src/vs/workbench/contrib/files/browser/fileCommands.ts b/src/vs/workbench/contrib/files/browser/fileCommands.ts -index 81bf68c3e0b..1075398b4b8 100644 +index b9beba774a5..40627803528 100644 --- a/src/vs/workbench/contrib/files/browser/fileCommands.ts +++ b/src/vs/workbench/contrib/files/browser/fileCommands.ts -@@ -53,6 +53,7 @@ import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; - import { RemoveRootFolderAction } from 'vs/workbench/browser/actions/workspaceActions'; - import { OpenEditorsView } from 'vs/workbench/contrib/files/browser/views/openEditorsView'; - import { ExplorerView } from 'vs/workbench/contrib/files/browser/views/explorerView'; -+import { mainWindow } from '../../../../base/browser/window'; - - export const openWindowCommand = (accessor: ServicesAccessor, toOpen: IWindowOpenable[], options?: IOpenWindowOptions) => { - if (Array.isArray(toOpen)) { -@@ -734,3 +735,43 @@ CommandsRegistry.registerCommand({ - }); +@@ -567,6 +567,38 @@ CommandsRegistry.registerCommand({ } }); -+ + +CommandsRegistry.registerCommand({ + id: 'workbench.action.files.revertResource', + handler: async (accessor, resource: URI) => { @@ -1064,76 +1121,148 @@ index 81bf68c3e0b..1075398b4b8 100644 + } +}); + -+ -+CommandsRegistry.registerCommand({ -+ id: 'utopia.toUtopiaMessage', -+ handler: async (_accessor, message: any) => { -+ mainWindow.top?.postMessage(message); -+ } -+}); -\ No newline at end of file + CommandsRegistry.registerCommand({ + id: REMOVE_ROOT_FOLDER_COMMAND_ID, + handler: (accessor, resource: URI | object) => { diff --git a/src/vs/workbench/contrib/files/browser/files.contribution.ts b/src/vs/workbench/contrib/files/browser/files.contribution.ts -index d525ba5860c..56ae5cfbc70 100644 +index 114f1357403..bec56e38917 100644 --- a/src/vs/workbench/contrib/files/browser/files.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/files.contribution.ts -@@ -267,7 +267,7 @@ configurationRegistry.registerConfiguration({ - nls.localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'files.autoSave.onFocusChange' }, "An editor with changes is automatically saved when the editor loses focus."), - nls.localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'files.autoSave.onWindowChange' }, "An editor with changes is automatically saved when the window loses focus.") +@@ -234,12 +234,12 @@ configurationRegistry.registerConfiguration({ + nls.localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'files.autoSave.onFocusChange' }, "A dirty editor is automatically saved when the editor loses focus."), + nls.localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'files.autoSave.onWindowChange' }, "A dirty editor is automatically saved when the window loses focus.") ], - 'default': isWeb ? AutoSaveConfiguration.AFTER_DELAY : AutoSaveConfiguration.OFF, + 'default': AutoSaveConfiguration.OFF, - 'markdownDescription': nls.localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'autoSave' }, "Controls [auto save](https://code.visualstudio.com/docs/editor/codebasics#_save-auto-save) of editors that have unsaved changes.", AutoSaveConfiguration.OFF, AutoSaveConfiguration.AFTER_DELAY, AutoSaveConfiguration.ON_FOCUS_CHANGE, AutoSaveConfiguration.ON_WINDOW_CHANGE, AutoSaveConfiguration.AFTER_DELAY), - scope: ConfigurationScope.LANGUAGE_OVERRIDABLE + 'markdownDescription': nls.localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'autoSave' }, "Controls auto save of dirty editors. Read more about autosave [here](https://code.visualstudio.com/docs/editor/codebasics#_save-auto-save).", AutoSaveConfiguration.OFF, AutoSaveConfiguration.AFTER_DELAY, AutoSaveConfiguration.ON_FOCUS_CHANGE, AutoSaveConfiguration.ON_WINDOW_CHANGE, AutoSaveConfiguration.AFTER_DELAY) + }, + 'files.autoSaveDelay': { + 'type': 'number', +- 'default': 1000, ++ 'default': 100, + 'markdownDescription': nls.localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'autoSaveDelay' }, "Controls the delay in ms after which a dirty editor is saved automatically. Only applies when `#files.autoSave#` is set to `{0}`.", AutoSaveConfiguration.AFTER_DELAY) }, -diff --git a/src/vs/workbench/services/extensionManagement/browser/builtinExtensionsScannerService.ts b/src/vs/workbench/services/extensionManagement/browser/builtinExtensionsScannerService.ts -index e1913359976..3834f2dde41 100644 ---- a/src/vs/workbench/services/extensionManagement/browser/builtinExtensionsScannerService.ts -+++ b/src/vs/workbench/services/extensionManagement/browser/builtinExtensionsScannerService.ts -@@ -51,10 +51,11 @@ export class BuiltinExtensionsScannerService implements IBuiltinExtensionsScanne - if (builtinExtensionsServiceUrl) { - let bundledExtensions: IBundledExtension[] = []; + 'files.watcherExclude': { +diff --git a/src/vs/workbench/contrib/search/browser/search.contribution.ts b/src/vs/workbench/contrib/search/browser/search.contribution.ts +index 79e5defa112..318ab13d6fd 100644 +--- a/src/vs/workbench/contrib/search/browser/search.contribution.ts ++++ b/src/vs/workbench/contrib/search/browser/search.contribution.ts +@@ -889,7 +889,7 @@ configurationRegistry.registerConfiguration({ + 'search.quickOpen.includeHistory': { + type: 'boolean', + description: nls.localize('search.quickOpen.includeHistory', "Whether to include results from recently opened files in the file results for Quick Open."), +- default: true ++ default: false // FIXME We should be filtering history based on project rather than disabling it + }, + 'search.quickOpen.history.filterSortOrder': { + 'type': 'string', +diff --git a/src/vs/workbench/contrib/url/browser/trustedDomains.ts b/src/vs/workbench/contrib/url/browser/trustedDomains.ts +index 265a5dc43e3..8f1db546879 100644 +--- a/src/vs/workbench/contrib/url/browser/trustedDomains.ts ++++ b/src/vs/workbench/contrib/url/browser/trustedDomains.ts +@@ -217,7 +217,11 @@ export function readStaticTrustedDomains(accessor: ServicesAccessor): IStaticTru -- if (environmentService.isBuilt) { -- // Built time configuration (do NOT modify) -- bundledExtensions = [/*BUILD->INSERT_BUILTIN_EXTENSIONS*/]; -- } else { -+ // This code prevents us from slipping our extension into the list of built in extensions -+ // if (environmentService.isBuilt) { -+ // // Built time configuration (do NOT modify) -+ // bundledExtensions = [/*BUILD->INSERT_BUILTIN_EXTENSIONS*/]; -+ // } else { - // Find builtin extensions by checking for DOM - const builtinExtensionsElement = mainWindow.document.getElementById('vscode-workbench-builtin-extensions'); - const builtinExtensionsElementAttribute = builtinExtensionsElement ? builtinExtensionsElement.getAttribute('data-settings') : undefined; -@@ -63,7 +64,7 @@ export class BuiltinExtensionsScannerService implements IBuiltinExtensionsScanne - bundledExtensions = JSON.parse(builtinExtensionsElementAttribute); - } catch (error) { /* ignore error*/ } - } -- } -+ // } + const defaultTrustedDomains = [ + ...productService.linkProtectionTrustedDomains ?? [], +- ...environmentService.options?.additionalTrustedDomains ?? [] ++ ...environmentService.options?.additionalTrustedDomains ?? [], ++ "https://utopia.app", ++ "https://utopia.fm", ++ "https://utopia.pizza", ++ "https://utopia95.com" + ]; - this.builtinExtensionsPromises = bundledExtensions.map(async e => { - const id = getGalleryExtensionId(e.packageJSON.publisher, e.packageJSON.name); + let trustedDomains: string[] = []; +diff --git a/src/vs/workbench/contrib/webview/browser/pre/main.js b/src/vs/workbench/contrib/webview/browser/pre/main.js +index 618837df8e2..29b5ba8d333 100644 +--- a/src/vs/workbench/contrib/webview/browser/pre/main.js ++++ b/src/vs/workbench/contrib/webview/browser/pre/main.js +@@ -810,6 +810,7 @@ onDomReady(() => { + if (options.allowScripts) { + sandboxRules.add('allow-scripts'); + sandboxRules.add('allow-downloads'); ++ sandboxRules.add('allow-popups'); + } + if (options.allowForms) { + sandboxRules.add('allow-forms'); +diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts +index b23847b8bf1..9c2a6875fe0 100644 +--- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts ++++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts +@@ -370,7 +370,7 @@ export class IFrameWebview extends Disposable implements Webview { + const element = document.createElement('iframe'); + element.name = this.id; + element.className = `webview ${options.customClasses || ''}`; +- element.sandbox.add('allow-scripts', 'allow-same-origin', 'allow-forms', 'allow-pointer-lock', 'allow-downloads'); ++ element.sandbox.add('allow-scripts', 'allow-same-origin', 'allow-forms', 'allow-pointer-lock', 'allow-downloads', 'allow-popups'); + if (!isFirefox) { + element.setAttribute('allow', 'clipboard-read; clipboard-write;'); + } +diff --git a/src/vs/workbench/contrib/welcome/page/browser/welcomePage.contribution.ts b/src/vs/workbench/contrib/welcome/page/browser/welcomePage.contribution.ts +index 674eade0db1..f0b7198440f 100644 +--- a/src/vs/workbench/contrib/welcome/page/browser/welcomePage.contribution.ts ++++ b/src/vs/workbench/contrib/welcome/page/browser/welcomePage.contribution.ts +@@ -26,7 +26,7 @@ Registry.as(ConfigurationExtensions.Configuration) + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.newUntitledFile' }, "Open a new untitled file (only applies when opening an empty window)."), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.welcomePageInEmptyWorkbench' }, "Open the Welcome page when opening an empty workbench."), + ], +- 'default': 'welcomePage', ++ 'default': 'none', + 'description': localize('workbench.startupEditor', "Controls which editor is shown at startup, if none are restored from the previous session.") + }, + } diff --git a/src/vs/workbench/services/extensions/common/abstractExtensionService.ts b/src/vs/workbench/services/extensions/common/abstractExtensionService.ts -index 731a48bf5b0..b77097ce7c2 100644 +index aedda47bd66..1e685812b0e 100644 --- a/src/vs/workbench/services/extensions/common/abstractExtensionService.ts +++ b/src/vs/workbench/services/extensions/common/abstractExtensionService.ts -@@ -121,7 +121,7 @@ export abstract class AbstractExtensionService extends Disposable implements IEx +@@ -190,7 +190,7 @@ export abstract class AbstractExtensionService extends Disposable implements IEx + // help the file service to activate providers by activating extensions by file system event this._register(this._fileService.onWillActivateFileSystemProvider(e => { - if (e.scheme !== Schemas.vscodeRemote) { -- e.join(this.activateByEvent(`onFileSystem:${e.scheme}`)); -+ e.join(this.activateByEvent(`onFileSystem:utopia`)); - } +- e.join(this.activateByEvent(`onFileSystem:${e.scheme}`)); ++ e.join(this.activateByEvent(`onFileSystem:utopia`)); })); + this._registry = new ExtensionDescriptionRegistry([]); +diff --git a/src/vs/workbench/services/workspaces/browser/workspaces.ts b/src/vs/workbench/services/workspaces/browser/workspaces.ts +index 3b90080dc3d..cf9ac44fca9 100644 +--- a/src/vs/workbench/services/workspaces/browser/workspaces.ts ++++ b/src/vs/workbench/services/workspaces/browser/workspaces.ts +@@ -5,7 +5,6 @@ + + import { ISingleFolderWorkspaceIdentifier, IWorkspaceIdentifier } from 'vs/platform/workspaces/common/workspaces'; + import { URI } from 'vs/base/common/uri'; +-import { hash } from 'vs/base/common/hash'; + + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // NOTE: DO NOT CHANGE. IDENTIFIERS HAVE TO REMAIN STABLE +@@ -30,5 +29,6 @@ export function getSingleFolderWorkspaceIdentifier(folderPath: URI): ISingleFold + } + + function getWorkspaceId(uri: URI): string { +- return hash(uri.toString()).toString(16); ++ const urlParams = new URLSearchParams(window.location.search); ++ return urlParams.get('vs_code_session_id')!; + } diff --git a/yarn.lock b/yarn.lock -index 71aef4295fa..3cfe4dade65 100644 +index 2f4f87570dd..cdeffa74f26 100644 --- a/yarn.lock +++ b/yarn.lock -@@ -6508,6 +6508,13 @@ levn@^0.4.1: - prelude-ls "^1.2.1" - type-check "~0.4.0" +@@ -5248,6 +5248,11 @@ ignore@^5.1.4: + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" + integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== + ++immediate@~3.0.5: ++ version "3.0.6" ++ resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" ++ integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== ++ + import-cwd@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" +@@ -6138,6 +6143,13 @@ levn@^0.3.0, levn@~0.3.0: + prelude-ls "~1.1.2" + type-check "~0.3.2" +lie@3.1.1: + version "3.1.1" @@ -1142,10 +1271,10 @@ index 71aef4295fa..3cfe4dade65 100644 + dependencies: + immediate "~3.0.5" + - lie@~3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" -@@ -6576,6 +6583,13 @@ loader-utils@^2.0.0: + liftoff@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/liftoff/-/liftoff-3.1.0.tgz#c9ba6081f908670607ee79062d700df062c52ed3" +@@ -6201,6 +6213,13 @@ loader-utils@^2.0.0: emojis-list "^3.0.0" json5 "^2.1.2" @@ -1159,21 +1288,9 @@ index 71aef4295fa..3cfe4dade65 100644 locate-path@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" -@@ -7200,11 +7214,6 @@ native-is-elevated@0.7.0: - resolved "https://registry.yarnpkg.com/native-is-elevated/-/native-is-elevated-0.7.0.tgz#77499639e232edad1886403969e2bf236294e7af" - integrity sha512-tp8hUqK7vexBiyIWKMvmRxdG6kqUtO+3eay9iB0i16NYgvCqE5wMe1Y0guHilpkmRgvVXEWNW4et1+qqcwpLBA== - --native-keymap@^3.3.5: -- version "3.3.5" -- resolved "https://registry.yarnpkg.com/native-keymap/-/native-keymap-3.3.5.tgz#b1da65d32e42bf65e3ff9db05bed319927dc2b01" -- integrity sha512-7XDOLPNX1FnUFC/cX3cioBz2M+dO212ai9DuwpfKFzkPu3xTmEzOm5xewOMLXE4V9YoRhNPxvq1H2YpPWDgSsg== -- - native-watchdog@^1.4.1: - version "1.4.2" - resolved "https://registry.yarnpkg.com/native-watchdog/-/native-watchdog-1.4.2.tgz#cf9f913157ee992723aa372b6137293c663be9b7" -@@ -8287,6 +8296,11 @@ prelude-ls@~1.1.2: - resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" - integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= +@@ -8079,6 +8098,11 @@ prepend-http@^2.0.0: + resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" + integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= +prettier@2.8.8: + version "2.8.8" @@ -1183,16 +1300,16 @@ index 71aef4295fa..3cfe4dade65 100644 pretty-hrtime@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" -@@ -10235,6 +10249,12 @@ util@^0.12.4: - is-typed-array "^1.1.3" +@@ -10213,6 +10237,12 @@ util@^0.12.4: + safe-buffer "^5.1.2" which-typed-array "^1.1.2" +"utopia-vscode-common@file:../../utopia-vscode-common": -+ version "0.1.6" ++ version "0.1.4" + dependencies: + localforage "1.9.0" + prettier "2.8.8" + - uuid@^8.3.0: - version "8.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + uuid@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04" diff --git a/vscode-build/yarn.lock b/vscode-build/yarn.lock index 0f700f0ca86b..256a78553839 100644 --- a/vscode-build/yarn.lock +++ b/vscode-build/yarn.lock @@ -422,6 +422,11 @@ safe-buffer@5.1.2: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +semver-umd@5.5.7: + version "5.5.7" + resolved "https://registry.yarnpkg.com/semver-umd/-/semver-umd-5.5.7.tgz#966beb5e96c7da6fbf09c3da14c2872d6836c528" + integrity sha512-XgjPNlD0J6aIc8xoTN6GQGwWc2Xg0kq8NzrqMVuKG/4Arl6ab1F8+Am5Y/XKKCR+FceFr2yN/Uv5ZJBhRyRqKg== + send@0.17.1: version "0.17.1" resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" @@ -490,7 +495,7 @@ utils-merge@1.0.1: integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= "utopia-vscode-common@file:../utopia-vscode-common": - version "0.1.6" + version "0.1.4" dependencies: localforage "1.9.0" prettier "2.8.8" @@ -500,6 +505,16 @@ vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= +vscode-oniguruma@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/vscode-oniguruma/-/vscode-oniguruma-1.4.0.tgz#3795fd1ef9633a4a33f208bce92c008e64a6fc8f" + integrity sha512-VvTl/jIAADEqWWpEYRsOI1sXiYOTDA8KYNgK60+Mb3T+an9zPz3Cqc6RVJeYgOx/P5G+4M4jygB3X5xLLfYD0g== + +vscode-textmate@5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/vscode-textmate/-/vscode-textmate-5.2.0.tgz#01f01760a391e8222fe4f33fbccbd1ad71aed74e" + integrity sha512-Uw5ooOQxRASHgu6C7GVvUxisKXfSgW4oFlO+aa+PAkgmH89O3CXxEEzNRNtHSqtXFTl0nAC1uYj0GMSH27uwtQ== + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" diff --git a/website-next/components/common/env-vars.ts b/website-next/components/common/env-vars.ts index 4415118af3b1..269a7f3f4cf3 100644 --- a/website-next/components/common/env-vars.ts +++ b/website-next/components/common/env-vars.ts @@ -76,6 +76,7 @@ export const STATIC_BASE_URL: string = export const FLOATING_PREVIEW_BASE_URL: string = SECONDARY_BASE_URL export const PROPERTY_CONTROLS_INFO_BASE_URL: string = SECONDARY_BASE_URL +export const MONACO_EDITOR_IFRAME_BASE_URL: string = SECONDARY_BASE_URL export const ASSET_ENDPOINT = UTOPIA_BACKEND + 'asset/' export const THUMBNAIL_ENDPOINT = UTOPIA_BACKEND + 'thumbnail/'