/** * Provides a simple Select list view style API * similar to atom space pen views */ import React = require("react"); var ReactDOM = require("react-dom"); import csx = require('./base/csx'); import { BaseComponent } from "./ui"; import * as ui from "./ui"; import Modal = require('react-modal'); import * as styles from "./styles/styles"; import { debounce, createMap, rangeLimited, getFileName } from "../common/utils"; import { cast, server } from "../socket/socketClient"; import * as commands from "./commands/commands"; import { match, filter as fuzzyFilter } from "fuzzaldrin"; import * as utils from "../common/utils"; import * as typestyle from "typestyle"; const inputClassName = typestyle.style(styles.modal.inputStyleBase); /** * The singleton select list view */ export var selectListView: SelectListView; type DataItem = any; export interface Props { } export interface State { isOpen?: boolean; filterValue?: string; selectedIndex?: number; header?: string; data?: DataItem[]; render?: (t: DataItem, highlighted: JSX.Element[]) => any; textify?: (t: DataItem) => string; onSelect?: (t: DataItem) => string; getNewData?: (filterValue: string) => Promise; } export class SelectListView extends BaseComponent{ filteredResults: DataItem[]; /* API sample usage this.refs.selectListView.show({ header: 'Select the active project', data: availableProjects, render: (d,highlitedText) =>
{highlitedText}
, textify: (d) => d.name, onSelect: (d) => { server.setActiveProjectName({ name: d.name }); state.setActiveProject(d.name); state.setInActiveProject(types.TriState.Unknown); } }); */ /** * The main interaction API */ show(args: { header: string; data: T[]; render: (t: T, highlighted: JSX.Element[]) => any; /** This text will be used for filtering as well as creating 'highlighted' which is passed to render */ textify: (t: T) => string; /** * If onselect returns a string then it is shown as an error * TODO: actually support this `return` */ onSelect: (t: T) => string; /** * Allows you to provide new data that can be used for filtering * Use Case: Opening a file from the server disk * TODO: actually support this */ getNewData?: (text: string) => Promise; }) { this.filteredResults = args.data.concat([]); this.setState({ isOpen: true, filterValue: '', selectedIndex: 0, header: args.header, data: args.data, render: args.render, textify: args.textify, onSelect: args.onSelect, getNewData: args.getNewData || (() => Promise.resolve(args.data)), }); ReactDOM.findDOMNode(this.refs.omniSearchInput).focus(); } maxShowCount = 15; constructor(props: Props) { super(props); this.filteredResults = []; this.state = { isOpen: false, selectedIndex: 0, header: '', data: [], }; } refs: { [string: string]: any; omniSearch: any; omniSearchInput: any; selected: Element; } componentDidMount() { selectListView = this; commands.esc.on(() => { this.closeOmniSearch(); }); } componentDidUpdate() { // get the dom node that is selected // make sure its parent scrolls to make this visible setTimeout(() => { if (this.refs.selected) { let selected = this.refs.selected as HTMLDivElement; selected.scrollIntoViewIfNeeded(false); } }); } render() { let fileList = this.filteredResults; let selectedIndex = this.state.selectedIndex; let fileListRendered = fileList.map((item, i) => { // key = i let selected = selectedIndex === i; let selectedStyle = selected ? { background: '#545454', color: 'white' } : {}; let ref = selected && "selected"; return (
this.selectIndex(i)} ref={ref}> {this.state.render(item, renderMatchedSegments(this.state.textify(item), this.state.filterValue))}
); }); return

{this.state.header}

Esc to exit Enter to select
{fileListRendered}
} closeOmniSearch = () => { this.setState({ isOpen: false, filterValue: '' }); }; onChangeFilter = debounce((e) => { let filterValue = ReactDOM.findDOMNode(this.refs.omniSearchInput).value; this.getNewData().then(() => { this.filteredResults = getFilteredItems({ items: this.state.data, textify: this.state.textify, filterValue }); this.filteredResults = this.filteredResults.slice(0, this.maxShowCount); this.setState({ filterValue, selectedIndex: 0 }); }); }, 50); incrementSelected = debounce(() => { this.setState({ selectedIndex: rangeLimited({ num: this.state.selectedIndex + 1, min: 0, max: Math.min(this.maxShowCount - 1, this.filteredResults.length - 1), loopAround: true }) }); }, 0, true); decrementSelected = debounce(() => { this.setState({ selectedIndex: rangeLimited({ num: this.state.selectedIndex - 1, min: 0, max: Math.min(this.maxShowCount - 1, this.filteredResults.length - 1), loopAround: true }) }); }, 0, true); onChangeSelected = (e) => { if (e.key == 'ArrowUp') { e.preventDefault(); this.decrementSelected(); } if (e.key == 'ArrowDown') { e.preventDefault(); this.incrementSelected(); } if (e.key == 'Enter') { e.preventDefault(); this.selectIndex(this.state.selectedIndex); } }; selectIndex = (index: number) => { let result = this.filteredResults[index]; this.state.onSelect(result); this.closeOmniSearch(); }; getNewData = utils.onlyLastCall(() => { let filterValue = ReactDOM.findDOMNode(this.refs.omniSearchInput).value; return this.state.getNewData(filterValue).then((data) => { this.setState({ data }); }); }); } /** * Applies fuzzy filter to the text version of each item returning the matched items */ export function getFilteredItems(args: { items: T[], textify: (item: T) => string, filterValue: string }): T[] { // Store the items for each text value let textValueToItems: { [text: string]: T[] } = Object.create(null); args.items.forEach((item) => { let text = args.textify(item); if (!textValueToItems[text]) textValueToItems[text] = []; textValueToItems[text].push(item); }) // Get the unique text values let textValues = Object.keys(utils.createMap(args.items.map(args.textify))); // filter them let filteredTextValues = fuzzyFilter(textValues, args.filterValue); return utils.selectMany(filteredTextValues.map((textvalue) => textValueToItems[textvalue])); } /** * Based on https://github.com/atom/fuzzy-finder/blob/51f1f2415ecbfab785596825a011c1d2fa2658d3/lib/fuzzy-finder-view.coffee#L56-L74 */ export function renderMatchedSegments(result: string, query: string): JSX.Element[] { // A data structure which is efficient to render type MatchedSegments = { str: string, matched: boolean }[]; // local function that creates the *matched segment* data structure function getMatchedSegments(result: string, query: string) { let matches = match(result, query); let matchMap = createMap(matches); // collapse contiguous sections into a single `` let currentUnmatchedCharacters = []; let currentMatchedCharacters = []; let combined: MatchedSegments = []; function closeOffUnmatched() { if (currentUnmatchedCharacters.length) { combined.push({ str: currentUnmatchedCharacters.join(''), matched: false }); currentUnmatchedCharacters = []; } } function closeOffMatched() { if (currentMatchedCharacters.length) { combined.push({ str: currentMatchedCharacters.join(''), matched: true }); currentMatchedCharacters = []; } } result.split('').forEach((c, i) => { let isMatched = matchMap[i]; if (isMatched) { if (currentMatchedCharacters.length) { currentMatchedCharacters.push(c); } else { currentMatchedCharacters = [c] // close off any unmatched characters closeOffUnmatched(); } } else { if (currentUnmatchedCharacters.length) { currentUnmatchedCharacters.push(c); } else { currentUnmatchedCharacters = [c] // close off any matched characters closeOffMatched(); } } }); closeOffMatched(); closeOffUnmatched(); return combined; } /** * Rendering the matched segment data structure is trivial */ let matched = getMatchedSegments(result, query); let matchedStyle = { fontWeight: 'bold' as 'bold', color: '#66d9ef' }; return matched.map((item, i) => { return {item.str}; }); }