diff --git a/browser/src/Editor/NeovimEditor/NeovimBufferLayersView.tsx b/browser/src/Editor/NeovimEditor/NeovimBufferLayersView.tsx index 7e3f6a69f2..8b434f1660 100644 --- a/browser/src/Editor/NeovimEditor/NeovimBufferLayersView.tsx +++ b/browser/src/Editor/NeovimEditor/NeovimBufferLayersView.tsx @@ -12,18 +12,30 @@ import * as Oni from "oni-api" import { NeovimActiveWindow } from "./NeovimActiveWindow" +import { clearBufferDecorations, updateBufferDecorations } from "./NeovimEditorActions" import * as State from "./NeovimEditorStore" import styled, { StackLayer } from "../../UI/components/common" -export interface NeovimBufferLayersViewProps { +interface StateProps { activeWindowId: number windows: State.IWindow[] layers: State.Layers fontPixelWidth: number fontPixelHeight: number + decorations: State.IDecorations } +type UpdateDecorations = (decorations: State.IDecoration[], layerId: string) => void +type ClearDecorations = (layerId: string) => void + +interface DispatchProps { + updateBufferDecorations: UpdateDecorations + clearBufferDecorations: ClearDecorations +} + +export interface NeovimBufferLayersViewProps extends StateProps, DispatchProps {} + const InnerLayer = styled.div` position: absolute; top: 0px; @@ -33,17 +45,20 @@ const InnerLayer = styled.div` overflow: hidden; ` -export interface LayerContextWithCursor extends Oni.BufferLayerRenderContext { +export interface UpdatedLayerContext extends Oni.BufferLayerRenderContext { cursorLine: number cursorColumn: number + decorations: State.IDecorations + updateBufferDecorations: UpdateDecorations + clearBufferDecorations: ClearDecorations } export class NeovimBufferLayersView extends React.PureComponent { - public render(): JSX.Element { + public render() { const containers = this.props.windows.map(windowState => { const layers: Oni.BufferLayer[] = this.props.layers[windowState.bufferId] || [] - const layerContext: LayerContextWithCursor = { + const layerContext: UpdatedLayerContext = { isActive: windowState.windowId === this.props.activeWindowId, windowId: windowState.windowId, fontPixelWidth: this.props.fontPixelWidth, @@ -57,6 +72,9 @@ export class NeovimBufferLayersView extends React.PureComponent { @@ -110,12 +128,13 @@ const getWindowPixelDimensions = (win: State.IWindow) => { } } -const EmptyState: NeovimBufferLayersViewProps = { +const EmptyState: StateProps = { activeWindowId: -1, layers: {}, windows: [], fontPixelHeight: -1, fontPixelWidth: -1, + decorations: {}, } const getActiveVimTabPage = (state: State.IState) => state.activeVimTabPage @@ -132,7 +151,7 @@ const windowSelector = createSelector( }, ) -const mapStateToProps = (state: State.IState): NeovimBufferLayersViewProps => { +const mapStateToProps = (state: State.IState) => { if (!state.activeVimTabPage) { return EmptyState } @@ -140,12 +159,21 @@ const mapStateToProps = (state: State.IState): NeovimBufferLayersViewProps => { const windows = windowSelector(state) return { - activeWindowId: state.windowState.activeWindow, windows, layers: state.layers, + activeWindowId: state.windowState.activeWindow, fontPixelWidth: state.fontPixelWidth, fontPixelHeight: state.fontPixelHeight, + decorations: state.decorations, } } -export const NeovimBufferLayers = connect(mapStateToProps)(NeovimBufferLayersView) +const mapDispatchToProps = { + updateBufferDecorations, + clearBufferDecorations, +} + +export const NeovimBufferLayers = connect( + mapStateToProps, + mapDispatchToProps, +)(NeovimBufferLayersView) diff --git a/browser/src/Editor/NeovimEditor/NeovimEditorActions.ts b/browser/src/Editor/NeovimEditor/NeovimEditorActions.ts index df68087414..7b419f5544 100644 --- a/browser/src/Editor/NeovimEditor/NeovimEditorActions.ts +++ b/browser/src/Editor/NeovimEditor/NeovimEditorActions.ts @@ -59,6 +59,21 @@ export interface IRemoveBufferLayerAction { } } +export interface IUpdateBufferDecorationsAction { + type: "UPDATE_DECORATIONS" + payload: { + decorations: State.IDecoration[] + layerId: string + } +} + +export interface IClearBufferDecorationsAction { + type: "CLEAR_DECORATIONS" + payload: { + layerId: string + } +} + export interface ISetViewportAction { type: "SET_VIEWPORT" payload: { @@ -293,6 +308,8 @@ export type Action = SimpleAction | Action export type SimpleAction = | IAddBufferLayerAction | IRemoveBufferLayerAction + | IUpdateBufferDecorationsAction + | IClearBufferDecorationsAction | IBufferEnterAction | IBufferSaveAction | IBufferUpdateAction @@ -455,6 +472,19 @@ export const removeBufferLayer = ( }, }) +export const updateBufferDecorations = ( + decorations: State.IDecoration[], + layerId: string, +): IUpdateBufferDecorationsAction => ({ + type: "UPDATE_DECORATIONS", + payload: { decorations, layerId }, +}) + +export const clearBufferDecorations = (layerId: string): IClearBufferDecorationsAction => ({ + type: "CLEAR_DECORATIONS", + payload: { layerId }, +}) + export const bufferEnter = (buffers: Array) => ({ type: "BUFFER_ENTER", payload: { diff --git a/browser/src/Editor/NeovimEditor/NeovimEditorReducer.ts b/browser/src/Editor/NeovimEditor/NeovimEditorReducer.ts index 3b91a43f21..6cb1029a52 100644 --- a/browser/src/Editor/NeovimEditor/NeovimEditorReducer.ts +++ b/browser/src/Editor/NeovimEditor/NeovimEditorReducer.ts @@ -157,6 +157,7 @@ export function reducer( buffers: buffersReducer(s.buffers, a), definition: definitionReducer(s.definition, a), layers: layersReducer(s.layers, a), + decorations: decorationsReducer(s.decorations, a), tabState: tabStateReducer(s.tabState, a), errors: errorsReducer(s.errors, a), toolTips: toolTipsReducer(s.toolTips, a), @@ -191,6 +192,52 @@ export const layersReducer = (s: State.Layers, a: Actions.SimpleAction) => { } } +const updateLayerDecorations = ( + decorations: State.IDecoration[], + prevDecorations: State.IDecorations, + id: string, +) => { + const updatedDecorations = decorations.reduce((newDecorations, { line }) => { + const decorationsOnLine = prevDecorations[line] + if (decorationsOnLine) { + if (!decorationsOnLine.includes(id)) { + newDecorations[line] = [...decorationsOnLine, id] + } + } else { + newDecorations[line] = [id] + } + return newDecorations + }, {}) + // console.log("updatedDecorations: ", updatedDecorations) + return updatedDecorations +} + +const removeLayerDecorations = (prevDecorations: State.IDecorations, id: string) => { + const updatedDecorations = Object.entries(prevDecorations).reduce( + (newDecorations, [line, decorations]) => { + const replacement = decorations.includes(id) + ? decorations.filter((layerId: string) => layerId !== id) + : decorations + newDecorations[line] = replacement + return newDecorations + }, + {}, + ) + // console.log("updatedDecorations: ", updatedDecorations) + return updatedDecorations +} + +export const decorationsReducer = (state: State.IDecorations, action: Actions.SimpleAction) => { + switch (action.type) { + case "UPDATE_DECORATIONS": + return updateLayerDecorations(action.payload.decorations, state, action.payload.layerId) + case "CLEAR_DECORATIONS": + return removeLayerDecorations(state, action.payload.layerId) + default: + return state + } +} + export const definitionReducer = (s: State.IDefinition, a: Actions.SimpleAction) => { switch (a.type) { case "SHOW_DEFINITION": diff --git a/browser/src/Editor/NeovimEditor/NeovimEditorStore.ts b/browser/src/Editor/NeovimEditor/NeovimEditorStore.ts index 6ec4827068..3327ab3cc8 100644 --- a/browser/src/Editor/NeovimEditor/NeovimEditorStore.ts +++ b/browser/src/Editor/NeovimEditor/NeovimEditorStore.ts @@ -47,6 +47,14 @@ export interface IToolTip { element: JSX.Element } +export interface IDecoration { + line: number +} + +export interface IDecorations { + [lineNo: number]: string[] +} + export interface IState { // Editor cursorScale: number @@ -82,6 +90,13 @@ export interface IState { layers: Layers + /** + * A mapping of position of layer ui elements by line + * this allows each layer to be aware of where other layers are rendering + * elements + */ + decorations: IDecorations + windowState: IWindowState errors: Errors @@ -213,6 +228,8 @@ export const createDefaultState = (): IState => ({ layers: {}, + decorations: {}, + tabState: { selectedTabId: null, tabs: [], diff --git a/browser/src/Services/VersionControl/VersionControlBlameLayer.tsx b/browser/src/Services/VersionControl/VersionControlBlameLayer.tsx index 0f21be600f..cf5cf7ab29 100644 --- a/browser/src/Services/VersionControl/VersionControlBlameLayer.tsx +++ b/browser/src/Services/VersionControl/VersionControlBlameLayer.tsx @@ -5,7 +5,7 @@ import * as React from "react" import { Transition } from "react-transition-group" import { Position } from "vscode-languageserver-types" -import { LayerContextWithCursor } from "../../Editor/NeovimEditor/NeovimBufferLayersView" +import { UpdatedLayerContext } from "../../Editor/NeovimEditor/NeovimBufferLayersView" import styled, { pixel, textOverflow, withProps } from "../../UI/components/common" import { getTimeSince } from "../../Utility" import { VersionControlProvider } from "./" @@ -30,7 +30,8 @@ interface ILineDetails { lastEmptyLine: number } -export interface IProps extends LayerContextWithCursor { +export interface IProps extends UpdatedLayerContext { + id: string getBlame: (lineOne: number, lineTwo: number) => Promise timeout: number cursorScreenLine: number @@ -43,6 +44,8 @@ export interface IProps extends LayerContextWithCursor { export interface IState { blame: IBlame + message: string + position: IBlamePosition showBlame: boolean currentLineContent: string currentCursorBufferLine: number @@ -104,16 +107,18 @@ export class Blame extends React.PureComponent { // Reset show blame to false when props change - do it here so it happens before rendering // hide if the current line has changed or if the text of the line has changed // aka input is in progress or if there is an empty line - public static getDerivedStateFromProps(nextProps: IProps, prevState: IState) { - const lineNumberChanged = nextProps.cursorBufferLine !== prevState.currentCursorBufferLine - const lineContentChanged = prevState.currentLineContent !== nextProps.currentLine + public static getDerivedStateFromProps(nextProps: IProps, state: IState) { + const lineNumberChanged = nextProps.cursorBufferLine !== state.currentCursorBufferLine + const lineContentChanged = state.currentLineContent !== nextProps.currentLine if ( - (prevState.showBlame && (lineNumberChanged || lineContentChanged)) || + (state.showBlame && (lineNumberChanged || lineContentChanged)) || !nextProps.currentLine ) { return { showBlame: false, - blame: prevState.blame, + blame: state.blame, + message: state.message, + position: state.position, currentLineContent: nextProps.currentLine, currentCursorBufferLine: nextProps.cursorBufferLine, } @@ -125,11 +130,13 @@ export class Blame extends React.PureComponent { error: null, blame: null, showBlame: null, + message: null, + position: null, currentLineContent: this.props.currentLine, currentCursorBufferLine: this.props.cursorBufferLine, } - private _timeout: any + private _timeout: any // typing as NodeJS.Timer causes TS error/bug private readonly DURATION = 300 private readonly LEFT_OFFSET = 4 @@ -150,7 +157,7 @@ export class Blame extends React.PureComponent { if (prevProps.cursorBufferLine !== cursorBufferLine && currentLine) { await this.updateBlame(cursorBufferLine, cursorBufferLine) if (mode === "auto") { - return this.resetTimer() + this.resetTimer() } } } @@ -216,31 +223,41 @@ export class Blame extends React.PureComponent { return this.getPosition() } - // TODO: possibly add a caching strategy so a new call isn't made each time or - // get a blame for the entire file and store it public updateBlame = async (lineOne: number, lineTwo: number) => { const outOfBounds = this.isOutOfBounds(lineOne, lineTwo) const blame = !outOfBounds ? await this.props.getBlame(lineOne, lineTwo) : null - this.setState({ blame }) + // The blame must be passed in here since it is not yet set in state + const { message, position } = this.canFit(blame) + this.setState({ blame, position, message }) } public formatCommitDate(timestamp: string) { return new Date(parseInt(timestamp, 10) * 1000) } - public getPosition(positionToRender?: Position): IBlamePosition { + public getPosition(newPosition?: Position): IBlamePosition { const emptyPosition: IBlamePosition = { hide: true, top: null, left: null, } - if (!positionToRender) { + + if (!newPosition) { + this.props.clearBufferDecorations(this.props.id) return emptyPosition } - const position = this.props.bufferToPixel(positionToRender) + + const position = this.props.bufferToPixel(newPosition) + if (!position) { + this.props.clearBufferDecorations(this.props.id) return emptyPosition } + + // Update the buffer layers store with the position of the git blame decoration + const line = newPosition.line + 1 + this.props.updateBufferDecorations([{ line }], this.props.id) + return { hide: false, top: position.pixelY, @@ -254,10 +271,9 @@ export class Blame extends React.PureComponent { ) } - public getBlameText = (numberOfTruncations = 0) => { - const { blame } = this.state + public getBlameText = (blame: IBlame, numberOfTruncations = 0) => { if (!blame) { - return null + return "" } const { author, hash, committer_time } = blame const formattedDate = this.formatCommitDate(committer_time) @@ -278,16 +294,16 @@ export class Blame extends React.PureComponent { // Recursively calls get blame text if the message will not fit onto the screen up // to a limit of 6 times each time removing one word from the blame message // if after 6 attempts the message is still not small enougth then we render the popup - public canFit = (truncationAmount = 0): ICanFit => { + public canFit = (blame: IBlame, truncationAmount = 0): ICanFit => { const { visibleLines, dimensions, cursorScreenLine } = this.props - const message = this.getBlameText(truncationAmount) + const message = this.getBlameText(blame, truncationAmount) const currentLine = visibleLines[cursorScreenLine] || "" const canFit = dimensions.width > currentLine.length + message.length + this.LEFT_OFFSET if (!canFit && truncationAmount <= 6) { - return this.canFit(truncationAmount + 1) + return this.canFit(blame, truncationAmount + 1) } - const truncatedOrFullMessage = canFit ? message : this.getBlameText() + const truncatedOrFullMessage = canFit ? message : this.getBlameText(blame) return { canFit, message: truncatedOrFullMessage, @@ -296,11 +312,10 @@ export class Blame extends React.PureComponent { } public render() { - const { blame, showBlame, error } = this.state + const { blame, showBlame, error, message, position } = this.state if (!blame || !showBlame || error) { return null } - const { message, position } = this.canFit() return ( {(state: TransitionStates) => ( @@ -358,7 +373,7 @@ export default class VersionControlBlameLayer implements BufferLayer { return { timeout, mode, fontFamily } } - public render(context: LayerContextWithCursor) { + public render(context: UpdatedLayerContext) { const cursorBufferLine = context.cursorLine + 1 const cursorScreenLine = cursorBufferLine - context.topBufferLine const config = this.getConfigOpts() @@ -366,6 +381,7 @@ export default class VersionControlBlameLayer implements BufferLayer { return ( activated && ( ", () => { summary: "the first paragraph", } const getBlame = jest.fn().mockReturnValue(blame) - const context: LayerContextWithCursor = { + const context: UpdatedLayerContext = { isActive: true, windowId: 1, fontPixelWidth: 3, fontPixelHeight: 10, cursorColumn: 4, cursorLine: 30, + decorations: {}, + updateBufferDecorations: jest.fn(), + clearBufferDecorations: jest.fn(), bufferToScreen: jest.fn(), screenToPixel: jest.fn(), bufferToPixel: jest.fn().mockReturnValue({ @@ -84,9 +87,10 @@ describe("", () => { const cursorScreenLine = cursorBufferLine - context.topBufferLine const wrapper = mount( ", () => { it("should render without crashing", () => { expect(wrapper.length).toBe(1) }) + it("should render the component if there is a blame present and show blame is true", () => { wrapper.setState({ showBlame: true, blame }) expect(wrapper.find(BlameContainer).length).toBe(1) }) + it("should render the correct message", () => { const text = wrapper.find("span").text() expect(text).toMatch(/the first paragraph/) }) + it("should return a formatted hash in the message", () => { const text = wrapper.find("span").text() expect(text).toMatch(/#2234/) }) + it("should correctly return a position if the component is able to fit", () => { const position = instance.calculatePosition(true) expect(position).toEqual({ hide: false, top: 20, left: 20 }) }) + it("should return a position even if can't fit BUT there is an available empty line", () => { const canFit = false const position = instance.calculatePosition(canFit) expect(position).toEqual({ hide: false, top: 20, left: 20 }) }) + it("Should correctly determine if a line is out of bounds", () => { const outOfBounds = instance.isOutOfBounds(50, 10) expect(outOfBounds).toBe(true) }) + it("should return false if no lines passed are out of bounds", () => { const outOfBounds = instance.isOutOfBounds(22, 24) expect(outOfBounds).toBe(false) }) + it("should correctly truncate the blame text based on window width prop", () => { const expected = "ernest hemmingway, 2 days ago, the first… #2234" wrapper.setProps({ dimensions: { width: 60, height: 100, x: 0, y: 0 } }) - const { message, canFit, position } = instance.canFit() + const { message, canFit, position } = instance.canFit(blame) expect(message).toEqual(expected) }) + it("should have the correct current line", () => { const line = wrapper.prop("currentLine") expect(line).toBe("cursor") }) + it("should correctly identify the last empty line", () => { const { lastEmptyLine } = instance.getLastEmptyLine() expect(lastEmptyLine).toBe(22) // aka the 3 item in the array