diff --git a/browser/src/App.ts b/browser/src/App.ts index 7cc3ad5fe7..ab9efcbf99 100644 --- a/browser/src/App.ts +++ b/browser/src/App.ts @@ -75,6 +75,7 @@ export const start = async (args: string[]): Promise => { 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") @@ -327,6 +328,10 @@ export const start = async (args: string[]): Promise => { Sidebar.getInstance(), WindowManager.windowManager, ) + + const Sessions = await sessionManagerPromise + Sessions.activate(oniApi, sidebarManager) + Performance.endMeasure("Oni.Start.Sidebar") const createLanguageClientsFromConfiguration = diff --git a/browser/src/Editor/NeovimEditor/NeovimEditor.tsx b/browser/src/Editor/NeovimEditor/NeovimEditor.tsx index 171584c190..348b848da7 100644 --- a/browser/src/Editor/NeovimEditor/NeovimEditor.tsx +++ b/browser/src/Editor/NeovimEditor/NeovimEditor.tsx @@ -44,6 +44,7 @@ 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" @@ -99,6 +100,8 @@ import { CanvasRenderer } from "../../Renderer/CanvasRenderer" import { WebGLRenderer } from "../../Renderer/WebGL/WebGLRenderer" 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 @@ -889,6 +892,31 @@ export class NeovimEditor extends Editor implements Oni.Editor { ) } + // "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, @@ -1295,4 +1323,16 @@ export class NeovimEditor extends Editor implements Oni.Editor { } } } + + 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) + } + } } diff --git a/browser/src/Editor/OniEditor/OniEditor.tsx b/browser/src/Editor/OniEditor/OniEditor.tsx index ce02b57602..6f711b5d2d 100644 --- a/browser/src/Editor/OniEditor/OniEditor.tsx +++ b/browser/src/Editor/OniEditor/OniEditor.tsx @@ -49,6 +49,7 @@ import { NeovimEditor } from "./../NeovimEditor" import { SplitDirection, windowManager } from "./../../Services/WindowManager" +import { ISession } from "../../Services/Sessions" import { IBuffer } from "../BufferManager" import ColorHighlightLayer from "./ColorHighlightLayer" import { ImageBufferLayer } from "./ImageBufferLayer" @@ -101,6 +102,10 @@ export class OniEditor extends Utility.Disposable implements Oni.Editor { return this._neovimEditor.activeBuffer } + public get onQuit(): IEvent { + return this._neovimEditor.onNeovimQuit + } + // Capabilities public get neovim(): Oni.NeovimEditorCapability { return this._neovimEditor.neovim @@ -288,6 +293,18 @@ export class OniEditor extends Utility.Disposable implements Oni.Editor { 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() } diff --git a/browser/src/Input/KeyBindings.ts b/browser/src/Input/KeyBindings.ts index d5e9d5c0f3..adb220a809 100644 --- a/browser/src/Input/KeyBindings.ts +++ b/browser/src/Input/KeyBindings.ts @@ -35,6 +35,7 @@ export const applyDefaultKeyBindings = (oni: Oni.Plugin.Api, config: Configurati !isMenuOpen() const isExplorerActive = () => isSidebarPaneOpen("oni.sidebar.explorer") + const areSessionsActive = () => isSidebarPaneOpen("oni.sidebar.sessions") const isVCSActive = () => isSidebarPaneOpen("oni.sidebar.vcs") const isMenuOpen = () => menu.isMenuOpen() @@ -168,4 +169,7 @@ export const applyDefaultKeyBindings = (oni: Oni.Plugin.Api, config: Configurati input.bind("u", "vcs.unstage", isVCSActive) input.bind("", "vcs.refresh", isVCSActive) input.bind("?", "vcs.showHelp", isVCSActive) + + // Sessions + input.bind("", "oni.sessions.delete", areSessionsActive) } diff --git a/browser/src/PersistentStore.ts b/browser/src/PersistentStore.ts index 22840230e4..1b4fb63684 100644 --- a/browser/src/PersistentStore.ts +++ b/browser/src/PersistentStore.ts @@ -15,6 +15,8 @@ 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 = ( @@ -70,4 +72,12 @@ export class PersistentStore implements IPersistentStore { 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}`) + } } diff --git a/browser/src/Services/Configuration/DefaultConfiguration.ts b/browser/src/Services/Configuration/DefaultConfiguration.ts index 4384530edb..758fecd66d 100644 --- a/browser/src/Services/Configuration/DefaultConfiguration.ts +++ b/browser/src/Services/Configuration/DefaultConfiguration.ts @@ -53,10 +53,12 @@ const BaseConfiguration: IConfigurationValues = { "wildmenu.mode": true, "commandline.mode": true, "commandline.icons": true, - "experimental.vcs.sidebar": false, - "experimental.particles.enabled": false, "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, diff --git a/browser/src/Services/Configuration/IConfigurationValues.ts b/browser/src/Services/Configuration/IConfigurationValues.ts index 354eb8b235..2af6c5d219 100644 --- a/browser/src/Services/Configuration/IConfigurationValues.ts +++ b/browser/src/Services/Configuration/IConfigurationValues.ts @@ -48,7 +48,10 @@ export interface IConfigurationValues { // 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 diff --git a/browser/src/Services/Sessions/SessionManager.ts b/browser/src/Services/Sessions/SessionManager.ts new file mode 100644 index 0000000000..1234d65f06 --- /dev/null +++ b/browser/src/Services/Sessions/SessionManager.ts @@ -0,0 +1,179 @@ +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 + sessions: 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._sidebarManager.add( + "save", + new SessionsPane({ store: this._store, commands: this._oni.commands }), + ) + } + this._setupSubscriptions() + } + + public get sessions() { + return this._store.getState().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() diff --git a/browser/src/Services/Sessions/Sessions.tsx b/browser/src/Services/Sessions/Sessions.tsx new file mode 100644 index 0000000000..f37748ce9d --- /dev/null +++ b/browser/src/Services/Sessions/Sessions.tsx @@ -0,0 +1,228 @@ +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) diff --git a/browser/src/Services/Sessions/SessionsPane.tsx b/browser/src/Services/Sessions/SessionsPane.tsx new file mode 100644 index 0000000000..218019ea03 --- /dev/null +++ b/browser/src/Services/Sessions/SessionsPane.tsx @@ -0,0 +1,71 @@ +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, + }) + } +} diff --git a/browser/src/Services/Sessions/SessionsStore.ts b/browser/src/Services/Sessions/SessionsStore.ts new file mode 100644 index 0000000000..739b424893 --- /dev/null +++ b/browser/src/Services/Sessions/SessionsStore.ts @@ -0,0 +1,303 @@ +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 diff --git a/browser/src/Services/Sessions/index.ts b/browser/src/Services/Sessions/index.ts new file mode 100644 index 0000000000..27c354b905 --- /dev/null +++ b/browser/src/Services/Sessions/index.ts @@ -0,0 +1,5 @@ +export * from "./SessionManager" +export * from "./SessionsStore" +export { default as SessionsPane } from "./SessionsPane" +export { default as Sessions } from "./Sessions" +export { default as store } from "./SessionsStore" diff --git a/browser/src/Services/VersionControl/VersionControlView.tsx b/browser/src/Services/VersionControl/VersionControlView.tsx index eab36e6aba..253bb821d8 100644 --- a/browser/src/Services/VersionControl/VersionControlView.tsx +++ b/browser/src/Services/VersionControl/VersionControlView.tsx @@ -2,9 +2,9 @@ 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 { SectionTitle, Title } from "./../../UI/components/VersionControl/SectionTitle" import StagedSection from "./../../UI/components/VersionControl/Staged" import VersionControlStatus from "./../../UI/components/VersionControl/Status" import { VimNavigator } from "./../../UI/components/VimNavigator" diff --git a/browser/src/UI/components/LightweightText.tsx b/browser/src/UI/components/LightweightText.tsx index 16bae99731..851ce6a13e 100644 --- a/browser/src/UI/components/LightweightText.tsx +++ b/browser/src/UI/components/LightweightText.tsx @@ -32,6 +32,10 @@ const WordRegex = /[$_a-zA-Z0-9]/i * common functionality (like focus management, key handling) */ export class TextInputView extends React.PureComponent { + public static defaultProps = { + InputComponent: Input, + } + private _element: HTMLInputElement public componentDidMount(): void { @@ -48,7 +52,7 @@ export class TextInputView extends React.PureComponent } const defaultValue = this.props.defaultValue || "" - const { InputComponent = Input } = this.props + const { InputComponent } = this.props return (
diff --git a/browser/src/UI/components/VersionControl/SectionTitle.tsx b/browser/src/UI/components/SectionTitle.tsx similarity index 79% rename from browser/src/UI/components/VersionControl/SectionTitle.tsx rename to browser/src/UI/components/SectionTitle.tsx index 9475270d85..94f0ae057a 100644 --- a/browser/src/UI/components/VersionControl/SectionTitle.tsx +++ b/browser/src/UI/components/SectionTitle.tsx @@ -1,7 +1,7 @@ import * as React from "react" -import Caret from "./../Caret" -import { sidebarItemSelected, styled, withProps } from "./../common" +import Caret from "./Caret" +import { sidebarItemSelected, styled, withProps } from "./common" export const Title = styled.h4` margin: 0; @@ -25,7 +25,7 @@ interface IProps { testId: string } -const VCSSectionTitle: React.SFC = props => ( +const SidebarSectionTitle: React.SFC = props => ( {props.title.toUpperCase()} @@ -33,4 +33,4 @@ const VCSSectionTitle: React.SFC = props => ( ) -export default VCSSectionTitle +export default SidebarSectionTitle diff --git a/browser/src/UI/components/VersionControl/Commits.tsx b/browser/src/UI/components/VersionControl/Commits.tsx index ec6cb6c46b..153da57963 100644 --- a/browser/src/UI/components/VersionControl/Commits.tsx +++ b/browser/src/UI/components/VersionControl/Commits.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { Logs } from "../../../Services/VersionControl/VersionControlProvider" import { sidebarItemSelected, styled, withProps } from "./../../../UI/components/common" import { formatDate } from "./../../../Utility" -import VCSSectionTitle from "./SectionTitle" +import VCSSectionTitle from "./../SectionTitle" interface ICommitsSection { commits: Logs["all"] diff --git a/browser/src/UI/components/VersionControl/Help.tsx b/browser/src/UI/components/VersionControl/Help.tsx index abf104cd2e..8ce2874a48 100644 --- a/browser/src/UI/components/VersionControl/Help.tsx +++ b/browser/src/UI/components/VersionControl/Help.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { inputManager } from "../../../Services/InputManager" import styled from "../common" -import { SectionTitle, Title } from "./SectionTitle" +import { SectionTitle, Title } from "./../SectionTitle" const sidebarCommands = [ { command: "vcs.openFile", description: "Open the currently selected file" }, diff --git a/browser/src/UI/components/VersionControl/Staged.tsx b/browser/src/UI/components/VersionControl/Staged.tsx index 3c581cb47d..40aa90e96b 100644 --- a/browser/src/UI/components/VersionControl/Staged.tsx +++ b/browser/src/UI/components/VersionControl/Staged.tsx @@ -1,10 +1,10 @@ import * as React from "react" import styled, { Center, sidebarItemSelected, withProps } from "../common" +import SectionTitle from "../SectionTitle" import { LoadingSpinner } from "./../../../UI/components/LoadingSpinner" import CommitMessage from "./CommitMessage" import File from "./File" -import SectionTitle from "./SectionTitle" const Explainer = styled.div` width: 100%; diff --git a/browser/src/UI/components/VersionControl/Status.tsx b/browser/src/UI/components/VersionControl/Status.tsx index 92b8d9300d..776ae8ca0e 100644 --- a/browser/src/UI/components/VersionControl/Status.tsx +++ b/browser/src/UI/components/VersionControl/Status.tsx @@ -1,7 +1,7 @@ import * as React from "react" +import VCSSectionTitle from "../SectionTitle" import File from "./File" -import VCSSectionTitle from "./SectionTitle" interface IModifiedFilesProps { files?: string[] diff --git a/browser/src/UI/components/VimNavigator.tsx b/browser/src/UI/components/VimNavigator.tsx index 065795aecd..93c1f3f809 100644 --- a/browser/src/UI/components/VimNavigator.tsx +++ b/browser/src/UI/components/VimNavigator.tsx @@ -32,7 +32,7 @@ export interface IVimNavigatorProps { onSelectionChanged?: (selectedId: string) => void onSelected?: (selectedId: string) => void - render: (selectedId: string) => JSX.Element + render: (selectedId: string, updateSelection: (id: string) => void) => JSX.Element style?: React.CSSProperties idToSelect?: string @@ -66,6 +66,10 @@ export class VimNavigator extends React.PureComponent { + this.setState({ selectedId: id }) + } + public render() { const inputElement = (
@@ -85,7 +89,9 @@ export class VimNavigator extends React.PureComponent -
{this.props.render(this.state.selectedId)}
+
+ {this.props.render(this.state.selectedId, this.updateSelection)} +
{this.props.active ? inputElement : null}
) diff --git a/browser/src/UI/components/common.ts b/browser/src/UI/components/common.ts index 23070bb22e..68eff28003 100644 --- a/browser/src/UI/components/common.ts +++ b/browser/src/UI/components/common.ts @@ -78,8 +78,10 @@ export const StackLayer = styled<{ zIndex?: number | string }, "div">("div")` ` export const sidebarItemSelected = css` - border: ${(p: any) => - p.isSelected && `1px solid ${p.theme["highlight.mode.normal.background"]}`}; + border: ${(p: { isSelected?: boolean; theme?: styledComponents.ThemeProps }) => + p.isSelected + ? `1px solid ${p.theme["highlight.mode.normal.background"]}` + : `1px solid transparent`}; ` export type StyledFunction = styledComponents.ThemedStyledFunction diff --git a/browser/src/neovim/NeovimInstance.ts b/browser/src/neovim/NeovimInstance.ts index 6f4c9dbaf6..208ce294f5 100644 --- a/browser/src/neovim/NeovimInstance.ts +++ b/browser/src/neovim/NeovimInstance.ts @@ -560,6 +560,15 @@ export class NeovimInstance extends EventEmitter implements INeovimInstance { return this.command(`e! ${fileName}`) } + /** + * closeAllBuffers + * + * silently close all open buffers + */ + public async closeAllBuffers() { + await this.command(`silent! %bdelete`) + } + /** * getInitVimPath * return the init vim path with no check to ensure existence diff --git a/browser/test/Mocks/MockPersistentStore.ts b/browser/test/Mocks/MockPersistentStore.ts index e94c68c2c0..4e60bd3cbf 100644 --- a/browser/test/Mocks/MockPersistentStore.ts +++ b/browser/test/Mocks/MockPersistentStore.ts @@ -18,4 +18,13 @@ export class MockPersistentStore implements IPersistentStore { public async get(): Promise { return this._state } + + public async delete(key: string): Promise { + this._state[key] = undefined + return this._state + } + + public has(key: string) { + return !!this._state[key] + } } diff --git a/jest.config.js b/jest.config.js index 0267ce6f18..effa337767 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,7 +9,9 @@ module.exports = { PersistentSettings: "/ui-tests/mocks/PersistentSettings.ts", Utility: "/ui-tests/mocks/Utility.ts", Configuration: "/ui-tests/mocks/Configuration.ts", + UserConfiguration: "/ui-tests/mocks/UserConfiguration.ts", KeyboardLayout: "/ui-tests/mocks/keyboardLayout.ts", + SharedNeovimInstance: "/ui-tests/mocks/SharedNeovimInstance.ts", }, snapshotSerializers: ["enzyme-to-json/serializer"], transform: { diff --git a/package.json b/package.json index 508efe83a4..e7dc916f50 100644 --- a/package.json +++ b/package.json @@ -672,7 +672,7 @@ "start-not-dev": "cross-env electron main.js", "watch:browser": "webpack-dev-server --config browser/webpack.development.config.js --host localhost --port 8191", - "watch:plugins": "run-p watch:plugins:*", + "watch:plugins": "run-p --race watch:plugins:*", "watch:plugins:oni-plugin-typescript": "cd vim/core/oni-plugin-typescript && tsc --watch", "watch:plugins:oni-plugin-markdown-preview": "cd extensions/oni-plugin-markdown-preview && tsc --watch", diff --git a/ui-tests/SessionManager.test.tsx b/ui-tests/SessionManager.test.tsx new file mode 100644 index 0000000000..1e1ac476ce --- /dev/null +++ b/ui-tests/SessionManager.test.tsx @@ -0,0 +1,75 @@ +import * as path from "path" + +import { + ISession, + SessionManager, + UpdatedOni, +} from "./../browser/src/Services/Sessions/SessionManager" + +import Oni from "./mocks/Oni" +import Sidebar from "./mocks/Sidebar" + +jest.mock("./../browser/src/Services/Configuration/UserConfiguration", () => ({ + getUserConfigFolderPath: jest.fn().mockReturnValue("~/.config/oni"), +})) + +interface IStore { + [key: string]: ISession +} + +const mockPersistentStore = { + _store: {} as IStore, + get(): Promise<{ [key: string]: ISession }> { + return new Promise((resolve, reject) => { + resolve(this._store || {}) + }) + }, + set(obj: { [key: string]: any }) { + return new Promise(resolve => { + this._store = { ...this._store, ...obj } + resolve(null) + }) + }, + delete(key: string) { + delete this._store[key] + return new Promise(resolve => resolve(this._store)) + }, + has(key) { + return !!this._store[key] + }, +} + +describe("Session Manager Tests", () => { + const persistentStore = mockPersistentStore + const oni = new Oni({}) + const manager = new SessionManager(oni as UpdatedOni, new Sidebar(), persistentStore) + + beforeEach(() => { + mockPersistentStore._store = {} + }) + + it("Should return the correct session directory", () => { + expect(manager.sessionsDir).toMatch(path.join(".config", "oni", "session")) + }) + + it("should save a session in the persistentStore", async () => { + await manager.persistSession("test-session") + const session = await persistentStore.get() + expect(session).toBeTruthy() + }) + + it("should correctly delete a session", async () => { + await manager.persistSession("test-session") + const session = await persistentStore.get() + expect(session).toBeTruthy() + await manager.deleteSession("test-session") + expect(session["test-session"]).toBeFalsy() + }) + + it("should correctly update a session", async () => { + await manager.persistSession("test-session") + await manager.updateOniSession("test-session", { newValue: 2 }) + const session = await manager.getSessionFromStore("test-session") + expect(session.newValue).toBe(2) + }) +}) diff --git a/ui-tests/Sessions.test.tsx b/ui-tests/Sessions.test.tsx new file mode 100644 index 0000000000..9428c9f796 --- /dev/null +++ b/ui-tests/Sessions.test.tsx @@ -0,0 +1,168 @@ +import { shallow, mount } from "enzyme" +import * as React from "react" + +import { Sessions, Container } from "./../browser/src/Services/Sessions/Sessions" +import TextInputView from "../browser/src/UI/components/LightweightText" + +const noop = () => ({}) + +jest.mock("./../browser/src/neovim/SharedNeovimInstance", () => ({ + getInstance: () => ({ + bindToMenu: () => ({ + setItems: jest.fn(), + onCursorMoved: { + subscribe: jest.fn(), + }, + }), + }), +})) + +describe("", () => { + const sessions = [ + { + name: "test", + id: "test-1", + file: "/sessions/test.vim", + directory: "/sessions", + updatedAt: null, + workspace: "/workspace", + }, + { + name: "testing", + id: "testing-2", + file: "/sessions/testing.vim", + directory: "/sessions", + updatedAt: null, + workspace: "/workspace", + }, + ] + it("should render without crashing", () => { + const wrapper = shallow( + , + ) + }) + it("should render no children if showAll is false", () => { + const wrapper = shallow( + , + ) + wrapper.setState({ showAll: false }) + const items = wrapper + .dive() + .find("ul") + .children() + expect(items.length).toBe(0) + }) + + it("should render correct number of children if showAll is true", () => { + const wrapper = mount( + , + ) + wrapper.setState({ showAll: true }) + const items = wrapper.find("ul").children() + expect(items.length).toBe(3) + }) + + it("should render an input if creating is true", () => { + const wrapper = mount( + , + ) + const hasInput = wrapper.find(TextInputView).length + + expect(hasInput).toBeTruthy() + }) + + it("should render no input if creating is false", () => { + const wrapper = mount( + , + ) + const hasInput = wrapper.find(TextInputView).length + + expect(hasInput).toBeFalsy() + }) + + it("should empty message if there are no sessions", () => { + const wrapper = mount( + , + ) + expect(wrapper.find(Container).length).toBe(1) + expect(wrapper.find(Container).text()).toBe("No Sessions Saved") + }) +}) diff --git a/ui-tests/VersionControl/VersionControlSectionTitle.test.tsx b/ui-tests/VersionControl/VersionControlSectionTitle.test.tsx index 0fc1a45e80..4adbb400f4 100644 --- a/ui-tests/VersionControl/VersionControlSectionTitle.test.tsx +++ b/ui-tests/VersionControl/VersionControlSectionTitle.test.tsx @@ -2,9 +2,7 @@ import * as React from "react" import { shallow } from "enzyme" import { shallowToJson } from "enzyme-to-json" -import VersionControlTitle, { - Title, -} from "./../../browser/src/UI/components/VersionControl/SectionTitle" +import VersionControlTitle, { Title } from "./../../browser/src/UI/components/SectionTitle" describe("", () => { it("correctly renders without crashing", () => { diff --git a/ui-tests/VersionControl/VersionControlView.test.tsx b/ui-tests/VersionControl/VersionControlView.test.tsx index 7753c7520e..a5ee9e7c3c 100644 --- a/ui-tests/VersionControl/VersionControlView.test.tsx +++ b/ui-tests/VersionControl/VersionControlView.test.tsx @@ -2,6 +2,8 @@ import { shallow, mount } from "enzyme" import { shallowToJson } from "enzyme-to-json" import * as React from "react" +import { SectionTitle, Title } from "./../../browser/src/UI/components/SectionTitle" + import { DefaultState, VersionControlState, @@ -12,7 +14,6 @@ import CommitMessage, { } from "./../../browser/src/UI/components/VersionControl/CommitMessage" import Commits from "./../../browser/src/UI/components/VersionControl/Commits" import Help from "./../../browser/src/UI/components/VersionControl/Help" -import { SectionTitle, Title } from "./../../browser/src/UI/components/VersionControl/SectionTitle" import Staged, { LoadingHandler } from "./../../browser/src/UI/components/VersionControl/Staged" import VersionControlStatus from "./../../browser/src/UI/components/VersionControl/Status" diff --git a/ui-tests/VersionControl/__snapshots__/VersionControlView.test.tsx.snap b/ui-tests/VersionControl/__snapshots__/VersionControlView.test.tsx.snap index 1927eb7909..5981beef32 100644 --- a/ui-tests/VersionControl/__snapshots__/VersionControlView.test.tsx.snap +++ b/ui-tests/VersionControl/__snapshots__/VersionControlView.test.tsx.snap @@ -2,7 +2,7 @@ exports[` should match the last recorded snapshot unless a change was made 1`] = `
- ().mockImplementation(() => { - return { - onConfigurationChanged() { - return { - subscribe: jest.fn(), - } - }, - notifyListeners: jest.fn(), - updateConfig: jest.fn(), - getValue: jest.fn(), - } -}) +const Configuration = jest.fn().mockImplementation(() => ({ + onConfigurationChanged() { + return { + subscribe: jest.fn(), + } + }, + notifyListeners: jest.fn(), + updateConfig: jest.fn(), + getValue: jest.fn(), +})) export const configuration = new Configuration() + export default Configuration diff --git a/ui-tests/mocks/EditorManager.ts b/ui-tests/mocks/EditorManager.ts index 2fa815fe7e..ff377ce746 100644 --- a/ui-tests/mocks/EditorManager.ts +++ b/ui-tests/mocks/EditorManager.ts @@ -3,13 +3,19 @@ import { EditorManager } from "./../../browser/src/Services/EditorManager" const _onBufferSaved = new Event("Test:ActiveEditor-BufferSaved") const _onBufferEnter = new Event("Test:ActiveEditor-BufferEnter") +const _onQuit = new Event() + const MockEditorManager = jest.fn().mockImplementation(() => ({ activeEditor: { + onQuit: _onQuit, activeBuffer: { filePath: "test.txt", }, onBufferEnter: _onBufferEnter, onBufferSaved: _onBufferSaved, + restoreSession: jest.fn(), + persistSession: jest.fn(), + getCurrentSession: jest.fn().mockReturnValue("test-session"), }, })) diff --git a/ui-tests/mocks/Oni.ts b/ui-tests/mocks/Oni.ts index 8c2cd9fefa..dc51d1015d 100644 --- a/ui-tests/mocks/Oni.ts +++ b/ui-tests/mocks/Oni.ts @@ -1,120 +1,53 @@ import * as Oni from "oni-api" import MockCommands from "./CommandManager" -import { configuration } from "./Configuration" import MockEditorManager from "./EditorManager" import MockMenu from "./MenuManager" import MockSidebar from "./../mocks/Sidebar" import MockStatusbar from "./Statusbar" import MockWorkspace from "./Workspace" -class MockOni implements Oni.Plugin.Api { - private _commands = new MockCommands() - private _configuration = configuration - private _editorManager = new MockEditorManager() - private _sidebar = new MockSidebar() - private _statusBar = new MockStatusbar() - private _workspace = new MockWorkspace() - - get automation(): Oni.Automation.Api { - throw Error("Not yet implemented") - } - - get colors(): Oni.IColors { - throw Error("Not yet implemented") - } - - get commands(): Oni.Commands.Api { - return this._commands - } - - get configuration(): Oni.Configuration { - return this._configuration - } - - get contextMenu(): any /* TODO */ { - throw Error("Not yet implemented") - } - - get diagnostics(): Oni.Plugin.Diagnostics.Api { - throw Error("Not yet implemented") - } - - get editors(): Oni.EditorManager { - return this._editorManager - } - - get filter(): Oni.Menu.IMenuFilters { - throw Error("Not yet implemented") - } - - get input(): Oni.Input.InputManager { - throw Error("Not yet implemented") - } - - get language(): any /* TODO */ { - throw Error("Not yet implemented") - } - - get log(): any /* TODO */ { - throw Error("Not yet implemented") - } - - get notifications(): Oni.Notifications.Api { - throw Error("Not yet implemented") - } - - get overlays(): Oni.Overlays.Api { - throw Error("Not yet implemented") - } - - get plugins(): Oni.IPluginManager { - throw Error("Not yet implemented") - } - - get search(): Oni.Search.ISearch { - throw Error("Not yet implemented") - } - - get sidebar(): Oni.Sidebar.Api { - return this._sidebar - } - - get ui(): Oni.Ui.IUi { - throw Error("Not yet implemented") - } - - get menu(): Oni.Menu.Api { - throw Error("Not yet implemented") - } - - get process(): Oni.Process { - throw Error("Not yet implemented") - } - - get recorder(): Oni.Recorder { - throw Error("Not yet implemented") - } - - get snippets(): Oni.Snippets.SnippetManager { - throw Error("Not yet implemented") - } - - get statusBar(): Oni.StatusBar { - return this._statusBar - } - - get windows(): Oni.IWindowManager { - throw Error("Not yet implemented") - } - - get workspace(): Oni.Workspace.Api { - return this._workspace - } - - public populateQuickFix(entries: Oni.QuickFixEntry[]): void { - throw Error("Not yet implemented") - } -} +const MockOni = jest + .fn() + .mockImplementation((values: { apiMock: { [k: string]: any } }) => { + const commands = new MockCommands() + const editors = new MockEditorManager() + const sidebar = new MockSidebar() + const statusBar = new MockStatusbar() + const workspace = new MockWorkspace() + + return { + commands, + configuration: { + getValue: jest.fn(), + }, + editors, + sidebar, + statusBar, + workspace, + filter: null, + input: null, + language: null, + log: null, + notifications: null, + overlays: null, + plugins: null, + search: null, + ui: null, + menu: null, + process: null, + recorder: null, + snippets: null, + windows: null, + automation: null, + colors: null, + contextMenu: null, + diagnostics: null, + populateQuickFix(entries: Oni.QuickFixEntry[]): void { + throw Error("Not yet implemented") + }, + ...values, + } + }) export default MockOni diff --git a/ui-tests/mocks/SharedNeovimInstance.ts b/ui-tests/mocks/SharedNeovimInstance.ts new file mode 100644 index 0000000000..b1358c001e --- /dev/null +++ b/ui-tests/mocks/SharedNeovimInstance.ts @@ -0,0 +1,10 @@ +const SharedNeovimInstance = jest.fn().mockImplementation(() => ({ + bindToMenu: () => ({ + setItems: jest.fn(), + onCursorMoved: { + subscribe: jest.fn(), + }, + }), +})) + +export const getInstance = new SharedNeovimInstance() diff --git a/ui-tests/mocks/Sidebar.ts b/ui-tests/mocks/Sidebar.ts index 08067b2365..aca844a795 100644 --- a/ui-tests/mocks/Sidebar.ts +++ b/ui-tests/mocks/Sidebar.ts @@ -1,6 +1,7 @@ import { SidebarManager } from "./../../browser/src/Services/Sidebar" const MockSidebar = jest.fn().mockImplementation(() => ({ + add: jest.fn(), entries: [ { id: "git-vcs", diff --git a/ui-tests/mocks/UserConfiguration.ts b/ui-tests/mocks/UserConfiguration.ts new file mode 100644 index 0000000000..82c3a14fb7 --- /dev/null +++ b/ui-tests/mocks/UserConfiguration.ts @@ -0,0 +1 @@ +export const getUserConfigFolderPath = jest.fn().mockReturnValue("~/.config/oni") diff --git a/vim/core/oni-plugin-git/src/index.tsx b/vim/core/oni-plugin-git/src/index.tsx index 745803ec38..93422beb4b 100644 --- a/vim/core/oni-plugin-git/src/index.tsx +++ b/vim/core/oni-plugin-git/src/index.tsx @@ -249,6 +249,9 @@ export class GitVersionControlProvider implements VCS.VersionControlProvider { } private _formatRawBlame(rawOutput: string): VCS.Blame { + if (!rawOutput) { + return null + } const firstSpace = (str: string) => str.indexOf(" ") const blameArray = rawOutput.split("\n") const formatted = blameArray @@ -258,11 +261,13 @@ export class GitVersionControlProvider implements VCS.VersionControlProvider { const formattedKey = key.replace("-", "_") if (!index && value) { acc.hash = formattedKey - const [originalLine, finalLine, numberOfLines] = value.split(" ") - acc.line = { - originalLine, - finalLine, - numberOfLines, + if (value) { + const [originalLine, finalLine, numberOfLines] = value.split(" ") + acc.line = { + originalLine, + finalLine, + numberOfLines, + } } return acc } else if (!key) {