mirror of https://github.com/onivim/oni.git
665 lines
20 KiB
TypeScript
665 lines
20 KiB
TypeScript
/**
|
|
* NeovimEditor.ts
|
|
*
|
|
* IEditor implementation for Neovim
|
|
*/
|
|
|
|
import * as Oni from "oni-api"
|
|
import * as Log from "oni-core-logging"
|
|
import { Event } from "oni-types"
|
|
import * as React from "react"
|
|
|
|
import { getMetadata } from "./../../Services/Metadata"
|
|
import { ISession, SessionManager } from "./../../Services/Sessions"
|
|
import styled, {
|
|
boxShadowInset,
|
|
Css,
|
|
css,
|
|
enableMouse,
|
|
getSelectedBorder,
|
|
keyframes,
|
|
lighten,
|
|
} from "./../../UI/components/common"
|
|
import { Icon } from "./../../UI/Icon"
|
|
|
|
// const entrance = keyframes`
|
|
// 0% { opacity: 0; transform: translateY(2px); }
|
|
// 100% { opacity: 0.5; transform: translateY(0px); }
|
|
// `
|
|
|
|
// const enterLeft = keyframes`
|
|
// 0% { opacity: 0; transform: translateX(-4px); }
|
|
// 100% { opacity: 1; transform: translateX(0px); }
|
|
// `
|
|
|
|
// const enterRight = keyframes`
|
|
// 0% { opacity: 0; transform: translateX(4px); }
|
|
// 100% { opacity: 1; transform: translateX(0px); }
|
|
// `
|
|
|
|
const entranceFull = keyframes`
|
|
0% {
|
|
opacity: 0;
|
|
transform: translateY(8px);
|
|
}
|
|
100% {
|
|
opacity: 1;
|
|
transform: translateY(0px);
|
|
}
|
|
`
|
|
const WelcomeWrapper = styled.div`
|
|
background-color: ${p => p.theme["editor.background"]};
|
|
color: ${p => p.theme["editor.foreground"]};
|
|
overflow-y: hidden;
|
|
user-select: none;
|
|
pointer-events: all;
|
|
width: 100%;
|
|
height: 100%;
|
|
opacity: 0;
|
|
animation: ${entranceFull} 0.25s ease-in 0.1s forwards ${enableMouse};
|
|
`
|
|
|
|
interface IColumnProps {
|
|
alignment?: string
|
|
justify?: string
|
|
flex?: string
|
|
height?: string
|
|
extension?: Css
|
|
}
|
|
|
|
const Column = styled<IColumnProps, "div">("div")`
|
|
background: inherit;
|
|
display: flex;
|
|
justify-content: ${({ justify }) => justify || `center`};
|
|
align-items: ${({ alignment }) => alignment || "center"};
|
|
flex-direction: column;
|
|
width: 100%;
|
|
flex: ${({ flex }) => flex || "1"};
|
|
height: ${({ height }) => height || `auto`};
|
|
${({ extension }) => extension};
|
|
`
|
|
|
|
const sectionStyles = css`
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: flex-start;
|
|
align-items: center;
|
|
height: 90%;
|
|
overflow-y: hidden;
|
|
direction: rtl;
|
|
&:hover {
|
|
overflow-y: overlay;
|
|
}
|
|
& > * {
|
|
direction: ltr;
|
|
}
|
|
`
|
|
|
|
const LeftColumn = styled.div`
|
|
${sectionStyles};
|
|
padding: 0;
|
|
padding-left: 1rem;
|
|
overflow-y: hidden;
|
|
width: 60%;
|
|
`
|
|
|
|
const RightColumn = styled.div`
|
|
${sectionStyles};
|
|
width: 30%;
|
|
border-left: 1px solid ${({ theme }) => theme["editor.background"]};
|
|
`
|
|
|
|
const Row = styled<{ extension?: Css }, "div">("div")`
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
flex-direction: row;
|
|
opacity: 0;
|
|
${({ extension }) => extension};
|
|
`
|
|
|
|
const TitleText = styled.div`
|
|
font-size: 2em;
|
|
text-align: right;
|
|
`
|
|
|
|
const SubtitleText = styled.div`
|
|
font-size: 1.2em;
|
|
text-align: right;
|
|
`
|
|
|
|
const HeroImage = styled.img`
|
|
width: 192px;
|
|
height: 192px;
|
|
opacity: 0.4;
|
|
`
|
|
|
|
export const SectionHeader = styled.div`
|
|
margin-top: 1em;
|
|
margin-bottom: 1em;
|
|
font-size: 1.2em;
|
|
font-weight: bold;
|
|
text-align: left;
|
|
width: 100%;
|
|
`
|
|
|
|
const WelcomeButtonHoverStyled = `
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 8px 2px rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
|
`
|
|
|
|
export interface WelcomeButtonWrapperProps {
|
|
isSelected: boolean
|
|
borderSize: string
|
|
}
|
|
|
|
const WelcomeButtonWrapper = styled<WelcomeButtonWrapperProps, "button">("button")`
|
|
box-sizing: border-box;
|
|
font-size: inherit;
|
|
font-family: inherit;
|
|
border: 0px solid ${props => props.theme.foreground};
|
|
border-left: ${getSelectedBorder};
|
|
border-right: 4px solid transparent;
|
|
cursor: pointer;
|
|
color: ${({ theme }) => theme.foreground};
|
|
background-color: ${({ theme }) => lighten(theme.background)};
|
|
transform: ${({ isSelected }) => (isSelected ? "translateX(-4px)" : "translateX(0px)")};
|
|
transition: transform 0.25s;
|
|
width: 100%;
|
|
margin: 0.8em 0;
|
|
padding: 0.8em;
|
|
display: flex;
|
|
flex-direction: row;
|
|
&:hover {
|
|
${WelcomeButtonHoverStyled};
|
|
}
|
|
`
|
|
|
|
const AnimatedContainer = styled<{ duration: string }, "div">("div")`
|
|
width: 100%;
|
|
animation: ${entranceFull} ${p => p.duration} ease-in 1s both;
|
|
`
|
|
|
|
const WelcomeButtonTitle = styled.span`
|
|
font-size: 1em;
|
|
font-weight: bold;
|
|
margin: 0.4em;
|
|
width: 100%;
|
|
text-align: left;
|
|
`
|
|
|
|
const WelcomeButtonDescription = styled.span`
|
|
font-size: 0.8em;
|
|
opacity: 0.75;
|
|
margin: 4px;
|
|
width: 100%;
|
|
text-align: right;
|
|
`
|
|
|
|
const boxStyling = css`
|
|
width: 60%;
|
|
height: 60%;
|
|
padding: 0 1em;
|
|
opacity: 1;
|
|
margin-top: 64px;
|
|
box-sizing: border-box;
|
|
border: 1px solid ${p => p.theme["editor.hover.contents.background"]};
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
justify-content: space-around;
|
|
background-color: ${p => p.theme["editor.hover.contents.codeblock.background"]};
|
|
${boxShadowInset};
|
|
`
|
|
|
|
const titleRow = css`
|
|
width: 100%;
|
|
padding-top: 32px;
|
|
animation: ${entranceFull} 0.25s ease-in 0.25s forwards};
|
|
`
|
|
|
|
const selectedSectionItem = css`
|
|
${({ theme }) => `
|
|
text-decoration: underline;
|
|
color: ${theme["highlight.mode.normal.background"]};
|
|
`};
|
|
`
|
|
|
|
export const SectionItem = styled<{ isSelected?: boolean }, "li">("li")`
|
|
width: 100%;
|
|
margin: 0.2em;
|
|
text-align: left;
|
|
height: auto;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
${({ isSelected }) => isSelected && selectedSectionItem};
|
|
|
|
&:hover {
|
|
text-decoration: underline;
|
|
}
|
|
`
|
|
|
|
export const SessionsList = styled.ul`
|
|
width: 70%;
|
|
margin: 0;
|
|
list-style-type: none;
|
|
border-radius: 4px;
|
|
padding: 0 1em;
|
|
border: 1px solid ${p => p.theme["editor.hover.contents.codeblock.background"]};
|
|
`
|
|
|
|
export interface WelcomeButtonProps {
|
|
title: string
|
|
description: string
|
|
command: string
|
|
selected: boolean
|
|
onClick: () => void
|
|
}
|
|
|
|
interface IChromeDiv extends HTMLButtonElement {
|
|
scrollIntoViewIfNeeded: () => void
|
|
}
|
|
|
|
export class WelcomeButton extends React.PureComponent<WelcomeButtonProps> {
|
|
private _button = React.createRef<IChromeDiv>()
|
|
|
|
public componentDidUpdate(prevProps: WelcomeButtonProps) {
|
|
if (!prevProps.selected && this.props.selected) {
|
|
this._button.current.scrollIntoViewIfNeeded()
|
|
}
|
|
}
|
|
|
|
public render() {
|
|
return (
|
|
<WelcomeButtonWrapper
|
|
borderSize="4px"
|
|
innerRef={this._button}
|
|
isSelected={this.props.selected}
|
|
onClick={this.props.onClick}
|
|
>
|
|
<WelcomeButtonTitle>{this.props.title}</WelcomeButtonTitle>
|
|
<WelcomeButtonDescription>{this.props.description}</WelcomeButtonDescription>
|
|
</WelcomeButtonWrapper>
|
|
)
|
|
}
|
|
}
|
|
|
|
export interface WelcomeHeaderState {
|
|
version: string
|
|
}
|
|
|
|
export interface OniWithActiveSection extends Oni.Plugin.Api {
|
|
sessions: SessionManager
|
|
getActiveSection(): string
|
|
}
|
|
|
|
type ExecuteCommand = <T>(command: string, args?: T) => void
|
|
|
|
export interface IWelcomeInputEvent {
|
|
select: boolean
|
|
vertical: number
|
|
horizontal?: number
|
|
}
|
|
|
|
interface ICommandMetadata {
|
|
execute: <T = undefined>(args?: T) => void
|
|
command: string
|
|
}
|
|
|
|
export interface IWelcomeCommandsDictionary {
|
|
openFile: ICommandMetadata
|
|
openTutor: ICommandMetadata
|
|
openDocs: ICommandMetadata
|
|
openConfig: ICommandMetadata
|
|
openThemes: ICommandMetadata
|
|
openWorkspaceFolder: ICommandMetadata
|
|
commandPalette: ICommandMetadata
|
|
commandline: ICommandMetadata
|
|
restoreSession: (sessionName: string) => Promise<void>
|
|
}
|
|
|
|
export class WelcomeBufferLayer implements Oni.BufferLayer {
|
|
public inputEvent = new Event<IWelcomeInputEvent>()
|
|
|
|
constructor(private _oni: OniWithActiveSection) {}
|
|
|
|
public showCommandline = async () => {
|
|
const remapping: string = await this._oni.editors.activeEditor.neovim.callFunction(
|
|
"mapcheck",
|
|
[":", "n"],
|
|
)
|
|
const mapping = remapping || ":"
|
|
this._oni.automation.sendKeys(mapping)
|
|
}
|
|
|
|
public executeCommand: ExecuteCommand = (cmd, args) => {
|
|
this._oni.commands.executeCommand(cmd, args)
|
|
}
|
|
|
|
public restoreSession = async (name: string) => {
|
|
await this._oni.sessions.restoreSession(name)
|
|
}
|
|
|
|
// tslint:disable-next-line
|
|
public readonly welcomeCommands: IWelcomeCommandsDictionary = {
|
|
openFile: {
|
|
execute: args => this.executeCommand("oni.editor.newFile", args),
|
|
command: "oni.editor.newFile",
|
|
},
|
|
openWorkspaceFolder: {
|
|
execute: args => this.executeCommand("workspace.openFolder", args),
|
|
command: "workspace.openFolder",
|
|
},
|
|
commandPalette: {
|
|
execute: args => this.executeCommand("commands.show", args),
|
|
command: "commands.show",
|
|
},
|
|
commandline: {
|
|
execute: this.showCommandline,
|
|
command: "editor.executeVimCommand",
|
|
},
|
|
openTutor: {
|
|
execute: args => this.executeCommand("oni.tutor.open", args),
|
|
command: "oni.tutor.open",
|
|
},
|
|
openDocs: {
|
|
execute: args => this.executeCommand("oni.docs.open", args),
|
|
command: "oni.docs.open",
|
|
},
|
|
openConfig: {
|
|
execute: args => this.executeCommand("oni.config.openUserConfig", args),
|
|
command: "oni.config.openUserConfig",
|
|
},
|
|
openThemes: {
|
|
execute: args => this.executeCommand("oni.themes.choose", args),
|
|
command: "oni.themes.open",
|
|
},
|
|
restoreSession: args => this.restoreSession(args),
|
|
}
|
|
|
|
public get id() {
|
|
return "oni.welcome"
|
|
}
|
|
|
|
public get friendlyName() {
|
|
return "Welcome"
|
|
}
|
|
|
|
public isActive(): boolean {
|
|
const activeSection = this._oni.getActiveSection()
|
|
return activeSection === "editor"
|
|
}
|
|
|
|
public handleInput(key: string) {
|
|
Log.info(`ONI WELCOME INPUT KEY: ${key}`)
|
|
switch (key) {
|
|
case "j":
|
|
this.inputEvent.dispatch({ vertical: 1, select: false })
|
|
break
|
|
case "k":
|
|
this.inputEvent.dispatch({ vertical: -1, select: false })
|
|
break
|
|
case "l":
|
|
this.inputEvent.dispatch({ vertical: 0, select: false, horizontal: 1 })
|
|
break
|
|
case "h":
|
|
this.inputEvent.dispatch({ vertical: 0, select: false, horizontal: -1 })
|
|
break
|
|
case "<enter>":
|
|
this.inputEvent.dispatch({ vertical: 0, select: true })
|
|
break
|
|
default:
|
|
this.inputEvent.dispatch({ vertical: 0, select: false })
|
|
}
|
|
}
|
|
|
|
public getProps() {
|
|
const active = this._oni.getActiveSection() === "editor"
|
|
const commandIds = Object.values(this.welcomeCommands)
|
|
.map(({ command }) => command)
|
|
.filter(Boolean)
|
|
|
|
const sessions = this._oni.sessions ? this._oni.sessions.allSessions : ([] as ISession[])
|
|
const sessionIds = sessions.map(({ id }) => id)
|
|
const ids = [...commandIds, ...sessionIds]
|
|
const sections = [commandIds.length, sessionIds.length].filter(Boolean)
|
|
|
|
return { active, ids, sections, sessions }
|
|
}
|
|
|
|
public render(context: Oni.BufferLayerRenderContext) {
|
|
const props = this.getProps()
|
|
return (
|
|
<WelcomeWrapper>
|
|
<WelcomeView
|
|
{...props}
|
|
getMetadata={getMetadata}
|
|
inputEvent={this.inputEvent}
|
|
commands={this.welcomeCommands}
|
|
restoreSession={this.restoreSession}
|
|
executeCommand={this.executeCommand}
|
|
/>
|
|
</WelcomeWrapper>
|
|
)
|
|
}
|
|
}
|
|
|
|
export interface WelcomeViewProps {
|
|
active: boolean
|
|
sessions: ISession[]
|
|
sections: number[]
|
|
ids: string[]
|
|
inputEvent: Event<IWelcomeInputEvent>
|
|
commands: IWelcomeCommandsDictionary
|
|
getMetadata: () => Promise<{ version: string }>
|
|
restoreSession: (name: string) => Promise<void>
|
|
executeCommand: ExecuteCommand
|
|
}
|
|
|
|
export interface WelcomeViewState {
|
|
version: string
|
|
selectedId: string
|
|
currentIndex: number
|
|
}
|
|
|
|
export class WelcomeView extends React.PureComponent<WelcomeViewProps, WelcomeViewState> {
|
|
public state: WelcomeViewState = {
|
|
version: null,
|
|
currentIndex: 0,
|
|
selectedId: this.props.ids[0],
|
|
}
|
|
|
|
private _welcomeElement = React.createRef<HTMLDivElement>()
|
|
|
|
public async componentDidMount() {
|
|
const metadata = await this.props.getMetadata()
|
|
this.setState({ version: metadata.version })
|
|
this.props.inputEvent.subscribe(this.handleInput)
|
|
}
|
|
|
|
public handleInput = async ({ vertical, select, horizontal }: IWelcomeInputEvent) => {
|
|
const { currentIndex } = this.state
|
|
const { sections, ids, active } = this.props
|
|
|
|
const newIndex = this.getNextIndex(currentIndex, vertical, horizontal, sections)
|
|
const selectedId = ids[newIndex]
|
|
this.setState({ currentIndex: newIndex, selectedId })
|
|
|
|
const selectedSession = this.props.sessions.find(session => session.id === selectedId)
|
|
|
|
if (select && active) {
|
|
if (selectedSession) {
|
|
await this.props.commands.restoreSession(selectedSession.name)
|
|
} else {
|
|
const currentCommand = this.getCurrentCommand(selectedId)
|
|
currentCommand.execute()
|
|
}
|
|
}
|
|
}
|
|
|
|
public getCurrentCommand(selectedId: string): ICommandMetadata {
|
|
const { commands } = this.props
|
|
const currentCommand = Object.values(commands).find(({ command }) => command === selectedId)
|
|
return currentCommand
|
|
}
|
|
|
|
public getNextIndex(
|
|
currentIndex: number,
|
|
vertical: number,
|
|
horizontal: number,
|
|
sections: number[],
|
|
) {
|
|
const nextPosition = currentIndex + vertical
|
|
const numberOfItems = this.props.ids.length
|
|
const multipleSections = sections.length > 1
|
|
|
|
// TODO: this currently handles *TWO* sections if more sections
|
|
// are to be added will need to rethink how to allow navigation across multiple sections
|
|
switch (true) {
|
|
case multipleSections && horizontal === 1:
|
|
return sections[0]
|
|
case multipleSections && horizontal === -1:
|
|
return 0
|
|
case nextPosition < 0:
|
|
return numberOfItems - 1
|
|
case nextPosition === numberOfItems:
|
|
return 0
|
|
default:
|
|
return nextPosition
|
|
}
|
|
}
|
|
|
|
public componentDidUpdate() {
|
|
if (this.props.active && this._welcomeElement && this._welcomeElement.current) {
|
|
this._welcomeElement.current.focus()
|
|
}
|
|
}
|
|
|
|
public render() {
|
|
const { version, selectedId } = this.state
|
|
return (
|
|
<Column innerRef={this._welcomeElement} height="100%" data-id="welcome-screen">
|
|
<Row extension={titleRow}>
|
|
<Column />
|
|
<Column alignment="flex-end">
|
|
<TitleText>Oni</TitleText>
|
|
<SubtitleText>Modern Modal Editing</SubtitleText>
|
|
</Column>
|
|
<Column flex="0 0">
|
|
<HeroImage src="images/oni-icon-no-border.svg" />
|
|
</Column>
|
|
<Column alignment="flex-start">
|
|
{version && <SubtitleText>{`v${version}`}</SubtitleText>}
|
|
<div>{"https://onivim.io"}</div>
|
|
</Column>
|
|
<Column />
|
|
</Row>
|
|
<Row extension={boxStyling}>
|
|
<WelcomeCommandsView
|
|
commands={this.props.commands}
|
|
selectedId={selectedId}
|
|
executeCommand={this.props.executeCommand}
|
|
/>
|
|
<RightColumn>
|
|
<SessionsList>
|
|
<SectionHeader>Sessions</SectionHeader>
|
|
{this.props.sessions.length ? (
|
|
this.props.sessions.map(session => (
|
|
<SectionItem
|
|
isSelected={session.id === selectedId}
|
|
onClick={() => this.props.restoreSession(session.name)}
|
|
key={session.id}
|
|
>
|
|
<Icon name="file" style={{ marginRight: "0.3em" }} />{" "}
|
|
{session.name}
|
|
</SectionItem>
|
|
))
|
|
) : (
|
|
<SectionItem>No Sessions Available</SectionItem>
|
|
)}
|
|
</SessionsList>
|
|
</RightColumn>
|
|
</Row>
|
|
</Column>
|
|
)
|
|
}
|
|
}
|
|
|
|
export interface IWelcomeCommandsViewProps extends Partial<WelcomeViewProps> {
|
|
selectedId: string
|
|
}
|
|
|
|
export const WelcomeCommandsView: React.SFC<IWelcomeCommandsViewProps> = props => {
|
|
const isSelected = (command: string) => command === props.selectedId
|
|
const { commands } = props
|
|
return (
|
|
<LeftColumn>
|
|
<AnimatedContainer duration="0.25s">
|
|
<SectionHeader>Quick Commands</SectionHeader>
|
|
<WelcomeButton
|
|
title="New File"
|
|
onClick={() => commands.openFile.execute()}
|
|
description="Control + N"
|
|
command={commands.openFile.command}
|
|
selected={isSelected(commands.openFile.command)}
|
|
/>
|
|
<WelcomeButton
|
|
title="Open File / Folder"
|
|
description="Control + O"
|
|
onClick={() => commands.openWorkspaceFolder.execute()}
|
|
command={commands.openWorkspaceFolder.command}
|
|
selected={isSelected(commands.openWorkspaceFolder.command)}
|
|
/>
|
|
<WelcomeButton
|
|
title="Command Palette"
|
|
onClick={() => commands.commandPalette.execute()}
|
|
description="Control + Shift + P"
|
|
command={commands.commandPalette.command}
|
|
selected={isSelected(commands.commandPalette.command)}
|
|
/>
|
|
<WelcomeButton
|
|
title="Vim Ex Commands"
|
|
description=":"
|
|
command="editor.openExCommands"
|
|
onClick={() => commands.commandline.execute()}
|
|
selected={isSelected(commands.commandline.command)}
|
|
/>
|
|
</AnimatedContainer>
|
|
<AnimatedContainer duration="0.25s">
|
|
<SectionHeader>Learn</SectionHeader>
|
|
<WelcomeButton
|
|
title="Tutor"
|
|
onClick={() => commands.openTutor.execute()}
|
|
description="Learn modal editing with an interactive tutorial."
|
|
command={commands.openTutor.command}
|
|
selected={isSelected(commands.openTutor.command)}
|
|
/>
|
|
<WelcomeButton
|
|
title="Documentation"
|
|
onClick={() => commands.openDocs.execute()}
|
|
description="Discover what Oni can do for you."
|
|
command={commands.openDocs.command}
|
|
selected={isSelected(commands.openDocs.command)}
|
|
/>
|
|
</AnimatedContainer>
|
|
<AnimatedContainer duration="0.25s">
|
|
<SectionHeader>Customize</SectionHeader>
|
|
<WelcomeButton
|
|
title="Configure"
|
|
onClick={() => commands.openConfig.execute()}
|
|
description="Make Oni work the way you want."
|
|
command={commands.openConfig.command}
|
|
selected={isSelected(commands.openConfig.command)}
|
|
/>
|
|
<WelcomeButton
|
|
title="Themes"
|
|
onClick={() => commands.openThemes.execute()}
|
|
description="Choose a theme that works for you."
|
|
command={commands.openThemes.command}
|
|
selected={isSelected(commands.openThemes.command)}
|
|
/>
|
|
</AnimatedContainer>
|
|
</LeftColumn>
|
|
)
|
|
}
|