Repository: onivim/oni Branch: master Commit: 17ceaa453119 Files: 800 Total size: 4.8 MB Directory structure: gitextract_k5a77194/ ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE.md │ └── config.yml ├── .gitignore ├── .gitmodules ├── .npmignore ├── .nvmrc ├── .oni/ │ ├── config.js │ └── templates/ │ └── UnitTestTemplate.ts.template ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── .vscode/ │ └── launch.json ├── .yarnrc ├── @types/ │ ├── color-normalize/ │ │ └── index.d.ts │ └── font-manager/ │ └── index.d.ts ├── ACCOUNTING.md ├── BACKERS.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── appveyor.yml ├── browser/ │ ├── src/ │ │ ├── App.ts │ │ ├── CSS.ts │ │ ├── Constants.ts │ │ ├── Editor/ │ │ │ ├── BufferHighlights.ts │ │ │ ├── BufferManager.ts │ │ │ ├── Editor.ts │ │ │ ├── NeovimEditor/ │ │ │ │ ├── BufferLayerManager.ts │ │ │ │ ├── CompletionMenu.ts │ │ │ │ ├── Definition.ts │ │ │ │ ├── FileDropHandler.tsx │ │ │ │ ├── HoverRenderer.tsx │ │ │ │ ├── NeovimActiveWindow.tsx │ │ │ │ ├── NeovimBufferLayersView.tsx │ │ │ │ ├── NeovimEditor.tsx │ │ │ │ ├── NeovimEditorActions.ts │ │ │ │ ├── NeovimEditorCommands.ts │ │ │ │ ├── NeovimEditorLoadingOverlay.tsx │ │ │ │ ├── NeovimEditorReducer.ts │ │ │ │ ├── NeovimEditorSelectors.ts │ │ │ │ ├── NeovimEditorStore.ts │ │ │ │ ├── NeovimInput.tsx │ │ │ │ ├── NeovimPopupMenu.tsx │ │ │ │ ├── NeovimRenderer.tsx │ │ │ │ ├── NeovimSurface.tsx │ │ │ │ ├── Rename.tsx │ │ │ │ ├── Symbols.ts │ │ │ │ ├── ToolTipsProvider.ts │ │ │ │ ├── WelcomeBufferLayer.tsx │ │ │ │ ├── index.ts │ │ │ │ └── markdown.ts │ │ │ └── OniEditor/ │ │ │ ├── ColorHighlightLayer.tsx │ │ │ ├── ImageBufferLayer.tsx │ │ │ ├── IndentGuideBufferLayer.tsx │ │ │ ├── OniEditor.tsx │ │ │ ├── containers/ │ │ │ │ ├── BufferScrollBarContainer.ts │ │ │ │ ├── DefinitionContainer.ts │ │ │ │ └── ErrorsContainer.ts │ │ │ └── index.ts │ │ ├── Font.ts │ │ ├── Grid.ts │ │ ├── Input/ │ │ │ ├── KeyBindings.ts │ │ │ ├── KeyParser.ts │ │ │ ├── Keyboard/ │ │ │ │ ├── KeyboardLayout.ts │ │ │ │ ├── KeyboardResolver.ts │ │ │ │ ├── Resolvers.ts │ │ │ │ └── index.ts │ │ │ ├── KeyboardInput.tsx │ │ │ └── Mouse.ts │ │ ├── Performance.ts │ │ ├── PeriodicJobs.ts │ │ ├── PersistentStore.ts │ │ ├── Platform.ts │ │ ├── Plugins/ │ │ │ ├── AnonymousPlugin.ts │ │ │ ├── Api/ │ │ │ │ ├── Capabilities.ts │ │ │ │ ├── LanguageClient/ │ │ │ │ │ ├── LanguageClientHelpers.ts │ │ │ │ │ └── LanguageClientLogger.ts │ │ │ │ ├── Oni.ts │ │ │ │ ├── Process.ts │ │ │ │ ├── Services.ts │ │ │ │ ├── Ui.ts │ │ │ │ └── shell-env.d.ts │ │ │ ├── PackageMetadataParser.ts │ │ │ ├── Plugin.ts │ │ │ ├── PluginConfigurationSynchronizer.ts │ │ │ ├── PluginInstaller.ts │ │ │ ├── PluginManager.ts │ │ │ └── PluginSidebarPane.tsx │ │ ├── Redux/ │ │ │ ├── LoggingMiddleware.ts │ │ │ ├── RequestAnimationFrameNotifyBatcher.ts │ │ │ ├── createStore.ts │ │ │ └── index.ts │ │ ├── Renderer/ │ │ │ ├── CanvasRenderer.ts │ │ │ ├── INeovimRenderer.ts │ │ │ ├── Span.ts │ │ │ ├── WebGLRenderer/ │ │ │ │ ├── SolidRenderer.ts │ │ │ │ ├── TextRenderer/ │ │ │ │ │ ├── GlyphAtlas/ │ │ │ │ │ │ ├── GlyphAtlas.ts │ │ │ │ │ │ ├── IRasterizedGlyph.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── ICellGroup.ts │ │ │ │ │ ├── LigatureGrouper/ │ │ │ │ │ │ ├── ILigatureGrouper.ts │ │ │ │ │ │ ├── NoopLigatureGrouper.ts │ │ │ │ │ │ ├── OpenTypeLigatureGrouper.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TextRenderer.ts │ │ │ │ │ ├── groupCells.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── WebGLRenderer.ts │ │ │ │ ├── WebGLUtilities.ts │ │ │ │ ├── index.ts │ │ │ │ └── normalizeColor.ts │ │ │ └── index.ts │ │ ├── Services/ │ │ │ ├── AutoClosingPairs.ts │ │ │ ├── AutoUpdate.ts │ │ │ ├── Automation.ts │ │ │ ├── Bookmarks/ │ │ │ │ ├── BookmarksPane.tsx │ │ │ │ └── index.ts │ │ │ ├── Browser/ │ │ │ │ ├── AddressBarView.tsx │ │ │ │ ├── BrowserButtonView.tsx │ │ │ │ ├── BrowserView.tsx │ │ │ │ └── index.tsx │ │ │ ├── BrowserWindowConfigurationSynchronizer.ts │ │ │ ├── Colors.ts │ │ │ ├── CommandManager.ts │ │ │ ├── Commands/ │ │ │ │ ├── GlobalCommands.ts │ │ │ │ └── index.ts │ │ │ ├── Completion/ │ │ │ │ ├── Completion.ts │ │ │ │ ├── CompletionProviders.ts │ │ │ │ ├── CompletionSelectors.ts │ │ │ │ ├── CompletionState.ts │ │ │ │ ├── CompletionStore.ts │ │ │ │ ├── CompletionUtility.ts │ │ │ │ ├── CompletionsRequestor.ts │ │ │ │ └── index.ts │ │ │ ├── Configuration/ │ │ │ │ ├── Configuration.ts │ │ │ │ ├── ConfigurationCommands.ts │ │ │ │ ├── ConfigurationEditor.ts │ │ │ │ ├── DefaultConfiguration.ts │ │ │ │ ├── DeprecatedConfigurationValues.ts │ │ │ │ ├── FileConfigurationProvider.ts │ │ │ │ ├── IConfigurationValues.ts │ │ │ │ ├── PersistentSettings.ts │ │ │ │ ├── ReasonConfiguration.ts │ │ │ │ ├── UserConfiguration.ts │ │ │ │ └── index.ts │ │ │ ├── ContextMenu/ │ │ │ │ ├── ContextMenu.tsx │ │ │ │ ├── ContextMenuComponent.tsx │ │ │ │ └── index.ts │ │ │ ├── Debug.ts │ │ │ ├── Diagnostics/ │ │ │ │ ├── index.ts │ │ │ │ └── navigateErrors.ts │ │ │ ├── DragAndDrop.tsx │ │ │ ├── EditorManager.ts │ │ │ ├── Explorer/ │ │ │ │ ├── ExplorerFileSystem.ts │ │ │ │ ├── ExplorerSelectors.ts │ │ │ │ ├── ExplorerSplit.tsx │ │ │ │ ├── ExplorerStore.ts │ │ │ │ ├── ExplorerView.tsx │ │ │ │ └── index.tsx │ │ │ ├── FileIcon.tsx │ │ │ ├── FileMappings.ts │ │ │ ├── FileSystemWatcher/ │ │ │ │ └── index.ts │ │ │ ├── FocusManager.ts │ │ │ ├── IconThemes/ │ │ │ │ ├── IconThemeLoader.ts │ │ │ │ ├── Icons.ts │ │ │ │ ├── StyleWriter.ts │ │ │ │ └── index.ts │ │ │ ├── InputManager.ts │ │ │ ├── KeyDisplayer/ │ │ │ │ ├── KeyDisplayer.tsx │ │ │ │ ├── KeyDisplayerStore.ts │ │ │ │ ├── KeyDisplayerView.tsx │ │ │ │ └── index.tsx │ │ │ ├── Language/ │ │ │ │ ├── CodeAction.ts │ │ │ │ ├── DefinitionRequestor.ts │ │ │ │ ├── Edits.ts │ │ │ │ ├── FindAllReferences.ts │ │ │ │ ├── Formatting.ts │ │ │ │ ├── HoverRequestor.ts │ │ │ │ ├── LanguageClient.ts │ │ │ │ ├── LanguageClientProcess.ts │ │ │ │ ├── LanguageClientStatusBar.tsx │ │ │ │ ├── LanguageClientTypes.ts │ │ │ │ ├── LanguageConfiguration.ts │ │ │ │ ├── LanguageEditorIntegration.ts │ │ │ │ ├── LanguageManager.ts │ │ │ │ ├── LanguageStore.ts │ │ │ │ ├── PromiseQueue.ts │ │ │ │ ├── RenameView.tsx │ │ │ │ ├── ServerCapabilities.ts │ │ │ │ ├── SignatureHelp.ts │ │ │ │ ├── SignatureHelpView.tsx │ │ │ │ ├── Workspace.ts │ │ │ │ ├── addInsertModeLanguageFunctionality.ts │ │ │ │ └── index.ts │ │ │ ├── Learning/ │ │ │ │ ├── Achievements/ │ │ │ │ │ ├── AchievementNotificationRenderer.tsx │ │ │ │ │ ├── AchievementsBufferLayer.tsx │ │ │ │ │ ├── AchievementsManager.ts │ │ │ │ │ └── index.tsx │ │ │ │ ├── LearningPane.tsx │ │ │ │ ├── Tutorial/ │ │ │ │ │ ├── CompletionView.tsx │ │ │ │ │ ├── GameplayBufferLayer.tsx │ │ │ │ │ ├── GoalView.tsx │ │ │ │ │ ├── ITutorial.ts │ │ │ │ │ ├── Notes.tsx │ │ │ │ │ ├── Stages/ │ │ │ │ │ │ ├── CompositeStage.tsx │ │ │ │ │ │ ├── CorrectLineStage.tsx │ │ │ │ │ │ ├── DeleteCharactersStage.tsx │ │ │ │ │ │ ├── FadeInLineStage.tsx │ │ │ │ │ │ ├── InitializeBufferStage.tsx │ │ │ │ │ │ ├── MoveToGoalStage.tsx │ │ │ │ │ │ ├── SetBufferStage.tsx │ │ │ │ │ │ ├── SetCursorPositionStage.tsx │ │ │ │ │ │ ├── WaitForModeStage.tsx │ │ │ │ │ │ ├── WaitForRegisterStage.tsx │ │ │ │ │ │ ├── WaitForStateStage.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── TutorialBufferLayer.tsx │ │ │ │ │ ├── TutorialGameplayManager.ts │ │ │ │ │ ├── TutorialManager.ts │ │ │ │ │ ├── Tutorials/ │ │ │ │ │ │ ├── BasicMovementTutorial.tsx │ │ │ │ │ │ ├── BeginningsAndEndingsTutorial.tsx │ │ │ │ │ │ ├── ChangeOperatorTutorial.tsx │ │ │ │ │ │ ├── CopyPasteTutorial.tsx │ │ │ │ │ │ ├── DeleteCharacterTutorial.tsx │ │ │ │ │ │ ├── DeleteOperatorTutorial.tsx │ │ │ │ │ │ ├── DotCommandTutorial.tsx │ │ │ │ │ │ ├── InlineFindingTutorial.tsx │ │ │ │ │ │ ├── InsertAndUndoTutorial.tsx │ │ │ │ │ │ ├── SearchInBufferTutorial.tsx │ │ │ │ │ │ ├── SwitchModeTutorial.tsx │ │ │ │ │ │ ├── TargetsVimPluginTutorial.tsx │ │ │ │ │ │ ├── TextObjectsTutorial.tsx │ │ │ │ │ │ ├── VerticalMovementTutorial.tsx │ │ │ │ │ │ ├── VisualModeTutorial.tsx │ │ │ │ │ │ ├── WordMotionTutorial.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── Menu/ │ │ │ │ ├── Filter/ │ │ │ │ │ ├── FuseFilter.ts │ │ │ │ │ ├── NoFilter.ts │ │ │ │ │ ├── RegExFilter.ts │ │ │ │ │ ├── Utils.ts │ │ │ │ │ ├── VSCodeFilter.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── Menu.less │ │ │ │ ├── Menu.ts │ │ │ │ ├── MenuActionCreators.ts │ │ │ │ ├── MenuActions.ts │ │ │ │ ├── MenuComponent.tsx │ │ │ │ ├── MenuReducer.ts │ │ │ │ ├── MenuState.ts │ │ │ │ ├── PinnedIconView.tsx │ │ │ │ └── index.ts │ │ │ ├── Metadata.ts │ │ │ ├── MultiProcess.ts │ │ │ ├── Notifications/ │ │ │ │ ├── Notification.ts │ │ │ │ ├── NotificationStore.ts │ │ │ │ ├── Notifications.ts │ │ │ │ ├── NotificationsView.tsx │ │ │ │ └── index.ts │ │ │ ├── Overlay.ts │ │ │ ├── Particles/ │ │ │ │ ├── ParticleSystem.tsx │ │ │ │ └── index.tsx │ │ │ ├── Preview/ │ │ │ │ ├── PreviewBufferLayer.tsx │ │ │ │ └── index.tsx │ │ │ ├── Recorder.ts │ │ │ ├── Search/ │ │ │ │ ├── FinderProcess.ts │ │ │ │ ├── RipGrep.ts │ │ │ │ ├── Scorer/ │ │ │ │ │ ├── CharCode.ts │ │ │ │ │ ├── Comparers.ts │ │ │ │ │ ├── OniQuickOpenScorer.ts │ │ │ │ │ ├── QuickOpenScorer.ts │ │ │ │ │ ├── Utilities.ts │ │ │ │ │ ├── filters.ts │ │ │ │ │ └── strings.ts │ │ │ │ ├── SearchPaneView.tsx │ │ │ │ ├── SearchProvider.ts │ │ │ │ ├── SearchResultsSpinnerView.tsx │ │ │ │ ├── SearchTextBox.tsx │ │ │ │ └── index.tsx │ │ │ ├── Sessions/ │ │ │ │ ├── SessionManager.ts │ │ │ │ ├── Sessions.tsx │ │ │ │ ├── SessionsPane.tsx │ │ │ │ ├── SessionsStore.ts │ │ │ │ └── index.ts │ │ │ ├── Sidebar/ │ │ │ │ ├── SidebarContentSplit.tsx │ │ │ │ ├── SidebarSplit.tsx │ │ │ │ ├── SidebarStore.ts │ │ │ │ ├── SidebarView.tsx │ │ │ │ └── index.ts │ │ │ ├── Sneak/ │ │ │ │ ├── Sneak.tsx │ │ │ │ ├── SneakStore.ts │ │ │ │ ├── SneakView.tsx │ │ │ │ └── index.tsx │ │ │ ├── Snippets/ │ │ │ │ ├── OniSnippet.ts │ │ │ │ ├── SnippetBufferLayer.tsx │ │ │ │ ├── SnippetCompletionProvider.ts │ │ │ │ ├── SnippetManager.ts │ │ │ │ ├── SnippetProvider.ts │ │ │ │ ├── SnippetSession.ts │ │ │ │ ├── SnippetVariableResolver.ts │ │ │ │ ├── UserSnippetProvider.ts │ │ │ │ └── index.ts │ │ │ ├── StatusBar.ts │ │ │ ├── SyntaxHighlighting/ │ │ │ │ ├── Definitions.ts │ │ │ │ ├── GrammarLoader.ts │ │ │ │ ├── ISyntaxHighlighter.ts │ │ │ │ ├── SyntaxHighlightReconciler.ts │ │ │ │ ├── SyntaxHighlightSelectors.ts │ │ │ │ ├── SyntaxHighlighting.ts │ │ │ │ ├── SyntaxHighlightingPeriodicJob.ts │ │ │ │ ├── SyntaxHighlightingReducer.ts │ │ │ │ ├── SyntaxHighlightingStore.ts │ │ │ │ ├── TokenGenerator.tsx │ │ │ │ ├── TokenScorer.ts │ │ │ │ ├── TokenThemeProvider.tsx │ │ │ │ └── index.ts │ │ │ ├── Tasks.ts │ │ │ ├── Terminal.ts │ │ │ ├── Themes/ │ │ │ │ ├── ThemeLoader.ts │ │ │ │ ├── ThemeManager.ts │ │ │ │ ├── ThemePicker.ts │ │ │ │ └── index.ts │ │ │ ├── TokenColors.ts │ │ │ ├── TypingPredictionManager.ts │ │ │ ├── UnhandledErrorMonitor.ts │ │ │ ├── VersionControl/ │ │ │ │ ├── VersionControlBlameLayer.tsx │ │ │ │ ├── VersionControlManager.tsx │ │ │ │ ├── VersionControlPane.tsx │ │ │ │ ├── VersionControlProvider.ts │ │ │ │ ├── VersionControlStore.ts │ │ │ │ ├── VersionControlView.tsx │ │ │ │ └── index.ts │ │ │ ├── VimConfigurationSynchronizer.ts │ │ │ ├── WindowManager/ │ │ │ │ ├── LinearSplitProvider.ts │ │ │ │ ├── RelationalSplitNavigator.ts │ │ │ │ ├── SingleSplitProvider.ts │ │ │ │ ├── WindowDock.ts │ │ │ │ ├── WindowManager.ts │ │ │ │ ├── WindowManagerStore.ts │ │ │ │ ├── index.ts │ │ │ │ └── layoutFromSplitInfo.ts │ │ │ └── Workspace/ │ │ │ ├── Workspace.ts │ │ │ ├── WorkspaceCommands.ts │ │ │ ├── WorkspaceConfiguration.ts │ │ │ ├── find-up.d.ts │ │ │ └── index.ts │ │ ├── UI/ │ │ │ ├── Icon.tsx │ │ │ ├── Shell/ │ │ │ │ ├── OverlayView.tsx │ │ │ │ ├── Shell.tsx │ │ │ │ ├── ShellActionCreators.ts │ │ │ │ ├── ShellActions.ts │ │ │ │ ├── ShellReducer.ts │ │ │ │ ├── ShellState.ts │ │ │ │ ├── ShellView.tsx │ │ │ │ └── index.ts │ │ │ └── components/ │ │ │ ├── Arrow.less │ │ │ ├── Arrow.tsx │ │ │ ├── Background.tsx │ │ │ ├── BufferLayerHeader.tsx │ │ │ ├── BufferScrollBar.tsx │ │ │ ├── Caret.tsx │ │ │ ├── CodeActions.tsx │ │ │ ├── CommandLine.tsx │ │ │ ├── Cursor.tsx │ │ │ ├── CursorLine.tsx │ │ │ ├── CursorPositioner.tsx │ │ │ ├── Definition.tsx │ │ │ ├── Error.tsx │ │ │ ├── ErrorInfo.tsx │ │ │ ├── ExternalMenus.tsx │ │ │ ├── FlipCard.tsx │ │ │ ├── HighlightText.tsx │ │ │ ├── InstallHelp.less │ │ │ ├── InstallHelp.tsx │ │ │ ├── KeyBindingInfo.tsx │ │ │ ├── LightweightText.tsx │ │ │ ├── Loading.tsx │ │ │ ├── LoadingSpinner.tsx │ │ │ ├── Octicon.tsx │ │ │ ├── PureComponentWithDisposeTracking.tsx │ │ │ ├── QuickInfo.tsx │ │ │ ├── QuickInfoContainer.tsx │ │ │ ├── RedErrorScreen.tsx │ │ │ ├── SectionTitle.tsx │ │ │ ├── SidebarButton.tsx │ │ │ ├── SidebarEmptyPaneView.tsx │ │ │ ├── SidebarItemView.tsx │ │ │ ├── Sneakable.tsx │ │ │ ├── StatusBar.tsx │ │ │ ├── StatusResize.tsx │ │ │ ├── Tabs.tsx │ │ │ ├── Text.tsx │ │ │ ├── ToolTip.tsx │ │ │ ├── VersionControl/ │ │ │ │ ├── Branch.tsx │ │ │ │ ├── CommitMessage.tsx │ │ │ │ ├── Commits.tsx │ │ │ │ ├── File.tsx │ │ │ │ ├── Help.tsx │ │ │ │ ├── Staged.tsx │ │ │ │ └── Status.tsx │ │ │ ├── VimNavigator.tsx │ │ │ ├── Visible.tsx │ │ │ ├── WildMenu.tsx │ │ │ ├── WindowSplitHost.tsx │ │ │ ├── WindowSplits.tsx │ │ │ ├── WindowTitle.tsx │ │ │ ├── WithWidth.tsx │ │ │ ├── animations.ts │ │ │ ├── common.less │ │ │ └── common.ts │ │ ├── Utility.ts │ │ ├── index.tsx │ │ ├── neovim/ │ │ │ ├── CommandContext.ts │ │ │ ├── EventContext.ts │ │ │ ├── MsgPack.ts │ │ │ ├── NeovimAutoCommands.ts │ │ │ ├── NeovimBufferUpdateManager.ts │ │ │ ├── NeovimInstance.ts │ │ │ ├── NeovimMarks.ts │ │ │ ├── NeovimProcessSpawner.ts │ │ │ ├── NeovimTokenColorSynchronizer.ts │ │ │ ├── NeovimWindowManager.ts │ │ │ ├── QuickFix.ts │ │ │ ├── Screen.ts │ │ │ ├── ScreenWithPredictions.ts │ │ │ ├── Session.ts │ │ │ ├── SharedNeovimInstance.ts │ │ │ ├── VimHighlights.ts │ │ │ ├── actions.ts │ │ │ └── index.ts │ │ ├── neovim-client.d.ts │ │ ├── overlay.less │ │ ├── startEditors.ts │ │ ├── sudo-prompt.d.ts │ │ └── units-css.d.ts │ ├── test/ │ │ ├── AppTests.ts │ │ ├── Editor/ │ │ │ └── NeovimEditor/ │ │ │ ├── BufferStateTests.ts │ │ │ ├── NeovimEditorReducerTests.ts │ │ │ └── SymbolsTests.ts │ │ ├── GridTests.ts │ │ ├── Input/ │ │ │ ├── InputManagerTests.ts │ │ │ ├── KeyParserTests.ts │ │ │ └── Keyboard/ │ │ │ └── ResolverTests.ts │ │ ├── MarkdownTests.ts │ │ ├── Mocks/ │ │ │ ├── MockBuffer.ts │ │ │ ├── MockPersistentStore.ts │ │ │ ├── MockPluginManager.ts │ │ │ ├── MockThemeLoader.ts │ │ │ ├── index.ts │ │ │ ├── neovim/ │ │ │ │ └── MockNeovimInstance.ts │ │ │ └── neovim.ts │ │ ├── Plugins/ │ │ │ └── Api/ │ │ │ └── ProcessTests.ts │ │ ├── Renderer/ │ │ │ └── SpanTests.ts │ │ ├── Services/ │ │ │ ├── AutoClosingPairsTests.ts │ │ │ ├── Completion/ │ │ │ │ ├── CompletionProvidersTests.ts │ │ │ │ ├── CompletionSelectorsTests.ts │ │ │ │ ├── CompletionStoreTests.ts │ │ │ │ ├── CompletionTests.ts │ │ │ │ ├── CompletionUtilityTests.ts │ │ │ │ └── CompletionsRequestorTests.ts │ │ │ ├── Configuration/ │ │ │ │ ├── ConfigurationTests.ts │ │ │ │ └── FileConfigurationProviderTests.ts │ │ │ ├── Explorer/ │ │ │ │ ├── ExplorerFileSystemTests.ts │ │ │ │ ├── ExplorerSelectorsTests.ts │ │ │ │ └── ExplorerStoreTests.ts │ │ │ ├── FileMappingsTests.ts │ │ │ ├── Language/ │ │ │ │ ├── EditTests.ts │ │ │ │ ├── LanguageEditorIntegrationTests.ts │ │ │ │ └── LanguageManagerTests.ts │ │ │ ├── Learning/ │ │ │ │ ├── Achievements/ │ │ │ │ │ └── AchievementsManagerTests.ts │ │ │ │ └── Tutorial/ │ │ │ │ ├── TutorialGameplayManagerTests.ts │ │ │ │ └── TutorialManagerTests.ts │ │ │ ├── Menu/ │ │ │ │ └── MenuReducerTests.ts │ │ │ ├── Notifications/ │ │ │ │ └── NotificationStoreTests.ts │ │ │ ├── QuickOpen/ │ │ │ │ ├── FinderProcessTests.ts │ │ │ │ ├── RegExFilterTests.ts │ │ │ │ └── VSCodeFilterTests.ts │ │ │ ├── Sneak/ │ │ │ │ └── SneakStoreTests.ts │ │ │ ├── Snippets/ │ │ │ │ ├── OniSnippetTests.ts │ │ │ │ ├── SnippetCompletionProviderTests.ts │ │ │ │ ├── SnippetProviderTests.ts │ │ │ │ ├── SnippetSessionTests.ts │ │ │ │ └── SnippetVariableResolverTests.ts │ │ │ ├── SyntaxHighlighting/ │ │ │ │ ├── SyntaxHighlightingReconcilerTests.ts │ │ │ │ └── SyntaxHighlightingReducerTests.ts │ │ │ ├── TokenColorsTests.ts │ │ │ ├── TypingPredictionManagerTests.ts │ │ │ ├── WindowManager/ │ │ │ │ ├── LinearSplitProviderTests.ts │ │ │ │ ├── RelationalSplitNavigatorTests.ts.ts │ │ │ │ ├── WindowManagerTests.ts │ │ │ │ └── layoutFromSplitInfoTests.ts │ │ │ └── Workspace/ │ │ │ └── WorkspaceConfigurationTests.ts │ │ ├── Tabs/ │ │ │ └── TabsTest.tsx │ │ ├── TestHelpers.ts │ │ ├── UtilityTests.ts │ │ └── neovim/ │ │ ├── NeovimBufferUpdateManagerTests.ts │ │ ├── NeovimMarksTests.ts │ │ ├── NeovimTokenColorSynchronizerTests.ts │ │ └── ScreenWithPredictionsTest.ts │ ├── testCoverageReporter.js │ ├── testHelpers.js │ ├── tsconfig.json │ ├── tsconfig.test.json │ ├── webpack.debug.config.js │ ├── webpack.development.config.js │ └── webpack.production.config.js ├── build/ │ ├── BuildSetupTemplate.js │ ├── CopyIcons.js │ ├── icon.icns │ ├── script/ │ │ ├── CheckBinariesForBuild.js │ │ ├── UploadDistributionBuildsToAzure.js │ │ ├── appveyor-test.ps1 │ │ ├── install-reason.sh │ │ ├── travis-build.sh │ │ ├── travis-pack.sh │ │ └── travis-test.sh │ └── setup.template.iss ├── cli/ │ ├── linux/ │ │ └── oni.sh │ ├── mac/ │ │ └── oni.sh │ ├── src/ │ │ ├── cli.ts │ │ └── cli_args.ts │ ├── tsconfig.json │ └── win/ │ └── oni.cmd ├── codecov.yml ├── configuration/ │ └── config.default.js ├── extensions/ │ ├── README.md │ ├── clojure/ │ │ └── syntaxes/ │ │ └── clojure.tmLanguage.json │ ├── csharp/ │ │ └── syntaxes/ │ │ └── csharp.tmLanguage.json │ ├── css/ │ │ └── syntaxes/ │ │ └── css.tmLanguage.json │ ├── elixir/ │ │ └── syntaxes/ │ │ ├── eex.tmLanguage.json │ │ ├── elixir.tmLanguage.json │ │ └── html(eex).tmLanguage.json │ ├── go/ │ │ ├── README.md │ │ └── syntaxes/ │ │ └── go.json │ ├── html/ │ │ ├── package.json │ │ └── snippets/ │ │ └── html.json │ ├── images/ │ │ └── package.json │ ├── java/ │ │ └── syntaxes/ │ │ └── Java.tmLanguage.json │ ├── javascript/ │ │ ├── package.json │ │ ├── snippets/ │ │ │ └── javascript.json │ │ └── syntaxes/ │ │ ├── JavaScript.tmLanguage.json │ │ └── JavaScriptReact.tmLanguage.json │ ├── less/ │ │ └── syntaxes/ │ │ └── less.tmLanguage.json │ ├── lua/ │ │ └── syntaxes/ │ │ └── lua.tmLanguage.json │ ├── markdown/ │ │ └── syntaxes/ │ │ └── markdown.tmLanguage.json │ ├── objective-c/ │ │ └── syntaxes/ │ │ ├── objective-c++.tmLanguage.json │ │ └── objective-c.tmLanguage.json │ ├── oni-plugin-markdown-preview/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.tsx │ │ └── tsconfig.json │ ├── oni-plugin-prettier/ │ │ ├── .eslintrc.json │ │ ├── index.js │ │ ├── package.json │ │ └── requirePackage.js │ ├── oni-plugin-quickopen/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── BookmarksSearch.ts │ │ │ ├── QuickOpen.ts │ │ │ ├── QuickOpenItem.ts │ │ │ └── index.tsx │ │ └── tsconfig.json │ ├── php/ │ │ └── syntaxes/ │ │ ├── html.tmLanguage.json │ │ └── php.tmLanguage.json │ ├── python/ │ │ └── syntaxes/ │ │ └── python.tmLanguage.json │ ├── reason/ │ │ ├── README.md │ │ ├── package.json │ │ ├── snippets/ │ │ │ └── reason.json │ │ └── syntaxes/ │ │ └── reason.json │ ├── ruby/ │ │ └── syntaxes/ │ │ └── ruby.tmLanguage.json │ ├── rust/ │ │ └── syntaxes/ │ │ └── rust.tmLanguage.json │ ├── scss/ │ │ └── syntaxes/ │ │ └── scss.json │ ├── shell/ │ │ └── syntaxes/ │ │ └── shell.tmLanguage.json │ ├── swift/ │ │ └── syntaxes/ │ │ └── swift.tmLanguage.json │ ├── theme-dracula/ │ │ ├── colors/ │ │ │ ├── dracula.json │ │ │ └── dracula.vim │ │ └── package.json │ ├── theme-gruvbox/ │ │ ├── colors/ │ │ │ ├── gruvbox.vim │ │ │ ├── gruvbox_dark.json │ │ │ └── gruvbox_light.json │ │ └── package.json │ ├── theme-hybrid/ │ │ ├── colors/ │ │ │ ├── hybrid.vim │ │ │ ├── hybrid_dark.json │ │ │ └── hybrid_light.json │ │ └── package.json │ ├── theme-icons-seti/ │ │ ├── README.md │ │ ├── icons/ │ │ │ └── seti-icon-theme.json │ │ ├── package.json │ │ └── thirdpartynotices.txt │ ├── theme-nord/ │ │ ├── README.md │ │ ├── colors/ │ │ │ ├── nord.json │ │ │ └── nord.vim │ │ └── package.json │ ├── theme-onedark/ │ │ ├── colors/ │ │ │ ├── onedark.json │ │ │ └── onedark.vim │ │ └── package.json │ ├── theme-solarized/ │ │ ├── colors/ │ │ │ ├── solarized8.vim │ │ │ ├── solarized8_dark.json │ │ │ └── solarized8_light.json │ │ └── package.json │ ├── typescript/ │ │ ├── package.json │ │ ├── snippets/ │ │ │ └── typescript.json │ │ └── syntaxes/ │ │ ├── TypeScript.tmLanguage.json │ │ └── TypeScriptReact.tmLanguage.json │ └── vue/ │ └── syntaxes/ │ └── vue.json ├── font-awesome/ │ ├── css/ │ │ └── font-awesome.css │ └── fonts/ │ └── FontAwesome.otf ├── index.dev.html ├── index.html ├── jest.config.js ├── main/ │ ├── src/ │ │ ├── Log.ts │ │ ├── ProcessLifecycle.ts │ │ ├── WindowManager.ts │ │ ├── installDevTools.ts │ │ ├── main.ts │ │ └── menu.ts │ ├── test/ │ │ └── WindowManagerTests.ts │ ├── tsconfig.json │ └── tsconfig.test.json ├── package.json ├── preload.js ├── scripts/ │ ├── dev_webpack_loader.js │ └── webm2gif.sh ├── test/ │ ├── CiTests.ts │ ├── Demo.ts │ ├── Manual.md │ ├── ci/ │ │ ├── Api.Buffer.AddLayer.tsx │ │ ├── Api.Overlays.AddRemoveTest.tsx │ │ ├── Assert.ts │ │ ├── AutoClosingPairsTest.ts │ │ ├── AutoCompletionTest-CSS.ts │ │ ├── AutoCompletionTest-HTML.ts │ │ ├── AutoCompletionTest-Reason.ts │ │ ├── AutoCompletionTest-TypeScript.ts │ │ ├── Browser.LocationTest.ts │ │ ├── ColorHighlight.BufferLayerTest.ts │ │ ├── Common.ts │ │ ├── Configuration.JavaScriptEditorTest.ts │ │ ├── Configuration.TypeScriptEditor.CompletionTest.ts │ │ ├── Configuration.TypeScriptEditor.NewConfigurationTest.ts │ │ ├── Editor.BufferModifiedState.ts │ │ ├── Editor.BuffersCursorTest.ts │ │ ├── Editor.CloseTabWithTabModesTabsTest.ts │ │ ├── Editor.ExternalCommandLineTest.ts │ │ ├── Editor.NextPreviousErrorTest.ts │ │ ├── Editor.OpenFile.PathWithSpacesTest.ts │ │ ├── Editor.ScrollEventTest.ts │ │ ├── Editor.TabModifiedState.ts │ │ ├── Explorer.LocateBufferTest.ts │ │ ├── IndentGuide.BufferLayerTest.tsx │ │ ├── LargeFileTest.ts │ │ ├── LargePasteTest.ts │ │ ├── MarkdownPreviewTest.tsx │ │ ├── Neovim.CallOniCommands.ts │ │ ├── Neovim.InvalidInitVimHandlingTest.ts │ │ ├── NoInstalledNeovim.config.js │ │ ├── NoInstalledNeovim.ts │ │ ├── OSX.WindowTitleTest.ts │ │ ├── PaintPerformanceTest.config.js │ │ ├── PaintPerformanceTest.ts │ │ ├── PrettierPluginTest.ts │ │ ├── QuickOpenTest.ts │ │ ├── Regression.1251.NoAdditionalProcessesOnStartup.ts │ │ ├── Regression.1295.UnfocusedWindowTest.ts │ │ ├── Regression.1296.SettingColorsTest.ts │ │ ├── Regression.1799.MacroApplicationTest.ts │ │ ├── Regression.1819.AutoReadCheckTimeTest.ts │ │ ├── Regression.2047.VerifyCanvasIsIntegerSize.ts │ │ ├── Sidebar.ToggleSplitTest.ts │ │ ├── Snippets.BasicInsertTest.ts │ │ ├── StatusBar-Mode.ts │ │ ├── TabBarSneakTest.ts │ │ ├── TextmateHighlighting.DebugScopesTest.ts │ │ ├── TextmateHighlighting.ScopesOnEnterTest.ts │ │ ├── TextmateHighlighting.TokenColorOverrideTest.ts │ │ ├── Theming.LightAndDarkColorsTest.ts │ │ ├── Welcome.BufferLayerTest.ts │ │ ├── WindowManager.ErrorBoundary.tsx │ │ ├── Workspace.ConfigurationTest.ts │ │ └── initVimPromptNotificationTest.ts │ ├── collateral/ │ │ └── 1799_test.csv │ ├── common/ │ │ ├── Oni.ts │ │ ├── ensureProcessNotRunning.ts │ │ ├── index.ts │ │ └── runInProcTest.ts │ ├── demo/ │ │ ├── DemoCommon.ts │ │ ├── HeroDemo.ts │ │ └── HeroScreenshot.ts │ ├── setup/ │ │ └── WindowsInstallerTests.ts │ └── tsconfig.json ├── tslint.json ├── ui-tests/ │ ├── BrowserView.test.tsx │ ├── BufferManager.test.ts │ ├── BufferScrollBar.test.tsx │ ├── CommandLine.test.tsx │ ├── ContextMenuComponent.test.tsx │ ├── ErrorInfo.test.tsx │ ├── ExplorerSplit.test.tsx │ ├── ExplorerView.test.tsx │ ├── ExternalMenus.test.tsx │ ├── HighlightText.test.tsx │ ├── NeovimBufferLayersView.test.tsx │ ├── NodeView.test.tsx │ ├── NotificationView.test.tsx │ ├── QuickInfo.test.tsx │ ├── SessionManager.test.tsx │ ├── Sessions.test.tsx │ ├── SidebarStore.test.ts │ ├── Tabs.test.tsx │ ├── Text.test.tsx │ ├── TokenScorer.test.ts │ ├── TokenThemeProvider.test.tsx │ ├── VersionControl/ │ │ ├── Help.test.tsx │ │ ├── VersionControlCommits.test.tsx │ │ ├── VersionControlComponents.test.tsx │ │ ├── VersionControlManager.test.tsx │ │ ├── VersionControlPane.test.tsx │ │ ├── VersionControlSectionTitle.test.tsx │ │ ├── VersionControlStore.test.ts │ │ ├── VersionControlView.test.tsx │ │ └── __snapshots__/ │ │ ├── VersionControlComponents.test.tsx.snap │ │ ├── VersionControlSectionTitle.test.tsx.snap │ │ └── VersionControlView.test.tsx.snap │ ├── VersionControlBlameLayer.test.tsx │ ├── VimNavigator.test.tsx │ ├── WelcomeCommandsView.test.tsx │ ├── WelcomeView.test.tsx │ ├── WindowTitleView.test.tsx │ ├── __snapshots__/ │ │ ├── BrowserView.test.tsx.snap │ │ ├── BufferScrollBar.test.tsx.snap │ │ ├── CommandLine.test.tsx.snap │ │ ├── ErrorInfo.test.tsx.snap │ │ ├── ExternalMenus.test.tsx.snap │ │ ├── NodeView.test.tsx.snap │ │ ├── NotificationView.test.tsx.snap │ │ ├── QuickInfo.test.tsx.snap │ │ ├── Tabs.test.tsx.snap │ │ ├── Text.test.tsx.snap │ │ ├── WelcomeCommandsView.test.tsx.snap │ │ ├── WelcomeView.test.tsx.snap │ │ └── WindowTitleView.test.tsx.snap │ ├── enzyme-adapter-react-16.d.ts │ ├── jestsetup.ts │ ├── mocks/ │ │ ├── CommandManager.ts │ │ ├── Configuration.ts │ │ ├── EditorManager.ts │ │ ├── MenuManager.ts │ │ ├── Notifications.ts │ │ ├── Oni.ts │ │ ├── PersistentSettings.ts │ │ ├── SharedNeovimInstance.ts │ │ ├── Sidebar.ts │ │ ├── Statusbar.ts │ │ ├── UserConfiguration.ts │ │ ├── Utility.ts │ │ ├── Workspace.ts │ │ ├── electronMock.ts │ │ └── keyboardLayout.ts │ ├── tsconfig.react.json │ └── welcomeLayer.test.tsx ├── vim/ │ ├── core/ │ │ ├── colors/ │ │ │ └── Monokai.vim │ │ ├── oni-core-interop/ │ │ │ ├── plugin/ │ │ │ │ └── init.vim │ │ │ └── readme.md │ │ ├── oni-core-statusbar/ │ │ │ ├── index.js │ │ │ └── package.json │ │ ├── oni-plugin-buffers/ │ │ │ ├── index.js │ │ │ ├── jsconfig.json │ │ │ └── package.json │ │ ├── oni-plugin-git/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.tsx │ │ │ │ └── vcs.ts │ │ │ └── tsconfig.json │ │ ├── oni-plugin-reasonml/ │ │ │ ├── ftdetect/ │ │ │ │ └── reason.vim │ │ │ ├── indent/ │ │ │ │ └── reason.vim │ │ │ └── syntax/ │ │ │ └── reason.vim │ │ └── oni-plugin-typescript/ │ │ ├── .gitignore │ │ ├── package.json │ │ ├── src/ │ │ │ ├── CodeActions.ts │ │ │ ├── Completion.ts │ │ │ ├── Definition.ts │ │ │ ├── FindAllReferences.ts │ │ │ ├── Formatting.ts │ │ │ ├── LightweightLanguageClient.ts │ │ │ ├── QuickInfo.ts │ │ │ ├── Rename.ts │ │ │ ├── SignatureHelp.ts │ │ │ ├── Symbols.ts │ │ │ ├── TypeScriptConfigurationEditor.ts │ │ │ ├── TypeScriptServerHost.ts │ │ │ ├── Types.ts │ │ │ ├── Utility.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsconfig.test.json │ ├── default/ │ │ └── bundle/ │ │ └── oni-vim-defaults/ │ │ └── plugin/ │ │ └── init.vim │ └── noop.vim └── webview_preload/ ├── src/ │ └── index.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = space indent_size = 4 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true ================================================ FILE: .gitattributes ================================================ # Set the default behavior, in case people don't have core.autocrlf set. * text=auto # Declare text files that will always have LF line endings on checking oni eol=lf # Ignore the yarn library from Linguist, for the Github Language Stats. lib/yarn/* linguist-vendored=true ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ **Oni Version:** **Neovim Version (Linux only):** **Operating System:** **Issue:** **Expected behavior:** **Actual behavior:** **Steps to reproduce:** ================================================ FILE: .github/config.yml ================================================ # Comment to be posted to on first time issues 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 - 347552 - 977348 - 28748 - 2835826 - 515720 - 124171 - 230476 - 10102132 - 10038688 - 817509 - 163128 - 4762 - 933251 - 3974037 - 141159 - 10263 - 3117205 - 5697723 - 6803419 - 1718128 - 2042893 - 14060883 - 244396 - 8832878 - 5127194 - 1764368 - 468548 - 2318955 - 28788713 - 1491574 - 6972449 - 20133970 - 5804765 - 360703 - 120710 - 4607311 - 7727602 - 9206426 - 119977 - 14045559 - 284789 - 2899448 - 2766423 ================================================ FILE: .gitignore ================================================ # Yarn yarn.lock .DS_Store # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Python .mypy_cache # Local app data $LOCALAPPDATA # Output lib lib_test dist s3_dist ### https://raw.github.com/github/gitignore/2b3b1f428fb84dc4ba3ad2307ec44af3c5799848/Node.gitignore # Logs logs *.log npm-debug.log* # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output # node-waf configuration .lock-wscript # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release build/*.ico # Dependency directories node_modules jspm_packages # Optional npm cache directory .npm # npm package-lock files package-lock.json # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity ### https://raw.github.com/github/gitignore/2b3b1f428fb84dc4ba3ad2307ec44af3c5799848/Global/Linux.gitignore *~ # temporary files which can be created if a process still has a handle open of a deleted file .fuse_hidden* # KDE directory preferences .directory # Linux trash folder which might appear on any partition or disk .Trash-* # .nfs files are created when an open file is removed but is still being accessed .nfs* ### https://raw.github.com/github/gitignore/2b3b1f428fb84dc4ba3ad2307ec44af3c5799848/Global/macOS.gitignore *.DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk ### https://raw.github.com/github/gitignore/2b3b1f428fb84dc4ba3ad2307ec44af3c5799848/Global/Windows.gitignore # Windows thumbnail cache files Thumbs.db ehthumbs.db ehthumbs_vista.db # Folder config file Desktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ # Windows Installer files *.cab *.msi *.msm *.msp # Windows shortcuts *.lnk # OCaml / Reason .merlin yarn.lock # Webpack stats file stats.json # User Notes .notes ================================================ FILE: .gitmodules ================================================ [submodule "vim/default/bundle/targets.vim"] path = vim/default/bundle/targets.vim url = https://github.com/wellle/targets.vim [submodule "vim/default/bundle/vim-commentary"] path = vim/default/bundle/vim-commentary url = https://github.com/tpope/vim-commentary [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 ================================================ FILE: .npmignore ================================================ # Empty .npmignore to allow for including .gitignore'd built files ================================================ FILE: .nvmrc ================================================ 9 ================================================ FILE: .oni/config.js ================================================ // For more information on customizing Oni, // check out our wiki page: // https://github.com/onivim/oni/wiki/Configuration const activate = oni => { console.log("Oni config activated") } const deactivate = () => { console.log("Oni config deactivated") } module.exports = { activate, deactivate, "workspace.testFileMappings": [ { sourceFolder: "browser/src", mappedFolder: "browser/test", mappedFileName: "${fileName}Tests.ts", templateFilePath: ".oni/templates/UnitTestTemplate.ts.template", }, ], } ================================================ FILE: .oni/templates/UnitTestTemplate.ts.template ================================================ /** * ${TM_FILENAME_BASE}.ts */ import * as assert from "assert" describe("${TM_FILENAME_BASE}", () => { it("${1:tests}", async () => { ${2:assert.ok(false, "fail")} ${0} }) }) ================================================ FILE: .prettierignore ================================================ package.json vim/core/oni-plugin-typescript/package.json lib/yarn/* ================================================ FILE: .prettierrc ================================================ { "printWidth": 100, "semi": false, "singleQuote": false, "tabWidth": 4, "trailingComma": "all" } ================================================ FILE: .travis.yml ================================================ sudo: required dist: trusty language: node_js branches: only: - master - /^release.*/ cache: directories: - .oni_build_cache matrix: include: - os: linux sudo: required dist: trusty node_js: 8 - os: osx # OSX 10.12 node_js: 8 - os: osx # OSX 10.11 node_js: 8 osx_image: xcode8 allow_failures: - osx_image: xcode8 addons: apt: packages: - libxkbfile-dev - libgnome-keyring-dev - icnsutils - graphicsmagick - xz-utils - rpm - bsdtar before_install: - | # Get the files modified in this commit, and check there is # actual code changes made. If there isn't, stop the build. MODIFIED_FILES=$(git diff --name-only $TRAVIS_COMMIT_RANGE) if ! echo ${MODIFIED_FILES} | grep -qvE '(\.md$)/'; then echo "Only documents were updated, stopping..." exit fi install: - npm install -g yarn@1.9.4 # Remove problematic version of yarn - rm -rf ~/.yarn/bin - yarn --version - which yarn - 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 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-media local-dir: dist/media acl: public_read region: us-west-2 skip_cleanup: true on: condition: $TRAVIS_OS_NAME = osx repo: onivim/oni - provider: releases api_key: secure: qB2KX7c9gRf9HDNetUVJOSa1Lo81QJiukOChOEzGUkYzD/et/2uNgzl0AQX0jB6aOYNwtZAxTd/ON3TTbEWh90o+R1PmQUgQCZ8xIFjOwnQmuHFp4hHoOWNI/ahmQ3W5UD+gmkV5YTRDMfuNnRjraDcQ5R6744Gii4zHGBwnJQsKVh65rxChHfkAJ1WEoX0lUbEM9Veyof4W+xLEgf45eDNvG3fz2y11D2qcvJNckVdvaYIWFwVrefcmofnQvLoWhs8gs6tLBKxaieZ4DcKH+Q5ux+t2VT8LYOR6gkCzuBgUbGUB+AlfCrNR2T7H3LLONIbUMB8/3sF0+oojj9tXPoagHzmwL2gnE7esLxIXc90LbAMpzLwMDvOgA8YEIsgKKtM92BqMK3Pv2clDv+Wmu8Al/QOU8v4Zj5dF09pqa8VM95xPx8t5Harrz+AN6HhZtzoqceooCBtJaGDb5jRdIjWtg4LkJN82mMuNAcbTLUotWp9UxJyqiS+WLrF4cIjPBoq9AAay8XnqLJHpjLGq2Mfp8i9qRFJPr7m2a1WozUUL5/s8Fb7oVOm6rYodXP3ZrdF+0OFMMKoaMfxOg2IhKRIk+S6XYp64i8J4lOFJ0W0dg0ap+f3PsWTYaA7YQ+/SMSv0zsZdrprT80i/Mx5F5HjiljX6GDmanZCdEG1b2a8= file_glob: true file: - dist/*.dmg - dist/*.zip skip_cleanup: true on: condition: $TRAVIS_OS_NAME = osx tags: true repo: onivim/oni - provider: releases api_key: secure: qB2KX7c9gRf9HDNetUVJOSa1Lo81QJiukOChOEzGUkYzD/et/2uNgzl0AQX0jB6aOYNwtZAxTd/ON3TTbEWh90o+R1PmQUgQCZ8xIFjOwnQmuHFp4hHoOWNI/ahmQ3W5UD+gmkV5YTRDMfuNnRjraDcQ5R6744Gii4zHGBwnJQsKVh65rxChHfkAJ1WEoX0lUbEM9Veyof4W+xLEgf45eDNvG3fz2y11D2qcvJNckVdvaYIWFwVrefcmofnQvLoWhs8gs6tLBKxaieZ4DcKH+Q5ux+t2VT8LYOR6gkCzuBgUbGUB+AlfCrNR2T7H3LLONIbUMB8/3sF0+oojj9tXPoagHzmwL2gnE7esLxIXc90LbAMpzLwMDvOgA8YEIsgKKtM92BqMK3Pv2clDv+Wmu8Al/QOU8v4Zj5dF09pqa8VM95xPx8t5Harrz+AN6HhZtzoqceooCBtJaGDb5jRdIjWtg4LkJN82mMuNAcbTLUotWp9UxJyqiS+WLrF4cIjPBoq9AAay8XnqLJHpjLGq2Mfp8i9qRFJPr7m2a1WozUUL5/s8Fb7oVOm6rYodXP3ZrdF+0OFMMKoaMfxOg2IhKRIk+S6XYp64i8J4lOFJ0W0dg0ap+f3PsWTYaA7YQ+/SMSv0zsZdrprT80i/Mx5F5HjiljX6GDmanZCdEG1b2a8= file_glob: true file: - dist/*.deb - dist/*.rpm - dist/*.tar.gz skip_cleanup: true on: condition: $TRAVIS_OS_NAME = linux tags: true repo: onivim/oni ================================================ FILE: .vscode/launch.json ================================================ { "version": "0.2.0", "configurations": [ { // Debug files that configure Electron (main.js, Menu.js, etc.) "name": "Oni Main Process", "type": "node", "request": "launch", "cwd": "${workspaceRoot}", "program": "${workspaceRoot}/lib/main/src/main.js", "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", "windows": { "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd" }, "runtimeArgs": ["--enable-logging"], "console": "internalConsole" }, { // Debug typescript files // (must run `npm run build-debug` first to generate bundle.js.map) "name": "Oni Application", "type": "chrome", // <-- requires Extension "Debugger for Chrome" "request": "launch", "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", "windows": { "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd" }, "runtimeArgs": ["--enable-logging", "${workspaceRoot}/lib/main/src/main.js"], "webRoot": "${workspaceRoot}", "sourceMaps": true, "sourceMapPathOverrides": { "webpack:///./*": "${webRoot}/*" } } ] } ================================================ FILE: .yarnrc ================================================ --add.ignore-engines true ================================================ FILE: @types/color-normalize/index.d.ts ================================================ type ColorInput = | string | Int8Array | Int16Array | Int32Array | Uint8Array | Uint16Array | Uint32Array | Float32Array | Float64Array | Array | Uint8ClampedArray declare function colorNormalize(color: ColorInput, type: "float"): float[] declare function colorNormalize(color: ColorInput, type: "array"): float[] declare function colorNormalize(color: ColorInput, type: "int8"): Int8Array declare function colorNormalize(color: ColorInput, type: "int16"): Int8Array declare function colorNormalize(color: ColorInput, type: "int32"): Int8Array declare function colorNormalize(color: ColorInput, type: "uint"): Uint8Array declare function colorNormalize(color: ColorInput, type: "uint8"): Uint8Array declare function colorNormalize(color: ColorInput, type: "uint16"): Uint8Array declare function colorNormalize(color: ColorInput, type: "uint32"): Uint8Array declare function colorNormalize(color: ColorInput, type: "float32"): Float32Array declare function colorNormalize(color: ColorInput, type: "float64"): Float64Array declare function colorNormalize(color: ColorInput, type: "uint_clamped"): Uint8ClampedArray declare function colorNormalize(color: ColorInput, type: "uint8_clamped"): Uint8ClampedArray declare function colorNormalize(color: ColorInput): float[] declare module "color-normalize" { export default colorNormalize } ================================================ FILE: @types/font-manager/index.d.ts ================================================ type FontWeight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 type FontWidth = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 interface QueryFontDescriptor { postscriptName?: string family?: string style?: string weight?: FontWeight width?: FontWidth italic?: boolean monospace?: boolean } interface ResultFontDescriptor { path: string postscriptName: string family: string style: string weight: FontWeight width: FontWidth italic: boolean monospace: boolean } interface FontManager { getAvailableFonts: (callback: (availableFonts: ResultFontDescriptor[]) => void) => void getAvailableFontsSync: () => ResultFontDescriptor[] findFonts: ( fontDescriptor: QueryFontDescriptor, callback: (foundFonts: ResultFontDescriptor[]) => void, ) => void findFontsSync: (fontDescriptor: QueryFontDescriptor) => ResultFontDescriptor[] findFont: ( fontDescriptor: QueryFontDescriptor, callback: (foundFont: ResultFontDescriptor | null) => void, ) => void findFontSync: (fontDescriptor: QueryFontDescriptor) => ResultFontDescriptor | null substituteFont: ( postscriptName: string, text: string, callback: (replacement: ResultFontDescriptor[]) => void, ) => void substituteFontSync: (postscriptName: string, text: string) => ResultFontDescriptor[] } declare module "font-manager" { declare const fontManager: FontManager export default fontManager } ================================================ FILE: ACCOUNTING.md ================================================ # ONI ## Accounting This file will contain a monthly report including: * Incoming contributions * How the contributions are distributed, in accordance with the project's goals The initial plan for allocation is as follows: * 10% - Vim - Contribute to Bram's charity of choice * 20% - Neovim Development * 35% - Paid to contributors via bounties * 35% - Paid to maintainer Your contributions help keep this project alive! ### March 2017 IN-PROGRESS ================================================ FILE: BACKERS.md ================================================ # Sponsors & Backers Oni is an MIT-licensed open-source project. It's an independent project without the backing of a large company, and the ongoing development is made possible by our backers. Thanks you to all our backers for making Oni possible! ## VIP Backers via BountySource * @jordwalke * @mhartington * @MikaAK * @emolitor ## VIP Backers via Patreon * @mikl * Tom Boland * Simon Smith * [JavaScript.Ninja](https://www.patreon.com/search?q=javascript.ninja) * Mika Kalathil * Franky Chung * Jackie McGhee ## Backers via BountySource * @adambard * @akin_so * @ayohan * @badosu * @josemarluedke * @napcode * @robtrac * @rrichardson * @sbuljac * @parkerault * @city41 * @nithesh * @erandac * @appelgriebsch * Mateusz Wieloch ## Backers via PayPal * @mchalkley * @am2605 * Nathan Ensmenger * Cesar Avitia ## Backers via OpenCollective * Tal Amuyal * Akinola Sowemimo * Martijn Arts * Amadeus Folego * Kiyoshi Murata * @Himura2la * Frederick Gnodtke ## Backers via Patreon * @bennettrogers * @muream * Johnnie Hård * @robin-pham * Ryan Campbell * Balint Fulop * Quasar Jarosz * Channing Conger * Clinton Bloodworth * Lex Song * Paul Baumgart * Kaiden Sin * Troy Vitullo * Leo Critchley * Patrick Massot * Jerome Pellois * Wesley Moore * Kim Fiedler * Nicolaus Hepler * Nick Price * Domenico Maisano * Daniel Polanco * Eric Hall * Dimas Cyriaco * Carlos Coves Prieto * Bryan Germann * James Herdman * Wayan Jimmy * Alex * Phil Plückthun * Norikazu Hayashi * Paul Anderson * Thomas Frick * LinuxLefty * Leon Bogaert * Jorrit Siebelink * Zac Sims * Doug Beney * Aditya Gudimella * Michal Hantl * Lennaert Meijvogel * Jonas Strømsodd * Trevor Barton * Tercio de Melo * Jon Plotner * Patrick Ball * Grégory Reinbold * Antti Holvikari * Christopher Auer * Daniel Falk * David Froger * Dominic Saadi * Gianni Chiappetta * Jake Swanson * James Herdman * Jannis Kaiser * Kosuke Hamada * Lance * Marius Gripsgard * Matti Klock * Selwyn * Saito Nakamura * artalar * Jeff Hertzler * Drew Lazzeri * Bob Gunion * Daniel Blanco * Philip Larson * Josemar Luedke * Mateusz Wieloch * Marc Agbanchenou * Andrew Myers * Corey T Kump * Claudia Hardman * Krakonos * sschwarzer * Andrew Cobby * Imobach González Sosa * Devin * Antonio de Jesus Ochoa Solano * Parker Ault * Rauan Mayemir * Matt Rockwell * Anatoly * Kino * Andrey Popp * James Atkinson * Yohan Lee * Sime Buljac * Brian Recchia * Brandon Ubben * Devin * sschwarzer * Logan Call * Adam Recvlohe * Shawn MacIntyre * Alexandre Mounton-Brady * Todd Epple * Jacob Mischka * Javier Chavarri * David Izquierdo * Richou Degenne * Tyler Compton * Marcos Ojeda * Daniel Martinez * Anthony Mittaz * Yohanes Bandung * Jaap Frölich * Aaron Franks * Adam Howard * Jeff See * آرين دانشور * Alpha Shuro * Sundeep Malladi * Sylvan * Vitezslav Homolka * Jens Aronsson * Benjie Gillam * Christophe Riolo * Jih-Chi Lee * Paul Naranja * Krisztián Szegi * Karlin Fox * Wayne Maurer * Ragnar Hardarson * Andrew Herron * TxH * Richard Feldman * ELLIOTCABLE * Alexey Alekhin * Gal Schlezinger * Michael Jackson * Tim Gebauer * David Gregory * Tony André Haugen * Matthieu Tabuteau * Rich Dean * Dmytro Gladkyi * Brett Eisenberg * Oswaldo Caballero * Goku * Sawyer Bergeron * Igor Matuszewski * Visate * Edward Vetter-Drake * Steven Volocyk * Danny Martini * Mitchell Hanberg ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at admin@onivim.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ ================================================ FILE: CONTRIBUTING.md ================================================ # Contribute ## Introduction First, thank you for considering contributing to oni! It's people like you that make the open source community such a great community! 😊 We welcome any type of contribution, not only code. You can help with * **QA**: file bug reports, the more details you can give the better (e.g. screenshots with the console open) * **Marketing**: writing blog posts, howto's, printing stickers, ... * **Community**: presenting the project at meetups, organizing a dedicated meetup for the local community, ... * **Code**: take a look at the [open issues](issues). Even if you can't write code, commenting on them, showing that you care about a given issue matters. It helps us triage them. * **Money**: we welcome financial contributions in full transparency on our [open collective](https://opencollective.com/oni). ## Your First Contribution Working on your first Pull Request? You can learn how from this _free_ series, [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github). ## Submitting code Any code change should be submitted as a pull request. The description should explain what the code does and give steps to execute it. The pull request should also contain tests. We welcome and appreciate pull requests! ## Code review guidelines * Keep PRs **small and scoped**. The bigger the pull request, the longer it will take to review and merge. Break down large pull requests into smaller incremental chunks - this will help catch issues earlier and be easier on both you and the maintainer. * Following from the previous bullet point, **do not include unrelated changes in a PR**. It can be tempting to include extra styling changes or additional functionality, but these should be added as separate PRs. * Think of each PR as **improving the quality of the codebase**. Codebases tend towards entropy and disorder unless actively managed - make sure that your change moves the quality needle in the right direction. This can take a variety of forms, including adding test coverage, reducing coupling, etc. _As we are a small team moving fast, we cannot afford to accumulate technical debt._ * If there is ambiguity in terms of design, architecture, or implementation, it's best to get **feedback before implementing**, to save both you and the maintainer time. If you're not sure, feel free to ask! * For your first few PRs, **don't try and change the world** - pick some small issues and get familiar with the codebase. Then, work your way up to bigger issues - this will set you up for success. PRs require approval from one other person, either a maintainer or a contributor. Keep in mind that when you approve code, you are accountable for it, too! Reviewers are the gatekeepers of quality and ensuring adherence to the guidelines above. ## Financial contributions We also welcome financial contributions in full transparency on our [open collective](https://opencollective.com/oni). Anyone can file an expense. If the expense makes sense for the development of the community, it will be "merged" in the ledger of our open collective by the core contributors and the person who filed the expense will be reimbursed. ### Bounties The primary allotment of our [open collective](https://opencollective.com/oni) budget is dedicated to bounties. Developing features and fixing bugs is a lot of work, and those go directly to the developers doing this work via bounties. It is the role of the _maintainer_ to set bounties and clear completion criteria. Issues that have a bounty associated with them will have a `bounty` label as well as an amount, ie, `bounty-50` means a $50 bounty. * Guidelines: * The fix for the bug/feature/issue _MUST_ be complete and _MUST_ be covered by tests to be eligible for a bounty. * Any associated documentation relevant to the bug/feature _MUST_ be updated. * If you begin working on an issue with an associated bounty, open a PR with "WIP" and the bug number in the title, as well as reference the issue #. This is important to reduce duplicate work. #### Claiming a bounty * Upon completion of an issue with an associated bounty, bounties are payable by [Submitting an Expense](https://opencollective.com/oni/expenses/new) on our [OpenCollective](https://opencollective.com/oni). Note that OpenCollective requires a PDF or Photo of an expense form for an expense claim to be accepted - more information, including an example expense form can be found [here](https://opencollective.com/faq#expense). Check out our [expenses](https://opencollective.com/oni/expenses#) page for an example. * A collaborator will approve the expense once we have verified it meets the criteria outlined above (complete fix, covered by tests, associated documentation updated) If you questions about the guidelines, please don't hesitate to contact the maintainer. ## Roles There are various roles and responsibilities in managing an open-source project. Users that are active and have a positive impact on the project and community will be recognized and have the option of assuming additional responsibilities. * **Maintainer** - A maintainer communicates goals and drives the vision for the project. The maintainer is responsible for breaking down hurdles and supporting contributors. In addition, the maintainer triages issues, produces releases, assigns bounties, and establishes completion criteria. Today, there is one maintainer, but that isn't a strict requirement. * **Collaborator** - A collaborator is an established member of the project that is recognized for their impact and contributions. They can triage and close issues, approve PRs from other contributors / collaborators, and can approve expenses on our [open collective](https://opencollective.com/oni) * **Contributor** - A contributor is a developer that has submitted a successful PR for the project. ### Becoming a collaborator A collaborator is a contributor who has been recognized for the impact they've had on the project, over a sustained period of time. In general, this means the following: * **Supporting the community** - helping others in issues and chat, supporting new developers, creating a positive and supportive environment. * **Technical impact** - involvement in a core technical piece of the editor, or a broad impact on the ecosystem. * **Positive and collaborative mindset** - creating good vibes, willingness to give and receive constructive feedback, being a team player. ## Questions If you have any questions, create an [issue](issue) (protip: do a quick search first to see if someone else didn't ask the same question before!). You can also reach us at hello@oni.opencollective.com. ## Credits ### Contributors Thank you to all the people who have already contributed to oni! ### Backers Thank you to all our backers! [[Become a backer](https://opencollective.com/oni#backer)] ### Sponsors Thank you to all our sponsors! (please ask your company to also support this open source project by [becoming a sponsor](https://opencollective.com/oni#sponsor)) ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2016 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ --- __NOTE:__ This repository is unmaintained - we are focusing on [Onivim 2](https://github.com/onivim/oni2) and [libvim](https://github.com/onivim/libvim). ---

Logo

Modern Modal Editing

Build Status Build Status codecov

Join the chat on discord! Total Downloads

## Introduction Oni is a new kind of editor, focused on maximizing productivity - combining _modal editing_ with features you expect in modern editors. Oni is built with [neovim](https://github.com/neovim/neovim), and inspired by [VSCode](https://github.com/Microsoft/vscode), [Atom](https://atom.io/), [LightTable](http://lighttable.com/), and [Emacs](https://www.gnu.org/software/emacs/) The vision of Oni is to build an editor that allows you to go from _thought to code_ as easily as possible - bringing together the raw editing power of Vim, the feature capabilities of Atom/VSCode, and a powerful and intuitive extensibility model - wrapped up in a beautiful package.

Check out [Releases](https://github.com/onivim/oni/releases) for the latest binaries, or [Build Oni](https://github.com/onivim/oni/wiki/Development) from source. Consider making a donation via [OpenCollective](https://opencollective.com/oni) [BountySource](https://salt.bountysource.com/teams/oni) if you find this project useful! ## Features Oni brings several IDE-like integrations to neovim: * [Embedded Browser](https://github.com/onivim/oni/wiki/Features#embedded-browser) * [Quick Info](https://github.com/onivim/oni/wiki/Features#quick-info) * [Code Completion](https://github.com/onivim/oni/wiki/Features#code-completion) * [Syntax / Compilation Errors](https://github.com/onivim/oni/wiki/Features#syntax--compilation-errors) * [Fuzzy Finding](https://github.com/onivim/oni/wiki/Features#fuzzy-finder) * [Status Bar](https://github.com/onivim/oni/wiki/Features#status-bar) * [Interactive Tutorial](https://github.com/onivim/oni/wiki/Features#interactive-tutorial) And more coming - check out our [Roadmap](https://github.com/onivim/oni/wiki/Roadmap) Oni is cross-platform and supports Windows, Mac, and Linux. > If you're a Vim power user, and don't need all these features, check out our [minimal configuration](https://github.com/onivim/oni/wiki/How-To:-Minimal-Oni-Configuration). ## Installation We have installation guides for each platform: * [Windows](https://github.com/onivim/oni/wiki/Installation-Guide#windows) * [Mac](https://github.com/onivim/oni/wiki/Installation-Guide#mac) * [Linux](https://github.com/onivim/oni/wiki/Installation-Guide#linux) The latest binaries are available on our [Releases](https://github.com/onivim/oni/releases) page, and if you'd prefer to build from source, check out our [Development](https://github.com/onivim/oni/wiki/Development) guide. ## Goals The goal of this project is to provide both the full-fledged Vim experience, with no compromises, while pushing forward to enable new productivity scenarios. * **Modern UX** - The Vim experience should not be compromised by terminal limitations. * **Rich plugin development** - using JavaScript, instead of VimL. * **Cross-platform support** - across Windows, OS X, and Linux. * **Batteries included** - rich features are available out of the box - minimal setup needed to be productive. * **Performance** - no compromises, Vim is fast, and Oni should be fast too. * **Ease Learning Curve** - without sacrificing the Vim experience. Vim is an incredible tool for manipulating _text_ at the speed of thought. With a composable, modal command language, it is no wonder that Vim usage is still prevalent today. However, going from thought to _code_ has some different challenges than going from thought to _text_. Code editors today provide several benefits that help to reduce **cognitive load** when writing code, and that benefit is tremendously important - not only in terms of pure coding efficiency and productivity, but also in making the process of writing code enjoyable and fun. The goal of this project is to give an editor that gives the best of both worlds - the power, speed, and flexibility of using Vim for manipulating text, as well as the rich tooling that comes with an IDE. We want to make coding as efficient, fast, and fun as we can! ## Documentation * Check out the [Wiki](https://github.com/onivim/oni/wiki) for documentation on how to use and modify Oni. * [FAQ](https://github.com/onivim/oni/wiki/FAQ) * [Roadmap](https://github.com/onivim/oni/wiki/Roadmap) ## Available Plugins Some available plugins created by Oni users are listed below (if you'd like to add your plugin to this list please create a PR updating this **README** with the details). * [Oni Touchbar Plugin](https://github.com/jordan-arenstein/oni-plugin-touchbar) - by [jordan-arenstein](https://github.com/jordan-arenstein?tab=overview&from=2018-07-01&to=2018-07-31) * [quickFind](https://github.com/marene/quickFind) - by [marene](https://github.com/marene) * Themes * [Night Owl](https://github.com/Akin909/oni-theme-night-owl) ## Contributing There many ways to get involved & contribute to Oni: * Thumbs up existing [issues](https://github.com/onivim/oni/issues) if they impact you. * [Create an issue](https://github.com/onivim/oni/issues) for bugs or new features. * Review and update our [documentation](https://github.com/onivim/oni/wiki). * Try out the latest [released build](https://github.com/onivim/oni/releases). * Help us [develop](https://github.com/onivim/oni/wiki/Development): * Review [PRs](https://github.com/onivim/oni/pulls) * Submit a bug fix or feature * Add test cases * Create a blog post or YouTube video * Follow us on [Twitter](https://twitter.com/oni_vim) ## Acknowledgements Oni is an independent project and is made possible by the support of some exceptional people. Big thanks to the following people for helping to realize this project: * the [neovim team](https://neovim.io/), especially [justinmk](https://github.com/justinmk) and [tarruda](https://github.com/tarruda) - Oni would not be possible without their vision * [jordwalke](https://github.com/jordwalke) for his generous support, inspiration, and ideas. And React ;) * [keforbes](https://github.com/keforbes) for helping to get this project off the ground * [Akin909](https://github.com/Akin909) for his extensive contributions * [CrossR](https://github.com/CrossR) for polishing features and configurations * [Cryza](https://github.com/Cryza) for the webgl renderer * [tillarnold](https://github.com/tillarnold) for giving us the `oni` npm package name * [mhartington](https://github.com/mhartington) for his generous support * [badosu](https://github.com/badosu) for his support, contributions, and managing the AUR releases * All our current monthly [sponsors](https://salt.bountysource.com/teams/oni/supporters) and [backers](BACKERS.md) * All of our [contributors](https://github.com/onivim/oni/graphs/contributors) - thanks for helping to improve this project! Several other great neovim front-end UIs [here](https://github.com/neovim/neovim/wiki/Related-projects) served as a reference, especially [NyaoVim](https://github.com/rhysd/NyaoVim) and [VimR](https://github.com/qvacua/vimr). I encourage you to check those out! Thank you! ## Contributors This project exists thanks to all the people who have [contributed](CONTRIBUTING.md): ## License MIT License. Copyright (c) Bryan Phelps Windows and OSX have a bundled version of Neovim, which is covered under [Neovim's license](https://github.com/neovim/neovim/blob/master/LICENSE) ### Bundled Plugins Bundled plugins have their own license terms. These include: * [typescript-vim](https://github.com/leafgarland/typescript-vim) (`oni/vim/core/typescript.vim`) * [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`) ================================================ FILE: appveyor.yml ================================================ # Test against the latest version of this Node.js version environment: nodejs_version: "8" os: unstable branches: only: - master - /^release.*/ # Skip CI build if the changes match these rules exactly. # Ie, if the BACKERS.md file is changed, we don't need to build. skip_commits: files: - "**/*.md" cache: - .oni_build_cache -> package.json platform: - x86 - x64 # Install scripts. (runs after repo cloning) install: # Ensure the Git Submoduldes have been pulled down too - git submodule update --init --recursive # Get the latest stable version of Node.js or io.js - ps: Install-Product node $env:nodejs_version # Workaround https://github.com/npm/npm/issues/18380 - npm install -g yarn - node --version - npm --version # install modules - yarn install - yarn run check-cached-binaries # Post-install test scripts. test_script: - powershell build/script/appveyor-test.ps1 # Don't actually build. build: off ================================================ FILE: browser/src/App.ts ================================================ /** * App.ts * * Entry point for the Oni application - managing the overall lifecycle */ import { ipcRenderer, remote } from "electron" import * as fs from "fs" import * as minimist from "minimist" import * as path from "path" import { IDisposable } from "oni-types" import * as Log from "oni-core-logging" 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]) // On mac we should quit the application when the user press Cmd + Q if (process.platform === "darwin") { Log.info("[App::quit] quitting app") remote.app.quit() } 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 sessionManagerPromise = import("./Services/Sessions") 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 vcsManagerPromise = import("./Services/VersionControl") const notificationsPromise = import("./Services/Notifications") const snippetPromise = import("./Services/Snippets") const keyDisplayerPromise = import("./Services/KeyDisplayer") 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, { string: "_" }) const currentWorkingDirectory = process.cwd() const normalizedFiles = parsedArgs._.map( arg => (path.isAbsolute(arg) ? arg : path.join(currentWorkingDirectory, arg)), ) const filesToOpen = normalizedFiles.filter(f => { if (fs.existsSync(f)) { return fs.statSync(f).isFile() } else { return true } }) const foldersToOpen = normalizedFiles.filter( f => fs.existsSync(f) && fs.statSync(f).isDirectory(), ) Log.info("Files to open: " + JSON.stringify(filesToOpen)) Log.info("Folders to open: " + JSON.stringify(foldersToOpen)) let workspaceToLoad = null // If a folder has been specified, we'll change directory to it if (foldersToOpen.length > 0) { workspaceToLoad = foldersToOpen[0] } else if (filesToOpen.length > 0) { workspaceToLoad = path.dirname(filesToOpen[0]) } // 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() const developmentPlugin = parsedArgs["plugin-develop"] let developmentPluginError: { title: string; errorText: string } if (typeof developmentPlugin === "string") { Log.info("Registering development plugin: " + developmentPlugin) if (fs.existsSync(developmentPlugin)) { pluginManager.addDevelopmentPlugin(developmentPlugin) } else { developmentPluginError = { title: "Error parsing arguments", errorText: "Could not find plugin: " + developmentPlugin, } Log.warn(developmentPluginError.errorText) } } else if (typeof developmentPlugin === "boolean") { developmentPluginError = { title: "Error parsing arguments", errorText: "--plugin-develop must be followed by a plugin path", } Log.warn(developmentPluginError.errorText) } Performance.startMeasure("Oni.Start.Plugins.Discover") pluginManager.discoverPlugins() Performance.endMeasure("Oni.Start.Plugins.Discover") const oniApi = pluginManager.getApi() 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, workspaceToLoad) const workspace = Workspace.getInstance() const WindowManager = await windowManagerPromise const MultiProcess = await multiProcessPromise MultiProcess.activate(WindowManager.windowManager) const StatusBar = await statusBarPromise StatusBar.activate(configuration) 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 Notifications = await notificationsPromise Notifications.activate(configuration, overlayManager) const notifications = Notifications.getInstance() if (typeof developmentPluginError !== "undefined") { const notification = notifications.createItem() notification.setContents(developmentPluginError.title, developmentPluginError.errorText) notification.setLevel("error") notification.onClick.subscribe(() => commandManager.executeCommand("oni.config.openConfigJs"), ) notification.show() } configuration.onConfigurationError.subscribe(err => { 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(oniApi) 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() const VCSManager = await vcsManagerPromise VCSManager.activate(oniApi, sidebarManager, notifications) Explorer.activate(oniApi, configuration, Sidebar.getInstance()) Learning.activate( commandManager, configuration, editorManager, overlayManager, Sidebar.getInstance(), WindowManager.windowManager, ) const Sessions = await sessionManagerPromise Sessions.activate(oniApi, sidebarManager) 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() Search.activate(api) 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(commandManager, 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) } ================================================ FILE: browser/src/CSS.ts ================================================ /** * CSS.ts * * Entry point for loading all of Oni's CSS */ export const activate = () => { require("./UI/components/common.less") // tslint:disable-line no-var-requires require("./overlay.less") // tslint:disable-line require("./Services/Menu/Menu.less") require("./UI/components/InstallHelp.less") } ================================================ FILE: browser/src/Constants.ts ================================================ /** * Constants.ts */ // Performance Constants export namespace Delay { export const INSTANT = 1 export const REAL_TIME = 10 export const NEAR_REAL_TIME = 50 export const NOT_REAL_TIME = 250 } export namespace Vim { export const MAX_VALUE = 2147483647 } ================================================ FILE: browser/src/Editor/BufferHighlights.ts ================================================ /** * BufferHighlights.ts * * Helpers to manage buffer highlight state */ import * as SyntaxHighlighting from "./../Services/SyntaxHighlighting" import { NeovimInstance } from "./../neovim" // Line number to highlight src id, for clearing export type BufferHighlightId = number export interface IBufferHighlightsUpdater { setHighlightsForLine(line: number, highlights: SyntaxHighlighting.HighlightInfo[]): void clearHighlightsForLine(line: number): void } /** * Helper class to efficiently update * buffer highlights in a batch * * @name BufferHighlightsUpdater * @class */ export class BufferHighlightsUpdater implements IBufferHighlightsUpdater { private _calls: any[] = [] constructor( private _bufferId: number, private _neovimInstance: NeovimInstance, private _highlightId: BufferHighlightId, ) {} public async start(): Promise { if (!this._highlightId) { this._highlightId = await this._neovimInstance.request( "nvim_buf_add_highlight", [this._bufferId, 0, "", 0, 0, 0], ) } } public setHighlightsForLine( line: number, highlights: SyntaxHighlighting.HighlightInfo[], ): void { this.clearHighlightsForLine(line) if (!highlights || !highlights.length) { return } const addHighlightCalls = highlights.map(hl => { const highlightGroup = this._neovimInstance.tokenColorSynchronizer.getHighlightGroupForTokenColor( hl.tokenColor, ) return [ "nvim_buf_add_highlight", [ this._bufferId, this._highlightId, highlightGroup, hl.range.start.line, hl.range.start.character, hl.range.end.character, ], ] }) this._calls = this._calls.concat(addHighlightCalls) } public clearHighlightsForLine(line: number): void { this._calls.push([ "nvim_buf_clear_highlight", [this._bufferId, this._highlightId, line, line + 1], ]) } public async apply(): Promise { if (this._calls.length > 0) { await this._neovimInstance.request("nvim_call_atomic", [this._calls]) } return this._highlightId } } ================================================ FILE: browser/src/Editor/BufferManager.ts ================================================ /** * BufferManager.ts * * Helpers to manage buffer state */ import * as os from "os" import * as types from "vscode-languageserver-types" import { Observable } from "rxjs/Observable" import "rxjs/add/observable/defer" import "rxjs/add/observable/from" import "rxjs/add/operator/concatMap" import { Store } from "redux" import * as detectIndent from "detect-indent" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { BufferEventContext, EventContext, InactiveBufferContext, NeovimInstance, } from "./../neovim" import * as LanguageManager from "./../Services/Language" import { PromiseQueue } from "./../Services/Language/PromiseQueue" import { BufferHighlightId, BufferHighlightsUpdater, IBufferHighlightsUpdater, } from "./BufferHighlights" import * as Actions from "./NeovimEditor/NeovimEditorActions" import * as State from "./NeovimEditor/NeovimEditorStore" import * as Constants from "./../Constants" import { TokenColor } from "./../Services/TokenColors" import { IBufferLayer } from "./NeovimEditor/BufferLayerManager" /** * Candidate API methods */ export interface IBuffer extends Oni.Buffer { tabstop: number shiftwidth: number comment: ICommentFormats setLanguage(lang: string): Promise getLayerById(id: string): T getCursorPosition(): Promise handleInput(key: string): boolean detectIndentation(): Promise setScratchBuffer(): Promise } type NvimError = [1, string] interface ICommentFormats { start: string middle: string end: string defaults: 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" export interface BufferIndentationInfo { type: IndentationType // If indentation is 'space', this is how // many spaces are at a particular tabstop amount: number // String value for indentation indent: string } const getStringFromTypeAndAmount = (type: IndentationType, amount: number): string => { if (type === "tab") { return "\t" } else { let str = "" for (let i = 0; i < amount; i++) { str += " " } return str } } export class Buffer implements IBuffer { private _id: string private _filePath: string private _language: string private _cursor: Oni.Cursor private _cursorOffset: number private _version: number private _modified: boolean private _lineCount: number private _tabstop: number private _shiftwidth: number private _comment: ICommentFormats private _bufferHighlightId: BufferHighlightId = null private _promiseQueue = new PromiseQueue() public get shiftwidth(): number { return this._shiftwidth } public get tabstop(): number { return this._tabstop } public get comment(): ICommentFormats { return this._comment } public get filePath(): string { return this._filePath } public get language(): string { return this._language } public get lineCount(): number { return this._lineCount } public get cursor(): Oni.Cursor { return this._cursor } public get cursorOffset(): number { return this._cursorOffset } public get version(): number { return this._version } public get modified(): boolean { return this._modified } public get id(): string { return this._id } constructor( private _neovimInstance: NeovimInstance, private _actions: typeof Actions, private _store: Store, evt: EventContext, ) { this.updateFromEvent(evt) } public addLayer(layer: IBufferLayer): void { this._actions.addBufferLayer(parseInt(this._id, 10), layer) } public getLayerById(id: string): T | null { return ( ((this._store .getState() .layers[parseInt(this._id, 10)].find(layer => layer.id === id) as any) as T) || null ) } public removeLayer(layer: IBufferLayer): void { this._actions.removeBufferLayer(parseInt(this._id, 10), layer) } /** * convertOffsetToLineColumn */ public async convertOffsetToLineColumn( cursorOffset = this._cursorOffset, ): Promise { const line: number = await this._neovimInstance.callFunction("byte2line", [cursorOffset]) const countFromLine: number = await this._neovimInstance.callFunction("line2byte", [line]) const column = cursorOffset - countFromLine return types.Position.create(line - 1, column) } public async getCursorPosition(): Promise { const pos = await this._neovimInstance.callFunction("getpos", ["."]) const [, oneBasedLine, oneBasedColumn] = pos return types.Position.create(oneBasedLine - 1, oneBasedColumn - 1) } public async getLines(start?: number, end?: number): Promise { if (typeof start !== "number") { start = 0 } if (typeof end !== "number") { end = this._lineCount } if (end - start > 2500) { Log.warn("getLines called with over 2500 lines, this may cause instability.") } // 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, ]) if (isStringArray(lines)) { return lines } return [] } public async setLanguage(language: string): Promise { this._language = 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"]], ] 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 { const bufferLines = await this.getLines(0, 1024) const ret = detectIndent(bufferLines.join("\n")) // We were able to infer tab settings from lines, so return if (ret.type === "tab" || ret.type === "space") { return ret } // Otherwise, we'll fall back to getting vim tab settings const isSpaces = await this._neovimInstance.request("nvim_get_option", [ "expandtab", ]) const tabSize = await this._neovimInstance.request("nvim_get_option", ["tabstop"]) const tabType = isSpaces ? "space" : "tab" return { amount: tabSize, type: tabType, indent: getStringFromTypeAndAmount(tabType, tabSize), } } public async applyTextEdits(textEdits: types.TextEdit | types.TextEdit[]): Promise { const textEditsAsArray = textEdits instanceof Array ? textEdits : [textEdits] const sortedEdits = LanguageManager.sortTextEdits(textEditsAsArray) const deferredEdits = sortedEdits.map(te => { return Observable.defer(async () => { const range = te.range Log.info("[Buffer] Applying edit") const characterStart = range.start.character const lineStart = range.start.line const lineEnd = range.end.line const characterEnd = range.end.character const calls = [] calls.push(["nvim_command", ["silent! undojoin"]]) if (lineStart === lineEnd) { const [lineContents] = await this.getLines(lineStart, lineStart + 1) const beginning = lineContents.substring(0, range.start.character) const end = lineContents.substring(range.end.character, lineContents.length) const newLine = beginning + te.newText + end const lines = newLine.split(os.EOL) calls.push([ "nvim_buf_set_lines", [parseInt(this._id, 10), lineStart, lineStart + 1, false, lines], ]) } else if (characterEnd === 0 && characterStart === 0) { const lines = te.newText.split(os.EOL) calls.push([ "nvim_buf_set_lines", [parseInt(this._id, 10), lineStart, lineEnd, false, lines], ]) } else { Log.warn("Multi-line mid character edits not currently supported") } await this._neovimInstance.request("nvim_call_atomic", [calls]) }) }) await Observable.from(deferredEdits) .concatMap(de => de) .toPromise() } public handleInput(key: string): boolean { const state = this._store.getState() const bufferLayers: IBufferLayer[] = state.layers[this._id] if (!bufferLayers || !bufferLayers.length) { return false } const layerShouldHandleInput = bufferLayers.reduce( (layerHandlerExists, currentLayer) => { if (layerHandlerExists) { return true } if (!currentLayer || !currentLayer.handleInput) { return false } else if (currentLayer.isActive && currentLayer.isActive()) { return currentLayer.handleInput(key) } return false }, false, ) return layerShouldHandleInput } public async updateHighlights( tokenColors: TokenColor[], updateFunction: (highlightsUpdater: IBufferHighlightsUpdater) => void, ): Promise { this._promiseQueue.enqueuePromise(async () => { const bufferId = parseInt(this._id, 10) const bufferUpdater = new BufferHighlightsUpdater( bufferId, this._neovimInstance, this._bufferHighlightId, ) await this._neovimInstance.tokenColorSynchronizer.synchronizeTokenColors(tokenColors) await bufferUpdater.start() updateFunction(bufferUpdater) this._bufferHighlightId = await bufferUpdater.apply() }) } public async setLines(start: number, end: number, lines: string[]): Promise { return this._neovimInstance.request("nvim_buf_set_lines", [ parseInt(this._id, 10), start, end, false, lines, ]) } public async setCursorPosition(row: number, column: number): Promise { await this._neovimInstance.eval(`setpos(".", [${this._id}, ${row + 1}, ${column + 1}, 0])`) } public async getSelectionRange(): Promise { const startRange = await this._neovimInstance.callFunction("getpos", ["'<'"]) const endRange = await this._neovimInstance.callFunction("getpos", ["'>"]) const [, startLine, startColumn] = startRange let [, endLine, endColumn] = endRange if (startLine === 0 && startColumn === 0 && endLine === 0 && endColumn === 0) { return null } if (endColumn === Constants.Vim.MAX_VALUE) { endLine++ endColumn = 1 } return types.Range.create(startLine - 1, startColumn - 1, endLine - 1, endColumn - 1) } public async getTokenAt(line: number, column: number): Promise { const result = await this.getLines(line, line + 1) const tokenRegEx = LanguageManager.getInstance().getTokenRegex(this.language) const getLastMatchingCharacter = ( lineContents: string, character: number, dir: number, regex: RegExp, ) => { while (character > 0 && character < lineContents.length) { if (!lineContents[character].match(regex)) { return character - dir } character += dir } return character } const getToken = (lineContents: string, character: number): Oni.IToken => { if (!lineContents || !character) { return null } const tokenStart = getLastMatchingCharacter(lineContents, character, -1, tokenRegEx) const tokenEnd = getLastMatchingCharacter(lineContents, character, 1, tokenRegEx) const range = types.Range.create(line, tokenStart, line, tokenEnd) const tokenName = lineContents.substring(tokenStart, tokenEnd + 1) return { tokenName, range, } } return getToken(result[0], column) } public updateFromEvent(evt: EventContext): void { this._id = evt.bufferNumber.toString() this._filePath = evt.bufferFullPath this._language = evt.filetype this._version = evt.version this._modified = evt.modified this._lineCount = evt.bufferTotalLines this._cursorOffset = evt.byte this._tabstop = evt.tabstop this._shiftwidth = evt.shiftwidth this._comment = this.formatCommentOption(evt.comments) this._cursor = { line: evt.line - 1, column: evt.column - 1, } } public formatCommentOption(comments: string): ICommentFormats { if (!comments) { return null } try { const commentsArray = comments.split(",") const commentFormats = commentsArray.reduce( (acc, str) => { const [flag, character] = str.split(":") switch (true) { case flag.includes("s"): acc.start = character return acc case flag.includes("m"): acc.middle = character return acc case flag.includes("e"): acc.end = character return acc default: acc.defaults.push(character) return acc } }, { start: null, middle: null, end: null, defaults: [], }, ) return commentFormats } catch (e) { Log.warn(`Error formatting neovim comment options due to ${e.message}`) return null } } } // Helper for managing buffer state export class BufferManager { private _idToBuffer: { [id: string]: Buffer } = {} private _filePathToId: { [filePath: string]: string } = {} private _bufferList: { [id: string]: InactiveBuffer } = {} constructor( private _neovimInstance: NeovimInstance, private _actions: typeof Actions, private _store: Store, ) {} public updateBufferFromEvent(evt: EventContext): Buffer { const id = evt.bufferNumber.toString() const currentBuffer = this.getBufferById(id) if (evt.bufferFullPath) { this._filePathToId[evt.bufferFullPath] = id } if (currentBuffer) { currentBuffer.updateFromEvent(evt) } else { const buf = new Buffer(this._neovimInstance, this._actions, this._store, evt) this._idToBuffer[id] = buf } return this._idToBuffer[id] } public populateBufferList(buffers: BufferEventContext): void { const bufferlist = buffers.existingBuffers.reduce((list, buffer) => { const id = `${buffer.bufferNumber}` if (buffer.bufferFullPath) { this._filePathToId[buffer.bufferFullPath] = id list[id] = new InactiveBuffer(buffer) } return list }, {}) const currentId = buffers.current.bufferNumber.toString() const current = this.getBufferById(currentId) this._bufferList = { ...bufferlist, [currentId]: current } } public getBufferById(id: string): Buffer { return this._idToBuffer[id] } public getBuffers(): Array { return Object.values(this._bufferList) } } export class InactiveBuffer implements Oni.InactiveBuffer { private _id: string private _filePath: string private _language: string private _version: number private _modified: boolean private _lineCount: number public get id(): string { return this._id } public get filePath(): string { return this._filePath } public get language(): string { return this._language } public get version(): number { return this._version } public get modified(): boolean { return this._modified } public get lineCount(): number { return this._lineCount } constructor(inactiveBuffer: InactiveBufferContext) { this._id = `${inactiveBuffer.bufferNumber}` this._filePath = inactiveBuffer.bufferFullPath this._language = inactiveBuffer.filetype this._version = inactiveBuffer.version || null this._modified = inactiveBuffer.modified || false this._lineCount = null } } ================================================ FILE: browser/src/Editor/Editor.ts ================================================ /** * Interface that describes an Editor - * an editor handles rendering and input * for a specific window. */ import * as Oni from "oni-api" import { Event, IEvent } from "oni-types" import * as types from "vscode-languageserver-types" import { Disposable } from "./../Utility" /** * Base class for Editor implementations */ export abstract class Editor extends Disposable implements Oni.Editor { private _currentMode: string private _onBufferEnterEvent = new Event() private _onBufferLeaveEvent = new Event() private _onBufferChangedEvent = new Event() private _onBufferSavedEvent = new Event() private _onBufferScrolledEvent = new Event() private _onCursorMoved = new Event() private _onModeChangedEvent = new Event() public get mode(): string { return this._currentMode } public get activeBuffer(): Oni.Buffer { return null } public get onCursorMoved(): IEvent { return this._onCursorMoved } public abstract init(filesToOpen: string[]): void // Events public get onModeChanged(): IEvent { return this._onModeChangedEvent } public get onBufferEnter(): IEvent { return this._onBufferEnterEvent } public get onBufferLeave(): IEvent { return this._onBufferLeaveEvent } public get onBufferChanged(): IEvent { return this._onBufferChangedEvent } public get onBufferSaved(): IEvent { return this._onBufferSavedEvent } public get onBufferScrolled(): IEvent { return this._onBufferScrolledEvent } public getBuffers(): Array { return [] } public /* virtual */ openFile( filePath: string, openOptions: Oni.FileOpenOptions = Oni.DefaultFileOpenOptions, ): Promise { return Promise.reject("Not implemented") } public async blockInput( inputFunction: (input: Oni.InputCallbackFunction) => Promise, ): Promise { return Promise.reject("Not implemented") } public setTextOptions(options: Oni.EditorTextOptions): Promise { return Promise.reject("Not implemented") } public abstract render(): JSX.Element public abstract setSelection(selectionRange: types.Range): Promise protected setMode(mode: Oni.Vim.Mode): void { if (mode !== this._currentMode) { this._currentMode = mode this._onModeChangedEvent.dispatch(mode) } } protected notifyCursorMoved(cursor: Oni.Cursor): void { this._onCursorMoved.dispatch(cursor) } protected notifyBufferChanged(bufferChangeEvent: Oni.EditorBufferChangedEventArgs): void { this._onBufferChangedEvent.dispatch(bufferChangeEvent) } protected notifyBufferEnter(bufferEvent: Oni.EditorBufferEventArgs): void { this._onBufferEnterEvent.dispatch(bufferEvent) } protected notifyBufferLeave(bufferEvent: Oni.EditorBufferEventArgs): void { this._onBufferLeaveEvent.dispatch(bufferEvent) } protected notifyBufferSaved(bufferEvent: Oni.EditorBufferEventArgs): void { this._onBufferSavedEvent.dispatch(bufferEvent) } protected notifyBufferScrolled(bufferScrollEvent: Oni.EditorBufferScrolledEventArgs): void { this._onBufferScrolledEvent.dispatch(bufferScrollEvent) } } ================================================ FILE: browser/src/Editor/NeovimEditor/BufferLayerManager.ts ================================================ /** * BufferLayerManager.ts * * BufferLayerManager tracks the lifecycle of 'buffer layers' */ import * as Oni from "oni-api" export type BufferLayerFactory = (buf: Oni.Buffer) => Oni.BufferLayer export type BufferFilter = (buf: Oni.Buffer) => boolean export interface IBufferLayer extends Oni.BufferLayer { handleInput?: (key: string) => boolean isActive?: () => boolean } export const createBufferFilterFromLanguage = (language: string) => (buf: Oni.Buffer): boolean => { if (!language || language === "*") { return true } else { return buf.language === language } } export interface BufferLayerInfo { filter: BufferFilter layerFactory: BufferLayerFactory } export class BufferLayerManager { private _layers: BufferLayerInfo[] = [] private _buffers: Oni.Buffer[] = [] public addBufferLayer( filterOrLanguage: BufferFilter | string, layerFactory: BufferLayerFactory, ) { const filter: BufferFilter = typeof filterOrLanguage === "string" ? createBufferFilterFromLanguage(filterOrLanguage) : filterOrLanguage this._layers.push({ filter, layerFactory, }) this._buffers.forEach(buf => { if (filter(buf)) { buf.addLayer(layerFactory(buf)) } }) } public notifyBufferEnter(buf: Oni.Buffer): void { if (this._buffers.indexOf(buf) === -1) { this._buffers.push(buf) this._layers.forEach(layerInfo => { if (layerInfo.filter(buf)) { buf.addLayer(layerInfo.layerFactory(buf)) } }) } } public notifyBufferFileTypeChanged(buf: Oni.Buffer): void { this._buffers = this._buffers.filter(b => b.id !== buf.id) this.notifyBufferEnter(buf) } } const getInstance = (() => { const instance = new BufferLayerManager() return () => instance })() export default getInstance export const wrapReactComponentWithLayer = ( id: string, component: JSX.Element, ): Oni.BufferLayer => { return { id, render: (context: Oni.BufferLayerRenderContext) => (context.isActive ? component : null), } } ================================================ FILE: browser/src/Editor/NeovimEditor/CompletionMenu.ts ================================================ /** * CompletionMenu.ts * * This is the completion menu that integrates with the completion providers * (which is primarily language server right now) * It's really just glue between the ContextMenu and Completion store. */ import * as types from "vscode-languageserver-types" import { Event, IEvent } from "oni-types" import { getDocumentationText } from "../../Plugins/Api/LanguageClient/LanguageClientHelpers" import { ContextMenu } from "./../../Services/ContextMenu" import * as CompletionUtility from "./../../Services/Completion/CompletionUtility" export class CompletionMenu { private _onItemFocusedEvent: Event = new Event() private _onItemSelectedEvent: Event = new Event() public get onItemFocused(): IEvent { return this._onItemFocusedEvent } public get onItemSelected(): IEvent { return this._onItemSelectedEvent } constructor(private _contextMenu: ContextMenu) { this._contextMenu.onSelectedItemChanged.subscribe(item => this._onItemFocusedEvent.dispatch(item.rawCompletion), ) this._contextMenu.onItemSelected.subscribe(item => this._onItemSelectedEvent.dispatch(item.rawCompletion), ) } public show(options: types.CompletionItem[], filterText: string): void { const menuOptions = options.map(_convertCompletionForContextMenu) if (this._contextMenu.isOpen()) { this._contextMenu.setItems(menuOptions) this._contextMenu.setFilter(filterText) } else { this._contextMenu.show(menuOptions, filterText) } } public hide(): void { this._contextMenu.hide() } } // TODO: Should this be moved to another level? Like over to the menu renderer? // It'd be nice if this layer only cared about `types.CompletionItem` and didn't // have to worry about presentational aspects.. const _convertCompletionForContextMenu = (completion: types.CompletionItem): any => ({ label: completion.label, detail: completion.detail, documentation: getCompletionDocumentation(completion), icon: CompletionUtility.convertKindToIconName(completion.kind), rawCompletion: completion, }) const getCompletionDocumentation = (item: types.CompletionItem): string | null => { if (item.documentation) { return getDocumentationText(item.documentation) } else if (item.data && item.data.documentation) { return item.data.documentation } else { return null } } ================================================ FILE: browser/src/Editor/NeovimEditor/Definition.ts ================================================ /** * Definition.ts */ import { Store } from "redux" import * as Oni from "oni-api" import * as Helpers from "./../../Plugins/Api/LanguageClient/LanguageClientHelpers" import * as State from "./NeovimEditorStore" export enum OpenType { NewTab = 0, SplitVertical = 1, SplitHorizontal = 2, } export class Definition { constructor(private _editor: Oni.Editor, private _store: Store) {} public async gotoDefinitionUnderCursor(openOptions?: Oni.FileOpenOptions): Promise { const activeDefinition = this._store.getState().definition if (!activeDefinition) { return } const { uri, range } = activeDefinition.definitionLocation const line = range.start.line const column = range.start.character await this.gotoPositionInUri(uri, line, column, openOptions) } public async gotoPositionInUri( uri: string, line: number, column: number, openOptions?: Oni.FileOpenOptions, ): Promise { const filePath = Helpers.unwrapFileUriPath(uri) const activeEditor = this._editor await this._editor.openFile(filePath, openOptions) await activeEditor.neovim.command(`cal cursor(${line + 1}, ${column + 1})`) await activeEditor.neovim.command("norm zz") } } ================================================ FILE: browser/src/Editor/NeovimEditor/FileDropHandler.tsx ================================================ import * as React from "react" type SetRef = (elem: HTMLElement) => void interface IFileDropHandler { handleFiles: (files: FileList) => void children: (args: { setRef: SetRef }) => React.ReactElement<{ setRef: SetRef }> } type DragTypeName = "ondragover" | "ondragleave" | "ondragenter" /** * Gets a target element via a callback ref and attaches a file drop event listener callback * N.B. the element cannot be obscured as this will prevent event transmission * @name FileDropHandler * @function * * @extends {React} */ export default class FileDropHandler extends React.Component { private _target: HTMLElement public componentDidMount() { this.addDropHandler() } public setRef = (element: HTMLElement) => { this._target = element } public addDropHandler() { if (!this._target) { return } const dragTypes = ["ondragenter", "ondragover", "ondragleave"] dragTypes.map((event: DragTypeName) => { if (this._target[event]) { this._target[event] = ev => { ev.preventDefault() ev.stopPropagation() } } }) this._target.ondrop = async ev => { const { files } = ev.dataTransfer if (files.length) { await this.props.handleFiles(files) } ev.preventDefault() } } public render() { return this.props.children({ setRef: this.setRef }) } } ================================================ FILE: browser/src/Editor/NeovimEditor/HoverRenderer.tsx ================================================ /** * Hover.tsx */ import * as Oni from "oni-api" import * as os from "os" import * as React from "react" import * as types from "vscode-languageserver-types" import getTokens from "./../../Services/SyntaxHighlighting/TokenGenerator" import styled, { enableMouse } from "./../../UI/components/common" import { ErrorInfo } from "./../../UI/components/ErrorInfo" import { QuickInfoElement } from "./../../UI/components/QuickInfo" import QuickInfoWithTheme from "./../../UI/components/QuickInfoContainer" import * as Helpers from "./../../Plugins/Api/LanguageClient/LanguageClientHelpers" import { Configuration } from "./../../Services/Configuration" import { convertMarkdown } from "./markdown" import { IToolTipsProvider } from "./ToolTipsProvider" const HoverToolTipId = "hover-tool-tip" const HoverRendererContainer = styled.div` user-select: none; cursor: default; ${enableMouse}; ` export class HoverRenderer { constructor( private _editor: Oni.Editor, private _configuration: Configuration, private _toolTipsProvider: IToolTipsProvider, ) {} public async showQuickInfo( x: number, y: number, hover: types.Hover, errors: types.Diagnostic[], ): Promise { const elem = await this._renderQuickInfoElement(hover, errors) if (!elem) { return } this._toolTipsProvider.showToolTip(HoverToolTipId, elem, { position: { pixelX: x, pixelY: y }, openDirection: 1, padding: "0px", }) } public hideQuickInfo(): void { this._toolTipsProvider.hideToolTip(HoverToolTipId) } private async _renderQuickInfoElement( hover: types.Hover, errors: types.Diagnostic[], ): Promise { const titleAndContents = await getTitleAndContents(hover) const showDebugScope = this._configuration.getValue( "editor.textMateHighlighting.debugScopes", ) const errorsExist = Boolean(errors && errors.length) const contentExists = Boolean(errorsExist || titleAndContents || showDebugScope) return ( contentExists && (
{showDebugScope && this._getDebugScopesElement()}
) ) } private _getDebugScopesElement(): JSX.Element { const editor: any = this._editor if (!editor || !editor.syntaxHighlighter) { return null } const cursor = editor.activeBuffer.cursor const scopeInfo = editor.syntaxHighlighter.getHighlightTokenAt(editor.activeBuffer.id, { line: cursor.line, character: cursor.column, }) if (!scopeInfo || !scopeInfo.scopes) { return null } const items = scopeInfo.scopes.map((si: string) =>
  • {si}
  • ) return (
    DEBUG: TextMate Scopes:
      {items}
    ) } } const html = (str: string) => ({ __html: str }) interface ErrorElementProps { errors: types.Diagnostic[] hasQuickInfo: boolean isVisible: boolean } const ErrorElement = ({ isVisible, errors, hasQuickInfo }: ErrorElementProps) => { return ( isVisible && ( ) ) } const getTitleAndContents = async (result: types.Hover) => { if (!result || !result.contents) { return null } const contents = Helpers.getTextFromContents(result.contents) if (!contents.length) { return null } const [{ value: titleContent, language }, ...remaining] = contents if (!titleContent) { return null } const remainder = remaining.map(r => r.value) const [hasRemainder] = remainder if (!hasRemainder) { const tokensPerLine = await getTokens({ language, line: titleContent }) return { title: html(convertMarkdown({ markdown: titleContent, tokens: tokensPerLine })), description: null, } } else { const descriptionContent = remainder.join(os.EOL) const tokensPerLine = await getTokens({ language, line: titleContent }) return { title: html(convertMarkdown({ markdown: titleContent, tokens: tokensPerLine })), description: html( convertMarkdown({ markdown: descriptionContent, type: "documentation", }), ), } } } ================================================ FILE: browser/src/Editor/NeovimEditor/NeovimActiveWindow.tsx ================================================ /** * ActiveWindow.tsx * * Helper component that is always sized and positioned around the currently * active window in Neovim. */ import * as React from "react" export interface IActiveWindowProps { pixelX: number pixelY: number pixelWidth: number pixelHeight: number } export class NeovimActiveWindow extends React.PureComponent { public render(): JSX.Element { const px = (str: number): string => `${str}px` const style: React.CSSProperties = { position: "absolute", left: px(this.props.pixelX), top: px(this.props.pixelY), width: px(this.props.pixelWidth), height: px(this.props.pixelHeight), overflowY: "hidden", overflowX: "hidden", } return
    {this.props.children}
    } } ================================================ FILE: browser/src/Editor/NeovimEditor/NeovimBufferLayersView.tsx ================================================ /** * NeovimLayersView.tsx * * Renders layers above vim windows */ import * as React from "react" import { connect } from "react-redux" import { createSelector } from "reselect" import * as Oni from "oni-api" import { NeovimActiveWindow } from "./NeovimActiveWindow" import * as State from "./NeovimEditorStore" import styled, { StackLayer } from "../../UI/components/common" export interface NeovimBufferLayersViewProps { activeWindowId: number windows: State.IWindow[] layers: State.Layers fontPixelWidth: number fontPixelHeight: number } const InnerLayer = styled.div` position: absolute; top: 0px; left: 0px; right: 0px; bottom: 0px; overflow: hidden; ` export interface LayerContextWithCursor extends Oni.BufferLayerRenderContext { cursorLine: number cursorColumn: number } export class NeovimBufferLayersView extends React.PureComponent { public render(): JSX.Element { const containers = this.props.windows.map(windowState => { const layers: Oni.BufferLayer[] = this.props.layers[windowState.bufferId] || [] const layerContext: LayerContextWithCursor = { isActive: windowState.windowId === this.props.activeWindowId, windowId: windowState.windowId, fontPixelWidth: this.props.fontPixelWidth, fontPixelHeight: this.props.fontPixelHeight, bufferToScreen: windowState.bufferToScreen, screenToPixel: windowState.screenToPixel, bufferToPixel: windowState.bufferToPixel, dimensions: windowState.dimensions, visibleLines: windowState.visibleLines, topBufferLine: windowState.topBufferLine, bottomBufferLine: windowState.bottomBufferLine, cursorColumn: windowState.column, cursorLine: windowState.line, } const layerElements = layers.map(layer => { return ( {layer.render(layerContext)} ) }) const dimensions = getWindowPixelDimensions(windowState) return ( {layerElements} ) }) return {containers} } } const EmptySize = { pixelX: -1, pixelY: -1, pixelWidth: 0, pixelHeight: 0, } const getWindowPixelDimensions = (win: State.IWindow) => { if (!win || !win.screenToPixel) { return EmptySize } const start = win.screenToPixel({ screenX: win.dimensions.x, screenY: win.dimensions.y, }) const size = win.screenToPixel({ screenX: win.dimensions.width, screenY: win.dimensions.height, }) return { pixelX: start.pixelX, pixelY: start.pixelY - 1, pixelWidth: size.pixelX, pixelHeight: size.pixelY + 2, } } const EmptyState: NeovimBufferLayersViewProps = { activeWindowId: -1, layers: {}, windows: [], fontPixelHeight: -1, fontPixelWidth: -1, } const getActiveVimTabPage = (state: State.IState) => state.activeVimTabPage const getWindowState = (state: State.IState) => state.windowState const windowSelector = createSelector( [getActiveVimTabPage, getWindowState], (tabPage: State.IVimTabPage, windowState: State.IWindowState) => { const windows = tabPage.windowIds.map(windowId => { return windowState.windows[windowId] }) return windows.sort((a, b) => a.windowId - b.windowId) }, ) const mapStateToProps = (state: State.IState): NeovimBufferLayersViewProps => { if (!state.activeVimTabPage) { return EmptyState } const windows = windowSelector(state) return { activeWindowId: state.windowState.activeWindow, windows, layers: state.layers, fontPixelWidth: state.fontPixelWidth, fontPixelHeight: state.fontPixelHeight, } } export const NeovimBufferLayers = connect(mapStateToProps)(NeovimBufferLayersView) ================================================ FILE: browser/src/Editor/NeovimEditor/NeovimEditor.tsx ================================================ /** * NeovimEditor.ts * * Editor implementation for Neovim */ import * as os from "os" import * as React from "react" import "rxjs/add/observable/defer" import "rxjs/add/observable/merge" import "rxjs/add/operator/map" import "rxjs/add/operator/mergeMap" import { Observable } from "rxjs/Observable" import * as types from "vscode-languageserver-types" import { Provider } from "react-redux" import { bindActionCreators, Store } from "redux" import { clipboard, ipcRenderer } from "electron" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { Event, IEvent } from "oni-types" import { addDefaultUnitIfNeeded } from "./../../Font" import { BufferEventContext, EventContext, INeovimStartOptions, NeovimInstance, NeovimScreen, NeovimWindowManager, ScreenWithPredictions, } from "./../../neovim" import { INeovimRenderer } from "./../../Renderer" import { PluginManager } from "./../../Plugins/PluginManager" import { IColors } from "./../../Services/Colors" import { commandManager } from "./../../Services/CommandManager" import { Completion, CompletionProviders } from "./../../Services/Completion" import { Configuration, IConfigurationValues } from "./../../Services/Configuration" import { IDiagnosticsDataSource } from "./../../Services/Diagnostics" import { Overlay, OverlayManager } from "./../../Services/Overlay" import { ISession } from "./../../Services/Sessions" import { SnippetManager } from "./../../Services/Snippets" import { TokenColors } from "./../../Services/TokenColors" import * as Shell from "./../../UI/Shell" import { addInsertModeLanguageFunctionality, LanguageEditorIntegration, LanguageManager, } from "./../../Services/Language" import { ISyntaxHighlighter, NullSyntaxHighlighter, SyntaxHighlighter, } from "./../../Services/SyntaxHighlighting" import { MenuManager } from "./../../Services/Menu" import { IThemeMetadata, ThemeManager } from "./../../Services/Themes" import { TypingPredictionManager } from "./../../Services/TypingPredictionManager" import { Workspace } from "./../../Services/Workspace" import { Editor } from "./../Editor" import { BufferManager } from "./../BufferManager" import { CompletionMenu } from "./CompletionMenu" import { HoverRenderer } from "./HoverRenderer" import { NeovimPopupMenu } from "./NeovimPopupMenu" import NeovimSurface from "./NeovimSurface" import { ContextMenuManager } from "./../../Services/ContextMenu" import { asObservable, normalizePath, sleep } from "./../../Utility" import * as VimConfigurationSynchronizer from "./../../Services/VimConfigurationSynchronizer" import getLayerManagerInstance from "./BufferLayerManager" import { Definition } from "./Definition" import * as ActionCreators from "./NeovimEditorActions" import { NeovimEditorCommands } from "./NeovimEditorCommands" import { createStore, IState, ITab } from "./NeovimEditorStore" import { Rename } from "./Rename" import { Symbols } from "./Symbols" import { IToolTipsProvider, NeovimEditorToolTipsProvider } from "./ToolTipsProvider" import CommandLine from "./../../UI/components/CommandLine" import ExternalMenus from "./../../UI/components/ExternalMenus" import WildMenu from "./../../UI/components/WildMenu" import { CanvasRenderer, WebGLRenderer } from "../../Renderer" import { getInstance as getNotificationsInstance } from "./../../Services/Notifications" type NeovimError = [number, string] export class NeovimEditor extends Editor implements Oni.Editor { private _bufferManager: BufferManager private _neovimInstance: NeovimInstance private _renderer: INeovimRenderer private _screen: NeovimScreen private _completionMenu: CompletionMenu private _contextMenuManager: ContextMenuManager private _popupMenu: NeovimPopupMenu private _errorInitializing: boolean = false private _store: Store private _actions: typeof ActionCreators private _pendingAnimationFrame: boolean = false private _onEnterEvent: Event = new Event() private _modeChanged$: Observable private _cursorMoved$: Observable private _cursorMovedI$: Observable private _onScroll$: Observable private _hasLoaded: boolean = false private _windowManager: NeovimWindowManager private _currentColorScheme: string = "" private _currentBackground: string = "" private _isFirstRender: boolean = true private _lastBufferId: string = null private _typingPredictionManager: TypingPredictionManager = new TypingPredictionManager() private _syntaxHighlighter: ISyntaxHighlighter private _languageIntegration: LanguageEditorIntegration private _completion: Completion private _hoverRenderer: HoverRenderer private _rename: Rename = null private _symbols: Symbols = null private _definition: Definition = null private _toolTipsProvider: IToolTipsProvider private _commands: NeovimEditorCommands private _externalMenuOverlay: Overlay private _bufferLayerManager = getLayerManagerInstance() private _screenWithPredictions: ScreenWithPredictions private _onShowWelcomeScreen = new Event() private _onNeovimQuit: Event = new Event() private _autoFocus: boolean = true public get onNeovimQuit(): IEvent { return this._onNeovimQuit } public get onShowWelcomeScreen() { return this._onShowWelcomeScreen } public get /* override */ activeBuffer(): Oni.Buffer { return this._bufferManager.getBufferById(this._lastBufferId) } // Capabilities public get neovim(): Oni.NeovimEditorCapability { return this._neovimInstance } public get bufferLayers() { 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 } constructor( private _colors: IColors, private _completionProviders: CompletionProviders, private _configuration: Configuration, private _diagnostics: IDiagnosticsDataSource, private _languageManager: LanguageManager, private _menuManager: MenuManager, private _overlayManager: OverlayManager, private _pluginManager: PluginManager, private _snippetManager: SnippetManager, private _themeManager: ThemeManager, private _tokenColors: TokenColors, private _workspace: Workspace, ) { super() this._store = createStore() this._actions = bindActionCreators(ActionCreators as any, this._store.dispatch) this._toolTipsProvider = new NeovimEditorToolTipsProvider(this._actions) this._contextMenuManager = new ContextMenuManager(this._toolTipsProvider) this._neovimInstance = new NeovimInstance(100, 100, this._configuration) this._bufferManager = new BufferManager(this._neovimInstance, this._actions, this._store) this._screen = new NeovimScreen() this._screenWithPredictions = new ScreenWithPredictions(this._screen, this._configuration) this._hoverRenderer = new HoverRenderer(this, this._configuration, this._toolTipsProvider) this._definition = new Definition(this, this._store) this._symbols = new Symbols( this, this._definition, this._languageManager, this._menuManager, ) this._diagnostics.onErrorsChanged.subscribe(() => { const errors = this._diagnostics.getErrors() this._actions.setErrors(errors) }) this._externalMenuOverlay = this._overlayManager.createItem() this._externalMenuOverlay.setContents( , ) this._popupMenu = new NeovimPopupMenu( this._neovimInstance.onShowPopupMenu, this._neovimInstance.onHidePopupMenu, this._neovimInstance.onSelectPopupMenu, this.onBufferEnter, this._toolTipsProvider, ) const notificationManager = getNotificationsInstance() this._neovimInstance.onMessage.subscribe(messageInfo => { // TODO: Hook up real notifications const notification = notificationManager.createItem() notification.setLevel("error") notification.setContents(messageInfo.title, messageInfo.details) notification.onClick.subscribe(() => commandManager.executeCommand("oni.config.openInitVim"), ) notification.show() }) const initVimPath = this._neovimInstance.doesInitVimExist() const initVimInUse = this._configuration.getValue("oni.loadInitVim") const hasCheckedInitVim = this._configuration.getValue("_internal.hasCheckedInitVim") if (initVimPath && !initVimInUse && !hasCheckedInitVim) { const initVimNotification = notificationManager.createItem() initVimNotification.setLevel("info") initVimNotification.setContents( "init.vim found", `We found an init.vim file would you like Oni to use it? This will result in Oni being reloaded`, ) initVimNotification.setButtons([ { title: "Yes", callback: () => { this._configuration.setValues( { "_internal.hasCheckedInitVim": true, "oni.loadInitVim": true }, true, ) commandManager.executeCommand("oni.debug.reload") }, }, { title: "No", callback: () => { this._configuration.setValues( { "oni.loadInitVim": false, "_internal.hasCheckedInitVim": true }, true, ) }, }, ]) initVimNotification.show() } const ligaturesEnabled = this._configuration.getValue("editor.fontLigatures") this._renderer = this._configuration.getValue("editor.renderer") === "webgl" ? new WebGLRenderer(ligaturesEnabled) : new CanvasRenderer() this._rename = new Rename( this, this._languageManager, this._toolTipsProvider, this._workspace, ) // Services const onColorsChanged = () => { const updatedColors = this._colors.getColors() this._actions.setColors(updatedColors) } this._colors.onColorsChanged.subscribe(onColorsChanged) onColorsChanged() this.trackDisposable( this._tokenColors.onTokenColorsChanged.subscribe(() => { if (this._neovimInstance.isInitialized) { this._syntaxHighlighter.notifyColorschemeRedraw(`${this.activeBuffer.id}`) } }), ) // Overlays // TODO: Replace `OverlayManagement` concept and associated window management code with // explicit window management: #362 this._windowManager = new NeovimWindowManager(this._neovimInstance) 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.trackDisposable( this._neovimInstance.onWildMenuShow.subscribe(wildMenuInfo => { this._actions.showWildMenu(wildMenuInfo) }), ) this.trackDisposable( this._neovimInstance.onWildMenuSelect.subscribe(wildMenuInfo => { this._actions.wildMenuSelect(wildMenuInfo) }), ) this.trackDisposable( this._neovimInstance.onWildMenuHide.subscribe(() => { this._actions.hideWildMenu() }), ) this.trackDisposable( this._neovimInstance.onCommandLineHide.subscribe(() => { this._actions.hideCommandLine() this._externalMenuOverlay.hide() }), ) this.trackDisposable( this._neovimInstance.onCommandLineSetCursorPosition.subscribe(commandLinePos => { this._actions.setCommandLinePosition(commandLinePos) }), ) this.trackDisposable( this._windowManager.onWindowStateChanged.subscribe(tabPageState => { if (!tabPageState) { return } 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, activeWindow.visibleLines, ) } filteredTabState.map(w => { this._actions.setInactiveWindowState(w.windowNumber, w.dimensions) }) }), ) 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) { const content = yankInfo.regcontents.join(os.EOL) const postfix = yankInfo.regtype === "V" ? os.EOL : "" clipboard.writeText(content + postfix) } } }), ) this.trackDisposable( this._neovimInstance.onTitleChanged.subscribe(newTitle => { const title = newTitle.replace(" - NVIM", " - ONI") Shell.Actions.setWindowTitle(title) }), ) this.trackDisposable( this._neovimInstance.onLeave.subscribe(() => { this._onNeovimQuit.dispatch() }), ) this.trackDisposable( this._neovimInstance.onOniCommand.subscribe(context => { const commandToExecute = context.command const commandArgs = context.args commandManager.executeCommand(commandToExecute, commandArgs) }), ) // 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.trackDisposable( this._neovimInstance.autoCommands.onBufDelete.subscribe((evt: BufferEventContext) => this._onBufDelete(evt), ), ) this.trackDisposable( this._neovimInstance.autoCommands.onBufUnload.subscribe((evt: BufferEventContext) => this._onBufUnload(evt), ), ) this.trackDisposable( this._neovimInstance.autoCommands.onBufEnter.subscribe((evt: BufferEventContext) => this._onBufEnter(evt), ), ) this.trackDisposable( this._neovimInstance.autoCommands.onBufWinEnter.subscribe((evt: BufferEventContext) => this._onBufEnter(evt), ), ) this.trackDisposable( this._neovimInstance.autoCommands.onFileTypeChanged.subscribe((evt: EventContext) => this._onFileTypeChanged(evt), ), ) this.trackDisposable( this._neovimInstance.autoCommands.onBufWipeout.subscribe((evt: BufferEventContext) => this._onBufWipeout(evt), ), ) this.trackDisposable( this._neovimInstance.autoCommands.onBufWritePost.subscribe((evt: EventContext) => this._onBufWritePost(evt), ), ) 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.trackDisposable( this._neovimInstance.onDirectoryChanged.subscribe(async newDirectory => { if (newDirectory !== this._workspace.activeWorkspace) { await this._workspace.changeDirectory(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) this._scheduleRender() }) this._typingPredictionManager.onPredictionsChanged.subscribe(predictions => { this._screenWithPredictions.updatePredictions(predictions, this._screen.cursorRow) this._renderImmediate() }) 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]]] }) const response = await this._neovimInstance.request("nvim_call_atomic", [atomicCalls]) tabs.map((tab: ITab, index: number) => { tab.buffersInTab = response[0][index] instanceof Array ? response[0][index] : [] }) this._actions.setTabs(currentTabId, tabs) }) // TODO: Does any disposal need to happen for the observables? this._cursorMoved$ = asObservable(this._neovimInstance.autoCommands.onCursorMoved).map( (evt): Oni.Cursor => ({ line: evt.line - 1, column: evt.column - 1, }), ) this._cursorMovedI$ = asObservable(this._neovimInstance.autoCommands.onCursorMovedI).map( (evt): Oni.Cursor => ({ line: evt.line - 1, column: evt.column - 1, }), ) Observable.merge(this._cursorMoved$, this._cursorMovedI$).subscribe(cursorMoved => { this.notifyCursorMoved(cursorMoved) }) this._modeChanged$ = asObservable(this._neovimInstance.onModeChanged) this._onScroll$ = asObservable(this._neovimInstance.onScroll) this.trackDisposable( this._neovimInstance.onModeChanged.subscribe(newMode => this._onModeChanged(newMode)), ) this.trackDisposable( this._neovimInstance.onBufferUpdate.subscribe(update => { const buffer = this._bufferManager.updateBufferFromEvent(update.eventContext) const bufferUpdate = { context: update.eventContext, buffer, contentChanges: update.contentChanges, } this.notifyBufferChanged(bufferUpdate) this._actions.bufferUpdate( parseInt(bufferUpdate.buffer.id, 10), bufferUpdate.buffer.modified, bufferUpdate.buffer.lineCount, ) 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$, this._modeChanged$, this._onScroll$, this._toolTipsProvider, ) const textMateHighlightingEnabled = this._configuration.getValue( "editor.textMateHighlighting.enabled", ) this._syntaxHighlighter = textMateHighlightingEnabled ? new SyntaxHighlighter(this, this._tokenColors) : new NullSyntaxHighlighter() this._completion = new Completion( this, this._configuration, this._completionProviders, this._languageManager, this._snippetManager, this._syntaxHighlighter, ) this._completionMenu = new CompletionMenu(this._contextMenuManager.create()) this.trackDisposable( this._completion.onShowCompletionItems.subscribe(completions => { this._completionMenu.show(completions.filteredCompletions, completions.base) }), ) this.trackDisposable( this._completion.onHideCompletionItems.subscribe(completions => { this._completionMenu.hide() }), ) this.trackDisposable( this._completionMenu.onItemFocused.subscribe(item => { this._completion.resolveItem(item) }), ) this.trackDisposable( this._completionMenu.onItemSelected.subscribe(item => { this._completion.commitItem(item) }), ) this._languageIntegration = new LanguageEditorIntegration( this, this._configuration, this._languageManager, ) 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.trackDisposable( this._languageIntegration.onHideHover.subscribe(() => { this._hoverRenderer.hideQuickInfo() }), ) this.trackDisposable( this._languageIntegration.onShowDefinition.subscribe(definition => { this._actions.setDefinition(definition.token, definition.location) }), ) this.trackDisposable( this._languageIntegration.onHideDefinition.subscribe(definition => { this._actions.hideDefinition() }), ) this._commands = new NeovimEditorCommands( commandManager, this._contextMenuManager, this._definition, this._languageIntegration, this._neovimInstance, this._rename, this._symbols, ) this._renderImmediate() this._onConfigChanged(this._configuration.getValues()) this.trackDisposable( this._configuration.onConfigurationChanged.subscribe( (newValues: Partial) => this._onConfigChanged(newValues), ), ) // 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}`) }) } public async blockInput( inputFunction: (inputCallback: Oni.InputCallbackFunction) => Promise, ): Promise { return this._neovimInstance.blockInput(inputFunction) } public async checkMapping( key: string, mode: "n" | "v" | "i", ): Promise<{ key: string; mapping: string }> { return this._neovimInstance.checkUserMapping({ key, mode }) } public dispose(): void { super.dispose() if (this._neovimInstance) { this._neovimInstance.dispose() this._neovimInstance = null } if (this._syntaxHighlighter) { this._syntaxHighlighter.dispose() this._syntaxHighlighter = null } if (this._languageIntegration) { this._languageIntegration.dispose() this._languageIntegration = null } if (this._completion) { this._completion.dispose() this._completion = null } if (this._externalMenuOverlay) { this._externalMenuOverlay.hide() this._externalMenuOverlay = null } if (this._popupMenu) { this._popupMenu.dispose() this._popupMenu = null } if (this._windowManager) { this._windowManager.dispose() this._windowManager = null } } public enter(): void { 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 createWelcomeBuffer() { const buf = await this.openFile("WELCOME") await buf.setScratchBuffer() return buf } public async clearSelection(): Promise { await this._neovimInstance.input("") await this._neovimInstance.input("a") } public async setSelection(range: types.Range): Promise { await this._neovimInstance.input("") // Clear out any pending block selection // Without this, if there was a line-wise visual selection, // range selection would not work correctly. const atomicCallsVisualMode = [ [ "nvim_call_function", ["setpos", [".", [0, range.start.line + 1, range.start.character + 1]]], ], ["nvim_command", ["normal! v"]], [ "nvim_call_function", ["setpos", [".", [0, range.end.line + 1, range.end.character + 1]]], ], ] await this._neovimInstance.request("nvim_call_atomic", [atomicCallsVisualMode]) await this._neovimInstance.input("") // Re-select the selection and switch to 'select' mode so that typing // overwrites the selection const atomicCalls = [ [ "nvim_call_function", ["setpos", ["'<", [0, range.start.line + 1, range.start.character + 1]]], ], [ "nvim_call_function", ["setpos", ["'>", [0, range.end.line + 1, range.end.character + 1]]], ], // ["nvim_command", ["normal! v"]], ["nvim_command", ["set selectmode=cmd"]], ["nvim_command", ["normal! gv"]], ["nvim_command", ["set selectmode="]], ] await this._neovimInstance.request("nvim_call_atomic", [atomicCalls]) } public async setTextOptions(textOptions: Oni.EditorTextOptions): Promise { const { insertSpacesForTab, tabSize } = textOptions if (insertSpacesForTab) { await this._neovimInstance.command("set expandtab") } else { await this._neovimInstance.command("set noexpandtab") } await this._neovimInstance.command( `set tabstop=${tabSize} shiftwidth=${tabSize} softtabstop=${tabSize}`, ) } // "v:this_session" |this_session-variable| - is a variable nvim sets to the path of // the current session file when one is loaded we use it here to check the current session // if it in oni's session dir then this is updated public async getCurrentSession(): Promise { const result = await this._neovimInstance.request("nvim_get_vvar", [ "this_session", ]) if (Array.isArray(result)) { return this._handleNeovimError(result) } return result } public async persistSession(session: ISession) { const result = await this._neovimInstance.command(`mksession! ${session.file}`) return this._handleNeovimError(result) } public async restoreSession(session: ISession) { await this._neovimInstance.closeAllBuffers() const result = await this._neovimInstance.command(`source ${session.file}`) return this._handleNeovimError(result) } public async openFile( file: string, openOptions: Oni.FileOpenOptions = Oni.DefaultFileOpenOptions, ): Promise { const tabsMode = this._configuration.getValue("tabs.mode") === "tabs" const cmd = new Proxy( { [Oni.FileOpenMode.NewTab]: "tabnew!", [Oni.FileOpenMode.HorizontalSplit]: "sp!", [Oni.FileOpenMode.VerticalSplit]: "vsp!", [Oni.FileOpenMode.Edit]: tabsMode ? "tab drop" : "e!", [Oni.FileOpenMode.ExistingTab]: "e!", }, { get: (target: { [cmd: string]: string }, name: string) => name in target ? target[name] : "e!", }, ) await this._neovimInstance.command( `:${cmd[openOptions.openMode]} ${this._escapeSpaces(file)}`, ) return this.activeBuffer } public openFiles = async ( 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() const buffer = this._bufferManager.updateBufferFromEvent(context) return buffer } public executeCommand(command: string): void { commandManager.executeCommand(command, null) } public _onFilesDropped = async (files: FileList) => { if (files.length) { const normalisedPaths = Array.from(files).map(f => normalizePath(f.path)) await this.openFiles(normalisedPaths, { openMode: Oni.FileOpenMode.Edit }) } } public async init( filesToOpen: string[], startOptions?: Partial, ): Promise { Log.info("[NeovimEditor::init] Called with filesToOpen: " + filesToOpen) const defaultOptions: INeovimStartOptions = { runtimePaths: this._pluginManager.getAllRuntimePaths(), transport: this._configuration.getValue("experimental.neovim.transport"), neovimPath: this._configuration.getValue("debug.neovimPath"), loadInitVim: this._configuration.getValue("oni.loadInitVim"), useDefaultConfig: this._configuration.getValue("oni.useDefaultConfig"), } const combinedOptions = { ...defaultOptions, ...startOptions, } await this._neovimInstance.start(combinedOptions) if (this._errorInitializing) { return } VimConfigurationSynchronizer.synchronizeConfiguration( this._neovimInstance, this._configuration.getValues(), ) this._themeManager.onThemeChanged.subscribe(() => { const newTheme = this._themeManager.activeTheme if ( newTheme.baseVimTheme && (newTheme.baseVimTheme !== this._currentColorScheme || newTheme.baseVimBackground !== this._currentBackground) ) { this.setColorSchemeFromTheme(newTheme) } }) if (this._themeManager.activeTheme && this._themeManager.activeTheme.baseVimTheme) { await this.setColorSchemeFromTheme(this._themeManager.activeTheme) } if (filesToOpen && filesToOpen.length > 0) { await this.openFiles(filesToOpen, { openMode: Oni.FileOpenMode.Edit }) } else { if (this._configuration.getValue("experimental.welcome.enabled")) { this._onShowWelcomeScreen.dispatch() } } this._actions.setLoadingComplete() this._hasLoaded = true this._isFirstRender = true this._scheduleRender() } public async setColorSchemeFromTheme(theme: IThemeMetadata): Promise { if ( (theme.baseVimBackground === "dark" || theme.baseVimBackground === "light") && theme.baseVimBackground !== this._currentBackground ) { await this._neovimInstance.command(":set background=" + theme.baseVimBackground) this._currentBackground = theme.baseVimBackground } await this._neovimInstance.command(":color " + theme.baseVimTheme) } public getBuffers(): Array { return this._bufferManager.getBuffers() } public async bufferDelete(bufferId: string = this.activeBuffer.id): Promise { // FIXME: currently this command forces a bufEnter event by navigating away // from the closed buffer which is currently the only means of updating Oni // post a BufDelete event await this._neovimInstance.command(`bd ${bufferId}`) if (bufferId === "%" || bufferId === this.activeBuffer.id) { await this._neovimInstance.command(`bnext`) } else { await this._neovimInstance.command(`bnext`) await this._neovimInstance.command(`bprev`) } } public render(): JSX.Element { const onBufferClose = (bufferId: number) => { this._neovimInstance.command(`bw! ${bufferId}`) } const onBufferSelect = (bufferId: number) => { this._neovimInstance.command(`buf ${bufferId}`) } const onTabClose = (tabId: number) => { this._neovimInstance.command(`tabclose ${tabId}`) } const onTabSelect = (tabId: number) => { this._neovimInstance.command(`tabn ${tabId}`) } const onKeyDown = (key: string) => { this.input(key) } return ( this._onBounceStart()} onBounceEnd={() => this._onBounceEnd()} onImeStart={() => this._onImeStart()} onImeEnd={() => this._onImeEnd()} onTabClose={onTabClose} onTabSelect={onTabSelect} /> ) } public async input(key: string): Promise { if (this._configuration.getValue("debug.fakeLag.neovimInput")) { await sleep(this._configuration.getValue("debug.fakeLag.neovimInput")) } // Check if any of the buffer layers can handle the input... const buf = this.activeBuffer const layerInputHandler = buf && buf.handleInput(key) if (layerInputHandler) { return } await this._neovimInstance.input(key) } public async quit(): Promise { if (this._windowManager) { this._windowManager.dispose() this._windowManager = null } return this._neovimInstance.quit() } private _onBounceStart(): void { this._actions.setCursorScale(1.1) } private _onBounceEnd(): void { this._actions.setCursorScale(1.0) } private _onModeChanged(newMode: string): void { // 'Bounce' the cursor for show match if (newMode === "showmatch") { this._actions.setCursorScale(0.9) } this._typingPredictionManager.clearAllPredictions() if (newMode === "insert" && this._configuration.getValue("editor.typingPrediction")) { this._typingPredictionManager.enable() } else { this._typingPredictionManager.disable() } this._actions.setMode(newMode) this.setMode(newMode as Oni.Vim.Mode) } private _updateWindow(currentBuffer: EventContext) { this._actions.setWindowCursor( currentBuffer.windowNumber, currentBuffer.line - 1, currentBuffer.column - 1, ) // Convert to 0-based positions this._syntaxHighlighter.notifyViewportChanged( currentBuffer.bufferNumber.toString(), currentBuffer.windowTopLine - 1, currentBuffer.windowBottomLine - 1, ) } private _onFileTypeChanged(evt: EventContext): void { const buf = this._bufferManager.updateBufferFromEvent(evt) this._bufferLayerManager.notifyBufferFileTypeChanged(buf) } private async _onBufEnter(evt: BufferEventContext): Promise { const buf = this._bufferManager.updateBufferFromEvent(evt.current) this._bufferManager.populateBufferList(evt) this._workspace.autoDetectWorkspace(buf.filePath) const lastBuffer = this.activeBuffer if (lastBuffer && lastBuffer.filePath !== buf.filePath) { this.notifyBufferLeave({ filePath: lastBuffer.filePath, language: lastBuffer.language, }) } this._lastBufferId = evt.current.bufferNumber.toString() this.notifyBufferEnter(buf) this._bufferLayerManager.notifyBufferEnter(buf) // Existing buffers contains a duplicate current buffer object which should be filtered out // and current buffer sent instead. Finally Filter out falsy viml values. const existingBuffersWithoutCurrent = evt.existingBuffers.filter( b => b.bufferNumber !== evt.current.bufferNumber, ) const buffers = [evt.current, ...existingBuffersWithoutCurrent].filter(b => !!b) this._actions.bufferEnter(buffers) } private _escapeSpaces(str: string): string { return str.split(" ").join("\\ ") } private _onImeStart(): void { this._actions.setImeActive(true) } private _onImeEnd(): void { this._actions.setImeActive(false) } private async _onBufWritePost(evt: EventContext): Promise { // After we save we aren't modified... but we can pass it in just to be safe this._actions.bufferSave(evt.bufferNumber, evt.modified, evt.version) this.notifyBufferSaved({ filePath: evt.bufferFullPath, language: evt.filetype, }) } private async _onBufUnload(evt: BufferEventContext): Promise { this._bufferManager.populateBufferList(evt) this._neovimInstance.getBufferIds().then(ids => this._actions.setCurrentBuffers(ids)) } private async _onBufDelete(evt: BufferEventContext): Promise { this._bufferManager.populateBufferList(evt) this._neovimInstance.getBufferIds().then(ids => this._actions.setCurrentBuffers(ids)) } private async _onBufWipeout(evt: BufferEventContext): Promise { this._bufferManager.populateBufferList(evt) this._neovimInstance.getBufferIds().then(ids => this._actions.setCurrentBuffers(ids)) } private _onConfigChanged(newValues: Partial): void { const fontFamily = this._configuration.getValue("editor.fontFamily") const fontSize = addDefaultUnitIfNeeded(this._configuration.getValue("editor.fontSize")) const fontWeight = this._configuration.getValue("editor.fontWeight") const linePadding = this._configuration.getValue("editor.linePadding") this._actions.setFont(fontFamily, fontSize, fontWeight) this._neovimInstance.setFont(fontFamily, fontSize, fontWeight, linePadding) Object.keys(newValues).forEach(key => { const value = newValues[key] this._actions.setConfigValue(key, value) }) if (this._hasLoaded) { VimConfigurationSynchronizer.synchronizeConfiguration(this._neovimInstance, newValues) } this._isFirstRender = true this._scheduleRender() } private async _onColorsChanged(): Promise { const newColorScheme = await this._neovimInstance.eval("g:colors_name") const { bufferNumber } = await this._neovimInstance.getContext() this._syntaxHighlighter.notifyColorschemeRedraw(`${bufferNumber}`) // In error cases, the neovim API layer returns an array if (typeof newColorScheme !== "string") { return } this._currentColorScheme = newColorScheme const backgroundColor = this._screen.backgroundColor const foregroundColor = this._screen.foregroundColor Log.info( `[NeovimEditor] Colors changed: ${newColorScheme} - background: ${backgroundColor} foreground: ${foregroundColor}`, ) this._themeManager.notifyVimThemeChanged(newColorScheme, backgroundColor, foregroundColor) const tokenColors = await this._neovimInstance.getTokenColors() this._tokenColors.setDefaultTokenColors(tokenColors) // Flip first render to force a full redraw this._isFirstRender = true this._scheduleRender() } private _scheduleRender(): void { if (this._pendingAnimationFrame) { return } this._pendingAnimationFrame = true window.requestAnimationFrame(() => this._renderImmediate()) } private _renderImmediate(): void { this._pendingAnimationFrame = false if (this._hasLoaded) { if (this._isFirstRender) { this._isFirstRender = false this._renderer.redrawAll(this._screenWithPredictions as any) } else { this._renderer.draw(this._screenWithPredictions as any) } } } private _handleNeovimError(result: NeovimError | void): void { if (!result) { return null } // the first value of the error response is a 0 if (Array.isArray(result) && !result[0]) { const [, error] = result Log.warn(error) throw new Error(error) } } } ================================================ FILE: browser/src/Editor/NeovimEditor/NeovimEditorActions.ts ================================================ /** * ActionCreators.ts * * Action Creators are relatively simple - they are just a function that returns an `Action` * * For information on Action Creators, check out this link: * http://redux.js.org/docs/basics/Actions.html */ import * as types from "vscode-languageserver-types" import * as Oni from "oni-api" import * as State from "./NeovimEditorStore" import { EventContext, InactiveBufferContext, IScreen } from "./../../neovim" import { normalizePath } from "./../../Utility" import { IConfigurationValues } from "./../../Services/Configuration" import { Errors } from "./../../Services/Diagnostics" import { IThemeColors } from "./../../Services/Themes" import { IBufferLayer } from "./../NeovimEditor/BufferLayerManager" export type DispatchFunction = (action: any) => void export type GetStateFunction = () => State.IState export interface ISetHasFocusAction { type: "SET_HAS_FOCUS" payload: { hasFocus: boolean } } export interface ISetLoadingCompleteAction { type: "SET_LOADING_COMPLETE" } export interface ISetColorsAction { type: "SET_COLORS" payload: { colors: IThemeColors } } export interface IAddBufferLayerAction { type: "ADD_BUFFER_LAYER" payload: { bufferId: number layer: IBufferLayer } } export interface IRemoveBufferLayerAction { type: "REMOVE_BUFFER_LAYER" payload: { bufferId: number layer: IBufferLayer } } export interface ISetViewportAction { type: "SET_VIEWPORT" payload: { width: number height: number } } export interface ISetCommandLinePosition { type: "SET_COMMAND_LINE_POSITION" payload: { position: number level: number } } export interface IHideCommandLineAction { type: "HIDE_COMMAND_LINE" } export interface IShowCommandLineAction { type: "SHOW_COMMAND_LINE" payload: { content: Array<[any, string]> position: number firstchar: string prompt: string indent: number level: number } } export interface IWildMenuSelectedAction { type: "WILDMENU_SELECTED" payload: { selected: number } } export interface IShowWildMenuAction { type: "SHOW_WILDMENU" payload: { options: string[] } } export interface IHideWildMenuAction { type: "HIDE_WILDMENU" } export interface ISetNeovimErrorAction { type: "SET_NEOVIM_ERROR" payload: { neovimError: boolean } } export interface ISetCursorScaleAction { type: "SET_CURSOR_SCALE" payload: { cursorScale: number } } export interface ISetCurrentBuffersAction { type: "SET_CURRENT_BUFFERS" payload: { bufferIds: number[] } } export interface ISetImeActive { type: "SET_IME_ACTIVE" payload: { imeActive: boolean } } export interface ISetFont { type: "SET_FONT" payload: { fontFamily: string fontSize: string fontWeight: string } } export interface IBufferEnterAction { type: "BUFFER_ENTER" payload: { buffers: State.IBuffer[] } } export interface IShowToolTipAction { type: "SHOW_TOOL_TIP" payload: { id: string element: JSX.Element options?: Oni.ToolTip.ToolTipOptions } } export interface IHideToolTipAction { type: "HIDE_TOOL_TIP" payload: { id: string } } export interface IBufferUpdateAction { type: "BUFFER_UPDATE" payload: { id: number modified: boolean version: number totalLines: number } } export interface IBufferSaveAction { type: "BUFFER_SAVE" payload: { id: number modified: boolean version: number } } export interface ISetTabs { type: "SET_TABS" payload: { selectedTabId: number tabs: State.ITab[] } } export interface ISetActiveVimTabPage { type: "SET_ACTIVE_VIM_TAB_PAGE" payload: { id: number windowIds: number[] } } export interface ISetWindowCursor { type: "SET_WINDOW_CURSOR" payload: { windowId: number line: number column: number } } export interface ISetWindowState { type: "SET_WINDOW_STATE" payload: { windowId: number bufferId: number file: string column: number line: number dimensions: Oni.Shapes.Rectangle bufferToScreen: Oni.Coordinates.BufferToScreen screenToPixel: Oni.Coordinates.ScreenToPixel bufferToPixel: Oni.Coordinates.BufferToPixel topBufferLine: number bottomBufferLine: number visibleLines: string[] } } export interface ISetInactiveWindowState { type: "SET_INACTIVE_WINDOW_STATE" payload: { windowId: number dimensions: Oni.Shapes.Rectangle } } export interface ISetErrorsAction { type: "SET_ERRORS" payload: { errors: Errors } } export interface ISetCursorPositionAction { type: "SET_CURSOR_POSITION" payload: { pixelX: number pixelY: number fontPixelWidth: number fontPixelHeight: number cursorCharacter: string cursorPixelWidth: number } } export interface ISetModeAction { type: "SET_MODE" payload: { mode: string } } export interface IShowDefinitionAction { type: "SHOW_DEFINITION" payload: { token: Oni.IToken definitionLocation: types.Location } } export interface IHideDefinitionAction { type: "HIDE_DEFINITION" } export interface ISetConfigurationValue { type: "SET_CONFIGURATION_VALUE" payload: { key: K value: IConfigurationValues[K] } } export type Action = SimpleAction | ActionWithGeneric export type SimpleAction = | IAddBufferLayerAction | IRemoveBufferLayerAction | IBufferEnterAction | IBufferSaveAction | IBufferUpdateAction | ISetColorsAction | ISetCursorPositionAction | ISetImeActive | ISetFont | IHideToolTipAction | IShowToolTipAction | IHideDefinitionAction | IShowDefinitionAction | ISetModeAction | ISetCursorScaleAction | ISetErrorsAction | ISetCurrentBuffersAction | ISetHasFocusAction | ISetNeovimErrorAction | ISetTabs | ISetActiveVimTabPage | ISetLoadingCompleteAction | ISetViewportAction | ISetWindowCursor | ISetWindowState | ISetInactiveWindowState | IShowCommandLineAction | IHideCommandLineAction | ISetCommandLinePosition | IHideWildMenuAction | IShowWildMenuAction | IWildMenuSelectedAction export type ActionWithGeneric = ISetConfigurationValue export const setHasFocus = (hasFocus: boolean) => { return { type: "SET_HAS_FOCUS", payload: { hasFocus, }, } } export const setLoadingComplete = () => { return { type: "SET_LOADING_COMPLETE", } } export const setColors = (colors: IThemeColors) => ({ type: "SET_COLORS", payload: { colors, }, }) export const setCommandLinePosition = ({ pos: position, level, }: { pos: number level: number }) => ({ type: "SET_COMMAND_LINE_POSITION", payload: { position, level, }, }) export const hideCommandLine = () => ({ type: "HIDE_COMMAND_LINE", }) export const showCommandLine = ( content: Array<[any, string]>, pos: number, firstchar: string, prompt: string, indent: number, level: number, ) => ({ type: "SHOW_COMMAND_LINE", payload: { content, position: pos, firstchar, prompt, indent, level, }, }) export const showWildMenu = (payload: { options: string[] }) => ({ type: "SHOW_WILDMENU", payload, }) export const wildMenuSelect = (payload: { selected: number }) => ({ type: "WILDMENU_SELECTED", payload, }) export const hideWildMenu = () => ({ type: "HIDE_WILDMENU", }) export const setNeovimError = (neovimError: boolean) => ({ type: "SET_NEOVIM_ERROR", payload: { neovimError, }, }) export const setViewport = (width: number, height: number) => ({ type: "SET_VIEWPORT", payload: { width, height, }, }) export const setCursorScale = (cursorScale: number) => ({ type: "SET_CURSOR_SCALE", payload: { cursorScale, }, }) const formatBuffers = (buffer: InactiveBufferContext & EventContext) => { return { id: buffer.bufferNumber, file: buffer.bufferFullPath ? normalizePath(buffer.bufferFullPath) : "", totalLines: buffer.bufferTotalLines ? buffer.bufferTotalLines : null, language: buffer.filetype, hidden: buffer.hidden, listed: buffer.listed, modified: buffer.modified, } } export const addBufferLayer = ( bufferId: number, layer: Oni.BufferLayer, ): IAddBufferLayerAction => ({ type: "ADD_BUFFER_LAYER", payload: { bufferId, layer, }, }) export const removeBufferLayer = ( bufferId: number, layer: Oni.BufferLayer, ): IRemoveBufferLayerAction => ({ type: "REMOVE_BUFFER_LAYER", payload: { bufferId, layer, }, }) export const bufferEnter = (buffers: Array) => ({ type: "BUFFER_ENTER", payload: { buffers: buffers.map(formatBuffers), }, }) export const bufferUpdate = (id: number, modified: boolean, totalLines: number) => ({ type: "BUFFER_UPDATE", payload: { id, modified, totalLines, }, }) export const bufferSave = (id: number, modified: boolean, version: number) => ({ type: "BUFFER_SAVE", payload: { id, modified, version, }, }) export const setCurrentBuffers = (bufferIds: number[]) => ({ type: "SET_CURRENT_BUFFERS", payload: { bufferIds, }, }) export const setImeActive = (imeActive: boolean) => ({ type: "SET_IME_ACTIVE", payload: { imeActive, }, }) export const setFont = (fontFamily: string, fontSize: string, fontWeight: string) => ({ type: "SET_FONT", payload: { fontFamily, fontSize, fontWeight, }, }) export const setTabs = (selectedTabId: number, tabs: State.ITab[]): ISetTabs => ({ type: "SET_TABS", payload: { selectedTabId, tabs, }, }) export const setWindowCursor = (windowId: number, line: number, column: number) => ({ type: "SET_WINDOW_CURSOR", payload: { windowId, line, column, }, }) export const setWindowState = ( windowId: number, bufferId: number, file: string, column: number, line: number, bottomBufferLine: number, topBufferLine: number, dimensions: Oni.Shapes.Rectangle, bufferToScreen: Oni.Coordinates.BufferToScreen, visibleLines: string[], ) => (dispatch: DispatchFunction, getState: GetStateFunction) => { const { fontPixelWidth, fontPixelHeight } = getState() const screenToPixel = (screenSpace: Oni.Coordinates.ScreenSpacePoint) => { if ( !screenSpace || typeof screenSpace.screenX !== "number" || typeof screenSpace.screenY !== "number" ) { return { pixelX: NaN, pixelY: NaN, } } return { pixelX: screenSpace.screenX * fontPixelWidth, pixelY: screenSpace.screenY * fontPixelHeight, } } const bufferToPixel = (position: types.Position): Oni.Coordinates.PixelSpacePoint => { const screenPosition = bufferToScreen(position) if (!screenPosition) { return null } return screenToPixel(screenPosition) } dispatch({ type: "SET_WINDOW_STATE", payload: { windowId, bufferId, file: normalizePath(file), column, dimensions, line, bufferToScreen, screenToPixel, bufferToPixel, bottomBufferLine, topBufferLine, visibleLines, }, }) } export const setInactiveWindowState = ( windowId: number, dimensions: Oni.Shapes.Rectangle, ): ISetInactiveWindowState => ({ type: "SET_INACTIVE_WINDOW_STATE", payload: { windowId, dimensions, }, }) export const showToolTip = ( id: string, element: JSX.Element, options?: Oni.ToolTip.ToolTipOptions, ) => ({ type: "SHOW_TOOL_TIP", payload: { id, element, options, }, }) export const hideToolTip = (id: string) => ({ type: "HIDE_TOOL_TIP", payload: { id, }, }) export const setErrors = (errors: Errors) => ({ type: "SET_ERRORS", payload: { errors, }, }) export const setCursorPosition = (screen: IScreen) => (dispatch: DispatchFunction) => { const cell = screen.getCell(screen.cursorColumn, screen.cursorRow) dispatch( _setCursorPosition( screen.cursorColumn * screen.fontWidthInPixels, screen.cursorRow * screen.fontHeightInPixels, screen.fontWidthInPixels, screen.fontHeightInPixels, cell.character, cell.characterWidth * screen.fontWidthInPixels, ), ) } export const setMode = (mode: string) => ({ type: "SET_MODE", payload: { mode }, }) export const setDefinition = ( token: Oni.IToken, definitionLocation: types.Location, ): IShowDefinitionAction => ({ type: "SHOW_DEFINITION", payload: { token, definitionLocation, }, }) export const hideDefinition = () => ({ type: "HIDE_DEFINITION", }) export const setCursorLineOpacity = (opacity: number) => ({ type: "SET_CURSOR_LINE_OPACITY", payload: { opacity, }, }) export const setCursorColumnOpacity = (opacity: number) => ({ type: "SET_CURSOR_COLUMN_OPACITY", payload: { opacity, }, }) export const setActiveVimTabPage = (tabId: number, windowIds: number[]): ISetActiveVimTabPage => ({ type: "SET_ACTIVE_VIM_TAB_PAGE", payload: { id: tabId, windowIds, }, }) export function setConfigValue( k: K, v: IConfigurationValues[K], ): ISetConfigurationValue { return { type: "SET_CONFIGURATION_VALUE", payload: { key: k, value: v, }, } } const _setCursorPosition = ( cursorPixelX: any, cursorPixelY: any, fontPixelWidth: any, fontPixelHeight: any, cursorCharacter: string, cursorPixelWidth: number, ) => ({ type: "SET_CURSOR_POSITION", payload: { pixelX: cursorPixelX, pixelY: cursorPixelY, fontPixelWidth, fontPixelHeight, cursorCharacter, cursorPixelWidth, }, }) ================================================ FILE: browser/src/Editor/NeovimEditor/NeovimEditorCommands.ts ================================================ /** * NeovimEditorCommands * * Contextual commands for NeovimEditor * */ import * as os from "os" import { clipboard } from "electron" import * as Oni from "oni-api" import { NeovimInstance } from "./../../neovim" import { CallbackCommand, CommandManager } from "./../../Services/CommandManager" import { ContextMenuManager } from "./../../Services/ContextMenu" import { editorManager } from "./../../Services/EditorManager" import { findAllReferences, format, LanguageEditorIntegration } from "./../../Services/Language" import { replaceAll } from "./../../Utility" import { Definition } from "./Definition" import { Rename } from "./Rename" import { Symbols } from "./Symbols" export class NeovimEditorCommands { private _lastCommands: CallbackCommand[] = [] constructor( private _commandManager: CommandManager, private _contextMenuManager: ContextMenuManager, private _definition: Definition, private _languageEditorIntegration: LanguageEditorIntegration, private _neovimInstance: NeovimInstance, private _rename: Rename, private _symbols: Symbols, ) {} public activate(): void { const isContextMenuOpen = () => this._contextMenuManager.isMenuOpen() /** * Higher-order function for commands dealing with completion * - checks that the completion menu is open */ const contextMenuCommand = (innerCommand: Oni.Commands.CommandCallback) => { return () => { if (this._contextMenuManager.isMenuOpen()) { return innerCommand() } return false } } const selectContextMenuItem = contextMenuCommand(() => { this._contextMenuManager.selectMenuItem() }) const nextContextMenuItem = contextMenuCommand(() => { this._contextMenuManager.nextMenuItem() }) const closeContextMenu = contextMenuCommand(() => { this._contextMenuManager.closeActiveMenu() }) const previousContextMenuItem = contextMenuCommand(() => { this._contextMenuManager.previousMenuItem() }) const pasteContents = async (neovimInstance: NeovimInstance) => { const textToPaste = clipboard.readText() const replacements = { "'": "''" } replacements[os.EOL] = "\n" const sanitizedTextLines = replaceAll(textToPaste, replacements) await neovimInstance.command('let b:oniclipboard=@"') await neovimInstance.command(`let @"='${sanitizedTextLines}'`) if ( editorManager.activeEditor.mode === "insert" || editorManager.activeEditor.mode === "cmdline_normal" ) { await neovimInstance.command("set paste") await neovimInstance.input('"') await neovimInstance.command("set nopaste") } else { await neovimInstance.command("normal! p") } await neovimInstance.command('let @"=b:oniclipboard') await neovimInstance.command("unlet b:oniclipboard") } const commands = [ new CallbackCommand( "contextMenu.select", null, null, selectContextMenuItem, isContextMenuOpen, ), new CallbackCommand( "contextMenu.next", null, null, nextContextMenuItem, isContextMenuOpen, ), new CallbackCommand( "contextMenu.previous", null, null, previousContextMenuItem, isContextMenuOpen, ), new CallbackCommand( "contextMenu.close", null, null, closeContextMenu, isContextMenuOpen, ), new CallbackCommand( "editor.clipboard.paste", "Clipboard: Paste", "Paste clipboard contents into active text", () => pasteContents(this._neovimInstance), ), new CallbackCommand( "editor.clipboard.yank", "Clipboard: Yank", "Yank contents to clipboard", () => this._neovimInstance.command('normal! "+y'), ), new CallbackCommand( "editor.clipboard.cut", "Clipboard: Cut", "Cut contents to clipboard", () => this._neovimInstance.command('normal! "+x'), ), new CallbackCommand("oni.editor.findAllReferences", null, null, () => findAllReferences(), ), new CallbackCommand( "language.findAllReferences", "Find All References", "Find all references using a language service", () => findAllReferences(), ), new CallbackCommand("language.format", null, null, () => format()), // TODO: Deprecate new CallbackCommand("oni.editor.gotoDefinition", null, null, () => this._definition.gotoDefinitionUnderCursor(), ), new CallbackCommand( "language.gotoDefinition", "Goto Definition", "Goto definition using a language service", () => this._definition.gotoDefinitionUnderCursor(), ), new CallbackCommand("language.gotoDefinition.openVertical", null, null, () => this._definition.gotoDefinitionUnderCursor({ openMode: Oni.FileOpenMode.VerticalSplit, }), ), new CallbackCommand("language.gotoDefinition.openHorizontal", null, null, () => this._definition.gotoDefinitionUnderCursor({ openMode: Oni.FileOpenMode.HorizontalSplit, }), ), new CallbackCommand("language.gotoDefinition.openNewTab", null, null, () => this._definition.gotoDefinitionUnderCursor({ openMode: Oni.FileOpenMode.NewTab }), ), new CallbackCommand("language.gotoDefinition.openEdit", null, null, () => this._definition.gotoDefinitionUnderCursor({ openMode: Oni.FileOpenMode.Edit }), ), new CallbackCommand("language.gotoDefinition.openExistingTab", null, null, () => this._definition.gotoDefinitionUnderCursor({ openMode: Oni.FileOpenMode.ExistingTab, }), ), new CallbackCommand("editor.rename", "Rename", "Rename an item", () => this._rename.startRename(), ), new CallbackCommand("editor.quickInfo.show", null, null, () => this._languageEditorIntegration.showHover(), ), new CallbackCommand("language.symbols.document", null, null, () => this._symbols.openDocumentSymbolsMenu(), ), new CallbackCommand("language.symbols.workspace", null, null, () => this._symbols.openWorkspaceSymbolsMenu(), ), new CallbackCommand( "oni.config.openInitVim", "Configuration: Edit Neovim Config", "Edit configuration file ('init.vim') for Neovim", () => this._neovimInstance.openInitVim(), ), ] this._lastCommands = commands commands.forEach(c => this._commandManager.registerCommand(c)) } public deactivate(): void { this._lastCommands.forEach(c => this._commandManager.unregisterCommand(c.command)) this._lastCommands = [] } } ================================================ FILE: browser/src/Editor/NeovimEditor/NeovimEditorLoadingOverlay.tsx ================================================ /** * NeovimEditorLoadingOverlay * * Overlay shown over the editor window while initializing (loading Neovim) */ import * as React from "react" import { connect } from "react-redux" import * as State from "./NeovimEditorStore" import styled from "styled-components" import { withProps } from "./../../UI/components/common" const LoadingSpinnerWrapper = withProps<{}>(styled.div)` background-color: ${props => props.theme["editor.background"]}; color: ${props => props.theme["editor.foreground"]}; display: flex; justify-content: center; align-items: center; opacity: 1; transition: opacity 0.15s ease-in; &.loaded { opacity: 0; pointer-events: none; } ` export const NeovimEditorLoadingOverlayView = (props: { visible: boolean }): JSX.Element => { const className = props.visible ? "stack layer" : " stack layer loaded" return } export const mapStateToProps = (state: State.IState): { visible: boolean } => { return { visible: !state.isLoaded } } export const NeovimEditorLoadingOverlay = connect(mapStateToProps)(NeovimEditorLoadingOverlayView) ================================================ FILE: browser/src/Editor/NeovimEditor/NeovimEditorReducer.ts ================================================ /** * Reducer.ts * * Top-level reducer for UI state transforms */ import * as State from "./NeovimEditorStore" import * as Actions from "./NeovimEditorActions" import { IConfigurationValues } from "./../../Services/Configuration" import { Errors } from "./../../Services/Diagnostics" import * as pick from "lodash/pick" export function reducer( s: State.IState, a: Actions.Action, ): State.IState { if (!s) { return s } switch (a.type) { case "SET_HAS_FOCUS": return { ...s, hasFocus: a.payload.hasFocus, } case "SET_COLORS": return { ...s, colors: a.payload.colors, } case "SET_LOADING_COMPLETE": return { ...s, isLoaded: true, } case "SET_NEOVIM_ERROR": return { ...s, neovimError: a.payload.neovimError, } case "SET_VIEWPORT": return { ...s, viewport: viewportReducer(s.viewport, a), } case "SET_CURSOR_SCALE": return { ...s, cursorScale: a.payload.cursorScale, } case "SET_ACTIVE_VIM_TAB_PAGE": return { ...s, activeVimTabPage: a.payload, } case "SET_CURSOR_POSITION": return { ...s, cursorPixelX: a.payload.pixelX, cursorPixelY: a.payload.pixelY, fontPixelWidth: a.payload.fontPixelWidth, fontPixelHeight: a.payload.fontPixelHeight, cursorCharacter: a.payload.cursorCharacter, cursorPixelWidth: a.payload.cursorPixelWidth, } case "SET_IME_ACTIVE": return { ...s, imeActive: a.payload.imeActive, } case "SET_FONT": return { ...s, fontFamily: a.payload.fontFamily, fontSize: a.payload.fontSize, fontWeight: a.payload.fontWeight, } case "SET_MODE": return { ...s, ...{ mode: a.payload.mode } } case "SET_CONFIGURATION_VALUE": const obj: Partial = {} obj[a.payload.key] = a.payload.value const newConfig = { ...s.configuration, ...obj } return { ...s, configuration: newConfig, } case "SHOW_WILDMENU": return { ...s, wildmenu: { ...s.wildmenu, visible: true, options: a.payload.options, }, } case "WILDMENU_SELECTED": return { ...s, wildmenu: { ...s.wildmenu, selected: a.payload.selected, }, } case "HIDE_WILDMENU": return { ...s, wildmenu: { ...s.wildmenu, visible: false, }, } case "SHOW_COMMAND_LINE": // Array<[any, string]> const [[, content]] = a.payload.content return { ...s, commandLine: { content, visible: true, position: a.payload.position, firstchar: a.payload.firstchar, prompt: a.payload.prompt, indent: a.payload.indent, level: a.payload.level, }, } case "HIDE_COMMAND_LINE": return { ...s, commandLine: { visible: false, content: null, firstchar: "", position: null, prompt: "", indent: null, level: null, }, } case "SET_COMMAND_LINE_POSITION": return { ...s, commandLine: { ...s.commandLine, position: a.payload.position, level: a.payload.level, }, } default: return { ...s, buffers: buffersReducer(s.buffers, a), definition: definitionReducer(s.definition, a), layers: layersReducer(s.layers, a), tabState: tabStateReducer(s.tabState, a), errors: errorsReducer(s.errors, a), toolTips: toolTipsReducer(s.toolTips, a), windowState: windowStateReducer(s.windowState, a), } } } export const layersReducer = (s: State.Layers, a: Actions.SimpleAction) => { switch (a.type) { case "ADD_BUFFER_LAYER": { const currentLayers = s[a.payload.bufferId] || [] if (currentLayers.find(layer => layer.id === a.payload.layer.id)) { return s } return { ...s, [a.payload.bufferId]: [...currentLayers, a.payload.layer], } } case "REMOVE_BUFFER_LAYER": { const currentLayers = s[a.payload.bufferId] || [] return { ...s, [a.payload.bufferId]: currentLayers.filter(l => l !== a.payload.layer), } } default: return s } } export const definitionReducer = (s: State.IDefinition, a: Actions.SimpleAction) => { switch (a.type) { case "SHOW_DEFINITION": const { definitionLocation, token } = a.payload return { definitionLocation, token, } case "HIDE_DEFINITION": return null default: return s } } export const viewportReducer = (s: State.IViewport, a: Actions.ISetViewportAction) => { const { width, height } = a.payload switch (a.type) { case "SET_VIEWPORT": return { ...s, width, height, } default: return s } } export const tabStateReducer = (s: State.ITabState, a: Actions.SimpleAction): State.ITabState => { switch (a.type) { case "SET_TABS": return { ...s, ...a.payload, } default: return s } } export const buffersReducer = ( s: State.IBufferState, a: Actions.SimpleAction, ): State.IBufferState => { let byId = s.byId let allIds = s.allIds const emptyBuffer = (id: number): State.IBuffer => ({ id, file: null, modified: false, hidden: true, listed: false, totalLines: 0, }) switch (a.type) { case "BUFFER_ENTER": byId = a.payload.buffers.reduce((buffersById, buffer) => { buffersById[buffer.id] = { ...buffer, } return byId }, byId) const bufIds = a.payload.buffers.map(b => b.id) allIds = [...new Set(bufIds)] return { activeBufferId: a.payload.buffers[0].id, byId, allIds, } case "BUFFER_SAVE": const currentItem = s.byId[a.payload.id] || emptyBuffer(a.payload.id) byId = { ...s.byId, [a.payload.id]: { ...currentItem, id: a.payload.id, modified: a.payload.modified, lastSaveVersion: a.payload.version, }, } return { ...s, byId, } case "BUFFER_UPDATE": const currentItem3 = s.byId[a.payload.id] || emptyBuffer(a.payload.id) // If the last save version hasn't been set, this means it is the first update, // and should clamp to the incoming version const lastSaveVersion = currentItem3.lastSaveVersion || a.payload.version byId = { ...s.byId, [a.payload.id]: { ...currentItem3, id: a.payload.id, modified: a.payload.modified, totalLines: a.payload.totalLines, lastSaveVersion, }, } return { ...s, byId, } case "SET_CURRENT_BUFFERS": allIds = s.allIds.filter(id => a.payload.bufferIds.indexOf(id) >= 0) let activeBufferId = s.activeBufferId if (a.payload.bufferIds.indexOf(activeBufferId) === -1) { activeBufferId = null } const newById: any = pick(s.byId, a.payload.bufferIds) return { activeBufferId, byId: newById, allIds, } default: return s } } export const errorsReducer = (s: Errors, a: Actions.SimpleAction) => { switch (a.type) { case "SET_ERRORS": return { ...a.payload.errors, } default: return s } } export const toolTipsReducer = (s: State.ToolTips, a: Actions.SimpleAction): State.ToolTips => { switch (a.type) { case "SHOW_TOOL_TIP": const existingItem = s[a.payload.id] || {} const newItem = { ...existingItem, ...a.payload, } return { ...s, [a.payload.id]: newItem, } case "HIDE_TOOL_TIP": return { ...s, [a.payload.id]: null, } default: return s } } export const windowStateReducer = ( s: State.IWindowState, a: Actions.SimpleAction, ): State.IWindowState => { let currentWindow switch (a.type) { case "SET_WINDOW_CURSOR": currentWindow = s.windows[a.payload.windowId] || null return { activeWindow: a.payload.windowId, windows: { ...s.windows, [a.payload.windowId]: { ...currentWindow, column: a.payload.column, line: a.payload.line, }, }, } case "SET_INACTIVE_WINDOW_STATE": currentWindow = s.windows[a.payload.windowId] || null return { ...s, windows: { ...s.windows, [a.payload.windowId]: { ...currentWindow, windowId: a.payload.windowId, column: -1, line: -1, topBufferLine: -1, bottomBufferLine: -1, dimensions: a.payload.dimensions, }, }, } case "SET_WINDOW_STATE": currentWindow = s.windows[a.payload.windowId] || null return { activeWindow: a.payload.windowId, windows: { ...s.windows, [a.payload.windowId]: { ...currentWindow, file: a.payload.file, bufferId: a.payload.bufferId, windowId: a.payload.windowId, column: a.payload.column, line: a.payload.line, bufferToScreen: a.payload.bufferToScreen, screenToPixel: a.payload.screenToPixel, bufferToPixel: a.payload.bufferToPixel, dimensions: a.payload.dimensions, topBufferLine: a.payload.topBufferLine, bottomBufferLine: a.payload.bottomBufferLine, visibleLines: a.payload.visibleLines, }, }, } default: return s } } ================================================ FILE: browser/src/Editor/NeovimEditor/NeovimEditorSelectors.ts ================================================ /** * Selectors.ts * * Selectors are basically helper methods for operating on the State * See Redux documents here fore more info: * http://redux.js.org/docs/recipes/ComputingDerivedData.html */ import * as types from "vscode-languageserver-types" import * as Oni from "oni-api" import { createSelector } from "reselect" import { getAllErrorsForFile } from "./../../Services/Diagnostics" import * as Utility from "./../../Utility" import * as State from "./NeovimEditorStore" export const EmptyArray: any[] = [] const getWindows = (state: State.IState) => state.windowState export const getActiveWindow = createSelector([getWindows], windowState => { if (windowState.activeWindow === null) { return null } const activeWindow = windowState.activeWindow return windowState.windows[activeWindow] }) const emptyRectangle: Oni.Shapes.Rectangle = { x: 0, y: 0, width: 0, height: 0, } export const getFontPixelWidthHeight = (state: State.IState) => ({ fontPixelWidth: state.fontPixelWidth, fontPixelHeight: state.fontPixelHeight, }) export const getActiveWindowScreenDimensions = createSelector([getActiveWindow], win => { if (!win || !win.dimensions) { return emptyRectangle } return win.dimensions }) export const getActiveWindowPixelDimensions = createSelector( [getActiveWindowScreenDimensions, getFontPixelWidthHeight], (dimensions, fontSize) => { const pixelDimensions = { x: dimensions.x * fontSize.fontPixelWidth, y: dimensions.y * fontSize.fontPixelHeight, width: dimensions.width * fontSize.fontPixelWidth, height: dimensions.height * fontSize.fontPixelHeight, } return pixelDimensions }, ) export const getErrors = (state: State.IState) => state.errors export const getErrorsForActiveFile = createSelector( [getActiveWindow, getErrors], (win, errors) => { const errorsForFile: types.Diagnostic[] = win && win.file ? getAllErrorsForFile(win.file, errors) : (EmptyArray as types.Diagnostic[]) return errorsForFile }, ) export const getErrorsForPosition = createSelector( [getActiveWindow, getErrorsForActiveFile], (win, errors) => { if (!win) { return EmptyArray } const { line, column } = win return errors.filter(diag => Utility.isInRange(line, column, diag.range)) }, ) const getBufferState = (state: State.IState) => state.buffers export const getAllBuffers = createSelector([getBufferState], buffers => buffers.allIds.map(id => buffers.byId[id]).filter(buf => buf.listed), ) export const getBufferMetadata = createSelector([getAllBuffers], buffers => buffers.map(b => ({ id: b.id, file: b.file, modified: b.modified, })), ) export const getActiveBuffer = createSelector([getActiveWindow, getAllBuffers], (win, buffers) => { if (!win || !win.file) { return null } const buf = buffers.find(b => b.file === win.file) return buf || null }) export const getActiveBufferId = createSelector( [getActiveBuffer], buf => (buf === null ? null : buf.id), ) ================================================ FILE: browser/src/Editor/NeovimEditor/NeovimEditorStore.ts ================================================ /** * State.ts * * This file describes the Redux state of the app */ import * as types from "vscode-languageserver-types" import * as Oni from "oni-api" import { Store } from "redux" import thunk from "redux-thunk" import { IConfigurationValues } from "./../../Services/Configuration" import { DefaultThemeColors, IThemeColors } from "./../../Services/Themes" import { createStore as createReduxStore } from "./../../Redux" import { IBufferLayer } from "./../NeovimEditor/BufferLayerManager" export interface Layers { [id: number]: IBufferLayer[] } export interface Buffers { [filePath: string]: IBuffer } export interface Errors { [file: string]: { [key: string]: types.Diagnostic[] } } export interface ToolTips { [id: string]: IToolTip } import { reducer } from "./NeovimEditorReducer" /** * Viewport encompasses the actual 'app' height */ export interface IViewport { width: number height: number } export interface IToolTip { id: string options?: Oni.ToolTip.ToolTipOptions element: JSX.Element } export interface IState { // Editor cursorScale: number cursorPixelX: number cursorPixelY: number cursorPixelWidth: number cursorCharacter: string fontPixelWidth: number fontPixelHeight: number fontFamily: string fontSize: string fontWeight: string hasFocus: boolean mode: string definition: null | IDefinition cursorLineOpacity: number cursorColumnOpacity: number configuration: IConfigurationValues imeActive: boolean isLoaded: boolean viewport: IViewport colors: IThemeColors toolTips: ToolTips neovimError: boolean /** * Tabs refer to the Vim-concept of tabs */ tabState: ITabState buffers: IBufferState layers: Layers windowState: IWindowState errors: Errors activeVimTabPage: IVimTabPage commandLine: ICommandLine | null wildmenu: IWildMenu } export interface IWildMenu { selected: number visible: boolean options: string[] } export interface ICommandLine { visible: boolean content: string firstchar: string position: number prompt: string indent: number level: number } export interface IDefinition { token: Oni.IToken definitionLocation: types.Location } export interface IBufferState { activeBufferId: number byId: { [id: number]: IBuffer } allIds: number[] } export interface IBuffer { id: number file: string modified: boolean lastSaveVersion?: number version?: number totalLines: number hidden: boolean listed: boolean } export interface ITab { id: number name: string buffersInTab: number[] } export interface ITabState { selectedTabId: number | null tabs: ITab[] } export interface IVimTabPage { id: number windowIds: number[] } export interface IWindowState { activeWindow: number windows: { [windowId: number]: IWindow } } export interface IWindow { file: string bufferId: number windowId: number column: number line: number bufferToScreen: Oni.Coordinates.BufferToScreen screenToPixel: Oni.Coordinates.ScreenToPixel bufferToPixel: Oni.Coordinates.BufferToPixel dimensions: Oni.Shapes.Rectangle topBufferLine: number bottomBufferLine: number visibleLines: string[] } export function readConf( conf: IConfigurationValues, k: K, ): IConfigurationValues[K] { if (!conf) { return null } else { return conf[k] } } export const createDefaultState = (): IState => ({ cursorScale: 1, cursorPixelX: 10, cursorPixelY: 10, cursorPixelWidth: 10, cursorCharacter: "", fontPixelWidth: 10, fontPixelHeight: 10, fontFamily: "", fontSize: "", fontWeight: "", hasFocus: false, imeActive: false, mode: "normal", definition: null, colors: DefaultThemeColors, cursorLineOpacity: 0, cursorColumnOpacity: 0, neovimError: false, isLoaded: false, activeVimTabPage: null, configuration: {} as IConfigurationValues, buffers: { activeBufferId: null, byId: {}, allIds: [], }, layers: {}, tabState: { selectedTabId: null, tabs: [], }, windowState: { activeWindow: null, windows: {}, }, viewport: { width: 0, height: 0, }, errors: {}, toolTips: {}, commandLine: { content: null, prompt: null, indent: null, level: null, visible: false, firstchar: "", position: 0, }, wildmenu: { selected: null, visible: false, options: [], }, }) let neovimEditorId = 0 export const createStore = (): Store => { const editorId = neovimEditorId++ return createReduxStore("NeovimEditor" + editorId.toString(), reducer, createDefaultState(), [ thunk, ]) } ================================================ FILE: browser/src/Editor/NeovimEditor/NeovimInput.tsx ================================================ /** * NeovimInput.tsx * * Layer responsible for handling Neovim input interactiosn */ import * as React from "react" import { IEvent } from "oni-types" import { Mouse } from "./../../Input/Mouse" import { NeovimInstance, NeovimScreen } from "./../../neovim" import { TypingPredictionManager } from "./../../Services/TypingPredictionManager" import { KeyboardInput } from "./../../Input/KeyboardInput" export interface INeovimInputProps { neovimInstance: NeovimInstance screen: NeovimScreen onActivate: IEvent onBounceStart: () => void onBounceEnd: () => void onImeStart: () => void onImeEnd: () => void onKeyDown?: (key: string) => void startActive?: boolean typingPrediction: TypingPredictionManager } export class NeovimInput extends React.PureComponent { private _mouseElement: HTMLDivElement private _mouse: Mouse public componentDidMount(): void { if (this._mouseElement) { this._mouse = new Mouse(this._mouseElement, this.props.screen) this._mouse.on("mouse", (mouseInput: string) => { this.props.neovimInstance.input(mouseInput) }) } } public render(): JSX.Element { return (
    (this._mouseElement = elem)} className="stack enable-mouse">
    ) } } ================================================ FILE: browser/src/Editor/NeovimEditor/NeovimPopupMenu.tsx ================================================ /** * NeovimPopupMenu.tsx * * Implementation of Neovim's popup menu */ import * as React from "react" import * as Oni from "oni-api" import { IEvent } from "oni-types" import { INeovimCompletionInfo, INeovimCompletionItem } from "./../../neovim" import { ContextMenuView, IContextMenuItem } from "./../../Services/ContextMenu" import { IToolTipsProvider } from "./ToolTipsProvider" const mapNeovimCompletionItemToContextMenuItem = ( item: INeovimCompletionItem, idx: number, totalLength: number, ): IContextMenuItem => ({ label: item.word, detail: item.menu, documentation: (idx + 1).toString() + " of " + totalLength.toString(), icon: "align-right", }) export class NeovimPopupMenu { private _lastItems: IContextMenuItem[] = [] constructor( private _popupMenuShowEvent: IEvent, private _popupMenuHideEvent: IEvent, private _popupMenuSelectEvent: IEvent, private _onBufferEnterEvent: IEvent, private _toolTipsProvider: IToolTipsProvider, ) { this._popupMenuShowEvent.subscribe(completionInfo => { this._lastItems = completionInfo.items.map((i, idx) => mapNeovimCompletionItemToContextMenuItem(i, idx, completionInfo.items.length), ) this._renderCompletionMenu(completionInfo.selectedIndex) }) this._popupMenuSelectEvent.subscribe(idx => { this._renderCompletionMenu(idx) }) this._popupMenuHideEvent.subscribe(() => { this._toolTipsProvider.hideToolTip("nvim-popup") }) this._onBufferEnterEvent.subscribe(() => { this._toolTipsProvider.hideToolTip("nvim-popup") }) } public dispose(): void { // TODO: Implement 'unsubscribe' logic here // tslint:disable-line } private _renderCompletionMenu(selectedIndex: number): void { const itemsToRender: IContextMenuItem[] = this._lastItems const completionElement = ( ) this._toolTipsProvider.showToolTip("nvim-popup", completionElement, { position: null, openDirection: 2, padding: "0px", }) } } ================================================ FILE: browser/src/Editor/NeovimEditor/NeovimRenderer.tsx ================================================ /** * NeovimRenderer.tsx * * Layer responsible for invoking the INeovimRender strategy and applying to the DOM */ import * as React from "react" import { IScreen, NeovimInstance } from "./../../neovim" import { INeovimRenderer } from "./../../Renderer" export interface INeovimRendererProps { neovimInstance: NeovimInstance screen: IScreen renderer: INeovimRenderer } export class NeovimRenderer extends React.PureComponent { private _element: HTMLDivElement private _boundOnResizeMethod: any private _resizeObserver: any public componentDidMount(): void { if (this._element) { this.props.renderer.start(this._element) this._onResize() } if (!this._boundOnResizeMethod) { this._boundOnResizeMethod = this._onResize.bind(this) // tslint:disable-next-line no-string-literal this._resizeObserver = new window["ResizeObserver"]((entries: any) => { if (this._boundOnResizeMethod) { this._boundOnResizeMethod() } }) this._resizeObserver.observe(this._element) } } public componentWillUnmount(): void { // TODO: Stop renderer if (this._resizeObserver) { this._resizeObserver.disconnect() this._resizeObserver = null } if (this._boundOnResizeMethod) { this._boundOnResizeMethod = null } } public render(): JSX.Element { return
    (this._element = elem)} className="stack layer" /> } private _onResize(): void { if (!this._element) { return } const width = this._element.offsetWidth const height = this._element.offsetHeight this.props.neovimInstance .resize(width, height) .then(() => this.props.renderer.redrawAll(this.props.screen)) } } ================================================ FILE: browser/src/Editor/NeovimEditor/NeovimSurface.tsx ================================================ /** * NeovimSurface.tsx * * UI layer for the Neovim editor surface */ import * as React from "react" import { connect } from "react-redux" import { IEvent } from "oni-types" import { NeovimInstance, NeovimScreen } from "./../../neovim" import { INeovimRenderer } from "./../../Renderer" import FileDropHandler from "./FileDropHandler" import { TypingPredictionManager } from "./../../Services/TypingPredictionManager" import { Cursor } from "./../../UI/components/Cursor" import { CursorLine } from "./../../UI/components/CursorLine" import { InstallHelp } from "./../../UI/components/InstallHelp" import { TabsContainer } from "./../../UI/components/Tabs" import { ToolTips } from "./../../UI/components/ToolTip" import { StackLayer } from "../../UI/components/common" import { setViewport } from "./../NeovimEditor/NeovimEditorActions" import { NeovimBufferLayers } from "./NeovimBufferLayersView" import { NeovimEditorLoadingOverlay } from "./NeovimEditorLoadingOverlay" import { NeovimInput } from "./NeovimInput" import { NeovimRenderer } from "./NeovimRenderer" export interface INeovimSurfaceProps { autoFocus: boolean neovimInstance: NeovimInstance renderer: INeovimRenderer screen: NeovimScreen typingPrediction: TypingPredictionManager onActivate: IEvent onKeyDown?: (key: string) => void onBufferClose?: (bufferId: number) => void onBufferSelect?: (bufferId: number) => void onFileDrop?: (files: FileList) => void onImeStart: () => void onImeEnd: () => void onBounceStart: () => void onBounceEnd: () => void onTabClose?: (tabId: number) => void onTabSelect?: (tabId: number) => void setViewport: any } class NeovimSurface extends React.Component { private observer: any private _editor: HTMLDivElement public componentDidMount(): void { // tslint:disable-next-line this.observer = new window["ResizeObserver"](([entry]: any) => { this.setDimensions(entry.contentRect.width, entry.contentRect.height) }) this.observer.observe(this._editor) } public setDimensions = (width: number, height: number) => { this.props.setViewport(width, height) } public render(): JSX.Element { return ( {({ setRef }) => (
    (this._editor = e)}>
    )}
    ) } } export default connect(null, { setViewport })(NeovimSurface) ================================================ FILE: browser/src/Editor/NeovimEditor/Rename.tsx ================================================ /** * Rename.tsx */ import * as React from "react" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import * as Helpers from "./../../Plugins/Api/LanguageClient/LanguageClientHelpers" import { LanguageManager } from "./../../Services/Language" import { RenameView } from "./../../Services/Language/RenameView" import { Workspace } from "./../../Services/Workspace" import { IToolTipsProvider } from "./ToolTipsProvider" const _renameToolTipName = "rename-tool-tip" export class Rename { private _isRenameActive: boolean constructor( private _editor: Oni.Editor, private _languageManager: LanguageManager, private _toolTipsProvider: IToolTipsProvider, private _workspace: Workspace, ) {} public async startRename(): Promise { if (this._isRenameActive) { return } const activeBuffer = this._editor.activeBuffer const activeToken = await activeBuffer.getTokenAt( activeBuffer.cursor.line, activeBuffer.cursor.column, ) if (!activeToken || !activeToken.tokenName) { return } this._isRenameActive = true this._toolTipsProvider.showToolTip( _renameToolTipName, this.cancelRename()} onComplete={newValue => this.commitRename(newValue)} tokenName={activeToken.tokenName} />, { position: null, openDirection: 2, onDismiss: () => this.cancelRename(), }, ) } public commitRename(newValue: string): void { Log.verbose("[RENAME] Committing rename") this.doRename(newValue) this.closeToolTip() } public cancelRename(): void { Log.verbose("[RENAME] Cancelling") this.closeToolTip() } public closeToolTip(): void { Log.verbose("[RENAME] closeToolTip") this._isRenameActive = false this._toolTipsProvider.hideToolTip(_renameToolTipName) } public async doRename(newName: string): Promise { const activeBuffer = this._editor.activeBuffer const args = { textDocument: { uri: Helpers.wrapPathInFileUri(activeBuffer.filePath), }, position: { line: activeBuffer.cursor.line, character: activeBuffer.cursor.column, }, newName, } let result = null try { result = await this._languageManager.sendLanguageServerRequest( activeBuffer.language, activeBuffer.filePath, "textDocument/rename", args, ) } catch (ex) { Log.debug(ex) } if (result) { await this._workspace.applyEdits(result) } } } ================================================ FILE: browser/src/Editor/NeovimEditor/Symbols.ts ================================================ /** * CodeAction.ts * */ import * as _ from "lodash" import { ErrorCodes } from "vscode-jsonrpc/lib/messages" import * as types from "vscode-languageserver-types" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { LanguageManager } from "./../../Services/Language" import { Menu, MenuManager } from "./../../Services/Menu" import * as Helpers from "./../../Plugins/Api/LanguageClient/LanguageClientHelpers" import { asObservable, sleep } from "./../../Utility" import { Definition } from "./Definition" export class Symbols { constructor( private _editor: Oni.Editor, private _definition: Definition, private _languageManager: LanguageManager, private _menuManager: MenuManager, ) {} public async openWorkspaceSymbolsMenu() { const menu = this._menuManager.create() menu.show() menu.setItems([ { label: "Type to search symbols....", }, ]) menu.setLoading(true) const filterTextChanged$ = asObservable(menu.onFilterTextChanged) menu.onItemSelected.subscribe((selectedItem: Oni.Menu.MenuOption) => { const key = selectedItem.label + selectedItem.detail const loc = keyToLocation[key] if (loc) { this._definition.gotoPositionInUri( loc.uri, loc.range.start.line, loc.range.start.character, ) } }) let keyToLocation: any = {} const getKey = (si: types.SymbolInformation) => si.name + this._getDetailFromSymbol(si) filterTextChanged$ .debounceTime(25) .do(() => menu.setLoading(true)) .concatMap(async (newText: string) => { return this._requestSymbols(this._editor.activeBuffer, "workspace/symbol", menu, { query: newText, }) }) .subscribe((newItems: types.SymbolInformation[]) => { menu.setLoading(false) menu.setItems(newItems.map(item => this._symbolInfoToMenuItem(item))) keyToLocation = newItems.reduce((prev, curr) => { return { ...prev, [getKey(curr)]: curr.location, } }, {}) }) } public async openDocumentSymbolsMenu(): Promise { const menu = this._menuManager.create() menu.show() menu.setLoading(true) const buffer = this._editor.activeBuffer const result: types.SymbolInformation[] = await this._requestSymbols( buffer, "textDocument/documentSymbol", menu, ) const options: Oni.Menu.MenuOption[] = result.map(item => this._symbolInfoToMenuItem(item)) const labelToLocation = result.reduce((prev, curr) => { return { ...prev, [curr.name]: curr.location, } }, {}) menu.onItemSelected.subscribe(selectedItem => { const location: types.Location = labelToLocation[selectedItem.label] if (location) { this._definition.gotoPositionInUri( location.uri, location.range.start.line, location.range.start.character, ) } }) menu.setItems(options) menu.setLoading(false) } private _getDetailFromSymbol(si: types.SymbolInformation): string { const unwrappedPath = Helpers.unwrapFileUriPath(si.location.uri) if (si.containerName) { return si.containerName + "|" + unwrappedPath } else { return unwrappedPath } } private _symbolInfoToMenuItem(si: types.SymbolInformation): Oni.Menu.MenuOption { return { label: si.name, detail: this._getDetailFromSymbol(si), icon: this._convertSymbolKindToIconName(si.kind), } } private _convertSymbolKindToIconName(symbolKind: types.SymbolKind): string { switch (symbolKind) { case types.SymbolKind.Class: return "cube" case types.SymbolKind.Constructor: return "building" case types.SymbolKind.Enum: return "sitemap" case types.SymbolKind.Field: return "var" case types.SymbolKind.File: return "file" case types.SymbolKind.Function: return "cog" case types.SymbolKind.Interface: return "plug" case types.SymbolKind.Method: return "flash" case types.SymbolKind.Module: return "cubes" case types.SymbolKind.Property: return "wrench" case types.SymbolKind.Variable: return "code" default: return "question" } } /** * Send a request for symbols, retrying if the server is not ready, as long as the menu is open. */ private async _requestSymbols( buffer: Oni.Buffer, command: string, menu: Menu, options: any = {}, ): Promise { while (menu.isOpen()) { try { return await this._languageManager.sendLanguageServerRequest( buffer.language, buffer.filePath, command, _.extend( { textDocument: { uri: Helpers.wrapPathInFileUri(buffer.filePath), }, }, options, ), ) } catch (e) { if (e.code === ErrorCodes.ServerNotInitialized) { Log.warn("[Symbols] Language server not yet initialised, trying again...") await sleep(1000) } else { throw e } } } return [] } } ================================================ FILE: browser/src/Editor/NeovimEditor/ToolTipsProvider.ts ================================================ import * as Oni from "oni-api" import * as Actions from "./NeovimEditorActions" export interface IToolTipsProvider { showToolTip(id: string, element: JSX.Element, options: Oni.ToolTip.ToolTipOptions): void hideToolTip(id: string): void } export class NeovimEditorToolTipsProvider implements IToolTipsProvider { constructor(private _actions: typeof Actions) {} public showToolTip( id: string, element: JSX.Element, options: Oni.ToolTip.ToolTipOptions, ): void { this._actions.showToolTip(id, element, options) } public hideToolTip(id: string): void { this._actions.hideToolTip(id) } } ================================================ FILE: browser/src/Editor/NeovimEditor/WelcomeBufferLayer.tsx ================================================ /** * NeovimEditor.ts * * IEditor implementation for Neovim */ import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { Event } from "oni-types" import * as React from "react" import { getMetadata } from "./../../Services/Metadata" import { ISession, SessionManager } from "./../../Services/Sessions" import styled, { boxShadowInset, Css, css, enableMouse, getSelectedBorder, keyframes, lighten, } from "./../../UI/components/common" import { Icon } from "./../../UI/Icon" // const entrance = keyframes` // 0% { opacity: 0; transform: translateY(2px); } // 100% { opacity: 0.5; transform: translateY(0px); } // ` // const enterLeft = keyframes` // 0% { opacity: 0; transform: translateX(-4px); } // 100% { opacity: 1; transform: translateX(0px); } // ` // const enterRight = keyframes` // 0% { opacity: 0; transform: translateX(4px); } // 100% { opacity: 1; transform: translateX(0px); } // ` const entranceFull = keyframes` 0% { opacity: 0; transform: translateY(8px); } 100% { opacity: 1; transform: translateY(0px); } ` const WelcomeWrapper = styled.div` background-color: ${p => p.theme["editor.background"]}; color: ${p => p.theme["editor.foreground"]}; overflow-y: hidden; user-select: none; pointer-events: all; width: 100%; height: 100%; opacity: 0; animation: ${entranceFull} 0.25s ease-in 0.1s forwards ${enableMouse}; ` interface IColumnProps { alignment?: string justify?: string flex?: string height?: string extension?: Css } const Column = styled("div")` background: inherit; display: flex; justify-content: ${({ justify }) => justify || `center`}; align-items: ${({ alignment }) => alignment || "center"}; flex-direction: column; width: 100%; flex: ${({ flex }) => flex || "1"}; height: ${({ height }) => height || `auto`}; ${({ extension }) => extension}; ` const sectionStyles = css` display: flex; flex-direction: column; justify-content: flex-start; align-items: center; height: 90%; overflow-y: hidden; direction: rtl; &:hover { overflow-y: overlay; } & > * { direction: ltr; } ` const LeftColumn = styled.div` ${sectionStyles}; padding: 0; padding-left: 1rem; overflow-y: hidden; width: 60%; ` const RightColumn = styled.div` ${sectionStyles}; width: 30%; border-left: 1px solid ${({ theme }) => theme["editor.background"]}; ` const Row = styled<{ extension?: Css }, "div">("div")` display: flex; justify-content: center; align-items: center; flex-direction: row; opacity: 0; ${({ extension }) => extension}; ` const TitleText = styled.div` font-size: 2em; text-align: right; ` const SubtitleText = styled.div` font-size: 1.2em; text-align: right; ` const HeroImage = styled.img` width: 192px; height: 192px; opacity: 0.4; ` export const SectionHeader = styled.div` margin-top: 1em; margin-bottom: 1em; font-size: 1.2em; font-weight: bold; text-align: left; width: 100%; ` const WelcomeButtonHoverStyled = ` transform: translateY(-1px); box-shadow: 0 4px 8px 2px rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); ` export interface WelcomeButtonWrapperProps { isSelected: boolean borderSize: string } const WelcomeButtonWrapper = styled("button")` box-sizing: border-box; font-size: inherit; font-family: inherit; border: 0px solid ${props => props.theme.foreground}; border-left: ${getSelectedBorder}; border-right: 4px solid transparent; cursor: pointer; color: ${({ theme }) => theme.foreground}; background-color: ${({ theme }) => lighten(theme.background)}; transform: ${({ isSelected }) => (isSelected ? "translateX(-4px)" : "translateX(0px)")}; transition: transform 0.25s; width: 100%; margin: 0.8em 0; padding: 0.8em; display: flex; flex-direction: row; &:hover { ${WelcomeButtonHoverStyled}; } ` const AnimatedContainer = styled<{ duration: string }, "div">("div")` width: 100%; animation: ${entranceFull} ${p => p.duration} ease-in 1s both; ` const WelcomeButtonTitle = styled.span` font-size: 1em; font-weight: bold; margin: 0.4em; width: 100%; text-align: left; ` const WelcomeButtonDescription = styled.span` font-size: 0.8em; opacity: 0.75; margin: 4px; width: 100%; text-align: right; ` const boxStyling = css` width: 60%; height: 60%; padding: 0 1em; opacity: 1; margin-top: 64px; box-sizing: border-box; border: 1px solid ${p => p.theme["editor.hover.contents.background"]}; border-radius: 4px; overflow: hidden; justify-content: space-around; background-color: ${p => p.theme["editor.hover.contents.codeblock.background"]}; ${boxShadowInset}; ` const titleRow = css` width: 100%; padding-top: 32px; animation: ${entranceFull} 0.25s ease-in 0.25s forwards}; ` const selectedSectionItem = css` ${({ theme }) => ` text-decoration: underline; color: ${theme["highlight.mode.normal.background"]}; `}; ` export const SectionItem = styled<{ isSelected?: boolean }, "li">("li")` width: 100%; margin: 0.2em; text-align: left; height: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${({ isSelected }) => isSelected && selectedSectionItem}; &:hover { text-decoration: underline; } ` export const SessionsList = styled.ul` width: 70%; margin: 0; list-style-type: none; border-radius: 4px; padding: 0 1em; border: 1px solid ${p => p.theme["editor.hover.contents.codeblock.background"]}; ` export interface WelcomeButtonProps { title: string description: string command: string selected: boolean onClick: () => void } interface IChromeDiv extends HTMLButtonElement { scrollIntoViewIfNeeded: () => void } export class WelcomeButton extends React.PureComponent { private _button = React.createRef() public componentDidUpdate(prevProps: WelcomeButtonProps) { if (!prevProps.selected && this.props.selected) { this._button.current.scrollIntoViewIfNeeded() } } public render() { return ( {this.props.title} {this.props.description} ) } } export interface WelcomeHeaderState { version: string } export interface OniWithActiveSection extends Oni.Plugin.Api { sessions: SessionManager getActiveSection(): string } type ExecuteCommand = (command: string, args?: T) => void export interface IWelcomeInputEvent { select: boolean vertical: number horizontal?: number } interface ICommandMetadata { execute: (args?: T) => void command: string } export interface IWelcomeCommandsDictionary { openFile: ICommandMetadata openTutor: ICommandMetadata openDocs: ICommandMetadata openConfig: ICommandMetadata openThemes: ICommandMetadata openWorkspaceFolder: ICommandMetadata commandPalette: ICommandMetadata commandline: ICommandMetadata restoreSession: (sessionName: string) => Promise } export class WelcomeBufferLayer implements Oni.BufferLayer { public inputEvent = new Event() constructor(private _oni: OniWithActiveSection) {} public showCommandline = async () => { const remapping: string = await this._oni.editors.activeEditor.neovim.callFunction( "mapcheck", [":", "n"], ) const mapping = remapping || ":" this._oni.automation.sendKeys(mapping) } public executeCommand: ExecuteCommand = (cmd, args) => { this._oni.commands.executeCommand(cmd, args) } public restoreSession = async (name: string) => { await this._oni.sessions.restoreSession(name) } // tslint:disable-next-line public readonly welcomeCommands: IWelcomeCommandsDictionary = { openFile: { execute: args => this.executeCommand("oni.editor.newFile", args), command: "oni.editor.newFile", }, openWorkspaceFolder: { execute: args => this.executeCommand("workspace.openFolder", args), command: "workspace.openFolder", }, commandPalette: { execute: args => this.executeCommand("commands.show", args), command: "commands.show", }, commandline: { execute: this.showCommandline, command: "editor.executeVimCommand", }, openTutor: { execute: args => this.executeCommand("oni.tutor.open", args), command: "oni.tutor.open", }, openDocs: { execute: args => this.executeCommand("oni.docs.open", args), command: "oni.docs.open", }, openConfig: { execute: args => this.executeCommand("oni.config.openUserConfig", args), command: "oni.config.openUserConfig", }, openThemes: { execute: args => this.executeCommand("oni.themes.choose", args), command: "oni.themes.open", }, restoreSession: args => this.restoreSession(args), } public get id() { return "oni.welcome" } public get friendlyName() { return "Welcome" } public isActive(): boolean { const activeSection = this._oni.getActiveSection() return activeSection === "editor" } public handleInput(key: string) { Log.info(`ONI WELCOME INPUT KEY: ${key}`) switch (key) { case "j": this.inputEvent.dispatch({ vertical: 1, select: false }) break case "k": this.inputEvent.dispatch({ vertical: -1, select: false }) break case "l": this.inputEvent.dispatch({ vertical: 0, select: false, horizontal: 1 }) break case "h": this.inputEvent.dispatch({ vertical: 0, select: false, horizontal: -1 }) break case "": this.inputEvent.dispatch({ vertical: 0, select: true }) break default: this.inputEvent.dispatch({ vertical: 0, select: false }) } } public getProps() { const active = this._oni.getActiveSection() === "editor" const commandIds = Object.values(this.welcomeCommands) .map(({ command }) => command) .filter(Boolean) const sessions = this._oni.sessions ? this._oni.sessions.allSessions : ([] as ISession[]) const sessionIds = sessions.map(({ id }) => id) const ids = [...commandIds, ...sessionIds] const sections = [commandIds.length, sessionIds.length].filter(Boolean) return { active, ids, sections, sessions } } public render(context: Oni.BufferLayerRenderContext) { const props = this.getProps() return ( ) } } export interface WelcomeViewProps { active: boolean sessions: ISession[] sections: number[] ids: string[] inputEvent: Event commands: IWelcomeCommandsDictionary getMetadata: () => Promise<{ version: string }> restoreSession: (name: string) => Promise executeCommand: ExecuteCommand } export interface WelcomeViewState { version: string selectedId: string currentIndex: number } export class WelcomeView extends React.PureComponent { public state: WelcomeViewState = { version: null, currentIndex: 0, selectedId: this.props.ids[0], } private _welcomeElement = React.createRef() public async componentDidMount() { const metadata = await this.props.getMetadata() this.setState({ version: metadata.version }) this.props.inputEvent.subscribe(this.handleInput) } public handleInput = async ({ vertical, select, horizontal }: IWelcomeInputEvent) => { const { currentIndex } = this.state const { sections, ids, active } = this.props const newIndex = this.getNextIndex(currentIndex, vertical, horizontal, sections) const selectedId = ids[newIndex] this.setState({ currentIndex: newIndex, selectedId }) const selectedSession = this.props.sessions.find(session => session.id === selectedId) if (select && active) { if (selectedSession) { await this.props.commands.restoreSession(selectedSession.name) } else { const currentCommand = this.getCurrentCommand(selectedId) currentCommand.execute() } } } public getCurrentCommand(selectedId: string): ICommandMetadata { const { commands } = this.props const currentCommand = Object.values(commands).find(({ command }) => command === selectedId) return currentCommand } public getNextIndex( currentIndex: number, vertical: number, horizontal: number, sections: number[], ) { const nextPosition = currentIndex + vertical const numberOfItems = this.props.ids.length const multipleSections = sections.length > 1 // TODO: this currently handles *TWO* sections if more sections // are to be added will need to rethink how to allow navigation across multiple sections switch (true) { case multipleSections && horizontal === 1: return sections[0] case multipleSections && horizontal === -1: return 0 case nextPosition < 0: return numberOfItems - 1 case nextPosition === numberOfItems: return 0 default: return nextPosition } } public componentDidUpdate() { if (this.props.active && this._welcomeElement && this._welcomeElement.current) { this._welcomeElement.current.focus() } } public render() { const { version, selectedId } = this.state return ( Oni Modern Modal Editing {version && {`v${version}`}}
    {"https://onivim.io"}
    Sessions {this.props.sessions.length ? ( this.props.sessions.map(session => ( this.props.restoreSession(session.name)} key={session.id} > {" "} {session.name} )) ) : ( No Sessions Available )}
    ) } } export interface IWelcomeCommandsViewProps extends Partial { selectedId: string } export const WelcomeCommandsView: React.SFC = props => { const isSelected = (command: string) => command === props.selectedId const { commands } = props return ( Quick Commands commands.openFile.execute()} description="Control + N" command={commands.openFile.command} selected={isSelected(commands.openFile.command)} /> commands.openWorkspaceFolder.execute()} command={commands.openWorkspaceFolder.command} selected={isSelected(commands.openWorkspaceFolder.command)} /> commands.commandPalette.execute()} description="Control + Shift + P" command={commands.commandPalette.command} selected={isSelected(commands.commandPalette.command)} /> commands.commandline.execute()} selected={isSelected(commands.commandline.command)} /> Learn commands.openTutor.execute()} description="Learn modal editing with an interactive tutorial." command={commands.openTutor.command} selected={isSelected(commands.openTutor.command)} /> commands.openDocs.execute()} description="Discover what Oni can do for you." command={commands.openDocs.command} selected={isSelected(commands.openDocs.command)} /> Customize commands.openConfig.execute()} description="Make Oni work the way you want." command={commands.openConfig.command} selected={isSelected(commands.openConfig.command)} /> commands.openThemes.execute()} description="Choose a theme that works for you." command={commands.openThemes.command} selected={isSelected(commands.openThemes.command)} /> ) } ================================================ FILE: browser/src/Editor/NeovimEditor/index.ts ================================================ export * from "./NeovimEditor" ================================================ FILE: browser/src/Editor/NeovimEditor/markdown.ts ================================================ import { unescape } from "lodash" import * as marked from "marked" import * as Log from "oni-core-logging" import { IGrammarPerLine, IGrammarToken } from "./../../Services/SyntaxHighlighting/TokenGenerator" import * as DOMPurify from "dompurify" const renderer = new marked.Renderer() interface IRendererArgs { tokens?: IGrammarPerLine text: string element?: TextElement container?: TextElement } interface Symbols { [symbol: string]: string[] } export const scopesToString = (scope: string[]) => { if (scope) { return scope .map(s => { if (s.includes(".")) { const lastStop = s.lastIndexOf(".") const remainder = s.substring(0, lastStop) return remainder.replace(/\./g, "-") } return s }) .filter(value => !!value) .join(" ") } return null } /** * escapeRegExp * Escapes a string intended for use as a regexp * @param {string} str * @returns {string} */ export function escapeRegExp(str: string) { // NOTE This does NOT escape the "|" operator as it's needed for the Reg Exp // Also does not escape "\-" as hypenated tokens can be found return str.replace(/[\[\]\/\{\}\(\)\*\+\?\.\\\^\$\n\r]/g, "\\$&") } type TextElement = "code" | "pre" | "p" | "span" export const createContainer = (type: TextElement, content: string) => { switch (type) { case "pre": return `
    ${content}
    ` case "p": return `<${type} class="marked-paragraph">${content}` case "code": default: return content } } interface WrapTokenArgs { tokens: IGrammarToken[] element: string text: string } export function wrapTokens({ tokens, element, text }: WrapTokenArgs): string { try { const symbols: Symbols = tokens.reduce((acc, token) => { const symbol = text.substring(token.range.start.character, token.range.end.character) acc[symbol] = token.scopes return acc }, {}) const symbolNames = Object.keys(symbols) const banned = ["\n", "\r", " ", "|"] const filteredNames = symbolNames.filter(str => !banned.includes(str)) // Check if a word is alphabetical if so make sure to match full words only // if not alphabetical escape the string const wholeWordMatch = filteredNames.map( str => (/^[A-Za-z]/.test(str) ? `\\b${str}\\b` : escapeRegExp(str)), ) const symbolRegex = new RegExp("(" + wholeWordMatch.join("|") + ")", "g") const html = text.replace(symbolRegex, (match, ...args) => { const className = scopesToString(symbols[match]) return `<${element} class="marked ${className}">${match}` }) return html } catch (e) { Log.warn(`Regex construction failed with: ${e.message}`) return text } } /** * Takes a list of tokens which contain ranges, the text from marked (3rd party lib) * uses a reg exp to replace all matching tokens with an element with a class that is styled * else where * @returns {string} */ export function renderWithClasses({ tokens, text, element = "span", container = "p", }: IRendererArgs) { // This is critical because marked's renderer refuses to leave html untouched so it converts // special chars to html entities const unescapedText = unescape(text) const whiteSpaceForCode = (line: string, code: boolean) => (code ? line.trim() : line) if (tokens) { const isCodeBlock = container === "code" const tokenValues = Object.values(tokens) const tokenLines = tokenValues.map(l => whiteSpaceForCode(l.line, isCodeBlock)) const parts = unescapedText.split("\n") // Find common lines in lines to render and lines in tokenisation map const intersection = tokenLines.filter(x => parts.includes(x)) const lineToToken = tokenValues.reduce((acc, t) => { const key = whiteSpaceForCode(t.line, isCodeBlock) acc[key] = t return acc }, {}) if (intersection.length) { const html = intersection.reduce((acc, match) => { return `${(acc += wrapTokens({ tokens: lineToToken[match].tokens, element, text: lineToToken[match].line, }))}\n` }, "") if (container) { return createContainer(container, html) } return html } } return text } interface IConversionArgs { markdown: string tokens?: IGrammarPerLine type?: string } const config = { FORBID_TAGS: ["img", "script"], } /** * Takes a markdown string and defines a custom renderer then for the element type and returns an html string * * @name convertMarkdown * @function * @param {string} {markdown A Markdown String * @param {Object} tokens An Object of lines with tokens for each line * @param {string} type the section of the quickfix being processed * @returns {string} An html string */ export const convertMarkdown = ({ markdown, tokens, type = "title" }: IConversionArgs): string => { marked.setOptions({ sanitize: true, gfm: true, renderer, highlight: (code, lang) => { return renderWithClasses({ text: code, tokens, container: "code" }) }, }) switch (type) { case "documentation": renderer.html = htmlString => DOMPurify.sanitize(htmlString, config) renderer.paragraph = text => createContainer("p", DOMPurify.sanitize(text, config)) break case "title": default: renderer.html = htmlString => DOMPurify.sanitize(htmlString, config) renderer.paragraph = text => { const stringWithClasses = renderWithClasses({ text, tokens }) return DOMPurify.sanitize(stringWithClasses, config) } renderer.blockquote = text => { const stringWithClasses = renderWithClasses({ text, tokens, container: "pre", }) return DOMPurify.sanitize(stringWithClasses, config) } } const html = marked(markdown) return html } ================================================ FILE: browser/src/Editor/OniEditor/ColorHighlightLayer.tsx ================================================ import * as Color from "color" import * as memoize from "lodash/memoize" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import * as React from "react" import styled, { pixel, withProps } from "../../UI/components/common" interface IBackground { top: number left: number height: number width: number } interface IHighlight { color: string fontFamily: string height: number fontSize: string } const Background = withProps(styled.div).attrs({ style: (props: IBackground) => ({ top: pixel(props.top), left: pixel(props.left), height: pixel(props.height), width: pixel(props.width), }), })` background-color: ${p => p.theme["editor.background"]}; position: absolute; white-space: nowrap; ` const HighlightSpan = withProps(styled.div)` display: block; height: 100%; width: 100%; color: ${p => (Color(p.color).dark() ? "white" : "black")}; font-family: ${p => p.fontFamily}; font-size: ${p => p.fontSize}; line-height: ${p => pixel(p.height + 5)}; /* vertically center text inside the highlight */ background-color: ${p => p.color}; ` interface IState { error: Error } type IProps = IHighlight & IBackground class Highlight extends React.PureComponent { public state: IState = { error: null, } public componentDidCatch(error: Error) { this.setState({ error }) } public render() { return ( !this.state.error && ( {this.props.children} ) ) } } export default class ColorHighlightLayer implements Oni.BufferLayer { public render = memoize((context: Oni.BufferLayerRenderContext) => ( <>{this._getColorHighlights(context)} )) private readonly CSS_COLOR_NAMES = [ "AliceBlue", "AntiqueWhite", "Aqua", "Aquamarine", "Azure", "Beige", "Bisque", "Black", "BlanchedAlmond", "Blue", "BlueViolet", "Brown", "BurlyWood", "CadetBlue", "Chartreuse", "Chocolate", "Coral", "CornflowerBlue", "Cornsilk", "Crimson", "Cyan", "DarkBlue", "DarkCyan", "DarkGoldenRod", "DarkGray", "DarkGrey", "DarkGreen", "DarkKhaki", "DarkMagenta", "DarkOliveGreen", "Darkorange", "DarkOrchid", "DarkRed", "DarkSalmon", "DarkSeaGreen", "DarkSlateBlue", "DarkSlateGray", "DarkSlateGrey", "DarkTurquoise", "DarkViolet", "DeepPink", "DeepSkyBlue", "DimGray", "DimGrey", "DodgerBlue", "FireBrick", "FloralWhite", "ForestGreen", "Fuchsia", "Gainsboro", "GhostWhite", "Gold", "GoldenRod", "Gray", "Grey", "Green", "GreenYellow", "HoneyDew", "HotPink", "IndianRed", "Indigo", "Ivory", "Khaki", "Lavender", "LavenderBlush", "LawnGreen", "LemonChiffon", "LightBlue", "LightCoral", "LightCyan", "LightGoldenRodYellow", "LightGray", "LightGrey", "LightGreen", "LightPink", "LightSalmon", "LightSeaGreen", "LightSkyBlue", "LightSlateGray", "LightSlateGrey", "LightSteelBlue", "LightYellow", "Lime", "LimeGreen", "Linen", "Magenta", "Maroon", "MediumAquaMarine", "MediumBlue", "MediumOrchid", "MediumPurple", "MediumSeaGreen", "MediumSlateBlue", "MediumSpringGreen", "MediumTurquoise", "MediumVioletRed", "MidnightBlue", "MintCream", "MistyRose", "Moccasin", "NavajoWhite", "Navy", "OldLace", "Olive", "OliveDrab", "Orange", "OrangeRed", "Orchid", "PaleGoldenRod", "PaleGreen", "PaleTurquoise", "PaleVioletRed", "PapayaWhip", "PeachPuff", "Peru", "Pink", "Plum", "PowderBlue", "Purple", "Red", "RosyBrown", "RoyalBlue", "SaddleBrown", "Salmon", "SandyBrown", "SeaGreen", "SeaShell", "Sienna", "Silver", "SkyBlue", "SlateBlue", "SlateGray", "SlateGrey", "Snow", "SpringGreen", "SteelBlue", "Tan", "Teal", "Thistle", "Tomato", "Turquoise", "Violet", "Wheat", "White", "WhiteSmoke", "Yellow", "YellowGreen", ] // Match hex/rgb/rgba/hsl/hsla colors - // courtesy of https://gist.github.com/olmokramer/82ccce673f86db7cda5e // the first section matches a hex code which can be 3 or 6 digits long the // next section matches rgb or hsl value with an a optionally // NB - the regex was tweaked so it could match inside a string private _colorCodeRegex = /#(?:[0-9a-f]{3}){1,2}|(rgb|hsl)a?\((-?\d+%?[,\s]+){2,3}\s*[\d\.]+%?\)/gi private _colorRegex: RegExp private _fontSize: string private _fontFamily: string constructor(private _config: Oni.Configuration) { this._fontSize = this._config.getValue("editor.fontSize") this._fontFamily = this._config.getValue("editor.fontFamily") this._config.onConfigurationChanged.subscribe(this._updateFontFamily) this._constructRegex() } public get id() { return "color-highlight" } public get friendlyName() { return "CSS color highlight layer" } private _updateFontFamily = (configChanges: Partial) => { const fontFamilyChanged = Object.keys(configChanges).includes("editor.fontFamily") if (fontFamilyChanged) { this._fontFamily = configChanges["editor.fontFamily"] } } private _constructRegex() { // Construct a regex checking for both color codes and all the different css colornames const colorNames = this.CSS_COLOR_NAMES.map(name => `\\b${name}\\b`) const colorNamesRegex = new RegExp("(" + colorNames.join("|") + ")") this._colorRegex = new RegExp( colorNamesRegex.source + "|" + this._colorCodeRegex.source, "gi", ) } private _getColorHighlights = (context: Oni.BufferLayerRenderContext) => { return context.visibleLines.map((line, idx) => { try { const matches = line.match(this._colorRegex) if (matches) { const colors = matches.filter(Boolean) if (colors.length) { const locations = colors.map(c => ({ color: c, start: line.indexOf(c), end: line.indexOf(c) + c.length, })) const currentLine = context.topBufferLine + idx - 1 return locations.map(location => { const startPosition = context.bufferToPixel({ line: currentLine, character: location.start, }) const endPosition = context.bufferToPixel({ line: currentLine, character: location.end, }) if (!startPosition || !endPosition) { return null } const width = endPosition.pixelX - startPosition.pixelX return ( {location.color} ) }) } } } catch (e) { Log.warn(`Failed to create color highlights because ${e.message}`) return null } return null }) } } ================================================ FILE: browser/src/Editor/OniEditor/ImageBufferLayer.tsx ================================================ /** * ImageBufferLayer.tsx */ import * as React from "react" import styled from "styled-components" // import { inputManager, InputManager } from "./../../Services/InputManager" import * as Oni from "oni-api" import { withProps } from "./../../UI/components/common" // import { VimNavigator } from "./../../UI/components/VimNavigator" export class ImageBufferLayer implements Oni.BufferLayer { constructor(private _buffer: Oni.Buffer) {} public get id(): string { return "oni.image" } public get friendlyName(): string { return "Image" } public render(context: Oni.BufferLayerRenderContext): JSX.Element { return } } export interface IImageLayerViewProps { imagePath: string } export interface IImageLayerViewState { width: number height: number } const ImageContainer = withProps<{}>(styled.div)` background-color: ${props => props.theme["editor.background"]}; color: ${props => props.theme["editor.foreground"]}; position: absolute; top: 0px; left: 0px; right: 0px; bottom: 0px; display: flex; justify-content: center; align-items: center; flex-direction: column; opacity: 0.95; & img { max-width: 90%; max-height: 90%; padding-bottom: 2em; } ` export class ImageLayerView extends React.PureComponent< IImageLayerViewProps, IImageLayerViewState > { constructor(props: IImageLayerViewProps) { super(props) this.state = { width: -1, height: -1, } } public componentDidMount(): void { const image = new Image() image.onload = () => { this.setState({ width: image.width, height: image.height, }) } image.src = this.props.imagePath } public render(): JSX.Element { const dimensions = this.state ? `${this.state.width}x${this.state.height}` : "" return (
    {this.props.imagePath}
    {dimensions}
    ) } } ================================================ FILE: browser/src/Editor/OniEditor/IndentGuideBufferLayer.tsx ================================================ import * as React from "react" import * as detectIndent from "detect-indent" import * as flatten from "lodash/flatten" import * as last from "lodash/last" import * as memoize from "lodash/memoize" import * as Oni from "oni-api" import { IBuffer } from "../BufferManager" import styled, { pixel, withProps } from "./../../UI/components/common" interface IWrappedLine { start: number end: number line: string } interface IProps { height: number left: number top: number color?: string } interface ConfigOptions { skipFirst: boolean color?: string } interface LinePropsWithLevels extends IndentLinesProps { levelOfIndentation: number } interface IndentLinesProps { top: number left: number height: number line: string indentBy: number indentSize: number characterWidth: number } const Container = styled.div`` const IndentLine = withProps(styled.span).attrs({ style: ({ height, left, top }: IProps) => ({ height: pixel(height), left: pixel(left), top: pixel(top), }), })` border-left: 1px solid ${p => p.color || "rgba(100, 100, 100, 0.4)"}; position: absolute; ` interface IndentLayerArgs { buffer: IBuffer configuration: Oni.Configuration } class IndentGuideBufferLayer implements Oni.BufferLayer { public render = memoize((bufferLayerContext: Oni.BufferLayerRenderContext) => { return {this._renderIndentLines(bufferLayerContext)} }) private _buffer: IBuffer private _userSpacing: number private _configuration: Oni.Configuration constructor({ buffer, configuration }: IndentLayerArgs) { this._buffer = buffer this._configuration = configuration this._userSpacing = this._buffer.shiftwidth || this._buffer.tabstop } get id() { return "indent-guides" } get friendlyName() { return "Indent Guide Lines" } private _getIndentLines = (guidePositions: IndentLinesProps[], options: ConfigOptions) => { return flatten( guidePositions.map((props, idx) => { const indents: JSX.Element[] = [] // Create a line per indentation for ( let levelOfIndentation = 0; levelOfIndentation < props.indentBy; levelOfIndentation++ ) { const lineProps = { ...props, levelOfIndentation } const adjustedLeft = this._calculateLeftPosition(lineProps) const shouldSkip = this._determineIfShouldSkip(lineProps, options) const key = `${props.line.trim()}-${idx}-${levelOfIndentation}` indents.push( !shouldSkip && ( ), ) } return indents }), ) } private _determineIfShouldSkip(props: LinePropsWithLevels, options: ConfigOptions) { const skipFirstIndentLine = options.skipFirst && props.levelOfIndentation === props.indentBy - 1 return skipFirstIndentLine } /** * Remove one indent from left positioning and move lines slightly inwards - * by a third of a character for a better visual appearance */ private _calculateLeftPosition(props: LinePropsWithLevels) { const adjustedLeft = props.left - props.indentSize - props.levelOfIndentation * props.indentSize + props.characterWidth / 3 return adjustedLeft } private _getWrappedLines(context: Oni.BufferLayerRenderContext): IWrappedLine[] { const { lines } = context.visibleLines.reduce( (acc, line, index) => { const currentLine = context.topBufferLine + index const bufferInfo = context.bufferToScreen({ line: currentLine, character: 0 }) if (bufferInfo && bufferInfo.screenY) { const { screenY: screenLine } = bufferInfo if (acc.expectedLine !== screenLine) { acc.lines.push({ start: acc.expectedLine, end: screenLine, line, }) acc.expectedLine = screenLine + 1 } else { acc.expectedLine += 1 } } return acc }, { lines: [], expectedLine: 1 }, ) return lines } private _regulariseIndentation(indentation: detectIndent.IndentInfo) { const isOddBy = indentation.amount % this._userSpacing const amountToIndent = isOddBy ? indentation.amount - isOddBy : indentation.amount return amountToIndent } /** * Calculates the position of each indent guide element using shiftwidth or tabstop if no * shift width available * @name _renderIndentLines * @function * @param {Oni.BufferLayerRenderContext} bufferLayerContext The buffer layer context * @returns {JSX.Element[]} An array of react elements */ private _renderIndentLines = (bufferLayerContext: Oni.BufferLayerRenderContext) => { // TODO: If the beginning of the visible lines is wrapping no lines are drawn const wrappedScreenLines = this._getWrappedLines(bufferLayerContext) const options = { color: this._configuration.getValue("experimental.indentLines.color"), skipFirst: this._configuration.getValue("experimental.indentLines.skipFirst"), } const { visibleLines, fontPixelHeight, fontPixelWidth, topBufferLine } = bufferLayerContext const indentSize = this._userSpacing * fontPixelWidth const { allIndentations } = visibleLines.reduce( (acc, line, currenLineNumber) => { const rawIndentation = detectIndent(line) const regularisedIndent = this._regulariseIndentation(rawIndentation) const previous = last(acc.allIndentations) const height = Math.ceil(fontPixelHeight) // start position helps determine the initial indent offset const startPosition = bufferLayerContext.bufferToScreen({ line: topBufferLine, character: regularisedIndent, }) const wrappedLine = wrappedScreenLines.find(wrapped => wrapped.line === line) const levelsOfWrapping = wrappedLine ? wrappedLine.end - wrappedLine.start : 1 const adjustedHeight = height * levelsOfWrapping if (!startPosition) { return acc } const { pixelX: left, pixelY: top } = bufferLayerContext.screenToPixel({ screenX: startPosition.screenX, screenY: currenLineNumber, }) const adjustedTop = top + acc.wrappedHeightAdjustment // Only adjust height for Subsequent lines! if (wrappedLine) { acc.wrappedHeightAdjustment += adjustedHeight } if (!line && previous) { acc.allIndentations.push({ ...previous, line, top: adjustedTop, }) return acc } const indent = { left, line, indentSize, top: adjustedTop, height: adjustedHeight, characterWidth: fontPixelWidth, indentBy: regularisedIndent / this._userSpacing, } acc.allIndentations.push(indent) return acc }, { allIndentations: [], wrappedHeightAdjustment: 0 }, ) return this._getIndentLines(allIndentations, options) } } export default IndentGuideBufferLayer ================================================ FILE: browser/src/Editor/OniEditor/OniEditor.tsx ================================================ /** * OniEditor.ts * * Editor implementation for Oni * * Extends the capabilities of the NeovimEditor */ import * as path from "path" import * as React from "react" import * as types from "vscode-languageserver-types" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { IEvent } from "oni-types" // import { remote } from "electron" import * as App from "./../../App" import * as Utility from "./../../Utility" import { PluginManager } from "./../../Plugins/PluginManager" import { IColors } from "./../../Services/Colors" import { commandManager } from "./../../Services/CommandManager" import { CompletionProviders } from "./../../Services/Completion" import { Configuration } from "./../../Services/Configuration" import { IDiagnosticsDataSource } from "./../../Services/Diagnostics" import { editorManager } from "./../../Services/EditorManager" import { LanguageManager } from "./../../Services/Language" import { MenuManager } from "./../../Services/Menu" import { OverlayManager } from "./../../Services/Overlay" import { SnippetManager } from "./../../Services/Snippets" import { ISyntaxHighlighter } from "./../../Services/SyntaxHighlighting" import { ThemeManager } from "./../../Services/Themes" import { TokenColors } from "./../../Services/TokenColors" import { Workspace } from "./../../Services/Workspace" import { BufferScrollBarContainer } from "./containers/BufferScrollBarContainer" import { DefinitionContainer } from "./containers/DefinitionContainer" import { ErrorsContainer } from "./containers/ErrorsContainer" import { NeovimEditor } from "./../NeovimEditor" import { SplitDirection, windowManager } from "./../../Services/WindowManager" import { ISession } from "../../Services/Sessions" import { IBuffer } from "../BufferManager" import { OniWithActiveSection, WelcomeBufferLayer } from "../NeovimEditor/WelcomeBufferLayer" import ColorHighlightLayer from "./ColorHighlightLayer" import { ImageBufferLayer } from "./ImageBufferLayer" import IndentLineBufferLayer from "./IndentGuideBufferLayer" // Helper method to wrap a react component into a layer const wrapReactComponentWithLayer = (id: string, component: JSX.Element): Oni.BufferLayer => { return { id, render: (context: Oni.BufferLayerRenderContext) => (context.isActive ? component : null), } } export class OniEditor extends Utility.Disposable implements Oni.Editor { private _neovimEditor: NeovimEditor public get mode(): string { return this._neovimEditor.mode } public get onCursorMoved(): IEvent { return this._neovimEditor.onCursorMoved } public get onModeChanged(): IEvent { return this._neovimEditor.onModeChanged } public get onBufferEnter(): IEvent { return this._neovimEditor.onBufferEnter } public get onBufferLeave(): IEvent { return this._neovimEditor.onBufferLeave } public get onBufferChanged(): IEvent { return this._neovimEditor.onBufferChanged } public get onBufferSaved(): IEvent { return this._neovimEditor.onBufferSaved } public get onBufferScrolled(): IEvent { return this._neovimEditor.onBufferScrolled } public get /* override */ activeBuffer(): Oni.Buffer { return this._neovimEditor.activeBuffer } public get onQuit(): IEvent { return this._neovimEditor.onNeovimQuit } // Capabilities public get neovim(): Oni.NeovimEditorCapability { return this._neovimEditor.neovim } public get syntaxHighlighter(): ISyntaxHighlighter { return this._neovimEditor.syntaxHighlighter } constructor( private _colors: IColors, private _completionProviders: CompletionProviders, private _configuration: Configuration, private _diagnostics: IDiagnosticsDataSource, private _languageManager: LanguageManager, private _menuManager: MenuManager, private _overlayManager: OverlayManager, private _pluginManager: PluginManager, private _snippetManager: SnippetManager, private _themeManager: ThemeManager, private _tokenColors: TokenColors, private _workspace: Workspace, ) { super() this._neovimEditor = new NeovimEditor( this._colors, this._completionProviders, this._configuration, this._diagnostics, this._languageManager, this._menuManager, this._overlayManager, this._pluginManager, this._snippetManager, this._themeManager, this._tokenColors, this._workspace, ) editorManager.registerEditor(this) this.trackDisposable( this._neovimEditor.onNeovimQuit.subscribe(() => { 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", ), ) this._neovimEditor.bufferLayers.addBufferLayer("*", buf => wrapReactComponentWithLayer("oni.layer.definition", ), ) this._neovimEditor.bufferLayers.addBufferLayer("*", buf => wrapReactComponentWithLayer("oni.layer.errors", ), ) const imageExtensions = this._configuration.getValue("editor.imageLayerExtensions") const bannedIndentExtensions = this._configuration.getValue( "experimental.indentLines.bannedFiletypes", ) this._neovimEditor.bufferLayers.addBufferLayer( buf => imageExtensions.includes(path.extname(buf.filePath)), buf => new ImageBufferLayer(buf), ) if (this._configuration.getValue("experimental.indentLines.enabled")) { this._neovimEditor.bufferLayers.addBufferLayer( buf => { const extension = path.extname(buf.filePath) return extension && !bannedIndentExtensions.includes(extension) }, buffer => new IndentLineBufferLayer({ buffer: buffer as IBuffer, configuration: this._configuration, }), ) } if (this._configuration.getValue("experimental.colorHighlight.enabled")) { this._neovimEditor.bufferLayers.addBufferLayer( buf => this._configuration .getValue("experimental.colorHighlight.filetypes") .includes(path.extname(buf.filePath)), _buf => new ColorHighlightLayer(this._configuration), ) } this._neovimEditor.onShowWelcomeScreen.subscribe(this.openWelcomeScreen) } public dispose(): void { super.dispose() if (this._neovimEditor) { this._neovimEditor.dispose() this._neovimEditor = null } } public enter(): void { Log.info("[OniEditor::enter]") editorManager.setActiveEditor(this) this._neovimEditor.enter() commandManager.registerCommand({ name: "Oni: Show Welcome", detail: "Open the welcome screen", command: "oni.welcome.open", execute: this.openWelcomeScreen, enabled: () => this._configuration.getValue("experimental.welcome.enabled"), }) commandManager.registerCommand({ command: "editor.split.horizontal", execute: () => this._split("horizontal"), enabled: () => editorManager.activeEditor === this, name: null, detail: null, }) commandManager.registerCommand({ command: "editor.split.vertical", execute: () => this._split("vertical"), enabled: () => editorManager.activeEditor === this, name: null, detail: null, }) } public leave(): void { Log.info("[OniEditor::leave]") this._neovimEditor.leave() } public openWelcomeScreen = async () => { const oni = this._pluginManager.getApi() const welcomeBuffer = await this._neovimEditor.createWelcomeBuffer() const welcomeLayer = new WelcomeBufferLayer(oni as OniWithActiveSection) welcomeBuffer.addLayer(welcomeLayer) } public async openFile( file: string, openOptions: Oni.FileOpenOptions = Oni.DefaultFileOpenOptions, ): Promise { const openMode = openOptions.openMode if (this._configuration.getValue("editor.split.mode") === "oni") { if ( openMode === Oni.FileOpenMode.HorizontalSplit || openMode === Oni.FileOpenMode.VerticalSplit ) { const splitDirection = openMode === Oni.FileOpenMode.HorizontalSplit ? "horizontal" : "vertical" const newEditor = await this._split(splitDirection) return newEditor.openFile(file, { openMode: Oni.FileOpenMode.Edit }) } } return this._neovimEditor.openFile(file, openOptions) } public async newFile(filePath: string): Promise { return this._neovimEditor.newFile(filePath) } public async clearSelection(): Promise { return this._neovimEditor.clearSelection() } public async setSelection(range: types.Range): Promise { return this._neovimEditor.setSelection(range) } public async setTextOptions(textOptions: Oni.EditorTextOptions): Promise { return this._neovimEditor.setTextOptions(textOptions) } public async blockInput( inputFunction: (input: Oni.InputCallbackFunction) => Promise, ): Promise { return this._neovimEditor.blockInput(inputFunction) } public executeCommand(command: string): void { this._neovimEditor.executeCommand(command) } public restoreSession(sessionDetails: ISession) { return this._neovimEditor.restoreSession(sessionDetails) } public getCurrentSession() { return this._neovimEditor.getCurrentSession() } public persistSession(sessionDetails: ISession) { return this._neovimEditor.persistSession(sessionDetails) } public getBuffers(): Array { return this._neovimEditor.getBuffers() } public async bufferDelete(bufferId: string = this.activeBuffer.id): Promise { this._neovimEditor.bufferDelete(bufferId) } public async init(filesToOpen: string[]): Promise { Log.info("[OniEditor::init] Called with filesToOpen: " + filesToOpen) return this._neovimEditor.init(filesToOpen) } public async input(key: string): Promise { return this._neovimEditor.input(key) } public render(): JSX.Element { 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") { await this._neovimEditor.neovim.command(":sp") } else { await this._neovimEditor.neovim.command(":vsp") } return this } const newEditor = new OniEditor( this._colors, this._completionProviders, this._configuration, this._diagnostics, this._languageManager, this._menuManager, this._overlayManager, this._pluginManager, this._snippetManager, this._themeManager, this._tokenColors, this._workspace, ) windowManager.createSplit(direction, newEditor, this) await newEditor.init([]) return newEditor } } ================================================ FILE: browser/src/Editor/OniEditor/containers/BufferScrollBarContainer.ts ================================================ import * as types from "vscode-languageserver-types" import { connect } from "react-redux" import { createSelector } from "reselect" import { getColorFromSeverity } from "./../../../Services/Diagnostics" import { BufferScrollBar, IBufferScrollBarProps, IScrollBarMarker, } from "./../../../UI/components/BufferScrollBar" import * as Selectors from "./../../NeovimEditor/NeovimEditorSelectors" import * as State from "./../../NeovimEditor/NeovimEditorStore" export const getCurrentLine = createSelector([Selectors.getActiveWindow], activeWindow => { return activeWindow.line }) const NoScrollBar: IBufferScrollBarProps = { windowId: null, bufferSize: -1, height: -1, windowTopLine: -1, windowBottomLine: -1, markers: [], visible: false, } export const shouldIncludeCursorMarker = (state: State.IState) => { return state.configuration["editor.scrollBar.cursorTick.visible"] } export const getMarkers = createSelector( [getCurrentLine, Selectors.getErrorsForActiveFile, shouldIncludeCursorMarker], (activeLine, fileErrors, includeCursor) => { const errorMarkers = fileErrors.map((e: types.Diagnostic) => ({ line: e.range.start.line || 0, height: 1, color: getColorFromSeverity(e.severity), })) if (!includeCursor) { return errorMarkers } else { const cursorMarker: IScrollBarMarker = { line: activeLine - 1, height: 1, color: "rgb(200, 200, 200)", } return [...errorMarkers, cursorMarker] } }, ) const mapStateToProps = (state: State.IState): IBufferScrollBarProps => { const visible = state.configuration["editor.scrollBar.visible"] const activeWindow = Selectors.getActiveWindow(state) if (!activeWindow) { return NoScrollBar } const dimensions = Selectors.getActiveWindowPixelDimensions(state) const file = activeWindow.file const buffer = Selectors.getActiveBuffer(state) if (file === null || !buffer) { return NoScrollBar } const bufferSize = buffer.totalLines const markers = getMarkers(state) return { windowId: activeWindow.windowId, windowTopLine: activeWindow.topBufferLine, windowBottomLine: activeWindow.bottomBufferLine, bufferSize, markers, height: dimensions.height, visible, } } export const BufferScrollBarContainer = connect(mapStateToProps)(BufferScrollBar) ================================================ FILE: browser/src/Editor/OniEditor/containers/DefinitionContainer.ts ================================================ import { connect } from "react-redux" import * as types from "vscode-languageserver-types" import { Definition, IDefinitionProps } from "./../../../UI/components/Definition" import * as Selectors from "./../../NeovimEditor/NeovimEditorSelectors" import * as State from "./../../NeovimEditor/NeovimEditorStore" const emptyRange = types.Range.create(types.Position.create(-1, -1), types.Position.create(-1, -1)) const getActiveDefinition = (state: State.IState) => state.definition const mapStateToProps = (state: State.IState): IDefinitionProps => { const window = Selectors.getActiveWindow(state) const noop = (): any => null const activeDefinition = getActiveDefinition(state) const range = activeDefinition ? activeDefinition.token.range : emptyRange return { color: state.colors["editor.foreground"], range, fontWidthInPixels: state.fontPixelWidth, fontHeightInPixels: state.fontPixelHeight, bufferToScreen: window ? window.bufferToScreen : noop, screenToPixel: window ? window.screenToPixel : noop, } } export const DefinitionContainer = connect(mapStateToProps)(Definition) ================================================ FILE: browser/src/Editor/OniEditor/containers/ErrorsContainer.ts ================================================ import { connect } from "react-redux" import { Errors, IErrorsProps } from "./../../../UI/components/Error" import * as Selectors from "./../../NeovimEditor/NeovimEditorSelectors" import * as State from "./../../NeovimEditor/NeovimEditorStore" const mapStateToProps = (state: State.IState): IErrorsProps => { const window = Selectors.getActiveWindow(state) const errors = Selectors.getErrorsForActiveFile(state) const noop = (): any => null return { errors, fontWidthInPixels: state.fontPixelWidth, fontHeightInPixels: state.fontPixelHeight, bufferToScreen: window ? window.bufferToScreen : noop, screenToPixel: window ? window.screenToPixel : noop, } } export const ErrorsContainer = connect(mapStateToProps)(Errors) ================================================ FILE: browser/src/Editor/OniEditor/index.ts ================================================ export * from "./OniEditor" ================================================ FILE: browser/src/Font.ts ================================================ export const FallbackFonts = "Consolas,Monaco,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New, monospace" export interface IFontMeasurement { width: number height: number } export function measureFont( fontFamily: string, fontSize: string, fontWeight: string, characterToTest = "H", ) { const div = document.createElement("div") div.style.position = "absolute" div.style.left = "10px" div.style.top = "10px" div.style.backgroundColor = "red" div.style.left = "-1000px" div.style.top = "-1000px" div.textContent = characterToTest div.style.fontFamily = `${fontFamily},${FallbackFonts}` div.style.fontSize = fontSize div.style.fontWeight = fontWeight const isItalicAvailable = isStyleAvailable(fontFamily, "italic", fontSize) const isBoldAvailable = isStyleAvailable(fontFamily, "bold", fontSize) document.body.appendChild(div) const rect = div.getBoundingClientRect() const width = rect.width const height = rect.height document.body.removeChild(div) return { width, height, isItalicAvailable, isBoldAvailable, } } export function addDefaultUnitIfNeeded(fontSize: string) { const roundFont = `${Math.round(parseFloat(fontSize))}px` return roundFont } export function isStyleAvailable(fontName: string, style: string, fontSize = "12px") { const text = "abcdefghijklmnopqrstuvwxyz0123456789" let canvas = document.createElement("canvas") const context = canvas.getContext("2d") context.font = `${fontSize} ${fontName}` const baselineSize = context.measureText(text).width context.font = `${style} ${fontSize} ${fontName}` const newSize = context.measureText(text).width canvas = null return newSize === baselineSize } ================================================ FILE: browser/src/Grid.ts ================================================ export class Grid { private _cells: any = {} private _width: number = 0 private _height: number = 0 public get width(): number { return this._width } public get height(): number { return this._height } public getCell(x: number, y: number): null | T { const row = this._cells[y] if (!row) { return null } const col = row[x] if (typeof col === "undefined") { return null } return col } public setCell(x: number, y: number, val: T | null) { let row = this._cells[y] row = row || {} row[x] = val this._cells[y] = row if (x >= this._width) { this._width = x + 1 } if (y >= this._height) { this._height = y + 1 } } public clear(): void { this._cells = {} this._width = 0 this._height = 0 } public shiftRows(rowsToShift: number): void { // var val = typeof defaultVal === "undefined" ? null : defaultVal let dir: any let start: any if (rowsToShift >= 0) { dir = 1 start = 0 } else { dir = -1 start = this._height - 1 } let current = start while (current >= 0 && current < this._height) { const srcRow = current + rowsToShift for (let x = 0; x < this._width; x++) { const oldCell = this.getCell(x, srcRow) this.setCell(x, current, oldCell as any) } current += dir } } public setRegionFromGrid(grid: Grid, xPosition: number, yPosition: number): void { for (let x = 0; x < grid.width; x++) { for (let y = 0; y < grid.height; y++) { const sourceCell = grid.getCell(x, y) this.setCell(xPosition + x, yPosition + y, sourceCell) } } } public setRegion( startX: number, startY: number, width: number, height: number, val?: T | null, ): void { const valToSet = typeof val === "undefined" ? null : val for (let x = startX; x < startX + width; x++) { for (let y = startY; y < startY + height; y++) { this.setCell(x, y, valToSet) } } } public cloneRegion(x: number, y: number, width: number, height: number): Grid { const outputGrid = new Grid() for (let cloneX = 0; cloneX < width; cloneX++) { for (let cloneY = 0; cloneY < height; cloneY++) { const sourceCell = this.getCell(cloneX + x, cloneY + y) outputGrid.setCell(cloneX, cloneY, sourceCell) } } return outputGrid } } ================================================ FILE: browser/src/Input/KeyBindings.ts ================================================ /** * KeyBindings.ts * * Default, out-of-the-box keybindings for Oni */ import * as Oni from "oni-api" import * as Platform from "./../Platform" import { Configuration } from "./../Services/Configuration" interface ISidebar { sidebar: { activeEntryId: string isFocused: boolean } } export const applyDefaultKeyBindings = (oni: Oni.Plugin.Api, config: Configuration): void => { const { editors, input, menu } = oni input.unbindAll() const isVisualMode = () => editors.activeEditor.mode === "visual" const isNormalMode = () => editors.activeEditor.mode === "normal" const isNotInsertMode = () => editors.activeEditor.mode !== "insert" const isInsertOrCommandMode = () => editors.activeEditor.mode === "insert" || editors.activeEditor.mode === "cmdline_normal" const oniWithSidebar = oni as Oni.Plugin.Api & ISidebar const isSidebarPaneOpen = (paneId: string) => oniWithSidebar.sidebar.activeEntryId === paneId && oniWithSidebar.sidebar.isFocused && !isInsertOrCommandMode() && !isMenuOpen() const isExplorerActive = () => isSidebarPaneOpen("oni.sidebar.explorer") const areSessionsActive = () => isSidebarPaneOpen("oni.sidebar.sessions") const isVCSActive = () => isSidebarPaneOpen("oni.sidebar.vcs") const isMenuOpen = () => menu.isMenuOpen() if (Platform.isMac()) { input.bind("", "oni.quit") input.bind("", "quickOpen.show", () => isNormalMode() && !isMenuOpen()) input.bind("", "commands.show", isNormalMode) input.bind("", "language.codeAction.expand") input.bind("", "language.symbols.workspace", () => !menu.isMenuOpen()) input.bind("", "language.symbols.document") input.bind("", "oni.editor.minimize") input.bind("", "oni.editor.hide") input.bind("", "buffer.toggle") input.bind("", "search.searchAllFiles") input.bind("", "explorer.toggle") input.bind("", "sidebar.decreaseWidth") input.bind("", "sidebar.increaseWidth") input.bind("", "oni.config.openConfigJs") if (config.getValue("editor.clipboard.enabled")) { input.bind("", "editor.clipboard.yank", isVisualMode) input.bind("", "editor.clipboard.paste", isInsertOrCommandMode) } // Browser input.bind("", "browser.goBack") input.bind("", "browser.goForward") input.bind("", "browser.reload") } else { input.bind("", "oni.quit") input.bind("", "sidebar.decreaseWidth") input.bind("", "sidebar.increaseWidth") input.bind("", "quickOpen.show", () => isNormalMode() && !isMenuOpen()) input.bind("", "commands.show", isNormalMode) input.bind("", "language.codeAction.expand") input.bind("", "language.symbols.workspace", () => !menu.isMenuOpen()) input.bind("", "language.symbols.document") input.bind("", "buffer.toggle") input.bind("", "search.searchAllFiles") input.bind("", "explorer.toggle") input.bind("", "oni.config.openConfigJs") if (config.getValue("editor.clipboard.enabled")) { input.bind("", "editor.clipboard.yank", isVisualMode) input.bind("", "editor.clipboard.paste", isInsertOrCommandMode) } // Browser input.bind("", "browser.goBack") input.bind("", "browser.goForward") input.bind("", "browser.reload") } input.bind("", "editor.rename", () => isNormalMode()) input.bind("", "language.format") input.bind([""], "language.gotoDefinition", () => isNormalMode() && !menu.isMenuOpen()) input.bind( ["", ""], "language.gotoDefinition.openVertical", () => isNormalMode() && !menu.isMenuOpen(), ) input.bind( ["", ""], "language.gotoDefinition.openHorizontal", () => isNormalMode() && !menu.isMenuOpen(), ) input.bind("", "commands.show", isNormalMode) input.bind("", "oni.process.cyclePrevious") input.bind("", "oni.process.cycleNext") // QuickOpen input.bind("", "quickOpen.showBufferLines", isNormalMode) input.bind([""], "quickOpen.openFileVertical") input.bind([""], "quickOpen.openFileHorizontal") input.bind("", "quickOpen.openFileNewTab") input.bind([""], "quickOpen.openFileAlternative") // Snippets input.bind("", "snippet.nextPlaceholder") input.bind("", "snippet.previousPlaceholder") input.bind("", "snippet.cancel") // Completion input.bind([""], "contextMenu.select") input.bind(["", ""], "contextMenu.next") input.bind(["", ""], "contextMenu.previous") input.bind( [""], "contextMenu.close", isNotInsertMode /* In insert mode, the mode change will close the popupmenu anyway */, ) // Menu input.bind(["", ""], "menu.next") input.bind(["", ""], "menu.previous") input.bind(["", "", ""], "menu.close") input.bind("", "menu.select") input.bind(["", ""], "select") // TODO: Scope 's' to just the local window input.bind("", "sneak.show", () => isNormalMode() && !menu.isMenuOpen()) input.bind(["", ""], "sneak.hide") input.bind("", "sidebar.toggle", isNormalMode) // Explorer input.bind("d", "explorer.delete.persist", isExplorerActive) input.bind("", "explorer.delete.persist", isExplorerActive) input.bind("", "explorer.delete", isExplorerActive) input.bind("", "explorer.delete", isExplorerActive) input.bind("y", "explorer.yank", isExplorerActive) input.bind("p", "explorer.paste", isExplorerActive) input.bind("u", "explorer.undo", isExplorerActive) input.bind("h", "explorer.collapse.directory", isExplorerActive) input.bind("l", "explorer.expand.directory", isExplorerActive) input.bind("r", "explorer.rename", isExplorerActive) input.bind("", "explorer.create.file", isExplorerActive) input.bind("", "explorer.create.folder", isExplorerActive) input.bind("", "explorer.refresh", isExplorerActive) // Browser input.bind("k", "browser.scrollUp") input.bind("j", "browser.scrollDown") input.bind("h", "browser.scrollLeft") input.bind("l", "browser.scrollRight") // VCS input.bind("e", "vcs.openFile", isVCSActive) input.bind("u", "vcs.unstage", isVCSActive) input.bind("", "vcs.refresh", isVCSActive) input.bind("?", "vcs.showHelp", isVCSActive) // Sessions input.bind("", "oni.sessions.delete", areSessionsActive) } ================================================ FILE: browser/src/Input/KeyParser.ts ================================================ /** * KeyParser.ts * * Simple parsing logic to take vim key bindings / chords, * and return a normalized object. */ export interface IKey { character: string shift: boolean alt: boolean control: boolean meta: boolean } export interface IKeyChord { chord: IKey[] } export const parseKeysFromVimString = (keys: string): IKeyChord => { const chord: IKey[] = [] let idx = 0 while (idx < keys.length) { if (keys[idx] !== "<") { chord.push(parseKey(keys[idx])) } else { const endIndex = getNextCharacter(keys, idx + 1) // Malformed if there isn't a corresponding '>' if (endIndex === -1) { return { chord } } const keyContents = keys.substring(idx + 1, endIndex) chord.push(parseKey(keyContents)) idx = endIndex + 1 } idx++ } return { chord, } } const getNextCharacter = (str: string, startIndex: number): number => { let i = startIndex while (i < str.length) { if (str[i] === ">") { return i } i++ } return -1 } export const parseKey = (key: string): IKey => { if (key.indexOf("-") === -1) { return { character: key, shift: false, alt: false, control: false, meta: false, } } const hasControl = key.indexOf("c-") >= 0 || key.indexOf("C-") >= 0 const hasShift = key.indexOf("s-") >= 0 || key.indexOf("S-") >= 0 const hasAlt = key.indexOf("a-") >= 0 || key.indexOf("A-") >= 0 const hasMeta = key.indexOf("m-") >= 0 || key.indexOf("M-") >= 0 const lastIndexoFHyphen = key.lastIndexOf("-") const finalKey = key.substring(lastIndexoFHyphen + 1, key.length) return { character: finalKey, shift: hasShift, alt: hasAlt, control: hasControl, meta: hasMeta, } } // Parse a chord string (e.g. ) into textual descriptions of the relevant keys // -> ["control", "shift", "p"] export const parseChordParts = (keys: string): string[] => { const parsedKeys = parseKeysFromVimString(keys) if (!parsedKeys || !parsedKeys.chord || parsedKeys.chord.length === 0) { return null } const firstChord = parsedKeys.chord[0] const chordParts: string[] = [] if (firstChord.meta) { chordParts.push("meta") } if (firstChord.control) { chordParts.push("control") } if (firstChord.alt) { chordParts.push("alt") } if (firstChord.shift) { chordParts.push("shift") } chordParts.push(firstChord.character) return chordParts } ================================================ FILE: browser/src/Input/Keyboard/KeyboardLayout.ts ================================================ import * as Log from "oni-core-logging" import { Event, IEvent } from "oni-types" import * as Platform from "./../../Platform" export interface IKeyMap { [key: string]: IKeyInfo } export interface IKeyInfo { unmodified: string withShift: string withAltGraphShift?: string withAltGraph?: string } // Helper method to augment the key mapping in cases // where it isn't accurate from `keyboard-layout` const augmentKeyMap = (keyMap: IKeyMap, language: string): IKeyMap => { // Temporary hack to workaround atom/keyboard-layout#36 if (Platform.isWindows() && language === "es-ES") { // tslint:disable-next-line no-string-literal keyMap["BracketLeft"] = { unmodified: null, withShift: null, withAltGraph: "[", withAltGraphShift: null, } } return keyMap } export class KeyboardLayoutManager { private _keyMap: IKeyMap = null private _onKeyMapChanged: Event = new Event() /** * Event that is triggered when the keymap is changed, * ie, when the keyboard layout is changed externally */ public get onKeyMapChanged(): IEvent { return this._onKeyMapChanged } public getCurrentKeyMap(): IKeyMap { if (!this._keyMap) { const KeyboardLayout = require("keyboard-layout") // tslint:disable-line no-var-requires const keyboardLanguage = KeyboardLayout.getCurrentKeyboardLanguage() Log.verbose("[Keyboard Layout] " + KeyboardLayout.getCurrentKeyboardLayout()) this._keyMap = augmentKeyMap(KeyboardLayout.getCurrentKeymap(), keyboardLanguage) // Lazily subscribe to the KeyboardLayout.onDidChangeCurrentKeyboardLayout // This is lazy primarily for unit testing outside of electron (where this module isn't available) KeyboardLayout.onDidChangeCurrentKeyboardLayout((newLayout: string) => { Log.verbose("[Keyboard Layout] " + newLayout) this._keyMap = KeyboardLayout.getCurrentKeymap() this._onKeyMapChanged.dispatch() }) } return this._keyMap } } ================================================ FILE: browser/src/Input/Keyboard/KeyboardResolver.ts ================================================ /** * KeyboardResolver * * Manages set of resolvers, and adding/removing resolvers. */ import * as Log from "oni-core-logging" import { IDisposable } from "oni-types" import { KeyResolver } from "./Resolvers" export class KeyboardResolver { private _resolvers: KeyResolver[] = [] public addResolver(resolver: KeyResolver): IDisposable { this._resolvers.push(resolver) const dispose = () => { this._resolvers = this._resolvers.filter(r => r !== resolver) } return { dispose, } } public resolveKeyEvent(evt: KeyboardEvent): string | null { const mappedKey = this._resolvers.reduce((prev: string, current) => { if (prev === null) { return prev } else { return current(evt, prev) } }, evt.key) if (Log.isDebugLoggingEnabled()) { Log.debug( `[Key event] Code: ${evt.code} Key: ${evt.key} CtrlKey: ${evt.ctrlKey} ShiftKey: ${ evt.shiftKey } AltKey: ${evt.altKey} | Resolution: ${mappedKey}`, ) } return mappedKey } } ================================================ FILE: browser/src/Input/Keyboard/Resolvers.ts ================================================ import { IKeyInfo, IKeyMap, KeyboardLayoutManager } from "./KeyboardLayout" /** * Interface describing a 'key resolver' - a strategy * for resolving a keyboard event to a vim-facing input event. * * Key resolvers are intended to be chained together. * * If a key resolver returns null, it will prevent processing that key. */ export type KeyResolver = (evt: KeyboardEvent, previousResolution: string | null) => string | null const keysToIgnore = [ "Shift", "Control", "Alt", "AltGraph", "CapsLock", "Pause", "ScrollLock", "AudioVolumeUp", "AudioVolumeDown", ] export const ignoreMetaKeyResolver = ( evt: KeyboardEvent, previousResolution: string | null, ): string | null => { if (keysToIgnore.indexOf(evt.key) >= 0) { return null } else { return evt.key } } const keysToRemap: { [key: string]: string } = { Backspace: "bs", Escape: "esc", Enter: "enter", Tab: "tab", // Tab ArrowLeft: "left", // ArrowLeft ArrowUp: "up", // ArrowUp ArrowRight: "right", // ArrowRight ArrowDown: "down", // ArrowDown Insert: "insert", " ": "space", } export const remapResolver = ( evt: KeyboardEvent, previousResolution: string | null, ): string | null => { return keysToRemap[evt.key] ? keysToRemap[evt.key] : previousResolution } export const getMetaKeyResolver = () => { const keyboardLayout: KeyboardLayoutManager = new KeyboardLayoutManager() let keyMap = keyboardLayout.getCurrentKeyMap() keyboardLayout.onKeyMapChanged.subscribe(() => { keyMap = keyboardLayout.getCurrentKeyMap() }) return (evt: KeyboardEvent, previousResolution: string | null): null | string => { const isCharacterFromShiftKey = isShiftCharacter(keyMap, evt) const isCharacterFromAltGraphKey = isAltGraphCharacter(keyMap, evt) let mappedKey = previousResolution if (mappedKey === "<") { mappedKey = "lt" } const metaPressed = evt.metaKey let controlPressed = false // On Windows, when the AltGr key is pressed, _both_ // the evt.ctrlKey and evt.altKey are set to true. if (evt.ctrlKey && !isCharacterFromAltGraphKey) { mappedKey = "c-" + previousResolution + "" controlPressed = true evt.preventDefault() } if (evt.shiftKey && (!isCharacterFromShiftKey || controlPressed || metaPressed)) { mappedKey = "s-" + mappedKey } if (evt.altKey && !isCharacterFromAltGraphKey) { mappedKey = "a-" + mappedKey evt.preventDefault() } if (metaPressed) { mappedKey = "m-" + mappedKey evt.preventDefault() } if (mappedKey.length > 1) { mappedKey = "<" + mappedKey.toLowerCase() + ">" } return mappedKey } } export const createMetaKeyResolver = (keyMap: IKeyMap) => { return (evt: KeyboardEvent, previousResolution: string | null): null | string => { const isCharacterFromShiftKey = isShiftCharacter(keyMap, evt) const isCharacterFromAltGraphKey = isAltGraphCharacter(keyMap, evt) let mappedKey = previousResolution if (mappedKey === "<") { mappedKey = "lt" } const metaPressed = evt.metaKey let controlPressed = false // On Windows, when the AltGr key is pressed, _both_ // the evt.ctrlKey and evt.altKey are set to true. if (evt.ctrlKey && !isCharacterFromAltGraphKey) { mappedKey = "c-" + previousResolution + "" controlPressed = true evt.preventDefault() } if (evt.shiftKey && (!isCharacterFromShiftKey || controlPressed || metaPressed)) { mappedKey = "s-" + mappedKey } if (evt.altKey && !isCharacterFromAltGraphKey) { mappedKey = "a-" + mappedKey evt.preventDefault() } if (metaPressed) { mappedKey = "m-" + mappedKey evt.preventDefault() } if (mappedKey.length > 1) { mappedKey = "<" + mappedKey.toLowerCase() + ">" } return mappedKey } } const isShiftCharacter = (keyMap: IKeyMap, evt: KeyboardEvent): boolean => { const { key, code } = evt const mappedKey: IKeyInfo = keyMap[code] if (!mappedKey) { return false } if (code === "Space") { return false } if (mappedKey.withShift === key || mappedKey.withAltGraphShift === key) { return true } else { return false } } const isAltGraphCharacter = (keyMap: IKeyMap, evt: KeyboardEvent): boolean => { const { key, code } = evt const mappedKey: IKeyInfo = keyMap[code] if (!mappedKey) { return false } if (mappedKey.withAltGraph === key || mappedKey.withAltGraphShift === key) { return true } else { return false } } ================================================ FILE: browser/src/Input/Keyboard/index.ts ================================================ export * from "./Resolvers" export * from "./KeyboardLayout" ================================================ FILE: browser/src/Input/KeyboardInput.tsx ================================================ /** * KeyboardInput.tsx * * Specialized input control to handle IME & dead key cases * - Allows enabling / disabling IME * - Follows cursor * - Invisible when not composing */ import * as React from "react" import { connect } from "react-redux" import { IDisposable, IEvent } from "oni-types" import { IState } from "./../Editor/NeovimEditor/NeovimEditorStore" import { focusManager } from "./../Services/FocusManager" import { inputManager } from "./../Services/InputManager" import { TypingPredictionManager } from "./../Services/TypingPredictionManager" import { measureFont } from "./../Font" interface IKeyboardInputViewProps extends IKeyboardInputProps { top: number left: number height: number foregroundColor: string fontFamily: string fontSize: string fontCharacterWidthInPixels: number } interface IKeyboardInputViewState { /** * Tracks if composition is occurring (ie, an IME is active) */ isComposing: boolean /** * Tracks the width of the currently composing text. * This isn't the same as the input text string value .length(), * because usually for IMEs there are multi-byte characters. */ compositionTextWidthInPixels: number } export interface IKeyboardInputProps { startActive?: boolean onActivate: IEvent onKeyDown?: (key: string) => void onImeStart?: () => void onImeEnd?: () => void typingPrediction?: TypingPredictionManager // Optional methods for integrating animation, // ie: 'cursor bounce': onBounceStart?: () => void onBounceEnd?: () => void } /** * KeyboardInput * * Helper for managing state and sanitizing input from dead keys, IME, etc */ export class KeyboardInputView extends React.PureComponent< IKeyboardInputViewProps, IKeyboardInputViewState > { private _keyboardElement: HTMLInputElement private _disposables: IDisposable[] = [] constructor(props: IKeyboardInputViewProps) { super(props) this.state = { isComposing: false, compositionTextWidthInPixels: 0, } } public focus() { this._keyboardElement.focus() } public componentDidMount(): void { if (this.props.onActivate) { this._removeExistingDisposables() const d1 = this.props.onActivate.subscribe(() => { focusManager.setFocus(this._keyboardElement) }) this._disposables.push(d1) } if (this.props.startActive && this._keyboardElement) { focusManager.setFocus(this._keyboardElement) } } public componentWillUnmount(): void { this._removeExistingDisposables() } public render(): JSX.Element { const opacity = this.state.isComposing ? 0.8 : 0 const containerStyle: React.CSSProperties = { position: "absolute", top: this.props.top.toString() + "px", left: this.props.left.toString() + "px", height: this.props.height.toString() + "px", right: "0px", pointerEvents: "none", opacity, overflow: "hidden", transform: "translateZ(0px)", // See #1129 - needed to keep it from re-rendering } const inputStyle: React.CSSProperties = { position: "absolute", padding: "0px", width: "100%", left: "0px", right: "0px", color: "black", border: "0px", outline: "none", font: "inherit", backgroundColor: "transparent", } const backgroundStyle: React.CSSProperties = { position: "absolute", height: "100%", backgroundColor: "white", left: "0px", padding: "2px", marginTop: "-2px", marginLeft: "-2px", width: this.state.compositionTextWidthInPixels + "px", } return (
    (this._keyboardElement = elem)} type={"text"} onKeyDown={evt => this._onKeyDown(evt)} onKeyUp={evt => this._onKeyUp(evt)} onCompositionEnd={evt => this._onCompositionEnd(evt)} onCompositionUpdate={evt => this._onCompositionUpdate(evt)} onCompositionStart={evt => this._onCompositionStart(evt)} onInput={evt => this._onInput(evt)} />
    ) } private _onKeyUp(evt: React.KeyboardEvent) { if (this.props.onBounceEnd) { this.props.onBounceEnd() } } private _onKeyDown(evt: React.KeyboardEvent) { // 'Process' means hand-off to the IME - // so the composition events should handle this if (evt.key === "Process" || evt.key === "Dead") { return } if (this.state.isComposing) { return } if (this.props.onBounceStart) { this.props.onBounceStart() } const key = inputManager.resolvers.resolveKeyEvent(evt.nativeEvent) if (!key) { return } const isMetaCommand = key.length > 1 && key !== "" // We'll let the `input` handler take care of it, // unless it is a keystroke containing meta characters if (isMetaCommand) { this._commit(key) evt.preventDefault() return } else { if (this.props.typingPrediction) { this.props.typingPrediction.addPrediction(key) } } } private _onCompositionStart(evt: React.CompositionEvent) { if (this.props.onImeStart) { this.props.onImeStart() } if (this.props.typingPrediction) { this.props.typingPrediction.clearAllPredictions() } this.setState({ isComposing: true, }) } private _onCompositionUpdate(evt: React.CompositionEvent) { if (this._keyboardElement) { const measurements = measureFont(this.props.fontSize, this.props.fontFamily, evt.data) // Add some padding for an extra character to the end of the input box const roomForNextCharacter = this.props.fontCharacterWidthInPixels const width = Math.ceil(measurements.width) + roomForNextCharacter this.setState({ compositionTextWidthInPixels: width, }) } } private _onCompositionEnd(evt: React.CompositionEvent) { if (this.props.onImeEnd) { this.props.onImeEnd() } if (this._keyboardElement) { this._commit(this._keyboardElement.value) } } private _onInput(evt: React.FormEvent) { const valueLength = this._keyboardElement.value.length if (!this.state.isComposing && valueLength > 0) { this._commit(this._keyboardElement.value.replace("<", "")) } } private _commit(val: string): void { this.setState({ isComposing: false, compositionTextWidthInPixels: 0, }) this._keyboardElement.value = "" this.props.onKeyDown(val) } private _removeExistingDisposables(): void { this._disposables.forEach(d => d.dispose()) this._disposables = [] } } const mapStateToProps = ( state: IState, originalProps: IKeyboardInputProps, ): IKeyboardInputViewProps => { return { ...originalProps, top: state.cursorPixelY, left: state.cursorPixelX, height: state.fontPixelHeight, foregroundColor: state.colors["editor.foreground"], fontFamily: state.fontFamily, fontSize: state.fontSize, fontCharacterWidthInPixels: state.fontPixelWidth, } } export const KeyboardInput = connect(mapStateToProps)(KeyboardInputView) ================================================ FILE: browser/src/Input/Mouse.ts ================================================ import { EventEmitter } from "events" import { IScreen } from "./../neovim" const SCROLL_THRESHOLD_IN_PIXELS = 10 // TODO // Handle modifier keys export class Mouse extends EventEmitter { private _isDragging = false private _scrollDelta = 0 constructor(private _editorElement: HTMLDivElement, private _screen: IScreen) { super() this._editorElement.addEventListener("mousedown", (evt: MouseEvent) => { const { line, column } = this._convertEventToPosition(evt) this.emit("mouse", `<${line},${column}>`) this._isDragging = true }) this._editorElement.addEventListener("mousemove", (evt: MouseEvent) => { const { line, column } = this._convertEventToPosition(evt) if (this._isDragging) { this.emit("mouse", `<${line},${column}>`) } }) this._editorElement.addEventListener("mouseup", (evt: MouseEvent) => { const { line, column } = this._convertEventToPosition(evt) this.emit("mouse", `<${line},${column}>`) this._isDragging = false }) // The internet told me 'mousewheel' is deprecated and use this. this._editorElement.addEventListener("wheel", (evt: WheelEvent) => { const { line, column } = this._convertEventToPosition(evt) let scrollcmdY = `<` if (evt.ctrlKey || evt.shiftKey) { scrollcmdY += `C-` // The S- and C- prefixes have the same effect } const normalizedDelta = this._normalizeScrollDeltaToPixels(evt.deltaY, evt.deltaMode) if (!normalizedDelta) { return } this._isDragging = false this._scrollDelta += normalizedDelta if (this._scrollDeltaIsSignificant()) { // This is 'less than' because I made this on a mac to behave just like // the other applications I use. However, because OSX is super weird, it // might be backwards. if (this._scrollDelta < 0) { scrollcmdY += `ScrollWheelUp>` } else { scrollcmdY += `ScrollWheelDown>` } this._scrollDelta = 0 this.emit("mouse", scrollcmdY + `<${line},${column}>`) } }) } private _normalizeScrollDeltaToPixels(delta: number, deltaMode: number): number { switch (deltaMode) { case WheelEvent.DOM_DELTA_PIXEL: return delta case WheelEvent.DOM_DELTA_LINE: return delta * this._screen.fontHeightInPixels case WheelEvent.DOM_DELTA_PAGE: return delta * this._screen.fontHeightInPixels * this._screen.height default: return delta } } private _convertEventToPosition(evt: MouseEvent): { line: number; column: number } { const mouseX = evt.offsetX const mouseY = evt.offsetY return { line: Math.floor(mouseX / this._screen.fontWidthInPixels), column: Math.floor(mouseY / this._screen.fontHeightInPixels), } } private _scrollDeltaIsSignificant(): boolean { return Math.abs(this._scrollDelta) >= SCROLL_THRESHOLD_IN_PIXELS } } ================================================ FILE: browser/src/Performance.ts ================================================ /** * Thin wrapper around browser performance API */ export function mark(markerName: string): void { if (typeof window === "undefined") { return } if (process.env.NODE_ENV === "production") { return } performance.mark(markerName) const anyConsole: any = console anyConsole.timeStamp(markerName) console.log(`[PERFORMANCE] ${markerName}: ${performance.now()}`) // tslint:disable-line no-console } export const startMeasure = (measurementName: string): void => { console.time(measurementName) // tslint:disable-line } export const endMeasure = (measurementName: string): void => { console.timeEnd(measurementName) // tslint:disable-line } ================================================ FILE: browser/src/PeriodicJobs.ts ================================================ import * as Log from "oni-core-logging" import * as Constants from "./Constants" // IPeriodicJob implements the interface for a long-running job // that would be expensive to run synchronously, so it is // spread across multiple asynchronous iterations. export interface IPeriodicJob { // Execute should return `true` if the job is complete, // false otherwise execute(): boolean } export class PeriodicJobManager { private _currentScheduledJob: number = null private _pendingJobs: IPeriodicJob[] = [] public startJob(job: IPeriodicJob) { this._pendingJobs.push(job) Log.verbose("[PeriodicJobManager]::startJob - " + this._pendingJobs.length + " total jobs.") this._scheduleJobs() } private _scheduleJobs(): void { if (this._currentScheduledJob) { return } if (this._pendingJobs.length === 0) { Log.verbose("[PeriodicJobManager]::_scheduleJobs - no jobs pending.") } this._currentScheduledJob = window.setTimeout(() => { const completed = this._executePendingJobs() window.clearTimeout(this._currentScheduledJob) this._currentScheduledJob = null if (!completed) { this._scheduleJobs() } }, Constants.Delay.INSTANT) Log.verbose("[PeriodicJobManager]::_scheduleJobs - " + this._currentScheduledJob) } private _executePendingJobs(): boolean { const completedJobs: IPeriodicJob[] = [] this._pendingJobs.forEach(job => { const completed = job.execute() if (completed) { completedJobs.push(job) } }) // Remove completed jobs this._pendingJobs = this._pendingJobs.filter(job => completedJobs.indexOf(job) === -1) if (this._pendingJobs.length === 0) { Log.verbose("[PeriodicJobManager] All jobs complete.") } else { Log.verbose("[PeriodicJobManager] " + this._pendingJobs.length + " jobs remaining.") } // Return true if all jobs were completed, false otherwise return this._pendingJobs.length === 0 } } ================================================ FILE: browser/src/PersistentStore.ts ================================================ /** * Store.ts * * Abstraction for a persistent data store, that supports versioning and upgrade. */ import { remote } from "electron" import * as Log from "oni-core-logging" // 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 delete(key: string): Promise has(key: string): boolean } 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)) } public has(key: string) { return PersistentSettings.has(key) } public async delete(key: string) { return PersistentSettings.delete(`${this._storeKey}.${key}`) } } ================================================ FILE: browser/src/Platform.ts ================================================ import * as fs from "fs" import * as os from "os" import * as path from "path" export const isWindows = () => os.platform() === "win32" export const isMac = () => os.platform() === "darwin" export const isLinux = () => os.platform() === "linux" export const getUserHome = () => (isWindows() ? process.env["APPDATA"] : process.env["HOME"]) // tslint:disable-line no-string-literal export const getLinkPath = () => (isMac() ? "/usr/local/bin/oni" : "") // TODO: Linux export const isAddedToPath = () => { if (isMac()) { try { fs.lstatSync(getLinkPath()) const currentLinkPath = fs.readlinkSync(getLinkPath()) // Temporary guard to check if the old script has been linked to. if (currentLinkPath.indexOf("cli/mac/oni.sh") === -1) { return false } } catch (_) { return false } return true } return false } export const removeFromPath = () => (isMac() ? fs.unlinkSync(getLinkPath()) : false) // TODO: Linux export const addToPath = async () => { if (isMac()) { const appDirectory = path.join(path.dirname(process.mainModule.filename), "..", "..") const options = { name: "Oni", icns: path.join(appDirectory, "Resources", "Oni.icns") } const linkPath = path.join(appDirectory, "Resources", "app", "cli", "mac", "oni.sh") await _runSudoCommand(`ln -fs ${linkPath} ${getLinkPath()}`, options) } } const _runSudoCommand = async (command: string, options: any) => { const sudo = await import("sudo-prompt") return new Promise(resolve => { sudo.exec(command, options, (error: Error, stdout: string, stderr: string) => { resolve({ error, stdout, stderr }) }) }) } ================================================ FILE: browser/src/Plugins/AnonymousPlugin.ts ================================================ /** * AnonymousPlugin.ts * * Provides a globally-available, immediately-active plugin * Useful for testing the plugin API */ import * as OniApi from "oni-api" import { Oni } from "./Api/Oni" export class AnonymousPlugin { private _oni: OniApi.Plugin.Api public get oni(): OniApi.Plugin.Api { return this._oni } constructor() { this._oni = new Oni() // tslint:disable-line no-empty window["Oni"] = this._oni // tslint:disable-line no-string-literal } } ================================================ FILE: browser/src/Plugins/Api/Capabilities.ts ================================================ /** * Capabilities.ts * * Export utility types / functions for working with plugin capabilities */ export interface IContributions { commands: ICommandContribution[] languages: ILanguageContribution[] themes: IThemeContribution[] iconThemes: IIconThemeContribution[] snippets: ISnippetContribution[] } export const DefaultContributions: IContributions = { commands: [], themes: [], iconThemes: [], snippets: [], languages: [], } export interface ICommandContribution { command: string /* ie, myExtension.myCommand */ title: string /* My Extension Command */ category: string /* Testing */ } export interface IIconThemeContribution { id: string label: string path: string } export interface ILanguageContribution { id: string extensions: string[] } export interface ISnippetContribution { language: string path: string } export interface IThemeContribution { name: string path: string } export interface IPluginMetadata { name: string main: string engines: any contributes: IContributions } ================================================ FILE: browser/src/Plugins/Api/LanguageClient/LanguageClientHelpers.ts ================================================ /** * LanguageClientHelpers.ts */ import * as os from "os" import * as types from "vscode-languageserver-types" import * as Oni from "oni-api" import * as Utility from "./../../../Utility" export namespace TextDocumentSyncKind { export const None = 0 export const Full = 1 export const Incremental = 2 } export interface CompletionOptions { /** * The server provides support to resolve additional * information for a completion item. */ resolveProvider?: boolean /** * The characters that trigger completion automatically. */ triggerCharacters?: string[] } // ServerCapabilities // Defined in the LSP protocol: https://github.com/Microsoft/language-server-protocol/blob/master/protocol.md export interface ServerCapabilities { completionProvider?: CompletionOptions textDocumentSync?: number documentSymbolProvider?: boolean } export const wrapPathInFileUri = (path: string) => getFilePrefix() + Utility.normalizePath(path) export const unwrapFileUriPath = (uri: string) => decodeURIComponent(uri.split(getFilePrefix())[1]) export const getTextFromContents = ( contents: types.MarkedString | types.MarkupContent | types.MarkedString[], ): IMarkedStringResult[] => { if (contents instanceof Array) { return contents.map(markedString => getTextFromMarkedString(markedString)) } const text = isMarkupContent(contents) ? getDocumentationText(contents) : contents return [getTextFromMarkedString(text)] } export const pathToTextDocumentIdentifierParms = (path: string) => ({ textDocument: { uri: wrapPathInFileUri(path), }, }) export const pathToTextDocumentItemParams = ( path: string, language: string, text: string, version: number, ) => ({ textDocument: { uri: wrapPathInFileUri(path), languageId: language, text, version, }, }) export const eventContextToCodeActionParams = (filePath: string, range: types.Range) => { const emptyDiagnostics: types.Diagnostic[] = [] return { textDocument: { uri: wrapPathInFileUri(filePath), }, range, context: { diagnostics: emptyDiagnostics }, } } export const createTextDocumentPositionParams = ( filePath: string, line: number, column: number, ) => ({ textDocument: { uri: wrapPathInFileUri(filePath), }, position: { line, character: column, }, }) export const bufferToTextDocumentPositionParams = (buffer: Oni.Buffer) => { return createTextDocumentPositionParams( buffer.filePath, buffer.cursor.line, buffer.cursor.column, ) } export const createDidChangeTextDocumentParams = ( bufferFullPath: string, lines: string[], version: number, ) => { const text = lines.join(os.EOL) return { textDocument: { uri: wrapPathInFileUri(bufferFullPath), version, }, contentChanges: [ { text, }, ], } } interface IMarkedStringResult { value: string language: string } const getTextFromMarkedString = (markedString: types.MarkedString): IMarkedStringResult => { if (typeof markedString === "string") { return { language: null, value: markedString, } } else { return { // Split the language as it passed as e.g. "reason.hover.type" language: markedString.language ? markedString.language.split(".")[0] : null, value: markedString.value, } } } const getFilePrefix = () => { if (process.platform === "win32") { return "file:///" } else { return "file://" } } export function isMarkupContent(input: any): input is types.MarkupContent { return typeof input === "object" && input !== null && "value" in input && "kind" in input } export const getDocumentationText = (documentation: string | types.MarkupContent) => { // Documentation can be a string or an object specifying the documentations type as well as the value. if (typeof documentation === "string") { return documentation } return documentation.value } ================================================ FILE: browser/src/Plugins/Api/LanguageClient/LanguageClientLogger.ts ================================================ /** * LanguageClientLogger.ts * * Helper utility for handling logging from language service clients */ import * as Log from "oni-core-logging" export class LanguageClientLogger { public error(message: string): void { Log.error(message) } public warn(message: string): void { Log.warn(message) } public info(message: string): void { Log.info(message) } public log(message: string): void { Log.info(message) } } ================================================ FILE: browser/src/Plugins/Api/Oni.ts ================================================ /** * OniApi.ts * * Implementation of OniApi's API surface * TODO: Gradually move over to `oni-api` */ import * as ChildProcess from "child_process" import * as OniApi from "oni-api" import * as Log from "oni-core-logging" import Process from "./Process" import { Services } from "./Services" import { Ui } from "./Ui" import { getInstance as getPluginsManagerInstance } from "./../PluginManager" import { automation } from "./../../Services/Automation" import { Colors, getInstance as getColors } from "./../../Services/Colors" import { commandManager } from "./../../Services/CommandManager" import { getInstance as getCompletionProvidersInstance } from "./../../Services/Completion/CompletionProviders" import { configuration } from "./../../Services/Configuration" import { getInstance as getDiagnosticsInstance } from "./../../Services/Diagnostics" 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 getFiltersInstance } from "./../../Services/Menu/Filter" import { getInstance as getNotificationsInstance } from "./../../Services/Notifications" import { getInstance as getOverlayInstance } from "./../../Services/Overlay" import { recorder } from "./../../Services/Recorder" import { getInstance as getSessionManagerInstance, SessionManager } from "./../../Services/Sessions" import { getInstance as getSidebarInstance } from "./../../Services/Sidebar" import { getInstance as getSneakInstance } from "./../../Services/Sneak" import { getInstance as getSnippetsInstance } from "./../../Services/Snippets" import { getInstance as getStatusBarInstance } from "./../../Services/StatusBar" import { getInstance as getTokenColorsInstance } from "./../../Services/TokenColors" import { windowManager } from "./../../Services/WindowManager" import { getInstance as getWorkspaceInstance } from "./../../Services/Workspace" import { Search } from "./../../Services/Search/SearchProvider" import * as throttle from "lodash/throttle" const react = require("react") // tslint:disable-line no-var-requires export class Dependencies { public get React(): any { return react } } const helpers = { throttle, } /** * API instance for interacting with OniApi (and vim) */ export class Oni implements OniApi.Plugin.Api { private _dependencies: Dependencies private _ui: Ui private _services: Services public get achievements(): any /* TODO: Promote to API */ { return getAchievementsInstance() } public get automation(): OniApi.Automation.Api { return automation } public get colors(): Colors /* TODO: Promote to API */ { return getColors() } public get commands(): OniApi.Commands.Api { return commandManager } public get contextMenu(): any { return null } public get log(): OniApi.Log { return Log } public get plugins(): any { return getPluginsManagerInstance() } public get recorder(): any { return recorder } public get completions(): any { return getCompletionProvidersInstance() } public get configuration(): OniApi.Configuration { return configuration } public get diagnostics(): OniApi.Plugin.Diagnostics.Api { return getDiagnosticsInstance() } public get dependencies(): Dependencies { return this._dependencies } public get editors(): OniApi.EditorManager { return editorManager } public get input(): OniApi.Input.InputManager { return inputManager } public get language(): any { return LanguageManager.getInstance() } public get menu(): any /* TODO */ { return getMenuManagerInstance() } public get filter(): OniApi.Menu.IMenuFilters { return getFiltersInstance("") // TODO: Pass either "core" or plugin's name } public get notifications(): OniApi.Notifications.Api { return getNotificationsInstance() } public get overlays(): OniApi.Overlays.Api { return getOverlayInstance() } public get process(): OniApi.Process { return Process } public get sidebar(): any { return getSidebarInstance() } public get sneak(): any { return getSneakInstance() } public get snippets(): OniApi.Snippets.SnippetManager { return getSnippetsInstance() } public get statusBar(): OniApi.StatusBar { return getStatusBarInstance() } public get tokenColors(): any { return getTokenColorsInstance() } public get ui(): Ui { return this._ui } public get sessions(): SessionManager { return getSessionManagerInstance() } public get services(): Services { return this._services } public get tutorials(): any /* todo */ { return getTutorialManagerInstance() } public get windows(): OniApi.IWindowManager { return windowManager as any } public get workspace(): OniApi.Workspace.Api { return getWorkspaceInstance() } public get helpers() { return helpers } public get search(): OniApi.Search.ISearch { return new Search() } constructor() { this._dependencies = new Dependencies() this._ui = new Ui(react) this._services = new Services() } public getActiveSection() { const isInsertOrCommandMode = () => { return ( this.editors.activeEditor.mode === "insert" || this.editors.activeEditor.mode === "cmdline_normal" ) } switch (true) { case this.menu.isMenuOpen(): return "menu" case this.sidebar && this.sidebar.isFocused: return this.sidebar.activeEntryId case isInsertOrCommandMode(): return "commandline" default: return "editor" } } public populateQuickFix(entries: OniApi.QuickFixEntry[]): void { const neovim: any = editorManager.activeEditor.neovim neovim.quickFix.setqflist(entries, "Search Results") neovim.command(":copen") } public async execNodeScript( scriptPath: string, args: string[] = [], options: ChildProcess.ExecOptions = {}, callback: (err: any, stdout: string, stderr: string) => void, ): Promise { Log.warn( "WARNING: `OniApi.execNodeScript` is deprecated. Please use `OniApi.process.execNodeScript` instead", ) return Process.execNodeScript(scriptPath, args, options, callback) } /** * Wrapper around `child_process.exec` to run using electron as opposed to node */ public async spawnNodeScript( scriptPath: string, args: string[] = [], options: ChildProcess.SpawnOptions = {}, ): Promise { Log.warn( "WARNING: `OniApi.spawnNodeScript` is deprecated. Please use `OniApi.process.spawnNodeScript` instead", ) return Process.spawnNodeScript(scriptPath, args, options) } } ================================================ FILE: browser/src/Plugins/Api/Process.ts ================================================ import * as ChildProcess from "child_process" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import * as Platform from "./../../Platform" import { configuration } from "./../../Services/Configuration" export interface IShellEnvironmentFetcher { getEnvironmentVariables(): Promise } interface IShellEnv { default: { sync: (shell?: string) => NodeJS.ProcessEnv } } export class ShellEnvironmentFetcher implements IShellEnvironmentFetcher { private _shellEnvPromise: Promise private _shellEnv: IShellEnv constructor() { // Dynamic imports return { default: Module } this._shellEnvPromise = import("shell-env") } public async getEnvironmentVariables(): Promise { if (!this._shellEnv) { this._shellEnv = await this._shellEnvPromise } try { const userShell = configuration.getValue("oni.userShell") const shell = typeof userShell === "string" ? userShell : undefined const env = this._shellEnv.default.sync(shell) return env } catch (error) { Log.warn( `[Oni environment fetcher]: unable to get enviroment variables because: ${ error.message }`, ) } return {} } } export class Process implements Oni.Process { public _spawnedProcessIds: number[] = [] private _env: NodeJS.ProcessEnv constructor( private _shellEnvironmentFetcher: IShellEnvironmentFetcher = new ShellEnvironmentFetcher(), ) {} public getPathSeparator = () => { return Platform.isWindows() ? ";" : ":" } public mergePathEnvironmentVariable = (currentPath: string, pathsToAdd: string[]): string => { if (!pathsToAdd || !pathsToAdd.length) { return currentPath } const separator = this.getPathSeparator() const joinedPathsToAdd = pathsToAdd.join(separator) return currentPath + separator + joinedPathsToAdd } /** * API surface area responsible for handling process-related tasks * (spawning processes, managing running process, etc) */ public execNodeScript = async ( scriptPath: string, args: string[] = [], options: ChildProcess.ExecOptions = {}, callback: (err: any, stdout: string, stderr: string) => void, ): Promise => { const spawnOptions = await this.mergeSpawnOptions(options) spawnOptions.env.ELECTRON_RUN_AS_NODE = 1 const execOptions = [process.execPath, scriptPath].concat(args) const execString = execOptions.map(s => `"${s}"`).join(" ") const proc = ChildProcess.exec(execString, spawnOptions, callback) this._spawnedProcessIds.push(proc.pid) return proc } /** * Get the set of process IDs that were spawned by Oni */ public getPIDs = (): number[] => { return [...this._spawnedProcessIds] } /** * Get the __dirname of currently executing file */ public getDirname = () => { return __dirname } /** * Wrapper around `child_process.exec` to run using electron as opposed to node */ public spawnNodeScript = async ( scriptPath: string, args: string[] = [], options: ChildProcess.SpawnOptions = {}, ): Promise => { const spawnOptions = await this.mergeSpawnOptions(options) spawnOptions.env.ELECTRON_RUN_AS_NODE = 1 const allArgs = [scriptPath].concat(args) const proc = ChildProcess.spawn(process.execPath, allArgs, spawnOptions) this._spawnedProcessIds.push(proc.pid) return proc } /** * Spawn process - wrapper around `child_process.spawn` */ public spawnProcess = async ( startCommand: string, args: string[] = [], options: ChildProcess.SpawnOptions = {}, ): Promise => { const spawnOptions = await this.mergeSpawnOptions(options) const proc = ChildProcess.spawn(startCommand, args, spawnOptions) this._spawnedProcessIds.push(proc.pid) proc.on("error", (err: Error) => { Log.error(err) }) return proc } public mergeSpawnOptions = async ( originalSpawnOptions: ChildProcess.ExecOptions | ChildProcess.SpawnOptions, ): Promise => { let existingPath: string try { if (!this._env) { this._env = await this._shellEnvironmentFetcher.getEnvironmentVariables() } existingPath = process.env.Path || process.env.PATH } catch (e) { existingPath = process.env.Path || process.env.PATH } const additionalEnvironmentVariables = configuration.getValue("environment.additionalVariables") || {} const requiredOptions = { env: { ...process.env, ...this._env, ...originalSpawnOptions.env, ...additionalEnvironmentVariables, }, } // TODO: Workaround for the bug fix here: // https://github.com/neovim/neovim/pull/9345 // Once 0.3.2 is available and we've switched, we won't need this anymore. if (Platform.isMac() && !requiredOptions.env.LANG) { requiredOptions.env.LANG = "en_us.UTF-8" Log.warn( "'LANG' environment variable not set, using default value of 'en_us.UTF-8'. Consider setting environment.additionalVariables['LANG'].", ) } requiredOptions.env.PATH = this.mergePathEnvironmentVariable( existingPath, configuration.getValue("environment.additionalPaths"), ) return { ...originalSpawnOptions, ...requiredOptions, } } } export default new Process() ================================================ FILE: browser/src/Plugins/Api/Services.ts ================================================ import { getInstance, VersionControlManager } from "./../../Services/VersionControl" export class Services { public get vcs(): VersionControlManager { return getInstance() } } ================================================ FILE: browser/src/Plugins/Api/Ui.ts ================================================ import * as Oni from "oni-api" import { getFileIcon } from "../../Services/FileIcon" import { getInstance } from "../../Services/IconThemes" import { Icon, IconProps, IconSize } from "../../UI/Icon" export class Ui implements Oni.Ui.IUi { constructor(private _react: any) {} public createIcon(props: IconProps): any { return this._react.createElement(Icon, props) } public getIconClassForFile(filename: string, language?: string): string { const Icons = getInstance() return Icons.getIconClassForFile(filename, language) } public getFileIcon(fileName: string): any { return getFileIcon(fileName) } public get iconSize(): any { return IconSize } } ================================================ FILE: browser/src/Plugins/Api/shell-env.d.ts ================================================ declare module "shell-env" { export namespace shellEnv { export function sync(shell?: string): NodeJS.Env export default function(shell?: string): Promise } } ================================================ FILE: browser/src/Plugins/PackageMetadataParser.ts ================================================ /** * PackageMetadataParser.ts * * Responsible for parsing and normalizing package.json for ONI plugins */ import * as fs from "fs" import * as path from "path" import * as Log from "oni-core-logging" import * as Capabilities from "./Api/Capabilities" const remapToAbsolutePaths = ( packageRoot: string, contributes: Capabilities.IContributions, ): Capabilities.IContributions => { const remapThemePath = ( themes: Capabilities.IThemeContribution, ): Capabilities.IThemeContribution => { return { ...themes, path: path.join(packageRoot, themes.path), } } const remapIconPath = ( iconThemes: Capabilities.IIconThemeContribution, ): Capabilities.IIconThemeContribution => { return { ...iconThemes, path: path.join(packageRoot, iconThemes.path), } } const remapSnippetPath = ( snippet: Capabilities.ISnippetContribution, ): Capabilities.ISnippetContribution => { return { ...snippet, path: path.join(packageRoot, snippet.path), } } return { ...contributes, themes: contributes.themes.map(t => remapThemePath(t)), iconThemes: contributes.iconThemes.map(it => remapIconPath(it)), snippets: contributes.snippets.map(s => remapSnippetPath(s)), } } export const readMetadata = (packagePath: string): Capabilities.IPluginMetadata | null => { const packageContents = fs.readFileSync(packagePath, "utf8") let metadata: Capabilities.IPluginMetadata = null try { metadata = JSON.parse(packageContents) as Capabilities.IPluginMetadata } catch (ex) { Log.error(ex) } if (!metadata) { return null } // tslint:disable-next-line no-string-literal if (!metadata.engines || !metadata.engines["oni"]) { Log.warn("Aborting plugin load as Oni engine version not specified") return null } const contributes = { ...Capabilities.DefaultContributions, ...metadata.contributes, } return { ...metadata, contributes: remapToAbsolutePaths(path.dirname(packagePath), contributes), } } ================================================ FILE: browser/src/Plugins/Plugin.ts ================================================ import * as fs from "fs" import * as path from "path" import * as Log from "oni-core-logging" import * as Capabilities from "./Api/Capabilities" import { Oni } from "./Api/Oni" import * as PackageMetadataParser from "./PackageMetadataParser" export class Plugin { private _oniPluginMetadata: Capabilities.IPluginMetadata private _oni: Oni private _id: string private _instance: any public get id(): string { return this._id } public get instance(): any { return this._instance } public get metadata(): Capabilities.IPluginMetadata { return this._oniPluginMetadata } public get source(): string { return this._source } public get name(): string | null { const metadata = this.metadata return metadata === null || metadata === undefined ? null : metadata.name } constructor(private _pluginRootDirectory: string, private _source: string) { const packageJsonPath = path.join(this._pluginRootDirectory, "package.json") this._id = path.basename(this._pluginRootDirectory) if (fs.existsSync(packageJsonPath)) { this._oniPluginMetadata = PackageMetadataParser.readMetadata(packageJsonPath) } } public activate(): void { if (!this._oniPluginMetadata || !this._oniPluginMetadata.main) { return } this._oni = new Oni() const vm = require("vm") Log.info(`[PLUGIN] Activating: ${this._oniPluginMetadata.name}`) let moduleEntryPoint = path.normalize( path.join(this._pluginRootDirectory, this._oniPluginMetadata.main), ) moduleEntryPoint = moduleEntryPoint.split("\\").join("/") try { vm.runInNewContext( `debugger; const pluginEntryPoint = require('${moduleEntryPoint}').activate; if (!pluginEntryPoint) { console.warn('No activate method found for: ${moduleEntryPoint}'); } else { pluginContainer._instance = pluginEntryPoint(Oni); } `, { pluginContainer: this, Oni: this._oni, require: window["require"], // tslint:disable-line no-string-literal console, }, ) Log.info(`[PLUGIN] Activation successful.`) } catch (ex) { Log.error(`[PLUGIN] Failed to load plugin: ${this._oniPluginMetadata.name}`, ex) Log.error(ex) } } } ================================================ FILE: browser/src/Plugins/PluginConfigurationSynchronizer.ts ================================================ /** * PluginConfigurationSynchronizer.ts * * Responsible for synchronizing user's `plugin` configuration settings. */ import * as Log from "oni-core-logging" 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!") }) }) } ================================================ FILE: browser/src/Plugins/PluginInstaller.ts ================================================ /** * PluginInstaller.ts * * Responsible for installing, updating, and uninstalling plugins. */ import * as path from "path" import * as Log from "oni-core-logging" 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 { IFileSystem, OniFileSystem } from "./../Services/Explorer/ExplorerFileSystem" import Process from "./Api/Process" /** * 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 = OniFileSystem) {} 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") } } ================================================ FILE: browser/src/Plugins/PluginManager.ts ================================================ import * as fs from "fs" import * as path from "path" import * as Oni from "oni-api" import { Event, IEvent } from "oni-types" import { Configuration, getUserConfigFolderPath } from "./../Services/Configuration" import { IContributions } from "./Api/Capabilities" import { AnonymousPlugin } from "./AnonymousPlugin" import { Plugin } from "./Plugin" const corePluginsRoot = path.join(__dirname, "vim", "core") const defaultPluginsRoot = path.join(__dirname, "vim", "default") 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() private _pluginsLoaded = new Event() public get pluginsAllLoaded(): IEvent { return this._pluginsLoaded } private _developmentPluginsPath: string[] = [] public get plugins(): Plugin[] { return this._plugins } public get installer(): IPluginInstaller { return this._installer } constructor(private _config: Configuration) {} public addDevelopmentPlugin(pluginPath: string): void { this._developmentPluginsPath.push(pluginPath) } public discoverPlugins(): void { const corePluginRootPaths: string[] = [corePluginsRoot, extensionsRoot] const corePlugins: Plugin[] = this._getAllPluginPaths(corePluginRootPaths).map(p => this._createPlugin(p, "core"), ) let defaultPluginRootPaths: string[] = [] let defaultPlugins: Plugin[] = [] if (this._config.getValue("oni.useDefaultConfig")) { defaultPluginRootPaths = [defaultPluginsRoot, path.join(defaultPluginsRoot, "bundle")] defaultPlugins = this._getAllPluginPaths(defaultPluginRootPaths).map(p => this._createPlugin(p, "default"), ) } const userPluginsRootPath = [path.join(getUserConfigFolderPath(), "plugins")] const userPlugins = this._getAllPluginPaths(userPluginsRootPath).map(p => this._createPlugin(p, "user"), ) const developmentPlugins = this._developmentPluginsPath.map(dev => this._createPlugin(dev, "development"), ) this._rootPluginPaths = [ ...corePluginRootPaths, ...defaultPluginRootPaths, ...userPluginsRootPath, ] this._plugins = [...corePlugins, ...defaultPlugins, ...userPlugins, ...developmentPlugins] this._anonymousPlugin = new AnonymousPlugin() } public startApi(): Oni.Plugin.Api { this._plugins.forEach(plugin => { plugin.activate() }) this._pluginsActivated = true this._pluginsLoaded.dispatch() return this.getApi() } public getApi(): Oni.Plugin.Api { return this._anonymousPlugin.oni } public getAllRuntimePaths(): string[] { const pluginPaths = [ ...this._getAllPluginPaths(this._rootPluginPaths), ...this._developmentPluginsPath, ] return pluginPaths.concat(this._rootPluginPaths) } public get loaded(): boolean { return this._pluginsActivated } public getPlugin(name: string): any { for (const plugin of this._plugins) { if (plugin.name === name) { return plugin.instance } } return null } public getAllContributionsOfType(selector: (capabilities: IContributions) => T[]): T[] { const filteredPlugins = this.plugins.filter(p => p.metadata && p.metadata.contributes) const capabilities = flatMap( filteredPlugins, p => selector(p.metadata.contributes) || ([] as T[]), ) return capabilities } private _createPlugin(pluginRootDirectory: string, source: string): Plugin { return new Plugin(pluginRootDirectory, source) } private _getAllPluginPaths(rootPluginPaths: string[]): string[] { const paths: string[] = [] rootPluginPaths.forEach(rp => { const subPaths = getDirectories(rp) paths.push(...subPaths) }) return paths } } let _pluginManager: PluginManager = null export const activate = (configuration: Configuration): void => { _pluginManager = new PluginManager(configuration) } export const getInstance = (): PluginManager => _pluginManager function getDirectories(rootPath: string): string[] { if (!fs.existsSync(rootPath)) { return [] } return fs .readdirSync(rootPath) .map(f => path.join(rootPath.toString(), f)) .filter(f => fs.statSync(f).isDirectory()) } ================================================ FILE: browser/src/Plugins/PluginSidebarPane.tsx ================================================ /** * PluginsSidebarPane.tsx * * Sidebar pane for managing plugins */ import * as React from "react" import { Event, IDisposable, IEvent } from "oni-types" import { CommandManager } from "./../Services/CommandManager" import { Configuration } from "./../Services/Configuration" import { SidebarManager, SidebarPane } from "./../Services/Sidebar" import { SidebarContainerView, SidebarItemView } from "./../UI/components/SidebarItemView" import { VimNavigator } from "./../UI/components/VimNavigator" import { PluginManager } from "./../Plugins/PluginManager" import { noop } from "./../Utility" import * as Common from "./../UI/components/common" import styled from "styled-components" const PluginIconWrapper = styled.div` background-color: rgba(0, 0, 0, 0.1); width: 36px; height: 36px; ` const PluginCommandsWrapper = styled.div` flex: 0 0 auto; ` const PluginInfoWrapper = styled.div` flex: 1 1 auto; width: 100%; justify-content: center; display: flex; flex-direction: column; margin-left: 8px; margin-right: 8px; ` const PluginTitleWrapper = styled.div` font-size: 1.1em; ` export interface PluginSidebarItemViewProps { name: string } export class PluginSidebarItemView extends React.PureComponent { public render(): JSX.Element { return ( {this.props.name} ) } } export class PluginsSidebarPane implements SidebarPane { private _onEnter = new Event() private _onLeave = new Event() public get id(): string { return "oni.sidebar.plugins" } public get title(): string { return "Plugins" } constructor(private _pluginManager: PluginManager) {} public enter(): void { this._onEnter.dispatch() } public leave(): void { this._onLeave.dispatch() } public render(): JSX.Element { return ( ) } } export interface IPluginsSidebarPaneViewProps { onEnter: IEvent onLeave: IEvent pluginManager: PluginManager } export interface IPluginsSidebarPaneViewState { isActive: boolean defaultPluginsExpanded: boolean userPluginsExpanded: boolean workspacePluginsExpanded: boolean } export class PluginsSidebarPaneView extends React.PureComponent< IPluginsSidebarPaneViewProps, IPluginsSidebarPaneViewState > { private _subscriptions: IDisposable[] = [] constructor(props: IPluginsSidebarPaneViewProps) { super(props) this.state = { isActive: false, defaultPluginsExpanded: false, userPluginsExpanded: true, workspacePluginsExpanded: false, } } public componentDidMount(): void { this._clearExistingSubscriptions() const s2 = this.props.onEnter.subscribe(() => this.setState({ isActive: true })) const s3 = this.props.onLeave.subscribe(() => this.setState({ isActive: false })) this._subscriptions = [s2, s3] } public componentWillUnmount(): void { this._clearExistingSubscriptions() } public render(): JSX.Element { const plugins = this.props.pluginManager.plugins const defaultPlugins = plugins.filter(p => p.source === "default") const userPlugins = plugins.filter(p => p.source === "user") const defaultPluginIds = this.state.defaultPluginsExpanded ? defaultPlugins.map(p => p.id) : [] const userPluginIds = this.state.userPluginsExpanded ? userPlugins.map(p => p.id) : [] const allIds = [ "container.default", ...defaultPluginIds, "container.workspace", "container.user", ...userPluginIds, ] return ( this._onSelect(id)} render={(selectedId: string) => { const defaultPluginItems = defaultPlugins.map(p => ( } onClick={noop} /> )) const userPluginItems = userPlugins.map(p => ( } onClick={noop} /> )) return (
    this._onSelect("container.default")} > {defaultPluginItems} this._onSelect("container.workspace")} > {[]} this._onSelect("container.user")} > {userPluginItems}
    ) }} /> ) } private _onSelect(id: string): void { switch (id) { case "container.default": this._toggleDefaultPluginsExpanded() return case "container.user": this._toggleUserPluginsExpanded() return } } private _toggleDefaultPluginsExpanded(): void { this.setState({ defaultPluginsExpanded: !this.state.defaultPluginsExpanded, }) } private _toggleUserPluginsExpanded(): void { this.setState({ userPluginsExpanded: !this.state.userPluginsExpanded, }) } private _clearExistingSubscriptions(): void { this._subscriptions.forEach(sub => sub.dispose()) this._subscriptions = [] } } export const activate = ( commandManager: CommandManager, configuration: Configuration, pluginManager: PluginManager, sidebarManager: SidebarManager, ) => { if (configuration.getValue("sidebar.plugins.enabled")) { const pane = new PluginsSidebarPane(pluginManager) sidebarManager.add("plug", pane) } const togglePlugins = () => { sidebarManager.toggleVisibilityById("oni.sidebar.plugins") } commandManager.registerCommand({ command: "plugins.toggle", name: "Plugins: Toggle Visibility", detail: "Toggles the plugins pane in the sidebar", execute: togglePlugins, enabled: () => configuration.getValue("sidebar.plugins.enabled"), }) } ================================================ FILE: browser/src/Redux/LoggingMiddleware.ts ================================================ /* * LoggingMiddleware * * Logging strategy for Redux, specific to Oni */ import { Store } from "redux" import * as Log from "oni-core-logging" export const createLoggingMiddleware = (storeName: string) => (store: Store) => ( next: any, ) => (action: any): any => { Log.verbose("[REDUX - " + storeName + "][ACTION] " + action.type) const result = next(action) return result } ================================================ FILE: browser/src/Redux/RequestAnimationFrameNotifyBatcher.ts ================================================ /* * RAFNotifyBatcher * * Helper method to 'batch' dispatches to redux store * subscriptions, based on animation frames. * * This helps 'debounce' the rendering logic - * otherwise we'd be re-rendering the UI every time * an action is dispatched. */ import { NotifyFunction } from "redux-batched-subscribe" export const RequestAnimationFrameNotifyBatcher = () => { let rafId: number = null return (notify: NotifyFunction) => { if (rafId) { return } rafId = window.requestAnimationFrame(() => { rafId = null notify() }) } } ================================================ FILE: browser/src/Redux/createStore.ts ================================================ /* * createStore * * Common utilities for creating a redux store with Oni * * Implementations some common functionality, like: * - Logging * - Throttled subscriptions */ import { applyMiddleware, compose, createStore as reduxCreateStore, Middleware, Reducer, Store, } from "redux" import { batchedSubscribe } from "redux-batched-subscribe" import { createLoggingMiddleware } from "./LoggingMiddleware" import { RequestAnimationFrameNotifyBatcher } from "./RequestAnimationFrameNotifyBatcher" export const createStore = ( name: string, reducer: Reducer, defaultState: TState, optionalMiddleware: Middleware[] = [], ): Store => { // tslint:disable-next-line no-string-literal const composeFunction: any = window["__REDUX_DEVTOOLS_EXTENSION_COMPOSE__"] const composeEnhancers = typeof window === "object" && composeFunction ? composeFunction({ name }) : compose // tslint:disable-line no-string-literal const loggingMiddleware: Middleware = createLoggingMiddleware(name) const middleware = [loggingMiddleware, ...optionalMiddleware] const enhancer = composeEnhancers( applyMiddleware(...middleware), batchedSubscribe(RequestAnimationFrameNotifyBatcher()), ) return reduxCreateStore(reducer, defaultState, enhancer) } ================================================ FILE: browser/src/Redux/index.ts ================================================ export * from "./createStore" ================================================ FILE: browser/src/Renderer/CanvasRenderer.ts ================================================ import { Grid } from "./../Grid" import { ICell, MinimalScreenForRendering } from "./../neovim" import * as Performance from "./../Performance" import { INeovimRenderer } from "./INeovimRenderer" import { getSpansToEdit, IPosition, ISpan } from "./Span" import { configuration } from "./../Services/Configuration" export interface IRenderState { isWhitespace: boolean foregroundColor: string backgroundColor: string text: string startX: number bold: boolean italic: boolean underline: boolean y: number width: number } const isWhiteSpace = (text: string) => text === null || text === "" || text === " " const cellsAreSameColor = (cell1: ICell, cell2: ICell): boolean => { if (!cell1 || !cell2) { return false } return ( cell1.backgroundColor === cell2.backgroundColor && cell1.foregroundColor === cell2.foregroundColor && cell1.characterWidth === 1 && cell2.characterWidth === 1 ) } const cellsAreEqual = (cell1: ICell, cell2: ICell): boolean => { if (cell1 === cell2) { return true } if (cellsAreSameColor(cell1, cell2) && cell1.character === cell2.character) { return true } return false } export class CanvasRenderer implements INeovimRenderer { private _editorElement: HTMLDivElement private _canvasElement: HTMLCanvasElement private _canvasContext: CanvasRenderingContext2D private _width: number private _height: number private _isOpaque: boolean private _lastRenderGrid: Grid = new Grid() private _grid: Grid = new Grid() private _devicePixelRatio: number public start(element: HTMLDivElement): void { this._editorElement = element this._setContext() } public onAction(_action: any): void { // In the future, something like scrolling could be potentially optimized here } public redrawAll(screenInfo: MinimalScreenForRendering): void { if (!this._editorElement) { return } const cellsToUpdate: IPosition[] = [] this._setContext() if (this._isOpaque) { this._canvasContext.fillStyle = screenInfo.backgroundColor this._canvasContext.fillRect(0, 0, this._width, this._height) } else { this._canvasContext.clearRect(0, 0, this._width, this._height) } this._lastRenderGrid.clear() for (let x = 0; x < screenInfo.width; x++) { for (let y = 0; y < screenInfo.height; y++) { const cell = screenInfo.getCell(x, y) cellsToUpdate.push({ x, y }) this._lastRenderGrid.setCell(x, y, cell) } } this._draw(screenInfo, cellsToUpdate) } public draw(screenInfo: MinimalScreenForRendering): void { if (!this._editorElement) { return } const cellsToUpdate: IPosition[] = [] for (let x = 0; x < screenInfo.width; x++) { for (let y = 0; y < screenInfo.height; y++) { const lastCell = this._lastRenderGrid.getCell(x, y) const currentCell = screenInfo.getCell(x, y) if (!cellsAreEqual(lastCell, currentCell)) { cellsToUpdate.push({ x, y }) this._lastRenderGrid.setCell(x, y, currentCell) } } } this._draw(screenInfo, cellsToUpdate) } public _draw(screenInfo: MinimalScreenForRendering, modifiedCells: IPosition[]): void { Performance.mark("CanvasRenderer.update.start") this._canvasContext.font = `${screenInfo.fontWeight} ${screenInfo.fontSize} ${ screenInfo.fontFamily }` this._canvasContext.textBaseline = "top" this._canvasContext.setTransform(this._devicePixelRatio, 0, 0, this._devicePixelRatio, 0, 0) this._canvasContext.imageSmoothingEnabled = false this._editorElement.style.fontFamily = screenInfo.fontFamily this._editorElement.style.fontSize = screenInfo.fontSize this._editorElement.style.fontWeight = screenInfo.fontWeight const rowsToEdit = getSpansToEdit(this._grid, modifiedCells) for (const y of Object.keys(rowsToEdit)) { const row: ISpan[] = rowsToEdit[y] if (!row) { return } row.forEach((span: ISpan) => { // All spans that have changed in current rendering pass const rowIndex = Number.parseInt(y, 10) const currentCell = screenInfo.getCell(span.startX, rowIndex) // Check spans before & after, to see if they can be merged // (In other words, if they should be re-rendered together) // This is important for ligature cases. const gridCellBefore = screenInfo.getCell(span.startX - 1, rowIndex) const gridCellAfter = screenInfo.getCell(span.endX + 1, rowIndex) let updatedStartX = span.startX let updatedEndX = span.endX if (cellsAreSameColor(currentCell, gridCellBefore)) { const previousCell = this._grid.getCell(span.startX - 1, rowIndex) if (previousCell) { updatedStartX = previousCell.startX } } if (cellsAreSameColor(currentCell, gridCellAfter)) { const afterCell = this._grid.getCell(span.endX + 1, rowIndex) if (afterCell) { updatedEndX = afterCell.endX } } const updatedSpan: ISpan = { startX: updatedStartX, endX: updatedEndX, } this._renderSpan(updatedSpan, rowIndex, screenInfo) }) } Performance.mark("CanvasRenderer.update.end") } private _renderSpan(span: ISpan, y: number, screenInfo: MinimalScreenForRendering): void { let prevState: IRenderState = { isWhitespace: false, foregroundColor: screenInfo.foregroundColor, backgroundColor: screenInfo.backgroundColor, text: "", bold: false, italic: false, underline: false, startX: span.startX, y, width: 0, } let x = span.startX while (x < span.endX) { const cell = screenInfo.getCell(x, y) const nextRenderState = this._getNextRenderState(cell, x, y, prevState) if (this._isNewState(prevState, nextRenderState)) { this._renderText(prevState, screenInfo) } prevState = nextRenderState const increment = nextRenderState.startX + nextRenderState.width x = increment } this._renderText(prevState, screenInfo) } private _getNextRenderState( cell: ICell, x: number, y: number, currentState: IRenderState, ): IRenderState { const isCurrentCellWhiteSpace = isWhiteSpace(cell.character) if ( cell.foregroundColor !== currentState.foregroundColor || cell.backgroundColor !== currentState.backgroundColor || isCurrentCellWhiteSpace !== currentState.isWhitespace || cell.characterWidth > 1 ) { return { isWhitespace: isCurrentCellWhiteSpace, foregroundColor: cell.foregroundColor, backgroundColor: cell.backgroundColor, text: cell.character, bold: cell.bold, italic: cell.italic, underline: cell.underline, width: cell.characterWidth, startX: x, y, } } else { const adjustedCharacterWidth = isCurrentCellWhiteSpace ? 1 : cell.characterWidth // Not using spread (...) operator, which would simplify this, // because this is a hot-path for rendering and `Object.assign` // has some overhead that showed up in the profile. return { isWhitespace: currentState.isWhitespace, foregroundColor: cell.foregroundColor, backgroundColor: cell.backgroundColor, text: currentState.text + cell.character, bold: cell.bold, italic: cell.italic, underline: cell.underline, width: currentState.width + adjustedCharacterWidth, startX: currentState.startX, y: currentState.y, } } } private _isNewState(oldState: IRenderState, newState: IRenderState) { return oldState.startX !== newState.startX } private _renderText(state: IRenderState, screenInfo: MinimalScreenForRendering): void { // Spans can have a width of 0 if they are placeholders for cells // after a multibyte character. In this case, we don't need to bother // rendering or clearing, because that occurs with the multibyte character. if (state.width === 0) { return } const { backgroundColor, foregroundColor, bold, italic, text, startX, y } = state const { fontWidthInPixels, fontHeightInPixels, linePaddingInPixels } = screenInfo const boundsStartX = startX * fontWidthInPixels const boundsY = y * fontHeightInPixels const boundsWidth = state.width * fontWidthInPixels // This normalization is required to fix "cracks" due to anti-aliasing and rendering // rectangles on subpixel boundaries. Sometimes, the rectangle will not "connect" // between adjacent boundaries, and there is a crack between the blocks. Worse, // sometimes when clearing a rectangle, a thin line will be left. // // This normalization addresses it by making sure the rectangle bounds are aligned // to the nearest integer pixel. const normalizedBoundsStartX = Math.floor(boundsStartX) const delta = boundsStartX - normalizedBoundsStartX const normalizedBoundsWidth = Math.ceil(boundsWidth + delta) const normalizedBoundsY = Math.floor(boundsY) const deltaY = boundsY - normalizedBoundsY const normalizedHeight = Math.ceil(boundsY + deltaY) this._canvasContext.fillStyle = backgroundColor || screenInfo.backgroundColor if (this._isOpaque || (backgroundColor && backgroundColor !== screenInfo.backgroundColor)) { this._canvasContext.fillRect( normalizedBoundsStartX, normalizedHeight, normalizedBoundsWidth, fontHeightInPixels, ) } else { this._canvasContext.clearRect( normalizedBoundsStartX, normalizedHeight, normalizedBoundsWidth, fontHeightInPixels, ) } if (!state.isWhitespace) { const lastFontStyle = this._canvasContext.font this._canvasContext.fillStyle = foregroundColor if (bold) { this._canvasContext.font = `bold ${this._canvasContext.font}` } if (italic) { this._canvasContext.font = `italic ${this._canvasContext.font}` } this._canvasContext.fillText( text, boundsStartX, normalizedBoundsY + linePaddingInPixels / 2, ) this._canvasContext.font = lastFontStyle } // Commit span dimensions to grid const spanInfoToCommit: ISpan = { startX: state.startX, endX: state.startX + state.width, } for (let x = state.startX; x < state.startX + state.width; x++) { this._grid.setCell(x, state.y, spanInfoToCommit) } } private _setContext(): void { this._editorElement.innerHTML = "" this._devicePixelRatio = window.devicePixelRatio // offsetWidth and offsetHeight always return an integer const editorWidth = this._editorElement.offsetWidth const editorHeight = this._editorElement.offsetHeight this._canvasElement = document.createElement("canvas") this._canvasElement.style.width = editorWidth + "px" this._canvasElement.style.height = editorHeight + "px" this._editorElement.appendChild(this._canvasElement) this._width = this._canvasElement.width = editorWidth * this._devicePixelRatio this._height = this._canvasElement.height = editorHeight * this._devicePixelRatio if ( configuration.getValue("editor.backgroundImageUrl") && configuration.getValue("editor.backgroundOpacity") < 1.0 ) { this._canvasContext = this._canvasElement.getContext("2d", { alpha: true }) this._isOpaque = false } else { this._canvasContext = this._canvasElement.getContext("2d", { alpha: false }) this._isOpaque = true } } } ================================================ FILE: browser/src/Renderer/INeovimRenderer.ts ================================================ import { IScreen } from "./../neovim" export interface IPosition { x: number y: number } export interface INeovimRenderer { start(element: HTMLElement): void redrawAll(screenInfo: IScreen): void draw(screenInfo: IScreen): void onAction(action: any): void } ================================================ FILE: browser/src/Renderer/Span.ts ================================================ import { Grid } from "./../Grid" export interface ISpan { startX: number endX: number } export interface IPosition { x: number y: number } export interface RowMap { [key: number]: ISpan[] } export function getSpansToEdit(grid: Grid, cells: IPosition[]): RowMap { const rowToSpans: RowMap = {} cells.forEach(cell => { const { x, y } = cell const info = grid.getCell(x, y) const currentRow = rowToSpans[y] || [] if (!info) { currentRow.push({ startX: x, endX: x + 1, }) } else { currentRow.push({ startX: info.startX, endX: info.endX, }) grid.setRegion(info.startX, y, info.endX - info.startX, 1, null) } rowToSpans[y] = currentRow }) return collapseSpanMap(rowToSpans) } export function collapseSpanMap(currentSpanMap: RowMap): RowMap { const outMap = {} for (const k of Object.keys(currentSpanMap)) { outMap[k] = collapseSpans(currentSpanMap[k]) } return outMap } export function collapseSpans(spans: ISpan[] | undefined): ISpan[] { if (!spans) { return [] } const flattenedArray = flattenSpansToArray(spans) return expandArrayToSpans(flattenedArray) } export function flattenSpansToArray(spans: ISpan[]): any[] { if (!spans || !spans.length) { return [] } const bounds = spans.reduce( (prev, cur) => ({ startX: Math.min(prev.startX, cur.startX), endX: Math.max(prev.endX, cur.endX), }), { startX: spans[0].startX, endX: spans[0].endX }, ) const array: any[] = [] for (let x = 0; x < bounds.startX; x++) { array.push(null) } for (let x = bounds.startX; x < bounds.endX; x++) { array.push(false) } spans.forEach(s => { for (let i = s.startX; i < s.endX; i++) { array[i] = true } }) return array } export function expandArrayToSpans(array: any[]): ISpan[] { if (!array || !array.length) { return [] } let start = 0 while (array[start] === null) { start++ } const spans: ISpan[] = [] let currentSpan: ISpan | null = null let x = 0 while (x < array.length) { if (array[x]) { if (currentSpan === null) { currentSpan = { startX: x, endX: -1, } } } else { if (currentSpan !== null) { currentSpan.endX = x spans.push(currentSpan) currentSpan = null } } x++ } if (currentSpan) { currentSpan.endX = array.length spans.push(currentSpan) } return spans } ================================================ FILE: browser/src/Renderer/WebGLRenderer/SolidRenderer.ts ================================================ import { ICell } from "../../neovim" import { normalizeColor } from "./normalizeColor" import { createProgram, createUnitQuadElementIndicesBuffer, createUnitQuadVerticesBuffer, } from "./WebGLUtilities" const solidInstanceFieldCount = 8 const solidInstanceSizeInBytes = solidInstanceFieldCount * Float32Array.BYTES_PER_ELEMENT const vertexShaderAttributes = { unitQuadVertex: 0, targetOrigin: 1, targetSize: 2, colorRGBA: 3, } const vertexShaderSource = ` #version 300 es layout (location = 0) in vec2 unitQuadVertex; layout (location = 1) in vec2 targetOrigin; layout (location = 2) in vec2 targetSize; layout (location = 3) in vec4 colorRGBA; flat out vec4 color; uniform vec2 viewportScale; void main() { vec2 targetPixelPosition = targetOrigin + unitQuadVertex * targetSize; vec2 targetPosition = targetPixelPosition * viewportScale + vec2(-1.0, 1.0); gl_Position = vec4(targetPosition, 0.0, 1.0); color = colorRGBA; } `.trim() const fragmentShaderSource = ` #version 300 es precision mediump float; flat in vec4 color; layout (location = 0) out vec4 outColor; void main() { outColor = color; } `.trim() export class SolidRenderer { private _program: WebGLProgram private _viewportScaleLocation: WebGLUniformLocation private _unitQuadVerticesBuffer: WebGLBuffer private _unitQuadElementIndicesBuffer: WebGLBuffer private _solidInstances: Float32Array private _solidInstancesBuffer: WebGLBuffer private _vertexArrayObject: WebGLVertexArrayObject constructor(private _gl: WebGL2RenderingContext, private _devicePixelRatio: number) { this._program = createProgram(this._gl, vertexShaderSource, fragmentShaderSource) this._viewportScaleLocation = this._gl.getUniformLocation(this._program, "viewportScale") this._createBuffers() this._createVertexArrayObject() } public draw( columnCount: number, rowCount: number, getCell: (columnIndex: number, rowIndex: number) => ICell, fontWidthInPixels: number, fontHeightInPixels: number, defaultBackgroundColor: string, viewportScaleX: number, viewportScaleY: number, ) { const cellCount = columnCount * rowCount this._recreateSolidInstancesArrayIfRequired(cellCount) const solidInstanceCount = this._populateSolidInstances( columnCount, rowCount, getCell, fontWidthInPixels, fontHeightInPixels, defaultBackgroundColor, ) this._drawSolidInstances(solidInstanceCount, viewportScaleX, viewportScaleY) } private _createBuffers() { this._unitQuadVerticesBuffer = createUnitQuadVerticesBuffer(this._gl) this._unitQuadElementIndicesBuffer = createUnitQuadElementIndicesBuffer(this._gl) this._solidInstancesBuffer = this._gl.createBuffer() } private _createVertexArrayObject() { this._vertexArrayObject = this._gl.createVertexArray() this._gl.bindVertexArray(this._vertexArrayObject) this._gl.bindBuffer(this._gl.ELEMENT_ARRAY_BUFFER, this._unitQuadElementIndicesBuffer) this._gl.bindBuffer(this._gl.ARRAY_BUFFER, this._unitQuadVerticesBuffer) this._gl.enableVertexAttribArray(vertexShaderAttributes.unitQuadVertex) this._gl.vertexAttribPointer( vertexShaderAttributes.unitQuadVertex, 2, this._gl.FLOAT, false, 0, 0, ) this._gl.bindBuffer(this._gl.ARRAY_BUFFER, this._solidInstancesBuffer) this._gl.enableVertexAttribArray(vertexShaderAttributes.targetOrigin) this._gl.vertexAttribPointer( vertexShaderAttributes.targetOrigin, 2, this._gl.FLOAT, false, solidInstanceSizeInBytes, 0, ) this._gl.vertexAttribDivisor(vertexShaderAttributes.targetOrigin, 1) this._gl.enableVertexAttribArray(vertexShaderAttributes.targetSize) this._gl.vertexAttribPointer( vertexShaderAttributes.targetSize, 2, this._gl.FLOAT, false, solidInstanceSizeInBytes, 2 * Float32Array.BYTES_PER_ELEMENT, ) this._gl.vertexAttribDivisor(vertexShaderAttributes.targetSize, 1) this._gl.enableVertexAttribArray(vertexShaderAttributes.colorRGBA) this._gl.vertexAttribPointer( vertexShaderAttributes.colorRGBA, 4, this._gl.FLOAT, false, solidInstanceSizeInBytes, 4 * Float32Array.BYTES_PER_ELEMENT, ) this._gl.vertexAttribDivisor(vertexShaderAttributes.colorRGBA, 1) } private _recreateSolidInstancesArrayIfRequired(cellCount: number) { const requiredArrayLength = cellCount * solidInstanceFieldCount if (!this._solidInstances || this._solidInstances.length < requiredArrayLength) { this._solidInstances = new Float32Array(requiredArrayLength) } } private _populateSolidInstances( columnCount: number, rowCount: number, getCell: (columnIndex: number, rowIndex: number) => ICell, fontWidthInPixels: number, fontHeightInPixels: number, defaultBackgroundColor: string, ) { const pixelRatioAdaptedFontWidth = fontWidthInPixels * this._devicePixelRatio const pixelRatioAdaptedFontHeight = fontHeightInPixels * this._devicePixelRatio let solidCellCount = 0 let y = 0 for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { let x = 0 for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) { const cell = getCell(columnIndex, rowIndex) if (cell.backgroundColor && cell.backgroundColor !== defaultBackgroundColor) { const colorToUse = cell.backgroundColor || defaultBackgroundColor || "black" const normalizedBackgroundColor = normalizeColor(colorToUse) this._updateSolidInstance( solidCellCount, x, y, pixelRatioAdaptedFontWidth, pixelRatioAdaptedFontHeight, normalizedBackgroundColor, ) solidCellCount++ } x += pixelRatioAdaptedFontWidth } y += pixelRatioAdaptedFontHeight } return solidCellCount } private _drawSolidInstances( solidCount: number, viewportScaleX: number, viewportScaleY: number, ) { this._gl.bindVertexArray(this._vertexArrayObject) this._gl.disable(this._gl.BLEND) this._gl.useProgram(this._program) this._gl.uniform2f(this._viewportScaleLocation, viewportScaleX, viewportScaleY) this._gl.bindBuffer(this._gl.ARRAY_BUFFER, this._solidInstancesBuffer) this._gl.bufferData(this._gl.ARRAY_BUFFER, this._solidInstances, this._gl.STREAM_DRAW) this._gl.drawElementsInstanced(this._gl.TRIANGLES, 6, this._gl.UNSIGNED_BYTE, 0, solidCount) } private _updateSolidInstance( index: number, x: number, y: number, width: number, height: number, color: Float32Array, ) { const startOffset = solidInstanceFieldCount * index // targetOrigin this._solidInstances[0 + startOffset] = x this._solidInstances[1 + startOffset] = y // targetSize this._solidInstances[2 + startOffset] = width this._solidInstances[3 + startOffset] = height // colorRGBA this._solidInstances[4 + startOffset] = color[0] this._solidInstances[5 + startOffset] = color[1] this._solidInstances[6 + startOffset] = color[2] this._solidInstances[7 + startOffset] = color[3] } } ================================================ FILE: browser/src/Renderer/WebGLRenderer/TextRenderer/GlyphAtlas/GlyphAtlas.ts ================================================ import { IRasterizedGlyph } from "./IRasterizedGlyph" const backgroundColor = "black" const foregroundColor = "white" export interface IGlyphAtlasOptions { fontFamily: string fontSize: string fontWeight: number | string lineHeightInPixels: number linePaddingInPixels: number glyphPaddingInPixels: number devicePixelRatio: number offsetGlyphVariantCount: number textureSizeInPixels: number textureLayerCount: number } export class WebGLTextureSpaceExceededError extends Error {} export class GlyphAtlas { private _rasterizingContext: CanvasRenderingContext2D private _rasterizedGlyphs = new Map() private _texture: WebGLTexture private _currentTextureLayerIndex = 0 private _currentTextureLayerChangedSinceLastUpload = false private _nextX = 0 private _nextY = 0 constructor(private _gl: WebGL2RenderingContext, private _options: IGlyphAtlasOptions) { // TODO we should share at least the rasterizingCanvas and maybe even the texture among different buffers const rasterizingCanvas = document.createElement("canvas") rasterizingCanvas.width = this._options.textureSizeInPixels rasterizingCanvas.height = this._options.textureSizeInPixels this._rasterizingContext = rasterizingCanvas.getContext("2d", { alpha: false }) this._rasterizingContext.fillStyle = foregroundColor this._rasterizingContext.textBaseline = "top" this._rasterizingContext.scale(_options.devicePixelRatio, _options.devicePixelRatio) this._rasterizingContext.imageSmoothingEnabled = false document.body.appendChild(rasterizingCanvas) this._texture = this._gl.createTexture() this._gl.bindTexture(this._gl.TEXTURE_2D_ARRAY, this._texture) this._gl.texParameteri( this._gl.TEXTURE_2D_ARRAY, this._gl.TEXTURE_MIN_FILTER, this._gl.LINEAR, ) this._gl.texParameteri( this._gl.TEXTURE_2D_ARRAY, this._gl.TEXTURE_WRAP_S, this._gl.CLAMP_TO_EDGE, ) this._gl.texParameteri( this._gl.TEXTURE_2D_ARRAY, this._gl.TEXTURE_WRAP_T, this._gl.CLAMP_TO_EDGE, ) const textureLayerCount = Math.min( this._options.textureLayerCount, this._gl.MAX_ARRAY_TEXTURE_LAYERS, ) this._gl.texImage3D( this._gl.TEXTURE_2D_ARRAY, 0, this._gl.RGBA, this._options.textureSizeInPixels, this._options.textureSizeInPixels, textureLayerCount, 0, this._gl.RGBA, this._gl.UNSIGNED_BYTE, null, ) } public getRasterizedGlyph( text: string, isBold: boolean, isItalic: boolean, variantIndex: number, ) { const glyphKey = `${text} ${isBold ? "b" : ""}${isItalic ? "i" : ""}${variantIndex}` let rasterizedGlyph = this._rasterizedGlyphs.get(glyphKey) if (!rasterizedGlyph) { rasterizedGlyph = this._rasterizeGlyph(text, isBold, isItalic, variantIndex) this._rasterizedGlyphs.set(glyphKey, rasterizedGlyph) } return rasterizedGlyph } public uploadTexture() { if (this._currentTextureLayerChangedSinceLastUpload) { this._gl.bindTexture(this._gl.TEXTURE_2D_ARRAY, this._texture) this._gl.texSubImage3D( this._gl.TEXTURE_2D_ARRAY, 0, 0, 0, this._currentTextureLayerIndex, this._options.textureSizeInPixels, this._options.textureSizeInPixels, 1, this._gl.RGBA, this._gl.UNSIGNED_BYTE, this._rasterizingContext.canvas, ) this._currentTextureLayerChangedSinceLastUpload = false } } private _rasterizeGlyph( text: string, isBold: boolean, isItalic: boolean, variantIndex: number, ) { this._currentTextureLayerChangedSinceLastUpload = true const { fontWeight, devicePixelRatio, lineHeightInPixels, linePaddingInPixels, glyphPaddingInPixels, offsetGlyphVariantCount, } = this._options const style = getGlyphStyleString(fontWeight, isBold, isItalic) this._rasterizingContext.font = `${style} ${this._options.fontSize} ${ this._options.fontFamily }` const variantOffset = variantIndex / offsetGlyphVariantCount const height = lineHeightInPixels + 2 * glyphPaddingInPixels const { width: measuredGlyphWidth } = this._rasterizingContext.measureText(text) const width = Math.ceil(variantOffset) + Math.ceil(measuredGlyphWidth) + 2 * glyphPaddingInPixels if ((this._nextX + width) * devicePixelRatio > this._options.textureSizeInPixels) { this._nextX = 0 this._nextY = Math.ceil(this._nextY + height) } if ((this._nextY + height) * devicePixelRatio > this._options.textureSizeInPixels) { this._switchToNextLayer() } const x = this._nextX const y = this._nextY this._rasterizingContext.fillText( text, x + glyphPaddingInPixels + variantOffset, y + glyphPaddingInPixels + linePaddingInPixels / 2, ) this._nextX += width const rasterizedGlyph: IRasterizedGlyph = { width: width * devicePixelRatio, height: height * devicePixelRatio, textureLayerIndex: this._currentTextureLayerIndex, textureU: x * devicePixelRatio / this._options.textureSizeInPixels, textureV: y * devicePixelRatio / this._options.textureSizeInPixels, textureWidth: width * devicePixelRatio / this._options.textureSizeInPixels, textureHeight: height * devicePixelRatio / this._options.textureSizeInPixels, variantOffset, } return rasterizedGlyph } private _switchToNextLayer() { if (this._currentTextureLayerIndex + 1 >= this._options.textureLayerCount) { throw new WebGLTextureSpaceExceededError( "The WebGL renderer ran out of texture space. Please re-open the editor " + "with more texture layers or switch to a different renderer.", ) } this.uploadTexture() this._rasterizingContext.fillStyle = backgroundColor this._rasterizingContext.fillRect( 0, 0, this._rasterizingContext.canvas.width, this._rasterizingContext.canvas.width, ) this._rasterizingContext.fillStyle = foregroundColor this._currentTextureLayerIndex++ this._nextX = 0 this._nextY = 0 this._currentTextureLayerChangedSinceLastUpload = true } } const defaultFontWeight = 400 const getGlyphStyleString = ( baseFontWeight: number | string = defaultFontWeight, isBold: boolean, isItalic: boolean, ) => { const fontWeight = isBold ? getIncreasedFontWeightForBoldText(baseFontWeight) : getNumericFontWeight(baseFontWeight) || defaultFontWeight return "" + fontWeight + (isItalic ? " italic" : "") } const addedFontWeightForBoldText = 300 const maxFontWeight = 900 const getIncreasedFontWeightForBoldText = (baseFontWeight: number | string) => { const numericBaseFontWeight = getNumericFontWeight(baseFontWeight) return Math.min(numericBaseFontWeight + addedFontWeightForBoldText, maxFontWeight) } const getNumericFontWeight = (fontWeight: number | string) => { if (typeof fontWeight === "number") { return fontWeight } else { return numericFontWeightMap[fontWeight] || defaultFontWeight } } const numericFontWeightMap = { normal: 400, bold: 700, // The following two should in fact be dynamic based on the weight of other elements // but this is too complex and not relevant enough to warrant respecting this logic bolder: 700, lighter: 300, } ================================================ FILE: browser/src/Renderer/WebGLRenderer/TextRenderer/GlyphAtlas/IRasterizedGlyph.ts ================================================ export interface IRasterizedGlyph { width: number height: number textureLayerIndex: number textureWidth: number textureHeight: number textureU: number textureV: number variantOffset: number } ================================================ FILE: browser/src/Renderer/WebGLRenderer/TextRenderer/GlyphAtlas/index.ts ================================================ export * from "./GlyphAtlas" export * from "./IRasterizedGlyph" ================================================ FILE: browser/src/Renderer/WebGLRenderer/TextRenderer/ICellGroup.ts ================================================ export interface ICellGroup { startColumnIndex: number characters: string[] foregroundColor?: string backgroundColor?: string italic?: boolean bold?: boolean underline?: boolean } ================================================ FILE: browser/src/Renderer/WebGLRenderer/TextRenderer/LigatureGrouper/ILigatureGrouper.ts ================================================ export interface ILigatureGrouper { getLigatureGroups(characters: string[]): string[] } ================================================ FILE: browser/src/Renderer/WebGLRenderer/TextRenderer/LigatureGrouper/NoopLigatureGrouper.ts ================================================ import { ILigatureGrouper } from "./ILigatureGrouper" export class NoopLigatureGrouper implements ILigatureGrouper { public getLigatureGroups(characters: string[]) { return characters } } ================================================ FILE: browser/src/Renderer/WebGLRenderer/TextRenderer/LigatureGrouper/OpenTypeLigatureGrouper.ts ================================================ import fontManager from "font-manager" import * as fs from "fs" import * as Log from "oni-core-logging" import oniFontkit, { Font } from "oni-fontkit" import { ILigatureGrouper } from "./ILigatureGrouper" const ligatureFeatures = ["calt", "rclt", "liga", "dlig", "clig"] export class OpenTypeLigatureGrouper implements ILigatureGrouper { private readonly _font = loadFont(this._fontFamily) private readonly _fontHasLigatures = this._font && checkIfFontHasLigatures(this._font) private readonly _cache = new Map() constructor(private _fontFamily: string) {} public getLigatureGroups(characters: string[]) { if (!this._fontHasLigatures) { return characters } const concatenatedCharacters = characters.join("") const cachedLigatureGroups = this._cache.get(concatenatedCharacters) if (cachedLigatureGroups) { return cachedLigatureGroups } const fontGlyphs = this._font.glyphsForString(concatenatedCharacters) // Apply ligatures and get the contextGroup metadata in the Glyphs where context-based replacements happened const contextGroupArray = this._font.applySubstitutionFeatures(fontGlyphs, ligatureFeatures) const ligatureGroups: string[] = [] let currentContextGroupId: number = null contextGroupArray.forEach((contextGroupId, index) => { if (contextGroupId !== currentContextGroupId) { currentContextGroupId = contextGroupId ligatureGroups.push("") } ligatureGroups[ligatureGroups.length - 1] += concatenatedCharacters[index] }) this._cache.set(concatenatedCharacters, ligatureGroups) return [...ligatureGroups] } } const loadFont = (fontFamily: string) => { try { const fontDescriptor = findMatchingFont(fontFamily) if (!fontDescriptor) { Log.warn( `[OpenTypeLigatureGrouper] Could not find installed font for font family '${fontFamily}'. Ligatures won't be available.`, ) return null } const fontFileBuffer = fs.readFileSync(fontDescriptor.path) const font = oniFontkit.create(fontFileBuffer) Log.verbose( `[OpenTypeLigatureGrouper] Using font ${fontDescriptor.postscriptName} located at ${ fontDescriptor.path } for finding ligatures in '${fontFamily}'`, ) return font } catch (error) { Log.warn( `[OpenTypeLigatureGrouper] Error loading font file for font family '${fontFamily}': ${error} Ligatures won't be available.`, ) return null } } // This is a platform-independent reimplementation of the matching logic within font-manager's // findFont* methods. // We reimplemented it here because we encountered inconsistencies with matching on Windows. const findMatchingFont = (fontFamily: string) => { const availableFonts = fontManager.getAvailableFontsSync() const fontWithMatchingFamily = availableFonts.find(font => font.family === fontFamily) if (fontWithMatchingFamily) { return fontWithMatchingFamily } else { // Chromium allows to use the postscript name of the font as well, so we do the same // for compatibility const fontWithMatchingPostscriptName = availableFonts.find( font => font.postscriptName === fontFamily, ) return fontWithMatchingPostscriptName } } const checkIfFontHasLigatures = (font: Font) => { const fontHasLigatures = ligatureFeatures.some( ligatureFeature => font && font.availableFeatures && font.availableFeatures.includes(ligatureFeature), ) if (fontHasLigatures) { Log.verbose( `[OpenTypeLigatureGrouper] Found ligatures in '${ font.postscriptName }'. Ligatures will be available.`, ) return true } else { Log.verbose( `[OpenTypeLigatureGrouper] Could not find ligatures in '${ font.postscriptName }'. Ligatures won't be available.`, ) return false } } ================================================ FILE: browser/src/Renderer/WebGLRenderer/TextRenderer/LigatureGrouper/index.ts ================================================ export * from "./ILigatureGrouper" export * from "./OpenTypeLigatureGrouper" export * from "./NoopLigatureGrouper" ================================================ FILE: browser/src/Renderer/WebGLRenderer/TextRenderer/TextRenderer.ts ================================================ import { ICell } from "../../../neovim" import { normalizeColor } from "../normalizeColor" import { createProgram, createUnitQuadElementIndicesBuffer, createUnitQuadVerticesBuffer, } from "../WebGLUtilities" import { GlyphAtlas, IGlyphAtlasOptions, IRasterizedGlyph } from "./GlyphAtlas" import { groupCells } from "./groupCells" import { ILigatureGrouper } from "./LigatureGrouper" const glyphInstanceFieldCount = 13 const glyphInstanceSizeInBytes = glyphInstanceFieldCount * Float32Array.BYTES_PER_ELEMENT const vertexShaderAttributes = { unitQuadVertex: 0, targetOrigin: 1, targetSize: 2, textColorRGBA: 3, atlasLayerIndex: 4, atlasOrigin: 5, atlasSize: 6, } const vertexShaderSource = ` #version 300 es layout (location = 0) in vec2 unitQuadVertex; layout (location = 1) in vec2 targetOrigin; layout (location = 2) in vec2 targetSize; layout (location = 3) in vec4 textColorRGBA; layout (location = 4) in float atlasLayerIndex; layout (location = 5) in vec2 atlasOrigin; layout (location = 6) in vec2 atlasSize; uniform vec2 viewportScale; flat out vec4 textColor; flat out int convertedAtlasLayerIndex; out vec2 atlasPosition; void main() { vec2 targetPixelPosition = targetOrigin + unitQuadVertex * targetSize; vec2 targetPosition = targetPixelPosition * viewportScale + vec2(-1.0, 1.0); gl_Position = vec4(targetPosition, 0.0, 1.0); textColor = textColorRGBA; convertedAtlasLayerIndex = int(atlasLayerIndex); atlasPosition = atlasOrigin + unitQuadVertex * atlasSize; } `.trim() const firstPassFragmentShaderSource = ` #version 300 es precision mediump float; precision mediump sampler2DArray; layout(location = 0) out vec4 outColor; flat in vec4 textColor; flat in int convertedAtlasLayerIndex; in vec2 atlasPosition; uniform sampler2DArray atlasTextures; void main() { vec4 atlasColor = texture(atlasTextures, vec3(atlasPosition, convertedAtlasLayerIndex)); outColor = textColor.a * atlasColor; } `.trim() const secondPassFragmentShaderSource = ` #version 300 es precision mediump float; precision mediump sampler2DArray; layout(location = 0) out vec4 outColor; flat in vec4 textColor; flat in int convertedAtlasLayerIndex; in vec2 atlasPosition; uniform sampler2DArray atlasTextures; void main() { vec3 atlasColor = texture(atlasTextures, vec3(atlasPosition, convertedAtlasLayerIndex)).rgb; vec3 outColorRGB = atlasColor * textColor.rgb; float outColorA = max(outColorRGB.r, max(outColorRGB.g, outColorRGB.b)); outColor = vec4(outColorRGB, outColorA); } `.trim() export class TextRenderer { private _atlas: GlyphAtlas private _glyphOverlapInPixels: number private _subpixelDivisor: number private _devicePixelRatio: number private _firstPassProgram: WebGLProgram private _firstPassViewportScaleLocation: WebGLUniformLocation private _firstPassAtlasTexturesLocation: WebGLUniformLocation private _secondPassProgram: WebGLProgram private _secondPassViewportScaleLocation: WebGLUniformLocation private _secondPassAtlasTexturesLocation: WebGLUniformLocation private _unitQuadVerticesBuffer: WebGLBuffer private _unitQuadElementIndicesBuffer: WebGLBuffer private _glyphInstances: Float32Array private _glyphInstancesBuffer: WebGLBuffer private _vertexArrayObject: WebGLVertexArrayObject constructor( private _gl: WebGL2RenderingContext, private _ligatureGrouper: ILigatureGrouper, atlasOptions: IGlyphAtlasOptions, ) { this._glyphOverlapInPixels = atlasOptions.glyphPaddingInPixels this._subpixelDivisor = atlasOptions.offsetGlyphVariantCount this._devicePixelRatio = atlasOptions.devicePixelRatio this._atlas = new GlyphAtlas(this._gl, atlasOptions) this._firstPassProgram = createProgram( this._gl, vertexShaderSource, firstPassFragmentShaderSource, ) this._secondPassProgram = createProgram( this._gl, vertexShaderSource, secondPassFragmentShaderSource, ) this._firstPassViewportScaleLocation = this._gl.getUniformLocation( this._firstPassProgram, "viewportScale", ) this._secondPassViewportScaleLocation = this._gl.getUniformLocation( this._secondPassProgram, "viewportScale", ) this._firstPassAtlasTexturesLocation = this._gl.getUniformLocation( this._firstPassProgram, "atlasTextures", ) this._secondPassAtlasTexturesLocation = this._gl.getUniformLocation( this._secondPassProgram, "atlasTextures", ) this._createBuffers() this._createVertexArrayObject() } public prefillAtlasWithCommonGlyphs() { for (let asciiCode = 33; asciiCode <= 126; asciiCode++) { const character = String.fromCharCode(asciiCode) for (let variantIndex = 0; variantIndex < this._subpixelDivisor; variantIndex++) { this._atlas.getRasterizedGlyph(character, false, false, variantIndex) this._atlas.getRasterizedGlyph(character, true, false, variantIndex) this._atlas.getRasterizedGlyph(character, false, true, variantIndex) this._atlas.getRasterizedGlyph(character, true, true, variantIndex) } } } public draw( columnCount: number, rowCount: number, getCell: (columnIndex: number, rowIndex: number) => ICell, fontWidthInPixels: number, fontHeightInPixels: number, defaultForegroundColor: string, viewportScaleX: number, viewportScaleY: number, ) { const cellCount = columnCount * rowCount this._recreateGlyphInstancesArrayIfRequired(cellCount) const glyphInstanceCount = this._populateGlyphInstances( columnCount, rowCount, getCell, fontWidthInPixels, fontHeightInPixels, defaultForegroundColor, ) this._drawGlyphInstances(glyphInstanceCount, viewportScaleX, viewportScaleY) } private _createBuffers() { this._unitQuadVerticesBuffer = createUnitQuadVerticesBuffer(this._gl) this._unitQuadElementIndicesBuffer = createUnitQuadElementIndicesBuffer(this._gl) this._glyphInstancesBuffer = this._gl.createBuffer() } private _createVertexArrayObject() { this._vertexArrayObject = this._gl.createVertexArray() this._gl.bindVertexArray(this._vertexArrayObject) this._gl.bindBuffer(this._gl.ELEMENT_ARRAY_BUFFER, this._unitQuadElementIndicesBuffer) this._gl.bindBuffer(this._gl.ARRAY_BUFFER, this._unitQuadVerticesBuffer) this._gl.enableVertexAttribArray(vertexShaderAttributes.unitQuadVertex) this._gl.vertexAttribPointer( vertexShaderAttributes.unitQuadVertex, 2, this._gl.FLOAT, false, 0, 0, ) this._gl.bindBuffer(this._gl.ARRAY_BUFFER, this._glyphInstancesBuffer) this._gl.enableVertexAttribArray(vertexShaderAttributes.targetOrigin) this._gl.vertexAttribPointer( vertexShaderAttributes.targetOrigin, 2, this._gl.FLOAT, false, glyphInstanceSizeInBytes, 0, ) this._gl.vertexAttribDivisor(vertexShaderAttributes.targetOrigin, 1) this._gl.enableVertexAttribArray(vertexShaderAttributes.targetSize) this._gl.vertexAttribPointer( vertexShaderAttributes.targetSize, 2, this._gl.FLOAT, false, glyphInstanceSizeInBytes, 2 * Float32Array.BYTES_PER_ELEMENT, ) this._gl.vertexAttribDivisor(vertexShaderAttributes.targetSize, 1) this._gl.enableVertexAttribArray(vertexShaderAttributes.textColorRGBA) this._gl.vertexAttribPointer( vertexShaderAttributes.textColorRGBA, 4, this._gl.FLOAT, false, glyphInstanceSizeInBytes, 4 * Float32Array.BYTES_PER_ELEMENT, ) this._gl.vertexAttribDivisor(vertexShaderAttributes.textColorRGBA, 1) this._gl.enableVertexAttribArray(vertexShaderAttributes.atlasLayerIndex) this._gl.vertexAttribPointer( vertexShaderAttributes.atlasLayerIndex, 1, this._gl.FLOAT, false, glyphInstanceSizeInBytes, 8 * Float32Array.BYTES_PER_ELEMENT, ) this._gl.vertexAttribDivisor(vertexShaderAttributes.atlasLayerIndex, 1) this._gl.enableVertexAttribArray(vertexShaderAttributes.atlasOrigin) this._gl.vertexAttribPointer( vertexShaderAttributes.atlasOrigin, 2, this._gl.FLOAT, false, glyphInstanceSizeInBytes, 9 * Float32Array.BYTES_PER_ELEMENT, ) this._gl.vertexAttribDivisor(vertexShaderAttributes.atlasOrigin, 1) this._gl.enableVertexAttribArray(vertexShaderAttributes.atlasSize) this._gl.vertexAttribPointer( vertexShaderAttributes.atlasSize, 2, this._gl.FLOAT, false, glyphInstanceSizeInBytes, 11 * Float32Array.BYTES_PER_ELEMENT, ) this._gl.vertexAttribDivisor(vertexShaderAttributes.atlasSize, 1) } private _recreateGlyphInstancesArrayIfRequired(cellCount: number) { const requiredArrayLength = cellCount * glyphInstanceFieldCount if (!this._glyphInstances || this._glyphInstances.length < requiredArrayLength) { this._glyphInstances = new Float32Array(requiredArrayLength) } } private _populateGlyphInstances( columnCount: number, rowCount: number, getCell: (columnIndex: number, rowIndex: number) => ICell, fontWidthInPixels: number, fontHeightInPixels: number, defaultForegroundColor: string, ) { const pixelRatioAdaptedFontWidth = fontWidthInPixels * this._devicePixelRatio const pixelRatioAdaptedFontHeight = fontHeightInPixels * this._devicePixelRatio const pixelRatioAdaptedGlyphOverlap = this._glyphOverlapInPixels * this._devicePixelRatio let glyphCount = 0 for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { const cellGroups = groupCells(columnCount, rowIndex, getCell) cellGroups.forEach(cellGroup => { const { startColumnIndex, characters, bold, italic, foregroundColor } = cellGroup const ligatureGroups = this._ligatureGrouper.getLigatureGroups(characters) let offsetWithinCellGroup = 0 ligatureGroups.forEach(ligatureGroup => { const columnIndex = startColumnIndex + offsetWithinCellGroup const x = pixelRatioAdaptedFontWidth * columnIndex const y = pixelRatioAdaptedFontHeight * rowIndex const variantIndex = Math.round(x * this._subpixelDivisor) % this._subpixelDivisor const glyph = this._atlas.getRasterizedGlyph( ligatureGroup, bold, italic, variantIndex, ) const colorToUse = foregroundColor || defaultForegroundColor || "white" const normalizedTextColor = normalizeColor(colorToUse) this._updateGlyphInstance( glyphCount, Math.round(x - glyph.variantOffset) - pixelRatioAdaptedGlyphOverlap, y - pixelRatioAdaptedGlyphOverlap, glyph, normalizedTextColor, ) offsetWithinCellGroup += ligatureGroup.length glyphCount++ }) }) } this._atlas.uploadTexture() return glyphCount } private _drawGlyphInstances( glyphCount: number, viewportScaleX: number, viewportScaleY: number, ) { this._gl.bindVertexArray(this._vertexArrayObject) this._gl.enable(this._gl.BLEND) this._gl.useProgram(this._firstPassProgram) this._gl.uniform2f(this._firstPassViewportScaleLocation, viewportScaleX, viewportScaleY) this._gl.uniform1i(this._firstPassAtlasTexturesLocation, 0) this._gl.bindBuffer(this._gl.ARRAY_BUFFER, this._glyphInstancesBuffer) this._gl.bufferData(this._gl.ARRAY_BUFFER, this._glyphInstances, this._gl.STREAM_DRAW) this._gl.blendFuncSeparate( this._gl.ZERO, this._gl.ONE_MINUS_SRC_COLOR, this._gl.ZERO, this._gl.ONE, ) this._gl.drawElementsInstanced(this._gl.TRIANGLES, 6, this._gl.UNSIGNED_BYTE, 0, glyphCount) this._gl.useProgram(this._secondPassProgram) this._gl.blendFuncSeparate( this._gl.ONE, this._gl.ONE, this._gl.ONE, this._gl.ONE_MINUS_SRC_ALPHA, ) this._gl.uniform2f(this._secondPassViewportScaleLocation, viewportScaleX, viewportScaleY) this._gl.uniform1i(this._secondPassAtlasTexturesLocation, 0) this._gl.drawElementsInstanced(this._gl.TRIANGLES, 6, this._gl.UNSIGNED_BYTE, 0, glyphCount) } private _updateGlyphInstance( index: number, x: number, y: number, glyph: IRasterizedGlyph, color: Float32Array, ) { const startOffset = glyphInstanceFieldCount * index // targetOrigin this._glyphInstances[0 + startOffset] = x this._glyphInstances[1 + startOffset] = y // targetSize this._glyphInstances[2 + startOffset] = glyph.width this._glyphInstances[3 + startOffset] = glyph.height // textColorRGBA this._glyphInstances[4 + startOffset] = color[0] this._glyphInstances[5 + startOffset] = color[1] this._glyphInstances[6 + startOffset] = color[2] this._glyphInstances[7 + startOffset] = color[3] // atlasLayerIndex this._glyphInstances[8 + startOffset] = glyph.textureLayerIndex // atlasOrigin this._glyphInstances[9 + startOffset] = glyph.textureU this._glyphInstances[10 + startOffset] = glyph.textureV // atlasSize this._glyphInstances[11 + startOffset] = glyph.textureWidth this._glyphInstances[12 + startOffset] = glyph.textureHeight } } ================================================ FILE: browser/src/Renderer/WebGLRenderer/TextRenderer/groupCells.ts ================================================ import { ICell } from "../../../neovim" import { ICellGroup } from "./ICellGroup" export const groupCells = ( columnCount: number, rowIndex: number, getCell: (columnIndex: number, rowIndex: number) => ICell, ) => { const cellGroups: ICellGroup[] = [] for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) { const currentCell = getCell(columnIndex, rowIndex) const currentCharacter = currentCell.character const currentCellGroup = cellGroups.length && cellGroups[cellGroups.length - 1] if (!currentCharacter || currentCharacter === " ") { continue } else if ( currentCellGroup && cellStyleMatchesCellGroup(currentCell, currentCellGroup) && columnComesDirectlyAfterCellGroup(columnIndex, currentCellGroup) ) { currentCellGroup.characters.push(currentCharacter) } else { const newCellGroup = createNewCellGroup(columnIndex, currentCell) cellGroups.push(newCellGroup) } } return cellGroups } const cellStyleMatchesCellGroup = (cell: ICell, cellGroup: ICellGroup) => cellGroup.foregroundColor === cell.foregroundColor && cellGroup.backgroundColor === cell.backgroundColor && // Maybe this isn't necessary; should we still group different backgrounds? cellGroup.bold === cell.bold && cellGroup.italic === cell.italic && cellGroup.underline === cell.underline const columnComesDirectlyAfterCellGroup = (columnIndex: number, cellGroup: ICellGroup) => columnIndex === cellGroup.startColumnIndex + cellGroup.characters.length const createNewCellGroup = (startColumnIndex: number, startingCell: ICell) => { const { character, foregroundColor, backgroundColor, bold, italic, underline } = startingCell return { startColumnIndex, characters: [character], foregroundColor, backgroundColor, bold, italic, underline, } } ================================================ FILE: browser/src/Renderer/WebGLRenderer/TextRenderer/index.ts ================================================ export * from "./TextRenderer" ================================================ FILE: browser/src/Renderer/WebGLRenderer/WebGLRenderer.ts ================================================ import { INeovimRenderer } from ".." import { MinimalScreenForRendering } from "../../neovim" import { normalizeColor } from "./normalizeColor" import { SolidRenderer } from "./SolidRenderer" import { TextRenderer } from "./TextRenderer" import { IGlyphAtlasOptions, WebGLTextureSpaceExceededError } from "./TextRenderer/GlyphAtlas" import { ILigatureGrouper, NoopLigatureGrouper, OpenTypeLigatureGrouper, } from "./TextRenderer/LigatureGrouper" export class WebGLRenderer implements INeovimRenderer { private _editorElement: HTMLElement private _ligatureGrouper: ILigatureGrouper = new NoopLigatureGrouper() private _previousAtlasOptions: IGlyphAtlasOptions private _textureSizeInPixels = 1024 private _textureLayerCount = 2 private _gl: WebGL2RenderingContext private _solidRenderer: SolidRenderer private _textRenderer: TextRenderer public constructor(private _ligaturesEnabled: boolean) {} public start(editorElement: HTMLElement): void { this._editorElement = editorElement const canvasElement = document.createElement("canvas") this._editorElement.innerHTML = "" this._editorElement.appendChild(canvasElement) this._gl = canvasElement.getContext("webgl2") as WebGL2RenderingContext } public redrawAll(screenInfo: MinimalScreenForRendering): void { if (!this._editorElement) { return } this._updateCanvasDimensions() this._createNewRendererIfRequired(screenInfo) this._clear(screenInfo.backgroundColor) try { this._draw(screenInfo) } catch (error) { if (error instanceof WebGLTextureSpaceExceededError) { this._textureLayerCount *= 2 this.redrawAll(screenInfo) } else { throw error } } } public draw(screenInfo: MinimalScreenForRendering): void { this.redrawAll(screenInfo) } public onAction(action: any): void { // do nothing } private _updateCanvasDimensions() { const devicePixelRatio = window.devicePixelRatio const canvas = this._gl.canvas canvas.width = this._editorElement.offsetWidth * devicePixelRatio canvas.height = this._editorElement.offsetHeight * devicePixelRatio canvas.style.width = `${canvas.width / devicePixelRatio}px` canvas.style.height = `${canvas.height / devicePixelRatio}px` } private _createNewRendererIfRequired(screenInfo: MinimalScreenForRendering) { const { fontHeightInPixels, linePaddingInPixels, fontFamily, fontSize, fontWeight, } = screenInfo const devicePixelRatio = window.devicePixelRatio const offsetGlyphVariantCount = Math.max(Math.ceil(4 / devicePixelRatio), 1) const atlasOptions = { fontFamily, fontSize, fontWeight, lineHeightInPixels: fontHeightInPixels, linePaddingInPixels, glyphPaddingInPixels: Math.ceil(fontHeightInPixels / 4), devicePixelRatio, offsetGlyphVariantCount, textureSizeInPixels: this._textureSizeInPixels, textureLayerCount: this._textureLayerCount, } as IGlyphAtlasOptions if ( !this._solidRenderer || !this._textRenderer || !this._previousAtlasOptions || !isShallowEqual(this._previousAtlasOptions, atlasOptions) ) { if ( (!this._previousAtlasOptions || this._previousAtlasOptions.fontFamily !== fontFamily) && this._ligaturesEnabled ) { this._ligatureGrouper = new OpenTypeLigatureGrouper(fontFamily) } this._solidRenderer = new SolidRenderer(this._gl, atlasOptions.devicePixelRatio) this._textRenderer = new TextRenderer(this._gl, this._ligatureGrouper, atlasOptions) try { this._textRenderer.prefillAtlasWithCommonGlyphs() } catch (error) { if (error instanceof WebGLTextureSpaceExceededError) { this._textureLayerCount *= 2 this._createNewRendererIfRequired(screenInfo) } } this._previousAtlasOptions = atlasOptions } } private _clear(backgroundColor: string) { const backgroundColorToUse = backgroundColor || "black" const normalizedBackgroundColor = normalizeColor(backgroundColorToUse) this._gl.clearColor( normalizedBackgroundColor[0], normalizedBackgroundColor[1], normalizedBackgroundColor[2], normalizedBackgroundColor[3], ) this._gl.clear(this._gl.COLOR_BUFFER_BIT) } private _draw({ width: columnCount, height: rowCount, fontWidthInPixels, fontHeightInPixels, getCell, foregroundColor, backgroundColor, }: MinimalScreenForRendering) { const canvasWidth = this._gl.canvas.width const canvasHeight = this._gl.canvas.height const viewportScaleX = 2 / canvasWidth const viewportScaleY = -2 / canvasHeight this._gl.viewport(0, 0, canvasWidth, canvasHeight) this._solidRenderer.draw( columnCount, rowCount, getCell, fontWidthInPixels, fontHeightInPixels, backgroundColor, viewportScaleX, viewportScaleY, ) this._textRenderer.draw( columnCount, rowCount, getCell, fontWidthInPixels, fontHeightInPixels, foregroundColor, viewportScaleX, viewportScaleY, ) } } function isShallowEqual(objectA: T, objectB: T) { for (const key in objectA) { if (!(key in objectB) || objectA[key] !== objectB[key]) { return false } } for (const key in objectB) { if (!(key in objectA) || objectA[key] !== objectB[key]) { return false } } return true } ================================================ FILE: browser/src/Renderer/WebGLRenderer/WebGLUtilities.ts ================================================ export const createProgram = ( gl: WebGL2RenderingContext, vertexShaderSource: string, fragmentShaderSource: string, ) => { const vertexShader = compileShader(gl, vertexShaderSource, gl.VERTEX_SHADER) const fragmentShader = compileShader(gl, fragmentShaderSource, gl.FRAGMENT_SHADER) return linkProgram(gl, vertexShader, fragmentShader) } const compileShader = (gl: WebGL2RenderingContext, source: string, type: number) => { const shader = gl.createShader(type) gl.shaderSource(shader, source) gl.compileShader(shader) if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { const info = gl.getShaderInfoLog(shader) throw new Error("Could not compile WebGL program: \n\n" + info) } return shader } const linkProgram = ( gl: WebGL2RenderingContext, vertexShader: WebGLShader, fragmentShader: WebGLShader, ) => { const program = gl.createProgram() gl.attachShader(program, vertexShader) gl.attachShader(program, fragmentShader) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { const info = gl.getProgramInfoLog(program) throw new Error("Could not compile WebGL program: \n\n" + info) } return program } const unitQuadVertices = new Float32Array([1, 1, 1, 0, 0, 0, 0, 1]) export const createUnitQuadVerticesBuffer = (gl: WebGL2RenderingContext) => { const unitQuadVerticesBuffer = gl.createBuffer() gl.bindBuffer(gl.ARRAY_BUFFER, unitQuadVerticesBuffer) gl.bufferData(gl.ARRAY_BUFFER, unitQuadVertices, gl.STATIC_DRAW) return unitQuadVerticesBuffer } const unitQuadElementIndices = new Uint8Array([0, 1, 3, 1, 2, 3]) export const createUnitQuadElementIndicesBuffer = (gl: WebGL2RenderingContext) => { const unitQuadElementIndicesBuffer = gl.createBuffer() gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, unitQuadElementIndicesBuffer) gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, unitQuadElementIndices, gl.STATIC_DRAW) return unitQuadElementIndicesBuffer } ================================================ FILE: browser/src/Renderer/WebGLRenderer/index.ts ================================================ export * from "./WebGLRenderer" ================================================ FILE: browser/src/Renderer/WebGLRenderer/normalizeColor.ts ================================================ import colorNormalize from "color-normalize" const cache = new Map() export const normalizeColor = (cssColor: string) => { const cachedRgba = cache.get(cssColor) if (cachedRgba) { return cachedRgba } else { const rgba = colorNormalize(cssColor, "float32") cache.set(cssColor, rgba) return rgba } } ================================================ FILE: browser/src/Renderer/index.ts ================================================ export * from "./CanvasRenderer" export * from "./WebGLRenderer" export * from "./INeovimRenderer" ================================================ FILE: browser/src/Services/AutoClosingPairs.ts ================================================ /** * AutoClosingPairs * * Service to enable auto-closing pair key bindings */ import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { IBuffer } from "./../Editor/BufferManager" import { Configuration } from "./Configuration" import { EditorManager } from "./EditorManager" import { InputManager } from "./InputManager" import { LanguageManager } from "./Language" import { NeovimInstance } from "./../neovim" export interface IAutoClosingPair { open: string close: string // TODO: Support `notIn` equivalent } export const activate = ( configuration: Configuration, editorManager: EditorManager, inputManager: InputManager, languageManager: LanguageManager, ) => { const insertModeFilter = () => editorManager.activeEditor.mode === "insert" let subscriptions: Oni.DisposeFunction[] = [] const handleOpenCharacter = ( pair: IAutoClosingPair, 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) }) return true } 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 const lines = await activeBuffer.getLines( activeBuffer.cursor.line, activeBuffer.cursor.line + 1, ) const line = lines[0] const { column } = activeBuffer.cursor const matchingPair = pairs.find(p => { return column >= 1 && line[column] === p.close && line[column - 1] === p.open }) if (matchingPair) { // Remove the pairs const beforePair = line.substring(0, column - 1) const afterPair = line.substring(column + 1, line.length) const pos = await neovim.callFunction("getpos", ["."]) const [, oneBasedLine, oneBasedColumn] = pos await editor.activeBuffer.setCursorPosition(oneBasedLine - 1, oneBasedColumn - 2) await activeBuffer.setLines( activeBuffer.cursor.line, activeBuffer.cursor.line + 1, [beforePair + afterPair], ) } else { await inputFunc("") } }) return true } 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 const lines = await activeBuffer.getLines( activeBuffer.cursor.line, activeBuffer.cursor.line + 1, ) const line = lines[0] const { column } = activeBuffer.cursor const matchingPair = pairs.find(p => { return column >= 1 && line[column] === p.close && line[column - 1] === p.open }) if (matchingPair) { const whiteSpacePrefix = getWhiteSpacePrefix(line) const beforePair = line.substring(0, column) const afterPair = line.substring(column, line.length) const pos = await neovim.callFunction("getpos", ["."]) const [, oneBasedLine] = pos await activeBuffer.setLines( activeBuffer.cursor.line, activeBuffer.cursor.line + 1, [beforePair, whiteSpacePrefix, whiteSpacePrefix + afterPair], ) await activeBuffer.setCursorPosition(oneBasedLine, whiteSpacePrefix.length) await inputFunc("") } else { await inputFunc("") } }) return true } 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( activeBuffer.cursor.line, activeBuffer.cursor.line + 1, ) const line = lines[0] if (line[activeBuffer.cursor.column] === pair.close) { await activeBuffer.setCursorPosition( activeBuffer.cursor.line, activeBuffer.cursor.column + 1, ) } else { await inputFunc(pair.close) } }) return true } const onBufferEnter = (newBuffer: Oni.Buffer) => { if (!configuration.getValue("autoClosingPairs.enabled")) { Log.verbose("[Auto Closing Pairs] Not enabled.") return } if (subscriptions && subscriptions.length) { subscriptions.forEach(df => df()) } subscriptions = [] const autoClosingPairs = getAutoClosingPairs(configuration, newBuffer.language) autoClosingPairs.forEach(pair => { if (pair.open === pair.close) { subscriptions.push( inputManager.bind( pair.open, handleOpenCharacter(pair, editorManager.activeEditor, true), insertModeFilter, ), ) } subscriptions.push( inputManager.bind( pair.open, handleOpenCharacter(pair, editorManager.activeEditor, false), insertModeFilter, ), ) subscriptions.push( inputManager.bind( pair.close, handleCloseCharacter(pair, editorManager.activeEditor), insertModeFilter, ), ) }) subscriptions.push( inputManager.bind( "", handleBackspaceCharacter(autoClosingPairs, editorManager.activeEditor), insertModeFilter, ), ) subscriptions.push( inputManager.bind( "", handleEnterCharacter(autoClosingPairs, editorManager.activeEditor), insertModeFilter, ), ) } editorManager.anyEditor.onBufferEnter.subscribe(onBufferEnter) const activeEditor = editorManager.activeEditor if (activeEditor && activeEditor.activeBuffer) { onBufferEnter(activeEditor.activeBuffer) } } const nonWhiteSpaceRegEx = /\S/ export const getWhiteSpacePrefix = (str: string): string => { const firstIndex = str.search(nonWhiteSpaceRegEx) if (firstIndex === -1) { return "" } else { return str.substring(0, firstIndex) } } export const checkOpenCharacter = async ( inputFunc: any, pair: IAutoClosingPair, editor: Oni.Editor, openCharacterSameAsClosed: boolean, ): Promise => { // TODO: PERFORMANCE: Look at how to collapse this instead of needed multiple asynchronous calls. const activeBuffer = editor.activeBuffer as IBuffer if (openCharacterSameAsClosed) { const lines = await (activeBuffer as any).getLines( activeBuffer.cursor.line, activeBuffer.cursor.line + 1, false, ) const currentLine = lines[0] if (currentLine[activeBuffer.cursor.column] === pair.open) { await activeBuffer.setCursorPosition( activeBuffer.cursor.line, activeBuffer.cursor.column + 1, ) return } } await inputFunc(pair.open + pair.close) const pos = await activeBuffer.getCursorPosition() await editor.activeBuffer.setCursorPosition(pos.line, pos.character - 1) } const getAutoClosingPairs = ( configuration: Configuration, language: string, ): IAutoClosingPair[] => { const languagePairs = configuration.getValue(`language.${language}.autoClosingPairs`) if (languagePairs) { return languagePairs } else { return configuration.getValue("autoClosingPairs.default") || [] } } ================================================ FILE: browser/src/Services/AutoUpdate.ts ================================================ /** * AutoUpdate.ts * * Provides auto-update functionality * - Check for update * - Notifies when an update is available */ import * as os from "os" import { Observable } from "rxjs/Observable" import { Subject } from "rxjs/Subject" import { getMetadata } from "./Metadata" import { configuration } from "./Configuration" export interface IAutoUpdater { onUpdateNotAvailable: Observable onUpdateAvailable: Observable checkForUpdates(url: string): void } export const constructFeedUrl = async (baseUrl: string) => { const plat = os.platform() const { version } = await getMetadata() const isDevelopment = process.env["NODE_ENV"] === "development" // tslint:disable-line no-string-literal const channel = isDevelopment ? "development" : "release" return baseUrl + `?platform=${plat}&version=${version}&channel=${channel}` } export class AutoUpdater implements IAutoUpdater { private _onUpdateAvailable: Subject = new Subject() private _onUpdateNotAvailable: Subject = new Subject() public get onUpdateNotAvailable(): Observable { return this._onUpdateNotAvailable } public get onUpdateAvailable(): Observable { return this._onUpdateAvailable } public checkForUpdates(url: string): void { if (!configuration.getValue("autoUpdate.enabled")) { return } fetch(url).then(response => { if (response.status === 204) { this._onUpdateNotAvailable.next() } else { this._onUpdateAvailable.next() } }) } } export const autoUpdater = new AutoUpdater() ================================================ FILE: browser/src/Services/Automation.ts ================================================ /** * Automation.ts * * Helper methods for running automated tests */ import { remote } from "electron" import * as OniApi from "oni-api" import * as Log from "oni-core-logging" import * as App from "./../App" import * as Utility from "./../Utility" import { getUserConfigFilePath } from "./Configuration" import { editorManager } from "./EditorManager" import { inputManager } from "./InputManager" import { IKey, parseKeysFromVimString } from "./../Input/KeyParser" export interface ITestResult { passed: boolean exception?: any } import { Oni } from "./../Plugins/Api/Oni" export class Automation implements OniApi.Automation.Api { public sendKeys(keys: string): void { Log.info("[AUTOMATION] Sending keys: " + keys) if (!inputManager.handleKey(keys)) { Log.info("[AUTOMATION] InputManager did not handle key: " + keys) const anyEditor: any = editorManager.activeEditor as any anyEditor.input(keys) } } public sendKeysV2(keys: string): void { const parsedKeys = parseKeysFromVimString(keys) const contents = remote.getCurrentWebContents() const convertCharacter = (key: string) => { switch (key.toLowerCase()) { case "lt": return "<" case "cr": return "enter" default: return key } } const convertModifiers = (key: IKey): string[] => { const ret: string[] = [] if (key.control) { ret.push("control") } if (key.alt) { ret.push("alt") } if (key.meta) { ret.push("meta") } if (key.shift) { ret.push("shift") } return ret } parsedKeys.chord.forEach(key => { const character = convertCharacter(key.character) const modifiers = convertModifiers(key) contents.sendInputEvent({ keyCode: character, modifiers, type: "keyDown" } as any) contents.sendInputEvent({ keyCode: character, modifiers, type: "char" } as any) contents.sendInputEvent({ keyCode: character, type: "keyUp" } as any) }) } public async sleep(time: number = 1000): Promise { Log.info("[AUTOMATION] Sleeping for " + time + "ms") return new Promise(r => window.setTimeout(() => r(), time)) } public async waitFor(condition: () => boolean, timeout: number = 10000): Promise { Log.info( "[AUTOMATION] Starting wait - limit: " + timeout + " condition: " + condition.toString(), ) let time = 0 const interval = 1000 while (time <= timeout) { if (condition()) { Log.info("[AUTOMATION] Wait condition met at: " + time) return } await this.sleep(interval) time += interval Log.info("[AUTOMATION] Wait condition still not met: " + time + " / " + timeout) } Log.info("[AUTOMATION]: waitFor timeout expired for condition: " + condition.toString()) throw new Error("waitFor: Timeout expired") } public async waitForEditors(): Promise { Log.info("[AUTOMATION] Waiting for startup...") await App.waitForStart() Log.info("[AUTOMATION] Startup complete!") } public async runTest(testPath: string): Promise { const containerElement = this._getOrCreateTestContainer("automated-test-container") containerElement.innerHTML = "" const testPath2 = testPath Log.enableVerboseLogging() try { Log.info("[AUTOMATION] Starting test: " + testPath) Log.info("[AUTOMATION] Configuration path: " + getUserConfigFilePath()) const testCase: any = Utility.nodeRequire(testPath2) const oni = new Oni() this._initializeBrowseWindow() await testCase.test(oni) Log.info("[AUTOMATION] Completed test: " + testPath) await this._reportResult(true) } catch (ex) { await this._reportResult(false, ex) } finally { this._reportWindowSize() } } private _initializeBrowseWindow(): void { const win = remote.getCurrentWindow() win.maximize() win.focus() this._reportWindowSize() } private _reportWindowSize(): void { const win = remote.getCurrentWindow() const size = win.getContentSize() Log.info(`[AUTOMATION]: Window size reported as ${size}`) Log.info(`[AUTOMATION]: Window focus state: ${win.isFocused()}`) Log.info(`[AUTOMATION]: Is off-screen rendering: ${win.webContents.isOffscreen()}`) } private _getOrCreateTestContainer(className: string): HTMLDivElement { const containerElement = document.body.getElementsByClassName(className) if (containerElement && containerElement.length > 0) { return containerElement[0] as HTMLDivElement } const container = this._createElement(className, document.body) return container } 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"), ) if (exception && exception.code && exception.code === "ERR_ASSERTION") { resultElement.textContent = JSON.stringify({ passed, exception, expected: exception.expected, actual: exception.actual, message: exception.message, operator: exception.operator, }) } else { resultElement.textContent = JSON.stringify({ passed, exception: exception || null, }) } } private _createElement(className: string, parentElement: HTMLElement): HTMLDivElement { const elem = document.createElement("div") elem.className = className parentElement.appendChild(elem) return elem } } export const automation = new Automation() ================================================ FILE: browser/src/Services/Bookmarks/BookmarksPane.tsx ================================================ /** * BookmarksPane.tsx * * UX for rendering the bookmarks experience in the sidebar */ import * as React from "react" import styled from "styled-components" import * as path from "path" import { Event, IDisposable, IEvent } from "oni-types" 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" export class BookmarksPane implements SidebarPane { private _onEnter = new Event() private _onLeave = new Event() constructor(private _bookmarksProvider: IBookmarksProvider) {} public get id(): string { return "oni.sidebar.bookmarks" } public get title(): string { return "Marks" } public enter(): void { this._onEnter.dispatch() } public leave(): void { this._onLeave.dispatch() } public render(): JSX.Element { return ( ) } } export interface IBookmarksPaneViewProps { bookmarksProvider: IBookmarksProvider onEnter: IEvent onLeave: IEvent } export interface IBookmarksPaneViewState { bookmarks: IBookmark[] isActive: boolean isGlobalSectionExpanded: boolean isLocalSectionExpanded: boolean } const BookmarkItemWrapper = styled.div` display: flex; flex-direction: row; justify-content: center; align-items; center; margin-left: 8px; ` const BookmarkIconWrapper = styled.div` padding: 8px; margin: 4px; background-color: rgba(0, 0, 0, 0.2); flex: 0 0 auto; ` const BookmarkDescriptionWrapper = styled.div` display: flex; flex-direction: column; justify-content: center; margin-left: 8px; flex: 1 1 auto; overflow: hidden; ` const BookmarkTitleWrapper = styled.div` text-overflow: ellipsis; white-space: nowrap; ` const BookmarkLocationWrapper = styled.div` font-size: 0.8em; text-overflow: ellipsis; ` const BookmarkItemView = (props: { bookmark: IBookmark }) => { return ( {props.bookmark.id} {path.basename(props.bookmark.text)} {props.bookmark.line + ", " + props.bookmark.column} ) } export class BookmarksPaneView extends React.PureComponent< IBookmarksPaneViewProps, IBookmarksPaneViewState > { private _subscriptions: IDisposable[] = [] constructor(props: IBookmarksPaneViewProps) { super(props) this.state = { bookmarks: this.props.bookmarksProvider.bookmarks, isActive: false, isGlobalSectionExpanded: true, isLocalSectionExpanded: true, } } public componentDidMount(): void { this._clearExistingSubscriptions() const s1 = this.props.bookmarksProvider.onBookmarksUpdated.subscribe(() => { this.setState({ bookmarks: this.props.bookmarksProvider.bookmarks, }) }) const s2 = this.props.onEnter.subscribe(() => this.setState({ isActive: true })) const s3 = this.props.onLeave.subscribe(() => this.setState({ isActive: false })) this._subscriptions = [s1, s2, s3] } public componentWillUnmount(): void { this._clearExistingSubscriptions() } public render(): JSX.Element { if (this.state.bookmarks.length === 0) { return ( ) } else { const globalMarks = this.state.bookmarks.filter(bm => bm.group === "Global Marks") const localMarks = this.state.bookmarks.filter(bm => bm.group === "Local Marks") const globalMarkIds = this.state.isGlobalSectionExpanded ? globalMarks.map(bm => bm.id) : [] const localMarkIds = this.state.isLocalSectionExpanded ? localMarks.map(bm => bm.id) : [] const mapToItems = (selectedId: string) => (bm: IBookmark) => ( } isFocused={selectedId === bm.id} isContainer={false} indentationLevel={0} onClick={() => this._onSelected(bm.id)} /> ) const allIds = [ "container.global", ...globalMarkIds, "container.local", ...localMarkIds, ] return ( this._onSelected(id)} render={selectedId => { const mapFunc = mapToItems(selectedId) return (
    this._onSelected("container.global")} > {globalMarks.map(mapFunc)} this._onSelected("container.local")} > {localMarks.map(mapFunc)}
    ) }} /> ) } } private _onSelected(id: string): void { if (id === "container.global") { this.setState({ isGlobalSectionExpanded: !this.state.isGlobalSectionExpanded }) } else if (id === "container.local") { this.setState({ isLocalSectionExpanded: !this.state.isLocalSectionExpanded }) } } private _clearExistingSubscriptions(): void { this._subscriptions.forEach(sub => sub.dispose()) this._subscriptions = [] } } ================================================ FILE: browser/src/Services/Bookmarks/index.ts ================================================ import { Event, IEvent } from "oni-types" import { Configuration } from "./../Configuration" import { EditorManager } from "./../EditorManager" import { SidebarManager } from "./../Sidebar" import { BookmarksPane } from "./BookmarksPane" import { INeovimMarkInfo, INeovimMarks } from "./../../neovim" export interface IBookmark { group: string text: string line: number column: number id: string } export interface IBookmarksProvider { bookmarks: IBookmark[] onBookmarksUpdated: IEvent selectBookmark(bookmark: IBookmark): void } const marksToBookmarks = (mark: INeovimMarkInfo): IBookmark => ({ id: mark.mark, group: mark.global ? "Global Marks" : "Local Marks", text: mark.text, line: mark.line, column: mark.column, }) export class NeovimBookmarksProvider implements IBookmarksProvider { private _lastBookmarks: IBookmark[] = [] private _onBookmarksUpdated = new Event() public get bookmarks(): IBookmark[] { return this._lastBookmarks } public get onBookmarksUpdated(): IEvent { return this._onBookmarksUpdated } constructor(private _neovimMarks: INeovimMarks) { this._neovimMarks.onMarksUpdated.subscribe(marks => { this._lastBookmarks = marks.map(marksToBookmarks) this._onBookmarksUpdated.dispatch() }) } public selectBookmark(bookmark: IBookmark): void { alert("Selecting bookmark: " + bookmark.id) } } let _bookmarks: IBookmarksProvider export const activate = ( configuration: Configuration, editorManager: EditorManager, sidebarManager: SidebarManager, ) => { const marksEnabled = configuration.getValue("sidebar.enabled") && configuration.getValue("sidebar.marks.enabled") if (!marksEnabled) { return } // TODO: Push bookmarks provider to editor const neovim: any = editorManager.activeEditor.neovim neovim.marks.watchMarks() _bookmarks = new NeovimBookmarksProvider(neovim.marks) sidebarManager.add("bookmark", new BookmarksPane(_bookmarks)) } export const getInstance = (): IBookmarksProvider => _bookmarks ================================================ FILE: browser/src/Services/Browser/AddressBarView.tsx ================================================ /** * 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()} tag={"browser.address"}> 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() } } ================================================ FILE: browser/src/Services/Browser/BrowserButtonView.tsx ================================================ /** * 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 ( ) } ================================================ FILE: browser/src/Services/Browser/BrowserView.tsx ================================================ /** * oni-layer-browser/index.ts * * Entry point for browser integration plugin */ import * as path from "path" import * as React from "react" import styled from "styled-components" import { WebviewTag } from "electron" import * as Oni from "oni-api" import { IDisposable, IEvent } from "oni-types" 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; display: flex; flex-direction: column; width: 100%; height: 100%; ` const BrowserControlsWrapper = styled.div` display: flex; flex-direction: row; flex: 0 0 auto; user-select: none; height: 3em; width: 100%; background-color: ${props => props.theme["editor.background"]}; color: ${props => props.theme["editor.foreground"]}; ` const BrowserViewWrapper = styled.div` flex: 1 1 auto; width: 100%; height: 100%; position: relative; webview { height: 100%; width: 100%; } ` export interface IBrowserViewProps { initialUrl: string configuration: Configuration debug: IEvent goBack: IEvent goForward: IEvent reload: IEvent scrollUp: IEvent scrollDown: IEvent scrollLeft: IEvent scrollRight: IEvent webviewRef?: (webviewTag: WebviewTag) => void onFocusTag?: (tagName: string | null) => void } export interface IBrowserViewState { url: string } export interface SneakInfoFromBrowser { id: string rectangle: Oni.Shapes.Rectangle } export class BrowserView extends React.PureComponent { public _webviewElement: WebviewTag 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()) const d3 = this.props.reload.subscribe(() => this._reload()) const d4 = this.props.debug.subscribe(() => this._openDebugger()) const scrollDown = this.props.scrollDown.subscribe(() => this._scrollDown()) const scrollUp = this.props.scrollUp.subscribe(() => this._scrollUp()) const scrollRight = this.props.scrollRight.subscribe(() => this._scrollRight()) const scrollLeft = this.props.scrollLeft.subscribe(() => this._scrollLeft()) const d5 = getSneakInstance().addSneakProvider(async (): Promise => { if (this._webviewElement) { const promise = new Promise(resolve => { this._webviewElement.executeJavaScript( "window['__oni_sneak_collector__']()", null, result => { resolve(result) }, ) }) const webviewDimensions: ClientRect = this._webviewElement.getBoundingClientRect() const sneaks: SneakInfoFromBrowser[] = await promise 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 * zoomFactor, webviewDimensions.top + s.rectangle.y * zoomFactor, s.rectangle.width * zoomFactor, s.rectangle.height * zoomFactor, ), callback: callbackFunction(s.id), } }) } return [] }) 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, scrollUp, scrollDown, scrollLeft, scrollRight, ]) 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") } } public componentWillUnmount(): void { this._webviewElement = null this._disposables.forEach(d => d.dispose()) this._disposables = [] } public render(): JSX.Element { return ( this._navigate(url)} />
    (this._elem = elem)} style={{ position: "absolute", top: "0px", left: "0px", right: "0px", bottom: "0px", }} key={"test"} /> ) } 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 } public _scrollLeft = (): void => { if (this._webviewElement) { this._webviewElement.sendInputEvent({ type: "keyDown", keyCode: "Left", canScroll: true, modifiers: ["isAutoRepeat"], }) } } public _scrollRight = (): void => { if (this._webviewElement) { this._webviewElement.sendInputEvent({ type: "keyDown", keyCode: "Right", canScroll: true, modifiers: ["isAutoRepeat"], }) } } public _scrollDown = (): void => { if (this._webviewElement) { this._webviewElement.sendInputEvent({ type: "keyDown", keyCode: "Down", canScroll: true, modifiers: ["isAutoRepeat"], }) } } public _scrollUp = (): void => { if (this._webviewElement) { this._webviewElement.sendInputEvent({ type: "keyDown", keyCode: "Up", canScroll: true, modifiers: ["isAutoRepeat"], }) } } 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 => { if (this._webviewElement) { this._webviewElement.goForward() } } private _openDebugger = (): void => { if (this._webviewElement) { this._webviewElement.openDevTools() } } private _reload = (): void => { if (this._webviewElement) { this._webviewElement.reload() } } 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") webviewElement.autosize = "autosize" elem.appendChild(webviewElement) this._webviewElement = webviewElement 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) }) this._webviewElement.addEventListener("ipc-message", event => { switch (event.channel) { case "focusin": if (this.props.onFocusTag) { this.props.onFocusTag(event.args[0]) } return case "focusout": if (this.props.onFocusTag) { this.props.onFocusTag(null) } } }) if (this.props.webviewRef) { this.props.webviewRef(this._webviewElement) } } } } ================================================ FILE: browser/src/Services/Browser/index.tsx ================================================ /** * oni-layer-browser/index.ts * * Entry point for browser integration plugin */ import { shell, WebviewTag } from "electron" import * as React from "react" import * as Oni from "oni-api" import { Event } from "oni-types" import { IBuffer } from "./../../Editor/BufferManager" import { CommandManager } from "./../CommandManager" import { Configuration } from "./../Configuration" import { EditorManager } from "./../EditorManager" import { focusManager } from "./../FocusManager" import { AchievementsManager, getInstance as getAchievementsInstance, } from "./../Learning/Achievements" import { BrowserView } from "./BrowserView" export class BrowserLayer implements Oni.BufferLayer { private _debugEvent = new Event() private _goBackEvent = new Event() private _goForwardEvent = new Event() private _reloadEvent = new Event() private _scrollUpEvent = new Event() private _scrollDownEvent = new Event() private _scrollRightEvent = new Event() private _scrollLeftEvent = new Event() private _webview: WebviewTag | null = null private _activeTagName: string | null = null constructor(private _url: string, private _configuration: Configuration) {} public get id(): string { return "oni.browser" } public get webviewElement(): HTMLElement { return this._webview } public get activeTagName(): string { return this._activeTagName } public render(): JSX.Element { return ( (this._webview = webview)} onFocusTag={newTag => (this._activeTagName = newTag)} /> ) } public openDebugger(): void { this._debugEvent.dispatch() } public goBack(): void { this._goBackEvent.dispatch() } public goForward(): void { this._goForwardEvent.dispatch() } public reload(): void { this._reloadEvent.dispatch() } public scrollUp(): void { this._scrollUpEvent.dispatch() } public scrollDown(): void { this._scrollDownEvent.dispatch() } public scrollLeft(): void { this._scrollLeftEvent.dispatch() } public scrollRight(): void { this._scrollRightEvent.dispatch() } } export const activate = ( commandManager: CommandManager, configuration: Configuration, editorManager: EditorManager, ) => { let count = 0 const browserEnabledSetting = configuration.registerSetting("browser.enabled", { requiresReload: false, description: "`browser.enabled` controls whether the embedded browser functionality is enabled", defaultValue: true, }) configuration.registerSetting("browser.zoomFactor", { description: `This sets the "zoomFactor" for nested browser windows. A value of "1" means "100%" zoom, a value of 0.5 means "50%" zoom, and a value of "2" means "200%" zoom.`, requiresReload: false, defaultValue: 1, }) const defaultUrlSetting = configuration.registerSetting("browser.defaultUrl", { description: "`browser.defaultUrl` sets the default url when opening a browser window, and no url was specified.", requiresReload: false, defaultValue: "https://github.com/onivim/oni", }) const openUrl = async (url: string, openMode: Oni.FileOpenMode = Oni.FileOpenMode.NewTab) => { if (browserEnabledSetting.getValue()) { url = url || defaultUrlSetting.getValue() count++ const buffer: Oni.Buffer = await editorManager.activeEditor.openFile( "Browser" + count.toString(), { openMode }, ) const layer = new BrowserLayer(url, configuration) buffer.addLayer(layer) const achievements = getAchievementsInstance() achievements.notifyGoal("oni.goal.openBrowser") } else { shell.openExternal(url) } } 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", execute: openUrl, name: null, detail: null, }) commandManager.registerCommand({ command: "oni.docs.open", execute: () => openUrl("https://onivim.github.io/oni-docs/#/"), name: "Browser: Open Documentation", detail: "Open Oni's Documentation website", }) const getLayerForBuffer = (buffer: Oni.Buffer): BrowserLayer => { return (buffer as IBuffer).getLayerById("oni.browser") } const executeCommandForLayer = (callback: (browserLayer: BrowserLayer) => void) => () => { const activeBuffer = editorManager.activeEditor.activeBuffer const browserLayer = getLayerForBuffer(activeBuffer) if (browserLayer) { callback(browserLayer) } } const isBrowserCommandEnabled = (): boolean => { if (!browserEnabledSetting.getValue()) { return false } const layer = getLayerForBuffer(editorManager.activeEditor.activeBuffer) if (!layer) { return false } // If the layer is open, but not focused, we shouldn't execute commands. // This could happen if there is a pop-up menu, or if we're working with some // non-webview UI in the browser (like the address bar) if (layer.webviewElement !== focusManager.focusedElement) { return false } return true } const isInputTag = (tagName: string): boolean => { return tagName === "INPUT" || tagName === "TEXTAREA" } const isBrowserScrollCommandEnabled = (): boolean => { if (!isBrowserCommandEnabled()) { return false } const layer = getLayerForBuffer(editorManager.activeEditor.activeBuffer) // Finally, if the webview _is_ focused, but something has focus, we'll // skip our bindings and defer to the browser if (isInputTag(layer.activeTagName)) { return false } return true } // Per-layer commands commandManager.registerCommand({ command: "browser.debug", execute: executeCommandForLayer(browser => browser.openDebugger()), name: "Browser: Open DevTools", detail: "Open the devtools pane for the current browser window.", enabled: isBrowserCommandEnabled, }) commandManager.registerCommand({ command: "browser.goBack", execute: executeCommandForLayer(browser => browser.goBack()), name: "Browser: Go back", detail: "", enabled: isBrowserCommandEnabled, }) commandManager.registerCommand({ command: "browser.goForward", execute: executeCommandForLayer(browser => browser.goForward()), name: "Browser: Go forward", detail: "", enabled: isBrowserCommandEnabled, }) commandManager.registerCommand({ command: "browser.reload", execute: executeCommandForLayer(browser => browser.reload()), name: "Browser: Reload", detail: "", enabled: isBrowserCommandEnabled, }) commandManager.registerCommand({ command: "browser.scrollDown", execute: executeCommandForLayer(browser => browser.scrollDown()), name: "Browser: Scroll Down", detail: "", enabled: isBrowserScrollCommandEnabled, }) commandManager.registerCommand({ command: "browser.scrollUp", execute: executeCommandForLayer(browser => browser.scrollUp()), name: "Browser: Scroll Up", detail: "", enabled: isBrowserScrollCommandEnabled, }) commandManager.registerCommand({ command: "browser.scrollLeft", execute: executeCommandForLayer(browser => browser.scrollLeft()), name: "Browser: Scroll Left", detail: "", enabled: isBrowserScrollCommandEnabled, }) commandManager.registerCommand({ command: "browser.scrollRight", execute: executeCommandForLayer(browser => browser.scrollRight()), name: "Browser: Scroll Right", detail: "", enabled: isBrowserScrollCommandEnabled, }) } 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, }, ], }) } ================================================ FILE: browser/src/Services/BrowserWindowConfigurationSynchronizer.ts ================================================ /** * BrowserWindowConfigurationSynchronizer * * Takes configuration settings, and applies them to the BrowserWindow */ import * as Color from "color" import { ipcRenderer, remote } from "electron" import { Colors } from "./Colors" import { Configuration, IConfigurationValues } from "./Configuration" import { addDefaultUnitIfNeeded } from "./../Font" export const activate = (configuration: Configuration, colors: Colors) => { const browserWindow = remote.getCurrentWindow() let loadInitVim: boolean = false const maximizeScreenOnStart: boolean = false const onColorsChanged = () => { // TODO: Read from 'persisted setting' instead const backgroundColor = colors.getColor("background") if (backgroundColor) { const background: string = Color(backgroundColor) .lighten(0.1) .hex() .toString() ;(browserWindow as any).setBackgroundColor(background) } } colors.onColorsChanged.subscribe(() => onColorsChanged()) const onConfigChanged = (newConfigValues: Partial) => { document.body.style.fontFamily = configuration.getValue("ui.fontFamily") document.body.style.fontSize = addDefaultUnitIfNeeded(configuration.getValue("ui.fontSize")) document.body.style.fontVariant = configuration.getValue("editor.fontLigatures") ? "normal" : "none" const fontSmoothing = configuration.getValue("ui.fontSmoothing") if (fontSmoothing) { document.body.style["-webkit-font-smoothing"] = fontSmoothing } const hideMenu: boolean | "hidden" = configuration.getValue("oni.hideMenu") if (hideMenu === "hidden") { browserWindow.setMenu(null) } else { browserWindow.setAutoHideMenuBar(hideMenu) browserWindow.setMenuBarVisibility(!hideMenu) } const loadInit: boolean = configuration.getValue("oni.loadInitVim") if (loadInit !== loadInitVim) { ipcRenderer.send("rebuild-menu", loadInit) // don't rebuild menu unless oni.loadInitVim actually changed loadInitVim = loadInit } const maximizeScreen: boolean = configuration.getValue("editor.maximizeScreenOnStart") if (maximizeScreen !== maximizeScreenOnStart) { browserWindow.maximize() } browserWindow.setFullScreen(configuration.getValue("editor.fullScreenOnStart")) } onConfigChanged(configuration.getValues()) configuration.onConfigurationChanged.subscribe(onConfigChanged) } ================================================ FILE: browser/src/Services/Colors.ts ================================================ /** * Colors * * - Rationalizes colors from both the active theme and configuration * - The 'source of truth' for colors in Oni * - Also will handle 'fallback logic' for colors */ import * as OniApi from "oni-api" import { Event, IDisposable, IEvent } from "oni-types" import { Configuration, IConfigurationValues } from "./Configuration" import * as PersistentSettings from "./Configuration/PersistentSettings" import { ThemeManager } from "./Themes" export interface ColorsDictionary { [colorName: string]: string } let _colors: Colors = null export const activate = (configuration: Configuration, themeManager: ThemeManager) => { _colors = new Colors(configuration, themeManager) } export const getInstance = (): Colors => { return _colors } export interface IColors extends OniApi.IColors { onColorsChanged: IEvent getColors(): any } export class Colors implements OniApi.IColors, IDisposable { private _subscriptions: IDisposable[] = [] private _colors: ColorsDictionary = {} private _onColorsChangedEvent: Event = new Event() public get onColorsChanged(): IEvent { return this._onColorsChangedEvent } constructor(private _configuration: Configuration, private _themeManager: ThemeManager) { const sub1 = this._themeManager.onThemeChanged.subscribe(() => { this._updateColorsFromConfig() }) const sub2 = this._configuration.onConfigurationChanged.subscribe( (newValues: Partial) => { const anyColorsChanged = Object.keys(newValues).filter( color => color.indexOf("colors.") >= 0, ) if (anyColorsChanged.length > 0) { this._updateColorsFromConfig() } }, ) this._subscriptions = [sub1, sub2] this._updateColorsFromConfig() } public getColors(): ColorsDictionary { return this._colors } public getColor(colorName: string): string | null { return this._colors[colorName] || null } public dispose(): void { if (this._subscriptions && this._subscriptions.length) { this._subscriptions.forEach(disposable => disposable.dispose()) this._subscriptions = null } } private _updateColorsFromConfig(): void { if (!this._themeManager.activeTheme) { return } const currentThemeColors = this._themeManager.getColors() this._colors = {} Object.keys(currentThemeColors).forEach(themeColor => { const configurationName = this._getConfigurationNameForColor(themeColor) const colorFromConfiguration = this._configuration.getValue(configurationName) this._colors[themeColor] = colorFromConfiguration ? colorFromConfiguration : currentThemeColors[themeColor] }) const lastBackgroundColor = this._colors.background || this._colors["editor.background"] || "#1E2127" PersistentSettings.set("_internal.lastBackgroundColor", lastBackgroundColor) this._onColorsChangedEvent.dispatch() } private _getConfigurationNameForColor(colorName: string): string { return "colors." + colorName } } ================================================ FILE: browser/src/Services/CommandManager.ts ================================================ /** * CommandManager.ts * * Manages Oni commands. These commands show up in the command palette, and are exposed to plugins. */ import * as values from "lodash/values" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { INeovimInstance } from "./../neovim" import { ITask, ITaskProvider } from "./Tasks" export class CallbackCommand implements Oni.Commands.ICommand { public messageSuccess?: string public messageFail?: string constructor( public command: string, public name: string, public detail: string, public execute: Oni.Commands.CommandCallback, public enabled?: Oni.Commands.CommandEnabledCallback, ) {} } export class VimCommand implements Oni.Commands.ICommand { constructor( public command: string, public name: string, public detail: string, private _vimCommand: string, private _neovimInstance: INeovimInstance, ) {} public execute(): void { this._neovimInstance.command(this._vimCommand) } } export class CommandManager implements ITaskProvider { private _commandDictionary: { [key: string]: Oni.Commands.ICommand } = {} public clearCommands(): void { this._commandDictionary = {} } // NOTE: Arrow function here preserves the "this" binding of this method public registerCommand = (command: Oni.Commands.ICommand): void => { if (this._commandDictionary[command.command]) { Log.verbose(`Overwriting existing command: ${command.command}`) } this._commandDictionary[command.command] = command } public hasCommand(commandName: string): boolean { return !!this._commandDictionary[commandName] } public unregisterCommand(commandName: string): void { delete this._commandDictionary[commandName] } public executeCommand(name: string, args?: any): boolean | void { const command = this._commandDictionary[name] if (!command) { return false } let enabled = true if (typeof command.enabled === "function") { enabled = command.enabled() } if (!enabled) { return false } if (!command) { Log.error(`Unable to find command: ${name}`) return false } return command.execute(args) } public getTasks(): Promise { const commands = values(this._commandDictionary).filter( (c: Oni.Commands.ICommand) => !c.enabled || c.enabled(), ) const tasks = commands.map(c => ({ name: c.name, detail: c.detail, command: c.command, messageSuccess: c.messageSuccess, messageFail: c.messageFail, callback: () => c.execute(), })) return Promise.resolve(tasks) } } export const commandManager = new CommandManager() ================================================ FILE: browser/src/Services/Commands/GlobalCommands.ts ================================================ /** * GlobalCommands.ts * * Built-in, general Oni commands, that are not specific * to an editor or service. */ import { remote } from "electron" import * as Oni from "oni-api" import { gotoNextError, gotoPreviousError } from "./../../Services/Diagnostics/navigateErrors" import { EditorManager } from "./../../Services/EditorManager" import { MenuManager } from "./../../Services/Menu" import { showAboutMessage } from "./../../Services/Metadata" import { multiProcess } from "./../../Services/MultiProcess" import { Tasks } from "./../../Services/Tasks" import { windowManager } from "./../../Services/WindowManager" // import * as UI from "./../UI/index" import { CallbackCommand, CommandManager } from "./../CommandManager" 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()) { return innerCommand() } return false } } const popupMenuClose = popupMenuCommand(() => menuManager.closeActiveMenu()) const popupMenuNext = popupMenuCommand(() => menuManager.nextMenuItem()) const popupMenuPrevious = popupMenuCommand(() => menuManager.previousMenuItem()) const popupMenuSelect = popupMenuCommand(() => menuManager.selectMenuItem()) const commands = [ new CallbackCommand("editor.executeVimCommand", null, null, (message: string) => { const { neovim } = editorManager.activeEditor if (message.startsWith(":")) { neovim.command('exec "' + message + '"') } else { neovim.command('exec ":normal! ' + message + '"') } }), new CallbackCommand("oni.about", null, null, () => showAboutMessage()), new CallbackCommand("oni.process.openWindow", "New Window", "Open a new window", () => multiProcess.openNewWindow(), ), new CallbackCommand("oni.editor.newFile", "Oni: Create new file", "Create a new file", () => editorManager.activeEditor.neovim.command("enew!"), ), new CallbackCommand( "oni.editor.maximize", "Oni: Maximize Window", "Maximize the current window", () => remote.getCurrentWindow().maximize(), ), new CallbackCommand( "oni.editor.minimize", "Oni: Minimize Window", "Minimize the current window", () => remote.getCurrentWindow().minimize(), ), new CallbackCommand("oni.editor.hide", "Hide Window", "Hide the current window", () => remote.app.hide(), ), new CallbackCommand( "oni.process.cycleNext", "Oni: Focus Next Oni", "Switch to the next running instance of Oni", () => multiProcess.focusNextInstance(), ), new CallbackCommand( "oni.process.cyclePrevious", "Oni: Focus Previous Oni", "Switch to the previous running instance of Oni", () => multiProcess.focusPreviousInstance(), ), new CallbackCommand("commands.show", null, null, () => tasks.show()), // Autocompletion // Menu new CallbackCommand("menu.close", null, null, popupMenuClose), new CallbackCommand("menu.next", null, null, popupMenuNext), new CallbackCommand("menu.previous", null, null, popupMenuPrevious), new CallbackCommand("menu.select", null, null, popupMenuSelect), // QuickOpen new CallbackCommand("window.moveLeft", null, null, () => windowManager.moveLeft()), new CallbackCommand("window.moveRight", null, null, () => windowManager.moveRight()), new CallbackCommand("window.moveDown", null, null, () => windowManager.moveDown()), new CallbackCommand("window.moveUp", null, null, () => windowManager.moveUp()), // Error list new CallbackCommand( "oni.editor.nextError", "Jump to next lint/compiler error", "Jump to the next error or warning from the linter or compiler", gotoNextError, ), new CallbackCommand( "oni.editor.previousError", "Jump to previous lint/compiler error", "Jump to the previous error or warning from the linter or compiler", gotoPreviousError, ), // Add additional commands here // ... ] // TODO: once implementations of this command work on all platforms, remove the exclusive check for OSX if (Platform.isMac()) { const addToPathCommand = new CallbackCommand( "oni.editor.removeFromPath", "Oni: Remove from PATH", "Disable executing 'oni' from terminal", Platform.removeFromPath, () => Platform.isAddedToPath(), ) addToPathCommand.messageSuccess = "Oni has been removed from the $PATH" const removeFromPathCommand = new CallbackCommand( "oni.editor.addToPath", "Oni: Add to PATH", "Enable executing 'oni' from terminal", Platform.addToPath, () => !Platform.isAddedToPath(), ) removeFromPathCommand.messageSuccess = "Oni has been added to the $PATH" commands.push(addToPathCommand) commands.push(removeFromPathCommand) } commands.forEach(c => commandManager.registerCommand(c)) } ================================================ FILE: browser/src/Services/Commands/index.ts ================================================ export * from "./GlobalCommands" ================================================ FILE: browser/src/Services/Completion/Completion.ts ================================================ /** * Completion.ts */ import * as Oni from "oni-api" import { Event, IDisposable, IEvent } from "oni-types" import { Store, Unsubscribe } from "redux" import * as types from "vscode-languageserver-types" import { LanguageManager } from "./../Language" import { SnippetManager } from "./../Snippets" import { ISyntaxHighlighter } from "./../SyntaxHighlighting" import { getFilteredCompletions } from "./CompletionSelectors" import { ICompletionsRequestor } from "./CompletionsRequestor" import { ICompletionState } from "./CompletionState" import { createStore } from "./CompletionStore" import { Configuration } from "./../Configuration" export interface ICompletionShowEventArgs { filteredCompletions: types.CompletionItem[] base: string } export class Completion implements IDisposable { private _lastCursorPosition: Oni.Cursor private _store: Store private _storeUnsubscribe: Unsubscribe = null private _subscriptions: IDisposable[] private _onShowCompletionItemsEvent: Event = new Event< ICompletionShowEventArgs >() private _onHideCompletionItemsEvent: Event = new Event() public get onShowCompletionItems(): IEvent { return this._onShowCompletionItemsEvent } public get onHideCompletionItems(): IEvent { return this._onHideCompletionItemsEvent } constructor( private _editor: Oni.Editor, private _configuration: Configuration, private _completionsRequestor: ICompletionsRequestor, private _languageManager: LanguageManager, private _snippetManager: SnippetManager, private _syntaxHighlighter: ISyntaxHighlighter, ) { this._completionsRequestor = this._completionsRequestor this._store = createStore( this._editor, this._languageManager, this._configuration, this._completionsRequestor, this._snippetManager, this._syntaxHighlighter, ) const sub1 = this._editor.onBufferEnter.subscribe((buf: Oni.Buffer) => { this._onBufferEnter(buf) }) const sub2 = this._editor.onBufferChanged.subscribe( (buf: Oni.EditorBufferChangedEventArgs) => { this._onBufferUpdate(buf) }, ) const sub3 = this._editor.onModeChanged.subscribe((newMode: string) => { this._onModeChanged(newMode) }) const sub4 = this._editor.onCursorMoved.subscribe((cursor: Oni.Cursor) => { this._onCursorMoved(cursor) }) this._subscriptions = [sub1, sub2, sub3, sub4] this._storeUnsubscribe = this._store.subscribe(() => this._onStateChanged(this._store.getState()), ) } public resolveItem(completionItem: types.CompletionItem): void { this._store.dispatch({ type: "GET_COMPLETION_ITEM_DETAILS", completionItem, }) } public commitItem(completionItem: types.CompletionItem): void { const state = this._store.getState() this._store.dispatch({ type: "COMMIT_COMPLETION", meetLine: state.meetInfo.meetLine, meetPosition: state.meetInfo.meetPosition, completion: completionItem, }) } public dispose(): void { if (this._subscriptions) { this._subscriptions.forEach(disposable => disposable.dispose()) this._subscriptions = null } if (this._storeUnsubscribe) { this._storeUnsubscribe() this._storeUnsubscribe = null } } private _onStateChanged(newState: ICompletionState): void { const filteredCompletions = getFilteredCompletions(newState) if (filteredCompletions && filteredCompletions.length) { this._onShowCompletionItemsEvent.dispatch({ filteredCompletions, base: newState.meetInfo.meetBase, }) } else { this._onHideCompletionItemsEvent.dispatch() } } private _onCursorMoved(cursor: Oni.Cursor): void { this._lastCursorPosition = cursor } private _onBufferEnter(buffer: Oni.Buffer): void { this._store.dispatch({ type: "BUFFER_ENTER", language: buffer.language, filePath: buffer.filePath, bufferId: buffer.id, }) } private _onBufferUpdate(bufferUpdate: Oni.EditorBufferChangedEventArgs): void { // Ignore if this is a full update const firstChange = bufferUpdate.contentChanges[0] if (!firstChange || !firstChange.range) { return } const range = firstChange.range // We only work with single line changes, for now. // Perhaps we could get the latest line by querying the activeBuffer // from cursorMoved, but right now, the update comes _after_ // the cursorMoved event - so this is the most reliable way. if (range.start.line + 1 !== range.end.line) { return } const newLine = firstChange.text if (this._lastCursorPosition && range.start.line === this._lastCursorPosition.line) { this._store.dispatch({ type: "CURSOR_MOVED", line: this._lastCursorPosition.line, column: this._lastCursorPosition.column, lineContents: newLine, }) } } private async _onModeChanged(newMode: string): Promise { if (newMode === "insert" && this._lastCursorPosition) { const [latestLine] = await this._editor.activeBuffer.getLines( this._lastCursorPosition.line, this._lastCursorPosition.line + 1, ) this._store.dispatch({ type: "CURSOR_MOVED", line: this._lastCursorPosition.line, column: this._lastCursorPosition.column, lineContents: latestLine, }) } this._store.dispatch({ type: "MODE_CHANGED", mode: newMode, }) } } ================================================ FILE: browser/src/Services/Completion/CompletionProviders.ts ================================================ /** * CompletionProviders.ts */ import * as types from "vscode-languageserver-types" import { LanguageManager } from "./../Language" import { CompletionsRequestContext, ICompletionsRequestor, LanguageServiceCompletionsRequestor, } from "./CompletionsRequestor" export interface ICompletionProviderInfo { id: string provider: ICompletionsRequestor } export interface ICompletionInfoWithProvider extends types.CompletionItem { __provider: string } export class CompletionProviders implements ICompletionsRequestor { private _completionProviders: ICompletionProviderInfo[] = [] public registerCompletionProvider(id: string, provider: ICompletionsRequestor): void { this._completionProviders.push({ id, provider, }) } public async getCompletions( context: CompletionsRequestContext, ): Promise { const completionItemsPromise = this._completionProviders.map(async prov => { const items = (await prov.provider.getCompletions(context)) || [] // Tag the items with the provider id, so we know who to ask for details const augmentedItems = items.map(item => { return { ...item, __provider: prov.id, } }) return augmentedItems }) const allItems = await Promise.all(completionItemsPromise) const flattenedItems = allItems.reduce( (prev: ICompletionInfoWithProvider[], current: ICompletionInfoWithProvider[]) => { return [...prev, ...current] }, [] as ICompletionInfoWithProvider[], ) return flattenedItems } public async getCompletionDetails( language: string, filePath: string, completionItem: ICompletionInfoWithProvider, ): Promise { if (completionItem && completionItem.__provider) { const prov = this._getProviderById(completionItem.__provider) if (prov && prov.getCompletionDetails) { return prov.getCompletionDetails(language, filePath, completionItem) } } return completionItem } private _getProviderById(id: string): ICompletionsRequestor { const providersMatchingId = this._completionProviders.filter(prov => prov.id === id) return providersMatchingId.length > 0 ? providersMatchingId[0].provider : null } } let _completionProviders: CompletionProviders export const activate = (languageManager: LanguageManager) => { _completionProviders = new CompletionProviders() const languageServiceCompletion = new LanguageServiceCompletionsRequestor(languageManager) _completionProviders.registerCompletionProvider( "oni.completions.language-server", languageServiceCompletion, ) } export const getInstance = (): CompletionProviders => { return _completionProviders } ================================================ FILE: browser/src/Services/Completion/CompletionSelectors.ts ================================================ /** * CompletionSelectors.ts * * Selectors are functions that take a state and derive a value from it. */ import * as isEqual from "lodash/isEqual" import * as omit from "lodash/omit" import { ICompletionState } from "./CompletionState" import * as types from "vscode-languageserver-types" const EmptyCompletions: types.CompletionItem[] = [] import * as CompletionUtility from "./CompletionUtility" export const getFilteredCompletions = (state: ICompletionState): types.CompletionItem[] => { if (!state.completionResults.completions || !state.completionResults.completions.length) { return EmptyCompletions } if (!state.meetInfo.shouldExpand) { return EmptyCompletions } // If the completions were for a different meet line/position, we probably // shouldn't show them... if ( state.meetInfo.meetLine !== state.completionResults.meetLine || state.meetInfo.meetPosition !== state.completionResults.meetPosition ) { return EmptyCompletions } // If we had previously accepted this completion, don't show it either if ( state.meetInfo.meetLine === state.lastCompletionInfo.meetLine && state.meetInfo.meetPosition === state.lastCompletionInfo.meetPosition && state.meetInfo.meetBase === CompletionUtility.getInsertText(state.lastCompletionInfo.completion) ) { return EmptyCompletions } const completions = state.completionResults.completions const filteredCompletions = filterCompletionOptions(completions, state.meetInfo.meetBase) if (!filteredCompletions || !filteredCompletions.length) { return EmptyCompletions } // If there is only one element, and it matches our base, // don't bother showing it.. if ( CompletionUtility.getInsertText(filteredCompletions[0]) === state.meetInfo.meetBase && filteredCompletions.length === 1 ) { return EmptyCompletions } return filteredCompletions } export const filterCompletionOptions = ( items: types.CompletionItem[], searchText: string, ): types.CompletionItem[] => { if (!searchText) { return items } if (!items || !items.length) { return null } // Must start with first letter in searchText, and then be at least abbreviated by searchText. const filterRegEx = new RegExp("^" + searchText.split("").join(".*") + ".*") const filteredOptions = items.filter(f => { const textToFilterOn = f.filterText || f.label return textToFilterOn.match(filterRegEx) }) const sortedOptions = filteredOptions.sort((itemA, itemB) => { const itemASortText = itemA.filterText || itemA.label const itemBSortText = itemB.filterText || itemB.label const indexOfA = itemASortText.indexOf(searchText) const indexOfB = itemBSortText.indexOf(searchText) // Ensure abbreviated matches are sorted below exact matches. if (indexOfA >= 0 && indexOfB === -1) { return -1 } else if (indexOfA === -1 && indexOfB >= 0) { return 1 // Else sort by label to keep related results together. } else if (itemASortText < itemBSortText) { return -1 } else if (itemASortText > itemBSortText) { return 1 // Fallback to sort by language server specified sortText. } else if (itemA.sortText < itemB.sortText) { return -1 } else if (itemA.sortText > itemB.sortText) { return 1 } return 0 }) // Language servers can return duplicate entries (e.g. cquery). const uniqueOptions: types.CompletionItem[] = _uniq(sortedOptions) return uniqueOptions } /** * Get unique completion items, assuming they're sorted so duplicates are contiguous. * * Adapted from https://github.com/lodash/lodash/blob/master/.internal/baseSortedUniq.js, since * lodash has no `sortedUniqWith` function. */ const _uniq = (array: types.CompletionItem[]) => { let seenReduced: any let index = -1 let resIndex = 0 const { length } = array const result = [] while (++index < length) { const value = array[index] // Omit the `sortText` which can be different even if all other attributes are the same. const reduced = omit(value, "sortText") if (!index || !isEqual(reduced, seenReduced)) { seenReduced = reduced result[resIndex++] = value } } return result } ================================================ FILE: browser/src/Services/Completion/CompletionState.ts ================================================ /** * CompletionStore.ts */ import * as types from "vscode-languageserver-types" export interface ICompletionState { enabled: boolean cursorInfo: ICursorInfo bufferInfo: ICompletionBufferInfo meetInfo: ICompletionMeetInfo completionResults: ICompletionResults lastCompletionInfo: ILastCompletionInfo } export interface ICompletionMeetInfo { meetLine: number meetPosition: number queryPosition: number meetBase: string shouldExpand: boolean textMateScopes: string[] } export const DefaultMeetInfo: ICompletionMeetInfo = { meetLine: -1, meetPosition: -1, queryPosition: -1, meetBase: "", shouldExpand: false, textMateScopes: [], } export interface ICompletionBufferInfo { language: string filePath: string bufferId: string } export const DefaultCompletionBufferInfo: ICompletionBufferInfo = { language: null, filePath: null, bufferId: null, } export interface ILastCompletionInfo { meetLine: number meetPosition: number completion: types.CompletionItem } export const DefaultLastCompletionInfo: ILastCompletionInfo = { meetLine: -1, meetPosition: -1, completion: null, } export interface ICompletionResults { completions: types.CompletionItem[] meetLine: number meetPosition: number } export const DefaultCompletionResults: ICompletionResults = { completions: [], meetLine: -1, meetPosition: -1, } export interface ICursorInfo { line: number column: number lineContents: string } export const DefaultCursorInfo: ICursorInfo = { line: -1, column: -1, lineContents: "", } export const DefaultCompletionState: ICompletionState = { enabled: false, cursorInfo: DefaultCursorInfo, bufferInfo: DefaultCompletionBufferInfo, meetInfo: DefaultMeetInfo, completionResults: DefaultCompletionResults, lastCompletionInfo: DefaultLastCompletionInfo, } ================================================ FILE: browser/src/Services/Completion/CompletionStore.ts ================================================ /** * CompletionStore.ts */ import * as types from "vscode-languageserver-types" import * as Oni from "oni-api" import "rxjs/add/operator/mergeMap" import { Observable } from "rxjs/Observable" import { combineReducers, Reducer, Store } from "redux" import { combineEpics, createEpicMiddleware, Epic } from "redux-observable" import { createStore as oniCreateStore } from "./../../Redux" import { Configuration } from "./../Configuration" import { LanguageManager } from "./../Language" import { SnippetManager } from "./../Snippets" import { ISyntaxHighlighter } from "./../SyntaxHighlighting" import * as CompletionSelects from "./CompletionSelectors" import { ICompletionsRequestor } from "./CompletionsRequestor" import * as CompletionUtility from "./CompletionUtility" import { DefaultCompletionResults, DefaultCompletionState, DefaultCursorInfo, DefaultLastCompletionInfo, DefaultMeetInfo, ICompletionBufferInfo, ICompletionMeetInfo, ICompletionResults, ICompletionState, ICursorInfo, ILastCompletionInfo, } from "./CompletionState" export type CompletionAction = | { type: "CURSOR_MOVED" line: number column: number lineContents: string } | { type: "MODE_CHANGED" mode: string } | { type: "BUFFER_ENTER" language: string filePath: string bufferId: string } | { type: "COMMIT_COMPLETION" meetBase: string meetLine: number meetPosition: number completion: types.CompletionItem } | { type: "MEET_CHANGED" currentMeet: ICompletionMeetInfo } | { type: "GET_COMPLETIONS_RESULT" meetLine: number meetPosition: number completions: types.CompletionItem[] } | { type: "GET_COMPLETION_ITEM_DETAILS" completionItem: types.CompletionItem } | { type: "GET_COMPLETION_ITEM_DETAILS_RESULT" completionItemWithDetails: types.CompletionItem } const bufferInfoReducer: Reducer = ( state: ICompletionBufferInfo = { language: null, filePath: null, bufferId: null, }, action: CompletionAction, ) => { switch (action.type) { case "BUFFER_ENTER": return { language: action.language, filePath: action.filePath, bufferId: action.bufferId, } default: return state } } const meetInfoReducer: Reducer = ( state: ICompletionMeetInfo = DefaultMeetInfo, action: CompletionAction, ) => { switch (action.type) { case "MODE_CHANGED": return DefaultMeetInfo case "MEET_CHANGED": return { ...action.currentMeet, } default: return state } } export const completionResultsReducer: Reducer = ( state: ICompletionResults = DefaultCompletionResults, action: CompletionAction, ) => { switch (action.type) { case "MODE_CHANGED": case "BUFFER_ENTER": return DefaultCompletionResults case "GET_COMPLETIONS_RESULT": return { meetLine: action.meetLine, meetPosition: action.meetPosition, completions: action.completions, } case "GET_COMPLETION_ITEM_DETAILS_RESULT": return { ...state, completions: state.completions.map(completion => { // Prefer `detail` field if available, to avoid splatting e.g. methods with // the same name but different signature. if (completion.detail && action.completionItemWithDetails.detail) { if (completion.detail === action.completionItemWithDetails.detail) { return action.completionItemWithDetails } else { return completion } } if (completion.label === action.completionItemWithDetails.label) { return action.completionItemWithDetails } else { return completion } }), } default: return state } } export const cursorInfoReducer: Reducer = ( state: ICursorInfo = DefaultCursorInfo, action: CompletionAction, ) => { switch (action.type) { case "CURSOR_MOVED": return { line: action.line, lineContents: action.lineContents, column: action.column, } default: return state } } export const enabledReducer: Reducer = ( state: boolean = false, action: CompletionAction, ) => { switch (action.type) { case "MODE_CHANGED": return action.mode === "insert" default: return state } } export const lastCompletionInfoReducer: Reducer = ( state: ILastCompletionInfo = DefaultLastCompletionInfo, action: CompletionAction, ) => { switch (action.type) { case "MODE_CHANGED": case "BUFFER_ENTER": return DefaultLastCompletionInfo case "COMMIT_COMPLETION": return { meetLine: action.meetLine, meetPosition: action.meetPosition, completion: action.completion, } default: return state } } const nullAction: CompletionAction = { type: null } as CompletionAction const createGetCompletionMeetEpic = ( languageManager: LanguageManager, configuration: Configuration, syntaxHighlighter: ISyntaxHighlighter, ): Epic => (action$, store) => action$ .ofType("CURSOR_MOVED") .filter( () => configuration.getValue("editor.completions.mode") === "oni" && configuration.getValue("editor.completions.enabled") !== false, ) .auditTime(10) .map((action: CompletionAction) => { const currentState: ICompletionState = store.getState() if (!currentState.enabled) { return nullAction } if (!currentState.bufferInfo || !currentState.bufferInfo.language) { return nullAction } if (!currentState.cursorInfo || !currentState.cursorInfo.lineContents) { return nullAction } const { bufferInfo } = currentState const token = languageManager.getTokenRegex(bufferInfo.language) const completionCharacters = languageManager.getCompletionTriggerCharacters( bufferInfo.language, ) const meet = CompletionUtility.getCompletionMeet( currentState.cursorInfo.lineContents, currentState.cursorInfo.column, token, completionCharacters, ) const highlightInfo = syntaxHighlighter.getHighlightTokenAt( currentState.bufferInfo.bufferId, types.Position.create(currentState.cursorInfo.line, meet.positionToQuery), ) const scopes = highlightInfo && highlightInfo.scopes ? highlightInfo.scopes : [] const meetForAction: ICompletionMeetInfo = { meetPosition: meet.position, meetLine: currentState.cursorInfo.line, queryPosition: meet.positionToQuery, meetBase: meet.base, shouldExpand: meet.shouldExpandCompletions, textMateScopes: scopes, } return { type: "MEET_CHANGED", currentMeet: meetForAction, } as CompletionAction }) const commitCompletionEpic = ( editor: Oni.Editor, snippetManager: SnippetManager, ): Epic => (action$, store) => action$ .ofType("COMMIT_COMPLETION") .do(async (action: CompletionAction) => { if (action.type !== "COMMIT_COMPLETION") { return } await CompletionUtility.commitCompletion( editor.activeBuffer, action.meetLine, action.meetPosition, action.completion, snippetManager, ) }) .map(_ => nullAction) const createGetCompletionsEpic = ( completionsRequestor: ICompletionsRequestor, ): Epic => (action$, store) => action$ .ofType("MEET_CHANGED") .filter(() => store.getState().enabled) .filter(action => { const state = store.getState() if (action.type !== "MEET_CHANGED") { return false } if (!action.currentMeet.shouldExpand) { return false } if ( action.currentMeet.meetLine === state.completionResults.meetLine && action.currentMeet.meetPosition === state.completionResults.meetPosition ) { return false } return true }) .switchMap((action: CompletionAction): Observable => { const state = store.getState() // Helper to let TypeScript know that we can assume this is 'MEET_CHANGED'... if (action.type !== "MEET_CHANGED") { return Observable.of(nullAction) } if (!state.enabled) { return Observable.of(nullAction) } // Check if the meet is different from the last meet we queried const requestResult: Observable = Observable.defer(async () => { const results = await completionsRequestor.getCompletions({ language: state.bufferInfo.language, filePath: state.bufferInfo.filePath, line: action.currentMeet.meetLine, column: action.currentMeet.queryPosition, meetCharacter: action.currentMeet.meetBase, textMateScopes: action.currentMeet.textMateScopes, }) const completions = results || [] const orderedCompletions = orderCompletions( completions, action.currentMeet.meetBase, ) return orderedCompletions }) const ret = requestResult.map(completions => { return { type: "GET_COMPLETIONS_RESULT", meetLine: action.currentMeet.meetLine, meetPosition: action.currentMeet.meetPosition, completions, } as CompletionAction }) return ret }) export const orderCompletions = ( completions: types.CompletionItem[], base: string, ): types.CompletionItem[] => { if (!completions || !completions.length) { return completions } const anyCompletionsMatchCurrentBase = completions.find( item => CompletionUtility.getInsertText(item) === base, ) if (!anyCompletionsMatchCurrentBase) { return completions } const filteredCompletions = completions.filter(item => item !== anyCompletionsMatchCurrentBase) const ret = [anyCompletionsMatchCurrentBase, ...filteredCompletions] return ret } const createGetCompletionDetailsEpic = ( completionsRequestor: ICompletionsRequestor, ): Epic => (action$, store) => action$.ofType("GET_COMPLETION_ITEM_DETAILS").switchMap(action => { if (action.type !== "GET_COMPLETION_ITEM_DETAILS") { return Observable.of(nullAction) } return Observable.defer(async () => { const state = store.getState() const result = await completionsRequestor.getCompletionDetails( state.bufferInfo.language, state.bufferInfo.filePath, action.completionItem, ) return result }).map((itemResult: types.CompletionItem) => { if (itemResult) { return { type: "GET_COMPLETION_ITEM_DETAILS_RESULT", completionItemWithDetails: itemResult, } as CompletionAction } else { return nullAction } }) }) const selectFirstItemEpic: Epic = (action$, store) => action$.ofType("GET_COMPLETIONS_RESULT").map(action => { if (action.type !== "GET_COMPLETIONS_RESULT") { return nullAction } const state = store.getState() const filteredItems = CompletionSelects.filterCompletionOptions( action.completions, state.meetInfo.meetBase, ) if (!filteredItems || !filteredItems.length) { return nullAction } return { type: "GET_COMPLETION_ITEM_DETAILS", completionItem: filteredItems[0], } as CompletionAction }) export const createStore = ( editor: Oni.Editor, languageManager: LanguageManager, configuration: Configuration, completionsRequestor: ICompletionsRequestor, snippetManager: SnippetManager, syntaxHighlighter: ISyntaxHighlighter, ): Store => { return oniCreateStore( "COMPLETION_STORE", combineReducers({ enabled: enabledReducer, bufferInfo: bufferInfoReducer, meetInfo: meetInfoReducer, completionResults: completionResultsReducer, lastCompletionInfo: lastCompletionInfoReducer, cursorInfo: cursorInfoReducer, }), DefaultCompletionState, [ createEpicMiddleware( combineEpics( commitCompletionEpic(editor, snippetManager), createGetCompletionMeetEpic(languageManager, configuration, syntaxHighlighter), createGetCompletionsEpic(completionsRequestor), createGetCompletionDetailsEpic(completionsRequestor), selectFirstItemEpic, ), ), ], ) } ================================================ FILE: browser/src/Services/Completion/CompletionUtility.ts ================================================ /** * CompletionUtility.ts * * Helper functions for auto completion */ import * as Oni from "oni-api" import * as types from "vscode-languageserver-types" import { SnippetManager } from "./../Snippets" export const commitCompletion = async ( buffer: Oni.Buffer, line: number, base: number, completion: types.CompletionItem, snippetManager?: SnippetManager, ) => { const currentLines = await buffer.getLines(line, line + 1) const column = buffer.cursor.column if (!currentLines || !currentLines.length) { return } const originalLine = currentLines[0] const isSnippet = completion.insertTextFormat === types.InsertTextFormat.Snippet && snippetManager // If it's a snippet, we don't insert any text - we'll let the insert manager handle that. const textToReplace = isSnippet ? "" : getInsertText(completion) const newLine = replacePrefixWithCompletion(originalLine, base, column, textToReplace) await buffer.setLines(line, line + 1, [newLine]) const cursorOffset = newLine.length - originalLine.length await buffer.setCursorPosition(line, column + cursorOffset) if (isSnippet) { await snippetManager.insertSnippet(completion.insertText) } } export function getCompletionStart( bufferLine: string, cursorColumn: number, completion: string, ): number { cursorColumn = Math.min(cursorColumn, bufferLine.length) let x = cursorColumn while (x >= 0) { const subWord = bufferLine.substring(x, cursorColumn + 1) if (completion.indexOf(subWord) === -1) { break } x-- } return x + 1 } export const getInsertText = (completionItem: types.CompletionItem): string => { return completionItem.insertText || completionItem.label } export function replacePrefixWithCompletion( bufferLine: string, basePosition: number, cursorColumn: number, completion: string, ): string { const startPosition = basePosition const before = bufferLine.substring(0, startPosition) const after = bufferLine.substring(cursorColumn, bufferLine.length) return before + completion + after } export interface CompletionMeetResult { // Position - where the meet starts position: number // PositionToQuery - where the query request should start positionToQuery: number // Base - the currentg prefix of the completion base: string // Whether or not completiosn should be expanded / queriried shouldExpandCompletions: boolean } export const doesCharacterMatchTriggerCharacters = ( character: string, triggerCharacters: string[], ): boolean => { return triggerCharacters.indexOf(character) >= 0 } /** * Returns the start of the 'completion meet' along with the current base for completion */ export function getCompletionMeet( line: string, cursorColumn: number, characterMatchRegex: RegExp, completionTriggerCharacters: string[], ): CompletionMeetResult { // Clamp column to within string bounds let col = Math.max(cursorColumn - 1, 0) col = Math.min(col, line.length - 1) let currentPrefix = "" while (col >= 0 && col < line.length) { const currentCharacter = line[col] if ( !currentCharacter.match(characterMatchRegex) || doesCharacterMatchTriggerCharacters(currentCharacter, completionTriggerCharacters) ) { break } currentPrefix = currentCharacter + currentPrefix col-- } const basePos = col + 1 const isFromTriggerCharacter = doesCharacterMatchTriggerCharacters( line[basePos - 1], completionTriggerCharacters, ) const isCharacterAfterCursor = cursorColumn < line.length && line[cursorColumn].match(characterMatchRegex) const shouldExpandCompletions = (currentPrefix.length > 0 || isFromTriggerCharacter) && !isCharacterAfterCursor const positionToQuery = isFromTriggerCharacter ? basePos : basePos + 1 return { position: basePos, positionToQuery, base: currentPrefix, shouldExpandCompletions, } } export const convertKindToIconName = (completionKind: types.CompletionItemKind) => { switch (completionKind) { case types.CompletionItemKind.Class: return "cube" case types.CompletionItemKind.Color: return "paint-brush" case types.CompletionItemKind.Constructor: return "building" case types.CompletionItemKind.Enum: return "sitemap" case types.CompletionItemKind.Field: return "var" case types.CompletionItemKind.File: return "file" case types.CompletionItemKind.Function: return "cog" case types.CompletionItemKind.Interface: return "plug" case types.CompletionItemKind.Keyword: return "key" case types.CompletionItemKind.Method: return "flash" case types.CompletionItemKind.Module: return "cubes" case types.CompletionItemKind.Property: return "wrench" case types.CompletionItemKind.Reference: return "chain" case types.CompletionItemKind.Snippet: return "align-justify" case types.CompletionItemKind.Text: return "align-justify" case types.CompletionItemKind.Unit: return "tag" case types.CompletionItemKind.Value: return "lock" case types.CompletionItemKind.Variable: return "code" default: return "question" } } ================================================ FILE: browser/src/Services/Completion/CompletionsRequestor.ts ================================================ /** * CompletionsRequestor.ts * * Abstraction over the action of requesting completions */ import * as types from "vscode-languageserver-types" import * as Log from "oni-core-logging" import * as Helpers from "./../../Plugins/Api/LanguageClient/LanguageClientHelpers" import { LanguageManager } from "./../Language" import { IServerCapabilities } from "./../Language/ServerCapabilities" export interface CompletionsRequestContext { language: string filePath: string line: number column: number meetCharacter: string textMateScopes: string[] } export interface ICompletionsRequestor { getCompletions(completionContext: CompletionsRequestContext): Promise getCompletionDetails( fileLanguage: string, filePath: string, completionItem: types.CompletionItem, ): Promise } export class LanguageServiceCompletionsRequestor implements ICompletionsRequestor { constructor(private _languageManager: LanguageManager) {} public async getCompletions( context: CompletionsRequestContext, ): Promise { if (Log.isDebugLoggingEnabled()) { Log.debug( `[COMPLETION] Requesting completions at line ${context.line} and character ${ context.column }`, ) } const args = { textDocument: { uri: Helpers.wrapPathInFileUri(context.filePath), }, position: { line: context.line, character: context.column, }, } let result = null try { result = await this._languageManager.sendLanguageServerRequest( context.language, context.filePath, "textDocument/completion", args, ) } catch (ex) { Log.verbose(ex) } if (!result) { return null } const items = getCompletionItems(result) if (!items) { return null } if (Log.isDebugLoggingEnabled()) { Log.debug(`[COMPLETION] Got completions: ${items.length}`) } return items } public async getCompletionDetails( language: string, filePath: string, completionItem: types.CompletionItem, ): Promise { const caps: IServerCapabilities = await this._languageManager.getCapabilitiesForLanguage( language, ) if (!caps.completionProvider.resolveProvider) { return completionItem } let result try { result = await this._languageManager.sendLanguageServerRequest( language, filePath, "completionItem/resolve", completionItem, ) } catch (ex) { Log.verbose(ex) } if (!result) { return null } return result } } const getCompletionItems = ( items: types.CompletionItem[] | types.CompletionList, ): types.CompletionItem[] => { if (!items) { return [] } if (Array.isArray(items)) { return items } else { return items.items || [] } } ================================================ FILE: browser/src/Services/Completion/index.ts ================================================ export * from "./Completion" export * from "./CompletionProviders" export * from "./CompletionsRequestor" export * from "./CompletionUtility" ================================================ FILE: browser/src/Services/Configuration/Configuration.ts ================================================ /** * Configuration.ts */ import { merge } from "lodash" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { Event, IDisposable, IEvent } from "oni-types" import { applyDefaultKeyBindings } from "./../../Input/KeyBindings" import * as Performance from "./../../Performance" import { diff } from "./../../Utility" import { IConfigurationEditor, JavaScriptConfigurationEditor } from "./ConfigurationEditor" import { DefaultConfiguration } from "./DefaultConfiguration" import { checkDeprecatedSettings } from "./DeprecatedConfigurationValues" import { FileConfigurationProvider } from "./FileConfigurationProvider" import { IConfigurationValues } from "./IConfigurationValues" import { PersistedConfiguration } from "./PersistentSettings" import * as UserConfiguration from "./UserConfiguration" export interface IConfigurationProvider { onConfigurationChanged: IEvent onConfigurationError: IEvent getValues(): GenericConfigurationValues getLastError(): Error | null activate(api: Oni.Plugin.Api): void deactivate(): void } export interface GenericConfigurationValues { [configKey: string]: any } 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 */ export interface IPersistedConfiguration { getPersistedValues(): GenericConfigurationValues setPersistedValues(configurationValues: GenericConfigurationValues): void } export interface IConfigurationUpdateEvent { requiresReload: boolean } export class Configuration implements Oni.Configuration { private _configurationProviders: IConfigurationProvider[] = [] private _onConfigurationChangedEvent: Event> = new Event< Partial >() 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() } public get onConfigurationError(): IEvent { return this._onConfigurationErrorEvent } public get onConfigurationChanged(): IEvent> { return this._onConfigurationChangedEvent } public get onConfigurationUpdated(): IEvent { return this._onConfigurationUpdatedEvent } constructor( private _defaultConfiguration: GenericConfigurationValues = DefaultConfiguration, private _persistedConfiguration: IPersistedConfiguration = new PersistedConfiguration(), ) { this._updateConfig() } public start(): void { Performance.mark("Config.load.start") this.addConfigurationFile(UserConfiguration.getUserConfigFilePath()) 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 } public addConfigurationFile(filePath: string): void { Log.info("[Configuration] Adding file: " + filePath) const fp = new FileConfigurationProvider(filePath) this.addConfigurationProvider(fp) this._fileToProvider[filePath] = fp } public removeConfigurationFile(filePath: string): void { Log.info("[Configuration] Removing file: " + filePath) const configProvider = this._fileToProvider[filePath] if (configProvider) { this.removeConfigurationProvider(configProvider) this._fileToProvider[filePath] = null } } public getErrors(): Error[] { return this._configurationProviders.map(cfp => cfp.getLastError()) } public addConfigurationProvider(configurationProvider: IConfigurationProvider): void { this._configurationProviders.push(configurationProvider) const d1 = configurationProvider.onConfigurationChanged.subscribe(() => { Log.info("[Configuration] Got update.") this._updateConfig() }) const d2 = configurationProvider.onConfigurationError.subscribe(error => { this._onConfigurationErrorEvent.dispatch(error) }) this._configProviderInfo.set(configurationProvider, { disposables: [d1, d2], }) this._updateConfig() } public removeConfigurationProvider(configurationProvider: IConfigurationProvider): void { this._configurationProviders = this._configurationProviders.filter( prov => prov !== configurationProvider, ) const configurationInfo = this._configProviderInfo.get(configurationProvider) configurationInfo.disposables.forEach(dispose => dispose.dispose()) this._configProviderInfo.delete(configurationProvider) this._updateConfig() } public hasValue(configValue: keyof IConfigurationValues): boolean { return !!this.getValue(configValue) } public setValue(valueName: string, value: any, persist: boolean = false): void { return this.setValues({ [valueName]: value }, persist) } public setValues(configValues: { [configValue: string]: any }, persist: boolean = false): void { this._setValues = configValues const oldValues = { ...this._config, } this._config = { ...this._config, ...configValues, } if (persist) { this._persistedConfiguration.setPersistedValues(configValues) } this._onConfigurationChangedEvent.dispatch(configValues) this._notifySubscribers(oldValues, this._config, Object.keys(configValues)) } public getValue(configValue: K, defaultValue?: any) { const v = this._config[configValue] const valueExists = v !== undefined return valueExists ? v : defaultValue } public getValues(): GenericConfigurationValues { return { ...this._config } } public activate(oni: Oni.Plugin.Api): void { this._oniApi = oni 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 let currentConfig = merge( this._defaultConfiguration, this._persistedConfiguration.getPersistedValues(), this._setValues, ) this._configurationProviders.forEach(configProvider => { const configurationValues = configProvider.getValues() currentConfig = { ...currentConfig, ...configurationValues } }) this._config = currentConfig checkDeprecatedSettings(this._config) this._deactivate() this._activateIfOniObjectIsAvailable() this._notifyListeners(previousConfig) } private _activateIfOniObjectIsAvailable(): void { if (this._oniApi) { applyDefaultKeyBindings(this._oniApi, this) this._configurationProviders.forEach(configurationProvider => configurationProvider.activate(this._oniApi), ) } } private _deactivate(): void { this._configurationProviders.forEach(configurationProvider => configurationProvider.deactivate(), ) if (this._config && this._config.deactivate) { this._config.deactivate() } } private _notifyListeners(previousConfig?: Partial): void { previousConfig = previousConfig || {} const changedValues = diff(this._config, previousConfig) const diffObject = changedValues.reduce( (previous: Partial, current: string) => { const currentValue = this._config[current] // Skip functions, because those will always be different if (currentValue && typeof currentValue === "function") { return previous } return { ...previous, [current]: this._config[current], } }, {}, ) 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 }) } } export const configuration = new Configuration() ================================================ FILE: browser/src/Services/Configuration/ConfigurationCommands.ts ================================================ /** * ConfigurationCommands */ import { CommandManager } from "./../CommandManager" import { EditorManager } from "./../EditorManager" import { Configuration } from "./Configuration" import { ConfigurationEditManager } from "./ConfigurationEditor" import { getUserConfigFilePath } from "./index" export const activate = ( commandManager: CommandManager, configuration: Configuration, editorManager: EditorManager, ) => { const configurationEditManager = new ConfigurationEditManager(configuration, editorManager) commandManager.registerCommand({ command: "oni.config.openUserConfig", name: "Configuration: Edit User Config", detail: "Edit user configuration file for Oni", execute: () => configurationEditManager.editConfiguration(getUserConfigFilePath()), }) commandManager.registerCommand({ command: "oni.config.openConfigJs", name: null, detail: null, execute: () => configurationEditManager.editConfiguration(getUserConfigFilePath()), }) } ================================================ FILE: browser/src/Services/Configuration/ConfigurationEditor.ts ================================================ /** * ConfigurationEditor.ts */ import * as fs from "fs" import * as Oni from "oni-api" import * as os from "os" import * as path from "path" import * as mkdirp from "mkdirp" import * as Log from "oni-core-logging" 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` // // However, we want to support configuring in dialects of JS, like: // - TypeScript // - Reason // - ClojureScript // - CoffeeScript // - Script# (C#) // etc... // // Or even wasm languages! // // `IConfigurationEditor` provides an interface for this functionality. // // The expectation is that implementors of this will specify a separate file, // and implement functionality for compilng to JavaScript. export interface IConfigurationEditor { // For configuration editors that use a different language // (TypeScript, Reason, etc), this specifies the file // that should be opened for editing. editConfiguration(configurationFilePath: string): Promise // When the edit file is saved, this is responsible for transpiling the contents // to javascript. transpileConfigurationToJavaScript(contents: string): Promise } export class JavaScriptConfigurationEditor { public async editConfiguration(configurationFilePath: string): Promise { // Create default file, if it doesn't already exist if (!fs.existsSync(configurationFilePath)) { const defaultConfigJsPath = path.join(__dirname, "configuration", "config.default.js") const defaultConfigLines = fs.readFileSync(defaultConfigJsPath, "utf8") mkdirp.sync(path.dirname(configurationFilePath)) fs.writeFileSync(configurationFilePath, defaultConfigLines) } return configurationFilePath } public async transpileConfigurationToJavaScript(contents: string): Promise { return contents } } export interface IConfigurationEditInfo { editor: IConfigurationEditor destinationConfigFilePath: string } export class ConfigurationEditManager { private _fileToEditor: { [filePath: string]: IConfigurationEditInfo } = {} constructor(private _configuration: Configuration, private _editorManager: EditorManager) { this._editorManager.anyEditor.onBufferSaved.subscribe(evt => { const activeEditingSession = this._fileToEditor[evt.filePath] if (activeEditingSession) { const currentBuffer = this._editorManager.activeEditor.activeBuffer if (currentBuffer.filePath === evt.filePath) { this._transpileConfiguration( currentBuffer, activeEditingSession.editor, activeEditingSession.destinationConfigFilePath, ) } } }) } public async editConfiguration(configFile: string): Promise { Log.info("[ConfigurationEditManager::editConfiguration]: " + configFile) const editor = this._configuration.editor const editFile = await editor.editConfiguration(configFile) const normalizedEditFile = !!editFile ? editFile : configFile if (editFile) { this._fileToEditor[editFile] = { editor, destinationConfigFilePath: configFile, } } else { this._fileToEditor[configFile] = { editor: new JavaScriptConfigurationEditor(), destinationConfigFilePath: configFile, } } const showReferenceBuffer = this._configuration.getValue( "configuration.showReferenceBuffer", ) if (showReferenceBuffer) { // 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, }) } else { await this._editorManager.activeEditor.openFile(normalizedEditFile, { openMode: Oni.FileOpenMode.Edit, }) } } 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( buffer: Oni.Buffer, editor: IConfigurationEditor, destinationConfigFilePath: string, ): Promise { Log.info( `[ConfigurationEditManager::_transpileConfiguration] Transpiling ${ buffer.filePath } to ${destinationConfigFilePath}`, ) const contents = await buffer.getLines() const joinedContents = contents.join(os.EOL) const transpiledContents = await editor.transpileConfigurationToJavaScript(joinedContents) if ( buffer.filePath === destinationConfigFilePath && joinedContents === transpiledContents ) { Log.info( `[ConfigurationEditManager::_transpileConfiguration] Aborting transpile since destination file / source file + contents are the same (expected for JavaScript strategy).`, ) return } fs.writeFileSync(destinationConfigFilePath, transpiledContents) } } ================================================ FILE: browser/src/Services/Configuration/DefaultConfiguration.ts ================================================ /** * DefaultConfiguration.ts * * Specifies Oni default settings */ import * as os from "os" import * as Oni from "oni-api" import * as path from "path" import * as Platform from "./../../Platform" import { IConfigurationValues } from "./IConfigurationValues" import { ocamlAndReasonConfiguration, ocamlLanguageServerPath } from "./ReasonConfiguration" const noop = () => {} // tslint:disable-line no-empty const cssLanguageServerPath = path.join( __dirname, "node_modules", "vscode-css-languageserver-bin", "cssServerMain.js", ) const htmlLanguageServerPath = path.join( __dirname, "node_modules", "vscode-html-languageserver-bin", "htmlServerMain.js", ) const BaseConfiguration: IConfigurationValues = { activate: noop, deactivate: noop, "autoUpdate.enabled": false, "browser.defaultUrl": "https://duckduckgo.com", "configuration.editor": "typescript", "configuration.showReferenceBuffer": true, "debug.fixedSize": null, "debug.neovimPath": null, "debug.persistOnNeovimExit": false, "debug.detailedSessionLogging": false, "debug.showTypingPrediction": false, "debug.showNotificationOnError": process.env.NODE_ENV !== "production", "debug.fakeLag.languageServer": null, "debug.fakeLag.neovimInput": null, "wildmenu.mode": true, "commandline.mode": true, "commandline.icons": true, "experimental.preview.enabled": false, "experimental.welcome.enabled": false, "experimental.particles.enabled": false, "experimental.sessions.enabled": false, "experimental.sessions.directory": null, "experimental.vcs.sidebar": false, "experimental.vcs.blame.enabled": false, "experimental.vcs.blame.mode": "auto", "experimental.vcs.blame.timeout": 800, "experimental.colorHighlight.enabled": false, "experimental.colorHighlight.filetypes": [ ".css", ".js", ".jsx", ".tsx", ".ts", ".re", ".sass", ".scss", ".less", ".pcss", ".sss", ".stylus", ".xml", ".svg", ], "experimental.indentLines.enabled": false, "experimental.indentLines.color": null, "experimental.indentLines.skipFirst": false, "experimental.indentLines.bannedFiletypes": [], "experimental.markdownPreview.enabled": false, "experimental.markdownPreview.autoScroll": true, "experimental.markdownPreview.syntaxHighlights": true, "experimental.markdownPreview.syntaxTheme": "atom-one-dark", "experimental.neovim.transport": "stdio", // TODO: Enable pipe transport for Windows // "experimental.neovim.transport": Platform.isWindows() ? "pipe" : "stdio", "editor.maxLinesForLanguageServices": 2500, "editor.textMateHighlighting.enabled": true, "autoClosingPairs.enabled": true, "autoClosingPairs.default": [ { open: "{", close: "}" }, { open: "[", close: "]" }, { open: "(", close: ")" }, ], "oni.audio.bellUrl": null, "oni.useDefaultConfig": true, "oni.enhancedSyntaxHighlighting": true, "oni.userShell": undefined, "oni.loadInitVim": false, "oni.hideMenu": false, "oni.exclude": ["node_modules", ".git"], "oni.bookmarks": [], "editor.renderer": "canvas", "editor.backgroundOpacity": 1.0, "editor.backgroundImageUrl": null, "editor.backgroundImageSize": "cover", "editor.clipboard.enabled": true, "editor.clipboard.synchronizeYank": true, "editor.clipboard.synchronizeDelete": false, "editor.definition.enabled": true, "editor.quickInfo.enabled": true, "editor.quickInfo.delay": 500, "editor.completions.mode": "oni", "editor.errors.slideOnFocus": true, "editor.formatting.formatOnSwitchToNormalMode": false, "editor.fontLigatures": true, "editor.fontSize": "12px", "editor.fontWeight": "normal", "editor.fontFamily": "", "editor.linePadding": 2, "editor.quickOpen.execCommand": undefined, "editor.quickOpen.filterStrategy": "vscode", "editor.quickOpen.defaultOpenMode": Oni.FileOpenMode.Edit, "editor.quickOpen.alternativeOpenMode": Oni.FileOpenMode.ExistingTab, "editor.quickOpen.showHidden": true, "quickOpen.defaultOpenMode": Oni.FileOpenMode.Edit, "editor.split.mode": "native", "editor.typingPrediction": true, "editor.scrollBar.visible": true, "editor.scrollBar.cursorTick.visible": true, "editor.fullScreenOnStart": false, "editor.maximizeScreenOnStart": false, "editor.cursorLine": true, "editor.cursorLineOpacity": 0.1, "editor.cursorColumn": false, "editor.cursorColumnOpacity": 0.1, "editor.tokenColors": [], "editor.imageLayerExtensions": [".gif", ".jpg", ".jpeg", ".bmp", ".png"], "explorer.persistDeletedFiles": true, "explorer.maxUndoFileSizeInBytes": 500_000, "environment.additionalPaths": [], "environment.additionalVariables": {}, "keyDisplayer.showInInsertMode": false, "language.html.languageServer.command": htmlLanguageServerPath, "language.html.languageServer.arguments": ["--stdio"], "language.go.languageServer.command": "go-langserver", "language.go.textMateGrammar": path.join(__dirname, "extensions", "go", "syntaxes", "go.json"), "language.vue.textMateGrammar": path.join( __dirname, "extensions", "vue", "syntaxes", "vue.json", ), "language.python.languageServer.command": "pyls", "language.cpp.languageServer.command": "clangd", "language.c.languageServer.command": "clangd", "language.css.languageServer.command": cssLanguageServerPath, "language.css.languageServer.arguments": ["--stdio"], "language.css.textMateGrammar": path.join( __dirname, "extensions", "css", "syntaxes", "css.tmLanguage.json", ), "language.css.tokenRegex": "[$_a-zA-Z0-9-]", "language.elixir.textMateGrammar": { ".ex": path.join(__dirname, "extensions", "elixir", "syntaxes", "elixir.tmLanguage.json"), ".exs": path.join(__dirname, "extensions", "elixir", "syntaxes", "elixir.tmLanguage.json"), ".eex": path.join(__dirname, "extensions", "elixir", "syntaxes", "eex.tmLanguage.json"), ".html.eex": path.join( __dirname, "extensions", "elixir", "syntaxes", "html(eex).tmLanguage.json", ), }, "language.less.languageServer.command": cssLanguageServerPath, "language.less.languageServer.arguments": ["--stdio"], "language.less.textMateGrammar": path.join( __dirname, "extensions", "less", "syntaxes", "less.tmLanguage.json", ), "language.less.tokenRegex": "[$_a-zA-Z0-9-]", "language.scss.languageServer.command": cssLanguageServerPath, "language.scss.languageServer.arguments": ["--stdio"], "language.scss.textMateGrammar": path.join( __dirname, "extensions", "scss", "syntaxes", "scss.json", ), "language.scss.tokenRegex": "[$_a-zA-Z0-9-]", "language.reason.languageServer.command": ocamlLanguageServerPath, "language.reason.languageServer.arguments": ["--stdio"], "language.reason.languageServer.rootFiles": [".merlin", "bsconfig.json"], "language.reason.languageServer.configuration": ocamlAndReasonConfiguration, "language.reason.textMateGrammar": path.join( __dirname, "extensions", "reason", "syntaxes", "reason.json", ), "language.ocaml.languageServer.command": ocamlLanguageServerPath, "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( __dirname, "extensions", "typescript", "syntaxes", "TypeScript.tmLanguage.json", ), ".tsx": path.join( __dirname, "extensions", "typescript", "syntaxes", "TypeScriptReact.tmLanguage.json", ), }, "language.lua.textMateGrammar": path.join( __dirname, "extensions", "lua", "syntaxes", "lua.tmLanguage.json", ), "language.clojure.textMateGrammar": path.join( __dirname, "extensions", "clojure", "syntaxes", "clojure.tmLanguage.json", ), "language.ruby.textMateGrammar": path.join( __dirname, "extensions", "ruby", "syntaxes", "ruby.tmLanguage.json", ), "language.swift.textMateGrammar": path.join( __dirname, "extensions", "swift", "syntaxes", "swift.tmLanguage.json", ), "language.rust.textMateGrammar": path.join( __dirname, "extensions", "rust", "syntaxes", "rust.tmLanguage.json", ), "language.php.textMateGrammar": path.join( __dirname, "extensions", "php", "syntaxes", "php.tmLanguage.json", ), "language.objc.textMateGrammar": { ".m": path.join( __dirname, "extensions", "objective-c", "syntaxes", "objective-c.tmLanguage.json", ), ".h": path.join( __dirname, "extensions", "objective-c", "syntaxes", "objective-c.tmLanguage.json", ), }, "language.objcpp.textMateGrammar": { ".mm": path.join( __dirname, "extensions", "objective-c++", "syntaxes", "objective-c++.tmLanguage.json", ), }, "language.python.textMateGrammar": path.join( __dirname, "extensions", "python", "syntaxes", "python.tmLanguage.json", ), "language.sh.textMateGrammar": path.join( __dirname, "extensions", "shell", "syntaxes", "shell.tmLanguage.json", ), "language.zsh.textMateGrammar": path.join( __dirname, "extensions", "shell", "syntaxes", "shell.tmLanguage.json", ), "language.markdown.textMateGrammar": { ".md": path.join( __dirname, "extensions", "markdown", "syntaxes", "markdown.tmLanguage.json", ), ".markdown": path.join( __dirname, "extensions", "markdown", "syntaxes", "markdown.tmLanguage.json", ), ".mkd": path.join( __dirname, "extensions", "markdown", "syntaxes", "markdown.tmLanguage.json", ), ".mdown": path.join( __dirname, "extensions", "markdown", "syntaxes", "markdown.tmLanguage.json", ), }, "language.java.textMateGrammar": { ".java": path.join(__dirname, "extensions", "java", "syntaxes", "Java.tmLanguage.json"), ".jar": path.join(__dirname, "extensions", "java", "syntaxes", "Java.tmLanguage.json"), }, "language.cs.textMateGrammar": path.join( __dirname, "extensions", "csharp", "syntaxes", "csharp.tmLanguage.json", ), "language.javascript.completionTriggerCharacters": [".", "/", "\\"], "language.javascript.textMateGrammar": { ".js": path.join( __dirname, "extensions", "javascript", "syntaxes", "JavaScript.tmLanguage.json", ), ".jsx": path.join( __dirname, "extensions", "javascript", "syntaxes", "JavaScriptReact.tmLanguage.json", ), }, "learning.enabled": true, "achievements.enabled": true, "menu.caseSensitive": "smart", "menu.rowHeight": 40, "menu.maxItemsToShow": 8, "notifications.enabled": true, "recorder.copyScreenshotToClipboard": false, "recorder.outputPath": os.tmpdir(), "sidebar.enabled": true, "sidebar.default.open": true, "sidebar.width": "15em", "sidebar.marks.enabled": false, "sidebar.plugins.enabled": false, "snippets.enabled": true, "snippets.userSnippetFolder": null, "statusbar.enabled": true, "statusbar.fontSize": "0.9em", "statusbar.priority": { "oni.status.workingDirectory": 0, "oni.status.linenumber": 2, "oni.status.gitHubRepo": 0, "oni.status.mode": 1, "oni.status.filetype": 1, "oni.status.git": 3, }, "oni.plugins.prettier": { settings: { semi: false, tabWidth: 2, useTabs: false, singleQuote: false, trailingComma: "es5", bracketSpacing: true, jsxBracketSameLine: false, arrowParens: "avoid", printWidth: 80, }, formatOnSave: false, enabled: false, }, "tabs.mode": "tabs", "tabs.height": "2.5em", "tabs.highlight": true, "tabs.maxWidth": "30em", "tabs.showFileIcon": true, "tabs.showIndex": false, "tabs.wrap": false, "tabs.dirtyMarker.userColor": "", "terminal.shellCommand": null, "ui.animations.enabled": true, "ui.colorscheme": "nord", "ui.iconTheme": "theme-icons-seti", "ui.fontFamily": "BlinkMacSystemFont, 'Lucida Grande', 'Segoe UI', Ubuntu, Cantarell, sans-serif", "ui.fontSize": "13px", "ui.fontSmoothing": "auto", "workspace.defaultWorkspace": null, "workspace.autoDetectWorkspace": "noworkspace", "workspace.autoDetectRootFiles": [ ".git", "node_modules", ".svn", "package.json", ".hg", ".bzr", "build.xml", ], } const MacConfigOverrides: Partial = { "editor.fontFamily": "Menlo", "environment.additionalPaths": ["/usr/bin", "/usr/local/bin"], } const WindowsConfigOverrides: Partial = { "editor.fontFamily": "Consolas", } const LinuxConfigOverrides: Partial = { "editor.fontFamily": "DejaVu Sans Mono", "environment.additionalPaths": ["/usr/bin", "/usr/local/bin"], } const PlatformConfigOverride = Platform.isWindows() ? WindowsConfigOverrides : Platform.isLinux() ? LinuxConfigOverrides : MacConfigOverrides export const DefaultConfiguration = { ...BaseConfiguration, ...PlatformConfigOverride, } ================================================ FILE: browser/src/Services/Configuration/DeprecatedConfigurationValues.ts ================================================ /** * DeprecatedConfigurationValues * * The purpose of this is to give users a heads up when we plan on deprecating a configuration, * by providing them with a notice. As we move towards v1, we'll want to have some sort of * deprecation policy in place - like we'll support deprecated configurations for x releases. */ import * as Log from "oni-core-logging" export interface IDeprecatedConfigurationInfo { replacementConfigurationName: string documentationUrl: string } const deprecatedSettings: { [deprecatedConfigurationName: string]: IDeprecatedConfigurationInfo } = { "editor.completions.enabled": { replacementConfigurationName: "editor.completions.mode", documentationUrl: "https://github.com/onivim/oni/wiki/Configuration#editor", }, } export const checkDeprecatedSettings = (config: { [key: string]: any }): void => { Object.keys(config).forEach(configurationName => { if (deprecatedSettings[configurationName]) { const deprecationInfo = deprecatedSettings[configurationName] Log.warn( `Configuration setting '${configurationName}' is deprecated and will be replaced with '${ deprecationInfo.replacementConfigurationName }'. See more info at: ${deprecationInfo.documentationUrl}`, ) } }) } ================================================ FILE: browser/src/Services/Configuration/FileConfigurationProvider.ts ================================================ /** * FileConfigurationProvider * * Implementation of a configuration provider backed by a file */ import * as fs from "fs" import * as isError from "lodash/isError" import * as mkdirp from "mkdirp" import * as path from "path" import * as vm from "vm" import "rxjs/add/operator/debounceTime" import { Subject } from "rxjs/Subject" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { Event, IEvent } from "oni-types" import { IConfigurationProvider } from "./Configuration" import { IConfigurationValues } from "./IConfigurationValues" // import * as Utility from "./../../Utility" const CONFIG_UPDATE_DEBOUNCE_TIME = 100 /*ms */ export class FileConfigurationProvider implements IConfigurationProvider { private _configurationFilePath: string private _containingFolder: string private _configurationChangedEvent = new Event() private _configurationErrorEvent = new Event() private _latestConfiguration: Partial = null private _lastError: Error | null = null private _configEverHadValue: boolean = false private _configChangedObservable: Subject private _configErrorObservable: Subject public get onConfigurationChanged(): IEvent { return this._configurationChangedEvent } public get onConfigurationError(): IEvent { return this._configurationErrorEvent } constructor(filePath: string) { this._configChangedObservable = new Subject() this._configErrorObservable = new Subject() this._configChangedObservable .debounceTime(CONFIG_UPDATE_DEBOUNCE_TIME) .subscribe(() => this._configurationChangedEvent.dispatch()) this._configErrorObservable .debounceTime(CONFIG_UPDATE_DEBOUNCE_TIME) .subscribe((err: Error) => this._configurationErrorEvent.dispatch(err)) this._configurationFilePath = filePath this._containingFolder = path.dirname(filePath) if (!fs.existsSync(this._containingFolder)) { mkdirp.sync(this._containingFolder) } // use watch() on the directory rather than on config.js because it watches // file references and changing a file in Vim typically saves a tmp file // then moves it over to the original filename, causing watch() to lose its // reference. Instead, watch() can watch the folder for the file changes // and continue to fire when file references are swapped out. // Unfortunately, this also means the 'change' event fires twice. // I could use watchFile() but that polls every 5 seconds. Not ideal. fs.watch(this._containingFolder, (event, filename) => { if ( (event === "change" && filename === "config.js") || (event === "rename" && filename === "config.js") ) { // invalidate the Config currently stored in cache delete global["require"].cache[global["require"].resolve(filePath)] // tslint:disable-line no-string-literal this._getLatestConfig() } }) this._getLatestConfig() } public getValues(): Partial { return this._latestConfiguration } public getLastError(): Error | null { return this._lastError } public activate(api: Oni.Plugin.Api): void { if (this._latestConfiguration && this._latestConfiguration.activate) { try { this._latestConfiguration.activate(api) } catch (e) { alert( "[Config Error] Failed to activate " + this._configurationFilePath + ":\n" + (e as Error).message, ) } } } public deactivate(): void { if (this._latestConfiguration && this._latestConfiguration.deactivate) { this._latestConfiguration.deactivate() } } private _notifyConfigurationChanged(): void { this._configChangedObservable.next() } private _notifyConfigurationError(err: Error): void { this._configErrorObservable.next(err) } private _getLatestConfig(): void { this._lastError = null let userRuntimeConfig: Partial | null = null let error: Error | null = null if (fs.existsSync(this._configurationFilePath)) { try { const configurationContent = fs.readFileSync(this._configurationFilePath, "utf-8") const script = new vm.Script(configurationContent, { filename: __filename, }) const windowAsAny = window as any const sandbox = { console, __filename, __dirname, module: {} as any, require: (str: string) => { const val = windowAsAny.require(str) return val }, exports: {}, } const context = vm.createContext(sandbox) script.runInContext(context) const exports = sandbox.module ? sandbox.module.exports || sandbox.exports : sandbox.exports userRuntimeConfig = promoteConfigurationToRootLevel(exports) } catch (e) { e.message = "[Config Error] Failed to parse " + this._configurationFilePath + ":\n" + (e as Error).message error = e this._lastError = e this._notifyConfigurationError(e) } } if (!error) { // If the configuration is null, but it had some value at some point, // we assume this is due to reading while the file write is still // in transition, and ignore it if (userRuntimeConfig === null && this._configEverHadValue) { Log.info("Configuraiton was null; skipping") return } if (isError(userRuntimeConfig)) { Log.error(userRuntimeConfig) return } if (userRuntimeConfig) { this._configEverHadValue = true this._latestConfiguration = userRuntimeConfig this._notifyConfigurationChanged() } } } } export const promoteConfigurationToRootLevel = ( config: Partial, ): Partial => { if (config.configuration) { const configurationValues = config.configuration let mergedConfig = { ...config } delete mergedConfig.configuration mergedConfig = { ...mergedConfig, ...configurationValues } return mergedConfig } return config } ================================================ FILE: browser/src/Services/Configuration/IConfigurationValues.ts ================================================ /** * IConfigurationValues * - Set of configuration values that Oni relies on * * NOTE: This may not be the complete set of configuration values, * because dependent packages or plugins may have their own set of configuration */ import * as Oni from "oni-api" import { TokenColor } from "./../TokenColors" export type FontSmoothingOptions = "auto" | "antialiased" | "subpixel-antialiased" | "none" export type DetectionSettings = "always" | "noworkspace" | "never" export interface IConfigurationValues { activate?: (oni: Oni.Plugin.Api) => void deactivate?: () => void // Debug settings "debug.fixedSize": { rows: number columns: number } | null // Option to override neovim path. Used for testing new versions before bringing them in. "debug.neovimPath": string | null "debug.persistOnNeovimExit": boolean "debug.detailedSessionLogging": boolean "debug.showTypingPrediction": boolean "browser.defaultUrl": string // Simulate slow language server, for debugging "debug.fakeLag.languageServer": number | null "debug.fakeLag.neovimInput": number | null "debug.showNotificationOnError": boolean "editor.split.mode": string "configuration.editor": string "configuration.showReferenceBuffer": boolean // - textMateHighlighting "editor.textMateHighlighting.enabled": boolean // Whether or not the learning pane is available "experimental.particles.enabled": boolean // Whether or not the sessions sidebar pane is enabled "experimental.sessions.enabled": boolean // A User specified directory for where Oni session files should be saved "experimental.sessions.directory": string // Whether Version control sidebar item is enabled "experimental.vcs.sidebar": boolean // Whether the color highlight layer is enabled "experimental.colorHighlight.enabled": boolean // Whitelist of extension for the color highlight layer "experimental.colorHighlight.filetypes": string[] // Whether the indent lines should be shown "experimental.indentLines.enabled": boolean // Whether or not to skip the first line of indentation "experimental.indentLines.skipFirst": boolean "experimental.indentLines.color": string // Filetypes the indent lines are not shown for "experimental.indentLines.bannedFiletypes": string[] // Whether or not the vcs blame layer is enabled "experimental.vcs.blame.enabled": boolean // Whether or not the blame shows up automatically following a timeout or is manually // triggered "experimental.vcs.blame.mode": "auto" | "manual" // Amount of millisenconds to delay before showing blame per line "experimental.vcs.blame.timeout": number // Whether the markdown preview pane should be shown "experimental.markdownPreview.enabled": boolean "experimental.markdownPreview.autoScroll": boolean "experimental.markdownPreview.syntaxHighlights": boolean "experimental.markdownPreview.syntaxTheme": string // The transport to use for Neovim // Valid values are "stdio" and "pipe" "experimental.neovim.transport": string "wildmenu.mode": boolean "commandline.mode": boolean "commandline.icons": boolean // Experimental flag for 'generalized preview' "experimental.preview.enabled": boolean "experimental.welcome.enabled": boolean "autoClosingPairs.enabled": boolean "autoClosingPairs.default": any // Production settings // Bell sound effect to use // See `:help bell` for instances where the bell sound would be used "oni.audio.bellUrl": string "autoUpdate.enabled": boolean // Set this to `true` to enable additional syntax highlighting // from Oni & language integrations "oni.enhancedSyntaxHighlighting": boolean // The default config is an opinionated, prescribed set of plugins. This is on by default to provide // a good out-of-box experience, but will likely conflict with a Vim/Neovim veteran's finely honed config. // // Set this to 'false' to avoid loading the default config, and load settings from init.vim instead. "oni.useDefaultConfig": boolean // This string represents the path to the shell that the user would like oni to use to extract // environment variables that it uses derives the $PATH variable from. "oni.userShell": string // By default, user's init.vim is not loaded, to avoid conflicts. // Set this to `true` to enable loading of init.vim. // Set this to a string to override the init.vim path. "oni.loadInitVim": string | boolean // If true, hide Menu bar by default // (can still be activated by pressing 'Alt') // If hidden, menu bar is hidden entirely. "oni.hideMenu": boolean | "hidden" // glob pattern of files to exclude from fuzzy finder (Ctrl-P) "oni.exclude": string[] // bookmarks to open if opened in install dir "oni.bookmarks": string[] // Editor settings // Setting this to "webgl" switches to the experimental // WebGL-based renderer. Please be aware that this might // lead to instability or unexpected behavior until it is // considered stable. "editor.renderer": "canvas" | "webgl" "editor.backgroundOpacity": number "editor.backgroundImageUrl": string "editor.backgroundImageSize": string // Setting this to true enables yank integration with Oni // When true, and text is yanked / deleted, that text will // automatically be put on the clipboard. // // In addition, this enables and behavior // in paste from clipboard in insert mode. "editor.clipboard.enabled": boolean // When true (default), and `editor.clipboard.enabled` is `true`, // yanks will be sent to the clipboard. "editor.clipboard.synchronizeYank": boolean // When true (not default), and `editor.clipboard.enabled` is `true`, // deletes will be sent to the clipboard. "editor.clipboard.synchronizeDelete": boolean // Whether the 'go-to definition' language feature is enabled "editor.definition.enabled": boolean "editor.quickInfo.enabled": boolean // Delay (in ms) for showing QuickInfo, when the cursor is on a term "editor.quickInfo.delay": number "editor.quickOpen.defaultOpenMode": Oni.FileOpenMode "editor.quickOpen.alternativeOpenMode": Oni.FileOpenMode "editor.quickOpen.showHidden": boolean // this is new command to replace the above legacy editor prefixed command "quickOpen.defaultOpenMode": Oni.FileOpenMode "editor.errors.slideOnFocus": boolean "editor.formatting.formatOnSwitchToNormalMode": boolean // TODO: Make this setting reliable. If formatting is slow, it will hose edits... not fun // Sets the `popupmenu_external` option in Neovim // Valid options are "oni", "native" or "hidden", // where "oni" uses the Oni stylised Popups, // "native" uses the default Vim ones, // and "hidden" disables the automatic pop-ups, but keeps the stylised tabs when invoked. // // This will override the default UI to show a consistent popupmenu, // whether using Oni's completion mechanisms or VIMs // // Use caution when changing the `menuopt` parameters if using // a custom init.vim, as that may cause problematic behavior "editor.completions.mode": string // If true (default), ligatures are enabled "editor.fontLigatures": boolean "editor.fontSize": string "editor.fontWeight": string "editor.fontFamily": string // Platform specific // Additional padding between lines "editor.linePadding": number // Maximum supported file size (by lines) // to include language services/completion/syntax highlight/etc "editor.maxLinesForLanguageServices": 2500 // If true (default), the buffer scroll bar will be visible "editor.scrollBar.visible": boolean // If true (default), the cursor tick will be shown in the scrollbar. "editor.scrollBar.cursorTick.visible": boolean // Allow overriding token colors for specific textmate scopes "editor.tokenColors": TokenColor[] // Additional paths to include when launching sub-process from Oni // (and available in terminal integration, later) "environment.additionalPaths": string[] // Additional environment variables that override the default settings "environment.additionalVariables": any // User configurable array of files for which // the image layer opens "editor.imageLayerExtensions": string[] // Command to list files for 'quick open' // For example, to use 'ag': ag --nocolor -l . // // The command must emit a list of filenames // // IE, Windows: // "editor.quickOpen.execCommand": "dir /s /b" "editor.quickOpen.execCommand": string | null // The filter strategy to use for processing results // Options: // - 'fuse' - use the fusejs strategy // - 'regex' - use a regex based strategy "editor.quickOpen.filterStrategy": string // Typing prediction is Oni's implementation of // 'zero-latency' mode typing, and increases responsiveness. "editor.typingPrediction": boolean // Files deleted in the explorer can be persisted for the duration // of the session meaning that deletion can be undone is this is set // to true "explorer.persistDeletedFiles": boolean "explorer.maxUndoFileSizeInBytes": number "editor.fullScreenOnStart": boolean "editor.maximizeScreenOnStart": boolean "editor.cursorLine": boolean "editor.cursorLineOpacity": number "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 // - if `'smart'`, is case sensitive if the query string // contains uppercase characters "menu.caseSensitive": string | boolean "menu.rowHeight": number "menu.maxItemsToShow": number "notifications.enabled": boolean // Output path to save screenshots and recordings "recorder.outputPath": string // If this is set to true, the recorder // will save screenshots to clipboard instead // of saving to file "recorder.copyScreenshotToClipboard": boolean "sidebar.enabled": boolean "sidebar.default.open": boolean "sidebar.width": string "sidebar.marks.enabled": boolean "sidebar.plugins.enabled": boolean "oni.plugins.prettier": { settings: { semi: boolean tabWidth: number useTabs: boolean singleQuote: boolean trailingComma: "es5" | "all" | "none" bracketSpacing: boolean jsxBracketSameLine: boolean arrowParens: "avoid" | "always" printWidth: number [key: string]: number | string | boolean } formatOnSave: boolean enabled: boolean allowedFiletypes?: string[] } "snippets.enabled": boolean "snippets.userSnippetFolder": string "statusbar.enabled": boolean "statusbar.fontSize": string "statusbar.priority": { "oni.status.filetype": number "oni.status.workingDirectory": number "oni.status.git": number "oni.status.gitHubRepo": number "oni.status.linenumber": number "oni.status.mode": number } "tabs.mode": string // Height of individual tabs in the tab strip "tabs.height": string // Whether or not to render a highlight on the top of the tab // (mode highlight) "tabs.highlight": boolean // Maximum width of a tab "tabs.maxWidth": string // Whether or not to show the index alongside the tab "tabs.showIndex": boolean // Whether or not tabs should wrap. // If `false`, a scrollbar will be shown. // If `true`, will wrap the tabs. "tabs.wrap": boolean // Whether or not the file icon // should be shown in the tab "tabs.showFileIcon": boolean // can be anything the a css color property accepts e.g.: // "red", "#112233", "rgb(11,22,33)" "tabs.dirtyMarker.userColor": string "terminal.shellCommand": string "ui.animations.enabled": boolean "ui.iconTheme": string "ui.colorscheme": string "ui.fontFamily": string "ui.fontSize": string "ui.fontSmoothing": FontSmoothingOptions // Path to the default workspace. The default workspace // will be opened if no workspace is specified in configuration. "workspace.defaultWorkspace": string "workspace.autoDetectWorkspace": DetectionSettings "workspace.autoDetectRootFiles": string[] // Handle other, non-predefined configuration keys [configurationKey: string]: any } ================================================ FILE: browser/src/Services/Configuration/PersistentSettings.ts ================================================ /** * Persisted Settings * * Simple wrapper around 'electron-settings' */ import { remote } from "electron" // 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") import { GenericConfigurationValues, IPersistedConfiguration } from "./Configuration" export const get = (key: string): T => { return PersistentSettings.get(key) as T } export const set = (key: string, val: T): void => { return PersistentSettings.set(key, val) } const PersistedConfigurationKey: string = "_internal.persistedConfiguration" export class PersistedConfiguration implements IPersistedConfiguration { public getPersistedValues(): GenericConfigurationValues { return get(PersistedConfigurationKey) } public setPersistedValues(configurationValues: GenericConfigurationValues): void { const currentValues = this.getPersistedValues() const combinedValues = { ...currentValues, ...configurationValues, } set(PersistedConfigurationKey, combinedValues) } } ================================================ FILE: browser/src/Services/Configuration/ReasonConfiguration.ts ================================================ /** * ReasonConfiguration.ts * * Settings for ocaml / reason language server */ import * as path from "path" import * as Platform from "./../../Platform" export const ocamlLanguageServerPath = path.join( __dirname, "node_modules", "ocaml-language-server", "bin", "server", "index.js", ) // If Windows, wrap in `bash -ic` to support WSL const wrapCommand = Platform.isWindows() ? (str: string) => "bash -ic " + str : (str: string) => str export const ocamlAndReasonConfiguration = { reason: { codelens: { enabled: true, unicode: true, }, bsb: { enabled: true, }, debounce: { linter: 500, }, diagnostics: { tools: ["bsb", "merlin"], }, path: { bsb: wrapCommand("bsb"), ocamlfind: wrapCommand("ocamlfind"), ocamlmerlin: wrapCommand("ocamlmerlin"), opam: wrapCommand("opam"), rebuild: wrapCommand("rebuild"), refmt: wrapCommand("refmt"), refmterr: wrapCommand("refmterr"), rtop: wrapCommand("rtop"), }, }, } ================================================ FILE: browser/src/Services/Configuration/UserConfiguration.ts ================================================ /** * UserConfiguration.ts * * Helpers and settings relating to per-user configuration */ import * as path from "path" import * as Log from "oni-core-logging" import * as Platform from "./../../Platform" export const getUserConfigFilePath = (): string => { const configFileFromEnv = process.env["ONI_CONFIG_FILE"] as string // tslint:disable-line if (configFileFromEnv) { Log.info( "getUserConfigFilePath - path overridden by environment variable: " + configFileFromEnv, ) return configFileFromEnv } return path.join(getUserConfigFolderPath(), "config.js") } export const getUserConfigFolderPath = (): string => { const configFileFromEnv = process.env["ONI_CONFIG_FILE"] as string // tslint:disable-line Log.info("$env:ONI_CONFIG_FILE: " + configFileFromEnv) if (configFileFromEnv) { const configDir = path.dirname(configFileFromEnv) Log.info("getUserConfigFolderPath - path overridden by environment variable: " + configDir) return configDir } return Platform.isWindows() ? path.join(Platform.getUserHome(), "oni") : path.join(Platform.getUserHome(), ".config/oni") // XDG-compliant } ================================================ FILE: browser/src/Services/Configuration/index.ts ================================================ export * from "./Configuration" export * from "./IConfigurationValues" export * from "./UserConfiguration" ================================================ FILE: browser/src/Services/ContextMenu/ContextMenu.tsx ================================================ /** * Menu.ts * * Implements API surface area for working with the status bar */ import * as React from "react" import { bindActionCreators, Store } from "redux" import thunk from "redux-thunk" import * as types from "vscode-languageserver-types" import * as Oni from "oni-api" import { Event, IEvent } from "oni-types" import { IToolTipsProvider } from "./../../Editor/NeovimEditor/ToolTipsProvider" import { createStore } from "./../../Redux" import * as ActionCreators from "./../Menu/MenuActionCreators" import { createReducer } from "./../Menu/MenuReducer" import * as State from "./../Menu/MenuState" import { ContextMenuContainer } from "./ContextMenuComponent" // TODO: Remove filtering from the context menu responsibility const reducer = createReducer() const noopFilter = (opts: types.CompletionItem[], searchText: string): types.CompletionItem[] => opts // TODO: This is essentially a duplicate of `MenuManager.ts` - can this be consolidated? // Can potentially move to a higher-order class that takes contextMenuActions/store as arguments export type ContextMenuState = State.IMenus export class ContextMenuManager { private _id: number = 0 private _store: Store private _actions: typeof ActionCreators constructor(private _toolTips: IToolTipsProvider) { this._store = createStore("CONTEXT-MENU", reducer, State.createDefaultState(), [thunk]) this._actions = bindActionCreators(ActionCreators as any, this._store.dispatch) } public create(): ContextMenu { this._id++ return new ContextMenu(this._id.toString(), this._store, this._actions, this._toolTips) } public isMenuOpen(): boolean { return !!this._store.getState().menu } public nextMenuItem(): void { this._actions.nextMenuItem() } public previousMenuItem(): void { this._actions.previousMenuItem() } public closeActiveMenu(): void { this._actions.hidePopupMenu() } public selectMenuItem(idx?: number): void { const contextMenuState = this._store.getState() if (contextMenuState && contextMenuState.menu) { contextMenuState.menu.onSelectItem(idx) } } } export class ContextMenu { private _onItemSelected = new Event() private _onFilterTextChanged = new Event() private _onHide = new Event() private _onSelectedItemChanged = new Event() private _lastItems: any = null public get onHide(): IEvent { return this._onHide } public get onItemSelected(): IEvent { return this._onItemSelected } public get onSelectedItemChanged(): IEvent { return this._onSelectedItemChanged } public get onFilterTextChanged(): IEvent { return this._onFilterTextChanged } public get selectedItem() { return this._getSelectedItem() } constructor( private _id: string, private _store: Store>, private _actions: typeof ActionCreators, private _toolTips: IToolTipsProvider, ) {} public isOpen(): boolean { const contextMenuState = this._store.getState() return contextMenuState.menu && contextMenuState.menu.id === this._id } public setFilter(filter: string): void { const contextMenuState = this._store.getState() if (contextMenuState.menu && contextMenuState.menu.filter !== filter) { this._actions.filterMenu(filter) } } public setLoading(isLoading: boolean): void { this._actions.setMenuLoading(this._id, isLoading) } public setItems(items: Oni.Menu.MenuOption[]): void { if (items === this._lastItems) { return } this._lastItems = items this._actions.setMenuItems(this._id, items) } public show(items?: any[], filter?: string): void { this._actions.showPopupMenu( this._id, { filterFunction: noopFilter, onSelectedItemChanged: (item: any) => this._onSelectedItemChanged.dispatch(item), onSelectItem: (idx: number) => this._onItemSelectedHandler(idx), onHide: () => this._onHidden(), onFilterTextChanged: (newText: string) => this._onFilterTextChanged.dispatch(newText), } as any, items, filter, ) this._toolTips.showToolTip( this._getContextMenuId(), , { openDirection: 2, position: null, padding: "0px", }, ) } public hide(): void { this._actions.hidePopupMenu() } private _onItemSelectedHandler(idx?: number): void { const selectedOption = this._getSelectedItem(idx) this._onItemSelected.dispatch(selectedOption) this.hide() } private _getSelectedItem(idx?: number) { const contextMenuState = this._store.getState() if (!contextMenuState.menu) { return null } const index = typeof idx === "number" ? idx : contextMenuState.menu.selectedIndex return contextMenuState.menu.filteredOptions[index] } private _onHidden(): void { this._toolTips.hideToolTip(this._getContextMenuId()) this._onHide.dispatch() } private _getContextMenuId(): string { return "context_menu_" + this._id.toString() } } ================================================ FILE: browser/src/Services/ContextMenu/ContextMenuComponent.tsx ================================================ /** * ContextMenu.tsx */ import * as React from "react" import * as types from "vscode-languageserver-types" import { connect, Provider } from "react-redux" import { Store } from "redux" import * as Oni from "oni-api" import { IMenus } from "./../Menu/MenuState" import { Arrow, ArrowDirection } from "./../../UI/components/Arrow" import styled, { enableMouse, layer } from "./../../UI/components/common" import { HighlightText } from "./../../UI/components/HighlightText" import { QuickInfoDocumentation } from "./../../UI/components/QuickInfo" import { Icon } from "./../../UI/Icon" import { ContextMenuState } from "./ContextMenu" export interface IContextMenuItem { label: string detail?: string documentation?: string | types.MarkupContent icon?: string } const Autocompletion = styled.div` ${layer}; ${enableMouse}; width: 600px; overflow: hidden; animation-name: appear; animation-duration: 0.1s; ` export interface IContextMenuProps { visible: boolean base: string entries: IContextMenuItem[] selectedIndex: number } export const ContextMenuView: React.SFC = props => { if (!props.visible) { return null } let entriesToRender: IContextMenuItem[] = [] let { selectedIndex: adjustedIndex } = props // TODO: sync max display items (10) with value in Reducer.autoCompletionReducer() (Reducer.ts) if (adjustedIndex < 10) { entriesToRender = props.entries.slice(0, 10) } else { entriesToRender = props.entries.slice(adjustedIndex - 9, adjustedIndex + 1) adjustedIndex = entriesToRender.length - 1 } const entries = entriesToRender.map((entry, index) => { const isSelected = index === adjustedIndex return ( ) }) const selectedItemDocumentation = getDocumentationFromItems(entriesToRender, adjustedIndex) return ( {entries} ) } const getDocumentationFromItems = (items: any[], selectedIndex: number): string => { if (!items || !items.length) { return null } if (selectedIndex < 0 || selectedIndex >= items.length) { return null } return items[selectedIndex].documentation } export const Label = styled(HighlightText)` flex: 1 0 auto; min-width: 100px; margin-left: 8px; ` const Highlight = styled.span` text-decoration: underline; ` interface ISelectedProps { isSelected: boolean } const Entry = styled("div")` ${({ isSelected }) => isSelected && `transform: translateY(0.1px); box-shadow: 0 1px 8px 1px rgba(0, 0, 0, 0.2), 0 1px 20px 0 rgba(0, 0, 0, 0.19); `}; ` const Main = styled("div")` transition: opacity 1s; opacity: ${props => (props.isSelected ? "1" : "0.8")}; display: flex; flex-direction: row; align-items: center; ` const IconWrapper = styled.div` padding: 4px; width: 1.2em; flex: 0 0 auto; text-align: center; background-color: ${({ theme }) => theme["contextMenu.highlight"]}; i { font-size: 0.9em; } ` export const Detail = styled("div")` flex: 0 1 auto; min-width: 50px; text-align: right; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; margin: 0 5px 0 16px; font-size: 0.8em; opacity: 0.8; visibility: hidden; ${props => (props.isSelected ? "visibility:visible;" : "")}; ` export interface IContextMenuItemProps extends Oni.Menu.MenuOption, ISelectedProps { base: string } export const ContextMenuItem: React.SFC = props => { const { isSelected, label, icon, base, detail } = props return (
    ) } export interface IContextMenuDocumentationProps { documentation: string } export const ContextMenuDocumentation = ({ documentation }: IContextMenuDocumentationProps) => { return documentation ? : null } type IState = IMenus const mapStateToProps = ({ menu: contextMenu }: IState): IContextMenuProps => { if (!contextMenu) { return { visible: false, base: "", entries: [] as IContextMenuItem[], selectedIndex: 0, } as IContextMenuProps } return { visible: true, base: contextMenu.filter, entries: contextMenu.filteredOptions, selectedIndex: contextMenu.selectedIndex, } } export const ConnectedContextMenu = connect(mapStateToProps)(ContextMenuView) export const ContextMenuContainer = (props: { store: Store }) => { return ( ) } ================================================ FILE: browser/src/Services/ContextMenu/index.ts ================================================ export * from "./ContextMenu" export * from "./ContextMenuComponent" ================================================ FILE: browser/src/Services/Debug.ts ================================================ /** * Debug.ts * * A set of commands used for debugging */ import { remote } from "electron" import { CommandManager } from "./CommandManager" import { AchievementsManager, getInstance as getAchievementsInstance, } from "./Learning/Achievements" export const activate = (commandManager: CommandManager) => { 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, }, ], }) } ================================================ FILE: browser/src/Services/Diagnostics/index.ts ================================================ /** * Diagnostics/index.ts * * Integrates the `textDocument/publishDiagnostics` protocol with Oni's UI */ import * as flatten from "lodash/flatten" import * as types from "vscode-languageserver-types" import { Event, IEvent } from "oni-types" import { ILanguageServerNotificationResponse, LanguageManager } from "../Language" import * as Helpers from "../../Plugins/Api/LanguageClient/LanguageClientHelpers" import * as Utility from "../../Utility" interface IPublishDiagnosticsParams { uri: string diagnostics: types.Diagnostic[] } export interface Errors { [file: string]: { [key: string]: types.Diagnostic[] } } export interface IDiagnosticsDataSource { onErrorsChanged: IEvent getErrors(): Errors setErrors(filePath: string, key: string, errors: types.Diagnostic[]): void getErrorsForPosition(filePath: string, line: number, column: number): types.Diagnostic[] start(languageManager: LanguageManager): void } 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) { return Utility.EmptyArray } const allErrorsByKey = errors[fileName] if (!allErrorsByKey) { return Utility.EmptyArray } const arrayOfErrorsArray = Object.values(allErrorsByKey) return flatten(arrayOfErrorsArray) } export class DiagnosticsDataSource { private _errors: Errors = {} private _onErrorsChangedEvent = new Event() public get onErrorsChanged(): IEvent { return this._onErrorsChangedEvent } public setErrors(filePath: string, key: string, errors: types.Diagnostic[]): void { filePath = Utility.normalizePath(filePath) const currentFile = this._errors[filePath] || null this._errors = { ...this._errors, [filePath]: { ...currentFile, [key]: [...errors], }, } this._onErrorsChangedEvent.dispatch() } public getErrors(): Errors { return this._errors } public getErrorsForPosition( filePath: string, line: number, column: number, ): types.Diagnostic[] { const errors = getAllErrorsForFile(Utility.normalizePath(filePath), this._errors) return errors.filter(diagnostic => { return Utility.isInRange(line, column, diagnostic.range) }) } public start(languageManager: LanguageManager): void { languageManager.subscribeToLanguageServerNotification( "textDocument/publishDiagnostics", (args: ILanguageServerNotificationResponse) => { const test = args.payload as IPublishDiagnosticsParams const file = Helpers.unwrapFileUriPath(test.uri) const key = "language-" + args.language this.setErrors(file, key, test.diagnostics) }, ) } } let _diagnostics: DiagnosticsDataSource = null export const getInstance = (): IDiagnosticsDataSource => { if (!_diagnostics) { _diagnostics = new DiagnosticsDataSource() } return _diagnostics } ================================================ FILE: browser/src/Services/Diagnostics/navigateErrors.ts ================================================ /** * navigateErrors.ts * * Functions to jump to previous/next diagnostic error in the active buffer */ import { isInRange } from "./../../Utility" import { getAllErrorsForFile, getInstance as getDiagnosticsInstance } from "./../Diagnostics" import { editorManager } from "./../EditorManager" export const gotoNextError = async () => { const errors = getDiagnosticsInstance().getErrors() const activeBuffer = editorManager.activeEditor.activeBuffer const currentFileErrors = getAllErrorsForFile(activeBuffer.filePath, errors) const currentPosition = activeBuffer.cursor if (!currentFileErrors || !currentFileErrors.length) { return } for (const error of currentFileErrors) { if (isInRange(currentPosition.line, currentPosition.column, error.range)) { continue } const currentLine = (await activeBuffer.getLines(currentPosition.line))[0] if ( currentPosition.line === error.range.start.line && currentLine.length <= error.range.start.character ) { continue } if ( error.range.start.line > currentPosition.line || (error.range.start.line === currentPosition.line && error.range.start.character > currentPosition.column) ) { await activeBuffer.setCursorPosition( error.range.start.line, error.range.start.character, ) return } } await activeBuffer.setCursorPosition( currentFileErrors[0].range.start.line, currentFileErrors[0].range.start.character, ) } export const gotoPreviousError = async () => { const errors = getDiagnosticsInstance().getErrors() const activeBuffer = editorManager.activeEditor.activeBuffer const currentFileErrors = getAllErrorsForFile(activeBuffer.filePath, errors) const currentPosition = activeBuffer.cursor if (!currentFileErrors || !currentFileErrors.length) { return } let lastError = currentFileErrors[currentFileErrors.length - 1] for (const error of currentFileErrors) { if ( isInRange(currentPosition.line, currentPosition.column, error.range) || error.range.start.line > currentPosition.line || (error.range.start.line === currentPosition.line && error.range.start.character > currentPosition.column) ) { await activeBuffer.setCursorPosition( lastError.range.start.line, lastError.range.start.character, ) return } lastError = error } await activeBuffer.setCursorPosition( lastError.range.start.line, lastError.range.start.character, ) } ================================================ FILE: browser/src/Services/DragAndDrop.tsx ================================================ import * as React from "react" import * as DND from "react-dnd" type Render = (props: T) => React.ReactElement type OnDrop = (item: any) => object | void type IsValidDrop = (item: any) => boolean // Drop Target ================================================================ export interface IDroppeable { isOver?: boolean connectDropTarget?: any onDrop: OnDrop isValidDrop: IsValidDrop canDrop?: boolean didDrop?: boolean accepts: string[] | string render: Render<{ canDrop: boolean isOver?: boolean didDrop?: boolean connectDropTarget?: DND.DropTargetConnector }> } interface DroppedProps { onDrop: OnDrop isValidDrop: IsValidDrop } const DropTarget = { drop(dropped: DroppedProps, monitor: DND.DropTargetMonitor) { return dropped.onDrop({ drag: monitor.getItem(), drop: dropped }) }, canDrop(props: DroppedProps, monitor: DND.DropTargetMonitor) { return props.isValidDrop({ drag: monitor.getItem(), drop: props }) }, } const DropCollect = (connect: DND.DropTargetConnector, monitor: DND.DropTargetMonitor) => ({ connectDropTarget: connect.dropTarget(), isOver: monitor.isOver(), canDrop: monitor.canDrop(), didDrop: monitor.didDrop(), }) /** * A component which can have items of a specific matching type dropped onto it */ @DND.DropTarget(({ accepts }) => accepts, DropTarget, DropCollect) export class Droppeable

    extends React.Component

    { public render() { const { isOver, connectDropTarget, canDrop, didDrop } = this.props return connectDropTarget(

    {this.props.render({ isOver, canDrop, didDrop })}
    ) } } // Drag Source ================================================================ export interface IDraggeable { target?: string isDragging?: boolean connectDragSource?: any render: Render<{ isDragging?: boolean; connectDragSource?: DND.DragSourceConnector }> } const DragSource = { beginDrag(props: object) { return props }, } const DragCollect = (connect: DND.DragSourceConnector, monitor: DND.DragSourceMonitor) => { return { connectDragSource: connect.dragSource(), isDragging: monitor.isDragging(), } } /** * A component that can be dragged onto a droppeable one * * @name props * @function * @param {String | String[]} props.target The target Type that responds to the drop * @param {Object} DragSource Object with a beginDrag which return the dragged props * @param {React.Component} A component which is dragged onto another * @returns {React.Component

    } A react class component */ @DND.DragSource(props => props.target, DragSource, DragCollect) export class Draggeable

    extends React.Component

    { public render() { const { isDragging, connectDragSource } = this.props return connectDragSource(

    {this.props.render({ isDragging })}
    ) } } interface IDragDrop { isOver?: boolean didDrop?: boolean onDrop: OnDrop accepts: string[] | string connectDropTarget?: any canDrop?: boolean isValidDrop: IsValidDrop dragTarget: string isDragging?: boolean connectDragSource?: any render: Render<{ didDrop?: boolean; canDrop?: boolean; isOver?: boolean; isDragging?: boolean }> } /** * A render prop which takes a given component and makes it a drop target as well as draggeable */ @DND.DropTarget(props => props.accepts, DropTarget, DropCollect) @DND.DragSource(props => props.dragTarget, DragSource, DragCollect) export class DragAndDrop

    extends React.Component

    { public render() { const { connectDragSource, connectDropTarget } = this.props return connectDropTarget(connectDragSource(

    {this.props.render(this.props)}
    )) } } ================================================ FILE: browser/src/Services/EditorManager.ts ================================================ /** * EditorManager.ts * * Responsible for managing state of the editor collection, and * switching between active editors. * * It also provides convenience methods for hooking events * to the active editor, and managing transitions between editors. */ import * as Oni from "oni-api" import { Event, IDisposable, IEvent } from "oni-types" import * as types from "vscode-languageserver-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 this._allEditors } /** * API Methods */ public get anyEditor(): Oni.Editor { return this._anyEditorProxy } public get activeEditor(): Oni.Editor { return this._activeEditor } public get onActiveEditorChanged(): IEvent { return this._onActiveEditorChanged } public openFile( filePath: string, openOptions: Oni.FileOpenOptions = Oni.DefaultFileOpenOptions, ): Promise { 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 */ public setActiveEditor(editor: Oni.Editor) { this._activeEditor = editor const oldEditor = this._anyEditorProxy.getUnderlyingEditor() if (editor !== oldEditor) { this._onActiveEditorChanged.dispatch(editor) this._anyEditorProxy.setActiveEditor(editor) } } } /** * AllEditors is a proxy for the Neovim interface, * exposing methods of 'all' editors, as an aggregate. * * This enables consumers to use `Oni.editor.allEditors.onModeChanged((newMode) => { ... }), * for convenience, as it handles manages tracking subscriptions as the active editor changes. */ class AnyEditorProxy implements Oni.Editor { private _activeEditor: Oni.Editor private _subscriptions: IDisposable[] = [] private _onModeChanged = new Event() private _onBufferEnter = new Event() private _onBufferLeave = new Event() private _onBufferChanged = new Event() private _onBufferSaved = new Event() private _onBufferScrolled = new Event() private _onCursorMoved = new Event() /** * API Methods */ public get mode(): string { if (!this._activeEditor) { return null } return this._activeEditor.mode } public get activeBuffer(): Oni.Buffer { // TODO: Replace with null-object pattern if (!this._activeEditor) { return null } return this._activeEditor.activeBuffer } public init(filesToOpen: string[]): void { if (!this._activeEditor) { return } this._activeEditor.init(filesToOpen) } public get neovim(): Oni.NeovimEditorCapability { if (!this._activeEditor) { return null } return this._activeEditor.neovim } public get onModeChanged(): IEvent { return this._onModeChanged } public get onBufferChanged(): IEvent { return this._onBufferChanged } public get onBufferEnter(): IEvent { return this._onBufferEnter } public get onBufferLeave(): IEvent { return this._onBufferLeave } public get onBufferSaved(): IEvent { return this._onBufferSaved } public get onBufferScrolled(): IEvent { return this._onBufferScrolled } public get onCursorMoved(): IEvent { return this._onCursorMoved } public dispose(): void { // tslint:disable-line } public async blockInput( inputFunction: (input: Oni.InputCallbackFunction) => Promise, ): Promise { return this._activeEditor.blockInput(inputFunction) } public async openFile(filePath: string, openOptions: Oni.FileOpenOptions): Promise { return this._activeEditor.openFile(filePath, openOptions) } public getBuffers(): Array { return this._activeEditor.getBuffers() } public setTextOptions(options: Oni.EditorTextOptions): Promise { return this._activeEditor.setTextOptions(options) } public render(): JSX.Element { if (!this._activeEditor) { return null } return this._activeEditor.render() } public setSelection(selectionRange: types.Range): Promise { if (!this._activeEditor) { return null } return this._activeEditor.setSelection(selectionRange) } /** * Internal methods */ 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)), newEditor.onBufferLeave.subscribe(val => this._onBufferLeave.dispatch(val)), newEditor.onBufferChanged.subscribe(val => this._onBufferChanged.dispatch(val)), newEditor.onBufferSaved.subscribe(val => this._onBufferSaved.dispatch(val)), newEditor.onBufferScrolled.subscribe(val => this._onBufferScrolled.dispatch(val)), newEditor.onCursorMoved.subscribe(val => this._onCursorMoved.dispatch(val)), ] } public getUnderlyingEditor(): Oni.Editor { return this._activeEditor } } export const editorManager: EditorManager = new EditorManager() ================================================ FILE: browser/src/Services/Explorer/ExplorerFileSystem.ts ================================================ /** * ExplorerFileSystem.ts * * State management for the explorer split */ import * as fs from "fs" import { ensureDirSync, mkdirp, move, pathExists, remove, writeFile } from "fs-extra" import * as os from "os" import * as path from "path" import { promisify } from "util" import { FolderOrFile } from "./ExplorerStore" /** * An abstraction of the node filesystem APIs to enable testing */ export interface IFileSystem { readdir(fullPath: string): Promise exists(fullPath: string): Promise realpath(fullPath: string): Promise persistNode(fullPath: string): Promise restoreNode(fullPath: string): Promise deleteNode(fullPath: string): Promise canPersistNode(fullPath: string, size: number): Promise move(source: string, dest: string): Promise moveNodesBack(collection: Array<{ source: string; destination: string }>): Promise writeFile(filepath: string): Promise mkdir(folderpath: string): Promise } export class FileSystem implements IFileSystem { private _fs: { readdir(path: string): Promise stat(path: string): Promise exists(path: string): Promise realpath(path: string): Promise } private _backupDirectory = path.join(os.tmpdir(), "oni_backup") public get backupDir(): string { return this._backupDirectory } constructor(nfs: typeof fs) { this._fs = { readdir: promisify(nfs.readdir.bind(nfs)), stat: promisify(nfs.stat.bind(nfs)), exists: promisify(nfs.exists.bind(nfs)), realpath: promisify(nfs.realpath.bind(nfs)), } this.init() } public init = () => { ensureDirSync(this._backupDirectory) } public async readdir(directoryPath: string): Promise { const files = await this._fs.readdir(directoryPath) const filesAndFolders = files.map(async f => { const fullPath = path.join(directoryPath, f) const isDirectory = await this._fs .stat(fullPath) .then(stat => stat.isDirectory()) .catch(() => false) if (isDirectory) { return { type: "folder", fullPath, } as FolderOrFile } else { return { type: "file", fullPath, } as FolderOrFile } }) return Promise.all(filesAndFolders) } public exists(fullPath: string): Promise { return this._fs.exists(fullPath) } /** * Resolve symlinks in a path to give the real absolute path. */ public realpath(fullPath: string): Promise { return this._fs.realpath(fullPath) } /** * Delete a file or Folder * * @name deleteNode * @function * @param {ExplorerNode} node The file or folder node */ public async deleteNode(fullPath: string): Promise { await remove(fullPath) } /** * Move a file or folder from the backup dir to its original location * * @name restoreNode * @function * @param {string} fileOrFolder The file or folder path */ public restoreNode = async (prevPath: string): Promise => { const name = path.basename(prevPath) const backupPath = path.join(this._backupDirectory, name) await move(backupPath, prevPath) } public move = async (source: string, dest: string): Promise => { return this.areDifferent(source, dest) && move(source, dest) } /** * Saves a file to the tmp directory to persist deleted files * * @name PersistNode * @function * @param {string} filename A file or folder path */ public persistNode = async (fileOrFolder: string): Promise => { const { size } = await this._fs.stat(fileOrFolder) const hasEnoughSpace = os.freemem() > size if (hasEnoughSpace) { const filename = path.basename(fileOrFolder) const newPath = path.join(this._backupDirectory, filename) await move(fileOrFolder, newPath, { overwrite: true }) } } /** * Moves an array of files and folders to their original locations * * @name moveNodesBack * @function * @param {Array} collection An array of object with a file/folder and its destination folder * @returns {void} */ public moveNodesBack = async ( collection: Array<{ destination: string; source: string }>, ): Promise => { await Promise.all( collection.map( async ({ source, destination }) => this.areDifferent(source, destination) && move(destination, source), ), ) } /** * canPersistNode * Determine based on size whether the directory should be persisted */ public canPersistNode = async (fullPath: string, maxSize: number): Promise => { const { size } = await this._fs.stat(fullPath) return size < maxSize } /** * createFile */ public async writeFile(filepath: string) { if (await pathExists(filepath)) { throw new Error("This path already exists") } await writeFile(filepath, "", null) } public async mkdir(folderpath: string) { if (await pathExists(folderpath)) { throw new Error("This path already exists") } await mkdirp(folderpath) } private areDifferent = (src: string, dest: string) => src !== dest } export const OniFileSystem = new FileSystem(fs) ================================================ FILE: browser/src/Services/Explorer/ExplorerSelectors.ts ================================================ /** * ExplorerSelectors.ts * * Selectors for the explorer state */ import * as path from "path" import * as flatten from "lodash/flatten" import { ExpandedFolders, FolderOrFile, IExplorerState } from "./ExplorerStore" export interface IContainerNode { id: string type: "container" expanded: boolean name: string } export interface IFolderNode { id: string type: "folder" folderPath: string expanded: boolean name: string indentationLevel: number } export interface IFileNode { id: string type: "file" filePath: string modified: boolean name: string indentationLevel: number } export const EmptyNode: ExplorerNode = { type: null, id: null, modified: null, filePath: null, name: null, indentationLevel: null, } export type ExplorerNode = IContainerNode | IFolderNode | IFileNode export const isPathExpanded = (state: IExplorerState, pathToCheck: string): boolean => { return !!state.expandedFolders[pathToCheck] } export const mapStateToNodeList = (state: IExplorerState): ExplorerNode[] => { let ret: ExplorerNode[] = [] // ret.push({ // id: "opened", // type: "container", // expanded: true, // name: "Opened Files", // }) // const openedFiles: ExplorerNode[] = Object.keys(state.openedFiles) // .filter(filePath => !!filePath) // .map(filePath => ({ // type: "file", // id: "opened:" + filePath, // filePath, // name: path.basename(filePath), // modified: false, // TODO // indentationLevel: 0, // } as ExplorerNode)) // ret = [...ret, ...openedFiles] if (!state.rootFolder || !state.rootFolder.fullPath) { return ret } ret.push({ id: "explorer", type: "container", expanded: !!state.expandedFolders[state.rootFolder.fullPath], name: state.rootFolder.fullPath, }) const expandedTree = flattenFolderTree(state.rootFolder, [], state.expandedFolders, 0) // The root node is included in the output, so we'll remove it const [, ...remainingTree] = expandedTree ret = [...ret, ...remainingTree] return ret } export const flattenFolderTree = ( folderTree: FolderOrFile, currentList: ExplorerNode[], expandedFolders: ExpandedFolders, indentationLevel: number, ): ExplorerNode[] => { switch (folderTree.type) { case "file": const file: ExplorerNode = { type: "file", name: path.basename(folderTree.fullPath), id: "explorer:" + folderTree.fullPath, filePath: folderTree.fullPath, modified: false, indentationLevel, } return [...currentList, file] case "folder": const expanded = !!expandedFolders[folderTree.fullPath] const folder: ExplorerNode = { type: "folder", id: "explorer:" + folderTree.fullPath, folderPath: folderTree.fullPath, name: path.basename(folderTree.fullPath), expanded, indentationLevel, } const folderChildren = expandedFolders[folderTree.fullPath] || [] const children = flatten( folderChildren.map(c => flattenFolderTree(c, [], expandedFolders, indentationLevel + 1), ), ) return [...currentList, folder, ...children] default: return [] } } ================================================ FILE: browser/src/Services/Explorer/ExplorerSplit.tsx ================================================ /** * ExplorerSplit.tsx * */ import * as path from "path" import * as React from "react" import { Provider } from "react-redux" import { Store } from "redux" import { FileSystemWatcher } from "./../../Services/FileSystemWatcher" import * as Oni from "oni-api" import { Event } from "oni-types" import { CallbackCommand } from "./../../Services/CommandManager" // TODO: Discuss: Move to API? import { getInstance as NotificationsInstance } from "./../../Services/Notifications" import { windowManager } from "./../../Services/WindowManager" import { createStore, getPathForNode, IExplorerState } from "./ExplorerStore" import * as ExplorerSelectors from "./ExplorerSelectors" import { Explorer } from "./ExplorerView" type Node = ExplorerSelectors.ExplorerNode export class ExplorerSplit { private _onEnterEvent: Event = new Event() private _selectedId: string = null private _store: Store private _watcher: FileSystemWatcher = null public get id(): string { return "oni.sidebar.explorer" } public get title(): string { return "Explorer" } constructor(private _oni: Oni.Plugin.Api) { this._store = createStore({ notifications: NotificationsInstance() }) this._initializeFileSystemWatcher() this._oni.workspace.onDirectoryChanged.subscribe(newDirectory => { this._store.dispatch({ type: "SET_ROOT_DIRECTORY", rootPath: newDirectory, }) if (this._watcher) { this._watcher.unwatch(this._oni.workspace.activeWorkspace) this._watcher.watch(newDirectory) } }) if (this._oni.workspace.activeWorkspace) { this._store.dispatch({ type: "SET_ROOT_DIRECTORY", rootPath: this._oni.workspace.activeWorkspace, }) } } public enter(): void { this._store.dispatch({ type: "ENTER" }) this._initialiseExplorerCommands() this._onEnterEvent.dispatch() } public leave(): void { this._store.dispatch({ type: "LEAVE" }) } public moveFileOrFolder = (source: Node, dest: Node): void => { this._store.dispatch({ type: "PASTE", pasted: [source], target: dest }) } public render(): JSX.Element { return ( this._onOpenItem(id)} moveFileOrFolder={this.moveFileOrFolder} onSelectionChanged={id => this._onSelectionChanged(id)} /> ) } public locateFile = (filePath: string) => { this._store.dispatch({ type: "SELECT_FILE", filePath }) } private _initializeFileSystemWatcher(): void { if (this._oni.configuration.getValue("explorer.autoRefresh")) { this._watcher = new FileSystemWatcher({ target: this._oni.workspace.activeWorkspace, options: { ignoreInitial: true, ignored: "**/node_modules" }, }) const events = ["onChange", "onAdd", "onAddDir", "onMove", "onDelete", "onDeleteDir"] events.forEach(event => this._watcher[event].subscribe(() => this._refresh())) } } private _inputInProgress = () => { const { register: { rename, create }, } = this._store.getState() return rename.active || create.active } private _refresh(): void { this._store.dispatch({ type: "REFRESH" }) } private _initialiseExplorerCommands(): void { this._oni.commands.registerCommand( new CallbackCommand( "explorer.delete.persist", null, null, () => !this._inputInProgress() && this._onDeleteItem({ persist: true }), ), ) this._oni.commands.registerCommand( new CallbackCommand( "explorer.delete", null, null, () => !this._inputInProgress() && this._onDeleteItem({ persist: false }), ), ) this._oni.commands.registerCommand( new CallbackCommand( "explorer.yank", "Explorer: Yank Selected Item", "Select a file to move", () => !this._inputInProgress() && this._onYankItem(), ), ) this._oni.commands.registerCommand( new CallbackCommand( "explorer.undo", "Explorer: Undo Last Action", null, () => !this._inputInProgress() && this._onUndoItem(), ), ) this._oni.commands.registerCommand( new CallbackCommand( "explorer.paste", "Explorer: Move/Paste Selected Item", "Paste the last yanked item", () => !this._inputInProgress() && this._onPasteItem(), ), ) this._oni.commands.registerCommand( new CallbackCommand( "explorer.refresh", "Explorer: Refresh The Tree", "Updates the explorer with the latest state on the file system", () => !this._inputInProgress() && this._refresh(), ), ) this._oni.commands.registerCommand( new CallbackCommand( "explorer.create.file", "Explorer: Create A New File", null, () => !this._inputInProgress() && this._onCreateNode({ type: "file" }), ), ) this._oni.commands.registerCommand( new CallbackCommand( "explorer.create.folder", "Explorer: Create A New Directory", null, () => !this._inputInProgress() && this._onCreateNode({ type: "folder" }), ), ) this._oni.commands.registerCommand( new CallbackCommand( "explorer.expand.directory", "Explorer: Expand Selected Directory", null, () => !this._inputInProgress() && this._toggleDirectory("expand"), ), ) this._oni.commands.registerCommand( new CallbackCommand( "explorer.collapse.directory", "Explorer: Collapse Selected Directory", null, () => !this._inputInProgress() && this._toggleDirectory("collapse"), ), ) this._oni.commands.registerCommand( new CallbackCommand( "explorer.rename", "Explorer: Rename Selected File/Folder", null, () => !this._inputInProgress() && this._renameItem(), ), ) } private _onSelectionChanged(id: string): void { this._selectedId = id // If we are trying to select a file, check if it's now selected, and if so trigger success. const fileToSelect: string = this._store.getState().fileToSelect if (fileToSelect) { const selectedPath: string = getPathForNode(this._getSelectedItem()) if (selectedPath === fileToSelect) { this._store.dispatch({ type: "SELECT_FILE_SUCCESS" }) } } } private _onOpenItem(id?: string): void { const selectedItem = this._getSelectedItem(id) if (!selectedItem) { return } const state = this._store.getState() switch (selectedItem.type) { case "file": this._oni.editors.activeEditor.openFile(selectedItem.filePath) // FIXME: the editor manager is not a windowSplit aka this // Should be being called with an ID not an active editor windowManager.focusSplit("oni.window.0") return case "container": case "folder": const directoryPath = selectedItem.type === "container" ? selectedItem.name : selectedItem.folderPath const isDirectoryExpanded = ExplorerSelectors.isPathExpanded(state, directoryPath) this._store.dispatch({ type: isDirectoryExpanded ? "COLLAPSE_DIRECTORY" : "EXPAND_DIRECTORY", directoryPath, }) return } } private _getSelectedItem(id: string = this._selectedId): ExplorerSelectors.ExplorerNode { const state = this._store.getState() const nodes = ExplorerSelectors.mapStateToNodeList(state) const items = nodes.filter(item => item.id === id) if (!items || !items.length) { return null } return items[0] } private _getSelectedItemParent(filePath: string): ExplorerSelectors.ExplorerNode { const state = this._store.getState() const nodes = ExplorerSelectors.mapStateToNodeList(state) const parentDir = path.dirname(filePath) const [parentNode] = nodes.filter( item => (item.type === "folder" && item.folderPath === parentDir) || (item.type === "container" && item.name === parentDir), ) return parentNode } private _renameItem = () => { const selected = this._getSelectedItem() if (!selected) { return } this._store.dispatch({ type: "RENAME_START", target: selected }) } private _completeRename = (newName: string) => { const target = this._getSelectedItem() if (!target) { return } this._store.dispatch({ type: "RENAME_COMMIT", newName, target }) } private _cancelRename = () => { this._store.dispatch({ type: "RENAME_CANCEL" }) } private _onCreateNode = ({ type }: { type: "file" | "folder" }) => { this._store.dispatch({ type: "CREATE_NODE_START", nodeType: type }) } private _completeCreation = (newName: string) => { const target = this._getSelectedItem() if (!target) { return } const nodePath = getPathForNode(target) const dirname = target.type === "file" ? path.dirname(nodePath) : nodePath this._store.dispatch({ type: "CREATE_NODE_COMMIT", name: path.join(dirname, newName) }) } private _cancelCreation = () => { this._store.dispatch({ type: "CREATE_NODE_CANCEL" }) } // This is different from on openItem since it only activates if the target is a folder // also it means that each bound key only does one thing aka "h" collapses and "l" // expands they are not toggles private _toggleDirectory(action: "expand" | "collapse"): void { const selectedItem = this._getSelectedItem() if (!selectedItem || selectedItem.type !== "folder") { return } const type = action === "expand" ? "EXPAND_DIRECTORY" : "COLLAPSE_DIRECTORY" this._store.dispatch({ type, directoryPath: selectedItem.folderPath }) } private _onUndoItem(): void { const { register: { undo }, } = this._store.getState() if (undo.length) { this._store.dispatch({ type: "UNDO" }) } } private _onYankItem(): void { const selectedItem = this._getSelectedItem() if (!selectedItem) { return } const { register: { yank }, } = this._store.getState() const inYankRegister = yank.some(({ id }) => id === selectedItem.id) if (!inYankRegister) { this._store.dispatch({ type: "YANK", target: selectedItem }) } else { this._store.dispatch({ type: "CLEAR_REGISTER", ids: [selectedItem.id] }) } } private _onPasteItem(): void { const pasteTarget = this._getSelectedItem() if (!pasteTarget) { return } const { register: { yank }, } = this._store.getState() if (yank.length && pasteTarget) { const sources = yank.map( node => (node.type === "file" ? this._getSelectedItemParent(node.filePath) : node), ) this._store.dispatch({ type: "PASTE", target: pasteTarget, pasted: yank, sources, }) } } private _onDeleteItem({ persist }: { persist: boolean }): void { const selectedItem = this._getSelectedItem() if (!selectedItem) { return } this._store.dispatch({ type: "DELETE", target: selectedItem, persist }) } } ================================================ FILE: browser/src/Services/Explorer/ExplorerStore.ts ================================================ /** * ExplorerStore.ts * * State management for the explorer split */ import * as capitalize from "lodash/capitalize" import * as last from "lodash/last" import * as omit from "lodash/omit" import * as path from "path" import { Reducer, Store } from "redux" import { combineEpics, createEpicMiddleware, Epic } from "redux-observable" import { forkJoin } from "rxjs/observable/forkJoin" import { fromPromise } from "rxjs/observable/fromPromise" import { timer } from "rxjs/observable/timer" import * as Log from "oni-core-logging" import { createStore as createReduxStore } from "./../../Redux" import { configuration } from "./../Configuration" import { EmptyNode, ExplorerNode } from "./ExplorerSelectors" import { Notifications } from "./../../Services/Notifications" import { NotificationLevel } from "./../../Services/Notifications/NotificationStore" import { IFileSystem, OniFileSystem } from "./ExplorerFileSystem" export interface IFolderState { type: "folder" fullPath: string } export const DefaultFolderState: IFolderState = { type: "folder", fullPath: null, } export const DefaultRegisterState: IRegisterState = { yank: [], undo: [], paste: EmptyNode, updated: null, create: { active: false, name: null, nodeType: null, }, rename: { active: false, target: null, }, } export interface IFileState { type: "file" fullPath: string } export interface IRecentFile { filePath: string modified: boolean } export type FolderOrFile = IFolderState | IFileState export interface ExpandedFolders { [fullPath: string]: FolderOrFile[] } export interface OpenedFiles { [fullPath: string]: any } export type RegisterAction = | IPasteAction | IDeleteSuccessAction | IDeleteFailAction | IDeleteAction | IUndoAction | IUndoSuccessAction | IUndoFailAction | IRenameSuccessAction | IRenameFailAction | ICreateNodeSuccessAction | ICreateNodeFailAction interface IRegisterState { yank: ExplorerNode[] paste: ExplorerNode undo: RegisterAction[] rename: { active: boolean target: ExplorerNode } updated: string[] create: { active: boolean name: string nodeType: "file" | "folder" } } export interface IExplorerState { // Open workspace rootFolder: IFolderState expandedFolders: ExpandedFolders fileToSelect: string hasFocus: boolean register: IRegisterState } export const DefaultExplorerState: IExplorerState = { rootFolder: null, expandedFolders: {}, fileToSelect: null, hasFocus: false, register: DefaultRegisterState, } export interface IUndoAction { type: "UNDO" } export interface IUndoSuccessAction { type: "UNDO_SUCCESS" } export interface IUndoFailAction { type: "UNDO_FAIL" reason: string } export interface IYankAction { type: "YANK" path: string target: ExplorerNode } export interface IPasteAction { type: "PASTE" target: ExplorerNode pasted: ExplorerNode[] sources: ExplorerNode[] } export interface IDeleteAction { type: "DELETE" target: ExplorerNode persist: boolean } export interface IDeleteSuccessAction { type: "DELETE_SUCCESS" target: ExplorerNode persist: boolean } export interface IDeleteFailAction { type: "DELETE_FAIL" reason: string } export interface IClearRegisterAction { type: "CLEAR_REGISTER" ids: string[] } export interface IExpandDirectoryAction { type: "EXPAND_DIRECTORY" directoryPath: string } export interface IRefreshAction { type: "REFRESH" } export interface ISetRootDirectoryAction { type: "SET_ROOT_DIRECTORY" rootPath: string } export interface ICollapseDirectory { type: "COLLAPSE_DIRECTORY" directoryPath: string } export interface IExpandDirectoryResult { type: "EXPAND_DIRECTORY_RESULT" directoryPath: string children: FolderOrFile[] } export interface ISelectFileAction { type: "SELECT_FILE" filePath: string } export interface ISelectFilePendingAction { type: "SELECT_FILE_PENDING" filePath: string } export interface ISelectFileSuccessAction { type: "SELECT_FILE_SUCCESS" } export interface ISelectFileFailAction { type: "SELECT_FILE_FAIL" reason: string } export interface IEnterAction { type: "ENTER" } export interface ILeaveAction { type: "LEAVE" } export interface IPasteFailAction { type: "PASTE_FAIL" reason: string } export interface IClearUpdateAction { type: "CLEAR_UPDATE" } export interface ICreateNodeStartAction { type: "CREATE_NODE_START" nodeType: "file" | "folder" } export interface ICreateNodeCancelAction { type: "CREATE_NODE_CANCEL" } export interface ICreateNodeCommitAction { type: "CREATE_NODE_COMMIT" name: string } export interface ICreateNodeFailAction { type: "CREATE_NODE_FAIL" reason: string } export interface ICreateNodeSuccessAction { type: "CREATE_NODE_SUCCESS" nodeType: "file" | "folder" name: string } export interface IPasteSuccessAction { type: "PASTE_SUCCESS" moved: IMovedNodes[] } export interface IRenameStartAction { type: "RENAME_START" target: ExplorerNode active: boolean } export interface IRenameSuccessAction { type: "RENAME_SUCCESS" source: string destination: string targetType: string } export interface IRenameFailAction { type: "RENAME_FAIL" reason: string } export interface ICancelRenameAction { type: "RENAME_CANCEL" } export interface IRenameCommitAction { type: "RENAME_COMMIT" target: ExplorerNode newName: string } export interface INotificationSentAction { type: "NOTIFICATION_SENT" typeOfNotification: string } export interface IMovedNodes { node: ExplorerNode destination: string } export type ExplorerAction = | IEnterAction | ILeaveAction | IExpandDirectoryResult | ICollapseDirectory | ISetRootDirectoryAction | IExpandDirectoryAction | IRenameStartAction | IRenameSuccessAction | IRenameFailAction | IRenameCommitAction | ICancelRenameAction | IDeleteFailAction | IRefreshAction | IDeleteAction | IDeleteSuccessAction | IYankAction | IPasteAction | IPasteFailAction | IPasteSuccessAction | IClearUpdateAction | IClearRegisterAction | IUndoAction | IUndoSuccessAction | IUndoFailAction | ICreateNodeStartAction | ICreateNodeFailAction | ICreateNodeCancelAction | ICreateNodeCommitAction | ICreateNodeSuccessAction | INotificationSentAction | ISelectFileAction | ISelectFilePendingAction | ISelectFileSuccessAction | ISelectFileFailAction // Helper functions for Updating state ======================================================== export const removePastedNode = (nodeArray: ExplorerNode[], ids: string[]): ExplorerNode[] => nodeArray.filter(node => !ids.includes(node.id)) export const removeUndoItem = (undoArray: RegisterAction[]): RegisterAction[] => undoArray.slice(0, undoArray.length - 1) const getSourceAndDestPaths = (source: ExplorerNode, dest: ExplorerNode) => { const sourcePath = getPathForNode(source) const destPath = dest.type === "file" ? path.dirname(dest.filePath) : getPathForNode(dest) const destination = path.join(destPath, path.basename(sourcePath)) return { source: sourcePath, destination } } // Do not add un-undoable action to the undo list export const shouldAddDeletion = (action: IDeleteSuccessAction) => (action.persist ? [action] : []) type Updates = | IPasteSuccessAction | IDeleteSuccessAction | IUndoSuccessAction | IRenameSuccessAction | ICreateNodeSuccessAction export const getUpdatedNode = (action: Updates, state?: IRegisterState): string[] => { switch (action.type) { case "PASTE_SUCCESS": return action.moved.map(node => node.destination) case "DELETE_SUCCESS": return [getPathForNode(action.target)] case "RENAME_SUCCESS": return [action.destination] case "CREATE_NODE_SUCCESS": return [action.name] case "UNDO_SUCCESS": const lastAction = last(state.undo) if (lastAction.type === "DELETE_SUCCESS") { return [getPathForNode(lastAction.target)] } else if (lastAction.type === "PASTE") { return lastAction.pasted.map(node => getPathForNode(node)) } else if (lastAction.type === "RENAME_SUCCESS") { return [lastAction.source] } return [] default: return [] } } const shouldExpandDirectory = (targets: ExplorerNode[]): IExpandDirectoryAction[] => targets .map(target => target.type !== "file" && Actions.expandDirectory(getPathForNode(target))) .filter(Boolean) export const getPathForNode = (node: ExplorerNode) => { if (!node) { return null } else if (node.type === "file") { return node.filePath } else if (node.type === "folder") { return node.folderPath } else { return node.name } } // Strongly typed actions/action-creators to be used in multiple epics const Actions = { Null: { type: null } as ExplorerAction, createNode: (args: { nodeType: "file" | "folder"; name: string }) => ({ type: "CREATE_NODE_SUCCESS", ...args } as ICreateNodeSuccessAction), createNodeFail: (reason: string) => ({ type: "CREATE_NODE_FAIL", reason } as ICreateNodeFailAction), pasteSuccess: (moved: IMovedNodes[]) => ({ type: "PASTE_SUCCESS", moved } as IPasteSuccessAction), pasteFail: (reason: string) => ({ type: "PASTE_FAIL", reason } as IPasteFailAction), undoFail: (reason: string) => ({ type: "UNDO_FAIL", reason } as IUndoFailAction), undoSuccess: { type: "UNDO_SUCCESS" } as IUndoSuccessAction, renameSuccess: (args: { source: string destination: string targetType: string }): IRenameSuccessAction => ({ type: "RENAME_SUCCESS", ...args, }), renameFail: (reason: string) => ({ type: "RENAME_FAIL", reason } as IRenameFailAction), paste: { type: "PASTE" } as IPasteAction, refresh: { type: "REFRESH" } as IRefreshAction, deleteFail: (reason: string) => ({ type: "DELETE_FAIL", reason } as IDeleteFailAction), clearRegister: (ids: string[]) => ({ type: "CLEAR_REGISTER", ids } as IClearRegisterAction), clearUpdate: { type: "CLEAR_UPDATE" } as IClearUpdateAction, deleteSuccess: (target: ExplorerNode, persist: boolean): IDeleteSuccessAction => ({ type: "DELETE_SUCCESS", target, persist, }), notificationSent: (typeOfNotification: string): INotificationSentAction => ({ type: "NOTIFICATION_SENT", typeOfNotification, }), expandDirectory: (directoryPath: string): IExpandDirectoryAction => ({ type: "EXPAND_DIRECTORY", directoryPath, }), expandDirectoryResult: ( pathToExpand: string, sortedFilesAndFolders: FolderOrFile[], ): ExplorerAction => { return { type: "EXPAND_DIRECTORY_RESULT", directoryPath: pathToExpand, children: sortedFilesAndFolders, } }, selectFile: (filePath: string): ISelectFileAction => ({ type: "SELECT_FILE", filePath, }), } // Yank, Paste Delete register ============================= // The undo register is essentially a list of past actions // => [paste, delete, paste], when an action is carried out // it is added to the back of the stack when an undo is triggered // it is removed. // The most recently actioned node(s) path(s) are set to the value of // the updated field, this is used to animate updated fields, // Updates are cleared shortly after to prevent re-animating export const yankRegisterReducer: Reducer = ( state: IRegisterState = DefaultRegisterState, action: ExplorerAction, ) => { switch (action.type) { case "CREATE_NODE_START": return { ...state, create: { active: true, name: null, nodeType: action.nodeType, }, } case "CREATE_NODE_FAIL": case "CREATE_NODE_CANCEL": return { ...state, create: { active: false, name: null, nodeType: null, }, } case "CREATE_NODE_SUCCESS": return { ...state, create: { active: false, name: null, nodeType: null, }, updated: getUpdatedNode(action), undo: [...state.undo, action], } case "RENAME_START": return { ...state, rename: { active: true, target: action.target, }, } case "RENAME_CANCEL": return { ...state, rename: { active: false, target: null, }, } case "RENAME_SUCCESS": return { ...state, undo: [...state.undo, action], updated: getUpdatedNode(action), rename: { active: false, target: null, }, } case "YANK": return { ...state, yank: [...state.yank, action.target], } case "PASTE": return { ...state, paste: action.target, undo: [...state.undo, action], } case "PASTE_SUCCESS": return { ...state, updated: getUpdatedNode(action), } case "UNDO_SUCCESS": return { ...state, undo: removeUndoItem(state.undo), updated: getUpdatedNode(action, state), } case "CLEAR_REGISTER": return { ...state, paste: EmptyNode, yank: removePastedNode(state.yank, action.ids), } case "CLEAR_UPDATE": return { ...state, updated: null, } case "DELETE_SUCCESS": return { ...state, undo: [...state.undo, ...shouldAddDeletion(action)], updated: getUpdatedNode(action), } case "LEAVE": return { ...DefaultRegisterState, undo: state.undo } case "DELETE_FAIL": default: return state } } export const rootFolderReducer: Reducer = ( state: IFolderState = DefaultFolderState, action: ExplorerAction, ) => { switch (action.type) { case "SET_ROOT_DIRECTORY": return { ...state, type: "folder", fullPath: action.rootPath, } default: return state } } export const expandedFolderReducer: Reducer = ( state: ExpandedFolders = {}, action: ExplorerAction, ) => { switch (action.type) { case "SET_ROOT_DIRECTORY": return {} case "COLLAPSE_DIRECTORY": return omit(state, [action.directoryPath]) case "EXPAND_DIRECTORY_RESULT": return { ...state, [action.directoryPath]: action.children, } default: return state } } export const hasFocusReducer: Reducer = ( state: boolean = false, action: ExplorerAction, ) => { switch (action.type) { case "ENTER": return true case "LEAVE": return false default: return state } } export const selectFileReducer: Reducer = ( state: string = null, action: ExplorerAction, ) => { switch (action.type) { case "SELECT_FILE_PENDING": return action.filePath case "SELECT_FILE_SUCCESS": return null default: return state } } export const reducer: Reducer = ( state: IExplorerState = DefaultExplorerState, action: ExplorerAction, ) => { return { ...state, hasFocus: hasFocusReducer(state.hasFocus, action), rootFolder: rootFolderReducer(state.rootFolder, action), expandedFolders: expandedFolderReducer(state.expandedFolders, action), fileToSelect: selectFileReducer(state.fileToSelect, action), register: yankRegisterReducer(state.register, action), } } const setRootDirectoryEpic: Epic = (action$, store) => action$.ofType("SET_ROOT_DIRECTORY").map((action: ISetRootDirectoryAction) => { if (!action.rootPath) { return Actions.Null } return Actions.expandDirectory(action.rootPath) }) const sortFilesAndFoldersFunc = (a: FolderOrFile, b: FolderOrFile) => { if (a.type < b.type) { return 1 } else if (a.type > b.type) { return -1 } else { if (a.fullPath < b.fullPath) { return -1 } else { return 1 } } } // Send Notifications ================================================== interface INotificationDetails { title: string details: string level?: NotificationLevel } const sendExplorerNotification = ( { title, details, level = "success" }: INotificationDetails, notifications: Notifications, ) => { const notification = notifications.createItem() notification.setContents(title, details) notification.setLevel(level) notification.setExpiration(5_000) notification.show() } interface MoveNotificationArgs { type: string name: string destination: string notifications: Notifications } const moveNotification = ({ type, name, destination, notifications }: MoveNotificationArgs) => sendExplorerNotification( { title: `${capitalize(type)} Moved`, details: `Successfully moved ${name} to ${destination}`, }, notifications, ) interface SendNotificationArgs { name: string type: string notifications: Notifications } const deletionNotification = ({ type, name, notifications }: SendNotificationArgs): void => sendExplorerNotification( { title: `${capitalize(type)} deleted`, details: `${path.basename(name)} was deleted successfully`, }, notifications, ) interface RenameNotificationArgs { type: string source: string destination: string notifications: Notifications } const renameNotification = ({ notifications, type, source, destination, }: RenameNotificationArgs): void => sendExplorerNotification( { title: `${capitalize(type)} renamed successfully`, details: `${path.basename(source)} renamed to ${path.basename(destination)}`, }, notifications, ) interface CreationNotificationArgs { notifications: Notifications type: "file" | "folder" name: string } const creationNotification = ({ notifications, type, name }: CreationNotificationArgs): void => sendExplorerNotification( { title: `${capitalize(type)} created successfully`, details: `${name} created`, }, notifications, ) interface ErrorNotificationArgs { type: string reason: string notifications: Notifications } const errorNotification = ({ type, reason, notifications }: ErrorNotificationArgs): void => sendExplorerNotification( { title: `${capitalize(type)} Failed`, details: reason, level: "warn", }, notifications, ) interface Dependencies { fileSystem: IFileSystem notifications: Notifications } // EPICS ============================================================= type ExplorerEpic = Epic export const pasteEpic: ExplorerEpic = (action$, store, { fileSystem }) => action$.ofType("PASTE").mergeMap(({ target, pasted }: IPasteAction) => { const ids = pasted.map(item => item.id) const clearRegister = Actions.clearRegister(ids) return forkJoin( pasted.map(async yankedItem => { const { source, destination } = getSourceAndDestPaths(yankedItem, target) await fileSystem.move(source, destination) return { node: yankedItem, destination } }), ) .flatMap(moved => { return [ clearRegister, ...shouldExpandDirectory([target]), Actions.refresh, Actions.pasteSuccess(moved), ] }) .catch(error => { Log.warn(error) return [clearRegister, Actions.pasteFail(error.message)] }) }) const successActions = (maybeDirsNodes: ExplorerNode[] = []) => [ Actions.undoSuccess, ...shouldExpandDirectory(maybeDirsNodes), Actions.refresh, ] const persistOrDeleteNode = async ( filepath: string, fileSystem: IFileSystem, persist = true, ): Promise => { const maxSize = configuration.getValue("explorer.maxUndoFileSizeInBytes") const persistEnabled = configuration.getValue("explorer.persistDeletedFiles") const canPersistNode = await fileSystem.canPersistNode(filepath, maxSize) persistEnabled && persist && canPersistNode ? await fileSystem.persistNode(filepath) : await fileSystem.deleteNode(filepath) } export const undoEpic: ExplorerEpic = (action$, store, { fileSystem }) => action$.ofType("UNDO").mergeMap(action => { const { register: { undo }, } = store.getState() const lastAction = last(undo) switch (lastAction.type) { case "PASTE": const { pasted, target: dir, sources } = lastAction const filesAndFolders = pasted.map(file => getSourceAndDestPaths(file, dir)) return fromPromise(fileSystem.moveNodesBack(filesAndFolders)) .flatMap(() => successActions(sources)) .catch(error => { Log.warn(error) return [Actions.undoFail("Sorry we can't undo the laste paste action")] }) case "DELETE_SUCCESS": const { target } = lastAction return lastAction.persist ? fromPromise(fileSystem.restoreNode(getPathForNode(target))) .flatMap(() => successActions([target])) .catch(error => { Log.warn(error) return [Actions.undoFail("The last deletion cannot be undone, sorry")] }) : [Actions.undoFail("The last deletion cannot be undone, sorry")] case "RENAME_SUCCESS": const { source, destination } = lastAction return fromPromise(fileSystem.move(destination, source)) .flatMap(() => successActions()) .catch(error => { Log.warn(error) return [Actions.undoFail("The last rename could not be undone, sorry")] }) case "CREATE_NODE_SUCCESS": return fromPromise(persistOrDeleteNode(lastAction.name, fileSystem)) .flatMap(() => successActions()) .catch(error => { Log.warn(error) return [ Actions.undoFail( "The last file/folder creation could not be undone, sorry", ), ] }) default: return [Actions.undoFail("Sorry we can't undo the last action")] } }) export const deleteEpic: ExplorerEpic = (action$, store, { fileSystem }) => action$.ofType("DELETE").mergeMap((action: IDeleteAction) => { const { target, persist } = action const filepath = getPathForNode(target) return fromPromise(persistOrDeleteNode(filepath, fileSystem, persist)) .flatMap(() => [Actions.deleteSuccess(target, persist), Actions.refresh]) .catch(error => { Log.warn(error) return [Actions.deleteFail(error.message)] }) }) export const renameEpic: ExplorerEpic = (action$, store, { fileSystem }) => action$.ofType("RENAME_COMMIT").mergeMap(({ newName, target }: IRenameCommitAction) => { const source = getPathForNode(target) const destination = path.join(path.dirname(source), newName) return fromPromise(fileSystem.move(source, destination)) .flatMap(() => [ Actions.renameSuccess({ source, destination, targetType: target.type }), Actions.refresh, ]) .catch(error => { Log.warn(error) return [Actions.renameFail(error.message)] }) }) export const clearYankRegisterEpic: ExplorerEpic = (action$, store) => action$.ofType("YANK").mergeMap((action: IYankAction) => { const oneMinute = 60_000 return timer(oneMinute).mapTo(Actions.clearRegister([action.target.id])) }) export const clearUpdateEpic: ExplorerEpic = (action$, store) => action$ .ofType("PASTE_SUCCESS", "UNDO_SUCCESS", "DELETE_SUCCESS") .mergeMap(() => timer(2_000).mapTo(Actions.clearUpdate)) const refreshEpic: ExplorerEpic = (action$, store) => action$ .ofType("REFRESH") .auditTime(300) .mergeMap(() => { const state = store.getState() return Object.keys(state.expandedFolders).map(p => { return Actions.expandDirectory(p) }) }) const expandDirectoryEpic: ExplorerEpic = (action$, store, { fileSystem }) => action$.ofType("EXPAND_DIRECTORY").flatMap(async (action: ExplorerAction) => { if (action.type !== "EXPAND_DIRECTORY") { return Actions.Null } const pathToExpand = action.directoryPath const filesAndFolders = await fileSystem.readdir(pathToExpand) const sortedFilesAndFolders = filesAndFolders.sort(sortFilesAndFoldersFunc) return Actions.expandDirectoryResult(pathToExpand, sortedFilesAndFolders) }) export const selectFileEpic: ExplorerEpic = (action$, store, { fileSystem }) => action$.ofType("SELECT_FILE").mergeMap(({ filePath }: ISelectFileAction) => { const rootPath = store.getState().rootFolder.fullPath // We need to resolve any symlinks, since the buffer and workspace path can otherwise // appear to be unrelated (at least on OSX). return fromPromise( Promise.all([fileSystem.realpath(rootPath), fileSystem.realpath(filePath)]), ).flatMap(([realRootPath, realFilePath]): ExplorerAction[] => { const relPath = path.relative(realRootPath, realFilePath) // Can only select files in the workspace. if (relPath.startsWith("..") || path.isAbsolute(relPath)) { const failure: ISelectFileFailAction = { type: "SELECT_FILE_FAIL", reason: `File is not in workspace: ${filePath}`, } return [failure] } // Get the list of directories to expand in the Explorer. const relDirectoryPath = path.relative(realRootPath, path.dirname(realFilePath)) const directories = relDirectoryPath.split(path.sep) const actions = [] // Expand each directory in turn from the project root down to the file we want. for (let dirNum = 1; dirNum <= directories.length; dirNum++) { const relParentDirectoryPath = directories.slice(0, dirNum).join(path.sep) const parentDirectoryPath = path.join(rootPath, relParentDirectoryPath) actions.push(Actions.expandDirectory(parentDirectoryPath)) } // Update the state with the file path we want the VimNaviator to select. const pending: ISelectFilePendingAction = { type: "SELECT_FILE_PENDING", filePath: realFilePath, } actions.push(pending) return actions }) }) export const createNodeEpic: ExplorerEpic = (action$, store, { fileSystem }) => action$.ofType("CREATE_NODE_COMMIT").mergeMap(({ name }: ICreateNodeCommitAction) => { const { register: { create: { nodeType }, }, } = store.getState() const createFileOrFolder = nodeType === "file" ? fileSystem.writeFile(name) : fileSystem.mkdir(name) return fromPromise(createFileOrFolder) .flatMap(() => [Actions.createNode({ nodeType, name }), Actions.selectFile(name)]) .catch(error => [Actions.createNodeFail(error.message)]) }) export const notificationEpic: ExplorerEpic = (action$, store, { notifications }) => action$ .ofType( "PASTE_SUCCESS", "DELETE_SUCCESS", "RENAME_SUCCESS", "CREATE_NODE_SUCCESS", "RENAME_FAIL", "PASTE_FAIL", "DELETE_FAIL", "CREATE_NODE_FAIL", "SELECT_FILE_FAIL", ) .map(action => { switch (action.type) { case "PASTE_SUCCESS": action.moved.map(item => moveNotification({ notifications, type: item.node.type, name: item.node.name, destination: item.destination, }), ) return Actions.notificationSent(action.type) case "DELETE_SUCCESS": deletionNotification({ notifications, type: action.target.type, name: action.target.name, }) return Actions.notificationSent(action.type) case "RENAME_SUCCESS": renameNotification({ notifications, type: action.targetType, source: action.source, destination: action.destination, }) return Actions.notificationSent(action.type) case "CREATE_NODE_SUCCESS": creationNotification({ notifications, type: action.nodeType, name: action.name, }) return Actions.notificationSent(action.type) case "PASTE_FAIL": case "DELETE_FAIL": case "RENAME_FAIL": case "CREATE_NODE_FAIL": case "SELECT_FILE_FAIL": const [type] = action.type.split("_") errorNotification({ type, notifications, reason: action.reason, }) return Actions.notificationSent(action.type) default: return Actions.Null } }) interface ICreateStore { fileSystem?: IFileSystem notifications: Notifications } export const createStore = ({ fileSystem = OniFileSystem, notifications, }: ICreateStore): Store => { return createReduxStore("Explorer", reducer, DefaultExplorerState, [ createEpicMiddleware( combineEpics( refreshEpic, setRootDirectoryEpic, createNodeEpic, clearUpdateEpic, clearYankRegisterEpic, renameEpic, pasteEpic, undoEpic, deleteEpic, expandDirectoryEpic, selectFileEpic, notificationEpic, ), { dependencies: { fileSystem, notifications } }, ), ]) } ================================================ FILE: browser/src/Services/Explorer/ExplorerView.tsx ================================================ /** * ExplorerSplit.tsx * */ import * as React from "react" import * as DND from "react-dnd" import HTML5Backend from "react-dnd-html5-backend" import { connect } from "react-redux" import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from "react-virtualized" import { compose } from "redux" import { CSSTransition, TransitionGroup } from "react-transition-group" import { css, enableMouse, styled } from "./../../UI/components/common" import { TextInputView } from "./../../UI/components/LightweightText" import { SidebarEmptyPaneView } from "./../../UI/components/SidebarEmptyPaneView" import { SidebarContainerView, SidebarItemView } from "./../../UI/components/SidebarItemView" import { Sneakable } from "./../../UI/components/Sneakable" import { VimNavigator } from "./../../UI/components/VimNavigator" import { DragAndDrop, Droppeable } from "./../DragAndDrop" import { commandManager } from "./../CommandManager" import { FileIcon } from "./../FileIcon" import * as ExplorerSelectors from "./ExplorerSelectors" import { getPathForNode, IExplorerState } from "./ExplorerStore" type Node = ExplorerSelectors.ExplorerNode export interface INodeViewProps { moveFileOrFolder: (source: Node, dest: Node) => void node: ExplorerSelectors.ExplorerNode isSelected: boolean onClick: () => void onCancelRename: () => void onCompleteRename: (newName: string) => void onCancelCreate?: () => void onCompleteCreate?: (path: string) => void yanked: string[] updated?: string[] isRenaming: Node isCreating: boolean children?: React.ReactNode } const stopPropagation = (fn: () => void) => { return (e?: React.MouseEvent) => { if (e) { e.stopPropagation() } fn() } } const Types = { FILE: "FILE", FOLDER: "FOLDER", } interface IMoveNode { drop: { node: ExplorerSelectors.ExplorerNode } drag: { node: ExplorerSelectors.ExplorerNode } } export const NodeWrapper = styled.div` cursor: pointer; &:hover { text-decoration: underline; } ` const NodeTransitionWrapper = styled.div` transition: all 400ms 50ms ease-in-out; &.move-enter { opacity: 0.01; transform: scale(0.9); } &.move-enter-active { transform: scale(1); opacity: 1; } ` interface ITransitionProps { children: React.ReactNode updated: boolean } const renameStyles = css` width: 100%; background-color: inherit; color: inherit; font-size: inherit; font-family: inherit; padding: 0.5em; box-sizing: border-box; border: 2px solid ${p => p.theme["highlight.mode.normal.background"]} !important; ` const createStyles = css` ${renameStyles}; margin-top: 0.2em; ` const Transition = ({ children, updated }: ITransitionProps) => ( {children} ) export class NodeView extends React.PureComponent { public moveFileOrFolder = ({ drag, drop }: IMoveNode) => { this.props.moveFileOrFolder(drag.node, drop.node) } public isSameNode = ({ drag, drop }: IMoveNode) => { return !(drag.node.name === drop.node.name) } public render() { const { isCreating, isRenaming, isSelected, node } = this.props const renameInProgress = isRenaming.name === node.name && isSelected && !isCreating const creationInProgress = isCreating && isSelected && !renameInProgress return ( {renameInProgress ? ( ) : (
    {this.getElement()} {creationInProgress && ( )}
    )}
    ) } public hasUpdated = (path: string) => !!this.props.updated && this.props.updated.some(nodePath => nodePath === path) public getElement() { const { node } = this.props const yanked = this.props.yanked.includes(node.id) switch (node.type) { case "file": return ( { const updated = this.hasUpdated(node.filePath) return ( } /> ) }} /> ) case "container": return ( true} render={({ isOver }) => { return ( ) }} /> ) case "folder": return ( { const updated = this.hasUpdated(node.folderPath) return ( ) }} /> ) default: return
    {JSON.stringify(node)}
    } } } export interface IExplorerViewContainerProps { moveFileOrFolder: (source: Node, dest: Node) => void onSelectionChanged: (id: string) => void onClick: (id: string) => void onCancelRename: () => void onCompleteRename: (newName: string) => void yanked?: string[] isCreating?: boolean isRenaming?: Node onCancelCreate?: () => void onCompleteCreate?: (path: string) => void } export interface IExplorerViewProps extends IExplorerViewContainerProps { nodes: ExplorerSelectors.ExplorerNode[] isActive: boolean updated: string[] idToSelect: string } interface ISneakableNode extends IExplorerViewProps { node: Node selectedId: string } const SneakableNode = ({ node, selectedId, ...props }: ISneakableNode) => ( props.onClick(node.id)}> props.onClick(node.id)} /> ) const ExplorerContainer = styled.div` height: 100%; ${enableMouse}; ` export class ExplorerView extends React.PureComponent { private _list = React.createRef() private _cache = new CellMeasurerCache({ defaultHeight: 30, fixedWidth: true, }) public openWorkspaceFolder = () => { commandManager.executeCommand("workspace.openFolder") } public getSelectedNode = (selectedId: string) => { return this.props.nodes.findIndex(n => selectedId === n.id) } public propsChanged(keys: Array, prevProps: IExplorerViewProps) { return keys.some(prop => this.props[prop] !== prevProps[prop]) } public componentDidUpdate(prevProps: IExplorerViewProps) { if (this.propsChanged(["isCreating", "isRenaming", "yanked"], prevProps)) { // TODO: if we could determine which nodes actually were involved // in the change this could potentially be optimised this._cache.clearAll() this._list.current.recomputeRowHeights() } } public render() { const ids = this.props.nodes.map(node => node.id) const isActive = this.props.isActive && !this.props.isRenaming && !this.props.isCreating if (!this.props.nodes || !this.props.nodes.length) { return ( ) } return ( this.props.onClick(id)} onSelectionChanged={this.props.onSelectionChanged} render={selectedId => { return ( {measurements => ( (
    )} /> )}
    ) }} />
    ) } } const getIdToSelect = (fileToSelect: string, nodes: ExplorerSelectors.ExplorerNode[]) => { // If parent has told us to select a file, attempt to convert the file path into a node ID. if (fileToSelect) { const [nodeToSelect] = nodes.filter(node => { const nodePath = getPathForNode(node) return nodePath === fileToSelect }) return nodeToSelect ? nodeToSelect.id : null } return null } const mapStateToProps = ( state: IExplorerState, containerProps: IExplorerViewContainerProps, ): IExplorerViewProps => { const yanked = state.register.yank.map(node => node.id) const { register: { updated, rename }, fileToSelect, } = state const nodes: ExplorerSelectors.ExplorerNode[] = ExplorerSelectors.mapStateToNodeList(state) return { ...containerProps, isActive: state.hasFocus, nodes, updated, yanked, idToSelect: getIdToSelect(fileToSelect, nodes), isCreating: state.register.create.active, isRenaming: rename.active && rename.target, } } export const Explorer = compose(connect(mapStateToProps), DND.DragDropContext(HTML5Backend))( ExplorerView, ) ================================================ FILE: browser/src/Services/Explorer/index.tsx ================================================ /** * Explorer/index.tsx * * Entry point for explorer-related features */ import * as Oni from "oni-api" import { CallbackCommand } from "./../CommandManager" import { Configuration } from "./../Configuration" import { SidebarManager } from "./../Sidebar" import { ExplorerSplit } from "./ExplorerSplit" export const activate = ( oni: Oni.Plugin.Api, configuration: Configuration, sidebarManager: SidebarManager, ) => { configuration.registerSetting("explorer.autoRefresh", { description: "When set to true, the explorer will listen for changes on the file system and refresh automatically.", requiresReload: true, defaultValue: false, }) const explorerSplit: ExplorerSplit = new ExplorerSplit(oni) sidebarManager.add("files-o", explorerSplit) const explorerId = "oni.sidebar.explorer" oni.commands.registerCommand( new CallbackCommand( "explorer.toggle", "Explorer: Toggle Visibility", "Toggles the explorer in the sidebar", () => sidebarManager.toggleVisibilityById(explorerId), () => !!oni.workspace.activeWorkspace, ), ) oni.commands.registerCommand( new CallbackCommand( "explorer.locate.buffer", "Explorer: Locate Current Buffer", "Locate current buffer in file tree", () => { if (sidebarManager.activeEntryId !== explorerId || !sidebarManager.isVisible) { sidebarManager.setActiveEntry(explorerId) } explorerSplit.locateFile(oni.editors.activeEditor.activeBuffer.filePath) }, () => !!oni.workspace.activeWorkspace, ), ) } ================================================ FILE: browser/src/Services/FileIcon.tsx ================================================ /** * Icons * * - Data source for icons present in Oni * - Loads icons based on the `ui.iconTheme` configuration setting */ import * as React from "react" import { css, keyframes, styled, withProps } from "../UI/components/common" import { getInstance } from "./IconThemes" 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 : "")} ` export interface IFileIconProps { fileName: string language?: string isLarge?: boolean playAppearAnimation?: boolean } export const FileIcon = (props: IFileIconProps) => { if (!props.fileName) { return null } const icons = getInstance() const className = icons.getIconClassForFile(props.fileName, props.language) + (props.isLarge ? " fa-lg" : "") return ( ) } export const getFileIcon = (fileName: string) => ================================================ FILE: browser/src/Services/FileMappings.ts ================================================ /** * FileMappings.ts * * Shared code / utilities for mapping files */ import * as path from "path" export interface IFileMapping { sourceFolder: string sourceFilesGlob?: string mappedFolder: string mappedFileName: string templateFilePath?: string } export interface IFileMappingResult { fullPath: string templateFileFullPath?: string } export const getMappedFile = ( rootFolder: string, filePath: string, mappings: IFileMapping[], ): IFileMappingResult | null => { const mappingsThatApply = mappings.filter(m => doesMappingMatchFile(rootFolder, filePath, m)) if (mappingsThatApply.length === 0) { return null } const mapping = mappingsThatApply[0] const fullPath = getMappedFileFromMapping(rootFolder, filePath, mapping) const templateFileFullPath = mapping.templateFilePath ? path.join(rootFolder, mapping.templateFilePath) : null return { fullPath, templateFileFullPath, } } export const doesMappingMatchFile = ( rootFolder: string, filePath: string, mapping: IFileMapping, ): boolean => { return filePath.indexOf(path.join(rootFolder, mapping.sourceFolder)) === 0 } export const getMappedFileFromMapping = ( rootFolder: string, filePath: string, mapping: IFileMapping, ): string | null => { const fullSourceRoot = path.join(rootFolder, mapping.sourceFolder) const difference = getPathDifference(fullSourceRoot, path.dirname(filePath)) // Resolve the variables in the file path, like `${fileName}` const resolvedMappedFile = replaceVariablesInFileName(mapping.mappedFileName, filePath) const mappedFile = path.join(rootFolder, mapping.mappedFolder, difference, resolvedMappedFile) return mappedFile } export const replaceVariablesInFileName = ( mappingFileNameWithVariables: string, originalFilePath: string, ): string => { const originalFileNameWithExtension = path.basename(originalFilePath) const originalExtension = path.extname(originalFileNameWithExtension) const originalFileNameWithoutExtension = path.basename(originalFilePath, originalExtension) let ret = mappingFileNameWithVariables // Resolve '${fileName}' variable ret = ret.split("${fileName}").join(originalFileNameWithoutExtension) // tslint:disable-line return ret } export const getPathDifference = (path1: string, path2: string): string => { const path1Parts = splitPath(path1) || [] const path2Parts = splitPath(path2) || [] const deltaPathParts = [] const basePathParts = path1Parts.length < path2Parts.length ? path1Parts : path2Parts const diffPathParts = path1Parts.length < path2Parts.length ? path2Parts : path1Parts let idx = 0 let isEqual: boolean = true while (idx < diffPathParts.length) { if (idx >= basePathParts.length) { deltaPathParts.push(diffPathParts[idx]) } else { if (isEqual && basePathParts[idx] === diffPathParts[idx]) { // just continue } else { isEqual = false deltaPathParts.push(diffPathParts[idx]) } } idx++ } return path.join.apply(path, deltaPathParts) } export const splitPath = (fullPath: string): string[] => { return path.normalize(fullPath).split(path.sep) } ================================================ FILE: browser/src/Services/FileSystemWatcher/index.ts ================================================ import * as chokidar from "chokidar" import { Stats } from "fs" import { Event, IEvent } from "oni-types" import * as Log from "oni-core-logging" export type Targets = string | string[] interface IFSOptions { options?: chokidar.WatchOptions target?: Targets } interface IFileChangeEvent { path: string } interface IStatsChangeEvent { path: string stats: Stats } export class FileSystemWatcher { private _watcher: chokidar.FSWatcher private _onAdd = new Event() private _onAddDir = new Event() private _onDelete = new Event() private _onDeleteDir = new Event() private _onMove = new Event() private _onChange = new Event() constructor({ target, options }: IFSOptions) { this._watcher = chokidar.watch(target, options) this._watcher.on("ready", () => { this._attachEventListeners() }) this._watcher.on("error", err => { Log.warn("FileSystemWatcher encountered an error: " + err) }) } public watch(target: Targets) { return this._watcher.add(target) } public unwatch(target: Targets) { return this._watcher.unwatch(target) } public close() { this._watcher.close() } private _attachEventListeners() { this._watcher.on("add", path => { return this._onAdd.dispatch(path) }) this._watcher.on("change", path => { return this._onChange.dispatch(path) }) this._watcher.on("move", path => { return this._onMove.dispatch(path) }) this._watcher.on("unlink", path => { return this._onDelete.dispatch(path) }) this._watcher.on("unlinkDir", path => { return this._onDeleteDir.dispatch(path) }) this._watcher.on("addDir", (path, stats) => { return this._onAddDir.dispatch({ path, stats }) }) } get allWatched(): chokidar.WatchedPaths { return this._watcher.getWatched() } get onChange(): IEvent { return this._onChange } get onDelete(): IEvent { return this._onDelete } get onDeleteDir(): IEvent { return this._onDeleteDir } get onMove(): IEvent { return this._onMove } get onAdd(): IEvent { return this._onAdd } get onAddDir(): IEvent { return this._onAddDir } } ================================================ FILE: browser/src/Services/FocusManager.ts ================================================ /* * FocusManager.ts */ import * as Log from "oni-core-logging" class FocusManager { private _focusElementStack: HTMLElement[] = [] public get focusedElement(): HTMLElement | null { return this._focusElementStack.length > 0 ? this._focusElementStack[0] : null } public pushFocus(element: HTMLElement) { this._focusElementStack = [element, ...this._focusElementStack] window.setTimeout(() => this.enforceFocus(), 0) } public popFocus(element: HTMLElement) { this._focusElementStack = this._focusElementStack.filter(elem => elem !== element) this.enforceFocus() } public setFocus(element: HTMLElement): void { if (element) { this._focusElementStack = [element] element.focus() } else { Log.warn("FocusManager.setFocus called with null element") } } public enforceFocus(): void { if (this._focusElementStack.length === 0) { return } const activeElement = this._focusElementStack[0] if (activeElement !== document.activeElement) { activeElement.focus() } } } export const focusManager = new FocusManager() ================================================ FILE: browser/src/Services/IconThemes/IconThemeLoader.ts ================================================ /** * IconThemeLoader.ts * * Class responsible for loading an icon theme */ import * as fs from "fs" import * as Log from "oni-core-logging" import { IIconThemeContribution } from "./../../Plugins/Api/Capabilities" import { IIconTheme } from "./Icons" import { PluginManager } from "./../../Plugins/PluginManager" export interface IIconThemeLoadResult { theme: IIconTheme filePath: string } export interface IIconThemeLoader { loadIconTheme(themeName: string): Promise } export class PluginIconThemeLoader { constructor(private _pluginManager: PluginManager) {} public async loadIconTheme(themeName: string): Promise { const plugins = this._pluginManager.plugins const pluginsWithThemes = plugins.filter(p => { return p.metadata && p.metadata.contributes && p.metadata.contributes.iconThemes }) const allIconThemes = pluginsWithThemes.reduce( (previous: IIconThemeContribution[], current) => { const iconThemes = current.metadata.contributes.iconThemes return [...previous, ...iconThemes] }, [] as IIconThemeContribution[], ) const matchingIconTheme = allIconThemes.find(t => t.id === themeName) if (!matchingIconTheme || !matchingIconTheme.path) { return null } const contents = await new Promise((resolve, reject) => { fs.readFile(matchingIconTheme.path, "utf8", (err, data: string) => { if (err) { reject(err) return } resolve(data) }) }) let theme = null try { theme = JSON.parse(contents) as IIconTheme } catch (ex) { Log.error("Error loading icon theme: " + ex) } return { theme, filePath: matchingIconTheme.path, } } } ================================================ FILE: browser/src/Services/IconThemes/Icons.ts ================================================ /** * Icons * * - Data source for icons present in Oni * - Loads icons based on the `ui.iconTheme` configuration setting */ import * as path from "path" import { Event, IEvent } from "oni-types" import { PluginManager } from "./../../Plugins/PluginManager" import { PluginIconThemeLoader } from "./IconThemeLoader" import { StyleWriter } from "./StyleWriter" import * as Utility from "./../../Utility" export interface IIconFontSource { path: string format: string } export interface IIconFont { id: string src: IIconFontSource[] weight: string style: string size: string } export interface IIconDefinition { fontCharacter: string fontColor: string } export interface IIconInfo extends IIconDefinition { fontFamily: string weight: string style: string size: string } export interface IconDefinitions { [key: string]: IIconDefinition } // File extension -> icon definition key export interface FileDefinitions { [extension: string]: string } // File name -> icon definition key export interface FileNames { [fileName: string]: string } // Language id -> icon definition key export interface Language { [language: string]: string } export interface IIconTheme { fonts: IIconFont[] iconDefinitions: IconDefinitions file: string fileExtensions: FileDefinitions fileNames: FileNames languageIds: Language light?: IIconTheme } export class Icons { private _activeIconTheme: IIconTheme = null private _onIconThemeChangedEvent: Event = new Event() public get activeIconTheme(): IIconTheme { return this._activeIconTheme } public get onIconThemeChanged(): IEvent { return this._onIconThemeChangedEvent } constructor(private _pluginManager: PluginManager) {} public getIconClassForFile(fileName: string, language?: string): string { if (!this._activeIconTheme) { return null } const normalizedFileName = fileName.toLowerCase() const classBase = "fa oni-icon oni-icon-" // First, see if there is a matching file name if (this._activeIconTheme.fileNames) { const fileNameIcon = this._activeIconTheme.fileNames[normalizedFileName] if (fileNameIcon) { return classBase + fileNameIcon } } // Next, see if there is a matching extension if (this._activeIconTheme.fileExtensions) { const extension = path.extname(fileName) if (extension && extension.length > 1) { const extensionWithoutPeriod = extension.substring(1, extension.length) const matchingExtension = this._activeIconTheme.fileExtensions[ extensionWithoutPeriod ] if (matchingExtension) { return classBase + matchingExtension } } } // Finally, see if there is a matching language if (language && this._activeIconTheme.languageIds) { const matchingLanguage = this._activeIconTheme.languageIds[language] if (matchingLanguage) { return classBase + matchingLanguage } } if (this._activeIconTheme.file) { return classBase + this._activeIconTheme.file } return null } public async applyIconTheme(themeName: string): Promise { const iconThemeLoader = new PluginIconThemeLoader(this._pluginManager) const loadResults = await iconThemeLoader.loadIconTheme(themeName) if (!loadResults || !loadResults.theme) { return } this._activeIconTheme = loadResults.theme const newStyle = document.createElement("style") const styleWriter = new StyleWriter("oni-icon") const fonts = this._activeIconTheme.fonts || [] fonts.forEach(font => { if (!font.src || !font.src.length) { return } const fontSrc = font.src[0] const fontPath = Utility.normalizePath( path.join(path.dirname(loadResults.filePath), fontSrc.path), ) const fontFormat = fontSrc.format styleWriter.writeFontFace(font.id, fontPath, fontFormat) }) const iconDefinitions = this._activeIconTheme.iconDefinitions if (iconDefinitions) { Object.keys(iconDefinitions).forEach((definitionName: string) => { const definitionContents = iconDefinitions[definitionName] styleWriter.writeIcon( definitionName, definitionContents.fontColor, definitionContents.fontCharacter, ) }) } newStyle.appendChild(document.createTextNode(styleWriter.style)) document.head.appendChild(newStyle) this._onIconThemeChangedEvent.dispatch() } } ================================================ FILE: browser/src/Services/IconThemes/StyleWriter.ts ================================================ /** * StyleWriter.ts * * Helper to generate text for an inline style element */ import * as os from "os" export class StyleWriter { private _style: string = "" public get style(): string { return this._style } constructor(private _primaryClassName: string) {} public writeFontFace(fontFamily: string, sourceUrl: string, format: string): void { // Inspired by: // https://stackoverflow.com/questions/11355147/font-face-changing-via-javascript const fontFaceBlock = [ "@font-face {", ` font-family: ${fontFamily};`, ` src: url('${sourceUrl}') format('${format}');`, "}", ] this._append(fontFaceBlock) const primaryClassBlock = [ ".fa." + this._primaryClassName + " {", "font-family: " + fontFamily + ";", "}", ] this._append(primaryClassBlock) } public writeIcon(iconName: string, fontColor: string, fontCharacter: string): void { const iconClass = this._primaryClassName + "-" + iconName const selector = ".fa." + this._primaryClassName + "." + iconClass if (fontColor) { const primaryClassBlock = [selector + " {", "color: " + fontColor + ";", "}"] this._append(primaryClassBlock) } const pseudoElementBlock = [selector + ":before {", ` content: '${fontCharacter}';`, "}"] this._append(pseudoElementBlock) } private _append(str: string[]): void { this._style += str.join(os.EOL) + os.EOL } } ================================================ FILE: browser/src/Services/IconThemes/index.ts ================================================ /** * Icons * * - Data source for icons present in Oni * - Loads icons based on the `ui.iconTheme` configuration setting */ export * from "./Icons" import { PluginManager } from "./../../Plugins/PluginManager" import { Configuration } from "./../Configuration" import { Icons } from "./Icons" let _icons: Icons = null export const getInstance = (): Icons => { return _icons } export const activate = async ( configuration: Configuration, pluginManager: PluginManager, ): Promise => { _icons = new Icons(pluginManager) const iconTheme = configuration.getValue("ui.iconTheme") await _icons.applyIconTheme(iconTheme) } ================================================ FILE: browser/src/Services/InputManager.ts ================================================ import * as Oni from "oni-api" import { commandManager } from "./CommandManager" export type ActionFunction = () => boolean export type ActionOrCommand = string | ActionFunction export type FilterFunction = () => boolean export interface KeyBinding { action: ActionOrCommand filter?: FilterFunction } export interface KeyBindingMap { [key: string]: KeyBinding[] } const MAX_DELAY_BETWEEN_KEY_CHORD = 250 /* milliseconds */ const MAX_CHORD_SIZE = 6 import { KeyboardResolver } from "./../Input/Keyboard/KeyboardResolver" import { getMetaKeyResolver, ignoreMetaKeyResolver, remapResolver, } from "./../Input/Keyboard/Resolvers" 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[] => { const chords = 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[], ) return chords.slice(0, MAX_CHORD_SIZE) } export class InputManager implements Oni.Input.InputManager { private _boundKeys: KeyBindingMap = {} private _resolver: KeyboardResolver private _keys: KeyPressInfo[] = [] constructor() { this._resolver = new KeyboardResolver() this._resolver.addResolver(ignoreMetaKeyResolver) this._resolver.addResolver(remapResolver) this._resolver.addResolver(getMetaKeyResolver()) } /** * API Methods */ public bind( keyChord: string | string[], action: ActionOrCommand, filterFunction?: () => boolean, ): Oni.DisposeFunction { if (Array.isArray(keyChord)) { const disposalFunctions = keyChord.map(key => this.bind(key, action, filterFunction)) return () => disposalFunctions.forEach(df => df()) } const normalizedKeyChord = keyChord.toLowerCase() const currentBinding = this._boundKeys[normalizedKeyChord] || [] const newBinding = { action, filter: filterFunction } this._boundKeys[normalizedKeyChord] = [...currentBinding, newBinding] return () => { const existingBindings = this._boundKeys[normalizedKeyChord] if (existingBindings) { this._boundKeys[normalizedKeyChord] = existingBindings.filter(f => f !== newBinding) } } } public unbind(keyChord: string | string[]) { if (Array.isArray(keyChord)) { keyChord.forEach(key => this.unbind(key)) return } const normalizedKeyChord = keyChord.toLowerCase() this._boundKeys[normalizedKeyChord] = [] } public unbindAll() { this._boundKeys = {} } /** * Potential API methods */ public hasBinding(keyChord: string): boolean { return !!this._boundKeys[keyChord] } public get resolvers(): KeyboardResolver { return this._resolver } // Returns an array of keys bound to a command public getBoundKeys = (command: string): string[] => { return Object.keys(this._boundKeys).reduce( (prev: string[], currentValue: string) => { const bindings = this._boundKeys[currentValue] if (bindings.find(b => b.action === command)) { return [...prev, currentValue] } else { return prev } }, [] as string[], ) } /** * Internal Methods */ // 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, 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 } const boundKey = this._boundKeys[keyChord] // tslint:disable-next-line prefer-for-of for (let i = 0; i < boundKey.length; i++) { const binding = boundKey[i] // Does the binding pass filter? if (binding.filter && !binding.filter()) { continue } const action = binding.action if (typeof action === "string") { const result = commandManager.executeCommand(action, null) if (result !== false) { return true } } else { const result = action() if (result !== false) { return true } } } return false } } export const inputManager = new InputManager() ================================================ FILE: browser/src/Services/KeyDisplayer/KeyDisplayer.tsx ================================================ /** * KeyDisplayer * * Utility for showing keys while typing */ import * as React from "react" import { Provider } from "react-redux" import { Store } from "redux" import { IDisposable } from "oni-types" import { parseChordParts } from "./../../Input/KeyParser" import { Configuration } from "./../Configuration" import { EditorManager } from "./../EditorManager" import { InputManager } from "./../InputManager" import { Overlay, OverlayManager } from "./../Overlay" import { createStore, KeyDisplayerState } from "./KeyDisplayerStore" import { KeyDisplayerContainer } from "./KeyDisplayerView" export class KeyDisplayer { private _activeOverlay: Overlay = null private _currentResolveSubscription: IDisposable = null private _store: Store constructor( private _configuration: Configuration, private _editorManager: EditorManager, private _inputManager: InputManager, private _overlayManager: OverlayManager, ) { this._store = createStore() } public get isActive(): boolean { return this._currentResolveSubscription !== null } public start(): void { this._currentResolveSubscription = this._inputManager.resolvers.addResolver( (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({ type: "ADD_KEY", key: parseChordParts(resolution).join("+"), timeInMilliseconds: new Date().getTime(), }) this._activeOverlay = this._overlayManager.createItem() this._activeOverlay.setContents( , ) this._activeOverlay.show() return resolution }, ) } public end(): void { this._store.dispatch({ type: "RESET" }) if (this._currentResolveSubscription) { this._currentResolveSubscription.dispose() this._currentResolveSubscription = null } } } ================================================ FILE: browser/src/Services/KeyDisplayer/KeyDisplayerStore.ts ================================================ /** * KeyDisplayerStore * * State management for the KeyDisplayer */ import { Reducer, Store } from "redux" import { createStore as createReduxStore } from "./../../Redux" import { createEpicMiddleware, Epic } from "redux-observable" import "rxjs/add/operator/delay" export interface IKeyPressInfo { timeInMilliseconds: number key: string } const EmptyArray: IKeyPressInfo[] = [] // This is the total 'size'. For example, if this value is 1000, // we will show all key presses over the last 1000ms (1s) 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 = 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 = 10 export interface KeyDisplayerState { keys: IKeyPressInfo[] currentTime: number } const DefaultKeyDisplayerState: KeyDisplayerState = { keys: EmptyArray, currentTime: -1, } export type KeyDisplayerAction = | { type: "ADD_KEY" key: string timeInMilliseconds: number } | { type: "UPDATE_TIME" time: number } | { type: "RESET" } export const reducer: Reducer = ( state: KeyDisplayerState = DefaultKeyDisplayerState, action: KeyDisplayerAction, ) => { switch (action.type) { case "ADD_KEY": return { ...state, keys: [ ...state.keys, { key: action.key, timeInMilliseconds: action.timeInMilliseconds }, ], } case "UPDATE_TIME": return { ...state, currentTime: action.time, keys: state.keys.filter( key => key.timeInMilliseconds > action.time - WindowToShowInMilliseconds, ), } case "RESET": return DefaultKeyDisplayerState default: return state } } export const getGroupedKeys = (currentTime: number, keys: IKeyPressInfo[]): IKeyPressInfo[][] => { const activeKeys = keys.sort((a, b) => a.timeInMilliseconds - b.timeInMilliseconds) const coalescedKeys = activeKeys.reduce( (prev: IKeyPressInfo[][], cur) => { const lastGroup = prev[prev.length - 1] if (lastGroup.length === 0) { return [...prev, [cur]] } else { const lastItemInLastGroup = lastGroup[lastGroup.length - 1] const diffTime = Math.abs( lastItemInLastGroup.timeInMilliseconds - cur.timeInMilliseconds, ) if (diffTime < WindowToGroupInMilliseconds) { // Avoid duplicates.. if (diffTime < DupWindow && lastItemInLastGroup.key === cur.key) { return prev } lastGroup.push(cur) return prev } else { return [...prev, [cur]] } } }, [[]], ) const sanitizedKeys = coalescedKeys.filter(group => group.length > 0) return sanitizedKeys } const clearKeysAfterDelayEpic: Epic = (action$, store) => action$ .ofType("ADD_KEY") .delay(WindowToShowInMilliseconds + DupWindow) .map(action => { return { type: "UPDATE_TIME", time: new Date().getTime(), } as KeyDisplayerAction }) export const createStore = (): Store => { return createReduxStore("KeyDisplayer", reducer, DefaultKeyDisplayerState, [ createEpicMiddleware(clearKeysAfterDelayEpic), ]) } ================================================ FILE: browser/src/Services/KeyDisplayer/KeyDisplayerView.tsx ================================================ /** * KeyDisplayer * * Utility for showing keys while typing */ import * as React from "react" import { connect } from "react-redux" import styled from "styled-components" const KeyHeight = 50 const Margin = 10 const KeyWrapper = styled.div` position: absolute; right: 50px; background-color: rgba(0, 0, 0, 0.2); padding: 0px 30px; height: ${KeyHeight}px; line-height: ${KeyHeight}px; font-size: 2em; font-weight: bold; color: white; ` import { getGroupedKeys, IKeyPressInfo, KeyDisplayerState } from "./KeyDisplayerStore" export interface IKeyDisplayerViewProps { groupedKeys: IKeyPressInfo[][] } export class KeyDisplayerView extends React.PureComponent { public render(): JSX.Element { const keyElements = this.props.groupedKeys.map((k, idx) => ( {k.map(keyPress => keyPress.key).join(" ")} )) return
    {keyElements}
    } } export const mapStateToProps = (state: KeyDisplayerState): IKeyDisplayerViewProps => ({ groupedKeys: getGroupedKeys(state.currentTime, state.keys), }) export const KeyDisplayerContainer = connect(mapStateToProps)(KeyDisplayerView) ================================================ FILE: browser/src/Services/KeyDisplayer/index.tsx ================================================ /** * KeyDisplayer * * Utility for showing keys while typing */ import { CommandManager } from "./../CommandManager" import { Configuration } from "./../Configuration" import { EditorManager } from "./../EditorManager" import { InputManager } from "./../InputManager" import { OverlayManager } from "./../Overlay" export interface IKeyPressInfo { timeInMilliseconds: number key: string } import { KeyDisplayer } from "./KeyDisplayer" export const activate = ( commandManager: CommandManager, configuration: Configuration, editorManager: EditorManager, inputManager: InputManager, overlayManager: OverlayManager, ) => { const keyDisplayer = new KeyDisplayer( configuration, editorManager, inputManager, overlayManager, ) commandManager.registerCommand({ command: "keyDisplayer.show", name: "Input: Show key presses", detail: "Show typed keys in an overlay.", execute: () => keyDisplayer.start(), enabled: () => !keyDisplayer.isActive, }) commandManager.registerCommand({ command: "keyDisplayer.hide", name: "Input: Hide key presses", detail: "Turn off visible typing.", execute: () => keyDisplayer.end(), enabled: () => keyDisplayer.isActive, }) } ================================================ FILE: browser/src/Services/Language/CodeAction.ts ================================================ /** * CodeAction.ts * */ // import * as os from "os" import * as types from "vscode-languageserver-types" import * as Log from "oni-core-logging" // import { configuration } from "./../Configuration" // import * as UI from "./../../UI" // import { contextMenuManager } from "./../ContextMenu" import * as LanguageManager from "./LanguageManager" import { editorManager } from "./../EditorManager" import * as Helpers from "./../../Plugins/Api/LanguageClient/LanguageClientHelpers" // const codeActionsContextMenu = contextMenuManager.create() // let lastFileInfo: any = {} // codeActionsContextMenu.onItemSelected.subscribe(async (selectedItem) => { // const commandName = selectedItem.data // const languageManager = LanguageManager.getInstance() // await languageManager.sendLanguageServerRequest(lastFileInfo.language, lastFileInfo.filePath, "workspace/executeCommand", { command: commandName }) // }) // export const expandCodeActions = async () => { // const commands = await getCodeActions() // if (!commands || !commands.length) { // return // } // const mapCommandsToItem = (command: types.Command, idx: number) => ({ // label: command.title, // icon: "lightbulb-o", // data: command.command, // }) // const contextMenuItems = commands.map(mapCommandsToItem) // codeActionsContextMenu.show(contextMenuItems) // } export const getCodeActions = async (): Promise => { const buffer = editorManager.activeEditor.activeBuffer const { language, filePath } = buffer const range = await buffer.getSelectionRange() if (!range) { return null } const languageManager = LanguageManager.getInstance() if (languageManager.isLanguageServerAvailable(language)) { let result: types.Command[] = null try { result = await languageManager.sendLanguageServerRequest( language, filePath, "textDocument/codeAction", Helpers.eventContextToCodeActionParams(filePath, range), ) } catch (ex) { Log.verbose(ex) } if (!result) { return null } // lastFileInfo = { // language, // filePath, // } return result } else { return null } } ================================================ FILE: browser/src/Services/Language/DefinitionRequestor.ts ================================================ /** * DefinitionRequestor.ts * * Abstraction over the action of requesting a definition */ import * as types from "vscode-languageserver-types" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import * as Helpers from "./../../Plugins/Api/LanguageClient/LanguageClientHelpers" import { LanguageManager } from "./LanguageManager" export interface IDefinitionResult { location: types.Location | null token: Oni.IToken | null } export interface IDefinitionRequestor { getDefinition( fileLanguage: string, filePath: string, line: number, column: number, ): Promise } export class LanguageServiceDefinitionRequestor { constructor(private _languageManager: LanguageManager, private _editor: Oni.Editor) {} public async getDefinition( fileLanguage: string, filePath: string, line: number, column: number, ): Promise { const args = { ...Helpers.createTextDocumentPositionParams(filePath, line, column) } const token: Oni.IToken = await this._editor.activeBuffer.getTokenAt(line, column) if (!token) { return { token: null, location: null, } } let result: types.Location | types.Location[] = null try { result = await this._languageManager.sendLanguageServerRequest( fileLanguage, filePath, "textDocument/definition", args, ) } catch (ex) { Log.warn(ex) } return { location: getFirstLocationFromArray(result), token, } } } export const getFirstLocationFromArray = ( result: types.Location | types.Location[], ): types.Location => { if (!result) { return null } if (result instanceof Array) { if (!result.length) { return null } return result[0] } return result } ================================================ FILE: browser/src/Services/Language/Edits.ts ================================================ /** * Edits.ts * * Helpers to work with TextEdits and buffer manipulation */ import * as orderBy from "lodash/orderBy" import * as types from "vscode-languageserver-types" export const sortTextEdits = (edits: types.TextEdit[]): types.TextEdit[] => { const sortedEdits = orderBy( edits, [e => e.range.start.line, e => e.range.start.character], ["desc", "desc"], ) return sortedEdits } export const convertTextDocumentChangesToFileMap = ( edits: Array, ): { [fileUri: string]: types.TextEdit[] } => { if (!edits) { return {} } return edits.reduce((prev, curr) => { if (!("textDocument" in curr)) { return prev } return { ...prev, [curr.textDocument.uri]: edits, } }, {}) } ================================================ FILE: browser/src/Services/Language/FindAllReferences.ts ================================================ /** * QuickInfo.ts * */ import * as types from "vscode-languageserver-types" import { INeovimInstance } from "./../../neovim" import { editorManager } from "./../EditorManager" import * as LanguageManager from "./LanguageManager" import * as Helpers from "./../../Plugins/Api/LanguageClient/LanguageClientHelpers" // TODO: // - Factor out event context to something simpler // - Remove plugin manager export const findAllReferences = async () => { const activeEditor = editorManager.activeEditor if (!activeEditor) { return } const activeBuffer = activeEditor.activeBuffer if (!activeBuffer) { return } const languageManager = LanguageManager.getInstance() if (languageManager.isLanguageServerAvailable(activeBuffer.language)) { const args = { ...Helpers.bufferToTextDocumentPositionParams(activeBuffer), context: { includeDeclaration: true, }, } const { line, column } = activeBuffer.cursor const token = await activeBuffer.getTokenAt(line, column) const result: types.Location[] = await languageManager.sendLanguageServerRequest( activeBuffer.language, activeBuffer.filePath, "textDocument/references", args, ) showReferencesInQuickFix(token.tokenName, result, activeEditor.neovim as any) } } export const showReferencesInQuickFix = async ( token: string, locations: types.Location[], neovimInstance: INeovimInstance, ) => { const convertToOneIndexedForQuickFix = (location: types.Location) => ({ filename: Helpers.unwrapFileUriPath(location.uri), lnum: location.range.start.line + 1, col: location.range.start.character + 1, text: token, }) const quickFixItems = locations.map(item => convertToOneIndexedForQuickFix(item)) neovimInstance.quickFix.setqflist(quickFixItems, ` Find All References: ${token}`) neovimInstance.command("copen") neovimInstance.command(`execute "normal! /${token}\\"`) } ================================================ FILE: browser/src/Services/Language/Formatting.ts ================================================ /** * Rename.tsx */ import * as types from "vscode-languageserver-types" import * as Log from "oni-core-logging" import * as Helpers from "./../../Plugins/Api/LanguageClient/LanguageClientHelpers" import { editorManager } from "./../EditorManager" import * as LanguageManager from "./LanguageManager" export const format = async () => { const activeBuffer = editorManager.activeEditor.activeBuffer const capabilities = await LanguageManager.getInstance().getCapabilitiesForLanguage( activeBuffer.language, ) if (capabilities.documentFormattingProvider) { await formatDocument() } else if (capabilities.documentRangeFormattingProvider) { await rangeFormatDocument() } else { Log.warn("No formatting provider available") } } export const formatDocument = async () => { const activeBuffer = editorManager.activeEditor.activeBuffer const args = { textDocument: { uri: Helpers.wrapPathInFileUri(activeBuffer.filePath), }, } let result: types.TextEdit[] = null try { result = await LanguageManager.getInstance().sendLanguageServerRequest( activeBuffer.language, activeBuffer.filePath, "textDocument/formatting", args, ) } catch (ex) { Log.warn(ex) } if (result) { await activeBuffer.applyTextEdits(result) } } export const rangeFormatDocument = async () => { const activeBuffer = editorManager.activeEditor.activeBuffer const args = { textDocument: { uri: Helpers.wrapPathInFileUri(activeBuffer.filePath), }, range: types.Range.create(0, 0, activeBuffer.lineCount - 1, 0), } let result: types.TextEdit[] = null try { result = await LanguageManager.getInstance().sendLanguageServerRequest( activeBuffer.language, activeBuffer.filePath, "textDocument/rangeFormatting", args, ) } catch (ex) { Log.warn(ex) } if (result) { await activeBuffer.applyTextEdits(result) } } ================================================ FILE: browser/src/Services/Language/HoverRequestor.ts ================================================ /** * HoverRequestor.ts * * Abstraction over the action of requesting a definition */ import * as types from "vscode-languageserver-types" import * as Log from "oni-core-logging" import * as Helpers from "./../../Plugins/Api/LanguageClient/LanguageClientHelpers" import { LanguageManager } from "./LanguageManager" import * as Diagnostics from "./../Diagnostics" export interface IHoverResult { hover: types.Hover errors: types.Diagnostic[] } export interface IHoverRequestor { getHover( fileLanguage: string, filePath: string, line: number, column: number, ): Promise } export class LanguageServiceHoverRequestor { constructor(private _languageManager: LanguageManager) {} public async getHover( language: string, filePath: string, line: number, column: number, ): Promise { const args = { ...Helpers.createTextDocumentPositionParams(filePath, line, column) } let result: types.Hover = null if (this._languageManager.isLanguageServerAvailable(language)) { try { result = await this._languageManager.sendLanguageServerRequest( language, filePath, "textDocument/hover", args, ) } catch (ex) { Log.warn(ex) } } const latestErrors = Diagnostics.getInstance().getErrorsForPosition(filePath, line, column) return { hover: result, errors: latestErrors, } } } ================================================ FILE: browser/src/Services/Language/LanguageClient.ts ================================================ import * as rpc from "vscode-jsonrpc" import * as Log from "oni-core-logging" import { Event } from "oni-types" import { ILanguageClientProcess } from "./LanguageClientProcess" import { PromiseQueue } from "./PromiseQueue" import { IServerCapabilities } from "./ServerCapabilities" import * as LanguageClientTypes from "./LanguageClientTypes" export interface ILanguageClient { serverCapabilities: IServerCapabilities subscribe(notificationName: string, evt: Event): void handleRequest(requestName: string, handler: LanguageClientTypes.RequestHandler): void sendRequest( fileName: string, requestName: string, protocolArguments: LanguageClientTypes.NotificationValueOrThunk, ): Promise sendNotification( fileName: string, notificationName: string, protocolArguments: LanguageClientTypes.NotificationValueOrThunk, ): void } export class LanguageClient implements ILanguageClient { private _promiseQueue = new PromiseQueue() private _connection: rpc.MessageConnection private _subscriptions: { [key: string]: Event } = {} private _requestHandlers: { [key: string]: LanguageClientTypes.RequestHandler } = {} public get serverCapabilities(): IServerCapabilities { return this._languageClientProcess.serverCapabilities } constructor(private _language: string, private _languageClientProcess: ILanguageClientProcess) { this._languageClientProcess.onConnectionChanged.subscribe( (newConnection: rpc.MessageConnection) => { this._connection = newConnection Object.keys(this._subscriptions).forEach(notification => { const evt = this._subscriptions[notification] this._connection.onNotification(notification, (args: any) => { evt.dispatch({ language: this._language, payload: args, }) }) }) Object.keys(this._requestHandlers).forEach(request => { const handler = this._requestHandlers[request] if (handler) { this._connection.onRequest(request, handler) } }) }, ) } public subscribe(notificationName: string, evt: Event) { if (this._connection) { this._connection.onNotification(notificationName, (args: any) => { evt.dispatch({ language: this._language, payload: args, }) }) } this._subscriptions[notificationName] = evt } public handleRequest(requestName: string, handler: (payload: any) => Promise): void { if (this._requestHandlers[requestName]) { Log.error("Only one handler is allowed") } if (this._connection) { this._connection.onRequest(requestName, handler) } this._requestHandlers[requestName] = handler } public sendRequest( fileName: string, requestName: string, protocolArguments: LanguageClientTypes.NotificationValueOrThunk, ): Promise { return this._promiseQueue.enqueuePromise(async () => { this._connection = await this._languageClientProcess.ensureActive(fileName) const args = await LanguageClientTypes.unwrapThunkOrValue( protocolArguments, this.serverCapabilities, ) logInfo(`Request ${requestName} - ${fileName}: start`) const result = await this._connection.sendRequest(requestName, args) logInfo(`Request ${requestName} - ${fileName}: end`) return result }) } public sendNotification( fileName: string, notificationName: string, protocolArguments: LanguageClientTypes.NotificationValueOrThunk, ): void { this._promiseQueue.enqueuePromise(async () => { this._connection = await this._languageClientProcess.ensureActive(fileName) const args = await LanguageClientTypes.unwrapThunkOrValue( protocolArguments, this.serverCapabilities, ) logInfo(`Notification ${notificationName} - ${fileName}: start`) await this._connection.sendNotification(notificationName, args) logInfo(`Notification ${notificationName} - ${fileName}: end`) }) } } const logInfo = (msg: string): void => { Log.info("[Language Client] " + msg) } ================================================ FILE: browser/src/Services/Language/LanguageClientProcess.ts ================================================ /** * LanguageClientProcess.ts * * Responsible for the lifecycle of the language server process, including: * - Creating the language process * - Restarting language process if working path / rootPath differ * - Sending initialization * - Managing the connection * - Getting server capabilities */ import * as path from "path" import { ChildProcess } from "child_process" import * as rpc from "vscode-jsonrpc" import * as Log from "oni-core-logging" import { Event, IEvent } from "oni-types" import { normalizePath } from "./../../Utility" import { LanguageClientLogger } from "./../../Plugins/Api/LanguageClient/LanguageClientLogger" import * as Helpers from "./../../Plugins/Api/LanguageClient/LanguageClientHelpers" import Process from "./../../Plugins/Api/Process" import { IServerCapabilities } from "./ServerCapabilities" export interface ILanguageClientProcess { onConnectionChanged: IEvent serverCapabilities: IServerCapabilities ensureActive(fileName: string): Promise } export type PathResolver = (filePath: string) => Promise export interface ServerRunOptions { /** * Specify `command` to use a shell command to spawn a process */ command?: string /** * Specify `module` to run a JavaScript module */ module?: string /** * Arguments to pass to the language servicew */ args?: string[] workingDirectory?: PathResolver } export interface InitializationOptions { rootPath: PathResolver } export class LanguageClientProcess { private _process: ChildProcess private _connection: rpc.MessageConnection private _onConnectionChangedEvent = new Event() private _lastWorkingDirectory: string = null private _lastRootPath: string = null private _serverCapabilities: IServerCapabilities = {} // Notifies when the connection has changed (due to process restart) // This allows consumers to re-subscribe to events public get onConnectionChanged(): IEvent { return this._onConnectionChangedEvent } public get serverCapabilities(): IServerCapabilities { return this._serverCapabilities } constructor( private _serverOptions: ServerRunOptions, private _initializationOptions: InitializationOptions, private _configuration: any = null, ) {} public async ensureActive(fileName: string): Promise { const rootDir = normalizePath(path.dirname(fileName)) const workingDirectory = await this._serverOptions.workingDirectory(rootDir) const rootPath = await this._initializationOptions.rootPath(rootDir) const shouldRestartServer = workingDirectory !== this._lastWorkingDirectory || this._lastRootPath !== rootPath || !this._connection if (shouldRestartServer) { this._end() await this._start(workingDirectory, rootPath) return this._connection } else { return this._connection } } private async _start(workingDirectory: string, rootPath: string): Promise { const args = this._serverOptions.args || [] const options = { cwd: workingDirectory || process.cwd(), } if (this._serverOptions.command) { Log.info( `[LanguageClientProcess]: Starting process via '${this._serverOptions.command}'`, ) this._process = await Process.spawnProcess(this._serverOptions.command, args, options) } else if (this._serverOptions.module) { Log.info( `[LanguageClientProcess]: Starting process via node script '${ this._serverOptions.module }'`, ) this._process = await Process.spawnNodeScript(this._serverOptions.module, args, options) } else { throw new Error("A command or module must be specified to start the server") } if (!this._process || !this._process.pid) { throw new Error("Unable to start language server process") } Log.info(`[LanguageClientProcess]: Started process ${this._process.pid}`) this._process.stderr.on("data", msg => { Log.info(`[LANGUAGE CLIENT - STDERR]: ${msg}`) // this._statusBar.setStatus(LanguageClientState.Error) }) this._lastWorkingDirectory = workingDirectory this._lastRootPath = rootPath this._connection = rpc.createMessageConnection( new rpc.StreamMessageReader(this._process.stdout) as any, new rpc.StreamMessageWriter(this._process.stdin) as any, new LanguageClientLogger(), ) this._onConnectionChangedEvent.dispatch(this._connection) this._connection.listen() const NoDynamicRegistration = { dynamicRegistration: false, } const SupportedMarkup = ["plaintext"] const oniLanguageClientParams = { clientName: "oni", rootPath, rootUri: Helpers.wrapPathInFileUri(rootPath), capabilities: { workspace: { applyEdit: true, workspaceEdit: { documentChanges: false, }, didChangeConfiguration: NoDynamicRegistration, didChangeWatchedFiles: NoDynamicRegistration, symbol: NoDynamicRegistration, executeCommand: NoDynamicRegistration, }, textDocument: { synchronization: { dynamicRegistration: false, willSave: false, willSaveWaitUntil: false, didSave: true, }, completion: { dynamicRegistration: false, completionItem: { snippetSupport: true, commitCharactersSupport: true, documentationFormat: SupportedMarkup, }, completionItemKind: {}, contextSupport: false, }, hover: { dynamicRegistration: false, contentFormat: SupportedMarkup, }, signatureHelp: { dynamicRegistration: false, signatureInformation: { documentationFormat: SupportedMarkup, }, }, references: NoDynamicRegistration, documentSymbol: NoDynamicRegistration, formatting: NoDynamicRegistration, rangeFormatting: NoDynamicRegistration, definition: NoDynamicRegistration, codeAction: NoDynamicRegistration, codeLens: NoDynamicRegistration, rename: NoDynamicRegistration, }, }, } try { const response: any = await this._connection.sendRequest( "initialize", oniLanguageClientParams, ) Log.info(`[LanguageClientManager]: Initialized`) if (response && response.capabilities) { this._serverCapabilities = response.capabilities } if (this._configuration) { Log.verbose("[LanguageClientProcess]: Sending configuration") this._connection.sendNotification("workspace/didChangeConfiguration", { settings: this._configuration, }) } } catch (ex) { Log.error(ex) } } private _end(): void { Log.info("[LanguageClientProcess] Ending language server session") if (this._connection) { this._connection.dispose() this._connection = null } if (this._process) { this._process.kill() this._process = null } } } ================================================ FILE: browser/src/Services/Language/LanguageClientStatusBar.tsx ================================================ /** * LanguageClientStatusBar.tsx * * Implements status bar for Oni */ import * as electron from "electron" import * as React from "react" import * as Oni from "oni-api" import { Icon } from "./../../UI/Icon" export class LanguageClientStatusBar { private _item: Oni.StatusBarItem private _fileType: string constructor(private _oni: Oni.Plugin.Api) { this._item = this._oni.statusBar.createItem(0, "oni.status.fileType") } public show(fileType: string): void { this._fileType = fileType this._item.setContents( , ) this._item.show() } public setStatus(status: LanguageClientState): void { this._item.setContents() } public hide(): void { this._item.hide() } } export enum LanguageClientState { NotAvailable = 0, Initializing, Initialized, Active, Error, } const SpinnerIcon = "circle-o-notch" const ConnectedIcon = "bolt" const ErrorIcon = "exclamation-circle" interface StatusBarRendererProps { state: LanguageClientState language: string } const getIconFromStatus = (status: LanguageClientState) => { switch (status) { case LanguageClientState.NotAvailable: return null case LanguageClientState.Initializing: return SpinnerIcon case LanguageClientState.Error: return ErrorIcon default: return ConnectedIcon } } const getClassNameFromstatus = (status: LanguageClientState) => { switch (status) { case LanguageClientState.Initializing: return "rotate-animation" default: return "" } } const StatusBarRenderer = (props: StatusBarRendererProps) => { const containerStyle: React.CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center", height: "100%", backgroundColor: "rgb(35, 35, 35)", color: "rgb(200, 200, 200)", paddingRight: "8px", paddingLeft: "8px", } const iconStyle: React.CSSProperties = { paddingRight: "6px", minWidth: "14px", textAlign: "center", } const openDevTools = () => { electron.remote.getCurrentWindow().webContents.openDevTools() } const onClick = props.state === LanguageClientState.Error ? openDevTools : null const iconName = getIconFromStatus(props.state) const icon = iconName ? ( ) : null return (
    {icon} {props.language}
    ) } ================================================ FILE: browser/src/Services/Language/LanguageClientTypes.ts ================================================ import * as types from "vscode-languageserver-types" import { IServerCapabilities } from "./ServerCapabilities" export type NotificationFunction = (capabilities: IServerCapabilities) => any export type NotificationFunctionWithPromise = (capabilities: IServerCapabilities) => Promise export type NotificationValueOrThunk = NotificationFunction | NotificationFunctionWithPromise | any export type RequestHandler = (payload: any) => Promise export interface IResultWithPosition { result: T position: types.Position } export const unwrapThunkOrValue = (val: NotificationValueOrThunk, args: any): Promise => { if (typeof val !== "function") { return Promise.resolve(val) } else { const returnValue = val(args) if (typeof returnValue.then === "function") { return Promise.resolve(returnValue) } else { return returnValue } } } ================================================ FILE: browser/src/Services/Language/LanguageConfiguration.ts ================================================ /** * LanguageConfiguration.ts * * Helper for registering language client information from config */ import * as Log from "oni-core-logging" import { LanguageClient } from "./LanguageClient" import { InitializationOptions, LanguageClientProcess, ServerRunOptions, } from "./LanguageClientProcess" import * as LanguageManager from "./LanguageManager" import { getRootProjectFileFunc } from "./../../Utility" export interface ILightweightLanguageConfiguration { languageServer?: ILightweightLanguageServerConfiguration } export interface ILightweightLanguageServerConfiguration { arguments?: string[] command?: string rootFiles?: string[] configuration?: any } export const createLanguageClientsFromConfiguration = (configurationValues: { [key: string]: any }) => { const languageInfo = expandLanguageConfiguration(configurationValues) const languages = Object.keys(languageInfo) languages.forEach(lang => { createLanguageClientFromConfig(lang, languageInfo[lang]) }) } const expandLanguageConfiguration = (configuration: { [key: string]: any }) => { // Filter for all `language.` keys, ie `language.go.languageServerCommand` const keys = Object.keys(configuration).filter(k => k.indexOf("language.") === 0) if (keys.length === 0) { return {} } const expanded = keys.reduce((prev, current) => { const newValue = expandConfigurationSetting( prev, current.split("."), configuration[current], ) return newValue }, {}) return expanded.language } const expandConfigurationSetting = ( rootObject: any, configurationPath: string[], value: string, ): any => { if (!configurationPath || !configurationPath.length) { return value } const [currentPath, ...remaining] = configurationPath const currentObject = rootObject[currentPath] || {} return { ...rootObject, [currentPath]: expandConfigurationSetting(currentObject, remaining, value), } } const simplePathResolver = (filePath: string) => Promise.resolve(filePath) const createLanguageClientFromConfig = ( language: string, config: ILightweightLanguageConfiguration, ): void => { if (!config || !config.languageServer || !config.languageServer.command) { return } const lightweightCommand = config.languageServer.command const rootFiles = config.languageServer.rootFiles const args = config.languageServer.arguments || [] const configuration = config.languageServer.configuration || null Log.info( `[Language Manager - Config] Registering info for language: ${language} - command: ${ config.languageServer.command }`, ) const commandOrModule = lightweightCommand.endsWith(".js") ? { module: lightweightCommand } : { command: lightweightCommand } let pathResolver = simplePathResolver if (rootFiles) { pathResolver = getRootProjectFileFunc(rootFiles) } const serverRunOptions: ServerRunOptions = { ...commandOrModule, args, workingDirectory: pathResolver, } const initializationOptions: InitializationOptions = { rootPath: pathResolver, } const languageClient = new LanguageClient( language, new LanguageClientProcess(serverRunOptions, initializationOptions, configuration), ) LanguageManager.getInstance().registerLanguageClient(language, languageClient) } ================================================ FILE: browser/src/Services/Language/LanguageEditorIntegration.ts ================================================ /** * LanguageEditorIntegration * * Responsible for listening to editor events, * and hooking up the language service functionality. */ import { Store, Unsubscribe } from "redux" import * as Oni from "oni-api" import * as OniTypes from "oni-types" import * as types from "vscode-languageserver-types" import { Configuration } from "./../Configuration" import { LanguageManager } from "./LanguageManager" import { createStore, DefaultLanguageState, ILanguageState } from "./LanguageStore" import { IDefinitionRequestor, IDefinitionResult, LanguageServiceDefinitionRequestor, } from "./DefinitionRequestor" import { IHoverRequestor, IHoverResult, LanguageServiceHoverRequestor } from "./HoverRequestor" export class LanguageEditorIntegration implements OniTypes.IDisposable { private _subscriptions: OniTypes.IDisposable[] = [] private _store: Store private _storeUnsubscribe: Unsubscribe = null private _lastState: ILanguageState = DefaultLanguageState private _onShowDefinition: OniTypes.Event = new OniTypes.Event< IDefinitionResult >() private _onHideDefinition: OniTypes.Event = new OniTypes.Event() private _onShowHover: OniTypes.Event = new OniTypes.Event() private _onHideHover: OniTypes.Event = new OniTypes.Event() public get onShowDefinition(): OniTypes.IEvent { return this._onShowDefinition } public get onHideDefinition(): OniTypes.IEvent { return this._onHideDefinition } public get onShowHover(): OniTypes.IEvent { return this._onShowHover } public get onHideHover(): OniTypes.IEvent { return this._onHideHover } constructor( private _editor: Oni.Editor, private _configuration: Configuration, private _languageManager?: LanguageManager, private _definitionRequestor?: IDefinitionRequestor, private _hoverRequestor?: IHoverRequestor, ) { this._definitionRequestor = this._definitionRequestor || new LanguageServiceDefinitionRequestor(this._languageManager, this._editor) this._hoverRequestor = this._hoverRequestor || new LanguageServiceHoverRequestor(this._languageManager) this._store = createStore( this._configuration, this._hoverRequestor, this._definitionRequestor, ) this._subscriptions = [ this._editor.onModeChanged.subscribe((newMode: string) => { this._store.dispatch({ type: "MODE_CHANGED", mode: newMode, }) }), this._editor.onBufferEnter.subscribe((bufferEvent: Oni.EditorBufferEventArgs) => { this._store.dispatch({ type: "BUFFER_ENTER", filePath: bufferEvent.filePath, language: bufferEvent.language, }) }), this._editor.onCursorMoved.subscribe((cursorMoveEvent: Oni.Cursor) => { this._store.dispatch({ type: "CURSOR_MOVED", line: cursorMoveEvent.line, column: cursorMoveEvent.column, }) }), this._editor.onBufferScrolled.subscribe(scrollEvent => { this._onHideHover.dispatch() this._onHideDefinition.dispatch() }), ] this._storeUnsubscribe = this._store.subscribe(() => this._onStateUpdate(this._store.getState()), ) } // Explicit gesture to show hover - ignores the setting public showHover(): void { const state = this._store.getState() this._store.dispatch({ type: "HOVER_QUERY", location: { filePath: state.activeBuffer.filePath, language: state.activeBuffer.language, line: state.cursor.line, column: state.cursor.column, }, }) } public dispose(): void { if (this._subscriptions && this._subscriptions.length) { this._subscriptions.forEach(disposable => disposable.dispose()) this._subscriptions = null } if (this._storeUnsubscribe) { this._storeUnsubscribe() this._storeUnsubscribe = null } } private _onStateUpdate(newState: ILanguageState): void { const newLocationResult = getLocationFromState(newState) const lastLocationResult = getLocationFromState(this._lastState) if (newLocationResult && !lastLocationResult) { this._onShowDefinition.dispatch(newState.definitionResult.result) } if (!newLocationResult && lastLocationResult) { this._onHideDefinition.dispatch() } if (newState.hoverResult.result && !this._lastState.hoverResult.result) { this._onShowHover.dispatch(newState.hoverResult.result) } if (!newState.hoverResult.result && this._lastState.hoverResult.result) { this._onHideHover.dispatch() } this._lastState = newState } } const getLocationFromState = (state: ILanguageState): types.Location => { if ( state && state.definitionResult && state.definitionResult.result && state.definitionResult.result.location ) { return state.definitionResult.result.location } else { return null } } ================================================ FILE: browser/src/Services/Language/LanguageManager.ts ================================================ /** * LanguageManager * * Service for integrating language services, like: * - Language server protocol * - Synchronizing language configuration * - Handling custom syntax (TextMate themes) */ import * as os from "os" import * as path from "path" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { Event, IDisposable } from "oni-types" import { ILanguageClient } from "./LanguageClient" import * as LanguageClientTypes from "./LanguageClientTypes" import { IServerCapabilities } from "./ServerCapabilities" import * as Capabilities from "./../../Plugins/Api/Capabilities" import { PluginManager } from "./../../Plugins/PluginManager" import { LanguageClientState, LanguageClientStatusBar } from "./LanguageClientStatusBar" import { listenForWorkspaceEdits } from "./Workspace" import * as Utility from "./../../Utility" import * as Helpers from "./../../Plugins/Api/LanguageClient/LanguageClientHelpers" export interface ILanguageServerNotificationResponse { language: string payload: any } export class LanguageManager { private _languageServerInfo: { [language: string]: ILanguageClient } = {} private _notificationSubscriptions: { [notificationMessage: string]: Event } = {} private _requestHandlers: { [request: string]: LanguageClientTypes.RequestHandler } = {} private _languageClientStatusBar: LanguageClientStatusBar private _currentTrackedFile: string = null constructor(private _oni: Oni.Plugin.Api) { this._languageClientStatusBar = new LanguageClientStatusBar(_oni) this._oni.editors.anyEditor.onBufferEnter.subscribe(async () => this._onBufferEnter()) this._oni.editors.anyEditor.onBufferLeave.subscribe( (bufferInfo: Oni.EditorBufferEventArgs) => { const { language, filePath } = bufferInfo if (this._currentTrackedFile !== filePath) { return } this.sendLanguageServerNotification( language, filePath, "textDocument/didClose", Helpers.pathToTextDocumentIdentifierParms(filePath), ) }, ) this._oni.editors.anyEditor.onBufferChanged.subscribe( async (change: Oni.EditorBufferChangedEventArgs) => { const { language, filePath } = change.buffer if (this._currentTrackedFile !== filePath) { return } const sendBufferThunk = async (capabilities: IServerCapabilities) => { const textDocument = { uri: Helpers.wrapPathInFileUri(filePath), version: change.buffer.version, } // If the service supports incremental capabilities, just pass it directly if (capabilities.textDocumentSync === 2) { return { textDocument, contentChanges: change.contentChanges, } // Otherwise, get the whole buffer and send it up } else { const allBufferLines = await change.buffer.getLines() return { textDocument, contentChanges: [{ text: allBufferLines.join(os.EOL) }], } } } this.sendLanguageServerNotification( language, filePath, "textDocument/didChange", sendBufferThunk, ) }, ) this._oni.editors.anyEditor.onBufferSaved.subscribe( (bufferInfo: Oni.EditorBufferEventArgs) => { const { language, filePath } = bufferInfo if (this._currentTrackedFile !== filePath) { return } this.sendLanguageServerNotification( language, filePath, "textDocument/didSave", Helpers.pathToTextDocumentIdentifierParms(filePath), ) }, ) this.subscribeToLanguageServerNotification("window/showMessage", args => { Log.warn("window/showMessage not implemented: " + JSON.stringify(args.toString())) }) this.subscribeToLanguageServerNotification("window/logMessage", args => { logVerbose(args) }) this.subscribeToLanguageServerNotification("telemetry/event", args => { logDebug(args) }) this.handleLanguageServerRequest("workspace/configuration", async req => { Log.warn("workspace/configuration not implemented: " + JSON.stringify(req.toString())) return null }) this.handleLanguageServerRequest("window/showMessageRequest", async req => { logVerbose(req) return null }) listenForWorkspaceEdits(this, this._oni) } public getCapabilitiesForLanguage(language: string): Promise { const languageClient = this._getLanguageClient(language) if (languageClient) { return Promise.resolve(languageClient.serverCapabilities) } else { return Promise.resolve(null) } } public getTokenRegex(language: string): RegExp { const languageSpecificTokenRegex = this._oni.configuration.getValue( `language.${language}.tokenRegex`, ) as RegExp if (languageSpecificTokenRegex) { return RegExp(languageSpecificTokenRegex, "i") } else { return /[$_a-zA-Z0-9]/i } } public getSignatureHelpTriggerCharacters(language: string): string[] { return ["("] } public getCompletionTriggerCharacters(language: string): string[] { const languageSpecificTriggerChars = this._oni.configuration.getValue( `language.${language}.completionTriggerCharacters`, ) as string[] if (languageSpecificTriggerChars) { return languageSpecificTriggerChars } else { return ["."] } } public isLanguageServerAvailable(language: string): boolean { return !!this._getLanguageClient(language) } public async sendLanguageServerNotification( language: string, filePath: string, protocolMessage: string, protocolPayload: LanguageClientTypes.NotificationValueOrThunk, ): Promise { const languageClient = this._getLanguageClient(language) await this._simulateFakeLag() if (languageClient) { await languageClient.sendNotification(filePath, protocolMessage, protocolPayload) } else { Log.verbose("No supported language") } } public async sendLanguageServerRequest( language: string, filePath: string, protocolMessage: string, protocolPayload: LanguageClientTypes.NotificationValueOrThunk, ): Promise { const languageClient = this._getLanguageClient(language) Log.verbose( "[LANGUAGE] Sending request: " + protocolMessage + "|" + JSON.stringify(protocolPayload), ) await this._simulateFakeLag() if (languageClient) { try { const result = await languageClient.sendRequest( filePath, protocolMessage, protocolPayload, ) this._setStatus(protocolMessage, LanguageClientState.Active) return result } catch (ex) { this._setStatus(protocolMessage, LanguageClientState.Error) throw ex } } else { this._setStatus(protocolMessage, LanguageClientState.NotAvailable) return Promise.reject("No language server registered") } } // Register a handler for requests incoming from the language server public handleLanguageServerRequest( protocolMessage: string, callback: (args: ILanguageServerNotificationResponse) => Promise, ): void { const currentHandler = this._requestHandlers[protocolMessage] if (currentHandler) { return } this._requestHandlers[protocolMessage] = callback const languageClients = Object.values(this._languageServerInfo) languageClients.forEach(ls => { ls.handleRequest(protocolMessage, callback) }) } public subscribeToLanguageServerNotification( protocolMessage: string, callback: (args: ILanguageServerNotificationResponse) => void, ): IDisposable { const currentSubscription = this._notificationSubscriptions[protocolMessage] if (!currentSubscription) { const evt = new Event() this._notificationSubscriptions[protocolMessage] = evt const languageClients = Object.values(this._languageServerInfo) languageClients.forEach(ls => { ls.subscribe(protocolMessage, evt) }) return evt.subscribe(args => callback(args)) } else { return currentSubscription.subscribe(args => callback(args)) } } public unwrapFileUriPath(fileUri: string): string { return Helpers.unwrapFileUriPath(fileUri) } public wrapPathInFileUri(filePath: string): string { return Helpers.wrapPathInFileUri(filePath) } public registerLanguageClient(language: string, languageClient: ILanguageClient): any { if (this._languageServerInfo[language]) { Log.error("Duplicate language server registered for: " + language) return } Object.keys(this._notificationSubscriptions).forEach(notification => { languageClient.subscribe(notification, this._notificationSubscriptions[notification]) }) Object.keys(this._requestHandlers).forEach(request => { languageClient.handleRequest(request, this._requestHandlers[request]) }) this._languageServerInfo[language] = languageClient // If there is already a buffer open matching this language, // we should send a buffer open event if ( this._oni.editors.activeEditor.activeBuffer && this._oni.editors.activeEditor.activeBuffer.language === language ) { this._onBufferEnter() } } private async _onBufferEnter(): Promise { if (!this._oni.editors.activeEditor.activeBuffer) { Log.warn("[LanguageManager] No active buffer on buffer enter") return } const buffer = this._oni.editors.activeEditor.activeBuffer const { language, filePath } = buffer if (!language && filePath) { const pluginManager = this._oni.plugins as PluginManager // TODO: Refactor API const languages = pluginManager.getAllContributionsOfType< Capabilities.ILanguageContribution >(contributes => contributes.languages) const extension = path.extname(filePath) const matchingLanguages = languages.filter( l => l.extensions.indexOf(extension) && extension && extension.length > 0, ) if (matchingLanguages.length > 0) { Log.info( `[LanguageManager::_onBufferEnter] Setting language for file ${filePath} to ${ matchingLanguages[0].id }`, ) await (buffer as any).setLanguage(matchingLanguages[0].id) } } if (language) { this._languageClientStatusBar.show(language) if (this._hasLanguageClient(language)) { this._languageClientStatusBar.setStatus(LanguageClientState.Initializing) } else { this._languageClientStatusBar.setStatus(LanguageClientState.NotAvailable) } } if ( buffer.lineCount > this._oni.configuration.getValue("editor.maxLinesForLanguageServices") ) { this._languageClientStatusBar.setStatus(LanguageClientState.NotAvailable) Log.info( "[LanguageManager] Not sending 'didOpen' because file line count exceeds limit.", ) return } await this.sendLanguageServerNotification( language, filePath, "textDocument/didOpen", async () => { this._currentTrackedFile = filePath const lines = await this._oni.editors.activeEditor.activeBuffer.getLines() const text = lines.join(os.EOL) const version = this._oni.editors.activeEditor.activeBuffer.version this._languageClientStatusBar.setStatus(LanguageClientState.Active) return Helpers.pathToTextDocumentItemParams(filePath, language, text, version) }, ) } private _getLanguageClient(language: string): ILanguageClient { if (!language) { return null } // Fix for #882 - handle cases like `javascript.jsx` where there is // some scoping to the filetype / language name const normalizedLanguage = language.split(".")[0] return this._languageServerInfo[normalizedLanguage] } private _hasLanguageClient(language: string): boolean { return !!this._languageServerInfo[language] } private _setStatus(protocolMessage: string, status: LanguageClientState): void { switch (protocolMessage) { case "textDocument/didOpen": case "textDocument/didChange": this._languageClientStatusBar.setStatus(status) break default: break } } private async _simulateFakeLag(): Promise { const delay = this._oni.configuration.getValue("debug.fakeLag.languageServer") as number if (!delay) { return } else { await Utility.delay(delay) } } } const logVerbose = (args: any) => { if (Log.isVerboseLoggingEnabled()) { Log.verbose("[Language Manager] " + JSON.stringify(args)) } } const logDebug = (args: any) => { if (Log.isDebugLoggingEnabled()) { Log.debug("[Language Manager] " + JSON.stringify(args)) } } let _languageManager: LanguageManager = null export const activate = (oni: Oni.Plugin.Api): void => { _languageManager = new LanguageManager(oni) } export const getInstance = (): LanguageManager => { return _languageManager } ================================================ FILE: browser/src/Services/Language/LanguageStore.ts ================================================ /** * LanguageStore.ts * * Manages state for UI-facing elements, like * hover & definition */ import "rxjs/add/observable/of" import { Observable } from "rxjs/Observable" import { combineReducers, Reducer, Store } from "redux" import { combineEpics, createEpicMiddleware, Epic } from "redux-observable" import { createStore as oniCreateStore } from "./../../Redux" import { Configuration } from "./../Configuration" import { IDefinitionRequestor, IDefinitionResult } from "./DefinitionRequestor" import { IHoverRequestor, IHoverResult } from "./HoverRequestor" export interface ILocation { filePath: string language: string line: number column: number } export interface ILocationBasedResult extends ILocation { result: T | null } export const DefaultLocationBasedResult: ILocationBasedResult = { filePath: null, language: null, line: -1, column: -1, result: null, } export interface IActiveBufferState { filePath: string language: string } export const DefaultActiveBuffer: IActiveBufferState = { filePath: null, language: null, } export interface ICursorPositionState { line: number column: number } export const DefaultCursorPosition: ICursorPositionState = { line: -1, column: -1, } export type HoverResult = ILocationBasedResult export type DefinitionResult = ILocationBasedResult export interface ILanguageState { mode: string activeBuffer: IActiveBufferState cursor: ICursorPositionState hoverResult: HoverResult definitionResult: DefinitionResult } export const DefaultLanguageState: ILanguageState = { mode: "", activeBuffer: DefaultActiveBuffer, cursor: DefaultCursorPosition, hoverResult: DefaultLocationBasedResult, definitionResult: DefaultLocationBasedResult, } export type LanguageAction = | { type: "MODE_CHANGED" mode: string } | { type: "CURSOR_MOVED" line: number column: number } | { type: "BUFFER_ENTER" filePath: string language: string } | { type: "HOVER_QUERY" location: ILocation } | { type: "DEFINITION_QUERY" location: ILocation } | { type: "HOVER_QUERY_RESULT" result: ILocationBasedResult } | { type: "DEFINITION_QUERY_RESULT" result: ILocationBasedResult } export const modeReducer: Reducer = (state: string = null, action: LanguageAction) => { switch (action.type) { case "MODE_CHANGED": return action.mode default: return state } } export const activeBufferReducer: Reducer = ( state: IActiveBufferState = DefaultActiveBuffer, action: LanguageAction, ) => { switch (action.type) { case "BUFFER_ENTER": return { ...state, filePath: action.filePath, language: action.language, } default: return state } } export const cursorMovedReducer: Reducer = ( state: ICursorPositionState = DefaultCursorPosition, action: LanguageAction, ) => { switch (action.type) { case "CURSOR_MOVED": return { ...state, line: action.line, column: action.column, } default: return state } } export const hoverResultReducer: Reducer = ( state: HoverResult = DefaultLocationBasedResult, action: LanguageAction, ) => { switch (action.type) { case "HOVER_QUERY_RESULT": return action.result case "CURSOR_MOVED": case "BUFFER_ENTER": case "MODE_CHANGED": return DefaultLocationBasedResult default: return state } } export const definitionResultReducer: Reducer = ( state: DefinitionResult = DefaultLocationBasedResult, action: LanguageAction, ) => { switch (action.type) { case "DEFINITION_QUERY_RESULT": return action.result case "CURSOR_MOVED": case "BUFFER_ENTER": case "MODE_CHANGED": return DefaultLocationBasedResult default: return state } } export const languageStateReducer = combineReducers({ mode: modeReducer, activeBuffer: activeBufferReducer, cursor: cursorMovedReducer, definitionResult: definitionResultReducer, hoverResult: hoverResultReducer, }) export const createStore = ( configuration: Configuration, hoverRequestor: IHoverRequestor, definitionRequestor: IDefinitionRequestor, ): Store => { const epicMiddleware = createEpicMiddleware( combineEpics( queryForDefinitionEpic(configuration), queryForHoverEpic(configuration), queryDefinitionEpic(definitionRequestor), queryHoverEpic(hoverRequestor), ), ) return oniCreateStore("LANGUAGE", languageStateReducer, DefaultLanguageState, [ epicMiddleware, ]) } export const queryForHoverEpic = ( configuration: Configuration, ): Epic => (action$, store) => action$ .ofType("CURSOR_MOVED") .filter( () => store.getState().mode === "normal" && configuration.getValue("editor.quickInfo.enabled"), ) .debounceTime(configuration.getValue("editor.quickInfo.delay")) .filter(() => store.getState().mode === "normal") .map((action: LanguageAction) => { const currentState = store.getState() const filePath = currentState.activeBuffer.filePath const language = currentState.activeBuffer.language const line = currentState.cursor.line const column = currentState.cursor.column const location = { filePath, language, line, column, } return { type: "HOVER_QUERY", location, } as LanguageAction }) export const queryForDefinitionEpic = ( configuration: Configuration, ): Epic => (action$, store) => action$ .ofType("CURSOR_MOVED") .debounceTime(configuration.getValue("editor.quickInfo.delay")) .filter(() => store.getState().mode === "normal") .filter( () => store.getState().mode === "normal" && configuration.getValue("editor.definition.enabled"), ) .map((action: LanguageAction) => { const currentState = store.getState() const filePath = currentState.activeBuffer.filePath const language = currentState.activeBuffer.language const line = currentState.cursor.line const column = currentState.cursor.column const location = { filePath, language, line, column, } return { type: "DEFINITION_QUERY", location, } as LanguageAction }) export const NullAction = { type: null } as LanguageAction export const doesLocationBasedResultMatchCursorPosition = ( result: ILocationBasedResult, state: ILanguageState, ) => { return ( result.filePath === state.activeBuffer.filePath && result.line === state.cursor.line && result.column === state.cursor.column && state.mode === "normal" ) } export const queryDefinitionEpic = ( definitionRequestor: IDefinitionRequestor, ): Epic => (action$, store) => action$ .ofType("DEFINITION_QUERY") .switchMap(() => { const state = store.getState() const { filePath, language } = state.activeBuffer const { line, column } = state.cursor return Observable.defer(async () => { const result = await definitionRequestor.getDefinition( language, filePath, line, column, ) return { type: "DEFINITION_QUERY_RESULT", result: { filePath, language, line, column, result, }, } as LanguageAction }) }) .filter(action => { if (action.type !== "DEFINITION_QUERY_RESULT") { return false } return doesLocationBasedResultMatchCursorPosition(action.result, store.getState()) }) export const queryHoverEpic = ( hoverRequestor: IHoverRequestor, ): Epic => (action$, store) => action$ .ofType("HOVER_QUERY") .switchMap(() => { const state = store.getState() const { filePath, language } = state.activeBuffer const { line, column } = state.cursor return Observable.defer(async () => { const result = await hoverRequestor.getHover(language, filePath, line, column) return { type: "HOVER_QUERY_RESULT", result: { filePath, language, line, column, result, }, } as LanguageAction }) }) .filter(action => { if (action.type !== "HOVER_QUERY_RESULT") { return false } return doesLocationBasedResultMatchCursorPosition(action.result, store.getState()) }) ================================================ FILE: browser/src/Services/Language/PromiseQueue.ts ================================================ import * as Log from "oni-core-logging" export class PromiseQueue { private _currentPromise: Promise = Promise.resolve(null) public enqueuePromise( functionThatReturnsPromiseOrThenable: () => Promise | Thenable, requireConnection: boolean = true, ): Promise { const promiseExecutor = () => { return functionThatReturnsPromiseOrThenable() } const newPromise = this._currentPromise.then( () => promiseExecutor(), err => { Log.error(err) return promiseExecutor() }, ) this._currentPromise = newPromise return newPromise } } ================================================ FILE: browser/src/Services/Language/RenameView.tsx ================================================ /** * RenameView.tsx * * Contents of the Rename tooltip */ import * as React from "react" import styled from "styled-components" import { TextInputView } from "./../../UI/components/LightweightText" export interface IRenameViewProps { tokenName: string onComplete: (val: string) => void onCancel: () => void } const ToolTipWrapper = styled.div` background-color: ${props => props.theme["toolTip.background"]}; color: ${props => props.theme["toolTip.foreground"]}; input { background-color: ${props => props.theme["toolTip.background"]}; color: ${props => props.theme["toolTip.foreground"]}; } ` export class RenameView extends React.PureComponent { public render(): JSX.Element { return ( ) } } ================================================ FILE: browser/src/Services/Language/ServerCapabilities.ts ================================================ /*/ * ServerCapibilities.ts */ // Copied from: // https://github.com/Microsoft/language-server-protocol/blob/master/protocol.md /** * Defines how the host (editor) should sync document changes to the language server. */ export namespace TextDocumentSyncKind { /** * Documents should not be synced at all. */ export const None = 0 /** * Documents are synced by always sending the full content * of the document. */ export const Full = 1 /** * Documents are synced by sending the full content on open. * After that only incremental updates to the document are * send. */ export const Incremental = 2 } /** * Completion options. */ export interface CompletionOptions { /** * The server provides support to resolve additional * information for a completion item. */ resolveProvider?: boolean /** * The characters that trigger completion automatically. */ triggerCharacters?: string[] } /** * Signature help options. */ export interface SignatureHelpOptions { /** * The characters that trigger signature help * automatically. */ triggerCharacters?: string[] } /** * Code Lens options. */ export interface CodeLensOptions { /** * Code lens has a resolve provider as well. */ resolveProvider?: boolean } /** * Format document on type options */ export interface DocumentOnTypeFormattingOptions { /** * A character on which formatting should be triggered, like `}`. */ firstTriggerCharacter: string /** * More trigger characters. */ moreTriggerCharacter?: string[] } /** * Document link options */ export interface DocumentLinkOptions { /** * Document links have a resolve provider as well. */ resolveProvider?: boolean } /** * Execute command options. */ export interface ExecuteCommandOptions { /** * The commands to be executed on the server */ commands: string[] } /** * Save options. */ export interface SaveOptions { /** * The client is supposed to include the content on save. */ includeText?: boolean } export interface TextDocumentSyncOptions { /** * Open and close notifications are sent to the server. */ openClose?: boolean /** * Change notifications are sent to the server. See TextDocumentSyncKind.None, TextDocumentSyncKind.Full * and TextDocumentSyncKindIncremental. */ change?: number /** * Will save notifications are sent to the server. */ willSave?: boolean /** * Will save wait until requests are sent to the server. */ willSaveWaitUntil?: boolean /** * Save notifications are sent to the server. */ save?: SaveOptions } export interface IServerCapabilities { /** * Defines how text documents are synced. Is either a detailed structure defining each notification or * for backwards compatibility the TextDocumentSyncKind number. */ textDocumentSync?: TextDocumentSyncOptions | number /** * The server provides hover support. */ hoverProvider?: boolean /** * The server provides completion support. */ completionProvider?: CompletionOptions /** * The server provides signature help support. */ signatureHelpProvider?: SignatureHelpOptions /** * The server provides goto definition support. */ definitionProvider?: boolean /** * The server provides find references support. */ referencesProvider?: boolean /** * The server provides document highlight support. */ documentHighlightProvider?: boolean /** * The server provides document symbol support. */ documentSymbolProvider?: boolean /** * The server provides workspace symbol support. */ workspaceSymbolProvider?: boolean /** * The server provides code actions. */ codeActionProvider?: boolean /** * The server provides code lens. */ codeLensProvider?: CodeLensOptions /** * The server provides document formatting. */ documentFormattingProvider?: boolean /** * The server provides document range formatting. */ documentRangeFormattingProvider?: boolean /** * The server provides document formatting on typing. */ documentOnTypeFormattingProvider?: DocumentOnTypeFormattingOptions /** * The server provides rename support. */ renameProvider?: boolean /** * The server provides document link support. */ documentLinkProvider?: DocumentLinkOptions /** * The server provides execute command support. */ executeCommandProvider?: ExecuteCommandOptions /** * Experimental server capabilities. */ experimental?: any } ================================================ FILE: browser/src/Services/Language/SignatureHelp.ts ================================================ /** * SignatureHelp.ts * */ import { Observable } from "rxjs/Observable" import * as types from "vscode-languageserver-types" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import * as Helpers from "./../../Plugins/Api/LanguageClient/LanguageClientHelpers" import { IToolTipsProvider } from "./../../Editor/NeovimEditor/ToolTipsProvider" import { editorManager } from "./../EditorManager" import { ILatestCursorAndBufferInfo } from "./addInsertModeLanguageFunctionality" import * as LanguageManager from "./LanguageManager" import * as SignatureHelp from "./SignatureHelpView" export const initUI = ( latestCursorAndBufferInfo$: Observable, modeChanged$: Observable, onScroll$: Observable, toolTips: IToolTipsProvider, ) => { const signatureHelpToolTipName = "signature-help-tool-tip" onScroll$.subscribe(_ => toolTips.hideToolTip(signatureHelpToolTipName)) // Show signature help as the cursor moves latestCursorAndBufferInfo$ .flatMap(val => showSignatureHelp(val.language, val.filePath, val.cursorLine, val.cursorColumn), ) .subscribe(result => { if (result) { toolTips.showToolTip(signatureHelpToolTipName, SignatureHelp.render(result), { position: null, openDirection: 1, padding: "0px", }) } else { toolTips.hideToolTip(signatureHelpToolTipName) } }) // Hide signature help when we leave insert mode modeChanged$.subscribe(newMode => { if (newMode !== "insert") { toolTips.hideToolTip(signatureHelpToolTipName) } }) } export const showSignatureHelp = async ( language: string, filePath: string, line: number, column: number, ): Promise => { const languageManager = LanguageManager.getInstance() if (languageManager.isLanguageServerAvailable(language)) { const buffer = editorManager.activeEditor.activeBuffer const currentLine = await buffer.getLines(line, line + 1) const requestColumn = getSignatureHelpTriggerColumn(currentLine[0], column, ["("]) if (requestColumn < 0) { return null } const args = { textDocument: { uri: Helpers.wrapPathInFileUri(filePath), }, position: { line, character: column, }, } let result: types.SignatureHelp = null try { result = await languageManager.sendLanguageServerRequest( language, filePath, "textDocument/signatureHelp", args, ) } catch (ex) { Log.debug(ex) } return result } else { return null } } // TODO: `getSignatureHelpTriggerColumn` rename to `getNearestTriggerCharacter` export const getSignatureHelpTriggerColumn = ( line: string, character: number, triggerCharacters: string[], ): number => { let idx = character while (idx >= 0) { if (line[idx] === triggerCharacters[0]) { break } idx-- } return idx } ================================================ FILE: browser/src/Services/Language/SignatureHelpView.tsx ================================================ import * as React from "react" import * as types from "vscode-languageserver-types" import { getDocumentationText } from "../../Plugins/Api/LanguageClient/LanguageClientHelpers" import { QuickInfoDocumentation, QuickInfoElement, QuickInfoWrapper, Title, } from "./../../UI/components/QuickInfo" import { SelectedText, Text } from "./../../UI/components/Text" export const getElementsFromType = (signatureHelp: types.SignatureHelp): JSX.Element => { const elements = [] const currentItem = signatureHelp.signatures[signatureHelp.activeSignature] if (!currentItem || !currentItem.label || !currentItem.parameters) { return null } const label = currentItem.label const parameters = currentItem.parameters let remainingSignatureString = label let keyIndex = 0 for (let i = 0; i < parameters.length; i++) { const parameterLabel = parameters[i].label const parameterIndex = remainingSignatureString.indexOf(parameterLabel) if (parameterIndex === -1) { continue } keyIndex++ const nonArgumentText = remainingSignatureString.substring(0, parameterIndex) elements.push() const argumentText = remainingSignatureString.substring( parameterIndex, parameterIndex + parameterLabel.length, ) keyIndex++ if (i === signatureHelp.activeParameter) { elements.push() } else { elements.push() } remainingSignatureString = remainingSignatureString.substring( parameterIndex + parameterLabel.length, remainingSignatureString.length, ) } elements.push() const selectedIndex = Math.min(currentItem.parameters.length, signatureHelp.activeParameter) const selectedArgument = currentItem.parameters[selectedIndex] return ( {elements} {!!(selectedArgument && selectedArgument.documentation) && ( )} ) } export const SignatureHelpView = (props: types.SignatureHelp) => ( {getElementsFromType(props)} ) export const render = (signatureHelp: types.SignatureHelp) => ( ) ================================================ FILE: browser/src/Services/Language/Workspace.ts ================================================ /** * Workspace.ts * * Handles workspace/ messages */ import * as Oni from "oni-api" import * as types from "vscode-languageserver-types" import { LanguageManager } from "./LanguageManager" export const listenForWorkspaceEdits = (languageManager: LanguageManager, oni: Oni.Plugin.Api) => { const workspace = oni.workspace languageManager.handleLanguageServerRequest("workspace/applyEdit", async (args: any) => { const payload: types.WorkspaceEdit = args.payload.edit.changes await workspace.applyEdits(payload) return { applied: true, } }) } ================================================ FILE: browser/src/Services/Language/addInsertModeLanguageFunctionality.ts ================================================ /** * LanguageEditorIntegration * * Responsible for listening to editor events, * and hooking up the language service functionality. */ import "rxjs/add/observable/never" import { Observable } from "rxjs/Observable" import * as Oni from "oni-api" import { editorManager } from "./../EditorManager" import * as SignatureHelp from "./SignatureHelp" import { IToolTipsProvider } from "./../../Editor/NeovimEditor/ToolTipsProvider" export interface ILatestCursorAndBufferInfo { filePath: string language: string cursorLine: number contents: string cursorColumn: number } export const addInsertModeLanguageFunctionality = ( cursorMoved$: Observable, modeChanged$: Observable, onScroll$: Observable, toolTips: IToolTipsProvider, ) => { const latestCursorAndBufferInfo$: Observable< ILatestCursorAndBufferInfo > = cursorMoved$.mergeMap(async cursorPos => { const editor = editorManager.activeEditor const buffer = editor.activeBuffer const changedLines: string[] = await buffer.getLines(cursorPos.line, cursorPos.line + 1) const changedLine = changedLines[0] return { filePath: buffer.filePath, language: buffer.language, cursorLine: cursorPos.line, contents: changedLine, cursorColumn: cursorPos.column, } }) SignatureHelp.initUI(latestCursorAndBufferInfo$, modeChanged$, onScroll$, toolTips) } ================================================ FILE: browser/src/Services/Language/index.ts ================================================ export * from "./addInsertModeLanguageFunctionality" export * from "./CodeAction" export * from "./DefinitionRequestor" export * from "./Edits" export * from "./FindAllReferences" export * from "./Formatting" export * from "./HoverRequestor" export * from "./LanguageClient" export * from "./LanguageClientProcess" export * from "./LanguageClientTypes" export * from "./LanguageConfiguration" export * from "./LanguageEditorIntegration" export * from "./LanguageManager" export * from "./SignatureHelp" ================================================ FILE: browser/src/Services/Learning/Achievements/AchievementNotificationRenderer.tsx ================================================ /** * AchievementNotificationRenderer.tsx * * This renders the achievement 'pop-up' when an achievement goal is met. */ import { Overlay, OverlayManager } from "./../../Overlay" import * as React from "react" 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 { window.setTimeout(() => { this._overlay.setContents() }, 10) // TODO: Better handle multiple achievements here window.setTimeout(() => { this._overlay.setContents() }, 5000) } } const AchievementsWrapper = styled.div` & .achievements { position: absolute; top: 0px; left: 0px; right: 0px; bottom: 0px; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; } ` const EnterKeyframes = keyframes` 0% { opacity: 0; transform: translateY(32px) rotateX(-30deg); } 100% { opacity: 1; transform: translateY(0px) rotateX(0deg)); } ` const ExitKeyframes = keyframes` 0% { opacity: 1; transform: translateY(0px) rotateX(0deg); } 100% { opacity: 0; transform: translateY(-32px) rotateX(30deg); } ` const AnimationDuration = "0.25s" const AchievementWrapper = withProps<{}>(styled.div)` ${boxShadow} display: flex; flex-direction: row; justify-content: center; align-items: center; background-color: ${props => props.theme.background}; color: ${props => props.theme.foreground}; border-radius: 3em; padding: 1em 2em; margin: 2em; max-width: 1000px; &.animate-enter { animation: ${EnterKeyframes}; animation-duration: ${AnimationDuration}; animation-timing-function: ease-in; animation-fill-mode: forwards; } &.animate-exit { animation: ${ExitKeyframes}; animation-duration: ${AnimationDuration}; animation-timing-function: ease-out; animation-fill-mode: forwards; } ` const FlipCardWrapper = styled.div` width: 48px; height: 48px; margin: 8px; flex: 0 0 auto; ` 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 { title: string description: string } export interface IAchievementsViewProps { achievements: IAchievement[] } export const AchievementsView = (props: IAchievementsViewProps) => { const achievements = props.achievements.map(a => ) return ( {achievements} ) } 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 ( } back={ } />
    Achievement Unlocked
    {this.props.title}
    ) } } ================================================ FILE: browser/src/Services/Learning/Achievements/AchievementsBufferLayer.tsx ================================================ /** * 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 } } ================================================ FILE: browser/src/Services/Learning/Achievements/AchievementsManager.ts ================================================ /** * AchievementsManager.ts * * Primary API entry point for the achievements feature */ 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[] } export interface AchievementGoalDefinition { name: string goalId: string 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: IPersistentStore) { this._enabled = true } public notifyGoal(goalId: string): void { if (!this._isInitialized() || !this._enabled) { return } const currentGoal = this._goalState.goalCounts[goalId] || 0 this._goalState.goalCounts[goalId] = currentGoal + 1 // Look at all achievements associated with the goal, and check victory conditions const trackingGoals = this._trackingGoals[goalId] || [] trackingGoals.forEach((achievementId: string) => { const achievement = this._achievements[achievementId] this._checkVictoryCondition(achievement) }) this._schedulePersist() } public async start(): Promise { this._goalState = await this._persistentStore.get() // Once we've loaded, we need to look at all our achievements, // and see if we should start tracking Object.values(this._achievements).forEach(achievement => { this._checkIfShouldTrackAchievement(achievement) this._checkVictoryCondition(achievement) }) } 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 { const clearedState: IPersistedAchievementState = { goalCounts: {}, achievedIds: [], } this._goalState = clearedState this._persistentStore.set(clearedState) } public registerAchievement(definition: AchievementDefinition): void { this._achievements[definition.uniqueId] = definition this._checkIfShouldTrackAchievement(definition) this._checkVictoryCondition(definition) } private _isInitialized(): boolean { return !!this._goalState } private _checkVictoryCondition(definition: AchievementDefinition): void { if (!this._isInitialized()) { return } if (this._hasAchievementBeenAchieved(definition.uniqueId)) { return } const areAllGoalsSatisfied = definition.goals.reduce((prev: boolean, goal) => { const hitCount = this._goalState.goalCounts[goal.goalId] || 0 return prev && goal.count <= hitCount }, true) if (areAllGoalsSatisfied) { this._goalState.achievedIds.push(definition.uniqueId) this._onAchievementAccomplishedEvent.dispatch(definition) } } private _hasAchievementBeenAchieved(achievementId: string): boolean { return this._goalState.achievedIds.indexOf(achievementId) >= 0 } private _checkIfShouldTrackAchievement(definition: AchievementDefinition): void { if (!this._isInitialized()) { return } if (this._hasAchievementBeenAchieved(definition.uniqueId)) { // Already been achieved! return } // Not achieved, so we'll track all the goalIds definition.goals.forEach(goal => { const currentTrackedItems = this._trackingGoals[goal.goalId] || [] this._trackingGoals[goal.goalId] = [...currentTrackedItems, definition.uniqueId] }) } private _schedulePersist(): void { if (this._currentIdleCallback !== null) { return } this._currentIdleCallback = Utility.requestIdleCallback(() => { this._persistentStore.set(this._goalState) this._currentIdleCallback = null }) } } export interface GoalCounts { [goalId: string]: number } export interface IPersistedAchievementState { goalCounts: GoalCounts // Persisted ids of achievements that are already completed // - no need to bother tracking these. achievedIds: string[] } ================================================ FILE: browser/src/Services/Learning/Achievements/index.tsx ================================================ /** * Achievements.ts * * Entry point for the 'achievements' feature */ import { Configuration } from "./../../Configuration" import { OverlayManager } from "./../../Overlay" 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, overlays: OverlayManager, ) => { 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, }) 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)) } 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 } ================================================ FILE: browser/src/Services/Learning/LearningPane.tsx ================================================ /** * LearningPane.tsx * * UX for rendering the bookmarks experience in the sidebar */ import * as React from "react" import { Event, IEvent } from "oni-types" import { CommandManager } from "./../CommandManager" 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 { 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" } public get title(): string { return "Learn" } public enter(): void { // this._tutorialManager.startTutorial(null) this._onEnter.dispatch() } public leave(): void { this._onLeave.dispatch() } 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 ( 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) } } } ================================================ FILE: browser/src/Services/Learning/Tutorial/CompletionView.tsx ================================================ /** * 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
    ) } ================================================ FILE: browser/src/Services/Learning/Tutorial/GameplayBufferLayer.tsx ================================================ /** * 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 } } ================================================ FILE: browser/src/Services/Learning/Tutorial/GoalView.tsx ================================================ /** * 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}
    ) } ================================================ FILE: browser/src/Services/Learning/Tutorial/ITutorial.ts ================================================ /** * 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[] } ================================================ FILE: browser/src/Services/Learning/Tutorial/Notes.tsx ================================================ /** * 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 UKey = (): JSX.Element => { return Undo a single change} /> } export const RedoKey = (): JSX.Element => { return ( Redo a single undo} /> ) } 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 BigWordKey = (): JSX.Element => { return ( Moves the cursor to the BEGINNING of the NEXT word by WHITESPACE. } /> ) } export const BigBeginningKey = (): JSX.Element => { return ( Moves the cursor to the BEGINNING of the PREVIOUS word by WHITESPACE. } /> ) } export const BigEndKey = (): JSX.Element => { return ( Moves the cursor to the END of the NEXT word by WHITESPACE.} /> ) } 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 specified by a `motion` } /> ) } 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 ChangeOperatorKey = (): JSX.Element => { return ( + motion: Change text specified by a `motion` } /> ) } export const ChangeWordKey = (): JSX.Element => { return ( Delete to the end of the current word and enter Insert mode.} /> ) } export const HJKLKeys = (): JSX.Element => { return ( } /> } /> } /> } /> ) } export const YankOperatorKey = (): JSX.Element => { return ( + motion: Yanks (copies) text specified by a `motion` } /> ) } export const YankWordKey = (): JSX.Element => { return ( Yank to the end of the current word.} /> ) } export const YankLineKey = (): JSX.Element => { return ( Yanks the CURRENT line.} /> ) } export const pasteKey = (): JSX.Element => { return Paste AFTER the cursor} /> } export const PasteKey = (): JSX.Element => { return ( Paste BEFORE the cursor} /> ) } export const VisualModeKey = (): JSX.Element => { return ( Move into Visual mode for selecting text} /> ) } export const VisualLineModeKey = (): JSX.Element => { return ( Move into line-wise Visual mode for selecting lines} /> ) } export const Targetckey = (): JSX.Element => { return ( Delete AND INSERT between next pair characters} /> ) } export const Targetdkey = (): JSX.Element => { return ( Delete between next pair characters} /> ) } export const Targetikey = (): JSX.Element => { return ( Select first character inside of pair characters} /> ) } export const Targetakey = (): JSX.Element => { return ( Select next pair including the pair characters} /> ) } export const TargetIkey = (): JSX.Element => { return ( Select contents of pair characters} /> ) } export const TargetAkey = (): JSX.Element => { return ( Select around the pair characters} /> ) } export const Targetnkey = (): JSX.Element => { return ( Select the next pair characters} /> ) } export const Targetlkey = (): JSX.Element => { return ( Select the previous pair characters} /> ) } export const fKey = (): JSX.Element => { return ( + char: Moves cursor to next occurence of [char]. } /> ) } export const FKey = (): JSX.Element => { return ( + char: Moves cursor to previous occurence of [char]. } /> ) } export const tKey = (): JSX.Element => { return ( + char: Moves cursor to before the next occurence of [char]. } /> ) } export const TKey = (): JSX.Element => { return ( + char: Moves cursor to after the previous occurence of [char]. } /> ) } export const RepeatKey = (): JSX.Element => { return ( Repeats last f, t, F, or T.} /> ) } export const RepeatOppositeKey = (): JSX.Element => { return ( Repeats last f, t, F, or T in the opposite direction.} /> ) } export const innerTextObjectKey = (): JSX.Element => { return ( Select a Text Object within delimiter characters} /> ) } export const aTextObjectKey = (): JSX.Element => { return ( Select a Text Object and its delimiter characters} /> ) } export const DotKey = (): JSX.Element => { return ( Repeat the last file change made} /> ) } ================================================ FILE: browser/src/Services/Learning/Tutorial/Stages/CompositeStage.tsx ================================================ /** * 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))} } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Stages/CorrectLineStage.tsx ================================================ /** * 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 (
    ) } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Stages/DeleteCharactersStage.tsx ================================================ /** * 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 ( ) } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Stages/FadeInLineStage.tsx ================================================ /** * 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} ) } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Stages/InitializeBufferStage.tsx ================================================ /** * 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 } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Stages/MoveToGoalStage.tsx ================================================ /** * 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 (
    ) } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Stages/SetBufferStage.tsx ================================================ /** * 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([]) } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Stages/SetCursorPositionStage.tsx ================================================ /** * 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 } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Stages/WaitForModeStage.tsx ================================================ /** * 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 } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Stages/WaitForRegisterStage.tsx ================================================ /** * SetRegisterStage.tsx * * Stage that waits for expected register contents */ import { ITutorialContext, ITutorialStage } from "./../ITutorial" export class WaitForRegisterStage implements ITutorialStage { public get goalName(): string { return this._goal } constructor( private _goal: string, private _contents: string, private _register: string = '"', ) {} public async tickFunction(context: ITutorialContext): Promise { const contents = await context.editor.neovim.callFunction("getreg", [this._register]) return contents === this._contents } public render(): JSX.Element { return null } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Stages/WaitForStateStage.tsx ================================================ /** * 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 } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Stages/index.tsx ================================================ /** * 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 "./WaitForRegisterStage" export * from "./DeleteCharactersStage" export * from "./WaitForModeStage" export * from "./WaitForStateStage" ================================================ FILE: browser/src/Services/Learning/Tutorial/TutorialBufferLayer.tsx ================================================ /** * 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)} onWillUnmount={() => this.stop()} /> ) } 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") } public stop(): void { this._tutorialGameplayManager.stop() } 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 onWillUnmount: () => 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%; min-height: 275px; display: flex; align-items: center; ` const PrimaryHeader = styled.div` padding-top: 1em; 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; ` const DescriptionWrapper = styled.div` display: none; @media (min-height: 800px) { display: block; } ` 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 componentWillUnmount(): void { this.props.onWillUnmount() } 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}
    ) } } ================================================ FILE: browser/src/Services/Learning/Tutorial/TutorialGameplayManager.ts ================================================ /** * 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 const TICK_RATE = 50 /* 50 ms, or 20 times pers second */ 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 _buf: Oni.Buffer private _pendingTimer: number | null = null 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._pendingTimer = window.setInterval(() => this._tick(), TICK_RATE) this._tick() } public stop(): void { if (this._pendingTimer) { window.clearInterval(this._pendingTimer) this._pendingTimer = null } } private async _tick(): Promise { if (this._isTickInProgress) { 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) } } 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) } } ================================================ FILE: browser/src/Services/Learning/Tutorial/TutorialManager.ts ================================================ /** * 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 } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Tutorials/BasicMovementTutorial.tsx ================================================ /** * 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 } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Tutorials/BeginningsAndEndingsTutorial.tsx ================================================ /** * 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 NON-WHITESPACE CHARACTER", 2, 4, ), new Stages.MoveToGoalStage( "Use '$' to move to the END of the line", 2, Line3.length - 1, ), new Stages.MoveToGoalStage( "Use '_' to move to the FIRST NON-WHITESPACE CHARACTER", 2, 4, ), new Stages.MoveToGoalStage("Use '0' to move to the BEGINNING of the line", 2, 0), 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: "Start/End Motion: _, 0, $", description: "You don't need to keep hitting `w` or `b` when you need to go all the way to the beginning or the end of a line. You can use the `0` key to move to the very beginning a line, and `$` to move to the end. Also, `_` moves to the first non-whitespace character in the line, which is often more convenient than `0`.", level: 140, } } public get stages(): ITutorialStage[] { return this._stages } public get notes(): JSX.Element[] { return [, , , ] } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Tutorials/ChangeOperatorTutorial.tsx ================================================ /** * ChangeOperatorTutorial.tsx * * Tutorial that exercises the change 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 change operator can be used for quikcly fixing typos" const Line1Marker = "The change operator can be used for ".length const Line1Pending = "The change operator can be used for fixing typos" const Line1Fixed = "The change operator can be used for quickly fixing typos" const Line2 = "Learning Vim can be tedious and repetitive" const Line2Fix1 = "Learning Vim can be fun and repetitive" const Line2Fix2 = "Learning Vim can be fun and exciting" export class ChangeOperatorTutorial implements ITutorial { private _stages: ITutorialStage[] constructor() { this._stages = [ new Stages.SetBufferStage([Line1]), new Stages.MoveToGoalStage("Move to the goal marker", 0, Line1Marker), new Stages.WaitForStateStage("Fix the typo by hitting 'cw'", [Line1Pending]), new Stages.WaitForStateStage("Enter the word 'quickly'", [Line1Fixed]), new Stages.WaitForModeStage("Exit Insert mode by hitting ", "normal"), new Stages.SetBufferStage([Line1Fixed, Line2]), new Stages.MoveToGoalStage("Move to the goal marker", 1, Line2.indexOf("tedious")), new Stages.WaitForStateStage("Change 'tedious' to 'fun'", [Line1Fixed, Line2Fix1]), new Stages.WaitForModeStage("Exit Insert mode by hitting ", "normal"), new Stages.MoveToGoalStage( "Move to the goal marker", 1, Line2Fix1.indexOf("repetitive"), ), new Stages.WaitForStateStage("Change 'repetitive' to 'exciting'", [ Line1Fixed, Line2Fix2, ]), new Stages.WaitForModeStage("Exit Insert mode by hitting ", "normal"), ] } public get metadata(): ITutorialMetadata { return { id: "oni.tutorials.change_operator", name: "Change Operator: c", description: "Now that you know about operators and motions pairing like a noun and a verb, we can start learning more operators. The `c` operator allows you to _change_ text. It deletes the selected text and immediately enters Insert mode so you can enter new text. The text to be changed is defined by any motion just like the delete operator. It might not seem very impressive right now but `c` will become more useful as you learn more motions.", level: 210, } } public get stages(): ITutorialStage[] { return this._stages } public get notes(): JSX.Element[] { return [ , , , , ] } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Tutorials/CopyPasteTutorial.tsx ================================================ /** * CopyPasteTutorial.tsx * * Tutorial for learning how to copy and paste text. */ import * as React from "react" import { ITutorial, ITutorialMetadata, ITutorialStage } from "./../ITutorial" import * as Notes from "./../Notes" import * as Stages from "./../Stages" const Line1 = "Like the 'd' operator, 'y' can be used to yank (copy) text" const Line2 = "Any deleted text or yanked can then be pasted with 'p'" const Line2YankMarker = "Any deleted ".length const Line2PasteMarker = "Any deleted text or yanked".length const Line2PostPaste1 = "Any deleted text or yanked text can then be pasted with 'p'" const Line2PostPaste2 = "text Any deleted text or yanked text can then be pasted with 'p'" const TransposeLine = "Sipmle tpyos can aslo be fiexd with 'xp'" const TransposeLine1 = "Simple tpyos can aslo be fiexd with 'xp'" const TransposeLine2 = "Simple typos can aslo be fiexd with 'xp'" const TransposeLine3 = "Simple typos can also be fiexd with 'xp'" const TransposeLine4 = "Simple typos can also be fixed with 'xp'" export class CopyPasteTutorial implements ITutorial { private _stages: ITutorialStage[] constructor() { this._stages = [ new Stages.SetBufferStage([Line1, Line2]), new Stages.MoveToGoalStage("Move to the word 'text'", 1, Line2YankMarker), new Stages.WaitForRegisterStage("Yank this word with 'yw'", "text "), new Stages.MoveToGoalStage("Move after the word 'yanked'", 1, Line2PasteMarker), new Stages.WaitForStateStage("Paste after the cursor with 'p'", [ Line1, Line2PostPaste1, ]), new Stages.MoveToGoalStage("Move to the beginning of the line", 1, 0), new Stages.WaitForStateStage("Paste before the cursor with 'P'", [ Line1, Line2PostPaste2, ]), new Stages.WaitForRegisterStage( "Yank the entire line with 'yy'", Line2PostPaste2 + "\n", ), new Stages.WaitForStateStage("Paste the yanked line below the cursor with 'p'", [ Line1, Line2PostPaste2, Line2PostPaste2, ]), new Stages.MoveToGoalStage("Move to the top of the file", 0, 0), new Stages.WaitForStateStage("Paste _above_ the cursor with 'P'", [ Line2PostPaste2, Line1, Line2PostPaste2, Line2PostPaste2, ]), new Stages.MoveToGoalStage("Move to the next line", 1, 0), new Stages.WaitForStateStage("Deleting also copies text. Delete a line with 'dd'", [ Line2PostPaste2, Line2PostPaste2, Line2PostPaste2, ]), new Stages.WaitForStateStage("Again, paste with 'p'", [ Line2PostPaste2, Line2PostPaste2, Line1, Line2PostPaste2, ]), new Stages.WaitForStateStage( "Copied text can be pasted multiple times, past again with 'p'", [Line2PostPaste2, Line2PostPaste2, Line1, Line1, Line2PostPaste2], ), new Stages.SetBufferStage([TransposeLine]), new Stages.MoveToGoalStage("Move to the first typo", 0, 2), new Stages.WaitForStateStage( "Since deleted text is also copied, transposing characters is simple. Try 'xp'", [TransposeLine1], ), new Stages.MoveToGoalStage("Move to the next typo", 0, 8), new Stages.WaitForStateStage("Again, fix the typo with 'xp'", [TransposeLine2]), new Stages.MoveToGoalStage("Move to the next typo", 0, 18), new Stages.WaitForStateStage("Again, fix the typo with 'xp'", [TransposeLine3]), new Stages.MoveToGoalStage("Move to the next typo", 0, 27), new Stages.WaitForStateStage("Again, fix the typo with 'xp'", [TransposeLine4]), ] } public get metadata(): ITutorialMetadata { return { id: "oni.tutorials.copy_paste", name: "Copy & Paste: y, p", description: "Now that you know the delete and change operators, let's learn vim's final operator: `y`. The `y` operator can be used to copy (\"yank\") text which can then be pasted with `p`. Using `p` pastes _after_ the cursor, and `P` pastes _before_ the cursor. The `y` operator behaves just like the `d` and `c` operators and can be paired with any motion.", level: 220, } } public get stages(): ITutorialStage[] { return this._stages } public get notes(): JSX.Element[] { return [ , , , , , , , , ] } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Tutorials/DeleteCharacterTutorial.tsx ================================================ /** * 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: 190, } } public get notes(): JSX.Element[] { return [ , In normal mode, deletes the character at the cursor position. } />, ] } public get stages(): ITutorialStage[] { return this._stages } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Tutorials/DeleteOperatorTutorial.tsx ================================================ /** * 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 Line4dCorrecta = "--> Help delete the duplicate words." const Line4dCorrectb = "--> Help delete the words." const Line2e = "--> `d` followed by any motion will delete to that destination" const Line3e = "--> This can allow for more precision when there aren't simple boundaries" const Line4e = "--> public void somethingsomething(arg1, arg2, extra1, extra2)" const Line4eCorrecta = "--> public void something(arg1, arg2, extra1, extra2)" const Line4eCorrectb = "--> public void something(arg1, arg2)" 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, Line4dCorrecta]), ), Stages.combine( "Delete the word again with 'dw'", new Stages.DeleteCharactersStage(null, 3, 20, "duplicate"), new Stages.WaitForStateStage(null, [Line1, Line2d, Line3d, Line4dCorrectb]), ), Stages.combine( null, new Stages.FadeInLineStage(null, 1, Line2e), new Stages.FadeInLineStage(null, 2, Line3e), new Stages.FadeInLineStage(null, 3, Line4e), new Stages.SetBufferStage([Line1, Line2e, Line3e, Line4e]), ), new Stages.MoveToGoalStage("Move to the goal marker", 3, 16), Stages.combine( "Use 'dts' to delete to the next 's'", new Stages.DeleteCharactersStage(null, 3, 16, "something"), new Stages.WaitForStateStage(null, [Line1, Line2e, Line3e, Line4eCorrecta]), ), new Stages.MoveToGoalStage("Move to the goal marker", 3, 36), Stages.combine( "Use 'dt)' to delete to the ')'", new Stages.DeleteCharactersStage(null, 3, 36, ", extra1, extra2"), new Stages.WaitForStateStage(null, [Line1, Line2e, Line3e, Line4eCorrectb]), ), ] } 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: 200, } } public get stages(): ITutorialStage[] { return this._stages } public get notes(): JSX.Element[] { return [ , , , , , , ] } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Tutorials/DotCommandTutorial.tsx ================================================ /** * DotCommandTutorial.tsx * * Tutorial that teaches the dot (.) command */ import * as React from "react" import { ITutorial, ITutorialMetadata, ITutorialStage } from "./../ITutorial" import * as Notes from "./../Notes" import * as Stages from "./../Stages" const stage1Line1 = "The dot (.) command can be used to repeat any single change." const stage1Line2 = "This line contains duplicate duplicate words" const stage1Line2a = "This line contains duplicate words" const stage2Line1 = "A change can be anything that happens between leaving Normal Mode and returning to Normal Mode" const stage2Line2 = "This line contains (an invalid) statement" const stage2Line2pending = "This line contains () statement" const stage2Line2diff = "This line contains (a different) statement" const stage2Line2a = "This line contains (the fixed) statement" export class DotCommandTutorial implements ITutorial { private _stages: ITutorialStage[] constructor() { this._stages = [ new Stages.SetBufferStage([ stage1Line1, stage1Line2, stage1Line2, stage1Line2, stage1Line2, ]), new Stages.MoveToGoalStage("Move to the goal marker", 1, 19), Stages.combine( "Remove the duplicate word using 'dw'", new Stages.DeleteCharactersStage(null, 1, 19, "duplicate"), new Stages.WaitForStateStage(null, [ stage1Line1, stage1Line2a, stage1Line2, stage1Line2, stage1Line2, ]), ), new Stages.MoveToGoalStage("Move to the goal marker", 2, 19), Stages.combine( "Repeat the 'dw' operation by just hitting '.'", new Stages.DeleteCharactersStage(null, 2, 19, "duplicate"), new Stages.WaitForStateStage(null, [ stage1Line1, stage1Line2a, stage1Line2a, stage1Line2, stage1Line2, ]), ), new Stages.MoveToGoalStage("Move to the goal marker", 3, 19), Stages.combine( "Again, hit '.'", new Stages.DeleteCharactersStage(null, 3, 19, "duplicate"), new Stages.WaitForStateStage(null, [ stage1Line1, stage1Line2a, stage1Line2a, stage1Line2a, stage1Line2, ]), ), new Stages.MoveToGoalStage("Move to the goal marker", 4, 19), Stages.combine( "Again, hit '.'", new Stages.DeleteCharactersStage(null, 4, 19, "duplicate"), new Stages.WaitForStateStage(null, [ stage1Line1, stage1Line2a, stage1Line2a, stage1Line2a, stage1Line2a, ]), ), new Stages.SetBufferStage([ stage2Line1, stage2Line2, stage2Line2, stage2Line2diff, stage2Line2, ]), new Stages.MoveToGoalStage("Move to the goal marker", 1, 23), Stages.combine( "Change the text within parentheses with 'ci('", new Stages.DeleteCharactersStage(null, 1, 20, "an invalid"), new Stages.WaitForStateStage(null, [ stage2Line1, stage2Line2pending, stage2Line2, stage2Line2diff, stage2Line2, ]), ), new Stages.WaitForStateStage("Enter the text 'the fixed'", [ stage2Line1, stage2Line2a, stage2Line2, stage2Line2diff, stage2Line2, ]), new Stages.WaitForModeStage("Hit to exit insert mode", "normal"), new Stages.MoveToGoalStage("Move to the goal marker", 2, 20), Stages.combine( "Repeat the entire change with '.'", new Stages.DeleteCharactersStage(null, 2, 20, "an invalid"), new Stages.WaitForStateStage(null, [ stage2Line1, stage2Line2a, stage2Line2a, stage2Line2diff, stage2Line2, ]), ), new Stages.MoveToGoalStage("Move to the goal marker", 3, 20), Stages.combine( "Repeat the entire change with '.'", new Stages.DeleteCharactersStage(null, 3, 20, "a different"), new Stages.WaitForStateStage(null, [ stage2Line1, stage2Line2a, stage2Line2a, stage2Line2a, stage2Line2, ]), ), new Stages.MoveToGoalStage("Move to the goal marker", 4, 20), Stages.combine( "Repeat the entire change with '.'", new Stages.DeleteCharactersStage(null, 4, 20, "an invalid"), new Stages.WaitForStateStage(null, [ stage2Line1, stage2Line2a, stage2Line2a, stage2Line2a, stage2Line2a, ]), ), Stages.combine( "Hit 'dd' to delete this line", new Stages.DeleteCharactersStage(null, 4, 0, stage2Line2a), new Stages.WaitForStateStage(null, [ stage2Line1, stage2Line2a, stage2Line2a, stage2Line2a, ]), ), Stages.combine( "Hit '.' to repeat the delete", new Stages.DeleteCharactersStage(null, 3, 0, stage2Line2a), new Stages.WaitForStateStage(null, [stage2Line1, stage2Line2a, stage2Line2a]), ), Stages.combine( "Hit '.' to repeat the delete", new Stages.DeleteCharactersStage(null, 2, 0, stage2Line2a), new Stages.WaitForStateStage(null, [stage2Line1, stage2Line2a]), ), new Stages.MoveToGoalStage("Move to the top of the file", 0, 0), Stages.combine( "Hit '.' to repeat the delete", new Stages.DeleteCharactersStage(null, 0, 0, stage2Line1), new Stages.WaitForStateStage(null, [stage2Line2a]), ), ] } public get metadata(): ITutorialMetadata { return { id: "oni.tutorials.dot_command", name: "Repeat Command: .", description: "One of Vim's most powerful commands is performed by simply pressing period ('.'). The '.' command will repeat whatever operation you just performed. Basically, it will repeat whatever keys you most recently hit to change the file.", level: 250, } } public get stages(): ITutorialStage[] { return this._stages } public get notes(): JSX.Element[] { return [] } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Tutorials/InlineFindingTutorial.tsx ================================================ /** * 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 = "Use 'f' to move to the next occurrence of a character within the same line." const Line2 = "And use 'F' to move to the previous occurrence of a character." const Line3 = "'t' is like 'f' except it moves to one spot before the character." const Line4 = "And 'T' is like 'F' except it moves one spot after." const Line5 = "Awesome! You can also use ';' to repeat the last f, t, F, or T." const Line6 = "Now use ',' to repeat the last f, t, F, or T in the opposite direction." export class InlineFindingTutorial implements ITutorial { private _stages: ITutorialStage[] constructor() { this._stages = [ new Stages.SetBufferStage([Line1]), new Stages.MoveToGoalStage("Move to the nearest 'n' using 'fn'", 0, 23), new Stages.MoveToGoalStage("Move to the nearest 'a' using 'fa'", 0, 42), new Stages.SetBufferStage([Line1, Line2]), new Stages.MoveToGoalStage("Move down a line", 1, 42), new Stages.MoveToGoalStage("Use 'Fm' to move BACKWARDS to the nearest 'm'", 1, 15), new Stages.MoveToGoalStage("Use 'Fs' to move BACKWARDS to the nearest 's'", 1, 5), new Stages.SetBufferStage([Line1, Line2, Line3]), new Stages.MoveToGoalStage("Move down a line", 2, 5), new Stages.MoveToGoalStage("Use 'tx' to move to the character before 'x'", 2, 16), new Stages.MoveToGoalStage("Use 'tb' to move to the character before 'b'", 2, 43), new Stages.SetBufferStage([Line1, Line2, Line3, Line4]), new Stages.MoveToGoalStage("Move down a line", 3, 43), new Stages.MoveToGoalStage( "Use 'Tx' to move BACKWARDS to the character before 'x'", 3, 22, ), new Stages.MoveToGoalStage( "Use 'Tl' to move BACKWARDS to the character before 'l'", 3, 12, ), new Stages.SetBufferStage([Line1, Line2, Line3, Line4, Line5]), new Stages.MoveToGoalStage("Move down a line", 4, 12), new Stages.MoveToGoalStage("Use 'fe' to move to the nearest 'e'", 4, 24), new Stages.MoveToGoalStage("Use ';' to move to the next instance of 'e'", 4, 34), new Stages.MoveToGoalStage("Use ';' to move to the next instance of 'e'", 4, 36), new Stages.MoveToGoalStage("Use ';' to move to the next instance of 'e'", 4, 42), new Stages.SetBufferStage([Line1, Line2, Line3, Line4, Line5, Line6]), new Stages.MoveToGoalStage("Move down a line", 5, 42), new Stages.MoveToGoalStage("Use ',' to move to the previous instance of 'e'", 5, 24), new Stages.MoveToGoalStage("Use ',' to move to the previous instance of 'e'", 5, 18), new Stages.MoveToGoalStage("Use ',' to move to the previous instance of 'e'", 5, 16), new Stages.MoveToGoalStage("Use ',' to move to the previous instance of 'e'", 5, 6), new Stages.MoveToGoalStage("Move up a line", 4, 6), new Stages.MoveToGoalStage("Use 'te' to move before the nearest 'e'", 4, 23), new Stages.MoveToGoalStage("Use ';' to move before the next instance of 'e'", 4, 33), new Stages.MoveToGoalStage("Use ';' to move before the next instance of 'e'", 4, 35), new Stages.MoveToGoalStage("Use ';' to move before the next instance of 'e'", 4, 41), new Stages.SetBufferStage([Line1, Line2, Line3, Line4, Line5, Line6]), new Stages.MoveToGoalStage("Move down a line", 5, 41), new Stages.MoveToGoalStage( "Use ',' to move before the previous instance of 'e'", 5, 25, ), new Stages.MoveToGoalStage( "Use ',' to move before the previous instance of 'e'", 5, 19, ), new Stages.MoveToGoalStage( "Use ',' to move before the previous instance of 'e'", 5, 17, ), new Stages.MoveToGoalStage("Use ',' to move before the previous instance of 'e'", 5, 7), ] } public get metadata(): ITutorialMetadata { return { id: "oni.tutorials.inline_finding", name: "Character Find Motion: f, F, t, T", description: "Sometimes you need to move faster than 'h' and 'l' allow but need more control than 'w', 'e', and 'b', especially when using the operators you'll learn later. 'f' followed by any character moves to the next instance of that character, 'F' followed by any character moves backwards to the next instance of that character. Similarly, 't' and 'T' move forwards and backwards up to (but not on) the specified character. After performing a 'f', 'F', 't', or 'T' operation ';' and ',' allow you to repeat those motions in different directions.", level: 145, } } public get notes(): JSX.Element[] { return [ , , , , , , ] } public get stages(): ITutorialStage[] { return this._stages } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Tutorials/InsertAndUndoTutorial.tsx ================================================ /** * InsertAndUndoTutorial.tsx * * Tutorial for undo and redo before we learn destructive changes */ 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 msing this ." const TutorialLine1Correct = "There is some text missing from this line." export class InsertAndUndoTutorial 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: `msing` 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.", ), new Stages.WaitForModeStage("Press '' to exit insert mode", "normal"), new Stages.WaitForStateStage("Press 'u' to undo the last change", [ "There is some text missing from this .", TutorialLine1Correct, ]), new Stages.WaitForStateStage("Press 'u' to undo another change", [ "There is some text missing this .", TutorialLine1Correct, ]), new Stages.WaitForStateStage("Press 'u' to undo yet another change", [ "There is some text msing this .", TutorialLine1Correct, ]), new Stages.WaitForStateStage("Press 'u' to undo yet another change", [ "There is text msing this .", TutorialLine1Correct, ]), new Stages.WaitForStateStage("Press 'Ctrl+r' to redo the last undo", [ "There is some text msing this .", TutorialLine1Correct, ]), new Stages.WaitForStateStage("Press 'Ctrl+r' to redo the next undo", [ "There is some text missing this .", TutorialLine1Correct, ]), new Stages.WaitForStateStage("Press 'Ctrl+r' to redo yet another undo", [ "There is some text missing from this .", TutorialLine1Correct, ]), new Stages.WaitForStateStage("Press 'Ctrl+r' to redo yet another undo", [ "There is some text missing from this line.", TutorialLine1Correct, ]), new Stages.WaitForStateStage("Press 'u' to undo the last change", [ "There is some text missing from this .", TutorialLine1Correct, ]), ] } public get metadata(): ITutorialMetadata { return { id: "oni.tutorial.undo_and_redo", name: "Undo and Redo", 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. If you make any mistakes, you can undo inserted text with 'u'. To bring back an undo, hit 'Ctrl+r' to redo.", level: 170, } } public get stages(): ITutorialStage[] { return this._stages } public get notes(): JSX.Element[] { return [ , , , , , ] } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Tutorials/SearchInBufferTutorial.tsx ================================================ /** * 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" const Line11 = "It may take you some practice to use reverse search" export class SearchInBufferTutorial implements ITutorial { private _stages: ITutorialStage[] constructor() { this._stages = [ // Forward search new Stages.SetBufferStage([Line1, Line2]), new Stages.MoveToGoalStage( "Use '/' to enter search mode, type the word 'move', then hit ", 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, Line8, Line9, Line10, Line11]), new Stages.SetCursorPositionStage(4, 33), new Stages.MoveToGoalStage("Use '?' to search backwards for the word 'you'", 4, 12), new Stages.MoveToGoalStage( "Use 'n' to go to the next (backwards) instance of 'you'", 3, 14, ), new Stages.MoveToGoalStage( "Use 'n' to go to the next (backwards) instance of 'you'", 2, 14, ), new Stages.MoveToGoalStage( "Use 'n' to go to the next (backwards) instance of 'you'", 0, 21, ), new Stages.MoveToGoalStage( "Use 'N' to go to the previous (backwards) instance of 'you'", 2, 14, ), new Stages.MoveToGoalStage( "Use 'N' to go to the previous (backwards) instance of 'you'", 3, 14, ), ] } public get metadata(): ITutorialMetadata { return { id: "oni.tutorials.find_across_buffer", name: "Search 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: 160, } } public get notes(): JSX.Element[] { return [, , , ] } public get stages(): ITutorialStage[] { return this._stages } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Tutorials/SwitchModeTutorial.tsx ================================================ /** * 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 } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Tutorials/TargetsVimPluginTutorial.tsx ================================================ /** * * Tutorial that exercises the targets.vim plugin */ import * as React from "react" import { ITutorial, ITutorialMetadata, ITutorialStage } from "./../ITutorial" import * as Notes from "./../Notes" import * as Stages from "./../Stages" const Line1a = "The Targets.vim plugin is very useful!" const Line1b = "It was created by Christian Wellenbrock." const Line1c1 = "Targets.vim adds text objects for additional (operations)." const Line1c2 = "Targets.vim adds text objects for additional (foo)." const Line2a1 = "'cin(' changes inside the next pair of (parenthesis)." const Line2a2 = "'cin(' changes inside the next pair of ()." const Line2a3 = "'cin(' changes inside the next pair of (foo)." const Line2b1 = "'can(' changes the next pair of (parenthesis)." const Line2b2 = "'can(' changes the next pair of bar." const Line2c = "Replacing 'n' with 'l' will change the previous pair." const Line2d1 = "Omitting either will change the (current pair) or the (next)." const Line2d2 = "Omitting either will change the or the (next)." const Line2d3 = "Omitting either will change the or the again." const Line3a1 = "Quote objects can 'also' be used." const Line3a2 = "Quote objects can '' be used." const Line3b1 = '\'cIn"\' changes the first characters inside of " quotes ".' const Line3b2 = '\'cIn"\' changes the first characters inside of " test ".' const Line3c = '\'cAn"\' changes around the "quotes" .' const Line4a1 = "'din,' will delete inside the next list, with, commas." const Line4a2 = "'din,' will delete inside the next list,, commas." const Line4b = "Many different seperators are possible." const Line4c1 = "Some applications you might consider not_a_list." const Line4c2 = "Some applications you might consider not__list." const Line5a = "Replacing the text character with 'a' will find the next programming argument." const Line5b1 = "'cina' will change inside the (next, argument)." const Line5b2 = "'cina' will change inside the (fixed, argument)." const Line5c1 = "'dana' deletes the (next, argument)" const Line5c2 = "'dana' deletes the (argument)" const Line6a = "These are only a brief overview of Targets.vim." const Line6b = "More advanced features can be found on its github repository." const Line6c = "https://github.com/wellle/targets.vim" export class TargetsVimPluginTutorial implements ITutorial { private _stages: ITutorialStage[] constructor() { this._stages = [ new Stages.SetBufferStage([Line1a, Line1b, Line1c1]), new Stages.SetCursorPositionStage(0, 0), new Stages.MoveToGoalStage("Use 'cin(' to change inside the next parenthesis", 2, 46), new Stages.WaitForStateStage("Type 'foo'", [Line1a, Line1b, Line1c2]), new Stages.WaitForModeStage("Press ESC to go back to normal mode", "normal"), Stages.combine( null, new Stages.FadeInLineStage(null, 1, Line2a1), new Stages.FadeInLineStage(null, 2, Line2b1), new Stages.FadeInLineStage(null, 3, Line2c), new Stages.SetBufferStage([Line2a1, Line2b1, Line2c, Line2d1]), ), new Stages.SetCursorPositionStage(0, 7), Stages.combine( "Use 'din(' to delete inside the next parenthesis", new Stages.DeleteCharactersStage(null, 0, 40, "parenthesis"), new Stages.WaitForStateStage(null, [Line2a2, Line2b1, Line2c, Line2d1]), ), new Stages.SetCursorPositionStage(1, 7), new Stages.MoveToGoalStage("Use 'can(' to change the next parenthesis", 1, 32), new Stages.WaitForStateStage("Type 'bar'", [Line2a2, Line2b2, Line2c, Line2d1]), new Stages.WaitForModeStage("Press ESC to go back to normal mode", "normal"), new Stages.MoveToGoalStage("Use 'cil(' to change inside the last parenthesis", 0, 40), new Stages.WaitForStateStage("Type 'foo'", [Line2a3, Line2b2, Line2c, Line2d1]), new Stages.WaitForModeStage("Press ESC to go back to normal mode", "normal"), new Stages.SetCursorPositionStage(3, 34), Stages.combine( "Use 'da(' to delete the current parenthesis", new Stages.DeleteCharactersStage(null, 3, 32, "(current pair)"), new Stages.WaitForStateStage(null, [Line2a3, Line2b2, Line2c, Line2d2]), ), new Stages.MoveToGoalStage("Use 'ca(' to change the next parenthesis", 3, 40), new Stages.WaitForStateStage("Type 'again'", [Line2a3, Line2b2, Line2c, Line2d3]), new Stages.WaitForModeStage("Press ESC to go back to normal mode", "normal"), Stages.combine( null, new Stages.FadeInLineStage(null, 1, Line3a1), new Stages.FadeInLineStage(null, 2, Line3b1), new Stages.SetBufferStage([Line3a1, Line3b1, Line3c]), ), new Stages.SetCursorPositionStage(0, 0), Stages.combine( "Use din' to delete inside the next single quotes", new Stages.DeleteCharactersStage(null, 0, 19, "also"), new Stages.WaitForStateStage(null, [Line3a2, Line3b1, Line3c]), ), new Stages.SetCursorPositionStage(1, 7), new Stages.MoveToGoalStage( 'Use cIn" to change the first non-whitespace characters inside the next double quotes', 1, 48, ), new Stages.WaitForStateStage("Type 'test'", [Line3a2, Line3b2, Line3c]), new Stages.WaitForModeStage("Press ESC to go back to normal mode", "normal"), new Stages.SetCursorPositionStage(2, 7), new Stages.MoveToGoalStage('Use cAn" to change the next double quotes', 2, 26), new Stages.WaitForModeStage("Press ESC to go back to normal mode", "normal"), Stages.combine( null, new Stages.FadeInLineStage(null, 1, Line4a1), new Stages.FadeInLineStage(null, 2, Line4b), new Stages.SetBufferStage([Line4a1, Line4b, Line4c1]), ), new Stages.SetCursorPositionStage(0, 7), Stages.combine( "Use 'din,' to delete the next item in the list", new Stages.DeleteCharactersStage(null, 0, 40, " with"), new Stages.WaitForStateStage(null, [Line4a2, Line4b, Line4c1]), ), Stages.combine( "Use 'din_' to delete inside the next underline in the variable", new Stages.DeleteCharactersStage(null, 2, 41, "a"), new Stages.WaitForStateStage(null, [Line4a2, Line4b, Line4c2]), ), new Stages.MoveToGoalStage("Type gg to go to the beginning.", 0, 0), Stages.combine( null, new Stages.FadeInLineStage(null, 1, Line5a), new Stages.FadeInLineStage(null, 2, Line5b1), new Stages.SetBufferStage([Line5a, Line5b1, Line5c1]), ), new Stages.SetCursorPositionStage(0, 0), new Stages.MoveToGoalStage("Use 'cina' to change the next programming argument", 1, 31), new Stages.WaitForStateStage("Type 'fixed'", [Line5a, Line5b2, Line5c1]), new Stages.WaitForModeStage("Press ESC to go back to normal mode", "normal"), new Stages.MoveToGoalStage("Move to the next line", 2, 7), Stages.combine( "Use 'dana' to delete the next argument", new Stages.DeleteCharactersStage(null, 2, 20, "next, "), new Stages.WaitForStateStage(null, [Line5a, Line5b2, Line5c2]), ), new Stages.MoveToGoalStage("Type gg to go to the beginning.", 0, 0), Stages.combine( null, new Stages.FadeInLineStage(null, 1, Line6a), new Stages.FadeInLineStage(null, 2, Line6b), new Stages.SetBufferStage([Line6a, Line6b, Line6c]), ), new Stages.SetCursorPositionStage(2, 0), new Stages.MoveToGoalStage("Type gg to go to the beginning.", 0, 0), ] } public get metadata(): ITutorialMetadata { return { id: "oni.tutorials.targets_plugin", name: "Targets.vim plugin", description: 'Targets.vim is a plugin installed by default to help move between pairs of characters such as (), {}, or "". It does this by adding various text objects to operate on and expand simple commands like \'di"\'.', level: 300, } } public get stages(): ITutorialStage[] { return this._stages } public get notes(): JSX.Element[] { return [ , , , , , , , , ] } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Tutorials/TextObjectsTutorial.tsx ================================================ /** * TextObjectsTutorial.tsx * * Tutorial to teach text objects */ import * as React from "react" import { ITutorial, ITutorialMetadata, ITutorialStage } from "./../ITutorial" import * as Notes from "./../Notes" import * as Stages from "./../Stages" const stage1line1 = "Text objects typically have delimiters like ( ) and { }" const stage1line2 = "This sentence has a phrase (within some parentheses) for testing" const stage1line2a = "This sentence has a phrase () for testing" const stage1line2b = "This sentence has a phrase for testing" const stage1line2c = "This sentence has a phrase (within some parentheses) for testingwithin some parentheses" const stage1line2d = "This sentence has a phrase (this is a test) for testing" const stage2line1 = "Text objects can also span multiple lines" const stage2line2 = "{" const stage2line3 = " these are mostly useful" const stage2line4 = " when editing code" const stage2line5 = "}" const stage3line1 = "Text Objects aren't limited to single character delimiters; they also work with HTML!" const stage3line2 = "" const stage3line3 = "

    " const stage3line4 = " here is some text" const stage3line4a = " " const stage3line4b = " " const stage3line5 = "

    " const stage3line5a = " " const stage3line6 = "" const stage4line1 = "There are many other Text Objects we can manipulate" const stage4line2 = "[ there ] ( are ) { many } < text > objects ' to ' \" try \"" const stage4line2a = "[] ( are ) { many } < text > objects ' to ' \" try \"" const stage4line2b = "[] () { many } < text > objects ' to ' \" try \"" const stage4line2c = "[] () {} < text > objects ' to ' \" try \"" const stage4line2d = "[] () {} <> objects ' to ' \" try \"" const stage4line2e = "[] () {} <> ' to ' \" try \"" const stage4line2f = "[] () {} <> '' \" try \"" const stage4line2g = "[] () {} <> '' \"\"" export class TextObjectsTutorial implements ITutorial { private _stages: ITutorialStage[] constructor() { this._stages = [ new Stages.SetBufferStage([stage1line1, stage1line2]), new Stages.MoveToGoalStage("Move inside the parentheses", 1, 41), Stages.combine( "Use 'di(' to delete everything within the parentheses", new Stages.DeleteCharactersStage(null, 1, 28, "within some parentheses"), new Stages.WaitForStateStage(null, [stage1line1, stage1line2a]), ), new Stages.WaitForStateStage("Notice it left the parentheses. Hit 'u' to undo", [ stage1line1, stage1line2, ]), new Stages.MoveToGoalStage("Move inside the parentheses", 1, 41), Stages.combine( "Use 'da(' to delete the parentheses and everything within", new Stages.DeleteCharactersStage(null, 1, 27, "(within some parentheses)"), new Stages.WaitForStateStage(null, [stage1line1, stage1line2b]), ), new Stages.WaitForStateStage("Hit 'u' to undo", [stage1line1, stage1line2]), new Stages.MoveToGoalStage("Move inside the parentheses", 1, 41), new Stages.WaitForRegisterStage( "Use 'yi(' to yank everything with the parentheses", "within some parentheses", ), new Stages.MoveToGoalStage("Move to the end of the line", 1, 63), new Stages.WaitForStateStage("Paste from the clipboard with 'p'", [ stage1line1, stage1line2c, ]), new Stages.WaitForStateStage("Hit 'u' to undo", [stage1line1, stage1line2]), new Stages.MoveToGoalStage("Move inside the parentheses", 1, 41), Stages.combine( "Use 'ci(' to change everything within the parentheses", new Stages.WaitForModeStage(null, "insert"), new Stages.WaitForStateStage(null, [stage1line1, stage1line2a]), ), new Stages.WaitForStateStage("Type 'this is a test'", [stage1line1, stage1line2d]), new Stages.WaitForModeStage("Hit to exit insert mode", "normal"), new Stages.SetBufferStage([ stage2line1, stage2line2, stage2line3, stage2line4, stage2line5, ]), new Stages.MoveToGoalStage("Move inside the curly brackets", 2, 14), Stages.combine( "Use 'vi{' to select everything within the curly brackets", new Stages.MoveToGoalStage(null, 3, 19), new Stages.WaitForModeStage(null, "visual"), ), new Stages.WaitForModeStage("Hit to exit visual mode", "normal"), Stages.combine( "Use 'va{' to select everything including the curly brackets", new Stages.MoveToGoalStage(null, 4, 0), new Stages.WaitForModeStage(null, "visual"), ), new Stages.WaitForModeStage("Hit to exit visual mode", "normal"), new Stages.SetBufferStage([ stage3line1, stage3line2, stage3line3, stage3line4, stage3line5, stage3line6, ]), new Stages.MoveToGoalStage("Move inside the HTML tag", 3, 20), Stages.combine( "Use 'dit' to delete everything within the tag", new Stages.DeleteCharactersStage(null, 3, 12, "here is some text"), new Stages.WaitForStateStage(null, [ stage3line1, stage3line2, stage3line3, stage3line4a, stage3line5, stage3line6, ]), ), new Stages.WaitForStateStage("Hit 'u' to undo", [ stage3line1, stage3line2, stage3line3, stage3line4, stage3line5, stage3line6, ]), Stages.combine( "Use 'dat' to delete the tag and its contents", new Stages.DeleteCharactersStage(null, 3, 6, "here is some text"), new Stages.WaitForStateStage(null, [ stage3line1, stage3line2, stage3line3, stage3line4b, stage3line5, stage3line6, ]), ), Stages.combine( "Use 'dat' to delete the

    tag and its contents", new Stages.DeleteCharactersStage(null, 2, 3, "

    "), new Stages.DeleteCharactersStage(null, 3, 0, " "), new Stages.DeleteCharactersStage(null, 4, 0, "

    "), new Stages.WaitForStateStage(null, [ stage3line1, stage3line2, stage3line5a, stage3line6, ]), ), new Stages.SetBufferStage([stage4line1, stage4line2]), new Stages.MoveToGoalStage("Move to the next line", 1, 0), Stages.combine( "Try 'di['", new Stages.DeleteCharactersStage(null, 1, 1, " there "), new Stages.WaitForStateStage(null, [stage4line1, stage4line2a]), ), Stages.combine( "Try 'di('", new Stages.DeleteCharactersStage(null, 1, 4, " are "), new Stages.WaitForStateStage(null, [stage4line1, stage4line2b]), ), Stages.combine( "Try 'di{'", new Stages.DeleteCharactersStage(null, 1, 7, " many "), new Stages.WaitForStateStage(null, [stage4line1, stage4line2c]), ), Stages.combine( "Try 'di<'", new Stages.DeleteCharactersStage(null, 1, 10, " text "), new Stages.WaitForStateStage(null, [stage4line1, stage4line2d]), ), new Stages.MoveToGoalStage("Move inside the word 'objects'", 1, 15), Stages.combine( "Try 'diw'", new Stages.DeleteCharactersStage(null, 1, 12, "objects"), new Stages.WaitForStateStage(null, [stage4line1, stage4line2e]), ), Stages.combine( "Try di'", new Stages.DeleteCharactersStage(null, 1, 14, " to "), new Stages.WaitForStateStage(null, [stage4line1, stage4line2f]), ), Stages.combine( 'Try di"', new Stages.DeleteCharactersStage(null, 1, 17, " try "), new Stages.WaitForStateStage(null, [stage4line1, stage4line2g]), ), ] } public get metadata(): ITutorialMetadata { return { id: "oni.tutorial.text_objects", name: "Text Objects: i, a", description: 'Everything you\'ve learned so far has involved motions from the current cursor position to a destination of some kind. Now it\'s time to learn about Text Objects, blocks of text with their own starting and ending characters. Text Objects can be manipulated with the operators you\'ve already learned such as `y`, `c`, `d`, and `v`. In general, defining a text obect with `i` will be the "inner" object, ignoring the text object boundary characters. Defining a text object with `a` will be "an" object, including the text object boundaries.', level: 240, } } public get stages(): ITutorialStage[] { return this._stages } public get notes(): JSX.Element[] { return [ , , , , , , ] } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Tutorials/VerticalMovementTutorial.tsx ================================================ /** * 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 = 1; i < 151; 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: "File Motion: gg, G", description: "When working with large files, it's very helpful to be able to quickly move to the top or bottom of the file, as well as to a particular line number. `gg`, `G`, and `G` can help us here!", level: 150, } } public get stages(): ITutorialStage[] { return this._stages } public get notes(): JSX.Element[] { return [, , ] } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Tutorials/VisualModeTutorial.tsx ================================================ /** * VisualModeTutorial.tsx * * Tutorial for learning how to select text in visual mode. */ import * as React from "react" import { ITutorial, ITutorialMetadata, ITutorialStage } from "./../ITutorial" import * as Notes from "./../Notes" import * as Stages from "./../Stages" const Line1 = "Text can be selectedselected with 'v'." const Line2 = "Selected text can then be (y)anked, (c)hanged, or (d)eleted with a single keypress" const Line3 = "To select entire lines, use 'V' to include line-ending characters." const Line4 = "Selected lines can also be with a single keypress" const Line1Marker = "Text can be ".length const Line1Marker2 = "Text can be selecte".length const Line1Change = "Text can be selected with 'v'." const Line2Marker = "Selected text can then be ".length const Line2Marker2 = "Selected text can then be (y)anked, (c)hanged, or (d)elete".length const Line4Marker = "Selected lines can also be".length const Line4PostPaste = "Selected lines can also be (y)anked, (c)hanged, or (d)eleted with a single keypress" const Line3Marker = "To select entire lines, use 'V' to include ".length const Line3Marker2 = "To select entire lines, use 'V' to include line-endin".length const Line3Pending = "To select entire lines, use 'V' to include characters." const Line3Change = "To select entire lines, use 'V' to include newline characters." const Line3Marker3 = "To select entire lines, use 'V' to include newlin".length export class VisualModeTutorial implements ITutorial { private _stages: ITutorialStage[] constructor() { this._stages = [ new Stages.SetBufferStage([Line1, Line2, Line3, Line4]), new Stages.MoveToGoalStage("Move to the goal marker", 0, Line1Marker), new Stages.WaitForModeStage("Change to Visual mode with 'v'", "visual"), new Stages.MoveToGoalStage("Move to the goal marker", 0, Line1Marker2), new Stages.WaitForStateStage("Hit 'd' to delete the selected text", [ Line1Change, Line2, Line3, Line4, ]), new Stages.MoveToGoalStage("Move to the goal marker", 1, Line2Marker), new Stages.WaitForModeStage("Change to Visual mode with 'v'", "visual"), new Stages.MoveToGoalStage("Move to the goal marker", 1, Line2Marker2), new Stages.WaitForRegisterStage( "Yank the selection with 'y'", "(y)anked, (c)hanged, or (d)eleted", ), new Stages.MoveToGoalStage("Move to the goal marker", 3, Line4Marker), new Stages.WaitForStateStage("Paste the yanked text with 'p'", [ Line1Change, Line2, Line3, Line4PostPaste, ]), new Stages.MoveToGoalStage("Move to the goal marker", 2, Line3Marker), new Stages.WaitForModeStage("Change to Visual mode with 'v'", "visual"), new Stages.MoveToGoalStage("Move to the goal marker", 2, Line3Marker2), new Stages.WaitForStateStage("Change the selected text with 'c'", [ Line1Change, Line2, Line3Pending, Line4PostPaste, ]), new Stages.WaitForStateStage("Enter the word 'newline'", [ Line1Change, Line2, Line3Change, Line4PostPaste, ]), new Stages.WaitForModeStage("Exit Insert mode by hitting ", "normal"), new Stages.WaitForModeStage("Move into Visual Line mode with 'V'", "visual"), new Stages.MoveToGoalStage("Move to the next line", 3, Line3Marker3), new Stages.WaitForStateStage("Delete the selected lines with 'd'", [ Line1Change, Line2, ]), ] } public get metadata(): ITutorialMetadata { return { id: "oni.tutorials.visual_mode", name: "Visual Select: v, V", description: "Sometimes the text you want to modify isn't on a simple word boundary. We often need to change, yank, or delete any arbitrary text. Rather than performing a cursor movement and hoping you affected the correct characters, you can visually select text. Using 'v' will select characters as you move, and 'V' will select lines", level: 230, } } public get stages(): ITutorialStage[] { return this._stages } public get notes(): JSX.Element[] { return [ , , , , , , ] } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Tutorials/WordMotionTutorial.tsx ================================================ /** * 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." const Empty = "" const Line4 = "Word boundaries are defined by characters like '\"[]().:" const Line5 = "Use the W, E, B keys to use WHITESPACE as the only word boundary" const Line6 = "Try moving over an IP address like 192.168.100.252 to see the behavior" const Line7 = "Move past this timestamp [1970-01-01_00:00:00,000] with a single key press" 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), new Stages.MoveToGoalStage( "Use the 'e' key to move to the end of the current word", 1, 12, ), new Stages.MoveToGoalStage( "Use the 'e' key to move to the end of the next word", 1, 15, ), new Stages.MoveToGoalStage( "Use the 'e' key to move to the end of the next word", 1, 20, ), new Stages.SetBufferStage([Line1, Line2, Line3]), new Stages.MoveToGoalStage("Use the 'j' key to move down a line", 2, 20), new Stages.MoveToGoalStage( "Use the 'b' key to move to the beginning of the current word", 2, 17, ), new Stages.MoveToGoalStage( "Use the 'b' key to move to the beginning of the previous word", 2, 14, ), new Stages.MoveToGoalStage( "Use the 'b' key to move to the beginning of the previous word", 2, 10, ), new Stages.SetBufferStage([Line1, Line2, Line3, Empty, Line4, Line5, Line6]), new Stages.MoveToGoalStage("Move to the goal marker", 6, 35), new Stages.MoveToGoalStage( "Keep hitting 'w' until you move past the IP address", 6, 51, ), new Stages.MoveToGoalStage( "Well that was annoying. Now use 'B' to jump backwards by whitespace", 6, 35, ), new Stages.MoveToGoalStage( "Use 'W' to jump to the next word by whitespace. Much faster!", 6, 51, ), new Stages.SetBufferStage([Line1, Line2, Line3, Empty, Line4, Line5, Line6, Line7]), new Stages.MoveToGoalStage("Move to the goal marker", 7, 25), new Stages.MoveToGoalStage("Use 'W' to move to the word after the timestamp", 7, 51), new Stages.MoveToGoalStage("Use 'B' to move to the beginning of the timestamp", 7, 25), new Stages.MoveToGoalStage("Use 'E' to move to the end of the timestamp", 7, 49), ] } public get metadata(): ITutorialMetadata { return { id: "oni.tutorials.word_motion", name: "Word 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 `e` key moves to the end of the next word, and the `b` key moves to the beginning letter of the previous word.", level: 130, } } public get stages(): ITutorialStage[] { return this._stages } public get notes(): JSX.Element[] { return [ , , , , , , , ] } } ================================================ FILE: browser/src/Services/Learning/Tutorial/Tutorials/index.tsx ================================================ /** * TutorialManager */ import { ITutorial } from "./../ITutorial" import { BasicMovementTutorial } from "./BasicMovementTutorial" import { BeginningsAndEndingsTutorial } from "./BeginningsAndEndingsTutorial" import { ChangeOperatorTutorial } from "./ChangeOperatorTutorial" import { CopyPasteTutorial } from "./CopyPasteTutorial" import { DeleteCharacterTutorial } from "./DeleteCharacterTutorial" import { DeleteOperatorTutorial } from "./DeleteOperatorTutorial" import { DotCommandTutorial } from "./DotCommandTutorial" import { InlineFindingTutorial } from "./InlineFindingTutorial" import { InsertAndUndoTutorial } from "./InsertAndUndoTutorial" import { SearchInBufferTutorial } from "./SearchInBufferTutorial" import { SwitchModeTutorial } from "./SwitchModeTutorial" import { TargetsVimPluginTutorial } from "./TargetsVimPluginTutorial" import { TextObjectsTutorial } from "./TextObjectsTutorial" import { VerticalMovementTutorial } from "./VerticalMovementTutorial" import { VisualModeTutorial } from "./VisualModeTutorial" 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 InsertAndUndoTutorial(), new VerticalMovementTutorial(), new WordMotionTutorial(), new SearchInBufferTutorial(), new CopyPasteTutorial(), new ChangeOperatorTutorial(), new VisualModeTutorial(), new TargetsVimPluginTutorial(), new InlineFindingTutorial(), new TextObjectsTutorial(), new DotCommandTutorial(), ] ================================================ FILE: browser/src/Services/Learning/Tutorial/index.ts ================================================ export * from "./ITutorial" export * from "./TutorialManager" export * from "./Tutorials" ================================================ FILE: browser/src/Services/Learning/index.ts ================================================ /** * Learning.ts */ 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("learning.enabled") Achievements.activate( commandManager, configuration, editorManager, sidebarManager, overlayManager, ) const achievements = Achievements.getInstance() if (!learningEnabled) { return } 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.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 ================================================ FILE: browser/src/Services/Menu/Filter/FuseFilter.ts ================================================ import * as Fuse from "fuse.js" import * as sortBy from "lodash/sortBy" import * as utils from "./Utils" import { IMenuOptionWithHighlights } from "./../Menu" export function filter(options: any[], searchString: string): IMenuOptionWithHighlights[] { if (!searchString) { const opt = options.map(o => { return { ...o, label: o.label, detail: o.detail, icon: o.icon, pinned: o.pinned, metadata: o.metadata, detailHighlights: [], labelHighlights: [], additionalComponent: o.additionalComponent, } }) return sortBy(opt, o => (o.pinned ? 0 : 1)) } const fuseOptions = { keys: [ { name: "label", weight: 0.6, }, { name: "detail", weight: 0.4, }, ], caseSensitive: utils.shouldBeCaseSensitive(searchString), include: ["matches"], } // remove duplicate characters const searchSet = new Set(searchString) // remove any items that don't have all the characters from searchString // For this first pass, ignore case const filteredOptions = options.filter(o => { if (!o.label && !o.detail) { return false } const label = o.label ? o.label.toLowerCase() : "" const detail = o.detail ? o.detail.toLowerCase() : "" const combined = label + detail for (const c of searchSet) { if (combined.indexOf(c.toLowerCase()) === -1) { return false } } return true }) const fuse = new Fuse(filteredOptions, fuseOptions) const results = fuse.search(searchString) const highlightOptions = results.map((f: any) => { let labelHighlights: number[][] = [] let detailHighlights: number[][] = [] // matches will have 1 or 2 items depending on // whether one or both (label and detail) matched f.matches.forEach((obj: any) => { if (obj.key === "label") { labelHighlights = obj.indices } else { detailHighlights = obj.indices } }) return { ...f, icon: f.item.icon, pinned: f.item.pinned, label: f.item.label, detail: f.item.detail, metadata: f.item.metadata, labelHighlights: convertArrayOfPairsToIndices(labelHighlights), detailHighlights: convertArrayOfPairsToIndices(detailHighlights), additionalComponent: f.item.additionalComponent, } }) return highlightOptions } function convertArrayOfPairsToIndices(pairs: number[][]): number[] { const ret: number[] = [] pairs.forEach(p => { const [startIndex, endIndex] = p for (let i = startIndex; i <= endIndex; i++) { ret.push(i) } }) return ret } ================================================ FILE: browser/src/Services/Menu/Filter/NoFilter.ts ================================================ // import * as Oni from "oni-api" // TODO: add additionalComponent to Oni.Menu.MenuOption (as optional?) // TODO: Also might want to merge IMenuOptionWithHighlights as optional fields import { IMenuOptionWithHighlights } from "../Menu" function convert(entry: any /*Oni.Menu.MenuOption*/): IMenuOptionWithHighlights { return { ...entry, label: entry.label, detail: entry.detail, icon: entry.icon, pinned: entry.pinned, metadata: entry.metadata, detailHighlights: [], labelHighlights: [], additionalComponent: entry.additionalComponent, } } export function filter(options: any[], searchString: string): IMenuOptionWithHighlights[] { return options.map(o => convert(o)) } ================================================ FILE: browser/src/Services/Menu/Filter/RegExFilter.ts ================================================ import * as sortBy from "lodash/sortBy" import * as Oni from "oni-api" import * as utils from "./Utils" import { IMenuOptionWithHighlights } from "./../Menu" import { createLetterCountDictionary, LetterCountDictionary, } from "./../../../UI/components/HighlightText" export function filter( options: Oni.Menu.MenuOption[], searchString: string, ): IMenuOptionWithHighlights[] { if (!searchString) { const opt = options.map(o => { return { ...o, detailHighlights: [], labelHighlights: [], } }) return sortBy(opt, o => (o.pinned ? 0 : 1)) } const isCaseSensitive = utils.shouldBeCaseSensitive(searchString) if (!isCaseSensitive) { searchString = searchString.toLowerCase() } const listOfSearchTerms = searchString.split(" ").filter(x => x) let filteredOptions = options listOfSearchTerms.map(searchTerm => { filteredOptions = processSearchTerm(searchTerm, filteredOptions, isCaseSensitive) }) const ret = filteredOptions.map(fo => { const letterCountDictionary = createLetterCountDictionary(searchString) const detailHighlights = getHighlightsFromString( fo.detail, letterCountDictionary, isCaseSensitive, ) const labelHighlights = getHighlightsFromString( fo.label, letterCountDictionary, isCaseSensitive, ) return { ...fo, detailHighlights, labelHighlights, } }) return ret } export function 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 function getHighlightsFromString( text: string, letterCountDictionary: LetterCountDictionary, isCaseSensitive: boolean = false, ): number[] { if (!text) { return [] } const ret: number[] = [] for (let i = 0; i < text.length; i++) { const letter = isCaseSensitive ? text[i] : text[i].toLowerCase() const idx = i if (letterCountDictionary[letter] && letterCountDictionary[letter] > 0) { ret.push(idx) letterCountDictionary[letter]-- } } return ret } ================================================ FILE: browser/src/Services/Menu/Filter/Utils.ts ================================================ import { configuration } from "./../../../Services/Configuration" export const shouldBeCaseSensitive = (searchString: string): boolean => { // TODO: Technically, this makes the reducer 'impure', // which is not ideal - need to refactor eventually. // // One option is to plumb through the configuration setting // from the top-level, but it might be worth extracting // out the filter strategy in general. const caseSensitivitySetting = configuration.getValue("menu.caseSensitive") if (caseSensitivitySetting === false) { return false } else if (caseSensitivitySetting === true) { return true } else { // "Smart" casing strategy // If the string is all lower-case, not case sensitive.. if (searchString === searchString.toLowerCase()) { return false // Otherwise, it is case sensitive.. } else { return true } } } ================================================ FILE: browser/src/Services/Menu/Filter/VSCodeFilter.ts ================================================ import * as sortBy from "lodash/sortBy" import * as Oni from "oni-api" import { compareItemsByScoreOni, getHighlightsFromResult, scoreItemOni, } from "./../../Search/Scorer/OniQuickOpenScorer" import { ScorerCache } from "./../../Search/Scorer/QuickOpenScorer" import * as utils from "./Utils" import { IMenuOptionWithHighlights } from "./../Menu" export function filter( options: Oni.Menu.MenuOption[], searchString: string, ): IMenuOptionWithHighlights[] { if (!searchString) { const opt = options.map(o => { return { ...o, detailHighlights: [], labelHighlights: [], } }) return sortBy(opt, o => (o.pinned ? 0 : 1)) } const isCaseSensitive = utils.shouldBeCaseSensitive(searchString) if (!isCaseSensitive) { searchString = searchString.toLowerCase() } const listOfSearchTerms = searchString.split(" ").filter(x => x) // Since the VSCode scorer doesn't deal so well with the spaces, // instead rebuild the term in reverse order. // ie `index browser editor` becomes `browsereditorindex` // This allows the scoring and highlighting to work better. const vsCodeSearchString = listOfSearchTerms.length > 1 ? listOfSearchTerms.slice(1).join("") + listOfSearchTerms[0] : listOfSearchTerms[0] // Adds a cache for the scores. This is needed to stop the final score // compare from repeating all the scoring logic again. // Currently, this only persists for the current search, which will speed // up that search only. // TODO: Is it worth instead persisting this cache? // Plus side is repeated searches are fast. // Down side is there will be a lot of rubbish being stored too. const cache: ScorerCache = {} const filteredOptions = processSearchTerm(vsCodeSearchString, options, cache) const ret = filteredOptions.filter(fo => { if (fo.score === 0) { return false } else { return true } }) return ret.sort((e1, e2) => compareItemsByScoreOni(e1, e2, vsCodeSearchString, true, cache)) } export function processSearchTerm( searchString: string, options: Oni.Menu.MenuOption[], cache: ScorerCache, ): Oni.Menu.IMenuOptionWithHighlights[] { const result: Oni.Menu.IMenuOptionWithHighlights[] = options.map(f => { const itemScore = scoreItemOni(f, searchString, true, cache) const detailHighlights = getHighlightsFromResult(itemScore.descriptionMatch) const labelHighlights = getHighlightsFromResult(itemScore.labelMatch) return { ...f, detailHighlights, labelHighlights, score: f.pinned ? Number.MAX_SAFE_INTEGER : itemScore.score, } }) return result } ================================================ FILE: browser/src/Services/Menu/Filter/index.ts ================================================ import * as Oni from "oni-api" import { filter as fuseFilter } from "./FuseFilter" import { filter as noFilter } from "./NoFilter" import { filter as RegExFilter } from "./RegExFilter" import { filter as vscodeFilter } from "./VSCodeFilter" class Filters implements Oni.Menu.IMenuFilters { private _filters = new Map() constructor() { this._filters .set("default", noFilter) .set("none", noFilter) .set("fuse", fuseFilter) .set("regex", RegExFilter) .set("vscode", vscodeFilter) } public getDefault(): Oni.Menu.IMenuFilter { return this.getByName("default") } public getByName(name: string): Oni.Menu.IMenuFilter { return this._filters.has(name) ? this._filters.get(name) : this.getDefault() } // TODO: Add register & unregister for plugins } const _instance = new Filters() export function getInstance(owner: string): Oni.Menu.IMenuFilters { return _instance } ================================================ FILE: browser/src/Services/Menu/Menu.less ================================================ @import (reference) "./../../UI/components/common.less"; @keyframes fade-in-and-down { from { opacity: 0; } to { opacity: 1; } } .tool-tip-container { .box-shadow; } .menu-background { position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; background-color: rgba(0, 0, 0, 0.25); display: flex; flex-direction: column; align-items: center; } .menu { .box-shadow; position: relative; margin-top: 16px; padding: 8px; width: 75%; max-width: 900px; input { border: 0px; background-color: rgba(0, 0, 0, 0.2); font-size: 1.1em; box-sizing: border-box; width: 100%; padding: 8px; outline: none; } .items { .item { .fa:not(.fa-spin) { padding-left: 4px; padding-right: 4px; } &:hover { background-color: rgba(0, 0, 0, 0.1); } &.selected { background-color: rgba(0, 0, 0, 0.1); } .label { margin: 4px; padding-right: 8px; } .detail { font-size: @font-size-small; color: @text-color-detail; flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; padding-right: 8px; } } } .footer { .box-shadow; text-align: center; width: 100%; transition-property: transform, opacity; transition-duration: 0.5s; position: absolute; bottom: 0px; height: 3em; margin-left: -8px; // Put behind items / menu element z-index: -1; &.loading { transform: translateY(100%); opacity: 1; .loading-spinner { opacity: 1; } } &.loaded { opacity: 0; transform: translateY(0%); } .loading-spinner { line-height: 3em; height: 3em; opacity: 0; .loading & { opacity: 1; } } } } ================================================ FILE: browser/src/Services/Menu/Menu.ts ================================================ /** * Menu.ts * * Implements API surface area for working with the status bar */ import { bindActionCreators } from "redux" import thunk from "redux-thunk" import * as Oni from "oni-api" import { Event, IEvent } from "oni-types" import { filter as fuseFilter } from "./Filter/FuseFilter" import * as ActionCreators from "./MenuActionCreators" import { MenuContainer } from "./MenuComponent" import { createReducer } from "./MenuReducer" import * as State from "./MenuState" import { Configuration } from "./../Configuration" import { Overlay, OverlayManager } from "./../Overlay" import { createStore } from "./../../Redux" export interface IMenuOptionWithHighlights extends Oni.Menu.MenuOption { labelHighlights: number[] detailHighlights: number[] } export type MenuState = State.IMenus const reducer = createReducer() export const menuStore = createStore( "MenuStore", reducer, State.createDefaultState(), [thunk], ) export const menuActions: typeof ActionCreators = bindActionCreators( ActionCreators as any, menuStore.dispatch, ) export const sanitizeConfigurationValue = (value: any, defaultValue: number): number => { const parsedValue = parseInt(value, 10) return parsedValue > 0 ? parsedValue : defaultValue } export class MenuManager { private _id: number = 0 private _overlay: Overlay constructor(private _configuration: Configuration, private _overlayManager: OverlayManager) { this._overlay = this._overlayManager.createItem() this._overlay.setContents(MenuContainer()) this._overlay.show() this._configuration.onConfigurationChanged.subscribe(() => { this._updateConfiguration() }) this._updateConfiguration() } public create(): Menu { this._id++ return new Menu(this._id.toString()) } public isMenuOpen(): boolean { return !!menuStore.getState().menu } public nextMenuItem(): void { menuActions.nextMenuItem() } public previousMenuItem(): void { menuActions.previousMenuItem() } public closeActiveMenu(): void { menuActions.hidePopupMenu() } public selectMenuItem(idx?: number): void { const menuState = menuStore.getState() if (menuState && menuState.menu) { menuState.menu.onSelectItem(idx) } } private _updateConfiguration(): void { const values = this._configuration.getValues() const rowHeightUnsanitized = values["menu.rowHeight"] const maxItemsUnsanitized = values["menu.maxItemsToShow"] menuActions.setMenuConfiguration( sanitizeConfigurationValue(rowHeightUnsanitized, 40), sanitizeConfigurationValue(maxItemsUnsanitized, 6), ) } } export class Menu implements Oni.Menu.MenuInstance { private _onItemSelected = new Event() private _onSelectedItemChanged = new Event() private _onFilterTextChanged = new Event() private _onHide = new Event() private _filterFunction = fuseFilter public get onHide(): IEvent { return this._onHide } public get onItemSelected(): IEvent { return this._onItemSelected } public get onSelectedItemChanged(): IEvent { return this._onSelectedItemChanged } public get onFilterTextChanged(): IEvent { return this._onFilterTextChanged } public get selectedItem() { return this._getSelectedItem() } constructor(private _id: string) {} public isOpen(): boolean { const menuState = menuStore.getState() return menuState.menu && menuState.menu.id === this._id } public setLoading(isLoading: boolean): void { menuActions.setMenuLoading(this._id, isLoading) } public setItems(items: Oni.Menu.MenuOption[]): void { menuActions.setMenuItems(this._id, items) } public setFilterFunction( filterFunc: ( items: Oni.Menu.MenuOption[], searchString: string, ) => IMenuOptionWithHighlights[], ) { this._filterFunction = filterFunc } public show(): void { menuActions.showPopupMenu(this._id, { filterFunction: this._filterFunction, onSelectedItemChanged: item => this._onSelectedItemChanged.dispatch(item), onSelectItem: (idx: number) => this._onItemSelectedHandler(idx), onHide: () => this._onHide.dispatch(), onFilterTextChanged: newText => this._onFilterTextChanged.dispatch(newText), }) } public hide(): void { menuActions.hidePopupMenu() } private _onItemSelectedHandler(idx?: number): void { const selectedOption = this._getSelectedItem(idx) this._onItemSelected.dispatch(selectedOption) this.hide() } private _getSelectedItem(idx?: number) { const menuState = menuStore.getState() if (!menuState.menu) { return null } const index = typeof idx === "number" ? idx : menuState.menu.selectedIndex return menuState.menu.filteredOptions[index] } } ================================================ FILE: browser/src/Services/Menu/MenuActionCreators.ts ================================================ /** * MenuActionCreators.ts */ import * as MenuActions from "./MenuActions" // Selector const getSelectedItem = (contextMenuState: any) => { if (!contextMenuState.menu) { return null } const index = contextMenuState.menu.selectedIndex return contextMenuState.menu.filteredOptions[index] } const notifySelectedItemChange = (contextMenuState: any) => { const selectedItem = getSelectedItem(contextMenuState) if (contextMenuState && contextMenuState.menu && contextMenuState.menu.onSelectedItemChanged) { contextMenuState.menu.onSelectedItemChanged(selectedItem) } } export const setMenuConfiguration = (rowHeight: number, maxItemsToShow: number) => { return { type: "SET_MENU_CONFIGURATION", payload: { rowHeight, maxItemsToShow, }, } } export const showPopupMenu = ( id: string, opts?: MenuActions.IMenuOptions, items?: any, filter?: string, ) => { return { type: "SHOW_MENU", payload: { id, items, filter, options: opts, }, } } export const setMenuLoading = (id: string, isLoading: boolean) => ({ type: "SET_MENU_LOADING", payload: { id, isLoading, }, }) export const setMenuItems = (id: string, items: any[]) => ({ type: "SET_MENU_ITEMS", payload: { id, items, }, }) export const hidePopupMenu = () => (dispatch: any, getState: any) => { const state = getState() if (!state.menu) { return } if (state.menu.onHide) { state.menu.onHide() } dispatch({ type: "HIDE_MENU", }) } export const previousMenuItem = () => (dispatch: any, getState: any) => { dispatch({ type: "PREVIOUS_MENU", }) notifySelectedItemChange(getState()) } export const filterMenu = (filterString: string) => (dispatch: any, getState: any) => { const state = getState() if (!state.menu) { return } if (state.menu.onFilterTextChanged) { state.menu.onFilterTextChanged(filterString) } dispatch({ type: "FILTER_MENU", payload: { filter: filterString, }, }) notifySelectedItemChange(getState()) } export const nextMenuItem = () => (dispatch: any, getState: any) => { dispatch({ type: "NEXT_MENU", }) notifySelectedItemChange(getState()) } ================================================ FILE: browser/src/Services/Menu/MenuActions.ts ================================================ /** * Menu.ts * * Implements API surface area for working with the status bar */ export interface IMenuOptions { foregroundColor?: string backgroundColor?: string highlightColor?: string filterFunction?: (items: any[], searchString: string) => any[] onSelectedItemChanged?: (newItem: any) => void onSelectItem?: (idx: number) => void onHide?: () => void onFilterTextChanged?: (newText: string) => void } export interface ISetConfigurationMenuAction { type: "SET_MENU_CONFIGURATION" payload: { rowHeight: number maxItemsToShow: number } } export interface IShowMenuAction { type: "SHOW_MENU" payload: { id: string options?: IMenuOptions items?: any[] filter?: string } } export interface ISetMenuItems { type: "SET_MENU_ITEMS" payload: { id: string items: T[] } } export interface ISetMenuLoading { type: "SET_MENU_LOADING" payload: { id: string isLoading: boolean } } export interface IFilterMenuAction { type: "FILTER_MENU" payload: { id: string filter: string } } export interface IHideMenuAction { type: "HIDE_MENU" } export interface INextMenuAction { type: "NEXT_MENU" } export interface IPreviousMenuAction { type: "PREVIOUS_MENU" } export type MenuAction = | IShowMenuAction | ISetConfigurationMenuAction | ISetMenuLoading | ISetMenuItems | IFilterMenuAction | IHideMenuAction | INextMenuAction | IPreviousMenuAction ================================================ FILE: browser/src/Services/Menu/MenuComponent.tsx ================================================ import * as React from "react" import * as ReactDOM from "react-dom" import { connect, Provider } from "react-redux" import { AutoSizer, List } from "react-virtualized" import * as Oni from "oni-api" import styled, { getSelectedBorder } from "../../UI/components/common" import { HighlightTextByIndex } from "./../../UI/components/HighlightText" import { Icon, IconSize } from "./../../UI/Icon" import { focusManager } from "./../FocusManager" import { IMenuOptionWithHighlights, menuStore } from "./Menu" import * as ActionCreators from "./MenuActionCreators" import * as State from "./MenuState" import { render as renderPinnedIcon } from "./PinnedIconView" import { withProps } from "./../../UI/components/common" import { TextInputView } from "./../../UI/components/LightweightText" export interface IMenuProps { visible: boolean selectedIndex: number filterText: string onChangeFilterText: (text: string) => void onSelect: (selectedIndex?: number) => void onHide: () => void items: IMenuOptionWithHighlights[] isLoading: boolean rowHeight: number maxItemsToShow: number } const MenuStyleWrapper = styled.div` background-color: ${props => props.theme["menu.background"]}; color: ${props => props.theme["menu.foreground"]}; & input { color: ${props => props.theme["menu.foreground"]}; background-color: rgba(0, 0, 0.2); } ` export class MenuView extends React.PureComponent { private _inputElement: HTMLInputElement = null private _popupBody: Element = null public componentWillUpdate(newProps: Readonly): void { if (newProps.visible !== this.props.visible && !newProps.visible && this._inputElement) { focusManager.popFocus(this._inputElement) } } public render(): null | JSX.Element { if (!this.props.visible) { return null } const rowRenderer = (props: { key: string; index: number; style: React.CSSProperties }) => { const item = this.props.items[props.index] return (
    this.props.onSelect(props.index)} />
    ) } const footerClassName = "footer " + (this.props.isLoading ? "loading" : "loaded") const height = Math.min(this.props.items.length, this.props.maxItemsToShow) * this.props.rowHeight return (
    { this._popupBody = elem }} > this._onChange(evt)} />
    {({ width }) => ( )}
    ) } private _onChange(evt: React.FormEvent) { const target: any = evt.target this.props.onChangeFilterText(target.value) } /** * Hide the popup if a click event was registered outside of it */ private handleHide = (event: any) => { const node = ReactDOM.findDOMNode(this._popupBody) if (!node.contains(event.target as Node)) { this.props.onHide() } } } const EmptyArray: any[] = [] const noop = () => {} // tslint:disable-line const NullProps: any = { visible: false, selectedIndex: 0, filterText: "", items: EmptyArray, onSelect: noop, isLoading: true, rowHeight: 0, maxItemsToShow: 0, } const mapStateToProps = ( state: State.IMenus, ): any => { if (!state.menu) { return NullProps } else { const popupMenu = state.menu return { visible: true, selectedIndex: popupMenu.selectedIndex, filterText: popupMenu.filter, items: popupMenu.filteredOptions, onSelect: popupMenu.onSelectItem, isLoading: popupMenu.isLoading, rowHeight: state.configuration.rowHeight, maxItemsToShow: state.configuration.maxItemsToShow, } } } const mapDispatchToProps = { onChangeFilterText: ActionCreators.filterMenu, onHide: ActionCreators.hidePopupMenu, } export const ConnectedMenu: any = connect(mapStateToProps, mapDispatchToProps)(MenuView) export const MenuContainer = () => { return ( ) } export interface IMenuItemProps { icon?: string | JSX.Element isSelected: boolean filterText: string label: string labelHighlights: number[] detail: string detailHighlights: number[] pinned: boolean additionalComponent?: JSX.Element onClick: () => void height: number } export interface IMenuItemWrapperProps { isSelected: boolean borderSize?: string } const MenuItemWrapper = withProps(styled.div)` position: absolute; top: 4px; left: 0px; right: 4px; bottom: 4px; border-left: ${getSelectedBorder}; display: flex; flex-direction: row; align-items: center; user-select: none; -webkit-user-drag:none; cursor: pointer; font-size: 1em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; ` export class MenuItem extends React.PureComponent { public render(): JSX.Element { const className = "item" + (this.props.isSelected ? " selected" : "") return ( this.props.onClick()} style={{ height: this.props.height + "px" }} > {this.getIcon()} {this.props.additionalComponent} {renderPinnedIcon({ pinned: this.props.pinned })} ) } private getIcon() { if (!this.props.icon) { return } if (typeof this.props.icon === "string") { return } return this.props.icon } } const LabelHighlight = styled.span` font-weight: bold; color: ${props => props.theme["highlight.mode.normal.background"]}; ` const DetailHighlight = styled.span` font-weight: bold; color: #757575; ` ================================================ FILE: browser/src/Services/Menu/MenuReducer.ts ================================================ /** * MenuReducer.ts * * Implements state-change logic for the menu */ import { filter } from "./Filter/FuseFilter" import * as Actions from "./MenuActions" import * as State from "./MenuState" export function createReducer() { const reducer = ( s: State.IMenus, a: Actions.MenuAction, ): State.IMenus => { return { ...s, configuration: configurationReducer(s.configuration, a), menu: popupMenuReducer(s.menu, a), } } const configurationReducer = ( s: State.IMenuConfigurationSettings = State.DefaultMenuConfigurationSettings, a: Actions.MenuAction, ) => { switch (a.type) { case "SET_MENU_CONFIGURATION": return { ...s, rowHeight: a.payload.rowHeight, maxItemsToShow: a.payload.maxItemsToShow, } default: return s } } function popupMenuReducer( s: State.IMenu | null, a: any, ): State.IMenu { // TODO: sync max display items (10) with value in Menu.render() (Menu.tsx) const size = s && s.filteredOptions ? s.filteredOptions.length : 0 switch (a.type) { case "SHOW_MENU": { const options3 = a.payload.items || [] const filterText = a.payload.filter || "" const filterFunc = a.payload.options && a.payload.options.filterFunction ? a.payload.options.filterFunction : filter const filteredOptions3 = filterFunc(options3, filterText) return { ...a.payload.options, id: a.payload.id, filter: filterText, filterFunction: filterFunc, filteredOptions: filteredOptions3, options: options3, selectedIndex: 0, isLoading: false, } } case "SET_MENU_ITEMS": { if (!s || s.id !== a.payload.id) { return s } const filterFunc = s.filterFunction const filteredOptions = filterFunc(a.payload.items, s.filter) return { ...s, options: a.payload.items, filteredOptions, } } case "SET_MENU_LOADING": if (!s || s.id !== a.payload.id) { return s } return { ...s, isLoading: a.payload.isLoading, } case "HIDE_MENU": return null case "NEXT_MENU": return { ...s, selectedIndex: (s.selectedIndex + 1) % size, } case "PREVIOUS_MENU": return { ...s, selectedIndex: s.selectedIndex > 0 ? s.selectedIndex - 1 : size - 1, } case "FILTER_MENU": { if (!s) { return s } // Note that for language server completions, the `filterFunc` is a no-op - the // items are filtered elsewhere (but this FILTER_MENU action is still dispatched). const filterFunc = s.filterFunction const filteredOptionsSorted = filterFunc(s.options, a.payload.filter) return { ...s, filter: a.payload.filter, filteredOptions: filteredOptionsSorted, selectedIndex: 0, } } default: return s } } return reducer } ================================================ FILE: browser/src/Services/Menu/MenuState.ts ================================================ /** * MenuState.ts * * Definition of State for Menu functionality */ export interface IMenus { // TOOD: In the future, could handle multiple menus here... menu: IMenu configuration: IMenuConfigurationSettings } export interface IMenuConfigurationSettings { rowHeight: number maxItemsToShow: number } export const DefaultMenuConfigurationSettings: IMenuConfigurationSettings = { rowHeight: 40, maxItemsToShow: 6, } export interface IMenu { id: string filter: string filteredOptions: FilteredT[] options: T[] selectedIndex: number isLoading: boolean backgroundColor: string foregroundColor: string borderColor: string highlightColor: string filterFunction: (items: T[], searchString: string) => FilteredT[] onFilterTextChanged: (newText: string) => void onSelectedItemChanged: (newItem: FilteredT) => void onSelectItem: (idx: number) => void onHide: () => void } export function createDefaultState(): IMenus { return { menu: null, configuration: { rowHeight: 20, maxItemsToShow: 10, }, } } ================================================ FILE: browser/src/Services/Menu/PinnedIconView.tsx ================================================ /** * PinnedIconView.tsx * * Shows the pinned icon for recently navigated items in quick open */ import * as React from "react" import { Visible } from "./../../UI/components/Visible" import { Icon } from "./../../UI/Icon" export const render = (props: { pinned: boolean }) => { return ( ) } ================================================ FILE: browser/src/Services/Menu/index.ts ================================================ export * from "./Menu" export * from "./MenuComponent" export * from "./Filter" import { Configuration } from "./../Configuration" import { OverlayManager } from "./../Overlay" import { MenuManager } from "./Menu" let _menuManager: MenuManager export const activate = (configuration: Configuration, overlayManager: OverlayManager) => { _menuManager = new MenuManager(configuration, overlayManager) } export const getInstance = (): MenuManager => { return _menuManager } ================================================ FILE: browser/src/Services/Metadata.ts ================================================ /** * Metadata.ts * * Provides information about Oni's pkg */ import { readFile } from "fs-extra" import * as Log from "oni-core-logging" import * as os from "os" import * as path from "path" export interface IMetadata { name: string version: string } export const getMetadata = async (): Promise => { const packageMetadata = path.join(__dirname, "package.json") try { const data = await readFile(packageMetadata, "utf8") const pkg = JSON.parse(data) const metadata = { name: pkg.name, version: pkg.version } return metadata } catch (e) { Log.warn(`Oni Error: failed to fetch Oni package metadata because ${e.message}`) return { name: null, version: null } } } export const showAboutMessage = async () => { const metadata = await getMetadata() const infoLines = [ `${metadata.name} version ${metadata.version}`, "https://www.onivim.io", "", "Copyright 2018 Bryan Phelps", "MIT License", ] alert(infoLines.join(os.EOL)) } ================================================ FILE: browser/src/Services/MultiProcess.ts ================================================ /** * MultiProcess.ts * * Utilities for managing interop between multiple open instances of ONI */ import { ipcRenderer } from "electron" import { WindowManager } from "./WindowManager" export class MultiProcess { public focusPreviousInstance(): void { ipcRenderer.send("focus-previous-instance") } public focusNextInstance(): void { ipcRenderer.send("focus-next-instance") } public moveToNextOniInstance(direction: string): void { ipcRenderer.send("move-to-next-oni-instance", direction) } public openNewWindow(): void { ipcRenderer.send("open-oni-window") } } 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() ================================================ FILE: browser/src/Services/Notifications/Notification.ts ================================================ /** * Notification.ts * * API interface for notification UX */ import { Store } from "redux" import { Event, IEvent } from "oni-types" 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" private _onClickEvent = new Event() private _onCloseEvent = new Event() public get onClick(): IEvent { return this._onClickEvent } public get onClose(): IEvent { return this._onCloseEvent } constructor(private _id: string, private _store: Store) {} public setContents(title: string, detail: string): void { this._title = title 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 } public setExpiration(expiration: number = 20000) { if (this._level !== "error") { this._expirationTime = expiration } } public show(): void { this._store.dispatch({ type: "SHOW_NOTIFICATION", id: this._id, title: this._title, detail: this._detail, buttons: this._buttons, level: this._level, expirationTime: this._expirationTime, onClick: () => { this._onClickEvent.dispatch() this.hide() }, onClose: () => { this._onCloseEvent.dispatch() this.hide() }, }) } public hide(): void { this._store.dispatch({ type: "HIDE_NOTIFICATION", id: this._id, }) } } ================================================ FILE: browser/src/Services/Notifications/NotificationStore.ts ================================================ /** * NotificationStore.ts * * State management for Notifications */ import { Reducer, Store } from "redux" import { combineEpics, createEpicMiddleware, Epic } from "redux-observable" import { Observable } from "rxjs" 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 } export interface INotificationsState { notifications: IdToNotification } export const DefaultNotificationState: INotificationsState = { notifications: {}, } export interface INotification { id: string level: NotificationLevel title: string detail: string expirationTime: number buttons?: INotificationButton[] onClick: () => void onClose: () => void } interface IShowNotification { type: "SHOW_NOTIFICATION" id: string level: NotificationLevel buttons: INotificationButton[] title: string detail: string expirationTime: number onClick: () => void onClose: () => void } interface IHideNotification { type: "HIDE_NOTIFICATION" id: string } export type NotificationAction = IShowNotification | IHideNotification export const notificationsReducer: Reducer = ( state: IdToNotification = {}, action: NotificationAction, ) => { switch (action.type) { case "SHOW_NOTIFICATION": return { ...state, [action.id]: { id: action.id, level: action.level, title: action.title, detail: action.detail, buttons: action.buttons, onClick: action.onClick, onClose: action.onClose, expirationTime: action.expirationTime, }, } case "HIDE_NOTIFICATION": return { ...state, [action.id]: null, } default: return state } } const hideNotificationAfterExpirationEpic: Epic = ( action$, store, ) => { return action$ .ofType("SHOW_NOTIFICATION") .filter((action: IShowNotification) => !!action.expirationTime) .mergeMap(({ expirationTime, id }: IShowNotification) => { return Observable.timer(expirationTime).mapTo({ type: "HIDE_NOTIFICATION", id, } as IHideNotification) }) } export const stateReducer: Reducer = ( state: INotificationsState = DefaultNotificationState, action: NotificationAction, ) => { return { notifications: notificationsReducer(state.notifications, action), } } export const createStore = (): Store => { return createReduxStore("Notifications", stateReducer, DefaultNotificationState, [ createEpicMiddleware(combineEpics(hideNotificationAfterExpirationEpic)), ]) } ================================================ FILE: browser/src/Services/Notifications/Notifications.ts ================================================ /** * Notifications.ts * * API interface and lifecycle manager for notifications UX */ import { Store } from "redux" import { Overlay, OverlayManager } from "./../Overlay" import { Notification } from "./Notification" import { createStore, INotificationsState } from "./NotificationStore" import { getView } from "./NotificationsView" export class Notifications { private _id: number = 0 private _overlay: Overlay private _store: Store constructor(private _overlayManager: OverlayManager) { this._store = createStore() this._overlay = this._overlayManager.createItem() this._overlay.setContents(getView(this._store)) } public enable(): void { this._overlay.show() } public disable(): void { this._overlay.hide() } public createItem(): Notification { this._id++ return new Notification("notification" + this._id.toString(), this._store) } } ================================================ FILE: browser/src/Services/Notifications/NotificationsView.tsx ================================================ /** * NotificationsView.tsx * * View / React layer for Notifications */ import * as React from "react" import { connect, Provider } from "react-redux" import { CSSTransition, TransitionGroup } from "react-transition-group" 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" export interface NotificationsViewProps { notifications: INotification[] } const Transition = (props: { children: React.ReactNode }) => { return ( {props.children} ) } const NotificationsWrapper = styled.div` position: absolute; top: 1em; right: 1em; max-height: 90%; max-width: 33vw; pointer-events: all; overflow: auto; .notification:first-child { margin-top: 0; } ` export class NotificationsView extends React.PureComponent { public render(): JSX.Element { return ( {this.props.notifications.map(notification => { return ( ) })} ) } } const frames = keyframes` 0% { opacity: 0; transform: translateY(4px); } 100% { opacity: 1; transform: translateY(0px); } ` interface IErrorStyles { level?: NotificationLevel } const getColorForErrorLevel = (level: NotificationLevel) => { const colorToLevel = { warn: "yellow", error: "red", info: "#1D7CF2", // blue success: "#5AB379", // green } return colorToLevel[level] } const NotificationWrapper = withProps(styled.div)` background-color: ${p => p.theme["toolTip.background"]}; border-radius: 4px; border-left: solid 4px ${p => getColorForErrorLevel(p.level)}; padding: 0 1rem 1rem; color: white; margin: 1rem 0 1rem 1rem; ${boxShadow}; max-height: 50%; display: flex; flex: auto; flex-direction: column; justify-content: center; align-items: center; pointer-events: auto; cursor: pointer; overflow: hidden; transition: all 0.1s ease-in; &.notification-enter { animation: ${frames} 0.25s ease-in; } &.notification-exit { animation: ${frames} 0.25s ease-in both reverse; } &:hover { transform: translateY(-1px); } ` const IconContainer = styled.div` display: flex; width: 100%; flex-direction: row; align-items: center; justify-content: space-between; ` const NotificationIconWrapper = withProps(styled.div)` ${({ level }) => level && `color: ${getColorForErrorLevel(level)};`}; flex: 0 0 auto; align-self: flex-start; padding: 8px; &:hover { ${boxShadow}; transform: translateY(-1px); } ` export const NotificationContents = styled.div` flex: 1 1 auto; width: 100%; display: flex; flex-direction: column; justify-content: center; padding: 8px; overflow-y: auto; overflow-x: hidden; ` export const NotificationTitle = withProps(styled.div)` ${({ level }) => level && `color: ${getColorForErrorLevel(level)};`}; flex: 0 0 auto; width: 100%; word-break: break-word; font-weight: bold; font-size: 1.1em; ` export const NotificationDescription = styled.div` flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; margin: 1em 0em; font-size: 0.9em; ` const NotificationHeader = styled.header` width: 100%; display: flex; flex-wrap: wrap; flex-direction: row; justify-content: space-between; align-items: center; border-bottom: 1px solid ${p => p.theme["toolTip.border"]}; 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[] onClose: () => void } const Buttons = ({ buttons, onClose }: IButtonProps) => { const executeThenClose = (callback: (args?: any) => void) => () => { callback() onClose() } return ( {buttons.map(({ callback, title }, index) => ( ))} ) } export class NotificationView extends React.PureComponent { private iconDictionary = { error: "times-circle", warn: "exclamation-triangle", info: "info-circle", success: "check-circle", } public render(): JSX.Element { const { level, buttons } = this.props return ( this._onClickClose(evt)}> {this.props.title} {this.props.detail} {buttons && } ) } private _onClickClose = (evt: React.MouseEvent): void => { this.props.onClose() evt.stopPropagation() evt.preventDefault() } } export const mapStateToProps = (state: INotificationsState): NotificationsViewProps => { const objs = Object.keys(state.notifications).map(key => state.notifications[key]) const activeNotifications = objs.filter(o => o !== null) return { notifications: activeNotifications, } } const NotificationsContainer = connect(mapStateToProps)(NotificationsView) export const getView = (store: any) => ( ) ================================================ FILE: browser/src/Services/Notifications/index.ts ================================================ /** * index.ts */ import * as Log from "oni-core-logging" import { OverlayManager } from "./../Overlay" import { Configuration } from "./../Configuration" import { Notifications } from "./Notifications" export * from "./Notifications" let _notifications: Notifications = null export const activate = (configuration: Configuration, overlayManager: OverlayManager): void => { _notifications = new Notifications(overlayManager) const updateFromConfiguration = () => { const areNotificationsEnabled = configuration.getValue("notifications.enabled") Log.info("[Notifications] Setting enabled: " + areNotificationsEnabled) areNotificationsEnabled ? _notifications.enable() : _notifications.disable() } configuration.onConfigurationChanged.subscribe(val => { if (typeof val["notifications.enabled"] === "boolean") { updateFromConfiguration() } }) updateFromConfiguration() } export const getInstance = (): Notifications => { return _notifications } ================================================ FILE: browser/src/Services/Overlay.ts ================================================ /** * Overlay.ts * * API adapter for the Overlay store actions */ import * as Shell from "./../UI/Shell" export class Overlay { private _contents: JSX.Element private _visible: boolean = false constructor(private _id: string) {} public show(): void { this._visible = true Shell.Actions.showOverlay(this._id, this._contents) } public hide(): void { this._visible = false Shell.Actions.hideOverlay(this._id) } public setContents(element: any): void { this._contents = element if (this._visible) { Shell.Actions.showOverlay(this._id, this._contents) } } } export class OverlayManager { private _id: number = 0 public createItem(): Overlay { this._id++ return new Overlay("overlay" + this._id.toString()) } } let _overlays: OverlayManager = null export const activate = (): void => { _overlays = new OverlayManager() } export const getInstance = (): OverlayManager => { return _overlays } ================================================ FILE: browser/src/Services/Particles/ParticleSystem.tsx ================================================ /** * 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) }) } } ================================================ FILE: browser/src/Services/Particles/index.tsx ================================================ /** * 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 } ================================================ FILE: browser/src/Services/Preview/PreviewBufferLayer.tsx ================================================ /** * 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} } } ================================================ FILE: browser/src/Services/Preview/index.tsx ================================================ /** * 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), ), ) } } ================================================ FILE: browser/src/Services/Recorder.ts ================================================ /** * Recorder.ts * * Manages a variety of recording scenarios, including: * - Take & save screenshot * - Record & save video (.webm) */ import { clipboard, desktopCapturer } from "electron" import * as fs from "fs" import * as path from "path" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { configuration } from "./Configuration" import { getInstance as getNotificationsInstance } from "./Notifications" declare var MediaRecorder: any const ONI_RECORDER_TITLE = "oni_recorder_title" const toBuffer = (ab: ArrayBuffer) => { const buffer = new Buffer(ab.byteLength) const arr = new Uint8Array(ab) for (let i = 0; i < arr.byteLength; i++) { buffer[i] = arr[i] } return buffer } class Recorder implements Oni.Recorder { private _recorder: any = null private _blobs: Blob[] = [] public startRecording(): void { const title = document.title document.title = ONI_RECORDER_TITLE desktopCapturer.getSources({ types: ["window", "screen"] }, (error, sources) => { if (error) { throw error } // tslint:disable-next-line prefer-for-of for (let i = 0; i < sources.length; i++) { const src = sources[i] if (src.name === ONI_RECORDER_TITLE) { document.title = title const size = getDimensions() // tslint:disable-next-line no-string-literal navigator["webkitGetUserMedia"]( { audio: false, video: { mandatory: { chromeMediaSource: "desktop", chromeMediaSourceId: src.id, minWidth: 320, maxWidth: size.width, minHeight: 240, maxHeight: size.height, }, }, }, (stream: any) => { this._handleStream(stream) }, (err: Error) => { this._handleUserMediaError(err) }, ) return } } }) } public get isRecording(): boolean { return !!this._recorder } public async stopRecording(fileName?: string): Promise { this._recorder.stop() const arrayBuffer = await toArrayBuffer(new Blob(this._blobs, { type: "video/webm" })) const buffer = toBuffer(arrayBuffer) fileName = fileName || getFileName("oni-video", "webm") const videoFilePath = getOutputPath(fileName) // TODO: Finish making this async if (fs.existsSync(videoFilePath)) { fs.unlinkSync(videoFilePath) } fs.writeFileSync(videoFilePath, buffer) this._recorder = null this._blobs = [] const notification = getNotificationsInstance().createItem() notification.setContents("Recording finished", "Recording saved to: " + videoFilePath) notification.setLevel("success") notification.show() } public takeScreenshot(fileName?: string, scale: number = 1): void { const webContents = require("electron").remote.getCurrentWebContents() webContents.capturePage(image => { const pngBuffer = image.toPNG({ scaleFactor: scale }) fileName = fileName || getFileName("oni-screenshot", "png") const screenshotPath = getOutputPath(fileName) fs.writeFileSync(screenshotPath, pngBuffer) if (configuration.getValue("recorder.copyScreenshotToClipboard")) { clipboard.writeImage(screenshotPath as any) } const notification = getNotificationsInstance().createItem() notification.setContents("Screenshot taken", "Screenshot saved to " + screenshotPath) notification.setLevel("success") notification.show() }) } private _handleStream(stream: any) { this._recorder = new MediaRecorder(stream) this._blobs = [] this._recorder.ondataavailable = (evt: any) => { this._blobs.push(evt.data) } this._recorder.start(100 /* ms */) } private _handleUserMediaError(err: Error) { Log.error(err) } } // Some of this code was adapted and modified from this stackoverflow post: // https://stackoverflow.com/questions/36753288/saving-desktopcapturer-to-video-file-in-electron const toArrayBuffer = async (blob: Blob): Promise => { return new Promise((resolve, reject) => { const fileReader = new FileReader() fileReader.onload = evt => { const arrayBuffer = fileReader.result as ArrayBuffer resolve(arrayBuffer) } fileReader.readAsArrayBuffer(blob) }) } const getDimensions = () => { const size = require("electron") .remote.getCurrentWindow() .getSize() return { width: size[0], height: size[1], } } const getFileName = (fileBase: string, fileExtension: string) => { return `${fileBase}-${new Date().getTime()}.${fileExtension}` } const getOutputPath = (fileName: string) => { const outputPath = configuration.getValue("recorder.outputPath") return path.join(outputPath, fileName) } export const recorder = new Recorder() ================================================ FILE: browser/src/Services/Search/FinderProcess.ts ================================================ /** * FinderProcess.ts * * Manages communication with the external finder process */ import { ChildProcess, spawn } from "child_process" import { Event, IEvent } from "oni-types" import * as Log from "oni-core-logging" import { getInstance } from "./../Workspace" export class FinderProcess { private _process: ChildProcess private _lastData: string = "" private _onData = new Event() private _onError = new Event() private _onComplete = new Event() private _workspace = getInstance() public get onData(): IEvent { return this._onData } public get onComplete(): IEvent { return this._onComplete } constructor(private _command: string, private _splitCharacter: string) {} public start(): void { if (this._process) { return } if (Log.isDebugLoggingEnabled()) { Log.debug( "[FinderProcess::start] Starting finder process with this command: " + this._command, ) } this._process = spawn(this._command, [], { shell: true, cwd: this._workspace.activeWorkspace, }) this._process.stdout.on("data", data => { const { didExtract, remnant, splitData } = extractSplitData( data, this._splitCharacter, this._lastData, ) if (!didExtract) { return } this._lastData = remnant this._onData.dispatch(splitData) }) this._process.stderr.on("data", data => { this._onError.dispatch(data.toString()) }) this._process.on("exit", code => { this._onComplete.dispatch() }) } public stop(): void { this._process.kill() } } export const extractSplitData = ( data: string | Buffer, splitCharacter: string, lastRemnant: string, ) => { if (!data) { return { didExtract: false, remnant: "", splitData: [], } } const dataString = lastRemnant + data.toString() const isCleanEnd = dataString.endsWith(splitCharacter) const splitData = dataString.split(splitCharacter) let remnant = "" if (!isCleanEnd) { remnant = splitData.pop() } else { // split leaves behind an empty string in the array if the string to split ends with the delimiter splitData.splice(-1, 1) } return { didExtract: true, remnant, splitData } } ================================================ FILE: browser/src/Services/Search/RipGrep.ts ================================================ /** * RipGrep.ts * * Gets command / arguments for packaged ripgrep command */ import * as path from "path" import * as Platform from "./../../Platform" export function getCommand(): string { const rootPath = path.join(__dirname, "node_modules", "oni-ripgrep", "bin") const executableName = Platform.isWindows() ? "rg.exe" : "rg" // Wrap in quotes in case there are spaces in the path return '"' + path.join(rootPath, executableName) + '"' } export function getArguments(excludePaths: string[], shouldShowHidden: boolean): string[] { const ignoreArguments = excludePaths.reduce((prev, cur) => { return prev.concat(["-g", "!" + cur]) }, []) const showHidden = shouldShowHidden ? ["--hidden"] : [] return ["--vimgrep"].concat(showHidden, ignoreArguments) } ================================================ FILE: browser/src/Services/Search/Scorer/CharCode.ts ================================================ /* tslint:disable */ /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ "use strict" // Names from https://blog.codinghorror.com/ascii-pronunciation-rules-for-programmers/ /** * An inlined enum containing useful character codes (to be used with String.charCodeAt). * Please leave the const keyword such that it gets inlined when compiled to JavaScript! */ export const enum CharCode { Null = 0, /** * The `\t` character. */ Tab = 9, /** * The `\n` character. */ LineFeed = 10, /** * The `\r` character. */ CarriageReturn = 13, Space = 32, /** * The `!` character. */ ExclamationMark = 33, /** * The `"` character. */ DoubleQuote = 34, /** * The `#` character. */ Hash = 35, /** * The `$` character. */ DollarSign = 36, /** * The `%` character. */ PercentSign = 37, /** * The `&` character. */ Ampersand = 38, /** * The `'` character. */ SingleQuote = 39, /** * The `(` character. */ OpenParen = 40, /** * The `)` character. */ CloseParen = 41, /** * The `*` character. */ Asterisk = 42, /** * The `+` character. */ Plus = 43, /** * The `,` character. */ Comma = 44, /** * The `-` character. */ Dash = 45, /** * The `.` character. */ Period = 46, /** * The `/` character. */ Slash = 47, Digit0 = 48, Digit1 = 49, Digit2 = 50, Digit3 = 51, Digit4 = 52, Digit5 = 53, Digit6 = 54, Digit7 = 55, Digit8 = 56, Digit9 = 57, /** * The `:` character. */ Colon = 58, /** * The `;` character. */ Semicolon = 59, /** * The `<` character. */ LessThan = 60, /** * The `=` character. */ Equals = 61, /** * The `>` character. */ GreaterThan = 62, /** * The `?` character. */ QuestionMark = 63, /** * The `@` character. */ AtSign = 64, A = 65, B = 66, C = 67, D = 68, E = 69, F = 70, G = 71, H = 72, I = 73, J = 74, K = 75, L = 76, M = 77, N = 78, O = 79, P = 80, Q = 81, R = 82, S = 83, T = 84, U = 85, V = 86, W = 87, X = 88, Y = 89, Z = 90, /** * The `[` character. */ OpenSquareBracket = 91, /** * The `\` character. */ Backslash = 92, /** * The `]` character. */ CloseSquareBracket = 93, /** * The `^` character. */ Caret = 94, /** * The `_` character. */ Underline = 95, /** * The ``(`)`` character. */ BackTick = 96, a = 97, b = 98, c = 99, d = 100, e = 101, f = 102, g = 103, h = 104, i = 105, j = 106, k = 107, l = 108, m = 109, n = 110, o = 111, p = 112, q = 113, r = 114, s = 115, t = 116, u = 117, v = 118, w = 119, x = 120, y = 121, z = 122, /** * The `{` character. */ OpenCurlyBrace = 123, /** * The `|` character. */ Pipe = 124, /** * The `}` character. */ CloseCurlyBrace = 125, /** * The `~` character. */ Tilde = 126, U_Combining_Grave_Accent = 0x0300, // U+0300 Combining Grave Accent U_Combining_Acute_Accent = 0x0301, // U+0301 Combining Acute Accent U_Combining_Circumflex_Accent = 0x0302, // U+0302 Combining Circumflex Accent U_Combining_Tilde = 0x0303, // U+0303 Combining Tilde U_Combining_Macron = 0x0304, // U+0304 Combining Macron U_Combining_Overline = 0x0305, // U+0305 Combining Overline U_Combining_Breve = 0x0306, // U+0306 Combining Breve U_Combining_Dot_Above = 0x0307, // U+0307 Combining Dot Above U_Combining_Diaeresis = 0x0308, // U+0308 Combining Diaeresis U_Combining_Hook_Above = 0x0309, // U+0309 Combining Hook Above U_Combining_Ring_Above = 0x030a, // U+030A Combining Ring Above U_Combining_Double_Acute_Accent = 0x030b, // U+030B Combining Double Acute Accent U_Combining_Caron = 0x030c, // U+030C Combining Caron U_Combining_Vertical_Line_Above = 0x030d, // U+030D Combining Vertical Line Above U_Combining_Double_Vertical_Line_Above = 0x030e, // U+030E Combining Double Vertical Line Above U_Combining_Double_Grave_Accent = 0x030f, // U+030F Combining Double Grave Accent U_Combining_Candrabindu = 0x0310, // U+0310 Combining Candrabindu U_Combining_Inverted_Breve = 0x0311, // U+0311 Combining Inverted Breve U_Combining_Turned_Comma_Above = 0x0312, // U+0312 Combining Turned Comma Above U_Combining_Comma_Above = 0x0313, // U+0313 Combining Comma Above U_Combining_Reversed_Comma_Above = 0x0314, // U+0314 Combining Reversed Comma Above U_Combining_Comma_Above_Right = 0x0315, // U+0315 Combining Comma Above Right U_Combining_Grave_Accent_Below = 0x0316, // U+0316 Combining Grave Accent Below U_Combining_Acute_Accent_Below = 0x0317, // U+0317 Combining Acute Accent Below U_Combining_Left_Tack_Below = 0x0318, // U+0318 Combining Left Tack Below U_Combining_Right_Tack_Below = 0x0319, // U+0319 Combining Right Tack Below U_Combining_Left_Angle_Above = 0x031a, // U+031A Combining Left Angle Above U_Combining_Horn = 0x031b, // U+031B Combining Horn U_Combining_Left_Half_Ring_Below = 0x031c, // U+031C Combining Left Half Ring Below U_Combining_Up_Tack_Below = 0x031d, // U+031D Combining Up Tack Below U_Combining_Down_Tack_Below = 0x031e, // U+031E Combining Down Tack Below U_Combining_Plus_Sign_Below = 0x031f, // U+031F Combining Plus Sign Below U_Combining_Minus_Sign_Below = 0x0320, // U+0320 Combining Minus Sign Below U_Combining_Palatalized_Hook_Below = 0x0321, // U+0321 Combining Palatalized Hook Below U_Combining_Retroflex_Hook_Below = 0x0322, // U+0322 Combining Retroflex Hook Below U_Combining_Dot_Below = 0x0323, // U+0323 Combining Dot Below U_Combining_Diaeresis_Below = 0x0324, // U+0324 Combining Diaeresis Below U_Combining_Ring_Below = 0x0325, // U+0325 Combining Ring Below U_Combining_Comma_Below = 0x0326, // U+0326 Combining Comma Below U_Combining_Cedilla = 0x0327, // U+0327 Combining Cedilla U_Combining_Ogonek = 0x0328, // U+0328 Combining Ogonek U_Combining_Vertical_Line_Below = 0x0329, // U+0329 Combining Vertical Line Below U_Combining_Bridge_Below = 0x032a, // U+032A Combining Bridge Below U_Combining_Inverted_Double_Arch_Below = 0x032b, // U+032B Combining Inverted Double Arch Below U_Combining_Caron_Below = 0x032c, // U+032C Combining Caron Below U_Combining_Circumflex_Accent_Below = 0x032d, // U+032D Combining Circumflex Accent Below U_Combining_Breve_Below = 0x032e, // U+032E Combining Breve Below U_Combining_Inverted_Breve_Below = 0x032f, // U+032F Combining Inverted Breve Below U_Combining_Tilde_Below = 0x0330, // U+0330 Combining Tilde Below U_Combining_Macron_Below = 0x0331, // U+0331 Combining Macron Below U_Combining_Low_Line = 0x0332, // U+0332 Combining Low Line U_Combining_Double_Low_Line = 0x0333, // U+0333 Combining Double Low Line U_Combining_Tilde_Overlay = 0x0334, // U+0334 Combining Tilde Overlay U_Combining_Short_Stroke_Overlay = 0x0335, // U+0335 Combining Short Stroke Overlay U_Combining_Long_Stroke_Overlay = 0x0336, // U+0336 Combining Long Stroke Overlay U_Combining_Short_Solidus_Overlay = 0x0337, // U+0337 Combining Short Solidus Overlay U_Combining_Long_Solidus_Overlay = 0x0338, // U+0338 Combining Long Solidus Overlay U_Combining_Right_Half_Ring_Below = 0x0339, // U+0339 Combining Right Half Ring Below U_Combining_Inverted_Bridge_Below = 0x033a, // U+033A Combining Inverted Bridge Below U_Combining_Square_Below = 0x033b, // U+033B Combining Square Below U_Combining_Seagull_Below = 0x033c, // U+033C Combining Seagull Below U_Combining_X_Above = 0x033d, // U+033D Combining X Above U_Combining_Vertical_Tilde = 0x033e, // U+033E Combining Vertical Tilde U_Combining_Double_Overline = 0x033f, // U+033F Combining Double Overline U_Combining_Grave_Tone_Mark = 0x0340, // U+0340 Combining Grave Tone Mark U_Combining_Acute_Tone_Mark = 0x0341, // U+0341 Combining Acute Tone Mark U_Combining_Greek_Perispomeni = 0x0342, // U+0342 Combining Greek Perispomeni U_Combining_Greek_Koronis = 0x0343, // U+0343 Combining Greek Koronis U_Combining_Greek_Dialytika_Tonos = 0x0344, // U+0344 Combining Greek Dialytika Tonos U_Combining_Greek_Ypogegrammeni = 0x0345, // U+0345 Combining Greek Ypogegrammeni U_Combining_Bridge_Above = 0x0346, // U+0346 Combining Bridge Above U_Combining_Equals_Sign_Below = 0x0347, // U+0347 Combining Equals Sign Below U_Combining_Double_Vertical_Line_Below = 0x0348, // U+0348 Combining Double Vertical Line Below U_Combining_Left_Angle_Below = 0x0349, // U+0349 Combining Left Angle Below U_Combining_Not_Tilde_Above = 0x034a, // U+034A Combining Not Tilde Above U_Combining_Homothetic_Above = 0x034b, // U+034B Combining Homothetic Above U_Combining_Almost_Equal_To_Above = 0x034c, // U+034C Combining Almost Equal To Above U_Combining_Left_Right_Arrow_Below = 0x034d, // U+034D Combining Left Right Arrow Below U_Combining_Upwards_Arrow_Below = 0x034e, // U+034E Combining Upwards Arrow Below U_Combining_Grapheme_Joiner = 0x034f, // U+034F Combining Grapheme Joiner U_Combining_Right_Arrowhead_Above = 0x0350, // U+0350 Combining Right Arrowhead Above U_Combining_Left_Half_Ring_Above = 0x0351, // U+0351 Combining Left Half Ring Above U_Combining_Fermata = 0x0352, // U+0352 Combining Fermata U_Combining_X_Below = 0x0353, // U+0353 Combining X Below U_Combining_Left_Arrowhead_Below = 0x0354, // U+0354 Combining Left Arrowhead Below U_Combining_Right_Arrowhead_Below = 0x0355, // U+0355 Combining Right Arrowhead Below U_Combining_Right_Arrowhead_And_Up_Arrowhead_Below = 0x0356, // U+0356 Combining Right Arrowhead And Up Arrowhead Below U_Combining_Right_Half_Ring_Above = 0x0357, // U+0357 Combining Right Half Ring Above U_Combining_Dot_Above_Right = 0x0358, // U+0358 Combining Dot Above Right U_Combining_Asterisk_Below = 0x0359, // U+0359 Combining Asterisk Below U_Combining_Double_Ring_Below = 0x035a, // U+035A Combining Double Ring Below U_Combining_Zigzag_Above = 0x035b, // U+035B Combining Zigzag Above U_Combining_Double_Breve_Below = 0x035c, // U+035C Combining Double Breve Below U_Combining_Double_Breve = 0x035d, // U+035D Combining Double Breve U_Combining_Double_Macron = 0x035e, // U+035E Combining Double Macron U_Combining_Double_Macron_Below = 0x035f, // U+035F Combining Double Macron Below U_Combining_Double_Tilde = 0x0360, // U+0360 Combining Double Tilde U_Combining_Double_Inverted_Breve = 0x0361, // U+0361 Combining Double Inverted Breve U_Combining_Double_Rightwards_Arrow_Below = 0x0362, // U+0362 Combining Double Rightwards Arrow Below U_Combining_Latin_Small_Letter_A = 0x0363, // U+0363 Combining Latin Small Letter A U_Combining_Latin_Small_Letter_E = 0x0364, // U+0364 Combining Latin Small Letter E U_Combining_Latin_Small_Letter_I = 0x0365, // U+0365 Combining Latin Small Letter I U_Combining_Latin_Small_Letter_O = 0x0366, // U+0366 Combining Latin Small Letter O U_Combining_Latin_Small_Letter_U = 0x0367, // U+0367 Combining Latin Small Letter U U_Combining_Latin_Small_Letter_C = 0x0368, // U+0368 Combining Latin Small Letter C U_Combining_Latin_Small_Letter_D = 0x0369, // U+0369 Combining Latin Small Letter D U_Combining_Latin_Small_Letter_H = 0x036a, // U+036A Combining Latin Small Letter H U_Combining_Latin_Small_Letter_M = 0x036b, // U+036B Combining Latin Small Letter M U_Combining_Latin_Small_Letter_R = 0x036c, // U+036C Combining Latin Small Letter R U_Combining_Latin_Small_Letter_T = 0x036d, // U+036D Combining Latin Small Letter T U_Combining_Latin_Small_Letter_V = 0x036e, // U+036E Combining Latin Small Letter V U_Combining_Latin_Small_Letter_X = 0x036f, // U+036F Combining Latin Small Letter X /** * Unicode Character 'LINE SEPARATOR' (U+2028) * http://www.fileformat.info/info/unicode/char/2028/index.htm */ LINE_SEPARATOR_2028 = 8232, // http://www.fileformat.info/info/unicode/category/Sk/list.htm U_CIRCUMFLEX = 0x005e, // U+005E CIRCUMFLEX U_GRAVE_ACCENT = 0x0060, // U+0060 GRAVE ACCENT U_DIAERESIS = 0x00a8, // U+00A8 DIAERESIS U_MACRON = 0x00af, // U+00AF MACRON U_ACUTE_ACCENT = 0x00b4, // U+00B4 ACUTE ACCENT U_CEDILLA = 0x00b8, // U+00B8 CEDILLA U_MODIFIER_LETTER_LEFT_ARROWHEAD = 0x02c2, // U+02C2 MODIFIER LETTER LEFT ARROWHEAD U_MODIFIER_LETTER_RIGHT_ARROWHEAD = 0x02c3, // U+02C3 MODIFIER LETTER RIGHT ARROWHEAD U_MODIFIER_LETTER_UP_ARROWHEAD = 0x02c4, // U+02C4 MODIFIER LETTER UP ARROWHEAD U_MODIFIER_LETTER_DOWN_ARROWHEAD = 0x02c5, // U+02C5 MODIFIER LETTER DOWN ARROWHEAD U_MODIFIER_LETTER_CENTRED_RIGHT_HALF_RING = 0x02d2, // U+02D2 MODIFIER LETTER CENTRED RIGHT HALF RING U_MODIFIER_LETTER_CENTRED_LEFT_HALF_RING = 0x02d3, // U+02D3 MODIFIER LETTER CENTRED LEFT HALF RING U_MODIFIER_LETTER_UP_TACK = 0x02d4, // U+02D4 MODIFIER LETTER UP TACK U_MODIFIER_LETTER_DOWN_TACK = 0x02d5, // U+02D5 MODIFIER LETTER DOWN TACK U_MODIFIER_LETTER_PLUS_SIGN = 0x02d6, // U+02D6 MODIFIER LETTER PLUS SIGN U_MODIFIER_LETTER_MINUS_SIGN = 0x02d7, // U+02D7 MODIFIER LETTER MINUS SIGN U_BREVE = 0x02d8, // U+02D8 BREVE U_DOT_ABOVE = 0x02d9, // U+02D9 DOT ABOVE U_RING_ABOVE = 0x02da, // U+02DA RING ABOVE U_OGONEK = 0x02db, // U+02DB OGONEK U_SMALL_TILDE = 0x02dc, // U+02DC SMALL TILDE U_DOUBLE_ACUTE_ACCENT = 0x02dd, // U+02DD DOUBLE ACUTE ACCENT U_MODIFIER_LETTER_RHOTIC_HOOK = 0x02de, // U+02DE MODIFIER LETTER RHOTIC HOOK U_MODIFIER_LETTER_CROSS_ACCENT = 0x02df, // U+02DF MODIFIER LETTER CROSS ACCENT U_MODIFIER_LETTER_EXTRA_HIGH_TONE_BAR = 0x02e5, // U+02E5 MODIFIER LETTER EXTRA-HIGH TONE BAR U_MODIFIER_LETTER_HIGH_TONE_BAR = 0x02e6, // U+02E6 MODIFIER LETTER HIGH TONE BAR U_MODIFIER_LETTER_MID_TONE_BAR = 0x02e7, // U+02E7 MODIFIER LETTER MID TONE BAR U_MODIFIER_LETTER_LOW_TONE_BAR = 0x02e8, // U+02E8 MODIFIER LETTER LOW TONE BAR U_MODIFIER_LETTER_EXTRA_LOW_TONE_BAR = 0x02e9, // U+02E9 MODIFIER LETTER EXTRA-LOW TONE BAR U_MODIFIER_LETTER_YIN_DEPARTING_TONE_MARK = 0x02ea, // U+02EA MODIFIER LETTER YIN DEPARTING TONE MARK U_MODIFIER_LETTER_YANG_DEPARTING_TONE_MARK = 0x02eb, // U+02EB MODIFIER LETTER YANG DEPARTING TONE MARK U_MODIFIER_LETTER_UNASPIRATED = 0x02ed, // U+02ED MODIFIER LETTER UNASPIRATED U_MODIFIER_LETTER_LOW_DOWN_ARROWHEAD = 0x02ef, // U+02EF MODIFIER LETTER LOW DOWN ARROWHEAD U_MODIFIER_LETTER_LOW_UP_ARROWHEAD = 0x02f0, // U+02F0 MODIFIER LETTER LOW UP ARROWHEAD U_MODIFIER_LETTER_LOW_LEFT_ARROWHEAD = 0x02f1, // U+02F1 MODIFIER LETTER LOW LEFT ARROWHEAD U_MODIFIER_LETTER_LOW_RIGHT_ARROWHEAD = 0x02f2, // U+02F2 MODIFIER LETTER LOW RIGHT ARROWHEAD U_MODIFIER_LETTER_LOW_RING = 0x02f3, // U+02F3 MODIFIER LETTER LOW RING U_MODIFIER_LETTER_MIDDLE_GRAVE_ACCENT = 0x02f4, // U+02F4 MODIFIER LETTER MIDDLE GRAVE ACCENT U_MODIFIER_LETTER_MIDDLE_DOUBLE_GRAVE_ACCENT = 0x02f5, // U+02F5 MODIFIER LETTER MIDDLE DOUBLE GRAVE ACCENT U_MODIFIER_LETTER_MIDDLE_DOUBLE_ACUTE_ACCENT = 0x02f6, // U+02F6 MODIFIER LETTER MIDDLE DOUBLE ACUTE ACCENT U_MODIFIER_LETTER_LOW_TILDE = 0x02f7, // U+02F7 MODIFIER LETTER LOW TILDE U_MODIFIER_LETTER_RAISED_COLON = 0x02f8, // U+02F8 MODIFIER LETTER RAISED COLON U_MODIFIER_LETTER_BEGIN_HIGH_TONE = 0x02f9, // U+02F9 MODIFIER LETTER BEGIN HIGH TONE U_MODIFIER_LETTER_END_HIGH_TONE = 0x02fa, // U+02FA MODIFIER LETTER END HIGH TONE U_MODIFIER_LETTER_BEGIN_LOW_TONE = 0x02fb, // U+02FB MODIFIER LETTER BEGIN LOW TONE U_MODIFIER_LETTER_END_LOW_TONE = 0x02fc, // U+02FC MODIFIER LETTER END LOW TONE U_MODIFIER_LETTER_SHELF = 0x02fd, // U+02FD MODIFIER LETTER SHELF U_MODIFIER_LETTER_OPEN_SHELF = 0x02fe, // U+02FE MODIFIER LETTER OPEN SHELF U_MODIFIER_LETTER_LOW_LEFT_ARROW = 0x02ff, // U+02FF MODIFIER LETTER LOW LEFT ARROW U_GREEK_LOWER_NUMERAL_SIGN = 0x0375, // U+0375 GREEK LOWER NUMERAL SIGN U_GREEK_TONOS = 0x0384, // U+0384 GREEK TONOS U_GREEK_DIALYTIKA_TONOS = 0x0385, // U+0385 GREEK DIALYTIKA TONOS U_GREEK_KORONIS = 0x1fbd, // U+1FBD GREEK KORONIS U_GREEK_PSILI = 0x1fbf, // U+1FBF GREEK PSILI U_GREEK_PERISPOMENI = 0x1fc0, // U+1FC0 GREEK PERISPOMENI U_GREEK_DIALYTIKA_AND_PERISPOMENI = 0x1fc1, // U+1FC1 GREEK DIALYTIKA AND PERISPOMENI U_GREEK_PSILI_AND_VARIA = 0x1fcd, // U+1FCD GREEK PSILI AND VARIA U_GREEK_PSILI_AND_OXIA = 0x1fce, // U+1FCE GREEK PSILI AND OXIA U_GREEK_PSILI_AND_PERISPOMENI = 0x1fcf, // U+1FCF GREEK PSILI AND PERISPOMENI U_GREEK_DASIA_AND_VARIA = 0x1fdd, // U+1FDD GREEK DASIA AND VARIA U_GREEK_DASIA_AND_OXIA = 0x1fde, // U+1FDE GREEK DASIA AND OXIA U_GREEK_DASIA_AND_PERISPOMENI = 0x1fdf, // U+1FDF GREEK DASIA AND PERISPOMENI U_GREEK_DIALYTIKA_AND_VARIA = 0x1fed, // U+1FED GREEK DIALYTIKA AND VARIA U_GREEK_DIALYTIKA_AND_OXIA = 0x1fee, // U+1FEE GREEK DIALYTIKA AND OXIA U_GREEK_VARIA = 0x1fef, // U+1FEF GREEK VARIA U_GREEK_OXIA = 0x1ffd, // U+1FFD GREEK OXIA U_GREEK_DASIA = 0x1ffe, // U+1FFE GREEK DASIA U_OVERLINE = 0x203e, // Unicode Character 'OVERLINE' /** * UTF-8 BOM * Unicode Character 'ZERO WIDTH NO-BREAK SPACE' (U+FEFF) * http://www.fileformat.info/info/unicode/char/feff/index.htm */ UTF8_BOM = 65279, } ================================================ FILE: browser/src/Services/Search/Scorer/Comparers.ts ================================================ /* tslint:disable */ /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ "use strict" import { nativeSep } from "./Utilities" let intlFileNameCollator: Intl.Collator let intlFileNameCollatorIsNumeric: boolean export function setFileNameComparer(collator: Intl.Collator): void { intlFileNameCollator = collator intlFileNameCollatorIsNumeric = collator.resolvedOptions().numeric } export function compareFileNames(one: string, other: string, caseSensitive = false): number { if (intlFileNameCollator) { const a = one || "" const b = other || "" const result = intlFileNameCollator.compare(a, b) // Using the numeric option in the collator will // make compare(`foo1`, `foo01`) === 0. We must disambiguate. if (intlFileNameCollatorIsNumeric && result === 0 && a !== b) { return a < b ? -1 : 1 } return result } return noIntlCompareFileNames(one, other, caseSensitive) } const FileNameMatch = /^(.*?)(\.([^.]*))?$/ export function noIntlCompareFileNames(one: string, other: string, caseSensitive = false): number { if (!caseSensitive) { one = one && one.toLowerCase() other = other && other.toLowerCase() } const [oneName, oneExtension] = extractNameAndExtension(one) const [otherName, otherExtension] = extractNameAndExtension(other) if (oneName !== otherName) { return oneName < otherName ? -1 : 1 } if (oneExtension === otherExtension) { return 0 } return oneExtension < otherExtension ? -1 : 1 } export function compareFileExtensions(one: string, other: string): number { if (intlFileNameCollator) { const [oneName, oneExtension] = extractNameAndExtension(one) const [otherName, otherExtension] = extractNameAndExtension(other) let result = intlFileNameCollator.compare(oneExtension, otherExtension) if (result === 0) { // Using the numeric option in the collator will // make compare(`foo1`, `foo01`) === 0. We must disambiguate. if (intlFileNameCollatorIsNumeric && oneExtension !== otherExtension) { return oneExtension < otherExtension ? -1 : 1 } // Extensions are equal, compare filenames result = intlFileNameCollator.compare(oneName, otherName) if (intlFileNameCollatorIsNumeric && result === 0 && oneName !== otherName) { return oneName < otherName ? -1 : 1 } } return result } return noIntlCompareFileExtensions(one, other) } function noIntlCompareFileExtensions(one: string, other: string): number { const [oneName, oneExtension] = extractNameAndExtension(one && one.toLowerCase()) const [otherName, otherExtension] = extractNameAndExtension(other && other.toLowerCase()) if (oneExtension !== otherExtension) { return oneExtension < otherExtension ? -1 : 1 } if (oneName === otherName) { return 0 } return oneName < otherName ? -1 : 1 } function extractNameAndExtension(str?: string): [string, string] { const match = str ? FileNameMatch.exec(str) : ([] as RegExpExecArray) return [(match && match[1]) || "", (match && match[3]) || ""] } function comparePathComponents(one: string, other: string, caseSensitive = false): number { if (!caseSensitive) { one = one && one.toLowerCase() other = other && other.toLowerCase() } if (one === other) { return 0 } return one < other ? -1 : 1 } export function comparePaths(one: string, other: string, caseSensitive = false): number { const oneParts = one.split(nativeSep) const otherParts = other.split(nativeSep) const lastOne = oneParts.length - 1 const lastOther = otherParts.length - 1 let endOne: boolean, endOther: boolean for (let i = 0; ; i++) { endOne = lastOne === i endOther = lastOther === i if (endOne && endOther) { return compareFileNames(oneParts[i], otherParts[i], caseSensitive) } else if (endOne) { return -1 } else if (endOther) { return 1 } const result = comparePathComponents(oneParts[i], otherParts[i], caseSensitive) if (result !== 0) { return result } } } export function compareAnything(one: string, other: string, lookFor: string): number { let elementAName = one.toLowerCase() let elementBName = other.toLowerCase() // Sort prefix matches over non prefix matches const prefixCompare = compareByPrefix(one, other, lookFor) if (prefixCompare) { return prefixCompare } // Sort suffix matches over non suffix matches let elementASuffixMatch = elementAName.endsWith(lookFor) let elementBSuffixMatch = elementBName.endsWith(lookFor) if (elementASuffixMatch !== elementBSuffixMatch) { return elementASuffixMatch ? -1 : 1 } // Understand file names let r = compareFileNames(elementAName, elementBName) if (r !== 0) { return r } // Compare by name return elementAName.localeCompare(elementBName) } export function compareByPrefix(one: string, other: string, lookFor: string): number { let elementAName = one.toLowerCase() let elementBName = other.toLowerCase() // Sort prefix matches over non prefix matches let elementAPrefixMatch = elementAName.startsWith(lookFor) let elementBPrefixMatch = elementBName.startsWith(lookFor) if (elementAPrefixMatch !== elementBPrefixMatch) { return elementAPrefixMatch ? -1 : 1 } else if (elementAPrefixMatch && elementBPrefixMatch) { // Same prefix: Sort shorter matches to the top to have those on top that match more precisely if (elementAName.length < elementBName.length) { return -1 } if (elementAName.length > elementBName.length) { return 1 } } return 0 } ================================================ FILE: browser/src/Services/Search/Scorer/OniQuickOpenScorer.ts ================================================ import { IMatch } from "./filters" import { compareItemsByScore, IItemAccessor, IItemScore, prepareQuery, scoreItem, ScorerCache, } from "./QuickOpenScorer" import { nativeSep } from "./Utilities" export const NO_ITEM_SCORE: IItemScore = Object.freeze({ score: 0 }) class OniAccessor implements IItemAccessor { public getItemLabel(result: any): string { return result.label ? result.label : "" } public getItemDescription(result: any): string { return result.detail ? result.detail : "" } public getItemPath(result: any): string { return result.detail + nativeSep + result.label } } export function scoreItemOni( resultObject: any, searchString: string, fuzzy: boolean, cache: ScorerCache, ): IItemScore { if (!searchString) { return NO_ITEM_SCORE } const query = prepareQuery(searchString) if (!resultObject || !query.value) { return NO_ITEM_SCORE } const accessor = new OniAccessor() return scoreItem(resultObject, query, fuzzy, accessor, cache) } export function compareItemsByScoreOni( resultObjectA: any, resultObjectB: any, searchString: string, fuzzy: boolean, cache: ScorerCache, ): number { if (!searchString) { return 0 } const query = prepareQuery(searchString) if (!resultObjectA || !resultObjectB || !query.value) { return 0 } const accessor = new OniAccessor() return compareItemsByScore(resultObjectA, resultObjectB, query, fuzzy, accessor, cache) } export const getHighlightsFromResult = (result: IMatch[]): number[] => { if (!result) { return [] } const highlights: number[] = [] result.forEach(r => { for (let i = r.start; i < r.end; i++) { highlights.push(i) } }) return highlights } ================================================ FILE: browser/src/Services/Search/Scorer/QuickOpenScorer.ts ================================================ /* tslint:disable */ /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ "use strict" import { CharCode } from "./CharCode" import { compareAnything } from "./Comparers" import { createMatches, IMatch, matchesCamelCase, matchesPrefix } from "./filters" import { equalsIgnoreCase } from "./strings" import { isLinux, isWindows, stripWildcards, nativeSep, isUpper } from "./Utilities" export type Score = [number /* score */, number[] /* match positions */] export type ScorerCache = { [key: string]: IItemScore } const NO_MATCH = 0 const NO_SCORE: Score = [NO_MATCH, []] // const DEBUG = false; // const DEBUG_MATRIX = false; export function score(target: string, query: string, queryLower: string, fuzzy: boolean): Score { if (!target || !query) { return NO_SCORE // return early if target or query are undefined } const targetLength = target.length const queryLength = query.length if (targetLength < queryLength) { return NO_SCORE // impossible for query to be contained in target } // if (DEBUG) { // console.group(`Target: ${target}, Query: ${query}`); // } const targetLower = target.toLowerCase() // When not searching fuzzy, we require the query to be contained fully // in the target string contiguously. if (!fuzzy) { const indexOfQueryInTarget = targetLower.indexOf(queryLower) if (indexOfQueryInTarget === -1) { // if (DEBUG) { // console.log(`Characters not matching consecutively ${queryLower} within ${targetLower}`); // } return NO_SCORE } } const res = doScore(query, queryLower, queryLength, target, targetLower, targetLength) // if (DEBUG) { // console.log(`%cFinal Score: ${res[0]}`, 'font-weight: bold'); // console.groupEnd(); // } return res } function doScore( query: string, queryLower: string, queryLength: number, target: string, targetLower: string, targetLength: number, ): [number, number[]] { const scores = [] const matches = [] // // Build Scorer Matrix: // // The matrix is composed of query q and target t. For each index we score // q[i] with t[i] and compare that with the previous score. If the score is // equal or larger, we keep the match. In addition to the score, we also keep // the length of the consecutive matches to use as boost for the score. // // t a r g e t // q // u // e // r // y // for (let queryIndex = 0; queryIndex < queryLength; queryIndex++) { for (let targetIndex = 0; targetIndex < targetLength; targetIndex++) { const currentIndex = queryIndex * targetLength + targetIndex const leftIndex = currentIndex - 1 const diagIndex = (queryIndex - 1) * targetLength + targetIndex - 1 const leftScore: number = targetIndex > 0 ? scores[leftIndex] : 0 const diagScore: number = queryIndex > 0 && targetIndex > 0 ? scores[diagIndex] : 0 const matchesSequenceLength: number = queryIndex > 0 && targetIndex > 0 ? matches[diagIndex] : 0 // If we are not matching on the first query character any more, we only produce a // score if we had a score previously for the last query index (by looking at the diagScore). // This makes sure that the query always matches in sequence on the target. For example // given a target of "ede" and a query of "de", we would otherwise produce a wrong high score // for query[1] ("e") matching on target[0] ("e") because of the "beginning of word" boost. let score: number if (!diagScore && queryIndex > 0) { score = 0 } else { score = computeCharScore( query, queryLower, queryIndex, target, targetLower, targetIndex, matchesSequenceLength, ) } // We have a score and its equal or larger than the left score // Match: sequence continues growing from previous diag value // Score: increases by diag score value if (score && diagScore + score >= leftScore) { matches[currentIndex] = matchesSequenceLength + 1 scores[currentIndex] = diagScore + score } else { // We either have no score or the score is lower than the left score // Match: reset to 0 // Score: pick up from left hand side matches[currentIndex] = NO_MATCH scores[currentIndex] = leftScore } } } // Restore Positions (starting from bottom right of matrix) const positions = [] let queryIndex = queryLength - 1 let targetIndex = targetLength - 1 while (queryIndex >= 0 && targetIndex >= 0) { const currentIndex = queryIndex * targetLength + targetIndex const match = matches[currentIndex] if (match === NO_MATCH) { targetIndex-- // go left } else { positions.push(targetIndex) // go up and left queryIndex-- targetIndex-- } } // Print matrix // if (DEBUG_MATRIX) { // printMatrix(query, target, matches, scores); // } return [scores[queryLength * targetLength - 1], positions.reverse()] } function computeCharScore( query: string, queryLower: string, queryIndex: number, target: string, targetLower: string, targetIndex: number, matchesSequenceLength: number, ): number { let score = 0 if (queryLower[queryIndex] !== targetLower[targetIndex]) { return score // no match of characters } // Character match bonus score += 1 // if (DEBUG) { // console.groupCollapsed(`%cCharacter match bonus: +1 (char: ${queryLower[queryIndex]} at index ${targetIndex}, total score: ${score})`, 'font-weight: normal'); // } // Consecutive match bonus if (matchesSequenceLength > 0) { score += matchesSequenceLength * 5 // if (DEBUG) { // console.log('Consecutive match bonus: ' + (matchesSequenceLength * 5)); // } } // Same case bonus if (query[queryIndex] === target[targetIndex]) { score += 1 // if (DEBUG) { // console.log('Same case bonus: +1'); // } } // Start of word bonus if (targetIndex === 0) { score += 8 // if (DEBUG) { // console.log('Start of word bonus: +8'); // } } else { // After separator bonus const separatorBonus = scoreSeparatorAtPos(target.charCodeAt(targetIndex - 1)) if (separatorBonus) { score += separatorBonus // if (DEBUG) { // console.log('After separtor bonus: +4'); // } } else if (isUpper(target.charCodeAt(targetIndex))) { // Inside word upper case bonus (camel case) score += 1 // if (DEBUG) { // console.log('Inside word upper case bonus: +1'); // } } } // if (DEBUG) { // console.groupEnd(); // } return score } function scoreSeparatorAtPos(charCode: number): number { switch (charCode) { case CharCode.Slash: case CharCode.Backslash: return 5 // prefer path separators... case CharCode.Underline: case CharCode.Dash: case CharCode.Period: case CharCode.Space: case CharCode.SingleQuote: case CharCode.DoubleQuote: case CharCode.Colon: return 4 // ...over other separators default: return 0 } } // function printMatrix(query: string, target: string, matches: number[], scores: number[]): void { // console.log('\t' + target.split('').join('\t')); // for (let queryIndex = 0; queryIndex < query.length; queryIndex++) { // let line = query[queryIndex] + '\t'; // for (let targetIndex = 0; targetIndex < target.length; targetIndex++) { // const currentIndex = queryIndex * target.length + targetIndex; // line = line + 'M' + matches[currentIndex] + '/' + 'S' + scores[currentIndex] + '\t'; // } // console.log(line); // } // } /** * Scoring on structural items that have a label and optional description. */ export interface IItemScore { /** * Overall score. */ score: number /** * Matches within the label. */ labelMatch?: IMatch[] /** * Matches within the description. */ descriptionMatch?: IMatch[] } const NO_ITEM_SCORE: IItemScore = Object.freeze({ score: 0 }) export interface IItemAccessor { /** * Just the label of the item to score on. */ getItemLabel(item: T): string /** * The optional description of the item to score on. Can be null. */ getItemDescription(item: T): string /** * If the item is a file, the path of the file to score on. Can be null. */ getItemPath(file: T): string } const PATH_IDENTITY_SCORE = 1 << 18 const LABEL_PREFIX_SCORE = 1 << 17 const LABEL_CAMELCASE_SCORE = 1 << 16 const LABEL_SCORE_THRESHOLD = 1 << 15 export interface IPreparedQuery { original: string value: string lowercase: string containsPathSeparator: boolean } /** * Helper function to prepare a search value for scoring in quick open by removing unwanted characters. */ export function prepareQuery(original: string): IPreparedQuery { let lowercase: string let containsPathSeparator: boolean let value: string if (original) { value = stripWildcards(original).replace(/\s/g, "") // get rid of all wildcards and whitespace if (isWindows) { value = value.replace(/\//g, nativeSep) // Help Windows users to search for paths when using slash } lowercase = value.toLowerCase() containsPathSeparator = value.indexOf(nativeSep) >= 0 } return { original, value, lowercase, containsPathSeparator } } export function scoreItem( item: T, query: IPreparedQuery, fuzzy: boolean, accessor: IItemAccessor, cache: ScorerCache, ): IItemScore { if (!item || !query.value) { return NO_ITEM_SCORE // we need an item and query to score on at least } const label = accessor.getItemLabel(item) if (!label) { return NO_ITEM_SCORE // we need a label at least } const description = accessor.getItemDescription(item) let cacheHash: string if (description) { cacheHash = `${label}${description}${query.value}${fuzzy}` } else { cacheHash = `${label}${query.value}${fuzzy}` } const cached = cache[cacheHash] if (cached) { return cached } const itemScore = doScoreItem(label, description, accessor.getItemPath(item), query, fuzzy) cache[cacheHash] = itemScore return itemScore } function doScoreItem( label: string, description: string, path: string, query: IPreparedQuery, fuzzy: boolean, ): IItemScore { // 1.) treat identity matches on full path highest if (path && isLinux ? query.original === path : equalsIgnoreCase(query.original, path)) { return { score: PATH_IDENTITY_SCORE, labelMatch: [{ start: 0, end: label.length }], descriptionMatch: description ? [{ start: 0, end: description.length }] : void 0, } } // We only consider label matches if the query is not including file path separators const preferLabelMatches = !path || !query.containsPathSeparator if (preferLabelMatches) { // 2.) treat prefix matches on the label second highest const prefixLabelMatch = matchesPrefix(query.value, label) if (prefixLabelMatch) { return { score: LABEL_PREFIX_SCORE, labelMatch: prefixLabelMatch } } // 3.) treat camelcase matches on the label third highest const camelcaseLabelMatch = matchesCamelCase(query.value, label) if (camelcaseLabelMatch) { return { score: LABEL_CAMELCASE_SCORE, labelMatch: camelcaseLabelMatch } } // 4.) prefer scores on the label if any const [labelScore, labelPositions] = score(label, query.value, query.lowercase, fuzzy) if (labelScore) { return { score: labelScore + LABEL_SCORE_THRESHOLD, labelMatch: createMatches(labelPositions), } } } // 5.) finally compute description + label scores if we have a description if (description) { let descriptionPrefix = description if (!!path) { descriptionPrefix = `${description}${nativeSep}` // assume this is a file path } const descriptionPrefixLength = descriptionPrefix.length const descriptionAndLabel = `${descriptionPrefix}${label}` const [labelDescriptionScore, labelDescriptionPositions] = score( descriptionAndLabel, query.value, query.lowercase, fuzzy, ) if (labelDescriptionScore) { const labelDescriptionMatches = createMatches(labelDescriptionPositions) const labelMatch: IMatch[] = [] const descriptionMatch: IMatch[] = [] // We have to split the matches back onto the label and description portions labelDescriptionMatches.forEach(h => { // Match overlaps label and description part, we need to split it up if (h.start < descriptionPrefixLength && h.end > descriptionPrefixLength) { labelMatch.push({ start: 0, end: h.end - descriptionPrefixLength }) descriptionMatch.push({ start: h.start, end: descriptionPrefixLength }) } else if (h.start >= descriptionPrefixLength) { // Match on label part labelMatch.push({ start: h.start - descriptionPrefixLength, end: h.end - descriptionPrefixLength, }) } else { // Match on description part descriptionMatch.push(h) } }) return { score: labelDescriptionScore, labelMatch, descriptionMatch } } } return NO_ITEM_SCORE } export function compareItemsByScore( itemA: T, itemB: T, query: IPreparedQuery, fuzzy: boolean, accessor: IItemAccessor, cache: ScorerCache, fallbackComparer = fallbackCompare, ): number { const itemScoreA = scoreItem(itemA, query, fuzzy, accessor, cache) const itemScoreB = scoreItem(itemB, query, fuzzy, accessor, cache) const scoreA = itemScoreA.score const scoreB = itemScoreB.score // 1.) prefer identity matches if (scoreA === PATH_IDENTITY_SCORE || scoreB === PATH_IDENTITY_SCORE) { if (scoreA !== scoreB) { return scoreA === PATH_IDENTITY_SCORE ? -1 : 1 } } // 2.) prefer label prefix matches if (scoreA === LABEL_PREFIX_SCORE || scoreB === LABEL_PREFIX_SCORE) { if (scoreA !== scoreB) { return scoreA === LABEL_PREFIX_SCORE ? -1 : 1 } const labelA = accessor.getItemLabel(itemA) const labelB = accessor.getItemLabel(itemB) // prefer shorter names when both match on label prefix if (labelA.length !== labelB.length) { return labelA.length - labelB.length } } // 3.) prefer camelcase matches if (scoreA === LABEL_CAMELCASE_SCORE || scoreB === LABEL_CAMELCASE_SCORE) { if (scoreA !== scoreB) { return scoreA === LABEL_CAMELCASE_SCORE ? -1 : 1 } const labelA = accessor.getItemLabel(itemA) const labelB = accessor.getItemLabel(itemB) // prefer more compact camel case matches over longer const comparedByMatchLength = compareByMatchLength( itemScoreA.labelMatch, itemScoreB.labelMatch, ) if (comparedByMatchLength !== 0) { return comparedByMatchLength } // prefer shorter names when both match on label camelcase if (labelA.length !== labelB.length) { return labelA.length - labelB.length } } // 4.) prefer label scores if (scoreA > LABEL_SCORE_THRESHOLD || scoreB > LABEL_SCORE_THRESHOLD) { if (scoreB < LABEL_SCORE_THRESHOLD) { return -1 } if (scoreA < LABEL_SCORE_THRESHOLD) { return 1 } } // 5.) compare by score if (scoreA !== scoreB) { return scoreA > scoreB ? -1 : 1 } // 6.) scores are identical, prefer more compact matches (label and description) const itemAMatchDistance = computeLabelAndDescriptionMatchDistance(itemA, itemScoreA, accessor) const itemBMatchDistance = computeLabelAndDescriptionMatchDistance(itemB, itemScoreB, accessor) if (itemAMatchDistance && itemBMatchDistance && itemAMatchDistance !== itemBMatchDistance) { return itemBMatchDistance > itemAMatchDistance ? -1 : 1 } // 7.) at this point, scores are identical and match compactness as well // for both items so we start to use the fallback compare return fallbackComparer(itemA, itemB, query, accessor) } function computeLabelAndDescriptionMatchDistance( item: T, score: IItemScore, accessor: IItemAccessor, ): number { const hasLabelMatches = score.labelMatch && score.labelMatch.length const hasDescriptionMatches = score.descriptionMatch && score.descriptionMatch.length let matchStart: number = -1 let matchEnd: number = -1 // If we have description matches, the start is first of description match if (hasDescriptionMatches) { matchStart = score.descriptionMatch[0].start } else if (hasLabelMatches) { // Otherwise, the start is the first label match matchStart = score.labelMatch[0].start } // If we have label match, the end is the last label match // If we had a description match, we add the length of the description // as offset to the end to indicate this. if (hasLabelMatches) { matchEnd = score.labelMatch[score.labelMatch.length - 1].end if (hasDescriptionMatches) { const itemDescription = accessor.getItemDescription(item) if (itemDescription) { matchEnd += itemDescription.length } } } else if (hasDescriptionMatches) { // If we have just a description match, the end is the last description match matchEnd = score.descriptionMatch[score.descriptionMatch.length - 1].end } return matchEnd - matchStart } function compareByMatchLength(matchesA?: IMatch[], matchesB?: IMatch[]): number { if ((!matchesA && !matchesB) || (!matchesA.length && !matchesB.length)) { return 0 // make sure to not cause bad comparing when matches are not provided } if (!matchesB || !matchesB.length) { return -1 } if (!matchesA || !matchesA.length) { return 1 } // Compute match length of A (first to last match) const matchStartA = matchesA[0].start const matchEndA = matchesA[matchesA.length - 1].end const matchLengthA = matchEndA - matchStartA // Compute match length of B (first to last match) const matchStartB = matchesB[0].start const matchEndB = matchesB[matchesB.length - 1].end const matchLengthB = matchEndB - matchStartB // Prefer shorter match length return matchLengthA === matchLengthB ? 0 : matchLengthB < matchLengthA ? 1 : -1 } export function fallbackCompare( itemA: T, itemB: T, query: IPreparedQuery, accessor: IItemAccessor, ): number { // check for label + description length and prefer shorter const labelA = accessor.getItemLabel(itemA) const labelB = accessor.getItemLabel(itemB) const descriptionA = accessor.getItemDescription(itemA) const descriptionB = accessor.getItemDescription(itemB) const labelDescriptionALength = labelA.length + (descriptionA ? descriptionA.length : 0) const labelDescriptionBLength = labelB.length + (descriptionB ? descriptionB.length : 0) if (labelDescriptionALength !== labelDescriptionBLength) { return labelDescriptionALength - labelDescriptionBLength } // check for path length and prefer shorter const pathA = accessor.getItemPath(itemA) const pathB = accessor.getItemPath(itemB) if (pathA && pathB && pathA.length !== pathB.length) { return pathA.length - pathB.length } // 7.) finally we have equal scores and equal length, we fallback to comparer // compare by label if (labelA !== labelB) { return compareAnything(labelA, labelB, query.value) } // compare by description if (descriptionA && descriptionB && descriptionA !== descriptionB) { return compareAnything(descriptionA, descriptionB, query.value) } // compare by path if (pathA && pathB && pathA !== pathB) { return compareAnything(pathA, pathB, query.value) } // equal return 0 } ================================================ FILE: browser/src/Services/Search/Scorer/Utilities.ts ================================================ /** * Assortment of imported Utility functions from VSCode */ import { CharCode } from "./CharCode" export const isWindows = process.platform === "win32" export const isMacintosh = process.platform === "darwin" export const isLinux = process.platform === "linux" // The native path separator depending on the OS. export const nativeSep = isWindows ? "\\" : "/" export function isLower(code: number): boolean { return CharCode.a <= code && code <= CharCode.z } export function isUpper(code: number): boolean { return CharCode.A <= code && code <= CharCode.Z } export function isNumber(code: number): boolean { return CharCode.Digit0 <= code && code <= CharCode.Digit9 } export function isWhitespace(code: number): boolean { return ( code === CharCode.Space || code === CharCode.Tab || code === CharCode.LineFeed || code === CharCode.CarriageReturn ) } export function isAlphanumeric(code: number): boolean { return isLower(code) || isUpper(code) || isNumber(code) } export function stripWildcards(pattern: string): string { return pattern.replace(/\*/g, "") } ================================================ FILE: browser/src/Services/Search/Scorer/filters.ts ================================================ /* tslint:disable */ /** * Imported functions from VSCode's filters.ts */ import { startsWithIgnoreCase } from "./strings" import { isAlphanumeric, isLower, isNumber, isUpper, isWhitespace } from "./Utilities" export interface IFilter { // Returns null if word doesn't match. (word: string, wordToMatchAgainst: string): IMatch[] } export interface IMatch { start: number end: number } export const matchesPrefix: IFilter = _matchesPrefix.bind(undefined, true) function _matchesPrefix(ignoreCase: boolean, word: string, wordToMatchAgainst: string): IMatch[] { if (!wordToMatchAgainst || wordToMatchAgainst.length < word.length) { return null } let matches: boolean if (ignoreCase) { matches = startsWithIgnoreCase(wordToMatchAgainst, word) } else { matches = wordToMatchAgainst.indexOf(word) === 0 } if (!matches) { return null } return word.length > 0 ? [{ start: 0, end: word.length }] : [] } export function createMatches(position: number[]): IMatch[] { let ret: IMatch[] = [] if (!position) { return ret } let last: IMatch for (const pos of position) { if (last && last.end === pos) { last.end += 1 } else { last = { start: pos, end: pos + 1 } ret.push(last) } } return ret } function nextAnchor(camelCaseWord: string, start: number): number { for (let i = start; i < camelCaseWord.length; i++) { let c = camelCaseWord.charCodeAt(i) if ( isUpper(c) || isNumber(c) || (i > 0 && !isAlphanumeric(camelCaseWord.charCodeAt(i - 1))) ) { return i } } return camelCaseWord.length } function _matchesCamelCase(word: string, camelCaseWord: string, i: number, j: number): IMatch[] { if (i === word.length) { return [] } else if (j === camelCaseWord.length) { return null } else if (word[i] !== camelCaseWord[j].toLowerCase()) { return null } else { let result: IMatch[] = null let nextUpperIndex = j + 1 result = _matchesCamelCase(word, camelCaseWord, i + 1, j + 1) while ( !result && (nextUpperIndex = nextAnchor(camelCaseWord, nextUpperIndex)) < camelCaseWord.length ) { result = _matchesCamelCase(word, camelCaseWord, i + 1, nextUpperIndex) nextUpperIndex++ } return result === null ? null : join({ start: j, end: j + 1 }, result) } } interface ICamelCaseAnalysis { upperPercent: number lowerPercent: number alphaPercent: number numericPercent: number } // Heuristic to avoid computing camel case matcher for words that don't // look like camelCaseWords. function analyzeCamelCaseWord(word: string): ICamelCaseAnalysis { let upper = 0, lower = 0, alpha = 0, numeric = 0, code = 0 for (let i = 0; i < word.length; i++) { code = word.charCodeAt(i) if (isUpper(code)) { upper++ } if (isLower(code)) { lower++ } if (isAlphanumeric(code)) { alpha++ } if (isNumber(code)) { numeric++ } } let upperPercent = upper / word.length let lowerPercent = lower / word.length let alphaPercent = alpha / word.length let numericPercent = numeric / word.length return { upperPercent, lowerPercent, alphaPercent, numericPercent } } function isUpperCaseWord(analysis: ICamelCaseAnalysis): boolean { const { upperPercent, lowerPercent } = analysis return lowerPercent === 0 && upperPercent > 0.6 } function isCamelCaseWord(analysis: ICamelCaseAnalysis): boolean { const { upperPercent, lowerPercent, alphaPercent, numericPercent } = analysis return lowerPercent > 0.2 && upperPercent < 0.8 && alphaPercent > 0.6 && numericPercent < 0.2 } // Heuristic to avoid computing camel case matcher for words that don't // look like camel case patterns. function isCamelCasePattern(word: string): boolean { let upper = 0, lower = 0, code = 0, whitespace = 0 for (let i = 0; i < word.length; i++) { code = word.charCodeAt(i) if (isUpper(code)) { upper++ } if (isLower(code)) { lower++ } if (isWhitespace(code)) { whitespace++ } } if ((upper === 0 || lower === 0) && whitespace === 0) { return word.length <= 30 } else { return upper <= 5 } } export function matchesCamelCase(word: string, camelCaseWord: string): IMatch[] { if (!camelCaseWord) { return null } camelCaseWord = camelCaseWord.trim() if (camelCaseWord.length === 0) { return null } if (!isCamelCasePattern(word)) { return null } if (camelCaseWord.length > 60) { return null } const analysis = analyzeCamelCaseWord(camelCaseWord) if (!isCamelCaseWord(analysis)) { if (!isUpperCaseWord(analysis)) { return null } camelCaseWord = camelCaseWord.toLowerCase() } let result: IMatch[] = null let i = 0 while ( i < camelCaseWord.length && (result = _matchesCamelCase(word.toLowerCase(), camelCaseWord, 0, i)) === null ) { i = nextAnchor(camelCaseWord, i + 1) } return result } function join(head: IMatch, tail: IMatch[]): IMatch[] { if (tail.length === 0) { tail = [head] } else if (head.end === tail[0].start) { tail[0].start = head.start } else { tail.unshift(head) } return tail } ================================================ FILE: browser/src/Services/Search/Scorer/strings.ts ================================================ /* tslint:disable */ /** * Imported functions from VSCode's strings.ts */ import { CharCode } from "./CharCode" function isLowerAsciiLetter(code: number): boolean { return code >= CharCode.a && code <= CharCode.z } function isUpperAsciiLetter(code: number): boolean { return code >= CharCode.A && code <= CharCode.Z } function isAsciiLetter(code: number): boolean { return isLowerAsciiLetter(code) || isUpperAsciiLetter(code) } export function equalsIgnoreCase(a: string, b: string): boolean { const len1 = a ? a.length : 0 const len2 = b ? b.length : 0 if (len1 !== len2) { return false } return doEqualsIgnoreCase(a, b) } function doEqualsIgnoreCase(a: string, b: string, stopAt = a.length): boolean { if (typeof a !== "string" || typeof b !== "string") { return false } for (let i = 0; i < stopAt; i++) { const codeA = a.charCodeAt(i) const codeB = b.charCodeAt(i) if (codeA === codeB) { continue } // a-z A-Z if (isAsciiLetter(codeA) && isAsciiLetter(codeB)) { let diff = Math.abs(codeA - codeB) if (diff !== 0 && diff !== 32) { return false } } else { // Any other charcode if ( String.fromCharCode(codeA).toLowerCase() !== String.fromCharCode(codeB).toLowerCase() ) { return false } } } return true } export function startsWithIgnoreCase(str: string, candidate: string): boolean { const candidateLength = candidate.length if (candidate.length > str.length) { return false } return doEqualsIgnoreCase(str, candidate, candidateLength) } ================================================ FILE: browser/src/Services/Search/SearchPaneView.tsx ================================================ /** * Search/index.tsx * * Entry point for search-related features */ import * as Oni from "oni-api" import * as React from "react" import { IDisposable, IEvent } from "oni-types" import { Workspace } from "./../Workspace" export * from "./SearchProvider" import styled from "styled-components" import { SearchTextBox } from "./SearchTextBox" import { SidebarEmptyPaneView } from "./../../UI/components/SidebarEmptyPaneView" import { VimNavigator } from "./../../UI/components/VimNavigator" const Label = styled.div` margin: 8px; ` export interface ISearchPaneViewProps { workspace: Workspace onEnter: IEvent onLeave: IEvent onFocus: IEvent focusImmediately?: boolean onSearchOptionsChanged: (opts: Oni.Search.Options) => void } export interface ISearchPaneViewState { activeWorkspace: string isActive: boolean activeTextbox: string searchQuery: string fileFilter: string } export class SearchPaneView extends React.PureComponent< ISearchPaneViewProps, ISearchPaneViewState > { private _subscriptions: IDisposable[] = [] constructor(props: ISearchPaneViewProps) { super(props) this.state = { activeWorkspace: this.props.workspace.activeWorkspace, isActive: false, activeTextbox: null, searchQuery: "Search...", fileFilter: null, } } public componentDidMount(): void { this._cleanExistingSubscriptions() const s1 = this.props.onEnter.subscribe(() => this.setState({ isActive: true })) const s2 = this.props.onLeave.subscribe(() => this.setState({ isActive: false })) const s3 = this.props.workspace.onDirectoryChanged.subscribe((wd: string) => this.setState({ activeWorkspace: wd }), ) const s4 = this.props.onFocus.subscribe(() => this.setState({ activeTextbox: "textbox.query" }), ) this._subscriptions = [s1, s2, s3, s4] if (this.props.focusImmediately) { this.setState({ activeTextbox: "textbox.query", }) } } public componentWillUnmount(): void { this._cleanExistingSubscriptions() } public render(): JSX.Element { if (!this.state.activeWorkspace) { return ( this.props.workspace.openFolder()} /> ) } return ( { this._onSelected(selectedId) }} render={(selectedId: string) => { return (
    this._onChangeSearchQuery(val)} onCommit={() => this._clearActiveTextbox()} onDismiss={() => this._clearActiveTextbox()} isFocused={selectedId === "textbox.query"} isActive={this.state.activeTextbox === "textbox.query"} onClick={() => this._onSelected("textbox.query")} /> {/* this._onChangeFilesFilter(val)} onCommit={() => this._clearActiveTextbox()} onDismiss={() => this._clearActiveTextbox()} isFocused={selectedId === "textbox.filter"} isActive={this.state.activeTextbox === "textbox.filter"} />*/}
    ) }} /> ) } private _cleanExistingSubscriptions(): void { this._subscriptions.forEach(s => s.dispose()) this._subscriptions = [] } // private _onChangeFilesFilter(val: string): void { // this.setState({ // fileFilter: val, // }) // this._startSearch() // } private _onChangeSearchQuery(val: string): void { this.setState({ searchQuery: val, }) this._startSearch(val) } // private _onCommit(): void { // } private _clearActiveTextbox(): void { this.setState({ activeTextbox: null }) } private _onSelected(selectedId: string): void { if (selectedId === "textbox.query") { this.setState({ activeTextbox: "textbox.query" }) } else if (selectedId === "textbox.filter") { this.setState({ activeTextbox: "textbox.filter" }) } } private _startSearch(val: string): void { this.props.onSearchOptionsChanged({ searchQuery: val, fileFilter: this.state.fileFilter, workspace: this.props.workspace.activeWorkspace, }) } } ================================================ FILE: browser/src/Services/Search/SearchProvider.ts ================================================ import { Event, IEvent } from "oni-types" import { configuration } from "./../Configuration" import * as Oni from "oni-api" import { FinderProcess } from "./FinderProcess" import * as RipGrep from "./RipGrep" class NullSearchQuery implements Oni.Search.Query { public _onSearchResults = new Event() public start(): void { return undefined } public cancel(): void { return undefined } public get onSearchResults(): IEvent { return this._onSearchResults } } export class Search implements Oni.Search.ISearch { public get nullSearch(): Oni.Search.Query { return new NullSearchQuery() } public findInFile(opts: Oni.Search.Options): Oni.Search.Query { const commandParts = [ RipGrep.getCommand(), "--ignore-case", ...RipGrep.getArguments( configuration.getValue("oni.exclude"), configuration.getValue("editor.quickOpen.showHidden"), ), // "-e", ...(opts.fileFilter ? ["-g", opts.fileFilter] : []), "--", opts.searchQuery, opts.workspace ? opts.workspace : ".", ] return new SearchQuery(commandParts.join(" "), parseRipGrepLine) } public findInPath(opts: Oni.Search.Options): Oni.Search.Query { const commandParts = [ RipGrep.getCommand(), ...RipGrep.getArguments( configuration.getValue("oni.exclude"), configuration.getValue("editor.quickOpen.showHidden"), ), "--files", "--", opts.workspace ? opts.workspace : ".", ] return new SearchQuery(commandParts.join(" "), parseRipGrepFilesLine) } } function parseRipGrepLine(ripGrepResult: string): Oni.Search.ResultItem { if (!ripGrepResult || ripGrepResult.length === 0) { return null } const splitString = ripGrepResult.split(":") if (splitString.length < 4) { return null } const [fileName, line, column, ...result] = splitString const text = result.join(":") return { fileName, line: parseInt(line, 10), column: parseInt(column, 10), text, } } function parseRipGrepFilesLine(line: string): Oni.Search.ResultItem { if (!line || line.length === 0) { return null } return { fileName: line, line: 0, column: 0, text: "", } } type IParseLine = (line: string) => Oni.Search.ResultItem class SearchQuery implements Oni.Search.Query { private _onSearchResults = new Event() private _finderProcess: FinderProcess private _items: Oni.Search.ResultItem[] = [] public get onSearchResults(): IEvent { return this._onSearchResults } constructor(command: string, parseLine: IParseLine) { this._finderProcess = new FinderProcess(command, "\n") this._finderProcess.onData.subscribe((lines: string[]) => { const results = lines.map(parseLine).filter(item => item !== null) this._items = [...this._items, ...results] this._onSearchResults.dispatch({ items: this._items, isComplete: false, }) }) this._finderProcess.onComplete.subscribe(() => { this._onSearchResults.dispatch({ items: this._items, isComplete: true, }) }) } public start(): void { this._items = [] this._finderProcess.start() } public cancel(): void { this._finderProcess.stop() } } ================================================ FILE: browser/src/Services/Search/SearchResultsSpinnerView.tsx ================================================ /** * Search/index.tsx * * Entry point for search-related features */ import * as React from "react" import { IDisposable, IEvent } from "oni-types" import styled, { keyframes } from "styled-components" import { withProps } from "./../../UI/components/common" import { Icon, IconSize } from "./../../UI/Icon" export interface ISearchResultSpinnerViewProps { onSearchStarted: IEvent onSearchFinished: IEvent } export interface ISearchResultSpinnerViewState { isActive: boolean } const SpinnerWrapper = withProps<{ isActive: boolean }>(styled.div)` opacity: ${props => (props.isActive ? "0.5" : "0")}; transition: opacity 0.5s ease-in; width: 100%; min-height: 5em; display: flex; justify-content: center; align-items: center; position: relative; ` const RotateKeyFrames = keyframes` 0% { transform: rotateZ(0deg); } 100% { transform: rotateZ(360deg); } ` const SpinnerRotator = styled.div` animation: ${RotateKeyFrames} 0.5s linear infinite; transform-origin: center; ` export class SearchResultSpinnerView extends React.PureComponent< ISearchResultSpinnerViewProps, ISearchResultSpinnerViewState > { private _subscriptions: IDisposable[] = [] constructor(props: ISearchResultSpinnerViewProps) { super(props) this.state = { isActive: false, } } public componentDidMount(): void { this._cleanExistingSubscriptions() const s1 = this.props.onSearchStarted.subscribe(() => this.setState({ isActive: true })) const s2 = this.props.onSearchFinished.subscribe(() => this.setState({ isActive: false })) this._subscriptions = [s1, s2] } public componentWillUnmount(): void { this._cleanExistingSubscriptions() } public render(): JSX.Element { return ( ) } private _cleanExistingSubscriptions(): void { this._subscriptions.forEach(s => s.dispose()) this._subscriptions = [] } } ================================================ FILE: browser/src/Services/Search/SearchTextBox.tsx ================================================ /** * SearchTextBox.tsx * * Component for textbox in search */ import * as React from "react" import styled from "styled-components" import { boxShadow, withProps } from "./../../UI/components/common" import { TextInputView } from "./../../UI/components/LightweightText" export interface ISearchTextBoxProps { isActive: boolean isFocused: boolean val: string onDismiss: () => void onCommit: (newValue: string) => void onChangeText: (newValue: string) => void onClick: () => void } const SearchBoxContainerWrapper = withProps(styled.div)` padding: 8px; background-color: ${props => (props.isFocused ? "rgba(0, 0, 0, 0.1)" : "transparent")}; border-left: 2px solid ${props => props.isFocused ? props.theme["highlight.mode.normal.background"] : "transparent"}; ` const SearchTextBoxWrapper = withProps(styled.div)` padding: 8px; border: ${props => props.isActive ? "2px solid " + props.theme["highlight.mode.normal.background"] : "1px solid " + props.theme["editor.foreground"]}; margin: 8px; background-color: ${props => props.theme.background}; ${props => (props.isActive ? boxShadow : "")}; transition: all 0.1s ease-in; input { background-color: transparent; color: ${props => props.theme["editor.foreground"]} } cursor: text; ` export class SearchTextBox extends React.PureComponent { public render(): JSX.Element { const inner = this.props.isActive ? ( this.props.onChangeText(elem.currentTarget.value)} onComplete={this.props.onCommit} /> ) : (
    {this.props.val}
    ) return ( {inner} ) } } ================================================ FILE: browser/src/Services/Search/index.tsx ================================================ import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { Workspace } from "./../Workspace" import * as React from "react" import { Subject } from "rxjs/Subject" import { Event, IEvent } from "oni-types" import { SearchPaneView } from "./SearchPaneView" import { SearchResultSpinnerView } from "./SearchResultsSpinnerView" import { getInstance as getSidebarManager } from "../Sidebar" // TODO: Replace with oni-api usage export class SearchPane { private _onEnter = new Event() private _onLeave = new Event() private _onSearchStarted = new Event() private _onSearchCompleted = new Event() private _shouldFocusAutomatically: boolean = false private _currentQuery: Oni.Search.Query private _searchOptionsObservable = new Subject() public get id(): string { return "oni.sidebar.search" } public get title(): string { return "Search" } constructor(private _onFocusEvent: IEvent, private _oni: Oni.Plugin.Api) { this._searchOptionsObservable.auditTime(100).subscribe((opts: Oni.Search.Options) => { this._startNewSearch(opts) }) this._onFocusEvent.subscribe(() => { this._shouldFocusAutomatically = true }) } public enter(): void { this._onEnter.dispatch() } public leave(): void { this._onLeave.dispatch() } public render(): JSX.Element { const immedateFocus = this._shouldFocusAutomatically this._shouldFocusAutomatically = false const typelessWorkspace: any = this._oni.workspace // TODO: Work-around this hack const workspace: Workspace = typelessWorkspace // TODO: Work-around this hack return (
    this._onSearchOptionsChanged(opts)} focusImmediately={immedateFocus} />
    ) } private _onSearchOptionsChanged(searchOpts: Oni.Search.Options): void { this._searchOptionsObservable.next(searchOpts) } private _startNewSearch(searchOpts: Oni.Search.Options): void { if (this._currentQuery) { this._currentQuery.cancel() } if (!searchOpts.searchQuery || searchOpts.searchQuery.length < 1) { return } Log.verbose("[SearchPane::_startNewSearch]: " + searchOpts.searchQuery) this._onSearchStarted.dispatch() const query = (this._currentQuery = this._oni.search.findInFile(searchOpts)) const toQuickFixItem = (r: Oni.Search.ResultItem) => { return { filename: r.fileName, lnum: r.line, col: r.column, text: r.text.trim(), } } query.onSearchResults.subscribe(result => { if (result.isComplete) { this._onSearchCompleted.dispatch() this._oni.populateQuickFix(result.items.map(toQuickFixItem)) } }) query.start() } } export function activate(oni: any): any { const onFocusEvent = new Event() const oniApi: Oni.Plugin.Api = oni // TODO: Add sidebar.add to the API and use oniApi instead of oni oni.sidebar.add("search", new SearchPane(onFocusEvent, oni)) const sidebarManager = getSidebarManager() // TODO: Remove const searchAllFiles = () => { sidebarManager.toggleVisibilityById("oni.sidebar.search") // TODO: Use oni-api instead // TODO: Add sidebar.setActiveEntry to the API and use oni as Oni (API) // oni.sidebar.setActiveEntry("oni.sidebar.search") onFocusEvent.dispatch() } oniApi.commands.registerCommand({ command: "search.searchAllFiles", name: "Search: All files", detail: "Search across files in the active workspace", execute: searchAllFiles, enabled: () => !!oniApi.workspace.activeWorkspace, }) } ================================================ FILE: browser/src/Services/Sessions/SessionManager.ts ================================================ import * as fs from "fs-extra" import { Editor, EditorManager, Plugin } from "oni-api" import { IEvent } from "oni-types" import * as path from "path" import { SidebarManager } from "../Sidebar" import { SessionActions, SessionsPane, store } from "./" import { getPersistentStore, IPersistentStore } from "./../../PersistentStore" import { getUserConfigFolderPath } from "./../../Services/Configuration/UserConfiguration" export interface ISession { name: string id: string file: string directory: string updatedAt?: string workspace: string // can be use to save other metadata for restoration like statusbar info or sidebar info etc metadata?: { [key: string]: any } } export interface ISessionService { sessionsDir: string allSessions: ISession[] persistSession(sessionName: string): Promise restoreSession(sessionName: string): Promise } export interface UpdatedOni extends Plugin.Api { editors: UpdatedEditorManager } interface UpdatedEditorManager extends EditorManager { activeEditor: UpdatedEditor } interface UpdatedEditor extends Editor { onQuit: IEvent persistSession(sessionDetails: ISession): Promise restoreSession(sessionDetails: ISession): Promise getCurrentSession(): Promise } /** * Class SessionManager * * Provides a service to manage oni session i.e. buffers, screen layout etc. * */ export class SessionManager implements ISessionService { private _store = store({ sessionManager: this, fs }) private get _sessionsDir() { const defaultDirectory = path.join(getUserConfigFolderPath(), "sessions") const userDirectory = this._oni.configuration.getValue( "experimental.sessions.directory", ) const directory = userDirectory || defaultDirectory return directory } constructor( private _oni: UpdatedOni, private _sidebarManager: SidebarManager, private _persistentStore: IPersistentStore<{ [sessionName: string]: ISession }>, ) { fs.ensureDirSync(this.sessionsDir) const enabled = this._oni.configuration.getValue("experimental.sessions.enabled") if (enabled) { this._store.dispatch({ type: "POPULATE_SESSIONS" }) this._sidebarManager.add( "save", new SessionsPane({ store: this._store, commands: this._oni.commands }), ) } this._setupSubscriptions() } public get allSessions() { const state = this._store.getState() return state.sessions } public get sessionsDir() { return this._sessionsDir } public async updateOniSession(name: string, value: Partial) { const persistedSessions = await this._persistentStore.get() if (name in persistedSessions) { this._persistentStore.set({ ...persistedSessions, [name]: { ...persistedSessions[name], ...value }, }) } } public async createOniSession(sessionName: string) { const persistedSessions = await this._persistentStore.get() const file = this._getSessionFilename(sessionName) const session: ISession = { file, id: sessionName, name: sessionName, directory: this.sessionsDir, workspace: this._oni.workspace.activeWorkspace, metadata: null, } this._persistentStore.set({ ...persistedSessions, [sessionName]: session }) return session } /** * Retrieve or Create a persistent Oni Session * * @name getSessionFromStore * @function * @param {string} sessionName The name of the session * @returns {ISession} The session metadata object */ public async getSessionFromStore(name: string) { const sessions = await this._persistentStore.get() if (name in sessions) { return sessions[name] } return this.createOniSession(name) } public persistSession = async (sessionName: string) => { const sessionDetails = await this.getSessionFromStore(sessionName) await this._oni.editors.activeEditor.persistSession(sessionDetails) return sessionDetails } public deleteSession = async (sessionName: string) => { await this._persistentStore.delete(sessionName) } public getCurrentSession = async () => { const filepath = await this._oni.editors.activeEditor.getCurrentSession() if (!filepath) { return null } const [name] = path.basename(filepath).split(".") return filepath.includes(this._sessionsDir) ? this.getSessionFromStore(name) : null } public restoreSession = async (name: string) => { const sessionDetails = await this.getSessionFromStore(name) await this._oni.editors.activeEditor.restoreSession(sessionDetails) const session = await this.getCurrentSession() return session } private _getSessionFilename(name: string) { return path.join(this.sessionsDir, `${name}.vim`) } private _setupSubscriptions() { this._oni.editors.activeEditor.onBufferEnter.subscribe(() => { this._store.dispatch(SessionActions.updateCurrentSession()) }) this._oni.editors.activeEditor.onQuit.subscribe(() => { this._store.dispatch(SessionActions.updateCurrentSession()) }) } } function init() { let instance: SessionManager return { getInstance: () => instance, activate: (oni: Plugin.Api, sidebarManager: SidebarManager) => { const persistentStore = getPersistentStore("sessions", {}, 1) instance = new SessionManager(oni as UpdatedOni, sidebarManager, persistentStore) }, } } export const { activate, getInstance } = init() ================================================ FILE: browser/src/Services/Sessions/Sessions.tsx ================================================ import * as path from "path" import * as React from "react" import { connect } from "react-redux" import SectionTitle from "../../UI/components/SectionTitle" import { Icon } from "../../UI/Icon" import styled, { css, sidebarItemSelected, withProps } from "../../UI/components/common" import TextInputView from "../../UI/components/LightweightText" import { VimNavigator } from "../../UI/components/VimNavigator" import { getTimeSince } from "../../Utility" import { ISession, ISessionState, SessionActions } from "./" interface IStateProps { sessions: ISession[] active: boolean creating: boolean selected: ISession } interface ISessionActions { populateSessions: () => void updateSelection: (selected: string) => void getAllSessions: (sessions: ISession[]) => void updateSession: (session: ISession) => void restoreSession: (session: string) => void persistSession: (session: string) => void createSession: () => void cancelCreating: () => void } interface IConnectedProps extends IStateProps, ISessionActions {} interface ISessionItem { session: ISession isSelected: boolean onClick: () => void } export const Container = styled.div` padding: 0 1em; ` const SessionItem: React.SFC = ({ session, isSelected, onClick }) => { const truncatedWorkspace = session.workspace .split(path.sep) .slice(-2) .join(path.sep) return (
    Name: {session.name}
    Workspace: {truncatedWorkspace}
    {
    Last updated: {getTimeSince(new Date(session.updatedAt))} ago
    }
    ) } const inputStyles = css` background-color: transparent; width: 100%; font-family: inherit; font-size: inherit; color: ${p => p.theme["sidebar.foreground"]}; ` const ListItem = withProps>(styled.li)` box-sizing: border-box; padding: 0.5em 1em; ${sidebarItemSelected}; ` const List = styled.ul` list-style-type: none; padding: 0; margin: 0; ` interface IState { sessionName: string showAll: boolean } interface IIDs { input: string title: string } export class Sessions extends React.PureComponent { public readonly _ID: Readonly = { input: "new_session", title: "title", } public state = { sessionName: "", showAll: true, } public async componentDidMount() { this.props.populateSessions() } public updateSelection = (selected: string) => { this.props.updateSelection(selected) } public handleSelection = async (id: string) => { const { sessionName } = this.state const inputSelected = id === this._ID.input const isTitle = id === this._ID.title const isReadonlyField = id in this._ID switch (true) { case inputSelected && this.props.creating: await this.props.persistSession(sessionName) break case inputSelected && !this.props.creating: this.props.createSession() break case isTitle: this.setState({ showAll: !this.state.showAll }) break case isReadonlyField: break default: await this.props.restoreSession(id) break } } public restoreSession = async (selected: string) => { if (selected) { await this.props.restoreSession(selected) } } public handleChange: React.ChangeEventHandler = evt => { const { value } = evt.currentTarget this.setState({ sessionName: value }) } public persistSession = async () => { const { sessionName } = this.state if (sessionName) { await this.props.persistSession(sessionName) } } public handleCancel = () => { if (this.props.creating) { this.props.cancelCreating() } this.setState({ sessionName: "" }) } public render() { const { showAll } = this.state const { sessions, active, creating } = this.props const ids = [this._ID.title, this._ID.input, ...sessions.map(({ id }) => id)] return ( ( this.handleSelection(selectedId)} /> {showAll && ( <> {creating ? ( ) : (
    this.handleSelection(selectedId)}> Create a new session
    )}
    {sessions.length ? ( sessions.map((session, idx) => ( { updateSelection(session.id) this.handleSelection(session.id) }} /> )) ) : ( No Sessions Saved )} )}
    )} /> ) } } const mapStateToProps = ({ sessions, selected, active, creating }: ISessionState): IStateProps => ({ sessions, active, creating, selected, }) export default connect(mapStateToProps, SessionActions)(Sessions) ================================================ FILE: browser/src/Services/Sessions/SessionsPane.tsx ================================================ import { Commands } from "oni-api" import * as React from "react" import { Provider } from "react-redux" import { ISessionStore, Sessions } from "./" interface SessionPaneProps { commands: Commands.Api store: ISessionStore } /** * Class SessionsPane * * A Side bar pane for Oni's Session Management * */ export default class SessionsPane { private _store: ISessionStore private _commands: Commands.Api constructor({ store, commands }: SessionPaneProps) { this._commands = commands this._store = store this._setupCommands() } get id() { return "oni.sidebar.sessions" } public get title() { return "Sessions" } public enter() { this._store.dispatch({ type: "ENTER" }) } public leave() { this._store.dispatch({ type: "LEAVE" }) } public render() { return ( ) } private _isActive = () => { const state = this._store.getState() return state.active && !state.creating } private _deleteSession = () => { this._store.dispatch({ type: "DELETE_SESSION" }) } private _setupCommands() { this._commands.registerCommand({ command: "oni.sessions.delete", name: "Sessions: Delete the current session", detail: "Delete the current or selected session", enabled: this._isActive, execute: this._deleteSession, }) } } ================================================ FILE: browser/src/Services/Sessions/SessionsStore.ts ================================================ import "rxjs" import * as fsExtra from "fs-extra" import * as path from "path" import { Store } from "redux" import { combineEpics, createEpicMiddleware, Epic, ofType } from "redux-observable" import { from } from "rxjs/observable/from" import { auditTime, catchError, filter, flatMap } from "rxjs/operators" import { ISession, SessionManager } from "./" import { createStore as createReduxStore } from "./../../Redux" export interface ISessionState { sessions: ISession[] selected: ISession currentSession: ISession active: boolean creating: boolean } const DefaultState: ISessionState = { sessions: [], selected: null, active: false, creating: false, currentSession: null, } interface IGenericAction { type: N payload?: T } export type ISessionStore = Store export type IUpdateMultipleSessions = IGenericAction<"GET_ALL_SESSIONS", { sessions: ISession[] }> export type IUpdateSelection = IGenericAction<"UPDATE_SELECTION", { selected: string }> export type IUpdateSession = IGenericAction<"UPDATE_SESSION", { session: ISession }> export type IRestoreSession = IGenericAction<"RESTORE_SESSION", { sessionName: string }> export type IPersistSession = IGenericAction<"PERSIST_SESSION", { sessionName: string }> export type IPersistSessionSuccess = IGenericAction<"PERSIST_SESSION_SUCCESS"> export type IPersistSessionFailed = IGenericAction<"PERSIST_SESSION_FAILED", { error: Error }> export type IRestoreSessionError = IGenericAction<"RESTORE_SESSION_ERROR", { error: Error }> export type IDeleteSession = IGenericAction<"DELETE_SESSION"> export type IDeleteSessionSuccess = IGenericAction<"DELETE_SESSION_SUCCESS"> export type IDeleteSessionFailed = IGenericAction<"DELETE_SESSION_FAILED"> export type IUpdateCurrentSession = IGenericAction<"UPDATE_CURRENT_SESSION"> export type ISetCurrentSession = IGenericAction<"SET_CURRENT_SESSION", { session: ISession }> export type IPopulateSessions = IGenericAction<"POPULATE_SESSIONS"> export type ICreateSession = IGenericAction<"CREATE_SESSION"> export type ICancelCreateSession = IGenericAction<"CANCEL_NEW_SESSION"> export type IEnter = IGenericAction<"ENTER"> export type ILeave = IGenericAction<"LEAVE"> export type ISessionActions = | IUpdateMultipleSessions | ICancelCreateSession | IRestoreSessionError | IUpdateCurrentSession | IPopulateSessions | IUpdateSelection | IUpdateSession | IPersistSession | IPersistSessionSuccess | IPersistSessionFailed | IDeleteSession | IDeleteSessionSuccess | IDeleteSessionFailed | IRestoreSession | ISetCurrentSession | ICreateSession | IEnter | ILeave export const SessionActions = { persistSessionSuccess: () => ({ type: "PERSIST_SESSION_SUCCESS" } as IPersistSessionSuccess), populateSessions: () => ({ type: "POPULATE_SESSIONS" } as IPopulateSessions), deleteSession: () => ({ type: "DELETE_SESSION" } as IDeleteSession), cancelCreating: () => ({ type: "CANCEL_NEW_SESSION" } as ICancelCreateSession), createSession: () => ({ type: "CREATE_SESSION" } as ICreateSession), updateCurrentSession: () => ({ type: "UPDATE_CURRENT_SESSION" } as IUpdateCurrentSession), deleteSessionSuccess: () => ({ type: "DELETE_SESSION_SUCCESS" } as IDeleteSessionSuccess), updateSession: (session: ISession) => ({ type: "UPDATE_SESSION", session } as IUpdateSession), setCurrentSession: (session: ISession) => ({ type: "SET_CURRENT_SESSION", payload: { session } } as ISetCurrentSession), deleteSessionFailed: (error: Error) => ({ type: "DELETE_SESSION_FAILED", error } as IDeleteSessionFailed), persistSessionFailed: (error: Error) => ({ type: "PERSIST_SESSION_FAILED", error } as IPersistSessionFailed), updateSelection: (selected: string) => ({ type: "UPDATE_SELECTION", payload: { selected } } as IUpdateSelection), getAllSessions: (sessions: ISession[]) => ({ type: "GET_ALL_SESSIONS", payload: { sessions }, } as IUpdateMultipleSessions), persistSession: (sessionName: string) => ({ type: "PERSIST_SESSION", payload: { sessionName }, } as IPersistSession), restoreSessionError: (error: Error) => ({ type: "RESTORE_SESSION_ERROR", payload: { error }, } as IRestoreSessionError), restoreSession: (sessionName: string) => ({ type: "RESTORE_SESSION", payload: { sessionName }, } as IRestoreSession), } type SessionEpic = Epic export const persistSessionEpic: SessionEpic = (action$, store, { sessionManager }) => action$.pipe( ofType("PERSIST_SESSION"), auditTime(200), flatMap((action: IPersistSession) => { return from(sessionManager.persistSession(action.payload.sessionName)).pipe( flatMap(session => { return [ SessionActions.cancelCreating(), SessionActions.persistSessionSuccess(), SessionActions.setCurrentSession(session), SessionActions.populateSessions(), ] }), catchError(error => [SessionActions.persistSessionFailed(error)]), ) }), ) const updateCurrentSessionEpic: SessionEpic = (action$, store, { fs, sessionManager }) => { return action$.pipe( ofType("UPDATE_CURRENT_SESSION"), auditTime(200), flatMap(() => from(sessionManager.getCurrentSession()).pipe( filter(session => !!session), flatMap(currentSession => [SessionActions.persistSession(currentSession.name)]), catchError(error => [SessionActions.persistSessionFailed(error)]), ), ), ) } const deleteSessionEpic: SessionEpic = (action$, store, { fs, sessionManager }) => action$.pipe( ofType("DELETE_SESSION"), flatMap(() => { const { selected, currentSession } = store.getState() const sessionToDelete = selected || currentSession return from( fs .remove(sessionToDelete.file) .then(() => sessionManager.deleteSession(sessionToDelete.name)), ).pipe( flatMap(() => [ SessionActions.deleteSessionSuccess(), SessionActions.populateSessions(), ]), catchError(error => { return [SessionActions.deleteSessionFailed(error)] }), ) }), ) const restoreSessionEpic: SessionEpic = (action$, store, { sessionManager }) => action$.pipe( ofType("RESTORE_SESSION"), flatMap((action: IRestoreSession) => from(sessionManager.restoreSession(action.payload.sessionName)).pipe( flatMap(session => [ SessionActions.setCurrentSession(session), SessionActions.populateSessions(), ]), ), ), catchError(error => [SessionActions.restoreSessionError(error)]), ) export const fetchSessionsEpic: SessionEpic = (action$, store, { fs, sessionManager }) => action$.pipe( ofType("POPULATE_SESSIONS"), flatMap((action: IPopulateSessions) => { return from( fs.readdir(sessionManager.sessionsDir).then(async dir => { const metadata = await Promise.all( dir.map(async file => { const filepath = path.join(sessionManager.sessionsDir, file) // use fs.stat mtime to figure when last a file was modified const { mtime } = await fs.stat(filepath) const [name] = file.split(".") return { name, file: filepath, updatedAt: mtime.toUTCString(), } }), ) const sessions = Promise.all( metadata.map(async ({ file, name, updatedAt }) => { const savedSession = await sessionManager.getSessionFromStore(name) await sessionManager.updateOniSession(name, { updatedAt }) return { ...savedSession, updatedAt } }), ) return sessions }), ).flatMap(sessions => [SessionActions.getAllSessions(sessions)]) }), ) const findSelectedSession = (sessions: ISession[], selected: string) => sessions.find(session => session.id === selected) const updateSessions = (sessions: ISession[], newSession: ISession) => sessions.map(session => (session.id === newSession.id ? newSession : session)) function reducer(state: ISessionState, action: ISessionActions) { switch (action.type) { case "UPDATE_SESSION": return { ...state, sessions: updateSessions(state.sessions, action.payload.session), } case "GET_ALL_SESSIONS": return { ...state, sessions: action.payload.sessions, } case "CREATE_SESSION": return { ...state, creating: true, } case "DELETE_SESSION_SUCCESS": return { ...state, currentSession: null, } case "SET_CURRENT_SESSION": return { ...state, currentSession: action.payload.session, } case "CANCEL_NEW_SESSION": return { ...state, creating: false, } case "ENTER": return { ...state, active: true, } case "LEAVE": return { ...state, active: false, } case "UPDATE_SELECTION": return { ...state, selected: findSelectedSession(state.sessions, action.payload.selected), } default: return state } } interface Dependencies { fs: typeof fsExtra sessionManager: SessionManager } const createStore = (dependencies: Dependencies) => createReduxStore("sessions", reducer, DefaultState, [ createEpicMiddleware( combineEpics( fetchSessionsEpic, persistSessionEpic, restoreSessionEpic, updateCurrentSessionEpic, deleteSessionEpic, ), { dependencies }, ), ]) export default createStore ================================================ FILE: browser/src/Services/Sessions/index.ts ================================================ export * from "./SessionManager" export * from "./SessionsStore" export { default as SessionsPane } from "./SessionsPane" export { default as Sessions } from "./Sessions" export { default as store } from "./SessionsStore" ================================================ FILE: browser/src/Services/Sidebar/SidebarContentSplit.tsx ================================================ /** * SidebarContentSplit.tsx */ import * as React from "react" import { connect, Provider } from "react-redux" import styled, { keyframes } from "styled-components" import { Event, IDisposable, IEvent } from "oni-types" import { enableMouse, withProps } from "./../../UI/components/common" import { ISidebarEntry, ISidebarState, SidebarManager, SidebarPane } from "./SidebarStore" export const getActiveEntry = (state: ISidebarState): ISidebarEntry => { const filteredEntries = state.entries.filter(entry => entry.id === state.activeEntryId) const activeEntry = filteredEntries.length > 0 ? filteredEntries[0] : null return activeEntry } /** * Split that is the container for the active sidebar item */ export class SidebarContentSplit { private _onEnterEvent = new Event() private _onLeaveEvent = new Event() public get activePane(): SidebarPane { const entry = getActiveEntry(this._sidebarManager.store.getState()) return entry && entry.pane ? entry.pane : null } constructor(private _sidebarManager: SidebarManager) {} public enter(): void { const pane: any = this.activePane if (pane && pane.enter) { pane.enter() } this._onEnterEvent.dispatch() } public leave(): void { const pane: any = this.activePane if (pane && pane.leave) { pane.leave() } this._onLeaveEvent.dispatch() } public render(): JSX.Element { return ( ) } } export interface ISidebarContentViewProps extends ISidebarContentContainerProps { activeEntry: ISidebarEntry width: string } export interface ISidebarContentContainerProps { onEnter: IEvent onLeave: IEvent } export interface ISidebarContentViewState { active: boolean } const EntranceKeyframes = keyframes` 0% { opacity: 0.5; transform: translateX(-5px); } 100%% { opacity: 1; transform: translateX(0px); } ` export const SidebarContentWrapper = withProps<{}>(styled.div)` ${enableMouse} width: ${props => props.width}; color: ${props => props.theme["editor.foreground"]}; background-color: ${props => props.theme["editor.background"]}; height: 100%; user-select: none; cursor: default; animation: ${EntranceKeyframes} 0.25s ease-in forwards; display: flex; flex-direction: column; ` export interface ISidebarHeaderProps { // True if the pane has focus, false otherwise hasFocus: boolean headerName: string } export const SidebarHeaderWrapper = withProps(styled.div)` height: 2.5em; line-height: 2.5em; text-align: center; border-top: ${props => props.hasFocus ? "2px solid " + props.theme["highlight.mode.normal.background"] : "2px solid transparent"}; flex: 0 0 auto; ` export class SidebarHeaderView extends React.PureComponent { public render(): JSX.Element { return ( {this.props.headerName} ) } } export const SidebarInnerPaneWrapper = withProps<{}>(styled.div)` flex: 1 1 auto; position: relative; height: 100%; ` export class SidebarContentView extends React.PureComponent< ISidebarContentViewProps, ISidebarContentViewState > { private _subs: IDisposable[] = [] constructor(props: ISidebarContentViewProps) { super(props) this.state = { active: false, } } public componentDidMount(): void { this._cleanSubscriptions() const s1 = this.props.onEnter.subscribe(() => this.setState({ active: true, }), ) const s2 = this.props.onLeave.subscribe(() => this.setState({ active: false, }), ) this._subs = [s1, s2] } public componentWillUnmount(): void { this._cleanSubscriptions() } public render(): JSX.Element { if (!this.props.activeEntry) { return null } const activeEntry = this.props.activeEntry const header = activeEntry && activeEntry.pane ? activeEntry.pane.title : null return ( {activeEntry.pane.render()} ) } private _cleanSubscriptions(): void { this._subs.forEach(s => s.dispose()) this._subs = [] } } export const mapStateToProps = ( state: ISidebarState, containerProps: ISidebarContentContainerProps, ): ISidebarContentViewProps => { const activeEntry = getActiveEntry(state) return { ...containerProps, activeEntry, width: state.width, } } export const SidebarContent = connect(mapStateToProps)(SidebarContentView) ================================================ FILE: browser/src/Services/Sidebar/SidebarSplit.tsx ================================================ /** * UI/index.tsx * * Root setup & state for the UI * - Top-level render function lives here */ import * as React from "react" import { Provider } from "react-redux" import { SidebarManager } from "./SidebarStore" import { Sidebar } from "./SidebarView" export class SidebarSplit { constructor(private _sidebarManager: SidebarManager) {} public enter(): void { this._sidebarManager.setActiveEntry(this._sidebarManager.activeEntryId) this._sidebarManager.enter() } public leave(): void { this._sidebarManager.setActiveEntry(null) this._sidebarManager.leave() } public render(): JSX.Element { return ( this._onSelectionChanged(val)} /> ) } private _onSelectionChanged(newVal: string): void { this._sidebarManager.setActiveEntry(newVal) } } ================================================ FILE: browser/src/Services/Sidebar/SidebarStore.ts ================================================ /** * SidebarStore.ts * * State management for the sidebar split */ import { Reducer, Store } from "redux" import { createStore as createReduxStore } from "./../../Redux" import { Configuration } from "../Configuration" import { DefaultConfiguration } from "../Configuration/DefaultConfiguration" import { WindowManager, WindowSplitHandle } from "./../WindowManager" import { SidebarContentSplit } from "./SidebarContentSplit" import { SidebarSplit } from "./SidebarSplit" import * as Oni from "oni-api" export interface ISidebarState { entries: ISidebarEntry[] // Active means that the tab is currently selected activeEntryId: string isActive: boolean width: string } export type SidebarIcon = string export interface ISidebarEntry { // TODO: Remove this, duplicated between here and `SidebarPane` id: string icon: SidebarIcon pane: SidebarPane hasNotification?: boolean } export interface SidebarPane extends Oni.IWindowSplit { id: string title: string enter(): void leave(): void } export class SidebarManager { private _store: Store private _iconSplit: WindowSplitHandle private _contentSplit: WindowSplitHandle public get activeEntryId(): string { return this._store.getState().activeEntryId } public get entries(): ISidebarEntry[] { return this._store.getState().entries } get isFocused(): boolean { return this._contentSplit.isFocused } get isVisible(): boolean { return this._contentSplit.isVisible } public get store(): Store { return this._store } constructor( private _windowManager: WindowManager = null, private _configuration: Configuration, ) { this._store = createStore() this._configuration.onConfigurationChanged.subscribe(val => { if (typeof val["sidebar.width"] === "string") { this.setWidth(val["sidebar.width"]) } }) this.setWidth(this._configuration.getValue("sidebar.width")) this._iconSplit = this._windowManager.createSplit("left", new SidebarSplit(this)) this._contentSplit = this._windowManager.createSplit("left", new SidebarContentSplit(this)) } public increaseWidth(): void { if (this._contentSplit.isVisible) { this.store.dispatch({ type: "INCREASE_WIDTH" }) } } public decreaseWidth(): void { if (this._contentSplit.isVisible) { this.store.dispatch({ type: "DECREASE_WIDTH" }) } } public setWidth(width: string): void { if (width) { this._store.dispatch({ type: "SET_WIDTH", width, }) } } public setNotification(id: string): void { if (id) { this._store.dispatch({ type: "SET_NOTIFICATION", id, }) } } public setActiveEntry(id: string): void { if (id) { const oldId = this._store.getState().activeEntryId this._store.dispatch({ type: "SET_ACTIVE_ID", activeEntryId: id, }) if (oldId !== id) { this._contentSplit.show() } else if (!this._contentSplit.isVisible) { this._contentSplit.show() } else if (this._contentSplit.isVisible) { this._contentSplit.hide() } } } public focusContents(): void { if (this._contentSplit.isVisible) { this._contentSplit.focus() } } public toggleSidebarVisibility(): void { if (this._contentSplit.isVisible) { this._contentSplit.hide() if (this._contentSplit.isFocused) { this._iconSplit.focus() } } else { this._contentSplit.show() } } public toggleVisibilityById(id: string): void { if (id) { if (id !== this.activeEntryId) { this._store.dispatch({ type: "SET_ACTIVE_ID", activeEntryId: id, }) this._contentSplit.show() this._contentSplit.focus() } else { if (this._contentSplit.isVisible) { this._contentSplit.hide() } else { // In some cases you can have an ACTIVE entry that is hidden this._contentSplit.show() this._contentSplit.focus() } } } } public enter(): void { this._store.dispatch({ type: "ENTER" }) } public leave(): void { this._store.dispatch({ type: "LEAVE" }) } public add(icon: SidebarIcon, pane: SidebarPane): void { const entry = { id: pane.id, icon, pane, } this._store.dispatch({ type: "ADD_ENTRY", entry, }) } public hide(): void { this._contentSplit.hide() this._iconSplit.hide() } } const DefaultSidebarState: ISidebarState = { entries: [], activeEntryId: null, isActive: false, width: null, } export type SidebarActions = | { type: "SET_ACTIVE_ID" activeEntryId: string } | { type: "ADD_ENTRY" entry: ISidebarEntry } | { type: "SET_NOTIFICATION" id: string } | { type: "SET_WIDTH" width: string } | { type: "ENTER" } | { type: "LEAVE" } | { type: "INCREASE_WIDTH" } | { type: "DECREASE_WIDTH" } export const changeSize = (change: "increase" | "decrease") => ( size: string, defaultValue = DefaultConfiguration["sidebar.width"], ): string => { const [numberString, letters = "em"] = size.match(/[a-zA-Z]+|[0-9]+/g) const isAllowedUnit = ["em", "px", "vw"].includes(letters) const unitsToUse = isAllowedUnit ? letters : "em" const convertedNumber = Number(numberString) if (isNaN(convertedNumber)) { return defaultValue } // If too small don't allow a decrease and vice versa const tooSmall = convertedNumber - 1 < 1 && change === "decrease" const tooBig = convertedNumber + 1 > 50 && change === "increase" const changed = tooBig || tooSmall ? convertedNumber : change === "increase" ? convertedNumber + 1 : convertedNumber - 1 return `${changed}${unitsToUse}` } export const increaseWidth = changeSize("increase") export const decreaseWidth = changeSize("decrease") export const sidebarReducer: Reducer = ( state: ISidebarState = DefaultSidebarState, action: SidebarActions, ) => { const newState = { ...state, entries: entriesReducer(state.entries, action), } switch (action.type) { case "ENTER": return { ...newState, isActive: true, } case "LEAVE": return { ...newState, isActive: false, } case "SET_WIDTH": return { ...newState, width: action.width, } case "SET_ACTIVE_ID": return { ...newState, activeEntryId: action.activeEntryId, } case "ADD_ENTRY": if (!state.activeEntryId) { return { ...newState, activeEntryId: action.entry.pane.id, } } else { return newState } case "DECREASE_WIDTH": return { ...newState, width: decreaseWidth(newState.width), } case "INCREASE_WIDTH": return { ...newState, width: increaseWidth(newState.width), } default: return newState } } export const entriesReducer: Reducer = ( state: ISidebarEntry[] = [], action: SidebarActions, ) => { 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 } } export const createStore = (): Store => { return createReduxStore("Sidebar", sidebarReducer, DefaultSidebarState) } ================================================ FILE: browser/src/Services/Sidebar/SidebarView.tsx ================================================ /** * SidebarView.tsx * * View component for the sidebar */ import * as React from "react" import { connect } from "react-redux" import { Icon, IconSize } from "./../../UI/Icon" import { ISidebarEntry, ISidebarState } from "./SidebarStore" import styled, { keyframes } from "styled-components" import { withProps } from "./../../UI/components/common" import { Sneakable } from "./../../UI/components/Sneakable" export interface ISidebarIconProps { id: string active: boolean focused: boolean iconName: string hasNotification: boolean onClick: () => void } import { VimNavigator } from "./../../UI/components/VimNavigator" const EntranceKeyframes = keyframes` 0% { opacity: 0.5; transform: scale(0.5) translateX(-10px); } 100%% { opacity: 1; transform: scale(1.0) translateX(0px); } ` const SidebarIconWrapper = withProps(styled.div)` position: relative; display: flex; justify-content: center; align-items: center; opacity: 0.5; outline: none; cursor: pointer; opacity: ${props => (props.active ? 0.9 : 0.75)}; border-left: 2px solid ${props => props.focused ? props.theme["sidebar.selection.border"] : "transparent"}; background-color: ${props => props.active ? props.theme["editor.background"] : props.theme.background}; transition: transform 0.2s ease-in; transform: ${props => (props.active || props.focused ? "translateY(0px)" : "translateY(0px)")}; animation: ${EntranceKeyframes} 0.1s ease-in forwards; &.active { opacity: 0.75; } &:hover { transform: translateY(0px); opacity: 0.9; } ` 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; ` export class SidebarIcon extends React.PureComponent { public render(): JSX.Element { const notification = this.props.hasNotification ? : null return ( {notification} ) } } export interface ISidebarViewProps extends ISidebarContainerProps { width: string visible: boolean entries: ISidebarEntry[] activeEntryId: string isActive: boolean } export interface ISidebarContainerProps { onSelectionChanged: (selectedId: string) => void } export interface ISidebarWrapperProps { width: string isActive: boolean } const SidebarWrapper = withProps(styled.div)` pointer-events: auto; display: flex; flex-direction: column; border-top: ${props => props.isActive ? "2px solid " + props.theme["highlight.mode.normal.background"] : "2px solid " + props.theme["editor.background"]}; color: ${props => props.theme["sidebar.foreground"]}; width: ${props => props.width}; ` export class SidebarView extends React.PureComponent { public render(): JSX.Element { if (!this.props.visible) { return null } const ids = this.props.entries.map(e => e.id) return ( this.props.onSelectionChanged(val)} render={(selectedId: string): JSX.Element => { const items = this.props.entries.map(e => { const isActive = e.id === this.props.activeEntryId const isFocused = e.id === selectedId && this.props.isActive return ( this.props.onSelectionChanged(e.id)} /> ) }) return
    {items}
    }} />
    ) } } export const mapStateToProps = ( state: ISidebarState, containerProps: ISidebarContainerProps, ): ISidebarViewProps => { return { ...containerProps, entries: state.entries, activeEntryId: state.activeEntryId, isActive: state.isActive, visible: true, width: "50px", } } export const Sidebar = connect(mapStateToProps)(SidebarView) ================================================ FILE: browser/src/Services/Sidebar/index.ts ================================================ import { commandManager } from "./../../Services/CommandManager" import { Configuration } from "./../../Services/Configuration" import { windowManager } from "./../../Services/WindowManager" import { Workspace } from "./../../Services/Workspace" import { SidebarManager } from "./SidebarStore" let _sidebarManager: SidebarManager = null export * from "./SidebarStore" export const activate = (configuration: Configuration, workspace: Workspace) => { // Always create the sidebar to prevent issues. If its disabled, just hide it. // See #1562 for more information. _sidebarManager = new SidebarManager(windowManager, configuration) const sideBarEnabled = configuration.getValue("sidebar.enabled") if (!sideBarEnabled) { _sidebarManager.hide() } if (sideBarEnabled && !configuration.getValue("sidebar.default.open")) { _sidebarManager.toggleSidebarVisibility() } commandManager.registerCommand({ command: "sidebar.increaseWidth", name: "Sidebar: Increase Width", detail: "Increase the width of the sidebar pane", execute: () => _sidebarManager.increaseWidth(), }) commandManager.registerCommand({ command: "sidebar.decreaseWidth", name: "Sidebar: Decrease Width", detail: "Decrease the width of the sidebar pane", execute: () => _sidebarManager.decreaseWidth(), }) commandManager.registerCommand({ command: "sidebar.toggle", name: "Sidebar: Toggle", detail: "Show / hide the contents of the sidebar pane.", execute: () => _sidebarManager.toggleSidebarVisibility(), }) } export const getInstance = (): SidebarManager => _sidebarManager ================================================ FILE: browser/src/Services/Sneak/Sneak.tsx ================================================ /** * Sneak.tsx * * Provides the 'sneak layer' UI */ import * as React from "react" import { Provider } from "react-redux" import { Store } from "redux" import { Event, IDisposable, IEvent } from "oni-types" import { Overlay, OverlayManager } from "./../Overlay" import { createStore as createSneakStore, IAugmentedSneakInfo, ISneakInfo, ISneakState, } from "./SneakStore" import { ConnectedSneakView } from "./SneakView" export type SneakProvider = () => Promise 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() } public get isActive(): boolean { return !!this._activeOverlay } public addSneakProvider(provider: SneakProvider): IDisposable { this._providers.push(provider) const dispose = () => (this._providers = this._providers.filter(prov => prov !== provider)) return { dispose } } // Get the first sneak with a 'tag' matching the passed in tag public getSneakMatchingTag(tag: string): IAugmentedSneakInfo | null { if (!this.isActive) { return null } const sneaks = this._store.getState().sneaks if (!sneaks || sneaks.length === 0) { return null } return sneaks.find(s => s.tag && s.tag === tag) } public show(): void { if (this._activeOverlay) { this._activeOverlay.hide() this._activeOverlay = null } this._store.dispatch({ type: "START", width: document.body.offsetWidth, height: document.body.offsetHeight, }) this._collectSneakRectangles() this._activeOverlay = this._overlayManager.createItem() this._activeOverlay.setContents( this._onComplete(info)} /> , ) this._activeOverlay.show() } public close(): void { if (this._activeOverlay) { this._store.dispatch({ type: "END" }) this._activeOverlay.hide() this._activeOverlay = null } } private _onComplete(sneakInfo: ISneakInfo): void { this.close() sneakInfo.callback() this._onSneakCompleted.dispatch(sneakInfo) } private _collectSneakRectangles(): void { this._providers.forEach(async provider => { const sneaks = await provider() const normalizedSneaks = sneaks.filter(s => !!s) this._store.dispatch({ type: "ADD_SNEAKS", sneaks: normalizedSneaks, }) }) } } ================================================ FILE: browser/src/Services/Sneak/SneakStore.ts ================================================ /** * SneakStore.ts * * State management for Sneaks */ import { Reducer, Store } from "redux" import { Shapes } from "oni-api" import { createStore as createReduxStore } from "./../../Redux" export interface ISneakInfo { rectangle: Shapes.Rectangle callback: () => void // `tag` is an optional string used to identify the sneak tag?: string } export interface IAugmentedSneakInfo extends ISneakInfo { triggerKeys: string } 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" } | { type: "ADD_SNEAKS" sneaks: ISneakInfo[] } export const sneakReducer: Reducer = ( state: ISneakState = DefaultSneakState, action: SneakAction, ) => { switch (action.type) { case "START": return { ...DefaultSneakState, width: action.width, height: action.height, } case "END": return { ...DefaultSneakState, isActive: false, } case "ADD_SNEAKS": if (!state.isActive) { return state } 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), } }) return { ...state, sneaks: [...state.sneaks, ...newSneaks], } default: return state } } export const getLabelFromIndex = (index: number): string => { const firstDigit = Math.floor(index / 26) const secondDigit = index - firstDigit * 26 return String.fromCharCode(97 + firstDigit, 97 + secondDigit).toUpperCase() } export const createStore = (): Store => { return createReduxStore("Sneaks", sneakReducer, DefaultSneakState) } ================================================ FILE: browser/src/Services/Sneak/SneakView.tsx ================================================ /** * SneakView.tsx * * UX for the sneak functionality */ import * as React from "react" import { connect } from "react-redux" import { boxShadow, OverlayWrapper } from "./../../UI/components/common" import { TextInputView } from "./../../UI/components/LightweightText" import { IAugmentedSneakInfo, ISneakInfo, ISneakState } from "./SneakStore" export interface ISneakContainerProps { onComplete: (sneakInfo: ISneakInfo) => void } export interface ISneakViewProps extends ISneakContainerProps { sneaks: IAugmentedSneakInfo[] } export interface ISneakViewState { filterText: string } // Render a keyboard input? // Grab input while 'sneaking'? export class SneakView extends React.PureComponent { constructor(props: ISneakViewProps) { super(props) this.state = { filterText: "", } } public render(): JSX.Element { const normalizedFilterText = this.state.filterText.toUpperCase() const filteredSneaks = this.props.sneaks.filter( sneak => sneak.triggerKeys.indexOf(normalizedFilterText) === 0, ) const sneaks = filteredSneaks.map(si => ( )) if (filteredSneaks.length === 1) { this.props.onComplete(filteredSneaks[0]) } return (
    { this.setState({ filterText: evt.currentTarget.value }) }} />
    {sneaks}
    ) } } export interface ISneakItemViewProps { sneak: IAugmentedSneakInfo filterLength: number } import styled, { keyframes } from "styled-components" const SneakEnterKeyFrames = keyframes` 0% { opacity: 0; transform: scale(0.9) translateY(-5px) rotateX(-70deg); } 100%% { opacity: 1; transform: scale(1.0) translateY(0px) rotateX(0deg); } ` const SneakItemWrapper = styled.div` ${boxShadow} background-color: ${props => props.theme["highlight.mode.visual.background"]}; color: ${props => props.theme["highlight.mode.visual.foreground"]}; animation: ${SneakEnterKeyFrames} 0.2s ease-in; text-align: center; ` const SneakItemViewSize = 22 const px = (num: number): string => num.toString() + "px" export class SneakItemView extends React.PureComponent { public render(): JSX.Element { const style: React.CSSProperties = { position: "absolute", left: px(this.props.sneak.rectangle.x), top: px(this.props.sneak.rectangle.y), width: px(SneakItemViewSize), height: px(SneakItemViewSize), } return ( {this.props.sneak.triggerKeys.substring(0, this.props.filterLength)} {this.props.sneak.triggerKeys.substring( this.props.filterLength, this.props.sneak.triggerKeys.length, )} ) } } const mapStateToProps = ( state: ISneakState, containerProps?: ISneakContainerProps, ): ISneakViewProps => { return { ...containerProps, sneaks: state.sneaks || [], } } export const ConnectedSneakView = connect(mapStateToProps)(SneakView) ================================================ FILE: browser/src/Services/Sneak/index.tsx ================================================ /** * Sneak/index.tsx * * 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" export * from "./SneakStore" let _sneak: Sneak export const activate = ( colors: Colors, commandManager: CommandManager, configuration: Configuration, overlayManager: OverlayManager, ) => { _sneak = new Sneak(overlayManager) commandManager.registerCommand( new CallbackCommand( "sneak.show", "Sneak: Current Window", "Show commands for current window", () => { _sneak.show() }, ), ) commandManager.registerCommand( new CallbackCommand( "sneak.hide", "Sneak: Hide", "Hide sneak view", () => _sneak.close(), () => _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 => { return _sneak } ================================================ FILE: browser/src/Services/Snippets/OniSnippet.ts ================================================ /** * OniSnippet.ts * * Wrapper around `TextmateSnippet`. There are some differences in behavior * due to differences in editor behavior, for Oni/Neovim, we need to * get the snippet split by new lines, with placeholders per line/character * instead of by offset. */ import * as Snippets from "vscode-snippet-parser/lib" import { normalizeNewLines } from "./../../Utility" export type VariableResolver = Snippets.VariableResolver export type Variable = Snippets.Variable export interface OniSnippetPlaceholder { index: number // Zero-based line relative to the start of the snippet line: number // Zero-based start character character: number value: string isFinalTabstop: boolean } export const getLineCharacterFromOffset = ( offset: number, lines: string[], ): { line: number; character: number } => { let idx = 0 let currentOffset = 0 while (idx < lines.length) { if (offset >= currentOffset && offset <= currentOffset + lines[idx].length) { return { line: idx, character: offset - currentOffset } } currentOffset += lines[idx].length + 1 idx++ } return { line: -1, character: -1 } } export class OniSnippet { private _parser: Snippets.SnippetParser = new Snippets.SnippetParser() private _placeholderValues: { [index: number]: string } = {} private _snippetString: string constructor(snippet: string, private _variableResolver?: VariableResolver) { this._snippetString = normalizeNewLines(snippet) } public setPlaceholder(index: number, newValue: string): void { this._placeholderValues[index] = newValue } public getPlaceholderValue(index: number): string { return this._placeholderValues[index] || "" } public getPlaceholders(): OniSnippetPlaceholder[] { const snippet = this._getSnippetWithFilledPlaceholders() const placeholders = snippet.placeholders const lines = this.getLines() const oniPlaceholders = placeholders.map(p => { const offset = snippet.offset(p) const position = getLineCharacterFromOffset(offset, lines) return { ...position, index: p.index, value: p.toString(), isFinalTabstop: p.isFinalTabstop, } }) return oniPlaceholders } public getLines(): string[] { const normalizedSnippetString = this._getNormalizedSnippet() return normalizedSnippetString.split("\n") } private _getSnippetWithFilledPlaceholders(): Snippets.TextmateSnippet { const snippet = this._parser.parse(this._snippetString) if (this._variableResolver) { snippet.resolveVariables(this._variableResolver) } Object.keys(this._placeholderValues).forEach((key: string) => { const val = this._placeholderValues[key] const snip = this._parser.parse(val) const placeholderToReplace = snippet.placeholders.filter( p => p.index.toString() === key, ) placeholderToReplace.forEach(rep => { const placeHolder = new Snippets.Placeholder(rep.index) placeHolder.appendChild(snip) snippet.replace(rep, [placeHolder]) }) }) return snippet } private _getNormalizedSnippet(): string { const snippetString = this._getSnippetWithFilledPlaceholders().toString() const normalizedSnippetString = snippetString.replace("\r\n", "\n") return normalizedSnippetString } } ================================================ FILE: browser/src/Services/Snippets/SnippetBufferLayer.tsx ================================================ /** * SnippetBufferLayer.tsx * * UX for the Snippet functionality, implemented as a buffer layer */ import * as React from "react" import styled, { keyframes } from "styled-components" import * as Oni from "oni-api" import { IDisposable } from "oni-types" import * as types from "vscode-languageserver-types" import { SnippetSession } from "./SnippetSession" import { withProps } from "./../../UI/components/common" export class SnippetBufferLayer implements Oni.BufferLayer { constructor(private _buffer: Oni.Buffer, private _snippetSession: SnippetSession) { this._buffer.addLayer(this) } public get id(): string { return "oni.layers.snippet" } public get friendlyName(): string { return "Snippet" } public render(context: Oni.BufferLayerRenderContext): JSX.Element { return } public dispose(): void { if (this._buffer) { ;(this._buffer as any).removeLayer(this) this._buffer = null this._snippetSession = null } } } export interface ISnippetBufferLayerViewProps { context: Oni.BufferLayerRenderContext snippetSession: SnippetSession } const EntranceKeyFrames = keyframes` 0% { opacity: 0; } 100% { opacity: 1; } ` const NonSnippetOverlayTop = styled.div` animation: ${EntranceKeyFrames} 0.2s ease-in; background-color: rgba(0, 0, 0, 0.1); box-shadow: inset 0 -5px 10px rgba(0, 0, 0, 0.2); ` const NonSnippetOverlayBottom = styled.div` animation: ${EntranceKeyFrames} 0.2s ease-in; background-color: rgba(0, 0, 0, 0.1); box-shadow: inset 0 5px 10px rgba(0, 0, 0, 0.2); ` const CursorWrapper = withProps<{}>(styled.div)` position: absolute; background-color: ${props => props.theme["editor.foreground"]}; ` export interface ISnippetBufferLayerViewState { mode: Oni.Vim.Mode cursors: types.Range[] } export class SnippetBufferLayerView extends React.PureComponent< ISnippetBufferLayerViewProps, ISnippetBufferLayerViewState > { private _disposables: IDisposable[] = [] constructor(props: ISnippetBufferLayerViewProps) { super(props) const latestCursorState = props.snippetSession.getLatestCursors() this.state = { mode: latestCursorState.mode, cursors: latestCursorState.cursors, } } public componentDidMount(): void { this._cleanup() const s1 = this.props.snippetSession.onCursorMoved.subscribe(p => { this.setState({ mode: p.mode, cursors: p.cursors, }) }) this._disposables = [s1] } public componentWillUnmount(): void { this._cleanup() } public render(): JSX.Element { if (!this.props.context.screenToPixel || !this.props.context.bufferToScreen) { return null } const fullScreenSize = this.props.context.dimensions // Get screen size in pixel space const fullSizeInPixels = this.props.context.screenToPixel({ screenX: fullScreenSize.width, screenY: fullScreenSize.height, }) const snippetStartPosition = this.props.snippetSession.position.line const snippetEndPosition = this.props.snippetSession.position.line + this.props.snippetSession.lines.length const startScreenPosition = this.props.context.bufferToScreen( types.Position.create(snippetStartPosition, 0), ) const endScreenPosition = this.props.context.bufferToScreen( types.Position.create(snippetEndPosition, 0), ) if (!startScreenPosition || !endScreenPosition) { return null } const startPositionInPixels = this.props.context.screenToPixel(startScreenPosition) const endPositionInPixels = this.props.context.screenToPixel(endScreenPosition) const topOverlay: React.CSSProperties = { position: "absolute", top: "0px", left: "0px", right: "0px", height: startPositionInPixels.pixelY.toString() + "px", } const bottomOverlay: React.CSSProperties = { position: "absolute", height: (fullSizeInPixels.pixelY - endPositionInPixels.pixelY).toString() + "px", left: "0px", bottom: "0px", right: "0px", } const cursors = this.state.cursors.map(c => { const pos = this.props.context.screenToPixel(this.props.context.bufferToScreen(c.start)) const size = this.props.context.screenToPixel(this.props.context.bufferToScreen(c.end)) const style: React.CSSProperties = { top: pos.pixelY.toString() + "px", left: pos.pixelX.toString() + "px", width: this.state.mode === "visual" ? (size.pixelX - pos.pixelX).toString() + "px" : "2px", opacity: this.state.mode === "visual" ? 0.2 : 0.8, // TODO: Add 'fontPixelWidth' and 'fontPixelHeight' as API methods height: (this.props.context as any).fontPixelHeight.toString() + "px", } return }) return (
    {cursors}
    ) } private _cleanup(): void { this._disposables.forEach(d => d.dispose()) this._disposables = [] } } ================================================ FILE: browser/src/Services/Snippets/SnippetCompletionProvider.ts ================================================ /** * SnippetCompletionProvider.ts * * Integrates snippets with completion provider */ import * as Oni from "oni-api" import * as Log from "oni-core-logging" import * as types from "vscode-languageserver-types" import { CompletionsRequestContext, ICompletionsRequestor } from "./../Completion" import { SnippetManager } from "./SnippetManager" export const convertSnippetToCompletionItem = ( snippet: Oni.Snippets.Snippet, ): types.CompletionItem => ({ insertTextFormat: types.InsertTextFormat.Snippet, insertText: snippet.body, label: snippet.prefix + " (snippet)", detail: snippet.description, documentation: snippet.body, kind: types.CompletionItemKind.Snippet, }) export class SnippetCompletionProvider implements ICompletionsRequestor { constructor(private _snippetManager: SnippetManager) {} public async getCompletions( context: CompletionsRequestContext, ): Promise { Log.verbose("[SnippetCompletionProvider::getCompletions] Starting...") if (!context.meetCharacter) { return [] } const commentsOrQuotedStrings = context.textMateScopes.filter( f => f.indexOf("comment.") === 0 || f.indexOf("string.quoted.") === 0, ) if (commentsOrQuotedStrings.length) { return [] } const snippets = await this._snippetManager.getSnippetsForLanguage(context.language) Log.verbose( "[SnippetCompletionProvider::getCompletions] Got " + snippets.length + " snippets.", ) const items = snippets.map(convertSnippetToCompletionItem) return items } public async getCompletionDetails( fileLanguage: string, fielPath: string, completionItem: types.CompletionItem, ): Promise { return completionItem } } ================================================ FILE: browser/src/Services/Snippets/SnippetManager.ts ================================================ /** * Snippets.ts * * Manages snippet integration */ import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { IDisposable } from "oni-types" import "rxjs/add/operator/auditTime" import { Subject } from "rxjs/Subject" import { Configuration } from "./../Configuration" import { EditorManager } from "./../EditorManager" import { SnippetBufferLayer } from "./SnippetBufferLayer" import { CompositeSnippetProvider } from "./SnippetProvider" import { SnippetSession } from "./SnippetSession" export class SnippetManager implements Oni.Snippets.SnippetManager { private _activeSession: SnippetSession private _disposables: IDisposable[] = [] private _currentLayer: SnippetBufferLayer = null private _snippetProvider: CompositeSnippetProvider private _synchronizeSnippetObservable: Subject = new Subject() public get isSnippetActive(): boolean { return !!this._activeSession } constructor(private _configuration: Configuration, private _editorManager: EditorManager) { this._snippetProvider = new CompositeSnippetProvider(this._configuration) this._synchronizeSnippetObservable.auditTime(50).subscribe(() => { const activeEditor = this._editorManager.activeEditor as any const activeSession = this._activeSession if (activeEditor && activeSession) { activeEditor.blockInput(() => activeSession.synchronizeUpdatedPlaceholders()) } }) } public async getSnippetsForLanguage(language: string): Promise { return this._snippetProvider.getSnippets(language) } public registerSnippetProvider(snippetProvider: Oni.Snippets.SnippetProvider): void { this._snippetProvider.registerProvider(snippetProvider) } /** * Inserts snippet in the active editor, at current cursor position */ public async insertSnippet(snippet: string): Promise { this.cancel() Log.info("[SnippetManager::insertSnippet]") const activeEditor = this._editorManager.activeEditor as any const snippetSession = new SnippetSession(activeEditor as any, snippet) await snippetSession.start() const buffer = this._editorManager.activeEditor.activeBuffer this._currentLayer = new SnippetBufferLayer(buffer, snippetSession) const s1 = activeEditor.onCursorMoved.subscribe(() => { if (this.isSnippetActive) { this._activeSession.updateCursorPosition() } }) const s2 = activeEditor.onModeChanged.subscribe(() => { if (this.isSnippetActive) { this._activeSession.updateCursorPosition() } }) const s3 = activeEditor.onBufferChanged.subscribe(() => { this._synchronizeSnippetObservable.next() }) const s4 = snippetSession.onCancel.subscribe(() => { this.cancel() }) this._disposables = [s1, s2, s3, s4] this._activeSession = snippetSession } public async nextPlaceholder(): Promise { if (this.isSnippetActive) { return this._activeSession.nextPlaceholder() } } public async previousPlaceholder(): Promise { if (this.isSnippetActive) { return this._activeSession.previousPlaceholder() } } public async cancel(): Promise { if (this._activeSession) { this._cleanupAfterSession() await (this._editorManager.activeEditor as any).clearSelection() // TODO: Add 'stopInsert' and 'startInsert' methods on editor await this._editorManager.activeEditor.neovim.command("stopinsert") } if (this._currentLayer) { this._currentLayer.dispose() } } private _cleanupAfterSession(): void { Log.info("[SnippetManager::cancel]") this._disposables.forEach(d => d.dispose()) this._disposables = [] this._activeSession = null } } ================================================ FILE: browser/src/Services/Snippets/SnippetProvider.ts ================================================ /** * SnippetProvider.ts * * Manages snippet integration */ import * as fs from "fs" import * as os from "os" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { PluginManager } from "./../../Plugins/PluginManager" import { Configuration } from "./../Configuration" import * as Utility from "./../../Utility" export class CompositeSnippetProvider implements Oni.Snippets.SnippetProvider { private _providers: Oni.Snippets.SnippetProvider[] = [] constructor(private _configuration: Configuration) {} public registerProvider(provider: Oni.Snippets.SnippetProvider): void { this._providers.push(provider) } public async getSnippets(language: string): Promise { if (!this._configuration.getValue("snippets.enabled")) { return [] } const snippets = this._providers.map(p => p.getSnippets(language)) const allSnippets = await Promise.all(snippets) return allSnippets.reduce((prev, cur) => { return [...prev, ...cur] }, []) } } export interface ISnippetPluginContribution { prefix: string body: string[] description: string } export class PluginSnippetProvider implements Oni.Snippets.SnippetProvider { private _snippetCache: { [language: string]: Oni.Snippets.Snippet[] } = {} constructor(private _pluginManager: PluginManager) {} public async getSnippets(language: string): Promise { // If we have existing snippets, we'll use those... const currentSnippets = this._snippetCache[language] if (currentSnippets) { return currentSnippets } // Otherwise, we need to discover snippets const filteredPlugins = this._pluginManager.plugins.filter( p => p.metadata && p.metadata.contributes && p.metadata.contributes.snippets, ) const snippets = Utility.flatMap( filteredPlugins, pc => pc.metadata.contributes.snippets, ).filter(s => s.language === language) const snippetLoadPromises = snippets.map(s => this._loadSnippetsFromFile(s.path)) const loadedSnippets = await Promise.all(snippetLoadPromises) const flattenedSnippets = loadedSnippets.reduce( (x: Oni.Snippets.Snippet[], y: Oni.Snippets.Snippet[]) => [...x, ...y], [], ) this._snippetCache[language] = flattenedSnippets return flattenedSnippets } private async _loadSnippetsFromFile(snippetFilePath: string): Promise { return loadSnippetsFromFile(snippetFilePath) } } export const loadSnippetsFromFile = async ( snippetFilePath: string, ): Promise => { Log.verbose("[loadSnippetsFromFile] Trying to load snippets from: " + snippetFilePath) const contents = await new Promise((resolve, reject) => { fs.readFile(snippetFilePath, "utf8", (err, data) => { if (err) { reject(err) return } resolve(data) }) }) const snippets = loadSnippetsFromText(contents) Log.verbose( `[loadSnippetsFromFile] - Loaded ${snippets.length} snippets from ${snippetFilePath}`, ) return snippets } interface KeyToSnippet { [key: string]: ISnippetPluginContribution } export const loadSnippetsFromText = (contents: string): Oni.Snippets.Snippet[] => { let snippets: ISnippetPluginContribution[] = [] try { const snippetObject = Utility.parseJson5(contents) snippets = Object.values(snippetObject) } catch (ex) { Log.error(ex) snippets = [] } const normalizedSnippets = snippets.map( (snip: ISnippetPluginContribution): Oni.Snippets.Snippet => { return { prefix: snip.prefix, description: snip.description, body: typeof snip.body === "string" ? snip.body : snip.body.join(os.EOL), } }, ) return normalizedSnippets } ================================================ FILE: browser/src/Services/Snippets/SnippetSession.ts ================================================ /** * Snippets.ts * * Manages snippet integration */ import * as detectIndent from "detect-indent" import * as types from "vscode-languageserver-types" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { Event, IEvent } from "oni-types" import { OniSnippet, OniSnippetPlaceholder } from "./OniSnippet" import { BufferIndentationInfo, IBuffer } from "./../../Editor/BufferManager" import { SnippetVariableResolver } from "./SnippetVariableResolver" export const splitLineAtPosition = (line: string, position: number): [string, string] => { const prefix = line.substring(0, position) const post = line.substring(position, line.length) return [prefix, post] } export const getFirstPlaceholder = ( placeholders: OniSnippetPlaceholder[], ): OniSnippetPlaceholder => { return placeholders.reduce((prev: OniSnippetPlaceholder, curr: OniSnippetPlaceholder) => { if (!prev || prev.isFinalTabstop) { return curr } if (curr.index < prev.index && !curr.isFinalTabstop) { return curr } return prev }, null) } export const getPlaceholderByIndex = ( placeholders: OniSnippetPlaceholder[], index: number, ): OniSnippetPlaceholder | null => { const matchingPlaceholders = placeholders.filter(p => p.index === index) if (matchingPlaceholders.length === 0) { return null } return matchingPlaceholders[0] } export const getFinalPlaceholder = ( placeholders: OniSnippetPlaceholder[], ): OniSnippetPlaceholder | null => { const matchingPlaceholders = placeholders.filter(p => p.isFinalTabstop) if (matchingPlaceholders.length === 0) { return null } return matchingPlaceholders[0] } export interface IMirrorCursorUpdateEvent { mode: Oni.Vim.Mode cursors: types.Range[] } export const makeSnippetConsistentWithExistingWhitespace = ( snippet: string, info: BufferIndentationInfo, ) => { return snippet.split("\t").join(info.indent) } export const makeSnippetIndentationConsistent = (snippet: string, info: BufferIndentationInfo) => { return snippet .split("\n") .map((line, index) => { if (index === 0) { return line } else { return info.indent + line } }) .join("\n") } export class SnippetSession { private _buffer: IBuffer private _snippet: OniSnippet private _position: types.Position private _onCancelEvent: Event = new Event() private _onCursorMovedEvent: Event = new Event< IMirrorCursorUpdateEvent >() // Get state of line where we inserted private _prefix: string private _suffix: string private _currentPlaceholder: OniSnippetPlaceholder = null private _lastCursorMovedEvent: IMirrorCursorUpdateEvent = { mode: null, cursors: [], } public get buffer(): IBuffer { return this._buffer } public get onCancel(): IEvent { return this._onCancelEvent } public get onCursorMoved(): IEvent { return this._onCursorMovedEvent } public get position(): types.Position { return this._position } public get lines(): string[] { return this._snippet.getLines() } constructor(private _editor: Oni.Editor, private _snippetString: string) {} public async start(): Promise { this._buffer = this._editor.activeBuffer as IBuffer const cursorPosition = await this._buffer.getCursorPosition() const [currentLine] = await this._buffer.getLines( cursorPosition.line, cursorPosition.line + 1, ) this._position = cursorPosition const [prefix, suffix] = splitLineAtPosition(currentLine, cursorPosition.character) const currentIndent = detectIndent(currentLine) this._prefix = prefix this._suffix = suffix const whitespaceSettings = await this._buffer.detectIndentation() const normalizedSnippet = makeSnippetConsistentWithExistingWhitespace( this._snippetString, whitespaceSettings, ) const indentedSnippet = makeSnippetIndentationConsistent(normalizedSnippet, currentIndent) this._snippet = new OniSnippet(indentedSnippet, new SnippetVariableResolver(this._buffer)) const snippetLines = this._snippet.getLines() const lastIndex = snippetLines.length - 1 snippetLines[0] = this._prefix + snippetLines[0] snippetLines[lastIndex] = snippetLines[lastIndex] + this._suffix // If there are no placeholders, add an implicit one at the end if (this._snippet.getPlaceholders().length === 0) { this._snippet = new OniSnippet( // tslint:disable-next-line indentedSnippet + "${0}", new SnippetVariableResolver(this._buffer), ) } await this._buffer.setLines(cursorPosition.line, cursorPosition.line + 1, snippetLines) const placeholders = this._snippet.getPlaceholders() if (!placeholders || placeholders.length === 0) { // If no placeholders, we're done with the session this._finish() return } await this.nextPlaceholder() await this.updateCursorPosition() } public async nextPlaceholder(): Promise { const placeholders = this._snippet.getPlaceholders() if (!this._currentPlaceholder) { const newPlaceholder = getFirstPlaceholder(placeholders) this._currentPlaceholder = newPlaceholder } else { if (this._currentPlaceholder.isFinalTabstop) { this._finish() return } const nextPlaceholder = getPlaceholderByIndex( placeholders, this._currentPlaceholder.index + 1, ) this._currentPlaceholder = nextPlaceholder || getFinalPlaceholder(placeholders) } await this._highlightPlaceholder(this._currentPlaceholder) } public async previousPlaceholder(): Promise { const placeholders = this._snippet.getPlaceholders() const nextPlaceholder = getPlaceholderByIndex( placeholders, this._currentPlaceholder.index - 1, ) this._currentPlaceholder = nextPlaceholder || getFirstPlaceholder(placeholders) await this._highlightPlaceholder(this._currentPlaceholder) } public async setPlaceholderValue(index: number, val: string): Promise { const previousValue = this._snippet.getPlaceholderValue(index) if (previousValue === val) { Log.verbose( "[SnippetSession::setPlaceHolderValue] Skipping because new placeholder value is same as previous", ) return } await this._snippet.setPlaceholder(index, val) // Update current placeholder this._currentPlaceholder = getPlaceholderByIndex(this._snippet.getPlaceholders(), index) await this._updateSnippet() } // Update the cursor position relative to all placeholders public async updateCursorPosition(): Promise { const pos = await this._buffer.getCursorPosition() const mode = this._editor.mode as Oni.Vim.Mode if ( !this._currentPlaceholder || pos.line !== this._currentPlaceholder.line + this._position.line ) { return } const boundsForPlaceholder = this._getBoundsForPlaceholder() const offset = pos.character - boundsForPlaceholder.start const allPlaceholdersAtIndex = this._snippet .getPlaceholders() .filter( f => f.index === this._currentPlaceholder.index && !( f.line === this._currentPlaceholder.line && f.character === this._currentPlaceholder.character ), ) const cursorPositions: types.Range[] = allPlaceholdersAtIndex.map(p => { if (mode === "visual") { const bounds = this._getBoundsForPlaceholder(p) return types.Range.create( bounds.line, bounds.start, bounds.line, bounds.start + bounds.length, ) } else { const bounds = this._getBoundsForPlaceholder(p) return types.Range.create( bounds.line, bounds.start + offset, bounds.line, bounds.start + offset, ) } }) 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, // propagate that to any other placeholders, and update the snippet public async synchronizeUpdatedPlaceholders(): Promise { // Get current cursor position const cursorPosition = await this._buffer.getCursorPosition() if (!this._currentPlaceholder) { return } const bounds = this._getBoundsForPlaceholder() if (cursorPosition.line !== bounds.line) { Log.info( "[SnippetSession::synchronizeUpdatedPlaceholder] Cursor outside snippet, cancelling snippet session", ) this._onCancelEvent.dispatch() return } // Check substring of placeholder start / placeholder finish const [currentLine] = await this._buffer.getLines(bounds.line, bounds.line + 1) const startPosition = bounds.start const endPosition = currentLine.length - bounds.distanceFromEnd if ( cursorPosition.character < startPosition || cursorPosition.character > endPosition + 2 ) { return } // Set placeholder value const newPlaceholderValue = currentLine.substring(startPosition, endPosition) await this.setPlaceholderValue(bounds.index, newPlaceholderValue) } private _finish(): void { this._onCancelEvent.dispatch() } private _getBoundsForPlaceholder( currentPlaceholder: OniSnippetPlaceholder = this._currentPlaceholder, ): { index: number line: number start: number length: number distanceFromEnd: number } { const currentSnippetLines = this._snippet.getLines() const start = currentPlaceholder.line === 0 ? this._prefix.length + currentPlaceholder.character : currentPlaceholder.character const length = currentPlaceholder.value.length const distanceFromEnd = currentSnippetLines[currentPlaceholder.line].length - (currentPlaceholder.character + length) const line = currentPlaceholder.line + this._position.line return { index: currentPlaceholder.index, line, start, length, distanceFromEnd } } private async _updateSnippet(): Promise { const snippetLines = this._snippet.getLines() const lastIndex = snippetLines.length - 1 snippetLines[0] = this._prefix + snippetLines[0] snippetLines[lastIndex] = snippetLines[lastIndex] + this._suffix await this._buffer.setLines( this._position.line, this._position.line + snippetLines.length, snippetLines, ) } private async _highlightPlaceholder(currentPlaceholder: OniSnippetPlaceholder): Promise { if (!currentPlaceholder) { return } const adjustedLine = currentPlaceholder.line + this._position.line const adjustedCharacter = currentPlaceholder.line === 0 ? this._position.character + currentPlaceholder.character : currentPlaceholder.character const placeHolderLength = currentPlaceholder.value.length if (placeHolderLength === 0) { await (this._editor as any).clearSelection() await this._editor.activeBuffer.setCursorPosition(adjustedLine, adjustedCharacter) } else { await this._editor.setSelection( types.Range.create( adjustedLine, adjustedCharacter, adjustedLine, adjustedCharacter + placeHolderLength - 1, ), ) } } } ================================================ FILE: browser/src/Services/Snippets/SnippetVariableResolver.ts ================================================ /** * Snippets.ts * * Manages snippet integration */ import * as path from "path" import * as Oni from "oni-api" import { Variable, VariableResolver } from "./OniSnippet" export class SnippetVariableResolver implements VariableResolver { private _variableToValue: { [key: string]: string } = {} constructor(private _buffer: Oni.Buffer) { const currentDate = new Date() const line = this._buffer && this._buffer.cursor ? this._buffer.cursor.line : 0 const filePath = this._buffer && this._buffer.filePath ? this._buffer.filePath : "" this._variableToValue = { CURRENT_YEAR: currentDate.getFullYear().toString(), CURRENT_YEAR_SHORT: currentDate .getFullYear() .toString() .slice(-2), CURRENT_MONTH: (currentDate.getMonth() + 1).toString(), CURRENT_DATE: currentDate.getDate().toString(), CURRENT_HOUR: currentDate.getHours().toString(), CURRENT_MINUTE: currentDate.getMinutes().toString(), CURRENT_SECOND: currentDate.getSeconds().toString(), CURRENT_DAY_NAME: currentDate.toLocaleString("en-US", { weekday: "long" }), CURRENT_DAY_NAME_SHORT: currentDate.toLocaleString("en-US", { weekday: "short" }), CURRENT_MONTH_NAME: currentDate.toLocaleString("en-US", { month: "long" }), CURRENT_MONTH_NAME_SHORT: currentDate.toLocaleString("en-US", { month: "short" }), // SELECTION: "", // CLIPBOARD: "", // TM_SELECTED_TEXT: "", // TM_CURRENT_LINE: "", // TM_CURRENT_WORD: "", TM_LINE_INDEX: line.toString(), TM_LINE_NUMBER: (line + 1).toString(), TM_FILENAME: path.basename(filePath), TM_FILENAME_BASE: path.basename(filePath, path.extname(filePath)), TM_DIRECTORY: path.dirname(filePath), TM_FILEPATH: filePath, } } public resolve(variable: Variable): string { const variableName = variable.name if (!this._variableToValue[variableName]) { return "" } return this._variableToValue[variableName] } } ================================================ FILE: browser/src/Services/Snippets/UserSnippetProvider.ts ================================================ /** * UserSnippetProvider.ts * * Manages loading user snippets, and opening user snippet files */ import * as fs from "fs" import * as path from "path" import * as mkdirp from "mkdirp" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { loadSnippetsFromFile } from "./SnippetProvider" import { CommandManager } from "./../CommandManager" import { Configuration, getUserConfigFolderPath } from "./../Configuration" import { EditorManager } from "./../EditorManager" const GLOBAL_SNIPPET_NAME = "global_snippets" const SnippetTemplate = [ "{", ' "For_Loop": {', ' "prefix": "for",', ' "body": [', ' "for (const ${2:element} of ${1:array}) {",', // tslint:disable-line ' "\\t$0",', //tslint:disable-line ' "}"', " ],", ' "description": "For Loop"', " }", "}", ] export class UserSnippetProvider implements Oni.Snippets.SnippetProvider { private _snippetCache: { [language: string]: Oni.Snippets.Snippet[] } = {} private _isWatching: boolean = false private _fileTypesToEditSnippets: Set = new Set() constructor( private _commandManager: CommandManager, private _configuration: Configuration, private _editorManager: EditorManager, ) { this._startWatchingSnippetsFolderIfExists() this._editorManager.anyEditor.onBufferEnter.subscribe(bufEnter => { this._addCommandForLanguage(bufEnter.language) }) this._commandManager.registerCommand({ command: "userSnippets.editGlobal", name: "Snippets: Edit User Snippets (global)", detail: "Edit user snippet definitions for all files.", execute: () => this._editSnippetFile(GLOBAL_SNIPPET_NAME), }) } public async getSnippets(language: string): Promise { const globalSnippets = await this._getSnippetForLanguage(GLOBAL_SNIPPET_NAME) const languageSnippets = await this._getSnippetForLanguage(language) return [...globalSnippets, ...languageSnippets] } public getUserSnippetFilePath(language: string): string { const snippetPath = this._getSnippetFolder() return path.join(snippetPath, language + ".json") } private _addCommandForLanguage(language: string): void { if (!this._fileTypesToEditSnippets.has(language)) { this._commandManager.registerCommand({ command: `userSnippets.edit.${language}`, name: `Snippets: Edit User Snippets (${language})`, detail: `Edit user snippet definitions for ${language} files.`, execute: () => this._editSnippetFile(language), enabled: () => this._editorManager.activeEditor.activeBuffer.language === language, }) this._fileTypesToEditSnippets.add(language) } } private async _editSnippetFile(language: string): Promise { // Make sure snippet folder exists const snippetFilePath = this.getUserSnippetFilePath(language) const snippetFolder = path.dirname(snippetFilePath) mkdirp.sync(snippetFolder) this._startWatchingSnippetsFolderIfExists() const isNewFile = !fs.existsSync(snippetFilePath) const buf = await this._editorManager.activeEditor.openFile(snippetFilePath, { openMode: Oni.FileOpenMode.VerticalSplit, }) if (isNewFile) { await buf.setLines(0, 1, SnippetTemplate) } } private _startWatchingSnippetsFolderIfExists(): void { if (this._isWatching) { return } if (!fs.existsSync(this._getSnippetFolder())) { return } Log.info("UserSnippetProvider - installing watcher...") this._isWatching = true fs.watch(this._getSnippetFolder(), (evt, filename) => { Log.info("UserSnippetProvider - invalidating cache because a change was detected.") this._snippetCache = {} }) } private _getSnippetFolder(): string { return ( this._configuration.getValue("snippets.userSnippetFolder") || path.join(getUserConfigFolderPath(), "snippets") ) } private async _getSnippetForLanguage(language: string): Promise { if (this._snippetCache[language]) { return this._snippetCache[language] } const filePath = this.getUserSnippetFilePath(language) let snippets: Oni.Snippets.Snippet[] = [] if (fs.existsSync(filePath)) { snippets = await loadSnippetsFromFile(filePath) } this._snippetCache[language] = snippets return snippets } } ================================================ FILE: browser/src/Services/Snippets/index.ts ================================================ export * from "./OniSnippet" export * from "./SnippetManager" export * from "./SnippetSession" export * from "./SnippetVariableResolver" import { PluginManager } from "./../../Plugins/PluginManager" import { CommandManager } from "./../CommandManager" import { CompletionProviders } from "./../Completion" import { Configuration } from "./../Configuration" import { editorManager } from "./../EditorManager" import { SnippetCompletionProvider } from "./SnippetCompletionProvider" import { SnippetManager } from "./SnippetManager" import { PluginSnippetProvider } from "./SnippetProvider" import { UserSnippetProvider } from "./UserSnippetProvider" let _snippetManager: SnippetManager export const activate = (commandManager: CommandManager, configuration: Configuration) => { _snippetManager = new SnippetManager(configuration, editorManager) commandManager.registerCommand({ command: "snippet.nextPlaceholder", name: null, detail: null, enabled: () => _snippetManager.isSnippetActive, execute: () => _snippetManager.nextPlaceholder(), }) commandManager.registerCommand({ command: "snippet.previousPlaceholder", name: null, detail: null, enabled: () => _snippetManager.isSnippetActive, execute: () => _snippetManager.previousPlaceholder(), }) commandManager.registerCommand({ command: "snippet.cancel", name: null, detail: null, enabled: () => _snippetManager.isSnippetActive, execute: () => _snippetManager.cancel(), }) } export const activateProviders = ( commandManager: CommandManager, completionProviders: CompletionProviders, configuration: Configuration, pluginManager: PluginManager, ) => { completionProviders.registerCompletionProvider( "oni-snippets", new SnippetCompletionProvider(_snippetManager), ) _snippetManager.registerSnippetProvider(new PluginSnippetProvider(pluginManager)) const userProvider = new UserSnippetProvider(commandManager, configuration, editorManager) _snippetManager.registerSnippetProvider(userProvider) } export const getInstance = (): SnippetManager => { return _snippetManager } ================================================ FILE: browser/src/Services/StatusBar.ts ================================================ /** * StatusBar.ts * * Implements API surface area for working with the status bar */ import { Subject } from "rxjs/Subject" import { Subscription } from "rxjs/Subscription" import "rxjs/add/operator/auditTime" import "rxjs/add/operator/debounceTime" import * as Oni from "oni-api" import { Configuration } from "./Configuration" import * as Shell from "./../UI/Shell" export enum StatusBarAlignment { Left, Right, } export class StatusBarItem implements Oni.StatusBarItem { private _contents: JSX.Element private _visible: boolean = false private _setContentsSubject: Subject = new Subject() private _subscription: Subscription constructor( private _id: string, private _alignment?: StatusBarAlignment | null, private _priority?: number | null, ) { this._subscription = this._setContentsSubject .debounceTime(25) .subscribe((contents: any) => { if (this._visible) { this.show() } }) } public show(): void { this._visible = true Shell.Actions.showStatusBarItem(this._id, this._contents, this._alignment, this._priority) } public hide(): void { this._visible = false Shell.Actions.hideStatusBarItem(this._id) } public setContents(element: any): void { this._contents = element this._setContentsSubject.next(element) } public dispose(): void { if (this._subscription) { this._subscription.unsubscribe() this._subscription = null this._setContentsSubject = null } } } class StatusBar implements Oni.StatusBar { private _id: number = 0 constructor(private _configuration: Configuration) {} public getItem(globalId: string): Oni.StatusBarItem { return new StatusBarItem(globalId) } public createItem(alignment: StatusBarAlignment, globalId?: string): Oni.StatusBarItem { this._id++ const statusBarId = globalId || `${this._id}` const statusItems = this._configuration.getValue("statusbar.priority") const currentItem = statusItems[globalId] const itemPriority = currentItem || 0 return new StatusBarItem(statusBarId, alignment, itemPriority) } } let _statusBar: StatusBar = null export const activate = (configuration: Configuration): void => { _statusBar = new StatusBar(configuration) } export const getInstance = (): StatusBar => { return _statusBar } ================================================ FILE: browser/src/Services/SyntaxHighlighting/Definitions.ts ================================================ import * as types from "vscode-languageserver-types" import { TokenColor } from "./../TokenColors" export interface HighlightInfo { range: types.Range tokenColor: TokenColor } ================================================ FILE: browser/src/Services/SyntaxHighlighting/GrammarLoader.ts ================================================ import { IGrammar, Registry } from "vscode-textmate" import * as Log from "oni-core-logging" import { configuration } from "./../Configuration" export interface IGrammarLoader { getGrammarForLanguage(language: string, extension: string): Promise } export interface ExtensionToGrammarMap { [extension: string]: string } export const getPathForLanguage = (language: string, extension: string): string => { const verifiedLanguage = language.includes(".") ? language.split(".")[0] : language const grammar: string | ExtensionToGrammarMap = configuration.getValue( "language." + verifiedLanguage + ".textMateGrammar", ) if (!grammar) { Log.warn("No grammar found for language: " + language) return null } else if (typeof grammar === "string") { return grammar } else { return grammar[extension] || null } } export class GrammarLoader implements IGrammarLoader { private _grammarCache: { [language: string]: IGrammar } = {} constructor(private _registry: Registry = new Registry()) {} public async getGrammarForLanguage(language: string, extension: string): Promise { if (!language) { return null } if (language in this._grammarCache) { return this._grammarCache[language] } const filePath = getPathForLanguage(language, extension) if (!filePath) { return null } const grammar = this._registry.loadGrammarFromPathSync(filePath) this._grammarCache[language] = grammar return grammar } } ================================================ FILE: browser/src/Services/SyntaxHighlighting/ISyntaxHighlighter.ts ================================================ /** * ISyntaxHighlighter.ts */ import * as types from "vscode-languageserver-types" import * as Oni from "oni-api" import { IDisposable } from "oni-types" import { ISyntaxHighlightTokenInfo } from "./SyntaxHighlightingStore" export interface ISyntaxHighlighter extends IDisposable { notifyBufferUpdate(evt: Oni.EditorBufferChangedEventArgs): Promise notifyViewportChanged(bufferId: string, topLineInView: number, bottomLineInView: number): void notifyColorschemeRedraw(id: string): void getHighlightTokenAt(bufferId: string, position: types.Position): ISyntaxHighlightTokenInfo } ================================================ FILE: browser/src/Services/SyntaxHighlighting/SyntaxHighlightReconciler.ts ================================================ /** * SyntaxHighlighting.ts * * Handles enhanced syntax highlighting */ import { Buffer, Editor } from "oni-api" import * as Log from "oni-core-logging" import { prettyPrint } from "./../../Utility" import { TokenColor, TokenColors } from "./../TokenColors" import { HighlightInfo } from "./Definitions" import { ISyntaxHighlightLineInfo, ISyntaxHighlightState, ISyntaxHighlightTokenInfo, } from "./SyntaxHighlightingStore" import { TokenScorer } from "./TokenScorer" import { IBufferHighlightsUpdater } from "../../Editor/BufferHighlights" import * as Selectors from "./SyntaxHighlightSelectors" interface IBufferWithSyntaxHighlighter extends Buffer { updateHighlights?: ( tokenColors: TokenColor[], highlightCallback: (args: IBufferHighlightsUpdater) => void, ) => void } export interface IEditorWithSyntaxHighlighter extends Editor { activeBuffer: IBufferWithSyntaxHighlighter } /** * SyntaxHighlightReconciler * * Essentially a renderer / reconciler, that will push * highlight calls to the active buffer based on the active * window and viewport * @name SyntaxHighlightReconciler * @class */ export class SyntaxHighlightReconciler { private _previousState: { [line: number]: ISyntaxHighlightLineInfo } = {} private _tokenScorer = new TokenScorer() constructor(private _editor: IEditorWithSyntaxHighlighter, private _tokenColors: TokenColors) {} public update(state: ISyntaxHighlightState) { const { activeBuffer } = this._editor if (!activeBuffer) { return } const bufferId = activeBuffer.id const currentHighlightState = state.bufferToHighlights[bufferId] if (currentHighlightState && currentHighlightState.lines) { const lineNumbers = Object.keys(currentHighlightState.lines) const relevantRange = Selectors.getRelevantRange(state, bufferId) const filteredLines = lineNumbers.filter(line => { const lineNumber = parseInt(line, 10) // Ignore lines that are not in current view if (lineNumber < relevantRange.top || lineNumber > relevantRange.bottom) { return false } const latestLine = Selectors.getLineFromBuffer(currentHighlightState, lineNumber) // If dirty (haven't processed tokens yet) - skip if (latestLine.dirty) { return false } // Or lines that haven't been updated return this._previousState[line] !== latestLine }) const tokens = filteredLines.map(currentLine => { const lineNumber = parseInt(currentLine, 10) const line = Selectors.getLineFromBuffer(currentHighlightState, lineNumber) const highlights = this._mapTokensToHighlights(line.tokens) return { line: parseInt(currentLine, 10), highlights, } }) // Get only the token colors that apply to the visible section of the buffer const visibleTokens = tokens.reduce((accumulator, { highlights }) => { if (highlights) { const tokenColors = highlights.map(({ tokenColor }) => tokenColor) accumulator.push(...tokenColors) } return accumulator }, []) filteredLines.forEach(line => { const lineNumber = parseInt(line, 10) this._previousState[line] = Selectors.getLineFromBuffer( currentHighlightState, lineNumber, ) }) if (tokens.length) { Log.verbose( `[SyntaxHighlightReconciler] Applying changes to ${tokens.length} lines.`, ) activeBuffer.updateHighlights(visibleTokens, highlightUpdater => { tokens.forEach(token => { const { line, highlights } = token if (Log.isDebugLoggingEnabled()) { Log.debug( `[SyntaxHighlightingReconciler] Updating tokens for line: ${line} | ${prettyPrint( highlights, )}`, ) } highlightUpdater.setHighlightsForLine(line, highlights) }) }) } } } private _mapTokensToHighlights(tokens: ISyntaxHighlightTokenInfo[]): HighlightInfo[] { const mapTokenToHighlight = (token: ISyntaxHighlightTokenInfo) => ({ tokenColor: this._getHighlightGroupFromScope(token.scopes), range: token.range, }) return tokens.map(mapTokenToHighlight).filter(t => !!t.tokenColor) } private _getHighlightGroupFromScope(scopes: string[]): TokenColor { const highestRanked = this._tokenScorer.rankTokenScopes( scopes, this._tokenColors.tokenColors, ) return highestRanked } } ================================================ FILE: browser/src/Services/SyntaxHighlighting/SyntaxHighlightSelectors.ts ================================================ // SyntaxHighlightingSelectors.ts // // Reducers for handling state changes from ISyntaxHighlightActions import { IBufferSyntaxHighlightState, ISyntaxHighlightLineInfo, ISyntaxHighlightState, } from "./SyntaxHighlightingStore" export interface SyntaxHighlightRange { top: number bottom: number } export const NullRange: SyntaxHighlightRange = { top: -1, bottom: -1 } export const getRelevantRange = ( state: ISyntaxHighlightState, bufferId: number | string, ): SyntaxHighlightRange => { if (!state.bufferToHighlights[bufferId]) { return NullRange } const buffer = state.bufferToHighlights[bufferId] return { top: buffer.topVisibleLine, bottom: buffer.bottomVisibleLine, } } export const getLineFromBuffer = ( state: IBufferSyntaxHighlightState, lineNumber: number, ): ISyntaxHighlightLineInfo => { const currentLine = state.lines[lineNumber] if ( state.insertModeLine && state.insertModeLine.info && state.insertModeLine.version > currentLine.version && state.insertModeLine.lineNumber === lineNumber ) { return state.insertModeLine.info } return currentLine } ================================================ FILE: browser/src/Services/SyntaxHighlighting/SyntaxHighlighting.ts ================================================ /** * SyntaxHighlighting.ts * * Handles enhanced syntax highlighting */ import * as os from "os" import * as path from "path" import { Subject } from "rxjs/Subject" import * as types from "vscode-languageserver-types" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { Store, Unsubscribe } from "redux" import { TokenColors } from "./../TokenColors" import { NeovimEditor } from "./../../Editor/NeovimEditor" import { createSyntaxHighlightStore, ISyntaxHighlightAction, ISyntaxHighlightState, ISyntaxHighlightTokenInfo, } from "./SyntaxHighlightingStore" import { ISyntaxHighlighter } from "./ISyntaxHighlighter" import { IEditorWithSyntaxHighlighter, SyntaxHighlightReconciler, } from "./SyntaxHighlightReconciler" import { getLineFromBuffer } from "./SyntaxHighlightSelectors" import * as Utility from "./../../Utility" export class SyntaxHighlighter implements ISyntaxHighlighter { private _store: Store private _reconciler: SyntaxHighlightReconciler private _unsubscribe: Unsubscribe private _throttledActions: Subject = new Subject< ISyntaxHighlightAction >() constructor(private _editor: NeovimEditor, private _tokenColors: TokenColors) { this._store = createSyntaxHighlightStore() this._reconciler = new SyntaxHighlightReconciler( this._editor as IEditorWithSyntaxHighlighter, this._tokenColors, ) this._unsubscribe = this._store.subscribe(() => { const state = this._store.getState() this._reconciler.update(state) }) this._throttledActions.auditTime(50).subscribe(action => { this._store.dispatch(action) }) } public notifyViewportChanged( bufferId: string, topLineInView: number, bottomLineInView: number, ): void { Log.verbose( `[SyntaxHighlighting.notifyViewportChanged] - bufferId: ${bufferId} topLineInView: ${topLineInView} bottomLineInView: ${bottomLineInView}`, ) const state = this._store.getState() const previousBufferState = state.bufferToHighlights[bufferId] if ( previousBufferState && topLineInView === previousBufferState.topVisibleLine && bottomLineInView === previousBufferState.bottomVisibleLine ) { return } this._store.dispatch({ type: "SYNTAX_UPDATE_BUFFER_VIEWPORT", bufferId, topVisibleLine: topLineInView, bottomVisibleLine: bottomLineInView, }) } public async notifyColorschemeRedraw(bufferId: string) { this._store.dispatch({ type: "SYNTAX_RESET_BUFFER", bufferId }) } public async notifyBufferUpdate(evt: Oni.EditorBufferChangedEventArgs): Promise { const firstChange = evt.contentChanges[0] if (!firstChange.range && !firstChange.rangeLength) { const lines = firstChange.text.split(os.EOL) this._store.dispatch({ type: "SYNTAX_UPDATE_BUFFER", extension: path.extname(evt.buffer.filePath), language: evt.buffer.language, bufferId: evt.buffer.id, lines, version: evt.buffer.version, }) } else { // Incremental update this._throttledActions.next({ type: "SYNTAX_UPDATE_BUFFER_LINE", bufferId: evt.buffer.id, version: evt.buffer.version, lineNumber: firstChange.range.start.line, line: firstChange.text, }) } } public getHighlightTokenAt( bufferId: string, position: types.Position, ): ISyntaxHighlightTokenInfo { const state = this._store.getState() const buffer = state.bufferToHighlights[bufferId] if (!buffer) { return null } const line = getLineFromBuffer(buffer, position.line) if (!line) { return null } return line.tokens.find(r => Utility.isInRange(position.line, position.character, r.range)) } public dispose(): void { if (this._reconciler) { this._reconciler = null } if (this._unsubscribe) { this._unsubscribe() this._unsubscribe = null } } } export class NullSyntaxHighlighter implements ISyntaxHighlighter { public notifyBufferUpdate(evt: Oni.EditorBufferChangedEventArgs): Promise { return Promise.resolve(null) } public getHighlightTokenAt( bufferId: string, position: types.Position, ): ISyntaxHighlightTokenInfo { return null } public notifyColorschemeRedraw(id: string): void { return null } public notifyViewportChanged( bufferId: string, topLineInView: number, bottomLineInView: number, ): void { // tslint: disable-line } public dispose(): void {} // tslint:disable-line } ================================================ FILE: browser/src/Services/SyntaxHighlighting/SyntaxHighlightingPeriodicJob.ts ================================================ /** * SyntaxHighlightingPeridiocJob.ts * * Periodic (asynchronous) job to process syntax highlights */ import { Store } from "redux" import * as types from "vscode-languageserver-types" import { IGrammar } from "vscode-textmate" import * as Log from "oni-core-logging" import * as SyntaxHighlighting from "./SyntaxHighlightingStore" import * as Selectors from "./SyntaxHighlightSelectors" import { IPeriodicJob } from "./../../PeriodicJobs" export const SYNTAX_JOB_BUDGET = 10 // Budget in milliseconds - time to allow the job to run for export class SyntaxHighlightingPeriodicJob implements IPeriodicJob { constructor( private _store: Store, private _bufferId: string, private _grammar: IGrammar, private _topLine: number, private _bottomLine: number, ) {} public execute(): boolean { const start = new Date().getTime() // If the window has changed, we should bail const currentWindow = Selectors.getRelevantRange(this._store.getState(), this._bufferId) if (currentWindow.top !== this._topLine || currentWindow.bottom !== this._bottomLine) { Log.verbose( "[SyntaxHighlightingPeriodicJob.execute] Completing without doing work, as window size has changed.", ) return true } while (true) { const current = new Date().getTime() if (current - start > SYNTAX_JOB_BUDGET) { Log.verbose( "[SyntaxHighlightingPeriodicJob.execute] Pending due to exceeding budget.", ) return false } const currentState = this._store.getState() const bufferState = currentState.bufferToHighlights[this._bufferId] if (!bufferState) { return true } const anyDirty = this._tokenizeFirstDirtyLine(bufferState) if (!anyDirty) { return true } } } private _tokenizeFirstDirtyLine( state: SyntaxHighlighting.IBufferSyntaxHighlightState, ): boolean { let index = this._topLine while (index <= this._bottomLine) { const line = state.lines[index] if (!line) { break } if (!line.dirty) { index++ continue } const previousStack = index === 0 ? null : state.lines[index - 1].ruleStack const tokenizeResult = this._grammar.tokenizeLine(line.line, previousStack) const tokens = tokenizeResult.tokens.map((t: any) => ({ range: types.Range.create(index, t.startIndex, index, t.endIndex), scopes: t.scopes, })) const ruleStack = tokenizeResult.ruleStack this._store.dispatch({ type: "SYNTAX_UPDATE_TOKENS_FOR_LINE", bufferId: state.bufferId, lineNumber: index, tokens, ruleStack, version: state.version, }) return true } return false } } ================================================ FILE: browser/src/Services/SyntaxHighlighting/SyntaxHighlightingReducer.ts ================================================ // SyntaxHighlightingReducer.ts // // Reducers for handling state changes from ISyntaxHighlightActions import { IBufferSyntaxHighlightState, ISyntaxHighlightAction, ISyntaxHighlightState, SyntaxHighlightLines, } from "./SyntaxHighlightingStore" import { Reducer } from "redux" export const reducer: Reducer = ( state: ISyntaxHighlightState = { bufferToHighlights: {}, }, action: ISyntaxHighlightAction, ) => { const newState = state return { ...newState, bufferToHighlights: bufferToHighlightsReducer(state.bufferToHighlights, action), } } export const bufferToHighlightsReducer: Reducer<{ [bufferId: string]: IBufferSyntaxHighlightState }> = ( state: { [bufferId: string]: IBufferSyntaxHighlightState } = {}, action: ISyntaxHighlightAction, ) => { return { ...state, [action.bufferId]: bufferReducer(state[action.bufferId], action), } } export const bufferReducer: Reducer = ( state: IBufferSyntaxHighlightState = { bufferId: null, extension: null, language: null, version: -1, topVisibleLine: -1, bottomVisibleLine: -1, insertModeLine: null, lines: {}, }, action: ISyntaxHighlightAction, ) => { switch (action.type) { case "SYNTAX_RESET_BUFFER": return { ...state, lines: linesReducer(state.lines, action), } case "SYNTAX_UPDATE_BUFFER": return { ...state, bufferId: action.bufferId, language: action.language, extension: action.extension, lines: linesReducer(state.lines, action), version: action.version, } case "SYNTAX_UPDATE_BUFFER_VIEWPORT": return { ...state, topVisibleLine: action.topVisibleLine, bottomVisibleLine: action.bottomVisibleLine, } case "SYNTAX_UPDATE_TOKENS_FOR_LINE": return { ...state, lines: linesReducer(state.lines, action), } case "SYNTAX_UPDATE_TOKENS_FOR_LINE_INSERT_MODE": return { ...state, insertModeLine: { version: action.version, lineNumber: action.lineNumber, info: { line: action.line, tokens: action.tokens, ruleStack: action.ruleStack, dirty: false, }, }, } default: return state } } export const linesReducer: Reducer = ( state: SyntaxHighlightLines = {}, action: ISyntaxHighlightAction, ) => { switch (action.type) { case "SYNTAX_UPDATE_TOKENS_FOR_LINE": { const newState = { ...state, } const originalLine = newState[action.lineNumber] // If the ruleStack changed, we need to invalidate the next line const shouldDirtyNextLine = originalLine && originalLine.ruleStack && !originalLine.ruleStack.equals(action.ruleStack) newState[action.lineNumber] = { ...originalLine, dirty: false, tokens: action.tokens, ruleStack: action.ruleStack, version: action.version, } const nextLine = newState[action.lineNumber + 1] if (shouldDirtyNextLine && nextLine) { newState[action.lineNumber + 1] = { ...nextLine, dirty: true, } } return newState } case "SYNTAX_RESET_BUFFER": const resetState = Object.entries(state).reduce( (newResetState, [lineNumber, line]) => { newResetState[lineNumber] = { tokens: [], ruleStack: null, ...line, dirty: true, } return newResetState }, {}, ) return resetState case "SYNTAX_UPDATE_BUFFER": const updatedBufferState: SyntaxHighlightLines = { ...state, } for (let i = 0; i < action.lines.length; i++) { const oldLine = updatedBufferState[i] const newLine = action.lines[i] // check if the buffer version has changed and if so // update the line - rather than check if specific line // is changed if (oldLine && oldLine.version >= action.version) { continue } updatedBufferState[i] = { tokens: [], ruleStack: null, ...oldLine, line: newLine, dirty: true, } } return updatedBufferState } return state } ================================================ FILE: browser/src/Services/SyntaxHighlighting/SyntaxHighlightingStore.ts ================================================ /** * SyntaxHighlighting.ts * * Handles enhanced syntax highlighting */ import { Store } from "redux" import * as types from "vscode-languageserver-types" import { StackElement } from "vscode-textmate" import * as Log from "oni-core-logging" import * as PeriodicJobs from "./../../PeriodicJobs" import { createStore } from "./../../Redux" import { configuration } from "./../Configuration" import { GrammarLoader } from "./GrammarLoader" import { SyntaxHighlightingPeriodicJob } from "./SyntaxHighlightingPeriodicJob" import { reducer } from "./SyntaxHighlightingReducer" import * as Selectors from "./SyntaxHighlightSelectors" const syntaxHighlightingJobs = new PeriodicJobs.PeriodicJobManager() export interface ISyntaxHighlightTokenInfo { scopes: string[] range: types.Range } export interface ISyntaxHighlightLineInfo { line: string ruleStack: StackElement tokens: ISyntaxHighlightTokenInfo[] dirty: boolean // The last version of the line that was 'tokenized' version?: number } export interface SyntaxHighlightLines { [key: number]: ISyntaxHighlightLineInfo } // This tracks the last insert-mode line modified export interface InsertModeLineState { version: number lineNumber: number info: ISyntaxHighlightLineInfo } export interface IBufferSyntaxHighlightState { bufferId: string language: string extension: string version: number // This doesn't work quite right if we have a buffer open in a separate window... topVisibleLine: number bottomVisibleLine: number insertModeLine: InsertModeLineState | null lines: SyntaxHighlightLines } export interface ISyntaxHighlightState { bufferToHighlights: { [bufferId: string]: IBufferSyntaxHighlightState } } export const DefaultSyntaxHighlightState: ISyntaxHighlightState = { bufferToHighlights: {}, } export type ISyntaxHighlightAction = | { type: "SYNTAX_RESET_BUFFER" bufferId: string } | { type: "SYNTAX_UPDATE_BUFFER" language: string extension: string bufferId: string lines: string[] version: number } | { type: "SYNTAX_UPDATE_BUFFER_LINE" bufferId: string lineNumber: number line: string version: number } | { type: "SYNTAX_UPDATE_TOKENS_FOR_LINE" bufferId: string lineNumber: number tokens: ISyntaxHighlightTokenInfo[] ruleStack: StackElement version: number } | { type: "SYNTAX_UPDATE_TOKENS_FOR_LINE_INSERT_MODE" bufferId: string line: string lineNumber: number tokens: ISyntaxHighlightTokenInfo[] ruleStack: StackElement version: number } | { type: "SYNTAX_UPDATE_BUFFER_VIEWPORT" bufferId: string topVisibleLine: number bottomVisibleLine: number } const grammarLoader = new GrammarLoader() // Middleware that handles insert-mode updates // For insert-mode updates, we'll resolve them immediately and apply them ephemerally const updateBufferLineMiddleware = (store: any) => (next: any) => (action: any) => { const result: ISyntaxHighlightAction = next(action) if (action.type === "SYNTAX_UPDATE_BUFFER_LINE") { const state: ISyntaxHighlightState = store.getState() const bufferId = action.bufferId if (!state.bufferToHighlights[bufferId]) { return result } const buffer = state.bufferToHighlights[bufferId] const language = buffer.language const extension = buffer.extension if (!language || !extension) { return result } if (buffer.version > action.version) { return result } grammarLoader.getGrammarForLanguage(language, extension).then(grammar => { if (!grammar) { return } // We'll resolve the tokens for const previousRuleStack = action.lineNumber === 0 ? null : buffer.lines[action.lineNumber - 1].ruleStack const tokenizeResult = grammar.tokenizeLine(action.line, previousRuleStack) const tokens = tokenizeResult.tokens.map(token => ({ range: types.Range.create( action.lineNumber, token.startIndex, action.lineNumber, token.endIndex, ), scopes: token.scopes, })) const updateInsertLineAction: ISyntaxHighlightAction = { type: "SYNTAX_UPDATE_TOKENS_FOR_LINE_INSERT_MODE", line: action.line, lineNumber: action.lineNumber, bufferId: buffer.bufferId, version: action.version, ruleStack: tokenizeResult.ruleStack, tokens, } store.dispatch(updateInsertLineAction) }) } return result } const updateTokenMiddleware = (store: any) => (next: any) => (action: any) => { const result: ISyntaxHighlightAction = next(action) if ( action.type === "SYNTAX_UPDATE_BUFFER" || action.type === "SYNTAX_UPDATE_BUFFER_VIEWPORT" || action.type === "SYNTAX_RESET_BUFFER" ) { const state: ISyntaxHighlightState = store.getState() const bufferId = action.bufferId const language = state.bufferToHighlights[bufferId].language const extension = state.bufferToHighlights[bufferId].extension if (!language || !extension) { return result } grammarLoader.getGrammarForLanguage(language, extension).then(grammar => { if (!grammar) { return } const buffer = state.bufferToHighlights[bufferId] if ( Object.keys(buffer.lines).length >= configuration.getValue("editor.textMateHighlighting.maxLines") ) { Log.info( "[SyntaxHighlighting - fullBufferUpdateEpic]: Not applying syntax highlighting as the maxLines limit was exceeded", ) return } const relevantRange = Selectors.getRelevantRange(state, bufferId) syntaxHighlightingJobs.startJob( new SyntaxHighlightingPeriodicJob( store, action.bufferId, grammar, relevantRange.top, relevantRange.bottom, ), ) }) } return result } export const createSyntaxHighlightStore = (): Store => { const syntaxHighlightStore: Store = createStore( "SyntaxHighlighting", reducer, DefaultSyntaxHighlightState, [updateTokenMiddleware, updateBufferLineMiddleware], ) return syntaxHighlightStore } ================================================ FILE: browser/src/Services/SyntaxHighlighting/TokenGenerator.tsx ================================================ import * as path from "path" import * as types from "vscode-languageserver-types" import { StackElement } from "vscode-textmate" import { editorManager } from "../../Services/EditorManager" import { GrammarLoader } from "../../Services/SyntaxHighlighting/GrammarLoader" export interface IGrammarToken { scopes: any range: types.Range } export interface IHighlight { foreground: number background?: number bold?: boolean italic?: boolean } interface IGetTokens { line: string language: string extension?: string } export interface IGrammarPerLine { [line: number]: IGrammarTokens } export interface IGrammarTokens { tokens: IGrammarToken[] ruleStack: StackElement line: string } /** * This function takes a language, its extension, and a line/lines * and it returns an object with keys representing each line as a number * each key has a value of the line, the line's associated tokens and the rulestack * @returns {IGrammarPerLine} */ export const getTokens = (Grammar: GrammarLoader) => async ({ language, extension, line, }: IGetTokens): Promise => { let lang = language let ext = extension if (!language || !extension) { const { activeBuffer: b } = editorManager.activeEditor lang = language || b.language ext = extension || path.extname(b.filePath) } const grammar = await Grammar.getGrammarForLanguage(lang, ext) let tokens = null let ruleStack = null if (grammar) { const lines = line.split(/\n/) const tokensPerLine: IGrammarPerLine = {} for (let index = 0; index < lines.length; index++) { const tokenizeResult = grammar.tokenizeLine(lines[index], ruleStack) tokens = tokenizeResult.tokens.map((t: any) => ({ range: types.Range.create(index, t.startIndex, index, t.endIndex), scopes: t.scopes, })) ruleStack = tokenizeResult.ruleStack tokensPerLine[index] = { tokens, ruleStack, line: lines[index] } } return tokensPerLine } return { 0: { tokens: [], ruleStack: null, line: null } } } const grammarloader = new GrammarLoader() export default getTokens(grammarloader) ================================================ FILE: browser/src/Services/SyntaxHighlighting/TokenScorer.ts ================================================ import { TokenColor } from "./../TokenColors" interface TokenRanking { depth: number highestRankedToken: TokenColor } /** * Determines the correct token to render for a particular item * in a line based on textmate highlighting rules * @name TokenScorer * @class */ export class TokenScorer { /** * meta tokens are not intended for syntax highlighting but for other types of plugins * source is a token that All items are given effectively giving it no value from the * point of view of syntax highlighting as it distinguishes nothing * * see: https://www.sublimetext.com/docs/3/scope_naming.html */ private _BANNED_TOKENS = ["meta", "source"] private readonly _SCOPE_PRIORITIES = { support: 1, } /** * rankTokenScopes * If more than one scope selector matches the current scope then they are ranked * according to how “good” a match they each are. The winner is the scope selector * which (in order of precedence): * 1. Match the element deepest down in the scope e.g. * string wins over source.php when the scope is source.php string.quoted. * 2. Match most of the deepest element e.g. string.quoted wins over string. * 3. Rules 1 and 2 applied again to the scope selector when removing the deepest element * (in the case of a tie), e.g. text source string wins over source string. * * Reference: https://macromates.com/manual/en/scope_selectors * * @name rankTokenScopes * @function * @param {string[]} scopes * @param {TokenColor[]} themeColors * @returns {TokenColor} */ public rankTokenScopes(scopes: string[], themeColors: TokenColor[]): TokenColor { const initialRanking: TokenRanking = { highestRankedToken: null, depth: null } const { highestRankedToken } = scopes.reduce((highestSoFar, scope) => { if (this._isBannedScope(scope)) { return highestSoFar } const matchingToken = this._getMatchingToken(scope, themeColors) if (!matchingToken) { return highestSoFar } const depth = scope.split(".").length if (depth === highestSoFar.depth) { const highestPrecedence = this._determinePrecedence( matchingToken, highestSoFar.highestRankedToken, ) return { highestRankedToken: highestPrecedence, depth } } if (depth > highestSoFar.depth) { return { highestRankedToken: matchingToken, depth } } return highestSoFar }, initialRanking) return highestRankedToken || null } private _isBannedScope = (scope: string) => { return this._BANNED_TOKENS.some(token => scope.includes(token)) } private _getPriority = (token: TokenColor) => { const priorities = Object.keys(this._SCOPE_PRIORITIES) return priorities.reduce( (acc, priority) => token.scope.includes(priority) && this._SCOPE_PRIORITIES[priority] < acc.priority ? { priority: this._SCOPE_PRIORITIES[priority], token } : acc, { priority: 0, token }, ) } /** * Assign each token a priority based on `SCOPE_PRIORITIES` and then * sort by priority take the first aka the highest priority one * * @name _determinePrecedence * @function * @param {TokenColor[]} ...tokens * @returns {TokenColor} */ private _determinePrecedence(...tokens: TokenColor[]): TokenColor { const [{ token }] = tokens .map(this._getPriority) .sort((prev, next) => next.priority - prev.priority) return token } /** * if the lowest scope level doesn't match then we go up one level * i.e. constant.numeric.special -> constant.numeric * and search the theme colors for a match * * @name _getMatchingToken * @function * @param {string} scope * @param {TokenColor[]} theme * @returns {TokenColor} */ private _getMatchingToken(scope: string, theme: TokenColor[]): TokenColor { const parts = scope.split(".") if (parts.length < 2) { return null } const matchingToken = theme.find(color => color.scope.includes(scope)) if (matchingToken) { return matchingToken } const currentScope = parts.slice(0, parts.length - 1).join(".") return this._getMatchingToken(currentScope, theme) } } ================================================ FILE: browser/src/Services/SyntaxHighlighting/TokenThemeProvider.tsx ================================================ import * as React from "react" import { css, ThemeProvider, withTheme } from "styled-components" import { TokenColor, TokenColorStyle } from "./../../Services/TokenColors" import { Css, IThemeColors } from "./../../UI/components/common" /** * A object representing a key of default oni tokens and an array of tokens * to create based of of the colors of the default token */ const defaultsToMap = { "variable.parameter": [ "support", "support.variable", "support.variable.property.dom", "support.variable.dom", "support.class.dom", "support.type.builtin", "support.class.builtin", "support.type.primitive", "support.variable.property", "variable.language", "variable.language.this", "variable.function", "variable.parameter", "variable.object", "variable", "meta.object.type", "meta.object", "variable.other.readwrite", "variable.other.readwrite.alias", "constant.numeric", "constant.language", "constant.numeric.integer", "constant.character.escape", ], "support.function": [ "invalid", "function", "support.function", "entity.name", "entity.name.section", "entity.name.type", "entity.name.tag", "entity.name.type.alias", "entity.name.type.class", "entity.name.function", "entity.name.type.enum", "entity.name.type.interface", "entity.name.type.module", "entity.other.attribute.name", "entity.other.inherited-class", "entity.other.attribute.name", "punctuation.accessor", "punctuation.separator.continuation", "punctuation.separator.comma", "punctuation.terminator", "punctuation.terminator", ], "variable.other.constant": [ "constant", "constant.language", "variable.other", "entity.other", "keyword", "keyword.package", "keyword.var", "keyword.const", "keyword.struct", "keyword.control", "keyword.function", "keyword.operator", "keyword.operator.expression", "keyword.operator.expression.void", "keyword.control.import", "storage.type", "storage.modifier", "storage.type.type", "storage.type.class", "storage.type.enum", "storage.type.string", "storage.type.interface", "storage.type.function", "storage.type.namespace", "keyword.control.import", "keyword.control.default", "keyword.control.export", "variable.object", "variable.object.property", "variable.other.constant", "variable.other.object", "variable.other.assignment", "variable.other.declaration", "variable.other.constant.object", "variable.other.object.property", "variable.other.property", ], "string.quoted.double": [ "string.quoted.double", "string.quoted.single", "string.quoted.triple", "string", "string.other", ], } type TokenFunc = (theme: INewTheme) => string export interface INewTheme extends IThemeColors { "editor.tokenColors.hoverTokens": { [token: string]: TokenColorStyle } } interface IDefaultMap { [defaultTokens: string]: string[] } interface RenderProps { theme: INewTheme styles: Css } interface IProps { render: (s: RenderProps) => React.ReactElement | React.ReactNode theme: INewTheme defaultMap?: IDefaultMap tokenColors?: TokenColor[] } interface IState { theme: INewTheme styles: Css } interface IGenerateTokenArgs { defaultMap?: IDefaultMap defaultTokens: TokenColor[] } type Style = "bold" | "italic" | "foreground" | "background" /** * **TokenThemeProvider** is a Render Prop * It is designed to be used to give UI components access to a * theme with token colors as an accessible object as well as associated * styles for the tokens. * It wraps the component it renders in a separate theme which shares values with the * main theme but adds on token colors to the theme as `"editor.tokenColors.hoverTokens"` * It takes the basic token colors and generates a larger set based on the existing * by copying the settings of the default ones this can be customised by passing a different set * of defaults as props */ class TokenThemeProvider extends React.Component { public state: IState = { styles: null, theme: this.props.theme, } public flattenedDefaults = Object.values(defaultsToMap).reduce((acc, a) => [...acc, ...a], []) public componentDidMount() { const themeTokenNames = this.convertTokenNamesToClasses(this.props.tokenColors) const tokensToHightlight = [...themeTokenNames, ...this.flattenedDefaults] const styles = this.constructStyles(tokensToHightlight) const editorTokens = this.createThemeFromTokens(this.props.tokenColors) const theme = { ...this.props.theme, ...editorTokens } this.setState({ theme, styles }) } public createThemeFromTokens(tokens: TokenColor[]) { const combinedThemeAndDefaultTokens = this.generateTokens({ defaultTokens: this.props.tokenColors, }) const tokenColorsMap = combinedThemeAndDefaultTokens.reduce( (theme, token) => { return { ...theme, [token.scope]: { ...token.settings, }, } }, {} as { [key: string]: TokenColorStyle }, ) return { "editor.tokenColors.hoverTokens": tokenColorsMap } } public generateTokens({ defaultMap = defaultsToMap, defaultTokens }: IGenerateTokenArgs) { const newTokens = Object.keys(defaultMap).reduce((acc, defaultTokenName) => { const defaultToken = this.props.tokenColors.find(token => token.scope.includes(defaultTokenName), ) if (defaultToken) { const tokens = defaultMap[defaultTokenName].map(name => this.generateSingleToken(name, defaultToken), ) return [...acc, ...tokens] } return acc }, []) return [...newTokens, ...this.props.tokenColors] } public generateSingleToken(name: string, { settings }: TokenColor) { return { scope: name, settings, } } /** * Provides a check that a token exists and has valid values * if not it returns nothing or in the case of the foregroundColor it returns a default * @returns {string} */ public getCssRule = ( hoverTokens: INewTheme["editor.tokenColors.hoverTokens"], token: string, style: Style, ) => { const details = hoverTokens[token] if (!details) { return "" } const italicOrBold = details.fontStyle && details.fontStyle.includes(style) switch (style) { case "italic": return italicOrBold ? "font-style: italic" : "" case "bold": return italicOrBold ? "font-weight: bold" : "" case "foreground": default: return details[style] ? `color: ${details[style]}` : "" } } /** * Construct Class is a function which takes a token * and returns another function which takes the theme as an argument * with which it creates a css class based on the token name and returns this as a string * @returns {fn(theme) => string} */ public constructClassName = (token: string) => (theme: INewTheme) => { const tokenAsClass = token.replace(/[.]/g, "-") const hoverTokens = theme["editor.tokenColors.hoverTokens"] if (!hoverTokens || !(token in hoverTokens)) { return "" } const foreground = this.getCssRule(hoverTokens, token, "foreground") const italics = this.getCssRule(hoverTokens, token, "italic") const bold = this.getCssRule(hoverTokens, token, "bold") const hasContent = foreground || italics || bold if (!hasContent) { return "" } const cssClass = ` .${tokenAsClass} { ${bold}; ${italics}; ${foreground}; } ` return cssClass } public convertTokenNamesToClasses = (tokenArray: TokenColor[]) => { const arrayOfArrays = tokenArray.map(token => token.scope) const names = [].concat(...arrayOfArrays) return names } public constructStyles = (tokensToMap: string[] = this.flattenedDefaults) => { const symbols = tokensToMap.map(this.constructClassName) const flattenSymbols = (theme: INewTheme, fns: TokenFunc[]) => fns.map(fn => fn(theme)).join("\n") const styles = css` ${p => flattenSymbols(p.theme, symbols)}; ` return styles } public render() { const { theme, styles } = this.state return ( theme && ( {this.props.render({ theme, styles })} ) ) } } export default withTheme(TokenThemeProvider) ================================================ FILE: browser/src/Services/SyntaxHighlighting/index.ts ================================================ export * from "./Definitions" export * from "./ISyntaxHighlighter" export * from "./SyntaxHighlighting" export * from "./SyntaxHighlightReconciler" export * from "./SyntaxHighlightingReducer" export * from "./SyntaxHighlightingStore" ================================================ FILE: browser/src/Services/Tasks.ts ================================================ /** * Tasks.ts * * Manages the 'tasks' pane / Command Palette * * Tasks encompass a few different pieces of functionality: * - Launch parameters from a .oni folder * - Plugin commands * - NPM tasks */ import { remote } from "electron" import * as find from "lodash/find" import * as flatten from "lodash/flatten" import * as Oni from "oni-api" import { Menu, MenuManager } from "./../Services/Menu" import { render as renderKeyBindingInfo } from "./../UI/components/KeyBindingInfo" export interface ITask { name: string detail: string command: string messageSuccess?: string messageFail?: string // TODO: implement callbacks to return boolean callback: () => void } export interface ITaskProvider { getTasks(): Promise } export class Tasks { private _lastTasks: ITask[] = [] private _menu: Menu private _providers: ITaskProvider[] = [] constructor(private _menuManager: MenuManager) {} // TODO: This should be refactored, as it is simply // a timing dependency on when the object is created versus when // it is shown. public registerTaskProvider(taskProvider: ITaskProvider): void { this._providers.push(taskProvider) } public show(): void { this._refreshTasks().then(() => { const options: Oni.Menu.MenuOption[] = this._lastTasks .filter(t => t.name || t.detail) .map(f => { return { label: f.name, detail: f.detail, additionalComponent: renderKeyBindingInfo({ command: f.command }), } }) this._menu = this._menuManager.create() this._menu.onItemSelected.subscribe((selection: any) => this._onItemSelected(selection)) this._menu.show() this._menu.setItems(options) }) } 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) if (selectedTask) { await selectedTask.callback() // TODO: we should make the callback return a bool so we can display either success/fail messages if (selectedTask.messageSuccess != null) { remote.dialog.showMessageBox({ type: "info", title: "Success", message: selectedTask.messageSuccess, }) } } } private async _refreshTasks(): Promise { this._lastTasks = [] const initialProviders: ITaskProvider[] = [] const taskProviders = initialProviders.concat(this._providers) const allTasks = await Promise.all( taskProviders.map(async (t: ITaskProvider) => (await t.getTasks()) || []), ) this._lastTasks = flatten(allTasks) } } let _tasks: Tasks = null export const activate = (menuManager: MenuManager) => { _tasks = new Tasks(menuManager) } export const getInstance = (): Tasks => { return _tasks } ================================================ FILE: browser/src/Services/Terminal.ts ================================================ /** * Terminal.ts * * Helper / convenience commands for Neovim's integrated terminal experience */ import * as Oni from "oni-api" import { CommandManager } from "./CommandManager" import { Configuration } from "./Configuration" import { EditorManager } from "./EditorManager" export const activate = ( commandManager: CommandManager, configuration: Configuration, editorManager: EditorManager, ) => { const openTerminal = async (openMode: Oni.FileOpenMode) => { const terminalCommand = configuration.getValue("terminal.shellCommand") || (await editorManager.activeEditor.neovim.callFunction("nvim_get_option", ["shell"])) editorManager.activeEditor.openFile(`term://${terminalCommand}`, { openMode }) } commandManager.registerCommand({ command: "terminal.openInVerticalSplit", name: "Terminal: Open Vertical", detail: "Open a terminal emulator in a vertical split", execute: () => openTerminal(Oni.FileOpenMode.VerticalSplit), }) commandManager.registerCommand({ command: "terminal.openInHorizontalSplit", name: "Terminal: Open Horizontal", detail: "Open a terminal emulator in a horizontal split", execute: () => openTerminal(Oni.FileOpenMode.HorizontalSplit), }) } ================================================ FILE: browser/src/Services/Themes/ThemeLoader.ts ================================================ /** * ThemeLoader * * - Manages loading of themes */ import * as fs from "fs" import { DefaultTheme, IThemeMetadata } from "./ThemeManager" import { IThemeContribution } from "./../../Plugins/Api/Capabilities" import { PluginManager } from "./../../Plugins/PluginManager" export interface IThemeLoader { getAllThemes(): Promise getThemeByName(name: string): Promise } export class DefaultLoader implements IThemeLoader { public async getAllThemes(): Promise { return Promise.resolve([]) } public async getThemeByName(name: string): Promise { return DefaultTheme } } export class PluginThemeLoader implements IThemeLoader { constructor(private _pluginManager: PluginManager) {} public async getAllThemes(): Promise { const plugins = this._pluginManager.plugins const pluginsWithThemes = plugins.filter(p => { return p.metadata && p.metadata.contributes && p.metadata.contributes.themes }) const allThemes = pluginsWithThemes.reduce( (previous: IThemeContribution[], current) => { const themes = current.metadata.contributes.themes return [...previous, ...themes] }, [] as IThemeContribution[], ) return allThemes } public async getThemeByName(name: string): Promise { const allThemes = await this.getAllThemes() const matchingTheme = allThemes.find(t => t.name === name) if (!matchingTheme || !matchingTheme.path) { return null } return this._loadThemeFromFile(matchingTheme.path) } private async _loadThemeFromFile(themeJsonPath: string): Promise { const contents = await new Promise((resolve, reject) => { fs.readFile(themeJsonPath, "utf8", (err, data: string) => { if (err) { reject(err) return } resolve(data) }) }) return JSON.parse(contents) as IThemeMetadata } } ================================================ FILE: browser/src/Services/Themes/ThemeManager.ts ================================================ /** * ThemeManager * * - Manages theming */ import { Event, IEvent } from "oni-types" import { IThemeContribution } from "./../../Plugins/Api/Capabilities" import { PluginManager } from "./../../Plugins/PluginManager" import { Configuration, configuration, GenericConfigurationValues } from "./../Configuration" import * as PersistentSettings from "./../Configuration/PersistentSettings" import { ThemeToken, TokenColor } from "./../TokenColors" import { IThemeLoader, PluginThemeLoader } from "./ThemeLoader" export interface IThemeColors { background: string foreground: string "editor.background": string "editor.foreground": string "highlight.mode.insert.foreground": string "highlight.mode.insert.background": string "highlight.mode.normal.foreground": string "highlight.mode.normal.background": string "highlight.mode.visual.foreground": string "highlight.mode.visual.background": string "highlight.mode.operator.foreground": string "highlight.mode.operator.background": string "tabs.background": string "tabs.foreground": string "tabs.borderBottom": string "tabs.active.foreground": string "tabs.active.background": string "scrollbar.track": string "scrollbar.thumb": string "scrollbar.thumb.hover": string // Tool tip is used for some contextual information, // like hover, as well as for rename. "toolTip.background": string "toolTip.foreground": string "toolTip.border": string // User coloring options for the hover menu "editor.hover.title.background": string "editor.hover.title.foreground": string "editor.hover.border": string "editor.hover.contents.background": string "editor.hover.contents.foreground": string "editor.hover.contents.codeblock.background": string "editor.hover.contents.codeblock.foreground": string // Context menu is used for completion, refactoring "contextMenu.background": string "contextMenu.foreground": string "contextMenu.border": string "contextMenu.highlight": string // Menu is used for the popup menu "menu.background": string "menu.foreground": string "menu.border": string "menu.highlight": string "sidebar.background": string "sidebar.foreground": string "sidebar.active.background": string "sidebar.selection.border": string "statusBar.background": string "statusBar.foreground": string "title.background": string "title.foreground": string "fileExplorer.background": string "fileExplorer.foreground": string "fileExplorer.selection.background": string "fileExplorer.selection.foreground": string "fileExplorer.cursor.background": string "fileExplorer.cursor.foreground": string "editor.tokenColors": ThemeToken[] // LATER: // - Notifications? // - Alert / message? } import * as Color from "color" /** * Gets a reasonable border color for popup elements, based on popups */ export const getBorderColor = (bgColor: string, fgColor: string): string => { const backgroundColor = Color(bgColor) const foregroundColor = Color(fgColor) const borderColor = backgroundColor.luminosity() > 0.5 ? foregroundColor.lighten(0.6) : foregroundColor.darken(0.6) return borderColor.hex().toString() } export const getBackgroundColor = (editorBackground: string): string => { return Color(editorBackground) .darken(0.25) .hex() .toString() } const darken = (c: string, deg = 0.15) => Color(c) .darken(0.15) .hex() .toString() const alterColor = (c: string) => (Color(c).luminosity() > 0.5 ? darken(c) : darken(c, 0.6)) export const getHoverColors = ( userConfig: GenericConfigurationValues, colors: Partial, ) => { const alteredBackground = alterColor(colors["toolTip.background"]) const hoverDefaults = { "editor.hover.title.background": alteredBackground, "editor.hover.title.foreground": colors["toolTip.foreground"], "editor.hover.border": colors["toolTip.border"], "editor.hover.contents.background": alteredBackground, "editor.hover.contents.foreground": colors["toolTip.foreground"], "editor.hover.contents.codeblock.background": darken(alteredBackground, 0.25), "editor.hover.contents.codeblock.foreground": colors["toolTip.foreground"], } const userHoverColors = Object.keys(userConfig) .filter(value => value.includes("editor.hover")) .reduce((acc, val) => { if (userConfig[val]) { acc[val] = userConfig[val] } return acc }, hoverDefaults) return userHoverColors } export const getColorsFromConfig = ({ config, defaultTheme, themeColors, }: { config: Configuration themeColors: Partial defaultTheme: IThemeColors }) => { const userConfig = config.getValues() const hoverColors = getHoverColors(userConfig, themeColors) return hoverColors } export const getColorsFromBackgroundAndForeground = ( background: string, foreground: string, ): IThemeColors => { const shellBackground = getBackgroundColor(background) const borderColor = getBorderColor(background, foreground) return { ...DefaultThemeColors, background: shellBackground, foreground, "editor.background": background, "editor.foreground": foreground, "toolTip.background": background, "toolTip.foreground": foreground, "toolTip.border": borderColor, "editor.hover.title.background": background, "editor.hover.title.foreground": foreground, "editor.hover.border": borderColor, "editor.hover.contents.background": background, "editor.hover.contents.foreground": foreground, "sidebar.background": shellBackground, "sidebar.foreground": foreground, "sidebar.active.background": background, "sidebar.selection.border": borderColor, "tabs.background": background, "tabs.foreground": foreground, "tabs.active.background": null, "tabs.active.foreground": null, "scrollbar.track": null, "scrollbar.thumb": null, "scrollbar.thumb.hover": null, "title.background": shellBackground, "title.foreground": foreground, // Context menu is used for completion, refactoring "contextMenu.background": background, "contextMenu.foreground": foreground, "contextMenu.border": borderColor, "contextMenu.highlight": borderColor, // Menu is used for the popup menu "menu.background": background, "menu.foreground": foreground, "menu.border": borderColor, } } const ColorBlack = (PersistentSettings.get("_internal.lastBackgroundColor") as string) || "#1E2127" const ColorWhite = "white" const InsertMode = "#00c864" const OperatorMode = "#ff6400" const NormalMode = "#0064ff" const HighlightForeground = "#dcdcdc" const StatusBarBackground = "#282828" const StatusBarForeground = "#c8c8c8" export const DefaultThemeColors: IThemeColors = { background: ColorBlack, foreground: ColorWhite, "editor.background": ColorBlack, "editor.foreground": ColorWhite, "title.background": ColorBlack, "title.foreground": ColorWhite, "highlight.mode.insert.foreground": HighlightForeground, "highlight.mode.insert.background": InsertMode, "highlight.mode.normal.foreground": HighlightForeground, "highlight.mode.normal.background": NormalMode, "highlight.mode.visual.foreground": HighlightForeground, "highlight.mode.visual.background": NormalMode, "highlight.mode.operator.foreground": HighlightForeground, "highlight.mode.operator.background": OperatorMode, // Tool tip is used for some contextual information, // like hover, as well as for rename. "toolTip.background": ColorBlack, "toolTip.foreground": ColorWhite, "toolTip.border": ColorWhite, "editor.hover.title.background": ColorBlack, "editor.hover.title.foreground": ColorWhite, "editor.hover.border": ColorWhite, "editor.hover.contents.background": ColorBlack, "editor.hover.contents.foreground": ColorWhite, "editor.hover.contents.codeblock.background": ColorBlack, "editor.hover.contents.codeblock.foreground": ColorWhite, // Context menu is used for completion, refactoring "contextMenu.background": ColorBlack, "contextMenu.foreground": ColorBlack, "contextMenu.border": ColorWhite, "contextMenu.highlight": ColorBlack, // Menu is used for the popup menu "menu.background": ColorBlack, "menu.foreground": ColorBlack, "menu.border": ColorWhite, "menu.highlight": ColorBlack, "statusBar.background": StatusBarBackground, "statusBar.foreground": StatusBarForeground, "sidebar.background": ColorBlack, "sidebar.foreground": ColorWhite, "sidebar.active.background": ColorBlack, "sidebar.selection.border": ColorWhite, "tabs.background": ColorBlack, "tabs.foreground": ColorWhite, "tabs.borderBottom": null, "tabs.active.background": null, "tabs.active.foreground": null, "scrollbar.track": null, "scrollbar.thumb": null, "scrollbar.thumb.hover": null, "fileExplorer.background": StatusBarBackground, "fileExplorer.foreground": StatusBarForeground, "fileExplorer.selection.background": NormalMode, "fileExplorer.selection.foreground": HighlightForeground, "fileExplorer.cursor.background": NormalMode, "fileExplorer.cursor.foreground": NormalMode, "editor.tokenColors": [], } // Value used to determine whether the base Vim theme // should be set to 'dark' or 'light' export type VimBackground = "light" | "dark" export interface IThemeMetadata { name: string baseVimTheme?: string baseVimBackground?: VimBackground colors: Partial tokenColors: TokenColor[] } export const DefaultTheme: IThemeMetadata = { name: "default", baseVimTheme: "default", colors: DefaultThemeColors, tokenColors: [], } export class ThemeManager { private _onThemeChangedEvent: Event = new Event() private _activeTheme: IThemeMetadata = DefaultTheme private _isAnonymousTheme: boolean = false // _colors stores the current theme colors mixed with configuration private _colors: IThemeColors = DefaultThemeColors public get activeTheme(): IThemeMetadata { return this._activeTheme } constructor(private _themeLoader: IThemeLoader) {} public async getAllThemes(): Promise { return this._themeLoader.getAllThemes() } public async setTheme(name: string): Promise { // TODO: Load theme... if (!name || name === this._activeTheme.name) { return } const theme = await this._themeLoader.getThemeByName(name) if (!theme) { // If we couldn't find the theme... we'll try // loading vim-style, and derive a theme from // that. this._isAnonymousTheme = true const temporaryVimTheme: IThemeMetadata = { name, baseVimTheme: name, colors: DefaultThemeColors, tokenColors: [], } this._updateTheme(temporaryVimTheme) } else { this._updateTheme(theme) } } public async notifyVimThemeChanged( vimName: string, backgroundColor: string, foregroundColor: string, ): Promise { // If the vim colorscheme changed, for example, via `:co `, // then we should update our theme to match if ( this._isAnonymousTheme || (this._activeTheme.baseVimTheme && this._activeTheme.baseVimTheme !== vimName && this._activeTheme.baseVimTheme !== "*") ) { this._isAnonymousTheme = false const vimTheme: IThemeMetadata = { name: vimName, baseVimTheme: vimName, colors: getColorsFromBackgroundAndForeground(backgroundColor, foregroundColor), tokenColors: [], } this._updateTheme(vimTheme) } } public get onThemeChanged(): IEvent { return this._onThemeChangedEvent } public getColors(): IThemeColors { return this._colors } private _updateTheme(theme: IThemeMetadata): void { this._activeTheme = theme const userColors = getColorsFromConfig({ config: configuration, defaultTheme: DefaultThemeColors, themeColors: this.activeTheme.colors, }) this._colors = { ...DefaultThemeColors, ...this._activeTheme.colors, ...userColors, } this._onThemeChangedEvent.dispatch() } } let _themeManager: ThemeManager = null export const activateThemes = (pluginManager: PluginManager): void => { const loader = new PluginThemeLoader(pluginManager) _themeManager = new ThemeManager(loader) } export const getThemeManagerInstance = () => { return _themeManager } ================================================ FILE: browser/src/Services/Themes/ThemePicker.ts ================================================ /** * ThemePicker * * UI for showing available themes in a menu */ import { CallbackCommand, commandManager } from "./../CommandManager" import { Configuration } from "./../Configuration" import { MenuManager } from "./../Menu" import { ThemeManager } from "./ThemeManager" const chooseTheme = async ( configuration: Configuration, menuManager: MenuManager, themeManager: ThemeManager, ) => { const themes = await themeManager.getAllThemes() const items = themes.map(t => ({ icon: "paint", label: t.name, detail: t.path, })) const currentTheme = themeManager.activeTheme.name const themeMenu = menuManager.create() themeMenu.show() themeMenu.setItems(items) let wasSelected = false themeMenu.onItemSelected.subscribe(() => (wasSelected = true)) themeMenu.onHide.subscribe(() => { if (!wasSelected) { themeManager.setTheme(currentTheme) } }) themeMenu.onSelectedItemChanged.subscribe(newOption => { if (newOption) { configuration.setValues({ "ui.colorscheme": newOption.label }) themeManager.setTheme(newOption.label) } else { themeManager.setTheme(currentTheme) } }) } export const activate = ( configuration: Configuration, menuManager: MenuManager, themeManager: ThemeManager, ) => { commandManager.registerCommand( new CallbackCommand( "oni.themes.choose", "Themes: Choose Theme", "Choose your theme from the available bundled themes.", () => chooseTheme(configuration, menuManager, themeManager), ), ) } ================================================ FILE: browser/src/Services/Themes/index.ts ================================================ export * from "./ThemeLoader" export * from "./ThemeManager" import { PluginManager } from "./../../Plugins/PluginManager" import { Configuration, IConfigurationValues } from "./../Configuration" import { activateThemes, getThemeManagerInstance } from "./ThemeManager" export const activate = async ( configuration: Configuration, pluginManager: PluginManager, ): Promise => { activateThemes(pluginManager) const updateColorScheme = async ( configurationValues: Partial, ): Promise => { const colorscheme = configurationValues["ui.colorscheme"] if (colorscheme) { const themeManager = getThemeManagerInstance() await themeManager.setTheme(colorscheme) } } configuration.onConfigurationChanged.subscribe((newValues: Partial) => { updateColorScheme(newValues) }) await updateColorScheme(configuration.getValues()) } ================================================ FILE: browser/src/Services/TokenColors.ts ================================================ /** * TokenColors * * - Rationalizes colors from both the active theme and configuration * - The 'source of truth' for tokenColors in Oni * - Also will handle 'fallback logic' for tokenColors */ import { Event, IDisposable, IEvent } from "oni-types" import { Configuration, IConfigurationValues } from "./Configuration" import { ThemeManager } from "./Themes" export interface TokenColor { scope: string[] settings: TokenColorStyle // private field for determining where a token came from _source?: string } export interface ThemeToken { scope: string | string[] settings: TokenColorStyle _source?: string } export interface TokenColorStyle { foreground: string background: string fontStyle: "bold" | "italic" | "bold italic" } export class TokenColors implements IDisposable { private _subscriptions: IDisposable[] = [] private _tokenColors: TokenColor[] = [] private _onTokenColorsChangedEvent: Event = new Event() private _defaultTokenColors: TokenColor[] = [] public get tokenColors(): TokenColor[] { return this._tokenColors } public get onTokenColorsChanged(): IEvent { return this._onTokenColorsChangedEvent } constructor(private _configuration: Configuration, private _themeManager: ThemeManager) { const sub1 = this._themeManager.onThemeChanged.subscribe(() => { this._updateTokenColors() }) const sub2 = this._configuration.onConfigurationChanged.subscribe( (newValues: Partial) => { if (newValues["editor.tokenColors"]) { this._updateTokenColors() } }, ) this._subscriptions = [sub1, sub2] } public setDefaultTokenColors(tokenColors: TokenColor[]): void { this._defaultTokenColors = tokenColors || [] this._updateTokenColors() } public dispose(): void { this._subscriptions.forEach(s => s.dispose()) this._subscriptions = [] } private _updateTokenColors(): void { const { activeTheme: { colors: { "editor.tokenColors": themeTokens = [] }, }, } = this._themeManager const userColors = this._configuration.getValue("editor.tokenColors") const combinedColors = this._mergeTokenColors({ user: userColors, theme: themeTokens, defaults: this._defaultTokenColors, }) this._tokenColors = this._convertThemeTokenScopes(combinedColors) this._onTokenColorsChangedEvent.dispatch() } /** * Theme tokens can pass in token scopes as a string or an array * this converts all token scopes passed in to an array of strings * * @name convertThemeTokenScopes * @function * @param {ThemeToken[]} tokens * @returns {TokenColor[]} */ private _convertThemeTokenScopes(tokens: ThemeToken[]) { // TODO: figure out how space separated token scopes should be handled // token.scope.split(" ") -> convert "meta.var string.quoted" -> ["meta.var", "string.quoted"] // this however breaks prioritisation of tokens return tokens.map(token => { const scope = !token.scope ? [] : Array.isArray(token.scope) ? token.scope : [token.scope] return { ...token, scope } }) } /** * Merge different token source whilst unifying settings * each source is passed by name so that later the priority * for merging can be used e.g. if user source has a * a higher priority then conflicting settings can prefer the * user source */ private _mergeTokenColors(tokens: { user: ThemeToken[] defaults: TokenColor[] theme: ThemeToken[] }) { return Object.entries(tokens).reduce( (output, [_source, tokenColors]) => tokenColors.reduce((mergedTokens, currentToken) => { const duplicateToken = mergedTokens.find(t => currentToken.scope === t.scope) if (duplicateToken) { return mergedTokens.map(existingToken => { if (existingToken.scope === duplicateToken.scope) { return this._mergeSettings(existingToken, { ...currentToken, _source, }) } return existingToken }) } return [...mergedTokens, { ...currentToken, _source }] }, output), [], ) } private _mergeSettings(prev: ThemeToken, next: ThemeToken) { const priority = { user: 2, theme: 1, defaults: 0, } if (priority[next._source] > priority[prev._source]) { return { ...next, settings: { ...prev.settings, ...next.settings, }, } } return { ...prev, settings: { ...next.settings, ...prev.settings, }, } } } let _tokenColors: TokenColors export const activate = (configuration: Configuration, themeManager: ThemeManager) => { _tokenColors = new TokenColors(configuration, themeManager) } export const getInstance = () => { return _tokenColors } ================================================ FILE: browser/src/Services/TypingPredictionManager.ts ================================================ /** * TypingPredictionManager * * Handles typing-prediction state management */ import { Event, IEvent } from "oni-types" import { IScreen } from "./../neovim" export interface IPredictedCharacter { character: string id: number } export interface ITypingPrediction { predictedCharacters: IPredictedCharacter[] predictedCursorColumn: number backgroundColor: string foregroundColor: string } export class TypingPredictionManager { private _predictionsChanged: Event = new Event() private _predictions: IPredictedCharacter[] = [] private _backgroundColor: string private _foregroundColor: string private _enabled: boolean = false private _line: number = null private _column: number = null private _latestScreenState: IScreen = null public get onPredictionsChanged(): IEvent { return this._predictionsChanged } public enable(): void { this._enabled = true } public disable(): void { this._enabled = false } public setCursorPosition(screen: IScreen): void { this._latestScreenState = screen const line = screen.cursorRow const column = screen.cursorColumn const { foregroundColor, backgroundColor } = getLastTextColorFromScreen(screen) this._foregroundColor = foregroundColor this._backgroundColor = backgroundColor let shouldClearAll = false // If we changed lines, our predictions are no longer valid if (this._line !== line) { shouldClearAll = true } // In the case where auto-indent pushes us back, // we don't have a good sense of current predictions, // so just clear them all out if (column < this._column) { shouldClearAll = true } this._line = line this._column = column if (shouldClearAll) { this.clearAllPredictions() } else { this._predictions = this._predictions.filter(pd => { return pd.id > this._column }) this._notifyPredictionsChanged() } } public addPrediction(character: string): void { if (!this._enabled || !this._latestScreenState) { return null } const id = this._column + this._predictions.length + 1 const newCharacterCell = this._latestScreenState.getCell(id, this._line) if (newCharacterCell && newCharacterCell.character) { return } this._predictions = [...this._predictions, { id, character }] this._notifyPredictionsChanged() } public clearAllPredictions(): void { this._predictions = [] this._notifyPredictionsChanged() } private _notifyPredictionsChanged(): void { this._predictionsChanged.dispatch({ predictedCharacters: this._predictions, predictedCursorColumn: this._column + this._predictions.length, backgroundColor: this._backgroundColor, foregroundColor: this._foregroundColor, }) } } export const getLastTextColorFromScreen = ( screen: IScreen, ): { foregroundColor: string; backgroundColor: string } => { const previousCharacterColumn = screen.cursorColumn - 2 if (previousCharacterColumn <= 0) { return { foregroundColor: screen.foregroundColor, backgroundColor: screen.backgroundColor, } } const previousCharacter = screen.getCell(previousCharacterColumn, screen.cursorRow) if (previousCharacter.character) { return { foregroundColor: previousCharacter.foregroundColor || screen.foregroundColor, backgroundColor: previousCharacter.backgroundColor || screen.backgroundColor, } } else { return { foregroundColor: screen.foregroundColor, backgroundColor: screen.backgroundColor, } } } ================================================ FILE: browser/src/Services/UnhandledErrorMonitor.ts ================================================ /** * UnhandledErrorMonitor * * Helper module to listen to unhandled errors */ import { Event, IEvent } from "oni-types" import { Configuration } from "./Configuration" import { Notifications } from "./Notifications" import * as Log from "oni-core-logging" export class UnhandledErrorMonitor { private _onUnhandledErrorEvent = new Event() private _onUnhandledRejectionEvent = new Event() private _queuedErrors: Error[] = [] private _queuedRejections: string[] = [] private _started: boolean = false public get onUnhandledError(): IEvent { return this._onUnhandledErrorEvent } public get onUnhandledRejection(): IEvent { return this._onUnhandledRejectionEvent } constructor() { window.addEventListener("unhandledrejection", (evt: any) => { if (!this._started) { this._queuedRejections.push(evt.reason) } this._onUnhandledRejectionEvent.dispatch(evt.reason) }) window.addEventListener("error", (evt: ErrorEvent) => { if (!this._started) { const hasOccured = this._queuedErrors.find( e => evt.error && e.name && e.name === evt.error.name, ) if (!hasOccured) { this._queuedErrors.push(evt.error) } } this._onUnhandledErrorEvent.dispatch(evt.error) }) } public start(): void { this._started = true this._queuedRejections.forEach(rejection => this._onUnhandledRejectionEvent.dispatch(rejection), ) this._queuedErrors.forEach(err => this._onUnhandledErrorEvent.dispatch(err)) this._queuedErrors = [] this._queuedRejections = [] } } let _unhandledErrorMonitor: UnhandledErrorMonitor = null export const activate = () => { if (!_unhandledErrorMonitor) { _unhandledErrorMonitor = new UnhandledErrorMonitor() } } 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 - " + title + ":" + errorText) return } const notification = notifications.createItem() notification.onClick.subscribe(() => { remote.getCurrentWebContents().openDevTools() }) notification.setLevel("error") notification.setContents(title, errorText) notification.show() } _unhandledErrorMonitor.onUnhandledError.subscribe(val => { const errorText = val ? val.toString() : "Open the debugger for more details." showError( "Unhandled Exception", errorText + `\nPlease report this error. ${val && val.stack ? `Callstack:` + val.stack : ""}`, ) }) _unhandledErrorMonitor.onUnhandledRejection.subscribe(val => { const errorText: string = val ? val.toString() : "Open the debugger for more details." showError("Unhandled Rejection", errorText + "\nPlease report this error.") }) } ================================================ FILE: browser/src/Services/VersionControl/VersionControlBlameLayer.tsx ================================================ import { pathExists } from "fs-extra" import { Buffer, BufferLayer, Commands, Configuration } from "oni-api" import { warn } from "oni-core-logging" import * as React from "react" import { Transition } from "react-transition-group" import { Position } from "vscode-languageserver-types" import { LayerContextWithCursor } from "../../Editor/NeovimEditor/NeovimBufferLayersView" import styled, { pixel, textOverflow, withProps } from "../../UI/components/common" import { getTimeSince } from "../../Utility" import { VersionControlProvider } from "./" import { Blame as IBlame } from "./VersionControlProvider" type TransitionStates = "entering" | "entered" | "exiting" interface IBlamePosition { top: number left: number hide: boolean } interface ICanFit { canFit: boolean message: string position: IBlamePosition } interface ILineDetails { nextSpacing: number lastEmptyLine: number } export interface IProps extends LayerContextWithCursor { getBlame: (lineOne: number, lineTwo: number) => Promise timeout: number cursorScreenLine: number cursorBufferLine: number currentLine: string mode: "auto" | "manual" fontFamily: string setupCommand: (callback: () => void) => void } export interface IState { blame: IBlame showBlame: boolean currentLineContent: string currentCursorBufferLine: number error: Error } interface IContainerProps { height: number top: number left: number fontFamily: string hide: boolean timeout: number animationState: TransitionStates } const getOpacity = (state: TransitionStates) => { const transitionStyles = { entering: 0, entered: 0.5, exiting: 0, } return transitionStyles[state] } export const BlameContainer = withProps(styled.div).attrs({ style: ({ top, left }: IContainerProps) => ({ top: pixel(top), left: pixel(left), }), })` ${p => p.hide && `visibility: hidden`}; width: auto; box-sizing: border-box; position: absolute; font-style: italic; font-family: ${p => p.fontFamily}; color: ${p => p.theme["menu.foreground"]}; opacity: ${p => getOpacity(p.animationState)}; transition: opacity ${p => p.timeout}ms ease-in-out; height: ${p => pixel(p.height)}; line-height: ${p => pixel(p.height)}; right: 3em; ${textOverflow} ` const BlameDetails = styled.span` color: inherit; width: 100%; ` // CurrentLine - the string in the current line // CursorLine - The 0 based position of the cursor in the file i.e. at line 30 this will be 29 // CursorBufferLine - The 1 based position of the cursor in the file i.e. at line 30 it will be 30 // CursorScreenLine - the position of the cursor within the visible lines so if line 30 is at the // top of the viewport it will be 0 export class Blame extends React.PureComponent { // Reset show blame to false when props change - do it here so it happens before rendering // hide if the current line has changed or if the text of the line has changed // aka input is in progress or if there is an empty line public static getDerivedStateFromProps(nextProps: IProps, prevState: IState) { const lineNumberChanged = nextProps.cursorBufferLine !== prevState.currentCursorBufferLine const lineContentChanged = prevState.currentLineContent !== nextProps.currentLine if ( (prevState.showBlame && (lineNumberChanged || lineContentChanged)) || !nextProps.currentLine ) { return { showBlame: false, blame: prevState.blame, currentLineContent: nextProps.currentLine, currentCursorBufferLine: nextProps.cursorBufferLine, } } return null } public state: IState = { error: null, blame: null, showBlame: null, currentLineContent: this.props.currentLine, currentCursorBufferLine: this.props.cursorBufferLine, } private _timeout: any private readonly DURATION = 300 private readonly LEFT_OFFSET = 4 public async componentDidMount() { const { cursorBufferLine, mode } = this.props await this.updateBlame(cursorBufferLine, cursorBufferLine) if (mode === "auto") { this.resetTimer() } this.props.setupCommand(() => { const { showBlame } = this.state this.setState({ showBlame: !showBlame }) }) } public async componentDidUpdate(prevProps: IProps, prevState: IState) { const { cursorBufferLine, currentLine, mode } = this.props if (prevProps.cursorBufferLine !== cursorBufferLine && currentLine) { await this.updateBlame(cursorBufferLine, cursorBufferLine) if (mode === "auto") { return this.resetTimer() } } } public componentWillUnmount() { clearTimeout(this._timeout) } public componentDidCatch(error: Error) { warn(`Oni VCS Blame layer failed because: ${error.message}`) this.setState({ error }) } public resetTimer = () => { clearTimeout(this._timeout) this._timeout = setTimeout(() => { if (this.props.currentLine) { this.setState({ showBlame: true }) } }, this.props.timeout) } public getLastEmptyLine() { const { cursorLine, visibleLines, topBufferLine } = this.props const lineDetails: ILineDetails = { lastEmptyLine: null, nextSpacing: null, } for ( let currentBufferLine = cursorLine; currentBufferLine >= topBufferLine; currentBufferLine-- ) { const screenLine = currentBufferLine - topBufferLine const line = visibleLines[screenLine] if (!line.length) { const nextLine = visibleLines[screenLine + 1] lineDetails.lastEmptyLine = currentBufferLine // search for index of first non-whitespace character which is equivalent // to the whitespace count lineDetails.nextSpacing = nextLine.search(/\S/) break } } return lineDetails } public calculatePosition(canFit: boolean) { const { cursorLine, cursorScreenLine, visibleLines } = this.props const currentLine = visibleLines[cursorScreenLine] const character = currentLine && currentLine.length + this.LEFT_OFFSET if (canFit) { return this.getPosition({ line: cursorLine, character }) } const { lastEmptyLine, nextSpacing } = this.getLastEmptyLine() if (lastEmptyLine) { return this.getPosition({ line: lastEmptyLine - 1, character: nextSpacing }) } return this.getPosition() } // TODO: possibly add a caching strategy so a new call isn't made each time or // get a blame for the entire file and store it public updateBlame = async (lineOne: number, lineTwo: number) => { const outOfBounds = this.isOutOfBounds(lineOne, lineTwo) const blame = !outOfBounds ? await this.props.getBlame(lineOne, lineTwo) : null this.setState({ blame }) } public formatCommitDate(timestamp: string) { return new Date(parseInt(timestamp, 10) * 1000) } public getPosition(positionToRender?: Position): IBlamePosition { const emptyPosition: IBlamePosition = { hide: true, top: null, left: null, } if (!positionToRender) { return emptyPosition } const position = this.props.bufferToPixel(positionToRender) if (!position) { return emptyPosition } return { hide: false, top: position.pixelY, left: position.pixelX, } } public isOutOfBounds = (...lines: number[]) => { return lines.some( line => !line || line > this.props.bottomBufferLine || line < this.props.topBufferLine, ) } public getBlameText = (numberOfTruncations = 0) => { const { blame } = this.state if (!blame) { return null } const { author, hash, committer_time } = blame const formattedDate = this.formatCommitDate(committer_time) const timeSince = `${getTimeSince(formattedDate)} ago` const formattedHash = hash.slice(0, 4).toUpperCase() const words = blame.summary.split(" ") const message = words.slice(0, words.length - numberOfTruncations).join(" ") const symbol = "…" const summary = numberOfTruncations && words.length > 2 ? message.concat(symbol) : message return words.length < 2 ? `${author}, ${timeSince}` : `${author}, ${timeSince}, ${summary} #${formattedHash}` } // Recursively calls get blame text if the message will not fit onto the screen up // to a limit of 6 times each time removing one word from the blame message // if after 6 attempts the message is still not small enougth then we render the popup public canFit = (truncationAmount = 0): ICanFit => { const { visibleLines, dimensions, cursorScreenLine } = this.props const message = this.getBlameText(truncationAmount) const currentLine = visibleLines[cursorScreenLine] || "" const canFit = dimensions.width > currentLine.length + message.length + this.LEFT_OFFSET if (!canFit && truncationAmount <= 6) { return this.canFit(truncationAmount + 1) } const truncatedOrFullMessage = canFit ? message : this.getBlameText() return { canFit, message: truncatedOrFullMessage, position: this.calculatePosition(canFit), } } public render() { const { blame, showBlame, error } = this.state if (!blame || !showBlame || error) { return null } const { message, position } = this.canFit() return ( {(state: TransitionStates) => ( {message} )} ) } } export default class VersionControlBlameLayer implements BufferLayer { constructor( private _buffer: Buffer, private _vcsProvider: VersionControlProvider, private _configuration: Configuration, private _commands: Commands.Api, ) {} public getBlame = async (lineOne: number, lineTwo: number) => { const fileExists = await pathExists(this._buffer.filePath) return ( fileExists && this._vcsProvider.getBlame({ file: this._buffer.filePath, lineOne, lineTwo }) ) } get id() { return "vcs.blame" } public setupCommand = (callback: () => void) => { this._commands.registerCommand({ command: "experimental.vcs.blame.toggleBlame", name: null, detail: null, enabled: this._isActive, execute: callback, }) } public getConfigOpts() { const fontFamily = this._configuration.getValue("editor.fontFamily") const timeout = this._configuration.getValue("experimental.vcs.blame.timeout") const mode = this._configuration.getValue<"auto" | "manual">("experimental.vcs.blame.mode") return { timeout, mode, fontFamily } } public render(context: LayerContextWithCursor) { const cursorBufferLine = context.cursorLine + 1 const cursorScreenLine = cursorBufferLine - context.topBufferLine const config = this.getConfigOpts() const activated = this._isActive() return ( activated && ( ) ) } private _isActive() { return this._vcsProvider && this._vcsProvider.isActivated } } ================================================ FILE: browser/src/Services/VersionControl/VersionControlManager.tsx ================================================ import { capitalize } from "lodash" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { IDisposable } from "oni-types" import * as React from "react" import { store, SupportedProviders, VersionControlPane, VersionControlProvider } from "./" import getBufferLayerInstance from "./../../Editor/NeovimEditor/BufferLayerManager" import { Notifications } from "./../../Services/Notifications" import { Branch } from "./../../UI/components/VersionControl/Branch" import { SidebarManager } from "./../Sidebar" import VersionControlBlameLayer from "./VersionControlBlameLayer" interface ISendNotificationsArgs { detail: string level: "info" | "warn" title: string expiration?: number } export type ISendVCSNotification = (args: ISendNotificationsArgs) => void export class VersionControlManager { private _vcs: SupportedProviders private _vcsProvider: VersionControlProvider private _menuInstance: Oni.Menu.MenuInstance private _vcsStatusItem: Oni.StatusBarItem private _subscriptions: IDisposable[] = [] private _providers = new Map() private _bufferLayerManager = getBufferLayerInstance() constructor( private _oni: Oni.Plugin.Api, private _sidebar: SidebarManager, private _notifications: Notifications, ) {} public get providers() { return this._providers } public get activeProvider(): VersionControlProvider { return this._vcsProvider } public async registerProvider(provider: VersionControlProvider): Promise { if (provider) { this._providers.set(provider.name, provider) const canHandleWorkspace = await provider.canHandleWorkspace() if (canHandleWorkspace) { await this._activateVCSProvider(provider) } this._oni.workspace.onDirectoryChanged.subscribe(async dir => { const providerToUse = await this.getCompatibleProvider(dir) await this.handleProviderStatus(providerToUse) }) } } // Use arrow function to maintain this binding of sendNotification public sendNotification: ISendVCSNotification = ({ expiration = 3_000, ...args }) => { const notification = this._notifications.createItem() notification.setContents(args.title, args.detail) notification.setExpiration(expiration) notification.setLevel(args.level) // TODO: Integrate setLevel into API notification.show() } public deactivateProvider(): void { this._vcsProvider.deactivate() this._subscriptions.map(s => s.dispose()) if (this._vcsStatusItem) { this._vcsStatusItem.hide() } this._vcsProvider = null this._vcs = null } public async handleProviderStatus(newProvider: VersionControlProvider): Promise { const isSameProvider = this._vcsProvider && newProvider && this._vcs === newProvider.name const noCompatibleProvider = this._vcsProvider && !newProvider const newReplacementProvider = Boolean(this._vcsProvider && newProvider) const compatibleProvider = Boolean(!this._vcsProvider && newProvider) switch (true) { case isSameProvider: break case noCompatibleProvider: this.deactivateProvider() break case newReplacementProvider: this.deactivateProvider() await this._activateVCSProvider(newProvider) break case compatibleProvider: await this._activateVCSProvider(newProvider) break default: break } } private async getCompatibleProvider(dir: string): Promise { const allCompatibleProviders: VersionControlProvider[] = [] for (const vcs of this._providers.values()) { const isCompatible = await vcs.canHandleWorkspace(dir) if (isCompatible) { allCompatibleProviders.push(vcs) } } // TODO: when we have multiple providers we will need logic to determine which to // use if more than one is compatible const [providerToUse] = allCompatibleProviders return providerToUse } private _activateVCSProvider = async (provider: VersionControlProvider) => { this._vcs = provider.name this._vcsProvider = provider await this._initialize() provider.activate() } private async _initialize() { try { await this._updateBranchIndicator() this._setupSubscriptions() const hasVcsSidebar = this._oni.sidebar.entries.some(({ id }) => id.includes("vcs")) const enabled = this._oni.configuration.getValue("experimental.vcs.sidebar") if (!hasVcsSidebar && enabled) { const vcsPane = new VersionControlPane( this._oni, this._vcsProvider, this.sendNotification, this._sidebar, // TODO: Refactor API store, ) this._sidebar.add("code-fork", vcsPane) // TODO: Refactor API } // TODO: this should only be active if this is a file under version control this._bufferLayerManager.addBufferLayer( buffer => this._oni.configuration.getValue("experimental.vcs.blame.enabled") && !!buffer.filePath, buf => new VersionControlBlameLayer( buf, this._vcsProvider, this._oni.configuration, this._oni.commands, ), ) this._registerCommands() } catch (e) { Log.warn(`Failed to initialise provider, because, ${e.message}`) } } private _setupSubscriptions() { this._subscriptions = [ this._oni.editors.activeEditor.onBufferEnter.subscribe(async () => { await this._updateBranchIndicator() }), this._vcsProvider.onBranchChanged.subscribe(async newBranch => { await this._updateBranchIndicator(newBranch) await this._oni.editors.activeEditor.neovim.command("e!") }), this._oni.editors.activeEditor.onBufferSaved.subscribe(async () => { await this._updateBranchIndicator() }), (this._oni.workspace as any).onFocusGained.subscribe(async () => { await this._updateBranchIndicator() }), ] } private _registerCommands = () => { const toggleVCS = () => { this._sidebar.toggleVisibilityById("oni.sidebar.vcs") // TODO: Refactor API } this._oni.commands.registerCommand({ command: "vcs.sidebar.toggle", name: "Version Control: Toggle Visibility", detail: "Toggles the vcs pane in the sidebar", execute: toggleVCS, enabled: () => this._oni.configuration.getValue("experimental.vcs.sidebar"), }) this._oni.commands.registerCommand({ command: `vcs.fetch`, name: "Fetch the selected branch", detail: "", execute: this._fetchBranch, }) this._oni.commands.registerCommand({ command: `vcs.branches`, name: `Local ${capitalize(this._vcs)} Branches`, detail: "Open a menu with a list of all local branches", execute: this._createBranchList, }) } private _updateBranchIndicator = async (branchName?: string) => { if (!this._vcsProvider) { return } else if (!this._vcsStatusItem) { const vcsId = `oni.status.${this._vcs}` this._vcsStatusItem = this._oni.statusBar.createItem(1, vcsId) } try { // FIXME: there is race condition on deactivation of the provider const branch = await this._vcsProvider.getBranch() const diff = await this._vcsProvider.getDiff() if (!branch || !diff) { return Log.warn(`The ${!branch ? "branch name" : "diff"} could not be found`) } else if (!branch && !diff) { return this._vcsStatusItem.hide() } this._vcsStatusItem.setContents() this._vcsStatusItem.show() } catch (e) { this._notifyOfError(e) return this._vcsStatusItem.hide() } } private _createBranchList = async () => { if (!this._vcsProvider) { return } const [currentBranch, branches] = await Promise.all([ this._vcsProvider.getBranch(), this._vcsProvider.getLocalBranches(), ]) this._menuInstance = this._oni.menu.create() if (!branches) { return } const branchItems = branches.all.map(branch => ({ label: branch, icon: "code-fork", pinned: currentBranch === branch, })) this._menuInstance.show() this._menuInstance.setItems(branchItems) this._menuInstance.onItemSelected.subscribe(async menuItem => { if (menuItem && menuItem.label) { try { await this._vcsProvider.changeBranch(menuItem.label) } catch (e) { this._notifyOfError(e) } } }) } private _notifyOfError(error: Error) { const name = this._vcsProvider ? capitalize(this._vcs) : "VCS" const errorMessage = error && error.message ? error.message : null this.sendNotification({ title: `${capitalize(name)} Plugin Error:`, detail: `${name} plugin encountered an error ${errorMessage}`, level: "warn", }) } private _fetchBranch = async () => { if (this._menuInstance.isOpen() && this._menuInstance.selectedItem) { try { await this._vcsProvider.fetchBranchFromRemote({ currentDir: this._oni.workspace.activeWorkspace, branch: this._menuInstance.selectedItem.label, }) } catch (e) { this._notifyOfError(e) } } } } // Shelter the instance from the global scope -> globals are evil. function init() { let Provider: VersionControlManager const Activate = ( oni: Oni.Plugin.Api, sidebar: SidebarManager, notifications: Notifications, ): void => { Provider = new VersionControlManager(oni, sidebar, notifications) } const GetInstance = () => { return Provider } return { activate: Activate, getInstance: GetInstance, } } export const { activate, getInstance } = init() ================================================ FILE: browser/src/Services/VersionControl/VersionControlPane.tsx ================================================ import * as capitalize from "lodash/capitalize" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import * as path from "path" import * as React from "react" import { Provider, Store } from "react-redux" import { SidebarManager } from "../Sidebar" import { VersionControlProvider, VersionControlView } from "./" import { ISendVCSNotification } from "./VersionControlManager" import { ProviderActions, VersionControlState } from "./VersionControlStore" export interface IDsMap { modified: "modified" staged: "staged" untracked: "untracked" commits: "commits" commitAll: "commit_all" } export default class VersionControlPane { public get id() { return "oni.sidebar.vcs" } public get title() { return capitalize(this._vcsProvider.name) } public readonly IDs: IDsMap = { modified: "modified", commits: "commits", untracked: "untracked", staged: "staged", commitAll: "commit_all", } constructor( private _oni: Oni.Plugin.Api, private _vcsProvider: VersionControlProvider, private _sendNotification: ISendVCSNotification, private _sidebarManager: SidebarManager, private _store: Store, ) { this._registerCommands() this._oni.workspace.onDirectoryChanged.subscribe(this._refresh) this._vcsProvider.onFileStatusChanged.subscribe(this._refresh) this._vcsProvider.onBranchChanged.subscribe(this._getStatusIfVisible) this._vcsProvider.onStagedFilesChanged.subscribe(this._getStatusIfVisible) this._oni.editors.activeEditor.onBufferSaved.subscribe(this._getStatusIfVisible) this._oni.editors.activeEditor.onBufferEnter.subscribe(this._getStatusIfVisible) this._vcsProvider.onPluginActivated.subscribe(async () => { this._store.dispatch({ type: "ACTIVATE" }) await this._refresh() }) this._vcsProvider.onPluginDeactivated.subscribe(() => { this._store.dispatch({ type: "DEACTIVATE" }) }) } public async enter() { this._store.dispatch({ type: "ENTER" }) await this._refresh() } public leave() { this._store.dispatch({ type: "LEAVE" }) } public getStatus = async () => { const status = await this._vcsProvider.getStatus() if (status) { this._store.dispatch({ type: "STATUS", payload: { status } }) } return status } public commit = async (messages: string[], files?: string[]) => { let summary = null const { status } = this._store.getState() const filesToCommit = files || status.staged this._dispatchLoading(true) try { summary = await this._vcsProvider.commitFiles(messages, filesToCommit) this._store.dispatch({ type: "COMMIT_SUCCESS", payload: { commit: summary } }) } catch (e) { this._sendNotification({ detail: e.message, level: "warn", title: `Error Commiting ${files[0]}`, }) this._store.dispatch({ type: "COMMIT_FAIL" }) } finally { await this._refresh() this._dispatchLoading(false) } } public stageFile = async (file: string) => { try { await this._vcsProvider.stageFile(file) } catch (e) { this._sendNotification({ detail: e.message, level: "warn", title: "Error Staging File", expiration: 8_000, }) } } public getLogs = async () => { this._dispatchLoading(true) const logs = await this._vcsProvider.getLogs() if (logs) { this._store.dispatch({ type: "LOG", payload: { logs } }) this._dispatchLoading(false) return logs } return null } public uncommitFile = async (sha: string) => { try { await this._vcsProvider.uncommit() await this._refresh() } catch (error) { this._sendNotification({ title: "Unable to revert last commit", detail: error.message, level: "warn", }) } } public unstageFile = async () => { const { selected, status: { staged }, } = this._store.getState() if (!this._isReadonlyField(selected) && staged.includes(selected)) { await this._vcsProvider.unstage([selected]) } } public setError = async (e: Error) => { Log.warn(`version control pane failed to render due to ${e.message}`) this._store.dispatch({ type: "ERROR" }) } public updateSelection = (selected: string) => { this._store.dispatch({ type: "SELECT", payload: { selected } }) } public handleSelection = async (selected: string) => { const { status, logs } = this._store.getState() switch (true) { case status.untracked.includes(selected): case status.modified.includes(selected): await this.stageFile(selected) break case logs && logs.latest && logs.latest.hash === selected: await this.uncommitFile(selected) break case status.staged.includes(selected): this._store.dispatch({ type: "COMMIT_START", payload: { files: [selected] } }) break case selected === "commit_all" && !!status.staged.length: this._store.dispatch({ type: "COMMIT_START", payload: { files: status.staged } }) break default: break } } public render() { return ( ) } private _refresh = async () => { await Promise.all([this.getStatus(), this.getLogs()]) } private _getStatusIfVisible = async () => { if (this._isVisible()) { await this._refresh() } } private _dispatchLoading = (loading: boolean, type: ProviderActions = "commit") => { this._store.dispatch({ type: "LOADING", payload: { loading, type } }) } private _isVisible = () => this._sidebarManager.isVisible && this._sidebarManager.activeEntryId === this.id private _isReadonlyField = (field: string) => Object.values(this.IDs).includes(field) private _toggleHelp = () => { this._store.dispatch({ type: "TOGGLE_HELP" }) } private _isCommiting = () => { const state = this._store.getState() return state.hasFocus && state.commit.active } private _hasFocus = () => { const state = this._store.getState() return state.hasFocus } private _getCurrentCommitMessage() { const state = this._store.getState() return state.commit.message } private _registerCommands() { this._oni.commands.registerCommand({ command: "vcs.commitAll", detail: "Commit all staged files", name: "Version Control: Commit all", enabled: this._isCommiting, execute: async () => { const currentMessage = this._getCurrentCommitMessage() if (currentMessage.length) { await this.commit(currentMessage) } }, }) this._oni.commands.registerCommand({ command: "vcs.openFile", detail: null, name: null, enabled: () => !this._isCommiting(), execute: async () => { const { selected } = this._store.getState() if (!this._isReadonlyField(selected)) { const filePath = path.join(this._oni.workspace.activeWorkspace, selected) await this._oni.editors.openFile(filePath) } }, }) this._oni.commands.registerCommand({ command: "vcs.refresh", detail: null, name: null, enabled: this._hasFocus, execute: this._refresh, }) this._oni.commands.registerCommand({ command: "vcs.unstage", detail: null, name: null, enabled: () => this._hasFocus() && !this._isCommiting(), execute: this.unstageFile, }) this._oni.commands.registerCommand({ command: "vcs.showHelp", detail: null, name: null, enabled: () => !this._isCommiting(), execute: this._toggleHelp, }) } } ================================================ FILE: browser/src/Services/VersionControl/VersionControlProvider.ts ================================================ import { IEvent } from "oni-types" import { BranchSummary, FetchResult } from "simple-git/promise" export enum Statuses { staged, committed, modified, } export type FileStatusChangedEvent = Array<{ path: string status: Statuses }> export type BranchChangedEvent = string export type StagedFilesChangedEvent = string export interface StatusResult { ahead: number behind: number currentBranch: string modified: string[] staged: string[] conflicted: string[] created: string[] deleted: string[] untracked: string[] remoteTrackingBranch: string } export interface BlameArgs { lineOne: number lineTwo: number file: string } export interface Blame { author: string author_mail: string author_time: string author_tz: string committer: string committer_mail: string committer_time: string committer_tz: string filename: string hash: string line: { originalLine: string; finalLine: string; numberOfLines: string } summary: string } export interface VersionControlProvider { // Events onFileStatusChanged: IEvent onStagedFilesChanged: IEvent onBranchChanged: IEvent onPluginActivated: IEvent onPluginDeactivated: IEvent name: SupportedProviders isActivated: boolean deactivate(): void activate(): void canHandleWorkspace(dir?: string): Promise getStatus(): Promise getRoot(): Promise getBlame(args: BlameArgs): Promise getDiff(): Promise getBranch(): Promise getLogs(file?: string): Promise getLocalBranches(): Promise changeBranch(branch: string): Promise stageFile(file: string): Promise unstage(files: string[]): Promise uncommit(sha?: string): Promise commitFiles(message: string[], files?: string[]): Promise fetchBranchFromRemote(args: { branch: string origin?: string currentDir: string }): Promise } export interface DiffResultTextFile { file: string changes: number insertions: number deletions: number binary: boolean } export interface DiffResultBinaryFile { file: string before: number after: number binary: boolean } export interface Diff { files: Array insertions: number deletions: number } export interface Commits { author: null | { email: string name: string } branch: string commit: string summary: { changes: number insertions: number deletions: number } } export interface DefaultLogFields { hash: string date: string message: string author_name: string author_email: string } export interface ListLogSummary { all: ReadonlyArray total: number latest: T } export type Logs = ListLogSummary export type Summary = StatusResult export type SupportedProviders = "git" | "svn" export default VersionControlProvider ================================================ FILE: browser/src/Services/VersionControl/VersionControlStore.ts ================================================ import { createStore as createReduxStore } from "./../../Redux" import { Commits, Logs, StatusResult } from "./VersionControlProvider" export interface PrevCommits extends Commits { message: string } interface ICommit { files: string[] active: boolean message: string[] previousCommits: PrevCommits[] } export type ProviderActions = "commit" | "pull" | "fetch" | "stage" export interface VersionControlState { loading: { active: boolean type: ProviderActions } selected: string logs: Logs status: StatusResult commit: ICommit hasFocus: boolean hasError: boolean activated: boolean help: { active: boolean } } interface IGenericAction { type: T payload?: P } export const DefaultState: VersionControlState = { loading: { active: false, type: null, }, selected: null, logs: { all: [], total: null, latest: null, }, status: { currentBranch: null, staged: [], conflicted: [], created: [], modified: [], remoteTrackingBranch: null, deleted: [], untracked: [], ahead: null, behind: null, }, commit: { files: [], message: [], active: false, previousCommits: [], }, hasFocus: null, activated: null, hasError: false, help: { active: false, }, } type ISelectAction = IGenericAction<"SELECT", { selected: string }> type ILoadingAction = IGenericAction<"LOADING", { loading: boolean; type: ProviderActions }> type IActivateAction = IGenericAction<"ACTIVATE"> type IDeactivateAction = IGenericAction<"DEACTIVATE"> type IToggleHelpAction = IGenericAction<"TOGGLE_HELP"> type IEnterAction = IGenericAction<"ENTER"> type ILeaveAction = IGenericAction<"LEAVE"> type IErrorAction = IGenericAction<"ERROR"> type IStatusAction = IGenericAction<"STATUS", { status: StatusResult }> type ILogAction = IGenericAction<"LOG", { logs: Logs }> type ICommitStartAction = IGenericAction<"COMMIT_START", { files: string[] }> type ICommitCancelAction = IGenericAction<"COMMIT_CANCEL"> type ICommitSuccessAction = IGenericAction<"COMMIT_SUCCESS", { commit: Commits }> type ICommitFailAction = IGenericAction<"COMMIT_FAIL"> type IUpdateCommitMessageAction = IGenericAction<"UPDATE_COMMIT_MESSAGE", { message: string[] }> type IAction = | ILoadingAction | IToggleHelpAction | ISelectAction | IStatusAction | ILogAction | IEnterAction | ILeaveAction | IErrorAction | IDeactivateAction | IActivateAction | ICommitStartAction | ICommitCancelAction | ICommitSuccessAction | ICommitFailAction | IUpdateCommitMessageAction export interface IVersionControlActions { cancelCommit: () => ICommitCancelAction updateCommitMessage: (message: string[]) => IUpdateCommitMessageAction setLoading: (isLoading: boolean) => ILoadingAction } export const VersionControlActions: IVersionControlActions = { setLoading: (isLoading: boolean, type = "commit") => ({ type: "LOADING", payload: { loading: isLoading, type }, }), cancelCommit: () => ({ type: "COMMIT_CANCEL" }), updateCommitMessage: (message: string[]) => ({ type: "UPDATE_COMMIT_MESSAGE", payload: { message }, }), } export function reducer(state: VersionControlState, action: IAction) { switch (action.type) { case "ENTER": return { ...state, hasFocus: true } case "LOADING": return { ...state, loading: { active: action.payload.loading, type: action.payload.type, }, } case "SELECT": return { ...state, selected: action.payload.selected } case "COMMIT_START": return { ...state, commit: { ...state.commit, files: action.payload.files, active: true }, } case "COMMIT_CANCEL": return { ...state, commit: { ...state.commit, message: [], active: false, files: [] } } case "COMMIT_SUCCESS": const { message: [message], } = state.commit return { ...state, loading: { active: false, type: null, }, commit: { files: [], message: [] as string[], active: false, previousCommits: [ ...state.commit.previousCommits, { ...action.payload.commit, message }, ], }, } case "COMMIT_FAIL": return { ...state, loading: { active: false, type: null, }, commit: { ...state.commit, files: [], message: [], active: false, }, } case "UPDATE_COMMIT_MESSAGE": return { ...state, commit: { ...state.commit, message: action.payload.message } } case "LEAVE": return { ...state, hasFocus: false } case "STATUS": return { ...state, status: action.payload.status, } case "LOG": return { ...state, logs: action.payload.logs, } case "DEACTIVATE": return { ...state, activated: false, status: DefaultState.status, } case "ACTIVATE": return { ...state, activated: true, } case "ERROR": return { ...state, hasError: true, } case "TOGGLE_HELP": return { ...state, help: { active: !state.help.active, }, } default: return state } } export default createReduxStore("Version Control", reducer, DefaultState) ================================================ FILE: browser/src/Services/VersionControl/VersionControlView.tsx ================================================ import * as React from "react" import { connect } from "react-redux" import { styled } from "./../../UI/components/common" import { SectionTitle, Title } from "./../../UI/components/SectionTitle" import CommitsSection from "./../../UI/components/VersionControl/Commits" import Help from "./../../UI/components/VersionControl/Help" import StagedSection from "./../../UI/components/VersionControl/Staged" import VersionControlStatus from "./../../UI/components/VersionControl/Status" import { VimNavigator } from "./../../UI/components/VimNavigator" import { IDsMap } from "./VersionControlPane" import { Logs, StatusResult } from "./VersionControlProvider" import { PrevCommits, ProviderActions, VersionControlActions, VersionControlState, } from "./VersionControlStore" const StatusContainer = styled.div` overflow-x: hidden; overflow-y: auto; ` interface IStateProps { loading: boolean filesToCommit: string[] loadingSection: ProviderActions status: StatusResult hasFocus: boolean hasError: boolean activated: boolean committing: boolean message: string[] selectedItem: string commits: PrevCommits[] showHelp: boolean logs: Logs } interface IDispatchProps { cancelCommit: () => void updateCommitMessage: (message: string[]) => void setLoading: (loading: boolean) => void } interface IProps { IDs: IDsMap setError?: (e: Error) => void commit?: (message: string[], files?: string[]) => Promise updateSelection?: (selection: string) => void handleSelection?: (selection: string) => void getStatus: () => void } type ConnectedProps = IProps & IStateProps & IDispatchProps interface State { modified: boolean staged: boolean untracked: boolean commits: boolean } export class VersionControlView extends React.Component { public state: State = { modified: true, staged: true, untracked: true, commits: true, } public async componentDidMount() { await this.props.getStatus() } public async componentDidCatch(e: Error) { this.props.setError(e) } public toggleVisibility = (section: keyof State) => { this.setState(prevState => ({ ...prevState, [section]: !prevState[section] })) } public toggleOrAction = (id: string) => { const isSectionId = Object.keys(this.state).includes(id) if (isSectionId) { this.toggleVisibility(id as keyof State) } this.props.handleSelection(id) } public formatCommit = (message: string) => { return message.length >= 50 ? [message.substr(0, 50), message.substr(50)] : [message] } public handleCommitMessage = (evt: React.ChangeEvent) => { const { value } = evt.currentTarget const message = this.formatCommit(value) this.props.updateCommitMessage(message) } public handleCommitOne = async () => { const { message, selectedItem } = this.props await this.props.commit(message, [selectedItem]) } public handleCommitAll = async () => { const { message } = this.props await this.props.commit(message) } public handleCommitCancel = () => { this.props.cancelCommit() } public insertIf(condition: boolean, element: string[]) { return condition ? element : [] } public isSelected = (id: string) => this.props.committing && this.props.status.staged.length && this.state.staged && this.props.selectedItem === id public getIds = () => { const { logs, status, IDs } = this.props const { modified, staged, untracked } = status const commitSHAs = logs.all.slice(0, 1).map(({ hash }) => hash) const ids = [ IDs.commits, ...this.insertIf(this.state.commits, commitSHAs), IDs.staged, ...this.insertIf(this.state.staged && !!staged.length, [IDs.commitAll]), ...this.insertIf(this.state.staged, staged), IDs.modified, ...this.insertIf(this.state.modified, modified), IDs.untracked, ...this.insertIf(this.state.untracked, untracked), ] return ids } public render() { const error = this.props.hasError && "Something Went Wrong!" const inactive = !this.props.activated && "Version Control Not Available" const warning = error || inactive const { IDs, logs, showHelp, loading, committing, filesToCommit, loadingSection, status: { modified, staged, untracked }, } = this.props const commitInProgress = loading && loadingSection === "commit" return warning ? ( {warning} ) : ( <> {!showHelp ? To show help press "?" : } ( this.toggleVisibility(IDs.commits)} /> this.toggleVisibility(IDs.staged)} handleCommitOne={this.handleCommitOne} handleCommitAll={this.handleCommitAll} handleCommitMessage={this.handleCommitMessage} handleCommitCancel={this.handleCommitCancel} /> this.toggleVisibility(IDs.modified)} /> this.toggleVisibility(IDs.untracked)} /> )} /> ) } } const mapStateToProps = (state: VersionControlState): IStateProps => ({ status: state.status, hasFocus: state.hasFocus, hasError: state.hasError, activated: state.activated, committing: state.commit.active, message: state.commit.message, selectedItem: state.selected, commits: state.commit.previousCommits, filesToCommit: state.commit.files, showHelp: state.help.active, loading: state.loading.active, loadingSection: state.loading.type, logs: state.logs, }) const ConnectedGitComponent = connect( mapStateToProps, VersionControlActions, )(VersionControlView) export default ConnectedGitComponent ================================================ FILE: browser/src/Services/VersionControl/index.ts ================================================ export { Diff, Summary, SupportedProviders, default as VersionControlProvider, } from "./VersionControlProvider" export { activate, getInstance, VersionControlManager } from "./VersionControlManager" export { default as VersionControlPane } from "./VersionControlPane" export { default as store } from "./VersionControlStore" export { default as VersionControlView } from "./VersionControlView" ================================================ FILE: browser/src/Services/VimConfigurationSynchronizer.ts ================================================ /** * VimConfigurationSynchronizer * * Helper method to synchronize configuration settings of the form: * `vim.globals.xxxx` * Ex: vim.globals.python_host_prog * `vim.setting.xxxx` */ import { INeovimInstance } from "./../neovim/NeovimInstance" export interface IConfigurationValues { [key: string]: any } const vimGlobalPrefix = "vim.global." const vimSettingPrefix = "vim.setting." // TODO: // - `onConfigChanged` with updated values // - Handle initial load case // - Update documentation / default config export const synchronizeConfiguration = ( neovimInstance: INeovimInstance, configuration: IConfigurationValues, ) => { const vimSettingKeys: string[] = Object.keys(configuration).filter( key => key.indexOf(vimSettingPrefix) === 0, ) vimSettingKeys.forEach(key => { const vimSettingValue: any = configuration[key] const baseSettingName = key.substring(vimSettingPrefix.length, key.length) if (typeof vimSettingValue === "boolean") { const settingValue = vimSettingValue ? baseSettingName : "no" + baseSettingName neovimInstance.command(`set ${settingValue}`) } else { neovimInstance.command(`set ${baseSettingName}=${vimSettingValue}`) } }) const vimGlobalKeys: string[] = Object.keys(configuration).filter( key => key.indexOf(vimGlobalPrefix) === 0, ) vimGlobalKeys.forEach(key => { const vimGlobalValue: any = configuration[key] const globalSettingName = key.substring(vimGlobalPrefix.length, key.length) neovimInstance.command(`let g:${globalSettingName}=${vimGlobalValue}`) }) } ================================================ FILE: browser/src/Services/WindowManager/LinearSplitProvider.ts ================================================ /** * TreeSplitProvider.ts * * Composite split provider responsible for managing * a tree-based hierarchy of horizontal and vertical splits */ import { Direction, IAugmentedSplitInfo, ISplitInfo, IWindowSplitProvider, SingleSplitProvider, SplitDirection, } from "./index" export const getInverseDirection = (splitDirection: SplitDirection): SplitDirection => { switch (splitDirection) { case "horizontal": return "vertical" case "vertical": default: return "horizontal" } } export class LinearSplitProvider implements IWindowSplitProvider { constructor( private _direction: SplitDirection, private _splitProviders: IWindowSplitProvider[] = [], ) {} public contains(split: IAugmentedSplitInfo): boolean { return this._getProviderForSplit(split) != null } public close(split: IAugmentedSplitInfo): boolean { const containingSplit = this._getProviderForSplit(split) if (!containingSplit) { return false } const handled = containingSplit.close(split) if (handled) { return true } // If it was unhandled by the split provider, but the provider contains it, then we'll remove the provider this._splitProviders = this._splitProviders.filter(prov => prov !== containingSplit) return true } public split( split: IAugmentedSplitInfo, direction: SplitDirection, referenceSplit?: IAugmentedSplitInfo, ): boolean { // If there are no children, we can just match direction if (this._splitProviders.length === 0) { this._direction = getInverseDirection(direction) this._splitProviders.push(new SingleSplitProvider(split)) return true } // If there is no reference split, we can just tack this split on if (!referenceSplit) { this._splitProviders.push(new SingleSplitProvider(split)) return true } const containingSplit = this._getProviderForSplit(referenceSplit) if (!containingSplit) { return false } const result = containingSplit.split(split, direction, referenceSplit) // Containing split handled it, so we're good if (result) { return true } // If the split requested is oriented differently, // create a new provider to handle that if (direction !== this._direction) { const singleSplitProvider = new SingleSplitProvider(split) this._splitProviders.push(singleSplitProvider) } else { // Otherwise, we can - let's wrap up the split in a provider const previousIndex = this._splitProviders.indexOf(containingSplit) const elementsBefore = this._splitProviders.slice(0, previousIndex) const elementsAfter = this._splitProviders.slice( previousIndex + 1, this._splitProviders.length, ) const children = [containingSplit, new SingleSplitProvider(split)] const childSplitProvider = new LinearSplitProvider( getInverseDirection(this._direction), children, ) this._splitProviders = [...elementsBefore, childSplitProvider, ...elementsAfter] } return true } public move(split: IAugmentedSplitInfo, direction: Direction): IAugmentedSplitInfo { if (!split) { if (this._direction === "horizontal") { const index = direction === "left" ? this._splitProviders.length - 1 : 0 return this._splitProviders[index].move(split, direction) } else { const index = direction === "up" ? this._splitProviders.length - 1 : 0 return this._splitProviders[index].move(split, direction) } } const containingSplit = this._getProviderForSplit(split) if (!containingSplit) { return null } const result = containingSplit.move(split, direction) if (result) { return result } if (!this._canHandleMove(direction)) { return null } // Since this wasn't handled by the containing split, let's try and handle it const originalIndex = this._splitProviders.indexOf(containingSplit) let increment = -1 if ( (this._direction === "horizontal" && direction === "right") || (this._direction === "vertical" && direction === "down") ) { increment = 1 } const newIndex = originalIndex + increment if (newIndex < 0 || newIndex >= this._splitProviders.length) { return null } // Move into the next split over return this._splitProviders[newIndex].move(null, direction) } public getState(): ISplitInfo { if (this._splitProviders.length === 0) { return null } return { type: "Split", direction: this._direction, splits: this._splitProviders.map(sp => sp.getState()).filter(s => s !== null), } } private _canHandleMove(direction: Direction): boolean { switch (direction) { case "up": return this._direction === "vertical" case "down": return this._direction === "vertical" case "left": return this._direction === "horizontal" case "right": return this._direction === "horizontal" default: return false } } private _getProviderForSplit(split: IAugmentedSplitInfo): IWindowSplitProvider { const providers = this._splitProviders.filter(prov => prov.contains(split)) return providers.length > 0 ? providers[0] : null } } ================================================ FILE: browser/src/Services/WindowManager/RelationalSplitNavigator.ts ================================================ /** * RelationalSplitProvider.ts * * Composite split provider responsible for managing * navigation relationships between other split provdiers */ import { Direction, getInverseDirection, IAugmentedSplitInfo, IWindowSplitNavigator } from "./index" export interface WindowSplitRelationship { from: IWindowSplitNavigator to: IWindowSplitNavigator direction: string } export class RelationalSplitNavigator implements IWindowSplitNavigator { private _relationships: WindowSplitRelationship[] = [] private _providers: IWindowSplitNavigator[] = [] public setRelationship( from: IWindowSplitNavigator, to: IWindowSplitNavigator, direction: Direction, ): void { this._relationships.push({ from, to, direction, }) // Also push the inverse this._relationships.push({ from: to, to: from, direction: getInverseDirection(direction), }) this._addToProvidersIfNeeded(from) this._addToProvidersIfNeeded(to) } public contains(split: IAugmentedSplitInfo): boolean { return this._getContainingSplit(split) !== null } public move(split: IAugmentedSplitInfo, direction: Direction): IAugmentedSplitInfo { // If there is no current split, that means we are entering if (split === null) { // Need to find the furthest split in the *reverse* direction. // For example, if we are moving *right* into this split, // we want to grab the furthest *left* split const reverseDirection = getInverseDirection(direction) const splitProvider = this._getFurthestSplitInDirection(reverseDirection, null) return splitProvider.move(null, direction) } const containingSplit = this._getContainingSplit(split) if (!containingSplit) { return null } // Check if the containing split handled it const moveResult = containingSplit.move(split, direction) if (moveResult) { return moveResult } // The containing split couldn't handle it, so let's see if there is a relationship from the containing split const applicableRelationship = this._relationships.filter( rel => rel.from === containingSplit && rel.direction === direction, ) if (applicableRelationship.length > 0) { return applicableRelationship[0].to.move(null, direction) } else { return null } } private _getFurthestSplitInDirection( direction: Direction, split: IWindowSplitNavigator, ): IWindowSplitNavigator { const splits = this._relationships.filter( rel => (rel.direction === direction && rel.from === split) || split === null, ) // Base case - there are no further splits in that direction, so return the current one if (splits.length === 0) { return split } // Recursive case - take the 'to' split and see if there is anything further const currentRelationship = splits[0] return this._getFurthestSplitInDirection(direction, currentRelationship.to) } private _getContainingSplit(split: IAugmentedSplitInfo): IWindowSplitNavigator { const providers = this._providers.filter(s => s.contains(split)) return providers.length === 0 ? null : providers[0] } private _addToProvidersIfNeeded(provider: IWindowSplitNavigator): void { if (this._providers.indexOf(provider) === -1) { this._providers.push(provider) } } } ================================================ FILE: browser/src/Services/WindowManager/SingleSplitProvider.ts ================================================ /** * SingleSplitProvider.ts * * Split provider for a leaf node */ import { Direction, IAugmentedSplitInfo, IWindowSplitProvider, SplitDirection, SplitOrLeaf, } from "./index" export class SingleSplitProvider implements IWindowSplitProvider { constructor(private _split: IAugmentedSplitInfo) {} public contains(split: IAugmentedSplitInfo): boolean { return this._split === split } public move(split: IAugmentedSplitInfo, direction: Direction): IAugmentedSplitInfo { if (split === null) { return this._split } else { return null } } public split(split: IAugmentedSplitInfo, direction: SplitDirection): boolean { return false } public close(split: IAugmentedSplitInfo): boolean { return false } public getState(): SplitOrLeaf { return { type: "Leaf", contents: this._split, } } } ================================================ FILE: browser/src/Services/WindowManager/WindowDock.ts ================================================ /** * WindowDock.ts */ import { Direction, IAugmentedSplitInfo, IWindowSplitNavigator } from "./index" export type DockStateGetter = () => IAugmentedSplitInfo[] export class WindowDockNavigator implements IWindowSplitNavigator { constructor(private _stateGetter: DockStateGetter) {} public contains(split: IAugmentedSplitInfo): boolean { const splits = this._stateGetter() return splits.indexOf(split) >= 0 } public move(startSplit: IAugmentedSplitInfo, direction: Direction): IAugmentedSplitInfo { const splits = this._stateGetter() const currentIndex = splits.indexOf(startSplit) if (currentIndex === -1) { if (direction === "left") { return splits[splits.length - 1] } else if (direction === "right") { return splits[0] } else { return null } } // TODO: Generalize this - this is baked for a 'left dock' case right now const newIndex = direction === "left" ? currentIndex - 1 : currentIndex + 1 if (newIndex >= 0 && newIndex < splits.length) { return splits[newIndex] } else { return null } } } ================================================ FILE: browser/src/Services/WindowManager/WindowManager.ts ================================================ /** * WindowManager.ts * * Responsible for managing state of the editor collection, and * switching between active editors. * * It also provides convenience methods for hooking events * to the active editor, and managing transitions between editors. */ import { remote } from "electron" import { Store } from "redux" import * as Oni from "oni-api" import { Event, IEvent } from "oni-types" import { Direction, SplitDirection } from "./index" import { LinearSplitProvider } from "./LinearSplitProvider" import { RelationalSplitNavigator } from "./RelationalSplitNavigator" import { WindowDockNavigator } from "./WindowDock" import { createStore, IAugmentedSplitInfo, ISplitInfo, leftDockSelector, WindowState, } from "./WindowManagerStore" export class WindowSplitHandle implements Oni.WindowSplitHandle { public get id(): string { return this._id } public get isVisible(): boolean { return this._store.getState().hiddenSplits.indexOf(this._id) === -1 } public get isFocused(): boolean { return this._store.getState().focusedSplitId === this._id } constructor( private _store: Store, private _windowManager: WindowManager, private _id: string, ) {} public hide(): void { this._store.dispatch({ type: "HIDE_SPLIT", splitId: this._id, }) this._windowManager.swapToPreviousSplit(this._id) } public show(): void { this._store.dispatch({ type: "SHOW_SPLIT", splitId: this._id, }) } public focus(): void { // TODO: this._windowManager.focusSplit(this._id) } public setSize(size: number): void { // TODO } public close(): void { this._windowManager.close(this._id) } } export class AugmentedWindow implements IAugmentedSplitInfo { public get id(): string { return this._id } constructor(private _id: string, private _innerSplit: Oni.IWindowSplit | any) {} public get innerSplit(): Oni.IWindowSplit { return this._innerSplit } public render(): JSX.Element { return this._innerSplit.render() } public enter(): void { if (this._innerSplit.enter) { this._innerSplit.enter() } } public leave(): void { if (this._innerSplit.leave) { this._innerSplit.leave() } } } export class WindowManager { private _lastId: number = 0 private _idToSplit: { [key: string]: IAugmentedSplitInfo } = {} private _onUnhandledMoveEvent = new Event() private _leftDock: WindowDockNavigator = null 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 { return this._onUnhandledMoveEvent } private _onFocusChanged = new Event>() public get onFocusChanged(): IEvent> { return this._onFocusChanged } public get splitRoot(): ISplitInfo { return this._primarySplit.getState() as ISplitInfo } public get store(): Store { return this._store } get activeSplitHandle(): WindowSplitHandle { return new WindowSplitHandle(this._store, this, this.activeSplit.id) } private get activeSplit(): IAugmentedSplitInfo { const focusedSplit = this._store.getState().focusedSplitId if (!focusedSplit) { return null } return this._idToSplit[focusedSplit] } 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") this._rootNavigator.setRelationship(this._leftDock, this._primarySplit, "right") } public createSplit( splitLocation: Direction | SplitDirection, newSplit: Oni.IWindowSplit, referenceSplit?: Oni.IWindowSplit, ): WindowSplitHandle { const nextId = this._lastId++ const windowId = "oni.window." + nextId.toString() const augmentedWindow = new AugmentedWindow(windowId, newSplit) this._idToSplit[windowId] = augmentedWindow switch (splitLocation) { case "right": case "up": case "down": case "left": { this._store.dispatch({ type: "ADD_DOCK_SPLIT", dock: splitLocation, split: augmentedWindow, }) break } case "horizontal": case "vertical": const augmentedRefSplit = this._getAugmentedWindowSplitFromSplit(referenceSplit) || this.activeSplit this._primarySplit.split(augmentedWindow, splitLocation, augmentedRefSplit) const newState = this._primarySplit.getState() as ISplitInfo this._store.dispatch({ type: "SET_PRIMARY_SPLITS", splits: newState, }) this._focusNewSplit(augmentedWindow) } return new WindowSplitHandle(this._store, this, windowId) } public getSplitHandle(split: Oni.IWindowSplit): WindowSplitHandle { const augmentedSplit = this._getAugmentedWindowSplitFromSplit(split) return new WindowSplitHandle(this._store, this, augmentedSplit.id) } public move(direction: Direction): void { const focusedSplit = this._store.getState().focusedSplitId if (!focusedSplit) { return } const activeSplit = this._idToSplit[focusedSplit] if (!activeSplit) { return } const newSplit = this._rootNavigator.move(activeSplit, direction) if (newSplit) { this._focusNewSplit(newSplit) } else { this._onUnhandledMoveEvent.dispatch(direction) } } public moveLeft(): void { this.move("left") } public moveRight(): void { this.move("right") } public moveUp(): void { this.move("up") } public moveDown(): void { this.move("down") } public close(splitId: string) { this.swapToPreviousSplit(splitId) delete this._idToSplit[splitId] } // Swaps focus to the most recently focused window, and hides // the passed split. public swapToPreviousSplit(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) const state = this._primarySplit.getState() this._store.dispatch({ type: "SET_PRIMARY_SPLITS", splits: state, }) } public focusSplit(splitId: string): void { const split = this._idToSplit[splitId] this._focusNewSplit(split) } private _getAugmentedWindowSplitFromSplit(split: Oni.IWindowSplit): IAugmentedSplitInfo { const augmentedWindows = Object.values(this._idToSplit) return augmentedWindows.find(aw => aw.innerSplit === split) || null } private _focusNewSplit(newSplit: any): void { if (this.activeSplit && this.activeSplit.leave) { this.activeSplit.leave() } this._store.dispatch({ type: "SET_FOCUSED_SPLIT", splitId: newSplit.id, }) const filteredSplits = this._focusQueue.filter(f => f !== newSplit.id) this._focusQueue = [newSplit.id, ...filteredSplits] if (newSplit && newSplit.enter) { newSplit.enter() } } } ================================================ FILE: browser/src/Services/WindowManager/WindowManagerStore.ts ================================================ /** * WindowManagerStore.ts * * Redux store for managing window state */ import * as Oni from "oni-api" import { Reducer, Store } from "redux" import { createStore as createReduxStore } from "./../../Redux" import { Direction, ISplitInfo, SplitDirection } from "./index" export interface IAugmentedSplitInfo extends Oni.IWindowSplit { // Internal bookkeeping id: string innerSplit: Oni.IWindowSplit // Potential API methods enter?(): void leave?(): void } export type SplitOrLeaf = ISplitInfo | ISplitLeaf export interface ISplitInfo { type: "Split" splits: Array> direction: SplitDirection } export interface ISplitLeaf { type: "Leaf" contents: T } type WindowActions = | { type: "ADD_DOCK_SPLIT" dock: Direction split: IAugmentedSplitInfo } | { type: "SET_PRIMARY_SPLITS" splits: ISplitInfo } | { type: "SET_FOCUSED_SPLIT" splitId: string } | { type: "SHOW_SPLIT" splitId: string } | { type: "HIDE_SPLIT" splitId: string } export interface DockWindows { [key: string]: IAugmentedSplitInfo[] } export const DefaultDocksState: DockWindows = { left: [], right: [], up: [], down: [], } export interface WindowState { docks: DockWindows primarySplit: ISplitInfo focusedSplitId: string hiddenSplits: string[] } export const DefaultWindowState: WindowState = { docks: DefaultDocksState, primarySplit: null, focusedSplitId: null, hiddenSplits: [], } export const reducer: Reducer = ( state: WindowState = DefaultWindowState, action: WindowActions, ) => { switch (action.type) { case "SET_PRIMARY_SPLITS": return { ...state, primarySplit: action.splits, } case "SET_FOCUSED_SPLIT": return { ...state, focusedSplitId: action.splitId, } case "SHOW_SPLIT": return { ...state, hiddenSplits: state.hiddenSplits.filter(s => s !== action.splitId), } case "HIDE_SPLIT": return { ...state, hiddenSplits: [ ...state.hiddenSplits.filter(s => s !== action.splitId), action.splitId, ], } default: return { ...state, docks: docksReducer(state.docks, action), } } } export const docksReducer: Reducer = ( state: DockWindows = DefaultDocksState, action: WindowActions, ) => { switch (action.type) { case "ADD_DOCK_SPLIT": return { ...state, [action.dock]: [...state[action.dock], action.split], } default: return state } } export const leftDockSelector = (state: WindowState) => { return state.docks.left.filter(s => state.hiddenSplits.indexOf(s.id) === -1) } export const createStore = (): Store => { return createReduxStore("WindowManager", reducer, DefaultWindowState, []) } ================================================ FILE: browser/src/Services/WindowManager/index.ts ================================================ /** * WindowManager.ts * * Responsible for managing state of the editor collection, and * switching between active editors. * * It also provides convenience methods for hooking events * to the active editor, and managing transitions between editors. */ export * from "./layoutFromSplitInfo" export * from "./LinearSplitProvider" export * from "./RelationalSplitNavigator" export * from "./SingleSplitProvider" export * from "./WindowDock" export * from "./WindowManager" export * from "./WindowManagerStore" // TODO: Possible API types? export type Direction = "up" | "down" | "left" | "right" export type SplitDirection = "horizontal" | "vertical" export const getInverseDirection = (direction: Direction): Direction => { switch (direction) { case "up": return "down" case "down": return "up" case "left": return "right" case "right": return "left" default: return null } } import { WindowManager } from "./WindowManager" import { IAugmentedSplitInfo, SplitOrLeaf } from "./WindowManagerStore" /** * Interface for something that can navigate between window splits */ export interface IWindowSplitNavigator { contains(split: IAugmentedSplitInfo): boolean move(startSplit: IAugmentedSplitInfo, direction: Direction): IAugmentedSplitInfo } /** * Interface for something that can manage window splits: * - Navigating splits * - Creating a new split * - Removing a split * Later - resizing a split? */ export interface IWindowSplitProvider extends IWindowSplitNavigator { split( newSplit: IAugmentedSplitInfo, direction: SplitDirection, referenceSplit?: IAugmentedSplitInfo, ): boolean close(split: IAugmentedSplitInfo): boolean getState(): SplitOrLeaf } export const windowManager = new WindowManager() ================================================ FILE: browser/src/Services/WindowManager/layoutFromSplitInfo.ts ================================================ /** * layoutFromSplitInfo.ts * * Function to layout splits into a particular window */ import * as Oni from "oni-api" import { IAugmentedSplitInfo, ISplitInfo, SplitOrLeaf } from "./WindowManagerStore" export interface LayoutResultInfo { rectangle: Oni.Shapes.Rectangle split: IAugmentedSplitInfo } export interface LayoutResult { [windowId: string]: LayoutResultInfo } export const layoutFromSplitInfo = ( splits: ISplitInfo, width: number, height: number, ): LayoutResult => { return layoutFromSplitInfoHelper(splits, Oni.Shapes.Rectangle.create(0, 0, width, height)) } const layoutFromSplitInfoHelper = ( split: SplitOrLeaf, rectangle: Oni.Shapes.Rectangle, ): LayoutResult => { // Base case.. if (split.type === "Leaf") { return { [split.contents.id]: { rectangle, split: split.contents, }, } } if (split.splits.length === 0) { return {} } // Recursive case // // TODO: Handle specified sizes for the windows. We're just distributing the space evenly, currently. const splitWidth = split.direction === "horizontal" ? rectangle.width / split.splits.length : rectangle.width const splitHeight = split.direction === "vertical" ? rectangle.height / split.splits.length : rectangle.height let ret = {} for (let i = 0; i < split.splits.length; i++) { const x = split.direction === "horizontal" ? rectangle.x + splitWidth * i : rectangle.x const y = split.direction === "vertical" ? rectangle.y + splitHeight * i : rectangle.y const rect = Oni.Shapes.Rectangle.create(x, y, splitWidth, splitHeight) ret = { ...ret, ...layoutFromSplitInfoHelper(split.splits[i], rect), } } return ret } ================================================ FILE: browser/src/Services/Workspace/Workspace.ts ================================================ /** * Workspace.ts * * The 'workspace' is responsible for managing the state of the current project: * - The current / active directory (and 'Open Folder') */ import { remote } from "electron" import * as findup from "find-up" import { stat } from "fs" import * as path from "path" import { promisify } from "util" import "rxjs/add/observable/defer" import "rxjs/add/observable/from" import "rxjs/add/operator/concatMap" import "rxjs/add/operator/toPromise" import { Observable } from "rxjs/Observable" import * as types from "vscode-languageserver-types" import * as Oni from "oni-api" import * as Log from "oni-core-logging" import { Event, IEvent } from "oni-types" import * as Helpers from "./../../Plugins/Api/LanguageClient/LanguageClientHelpers" import { Configuration } from "./../Configuration" import { EditorManager } from "./../EditorManager" import { convertTextDocumentChangesToFileMap } from "./../Language/Edits" import { WorkspaceConfiguration } from "./WorkspaceConfiguration" const fsStat = promisify(stat) export class Workspace implements Oni.Workspace.Api { private _onDirectoryChangedEvent = new Event() private _onFocusGainedEvent = new Event() private _onFocusLostEvent = new Event() private _mainWindow = remote.getCurrentWindow() private _lastActiveBuffer: Oni.Buffer private _activeWorkspace: string public get activeWorkspace(): string { return this._activeWorkspace } constructor(private _editorManager: EditorManager, private _configuration: Configuration) { this._mainWindow.on("focus", () => { this._onFocusGainedEvent.dispatch(this._lastActiveBuffer) }) this._mainWindow.on("blur", () => { this._lastActiveBuffer = this._editorManager.activeEditor.activeBuffer this._onFocusLostEvent.dispatch(this._lastActiveBuffer) }) } public get onDirectoryChanged(): IEvent { return this._onDirectoryChangedEvent } public async changeDirectory(newDirectory: string) { const exists = await this.pathIsDir(newDirectory) const dir = exists ? newDirectory : null if (newDirectory && exists) { process.chdir(newDirectory) } this._activeWorkspace = dir this._onDirectoryChangedEvent.dispatch(dir) } public async applyEdits(edits: types.WorkspaceEdit): Promise { const editsToUse = edits.documentChanges ? convertTextDocumentChangesToFileMap(edits.documentChanges) : edits.changes const files = Object.keys(editsToUse) // TODO: Show modal to grab input // await editorManager.activeEditor.openFiles(files) const deferredEdits = await files.map((fileUri: string) => { return Observable.defer(async () => { const changes = editsToUse[fileUri] const fileName = Helpers.unwrapFileUriPath(fileUri) // TODO: Sort changes? Log.verbose("[Workspace] Opening file: " + fileName) const buf = await this._editorManager.activeEditor.openFile(fileName) Log.verbose( "[Workspace] Got buffer for file: " + buf.filePath + " and id: " + buf.id, ) await buf.applyTextEdits(changes) Log.verbose("[Workspace] Applied " + changes.length + " edits to buffer") }) }) await Observable.from(deferredEdits) .concatMap(de => de) .toPromise() Log.verbose("[Workspace] Completed applying edits") // Hide modal } public get onFocusGained(): IEvent { return this._onFocusGainedEvent } public get onFocusLost(): IEvent { return this._onFocusLostEvent } public pathIsDir = async (p: string) => { try { const stats = await fsStat(p) return stats.isDirectory() } catch (error) { Log.info(error) return false } } public navigateToProjectRoot = async (bufferPath: string) => { const projectMarkers = this._configuration.getValue("workspace.autoDetectRootFiles") // 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 projectRoot = path.dirname(filePath) return projectRoot !== this._activeWorkspace ? this.changeDirectory(projectRoot) : null } } public openFolder(): void { const dialogOptions: any = { title: "Open Folder", properties: ["openDirectory"], } remote.dialog.showOpenDialog( remote.getCurrentWindow(), dialogOptions, async (folder: string[]) => { if (!folder || !folder[0]) { return } const folderToOpen = folder[0] await this.changeDirectory(folderToOpen) }, ) } public autoDetectWorkspace(filePath: string): void { const settings = this._configuration.getValue("workspace.autoDetectWorkspace") switch (settings) { case "never": break case "always": this.navigateToProjectRoot(filePath) break case "noworkspace": default: if (!this._activeWorkspace) { this.navigateToProjectRoot(filePath) } } } } let _workspace: Workspace = null let _workspaceConfiguration: WorkspaceConfiguration = null export const activate = ( configuration: Configuration, editorManager: EditorManager, workspaceToLoad?: string, ): void => { _workspace = new Workspace(editorManager, configuration) _workspaceConfiguration = new WorkspaceConfiguration(configuration, _workspace) const defaultWorkspace = workspaceToLoad || configuration.getValue("workspace.defaultWorkspace") if (defaultWorkspace) { _workspace.changeDirectory(defaultWorkspace) } _workspace.onDirectoryChanged.subscribe(newDirectory => { configuration.setValues({ "workspace.defaultWorkspace": newDirectory }, true) }) } export const getInstance = (): Workspace => { return _workspace } export const getConfigurationInstance = (): WorkspaceConfiguration => { return _workspaceConfiguration } ================================================ FILE: browser/src/Services/Workspace/WorkspaceCommands.ts ================================================ /** * WorkspaceCommands.ts * * Commands registered for the workspace */ import * as fs from "fs" import * as path from "path" import * as mkdirp from "mkdirp" import { CallbackCommand, commandManager } from "./../CommandManager" import { Configuration } from "./../Configuration" import { EditorManager } from "./../EditorManager" import * as FileMappings from "./../FileMappings" import { SnippetManager } from "./../Snippets" import { Workspace } from "./Workspace" export const activateCommands = ( configuration: Configuration, editorManager: EditorManager, snippetManager: SnippetManager, workspace: Workspace, ) => { const openTestFileInSplit = async () => { const mappingResult = getTestFileMappedToCurrentFile() const mappedFile = mappingResult.fullPath const templateFile = mappingResult.templateFileFullPath if (mappedFile) { let snippetToInsert: string = null if (!fs.existsSync(mappedFile)) { // Ensure the folder exists for the mapped file const containingFolder = path.dirname(mappedFile) mkdirp.sync(containingFolder) if (templateFile && fs.existsSync(templateFile)) { snippetToInsert = fs.readFileSync(templateFile).toString("utf8") } } await editorManager.activeEditor.openFile(mappedFile) if (snippetToInsert) { await snippetManager.insertSnippet(snippetToInsert) } } } const hasExistingTestFile = () => { const mappedFile = getTestFileMappedToCurrentFile() return mappedFile && fs.existsSync(mappedFile.fullPath) } const canCreateTestFile = () => { const mappedFile = getTestFileMappedToCurrentFile() return mappedFile && !fs.existsSync(mappedFile.fullPath) } const getTestFileMappedToCurrentFile = (): FileMappings.IFileMappingResult => { const mappings: FileMappings.IFileMapping[] = configuration.getValue( "workspace.testFileMappings", ) if (!mappings) { return null } // If we have no active workspace, we don't know where test files live. if (!workspace.activeWorkspace) { return null } const currentEditor = editorManager.activeEditor if (!currentEditor || !currentEditor.activeBuffer) { return null } const currentBufferPath = currentEditor.activeBuffer.filePath if (!currentBufferPath) { return null } const mappedFile = FileMappings.getMappedFile( workspace.activeWorkspace, currentBufferPath, mappings, ) return mappedFile } const commands = [ new CallbackCommand( "workspace.openFolder", "Workspace: Open Folder", "Set a folder as the working directory for Oni", () => workspace.openFolder(), ), new CallbackCommand( "workspace.openTestFile", "Workspace: Open Test File", "Open the test file corresponding to this source file.", () => openTestFileInSplit(), () => hasExistingTestFile(), ), new CallbackCommand( "workspace.createTestFile", "Workspace: Create Test File", "Create a test file for this source file.", () => openTestFileInSplit(), () => canCreateTestFile(), ), new CallbackCommand( "workspace.closeFolder", "Workspace: Close Folder", "Close the current folder", async () => workspace.changeDirectory(null), () => !!workspace.activeWorkspace, ), ] commands.forEach(c => commandManager.registerCommand(c)) } ================================================ FILE: browser/src/Services/Workspace/WorkspaceConfiguration.ts ================================================ /** * WorkspaceConfiguration.ts * * Responsible for managing settings / loading configuration for current workspace */ import * as fs from "fs" import * as Oni from "oni-api" import * as path from "path" import * as Log from "oni-core-logging" import { Configuration } from "./../Configuration" export const getWorkspaceConfigurationPath = (workspacePath: string): string => { return path.join(workspacePath, ".oni", "config.js") } export class WorkspaceConfiguration { private _activeWorkspaceConfiguration: string = null public get activeWorkspaceConfiguration(): string { return this._activeWorkspaceConfiguration } constructor( private _configuration: Configuration, private _workspace: Oni.Workspace.Api, private _fs: typeof fs = fs, ) { this._checkWorkspaceConfiguration() this._workspace.onDirectoryChanged.subscribe(() => { this._checkWorkspaceConfiguration() }) } private _checkWorkspaceConfiguration(): void { const activeWorkspace = this._workspace.activeWorkspace if (!activeWorkspace) { return } const configurationPath = getWorkspaceConfigurationPath(activeWorkspace) if ( this._fs.existsSync(configurationPath) && this._fs.statSync(configurationPath).isFile() ) { Log.info("[WorkspaceConfiguration] Found configuration file at: " + configurationPath) this._loadWorkspaceConfiguration(configurationPath) } } private _removePreviousWorkspaceConfiguration(): void { if (this._activeWorkspaceConfiguration) { this._configuration.removeConfigurationFile(this._activeWorkspaceConfiguration) this._activeWorkspaceConfiguration = null } } private _loadWorkspaceConfiguration(configurationPath: string): void { Log.info( "[WorkspaceConfiguration] Loading workspace configuration from: " + configurationPath, ) this._removePreviousWorkspaceConfiguration() this._activeWorkspaceConfiguration = configurationPath this._configuration.addConfigurationFile(configurationPath) } } ================================================ FILE: browser/src/Services/Workspace/find-up.d.ts ================================================ declare module "find-up" ================================================ FILE: browser/src/Services/Workspace/index.ts ================================================ export * from "./Workspace" export * from "./WorkspaceConfiguration" ================================================ FILE: browser/src/UI/Icon.tsx ================================================ import * as React from "react" export const Default = "" export const Large = "fa-lg" 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, Large, TwoX, 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