diff --git a/browser/src/App.ts b/browser/src/App.ts index 7cc3ad5fe7..221a96f556 100644 --- a/browser/src/App.ts +++ b/browser/src/App.ts @@ -5,6 +5,7 @@ */ import { ipcRenderer, remote } from "electron" +import { EventEmitter } from "events" import * as fs from "fs" import * as minimist from "minimist" import * as path from "path" @@ -17,6 +18,9 @@ import * as Utility from "./Utility" import { IConfigurationValues } from "./Services/Configuration/IConfigurationValues" +// Increase default max listeners +EventEmitter.defaultMaxListeners = 30 + const editorManagerPromise = import("./Services/EditorManager") const sharedNeovimInstancePromise = import("./neovim/SharedNeovimInstance") diff --git a/browser/src/Editor/NeovimEditor/BufferLayerManager.ts b/browser/src/Editor/NeovimEditor/BufferLayerManager.ts index c3f504a996..65f28a9552 100644 --- a/browser/src/Editor/NeovimEditor/BufferLayerManager.ts +++ b/browser/src/Editor/NeovimEditor/BufferLayerManager.ts @@ -69,13 +69,6 @@ export class BufferLayerManager { } } -const getInstance = (() => { - const instance = new BufferLayerManager() - return () => instance -})() - -export default getInstance - export const wrapReactComponentWithLayer = ( id: string, component: JSX.Element, @@ -85,3 +78,8 @@ export const wrapReactComponentWithLayer = ( render: (context: Oni.BufferLayerRenderContext) => (context.isActive ? component : null), } } + +const instance = new BufferLayerManager() +const getInstance = () => instance + +export default getInstance diff --git a/browser/src/Services/Configuration/DefaultConfiguration.ts b/browser/src/Services/Configuration/DefaultConfiguration.ts index 4384530edb..79b394f3d1 100644 --- a/browser/src/Services/Configuration/DefaultConfiguration.ts +++ b/browser/src/Services/Configuration/DefaultConfiguration.ts @@ -61,6 +61,7 @@ const BaseConfiguration: IConfigurationValues = { "experimental.vcs.blame.mode": "auto", "experimental.vcs.blame.timeout": 800, + "layers.priority": ["import-costs", "vcs.blame"], "experimental.colorHighlight.enabled": false, "experimental.colorHighlight.filetypes": [ ".css", diff --git a/browser/src/Services/Configuration/IConfigurationValues.ts b/browser/src/Services/Configuration/IConfigurationValues.ts index 354eb8b235..7699c411a4 100644 --- a/browser/src/Services/Configuration/IConfigurationValues.ts +++ b/browser/src/Services/Configuration/IConfigurationValues.ts @@ -46,6 +46,11 @@ export interface IConfigurationValues { // - textMateHighlighting "editor.textMateHighlighting.enabled": boolean + // A list of oni bufferlayer IDs, the ids order in this array determines their priority, + // the first element has the highest priority so if there is + // a clash the first buffer layer element will render + "layers.priority": string[] + // Whether or not the learning pane is available "experimental.particles.enabled": boolean diff --git a/browser/src/Services/VersionControl/VersionControlBlameLayer.tsx b/browser/src/Services/VersionControl/VersionControlBlameLayer.tsx index 0f21be600f..a288ee6241 100644 --- a/browser/src/Services/VersionControl/VersionControlBlameLayer.tsx +++ b/browser/src/Services/VersionControl/VersionControlBlameLayer.tsx @@ -17,6 +17,7 @@ interface IBlamePosition { top: number left: number hide: boolean + leftOffset: number } interface ICanFit { @@ -32,6 +33,7 @@ interface ILineDetails { export interface IProps extends LayerContextWithCursor { getBlame: (lineOne: number, lineTwo: number) => Promise + priority: number timeout: number cursorScreenLine: number cursorBufferLine: number @@ -55,7 +57,9 @@ interface IContainerProps { left: number fontFamily: string hide: boolean + priority: number timeout: number + leftOffset: number animationState: TransitionStates } @@ -69,9 +73,10 @@ const getOpacity = (state: TransitionStates) => { } export const BlameContainer = withProps(styled.div).attrs({ - style: ({ top, left }: IContainerProps) => ({ + style: ({ top, left, leftOffset }: IContainerProps) => ({ top: pixel(top), left: pixel(left), + paddingLeft: pixel(leftOffset), }), })` ${p => p.hide && `visibility: hidden`}; @@ -86,6 +91,7 @@ export const BlameContainer = withProps(styled.div).attrs({ height: ${p => pixel(p.height)}; line-height: ${p => pixel(p.height)}; right: 3em; + z-index: ${p => p.priority}; ${textOverflow} ` @@ -201,10 +207,10 @@ export class Blame extends React.PureComponent { public calculatePosition(canFit: boolean) { const { cursorLine, cursorScreenLine, visibleLines } = this.props const currentLine = visibleLines[cursorScreenLine] - const character = currentLine && currentLine.length + this.LEFT_OFFSET + const character = currentLine && currentLine.length if (canFit) { - return this.getPosition({ line: cursorLine, character }) + return this.getPosition({ line: cursorLine, character }, canFit) } const { lastEmptyLine, nextSpacing } = this.getLastEmptyLine() @@ -228,16 +234,19 @@ export class Blame extends React.PureComponent { return new Date(parseInt(timestamp, 10) * 1000) } - public getPosition(positionToRender?: Position): IBlamePosition { + public getPosition(positionToRender?: Position, canFit: boolean = false): IBlamePosition { const emptyPosition: IBlamePosition = { hide: true, top: null, left: null, + leftOffset: null, } if (!positionToRender) { return emptyPosition } + const position = this.props.bufferToPixel(positionToRender) + if (!position) { return emptyPosition } @@ -245,6 +254,7 @@ export class Blame extends React.PureComponent { hide: false, top: position.pixelY, left: position.pixelX, + leftOffset: canFit ? this.LEFT_OFFSET * this.props.fontPixelWidth : 0, } } @@ -309,6 +319,7 @@ export class Blame extends React.PureComponent { data-id="vcs.blame" timeout={this.DURATION} animationState={state} + priority={this.props.priority} height={this.props.fontPixelHeight} fontFamily={this.props.fontFamily} > @@ -354,8 +365,11 @@ export default class VersionControlBlameLayer implements BufferLayer { const fontFamily = this._configuration.getValue("editor.fontFamily") const timeout = this._configuration.getValue("experimental.vcs.blame.timeout") const mode = this._configuration.getValue<"auto" | "manual">("experimental.vcs.blame.mode") + const priorities = this._configuration.getValue("layers.priority", []) + const index = priorities.indexOf(this.id) + const priority = index >= 0 ? priorities.length - index : 0 - return { timeout, mode, fontFamily } + return { timeout, mode, fontFamily, priority } } public render(context: LayerContextWithCursor) { @@ -368,6 +382,7 @@ export default class VersionControlBlameLayer implements BufferLayer { `${s}px` + +const SizeDetail = styled("span")` + color: ${p => p.color}; +` + +const hidden: VisibilityState = "hidden" +const visible: VisibilityState = "visible" + +const Package = styled.div.attrs({ + style: (props: IPackageProps) => ({ + left: px(props.left), + top: px(props.top), + visibility: props.hide ? hidden : visible, + }), +})` + height: ${p => px(p.height)}; + background-color: ${p => p.background}; + line-height: ${p => px(p.height + 1)}; + position: absolute; + padding-left: 5px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + z-index: ${p => p.priority}; +` + +const Packages = styled.div` + position: relative; +` + +interface IPkgDetails { + kb: number + size: Size +} + +interface Props extends ImportSettings { + priority: number + buffer: Oni.EditorBufferEventArgs + context: Oni.BufferLayerRenderContext + colors: { [color: string]: string } + log: (...args: any[]) => void +} + +interface State { + error: string + packages: IPackage[] +} + +class ImportCosts extends React.Component { + emitter: NodeJS.EventEmitter + state: State = { + error: null, + packages: [], + } + + componentDidMount() { + this.setupEmitter() + } + + componentDidUpdate({ context: { visibleLines: prevLines } }) { + const { context } = this.props + if (context.visibleLines !== prevLines) { + this.setupEmitter() + } + } + + componentWillUnmount() { + this.emitter.removeAllListeners() + } + + componentDidCatch(error: Error) { + this.setState({ error: error.message }) + } + + setupEmitter() { + const { filePath } = this.props.buffer + const { visibleLines } = this.props.context + const fileContents = visibleLines.join("\n") + const fileType = path.extname(filePath).includes(".ts") ? TYPESCRIPT : JAVASCRIPT + + this.emitter = importCost(filePath, fileContents, fileType) + this.emitter.on("error", error => { + this.props.log("Oni import-cost error:", error) + this.setState({ error }) + }) + this.emitter.on("start", (packages: IPackage[]) => + this.setState({ + error: null, + packages: packages.map(pkg => ({ ...pkg, status: Status.calculating })), + }), + ) + this.emitter.on("calculated", (pkg: IPackage) => { + // Ignore packages with no size values + if (pkg.size) { + const packages = this.state.packages.map( + loaded => (pkg.name === loaded.name ? { ...pkg, status: Status.done } : loaded), + ) + + this.setState({ packages }) + } + }) + this.emitter.on("done", (packages: IPackage[]) => { + const updated = this.state.packages.map(pkg => ({ + ...pkg, + status: pkg.status === Status.done ? pkg.status : null, + })) + this.setState({ packages: updated }) + }) + } + + getPosition = (line: number) => { + const { context } = this.props + const zeroBasedLine = line - 1 + const width = context.dimensions.width * context.fontPixelWidth + const lineContent = context.visibleLines[zeroBasedLine] + const character = lineContent.length || 0 + // line is the non-zero indexed line, topBufferLine is also non-zero indexed + const bufferline = this.props.context.topBufferLine - 1 + zeroBasedLine + const position = this.props.context.bufferToPixel({ character, line: bufferline }) + const PADDING = 5 + + return { + left: position ? position.pixelX : null, + top: position ? position.pixelY : null, + width: position ? width - position.pixelX - PADDING : null, + hide: !position, + } + } + + getSize = (num: number): IPkgDetails => { + if (!num) { + return { + kb: null, + size: null, + } + } + const sizeInKbs = Math.round(num / 1024 * 10) / 10 + const sizeDescription = + sizeInKbs >= this.props.largeSize + ? "large" + : this.props.smallSize >= sizeInKbs + ? "small" + : "medium" + return { + kb: sizeInKbs, + size: sizeDescription, + } + } + + getCalculation = () => { + return this.props.showCalculating ? ( + calculating... + ) : null + } + + getSizeText = (gzip: IPkgDetails, pkg: IPkgDetails) => { + const { sizeColors } = this.props + return ( + <> + {pkg.kb}kb + (gzipped: {gzip.kb}kb) + + ) + } + + render() { + const { packages, error } = this.state + const { colors, context, priority } = this.props + const background = colors["editor.background"] + const height = context.fontPixelHeight + return ( + !error && ( + + {packages.map((pkg, idx) => { + const position = this.getPosition(pkg.line) + const gzipSize = this.getSize(pkg.gzip) + const pkgSize = this.getSize(pkg.size) + return pkg.status ? ( + + {pkg.status === Status.calculating + ? this.getCalculation() + : this.getSizeText(gzipSize, pkgSize)} + + ) : null + })} + + ) + ) + } +} + +interface ImportSettings { + enabled: boolean + largeSize: number + smallSize: number + showCalculating: boolean + sizeColors: { + small: string + large: string + medium: string + } +} + +export class ImportCostLayer implements Oni.BufferLayer { + private _config: ImportSettings + private defaultConfig: ImportSettings = { + enabled: false, + largeSize: 50, + smallSize: 5, + showCalculating: false, + sizeColors: { + small: "green", + large: "red", + medium: "yellow", + }, + } + + private readonly PLUGIN_NAME = "oni.plugins.importCost" + + constructor(private _oni: OniWithColor, private _buffer: Oni.EditorBufferEventArgs) { + this._config = this._getConfig() + + this._oni.configuration.onConfigurationChanged.subscribe(configChanges => { + if (this.PLUGIN_NAME in configChanges) { + this._config = configChanges[this.PLUGIN_NAME] + if (!this._config.enabled) { + cleanup() + } + } + }) + } + get id() { + return "import-costs" + } + + get friendlyName() { + return "Package sizes buffer layer" + } + + log = (...args: any[]) => { + this._oni.log.warn(...args) + } + + private _getConfig() { + const userConfig = this._oni.configuration.getValue(this.PLUGIN_NAME) + return { ...this.defaultConfig, ...userConfig } + } + + getPriority() { + const priorities = this._oni.configuration.getValue("layers.priority", []) + const index = priorities.indexOf(this.id) + return index >= 0 ? priorities.length - index : 0 + } + + render(context: Oni.BufferLayerRenderContext) { + const colors = this._oni.colors.getColors() + const priority = this.getPriority() + return ( + this._config.enabled && ( + + ) + ) + } +} + +// TODO: Add to API +export interface OniWithColor extends Oni.Plugin.Api { + colors: { + getColor(color: string): string + getColors(): { [key: string]: string } + } +} + +const isCompatible = (buf: Oni.EditorBufferEventArgs) => { + const ext = path.extname(buf.filePath) + const allowedExtensions = [".js", ".jsx", ".ts", ".tsx"] + return allowedExtensions.includes(ext) +} + +export const activate = (oni: OniWithColor) => { + oni.editors.activeEditor.onBufferEnter.subscribe(buf => { + const layer = new ImportCostLayer(oni, buf) + if (isCompatible(buf)) { + oni.editors.activeEditor.activeBuffer.addLayer(layer) + } else { + cleanup() // kill worker processes if layer isn't to be rendered + oni.editors.activeEditor.activeBuffer.removeLayer(layer) + } + }) +} diff --git a/extensions/oni-plugin-import-cost/tsconfig.json b/extensions/oni-plugin-import-cost/tsconfig.json new file mode 100644 index 0000000000..c71a5d2969 --- /dev/null +++ b/extensions/oni-plugin-import-cost/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "preserveConstEnums": true, + "outDir": "./lib", + "jsx": "react", + "lib": ["dom", "es2017"], + "declaration": true, + "sourceMap": true, + "target": "es2015", + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/package.json b/package.json index 508efe83a4..c7d442118e 100644 --- a/package.json +++ b/package.json @@ -600,6 +600,8 @@ "build:cli": "cd cli && tsc -p tsconfig.json", "build:plugin:oni-plugin-typescript": "cd vim/core/oni-plugin-typescript && yarn run build", "build:plugin:oni-plugin-git": "cd vim/core/oni-plugin-git && yarn run build", + "build:plugin:oni-plugin-import-cost": + "cd extensions/oni-plugin-import-cost && yarn run build", "build:plugin:oni-plugin-markdown-preview": "cd extensions/oni-plugin-markdown-preview && yarn run build", "build:plugin:oni-plugin-quickopen": "cd extensions/oni-plugin-quickopen && yarn run build", @@ -674,12 +676,16 @@ "webpack-dev-server --config browser/webpack.development.config.js --host localhost --port 8191", "watch:plugins": "run-p watch:plugins:*", "watch:plugins:oni-plugin-typescript": "cd vim/core/oni-plugin-typescript && tsc --watch", + "watch:plugins:oni-plugin-import-cost": + "cd extensions/oni-plugin-import-cost && tsc --watch", "watch:plugins:oni-plugin-markdown-preview": "cd extensions/oni-plugin-markdown-preview && tsc --watch", "watch:plugins:oni-plugin-git": "cd vim/core/oni-plugin-git && tsc --watch", "install:plugins": "run-s install:plugins:*", "install:plugins:oni-plugin-markdown-preview": "cd extensions/oni-plugin-markdown-preview && yarn install --prod", + "install:plugins:oni-plugin-import-cost": + "cd extensions/oni-plugin-import-cost && yarn install --prod", "install:plugins:oni-plugin-prettier": "cd extensions/oni-plugin-prettier && yarn install --prod", "install:plugins:oni-plugin-git": "cd vim/core/oni-plugin-git && yarn install --prod", diff --git a/ui-tests/VersionControlBlameLayer.test.tsx b/ui-tests/VersionControlBlameLayer.test.tsx index 1fa626449a..5c0e1e7fec 100644 --- a/ui-tests/VersionControlBlameLayer.test.tsx +++ b/ui-tests/VersionControlBlameLayer.test.tsx @@ -119,12 +119,12 @@ describe("", () => { }) 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 }) + expect(position).toEqual({ hide: false, top: 20, left: 20, leftOffset: 12 }) }) 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 }) + expect(position).toEqual({ hide: false, top: 20, left: 20, leftOffset: 0 }) }) it("Should correctly determine if a line is out of bounds", () => { const outOfBounds = instance.isOutOfBounds(50, 10)