diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index a75d7aaa34..b9ff69a5fd 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,2 +1,2 @@ - \ No newline at end of file + diff --git a/.github/config.yml b/.github/config.yml index 9b77b4aac1..6f6efd92a5 100644 --- a/.github/config.yml +++ b/.github/config.yml @@ -2,6 +2,9 @@ newIssueWelcomeComment: > Hello and welcome to the Oni repository! Thanks for opening your first issue here. To help us out, please make sure to include as much detail as possible - including screenshots and logs, if possible. backers: +- 78856 +- 1359421 +- 4650931 - 13532591 - 5097613 - 22454918 @@ -17,3 +20,9 @@ backers: - 817509 - 163128 - 4762 +- 933251 +- 3974037 +- 141159 +- 10263 +- 3117205 +- 5697723 diff --git a/.gitignore b/.gitignore index 9f2d426ae3..e6bdb5aad8 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,6 @@ yarn.lock # Webpack stats file stats.json + +# User Notes +.notes diff --git a/.gitmodules b/.gitmodules index 17330cc245..4b25205a24 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,6 +7,9 @@ [submodule "vim/default/bundle/vim-unimpaired"] path = vim/default/bundle/vim-unimpaired url = https://github.com/tpope/vim-unimpaired +[submodule "vim/default/bundle/vim-surround"] + path = vim/default/bundle/vim-surround + url = https://github.com/tpope/vim-surround.git [submodule "vim/core/typescript-vim"] path = vim/core/typescript-vim url = https://github.com/leafgarland/typescript-vim diff --git a/.prettierignore b/.prettierignore index a2cc533bde..f064adc127 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,3 @@ package.json vim/core/oni-plugin-typescript/package.json +lib/yarn/* diff --git a/.travis.yml b/.travis.yml index ef4f894dc1..1548408267 100644 --- a/.travis.yml +++ b/.travis.yml @@ -28,7 +28,10 @@ install: - yarn install script: - npm run check-cached-binaries +- ./build/script/install-reason.sh - ./build/script/travis-build.sh +- travis_wait ./build/script/travis-pack.sh +- ./build/script/travis-test.sh deploy: - provider: s3 access_key_id: AKIAIYMATI2CEFTHPBOQ @@ -42,18 +45,6 @@ deploy: on: condition: $TRAVIS_OS_NAME = osx repo: onivim/oni - - provider: s3 - access_key_id: AKIAIYMATI2CEFTHPBOQ - secret_access_key: - secure: S4f/aczEABGAMKk2tmVSkoGx+T2TLPmz5z6x6RKaM+eDmAaVSAELlIj1eAz6Tu2lv3jz+cpyAIISZNC/phORsJWwzbSZHVycLrMG0N3fDTqKFxu1fl6L3b3exRe9SiKXug73ZvHfktzd/XfRcgZKop4qgrwGiM57m0ZuZb/j1LkgjytTuvNAUxXbA84I8LZs/NhY17XuXq+KPlGElIHy3UFoGqQ8pBnTypkIU5rQTsoeAxXLBE8JAFfz+nBGZ7dx6OMbQcKX5jKh/gR3vk+4aTgV8gNE2Zp24ErjSqF2zly/gP9nE2DpfR7jqpZVHnb/v+OEjRDS80tLhPo8Dbibzwt2ZZNADpYBjSGtphwAmq4DCvJ7ORExOB5+O3wmXKQGdItyBTS7sW44n6BTyv87WxWuCaSDQ9QaO9PrbJdN5YGEYeRxSTM7Mn0t72IILkfFCUeSg6fl6tFs9iWIj5zltbxH1GQsRpA8j1Idg4O+894KnQABtw/YKh6rrdeYS9y/100qAjtV6qYyiP2IdPqMWGuasOiz87q3CQ8Ejd7uhiTjAaINVqos+0k04Yf5+rT4MqkeXnYFzjXuXcqDlpq6yJIZv3aD+PMSlZi2WmTYnPJXQFndHo/x9FhEh90UF9WdO5S27ySRSo8XQT4DyL3ToPkqz8y0slNmaNqiqMouQAU= - bucket: oni-builds - local-dir: s3_dist - upload-dir: $TRAVIS_BRANCH - acl: public_read - region: us-west-2 - skip_cleanup: true - on: - repo: onivim/oni - provider: releases api_key: secure: AjQUeQNockqkBrVQCOQGyKq+sZ9C4SabSqp/bmXayKTB+7AmM8oohenxC09Sc4/dmIW1PQnDYL/4fjclJSRaywV5oiPqUnfhTveALkKFErmYnhA8oFi3VJYg4Tbszb2lYGITLOluuuAZGw67JZIuuiXzw/yOUfdWTmRCAVGzTmqkPsusYg56L4iRBWDwYQ3mhHsuNKFO7SIx1nJatj5hK9AkDJlcVilpA5IuWLWOHLY7nplFPUPUwMkRd99nifB7ITycbaAX4zLwp2U2wCb2uSTOzsFNfXykksf8AlreH0615Jb+T39/dDwQurDAQE3h+KUH5QhEvRJ1uphkGvx/x6Vn0LkJuSqS5DLeSATmVOVRK2f6AXcymvn/64qxizjlBR7bBoUxM55311qWJNKKk2FYFTAIW5fMzN0MRbaulpnpBwmhnBvd03rOMIghnvClHv2m8Eh5A6ppPnLcl2Vn7jsrqTmMm+PM1ppIWhCpvC7xn4digx1GGHXlYzfHkDxtnHwHcbj+WOkc+j4ha8Os+1ctdT3OJXz5rwW4viorSIhWryK+G36beguXe5YaoeMcK9Vzmb+S0lHdA7RuCWiJ31i/9ZMbzBhLkdcf/wfj9n3mkqmzvc4Uc1NM8FHQ23URsodSHpTdDi7q25Eqge/JP82AqJ2zAWA+QKVg54xCQQc= diff --git a/BACKERS.md b/BACKERS.md index d10fbcdcbb..e03d40a3f7 100644 --- a/BACKERS.md +++ b/BACKERS.md @@ -56,6 +56,12 @@ Thanks you to all our backers for making Oni possible! * @jordwalke * @mhartington +* @MikaAK +* @emolitor + +## VIP Backers via Patreon + +* @mikl ## Backers via BountySource @@ -68,6 +74,10 @@ Thanks you to all our backers for making Oni possible! * @robtrac * @rrichardson * @sbuljac +* @parkerault +* @city41 +* @nithesh +* @erandac ## Backers via PayPal @@ -79,6 +89,18 @@ Thanks you to all our backers for making Oni possible! * Akinola Sowemimo * Martijn Arts * Amadeus Folego +* Kiyoshi Murata +* @Himura2la + +## Backers via Patreon + +* @bennettrogers +* @muream +* Johnnie Hård +* @robin-pham +* Ryan Campbell +* Balint Fulop +* Quasar Jarosz diff --git a/README.md b/README.md index 67ac232f2c..3ddbc40cde 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Oni is an independent, MIT-licensed open source project. Please consider supporting Oni by: +* [Become a backer or sponsor on Patreon](https://www.patreon.com/onivim) * [Become a backer or sponsor on Open Collective](https://opencollective.com/oni) * [Become a backer on BountySource](https://www.bountysource.com/teams/oni) @@ -157,4 +158,5 @@ Bundled plugins have their own license terms. These include: * [targets.vim](https://github.com/wellle/targets.vim) (`oni/vim/default/bundle/targets.vim`) * [vim-commentary](https://github.com/tpope/vim-commentary) (`oni/vim/default/bundle/vim-commentary`) * [vim-unimpaired](https://github.com/tpope/vim-unimpaired) (`oni/vim/default/bundle/vim-unimpaired`) +* [vim-surround](https://github.com/tpope/vim-surround) (`oni/vim/default/bundle/vim-surround`) * [vim-reasonml](https://github.com/reasonml-editor/vim-reason) (`.vim` files in `oni/vim/core/oni-plugin-reasonml`) diff --git a/appveyor.yml b/appveyor.yml index 92ba913f8e..1713f3d6a1 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -9,9 +9,6 @@ branches: - master - /^release.*/ -cache: - - "%LOCALAPPDATA%\\Yarn" - platform: - x86 - x64 @@ -27,7 +24,7 @@ install: - node --version - npm --version # install modules - - yarn install + - yarn install --verbose artifacts: - path: dist/*.exe @@ -50,33 +47,9 @@ deploy: on: appveyor_repo_tag: true - - provider: S3 - access_key_id: AKIAIYMATI2CEFTHPBOQ - secret_access_key: - secure: brqAO0yA6DSxwRbf7IbbmhvyEoQqSeetdrq4mutR2aaeLJBQu6by4u1Td+wq5Msc - bucket: oni-builds - region: us-west-2 - unzip: false - set_public: true - folder: $(APPVEYOR_REPO_BRANCH) - artifact: SetupExe, ProductZip - # Post-install test scripts. test_script: - # Output useful info for debugging. - - node --version - - npm --version - # build - - npm run build - - npm run lint - - npm run test:unit - # create setup package - - npm run copy-icons - - npm run dist:win - - npm run pack:win - # run integration tests - - npm run test:integration - - npm run demo:screenshot + - powershell build/script/appveyor-test.ps1 # Don't actually build. build: off diff --git a/browser/src/App.ts b/browser/src/App.ts new file mode 100644 index 0000000000..9d850ae979 --- /dev/null +++ b/browser/src/App.ts @@ -0,0 +1,387 @@ +/** + * App.ts + * + * Entry point for the Oni application - managing the overall lifecycle + */ + +import { ipcRenderer } from "electron" +import * as minimist from "minimist" +import * as path from "path" + +import { IDisposable } from "oni-types" + +import * as Log from "./Log" +import * as Performance from "./Performance" +import * as Utility from "./Utility" + +import { IConfigurationValues } from "./Services/Configuration/IConfigurationValues" + +const editorManagerPromise = import("./Services/EditorManager") +const sharedNeovimInstancePromise = import("./neovim/SharedNeovimInstance") + +export type QuitHook = () => Promise + +let _quitHooks: QuitHook[] = [] +const _initializePromise: Utility.ICompletablePromise = Utility.createCompletablePromise< + void +>() + +export const registerQuitHook = (quitHook: QuitHook): IDisposable => { + _quitHooks.push(quitHook) + + const dispose = () => { + _quitHooks = _quitHooks.filter(qh => qh !== quitHook) + } + + return { + dispose, + } +} + +export const quit = async (): Promise => { + Log.info(`[App::quit] called with ${_quitHooks.length} quitHooks`) + const promises = _quitHooks.map(async qh => { + Log.info("[App.quit] Waiting for quit hook...") + await qh() + Log.info("[App.quit] Quit hook completed successfully") + }) + await Promise.all([promises]) + Log.info("[App::quit] completed") +} + +export const waitForStart = (): Promise => { + return _initializePromise.promise +} + +export const start = async (args: string[]): Promise => { + Performance.startMeasure("Oni.Start") + + const UnhandledErrorMonitor = await import("./Services/UnhandledErrorMonitor") + UnhandledErrorMonitor.activate() + + const Shell = await import("./UI/Shell") + Shell.activate() + + const configurationPromise = import("./Services/Configuration") + const configurationCommandsPromise = import("./Services/Configuration/ConfigurationCommands") + const debugPromise = import("./Services/Debug") + const pluginManagerPromise = import("./Plugins/PluginManager") + const themesPromise = import("./Services/Themes") + const iconThemesPromise = import("./Services/IconThemes") + + const sidebarPromise = import("./Services/Sidebar") + const overlayPromise = import("./Services/Overlay") + const statusBarPromise = import("./Services/StatusBar") + const startEditorsPromise = import("./startEditors") + + const menuPromise = import("./Services/Menu") + + const browserWindowConfigurationSynchronizerPromise = import("./Services/BrowserWindowConfigurationSynchronizer") + const colorsPromise = import("./Services/Colors") + const tokenColorsPromise = import("./Services/TokenColors") + const diagnosticsPromise = import("./Services/Diagnostics") + const globalCommandsPromise = import("./Services/Commands/GlobalCommands") + const inputManagerPromise = import("./Services/InputManager") + const languageManagerPromise = import("./Services/Language") + const notificationsPromise = import("./Services/Notifications") + const snippetPromise = import("./Services/Snippets") + const keyDisplayerPromise = import("./Services/KeyDisplayer") + const quickOpenPromise = import("./Services/QuickOpen") + const taksPromise = import("./Services/Tasks") + const terminalPromise = import("./Services/Terminal") + const workspacePromise = import("./Services/Workspace") + const workspaceCommandsPromise = import("./Services/Workspace/WorkspaceCommands") + const windowManagerPromise = import("./Services/WindowManager") + const multiProcessPromise = import("./Services/MultiProcess") + + const themePickerPromise = import("./Services/Themes/ThemePicker") + const cssPromise = import("./CSS") + const completionProvidersPromise = import("./Services/Completion/CompletionProviders") + + const parsedArgs = minimist(args) + const currentWorkingDirectory = process.cwd() + const filesToOpen = parsedArgs._.map( + arg => (path.isAbsolute(arg) ? arg : path.join(currentWorkingDirectory, arg)), + ) + + // Helper for debugging: + Performance.startMeasure("Oni.Start.Config") + + const { configuration } = await configurationPromise + + const initialConfigParsingErrors = configuration.getErrors() + if (initialConfigParsingErrors && initialConfigParsingErrors.length > 0) { + initialConfigParsingErrors.forEach((err: Error) => Log.error(err)) + } + + const configChange = (newConfigValues: Partial) => { + let prop: keyof IConfigurationValues + for (prop in newConfigValues) { + if (newConfigValues[prop]) { + Shell.Actions.setConfigValue(prop, newConfigValues[prop]) + } + } + } + + configuration.start() + + configChange(configuration.getValues()) // initialize values + configuration.onConfigurationChanged.subscribe(configChange) + Performance.endMeasure("Oni.Start.Config") + + const PluginManager = await pluginManagerPromise + PluginManager.activate(configuration) + const pluginManager = PluginManager.getInstance() + + Performance.startMeasure("Oni.Start.Plugins.Discover") + pluginManager.discoverPlugins() + Performance.endMeasure("Oni.Start.Plugins.Discover") + + Performance.startMeasure("Oni.Start.Themes") + const Themes = await themesPromise + const IconThemes = await iconThemesPromise + await Promise.all([ + Themes.activate(configuration, pluginManager), + IconThemes.activate(configuration, pluginManager), + ]) + + const Colors = await colorsPromise + Colors.activate(configuration, Themes.getThemeManagerInstance()) + const colors = Colors.getInstance() + Shell.initializeColors(Colors.getInstance()) + Performance.endMeasure("Oni.Start.Themes") + + const TokenColors = await tokenColorsPromise + TokenColors.activate(configuration, Themes.getThemeManagerInstance()) + + const BrowserWindowConfigurationSynchronizer = await browserWindowConfigurationSynchronizerPromise + BrowserWindowConfigurationSynchronizer.activate(configuration, Colors.getInstance()) + + const { editorManager } = await editorManagerPromise + + const Workspace = await workspacePromise + Workspace.activate(configuration, editorManager) + const workspace = Workspace.getInstance() + + const WindowManager = await windowManagerPromise + const MultiProcess = await multiProcessPromise + + MultiProcess.activate(WindowManager.windowManager) + + const StatusBar = await statusBarPromise + StatusBar.activate(configuration) + const statusBar = StatusBar.getInstance() + + const Overlay = await overlayPromise + Overlay.activate() + const overlayManager = Overlay.getInstance() + + const sneakPromise = import("./Services/Sneak") + const { commandManager } = await import("./Services/CommandManager") + const Sneak = await sneakPromise + Sneak.activate(colors, commandManager, configuration, overlayManager) + + const Menu = await menuPromise + Menu.activate(configuration, overlayManager) + const menuManager = Menu.getInstance() + + const QuickOpen = await quickOpenPromise + QuickOpen.activate(commandManager, menuManager, editorManager, workspace) + + const Notifications = await notificationsPromise + Notifications.activate(configuration, overlayManager) + + configuration.onConfigurationError.subscribe(err => { + const notifications = Notifications.getInstance() + const notification = notifications.createItem() + notification.setContents("Error Loading Configuration", err.toString()) + notification.setLevel("error") + notification.onClick.subscribe(() => + commandManager.executeCommand("oni.config.openConfigJs"), + ) + notification.show() + }) + + UnhandledErrorMonitor.start(configuration, Notifications.getInstance()) + + const Tasks = await taksPromise + Tasks.activate(menuManager) + const tasks = Tasks.getInstance() + + const LanguageManager = await languageManagerPromise + LanguageManager.activate(configuration, editorManager, pluginManager, statusBar, workspace) + const languageManager = LanguageManager.getInstance() + + Performance.startMeasure("Oni.Start.Editors") + const SharedNeovimInstance = await sharedNeovimInstancePromise + const { startEditors } = await startEditorsPromise + + const CSS = await cssPromise + CSS.activate() + + const Snippets = await snippetPromise + Snippets.activate(commandManager, configuration) + + Shell.Actions.setLoadingComplete() + + const Diagnostics = await diagnosticsPromise + const diagnostics = Diagnostics.getInstance() + + const CompletionProviders = await completionProvidersPromise + CompletionProviders.activate(languageManager) + + const initializeAllEditors = async () => { + await startEditors( + filesToOpen, + Colors.getInstance(), + CompletionProviders.getInstance(), + configuration, + diagnostics, + languageManager, + menuManager, + overlayManager, + pluginManager, + Snippets.getInstance(), + Themes.getThemeManagerInstance(), + TokenColors.getInstance(), + workspace, + ) + + await SharedNeovimInstance.activate(configuration, pluginManager) + } + + await Promise.race([Utility.delay(5000), initializeAllEditors()]) + Performance.endMeasure("Oni.Start.Editors") + + Performance.startMeasure("Oni.Start.Sidebar") + const Sidebar = await sidebarPromise + const Learning = await import("./Services/Learning") + const Explorer = await import("./Services/Explorer") + const Search = await import("./Services/Search") + + Sidebar.activate(configuration, workspace) + const sidebarManager = Sidebar.getInstance() + + Explorer.activate(commandManager, editorManager, Sidebar.getInstance(), workspace) + Search.activate(commandManager, editorManager, Sidebar.getInstance(), workspace) + Learning.activate( + commandManager, + configuration, + editorManager, + overlayManager, + Sidebar.getInstance(), + WindowManager.windowManager, + ) + Performance.endMeasure("Oni.Start.Sidebar") + + const createLanguageClientsFromConfiguration = + LanguageManager.createLanguageClientsFromConfiguration + + diagnostics.start(languageManager) + + const Browser = await import("./Services/Browser") + Browser.activate(commandManager, configuration, editorManager) + + Performance.startMeasure("Oni.Start.Activate") + const api = pluginManager.startApi() + configuration.activate(api) + + Snippets.activateProviders( + commandManager, + CompletionProviders.getInstance(), + configuration, + pluginManager, + ) + + createLanguageClientsFromConfiguration(configuration.getValues()) + + const { inputManager } = await inputManagerPromise + + const autoClosingPairsPromise = import("./Services/AutoClosingPairs") + + const ConfigurationCommands = await configurationCommandsPromise + ConfigurationCommands.activate(commandManager, configuration, editorManager) + + const AutoClosingPairs = await autoClosingPairsPromise + AutoClosingPairs.activate(configuration, editorManager, inputManager, languageManager) + + const GlobalCommands = await globalCommandsPromise + GlobalCommands.activate(commandManager, editorManager, menuManager, tasks) + + const Debug = await debugPromise + Debug.activate(commandManager) + + const WorkspaceCommands = await workspaceCommandsPromise + WorkspaceCommands.activateCommands( + configuration, + editorManager, + Snippets.getInstance(), + workspace, + ) + + const Preview = await import("./Services/Preview") + Preview.activate(commandManager, configuration, editorManager) + + const KeyDisplayer = await keyDisplayerPromise + KeyDisplayer.activate( + commandManager, + configuration, + editorManager, + inputManager, + overlayManager, + ) + + const ThemePicker = await themePickerPromise + ThemePicker.activate(configuration, menuManager, Themes.getThemeManagerInstance()) + + const Bookmarks = await import("./Services/Bookmarks") + Bookmarks.activate(configuration, editorManager, Sidebar.getInstance()) + + const PluginsSidebarPane = await import("./Plugins/PluginSidebarPane") + PluginsSidebarPane.activate(configuration, pluginManager, sidebarManager) + + const Terminal = await terminalPromise + Terminal.activate(commandManager, configuration, editorManager) + + const Particles = await import("./Services/Particles") + Particles.activate(commandManager, configuration, editorManager, overlayManager) + + const PluginConfigurationSynchronizer = await import("./Plugins/PluginConfigurationSynchronizer") + PluginConfigurationSynchronizer.activate(configuration, pluginManager) + + const Achievements = await import("./Services/Learning/Achievements") + const achievements = Achievements.getInstance() + + if (achievements) { + Debug.registerAchievements(achievements) + Sneak.registerAchievements(achievements) + Browser.registerAchievements(achievements) + } + + Performance.endMeasure("Oni.Start.Activate") + + checkForUpdates() + + commandManager.registerCommand({ + command: "oni.quit", + name: null, + detail: null, + execute: () => quit(), + }) + + Performance.endMeasure("Oni.Start") + ipcRenderer.send("Oni.started", "started") + _initializePromise.resolve() +} + +const checkForUpdates = async (): Promise => { + const AutoUpdate = await import("./Services/AutoUpdate") + const { autoUpdater, constructFeedUrl } = AutoUpdate + + const feedUrl = await constructFeedUrl("https://api.onivim.io/v1/update") + + autoUpdater.onUpdateAvailable.subscribe(() => Log.info("Update available.")) + autoUpdater.onUpdateNotAvailable.subscribe(() => Log.info("Update not available.")) + + autoUpdater.checkForUpdates(feedUrl) +} diff --git a/browser/src/Editor/BufferManager.ts b/browser/src/Editor/BufferManager.ts index 5035ccc03c..92d2f64853 100644 --- a/browser/src/Editor/BufferManager.ts +++ b/browser/src/Editor/BufferManager.ts @@ -42,9 +42,20 @@ import { TokenColor } from "./../Services/TokenColors" import { IBufferLayer } from "./NeovimEditor/BufferLayerManager" export interface IBuffer extends Oni.Buffer { + setLanguage(lang: string): Promise getCursorPosition(): Promise handleInput(key: string): boolean detectIndentation(): Promise + setScratchBuffer(): Promise +} + +type NvimError = [1, string] + +const isStringArray = (value: NvimError | string[]): value is string[] => { + if (value && Array.isArray(value)) { + return typeof value[0] === "string" + } + return false } export type IndentationType = "tab" | "space" @@ -125,6 +136,12 @@ export class Buffer implements IBuffer { this._actions.addBufferLayer(parseInt(this._id, 10), layer) } + public getLayerById(id: string): T { + return (this._store + .getState() + .layers[parseInt(this._id, 10)].find(layer => layer.id === id) as any) as T + } + public removeLayer(layer: IBufferLayer): void { this._actions.removeBufferLayer(parseInt(this._id, 10), layer) } @@ -148,22 +165,48 @@ export class Buffer implements IBuffer { Log.warn("getLines called with over 2500 lines, this may cause instability.") } - const lines = await this._neovimInstance.request("nvim_buf_get_lines", [ + // Neovim does not error if it is unable to get lines instead it returns an array + // of type [1, "an error message"] **on Some occasions**, we only check the first on the assumption that + // that is where the number is placed by neovim + const lines = await this._neovimInstance.request("nvim_buf_get_lines", [ parseInt(this._id, 10), start, end, false, ]) - return lines + + if (isStringArray(lines)) { + return lines + } + return [] } public async setLanguage(language: string): Promise { this._language = language - await this._neovimInstance.request("nvim_buf_set_option", [ - parseInt(this._id, 10), - "filetype", - language, - ]) + await this._neovimInstance.command(`setl ft=${language}`) + } + + public async setScratchBuffer(): Promise { + // set the open buffer to be a readonly throw away buffer, also add scrollbind + // may need a config option + const calls = [ + ["nvim_command", ["setlocal buftype=nofile"]], + ["nvim_command", ["setlocal bufhidden=hide"]], + ["nvim_command", ["setlocal noswapfile"]], + ["nvim_command", ["setlocal nobuflisted"]], + ["nvim_command", ["setlocal nomodifiable"]], + ["nvim_command", ["windo set scrollbind!"]], + ] + + const [result, error] = await this._neovimInstance.request( + "nvim_call_atomic", + [calls], + ) + + if (typeof result === "number" && error) { + Log.info(`Failed to set scratch buffer due to ${error}`) + } + this._modified = false } public async detectIndentation(): Promise { diff --git a/browser/src/Editor/Editor.ts b/browser/src/Editor/Editor.ts index 2a9d50f59a..ec63c108ba 100644 --- a/browser/src/Editor/Editor.ts +++ b/browser/src/Editor/Editor.ts @@ -9,6 +9,8 @@ import { Event, IEvent } from "oni-types" import * as types from "vscode-languageserver-types" +import { Disposable } from "./../Utility" + export interface IEditor extends Oni.Editor { // Methods init(filesToOpen: string[]): void @@ -20,7 +22,7 @@ export interface IEditor extends Oni.Editor { /** * Base class for Editor implementations */ -export class Editor implements Oni.Editor { +export class Editor extends Disposable implements Oni.Editor { private _currentMode: string private _onBufferEnterEvent = new Event() private _onBufferLeaveEvent = new Event() diff --git a/browser/src/Editor/NeovimEditor/HoverRenderer.tsx b/browser/src/Editor/NeovimEditor/HoverRenderer.tsx index 910b01ca79..9194533646 100644 --- a/browser/src/Editor/NeovimEditor/HoverRenderer.tsx +++ b/browser/src/Editor/NeovimEditor/HoverRenderer.tsx @@ -9,7 +9,6 @@ import * as types from "vscode-languageserver-types" import getTokens from "./../../Services/SyntaxHighlighting/TokenGenerator" import { ErrorInfo } from "./../../UI/components/ErrorInfo" -import { QuickInfoDocumentation } from "./../../UI/components/QuickInfo" import QuickInfoWithTheme from "./../../UI/components/QuickInfoContainer" import * as Helpers from "./../../Plugins/Api/LanguageClient/LanguageClientHelpers" @@ -74,14 +73,15 @@ export class HoverRenderer { } const errorElements = getErrorElements(errors, customErrorStyle) - - // Remove falsy values as check below [null] is truthy - const elements = [...errorElements, quickInfoElement].filter(Boolean) + let debugScopeElement: JSX.Element = null if (this._configuration.getValue("editor.textMateHighlighting.debugScopes")) { - elements.push(this._getDebugScopesElement()) + debugScopeElement = this._getDebugScopesElement() } + // Remove falsy values as check below [null] is truthy + const elements = [...errorElements, quickInfoElement, debugScopeElement].filter(Boolean) + if (!elements.length) { return null } @@ -115,10 +115,14 @@ export class HoverRenderer { } const items = scopeInfo.scopes.map((si: string) =>
  • {si}
  • ) return ( - +
    DEBUG: TextMate Scopes:
    -
      {items}
    - +
      {items}
    +
    ) } } diff --git a/browser/src/Editor/NeovimEditor/NeovimEditor.tsx b/browser/src/Editor/NeovimEditor/NeovimEditor.tsx index 679ec5101f..102652ca0c 100644 --- a/browser/src/Editor/NeovimEditor/NeovimEditor.tsx +++ b/browser/src/Editor/NeovimEditor/NeovimEditor.tsx @@ -17,10 +17,10 @@ import * as types from "vscode-languageserver-types" import { Provider } from "react-redux" import { bindActionCreators, Store } from "redux" -import { clipboard, ipcRenderer, remote } from "electron" +import { clipboard, ipcRenderer } from "electron" import * as Oni from "oni-api" -import { Event } from "oni-types" +import { Event, IEvent } from "oni-types" import * as Log from "./../../Log" @@ -42,8 +42,6 @@ import { commandManager } from "./../../Services/CommandManager" import { Completion, CompletionProviders } from "./../../Services/Completion" import { Configuration, IConfigurationValues } from "./../../Services/Configuration" import { IDiagnosticsDataSource } from "./../../Services/Diagnostics" -import { editorManager } from "./../../Services/EditorManager" -import { Errors } from "./../../Services/Errors" import { Overlay, OverlayManager } from "./../../Services/Overlay" import { SnippetManager } from "./../../Services/Snippets" import { TokenColors } from "./../../Services/TokenColors" @@ -63,7 +61,6 @@ import { } from "./../../Services/SyntaxHighlighting" import { MenuManager } from "./../../Services/Menu" -import { Tasks } from "./../../Services/Tasks" import { ThemeManager } from "./../../Services/Themes" import { TypingPredictionManager } from "./../../Services/TypingPredictionManager" import { Workspace } from "./../../Services/Workspace" @@ -140,9 +137,16 @@ export class NeovimEditor extends Editor implements IEditor { private _toolTipsProvider: IToolTipsProvider private _commands: NeovimEditorCommands private _externalMenuOverlay: Overlay - private _bufferLayerManager: BufferLayerManager + private _onNeovimQuit: Event = new Event() + + private _autoFocus: boolean = true + + public get onNeovimQuit(): IEvent { + return this._onNeovimQuit + } + public get /* override */ activeBuffer(): Oni.Buffer { return this._bufferManager.getBufferById(this._lastBufferId) } @@ -156,6 +160,17 @@ export class NeovimEditor extends Editor implements IEditor { return this._bufferLayerManager } + /** + * Gets whether or not the editor should autoFocus, + * meaning, grab focus on first mount + */ + public get autoFocus(): boolean { + return this._autoFocus + } + public set autoFocus(val: boolean) { + this._autoFocus = val + } + public get syntaxHighlighter(): ISyntaxHighlighter { return this._syntaxHighlighter } @@ -170,15 +185,12 @@ export class NeovimEditor extends Editor implements IEditor { private _overlayManager: OverlayManager, private _pluginManager: PluginManager, private _snippetManager: SnippetManager, - private _tasks: Tasks, private _themeManager: ThemeManager, private _tokenColors: TokenColors, private _workspace: Workspace, ) { super() - const services: any[] = [] - this._store = createStore() this._actions = bindActionCreators(ActionCreators as any, this._store.dispatch) this._toolTipsProvider = new NeovimEditorToolTipsProvider(this._actions) @@ -252,8 +264,6 @@ export class NeovimEditor extends Editor implements IEditor { ) // Services - const errorService = new Errors(this._neovimInstance) - this._commands = new NeovimEditorCommands( commandManager, this._contextMenuManager, @@ -264,11 +274,6 @@ export class NeovimEditor extends Editor implements IEditor { this._symbols, ) - this._tasks.registerTaskProvider(commandManager) - this._tasks.registerTaskProvider(errorService) - - services.push(errorService) - const onColorsChanged = () => { const updatedColors: any = this._colors.getColors() this._actions.setColors(updatedColors) @@ -285,164 +290,216 @@ export class NeovimEditor extends Editor implements IEditor { } } - this._tokenColors.onTokenColorsChanged.subscribe(() => onTokenColorschanged()) + this.trackDisposable( + this._tokenColors.onTokenColorsChanged.subscribe(() => onTokenColorschanged()), + ) // Overlays // TODO: Replace `OverlayManagement` concept and associated window management code with // explicit window management: #362 this._windowManager = new NeovimWindowManager(this._neovimInstance) - this._neovimInstance.onCommandLineShow.subscribe(showCommandLineInfo => { - this._actions.showCommandLine( - showCommandLineInfo.content, - showCommandLineInfo.pos, - showCommandLineInfo.firstc, - showCommandLineInfo.prompt, - showCommandLineInfo.indent, - showCommandLineInfo.level, - ) - this._externalMenuOverlay.show() - }) + this.trackDisposable( + this._neovimInstance.onCommandLineShow.subscribe(showCommandLineInfo => { + this._actions.showCommandLine( + showCommandLineInfo.content, + showCommandLineInfo.pos, + showCommandLineInfo.firstc, + showCommandLineInfo.prompt, + showCommandLineInfo.indent, + showCommandLineInfo.level, + ) + this._externalMenuOverlay.show() + }), + ) - this._neovimInstance.onWildMenuShow.subscribe(wildMenuInfo => { - this._actions.showWildMenu(wildMenuInfo) - }) + this.trackDisposable( + this._neovimInstance.onWildMenuShow.subscribe(wildMenuInfo => { + this._actions.showWildMenu(wildMenuInfo) + }), + ) - this._neovimInstance.onWildMenuSelect.subscribe(wildMenuInfo => { - this._actions.wildMenuSelect(wildMenuInfo) - }) + this.trackDisposable( + this._neovimInstance.onWildMenuSelect.subscribe(wildMenuInfo => { + this._actions.wildMenuSelect(wildMenuInfo) + }), + ) - this._neovimInstance.onWildMenuHide.subscribe(() => { - this._actions.hideWildMenu() - }) + this.trackDisposable( + this._neovimInstance.onWildMenuHide.subscribe(() => { + this._actions.hideWildMenu() + }), + ) - this._neovimInstance.onCommandLineHide.subscribe(() => { - this._actions.hideCommandLine() - this._externalMenuOverlay.hide() - }) + this.trackDisposable( + this._neovimInstance.onCommandLineHide.subscribe(() => { + this._actions.hideCommandLine() + this._externalMenuOverlay.hide() + }), + ) - this._neovimInstance.onCommandLineSetCursorPosition.subscribe(commandLinePos => { - this._actions.setCommandLinePosition(commandLinePos) - }) + this.trackDisposable( + this._neovimInstance.onCommandLineSetCursorPosition.subscribe(commandLinePos => { + this._actions.setCommandLinePosition(commandLinePos) + }), + ) - this._windowManager.onWindowStateChanged.subscribe(tabPageState => { - const filteredTabState = tabPageState.inactiveWindows.filter(w => !!w) - const inactiveIds = filteredTabState.map(w => w.windowNumber) - - this._actions.setActiveVimTabPage(tabPageState.tabId, [ - tabPageState.activeWindow.windowNumber, - ...inactiveIds, - ]) - - const { activeWindow } = tabPageState - if (activeWindow) { - this._actions.setWindowState( - activeWindow.windowNumber, - activeWindow.bufferId, - activeWindow.bufferFullPath, - activeWindow.column, - activeWindow.line, - activeWindow.bottomBufferLine, - activeWindow.topBufferLine, - activeWindow.dimensions, - activeWindow.bufferToScreen, - ) - } + this.trackDisposable( + this._windowManager.onWindowStateChanged.subscribe(tabPageState => { + const filteredTabState = tabPageState.inactiveWindows.filter(w => !!w) + const inactiveIds = filteredTabState.map(w => w.windowNumber) + + this._actions.setActiveVimTabPage(tabPageState.tabId, [ + tabPageState.activeWindow.windowNumber, + ...inactiveIds, + ]) + + const { activeWindow } = tabPageState + if (activeWindow) { + this._actions.setWindowState( + activeWindow.windowNumber, + activeWindow.bufferId, + activeWindow.bufferFullPath, + activeWindow.column, + activeWindow.line, + activeWindow.bottomBufferLine, + activeWindow.topBufferLine, + activeWindow.dimensions, + activeWindow.bufferToScreen, + ) + } - filteredTabState.map(w => { - this._actions.setInactiveWindowState(w.windowNumber, w.dimensions) - }) - }) + filteredTabState.map(w => { + this._actions.setInactiveWindowState(w.windowNumber, w.dimensions) + }) + }), + ) - this._neovimInstance.onYank.subscribe(yankInfo => { - if (this._configuration.getValue("editor.clipboard.enabled")) { - const isYankAndAllowed = - yankInfo.operator === "y" && - this._configuration.getValue("editor.clipboard.synchronizeYank") - const isDeleteAndAllowed = - yankInfo.operator === "d" && - this._configuration.getValue("editor.clipboard.synchronizeDelete") - const isAllowed = isYankAndAllowed || isDeleteAndAllowed - - if (isAllowed) { - clipboard.writeText(yankInfo.regcontents.join(require("os").EOL)) + this.trackDisposable( + this._neovimInstance.onYank.subscribe(yankInfo => { + if (this._configuration.getValue("editor.clipboard.enabled")) { + const isYankAndAllowed = + yankInfo.operator === "y" && + this._configuration.getValue("editor.clipboard.synchronizeYank") + const isDeleteAndAllowed = + yankInfo.operator === "d" && + this._configuration.getValue("editor.clipboard.synchronizeDelete") + const isAllowed = isYankAndAllowed || isDeleteAndAllowed + + if (isAllowed) { + clipboard.writeText(yankInfo.regcontents.join(require("os").EOL)) + } } - } - }) + }), + ) - this._neovimInstance.onTitleChanged.subscribe(newTitle => { - const title = newTitle.replace(" - NVIM", " - ONI") - Shell.Actions.setWindowTitle(title) - }) + this.trackDisposable( + this._neovimInstance.onTitleChanged.subscribe(newTitle => { + const title = newTitle.replace(" - NVIM", " - ONI") + Shell.Actions.setWindowTitle(title) + }), + ) - this._neovimInstance.onLeave.subscribe(() => { - // TODO: Only leave if all editors are closed... - if (!this._configuration.getValue("debug.persistOnNeovimExit")) { - remote.getCurrentWindow().close() - } - }) + this.trackDisposable( + this._neovimInstance.onLeave.subscribe(() => { + this._onNeovimQuit.dispatch() + }), + ) - this._neovimInstance.onOniCommand.subscribe(command => { - commandManager.executeCommand(command) - }) + this.trackDisposable( + this._neovimInstance.onOniCommand.subscribe(context => { + const commandToExecute = context.command + const commandArgs = context.args - this._neovimInstance.on("event", (eventName: string, evt: any) => { - const current = evt.current || evt - this._updateWindow(current) - this._bufferManager.updateBufferFromEvent(current) - }) + commandManager.executeCommand(commandToExecute, commandArgs) + }), + ) - this._neovimInstance.autoCommands.onBufDelete.subscribe((evt: BufferEventContext) => - this._onBufDelete(evt), + // TODO: Refactor to event and track disposable + this.trackDisposable( + this._neovimInstance.onVimEvent.subscribe(evt => { + if (evt.eventName !== "VimLeave") { + this._updateWindow(evt.eventContext) + this._bufferManager.updateBufferFromEvent(evt.eventContext) + } + }), ) - this._neovimInstance.autoCommands.onBufUnload.subscribe((evt: BufferEventContext) => - this._onBufUnload(evt), + this.trackDisposable( + this._neovimInstance.autoCommands.onBufDelete.subscribe((evt: BufferEventContext) => + this._onBufDelete(evt), + ), ) - this._neovimInstance.autoCommands.onBufEnter.subscribe((evt: BufferEventContext) => - this._onBufEnter(evt), + this.trackDisposable( + this._neovimInstance.autoCommands.onBufUnload.subscribe((evt: BufferEventContext) => + this._onBufUnload(evt), + ), ) - this._neovimInstance.autoCommands.onBufWinEnter.subscribe((evt: BufferEventContext) => - this._onBufEnter(evt), + + this.trackDisposable( + this._neovimInstance.autoCommands.onBufEnter.subscribe((evt: BufferEventContext) => + this._onBufEnter(evt), + ), ) - this._neovimInstance.autoCommands.onFileTypeChanged.subscribe((evt: EventContext) => - this._onFileTypeChanged(evt), + this.trackDisposable( + this._neovimInstance.autoCommands.onBufWinEnter.subscribe((evt: BufferEventContext) => + this._onBufEnter(evt), + ), ) - this._neovimInstance.autoCommands.onBufWipeout.subscribe((evt: BufferEventContext) => - this._onBufWipeout(evt), + this.trackDisposable( + this._neovimInstance.autoCommands.onFileTypeChanged.subscribe((evt: EventContext) => + this._onFileTypeChanged(evt), + ), ) - this._neovimInstance.autoCommands.onBufWritePost.subscribe((evt: EventContext) => - this._onBufWritePost(evt), + this.trackDisposable( + this._neovimInstance.autoCommands.onBufWipeout.subscribe((evt: BufferEventContext) => + this._onBufWipeout(evt), + ), ) - this._neovimInstance.onColorsChanged.subscribe(() => { - this._onColorsChanged() - }) + this.trackDisposable( + this._neovimInstance.autoCommands.onBufWritePost.subscribe((evt: EventContext) => + this._onBufWritePost(evt), + ), + ) - this._neovimInstance.onError.subscribe(err => { - this._errorInitializing = true - this._actions.setNeovimError(true) - }) + this.trackDisposable( + this._neovimInstance.onColorsChanged.subscribe(() => { + this._onColorsChanged() + }), + ) + + this.trackDisposable( + this._neovimInstance.onError.subscribe(err => { + this._errorInitializing = true + this._actions.setNeovimError(true) + }), + ) // These functions are mirrors of each other if vim changes dir then oni responds // and if oni initiates the dir change then we inform vim // NOTE: the gates to check that the dirs being passed aren't already set prevent // an infinite loop!! - this._neovimInstance.onDirectoryChanged.subscribe(async newDirectory => { - if (newDirectory !== this._workspace.activeWorkspace) { - await this._workspace.changeDirectory(newDirectory) - } - }) + this.trackDisposable( + this._neovimInstance.onDirectoryChanged.subscribe(async newDirectory => { + if (newDirectory !== this._workspace.activeWorkspace) { + await this._workspace.changeDirectory(newDirectory) + } + }), + ) - this._workspace.onDirectoryChanged.subscribe(async newDirectory => { - if (newDirectory !== this._neovimInstance.currentVimDirectory) { - await this._neovimInstance.chdir(newDirectory) - } - }) + this.trackDisposable( + this._workspace.onDirectoryChanged.subscribe(async newDirectory => { + if (newDirectory !== this._neovimInstance.currentVimDirectory) { + await this._neovimInstance.chdir(newDirectory) + } + }), + ) + // TODO: Add first class event for this this._neovimInstance.on("action", (action: any) => { this._renderer.onAction(action) this._screen.dispatch(action) @@ -450,22 +507,25 @@ export class NeovimEditor extends Editor implements IEditor { this._scheduleRender() }) - this._neovimInstance.onRedrawComplete.subscribe(() => { - const isCursorInCommandRow = this._screen.cursorRow === this._screen.height - 1 - const isCommandLineMode = this.mode && this.mode.indexOf("cmdline") === 0 - - // In some cases, during redraw, Neovim will actually set the cursor position - // to the command line when rendering. This can happen when 'echo'ing or - // when a popumenu is enabled, and text is writing. - // - // We should ignore those cases, and only set the cursor in the command row - // when we're actually in command line mode. See #1265 for more context. - if (!isCursorInCommandRow || (isCursorInCommandRow && isCommandLineMode)) { - this._actions.setCursorPosition(this._screen) - this._typingPredictionManager.setCursorPosition(this._screen) - } - }) + this.trackDisposable( + this._neovimInstance.onRedrawComplete.subscribe(() => { + const isCursorInCommandRow = this._screen.cursorRow === this._screen.height - 1 + const isCommandLineMode = this.mode && this.mode.indexOf("cmdline") === 0 + + // In some cases, during redraw, Neovim will actually set the cursor position + // to the command line when rendering. This can happen when 'echo'ing or + // when a popumenu is enabled, and text is writing. + // + // We should ignore those cases, and only set the cursor in the command row + // when we're actually in command line mode. See #1265 for more context. + if (!isCursorInCommandRow || (isCursorInCommandRow && isCommandLineMode)) { + this._actions.setCursorPosition(this._screen) + this._typingPredictionManager.setCursorPosition(this._screen) + } + }), + ) + // TODO: Add first class event for this this._neovimInstance.on("tabline-update", async (currentTabId: number, tabs: ITab[]) => { const atomicCalls = tabs.map((tab: ITab) => { return ["nvim_call_function", ["tabpagebuflist", [tab.id]]] @@ -480,6 +540,7 @@ export class NeovimEditor extends Editor implements IEditor { this._actions.setTabs(currentTabId, tabs) }) + // TODO: Does any disposal need to happen for the observables? this._cursorMoved$ = this._neovimInstance.autoCommands.onCursorMoved .asObservable() .map((evt): Oni.Cursor => ({ @@ -499,35 +560,42 @@ export class NeovimEditor extends Editor implements IEditor { }) this._modeChanged$ = this._neovimInstance.onModeChanged.asObservable() - this._neovimInstance.onModeChanged.subscribe(newMode => this._onModeChanged(newMode)) - this._neovimInstance.onBufferUpdate.subscribe(update => { - const buffer = this._bufferManager.updateBufferFromEvent(update.eventContext) + this.trackDisposable( + this._neovimInstance.onModeChanged.subscribe(newMode => this._onModeChanged(newMode)), + ) - const bufferUpdate = { - context: update.eventContext, - buffer, - contentChanges: update.contentChanges, - } + this.trackDisposable( + this._neovimInstance.onBufferUpdate.subscribe(update => { + const buffer = this._bufferManager.updateBufferFromEvent(update.eventContext) - this.notifyBufferChanged(bufferUpdate) - this._actions.bufferUpdate( - parseInt(bufferUpdate.buffer.id, 10), - bufferUpdate.buffer.modified, - bufferUpdate.buffer.lineCount, - ) + const bufferUpdate = { + context: update.eventContext, + buffer, + contentChanges: update.contentChanges, + } - this._syntaxHighlighter.notifyBufferUpdate(bufferUpdate) - }) + this.notifyBufferChanged(bufferUpdate) + this._actions.bufferUpdate( + parseInt(bufferUpdate.buffer.id, 10), + bufferUpdate.buffer.modified, + bufferUpdate.buffer.lineCount, + ) - this._neovimInstance.onScroll.subscribe((args: EventContext) => { - const convertedArgs: Oni.EditorBufferScrolledEventArgs = { - bufferTotalLines: args.bufferTotalLines, - windowTopLine: args.windowTopLine, - windowBottomLine: args.windowBottomLine, - } - this.notifyBufferScrolled(convertedArgs) - }) + this._syntaxHighlighter.notifyBufferUpdate(bufferUpdate) + }), + ) + + this.trackDisposable( + this._neovimInstance.onScroll.subscribe((args: EventContext) => { + const convertedArgs: Oni.EditorBufferScrolledEventArgs = { + bufferTotalLines: args.bufferTotalLines, + windowTopLine: args.windowTopLine, + windowBottomLine: args.windowBottomLine, + } + this.notifyBufferScrolled(convertedArgs) + }), + ) addInsertModeLanguageFunctionality( this._cursorMovedI$, @@ -552,21 +620,29 @@ export class NeovimEditor extends Editor implements IEditor { ) this._completionMenu = new CompletionMenu(this._contextMenuManager.create()) - this._completion.onShowCompletionItems.subscribe(completions => { - this._completionMenu.show(completions.filteredCompletions, completions.base) - }) + this.trackDisposable( + this._completion.onShowCompletionItems.subscribe(completions => { + this._completionMenu.show(completions.filteredCompletions, completions.base) + }), + ) - this._completion.onHideCompletionItems.subscribe(completions => { - this._completionMenu.hide() - }) + this.trackDisposable( + this._completion.onHideCompletionItems.subscribe(completions => { + this._completionMenu.hide() + }), + ) - this._completionMenu.onItemFocused.subscribe(item => { - this._completion.resolveItem(item) - }) + this.trackDisposable( + this._completionMenu.onItemFocused.subscribe(item => { + this._completion.resolveItem(item) + }), + ) - this._completionMenu.onItemSelected.subscribe(item => { - this._completion.commitItem(item) - }) + this.trackDisposable( + this._completionMenu.onItemSelected.subscribe(item => { + this._completion.commitItem(item) + }), + ) this._languageIntegration = new LanguageEditorIntegration( this, @@ -574,85 +650,70 @@ export class NeovimEditor extends Editor implements IEditor { this._languageManager, ) - this._languageIntegration.onShowHover.subscribe(async hover => { - const { cursorPixelX, cursorPixelY } = this._store.getState() - await this._hoverRenderer.showQuickInfo( - cursorPixelX, - cursorPixelY, - hover.hover, - hover.errors, - ) - }) + this.trackDisposable( + this._languageIntegration.onShowHover.subscribe(async hover => { + const { cursorPixelX, cursorPixelY } = this._store.getState() + await this._hoverRenderer.showQuickInfo( + cursorPixelX, + cursorPixelY, + hover.hover, + hover.errors, + ) + }), + ) - this._languageIntegration.onHideHover.subscribe(() => { - this._hoverRenderer.hideQuickInfo() - }) + this.trackDisposable( + this._languageIntegration.onHideHover.subscribe(() => { + this._hoverRenderer.hideQuickInfo() + }), + ) - this._languageIntegration.onShowDefinition.subscribe(definition => { - this._actions.setDefinition(definition.token, definition.location) - }) + this.trackDisposable( + this._languageIntegration.onShowDefinition.subscribe(definition => { + this._actions.setDefinition(definition.token, definition.location) + }), + ) - this._languageIntegration.onHideDefinition.subscribe(definition => { - this._actions.hideDefinition() - }) + this.trackDisposable( + this._languageIntegration.onHideDefinition.subscribe(definition => { + this._actions.hideDefinition() + }), + ) this._render() - const browserWindow = remote.getCurrentWindow() - - browserWindow.on("blur", () => { - this._neovimInstance.autoCommands.executeAutoCommand("FocusLost") - }) - - browserWindow.on("focus", () => { - this._neovimInstance.autoCommands.executeAutoCommand("FocusGained") - - // If the user has autoread enabled, we should run ":checktime" on - // focus, as this is needed to get the file to auto-update. - // https://github.com/neovim/neovim/issues/1936 - if (_configuration.getValue("vim.setting.autoread")) { - this._neovimInstance.command(":checktime") - } - }) - this._onConfigChanged(this._configuration.getValues()) - this._configuration.onConfigurationChanged.subscribe( - (newValues: Partial) => this._onConfigChanged(newValues), + this.trackDisposable( + this._configuration.onConfigurationChanged.subscribe( + (newValues: Partial) => this._onConfigChanged(newValues), + ), ) - ipcRenderer.on("menu-item-click", (_evt: any, message: string) => { - if (message.startsWith(":")) { - this._neovimInstance.command('exec "' + message + '"') - } else { - this._neovimInstance.command('exec ":normal! ' + message + '"') - } - }) - - ipcRenderer.on("open-files", (_evt: any, message: string, files: string[]) => { - this._openFiles(files, message) + // TODO: Factor these out to a place that isn't dependent on a single editor instance + ipcRenderer.on("open-files", (_evt: any, files: string[]) => { + this.openFiles(files) }) ipcRenderer.on("open-file", (_evt: any, path: string) => { this._neovimInstance.command(`:e! ${path}`) }) + // TODO: Factor this out to a react component // enable opening a file via drag-drop document.body.ondragover = ev => { ev.preventDefault() + ev.stopPropagation() } + document.body.ondrop = ev => { ev.preventDefault() + ev.stopPropagation() const { files } = ev.dataTransfer - // open first file in current editor + if (files.length) { - this._neovimInstance.open(normalizePath(files[0].path)) - // open any subsequent files in new tabs - for (let i = 1; i < files.length; i++) { - this._neovimInstance.command( - 'exec ":tabe ' + normalizePath(files.item(i).path) + '"', - ) - } + const normalisedPaths = Array.from(files).map(f => normalizePath(f.path)) + this.openFiles(normalisedPaths, { openMode: Oni.FileOpenMode.Edit }) } } } @@ -664,6 +725,13 @@ export class NeovimEditor extends Editor implements IEditor { } public dispose(): void { + super.dispose() + + if (this._neovimInstance) { + this._neovimInstance.dispose() + this._neovimInstance = null + } + if (this._syntaxHighlighter) { this._syntaxHighlighter.dispose() this._syntaxHighlighter = null @@ -679,26 +747,53 @@ export class NeovimEditor extends Editor implements IEditor { this._completion = null } - // TODO: Implement full disposal logic - this._popupMenu.dispose() - this._popupMenu = null + if (this._externalMenuOverlay) { + this._externalMenuOverlay.hide() + this._externalMenuOverlay = null + } + + if (this._popupMenu) { + this._popupMenu.dispose() + this._popupMenu = null + } - this._windowManager.dispose() - this._windowManager = null + if (this._windowManager) { + this._windowManager.dispose() + this._windowManager = null + } } public enter(): void { - editorManager.setActiveEditor(this) Log.info("[NeovimEditor::enter]") this._onEnterEvent.dispatch() this._actions.setHasFocus(true) this._commands.activate() + + this._neovimInstance.autoCommands.executeAutoCommand("FocusGained") + this.checkAutoRead() + + if (this.activeBuffer) { + this.notifyBufferEnter(this.activeBuffer) + } + } + + public checkAutoRead(): void { + // If the user has autoread enabled, we should run ":checktime" on + // focus, as this is needed to get the file to auto-update. + // https://github.com/neovim/neovim/issues/1936 + if ( + this._neovimInstance.isInitialized && + this._configuration.getValue("vim.setting.autoread") + ) { + this._neovimInstance.command(":checktime") + } } public leave(): void { Log.info("[NeovimEditor::leave]") this._actions.setHasFocus(false) this._commands.deactivate() + this._neovimInstance.autoCommands.executeAutoCommand("FocusLost") } public async clearSelection(): Promise { @@ -771,6 +866,31 @@ export class NeovimEditor extends Editor implements IEditor { return this.activeBuffer } + public async openFiles( + files: string[], + openOptions: Oni.FileOpenOptions = Oni.DefaultFileOpenOptions, + ): Promise { + if (!files) { + return this.activeBuffer + } + + // Open the first file in the current buffer if there is no file there, + // otherwise use the passed option. + // Respects the users config and uses "tab drop" for Tab users, and "e!" + // otherwise. + if (this.activeBuffer.filePath === "") { + await this.openFile(files[0], { openMode: Oni.FileOpenMode.Edit }) + } else { + await this.openFile(files[0], openOptions) + } + + for (let i = 1; i < files.length; i++) { + await this.openFile(files[i], openOptions) + } + + return this.activeBuffer + } + public async newFile(filePath: string): Promise { await this._neovimInstance.command(":vsp " + filePath) const context = await this._neovimInstance.getContext() @@ -782,9 +902,12 @@ export class NeovimEditor extends Editor implements IEditor { commandManager.executeCommand(command, null) } - public async init(filesToOpen: string[]): Promise { + public async init( + filesToOpen: string[], + startOptions?: Partial, + ): Promise { Log.info("[NeovimEditor::init] Called with filesToOpen: " + filesToOpen) - const startOptions: INeovimStartOptions = { + const defaultOptions: INeovimStartOptions = { runtimePaths: this._pluginManager.getAllRuntimePaths(), transport: this._configuration.getValue("experimental.neovim.transport"), neovimPath: this._configuration.getValue("debug.neovimPath"), @@ -792,7 +915,12 @@ export class NeovimEditor extends Editor implements IEditor { useDefaultConfig: this._configuration.getValue("oni.useDefaultConfig"), } - await this._neovimInstance.start(startOptions) + const combinedOptions = { + ...defaultOptions, + ...startOptions, + } + + await this._neovimInstance.start(combinedOptions) if (this._errorInitializing) { return @@ -818,7 +946,7 @@ export class NeovimEditor extends Editor implements IEditor { } if (filesToOpen && filesToOpen.length > 0) { - await this._openFiles(filesToOpen, ":tabnew") + await this.openFiles(filesToOpen, { openMode: Oni.FileOpenMode.Edit }) } else { if (this._configuration.getValue("experimental.welcome.enabled")) { const buf = await this.openFile("WELCOME") @@ -874,6 +1002,7 @@ export class NeovimEditor extends Editor implements IEditor { return ( { + if (this._windowManager) { + this._windowManager.dispose() + this._windowManager = null + } + + return this._neovimInstance.quit() + } + private _onBounceStart(): void { this._actions.setCursorScale(1.1) } @@ -917,20 +1055,6 @@ export class NeovimEditor extends Editor implements IEditor { this._actions.setCursorScale(1.0) } - private async _openFiles(files: string[], action: string): Promise { - if (!files) { - return - } - - await this._neovimInstance.callFunction("OniOpenFile", [action, files[0]]) - - for (let i = 1; i < files.length; i++) { - await this._neovimInstance.command( - 'exec "' + action + " " + normalizePath(files[i]) + '"', - ) - } - } - private _onModeChanged(newMode: string): void { // 'Bounce' the cursor for show match if (newMode === "showmatch") { diff --git a/browser/src/Editor/NeovimEditor/NeovimSurface.tsx b/browser/src/Editor/NeovimEditor/NeovimSurface.tsx index 4834ef6afb..9a8683306c 100644 --- a/browser/src/Editor/NeovimEditor/NeovimSurface.tsx +++ b/browser/src/Editor/NeovimEditor/NeovimSurface.tsx @@ -28,6 +28,7 @@ import { NeovimInput } from "./NeovimInput" import { NeovimRenderer } from "./NeovimRenderer" export interface INeovimSurfaceProps { + autoFocus: boolean neovimInstance: NeovimInstance renderer: INeovimRenderer screen: NeovimScreen @@ -90,7 +91,7 @@ class NeovimSurface extends React.Component { { + const handle = windowManager.getSplitHandle(this) + handle.close() + editorManager.unregisterEditor(this) + + this.dispose() + }), + ) + + this.trackDisposable( + App.registerQuitHook(async () => { + if (!this.isDisposed) { + this.quit() + } + }), + ) + this._neovimEditor.bufferLayers.addBufferLayer("*", buf => wrapReactComponentWithLayer("oni.layer.scrollbar", ), ) @@ -155,6 +178,8 @@ export class OniEditor implements IEditor { } public dispose(): void { + super.dispose() + if (this._neovimEditor) { this._neovimEditor.dispose() this._neovimEditor = null @@ -163,10 +188,10 @@ export class OniEditor implements IEditor { public enter(): void { Log.info("[OniEditor::enter]") - this._neovimEditor.enter() - editorManager.setActiveEditor(this) + this._neovimEditor.enter() + commandManager.registerCommand({ command: "editor.split.horizontal", execute: () => this._split("horizontal"), @@ -253,6 +278,10 @@ export class OniEditor implements IEditor { return this._neovimEditor.render() } + public async quit(): Promise { + return this._neovimEditor.quit() + } + private async _split(direction: SplitDirection): Promise { if (this._configuration.getValue("editor.split.mode") !== "oni") { if (direction === "horizontal") { @@ -274,7 +303,6 @@ export class OniEditor implements IEditor { this._overlayManager, this._pluginManager, this._snippetManager, - this._tasks, this._themeManager, this._tokenColors, this._workspace, diff --git a/browser/src/Editor/OniEditor/containers/BufferScrollBarContainer.ts b/browser/src/Editor/OniEditor/containers/BufferScrollBarContainer.ts index 04dab4b38c..17d5d9aa63 100644 --- a/browser/src/Editor/OniEditor/containers/BufferScrollBarContainer.ts +++ b/browser/src/Editor/OniEditor/containers/BufferScrollBarContainer.ts @@ -3,7 +3,7 @@ import * as types from "vscode-languageserver-types" import { connect } from "react-redux" import { createSelector } from "reselect" -import { getColorFromSeverity } from "./../../../Services/Errors" +import { getColorFromSeverity } from "./../../../Services/Diagnostics" import { BufferScrollBar, IBufferScrollBarProps, diff --git a/browser/src/PersistentStore.ts b/browser/src/PersistentStore.ts new file mode 100644 index 0000000000..240816478d --- /dev/null +++ b/browser/src/PersistentStore.ts @@ -0,0 +1,73 @@ +/** + * Store.ts + * + * Abstraction for a persistent data store, that supports versioning and upgrade. + */ + +import { remote } from "electron" + +import * as Log from "./Log" + +// We need to use the 'main process' version of electron-settings. +// See: https://github.com/nathanbuchar/electron-settings/wiki/FAQs +const PersistentSettings = remote.require("electron-settings") + +export interface IPersistentStore { + get(): Promise + set(value: T): Promise +} + +export const getPersistentStore = ( + storeIdentifier: string, + defaultValue: T, + currentVersion: number = 0, +): IPersistentStore => { + return new PersistentStore(storeIdentifier, defaultValue, currentVersion) +} + +export interface IPersistedValueWithMetadata { + schemaVersion: number + value: T +} + +export class PersistentStore implements IPersistentStore { + private _currentValue: IPersistedValueWithMetadata = null + + constructor( + private _storeKey: string, + private _defaultValue: T, + private _currentVersion: number, + ) { + let val = null + try { + val = JSON.parse(PersistentSettings.get(this._storeKey)) + } catch (ex) { + Log.warn("Error deserializing from store: " + ex) + } + + this._currentValue = val + + if (!this._currentValue) { + this._currentValue = { + value: this._defaultValue, + schemaVersion: this._currentVersion, + } + } + + // TODO: Check if _currentVersion is ahead of the value, + // if so, upgrade + } + + public async get(): Promise { + return this._currentValue.value + } + + public async set(val: T): Promise { + this._currentValue = { + value: val, + schemaVersion: this._currentVersion, + } + + PersistentSettings.set(this._storeKey, JSON.stringify(this._currentValue)) + } +} diff --git a/browser/src/Platform.ts b/browser/src/Platform.ts index 73c0dc0840..b46bd126ba 100644 --- a/browser/src/Platform.ts +++ b/browser/src/Platform.ts @@ -1,5 +1,3 @@ -/// - import * as fs from "fs" import * as os from "os" import * as path from "path" diff --git a/browser/src/Plugins/Api/Oni.ts b/browser/src/Plugins/Api/Oni.ts index 0d334971f7..7c0e52391c 100644 --- a/browser/src/Plugins/Api/Oni.ts +++ b/browser/src/Plugins/Api/Oni.ts @@ -24,6 +24,8 @@ import { getInstance as getDiagnosticsInstance } from "./../../Services/Diagnost import { editorManager } from "./../../Services/EditorManager" import { inputManager } from "./../../Services/InputManager" import * as LanguageManager from "./../../Services/Language" +import { getTutorialManagerInstance } from "./../../Services/Learning" +import { getInstance as getAchievementsInstance } from "./../../Services/Learning/Achievements" import { getInstance as getMenuManagerInstance } from "./../../Services/Menu" import { getInstance as getNotificationsInstance } from "./../../Services/Notifications" import { getInstance as getOverlayInstance } from "./../../Services/Overlay" @@ -60,6 +62,10 @@ export class Oni implements OniApi.Plugin.Api { private _services: Services private _colors: Colors + public get achievements(): any /* TODO: Promote to API */ { + return getAchievementsInstance() + } + public get automation(): OniApi.Automation.Api { return automation } @@ -108,7 +114,7 @@ export class Oni implements OniApi.Plugin.Api { return editorManager } - public get input(): OniApi.InputManager { + public get input(): OniApi.Input.InputManager { return inputManager } @@ -120,11 +126,11 @@ export class Oni implements OniApi.Plugin.Api { return getMenuManagerInstance() } - public get notifications(): any { + public get notifications(): OniApi.Notifications.Api { return getNotificationsInstance() } - public get overlays(): any /* TODO */ { + public get overlays(): OniApi.Overlays.Api { return getOverlayInstance() } @@ -136,7 +142,7 @@ export class Oni implements OniApi.Plugin.Api { return getSidebarInstance() } - public get snippets(): any { + public get snippets(): OniApi.Snippets.SnippetManager { return getSnippetsInstance() } @@ -156,11 +162,15 @@ export class Oni implements OniApi.Plugin.Api { return this._services } + public get tutorials(): any /* todo */ { + return getTutorialManagerInstance() + } + public get windows(): OniApi.IWindowManager { return windowManager as any } - public get workspace(): OniApi.Workspace { + public get workspace(): OniApi.Workspace.Api { return getWorkspaceInstance() } diff --git a/browser/src/Plugins/PluginConfigurationSynchronizer.ts b/browser/src/Plugins/PluginConfigurationSynchronizer.ts new file mode 100644 index 0000000000..40d4af0e08 --- /dev/null +++ b/browser/src/Plugins/PluginConfigurationSynchronizer.ts @@ -0,0 +1,36 @@ +/** + * PluginConfigurationSynchronizer.ts + * + * Responsible for synchronizing user's `plugin` configuration settings. + */ + +import * as Log from "./../Log" + +import { Configuration } from "./../Services/Configuration" +import { PluginManager } from "./PluginManager" + +export const activate = (configuration: Configuration, pluginManager: PluginManager): void => { + const setting = configuration.registerSetting("plugins", { + description: + "`plugins` is an array of strings designating plugins that should be installed. Plugins can either be installed from `npm` (for Oni / JS plugins), or from GitHub (usually for Vim plugins). For an `npm` plugin, simply specify the package name - like 'oni-power-mode'. For a GitHub plugin, specify the user + plugin, for example 'tpope/vim-fugitive'", + requiresReload: false, + }) + + setting.onValueChanged.subscribe(evt => { + if (!evt.newValue || !evt.newValue.length) { + return + } + + Log.verbose("[PluginConfigurationSynchronizer - onValueChanged]") + + const newPlugins = evt.newValue.filter(plugin => evt.oldValue.indexOf(plugin) === -1) + + Log.info("[PluginConfigurationSynchronizer] New Plugins: " + newPlugins) + + newPlugins.forEach(async plugin => { + Log.info("[PluginConfigurationSynchronizer] Installing plugin: " + plugin) + await pluginManager.installer.install(plugin) + Log.info("[PluginConfigurationSynchronizer] Installation complete!") + }) + }) +} diff --git a/browser/src/Plugins/PluginInstaller.ts b/browser/src/Plugins/PluginInstaller.ts new file mode 100644 index 0000000000..3f6983a93f --- /dev/null +++ b/browser/src/Plugins/PluginInstaller.ts @@ -0,0 +1,162 @@ +/** + * PluginInstaller.ts + * + * Responsible for installing, updating, and uninstalling plugins. + */ + +import * as fs from "fs" +import * as path from "path" + +import { Event, IEvent } from "oni-types" + +// import * as Oni from "oni-api" + +import { getUserConfigFolderPath } from "./../Services/Configuration" +// import { IContributions } from "./Api/Capabilities" + +// import { AnonymousPlugin } from "./AnonymousPlugin" +// import { Plugin } from "./Plugin" + +import { FileSystem, IFileSystem } from "./../Services/Explorer/ExplorerFileSystem" + +import Process from "./Api/Process" + +import * as Log from "./../Log" + +/** + * Plugin identifier: + * - For _git_, this should be of the form `welle/targets.vim` + * - For _npm_, this should be the name of the module, `oni-plugin-tslint` + */ +export type PluginIdentifier = string + +export interface IPluginInstallerOperationEvent { + type: "install" | "uninstall" + identifier: string + error?: Error +} + +export interface IPluginInstaller { + onOperationStarted: IEvent + onOperationCompleted: IEvent + onOperationError: IEvent + + install(pluginInfo: PluginIdentifier): Promise + uninstall(pluginInfo: PluginIdentifier): Promise +} + +export class YarnPluginInstaller implements IPluginInstaller { + private _onOperationStarted = new Event() + private _onOperationCompleted = new Event() + private _onOperationError = new Event() + + public get onOperationStarted(): IEvent { + return this._onOperationStarted + } + + public get onOperationCompleted(): IEvent { + return this._onOperationCompleted + } + + public get onOperationError(): IEvent { + return this._onOperationError + } + + constructor(private _fileSystem: IFileSystem = new FileSystem(fs)) {} + + public async install(identifier: string): Promise { + const eventInfo: IPluginInstallerOperationEvent = { + type: "install", + identifier, + } + + try { + this._onOperationStarted.dispatch(eventInfo) + await this._ensurePackageJsonIsCreated() + await this._runYarnCommand("add", [identifier]) + this._onOperationCompleted.dispatch(eventInfo) + } catch (ex) { + this._onOperationError.dispatch({ + ...eventInfo, + error: ex, + }) + } + } + + public async uninstall(identifier: string): Promise { + const eventInfo: IPluginInstallerOperationEvent = { + type: "uninstall", + identifier, + } + + try { + this._onOperationStarted.dispatch(eventInfo) + await this._runYarnCommand("remove", [identifier]) + this._onOperationCompleted.dispatch(eventInfo) + } catch (ex) { + this._onOperationError.dispatch({ + ...eventInfo, + error: ex, + }) + } + } + + private async _ensurePackageJsonIsCreated(): Promise { + const packageJsonFile = this._getPackageJsonFile() + Log.info( + `[YarnPluginInstaller::_ensurePackageJsonIsCreated] - checking file: ${packageJsonFile}`, + ) + + const doesPackageFileExist = await this._fileSystem.exists(packageJsonFile) + + if (!doesPackageFileExist) { + Log.info( + `[YarnPluginInstaller::_ensurePackageJsonIsCreated] - package file does not exist, initializing.`, + ) + await this._runYarnCommand("init", ["-y"]) + Log.info( + `[YarnPluginInstaller::_ensurePackageJsonIsCreated] - package file created successfully.`, + ) + } else { + Log.info( + `[YarnPluginInstaller::_ensurePackageJsonIsCreated] - package file is available.`, + ) + } + } + + private async _runYarnCommand(command: string, args: string[]): Promise { + const yarnPath = this._getYarnPath() + + const workingDirectory = getUserConfigFolderPath() + const pluginDirectory = this._getPluginsFolder() + + return new Promise((resolve, reject) => { + Process.execNodeScript( + yarnPath, + ["--modules-folder", pluginDirectory, "--production", "true", command, ...args], + { cwd: workingDirectory }, + (err: any, stdout: string, stderr: string) => { + if (err) { + Log.error("Error installing: " + stderr) + reject(err) + return + } + + resolve() + }, + ) + }) + } + + private _getPackageJsonFile(): string { + return path.join(getUserConfigFolderPath(), "package.json") + } + + private _getPluginsFolder(): string { + return path.join(getUserConfigFolderPath(), "plugins") + } + + private _getYarnPath(): string { + return path.join(__dirname, "lib", "yarn", "yarn-1.5.1.js") + } +} diff --git a/browser/src/Plugins/PluginManager.ts b/browser/src/Plugins/PluginManager.ts index 9cd68d7e26..3c151f7123 100644 --- a/browser/src/Plugins/PluginManager.ts +++ b/browser/src/Plugins/PluginManager.ts @@ -15,16 +15,23 @@ const extensionsRoot = path.join(__dirname, "extensions") import { flatMap } from "./../Utility" +import { IPluginInstaller, YarnPluginInstaller } from "./PluginInstaller" + export class PluginManager implements Oni.IPluginManager { private _rootPluginPaths: string[] = [] private _plugins: Plugin[] = [] private _anonymousPlugin: AnonymousPlugin private _pluginsActivated: boolean = false + private _installer: IPluginInstaller = new YarnPluginInstaller() public get plugins(): Plugin[] { return this._plugins } + public get installer(): IPluginInstaller { + return this._installer + } + constructor(private _config: Configuration) {} public discoverPlugins(): void { diff --git a/browser/src/Plugins/PluginSidebarPane.tsx b/browser/src/Plugins/PluginSidebarPane.tsx index 2e0eaef78a..fb828d68b9 100644 --- a/browser/src/Plugins/PluginSidebarPane.tsx +++ b/browser/src/Plugins/PluginSidebarPane.tsx @@ -16,6 +16,8 @@ import { VimNavigator } from "./../UI/components/VimNavigator" import { PluginManager } from "./../Plugins/PluginManager" +import { noop } from "./../Utility" + export class PluginsSidebarPane implements SidebarPane { private _onEnter = new Event() private _onLeave = new Event() @@ -28,6 +30,8 @@ export class PluginsSidebarPane implements SidebarPane { return "Plugins" } + constructor(private _pluginManager: PluginManager) {} + public enter(): void { this._onEnter.dispatch() } @@ -36,8 +40,6 @@ export class PluginsSidebarPane implements SidebarPane { this._onLeave.dispatch() } - constructor(private _pluginManager: PluginManager) {} - public render(): JSX.Element { return ( )) @@ -129,6 +132,7 @@ export class PluginsSidebarPaneView extends React.PureComponent< isFocused={p.id === selectedId} isContainer={false} text={p.id} + onClick={noop} /> )) @@ -138,6 +142,7 @@ export class PluginsSidebarPaneView extends React.PureComponent< text={"Default"} isExpanded={this.state.defaultPluginsExpanded} isFocused={selectedId === "container.default"} + onClick={noop} > {defaultPluginItems} @@ -145,6 +150,7 @@ export class PluginsSidebarPaneView extends React.PureComponent< text={"User"} isExpanded={this.state.userPluginsExpanded} isFocused={selectedId === "container.user"} + onClick={noop} > {userPluginItems} diff --git a/browser/src/ProjectConfig.ts b/browser/src/ProjectConfig.ts deleted file mode 100644 index 05c5801393..0000000000 --- a/browser/src/ProjectConfig.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * The conventions for project configuration are inspired from the VSCode launch.json: - * https://code.visualstudio.com/Docs/editor/debugging - */ - -import * as fs from "fs" -import * as path from "path" - -const findUp = require("find-up") // tslint:disable-line no-var-requires - -export type LaunchType = "execute" -export type Request = "launch" // attach later - -export interface ILaunchConfiguration { - type: LaunchType - name: string - program: string - args: string[] - cwd: string - dependentCommands: string[] -} - -/** - * Per-project configuration options - */ -export interface IProjectConfiguration { - launchConfigurations: ILaunchConfiguration[] -} - -const DefaultConfiguration: IProjectConfiguration = { - launchConfigurations: [], -} - -/** - * Get the project configuration for a particular file - * Search upward for the relevant .oni folder - */ -export function getProjectConfiguration(filePath: string): Promise { - return findUp(".oni", { cwd: filePath }).then((oniDir: string) => { - if (!oniDir) { - return DefaultConfiguration - } - return loadConfigurationFromFolder(oniDir) - }) -} - -function loadConfigurationFromFolder(folder: string): IProjectConfiguration { - const launchPath = path.join(folder, "launch.json") - - if (!fs.existsSync(launchPath)) { - return DefaultConfiguration - } else { - const launchInfo: ILaunchConfiguration = JSON.parse(fs.readFileSync(launchPath, "utf8")) - const config = { - ...DefaultConfiguration, - ...{ - launchConfigurations: [launchInfo], - }, - } - - return config - } -} diff --git a/browser/src/Services/AutoClosingPairs.ts b/browser/src/Services/AutoClosingPairs.ts index 4f833b4e14..d710239512 100644 --- a/browser/src/Services/AutoClosingPairs.ts +++ b/browser/src/Services/AutoClosingPairs.ts @@ -37,6 +37,7 @@ export const activate = ( editor: Oni.Editor, openCharacterSameAsClosed: boolean, ) => () => { + Log.verbose("[AutoClosingPairs::handleOpenCharacter] " + pair.open) const neovim: NeovimInstance = editor.neovim as any neovim.blockInput(async (inputFunc: any) => { await checkOpenCharacter(inputFunc, pair, editor, openCharacterSameAsClosed) @@ -46,6 +47,7 @@ export const activate = ( } const handleBackspaceCharacter = (pairs: IAutoClosingPair[], editor: Oni.Editor) => () => { + Log.verbose("[AutoClosingPairs::handleBackspaceCharacter]") const neovim: NeovimInstance = editor.neovim as any neovim.blockInput(async (inputFunc: any) => { const activeBuffer = editor.activeBuffer @@ -84,6 +86,7 @@ export const activate = ( } const handleEnterCharacter = (pairs: IAutoClosingPair[], editor: Oni.Editor) => () => { + Log.verbose("[AutoClosingPairs::handleEnterCharacter]") const neovim: NeovimInstance = editor.neovim as any editor.blockInput(async (inputFunc: Oni.InputCallbackFunction) => { const activeBuffer = editor.activeBuffer @@ -123,6 +126,7 @@ export const activate = ( } const handleCloseCharacter = (pair: IAutoClosingPair, editor: Oni.Editor) => () => { + Log.verbose("[AutoClosingPairs::handleCloseCharacter]") editor.blockInput(async (inputFunc: Oni.InputCallbackFunction) => { const activeBuffer = editor.activeBuffer const lines = await activeBuffer.getLines( @@ -200,7 +204,7 @@ export const activate = ( ) } - editorManager.activeEditor.onBufferEnter.subscribe(onBufferEnter) + editorManager.anyEditor.onBufferEnter.subscribe(onBufferEnter) const activeEditor = editorManager.activeEditor if (activeEditor && activeEditor.activeBuffer) { diff --git a/browser/src/Services/Automation.ts b/browser/src/Services/Automation.ts index 63b93e3e46..7742e5bf94 100644 --- a/browser/src/Services/Automation.ts +++ b/browser/src/Services/Automation.ts @@ -8,15 +8,14 @@ import { remote } from "electron" import * as OniApi from "oni-api" +import * as App from "./../App" import * as Utility from "./../Utility" -import { getInstance as getSharedNeovimInstance } from "./../neovim/SharedNeovimInstance" import { getUserConfigFilePath } from "./Configuration" import { editorManager } from "./EditorManager" import { inputManager } from "./InputManager" import * as Log from "./../Log" -import * as Shell from "./../UI/Shell" import { IKey, parseKeysFromVimString } from "./../Input/KeyParser" @@ -45,6 +44,8 @@ export class Automation implements OniApi.Automation.Api { const convertCharacter = (key: string) => { switch (key.toLowerCase()) { + case "lt": + return "<" case "cr": return "enter" default: @@ -111,34 +112,9 @@ export class Automation implements OniApi.Automation.Api { } public async waitForEditors(): Promise { - // Add explicit wait for Neovim to be initialized - // The CI machines can often be slow, so we need a longer timout for it - // TODO: Replace with a more explicit condition, once our startup - // path is well-defined (#89, #355, #372) - - // Add explicit wait for Neovim to be initialized - // The CI machines can often be slow, so we need a longer timout for it - // TODO: Replace with a more explicit condition, once our startup - // path is well-defined (#89, #355, #372) Log.info("[AUTOMATION] Waiting for startup...") - await this.waitFor(() => (Shell.store.getState() as any).isLoaded, 30000) + await App.waitForStart() Log.info("[AUTOMATION] Startup complete!") - - Log.info("[AUTOMATION] Waiting for neovim to attach to editor...") - await this.waitFor( - () => - editorManager.activeEditor.neovim && - (editorManager.activeEditor as any).neovim.isInitialized, - 30000, - ) - Log.info("[AUTOMATION] Neovim initialized!") - - Log.info("[AUTOMATION] Waiting for shared neovim instance...") - await this.waitFor( - () => getSharedNeovimInstance() && getSharedNeovimInstance().isInitialized, - 30000, - ) - Log.info("[AUTOMATION] Shared neovim instance initialized!") } public async runTest(testPath: string): Promise { @@ -158,9 +134,10 @@ export class Automation implements OniApi.Automation.Api { await testCase.test(oni) Log.info("[AUTOMATION] Completed test: " + testPath) - this._reportResult(true) + + await this._reportResult(true) } catch (ex) { - this._reportResult(false, ex) + await this._reportResult(false, ex) } finally { this._reportWindowSize() } @@ -193,7 +170,20 @@ export class Automation implements OniApi.Automation.Api { return container } - private _reportResult(passed: boolean, exception?: any): void { + private async _reportResult(passed: boolean, exception?: any): Promise { + Log.info("[AUTOMATION] Quitting...") + // Close all Neovim instances, but don't close the browser window... let Spectron + // take care of that. + + // TODO: Bring this back once the 'quit' logic is more stable! + // editorManager.setCloseWhenNoEditors(false) + // try { + // await App.quit() + // } catch (ex) { + // Log.error(ex) + // } + // Log.info("[AUTOMATION] Quit successfully") + const resultElement = this._createElement( "automated-test-result", this._getOrCreateTestContainer("automated-test-container"), diff --git a/browser/src/Services/Bookmarks/BookmarksPane.tsx b/browser/src/Services/Bookmarks/BookmarksPane.tsx index 24a3cb162f..cfcc5fde7c 100644 --- a/browser/src/Services/Bookmarks/BookmarksPane.tsx +++ b/browser/src/Services/Bookmarks/BookmarksPane.tsx @@ -176,6 +176,7 @@ export class BookmarksPaneView extends React.PureComponent< isFocused={selectedId === bm.id} isContainer={false} indentationLevel={0} + onClick={() => this._onSelected(bm.id)} /> ) @@ -199,6 +200,7 @@ export class BookmarksPaneView extends React.PureComponent< text="Global Marks" isExpanded={this.state.isGlobalSectionExpanded} isFocused={selectedId === "container.global"} + onClick={() => this._onSelected("container.global")} > {globalMarks.map(mapFunc)} @@ -206,6 +208,7 @@ export class BookmarksPaneView extends React.PureComponent< text="Local Marks" isExpanded={this.state.isLocalSectionExpanded} isFocused={selectedId === "container.local"} + onClick={() => this._onSelected("container.local")} > {localMarks.map(mapFunc)} diff --git a/browser/src/Services/Browser/AddressBarView.tsx b/browser/src/Services/Browser/AddressBarView.tsx new file mode 100644 index 0000000000..c1b0cd3331 --- /dev/null +++ b/browser/src/Services/Browser/AddressBarView.tsx @@ -0,0 +1,106 @@ +/** + * AddressBarView.tsx + * + * Component to manage address bar state (whether it is focused or not) + */ + +import * as React from "react" +import styled from "styled-components" + +import { TextInputView } from "./../../UI/components/LightweightText" +import { Sneakable } from "./../../UI/components/Sneakable" + +import { withProps } from "./../../UI/components/common" + +const AddressBarWrapper = styled.div` + width: 100%; + + height: 2.5em; + line-height: 2.5em; + + text-align: left; +` + +const EditableAddressBarWrapper = withProps<{}>(styled.div)` + + border: 1px solid ${p => p.theme["highlight.mode.insert.background"]}; + + + &, & input { + background-color: ${p => p.theme["editor.background"]}; + color: ${p => p.theme["editor.foreground"]}; + } + + & input { + margin-left: 1em; + } +` + +export interface IAddressBarViewProps { + url: string + + onAddressChanged: (newAddress: string) => void +} + +export interface IAddressBarViewState { + isActive: boolean +} + +export class AddressBarView extends React.PureComponent< + IAddressBarViewProps, + IAddressBarViewState +> { + constructor(props: IAddressBarViewProps) { + super(props) + + this.state = { + isActive: false, + } + } + + public render(): JSX.Element { + const contents = this.state.isActive ? this._renderTextInput() : this._renderAddressSpan() + + return {contents} + } + + private _renderTextInput(): JSX.Element { + return ( + + { + this._onComplete(evt) + }} + onCancel={() => this._onCancel()} + /> + + ) + } + + private _renderAddressSpan(): JSX.Element { + return ( + this._setActive()}> + this._setActive()}>{this.props.url} + + ) + } + + private _setActive(): void { + this.setState({ + isActive: true, + }) + } + + private _onCancel(): void { + this.setState({ + isActive: false, + }) + } + + private _onComplete(val: string): void { + this.props.onAddressChanged(val) + + this._onCancel() + } +} diff --git a/browser/src/Services/Browser/BrowserButtonView.tsx b/browser/src/Services/Browser/BrowserButtonView.tsx new file mode 100644 index 0000000000..9343aea004 --- /dev/null +++ b/browser/src/Services/Browser/BrowserButtonView.tsx @@ -0,0 +1,43 @@ +/** + * BrowserButtonView.tsx + * + * Component for the browser buttons on the address bar of the integrated browser + */ + +import * as React from "react" +import styled from "styled-components" + +import { Icon, IconSize } from "./../../UI/Icon" + +import { Sneakable } from "./../../UI/components/Sneakable" + +const BrowserButtonWrapper = styled.div` + width: 2.5em; + height: 2.5em; + flex: 0 0 auto; + opacity: 0.9; + + display: flex; + justify-content: center; + align-items: center; + + &:hover { + opacity: 1; + box-shadow: 0 -8px 20px 0 rgba(0, 0, 0, 0.2); + } +` + +export interface IBrowserButtonViewProps { + onClick: () => void + icon: string +} + +export const BrowserButtonView = (props: IBrowserButtonViewProps): JSX.Element => { + return ( + + + + + + ) +} diff --git a/browser/src/Services/Browser/BrowserView.tsx b/browser/src/Services/Browser/BrowserView.tsx index 7f20f71a76..7e28784e71 100644 --- a/browser/src/Services/Browser/BrowserView.tsx +++ b/browser/src/Services/Browser/BrowserView.tsx @@ -9,12 +9,16 @@ import * as path from "path" import * as React from "react" import styled from "styled-components" -import { IDisposable, IEvent } from "oni-types" import * as Oni from "oni-api" +import { IDisposable, IEvent } from "oni-types" -import { Icon, IconSize } from "./../../UI/Icon" - +import { Configuration } from "./../../Services/Configuration" +import { getInstance as getAchievementsInstance } from "./../../Services/Learning/Achievements" import { getInstance as getSneakInstance, ISneakInfo } from "./../../Services/Sneak" +import { focusManager } from "./../FocusManager" + +import { AddressBarView } from "./AddressBarView" +import { BrowserButtonView } from "./BrowserButtonView" const Column = styled.div` pointer-events: auto; @@ -51,34 +55,10 @@ const BrowserViewWrapper = styled.div` } ` -const BrowserButton = styled.div` - width: 2.5em; - height: 2.5em; - flex: 0 0 auto; - opacity: 0.9; - - display: flex; - justify-content: center; - align-items: center; - - &:hover { - opacity: 1; - box-shadow: 0 -8px 20px 0 rgba(0, 0, 0, 0.2); - } -` - -const AddressBar = styled.div` - width: 100%; - flex: 1 1 auto; - - height: 2.5em; - line-height: 2.5em; - - text-align: left; -` - export interface IBrowserViewProps { - url: string + initialUrl: string + + configuration: Configuration debug: IEvent goBack: IEvent @@ -86,15 +66,28 @@ export interface IBrowserViewProps { reload: IEvent } +export interface IBrowserViewState { + url: string +} + export interface SneakInfoFromBrowser { id: string rectangle: Oni.Shapes.Rectangle } -export class BrowserView extends React.PureComponent { +export class BrowserView extends React.PureComponent { private _webviewElement: any + private _elem: HTMLElement private _disposables: IDisposable[] = [] + constructor(props: IBrowserViewProps) { + super(props) + + this.state = { + url: props.initialUrl, + } + } + public componentDidMount(): void { const d1 = this.props.goBack.subscribe(() => this._goBack()) const d2 = this.props.goForward.subscribe(() => this._goForward()) @@ -118,12 +111,13 @@ export class BrowserView extends React.PureComponent { return sneaks.map(s => { const callbackFunction = (id: string) => () => this._triggerSneak(id) + const zoomFactor = this._getZoomFactor() return { rectangle: Oni.Shapes.Rectangle.create( - webviewDimensions.left + s.rectangle.x, - webviewDimensions.top + s.rectangle.y, - s.rectangle.width, - s.rectangle.height, + webviewDimensions.left + s.rectangle.x * zoomFactor, + webviewDimensions.top + s.rectangle.y * zoomFactor, + s.rectangle.width * zoomFactor, + s.rectangle.height * zoomFactor, ), callback: callbackFunction(s.id), } @@ -133,12 +127,24 @@ export class BrowserView extends React.PureComponent { return [] }) - this._disposables = this._disposables.concat([d1, d2, d3, d4, d5]) + const d6 = this.props.configuration.onConfigurationChanged.subscribe(val => { + const newZoomFactor = val["browser.zoomFactor"] + + if (this._webviewElement && newZoomFactor) { + this._webviewElement.setZoomFactor(newZoomFactor) + } + }) + + this._disposables = this._disposables.concat([d1, d2, d3, d4, d5, d6]) + this._initializeElement(this._elem) } public _triggerSneak(id: string): void { if (this._webviewElement) { + this._webviewElement.focus() this._webviewElement.executeJavaScript(`window["__oni_sneak_execute__"]("${id}")`, true) + + getAchievementsInstance().notifyGoal("oni.goal.sneakIntoBrowser") } } @@ -152,25 +158,18 @@ export class BrowserView extends React.PureComponent { return ( - this._goBack()}> - - - this._goForward()}> - - - this._reload()}> - - - - {this.props.url} - - this._openDebugger()}> - - + + + + this._navigate(url)} + /> +
    this._initializeElement(elem)} + ref={elem => (this._elem = elem)} style={{ position: "absolute", top: "0px", @@ -185,37 +184,80 @@ export class BrowserView extends React.PureComponent { ) } - private _goBack(): void { + public prefixUrl = (url: string) => { + // Regex Explainer - match at the beginning of the string ^ + // brackets to match the selection not partial match like :// + // match http or https, then match :// + const hasValidProtocol = /^(https?:)\/\//i + if (url && !hasValidProtocol.test(url)) { + return `http://${url}` + } + return url + } + + private _navigate = (url: string): void => { + if (this._webviewElement) { + this._webviewElement.src = this.prefixUrl(url) + + this.setState({ + url, + }) + } + } + + private _goBack = (): void => { if (this._webviewElement) { this._webviewElement.goBack() } } - private _goForward(): void { + private _goForward = (): void => { if (this._webviewElement) { this._webviewElement.goForward() } } - private _openDebugger(): void { + private _openDebugger = (): void => { if (this._webviewElement) { this._webviewElement.openDevTools() } } - private _reload(): void { + private _reload = (): void => { if (this._webviewElement) { this._webviewElement.reload() } } - private _initializeElement(elem: HTMLElement) { + private _getZoomFactor = (): number => { + return this.props.configuration.getValue("browser.zoomFactor", 1.0) + } + + private _initializeElement = (elem: HTMLElement) => { if (elem && !this._webviewElement) { const webviewElement = document.createElement("webview") webviewElement.preload = path.join(__dirname, "lib", "webview_preload", "index.js") elem.appendChild(webviewElement) this._webviewElement = webviewElement - this._webviewElement.src = this.props.url + this._navigate(this.props.initialUrl) + + this._webviewElement.addEventListener("dom-ready", () => { + this._webviewElement.setZoomFactor(this._getZoomFactor()) + }) + + this._webviewElement.addEventListener("did-navigate", (evt: any) => { + this.setState({ + url: evt.url, + }) + }) + + this._webviewElement.addEventListener("focus", () => { + focusManager.pushFocus(this._webviewElement) + }) + + this._webviewElement.addEventListener("blur", () => { + focusManager.popFocus(this._webviewElement) + }) } } } diff --git a/browser/src/Services/Browser/index.tsx b/browser/src/Services/Browser/index.tsx index cea8c06589..49e8eb0ff7 100644 --- a/browser/src/Services/Browser/index.tsx +++ b/browser/src/Services/Browser/index.tsx @@ -13,6 +13,10 @@ import { Event } from "oni-types" import { CommandManager } from "./../CommandManager" import { Configuration } from "./../Configuration" import { EditorManager } from "./../EditorManager" +import { + AchievementsManager, + getInstance as getAchievementsInstance, +} from "./../Learning/Achievements" import { BrowserView } from "./BrowserView" @@ -22,7 +26,7 @@ export class BrowserLayer implements Oni.BufferLayer { private _goForwardEvent = new Event() private _reloadEvent = new Event() - constructor(private _url: string) {} + constructor(private _url: string, private _configuration: Configuration) {} public get id(): string { return "oni.browser" @@ -31,7 +35,8 @@ export class BrowserLayer implements Oni.BufferLayer { public render(): JSX.Element { return ( { - if (configuration.getValue("experimental.browser.enabled")) { - url = url || configuration.getValue("browser.defaultUrl") + if (browserEnabledSetting.getValue()) { + url = url || defaultUrlSetting.getValue() count++ const buffer: Oni.Buffer = await editorManager.activeEditor.openFile( @@ -75,29 +101,32 @@ export const activate = ( { openMode }, ) - const layer = new BrowserLayer(url) + const layer = new BrowserLayer(url, configuration) buffer.addLayer(layer) activeLayers[buffer.id] = layer + + const achievements = getAchievementsInstance() + achievements.notifyGoal("oni.goal.openBrowser") } else { shell.openExternal(url) } } - if (configuration.getValue("experimental.browser.enabled")) { - commandManager.registerCommand({ - command: "browser.openUrl.verticalSplit", - name: "Browser: Open in Vertical Split", - detail: "Open a browser window", - execute: (url?: string) => openUrl(url, Oni.FileOpenMode.VerticalSplit), - }) - - commandManager.registerCommand({ - command: "browser.openUrl.horizontalSplit", - name: "Browser: Open in Horizontal Split", - detail: "Open a browser window", - execute: (url?: string) => openUrl(url, Oni.FileOpenMode.HorizontalSplit), - }) - } + commandManager.registerCommand({ + command: "browser.openUrl.verticalSplit", + name: "Browser: Open in Vertical Split", + detail: "Open a browser window", + execute: (url?: string) => openUrl(url, Oni.FileOpenMode.VerticalSplit), + enabled: () => browserEnabledSetting.getValue(), + }) + + commandManager.registerCommand({ + command: "browser.openUrl.horizontalSplit", + name: "Browser: Open in Horizontal Split", + detail: "Open a browser window", + execute: (url?: string) => openUrl(url, Oni.FileOpenMode.HorizontalSplit), + enabled: () => browserEnabledSetting.getValue(), + }) commandManager.registerCommand({ command: "browser.openUrl", @@ -117,7 +146,7 @@ export const activate = ( const isBrowserLayerActive = () => !!activeLayers[editorManager.activeEditor.activeBuffer.id] && - !!configuration.getValue("experimental.browser.enabled") + browserEnabledSetting.getValue() // Per-layer commands commandManager.registerCommand({ @@ -152,3 +181,32 @@ export const activate = ( enabled: isBrowserLayerActive, }) } + +export const registerAchievements = (achievements: AchievementsManager) => { + achievements.registerAchievement({ + uniqueId: "oni.achievement.openBrowser", + name: "Browserception", + description: "Open a browser window inside Oni", + goals: [ + { + name: null, + goalId: "oni.goal.openBrowser", + count: 1, + }, + ], + }) + + achievements.registerAchievement({ + uniqueId: "oni.achievement.sneakIntoBrowser", + name: "Incognito", + dependsOnId: "oni.achievement.openBrowser", + description: "Use 'sneak' to interact with UI in the browser.", + goals: [ + { + name: null, + goalId: "oni.goal.sneakIntoBrowser", + count: 1, + }, + ], + }) +} diff --git a/browser/src/Services/Commands/GlobalCommands.ts b/browser/src/Services/Commands/GlobalCommands.ts index 3e1c1f5e3e..1c0a16dfe4 100644 --- a/browser/src/Services/Commands/GlobalCommands.ts +++ b/browser/src/Services/Commands/GlobalCommands.ts @@ -9,6 +9,7 @@ import { remote } from "electron" import * as Oni from "oni-api" +import { EditorManager } from "./../../Services/EditorManager" import { MenuManager } from "./../../Services/Menu" import { showAboutMessage } from "./../../Services/Metadata" import { multiProcess } from "./../../Services/MultiProcess" @@ -23,9 +24,12 @@ import * as Platform from "./../../Platform" export const activate = ( commandManager: CommandManager, + editorManager: EditorManager, menuManager: MenuManager, tasks: Tasks, ) => { + tasks.registerTaskProvider(commandManager) + const popupMenuCommand = (innerCommand: Oni.Commands.CommandCallback) => { return () => { if (menuManager.isMenuOpen()) { @@ -42,24 +46,16 @@ export const activate = ( const popupMenuSelect = popupMenuCommand(() => menuManager.selectMenuItem()) const commands = [ + new CallbackCommand("editor.executeVimCommand", null, null, (message: string) => { + const neovim = editorManager.activeEditor.neovim + if (message.startsWith(":")) { + neovim.command('exec "' + message + '"') + } else { + neovim.command('exec ":normal! ' + message + '"') + } + }), new CallbackCommand("oni.about", null, null, () => showAboutMessage()), - new CallbackCommand("oni.quit", null, null, () => remote.app.quit()), - - // Debug - new CallbackCommand( - "oni.debug.openDevTools", - "Open DevTools", - "Debug Oni and any running plugins using the Chrome developer tools", - () => remote.getCurrentWindow().webContents.openDevTools(), - ), - new CallbackCommand( - "oni.debug.reload", - "Reload Oni", - "Reloads the Oni instance. You will lose all unsaved changes", - () => remote.getCurrentWindow().reload(), - ), - new CallbackCommand( "oni.editor.maximize", "Maximize Window", diff --git a/browser/src/Services/Configuration/Configuration.ts b/browser/src/Services/Configuration/Configuration.ts index 40ce0be906..e64142a3c6 100644 --- a/browser/src/Services/Configuration/Configuration.ts +++ b/browser/src/Services/Configuration/Configuration.ts @@ -37,6 +37,49 @@ interface ConfigurationProviderInfo { disposables: IDisposable[] } +export interface IConfigurationSettingValueChangedEvent { + newValue: T + oldValue?: T +} + +export interface IConfigurationSetting extends IDisposable { + onValueChanged: IEvent> + getValue(): T +} + +export type ConfigurationSettingMergeStrategy = ( + higherPrecedenceValue: T, + lowerPrecedenceValue: T, +) => T + +export interface IConfigurationSettingMetadata { + defaultValue?: T + + // Comment that will be shown in the generated configuration + // metadata section + description?: string + + // Whether or not the configuration value requires reloading + // the editor to be picked up. If the value can be incrementally + // applied, set this to true so we don't prompt the user to + // reload the editor. + requiresReload?: boolean + + // TODO: Implement a merge strategy + // Specifies the merge strategy for a configuration setting + // By default, the higher precedence setting will be returned, + // but for things like arrays or objects, there may be a more + // involved merge strategy. + // mergeStrategy?: ConfigurationSettingMergeStrategy +} + +const DefaultConfigurationSettings: IConfigurationSettingMetadata = { + defaultValue: null, + description: null, + requiresReload: true, + // mergeStrategy: (higher: any, lower: any): any => higher, +} + /** * Interface describing persistence layer for configuration */ @@ -45,6 +88,10 @@ export interface IPersistedConfiguration { setPersistedValues(configurationValues: GenericConfigurationValues): void } +export interface IConfigurationUpdateEvent { + requiresReload: boolean +} + export class Configuration implements Oni.Configuration { private _configurationProviders: IConfigurationProvider[] = [] private _onConfigurationChangedEvent: Event> = new Event< @@ -52,15 +99,21 @@ export class Configuration implements Oni.Configuration { >() private _onConfigurationErrorEvent: Event = new Event() + private _onConfigurationUpdatedEvent = new Event() + private _oniApi: Oni.Plugin.Api = null private _config: GenericConfigurationValues = {} private _setValues: { [configValue: string]: any } = {} private _fileToProvider: { [key: string]: IConfigurationProvider } = {} private _configProviderInfo = new Map() - private _configurationEditors: { [key: string]: IConfigurationEditor } = {} + private _settingMetadata: { [settingName: string]: IConfigurationSettingMetadata } = {} + private _subscriptions: { + [settingName: string]: Array>> + } = {} + public get editor(): IConfigurationEditor { const val = this.getValue("configuration.editor") return this._configurationEditors[val] || new JavaScriptConfigurationEditor() @@ -74,6 +127,10 @@ export class Configuration implements Oni.Configuration { return this._onConfigurationChangedEvent } + public get onConfigurationUpdated(): IEvent { + return this._onConfigurationUpdatedEvent + } + constructor( private _defaultConfiguration: GenericConfigurationValues = DefaultConfiguration, private _persistedConfiguration: IPersistedConfiguration = new PersistedConfiguration(), @@ -89,6 +146,38 @@ export class Configuration implements Oni.Configuration { Performance.mark("Config.load.end") } + public registerSetting( + name: string, + options: IConfigurationSettingMetadata = DefaultConfigurationSettings, + ): IConfigurationSetting { + this._settingMetadata[name] = options + + const currentValue = this.getValue(name, null) + + if (options.defaultValue && currentValue === null) { + this.setValue(name, options.defaultValue) + } + + const newEvent = new Event>() + const subs: Array>> = + this._subscriptions[name] || [] + this._subscriptions[name] = [...subs, newEvent] + + const dispose = () => { + this._subscriptions[name] = this._subscriptions[name].filter(e => e !== newEvent) + } + + const getValue = () => { + return this.getValue(name) + } + + return { + onValueChanged: newEvent, + dispose, + getValue, + } + } + public registerEditor(id: string, editor: IConfigurationEditor): void { this._configurationEditors[id] = editor } @@ -150,9 +239,17 @@ export class Configuration implements Oni.Configuration { return !!this.getValue(configValue) } + public setValue(valueName: string, value: any): void { + return this.setValues({ [valueName]: value }) + } + public setValues(configValues: { [configValue: string]: any }, persist: boolean = false): void { this._setValues = configValues + const oldValues = { + ...this._config, + } + this._config = { ...this._config, ...configValues, @@ -163,6 +260,8 @@ export class Configuration implements Oni.Configuration { } this._onConfigurationChangedEvent.dispatch(configValues) + + this._notifySubscribers(oldValues, this._config, Object.keys(configValues)) } public getValue(configValue: K, defaultValue?: any) { @@ -183,6 +282,10 @@ export class Configuration implements Oni.Configuration { this._activateIfOniObjectIsAvailable() } + public getMetadata(settingName: string): IConfigurationSettingMetadata { + return this._settingMetadata[settingName] || null + } + private _updateConfig(): void { const previousConfig = this._config // Need a deep merge here to recursively update the config @@ -248,6 +351,32 @@ export class Configuration implements Oni.Configuration { ) this._onConfigurationChangedEvent.dispatch(diffObject) + + this._notifySubscribers(previousConfig, this._config, Object.keys(diffObject)) + } + + private _notifySubscribers(oldValues: any, newValues: any, changedKeys: string[]): void { + let requiresReload = false + changedKeys.forEach(name => { + const settings = this._subscriptions[name] + + const metadata = this.getMetadata(name) + + requiresReload = requiresReload || !metadata || metadata.requiresReload + + if (!settings) { + return + } + + const args = { + oldValue: oldValues[name], + newValue: newValues[name], + } + + settings.forEach(evt => evt.dispatch(args)) + }) + + this._onConfigurationUpdatedEvent.dispatch({ requiresReload: true }) } } diff --git a/browser/src/Services/Configuration/ConfigurationEditor.ts b/browser/src/Services/Configuration/ConfigurationEditor.ts index c0b6dabf33..6d44a38a25 100644 --- a/browser/src/Services/Configuration/ConfigurationEditor.ts +++ b/browser/src/Services/Configuration/ConfigurationEditor.ts @@ -14,6 +14,7 @@ import * as Log from "./../../Log" import { EditorManager } from "./../EditorManager" import { Configuration } from "./Configuration" +import { DefaultConfiguration } from "./DefaultConfiguration" // For configuring Oni, JavaScript is the de-facto language, and the configuration // today will _always_ happen through `config.js` @@ -71,7 +72,7 @@ export class ConfigurationEditManager { private _fileToEditor: { [filePath: string]: IConfigurationEditInfo } = {} constructor(private _configuration: Configuration, private _editorManager: EditorManager) { - this._editorManager.activeEditor.onBufferSaved.subscribe(evt => { + this._editorManager.anyEditor.onBufferSaved.subscribe(evt => { const activeEditingSession = this._fileToEditor[evt.filePath] if (activeEditingSession) { @@ -106,9 +107,29 @@ export class ConfigurationEditManager { } } - this._editorManager.activeEditor.openFile(normalizedEditFile, { + // Create the buffer with the list of all the available options + await this._createReadonlyReferenceBuffer() + + // Open the actual configuration file + await this._editorManager.activeEditor.openFile(normalizedEditFile, { + openMode: Oni.FileOpenMode.VerticalSplit, + }) + } + + private async _createReadonlyReferenceBuffer() { + const referenceBuffer = await this._editorManager.activeEditor.openFile("reference", { openMode: Oni.FileOpenMode.NewTab, }) + + // Format the default configuration values as a pretty JSON object, then + // set it as the reference buffer content + const referenceContent = JSON.stringify(DefaultConfiguration, null, " ") + await Promise.all([ + referenceBuffer.setLines(0, 1, referenceContent.split("\n")), + // FIXME: needs to be added to the Oni.Buffers API + (referenceBuffer as any).setLanguage("json"), + (referenceBuffer as any).setScratchBuffer(), + ]) } private async _transpileConfiguration( diff --git a/browser/src/Services/Configuration/DefaultConfiguration.ts b/browser/src/Services/Configuration/DefaultConfiguration.ts index ad69dc30d6..2512bf1bc2 100644 --- a/browser/src/Services/Configuration/DefaultConfiguration.ts +++ b/browser/src/Services/Configuration/DefaultConfiguration.ts @@ -50,7 +50,8 @@ const BaseConfiguration: IConfigurationValues = { "wildmenu.mode": true, "commandline.mode": true, "commandline.icons": true, - "experimental.learning.enabled": false, + "experimental.particles.enabled": false, + "experimental.preview.enabled": false, "experimental.welcome.enabled": false, "experimental.neovim.transport": "stdio", @@ -60,8 +61,6 @@ const BaseConfiguration: IConfigurationValues = { "editor.maxLinesForLanguageServices": 2500, "editor.textMateHighlighting.enabled": true, - "experimental.achievements.enabled": false, - "autoClosingPairs.enabled": true, "autoClosingPairs.default": [ { open: "{", close: "}" }, @@ -105,7 +104,7 @@ const BaseConfiguration: IConfigurationValues = { "editor.linePadding": 2, "editor.quickOpen.execCommand": null, - "editor.quickOpen.filterStrategy": "fuse", + "editor.quickOpen.filterStrategy": "regex", "editor.split.mode": "native", @@ -130,6 +129,8 @@ const BaseConfiguration: IConfigurationValues = { "environment.additionalPaths": [], + "keyDisplayer.showInInsertMode": false, + "language.html.languageServer.command": htmlLanguageServerPath, "language.html.languageServer.arguments": ["--stdio"], @@ -189,6 +190,11 @@ const BaseConfiguration: IConfigurationValues = { "language.ocaml.languageServer.arguments": ["--stdio"], "language.ocaml.languageServer.configuration": ocamlAndReasonConfiguration, + "language.haskell.languageServer.command": "stack", + "language.haskell.languageServer.arguments": ["exec", "--", "hie", "--lsp"], + "language.haskell.languageServer.rootFiles": [".git"], + "language.haskell.languageServer.configuration": {}, + "language.typescript.completionTriggerCharacters": [".", "/", "\\"], "language.typescript.textMateGrammar": { ".ts": path.join( @@ -224,6 +230,9 @@ const BaseConfiguration: IConfigurationValues = { ), }, + "learning.enabled": true, + "achievements.enabled": true, + "menu.caseSensitive": "smart", "menu.rowHeight": 40, "menu.maxItemsToShow": 8, diff --git a/browser/src/Services/Configuration/IConfigurationValues.ts b/browser/src/Services/Configuration/IConfigurationValues.ts index 61e51a02b2..98dcbcab83 100644 --- a/browser/src/Services/Configuration/IConfigurationValues.ts +++ b/browser/src/Services/Configuration/IConfigurationValues.ts @@ -30,8 +30,6 @@ export interface IConfigurationValues { "debug.detailedSessionLogging": boolean "debug.showTypingPrediction": boolean - "experimental.achievements.enabled": boolean - "browser.defaultUrl": string // Simulate slow language server, for debugging @@ -48,7 +46,7 @@ export interface IConfigurationValues { "editor.textMateHighlighting.enabled": boolean // Whether or not the learning pane is available - "experimental.learning.enabled": boolean + "experimental.particles.enabled": boolean // The transport to use for Neovim // Valid values are "stdio" and "pipe" @@ -57,6 +55,9 @@ export interface IConfigurationValues { "commandline.mode": boolean "commandline.icons": boolean + // Experimental flag for 'generalized preview' + "experimental.preview.enabled": boolean + "experimental.welcome.enabled": boolean "autoClosingPairs.enabled": boolean @@ -196,6 +197,11 @@ export interface IConfigurationValues { "editor.cursorColumn": boolean "editor.cursorColumnOpacity": number + "keyDisplayer.showInInsertMode": boolean + + "learning.enabled": boolean + "achievements.enabled": boolean + // Case-sensitivity strategy for menu filtering: // - if `true`, is case sensitive // - if `false`, is not case sensitive diff --git a/browser/src/Services/Configuration/UserConfiguration.ts b/browser/src/Services/Configuration/UserConfiguration.ts index 167b60400f..ff1b0c5130 100644 --- a/browser/src/Services/Configuration/UserConfiguration.ts +++ b/browser/src/Services/Configuration/UserConfiguration.ts @@ -36,5 +36,5 @@ export const getUserConfigFolderPath = (): string => { return Platform.isWindows() ? path.join(Platform.getUserHome(), "oni") - : path.join(Platform.getUserHome(), ".oni") + : path.join(Platform.getUserHome(), ".config/oni") // XDG-compliant } diff --git a/browser/src/Services/ContextMenu/ContextMenu.less b/browser/src/Services/ContextMenu/ContextMenu.less index e34e87ebf8..abfe2e4a52 100644 --- a/browser/src/Services/ContextMenu/ContextMenu.less +++ b/browser/src/Services/ContextMenu/ContextMenu.less @@ -46,11 +46,6 @@ flex: 1 0 auto; min-width: 100px; margin-left: 8px; - - .highlight { - // font-weight: bold; - text-decoration: underline; - } } .detail { diff --git a/browser/src/Services/ContextMenu/ContextMenuComponent.tsx b/browser/src/Services/ContextMenu/ContextMenuComponent.tsx index f1ba0cbfc7..c6ae2b18ee 100644 --- a/browser/src/Services/ContextMenu/ContextMenuComponent.tsx +++ b/browser/src/Services/ContextMenu/ContextMenuComponent.tsx @@ -13,6 +13,7 @@ import * as Oni from "oni-api" import { IMenus } from "./../Menu/MenuState" +import { styled } from "../../UI/components/common" import { Arrow, ArrowDirection } from "./../../UI/components/Arrow" import { HighlightText } from "./../../UI/components/HighlightText" import { QuickInfoDocumentation } from "./../../UI/components/QuickInfo" @@ -117,7 +118,7 @@ export class ContextMenuItem extends React.PureComponent @@ -128,6 +129,10 @@ export class ContextMenuItem extends React.PureComponent { + const openDevTools = () => { + remote.getCurrentWindow().webContents.openDevTools() + const achievements = getAchievementsInstance() + achievements.notifyGoal("oni.goal.openDevTools") + } + + commandManager.registerCommand({ + command: "oni.debug.openDevTools", + name: "Debug: Open Developer Tools", + detail: "Debug Oni and any running plugins, using the Chromium developer tools", + execute: () => openDevTools(), + }) + + commandManager.registerCommand({ + command: "oni.debug.reload", + name: "Debug: Reload Oni", + detail: "Reloads the Oni instance. You will lose all unsaved changes!", + execute: () => remote.getCurrentWindow().reload(), + }) +} + +export const registerAchievements = (achievements: AchievementsManager) => { + achievements.registerAchievement({ + uniqueId: "oni.achievement.openDevTools.1", + name: "Pop the Hood", + description: "Open the 'Developer Tools' for the first time", + goals: [ + { + name: null, + goalId: "oni.goal.openDevTools", + count: 1, + }, + ], + }) + + achievements.registerAchievement({ + uniqueId: "oni.achievement.openDevTools.2", + dependsOnId: "oni.achievement.openDevTools.1", + name: "Mechanic", + description: "Open the 'Developer Tools' ten times.", + goals: [ + { + name: null, + goalId: "oni.goal.openDevTools", + count: 10, + }, + ], + }) +} diff --git a/browser/src/Services/Diagnostics.ts b/browser/src/Services/Diagnostics.ts index 8d618ee5c2..a8d81147fe 100644 --- a/browser/src/Services/Diagnostics.ts +++ b/browser/src/Services/Diagnostics.ts @@ -35,7 +35,18 @@ export interface IDiagnosticsDataSource { start(languageManager: LanguageManager): void } -// export const getErrors = (state: State.IState) => state.errors +export const getColorFromSeverity = (severity: types.DiagnosticSeverity): string => { + switch (severity) { + case types.DiagnosticSeverity.Error: + return "red" + case types.DiagnosticSeverity.Warning: + return "yellow" + case types.DiagnosticSeverity.Information: + case types.DiagnosticSeverity.Hint: + default: + return "gray" + } +} export const getAllErrorsForFile = (fileName: string, errors: Errors): types.Diagnostic[] => { if (!fileName || !errors) { diff --git a/browser/src/Services/EditorManager.ts b/browser/src/Services/EditorManager.ts index a2d23f7708..6488bd2ac1 100644 --- a/browser/src/Services/EditorManager.ts +++ b/browser/src/Services/EditorManager.ts @@ -11,13 +11,18 @@ import * as Oni from "oni-api" import { Event, IDisposable, IEvent } from "oni-types" +import { remote } from "electron" + export class EditorManager implements Oni.EditorManager { + private _allEditors: Oni.Editor[] = [] private _activeEditor: Oni.Editor = null private _anyEditorProxy: AnyEditorProxy = new AnyEditorProxy() private _onActiveEditorChanged: Event = new Event() + private _closeWhenNoEditors: boolean = true + public get allEditors(): Oni.Editor[] { - return [] + return this._allEditors } /** @@ -42,6 +47,29 @@ export class EditorManager implements Oni.EditorManager { return this._activeEditor.openFile(filePath, openOptions) } + public setCloseWhenNoEditors(closeWhenNoEditors: boolean) { + this._closeWhenNoEditors = closeWhenNoEditors + } + + public registerEditor(editor: Oni.Editor) { + if (this._allEditors.indexOf(editor) === -1) { + this._allEditors.push(editor) + } + } + + public unregisterEditor(editor: Oni.Editor): void { + this._allEditors = this._allEditors.filter(ed => ed !== editor) + + if (this._activeEditor === editor) { + this.setActiveEditor(null) + } + + if (this._allEditors.length === 0 && this._closeWhenNoEditors) { + // Quit? + remote.getCurrentWindow().close() + } + } + /** * Internal Methods */ @@ -155,7 +183,13 @@ class AnyEditorProxy implements Oni.Editor { public setActiveEditor(newEditor: Oni.Editor) { this._activeEditor = newEditor + this._subscriptions.forEach(d => d.dispose()) + + if (!newEditor) { + return + } + this._subscriptions = [ newEditor.onModeChanged.subscribe(val => this._onModeChanged.dispatch(val)), newEditor.onBufferEnter.subscribe(val => this._onBufferEnter.dispatch(val)), diff --git a/browser/src/Services/Errors.ts b/browser/src/Services/Errors.ts deleted file mode 100644 index f32a4089f8..0000000000 --- a/browser/src/Services/Errors.ts +++ /dev/null @@ -1,76 +0,0 @@ -import * as flatten from "lodash/flatten" -import * as keys from "lodash/keys" - -import { INeovimInstance } from "./../neovim" - -import { ITask, ITaskProvider } from "./Tasks" - -import * as types from "vscode-languageserver-types" - -/** - * Window that shows terminal output - */ - -export const getColorFromSeverity = (severity: types.DiagnosticSeverity): string => { - switch (severity) { - case types.DiagnosticSeverity.Error: - return "red" - case types.DiagnosticSeverity.Warning: - return "yellow" - case types.DiagnosticSeverity.Information: - case types.DiagnosticSeverity.Hint: - default: - return "gray" - } -} - -export class Errors implements ITaskProvider { - private _neovimInstance: INeovimInstance - private _errors: { [fileName: string]: types.Diagnostic[] } = {} - - constructor(neovimInstance: INeovimInstance) { - this._neovimInstance = neovimInstance - } - - public setErrors(fileName: string, errors: types.Diagnostic[]) { - this._errors[fileName] = errors - } - - public getTasks(): Promise { - const showErrorTask: ITask = { - name: "Show Errors", - detail: "Open quickfix window and show error details", - command: "oni.editor.showErrors", - - callback: () => { - this._setQuickFixErrors() - this._neovimInstance.command("copen") - }, - } - - const tasks = [showErrorTask] - return Promise.resolve(tasks) - } - - private _setQuickFixErrors(): void { - const arrayOfErrors = keys(this._errors).map(filename => { - return this._errors[filename].map(e => ({ - ...e, - filename, - })) - }) - - const flattenedErrors = flatten(arrayOfErrors) - const errors = flattenedErrors.map( - e => - ({ - filename: e.filename, - col: e.range.start.character || 0, - lnum: e.range.start.line + 1, - text: e.message, - } as any), - ) - - this._neovimInstance.quickFix.setqflist(errors, "Errors", " ") - } -} diff --git a/browser/src/Services/Explorer/ExplorerFileSystem.ts b/browser/src/Services/Explorer/ExplorerFileSystem.ts index f42c0dfcd5..7e4428b29e 100644 --- a/browser/src/Services/Explorer/ExplorerFileSystem.ts +++ b/browser/src/Services/Explorer/ExplorerFileSystem.ts @@ -14,6 +14,7 @@ import { FolderOrFile } from "./ExplorerStore" */ export interface IFileSystem { readdir(fullPath: string): Promise + exists(fullPath: string): Promise } export class FileSystem implements IFileSystem { @@ -40,4 +41,12 @@ export class FileSystem implements IFileSystem { return Promise.resolve(filesAndFolders) } + + public exists(fullPath: string): Promise { + return new Promise((resolve, reject) => { + this._fs.exists(fullPath, (exists: boolean) => { + resolve(exists) + }) + }) + } } diff --git a/browser/src/Services/Explorer/ExplorerView.tsx b/browser/src/Services/Explorer/ExplorerView.tsx index 0b5b025b55..bd30fa83f1 100644 --- a/browser/src/Services/Explorer/ExplorerView.tsx +++ b/browser/src/Services/Explorer/ExplorerView.tsx @@ -70,7 +70,6 @@ export class NodeView extends React.PureComponent { return ( this.props.onClick()} innerRef={this.props.isSelected ? scrollIntoViewIfNeeded : noop} > {this.getElement()} @@ -96,6 +95,7 @@ export class NodeView extends React.PureComponent { isOver={isOver && canDrop} didDrop={didDrop} canDrop={canDrop} + onClick={() => this.props.onClick()} text={node.name} isFocused={this.props.isSelected} isContainer={false} @@ -118,6 +118,7 @@ export class NodeView extends React.PureComponent { isOver={isOver} isContainer={true} isExpanded={node.expanded} + onClick={() => this.props.onClick()} text={node.name} isFocused={this.props.isSelected} /> @@ -143,6 +144,7 @@ export class NodeView extends React.PureComponent { text={node.name} isFocused={this.props.isSelected} indentationLevel={node.indentationLevel} + onClick={() => this.props.onClick()} /> ) }} @@ -166,7 +168,6 @@ export interface IExplorerViewProps extends IExplorerViewContainerProps { } import { SidebarEmptyPaneView } from "./../../UI/components/SidebarEmptyPaneView" -import { Sneakable } from "./../../UI/components/Sneakable" import { commandManager } from "./../CommandManager" @@ -193,14 +194,12 @@ export class ExplorerView extends React.PureComponent { onSelected={id => this.props.onClick(id)} render={(selectedId: string) => { const nodes = this.props.nodes.map(node => ( - this.props.onClick(node.id)} key={node.id}> - this.props.onClick(node.id)} - /> - + this.props.onClick(node.id)} + /> )) return ( diff --git a/browser/src/Services/FileIcon.tsx b/browser/src/Services/FileIcon.tsx index 9d858b5bc0..c04643a8b0 100644 --- a/browser/src/Services/FileIcon.tsx +++ b/browser/src/Services/FileIcon.tsx @@ -7,32 +7,58 @@ import * as React from "react" +import { css, keyframes, styled, withProps } from "../UI/components/common" import { getInstance } from "./IconThemes" -export interface IFileIconProps { +const appearAnimationKeyframes = keyframes` + 0% { + opacity: 0; + transform: scale(0.8); + } + 100% { + opacity: 1; + transform: scale(1); + } +` + +const appearAnimation = css` + animation-name: ${appearAnimationKeyframes}; + animation-duration: 0.25s; + animation-timing-function: ease-in; + animation-fill-mode: forwards; + opacity: 1; +` + +const Icon = withProps<{ playAppearAnimation: boolean }>(styled.i)` + ${props => (props.playAppearAnimation ? appearAnimation : "")} +` + +interface IFileIconProps { fileName: string language?: string isLarge?: boolean - additionalClassNames?: string + playAppearAnimation?: boolean } -export class FileIcon extends React.PureComponent { - public render(): JSX.Element { - if (!this.props.fileName) { - return null - } +export const FileIcon = (props: IFileIconProps) => { + if (!props.fileName) { + return null + } - const icons = getInstance() + const icons = getInstance() - const className = - icons.getIconClassForFile(this.props.fileName, this.props.language) + - (this.props.isLarge ? " fa-lg" : "") - const additionalClasses = this.props.additionalClassNames || "" + const className = + icons.getIconClassForFile(props.fileName, props.language) + (props.isLarge ? " fa-lg" : "") - return - } + return ( + + ) } export const getFileIcon = (fileName: string) => diff --git a/browser/src/Services/InputManager.ts b/browser/src/Services/InputManager.ts index b6938fe6f6..2d188b7907 100644 --- a/browser/src/Services/InputManager.ts +++ b/browser/src/Services/InputManager.ts @@ -19,6 +19,8 @@ export interface KeyBindingMap { [key: string]: KeyBinding[] } +const MAX_DELAY_BETWEEN_KEY_CHORD = 250 /* milliseconds */ + import { KeyboardResolver } from "./../Input/Keyboard/KeyboardResolver" import { @@ -27,9 +29,38 @@ import { remapResolver, } from "./../Input/Keyboard/Resolvers" -export class InputManager implements Oni.InputManager { +export interface KeyPressInfo { + keyChord: string + time: number +} + +// Helper method to filter a set of key presses such that they are only the potential chords +export const getRecentKeyPresses = ( + keys: KeyPressInfo[], + maxTimeBetweenKeyPresses: number, +): KeyPressInfo[] => { + return keys.reduce( + (prev, curr) => { + if (prev.length === 0) { + return [curr] + } + + const lastItem = prev[prev.length - 1] + + if (curr.time - lastItem.time > maxTimeBetweenKeyPresses) { + return [curr] + } else { + return [...prev, curr] + } + }, + [] as KeyPressInfo[], + ) +} + +export class InputManager implements Oni.Input.InputManager { private _boundKeys: KeyBindingMap = {} private _resolver: KeyboardResolver + private _keys: KeyPressInfo[] = [] constructor() { this._resolver = new KeyboardResolver() @@ -118,7 +149,36 @@ export class InputManager implements Oni.InputManager { // Triggers an action handler if there is a bound-key that passes the filter. // Returns true if the key was handled and should not continue bubbling, // false otherwise. - public handleKey(keyChord: string): boolean { + public handleKey(keyChord: string, time: number = new Date().getTime()): boolean { + if (keyChord === null) { + return false + } + + const newKey: KeyPressInfo = { + keyChord, + time, + } + + this._keys.push(newKey) + const potentialKeys = getRecentKeyPresses(this._keys, MAX_DELAY_BETWEEN_KEY_CHORD) + this._keys = [...potentialKeys] + + // We'll try the longest key chord to the shortest + while (potentialKeys.length > 0) { + const fullChord = potentialKeys.map(k => k.keyChord).join("") + + if (this._handleKeyCore(fullChord)) { + this._keys = [] + return true + } + + potentialKeys.shift() + } + + return false + } + + private _handleKeyCore(keyChord: string): boolean { if (!this._boundKeys[keyChord]) { return false } diff --git a/browser/src/Services/KeyDisplayer/KeyDisplayer.tsx b/browser/src/Services/KeyDisplayer/KeyDisplayer.tsx index 7de8a2bd28..d08b55e799 100644 --- a/browser/src/Services/KeyDisplayer/KeyDisplayer.tsx +++ b/browser/src/Services/KeyDisplayer/KeyDisplayer.tsx @@ -5,11 +5,13 @@ */ import * as React from "react" -import { Store } from "redux" import { Provider } from "react-redux" +import { Store } from "redux" import { IDisposable } from "oni-types" +import { Configuration } from "./../Configuration" +import { EditorManager } from "./../EditorManager" import { InputManager } from "./../InputManager" import { Overlay, OverlayManager } from "./../Overlay" @@ -21,7 +23,12 @@ export class KeyDisplayer { private _currentResolveSubscription: IDisposable = null private _store: Store - constructor(private _inputManager: InputManager, private _overlayManager: OverlayManager) { + constructor( + private _configuration: Configuration, + private _editorManager: EditorManager, + private _inputManager: InputManager, + private _overlayManager: OverlayManager, + ) { this._store = createStore() } @@ -34,6 +41,14 @@ export class KeyDisplayer { (evt, resolution) => { if (this._activeOverlay) { this._activeOverlay.hide() + this._activeOverlay = null + } + + if ( + !this._configuration.getValue("keyDisplayer.showInInsertMode") && + this._editorManager.activeEditor.mode === "insert" + ) { + return resolution } this._store.dispatch({ diff --git a/browser/src/Services/KeyDisplayer/KeyDisplayerStore.ts b/browser/src/Services/KeyDisplayer/KeyDisplayerStore.ts index b7cef2479c..71b4790db9 100644 --- a/browser/src/Services/KeyDisplayer/KeyDisplayerStore.ts +++ b/browser/src/Services/KeyDisplayer/KeyDisplayerStore.ts @@ -24,12 +24,12 @@ export const WindowToShowInMilliseconds = 1000 // This is the size to 'group' key presses - any keys pressed // within this timeframe will be grouped together in a box, // instead of having their own box -export const WindowToGroupInMilliseconds = 250 +export const WindowToGroupInMilliseconds = 200 // Keys coming quicker than the 'DupWindow' will be removed // This is somewhat of a hack, as there is a bug in the input // resolver pipeline where they can be called multiple times -export const DupWindow = 5 +export const DupWindow = 10 export interface KeyDisplayerState { keys: IKeyPressInfo[] diff --git a/browser/src/Services/KeyDisplayer/index.tsx b/browser/src/Services/KeyDisplayer/index.tsx index 624ebf292b..087b33889c 100644 --- a/browser/src/Services/KeyDisplayer/index.tsx +++ b/browser/src/Services/KeyDisplayer/index.tsx @@ -5,6 +5,8 @@ */ import { CommandManager } from "./../CommandManager" +import { Configuration } from "./../Configuration" +import { EditorManager } from "./../EditorManager" import { InputManager } from "./../InputManager" import { OverlayManager } from "./../Overlay" @@ -17,10 +19,17 @@ import { KeyDisplayer } from "./KeyDisplayer" export const activate = ( commandManager: CommandManager, + configuration: Configuration, + editorManager: EditorManager, inputManager: InputManager, overlayManager: OverlayManager, ) => { - const keyDisplayer = new KeyDisplayer(inputManager, overlayManager) + const keyDisplayer = new KeyDisplayer( + configuration, + editorManager, + inputManager, + overlayManager, + ) commandManager.registerCommand({ command: "keyDisplayer.show", diff --git a/browser/src/Services/Learning/Achievements/AchievementNotificationRenderer.tsx b/browser/src/Services/Learning/Achievements/AchievementNotificationRenderer.tsx index 8508973a6a..872a205c2b 100644 --- a/browser/src/Services/Learning/Achievements/AchievementNotificationRenderer.tsx +++ b/browser/src/Services/Learning/Achievements/AchievementNotificationRenderer.tsx @@ -11,21 +11,26 @@ import { CSSTransition, TransitionGroup } from "react-transition-group" import styled, { keyframes } from "styled-components" import { boxShadow, withProps } from "./../../../UI/components/common" +import { FlipCard } from "./../../../UI/components/FlipCard" +import { Icon, IconSize } from "./../../../UI/Icon" export class AchievementNotificationRenderer { private _overlay: Overlay constructor(private _overlayManager: OverlayManager) { this._overlay = this._overlayManager.createItem() + this._overlay.show() + this._overlay.setContents() } public showAchievement(achievement: IAchievement): void { - this._overlay.show() - this._overlay.setContents() + window.setTimeout(() => { + this._overlay.setContents() + }, 10) // TODO: Better handle multiple achievements here window.setTimeout(() => { - this._overlay.hide() + this._overlay.setContents() }, 5000) } } @@ -52,11 +57,6 @@ const ExitKeyframes = keyframes` 100% { opacity: 0; transform: translateY(-32px) rotateX(30deg); } ` -const EnterIconKeyFrames = keyframes` - 0% { opacity: 0; transform: scale(0.9) rotateY(-90deg); } - 100% { opacity: 1.0; transform: scale(1) rotateY(0deg); } -` - const AnimationDuration = "0.25s" const AchievementWrapper = withProps<{}>(styled.div)` @@ -70,7 +70,8 @@ const AchievementWrapper = withProps<{}>(styled.div)` color: ${props => props.theme.foreground}; border-radius: 3em; - padding: 2em; + padding: 1em 2em; + margin: 2em; max-width: 1000px; @@ -89,17 +90,21 @@ const AchievementWrapper = withProps<{}>(styled.div)` } ` -const AchievementImageWrapper = styled.img` - width: 32px; - height: 32px; - padding: 8px; +const FlipCardWrapper = styled.div` + width: 48px; + height: 48px; + margin: 8px; + flex: 0 0 auto; +` - .animate-enter & { - animation: ${EnterIconKeyFrames}; - animation-duration: 0.5s; - animation-timing-function: ease-in; - animation-fill-mode: forwards; - } +const AchievementIconWrapper = withProps<{}>(styled.div)` + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background-color: ${props => props.theme["highlight.mode.normal.background"]}; + border-radius: 8em; ` export interface IAchievement { @@ -111,30 +116,61 @@ export interface IAchievementsViewProps { achievements: IAchievement[] } -export class AchievementsView extends React.PureComponent { - public render(): null | JSX.Element { - const achievements = this.props.achievements.map(a => ( - - )) - return ( - - {achievements} - - ) - } +export const AchievementsView = (props: IAchievementsViewProps) => { + const achievements = props.achievements.map(a => ) + return ( + + + {achievements} + + + ) } -export class AchievementView extends React.PureComponent { +export interface AchievementViewState { + flipCard: boolean +} + +export class AchievementView extends React.PureComponent { + constructor(props: IAchievement) { + super(props) + + this.state = { + flipCard: false, + } + } + + public componentDidMount(): void { + window.setTimeout(() => { + this.setState({ flipCard: true }) + }, 1000) + } + public render(): JSX.Element { return ( - + - -
    -
    - ACHIEVEMENT UNLOCKED: {this.props.title} + + + } + back={ + + + + } + /> + +
    +
    + Achievement Unlocked
    -
    {this.props.description}
    +
    {this.props.title}
    diff --git a/browser/src/Services/Learning/Achievements/AchievementsBufferLayer.tsx b/browser/src/Services/Learning/Achievements/AchievementsBufferLayer.tsx new file mode 100644 index 0000000000..d5fdace702 --- /dev/null +++ b/browser/src/Services/Learning/Achievements/AchievementsBufferLayer.tsx @@ -0,0 +1,245 @@ +/** + * AchievementsBufferLayer.tsx + * + * This is an implementation of a buffer layer to show the + * achievements in a 'trophy-case' style view + */ + +import * as React from "react" + +import styled from "styled-components" + +import { BufferLayerHeader } from "./../../../UI/components/BufferLayerHeader" +import { Bold, boxShadow, Fixed, Full, withProps } from "./../../../UI/components/common" +import { FlipCard } from "./../../../UI/components/FlipCard" +import { Icon, IconSize } from "./../../../UI/Icon" + +import * as Oni from "oni-api" +import { IDisposable } from "oni-types" + +import { AchievementsManager, AchievementWithProgressInfo } from "./AchievementsManager" + +export interface ITrophyCaseViewProps { + achievements: AchievementsManager +} + +export interface ITrophyCaseViewState { + progressInfo: AchievementWithProgressInfo[] +} + +export const TrophyCaseViewWrapper = withProps<{}>(styled.div)` + background-color: ${props => props.theme["editor.background"]}; + color: ${props => props.theme["editor.foreground"]}; + width: 100%; + height: 100%; + overflow-y: auto; + pointer-events: all; + + display: flex; + flex-direction: column; + + justify-content: flex-start; +` + +export const TrophyCaseItemViewWrapper = withProps<{}>(styled.div)` + ${boxShadow} + background-color: ${props => props.theme.background}; + margin: 1em; + position: relative; + + display: flex; + flex-direction: horizontal; +` + +export const TrophyCaseBackground = styled.div` + position: absolute; + color: black; + opacity: 0.1; + width: 100%; + height: 100%; + + display: flex; + justify-content: center; + align-items: center; +` + +export const TrophyItemIcon = styled.div` + width: 48px; + height: 48px; + display: flex; + justify-content: center; + align-items: center; + + padding: 1em; + margin: 0.5em; + background-color: rgba(0, 0, 0, 0.2); + color: rgba(255, 255, 255, 0.5); +` + +export const TitleText = styled.div` + padding-bottom: 0.25em; + font-weight: bold; + opacity: 0.9; +` + +export const DescriptionText = styled.div` + font-size: 0.9em; +` + +export interface ICenteredIconProps { + isSuccess?: boolean +} + +export const CenteredIcon = withProps(styled.div)` + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + + ${p => (p.isSuccess ? "color: " + p.theme["highlight.mode.insert.background"] + ";" : "")} +` + +export const TrophyCaseItemView = (props: { + achievementInfo: AchievementWithProgressInfo + dependentAchieventName?: string +}) => { + const isLocked = !!props.dependentAchieventName + + const icon = ( + + + + } + back={ + + + + } + /> + ) + + const lockedIcon = ( + + + + ) + + const description = isLocked ? ( + + Complete the {props.dependentAchieventName} achievement to unlock + + ) : ( + props.achievementInfo.achievement.description + ) + + return ( + + + {isLocked ? lockedIcon : icon} + + + {isLocked ? null : props.achievementInfo.achievement.name} + {description} + + + ) +} + +export class TrophyCaseView extends React.PureComponent< + ITrophyCaseViewProps, + ITrophyCaseViewState +> { + private _disposables: IDisposable[] = [] + + constructor(props: ITrophyCaseViewProps) { + super(props) + + this.state = { + progressInfo: props.achievements.getAchievements(), + } + } + + public componentDidMount(): void { + this._cleanSubscriptions() + + const s1 = this.props.achievements.onAchievementAccomplished.subscribe(() => { + this.setState({ + progressInfo: this.props.achievements.getAchievements(), + }) + }) + + this._disposables = [s1] + } + + public componentWillUnmount(): void { + this._cleanSubscriptions() + } + + public render(): JSX.Element { + const items = this.state.progressInfo.map(item => { + let dependentAchievementName = null + if (item.locked) { + const dependentId = item.achievement.dependsOnId + const dependentAchievement = this.state.progressInfo.find( + f => f.achievement.uniqueId === dependentId, + ) + if (dependentAchievement) { + dependentAchievementName = dependentAchievement.achievement.name + } + } + + return ( + + ) + }) + return ( + + + + + + + + {items} + + ) + } + + private _cleanSubscriptions(): void { + this._disposables.forEach(d => d.dispose()) + this._disposables = [] + } +} + +export class AchievementsBufferLayer implements Oni.BufferLayer { + public get id(): string { + return "oni.layer.achievements" + } + + public get friendlyName(): string { + return "Achievements" + } + + constructor(private _achievements: AchievementsManager) {} + + public render(context: Oni.BufferLayerRenderContext): JSX.Element { + return + } +} diff --git a/browser/src/Services/Learning/Achievements/AchievementsManager.ts b/browser/src/Services/Learning/Achievements/AchievementsManager.ts index 844a0bb614..9151269416 100644 --- a/browser/src/Services/Learning/Achievements/AchievementsManager.ts +++ b/browser/src/Services/Learning/Achievements/AchievementsManager.ts @@ -8,11 +8,17 @@ import { Event, IEvent } from "oni-types" import * as Utility from "./../../../Utility" +import { IPersistentStore } from "./../../../PersistentStore" + export interface AchievementDefinition { uniqueId: string name: string description: string + // An achievement 'id' that this achievement + // depends on, before it can be tracked or available + dependsOnId?: string + goals: AchievementGoalDefinition[] } @@ -22,22 +28,39 @@ export interface AchievementGoalDefinition { count: number } +export interface AchievementWithProgressInfo { + achievement: AchievementDefinition + locked?: boolean + completed: boolean +} + export class AchievementsManager { private _goalState: IPersistedAchievementState private _achievements: { [achievementId: string]: AchievementDefinition } = {} private _trackingGoals: { [goalId: string]: string[] } = {} + private _enabled: boolean private _currentIdleCallback: number | null = null private _onAchievementAccomplishedEvent = new Event() + public get enabled(): boolean { + return this._enabled + } + + public set enabled(val: boolean) { + this._enabled = val + } + public get onAchievementAccomplished(): IEvent { return this._onAchievementAccomplishedEvent } - constructor(private _persistentStore: IAchievementsPersistentStore) {} + constructor(private _persistentStore: IPersistentStore) { + this._enabled = true + } public notifyGoal(goalId: string): void { - if (!this._isInitialized()) { + if (!this._isInitialized() || !this._enabled) { return } @@ -65,6 +88,31 @@ export class AchievementsManager { }) } + public getAchievements(): AchievementWithProgressInfo[] { + const allAchievements = Object.values(this._achievements) + + return allAchievements.map(achievement => { + const isDependentAchievementCompleted = + !achievement.dependsOnId || + this._goalState.achievedIds.indexOf(achievement.dependsOnId) >= 0 + const completed = + isDependentAchievementCompleted && + this._goalState.achievedIds.indexOf(achievement.uniqueId) >= 0 + return { + achievement, + completed, + locked: !isDependentAchievementCompleted, + } + }) + } + + public clearAchievements(): void { + this._persistentStore.set({ + goalCounts: {}, + achievedIds: [], + }) + } + public registerAchievement(definition: AchievementDefinition): void { this._achievements[definition.uniqueId] = definition this._checkIfShouldTrackAchievement(definition) @@ -122,7 +170,7 @@ export class AchievementsManager { } this._currentIdleCallback = Utility.requestIdleCallback(() => { - this._persistentStore.store(this._goalState) + this._persistentStore.set(this._goalState) this._currentIdleCallback = null }) } @@ -139,17 +187,3 @@ export interface IPersistedAchievementState { // - no need to bother tracking these. achievedIds: string[] } - -// const DefaultGoalState: IPersistedAchievementState = { -// goalCounts: {} -// achievedIds: [] -// } - -export interface IAchievementsPersistentStore { - store(state: IPersistedAchievementState): Promise - get(): Promise -} - -// export class AchievementsPersistentFileStore { - -// } diff --git a/browser/src/Services/Learning/Achievements/index.tsx b/browser/src/Services/Learning/Achievements/index.tsx index 7593271a30..334d0244f8 100644 --- a/browser/src/Services/Learning/Achievements/index.tsx +++ b/browser/src/Services/Learning/Achievements/index.tsx @@ -7,21 +7,96 @@ import { Configuration } from "./../../Configuration" import { OverlayManager } from "./../../Overlay" -// import { AchievementNotificationRenderer } from "./AchievementNotificationRenderer" +import { getPersistentStore, IPersistentStore } from "./../../../PersistentStore" + +import { CommandManager } from "./../../CommandManager" +import { EditorManager } from "./../../EditorManager" +import { SidebarManager } from "./../../Sidebar" export * from "./AchievementsManager" +import { AchievementNotificationRenderer } from "./AchievementNotificationRenderer" +import { AchievementsBufferLayer } from "./AchievementsBufferLayer" +import { AchievementsManager, IPersistedAchievementState } from "./AchievementsManager" + +let _achievements: AchievementsManager = null + export const activate = ( + commandManager: CommandManager, configuration: Configuration, - // editorManager: EditorManager, - // sidebarManager: SidebarManager, + editorManager: EditorManager, + sidebarManager: SidebarManager, overlays: OverlayManager, ) => { - const achievementsEnabled = configuration.getValue("experimental.achievements.enabled") + const achievementsEnabled = configuration.getValue("achievements.enabled") + + const store: IPersistentStore = getPersistentStore( + "oni-achievements", + { + goalCounts: {}, + achievedIds: [], + }, + ) + + const manager = new AchievementsManager(store) + manager.enabled = achievementsEnabled + _achievements = manager + + const renderer = new AchievementNotificationRenderer(overlays) + + manager.onAchievementAccomplished.subscribe(achievement => { + renderer.showAchievement({ + title: achievement.name, + description: achievement.description, + }) - if (!achievementsEnabled) { - return + sidebarManager.setNotification("oni.sidebar.learning") + }) + + manager.registerAchievement({ + uniqueId: "oni.achievement.welcome", + name: "Welcome to Oni!", + description: "Launch Oni for the first time", + goals: [ + { + name: "Launch Oni", + goalId: "oni.goal.launch", + count: 1, + }, + ], + }) + + manager.registerAchievement({ + uniqueId: "oni.achievement.dedication", + dependsOnId: "oni.achievement.welcome", + name: "Dedication", + description: "Launch Oni 25 times", + goals: [ + { + name: "Launch Oni", + goalId: "oni.goal.launch", + count: 25, + }, + ], + }) + + manager.start().then(() => { + manager.notifyGoal("oni.goal.launch") + }) + + const showAchievements = async () => { + const buf = await editorManager.activeEditor.openFile("ACHIEVEMENTS.oni") + buf.addLayer(new AchievementsBufferLayer(manager)) } - // const renderer = new AchievementNotificationRenderer(overlays) + commandManager.registerCommand({ + command: "achievements.show", + name: "Achievements: Open Trophy Case", + detail: "Show accomplished and in-progress achievements", + execute: () => showAchievements(), + }) +} + +export const getInstance = (): AchievementsManager => { + return _achievements } diff --git a/browser/src/Services/Learning/LearningPane.tsx b/browser/src/Services/Learning/LearningPane.tsx index 782debaa0f..e287a16c65 100644 --- a/browser/src/Services/Learning/LearningPane.tsx +++ b/browser/src/Services/Learning/LearningPane.tsx @@ -6,25 +6,32 @@ import * as React from "react" -// import styled from "styled-components" +import { Event, IEvent } from "oni-types" -// import * as path from "path" +import { CommandManager } from "./../CommandManager" -import { Event } from "oni-types" - -// import { TutorialManager } from "./Tutorial/TutorialManager" +import { PureComponentWithDisposeTracking } from "./../../UI/components/PureComponentWithDisposeTracking" +import { SidebarButton } from "./../../UI/components/SidebarButton" +import { SidebarContainerView, SidebarItemView } from "./../../UI/components/SidebarItemView" +import { VimNavigator } from "./../../UI/components/VimNavigator" +import { Bold, Center, Container, Fixed, Full } from "./../../UI/components/common" +import { Icon, IconSize } from "./../../UI/Icon" import { SidebarPane } from "./../Sidebar" -// import { IBookmark, IBookmarksProvider } from "./index" -// import { SidebarEmptyPaneView } from "./../../UI/components/SidebarEmptyPaneView" -// import { SidebarContainerView, SidebarItemView } from "./../../UI/components/SidebarItemView" -import { VimNavigator } from "./../../UI/components/VimNavigator" +import { ITutorialMetadataWithProgress, TutorialManager } from "./Tutorial/TutorialManager" + +import { noop } from "./../../Utility" export class LearningPane implements SidebarPane { private _onEnter = new Event() private _onLeave = new Event() + constructor( + private _tutorialManager: TutorialManager, + private _commandManager: CommandManager, + ) {} + public get id(): string { return "oni.sidebar.learning" } @@ -43,14 +50,211 @@ export class LearningPane implements SidebarPane { } public render(): JSX.Element { + return ( + this._tutorialManager.startTutorial(id)} + onOpenAchievements={() => this._commandManager.executeCommand("achievements.show")} + /> + ) + } +} + +export interface ILearningPaneViewProps { + onEnter: IEvent + onLeave: IEvent + tutorialManager: TutorialManager + + onStartTutorial: (tutorialId: string) => void + onOpenAchievements: () => void +} + +export interface ILearningPaneViewState { + isActive: boolean + tutorialInfo: ITutorialMetadataWithProgress[] +} + +import styled from "styled-components" + +const TutorialItemViewIconContainer = styled.div` + width: 2em; + height: 100%; + background-color: rgba(0, 0, 0, 0.1); + + display: flex; + justify-content: center; + align-items: center; +` + +const TutorialItemTitleWrapper = styled.div` + font-size: 0.9em; + margin-left: 0.5em; +` + +const TutorialResultsWrapper = styled.div` + font-size: 0.8em; +` + +export const TutorialItemView = (props: { info: ITutorialMetadataWithProgress }): JSX.Element => { + const isCompleted = !!props.info.completionInfo + + const icon = isCompleted ? : + + // TODO: Refactor this to a 'success' theme color, ie: highlight.success.background + const backgroundColor = isCompleted ? "#5AB379" : "rgba(0, 0, 0, 0.1)" + // TODO: Refactor this to a 'success' theme color, ie: highlight.success.foreground + const color = isCompleted ? "white" : null + + const results = isCompleted ? ( +
    + + {(props.info.completionInfo.time / 1000).toFixed(2)}s + + + {props.info.completionInfo.keyPresses} keys + +
    + ) : ( +
    --
    + ) + + return ( + + + {icon} + + + {props.info.tutorialInfo.name} + + +
    {results}
    +
    +
    + ) +} + +export class LearningPaneView extends PureComponentWithDisposeTracking< + ILearningPaneViewProps, + ILearningPaneViewState +> { + constructor(props: ILearningPaneViewProps) { + super(props) + + this.state = { + isActive: false, + tutorialInfo: this.props.tutorialManager.getTutorialInfo(), + } + } + + public componentDidMount(): void { + super.componentDidMount() + + this.trackDisposable(this.props.onEnter.subscribe(() => this.setState({ isActive: true }))) + this.trackDisposable(this.props.onLeave.subscribe(() => this.setState({ isActive: false }))) + this.trackDisposable( + this.props.tutorialManager.onTutorialProgressChangedEvent.subscribe(() => { + this.setState({ + tutorialInfo: this.props.tutorialManager.getTutorialInfo(), + }) + }), + ) + } + + public render(): JSX.Element { + const tutorialIds = this.state.tutorialInfo.map(t => t.tutorialInfo.id) + const ids = ["tutorial_container", ...tutorialIds, "trophy_case"] + + const tutorialItems = (selectedId: string) => + this.state.tutorialInfo.map(t => ( + } + onClick={() => this._onSelect(t.tutorialInfo.id)} + /> + )) + + const InnerTrophyButton: JSX.Element = ( + + + + + +
    +
    Achievements
    +
    +
    +
    + ) + return ( { - return
    Coming soon!
    + ids={ids} + active={this.state.isActive} + onSelected={id => this._onSelect(id)} + render={selectedId => { + const items = tutorialItems(selectedId) + return ( + + + + {items} + + + +
    + this._onSelect("trophy_case")} + /> +
    +
    +
    + ) }} /> ) } + + private _onSelect(selectedId: string) { + if (selectedId === "tutorial_container") { + // TODO: Handle expansion + } else if (selectedId === "trophy_case") { + this.props.onOpenAchievements() + } else { + this.props.onStartTutorial(selectedId) + } + } } diff --git a/browser/src/Services/Learning/Tutorial/CompletionView.tsx b/browser/src/Services/Learning/Tutorial/CompletionView.tsx new file mode 100644 index 0000000000..2a73ef625f --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/CompletionView.tsx @@ -0,0 +1,133 @@ +/** + * CompletionView.tsx + * + * 'Goal' item for the tutorial + */ + +import * as React from "react" + +import styled, { keyframes } from "styled-components" + +import { Container, Fixed, withProps } from "./../../../UI/components/common" +// import { FlipCard } from "./../../../UI/components/FlipCard" +import { Icon, IconSize } from "./../../../UI/Icon" + +export interface ICompletionViewProps { + time: number + keyStrokes: number +} + +const RotatingKeyFrames = keyframes` + 0% { transform: rotateY(0deg); } + 100% { transform: rotateY(360deg); } +` + +const AppearKeyFrames = keyframes` + 0% { opacity: 0; } + 100% { opacity: 1; } +` + +export interface AppearWithDelayProps { + delay: number +} + +const AppearWithDelay = withProps(styled.div)` + animation: ${AppearKeyFrames} 1s linear ${p => p.delay}s forwards; + opacity: 0; +` + +const TrophyIconWrapper = withProps<{}>(styled.div)` + background-color: rgb(97, 175, 239); + color: white; + opacity: 0.1; + + width: 144px; + height: 144px; + border-radius: 72px; + + animation: ${RotatingKeyFrames} 2s linear infinite; + + display: flex; + justify-content: center; + align-items: center; +` + +const ResultsWrapper = styled.div` + color: white; + font-size: 2em; + + height: 100%; + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +` + +const Bold = styled.span` + font-weight: bold; +` + +const FooterWrapper = styled.div` + padding: 1em; +` + +const Layer = styled.div` + position: absolute; + top: 0px; + left: 0px; + right: 0px; + bottom: 0px; + + display: flex; + justify-content: center; + align-items: center; +` + +export const CompletionView = (props: ICompletionViewProps): JSX.Element => { + return ( + + + + + + + + +

    Level Complete!

    +
    + + + Time: {(props.time / 1000).toFixed(2)}s + + + Keystrokes: {props.keyStrokes} + + + + + + Press ENTER to continue or SPACE to restart + + + +
    +
    + ) +} diff --git a/browser/src/Services/Learning/Tutorial/GameplayBufferLayer.tsx b/browser/src/Services/Learning/Tutorial/GameplayBufferLayer.tsx new file mode 100644 index 0000000000..502bb0a98a --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/GameplayBufferLayer.tsx @@ -0,0 +1,78 @@ +/** + * GameplayBufferLayer.tsx + * + * The gameplay buffer layer is a buffer layer applied on the + * _nested_ NeovimEditor - so this actually renders the 'game' + * UI - any additional adorners that are necessary. + */ + +import * as React from "react" + +import * as Oni from "oni-api" + +import { TutorialGameplayManager } from "./TutorialGameplayManager" + +export class GameplayBufferLayer implements Oni.BufferLayer { + public get id(): string { + return "oni.layer.gameplay" + } + + public get friendlyName(): string { + return "Gameplay" + } + + constructor(private _tutorialGameplayManager: TutorialGameplayManager) {} + + public render(context: Oni.BufferLayerRenderContext): JSX.Element { + return ( + + ) + } +} + +export interface IGameplayBufferLayerViewProps { + tutorialGameplay: TutorialGameplayManager + context: Oni.BufferLayerRenderContext +} + +export interface IGameplayBufferLayerViewState { + renderFunction: (context: Oni.BufferLayerRenderContext) => JSX.Element + tick: number +} + +export class GameplayBufferLayerView extends React.PureComponent< + IGameplayBufferLayerViewProps, + IGameplayBufferLayerViewState +> { + constructor(props: IGameplayBufferLayerViewProps) { + super(props) + + this.state = { + renderFunction: () => null, + tick: 0, + } + } + + public componentDidMount(): void { + this.props.tutorialGameplay.onStateChanged.subscribe(newState => { + this.setState({ + renderFunction: newState.renderFunc, + }) + }) + + this.props.tutorialGameplay.onTick.subscribe(() => { + this.forceUpdate() + }) + } + + public render(): JSX.Element { + if (this.state.renderFunction) { + return this.state.renderFunction(this.props.context) + } + + return null + } +} diff --git a/browser/src/Services/Learning/Tutorial/GoalView.tsx b/browser/src/Services/Learning/Tutorial/GoalView.tsx new file mode 100644 index 0000000000..a117ebae29 --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/GoalView.tsx @@ -0,0 +1,69 @@ +/** + * GoalView.tsx + * + * 'Goal' item for the tutorial + */ + +import * as React from "react" + +import styled from "styled-components" + +import { boxShadow, withProps } from "./../../../UI/components/common" +import { FlipCard } from "./../../../UI/components/FlipCard" +import { Icon } from "./../../../UI/Icon" + +export interface IGoalViewProps { + active: boolean + completed: boolean + description: string + visible: boolean +} + +const GoalWrapper = withProps(styled.div)` + ${p => (p.active ? boxShadow : "")}; + display: ${p => (p.visible ? "flex" : "none")}; + background-color: ${p => p.theme.background}; + transition: all 0.5s linear; + + justify-content: center; + align-items: center; + flex-direction: row; + + margin: 1em 0em; +` + +const IconWrapper = withProps(styled.div)` + display: flex; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + background-color: rgba(0, 0, 0, 0.2); + + color: ${p => (p.completed ? p.theme["highlight.mode.insert.background"] : p.theme.foreground)}; +` + +export const GoalView = (props: IGoalViewProps): JSX.Element => { + return ( + +
    + + + + } + back={ + + + + } + /> +
    +
    + {props.description} +
    +
    + ) +} diff --git a/browser/src/Services/Learning/Tutorial/ITutorial.ts b/browser/src/Services/Learning/Tutorial/ITutorial.ts new file mode 100644 index 0000000000..911aa09273 --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/ITutorial.ts @@ -0,0 +1,31 @@ +/** + * TutorialManager + */ + +import * as Oni from "oni-api" + +// import * as types from "vscode-languageserver-types" + +export interface ITutorialContext { + buffer: Oni.Buffer + editor: Oni.Editor +} + +export interface ITutorialStage { + goalName?: string + tickFunction: (context: ITutorialContext) => Promise + render?: (renderContext: Oni.BufferLayerRenderContext) => JSX.Element +} + +export interface ITutorialMetadata { + id: string + name: string + description: string + level: number +} + +export interface ITutorial { + metadata: ITutorialMetadata + stages: ITutorialStage[] + notes?: JSX.Element[] +} diff --git a/browser/src/Services/Learning/Tutorial/Notes.tsx b/browser/src/Services/Learning/Tutorial/Notes.tsx new file mode 100644 index 0000000000..85fcf5ded8 --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Notes.tsx @@ -0,0 +1,292 @@ +/** + * TutorialBufferLayer.tsx + * + * Layer that handles the top-level rendering of the tutorial UI, + * including the nested `NeovimEditor`, description, goals, etc. + */ + +import * as React from "react" + +// import * as Oni from "oni-api" +// import { Event, IEvent } from "oni-types" + +import styled from "styled-components" + +import { Bold, withProps } from "./../../../UI/components/common" +import { Icon, IconSize } from "./../../../UI/Icon" + +const NoteWrapper = styled.div` + display: flex; + flex-direction: row; + align-items: center; +` + +const KeyWrapper = withProps<{}>(styled.div)` + background-color: ${props => props.theme.background}; + color: ${props => props.theme.foreground}; + border: 1px solid ${props => props.theme.foreground}; + + width: 40px; + height: 40px; + + flex: 0 0 auto; + + display: flex; + justify-content: center; + align-items: center; + + margin: 1em; +` + +const DescriptionWrapper = styled.div`` + +export const KeyWithDescription = (props: { + keyCharacter: string + description: JSX.Element +}): JSX.Element => { + return ( + + {props.keyCharacter} + {props.description} + + ) +} + +const VerticalStackWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +` + +const IconWrapper = styled.div`` + +export const KeyWithIconAbove = (props: { + keyCharacter: string + icon: JSX.Element +}): JSX.Element => { + return ( + + {props.icon} + {props.keyCharacter} + + ) +} + +export const IKey = (): JSX.Element => { + return ( + + Enters insert mode at the cursor position + + } + /> + ) +} + +export const EscKey = (): JSX.Element => { + return ( + + Goes back to normal mode + + } + /> + ) +} + +export const OKey = (): JSX.Element => { + return ( + + Enters insert mode, on a new line + + } + /> + ) +} + +export const GGKey = (): JSX.Element => { + return ( + Moves the cursor to the TOP of the file.} + /> + ) +} + +export const GKey = (): JSX.Element => { + return ( + Moves the cursor to the BOTTOM of the file.} + /> + ) +} + +export const XGKey = (): JSX.Element => { + return ( + Moves the cursor to line `#`. For example, `10G` moves to line 10. + } + /> + ) +} + +export const ZeroKey = (): JSX.Element => { + return ( + Moves the cursor to the BEGINNING of the line.} + /> + ) +} +export const UnderscoreKey = (): JSX.Element => { + return ( + Moves the cursor to the FIRST CHARACTER of the line.} + /> + ) +} +export const DollarKey = (): JSX.Element => { + return ( + Moves the cursor to the END of the line.} + /> + ) +} +export const WordKey = (): JSX.Element => { + return ( + Moves the cursor to the BEGINNING of the NEXT word.} + /> + ) +} +export const BeginningKey = (): JSX.Element => { + return ( + Moves the cursor to the BEGINNING of the PREVIOUS word.} + /> + ) +} +export const EndKey = (): JSX.Element => { + return ( + Moves the cursor to the END of the NEXT word.} + /> + ) +} +export const SlashKey = (): JSX.Element => { + return ( + Search for the given string} + /> + ) +} +export const QuestionKey = (): JSX.Element => { + return ( + Search backwards for the given string} + /> + ) +} +export const nKey = (): JSX.Element => { + return ( + Move the cursor to the next instance of the matched string} + /> + ) +} +export const NKey = (): JSX.Element => { + return ( + Move the cursor to the previous instance of the matched string + } + /> + ) +} +export const DeleteOperatorKey = (): JSX.Element => { + return ( + + + motion: Deletes text covered by the `motion`. Examples: + + } + /> + ) +} +export const DeleteLineKey = (): JSX.Element => { + return ( + Deletes the CURRENT line.} + /> + ) +} +export const DeleteLineBelowKey = (): JSX.Element => { + return ( + Deletes the CURRENT line and the one BELOW.} + /> + ) +} +export const DeleteLineAboveKey = (): JSX.Element => { + return ( + Deletes the CURRENT line and the one ABOVE.} + /> + ) +} +export const DeleteWordKey = (): JSX.Element => { + return ( + Delete to the end of the current word.} + /> + ) +} + +export const HJKLKeys = (): JSX.Element => { + return ( + + } + /> + } + /> + } + /> + } + /> + + ) +} diff --git a/browser/src/Services/Learning/Tutorial/Stages/CompositeStage.tsx b/browser/src/Services/Learning/Tutorial/Stages/CompositeStage.tsx new file mode 100644 index 0000000000..3185633ed4 --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Stages/CompositeStage.tsx @@ -0,0 +1,46 @@ +/** + * CompositeStage.tsx + * + * A stage that combines / composes multiple stages + */ + +import * as Oni from "oni-api" +import * as React from "react" + +import styled from "styled-components" + +import { ITutorialContext, ITutorialStage } from "./../ITutorial" + +export const combine = (goalName: string, ...stages: ITutorialStage[]): ITutorialStage => { + return new CompositeStage(goalName, stages) +} + +const ContainerWrapper = styled.div` + position: absolute; + top: 0px; + left: 0px; + right: 0px; + bottom: 0px; +` + +export class CompositeStage implements ITutorialStage { + public get goalName(): string { + return this._goalName + } + + constructor(private _goalName: string, private _stages: ITutorialStage[]) {} + + public async tickFunction(context: ITutorialContext): Promise { + const promises = this._stages.map(s => s.tickFunction(context)) + + const results = await Promise.all(promises) + + return results.reduce((prev, curr) => { + return prev && curr + }, true) + } + + public render(context: Oni.BufferLayerRenderContext): JSX.Element { + return {this._stages.map(s => s.render(context))} + } +} diff --git a/browser/src/Services/Learning/Tutorial/Stages/CorrectLineStage.tsx b/browser/src/Services/Learning/Tutorial/Stages/CorrectLineStage.tsx new file mode 100644 index 0000000000..76f4ec7b9a --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Stages/CorrectLineStage.tsx @@ -0,0 +1,118 @@ +/** + * CorrectLineStage.tsx + * + * + */ + +import * as Oni from "oni-api" +import * as React from "react" + +import * as types from "vscode-languageserver-types" + +import styled, { keyframes } from "styled-components" +import { withProps } from "./../../../../UI/components/common" + +import { ITutorialContext, ITutorialStage } from "./../ITutorial" + +const SpinnerKeyFrames = keyframes` + 0% {transform: rotateY(0deg); } + 100% { transform: rotateY(360deg); } +` + +export interface ArrowProps { + color: string +} + +const TopArrow = withProps(styled.div)` + animation: ${SpinnerKeyFrames} 2s linear infinite; + border-top: 6px solid ${p => p.color}; + border-left: 3px solid transparent; + border-right: 3px solid transparent; + border-bottom: 3px solid transparent; + opacity: 0.8; +` + +const BottomArrow = styled.div` + animation: ${SpinnerKeyFrames} 2s linear infinite; + margin-top: 2px; + border-top: 3px solid transparent; + border-left: 3px solid transparent; + border-right: 3px solid transparent; + border-bottom: 6px solid ${p => p.color}; + opacity: 0.8; +` + +const getFirstCharacterThatIsDifferent = (line1: string, line2: string): number => { + if (!line1 || !line2) { + return -1 + } + + let idx = 0 + + while (idx < line1.length && idx < line2.length) { + if (line1[idx] !== line2[idx]) { + return idx + } + + idx++ + } + return idx +} + +export class CorrectLineStage implements ITutorialStage { + private _diffPosition: number + + public get goalName(): string { + return this._goalName + } + + constructor( + private _goalName: string, + private _line: number, + private _expectedText: string, + private _color: string = "red", + private _minimumLine?: string, + ) { + if (!this._minimumLine) { + this._minimumLine = this._expectedText + } + } + + public async tickFunction(context: ITutorialContext): Promise { + const [currentLine] = await (context.buffer as any).getLines(this._line, this._line + 1) + const diffPosition = getFirstCharacterThatIsDifferent(currentLine, this._expectedText) + this._diffPosition = diffPosition + + if (currentLine.startsWith(this._minimumLine)) { + return true + } + + return false + } + + public render(context: Oni.BufferLayerRenderContext): JSX.Element { + // const anyContext = context as any + const screenPosition = context.bufferToScreen( + types.Position.create(this._line, this._diffPosition), + ) + + const pixelPosition = context.screenToPixel(screenPosition) + + if (pixelPosition.pixelX < 0 || pixelPosition.pixelY < 0) { + return null + } + + return ( +
    + + +
    + ) + } +} diff --git a/browser/src/Services/Learning/Tutorial/Stages/DeleteCharactersStage.tsx b/browser/src/Services/Learning/Tutorial/Stages/DeleteCharactersStage.tsx new file mode 100644 index 0000000000..1004f70af7 --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Stages/DeleteCharactersStage.tsx @@ -0,0 +1,63 @@ +/** + * DeleteCharactersStage.tsx + * + * Stage that visualizes characters that need to be deleted + */ + +import * as Oni from "oni-api" +import * as React from "react" + +import * as types from "vscode-languageserver-types" + +import styled from "styled-components" + +import { ITutorialContext, ITutorialStage } from "./../ITutorial" + +const DeleteCharacterWrapper = styled.div` + background-color: rgba(255, 0, 0, 0.2); + color: white; + position: absolute; + border-bottom: 1px solid rgba(255, 0, 0, 0.8); +` +export class DeleteCharactersStage implements ITutorialStage { + public get goalName(): string { + return this._goalName + } + + constructor( + private _goalName: string, + private _line: number, + private _startPosition: number, + private _charactersToDelete: string, + ) {} + + public async tickFunction(context: ITutorialContext): Promise { + // NOTE: This stage is purely for rendering + return true + } + + public render(context: Oni.BufferLayerRenderContext): JSX.Element { + const screenPosition = context.bufferToScreen( + types.Position.create(this._line, this._startPosition), + ) + const pixelPosition = context.screenToPixel(screenPosition) + + if (pixelPosition.pixelX < 0 || pixelPosition.pixelY < 0) { + return null + } + + const width = (context as any).fontPixelWidth + const height = (context as any).fontPixelHeight + + return ( + + ) + } +} diff --git a/browser/src/Services/Learning/Tutorial/Stages/FadeInLineStage.tsx b/browser/src/Services/Learning/Tutorial/Stages/FadeInLineStage.tsx new file mode 100644 index 0000000000..3bf4f0b321 --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Stages/FadeInLineStage.tsx @@ -0,0 +1,82 @@ +/** + * FadeInLineStage.tsx + * + * Stage that visualizes characters that need to be deleted + */ + +import * as Oni from "oni-api" +import * as React from "react" + +import * as types from "vscode-languageserver-types" + +import styled, { keyframes } from "styled-components" + +import { configuration } from "./../../../Configuration" + +import { ITutorialContext, ITutorialStage } from "./../ITutorial" + +import { withProps } from "./../../../../UI/components/common" + +const FuzzyFadeInKeyframes = keyframes` + 0% { opacity: 0; -webkit-filter: blur(10px); } + 100% { opacity: 1; } +` +const Wrapper = withProps<{}>(styled.div)` + background-color: ${props => props.theme["editor.background"]}; + color: ${props => props.theme["editor.foreground"]}; + position: absolute; +` + +const FadeInWrapper = styled.div` + animation: ${FuzzyFadeInKeyframes} 0.4s linear forwards; + + opacity: 0; +` +export class FadeInLineStage implements ITutorialStage { + private _fontFamily: string + private _fontSize: string + + public get goalName(): string { + return this._goalName + } + + constructor(private _goalName: string, private _line: number, private _characters: string) { + this._fontFamily = configuration.getValue("editor.fontFamily") + this._fontSize = configuration.getValue("editor.fontSize") + } + + public async tickFunction(context: ITutorialContext): Promise { + // NOTE: This stage is purely for rendering + return new Promise(resolve => { + window.setTimeout(() => { + resolve(true) + }, 300) + }) + } + + public render(context: Oni.BufferLayerRenderContext): JSX.Element { + const screenPosition = context.bufferToScreen(types.Position.create(0, 0)) + const pixelPosition = context.screenToPixel(screenPosition) + + if (pixelPosition.pixelX < 0 || pixelPosition.pixelY < 0) { + return null + } + + const height = (context as any).fontPixelHeight + + return ( + + {this._characters} + + ) + } +} diff --git a/browser/src/Services/Learning/Tutorial/Stages/InitializeBufferStage.tsx b/browser/src/Services/Learning/Tutorial/Stages/InitializeBufferStage.tsx new file mode 100644 index 0000000000..4dd433ccc3 --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Stages/InitializeBufferStage.tsx @@ -0,0 +1,40 @@ +/** + * InitializeBufferStage + * + * Shows some whitespace on the 'grid' + */ + +import * as Oni from "oni-api" + +import { ITutorialContext, ITutorialStage } from "./../ITutorial" + +export class InitializeBufferStage implements ITutorialStage { + public get goalName(): string { + return null + } + + public async tickFunction(context: ITutorialContext): Promise { + await context.editor.neovim.command(":set listchars=space:·,precedes:·,trail:·") + await context.editor.neovim.command(":set list!") + await context.buffer.setLines(0, 9, [ + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ]) + + await context.buffer.setCursorPosition(0, 0) + + return true + } + + public render(context: Oni.BufferLayerRenderContext): JSX.Element { + return null + } +} diff --git a/browser/src/Services/Learning/Tutorial/Stages/MoveToGoalStage.tsx b/browser/src/Services/Learning/Tutorial/Stages/MoveToGoalStage.tsx new file mode 100644 index 0000000000..4d3521371f --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Stages/MoveToGoalStage.tsx @@ -0,0 +1,134 @@ +/** + * TutorialManager + */ + +import * as Oni from "oni-api" +import * as React from "react" + +import * as types from "vscode-languageserver-types" + +import styled, { keyframes } from "styled-components" + +import { ITutorialContext, ITutorialStage } from "./../ITutorial" + +const SpinnerKeyFrames = keyframes` + 0% {transform: rotateY(0deg); } + 100% { transform: rotateY(360deg); } +` + +const MoveToCharacterWrapper = styled.div` + background-color: rgba(255, 255, 255, 0.2); + position: absolute; + border-bottom: 1px solid rgba(255, 255, 255, 0.8); +` + +const TopArrow = styled.div` + animation: ${SpinnerKeyFrames} 2s linear infinite; + border-top: 8px solid white; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid transparent; + opacity: 0.8; + margin-left: -3px; + margin-top: 1px; +` + +const EntranceKeyFrames = keyframes` + 0% { opacity: 0; } + 100% { opacity: 1; } +` + +const CommonAnimation = ` + animation-name: ${EntranceKeyFrames}; + animation-duration: 0.4s; + animation-delay: 0.25s; + animation-timing-function: linear; + animation-fill-mode: forwards; + opacity: 0; +` + +const MoveToTopWrapper = styled.div` + ${CommonAnimation} position: absolute; + top: 0px; + left: 25%; + right: 25%; + height: 4em; + background-color: rgba(0, 0, 0, 0.8); + + display: flex; + justify-content: center; + align-items: center; +` + +const MoveToBottomWrapper = styled.div` + ${CommonAnimation} position: absolute; + bottom: 0px; + left: 25%; + right: 25%; + height: 4em; + background-color: rgba(0, 0, 0, 0.8); + + display: flex; + justify-content: center; + align-items: center; +` + +export class MoveToGoalStage implements ITutorialStage { + private _goalColumn: number + private _currentCursorLine: number = 0 + public get goalName(): string { + return this._goalName + } + + constructor(private _goalName: string, private _line: number, private _column?: number) {} + + public async tickFunction(context: ITutorialContext): Promise { + const cursorPosition = await (context.buffer as any).getCursorPosition() + + this._currentCursorLine = cursorPosition.line + this._goalColumn = + typeof this._column === "number" ? this._column : cursorPosition.character + + return ( + cursorPosition.line === this._line && + (cursorPosition.character === this._goalColumn || typeof this._column !== "number") + ) + } + + public render(context: Oni.BufferLayerRenderContext): JSX.Element { + if (typeof this._goalColumn !== "number") { + return null + } + + const screenPosition = context.bufferToScreen( + types.Position.create(this._line, this._goalColumn), + ) + const pixelPosition = context.screenToPixel(screenPosition) + + if (isNaN(pixelPosition.pixelX) || isNaN(pixelPosition.pixelY)) { + if (this._currentCursorLine > this._line) { + return Move up to line: {this._line + 1} + } else { + return ( + Move down to line: {this._line + 1} + ) + } + } + + const width = (context as any).fontPixelWidth + const height = (context as any).fontPixelHeight + + return ( +
    + + +
    + ) + } +} diff --git a/browser/src/Services/Learning/Tutorial/Stages/SetBufferStage.tsx b/browser/src/Services/Learning/Tutorial/Stages/SetBufferStage.tsx new file mode 100644 index 0000000000..7fde3b61ab --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Stages/SetBufferStage.tsx @@ -0,0 +1,29 @@ +/** + * ClearBufferStage + */ + +import { ITutorialContext, ITutorialStage } from "./../ITutorial" + +export class SetBufferStage implements ITutorialStage { + public get goalName(): string { + return null + } + + constructor(private _lines: string[]) {} + + public async tickFunction(context: ITutorialContext): Promise { + const allLines = context.buffer.lineCount + await context.buffer.setLines(0, allLines, this._lines) + return true + } + + public render(): JSX.Element { + return null + } +} + +export class ClearBufferStage extends SetBufferStage { + constructor() { + super([]) + } +} diff --git a/browser/src/Services/Learning/Tutorial/Stages/SetCursorPositionStage.tsx b/browser/src/Services/Learning/Tutorial/Stages/SetCursorPositionStage.tsx new file mode 100644 index 0000000000..7abb0889af --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Stages/SetCursorPositionStage.tsx @@ -0,0 +1,22 @@ +/** + * SetCursorPositionStage.tsx + */ + +import { ITutorialContext, ITutorialStage } from "./../ITutorial" + +export class SetCursorPositionStage implements ITutorialStage { + public get goalName(): string { + return null + } + + constructor(private _line: number = 0, private _column: number = 0) {} + + public async tickFunction(context: ITutorialContext): Promise { + await context.buffer.setCursorPosition(this._line, this._column) + return true + } + + public render(): JSX.Element { + return null + } +} diff --git a/browser/src/Services/Learning/Tutorial/Stages/WaitForModeStage.tsx b/browser/src/Services/Learning/Tutorial/Stages/WaitForModeStage.tsx new file mode 100644 index 0000000000..def9c58a2a --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Stages/WaitForModeStage.tsx @@ -0,0 +1,25 @@ +/** + * WaitForModeStage + * + * Stage that just waits for a mode to complete + */ + +import * as Oni from "oni-api" + +import { ITutorialContext, ITutorialStage } from "./../ITutorial" + +export class WaitForModeStage implements ITutorialStage { + public get goalName(): string { + return this._goalName + } + + constructor(private _goalName: string, private _mode: string) {} + + public async tickFunction(context: ITutorialContext): Promise { + return context.editor.mode === this._mode + } + + public render(context: Oni.BufferLayerRenderContext): JSX.Element { + return null + } +} diff --git a/browser/src/Services/Learning/Tutorial/Stages/WaitForStateStage.tsx b/browser/src/Services/Learning/Tutorial/Stages/WaitForStateStage.tsx new file mode 100644 index 0000000000..0d11f59012 --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Stages/WaitForStateStage.tsx @@ -0,0 +1,39 @@ +/** + * WaitForStateStage.tsx + */ + +import * as Oni from "oni-api" + +import { ITutorialContext, ITutorialStage } from "./../ITutorial" + +export class WaitForStateStage implements ITutorialStage { + public get goalName(): string { + return this._goalName + } + + constructor(private _goalName: string, private _lines: string[]) {} + + public async tickFunction(context: ITutorialContext): Promise { + // return false + + const bufferLines = await context.buffer.getLines() + + if (bufferLines.length === this._lines.length) { + return bufferLines.reduce((prev, curr, idx) => { + return curr === this._lines[idx] && prev + }, true) + } + + return false + + // const cursorPosition = await (context.buffer as any).getCursorPosition() + + // this._goalColumn = this._column === null ? cursorPosition.character : this._column + + // return cursorPosition.line === this._line && cursorPosition.character === this._goalColumn + } + + public render(context: Oni.BufferLayerRenderContext): JSX.Element { + return null + } +} diff --git a/browser/src/Services/Learning/Tutorial/Stages/index.tsx b/browser/src/Services/Learning/Tutorial/Stages/index.tsx new file mode 100644 index 0000000000..61e98de796 --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Stages/index.tsx @@ -0,0 +1,17 @@ +/** + * Stages/index.tsx + * + * Entry point for stages, which are + * building blocks for the tutorial levels + */ + +export * from "./CompositeStage" +export * from "./CorrectLineStage" +export * from "./FadeInLineStage" +export * from "./InitializeBufferStage" +export * from "./MoveToGoalStage" +export * from "./SetBufferStage" +export * from "./SetCursorPositionStage" +export * from "./DeleteCharactersStage" +export * from "./WaitForModeStage" +export * from "./WaitForStateStage" diff --git a/browser/src/Services/Learning/Tutorial/TutorialBufferLayer.tsx b/browser/src/Services/Learning/Tutorial/TutorialBufferLayer.tsx new file mode 100644 index 0000000000..d9c42f42ce --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/TutorialBufferLayer.tsx @@ -0,0 +1,545 @@ +/** + * TutorialBufferLayer.tsx + * + * Layer that handles the top-level rendering of the tutorial UI, + * including the nested `NeovimEditor`, description, goals, etc. + */ + +import * as React from "react" + +import * as Oni from "oni-api" +import { Event, IEvent } from "oni-types" + +import styled from "styled-components" + +import { NeovimEditor } from "./../../../Editor/NeovimEditor" + +import { getInstance as getPluginManagerInstance } from "./../../../Plugins/PluginManager" +import { getInstance as getColorsInstance } from "./../../Colors" +import { getInstance as getCompletionProvidersInstance } from "./../../Completion" +import { configuration } from "./../../Configuration" +import { getInstance as getDiagnosticsInstance } from "./../../Diagnostics" +import { getInstance as getLanguageManagerInstance } from "./../../Language" +import { getInstance as getMenuManagerInstance } from "./../../Menu" +import { getInstance as getOverlayInstance } from "./../../Overlay" +import { getInstance as getSnippetManagerInstance } from "./../../Snippets" +import { getThemeManagerInstance } from "./../../Themes" +import { getInstance as getTokenColorsInstance } from "./../../TokenColors" +import { windowManager } from "./../../WindowManager" +import { getInstance as getWorkspaceInstance } from "./../../Workspace" + +import { Bold, withProps } from "./../../../UI/components/common" +import { FlipCard } from "./../../../UI/components/FlipCard" +import { StatusBar } from "./../../../UI/components/StatusBar" + +import { ITutorialState, TutorialGameplayManager } from "./TutorialGameplayManager" +import { TutorialManager } from "./TutorialManager" + +import { CompletionView } from "./CompletionView" +import { GameplayBufferLayer } from "./GameplayBufferLayer" +import { GoalView } from "./GoalView" + +import { getInstance, Vector } from "./../../Particles" + +export interface IGameplayCompletionInfo { + completed: boolean + keyPresses: number + timeInMilliseconds: number +} + +const DefaultCompletionInfo = { + completed: false, + keyPresses: -1, + timeInMilliseconds: 0, +} + +export interface IGameplayStateChangedEvent { + tutorialState: ITutorialState + completionInfo: IGameplayCompletionInfo + mode: string +} + +export class GameTracker { + private _startTime: Date + private _keyPresses: number + + public start(): void { + this._startTime = new Date() + this._keyPresses = 0 + } + + public addKeyPress(pressCount: number) { + this._keyPresses += pressCount + } + + public end(): IGameplayCompletionInfo { + return { + completed: true, + timeInMilliseconds: new Date().getTime() - this._startTime.getTime(), + keyPresses: this._keyPresses, + } + } +} + +export class TutorialBufferLayer implements Oni.BufferLayer { + private _editor: NeovimEditor + private _tutorialGameplayManager: TutorialGameplayManager + private _initPromise: Promise + + private _lastStage = -1 + private _hasAddedLayer: boolean = false + private _currentTutorialId: string + private _lastTutorialState: ITutorialState + private _completionInfo: IGameplayCompletionInfo = DefaultCompletionInfo + private _element: HTMLElement + private _notes: JSX.Element[] = [] + private _gameTracker: GameTracker = new GameTracker() + private _onStateChangedEvent: Event = new Event< + ITutorialBufferLayerState + >() + + public get id(): string { + return "oni.layer.tutorial" + } + + public get friendlyName(): string { + return "Tutorial" + } + + constructor(private _tutorialManager: TutorialManager) { + // TODO: Streamline dependences for NeovimEditor, so it's easier just to spin one up.. + this._editor = new NeovimEditor( + getColorsInstance(), + getCompletionProvidersInstance(), + configuration, + getDiagnosticsInstance(), + getLanguageManagerInstance(), + getMenuManagerInstance(), + getOverlayInstance(), + getPluginManagerInstance(), + getSnippetManagerInstance(), + getThemeManagerInstance(), + getTokenColorsInstance(), + getWorkspaceInstance(), + ) + + this._editor.autoFocus = false + + this._editor.onNeovimQuit.subscribe(() => { + // TODO: + // Maybe add an achievement for 'quitting vim'? + // Close current buffer / tab? + alert("quit!") + }) + + this._initPromise = this._editor.init([], { + loadInitVim: false, + }) + + this._tutorialGameplayManager = new TutorialGameplayManager(this._editor) + + this._tutorialGameplayManager.onStateChanged.subscribe(state => { + this._lastTutorialState = state + this._onStateChangedEvent.dispatch({ + tutorialState: state, + completionInfo: this._completionInfo, + mode: this._editor.mode, + notes: this._notes, + }) + + if (state.activeGoalIndex !== this._lastStage) { + this._lastStage = state.activeGoalIndex + + if (this._element) { + const cursor = this._element.getElementsByClassName("cursor") + if (cursor.length > 0) { + const cursorElement = cursor[0] + const position = cursorElement.getBoundingClientRect() + + this._spawnParticles("white", { x: position.left, y: position.top }) + } + } + } + }) + + this._tutorialGameplayManager.onCompleted.subscribe(() => { + this._completionInfo = this._gameTracker.end() + + this._onStateChangedEvent.dispatch({ + tutorialState: this._lastTutorialState, + completionInfo: this._completionInfo, + mode: "normal", + notes: this._notes, + }) + + this._tutorialManager.notifyTutorialCompleted(this._currentTutorialId, { + time: this._completionInfo.timeInMilliseconds, + keyPresses: this._completionInfo.keyPresses, + }) + + if (this._element) { + const bounds = this._element.getBoundingClientRect() + const blue = "rgb(97, 175, 239)" + + for (let i = 0; i < 8; i++) { + this._spawnParticles( + blue, + { + x: bounds.left + Math.random() * bounds.width, + y: bounds.top + Math.random() * bounds.height, + }, + { x: 300, y: 150 }, + ) + } + } + }) + } + + public handleInput(key: string): boolean { + if (this._completionInfo.completed) { + const nextTutorial = this._tutorialManager.getNextTutorialId(this._currentTutorialId) + + if (key === "") { + this.startTutorial(this._currentTutorialId) + } else if (nextTutorial && key === "") { + this.startTutorial(nextTutorial) + } else { + // No tutorial left - we'll pass through + return false + } + } else { + this._editor.input(key) + this._gameTracker.addKeyPress(1) + } + return true + } + + public render(context: Oni.BufferLayerRenderContext): JSX.Element { + return ( + (this._element = elem)} + /> + ) + } + + public async startTutorial(tutorialId: string): Promise { + await this._initPromise + this._completionInfo = DefaultCompletionInfo + this._currentTutorialId = tutorialId + const tutorial = this._tutorialManager.getTutorial(tutorialId) + + if (!this._hasAddedLayer) { + this._editor.activeBuffer.addLayer( + new GameplayBufferLayer(this._tutorialGameplayManager), + ) + this._hasAddedLayer = true + } + this._notes = tutorial.notes || [] + + await this._editor.activeBuffer.setCursorPosition(0, 0) + await this._editor.neovim.command("stopinsert") + + this._tutorialGameplayManager.start(tutorial, this._editor.activeBuffer) + this._gameTracker.start() + + windowManager.focusSplit("oni.window.0") + } + + private _spawnParticles( + color: string, + position: Vector, + velocityVariance: Vector = { x: 100, y: 50 }, + ): void { + const particles = getInstance() + + if (!particles || !this._element) { + return + } + + particles.createParticles(25, { + Position: position, + PositionVariance: { x: 10, y: 10 }, + Velocity: { x: 0, y: -150 }, + VelocityVariance: { x: 100, y: 50 }, + Color: color, + StartOpacity: 1, + EndOpacity: 0, + Time: 1, + }) + } +} + +export interface ITutorialBufferLayerViewProps { + renderContext: Oni.BufferLayerRenderContext + editor: NeovimEditor + onStateChangedEvent: IEvent + innerRef: (elem: HTMLElement) => void +} + +export interface ITutorialBufferLayerState { + tutorialState: ITutorialState + completionInfo: IGameplayCompletionInfo + mode: string + notes: JSX.Element[] +} + +const TutorialWrapper = withProps<{}>(styled.div)` + position: relative; + width: 100%; + height: 100%; + background-color: ${p => p.theme["editor.background"]}; + color: ${p => p.theme["editor.foreground"]}; + overflow: auto; + pointer-events: all; + + display: flex; + flex-direction: row; + ` + +const TutorialContentsWrapper = styled.div` + flex: 1 1 auto; + min-width: 600px; + max-width: 1000px; + + margin-left: 2em; + + display: flex; + flex-direction: column; +` + +const TutorialNotesWrapper = styled.div` + flex: 0 0 auto; + width: 250px; + border-left: 1px solid rgba(255, 255, 255, 0.2); + margin: 3em 0em; + + display: flex; + flex-direction: column; +` + +const TutorialSectionWrapper = styled.div` + width: 75%; + max-width: 1000px; + flex: 0 0 auto; +` + +const MainTutorialSectionWrapper = styled.div` + flex: 1 1 auto; + width: 100%; + height: 100%; + + display: flex; + align-items: center; +` + +const PrimaryHeader = styled.div` + padding-top: 2em; + font-size: 2em; +` + +const SubHeader = styled.div` + font-size: 1.6em; +` + +const SectionHeader = styled.div` + font-size: 1.1em; + font-weight: bold; +` + +const Section = styled.div` + padding-top: 1em; + padding-bottom: 2em; +` + +export interface IModeStatusBarItemProps { + mode: string +} + +const ModeStatusBarItem = withProps(styled.div)` + background-color: ${p => p.theme["highlight.mode." + p.mode + ".background"]}; + color: ${p => p.theme["highlight.mode." + p.mode + ".foreground"]}; + text-transform: uppercase; + + height: 2em; + line-height: 2em; + padding: 0px 4px; +` + +export class TutorialBufferLayerView extends React.PureComponent< + ITutorialBufferLayerViewProps, + ITutorialBufferLayerState +> { + constructor(props: ITutorialBufferLayerViewProps) { + super(props) + + this.state = { + tutorialState: { + goals: [], + activeGoalIndex: -1, + metadata: null, + }, + completionInfo: { + completed: false, + keyPresses: -1, + timeInMilliseconds: -1, + }, + mode: "normal", + notes: [], + } + } + + public componentDidMount(): void { + this.props.onStateChangedEvent.subscribe(newState => { + this.setState({ + ...newState, + }) + }) + } + + public render(): JSX.Element { + if (!this.state.tutorialState || !this.state.tutorialState.metadata) { + return null + } + + const title = this.state.tutorialState.metadata.name + const description = this.state.tutorialState.metadata.description + + const activeIndex = this.state.tutorialState.activeGoalIndex + + const goalsWithIndex = this.state.tutorialState.goals + .map((goal, idx) => ({ + goalIndex: idx, + goal, + })) + .filter(gi => !!gi.goal) + + let postActiveIndex = goalsWithIndex.findIndex(f => f.goalIndex === activeIndex) + + if (this.state.completionInfo.completed) { + postActiveIndex = goalsWithIndex.length + } + + const goalsToDisplay = goalsWithIndex.map((goal, postIndex) => { + const isCompleted = postActiveIndex > postIndex + + let visible = false + + if (postActiveIndex === 0) { + visible = postIndex < 3 + } else if (postActiveIndex > goalsWithIndex.length - 3) { + visible = goalsWithIndex.length - postIndex <= 3 + } else { + visible = Math.abs(postIndex - postActiveIndex) < 2 + } + + return ( + + ) + }) + + const isFlipped = this.state.completionInfo.completed + + return ( + + + + Tutorial + {title} + + +
    + +
    + {this.props.editor.render()} +
    +
    + , + id: "tutorial.null", + priority: 0, + }, + { + alignment: 1, + contents: ( + + {this.state.mode} + + ), + id: "tutorial.mode", + priority: 0, + }, + ]} + fontFamily={configuration.getValue("ui.fontFamily")} + fontSize={configuration.getValue("ui.fontSize")} + enabled={!isFlipped} + /> +
    +
    + } + back={ + isFlipped ? ( + + ) : null + } + /> +
    + + + Description: +
    {description}
    + Goals: +
    +
    {goalsToDisplay}
    +
    +
    + + + +
    + Notes: +
    +
    {this.state.notes}
    +
    + + ) + } +} diff --git a/browser/src/Services/Learning/Tutorial/TutorialGameplayManager.ts b/browser/src/Services/Learning/Tutorial/TutorialGameplayManager.ts new file mode 100644 index 0000000000..c9b9dfb3ce --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/TutorialGameplayManager.ts @@ -0,0 +1,130 @@ +/** + * TutorialManager + */ + +import * as Oni from "oni-api" + +import { Event, IEvent } from "oni-types" + +import { ITutorial, ITutorialMetadata, ITutorialStage } from "./ITutorial" + +export interface ITutorialState { + metadata: ITutorialMetadata + renderFunc?: (context: Oni.BufferLayerRenderContext) => JSX.Element + activeGoalIndex: number + goals: string[] +} + +/** + * Class that manages the state / lifecycle of the tutorial + * - Calls the 'tick' function + * - Calls the 'render' function + */ + +export class TutorialGameplayManager { + private _activeTutorial: ITutorial + private _currentStageIdx: number + private _onStateChanged = new Event() + private _onCompleted = new Event() + private _currentState: ITutorialState = null + private _onTick = new Event() + + private _isTickInProgress: boolean = false + private _isPendingTick: boolean = false + private _buf: Oni.Buffer + + public get onStateChanged(): IEvent { + return this._onStateChanged + } + + public get onCompleted(): IEvent { + return this._onCompleted + } + + public get onTick(): IEvent { + return this._onTick + } + + public get currentState(): ITutorialState { + return this._currentState + } + + public get currentStage(): ITutorialStage { + return this._activeTutorial.stages[this._currentStageIdx] + } + + public get currentTutorial(): ITutorial { + return this._activeTutorial + } + + constructor(private _editor: Oni.Editor) {} + + public start(tutorial: ITutorial, buffer: Oni.Buffer): void { + this._buf = buffer + this._currentStageIdx = 0 + this._activeTutorial = tutorial + + this._editor.onModeChanged.subscribe((evt: string) => { + this._tick() + }) + + this._editor.onBufferChanged.subscribe(() => { + this._tick() + }) + ;(this._editor as any).onCursorMoved.subscribe(() => { + this._tick() + }) + + this._tick() + } + + private async _tick(): Promise { + if (this._isTickInProgress) { + this._isPendingTick = true + return + } + + if (!this.currentStage) { + return + } + + this._isTickInProgress = true + + const result = await this.currentStage.tickFunction({ + editor: this._editor, + buffer: this._buf, + }) + this._onTick.dispatch() + + this._isTickInProgress = false + if (result) { + this._currentStageIdx++ + + if (this._currentStageIdx >= this._activeTutorial.stages.length) { + this._onCompleted.dispatch(true) + } + + // If we're on a new change, schedule a tick + window.setTimeout(() => this._tick()) + } + + const goalsToSend = this._activeTutorial.stages.map(f => f.goalName) + + const newState: ITutorialState = { + metadata: this._activeTutorial.metadata, + goals: goalsToSend, + activeGoalIndex: this._currentStageIdx, + renderFunc: (context: Oni.BufferLayerRenderContext) => + this.currentStage && this.currentStage.render + ? this.currentStage.render(context) + : null, + } + this._currentState = newState + this._onStateChanged.dispatch(newState) + + if (this._isPendingTick) { + this._isPendingTick = false + await this._tick() + } + } +} diff --git a/browser/src/Services/Learning/Tutorial/TutorialManager.ts b/browser/src/Services/Learning/Tutorial/TutorialManager.ts new file mode 100644 index 0000000000..b65179fd39 --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/TutorialManager.ts @@ -0,0 +1,157 @@ +/** + * TutorialManager + */ + +import * as Oni from "oni-api" +import { Event, IEvent } from "oni-types" + +import { EditorManager } from "./../../EditorManager" +import { WindowManager } from "./../../WindowManager" + +import { IPersistentStore } from "./../../../PersistentStore" + +import { ITutorial, ITutorialMetadata } from "./ITutorial" +import { TutorialBufferLayer } from "./TutorialBufferLayer" + +export interface ITutorialPersistedState { + completedTutorialIds: string[] +} + +export interface ITutorialMetadataWithProgress { + tutorialInfo: ITutorialMetadata + completionInfo: ITutorialCompletionInfo +} + +export interface ITutorialCompletionInfo { + keyPresses: number + time: number /* milliseconds */ +} + +export interface IdToCompletionInfo { + [tutorialId: string]: ITutorialCompletionInfo +} + +export interface IPersistedTutorialState { + completionInfo: IdToCompletionInfo +} + +export class TutorialManager { + private _tutorials: ITutorial[] = [] + private _initPromise: Promise + + private _persistedState: IPersistedTutorialState = { completionInfo: {} } + private _onTutorialCompletedEvent: Event = new Event() + private _onTutorialProgressChanged: Event = new Event() + + public get onTutorialCompletedEvent(): IEvent { + return this._onTutorialCompletedEvent + } + + public get onTutorialProgressChangedEvent(): IEvent { + return this._onTutorialProgressChanged + } + + constructor( + private _editorManager: EditorManager, + private _persistentStore: IPersistentStore, + private _windowManager: WindowManager, + ) {} + + public async start(): Promise { + if (this._initPromise) { + return this._initPromise + } + + this._initPromise = this._persistentStore.get() + + this._persistedState = await this._initPromise + this._onTutorialProgressChanged.dispatch() + return this._persistedState + } + + public getTutorialInfo(): ITutorialMetadataWithProgress[] { + return this._getSortedTutorials().map(tut => ({ + tutorialInfo: tut.metadata, + completionInfo: this._getCompletionState(tut.metadata.id), + })) + } + + public getTutorial(id: string): ITutorial { + return this._tutorials.find(t => t.metadata.id === id) + } + + public registerTutorial(tutorial: ITutorial): void { + this._tutorials.push(tutorial) + } + + public async notifyTutorialCompleted( + id: string, + completionInfo: ITutorialCompletionInfo, + ): Promise { + await this.start() + this._persistedState.completionInfo[id] = completionInfo + await this._persistentStore.set(this._persistedState) + this._onTutorialCompletedEvent.dispatch() + this._onTutorialProgressChanged.dispatch() + } + + public async clearProgress(): Promise { + await this.start() + this._persistedState = { + completionInfo: {}, + } + await this._persistentStore.set(this._persistedState) + this._onTutorialProgressChanged.dispatch() + } + + public getNextTutorialId(currentTutorialId?: string): string { + const sortedTutorials = this._getSortedTutorials() + + if (!currentTutorialId) { + // Get first tutorial not completed + const nextIncompleteTutorial = sortedTutorials.find(f => { + return !this._persistedState.completionInfo[f.metadata.id] + }) + + return nextIncompleteTutorial ? nextIncompleteTutorial.metadata.id : null + } + + const currentTuturial = sortedTutorials.findIndex( + tut => tut.metadata.id === currentTutorialId, + ) + const nextTutorial = currentTuturial + 1 + + if (nextTutorial >= sortedTutorials.length) { + return null + } + + return sortedTutorials[nextTutorial].metadata.id + } + + public async startTutorial(id: string): Promise { + const buf = await this._editorManager.activeEditor.openFile("oni://Tutorial", { + openMode: Oni.FileOpenMode.Edit, + }) + let tutorialLayer = (buf as any).getLayerById("oni.layer.tutorial") as TutorialBufferLayer + if (!tutorialLayer) { + tutorialLayer = new TutorialBufferLayer(this) + buf.addLayer(tutorialLayer) + } + + tutorialLayer.startTutorial(id) + // Focus the editor + const splitHandle = this._windowManager.getSplitHandle(this._editorManager + .activeEditor as any) + splitHandle.focus() + } + + private _getSortedTutorials(): ITutorial[] { + return this._tutorials.sort((a, b) => { + return a.metadata.level - b.metadata.level + }) + } + + private _getCompletionState(id: string): ITutorialCompletionInfo { + return this._persistedState.completionInfo[id] || null + } +} diff --git a/browser/src/Services/Learning/Tutorial/Tutorials/BasicMovementTutorial.tsx b/browser/src/Services/Learning/Tutorial/Tutorials/BasicMovementTutorial.tsx new file mode 100644 index 0000000000..d15d174756 --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Tutorials/BasicMovementTutorial.tsx @@ -0,0 +1,56 @@ +/** + * TutorialManager + */ + +import * as React from "react" + +import { ITutorial, ITutorialMetadata, ITutorialStage } from "./../ITutorial" +// import { InitializeBufferStage, MoveToGoalStage } from "./../Stages" + +import * as Notes from "./../Notes" +import * as Stages from "./../Stages" + +const Line1 = "In NORMAL mode, the 'l' key moves one character to the RIGHT..." +const Line2 = "...and 'h' moves one character to the LEFT." +const Line3 = "'j' moves DOWN one line." +const Line4 = "And 'k' moves UP one line." +const Line5 = "Nice, you're a pro! Let's put it all together now." + +export class BasicMovementTutorial implements ITutorial { + private _stages: ITutorialStage[] + + constructor() { + this._stages = [ + new Stages.SetBufferStage([Line1]), + new Stages.MoveToGoalStage("Use 'l' to move RIGHT to the goal", 0, 10), + new Stages.SetBufferStage([Line1, Line2]), + new Stages.MoveToGoalStage("Use 'h' to move LEFT to the goal", 0, 0), + new Stages.SetBufferStage([Line1, Line2, Line3]), + new Stages.MoveToGoalStage("Use 'j' to move DOWN to the goal", 2, 0), + new Stages.SetBufferStage([Line1, Line2, Line3, Line4]), + new Stages.MoveToGoalStage("Use 'k' to move UP to the goal", 0, 0), + new Stages.SetBufferStage([Line1, Line2, Line3, Line4, Line5]), + new Stages.MoveToGoalStage("Use h/j/k/l to move to the goal", 4, 8), + new Stages.MoveToGoalStage("Use h/j/k/l to move to the goal", 2, 1), + new Stages.MoveToGoalStage("Use h/j/k/l to move to the goal", 0, 10), + ] + } + + public get metadata(): ITutorialMetadata { + return { + id: "oni.tutorials.basic_movement", + name: "Motion: h, j, k, l", + description: + "To use Oni effectively in normal mode, you'll need to learn to move the cursor around! There are many ways to move the cursor, but the most basic is to use `h`, `j`, `k`, and `l`. These keys might seem strange at first, but they allow you to move the cursor without your fingers leaving the home row.", + level: 110, + } + } + + public get notes(): JSX.Element[] { + return [] + } + + public get stages(): ITutorialStage[] { + return this._stages + } +} diff --git a/browser/src/Services/Learning/Tutorial/Tutorials/BeginningsAndEndingsTutorial.tsx b/browser/src/Services/Learning/Tutorial/Tutorials/BeginningsAndEndingsTutorial.tsx new file mode 100644 index 0000000000..ef34601550 --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Tutorials/BeginningsAndEndingsTutorial.tsx @@ -0,0 +1,70 @@ +/** + * TutorialManager + */ + +import * as React from "react" + +import { ITutorial, ITutorialMetadata, ITutorialStage } from "./../ITutorial" +import * as Notes from "./../Notes" +import * as Stages from "./../Stages" + +const Line1 = "Use the `$` key to move to the end of a line." +const Line2 = "`0` moves to the beginning of the line." +const Line3 = " ...and `_` moves to the first character." + +export class BeginningsAndEndingsTutorial implements ITutorial { + private _stages: ITutorialStage[] + + constructor() { + this._stages = [ + new Stages.SetBufferStage([Line1]), + new Stages.MoveToGoalStage( + "Use '$' to move to the END of the line", + 0, + Line1.length - 1, + ), + new Stages.SetBufferStage([Line1, Line2]), + new Stages.MoveToGoalStage("Use '0' to move to the BEGINNING of the line", 0, 0), + new Stages.MoveToGoalStage("Use `j` to move down to the next line", 1), + new Stages.MoveToGoalStage( + "Use '$' to move to the END of the line", + 1, + Line2.length - 1, + ), + new Stages.MoveToGoalStage("Use '0' to move to the BEGINNING of the line", 1, 0), + new Stages.SetBufferStage([Line1, Line2, Line3]), + new Stages.MoveToGoalStage("Use `j` to move down to the next line", 2), + new Stages.MoveToGoalStage("Use '_' to move to the FIRST CHARACTER", 2, 4), + new Stages.MoveToGoalStage( + "Use '$' to move to the END of the line", + 2, + Line3.length - 1, + ), + new Stages.MoveToGoalStage("Use '0' to move to the BEGINNING of the line", 2, 0), + new Stages.MoveToGoalStage("Use '_' to move to the FIRST CHARACTER", 2, 4), + new Stages.MoveToGoalStage( + "Use '$' to move to the END of the line", + 2, + Line3.length - 1, + ), + ] + } + + public get metadata(): ITutorialMetadata { + return { + id: "oni.tutorials.beginnings_and_endings", + name: "Motion: _, 0, $", + description: + "Using `h` and `l` isn't always the most efficient way to get around a line. You can use the `0` key to move to the very beginning a line, and `$` to move to the end. `_` moves to the first character in the line, which is often more convenient than `0`.", + level: 160, + } + } + + public get stages(): ITutorialStage[] { + return this._stages + } + + public get notes(): JSX.Element[] { + return [, , , ] + } +} diff --git a/browser/src/Services/Learning/Tutorial/Tutorials/DeleteCharacterTutorial.tsx b/browser/src/Services/Learning/Tutorial/Tutorials/DeleteCharacterTutorial.tsx new file mode 100644 index 0000000000..da06302ae4 --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Tutorials/DeleteCharacterTutorial.tsx @@ -0,0 +1,155 @@ +/** + * DeleteCharacterTutorial.tsx + * + * Tutorial that runs through deleting a character. + */ + +import * as React from "react" + +import { ITutorial, ITutorialMetadata, ITutorialStage } from "./../ITutorial" +import * as Notes from "./../Notes" +import * as Stages from "./../Stages" + +import { Bold } from "./../../../../UI/components/common" + +const TutorialLine1Original = "The coww jumped over the mmoon" +const TutorialLine1CorrectA = "The cow jumped over the mmoon" +const TutorialLine1Correct = "The cow jumped over the moon" + +const TutorialLine2FirstMarker = "The b".length - 1 +const TutorialLine2Original = "The bboy bougght the baskketball" +const TutorialLine2SecondMarker = "The boy boug".length - 1 +const TutorialLine2CorrectA = "The boy bougght the baskketball" + +const TutorialLine2ThirdMarker = "The boy bought the bask".length - 1 +const TutorialLine2CorrectB = "The boy bought the baskketball" + +const TutorialLine2Correct = "The boy bought the basketball" + +const TutorialLine3Original = "Modal edditing is the ccats pajamas" + +const TutorialLine3FirstMarker = "Modal ed".length - 1 +const TutorialLine3CorrectA = "Modal editing is the ccats pajamas" + +const TutorialLine3SecondMarker = "Modal editing is the c".length - 1 +const TutorialLine3Correct = "Modal editing is the cats pajamas" + +export class DeleteCharacterTutorial implements ITutorial { + private _stages: ITutorialStage[] + + constructor() { + this._stages = [ + new Stages.SetBufferStage([TutorialLine1Original]), + new Stages.MoveToGoalStage("Move to the first duplicated 'w' character", 0, 6), + Stages.combine( + "Delete the duplicated 'w' character by pressing `x`", + new Stages.DeleteCharactersStage(null, 0, 6, "w"), + new Stages.WaitForStateStage(null, [TutorialLine1CorrectA]), + ), + new Stages.MoveToGoalStage( + "Move to the first duplicated 'm' character", + 0, + TutorialLine1CorrectA.length - 5, + ), + Stages.combine( + "Remove the duplicated 'm' character by pressing `x`", + new Stages.DeleteCharactersStage(null, 0, TutorialLine1CorrectA.length - 5, "m"), + new Stages.WaitForStateStage(null, [TutorialLine1Correct]), + ), + new Stages.SetBufferStage([TutorialLine1Correct, TutorialLine2Original]), + new Stages.MoveToGoalStage( + "Move to the first duplicated 'b' character", + 1, + TutorialLine2FirstMarker, + ), + Stages.combine( + "Remove the duplicated 'b' character by pressing `x`", + new Stages.DeleteCharactersStage(null, 1, TutorialLine2FirstMarker, "b"), + new Stages.WaitForStateStage(null, [TutorialLine1Correct, TutorialLine2CorrectA]), + ), + new Stages.MoveToGoalStage( + "Move to the first duplicated 'g' character", + 1, + TutorialLine2SecondMarker, + ), + Stages.combine( + "Remove the duplicated 'g' character by pressing `x`", + new Stages.DeleteCharactersStage(null, 1, TutorialLine2SecondMarker, "g"), + new Stages.WaitForStateStage(null, [TutorialLine1Correct, TutorialLine2CorrectB]), + ), + new Stages.MoveToGoalStage( + "Move to the first duplicated 'k' character", + 1, + TutorialLine2ThirdMarker, + ), + Stages.combine( + "Remove the duplicated 'k' character by pressing `x`", + new Stages.DeleteCharactersStage(null, 1, TutorialLine2ThirdMarker, "g"), + new Stages.WaitForStateStage(null, [TutorialLine1Correct, TutorialLine2Correct]), + ), + new Stages.SetBufferStage([ + TutorialLine1Correct, + TutorialLine2Correct, + TutorialLine3Original, + ]), + + new Stages.MoveToGoalStage( + "Move to the first duplicated 'd' character", + 2, + TutorialLine3FirstMarker, + ), + Stages.combine( + "Remove the duplicated 'd' character by pressing `x`", + new Stages.DeleteCharactersStage(null, 2, TutorialLine3FirstMarker, "d"), + new Stages.WaitForStateStage(null, [ + TutorialLine1Correct, + TutorialLine2Correct, + TutorialLine3CorrectA, + ]), + ), + + new Stages.MoveToGoalStage( + "Move to the first duplicated 'c' character", + 2, + TutorialLine3SecondMarker, + ), + Stages.combine( + "Remove the duplicated 'c' character by pressing `x`", + new Stages.DeleteCharactersStage(null, 2, TutorialLine3SecondMarker, "c"), + new Stages.WaitForStateStage(null, [ + TutorialLine1Correct, + TutorialLine2Correct, + TutorialLine3Correct, + ]), + ), + ] + } + + public get metadata(): ITutorialMetadata { + return { + id: "oni.tutorial.delete_character", + name: "Deleting a Character", + description: + "In normal mode, you can quickly delete characters. Move to the character (using h/j/k/l) and press `x` to delete. Correct the above lines without going to insert mode.", + level: 120, + } + } + + public get notes(): JSX.Element[] { + return [ + , + + In normal mode, deletes the character at the cursor position. + + } + />, + ] + } + + public get stages(): ITutorialStage[] { + return this._stages + } +} diff --git a/browser/src/Services/Learning/Tutorial/Tutorials/DeleteOperatorTutorial.tsx b/browser/src/Services/Learning/Tutorial/Tutorials/DeleteOperatorTutorial.tsx new file mode 100644 index 0000000000..5d5188735e --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Tutorials/DeleteOperatorTutorial.tsx @@ -0,0 +1,106 @@ +/** + * DeleteOperatorTutorial.tsx + * + * Tutorial that exercises the delete operator + */ + +import * as React from "react" + +import { ITutorial, ITutorialMetadata, ITutorialStage } from "./../ITutorial" +import * as Notes from "./../Notes" +import * as Stages from "./../Stages" + +const Line1 = "The delete operator is very useful!" +const Line2a = "--> Delete this line" + +const Line2b = "--> You can delete the current line AND the one BELOW it," +const Line3b = "--> using the `dj` command." + +const Line2c = "--> You can delete the current line AND the one ABOVE it," +const Line3c = "--> using the `dk` command." + +const Line2d = "--> The delete operator works with other motions, too." +const Line3d = "--> Let's try out `dw` - delete word. Delete the duplicate words below:" +const Line4d = "--> Help delete the duplicate duplicate words." +const Line4dCorrect = "--> Help delete the duplicate words." + +export class DeleteOperatorTutorial implements ITutorial { + private _stages: ITutorialStage[] + + constructor() { + this._stages = [ + new Stages.SetBufferStage([Line1, Line2a]), + // new Stages.SetCursorPositionStage(0, 0), + new Stages.MoveToGoalStage("Move to the goal marker", 1, 0), + Stages.combine( + "Delete the current line with 'dd'", + new Stages.DeleteCharactersStage(null, 1, 0, Line2a), + new Stages.WaitForStateStage(null, [Line1]), + ), + Stages.combine( + null, + new Stages.FadeInLineStage(null, 1, Line2b), + new Stages.FadeInLineStage(null, 2, Line3b), + ), + new Stages.SetBufferStage([Line1, Line2b, Line3b]), + new Stages.MoveToGoalStage("Move to the goal marker", 1, 0), + Stages.combine( + "Delete the current line, and the one below it, with 'dj'", + new Stages.DeleteCharactersStage(null, 1, 0, Line2b), + new Stages.DeleteCharactersStage(null, 2, 0, Line3b), + new Stages.WaitForStateStage(null, [Line1]), + ), + Stages.combine( + null, + new Stages.FadeInLineStage(null, 1, Line2c), + new Stages.FadeInLineStage(null, 2, Line3c), + new Stages.SetBufferStage([Line1, Line2c, Line3c]), + ), + new Stages.MoveToGoalStage("Move to the goal marker", 2, 0), + Stages.combine( + "Delete the current line, and the one above it, with 'dk'", + new Stages.DeleteCharactersStage(null, 1, 0, Line2c), + new Stages.DeleteCharactersStage(null, 2, 0, Line3c), + new Stages.WaitForStateStage(null, [Line1]), + ), + Stages.combine( + null, + new Stages.FadeInLineStage(null, 1, Line2d), + new Stages.FadeInLineStage(null, 2, Line3d), + new Stages.FadeInLineStage(null, 3, Line4d), + new Stages.SetBufferStage([Line1, Line2d, Line3d, Line4d]), + ), + new Stages.MoveToGoalStage("Move to the goal marker", 3, 20), + Stages.combine( + "Delete the duplicate word with 'dw'", + new Stages.DeleteCharactersStage(null, 3, 20, "duplicate"), + new Stages.WaitForStateStage(null, [Line1, Line2d, Line3d, Line4dCorrect]), + ), + ] + } + + public get metadata(): ITutorialMetadata { + return { + id: "oni.tutorials.delete_operator", + name: "Delete Operator: d", + description: + "We've stuck mostly with motions, but now we're going to learn about our first operator - delete (`d`). Operators are like _verbs_ in the vim world, and motions are like _nouns_. An operator can be paired with a motion - which means we can pair the `d` key with all sorts of motions - `dj` to delete the line and the line below, `dw` to delete a word, etc.", + level: 180, + } + } + + public get stages(): ITutorialStage[] { + return this._stages + } + + public get notes(): JSX.Element[] { + return [ + , + , + , + , + , + , + ] + } +} diff --git a/browser/src/Services/Learning/Tutorial/Tutorials/MoveAndInsertTutorial.tsx b/browser/src/Services/Learning/Tutorial/Tutorials/MoveAndInsertTutorial.tsx new file mode 100644 index 0000000000..f334bd99ea --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Tutorials/MoveAndInsertTutorial.tsx @@ -0,0 +1,74 @@ +/** + * MoveAndInsertTutorial.tsx + * + * Tutorial that brings together moving and inserting + */ + +import * as React from "react" + +import { ITutorial, ITutorialMetadata, ITutorialStage } from "./../ITutorial" +import * as Notes from "./../Notes" +import * as Stages from "./../Stages" + +const TutorialLine1Original = "There is text msng this ." +const TutorialLine1Correct = "There is some text missing from this line." + +export class MoveAndInsertTutorial implements ITutorial { + private _stages: ITutorialStage[] + + constructor() { + this._stages = [ + new Stages.SetBufferStage([TutorialLine1Original, TutorialLine1Correct]), + new Stages.MoveToGoalStage("Move to the letter 't'", 0, 9), + new Stages.WaitForModeStage("Press 'i' to enter insert mode", "insert"), + new Stages.CorrectLineStage( + "Add the missing word 'some'", + 0, + TutorialLine1Correct, + "green", + "There is some", + ), + new Stages.WaitForModeStage("Press '' to exit insert mode", "normal"), + new Stages.MoveToGoalStage("Move to the letter 's'", 0, 20), + new Stages.CorrectLineStage( + "Correct the word: `msng` should be `missing`", + 0, + TutorialLine1Correct, + "green", + "There is some text missing", + ), + new Stages.CorrectLineStage( + "Add the missing word 'from'", + 0, + TutorialLine1Correct, + "green", + "There is some text missing from", + ), + new Stages.CorrectLineStage( + "Add the missing word 'line'", + 0, + TutorialLine1Correct, + "green", + "There is some text missing from this line.", + ), + ] + } + + public get metadata(): ITutorialMetadata { + return { + id: "oni.tutorial.move_and_insert", + name: "Moving and Inserting", + description: + "It's important to be able to switch between normal and insert mode, in order to edit text! Let's put together the cursor motion and insert mode from the previous tutorials.", + level: 130, + } + } + + public get stages(): ITutorialStage[] { + return this._stages + } + + public get notes(): JSX.Element[] { + return [, , ] + } +} diff --git a/browser/src/Services/Learning/Tutorial/Tutorials/SearchInBufferTutorial.tsx b/browser/src/Services/Learning/Tutorial/Tutorials/SearchInBufferTutorial.tsx new file mode 100644 index 0000000000..3bbf9554ad --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Tutorials/SearchInBufferTutorial.tsx @@ -0,0 +1,81 @@ +/** + * TutorialManager + */ + +import * as React from "react" + +import { ITutorial, ITutorialMetadata, ITutorialStage } from "./../ITutorial" +// import { InitializeBufferStage, MoveToGoalStage } from "./../Stages" + +import * as Notes from "./../Notes" +import * as Stages from "./../Stages" + +const EmptyLine = "" + +// Forward search lines +const Line1 = "In NORMAL mode, the '/' key lets you search for a string." +const Line2 = "It's a very powerful way to move your way inside a buffer quickly" +const Line3 = "The 'n' key lets you move to the next instance of the matched string." +const Line4 = "Even if the match is way down, move here!" +const Line5 = "If you want to go the opposite way," +const Line6 = "The 'N' key lets you move to the previous match." + +// Backward search lines +const Line7 = "The '?' key will let you search backwards instead!" +const Line8 = "'?' searches backward, the 'n' and 'N' keys operate backward as well!" +const Line9 = "'N' will move you to the next instance going down" +const Line10 = "'n' will move you to the next instance going up" + +export class SearchInBufferTutorial implements ITutorial { + private _stages: ITutorialStage[] + + constructor() { + this._stages = [ + // Forward search + new Stages.SetBufferStage([Line1, Line2]), + new Stages.MoveToGoalStage("Use '/' to search for the word 'move'", 1, 28), + new Stages.SetBufferStage([Line1, Line2, Line3]), + new Stages.MoveToGoalStage("Use 'n' to go to the next instance of 'move'", 2, 21), + new Stages.SetBufferStage([Line1, Line2, Line3, EmptyLine, EmptyLine, Line4]), + new Stages.MoveToGoalStage("Use 'n' to go to the next instance of 'move'", 5, 31), + new Stages.SetBufferStage([ + Line1, + Line2, + Line3, + EmptyLine, + EmptyLine, + Line4, + Line5, + Line6, + ]), + new Stages.MoveToGoalStage("Use 'N' to go to the previous instance of 'move'", 2, 21), + new Stages.MoveToGoalStage("Use 'N' to go to the previous instance of 'move'", 1, 28), + // Backward search + new Stages.SetBufferStage([Line7]), + new Stages.SetCursorPositionStage(0, 48), + new Stages.MoveToGoalStage("Use '?' to search for the word 'you'", 0, 21), + new Stages.SetBufferStage([Line7, Line8, Line9]), + new Stages.MoveToGoalStage("Use 'N' to go to the previous instance of 'you'", 2, 14), + new Stages.SetBufferStage([Line7, Line8, Line9, Line10]), + new Stages.MoveToGoalStage("Use 'n' to go to the next instance of 'move'", 0, 21), + ] + } + + public get metadata(): ITutorialMetadata { + return { + id: "oni.tutorials.find_across_buffer", + name: "Motion: /, ?, n, N", + description: + "To navigate a buffer efficiently, Oni lets you search for strings with `/` and `?`. `n` and `N` let you navigate quickly between the matches!", + level: 190, + } + } + + public get notes(): JSX.Element[] { + return [, , , ] + } + + public get stages(): ITutorialStage[] { + return this._stages + } +} diff --git a/browser/src/Services/Learning/Tutorial/Tutorials/SwitchModeTutorial.tsx b/browser/src/Services/Learning/Tutorial/Tutorials/SwitchModeTutorial.tsx new file mode 100644 index 0000000000..d68507a9d8 --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Tutorials/SwitchModeTutorial.tsx @@ -0,0 +1,69 @@ +/** + * TutorialManager + */ + +import * as React from "react" + +import { ITutorial, ITutorialContext, ITutorialMetadata, ITutorialStage } from "./../ITutorial" +import * as Stages from "./../Stages" + +import * as Notes from "./../Notes" + +export class SwitchModeTutorial implements ITutorial { + private _stages: ITutorialStage[] + public get metadata(): ITutorialMetadata { + return { + id: "oni.tutorial.switch_modes", + name: "Switching Modes", + description: + "Oni is a modal editor, which means the editor works in different modes. This can seem strange coming from other editors - where the only mode is inserting text. However, when working with text, you'll find that only a small percentage of the time you are typing - the majority of the time, you are navigating and editing, which is where normal mode is used. Let's practice switching to and from insert mode!", + level: 100, + } + } + + public get notes(): JSX.Element[] { + return [, , ] + } + + public get stages(): ITutorialStage[] { + return this._stages + } + + constructor() { + this._stages = [ + new Stages.ClearBufferStage(), + new Stages.WaitForModeStage("Switch to INSERT mode by pressing 'i'", "insert"), + new WaitForTextStage("Type some text!"), + new Stages.WaitForModeStage("Switch back to NORMAL mode by pressing 'esc'", "normal"), + { + goalName: "Switch to insert mode on a new line by pressing 'o'", + tickFunction: async (context: ITutorialContext): Promise => { + return context.editor.mode === "insert" && context.buffer.cursor.line >= 1 + }, + }, + new WaitForTextStage("Type some more text!"), + new Stages.WaitForModeStage("Switch back to NORMAL mode by pressing 'esc'", "normal"), + ] + } +} + +export class WaitForTextStage implements ITutorialStage { + private _characterCount: number = 0 + + public get goalName(): string { + return `${this._goalName} [${this._characterCount}/4 characters entered]` + } + + constructor(private _goalName: string) {} + + public async tickFunction(context: ITutorialContext): Promise { + const [line] = await context.buffer.getLines( + context.buffer.cursor.line, + context.buffer.cursor.line + 1, + ) + + this._characterCount = !!line ? line.length : 0 + + return line && line.length > 3 + } +} diff --git a/browser/src/Services/Learning/Tutorial/Tutorials/VerticalMovementTutorial.tsx b/browser/src/Services/Learning/Tutorial/Tutorials/VerticalMovementTutorial.tsx new file mode 100644 index 0000000000..b4977245a1 --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Tutorials/VerticalMovementTutorial.tsx @@ -0,0 +1,49 @@ +/** + * Vertical Movement Tutorial + */ +import * as React from "react" + +import { ITutorial, ITutorialMetadata, ITutorialStage } from "./../ITutorial" +import * as Notes from "./../Notes" +import * as Stages from "./../Stages" + +export class VerticalMovementTutorial implements ITutorial { + private _stages: ITutorialStage[] + + constructor() { + const lines = [] + for (let i = 0; i < 150; i++) { + lines.push(`This is line ${i} of a large file!`) + } + + this._stages = [ + new Stages.SetBufferStage(lines), + new Stages.MoveToGoalStage("Use 'G' to move to the BOTTOM of the file.", 149, 0), + new Stages.MoveToGoalStage("Use 'gg' to move to the TOP of the file", 0, 0), + new Stages.MoveToGoalStage("Use 50G to move line 50", 49, 0), + new Stages.MoveToGoalStage("Use 100G to move line 100", 99, 0), + new Stages.MoveToGoalStage("Move to the bottom of the file", 149, 0), + new Stages.MoveToGoalStage("Move to the top of the file", 0, 0), + new Stages.MoveToGoalStage("Move to line 125", 124, 0), + new Stages.MoveToGoalStage("Move back to the top of the file", 0, 0), + ] + } + + public get metadata(): ITutorialMetadata { + return { + id: "oni.tutorials.vertical_movement", + name: "Motion: gg, G", + description: + "When working with large files, it's very helpful to quickly be able to move to the top or bottom of the file, as well as to particular lines. `gg`, `G`, and `G` can help us here!", + level: 150, + } + } + + public get stages(): ITutorialStage[] { + return this._stages + } + + public get notes(): JSX.Element[] { + return [, , ] + } +} diff --git a/browser/src/Services/Learning/Tutorial/Tutorials/WordMotionTutorial.tsx b/browser/src/Services/Learning/Tutorial/Tutorials/WordMotionTutorial.tsx new file mode 100644 index 0000000000..b137cd77b4 --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Tutorials/WordMotionTutorial.tsx @@ -0,0 +1,91 @@ +/** + * WordMotionTutorial.tsx + * + * Tutorial that exercises basic word motion - `w`, `b`, `e` + */ + +import * as React from "react" + +import { ITutorial, ITutorialMetadata, ITutorialStage } from "./../ITutorial" +import * as Notes from "./../Notes" +import * as Stages from "./../Stages" + +const Line1 = "Use the w key to move to the BEGINNING of the NEXT word." +const Line2 = "Use the e key to move to the END of the NEXT word." +const Line3 = "Use the b key to move to the BEGINNING of the PREVIOUS word." + +export class WordMotionTutorial implements ITutorial { + private _stages: ITutorialStage[] + + constructor() { + this._stages = [ + new Stages.SetBufferStage([Line1]), + new Stages.SetCursorPositionStage(0, 0), + new Stages.MoveToGoalStage("Use the `w` key to move to the 't' character", 0, 4), + new Stages.MoveToGoalStage("Use the `w` key to move to the next word", 0, 8), + new Stages.MoveToGoalStage("Use the `w` key to move to the 'key' word", 0, 10), + new Stages.SetBufferStage([Line1, Line2]), + new Stages.MoveToGoalStage("Use the 'j' key to move down a line", 1, 10 /* todo */), + new Stages.MoveToGoalStage( + "Use the '0' key to move to the beginning of the line", + 1, + 0, + ), + new Stages.MoveToGoalStage( + "Use the 'e' key to move to the end of the first word", + 1, + 2, + ), + new Stages.MoveToGoalStage( + "Use the 'e' key to move to the end of the second word", + 1, + 6, + ), + new Stages.MoveToGoalStage( + "Use the 'e' key to move to the end of the third word", + 1, + 8, + ), + new Stages.SetBufferStage([Line1, Line2, Line3]), + new Stages.MoveToGoalStage("Use the 'j' key to move down a line", 2, 8 /* todo */), + new Stages.MoveToGoalStage( + "Use the '$' key to move to the end of the line", + 2, + Line3.length - 1, + ), + new Stages.MoveToGoalStage( + "Use the 'b' key to move to the beginning of the last word", + 2, + Line3.length - "word.".length, + ), + new Stages.MoveToGoalStage( + "Use the 'b' key to move to the beginning of the second-to-last word", + 2, + Line3.length - "PREVIOUS word.".length, + ), + new Stages.MoveToGoalStage( + "Use the 'b' key to move to the beginning of the third-to-last word", + 2, + Line3.length - "the PREVIOUS word.".length, + ), + ] + } + + public get metadata(): ITutorialMetadata { + return { + id: "oni.tutorials.word_motion", + name: "Motion: w, e, b", + description: + "Often, `h` and `l` aren't the fastest way to move in a line. Word motions can be useful here - and even more useful when coupled with operators (we'll explore those later)! The `w` key moves to the first letter of the next word, the `b` key moves to the beginning letter of the previous word, and the `e` key moves to the end of the next word.", + level: 170, + } + } + + public get stages(): ITutorialStage[] { + return this._stages + } + + public get notes(): JSX.Element[] { + return [, , , ] + } +} diff --git a/browser/src/Services/Learning/Tutorial/Tutorials/index.tsx b/browser/src/Services/Learning/Tutorial/Tutorials/index.tsx new file mode 100644 index 0000000000..bbe5be06d4 --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/Tutorials/index.tsx @@ -0,0 +1,30 @@ +/** + * TutorialManager + */ + +import { ITutorial } from "./../ITutorial" + +import { BasicMovementTutorial } from "./BasicMovementTutorial" +import { BeginningsAndEndingsTutorial } from "./BeginningsAndEndingsTutorial" +import { DeleteCharacterTutorial } from "./DeleteCharacterTutorial" +import { DeleteOperatorTutorial } from "./DeleteOperatorTutorial" +import { MoveAndInsertTutorial } from "./MoveAndInsertTutorial" +import { SearchInBufferTutorial } from "./SearchInBufferTutorial" +import { SwitchModeTutorial } from "./SwitchModeTutorial" +import { VerticalMovementTutorial } from "./VerticalMovementTutorial" +import { WordMotionTutorial } from "./WordMotionTutorial" + +export * from "./DeleteCharacterTutorial" +export * from "./SwitchModeTutorial" + +export const AllTutorials: ITutorial[] = [ + new BeginningsAndEndingsTutorial(), + new SwitchModeTutorial(), + new BasicMovementTutorial(), + new DeleteCharacterTutorial(), + new DeleteOperatorTutorial(), + new MoveAndInsertTutorial(), + new VerticalMovementTutorial(), + new WordMotionTutorial(), + new SearchInBufferTutorial(), +] diff --git a/browser/src/Services/Learning/Tutorial/index.ts b/browser/src/Services/Learning/Tutorial/index.ts new file mode 100644 index 0000000000..4a3277231b --- /dev/null +++ b/browser/src/Services/Learning/Tutorial/index.ts @@ -0,0 +1,3 @@ +export * from "./ITutorial" +export * from "./TutorialManager" +export * from "./Tutorials" diff --git a/browser/src/Services/Learning/index.ts b/browser/src/Services/Learning/index.ts index ff6f70ad57..6ea1711f8d 100644 --- a/browser/src/Services/Learning/index.ts +++ b/browser/src/Services/Learning/index.ts @@ -2,30 +2,80 @@ * Learning.ts */ -// import { Event, IEvent } from "oni-types" +import { getPersistentStore, IPersistentStore } from "./../../PersistentStore" +import { CommandManager } from "./../CommandManager" import { Configuration } from "./../Configuration" import { EditorManager } from "./../EditorManager" import { OverlayManager } from "./../Overlay" import { SidebarManager } from "./../Sidebar" +import { WindowManager } from "./../WindowManager" import { LearningPane } from "./LearningPane" +import { IPersistedTutorialState, TutorialManager } from "./Tutorial/TutorialManager" import * as Achievements from "./Achievements" +import { ITutorial } from "./Tutorial/ITutorial" +import { AllTutorials } from "./Tutorial/Tutorials" + +let _tutorialManager: TutorialManager export const activate = ( + commandManager: CommandManager, configuration: Configuration, editorManager: EditorManager, overlayManager: OverlayManager, sidebarManager: SidebarManager, + windowManager: WindowManager, ) => { - const learningEnabled = configuration.getValue("experimental.learning.enabled") + const learningEnabled = configuration.getValue("learning.enabled") + + Achievements.activate( + commandManager, + configuration, + editorManager, + sidebarManager, + overlayManager, + ) + + const achievements = Achievements.getInstance() if (!learningEnabled) { return } - sidebarManager.add("trophy", new LearningPane()) + const store: IPersistentStore = getPersistentStore("oni-tutorial", { + completionInfo: {}, + }) + _tutorialManager = new TutorialManager(editorManager, store, windowManager) + _tutorialManager.start() + sidebarManager.add("trophy", new LearningPane(_tutorialManager, commandManager)) + + _tutorialManager.onTutorialCompletedEvent.subscribe(() => { + achievements.notifyGoal("oni.achievement.tutorial.complete") + }) - Achievements.activate(configuration, overlayManager) + achievements.registerAchievement({ + uniqueId: "oni.achievement.padawan", + name: "Padawan", + description: "Complete a level in the interactive tutorial", + goals: [ + { + name: null, + goalId: "oni.achievement.tutorial.complete", + count: 1, + }, + ], + }) + + AllTutorials.forEach((tut: ITutorial) => _tutorialManager.registerTutorial(tut)) + + commandManager.registerCommand({ + command: "experimental.tutorial.start", + name: null, + detail: null, + execute: () => _tutorialManager.startTutorial(null), + }) } + +export const getTutorialManagerInstance = () => _tutorialManager diff --git a/browser/src/Services/Menu/Menu.less b/browser/src/Services/Menu/Menu.less index ae581934c7..43676862e1 100644 --- a/browser/src/Services/Menu/Menu.less +++ b/browser/src/Services/Menu/Menu.less @@ -61,22 +61,15 @@ .label { margin: 4px; padding-right: 8px; - - .highlight { - font-weight: bold; - text-decoration: underline; - } } .detail { font-size: @font-size-small; color: @text-color-detail; flex: 1 1 auto; - - .highlight { - font-weight: bold; - text-decoration: underline; - } + overflow: hidden; + text-overflow: ellipsis; + padding-right: 8px; } } } diff --git a/browser/src/Services/Menu/MenuComponent.tsx b/browser/src/Services/Menu/MenuComponent.tsx index fdef1dc628..6a4545356c 100644 --- a/browser/src/Services/Menu/MenuComponent.tsx +++ b/browser/src/Services/Menu/MenuComponent.tsx @@ -2,12 +2,11 @@ import * as React from "react" import * as ReactDOM from "react-dom" import { connect, Provider } from "react-redux" -import styled from "styled-components" - import { AutoSizer, List } from "react-virtualized" import * as Oni from "oni-api" +import { styled } from "../../UI/components/common" import { HighlightTextByIndex } from "./../../UI/components/HighlightText" // import { Visible } from "./../../UI/components/Visible" import { Icon, IconSize } from "./../../UI/Icon" @@ -256,16 +255,26 @@ export class MenuItem extends React.PureComponent { className="label" text={this.props.label} highlightIndices={this.props.labelHighlights} - highlightClassName={"highlight"} + highlightComponent={LabelHighlight} /> {this.props.additionalComponent} ) } } + +const LabelHighlight = styled.span` + font-weight: bold; + color: ${props => props.theme["highlight.mode.normal.background"]}; +` + +const DetailHighlight = styled.span` + font-weight: bold; + color: #757575; +` diff --git a/browser/src/Services/MultiProcess.ts b/browser/src/Services/MultiProcess.ts index f65d26c37d..8ca66f0bed 100644 --- a/browser/src/Services/MultiProcess.ts +++ b/browser/src/Services/MultiProcess.ts @@ -5,6 +5,7 @@ */ import { ipcRenderer } from "electron" +import { WindowManager } from "./WindowManager" export class MultiProcess { public focusPreviousInstance(): void { @@ -14,6 +15,18 @@ export class MultiProcess { public focusNextInstance(): void { ipcRenderer.send("focus-next-instance") } + + public moveToNextOniInstance(direction: string): void { + ipcRenderer.send("move-to-next-oni-instance", direction) + } +} + +export const activate = (windowManager: WindowManager): void => { + // Wire up accepting unhandled moves to swap to the next + // available Oni instance. + windowManager.onUnhandledMove.subscribe((direction: string) => { + multiProcess.moveToNextOniInstance(direction) + }) } export const multiProcess = new MultiProcess() diff --git a/browser/src/Services/Notifications/Notification.ts b/browser/src/Services/Notifications/Notification.ts index 33ae77cd7b..175a96311e 100644 --- a/browser/src/Services/Notifications/Notification.ts +++ b/browser/src/Services/Notifications/Notification.ts @@ -8,11 +8,12 @@ import { Store } from "redux" import { Event, IEvent } from "oni-types" -import { INotificationsState, NotificationLevel } from "./NotificationStore" +import { INotificationButton, INotificationsState, NotificationLevel } from "./NotificationStore" export class Notification { private _title: string = "" private _detail: string = "" + private _buttons: INotificationButton[] private _expirationTime: number private _level: NotificationLevel = "info" @@ -34,6 +35,13 @@ export class Notification { this._detail = detail } + public setButtons(buttons: INotificationButton[]) { + // only set valid values + if (buttons && buttons.every(b => !!(b.title && b.callback))) { + this._buttons = buttons + } + } + public setLevel(level: NotificationLevel): void { this._level = level } @@ -50,6 +58,7 @@ export class Notification { id: this._id, title: this._title, detail: this._detail, + buttons: this._buttons, level: this._level, expirationTime: this._expirationTime, onClick: () => { diff --git a/browser/src/Services/Notifications/NotificationStore.ts b/browser/src/Services/Notifications/NotificationStore.ts index 871dbc8cd9..01264045f6 100644 --- a/browser/src/Services/Notifications/NotificationStore.ts +++ b/browser/src/Services/Notifications/NotificationStore.ts @@ -11,6 +11,11 @@ import { createStore as createReduxStore } from "./../../Redux" export type NotificationLevel = "info" | "warn" | "error" | "success" +export interface INotificationButton { + title: string + callback: (args?: any) => void +} + export interface IdToNotification { [key: string]: INotification } @@ -29,6 +34,7 @@ export interface INotification { title: string detail: string expirationTime: number + buttons?: INotificationButton[] onClick: () => void onClose: () => void } @@ -37,6 +43,7 @@ interface IShowNotification { type: "SHOW_NOTIFICATION" id: string level: NotificationLevel + buttons: INotificationButton[] title: string detail: string expirationTime: number @@ -64,6 +71,7 @@ export const notificationsReducer: Reducer = ( level: action.level, title: action.title, detail: action.detail, + buttons: action.buttons, onClick: action.onClick, onClose: action.onClose, expirationTime: action.expirationTime, diff --git a/browser/src/Services/Notifications/NotificationsView.tsx b/browser/src/Services/Notifications/NotificationsView.tsx index 8484e8659e..6f1d456f5c 100644 --- a/browser/src/Services/Notifications/NotificationsView.tsx +++ b/browser/src/Services/Notifications/NotificationsView.tsx @@ -10,9 +10,14 @@ import { connect, Provider } from "react-redux" import { CSSTransition, TransitionGroup } from "react-transition-group" -import { INotification, INotificationsState, NotificationLevel } from "./NotificationStore" - -import { boxShadow, keyframes, styled, withProps } from "./../../UI/components/common" +import { + INotification, + INotificationButton, + INotificationsState, + NotificationLevel, +} from "./NotificationStore" + +import { boxShadow, keyframes, lighten, styled, withProps } from "./../../UI/components/common" import { Sneakable } from "./../../UI/components/Sneakable" import { Icon, IconSize } from "./../../UI/Icon" @@ -138,7 +143,7 @@ const NotificationIconWrapper = withProps(styled.div)` } ` -const NotificationContents = styled.div` +export const NotificationContents = styled.div` flex: 1 1 auto; width: 100%; @@ -152,7 +157,7 @@ const NotificationContents = styled.div` overflow-x: hidden; ` -const NotificationTitle = withProps(styled.div)` +export const NotificationTitle = withProps(styled.div)` ${({ level }) => level && `color: ${getColorForErrorLevel(level)};`}; flex: 0 0 auto; width: 100%; @@ -161,7 +166,7 @@ const NotificationTitle = withProps(styled.div)` font-size: 1.1em; ` -const NotificationDescription = styled.div` +export const NotificationDescription = styled.div` flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; @@ -181,6 +186,48 @@ const NotificationHeader = styled.header` padding: 0.5rem; ` +const ButtonRow = styled.div` + width: 100%; + height: 10%; + display: flex; + justify-content: flex-end; +` + +export const Button = styled.button` + border: none; + cursor: pointer; + text-align: center; + overflow: hidden; + min-width: 5em; + min-height: 2em; + border-radius: 4px; + font-size: 0.9em; + font-family: inherit; + display: inline-block; + margin: 0 0.5em; + ${boxShadow}; + ${({ theme }) => ` + background-color: ${lighten(theme["editor.background"], 0.25)}; + color: ${theme["editor.foreground"]}; + `}; +` + +interface IButtonProps { + buttons: INotificationButton[] +} + +const Buttons = ({ buttons }: IButtonProps) => { + return ( + + {buttons.map(({ callback, title }) => ( + + + + ))} + + ) +} + export class NotificationView extends React.PureComponent { private iconDictionary = { error: "times-circle", @@ -190,7 +237,7 @@ export class NotificationView extends React.PureComponent { } public render(): JSX.Element { - const { level } = this.props + const { level, buttons } = this.props return ( { {this.props.detail} + {buttons && } ) } diff --git a/browser/src/Services/Particles/ParticleSystem.tsx b/browser/src/Services/Particles/ParticleSystem.tsx new file mode 100644 index 0000000000..3687c7834f --- /dev/null +++ b/browser/src/Services/Particles/ParticleSystem.tsx @@ -0,0 +1,189 @@ +/** + * ParticleSystem.tsx + * + * Lightweight, canvas-based particle system + * + * TODO: + * - Move this to a plugin, and access via the `getPlugin` API + */ + +import * as React from "react" + +import styled from "styled-components" + +import { Overlay, OverlayManager } from "./../Overlay" + +export interface Vector { + x: number + y: number +} + +export interface ParticleSystemDefinition { + // StartSize: number + // EndSize: number + + Position: Vector + Velocity: Vector + PositionVariance: Vector + VelocityVariance: Vector + Color: string + + StartOpacity: number + EndOpacity: number + + Gravity: Vector + + Time: number +} + +const ZeroVector = { x: 0, y: 0 } + +export const DefaultParticleSystemDefinition: Partial = { + Color: "white", + StartOpacity: 1, + EndOpacity: 0, + Gravity: { x: 0, y: 500 }, + Time: 1, + Position: ZeroVector, + Velocity: ZeroVector, + PositionVariance: ZeroVector, + VelocityVariance: ZeroVector, +} + +export interface Particle { + position: Vector + opacity: number + color: string + + velocity: Vector + opacityVelocity: number + gravity: Vector + + remainingTime: number +} + +const StyledCanvas = styled.canvas` + width: 100%; + height: 100%; +` + +/** + * Lightweight canvas-based particle system renderer + */ +export class ParticleSystem { + private _activeParticles: Particle[] = [] + private _activeOverlay: Overlay + private _activeCanvas: HTMLCanvasElement + + private _lastTime: number + private _enabled: boolean = false + + constructor(private _overlayManager: OverlayManager) {} + + public get enabled(): boolean { + return this._enabled + } + public set enabled(val: boolean) { + this._enabled = val + } + + public createParticles(count: number, particleSystem: Partial): void { + const newParticles: Particle[] = [] + + const system = { + ...DefaultParticleSystemDefinition, + ...particleSystem, + } + + for (let i = 0; i < count; i++) { + newParticles.push({ + position: { + x: system.Position.x + (Math.random() - 0.5) * system.PositionVariance.x, + y: system.Position.y + (Math.random() - 0.5) * system.PositionVariance.y, + }, + color: system.Color, + opacity: system.StartOpacity, + velocity: { + x: system.Velocity.x + (Math.random() - 0.5) * system.VelocityVariance.x, + y: system.Velocity.y + (Math.random() - 0.5) * system.VelocityVariance.y, + }, + gravity: system.Gravity, + opacityVelocity: (system.EndOpacity - system.StartOpacity) / system.Time, + remainingTime: system.Time, + }) + } + + this._activeParticles = [...this._activeParticles, ...newParticles] + + if (!this._activeOverlay) { + this._activeOverlay = this._overlayManager.createItem() + } + + this._activeOverlay.show() + this._activeOverlay.setContents( + (this._activeCanvas = elem)} />, + ) + + this._start() + } + + private _start(): void { + this._lastTime = new Date().getTime() + window.requestAnimationFrame(() => { + this._update() + }) + } + + private _update(): void { + const currentTime = new Date().getTime() + const deltaTime = (currentTime - this._lastTime) / 1000 + this._lastTime = currentTime + + const updatedParticles = this._activeParticles.map(p => { + return { + ...p, + position: { + x: p.position.x + p.velocity.x * deltaTime, + y: p.position.y + p.velocity.y * deltaTime, + }, + velocity: { + x: p.velocity.x + p.gravity.x * deltaTime, + y: p.velocity.y + p.gravity.y * deltaTime, + }, + opacity: p.opacity + p.opacityVelocity * deltaTime, + remainingTime: p.remainingTime - deltaTime, + } + }) + + const filteredParticles = updatedParticles.filter(p => p.remainingTime >= 0) + + this._activeParticles = filteredParticles + + this._draw() + + if (this._activeParticles.length > 0) { + window.requestAnimationFrame(() => this._update()) + } else { + if (this._activeOverlay) { + this._activeOverlay.hide() + } + } + } + + private _draw(): void { + if (!this._activeCanvas) { + return + } + + const context = this._activeCanvas.getContext("2d", { alpha: true }) + const width = (this._activeCanvas.width = this._activeCanvas.offsetWidth) + const height = (this._activeCanvas.height = this._activeCanvas.offsetHeight) + context.clearRect(0, 0, width, height) + + this._activeParticles.forEach(p => { + context.fillStyle = p.color + context.globalAlpha = p.opacity + context.fillRect(p.position.x, p.position.y, 2, 2) + }) + } +} diff --git a/browser/src/Services/Particles/index.tsx b/browser/src/Services/Particles/index.tsx new file mode 100644 index 0000000000..21a7101f4f --- /dev/null +++ b/browser/src/Services/Particles/index.tsx @@ -0,0 +1,51 @@ +/** + * index.tsx + * + * Entry point for particle system + */ + +import { CommandManager } from "./../CommandManager" +import { Configuration } from "./../Configuration" +import { EditorManager } from "./../EditorManager" +import { OverlayManager } from "./../Overlay" + +import { ParticleSystem } from "./ParticleSystem" + +export * from "./ParticleSystem" + +let _engine: ParticleSystem = null + +export const activate = ( + commandManager: CommandManager, + configuration: Configuration, + editorManager: EditorManager, + overlay: OverlayManager, +) => { + _engine = new ParticleSystem(overlay) + + if (configuration.getValue("experimental.particles.enabled")) { + _engine.enabled = true + + commandManager.registerCommand({ + command: "debug.particles.test", + name: null, + detail: null, + execute: () => { + _engine.createParticles(25, { + Position: { x: 600, y: 500 }, + PositionVariance: { x: 10, y: 10 }, + Velocity: { x: 0, y: -350 }, + VelocityVariance: { x: 200, y: 50 }, + Color: "white", + StartOpacity: 1, + EndOpacity: 0, + Time: 1, + }) + }, + }) + } +} + +export const getInstance = (): ParticleSystem => { + return _engine +} diff --git a/browser/src/Services/Preview/PreviewBufferLayer.tsx b/browser/src/Services/Preview/PreviewBufferLayer.tsx new file mode 100644 index 0000000000..0f733e6b92 --- /dev/null +++ b/browser/src/Services/Preview/PreviewBufferLayer.tsx @@ -0,0 +1,99 @@ +/** + * PreviewBufferLayer.tsx + * + * Buffer layer for showing preview + */ +import * as React from "react" + +import * as Oni from "oni-api" + +import styled from "styled-components" + +import { withProps } from "./../../UI/components/common" + +import { EditorManager } from "./../EditorManager" +import { IPreviewer, Preview } from "./index" + +const PreviewWrapper = withProps<{}>(styled.div)` + position: absolute; + top: 0px; + left: 0px; + right: 0px; + bottom: 0px; + background-color: ${p => p.theme["editor.background"]}; + color: ${p => p.theme["editor.foreground"]}; + + display: flex; + justify-content: center; + align-items: center; +` + +export class PreviewBufferLayer implements Oni.BufferLayer { + constructor(private _editorManager: EditorManager, private _preview: Preview) {} + + public get id(): string { + return "oni.layer.preview" + } + + public render(): JSX.Element { + return + } +} + +export interface IPreviewViewProps { + editorManager: EditorManager + previewManager: Preview +} + +export interface IPreviewViewState { + filePath: string + language: string + + previewer: IPreviewer +} + +export class PreviewView extends React.PureComponent { + // private _filePath = "E:/oni/lib_test/browser/src/UI/components/Arrow" + + constructor(props: any) { + super(props) + this.state = { + previewer: null, + filePath: null, + language: null, + } + } + + public componentDidMount(): void { + const currentBuffer = this.props.editorManager.activeEditor.activeBuffer + + if (currentBuffer) { + const currentPreviewer = this.props.previewManager.getPreviewer(currentBuffer.language) + this.setState({ + previewer: currentPreviewer, + language: currentBuffer.language, + filePath: currentBuffer.filePath, + }) + } + + this.props.editorManager.anyEditor.onBufferEnter.subscribe(onEnter => { + const previewer = this.props.previewManager.getPreviewer(onEnter.language) + this.setState({ + previewer, + language: onEnter.language, + filePath: onEnter.filePath, + }) + }) + } + + public render(): JSX.Element { + const element = this.state.previewer + ? this.state.previewer.render({ + language: this.state.language, + filePath: this.state.filePath, + }) + : null + + return {element} + } +} diff --git a/browser/src/Services/Preview/index.tsx b/browser/src/Services/Preview/index.tsx new file mode 100644 index 0000000000..7a7ade5c77 --- /dev/null +++ b/browser/src/Services/Preview/index.tsx @@ -0,0 +1,98 @@ +/** + * Preview.tsx + * + * Service for registering live-preview providers + */ + +import * as React from "react" + +import * as Oni from "oni-api" + +import { CallbackCommand, CommandManager } from "./../CommandManager" +import { Configuration } from "./../Configuration" +import { EditorManager } from "./../EditorManager" + +import { PreviewBufferLayer } from "./PreviewBufferLayer" + +export interface PreviewContext { + filePath: string + language: string +} + +export interface IPreviewer { + render(previewContext: PreviewContext): JSX.Element +} + +export interface IdToPreviewer { + [id: string]: IPreviewer +} +export interface LanguageToDefaultPreviewer { + [id: string]: IPreviewer +} + +export class NoopPreviewer { + public render(previewContext: PreviewContext): JSX.Element { + return ( +
    +
    no-op previewer for: {previewContext.filePath}
    +
    language: {previewContext.language}
    +
    + ) + } +} + +export class NullPreviewer { + public render(previewContext: PreviewContext): JSX.Element { + return
    No previewer registered for this filetype
    + } +} + +export class Preview { + // private _previewers: IdToPreviewer = {} + private _defaultPreviewers: LanguageToDefaultPreviewer = {} + + constructor(private _editorManager: EditorManager) { + this.registerDefaultPreviewer("html", new NoopPreviewer()) + } + + public async openPreviewPane( + openMode: Oni.FileOpenMode = Oni.FileOpenMode.VerticalSplit, + ): Promise { + const activeEditor: any = this._editorManager.activeEditor + const buf = await activeEditor.openFile("PREVIEW", { openMode }) + buf.addLayer(new PreviewBufferLayer(this._editorManager, this)) + } + + public registerDefaultPreviewer(language: string, previewer: IPreviewer): void { + this._defaultPreviewers[language] = previewer + } + + public getPreviewer(language: string): IPreviewer { + if (this._defaultPreviewers[language]) { + return this._defaultPreviewers[language] + } else { + return new NullPreviewer() + } + } +} + +let _preview: Preview + +export const activate = ( + commandManager: CommandManager, + configuration: Configuration, + editorManager: EditorManager, +) => { + _preview = new Preview(editorManager) + + if (configuration.getValue("experimental.preview.enabled")) { + commandManager.registerCommand( + new CallbackCommand( + "preview.open", + "Preview: Open in Vertical Split", + "Open preview pane in a vertical split", + () => _preview.openPreviewPane(Oni.FileOpenMode.VerticalSplit), + ), + ) + } +} diff --git a/browser/src/Services/QuickOpen/QuickOpen.ts b/browser/src/Services/QuickOpen/QuickOpen.ts index 3e9a296b35..20c2a3f386 100644 --- a/browser/src/Services/QuickOpen/QuickOpen.ts +++ b/browser/src/Services/QuickOpen/QuickOpen.ts @@ -191,6 +191,10 @@ export class QuickOpen { selectedOption: Oni.Menu.MenuOption, openInSplit: Oni.FileOpenMode = Oni.FileOpenMode.Edit, ): Promise { + if (!selectedOption) { + return + } + const arg = selectedOption if (arg.icon === QuickOpenItem.convertTypeToIcon(QuickOpenType.bookmarkHelp)) { diff --git a/browser/src/Services/QuickOpen/RegExFilter.ts b/browser/src/Services/QuickOpen/RegExFilter.ts index 4c3fc9ffe8..3fa7f0c475 100644 --- a/browser/src/Services/QuickOpen/RegExFilter.ts +++ b/browser/src/Services/QuickOpen/RegExFilter.ts @@ -37,23 +37,27 @@ export const regexFilter = ( searchString = searchString.toLowerCase() } - const filterRegExp = new RegExp(".*" + searchString.split("").join(".*") + ".*") - - const filteredOptions = options.filter(f => { - let textToFilterOn = f.detail + f.label + const listOfSearchTerms = searchString.split(" ").filter(x => x) - if (!isCaseSensitive) { - textToFilterOn = textToFilterOn.toLowerCase() - } + let filteredOptions = options - return textToFilterOn.match(filterRegExp) + listOfSearchTerms.map(searchTerm => { + filteredOptions = processSearchTerm(searchTerm, filteredOptions, isCaseSensitive) }) const ret = filteredOptions.map(fo => { const letterCountDictionary = createLetterCountDictionary(searchString) - const detailHighlights = getHighlightsFromString(fo.detail, letterCountDictionary) - const labelHighlights = getHighlightsFromString(fo.label, letterCountDictionary) + const detailHighlights = getHighlightsFromString( + fo.detail, + letterCountDictionary, + isCaseSensitive, + ) + const labelHighlights = getHighlightsFromString( + fo.label, + letterCountDictionary, + isCaseSensitive, + ) return { ...fo, @@ -65,9 +69,28 @@ export const regexFilter = ( return ret } +export const processSearchTerm = ( + searchString: string, + options: Oni.Menu.MenuOption[], + isCaseSensitive: boolean, +): Oni.Menu.MenuOption[] => { + const filterRegExp = new RegExp(".*" + searchString.split("").join(".*") + ".*") + + return options.filter(f => { + let textToFilterOn = f.detail + f.label + + if (!isCaseSensitive) { + textToFilterOn = textToFilterOn.toLowerCase() + } + + return textToFilterOn.match(filterRegExp) + }) +} + export const getHighlightsFromString = ( text: string, letterCountDictionary: LetterCountDictionary, + isCaseSensitive: boolean = false, ): number[] => { if (!text) { return [] @@ -76,7 +99,7 @@ export const getHighlightsFromString = ( const ret: number[] = [] for (let i = 0; i < text.length; i++) { - const letter = text[i] + const letter = isCaseSensitive ? text[i] : text[i].toLowerCase() const idx = i if (letterCountDictionary[letter] && letterCountDictionary[letter] > 0) { ret.push(idx) diff --git a/browser/src/Services/Search/SearchPaneView.tsx b/browser/src/Services/Search/SearchPaneView.tsx index 292a353f25..158294ba68 100644 --- a/browser/src/Services/Search/SearchPaneView.tsx +++ b/browser/src/Services/Search/SearchPaneView.tsx @@ -14,8 +14,8 @@ export * from "./SearchProvider" import { ISearchOptions } from "./SearchProvider" -import { SearchTextBox } from "./SearchTextBox" import styled from "styled-components" +import { SearchTextBox } from "./SearchTextBox" import { SidebarEmptyPaneView } from "./../../UI/components/SidebarEmptyPaneView" import { VimNavigator } from "./../../UI/components/VimNavigator" @@ -87,11 +87,6 @@ export class SearchPaneView extends React.PureComponent< this._cleanExistingSubscriptions() } - private _cleanExistingSubscriptions(): void { - this._subscriptions.forEach(s => s.dispose()) - this._subscriptions = [] - } - public render(): JSX.Element { if (!this.state.activeWorkspace) { return ( @@ -140,6 +135,11 @@ export class SearchPaneView extends React.PureComponent< ) } + private _cleanExistingSubscriptions(): void { + this._subscriptions.forEach(s => s.dispose()) + this._subscriptions = [] + } + // private _onChangeFilesFilter(val: string): void { // this.setState({ // fileFilter: val, diff --git a/browser/src/Services/Search/SearchResultsSpinnerView.tsx b/browser/src/Services/Search/SearchResultsSpinnerView.tsx index a2391a341a..cad5383db6 100644 --- a/browser/src/Services/Search/SearchResultsSpinnerView.tsx +++ b/browser/src/Services/Search/SearchResultsSpinnerView.tsx @@ -73,11 +73,6 @@ export class SearchResultSpinnerView extends React.PureComponent< this._cleanExistingSubscriptions() } - private _cleanExistingSubscriptions(): void { - this._subscriptions.forEach(s => s.dispose()) - this._subscriptions = [] - } - public render(): JSX.Element { return ( @@ -87,4 +82,9 @@ export class SearchResultSpinnerView extends React.PureComponent< ) } + + private _cleanExistingSubscriptions(): void { + this._subscriptions.forEach(s => s.dispose()) + this._subscriptions = [] + } } diff --git a/browser/src/Services/Search/SearchTextBox.tsx b/browser/src/Services/Search/SearchTextBox.tsx index 54059c3d80..98c039f164 100644 --- a/browser/src/Services/Search/SearchTextBox.tsx +++ b/browser/src/Services/Search/SearchTextBox.tsx @@ -7,8 +7,8 @@ import * as React from "react" import styled from "styled-components" +import { boxShadow, withProps } from "./../../UI/components/common" import { TextInputView } from "./../../UI/components/LightweightText" -import { withProps, boxShadow } from "./../../UI/components/common" export interface ISearchTextBoxProps { isActive: boolean @@ -36,7 +36,7 @@ const SearchTextBoxWrapper = withProps(styled.div)` ? "2px solid " + props.theme["highlight.mode.normal.background"] : "1px solid " + props.theme["editor.foreground"]}; margin: 8px; - background-color: ${props => props.theme["background"]}; + background-color: ${props => props.theme.background}; ${props => (props.isActive ? boxShadow : "")}; diff --git a/browser/src/Services/Search/index.tsx b/browser/src/Services/Search/index.tsx index eb498f8efa..465b56061a 100644 --- a/browser/src/Services/Search/index.tsx +++ b/browser/src/Services/Search/index.tsx @@ -27,8 +27,8 @@ import { RipGrepSearchProvider, } from "./SearchProvider" -import { SearchResultSpinnerView } from "./SearchResultsSpinnerView" import { SearchPaneView } from "./SearchPaneView" +import { SearchResultSpinnerView } from "./SearchResultsSpinnerView" export class SearchPane { private _onEnter = new Event() diff --git a/browser/src/Services/Sidebar/SidebarContentSplit.tsx b/browser/src/Services/Sidebar/SidebarContentSplit.tsx index 2445f74646..7720da8083 100644 --- a/browser/src/Services/Sidebar/SidebarContentSplit.tsx +++ b/browser/src/Services/Sidebar/SidebarContentSplit.tsx @@ -127,6 +127,7 @@ export class SidebarHeaderView extends React.PureComponent(styled.div)` flex: 1 1 auto; overflow-y: auto; + position: relative; ` export class SidebarContentView extends React.PureComponent< diff --git a/browser/src/Services/Sidebar/SidebarStore.ts b/browser/src/Services/Sidebar/SidebarStore.ts index bfcb1c6fa1..c1d73cd655 100644 --- a/browser/src/Services/Sidebar/SidebarStore.ts +++ b/browser/src/Services/Sidebar/SidebarStore.ts @@ -29,6 +29,7 @@ export interface ISidebarEntry { id: string icon: SidebarIcon pane: SidebarPane + hasNotification?: boolean } export interface SidebarPane extends Oni.IWindowSplit { @@ -69,6 +70,15 @@ export class SidebarManager { } } + public setNotification(id: string): void { + if (id) { + this._store.dispatch({ + type: "SET_NOTIFICATION", + id, + }) + } + } + public setActiveEntry(id: string): void { if (id) { this._store.dispatch({ @@ -136,6 +146,10 @@ export type SidebarActions = type: "ADD_ENTRY" entry: ISidebarEntry } + | { + type: "SET_NOTIFICATION" + id: string + } | { type: "ENTER" } @@ -147,37 +161,38 @@ export const sidebarReducer: Reducer = ( state: ISidebarState = DefaultSidebarState, action: SidebarActions, ) => { + const newState = { + ...state, + entries: entriesReducer(state.entries, action), + } + switch (action.type) { case "ENTER": return { - ...state, + ...newState, isActive: true, } case "LEAVE": return { - ...state, + ...newState, isActive: false, } case "SET_ACTIVE_ID": return { - ...state, + ...newState, activeEntryId: action.activeEntryId, } case "ADD_ENTRY": if (!state.activeEntryId) { return { - ...state, + ...newState, activeEntryId: action.entry.pane.id, - entries: entriesReducer(state.entries, action), } } else { - return { - ...state, - entries: entriesReducer(state.entries, action), - } + return newState } default: - return state + return newState } } @@ -188,6 +203,28 @@ export const entriesReducer: Reducer = ( switch (action.type) { case "ADD_ENTRY": return [...state, action.entry] + case "SET_ACTIVE_ID": + return state.map(e => { + if (e.id === action.activeEntryId) { + return { + ...e, + hasNotification: false, + } + } else { + return e + } + }) + case "SET_NOTIFICATION": + return state.map(e => { + if (e.id !== action.id) { + return e + } else { + return { + ...e, + hasNotification: true, + } + } + }) default: return state } diff --git a/browser/src/Services/Sidebar/SidebarView.tsx b/browser/src/Services/Sidebar/SidebarView.tsx index 7f34c80ff5..c43c92feed 100644 --- a/browser/src/Services/Sidebar/SidebarView.tsx +++ b/browser/src/Services/Sidebar/SidebarView.tsx @@ -20,6 +20,7 @@ export interface ISidebarIconProps { active: boolean focused: boolean iconName: string + hasNotification: boolean onClick: () => void } @@ -31,6 +32,7 @@ const EntranceKeyframes = keyframes` ` const SidebarIconWrapper = withProps(styled.div)` + position: relative; display: flex; justify-content: center; align-items: center; @@ -57,6 +59,28 @@ const SidebarIconWrapper = withProps(styled.div)` } ` +const NotificationEnterKeyFrames = keyframes` + 0% { opacity: 0; transform: scale(0.5); translateY(6px); } + 75% { opacity: 0.75; transform: scale(1.25); translateY(2px); } + 100% { opacity: 1; transform: scale(1); translateY(0px); } +` + +const SidebarIconNotification = withProps<{}>(styled.div)` + animation: ${NotificationEnterKeyFrames} 0.35s linear forwards; + animation-delay: 1s; + + opacity: 0; + + position:absolute; + top: 10px; + right: 10px; + width: 0.4rem; + height: 0.4rem; + + background-color: ${p => p.theme["highlight.mode.normal.background"]}; + border-radius: 1rem; +` + const SidebarIconInner = styled.div` margin-top: 16px; margin-bottom: 16px; @@ -64,12 +88,14 @@ const SidebarIconInner = styled.div` export class SidebarIcon extends React.PureComponent { public render(): JSX.Element { + const notification = this.props.hasNotification ? : null return ( + {notification} ) @@ -132,6 +158,7 @@ export class SidebarView extends React.PureComponent { iconName={e.icon} active={isActive} focused={isFocused} + hasNotification={e.hasNotification} onClick={() => this.props.onSelectionChanged(e.id)} /> ) diff --git a/browser/src/Services/Sneak/Sneak.tsx b/browser/src/Services/Sneak/Sneak.tsx index ad2e17a8ff..0d1fa0c08a 100644 --- a/browser/src/Services/Sneak/Sneak.tsx +++ b/browser/src/Services/Sneak/Sneak.tsx @@ -8,7 +8,7 @@ import * as React from "react" import { Provider } from "react-redux" import { Store } from "redux" -import { IDisposable } from "oni-types" +import { Event, IDisposable, IEvent } from "oni-types" import { Overlay, OverlayManager } from "./../Overlay" @@ -21,6 +21,11 @@ export class Sneak { private _activeOverlay: Overlay private _providers: SneakProvider[] = [] private _store: Store + private _onSneakCompleted = new Event() + + public get onSneakCompleted(): IEvent { + return this._onSneakCompleted + } constructor(private _overlayManager: OverlayManager) { this._store = createSneakStore() @@ -42,7 +47,11 @@ export class Sneak { this._activeOverlay = null } - this._store.dispatch({ type: "START" }) + this._store.dispatch({ + type: "START", + width: document.body.offsetWidth, + height: document.body.offsetHeight, + }) this._collectSneakRectangles() this._activeOverlay = this._overlayManager.createItem() @@ -66,6 +75,8 @@ export class Sneak { private _onComplete(sneakInfo: ISneakInfo): void { this.close() sneakInfo.callback() + + this._onSneakCompleted.dispatch(sneakInfo) } private _collectSneakRectangles(): void { diff --git a/browser/src/Services/Sneak/SneakStore.ts b/browser/src/Services/Sneak/SneakStore.ts index 46f89c5c33..9d4370c561 100644 --- a/browser/src/Services/Sneak/SneakStore.ts +++ b/browser/src/Services/Sneak/SneakStore.ts @@ -22,16 +22,22 @@ export interface IAugmentedSneakInfo extends ISneakInfo { export interface ISneakState { isActive: boolean sneaks: IAugmentedSneakInfo[] + width: number + height: number } const DefaultSneakState: ISneakState = { isActive: true, sneaks: [], + width: 0, + height: 0, } export type SneakAction = | { type: "START" + width: number + height: number } | { type: "END" @@ -47,7 +53,11 @@ export const sneakReducer: Reducer = ( ) => { switch (action.type) { case "START": - return DefaultSneakState + return { + ...DefaultSneakState, + width: action.width, + height: action.height, + } case "END": return { ...DefaultSneakState, @@ -58,7 +68,12 @@ export const sneakReducer: Reducer = ( return state } - const newSneaks: IAugmentedSneakInfo[] = action.sneaks.map((sneak, idx) => { + const filteredSneaks = action.sneaks.filter(sneak => { + const { x, y } = sneak.rectangle + return x >= 0 && y >= 0 && x < state.width && y < state.height + }) + + const newSneaks: IAugmentedSneakInfo[] = filteredSneaks.map((sneak, idx) => { return { ...sneak, triggerKeys: getLabelFromIndex(idx + state.sneaks.length), diff --git a/browser/src/Services/Sneak/index.tsx b/browser/src/Services/Sneak/index.tsx index eab33dbaef..095cb6389e 100644 --- a/browser/src/Services/Sneak/index.tsx +++ b/browser/src/Services/Sneak/index.tsx @@ -4,8 +4,12 @@ * Entry point for sneak functionality */ +import { Colors } from "./../Colors" import { CallbackCommand, CommandManager } from "./../CommandManager" +import { Configuration } from "./../Configuration" +import { AchievementsManager } from "./../Learning/Achievements" import { OverlayManager } from "./../Overlay" +import { getInstance as getParticlesInstance } from "./../Particles" import { Sneak } from "./Sneak" @@ -13,7 +17,12 @@ export * from "./SneakStore" let _sneak: Sneak -export const activate = (commandManager: CommandManager, overlayManager: OverlayManager) => { +export const activate = ( + colors: Colors, + commandManager: CommandManager, + configuration: Configuration, + overlayManager: OverlayManager, +) => { _sneak = new Sneak(overlayManager) commandManager.registerCommand( @@ -36,6 +45,53 @@ export const activate = (commandManager: CommandManager, overlayManager: Overlay () => _sneak.isActive, ), ) + + initializeParticles(colors, configuration) +} + +export const registerAchievements = (achievements: AchievementsManager) => { + achievements.registerAchievement({ + uniqueId: "oni.achievement.sneak.1", + name: "Sneaky", + description: "Use the 'sneak' functionality for the first time", + goals: [ + { + name: null, + goalId: "oni.goal.sneak.complete", + count: 1, + }, + ], + }) + + _sneak.onSneakCompleted.subscribe(val => { + achievements.notifyGoal("oni.goal.sneak.complete") + }) +} + +export const initializeParticles = (colors: Colors, configuration: Configuration) => { + const isAnimationEnabled = () => configuration.getValue("ui.animations.enabled") + const getVisualColor = () => colors.getColor("highlight.mode.visual.background") + + _sneak.onSneakCompleted.subscribe(sneak => { + if (!isAnimationEnabled()) { + return + } + const particles = getParticlesInstance() + + if (!particles) { + return + } + + particles.createParticles(15, { + Position: { x: sneak.rectangle.x, y: sneak.rectangle.y }, + PositionVariance: { x: 0, y: 0 }, + Velocity: { x: 0, y: 0 }, + Gravity: { x: 0, y: 300 }, + VelocityVariance: { x: 200, y: 200 }, + Time: 0.2, + Color: getVisualColor(), + }) + }) } export const getInstance = (): Sneak => { diff --git a/browser/src/Services/Snippets/SnippetBufferLayer.tsx b/browser/src/Services/Snippets/SnippetBufferLayer.tsx index 716cfc29f4..6cf7512aa9 100644 --- a/browser/src/Services/Snippets/SnippetBufferLayer.tsx +++ b/browser/src/Services/Snippets/SnippetBufferLayer.tsx @@ -85,9 +85,10 @@ export class SnippetBufferLayerView extends React.PureComponent< constructor(props: ISnippetBufferLayerViewProps) { super(props) + const latestCursorState = props.snippetSession.getLatestCursors() this.state = { - mode: null, - cursors: [], + mode: latestCursorState.mode, + cursors: latestCursorState.cursors, } } diff --git a/browser/src/Services/Snippets/SnippetSession.ts b/browser/src/Services/Snippets/SnippetSession.ts index 853ac85403..5118ad64cd 100644 --- a/browser/src/Services/Snippets/SnippetSession.ts +++ b/browser/src/Services/Snippets/SnippetSession.ts @@ -91,6 +91,11 @@ export class SnippetSession { private _currentPlaceholder: OniSnippetPlaceholder = null + private _lastCursorMovedEvent: IMirrorCursorUpdateEvent = { + mode: null, + cursors: [], + } + public get buffer(): IBuffer { return this._buffer } @@ -161,6 +166,7 @@ export class SnippetSession { } await this.nextPlaceholder() + await this.updateCursorPosition() } public async nextPlaceholder(): Promise { @@ -261,10 +267,16 @@ export class SnippetSession { } }) - this._onCursorMovedEvent.dispatch({ + this._lastCursorMovedEvent = { mode, cursors: cursorPositions, - }) + } + + this._onCursorMovedEvent.dispatch(this._lastCursorMovedEvent) + } + + public getLatestCursors(): IMirrorCursorUpdateEvent { + return this._lastCursorMovedEvent } // Helper method to query the value of the current placeholder, diff --git a/browser/src/Services/Tasks.ts b/browser/src/Services/Tasks.ts index 44536a882a..b5f54d1603 100644 --- a/browser/src/Services/Tasks.ts +++ b/browser/src/Services/Tasks.ts @@ -66,6 +66,10 @@ export class Tasks { } private async _onItemSelected(selectedOption: Oni.Menu.MenuOption): Promise { + if (!selectedOption) { + return + } + const { label, detail } = selectedOption const selectedTask = find(this._lastTasks, t => t.name === label && t.detail === detail) diff --git a/browser/src/Services/UnhandledErrorMonitor.ts b/browser/src/Services/UnhandledErrorMonitor.ts index 1a8700b019..a19933cbff 100644 --- a/browser/src/Services/UnhandledErrorMonitor.ts +++ b/browser/src/Services/UnhandledErrorMonitor.ts @@ -74,7 +74,7 @@ import { remote } from "electron" export const start = (configuration: Configuration, notifications: Notifications) => { const showError = (title: string, errorText: string) => { if (!configuration.getValue("debug.showNotificationOnError")) { - Log.error("Received notification for: " + errorText) + Log.error("Received notification for - " + title + ":" + errorText) return } @@ -91,7 +91,10 @@ export const start = (configuration: Configuration, notifications: Notifications _unhandledErrorMonitor.onUnhandledError.subscribe(val => { const errorText = val ? val.toString() : "Open the debugger for more details." - showError("Unhandled Exception", errorText + "\nPlease report this error.") + showError( + "Unhandled Exception", + errorText + "\nPlease report this error. Callstack: " + val.stack, + ) }) _unhandledErrorMonitor.onUnhandledRejection.subscribe(val => { diff --git a/browser/src/Services/WindowManager/WindowManager.ts b/browser/src/Services/WindowManager/WindowManager.ts index 0671794099..8a0b98b453 100644 --- a/browser/src/Services/WindowManager/WindowManager.ts +++ b/browser/src/Services/WindowManager/WindowManager.ts @@ -8,6 +8,8 @@ * to the active editor, and managing transitions between editors. */ +import { remote } from "electron" + import { Store } from "redux" import * as Oni from "oni-api" @@ -111,6 +113,10 @@ export class WindowManager { private _primarySplit: LinearSplitProvider private _rootNavigator: RelationalSplitNavigator + // Queue of recently focused windows, to fall-back to + // when closing a window. + private _focusQueue: string[] = [] + private _store: Store public get onUnhandledMove(): IEvent { @@ -144,6 +150,20 @@ export class WindowManager { constructor() { this._rootNavigator = new RelationalSplitNavigator() + const browserWindow = remote.getCurrentWindow() + + browserWindow.on("blur", () => { + if (this.activeSplit) { + this.activeSplit.leave() + } + }) + + browserWindow.on("focus", () => { + if (this.activeSplit) { + this.activeSplit.enter() + } + }) + this._store = createStore() this._leftDock = new WindowDockNavigator(() => leftDockSelector(this._store.getState())) this._primarySplit = new LinearSplitProvider("horizontal") @@ -234,7 +254,23 @@ export class WindowManager { this.move("down") } - public close(splitId: any) { + public close(splitId: string) { + const currentActiveSplit = this.activeSplit + + // Send focus back to most recently focused window + if (currentActiveSplit.id === splitId) { + const candidateSplits = this._focusQueue.filter( + f => f !== splitId && this._idToSplit[f], + ) + + this._focusQueue = candidateSplits + + if (this._focusQueue.length > 0) { + const splitToFocus = this._focusQueue[0] + this._focusNewSplit(this._idToSplit[splitToFocus]) + } + } + const split = this._idToSplit[splitId] this._primarySplit.close(split) @@ -244,7 +280,7 @@ export class WindowManager { splits: state, }) - this._idToSplit[splitId] = null + delete this._idToSplit[splitId] } public focusSplit(splitId: string): void { @@ -267,6 +303,9 @@ export class WindowManager { splitId: newSplit.id, }) + const filteredSplits = this._focusQueue.filter(f => f !== newSplit.id) + this._focusQueue = [newSplit.id, ...filteredSplits] + if (newSplit && newSplit.enter) { newSplit.enter() } diff --git a/browser/src/Services/Workspace/Workspace.ts b/browser/src/Services/Workspace/Workspace.ts index 2eaad4b42a..4b72ec599b 100644 --- a/browser/src/Services/Workspace/Workspace.ts +++ b/browser/src/Services/Workspace/Workspace.ts @@ -33,9 +33,7 @@ import { WorkspaceConfiguration } from "./WorkspaceConfiguration" const fsStat = promisify(stat) // Candidate interface to promote to Oni API -export interface IWorkspace extends Oni.Workspace { - activeWorkspace: string - +export interface IWorkspace extends Oni.Workspace.Api { applyEdits(edits: types.WorkspaceEdit): Promise } @@ -132,7 +130,12 @@ export class Workspace implements IWorkspace { public navigateToProjectRoot = async (bufferPath: string) => { const projectMarkers = this._configuration.getValue("workspace.autoDetectRootFiles") - const cwd = path.dirname(bufferPath) + + // If the supplied path is a folder, we should use that instead of + // moving up a folder again. + // Helps when calling Oni from the CLI with "oni ." + const cwd = (await this.pathIsDir(bufferPath)) ? bufferPath : path.dirname(bufferPath) + const filePath = await findup(projectMarkers, { cwd }) if (filePath) { const dir = path.dirname(filePath) diff --git a/browser/src/UI/Icon.tsx b/browser/src/UI/Icon.tsx index d2d576e022..7c85a9d9c4 100644 --- a/browser/src/UI/Icon.tsx +++ b/browser/src/UI/Icon.tsx @@ -6,6 +6,7 @@ export const TwoX = "fa-2x" export const ThreeX = "fa-3x" export const FourX = "fa-4x" export const FiveX = "fa-5x" +export const NineX = "fa-9x" export enum IconSize { Default = 0, @@ -14,20 +15,24 @@ export enum IconSize { ThreeX, FourX, FiveX, + NineX, } export interface IconProps { name: string size?: IconSize className?: string + style?: React.CSSProperties } +const EmptyStyle: React.CSSProperties = {} export class Icon extends React.PureComponent { public render(): JSX.Element { + const style = this.props.style || EmptyStyle const className = "fa fa-" + this.props.name + " " + this._getClassForIconSize(this.props.size as any) // FIXME: undefined const additionalClass = this.props.className || "" - return