Feature/oni sessions (#2479)

* add session manager, store and sidebar pane

* hook up session services to app

* move side effects into epics

* add creation and cancel creation actions

* add restore session epic and remove console.log

* await sessions in case of error

* add error handling to session functions

* Revert "Refocus previously open menu on reactivating Oni (#2472)"

This reverts commit 97f0c61345.

* Revert "Refocus previously open menu on reactivating Oni (#2472)"

This reverts commit 97f0c61345.

* remove console.log

* remove unused props passed to session pane

* add persist session command [WIP]

* Add current session action and update store with i

t

* use get user config add icons and some error handling

* add unclick handlers for sessions
move section title to general location
add delete session functionality

* add title and toggling of sessions add on vim leave handler

* fix lint errors

* refactor epics in preparation for 1.0 and rxjs 6

* update snapshot

* add bufdo bd command prior to restoring session
fix delete session bug causing reappearances

* create separate close all buffers method

* remove update session method

* Add audit time to update current session

* add close all buffers method and use persist session action in update session

* add restore session error action and complete action

* add console log for debugging return metadata directly

* add error handling to git blame function

* reduce persist audit time make ids in session.tsx readonly

* comment out console.log

* check neovim for current session when updating
this ensures the session is valid
added getCurrentSession method which checks vims v:this_session var

* fix lint errors

* add tests for sessions component and mock for neovim instance

* switch generic input check to specific text input view check

* add update timestamp functionality so these are more accurate

* switch to adding updated at by checking mtime of f

ile

* switch to storing sessions in persistent store

* add delete functionality to persistent store and mock

* fix lint error

* rename sessionName var to name for simplicity

* add session manager initial test

* create path using path.join

* add experimental config flag

* update Oni mock and increase sessionmanager tests

* add session store tests - [WIP]

* return simple session manager mock
remove need for instantiation, use done callback

* remove session store tests as redux observable api has changed
a large refactor of all observables is going to be required to upgrade redux observable so seems counter productive to write tests that will need to be re-written entirely once that is done

* add user configurable session directory

* tweak sidebar item styles

* update vim navigator to pass function to update selected item on click
render session sidebar item second

* fix lint error

* fix broken tests
This commit is contained in:
Akin 2018-08-22 08:16:46 +01:00 committed by GitHub
parent 2fd82f36cf
commit e3f65e6948
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1244 additions and 148 deletions

View File

@ -75,6 +75,7 @@ export const start = async (args: string[]): Promise<void> => {
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<void> => {
Sidebar.getInstance(),
WindowManager.windowManager,
)
const Sessions = await sessionManagerPromise
Sessions.activate(oniApi, sidebarManager)
Performance.endMeasure("Oni.Start.Sidebar")
const createLanguageClientsFromConfiguration =

View File

@ -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<string | void> {
const result = await this._neovimInstance.request<string | NeovimError>("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)
}
}
}

View File

@ -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<void> {
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<Oni.Buffer | Oni.InactiveBuffer> {
return this._neovimEditor.getBuffers()
}

View File

@ -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("<c-r>", "vcs.refresh", isVCSActive)
input.bind("?", "vcs.showHelp", isVCSActive)
// Sessions
input.bind("<c-d>", "oni.sessions.delete", areSessionsActive)
}

View File

@ -15,6 +15,8 @@ const PersistentSettings = remote.require("electron-settings")
export interface IPersistentStore<T> {
get(): Promise<T>
set(value: T): Promise<void>
delete(key: string): Promise<T>
has(key: string): boolean
}
export const getPersistentStore = <T>(
@ -70,4 +72,12 @@ export class PersistentStore<T> implements IPersistentStore<T> {
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}`)
}
}

View File

@ -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,

View File

@ -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

View File

@ -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<ISession>
restoreSession(sessionName: string): Promise<ISession>
}
export interface UpdatedOni extends Plugin.Api {
editors: UpdatedEditorManager
}
interface UpdatedEditorManager extends EditorManager {
activeEditor: UpdatedEditor
}
interface UpdatedEditor extends Editor {
onQuit: IEvent<void>
persistSession(sessionDetails: ISession): Promise<ISession>
restoreSession(sessionDetails: ISession): Promise<ISession>
getCurrentSession(): Promise<string | void>
}
/**
* 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<string>(
"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<boolean>("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<ISession>) {
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()

View File

@ -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<ISessionItem> = ({ session, isSelected, onClick }) => {
const truncatedWorkspace = session.workspace
.split(path.sep)
.slice(-2)
.join(path.sep)
return (
<ListItem isSelected={isSelected} onClick={onClick}>
<div>
<strong>
<Icon name="file" /> Name: {session.name}
</strong>
</div>
<div>Workspace: {truncatedWorkspace}</div>
{<div>Last updated: {getTimeSince(new Date(session.updatedAt))} ago</div>}
</ListItem>
)
}
const inputStyles = css`
background-color: transparent;
width: 100%;
font-family: inherit;
font-size: inherit;
color: ${p => p.theme["sidebar.foreground"]};
`
const ListItem = withProps<Partial<ISessionItem>>(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<IConnectedProps, IState> {
public readonly _ID: Readonly<IIDs> = {
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<HTMLInputElement> = 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 (
<VimNavigator
ids={ids}
active={active}
onSelected={this.handleSelection}
onSelectionChanged={this.updateSelection}
render={(selectedId, updateSelection) => (
<List>
<SectionTitle
active
count={sessions.length}
title="All Sessions"
testId="sessions-title"
isSelected={selectedId === this._ID.title}
onClick={() => this.handleSelection(selectedId)}
/>
{showAll && (
<>
<ListItem isSelected={selectedId === this._ID.input}>
{creating ? (
<TextInputView
styles={inputStyles}
onChange={this.handleChange}
onCancel={this.handleCancel}
onComplete={this.persistSession}
defaultValue="Enter a new Session Name"
/>
) : (
<div onClick={() => this.handleSelection(selectedId)}>
<Icon name="pencil" /> Create a new session
</div>
)}
</ListItem>
{sessions.length ? (
sessions.map((session, idx) => (
<SessionItem
key={session.id}
session={session}
isSelected={selectedId === session.id}
onClick={() => {
updateSelection(session.id)
this.handleSelection(session.id)
}}
/>
))
) : (
<Container>No Sessions Saved</Container>
)}
</>
)}
</List>
)}
/>
)
}
}
const mapStateToProps = ({ sessions, selected, active, creating }: ISessionState): IStateProps => ({
sessions,
active,
creating,
selected,
})
export default connect<IStateProps, ISessionActions>(mapStateToProps, SessionActions)(Sessions)

View File

@ -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 (
<Provider store={this._store}>
<Sessions />
</Provider>
)
}
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,
})
}
}

View File

@ -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<N, T = undefined> {
type: N
payload?: T
}
export type ISessionStore = Store<ISessionState>
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<ISessionActions, ISessionState, Dependencies>
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<ISessionActions, ISessionState, Dependencies>(
combineEpics(
fetchSessionsEpic,
persistSessionEpic,
restoreSessionEpic,
updateCurrentSessionEpic,
deleteSessionEpic,
),
{ dependencies },
),
])
export default createStore

View File

@ -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"

View File

@ -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"

View File

@ -32,6 +32,10 @@ const WordRegex = /[$_a-zA-Z0-9]/i
* common functionality (like focus management, key handling)
*/
export class TextInputView extends React.PureComponent<ITextInputViewProps, {}> {
public static defaultProps = {
InputComponent: Input,
}
private _element: HTMLInputElement
public componentDidMount(): void {
@ -48,7 +52,7 @@ export class TextInputView extends React.PureComponent<ITextInputViewProps, {}>
}
const defaultValue = this.props.defaultValue || ""
const { InputComponent = Input } = this.props
const { InputComponent } = this.props
return (
<div className="input-container enable-mouse">

View File

@ -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<IProps> = props => (
const SidebarSectionTitle: React.SFC<IProps> = props => (
<SectionTitle isSelected={props.isSelected} data-test={props.testId} onClick={props.onClick}>
<Caret active={props.active} />
<Title>{props.title.toUpperCase()}</Title>
@ -33,4 +33,4 @@ const VCSSectionTitle: React.SFC<IProps> = props => (
</SectionTitle>
)
export default VCSSectionTitle
export default SidebarSectionTitle

View File

@ -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"]

View File

@ -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" },

View File

@ -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%;

View File

@ -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[]

View File

@ -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<IVimNavigatorProps, IVimNa
this._releaseBinding()
}
public updateSelection = (id: string) => {
this.setState({ selectedId: id })
}
public render() {
const inputElement = (
<div className="input">
@ -85,7 +89,9 @@ export class VimNavigator extends React.PureComponent<IVimNavigatorProps, IVimNa
return (
<div style={this.props.style}>
<div className="items">{this.props.render(this.state.selectedId)}</div>
<div className="items">
{this.props.render(this.state.selectedId, this.updateSelection)}
</div>
{this.props.active ? inputElement : null}
</div>
)

View File

@ -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<IThemeColors> }) =>
p.isSelected
? `1px solid ${p.theme["highlight.mode.normal.background"]}`
: `1px solid transparent`};
`
export type StyledFunction<T> = styledComponents.ThemedStyledFunction<T, IThemeColors>

View File

@ -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

View File

@ -18,4 +18,13 @@ export class MockPersistentStore<T> implements IPersistentStore<T> {
public async get(): Promise<T> {
return this._state
}
public async delete(key: string): Promise<T> {
this._state[key] = undefined
return this._state
}
public has(key: string) {
return !!this._state[key]
}
}

View File

@ -9,7 +9,9 @@ module.exports = {
PersistentSettings: "<rootDir>/ui-tests/mocks/PersistentSettings.ts",
Utility: "<rootDir>/ui-tests/mocks/Utility.ts",
Configuration: "<rootDir>/ui-tests/mocks/Configuration.ts",
UserConfiguration: "<rootDir>/ui-tests/mocks/UserConfiguration.ts",
KeyboardLayout: "<rootDir>/ui-tests/mocks/keyboardLayout.ts",
SharedNeovimInstance: "<rootDir>/ui-tests/mocks/SharedNeovimInstance.ts",
},
snapshotSerializers: ["enzyme-to-json/serializer"],
transform: {

View File

@ -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",

View File

@ -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)
})
})

168
ui-tests/Sessions.test.tsx Normal file
View File

@ -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("<Sessions />", () => {
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(
<Sessions
active
cancelCreating={noop}
createSession={noop}
persistSession={noop}
restoreSession={noop}
updateSession={noop}
getAllSessions={noop}
updateSelection={noop}
sessions={sessions}
creating={false}
selected={sessions[0]}
populateSessions={noop}
/>,
)
})
it("should render no children if showAll is false", () => {
const wrapper = shallow(
<Sessions
active
cancelCreating={noop}
createSession={noop}
persistSession={noop}
restoreSession={noop}
updateSession={noop}
getAllSessions={noop}
updateSelection={noop}
sessions={sessions}
creating={false}
selected={sessions[0]}
populateSessions={noop}
/>,
)
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(
<Sessions
active
cancelCreating={noop}
createSession={noop}
persistSession={noop}
restoreSession={noop}
updateSession={noop}
getAllSessions={noop}
updateSelection={noop}
sessions={sessions.slice(1)} // remove one session
creating={false}
selected={sessions[0]}
populateSessions={noop}
/>,
)
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(
<Sessions
active
cancelCreating={noop}
createSession={noop}
persistSession={noop}
restoreSession={noop}
updateSession={noop}
getAllSessions={noop}
updateSelection={noop}
sessions={sessions.slice(1)} // remove one session
creating={true}
selected={sessions[0]}
populateSessions={noop}
/>,
)
const hasInput = wrapper.find(TextInputView).length
expect(hasInput).toBeTruthy()
})
it("should render no input if creating is false", () => {
const wrapper = mount(
<Sessions
active
cancelCreating={noop}
createSession={noop}
persistSession={noop}
restoreSession={noop}
updateSession={noop}
getAllSessions={noop}
updateSelection={noop}
sessions={sessions.slice(1)}
creating={false}
selected={null}
populateSessions={noop}
/>,
)
const hasInput = wrapper.find(TextInputView).length
expect(hasInput).toBeFalsy()
})
it("should empty message if there are no sessions", () => {
const wrapper = mount(
<Sessions
active
cancelCreating={noop}
createSession={noop}
persistSession={noop}
restoreSession={noop}
updateSession={noop}
getAllSessions={noop}
updateSelection={noop}
sessions={[]}
creating={true}
selected={null}
populateSessions={noop}
/>,
)
expect(wrapper.find(Container).length).toBe(1)
expect(wrapper.find(Container).text()).toBe("No Sessions Saved")
})
})

View File

@ -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("<VersionControlTitle />", () => {
it("correctly renders without crashing", () => {

View File

@ -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"

View File

@ -2,7 +2,7 @@
exports[`<VersionControlView /> should match the last recorded snapshot unless a change was made 1`] = `
<div>
<VCSSectionTitle
<SidebarSectionTitle
active={true}
count={2}
isSelected={false}

View File

@ -1,17 +1,16 @@
import * as Oni from "oni-api"
const Configuration = jest.fn<Oni.Configuration>().mockImplementation(() => {
return {
onConfigurationChanged() {
return {
subscribe: jest.fn(),
}
},
notifyListeners: jest.fn(),
updateConfig: jest.fn(),
getValue: jest.fn(),
}
})
const Configuration = jest.fn<Oni.Configuration>().mockImplementation(() => ({
onConfigurationChanged() {
return {
subscribe: jest.fn(),
}
},
notifyListeners: jest.fn(),
updateConfig: jest.fn(),
getValue: jest.fn(),
}))
export const configuration = new Configuration()
export default Configuration

View File

@ -3,13 +3,19 @@ import { EditorManager } from "./../../browser/src/Services/EditorManager"
const _onBufferSaved = new Event<any>("Test:ActiveEditor-BufferSaved")
const _onBufferEnter = new Event<any>("Test:ActiveEditor-BufferEnter")
const _onQuit = new Event<void>()
const MockEditorManager = jest.fn<EditorManager>().mockImplementation(() => ({
activeEditor: {
onQuit: _onQuit,
activeBuffer: {
filePath: "test.txt",
},
onBufferEnter: _onBufferEnter,
onBufferSaved: _onBufferSaved,
restoreSession: jest.fn(),
persistSession: jest.fn(),
getCurrentSession: jest.fn().mockReturnValue("test-session"),
},
}))

View File

@ -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()
const MockOni = jest
.fn<Oni.Plugin.Api>()
.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()
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")
}
}
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

View File

@ -0,0 +1,10 @@
const SharedNeovimInstance = jest.fn().mockImplementation(() => ({
bindToMenu: () => ({
setItems: jest.fn(),
onCursorMoved: {
subscribe: jest.fn(),
},
}),
}))
export const getInstance = new SharedNeovimInstance()

View File

@ -1,6 +1,7 @@
import { SidebarManager } from "./../../browser/src/Services/Sidebar"
const MockSidebar = jest.fn<SidebarManager>().mockImplementation(() => ({
add: jest.fn(),
entries: [
{
id: "git-vcs",

View File

@ -0,0 +1 @@
export const getUserConfigFolderPath = jest.fn().mockReturnValue("~/.config/oni")

View File

@ -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) {