diff options
author | alexwl <alexey.a.kiryushin@gmail.com> | 2018-10-02 13:17:04 +0300 |
---|---|---|
committer | alexwl <alexey.a.kiryushin@gmail.com> | 2018-10-02 13:17:04 +0300 |
commit | cf2c56c7061b7ed40fdd3b40a352ddb9c9b7371f (patch) | |
tree | b1de9ada0f1b1cb064e3a9e0d4042d1f519085bd /javascript/app |
Initial commit
Diffstat (limited to 'javascript/app')
66 files changed, 3160 insertions, 0 deletions
diff --git a/javascript/app/app.js b/javascript/app/app.js new file mode 100644 index 0000000..9b24136 --- /dev/null +++ b/javascript/app/app.js @@ -0,0 +1,15 @@ +import Ember from 'ember'; +import Resolver from 'ember-resolver'; +import loadInitializers from 'ember-load-initializers'; +import config from './config/environment'; + +var App; + +App = Ember.Application.extend({ + modulePrefix: config.modulePrefix, + Resolver: Resolver +}); + +loadInitializers(App, config.modulePrefix); + +export default App; diff --git a/javascript/app/components/.gitkeep b/javascript/app/components/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/javascript/app/components/.gitkeep diff --git a/javascript/app/components/bottom-panel.js b/javascript/app/components/bottom-panel.js new file mode 100644 index 0000000..5d8b5bc --- /dev/null +++ b/javascript/app/components/bottom-panel.js @@ -0,0 +1,52 @@ +import Ember from 'ember'; + +function show(component) { + const height = Math.floor(component.$containerElement.height() /2); + component.$().css({ + "display":"block", + "top" : height+"px" + }); + component.$topPanelElement.css({ + "height":height+"px" + }); +} + +function hide(component) { + const height = Math.floor(component.$containerElement.height()/2); + component.$().css({ + "display":"none", + "height":height+"px" + }); + component.$topPanelElement.css({ + "height":"100%" + }); +} + +export default Ember.Component.extend({ + classNames:["bottom-panel"], + didInsertElement : function () { + this._super(...arguments); + this.$topPanelElement = Ember.$(this.get('topPanelElementId')); + this.$containerElement = Ember.$(this.get('containerElementId')); + Ember.run.next(this,() => { + Ember.$(this.element).resizable({ + handles:"n", + maxHeight:700, + minHeight:200, + resize: (event,ui) => { + Ember.run.next(this,() => { + this.$topPanelElement.css({"height": this.$containerElement.height() - ui.size.height}); + }); + } + }); + }); + }, + visibilityObserver : Ember.observer('visible',function () { + this.get('visible') ? show(this) : hide(this); + }), + actions : { + close () { + this.set('visible',false); + } + } +}); diff --git a/javascript/app/components/expression-info.js b/javascript/app/components/expression-info.js new file mode 100644 index 0000000..d403c49 --- /dev/null +++ b/javascript/app/components/expression-info.js @@ -0,0 +1,3 @@ +import Ember from 'ember'; +export default Ember.Component.extend({ +}); diff --git a/javascript/app/components/file-tree.js b/javascript/app/components/file-tree.js new file mode 100644 index 0000000..c86a71d --- /dev/null +++ b/javascript/app/components/file-tree.js @@ -0,0 +1,94 @@ +import Ember from 'ember'; + +const directoryTreeToJsTree = function (packageId,directoryTree) { + return directoryTree.contents.map((node) => { + const jsTreeNode = {}; + jsTreeNode.text = node.name; + jsTreeNode.data = node; + if(node.path) { + jsTreeNode.id = node.path; + jsTreeNode.a_attr = {href:"/package/" + packageId + "/show/" + node.path}; + } + if(node.tag === "Dir") { + jsTreeNode.children = directoryTreeToJsTree(packageId,node); + jsTreeNode.state = {"opened" : containsHaskellModule(node)}; + } else { + if(node.isHaskellModule) { + jsTreeNode.icon = "/assets/haskell.ico"; + jsTreeNode.isHaskellModule = true; + } else { + jsTreeNode.icon = "jstree-file"; + jsTreeNode.isHaskellModule = false; + } + } + return jsTreeNode; + }); +}; + +const containsHaskellModule = function(node) { + return node.contents.some((n) => { + if(n.tag === "File") { + return n.isHaskellModule; + } else { + return containsHaskellModule(n); + } + }); +} + +export default Ember.Component.extend({ + query: null, + didInsertElement : function () { + this._super(...arguments); + const element = this.element.getElementsByClassName('file-tree')[0]; + + const jstreeElement = Ember.$(element).jstree({ + 'core' : { + 'data' : directoryTreeToJsTree(this.get('packageId'),this.get('directoryTree')) + }, + "plugins" : [ + "search" + ], + "search": { + "case_insensitive": true, + "show_only_matches" : true, + "show_only_matches_children": true + } + }); + + jstreeElement.on("select_node.jstree",(event,data) => { + const file = data.node.data; + if(file.tag != "Dir") { + this.sendAction('openFile',file.path); + } + }); + + const jstree = jstreeElement.jstree(true); + + if(this.get('currentFile')) { + jstree.select_node(this.get('currentFile')); + const node = jstree.get_node(this.get('currentFile'),true)[0]; + if(node) { + node.scrollIntoView(); + } + } + this.jstree = jstree; + }, + currentFileObserver : Ember.observer('currentFile',function() { + Ember.run.next(() => { + this.jstree.deselect_all(); + this.jstree.select_node(this.get('currentFile')); + }); + }), + queryObserver : Ember.observer('query',function() { + if(this.get('query')) { + this.jstree.search(this.get('query')); + } else { + this.jstree.clear_search(); + } + }), + actions : { + hide() { + this.get('hide')(); + } + } +}); diff --git a/javascript/app/components/haskell-module.js b/javascript/app/components/haskell-module.js new file mode 100644 index 0000000..8883a9c --- /dev/null +++ b/javascript/app/components/haskell-module.js @@ -0,0 +1,492 @@ +import Ember from 'ember'; +import {goToDefinition} from '../utils/go-to-definition'; +import {initializeLineSelection} from '../utils/line-selection'; + +function compareLocations (p1,p2) { + if(p1.line === p2.line) { + if(p1.column === p2.column) { + return 0; + } else if(p1.column > p2.column) { + return 1; + } else { + return -1; + } + } else if(p1.line > p2.line) { + return 1; + } else { + return -1; + } +} + +function buildSrcSpan(sourceCodeLines,start,end) { + if(sourceCodeLines[start.line] && sourceCodeLines[end.line]) { + if(start.line === end.line) { + return sourceCodeLines[start.line].slice(start.column-1,end.column-1); + } else { + const firstLine = sourceCodeLines[start.line]; + let middleLines = []; + for(let i = start.line + 1; i < end.line;i ++) { + middleLines.push(sourceCodeLines[i]); + } + const lastLine = sourceCodeLines[end.line]; + const minOffset = Math.min(start.column, + (middleLines.concat([lastLine])) + .map((line) => line.search(/\S/)) + .reduce((min,value) => Math.min(min,value))); + return firstLine.slice(start.column-1,firstLine.length) + "\n" + + middleLines.map((line) => line.slice(minOffset,line.length)).join("\n") + + (middleLines.length ? "\n" : "") + lastLine.slice(minOffset,end.column-1); + } + } else { + return null; + } +} + +function modifyClass(element,on) { + if(on) { + element.classList.add('highlighted-identifier'); + } else { + element.classList.remove('highlighted-identifier'); + } +} + +function highlightIdentifiers(parentElement,identifierElement,on) { + if(identifierElement.id) { + const identifiers = Array.prototype.slice.call(parentElement.querySelectorAll("span[id='"+identifierElement.id+"']")); + identifiers.forEach((identifier) => { + modifyClass(identifier,on); + }); + } else { + modifyClass(identifierElement,on);//Literal + } +} + +//divident is a string +//divident may have any number of digits +function modulo(divident, divisor) { + return Array.from(divident).map(c => parseInt(c)) + .reduce((acc, value) => { + return (acc * 10 + value) % divisor; + },0); +} + +function isDefinedInCurrentModule(moduleName,modulePath,identifierInfo) { + return (identifierInfo.sort === "External") && + (identifierInfo.locationInfo.modulePath === modulePath + || identifierInfo.locationInfo.moduleName === moduleName) +} + +function identifierStyle(identifierElement, + identifiers, + occurrences, + path, + colorTheme, + moduleName) { + const idOcc = occurrences[identifierElement.dataset.occurrence]; + + let color = colorTheme.defaultColor; + let fontWeight; + + if(idOcc) { + if(idOcc.sort.tag === 'TypeId') { + color = colorTheme.typeColor; + } else if(idOcc.description === "HsLit" || + idOcc.description === "HsOverLit"|| + idOcc.description === "LitPat" || + idOcc.description === "NPat" || + idOcc.description === "NPlusKPat" || + idOcc.description === "OverLit") { + color = colorTheme.literalColor; + } else { + const idInfo = identifiers[identifierElement.dataset.identifier]; + if(idInfo) { + if(isDefinedInCurrentModule(moduleName,path,idInfo)) { + color = colorTheme.topLevelIdFromCurrentModule; + } else if(idInfo.sort === "Internal" && idInfo.locationInfo.tag === "ExactLocation") { + const colorNumber = modulo(identifierElement.id,colorTheme.localIdentifierColor.length); + color = colorTheme.localIdentifierColor[colorNumber]; + fontWeight = "bold"; + } + } + } + } + + return "color:"+color+";" + +(fontWeight ? "font-weight:" + fontWeight : "")+";" + +(idOcc.isBinder ? "text-decoration:underline;" : ""); +} + +function initializeIdentifiers (sourceCodeContainerElement,component) { + const identifierElements = Array.prototype.slice.call(sourceCodeContainerElement.querySelectorAll("span.identifier")); + if(identifierElements.length > 0) { + const timeout = 250;//milliseconds + let timer = null; + + identifierElements.forEach((identifierElement) => { + + const cssText = identifierStyle(identifierElement, + component.get('identifiers'), + component.get('occurrences'), + component.get('path'), + component.get('colorTheme'), + component.get('name')); + + identifierElement.style.cssText = cssText; + + //go to definition + identifierElement.onmouseup = (event) => { + if(timer) { + clearTimeout(timer); + } + + if(!window.getSelection().isCollapsed) { + return; + } + + const identifierInfo = component.get('identifiers')[identifierElement.dataset.identifier]; + const idOccurrenceInfo = component.get('occurrences')[identifierElement.dataset.occurrence]; + + const currentLineNumber = parseInt(identifierElement.parentNode.dataset.line); + + if(idOccurrenceInfo.sort.tag === "ModuleId") { + goToDefinition(component.get('store'), + idOccurrenceInfo.sort.contents, + event.which, + currentLineNumber); + } + else { + if(!idOccurrenceInfo.isBinder && identifierInfo + && (event.which === 1 || event.which === 2)) { + goToDefinition(component.get('store'), + identifierInfo.locationInfo, + event.which, + currentLineNumber); + } + } + } + + identifierElement.onmouseover = () => { + highlightIdentifiers(sourceCodeContainerElement,identifierElement,true); + if(timer) { + clearTimeout(timer); + } + timer = setTimeout(() => { + Ember.run.next(component,() => { + const identifierInfo = component.get('identifiers')[identifierElement.dataset.identifier]; + const identifierOccurrence = component.get('occurrences')[identifierElement.dataset.occurrence]; + console.log(identifierOccurrence); + console.log(identifierInfo); + + component.set('selectedIdentifier',identifierElement); + component.set('currentLineNumber',parseInt(identifierElement.parentNode.dataset.line) || 1); + component.set('identifierInfo',identifierInfo); + component.set('identifierOccurrence',identifierOccurrence); + component.set('hasSelectedExpression',false); + component.set('isHoveredOverIdentifier',true); + + }); + },timeout); + }; + + identifierElement.onmouseout = () => { + highlightIdentifiers(sourceCodeContainerElement,identifierElement,false); + + if(timer) { + clearTimeout(timer); + } + + timer = setTimeout (() => { + Ember.run.next(component,() => { + component.set('isHoveredOverIdentifier',false); + }); + },timeout); + }; + }); + component.timer = timer; + } +} + + + +function contains (node, other) { + return node === other || !!(node.compareDocumentPosition(other) & 16); +} + +function initializeExpressionInfo(sourceCodeContainerElement,component) { + const lineElements = Array.prototype.slice.call(sourceCodeContainerElement.querySelectorAll("td.line-content")); + if(lineElements.length > 0) { + + //Line numbers start with 1 + let sourceCodeLines = [""]; + + lineElements.forEach((el) => { + sourceCodeLines.push(el.textContent); + }); + + const allowedNodeNames = ["#text","SPAN","TD"]; + let isLoading = false; + let shouldWait = false; + const timeout = 400;//milliseconds + + const onmouseup = function() { + Ember.run.next(() => { + if(isLoading || shouldWait) { + return; + } + shouldWait = true; + setTimeout(() => {shouldWait = false;},timeout); + + component.set('hasSelectedExpression',false); + + const selection = window.getSelection(); + + //Selection of multiple lines inside a table doesn't work in Firefox + //https://bugzilla.mozilla.org/show_bug.cgi?id=365900 + + if(!(selection.anchorNode && selection.focusNode) + || !contains(sourceCodeContainerElement,selection.anchorNode) + || !contains(sourceCodeContainerElement,selection.focusNode) + || (allowedNodeNames.indexOf(selection.anchorNode.nodeName) === -1) + || (allowedNodeNames.indexOf(selection.focusNode.nodeName) === -1) + || selection.isCollapsed) { + return; + } + + // Detects whether the selection is backwards + const detectionRange = document.createRange(); + detectionRange.setStart(selection.anchorNode, selection.anchorOffset); + detectionRange.setEnd(selection.focusNode, selection.focusOffset); + const isBackward = detectionRange.collapsed; + + let startNode,startNodeOffset,endNode,endNodeOffset; + + if(isBackward) { + startNode = selection.focusNode; + startNodeOffset = selection.focusOffset; + endNode = selection.anchorNode; + endNodeOffset = selection.anchorOffset; + } else { + startNode = selection.anchorNode; + startNodeOffset = selection.anchorOffset; + endNode = selection.focusNode; + endNodeOffset = selection.focusOffset; + } + + let lineStart,columnStart,lineEnd,columnEnd; + let infoWindowTargetElement; + + + //HTML inside source code container : + //<tr><td><span data-start="1" date-end="3">abc</span><span>...</span></td></tr> + //<tr>...</tr> + if(startNode.nodeName === "#text") { + const parent = startNode.parentNode;//<span> + columnStart = parseInt(parent.dataset.start) + startNodeOffset; + lineStart = parseInt(parent.parentNode.dataset.line); + + if(startNodeOffset === startNode.textContent.length && parent.nextSibling === null) { + const tr = startNode.parentNode.parentNode.parentNode;// span -> td -> tr + + //Skipping empty lines + let nextLine = tr.nextSibling; + while(nextLine.children[1].textContent === "") { + nextLine = nextLine.nextSibling; + } + infoWindowTargetElement = nextLine.children[1].children[0]; + + } else { + if(!(startNodeOffset === 0) && (parent.nextSibling)) { + infoWindowTargetElement = parent.nextSibling; + } else { + infoWindowTargetElement = parent; + } + } + } else if(startNode.nodeName === "SPAN") { + columnStart = 1; + lineStart = parseInt(startNode.parentNode.dataset.line); + + const tr = startNode.parentNode.parentNode; // td -> tr + let nextLine = tr.nextSibling; + while(nextLine.children[1].textContent === "") { + nextLine = nextLine.nextSibling; + } + infoWindowTargetElement = nextLine.children[1].children[0]; + + } else if(startNode.nodeName === "TD") { + if(startNodeOffset > 0) { + const child = startNode.children[startNodeOffset-1]; + columnStart = parseInt(child.dataset.start); + } else { + columnStart = 1; + } + lineStart = parseInt(startNode.id.slice(2)); + infoWindowTargetElement = startNode.children[0]; + } + + if(endNode.nodeName === "#text") { + columnEnd = parseInt(endNode.parentNode.dataset.start) + endNodeOffset; + lineEnd = parseInt(endNode.parentNode.parentNode.dataset.line); + } else if(endNode.nodeName === "SPAN") { + columnEnd = 1; + lineEnd = parseInt(endNode.parentNode.dataset.line); + } else if(endNode.nodeName === "TD"){ + if(endNodeOffset > 0) { + const child = endNode.children[endNodeOffset-1]; + columnEnd = parseInt(child.dataset.start); + } else { + columnEnd = 1; + } + lineEnd = parseInt(endNode.id.slice(2)); + } + + const loadExprPromise = component.get('store').loadExpressions( + component.get('packageId'), + component.get('path'), + lineStart, + columnStart, + lineEnd, + columnEnd); + isLoading = true; + + loadExprPromise.then((expressions) => { + Ember.run.next(() => { + if(expressions && expressions.length > 0) { + expressions.sort(function(expr1,expr2) { + if( compareLocations(expr1.srcSpan.start,expr2.srcSpan.start) <= 0 + && compareLocations(expr1.srcSpan.end,expr2.srcSpan.end) >= 0 ) { + return -1; + } else { + return 1; + } + }); + + const expressionsWithSourceCode = expressions.reduce((result,expression) => { + const object = Ember.copy(expression); + const srcSpan = buildSrcSpan(sourceCodeLines, + expression.srcSpan.start, + expression.srcSpan.end); + if(srcSpan) { + object.sourceCode = srcSpan; + return result.concat(object); + } else { + return result; + } + },[]); + + if(expressionsWithSourceCode.length > 0) { + component.set('selectedIdentifier',infoWindowTargetElement); + component.set('expressions',expressionsWithSourceCode); + component.set('currentLineNumber',parseInt(infoWindowTargetElement.parentNode.dataset.line) || 1); + component.set('hasSelectedExpression',true); + + } + } + isLoading = false; + }); + }); + }); + }; + + sourceCodeContainerElement.addEventListener('mouseup',onmouseup); + component._onmouseup = onmouseup; + } +} + +export default Ember.Component.extend({ + store : Ember.inject.service('store'), + selectedIdentifier : null, + isHoveredOverIdentifier : false, + hasSelectedExpression : false, + showDeclarations : true, + showDeclarationsLabel : Ember.computed('showDeclarations',function () { + return this.get('showDeclarations') ? "Hide" : "Show"; + }), + queryObserver : Ember.observer("query",function() { + Ember.run.debounce(this, () => { + const regExp = new RegExp(this.get('query'),"i"); + const filteredDeclarations = this.get('declarations').filter((d) => d.name.search(regExp) != -1); + Ember.run.next(() => { + this.set('filteredDeclarations',filteredDeclarations); + }); + }, 300); + }), + identifierLocationInfo : Ember.computed('identifierInfo','identifierOccurrence',function() { + const idOcc = this.get('identifierOccurrence'); + const idInfo = this.get('identifierInfo'); + if(idOcc) { + if(idOcc.sort.tag === "ModuleId") { + return idOcc.sort.contents; + } else { + if(idInfo) { + return idInfo.locationInfo; + } else { + return null; + } + } + } + }), + themeObserver : Ember.observer('colorTheme',function() { + Ember.run.next(this,() => { + this.cleanup(); + this.didInsertElement(); + }); + }), + fileObserver : Ember.observer('path',function() { + Ember.run.next(this,() => { + this.cleanup(); + this.didInsertElement(); + }); + }), + cleanup() { + if(this.timer) { + clearTimeout(this.timer); + } + if(this._onhashchange) { + window.removeEventListener('hashchange',this._onhashchange); + } + if(this._onkeydown) { + document.removeEventListener('keydown',this._onkeydown); + } + if(this._onkeyup) { + document.removeEventListener('keyup',this._onkeyup); + } + if(this._onmouseup) { + this.sourceCodeContainerElement.removeEventListener('mouseup',this._onmouseup); + } + this.set('selectedIdentifier',null); + this.set('isHoveredOverIdentifier',false); + this.set('hasSelectedExpression',false); + this.set('showDeclarations',true); + }, + didReceiveAttrs() { + this.set('filteredDeclarations',this.get('declarations')); + }, + didInsertElement() { + this._super(...arguments); + const sourceCodeContainerElement = this.element.querySelector('.source-code-container'); + sourceCodeContainerElement.innerHTML = this.get('html'); + this.sourceCodeContainerElement = sourceCodeContainerElement; + this.element.parentNode.scrollTop = 0; + const declarations = this.element.querySelector('.declarations-content'); + this.set('query',''); + if(declarations) { + declarations.scrollTop = 0; + } + Ember.run.next(this,() => { + initializeIdentifiers(sourceCodeContainerElement,this); + initializeLineSelection(sourceCodeContainerElement,this); + initializeExpressionInfo(sourceCodeContainerElement,this); + }); + }, + willDestroyElement() { + this.cleanup(); + }, + actions : { + goToLine(lineNumber) { + window.location.hash = "L"+lineNumber; + }, + toggleShowDeclarations() { + this.toggleProperty('showDeclarations'); + } + } +}); diff --git a/javascript/app/components/identifier-info.js b/javascript/app/components/identifier-info.js new file mode 100644 index 0000000..537697a --- /dev/null +++ b/javascript/app/components/identifier-info.js @@ -0,0 +1,62 @@ +import Ember from 'ember'; +import {goToDefinition} from '../utils/go-to-definition'; + +export default Ember.Component.extend({ + store : Ember.inject.service('store'), + downloadedDocumentation : null, + didInsertElement () { + const onmouseup = (event) => { + if(event.target.dataset.location) { + let location; + try { + location = JSON.parse(event.target.dataset.location); + } catch (e) { + console.log(e); + } + if(location) { + goToDefinition(this.get('store'),location,event.which,this.get('currentLineNumber')); + } + } + }; + this.element.addEventListener('mouseup',onmouseup); + this._onmouseup = onmouseup; + }, + willDestroyElement : function () { + if(this._onmouseup) { + this.element.removeEventListener('mouseup',this._onmouseup); + } + }, + //Naughty record selectors : + //https://github.com/ghc/ghc/blob/ced2cb5e8fbf4493488d1c336da7b00d174923ce/compiler/typecheck/TcTyDecls.hs#L940-L961 + isNaughtyRecSel : Ember.computed('identifierInfo',function () { + const idInfo = this.get('identifierInfo'); + return idInfo ? (idInfo.details === "RecSelIdNaughty") : false; + }), + isExternalIdentifier : Ember.computed('identifierInfo',function () { + const idInfo = this.get('identifierInfo'); + return idInfo ? (idInfo.sort === "External") : false; + }), + identifierObserver : Ember.observer('identifierInfo',function () { + this.set("downloadedDocumentation",""); + const idInfo = this.get('identifierInfo'); + if(idInfo) { + const locationInfo = idInfo.locationInfo; + if(locationInfo.tag === "ApproximateLocation") { + const packageId = locationInfo.packageId.name + "-" + locationInfo.packageId.version; + const currentIdentifier = idInfo; + + this.get('store').loadDefinitionSite(packageId, + locationInfo.moduleName, + locationInfo.componentId, + locationInfo.entity, + locationInfo.name) + .then((definitionSite) => { + Ember.run.next(this,() => { + if(currentIdentifier === this.get('identifierInfo')) { + this.set('downloadedDocumentation',definitionSite.documentation); + }}); + }); + } + } + }) +}); diff --git a/javascript/app/components/identifier-name.js b/javascript/app/components/identifier-name.js new file mode 100644 index 0000000..950b8c7 --- /dev/null +++ b/javascript/app/components/identifier-name.js @@ -0,0 +1,60 @@ +import Ember from 'ember'; +import {goToDefinition} from '../utils/go-to-definition'; + +export default Ember.Component.extend({ + store : Ember.inject.service('store'), + name : Ember.computed('identifierElement',function() { + const element = this.get('identifierElement'); + if(element) { + return element.innerText; + } + }), + style : Ember.computed('identifierElement',function() { + const element = this.get('identifierElement'); + if(element) { + return new Ember.String.htmlSafe("color:"+element.style.color); + } + }), + locationInfo : Ember.computed('identifierInfo','identifierOccurrence',function() { + if(this.get('identifierOccurrence.sort.tag') === "ModuleId") { + return this.get('identifierOccurrence.sort.contents'); + } else { + return this.get('identifierInfo.locationInfo'); + } + }), + location : Ember.computed('locationInfo',function() { + const loc = this.get('locationInfo'); + if(loc) { + if(loc.tag === "ExactLocation") { + return loc.modulePath; + } else if(loc.tag === "ApproximateLocation") { + if(loc.entity === "Mod") { + return loc.packageId.name + "-" + loc.packageId.version; + } else { + return loc.packageId.name + "-" + loc.packageId.version + " " + loc.moduleName; + } + } else { + return loc.contents; + } + } else { + return ""; + } + }), + isExternalIdentifier : Ember.computed('identifierInfo',function () { + return (this.get('identifierInfo.sort') === "External"); + }), + actions : { + goToDefinition (event) { + goToDefinition(this.get('store'), + this.get('locationInfo'), + event.which, + this.get('currentLineNumber')); + return false; + }, + findReferences (identifierInfo,currentPackageId) { + this.get('findReferences')(currentPackageId, + identifierInfo.externalId, + identifierInfo.demangledOccName); + } + } +}); diff --git a/javascript/app/components/infinite-list.js b/javascript/app/components/infinite-list.js new file mode 100644 index 0000000..b73b6d4 --- /dev/null +++ b/javascript/app/components/infinite-list.js @@ -0,0 +1,50 @@ +import Component from '@ember/component'; +import { run } from '@ember/runloop'; +import { observer } from '@ember/object'; + +let pageNumber; +let updating = false; + +function initialize(component) { + component.set('renderedElements',component.get('elements').slice(0,component.get('perPage'))); + pageNumber = 1; +} + +export default Component.extend({ + renderedElements : [], + init() { + this._super(...arguments); + initialize(this); + }, + elementsObserver : observer('elements',function() { + initialize(this); + const containerElement = document.getElementById(this.get('containerElementId')); + if(containerElement) { + containerElement.scrollTop = 0; + } + }), + didInsertElement() { + const containerElement = document.getElementById(this.get('containerElementId')); + if(containerElement) { + const component = this; + containerElement.onscroll = function() { + const perPage = component.get('perPage'); + const elements = component.get('elements'); + + if(!updating && + (pageNumber * perPage < elements.length) && + (containerElement.scrollTop + containerElement.offsetHeight + > component.element.offsetHeight - 100)) { + + updating = true; + run.next(component,() => { + const newElements = elements.slice(pageNumber * perPage,(pageNumber + 1) * perPage); + component.get('renderedElements').pushObjects(newElements); + pageNumber ++; + updating = false; + }); + } + } + } + } +}); diff --git a/javascript/app/components/info-window.js b/javascript/app/components/info-window.js new file mode 100644 index 0000000..a011f99 --- /dev/null +++ b/javascript/app/components/info-window.js @@ -0,0 +1,144 @@ +import Ember from 'ember'; + +let resizing = false; +let dragging = false; + +function updatePosition(component) { + const targetElement = component.get('targetElement'); + if(targetElement) { + const infoWindowHeight = component.element.offsetHeight; + const targetElementHeight = targetElement.offsetHeight; + + const parent = targetElement.parentNode;//<td> element + const containerElement = document.querySelector("#" + component.get('containerElementId')); + + //getBoundingClientRect() returns the smallest rectangle which contains + //the entire element, with read-only left, top, right, bottom, x, y, width, + //and height properties describing the overall border-box in pixels. Properties + //other than width and height are relative to the top-left of the *viewport*. + const targetTopViewport = targetElement.getBoundingClientRect().top; + + let containerTopViewport; + if (containerElement) { + containerTopViewport = containerElement.getBoundingClientRect().top; + } else { + containerTopViewport = 0; + } + + let infoWindowTop; + if(targetTopViewport < infoWindowHeight + containerTopViewport) { + //offsetTop is the number of pixels from the top of the closest relatively + //positioned parent element. + infoWindowTop = targetElement.offsetTop + parent.offsetTop + + targetElementHeight + 10 + "px"; + } else { + infoWindowTop = targetElement.offsetTop + parent.offsetTop + - infoWindowHeight + "px"; + } + + const infoWindowLeft = targetElement.offsetLeft + parent.offsetLeft + "px"; + + component.$().css({ + top:infoWindowTop, + left:infoWindowLeft + }); + } else { + component.set('isPinned',false); + } +} + +export default Ember.Component.extend({ + classNames : ["info-window-container"], + attributeBindings: ['hidden'], + isPinned : false, + isFocused: false, + didInsertElement () { + const component = this; + + const $headerElement = Ember.$(component.element.querySelector(".info-window-header")); + const $contentElement = Ember.$(component.element.querySelector(".info-window-content")); + const $infoWindowElement = Ember.$(component.element.querySelector(".info-window")); + const $infoWindowContainerElement = Ember.$(component.element); + + this.$headerElement = $headerElement; + this.$contentElement = $contentElement; + + this.$().resizable({ + handles: "n,w", + minHeight: 80, + minWidth: 400, + start: function() { + resizing = true; + }, + stop: function() { + resizing = false; + }, + resize : function() { + const containerHeight = $infoWindowContainerElement.height(); + $infoWindowElement.css({ + "height": containerHeight + 2 + "px" + }); + $contentElement.css({ + "max-height":(containerHeight - $headerElement.outerHeight(true)) + "px" + }); + } + }); + this.$().draggable({ + containment:"#" + this.get('containerElementId'), + handle: $headerElement, + start: function() { + dragging = true; + }, + stop: function() { + dragging = false; + } + }); + }, + mouseEnter () { + if(!this.get('hasSelectedExpression')) { + this.set('isFocused',true); + } + }, + mouseLeave (event) { + //Workaround for a bug in Chrome + const element = document.elementFromPoint(event.clientX,event.clientY); + if(element && element.classList.contains('link')) { + return; + } + if(!resizing + && !dragging + && !this.get('isPinned') + && !this.get('hasSelectedExpression')) { + this.set('isFocused',false); + } + }, + hidden : Ember.computed('isHoveredOverIdentifier', + 'isFocused', + 'hasSelectedExpression', + 'isPinned', function() { + if (this.$contentElement) { + this.$contentElement.scrollTop(0); + } + if (this.get('isPinned') + || this.get('isFocused') + || this.get('isHoveredOverIdentifier') + || this.get('hasSelectedExpression')) { + return false; + } else { + return true; + } + }), + didUpdate() { + updatePosition(this); + }, + actions : { + close() { + this.set('isPinned',false); + this.set('isFocused',false); + this.set('hasSelectedExpression',false); + }, + pin() { + this.toggleProperty('isPinned'); + } + } +}); diff --git a/javascript/app/components/input-with-autocomplete.js b/javascript/app/components/input-with-autocomplete.js new file mode 100644 index 0000000..2b09ea4 --- /dev/null +++ b/javascript/app/components/input-with-autocomplete.js @@ -0,0 +1,131 @@ +import Ember from 'ember'; +export default Ember.Component.extend({ + store : Ember.inject.service('store'), + highlightedItemIndex: -1, + items : [], + query: null, + didInsertElement() { + const $input = Ember.$(this.element).find(".search-input"); + const $autocompleteContainer = Ember.$(this.element).find(".autocomplete-container"); + this.$input = $input; + this.$autocompleteContainer = $autocompleteContainer; + const width = $input.width() + 300; + $autocompleteContainer.css({ + "width" : width+"px", + "top" : $input.outerHeight() + }); + $input.keyup((e) => { + if(e.which === 13) { + this.onEnter(); + } else if(e.which === 27) { + this.onEsc(); + } else if(e.which === 40) { + this.onDown(); + } else if(e.which === 38) { + this.onUp(); + } + }); + $input.focusin(() => { + this.showAutocompleteList(); + }); + $input.focusout(() => { + //Timeout is needed to make sure that click event fires + Ember.run.later((() => { + this.hideAutocompleteList(); + }), 100); + }); + }, + willDestroyElement() { + this._super(...arguments); + this.$input.off('keyup'); + this.$input.off('focusin'); + this.$input.off('focusout'); + }, + onEnter() { + if(this.get('highlightedItemIndex') !== -1) { + const item = this.get('items')[this.get('highlightedItemIndex')]; + if(item) { + this.hideAutocompleteList(); + this.get('selectItem')(item); + } + } else { + this.hideAutocompleteList(); + this.get('onSubmit')(this.get('query')); + } + }, + onEsc() { + this.hideAutocompleteList(); + }, + onDown() { + this.showAutocompleteList(); + const index = this.get('highlightedItemIndex'); + const items = this.get('items'); + const itemsCount = items.length; + if(itemsCount > 0) { + if(index !== -1) { + if(index === itemsCount - 1) { + this.set('highlightedItemIndex',0); + } else { + this.set('highlightedItemIndex',index+1); + } + } else { + this.set('highlightedItemIndex',0); + } + } + }, + onUp() { + this.showAutocompleteList(); + const index = this.get('highlightedItemIndex'); + const items = this.get('items'); + const itemsCount = items.length; + if(itemsCount > 0) { + if(index !== -1) { + if(index === 0) { + this.set('highlightedItemIndex',itemsCount - 1); + } else { + this.set('highlightedItemIndex',index - 1); + } + } else { + this.set('highlightedItemIndex',itemsCount - 1); + } + } + }, + hideAutocompleteList() { + this.set('highlightedItemIndex',-1); + this.$autocompleteContainer.css({ + "display":"none", + }); + }, + showAutocompleteList() { + this.$autocompleteContainer.css({ + "display":"block" + }); + }, + queryObserver : Ember.observer("query",function() { + if(this.get('query')) { + const perPage = this.get('maxItems') ? this.get('maxItems') : 10; + const url = this.get('createSearchUrlFunction')(this.get('query')) + "?per_page=" + perPage; + Ember.run.debounce(this, () => { + this.get('store').loadFromUrlPaginated(url).then((result) => { + Ember.run.next(() => { + this.set('items',result.items); + }); + }); + }, 400); + this.showAutocompleteList(); + } else { + this.hideAutocompleteList(); + this.set('items',[]); + } + }), + actions : { + onSubmit() { + this.hideAutocompleteList(); + this.get('onSubmit')(this.get('query')); + }, + goToDefinition (item) { + this.hideAutocompleteList(); + this.get('selectItem')(item); + } + } +}); diff --git a/javascript/app/components/instance-info.js b/javascript/app/components/instance-info.js new file mode 100644 index 0000000..a0e04ed --- /dev/null +++ b/javascript/app/components/instance-info.js @@ -0,0 +1,21 @@ +import Ember from 'ember'; +import {goToDefinition} from '../utils/go-to-definition'; + +export default Ember.Component.extend({ + store : Ember.inject.service('store'), + style : Ember.computed('nestedLevel',function() { + return new Ember.String.htmlSafe("margin-left :" + this.get('nestedLevel') * 10 + "px"); + }), + nextNestedLevel : Ember.computed('nestedLevel',function () { + return this.get('nestedLevel') + 1; + }), + actions : { + goToDefinition (event) { + goToDefinition(this.get('store'), + this.get('instance.location'), + event.which, + this.get('currentLineNumber')); + return false; + } + } +}); diff --git a/javascript/app/components/paginated-list.js b/javascript/app/components/paginated-list.js new file mode 100644 index 0000000..9d85610 --- /dev/null +++ b/javascript/app/components/paginated-list.js @@ -0,0 +1,48 @@ +import Ember from 'ember'; +function loadItems(store,component,url) { + store.loadFromUrlPaginated(url).then((result) => { + Ember.run.next(() => { + component.set('total',result.total); + component.set('items',result.items); + component.set('first',result.linkHeader.first); + component.set('next',result.linkHeader.next); + component.set('prev',result.linkHeader.prev); + component.set('last',result.linkHeader.last); + + const pageMatch = url.match(/(&|\?)page=(\d+)/); + const perPageMatch = url.match(/(&|\?)per_page=(\d+)/); + + const page = pageMatch ? pageMatch[2] : 1; + const perPage = perPageMatch ? perPageMatch[2] : 20; + + if(result.linkHeader.next || result.linkHeader.prev) { + component.set('firstItemOnPage',(page - 1) * perPage + 1); + if(!result.linkHeader.last) { + component.set('lastItemOnPage',result.total); + } else { + component.set('lastItemOnPage',page * perPage); + } + } + }); + }); +} + +export default Ember.Component.extend({ + store : Ember.inject.service('store'), + init() { + this._super(...arguments); + if(this.get('url')) { + loadItems(this.get('store'),this,this.get('url')); + } + }, + urlObserver : Ember.observer('url',function () { + loadItems(this.get('store'),this,this.get('url')); + this.element.querySelector(".paginated-list-content").scrollTop = 0; + }), + actions : { + update(url) { + this.element.querySelector(".paginated-list-content").scrollTop = 0; + loadItems(this.get('store'),this,url); + } + } +}); diff --git a/javascript/app/components/resizable-panel.js b/javascript/app/components/resizable-panel.js new file mode 100644 index 0000000..8dae7ed --- /dev/null +++ b/javascript/app/components/resizable-panel.js @@ -0,0 +1,72 @@ +import Ember from 'ember'; + +function hide (component,byUser) { + component.$alsoResizeElement.css({left: 0}); + component.$().css({width:0}); + component.set('hidden',true); + component.$(".show-left-panel-button").show(); + if(byUser) { + component.set('hiddenByUser',true); + } +} + +function show (component,byUser) { + component.$alsoResizeElement.css({left: 300}); + component.$().css({width:300}); + component.set('hidden',false); + component.$(".show-left-panel-button").hide(); + if(byUser) { + component.set('hiddenByUser',false); + } +} + +export default Ember.Component.extend({ + hidden:false, + hiddenByUser:false, + didInsertElement : function () { + this._super(...arguments); + Ember.run.next(this,() => { + const onresize = () => { + if(!this.get('hiddenByUser')) { + const width = window.innerWidth; + if(!this.get('hidden') && width < 700) { + hide(this,false); + } else if(this.get('hidden') && width > 700) { + show(this,false); + } + } + }; + this._onresize = onresize; + window.addEventListener('resize', onresize); + const $alsoResizeElement = Ember.$(this.get('alsoResizeElementId')); + Ember.$(this.element).resizable({ + maxWidth: 800, + minWidth: 200, + handles: 'e', + resize: (event,ui) => { + Ember.run.next(this,() => { + $alsoResizeElement.css({left: ui.size.width}); + }); + } + }); + this.$alsoResizeElement = $alsoResizeElement; + }); + }, + hideButtonLabel : Ember.computed('hidden',function() { + return this.get('hidden') ? ">" : "<"; + }), + willDestroyElement() { + if(this._onresize) { + window.removeEventListener('resize',this._onresize); + } + }, + actions : { + hide() { + if(this.get('hidden')) { + show(this,true); + } else { + hide(this,true); + } + } + } +}); diff --git a/javascript/app/components/text-file.js b/javascript/app/components/text-file.js new file mode 100644 index 0000000..05be31a --- /dev/null +++ b/javascript/app/components/text-file.js @@ -0,0 +1,67 @@ +/* global showdown */ +import Ember from 'ember'; +import {initializeLineSelection} from '../utils/line-selection'; + +function escapeHtml(text) { + return text.replace(/[\"&<>]/g, function (a) { + return { '"': '"', '&': '&', '<': '<', '>': '>' }[a]; + }); +} + +function addLineNumbers (text) { + const start = "<table class='source-code'><tbody>"; + const end = "</tbody></table>"; + let lineNumber = 0; + const lines = text.split("\n").map((line) => { + lineNumber ++; + const lineNumberHtml = "<td id='LN"+lineNumber+"' class='line-number'>"+lineNumber+"</td>"; + const lineContentHtml = "<td id='LC"+lineNumber+"' class='line-content'>"+escapeHtml(line)+"</td>"; + return "<tr>"+ lineNumberHtml + lineContentHtml + "</tr>"; + }).join(""); + return start + lines + end; +} + +const markdownExtensions = ["markdown", "mdown", "mkdn", "mkd", "md"]; + +export default Ember.Component.extend({ + isMarkdown : Ember.computed('path',function() { + const maybeExtension = this.get('path').split('.').pop(); + return markdownExtensions.any((extension) => (maybeExtension === extension)); + }), + html : Ember.computed('path','isMarkdown',function() { + if(this.get('isMarkdown')) { + return this.markdownConverter.makeHtml(this.get('text')); + } else { + return addLineNumbers(this.get('text')); + } + }), + init() { + this._super(...arguments); + this.markdownConverter = new showdown.Converter(); + }, + didInsertElement() { + const sourceCodeContainerElement = this.element.querySelector('.source-code-container'); + initializeLineSelection(sourceCodeContainerElement,this); + this.element.parentNode.scrollTop = 0; + }, + willDestroyElement : function () { + this.cleanup(); + }, + cleanup() { + if(this._onhashchange) { + window.removeEventListener('hashchange',this._onhashchange); + } + if(this._onkeydown) { + document.removeEventListener('keydown',this._onkeydown); + } + if(this._onkeyup) { + document.removeEventListener('keyup',this._onkeyup); + } + }, + pathObserver : Ember.observer('path',function() { + Ember.run.next(this,() => { + this.cleanup(); + this.didInsertElement(); + }); + }) +}); diff --git a/javascript/app/components/type-component.js b/javascript/app/components/type-component.js new file mode 100644 index 0000000..c19facc --- /dev/null +++ b/javascript/app/components/type-component.js @@ -0,0 +1,29 @@ +import Ember from 'ember'; +import {goToDefinition} from '../utils/go-to-definition'; + +export default Ember.Component.extend({ + store : Ember.inject.service('store'), + tagName : 'span', + classNames: ["type-component"], + contextMenu() {//right mouse button click to show kind of a type constructor or type variable + if(this.get('identifiers') && this.get('internalId')) { + this.set('expanded',true); + } + return false; + }, + linkClass : Ember.computed('identifierInfo',function() { + return this.get('identifierInfo') ? "link" : ""; + }), + identifierInfo : Ember.computed('internalId',function() { + return this.get('internalId') ? this.get('identifiers')[this.get('internalId')] : null; + }), + actions : { + onmouseup (event) { + if(this.get('identifierInfo') && (event.which !== 3 )) { + const locationInfo = this.get('identifierInfo').locationInfo; + goToDefinition(this.get('store'),locationInfo,event.which,this.get('currentLineNumber')); + return false; + } + } + } +}); diff --git a/javascript/app/components/type-signature-text.js b/javascript/app/components/type-signature-text.js new file mode 100644 index 0000000..e4eb200 --- /dev/null +++ b/javascript/app/components/type-signature-text.js @@ -0,0 +1,4 @@ +import Ember from 'ember'; +export default Ember.Component.extend({ + tagName : "span" +}); diff --git a/javascript/app/components/type-signature.js b/javascript/app/components/type-signature.js new file mode 100644 index 0000000..ed1849c --- /dev/null +++ b/javascript/app/components/type-signature.js @@ -0,0 +1,23 @@ +import Ember from 'ember'; +export default Ember.Component.extend({ + tagName : "span", + expandTypeSynonyms: false, + expandTypeSynonymsLabel : Ember.computed('expandTypeSynonyms',function() { + return this.get('expandTypeSynonyms') ? "Show type synonyms" : "Expand type synonyms"; + }), + components : Ember.computed('type','expandTypeSynonyms',function() { + if(this.get('expandTypeSynonyms') && this.get('type.componentsExpanded')) { + return this.get('type.componentsExpanded'); + } else { + return this.get('type.components'); + } + }), + typeObserver : Ember.observer('type',function() { + this.set('expandTypeSynonyms',false); + }), + actions : { + toggleExpandTypeSynonyms () { + this.toggleProperty('expandTypeSynonyms'); + } + } +}); diff --git a/javascript/app/controllers/application.js b/javascript/app/controllers/application.js new file mode 100644 index 0000000..2936011 --- /dev/null +++ b/javascript/app/controllers/application.js @@ -0,0 +1,21 @@ +import {updateColorThemeCss,themes} from '../utils/color-themes'; +import Ember from 'ember'; + +export default Ember.Controller.extend({ + settings : Ember.inject.service('settings'), + themes: Object.values(themes), + init() { + this._super(...arguments); + updateColorThemeCss(this.get('settings').get('colorTheme')); + }, + currentTheme: Ember.computed('settings',function() { + return this.get('settings.colorTheme.id'); + }), + actions : { + themeChanged (themeId) { + const theme = themes[themeId]; + this.get('settings').set('colorTheme',theme); + updateColorThemeCss(theme); + } + } +}); diff --git a/javascript/app/controllers/package.js b/javascript/app/controllers/package.js new file mode 100644 index 0000000..45a8bab --- /dev/null +++ b/javascript/app/controllers/package.js @@ -0,0 +1,24 @@ +import Ember from 'ember'; +import {goToDefinition} from '../utils/go-to-definition'; +export default Ember.Controller.extend({ + store : Ember.inject.service('store'), + currentFile : null, + loadItemsFunction : null, + query : null, + actions : { + searchIdentifier (query) { + if(query) { + this.set('currentFile',null); + document.title = this.get('model.id'); + this.transitionToRoute('package.search',query); + } + }, + showIdentifier (identifierInfo) { + goToDefinition(this.get('store'), + identifierInfo.locationInfo, + 1,//left mouse button + null); + return false; + } + } +}); diff --git a/javascript/app/controllers/package/index.js b/javascript/app/controllers/package/index.js new file mode 100644 index 0000000..cd02416 --- /dev/null +++ b/javascript/app/controllers/package/index.js @@ -0,0 +1,14 @@ +import Ember from 'ember'; +export default Ember.Controller.extend({ + modulesFiltered : Ember.computed('model','query',function () { + const query = this.get('query'); + const modules = Object.keys(this.get('model.modules')).sort(); + if(query) { + const regExp = new RegExp(query,"i"); + return modules.filter((p) => p.search(regExp) != -1); + } else { + return modules; + } + }) +}); + diff --git a/javascript/app/controllers/package/search.js b/javascript/app/controllers/package/search.js new file mode 100644 index 0000000..46f2efd --- /dev/null +++ b/javascript/app/controllers/package/search.js @@ -0,0 +1,15 @@ +import Ember from 'ember'; +import {goToDefinition} from '../../utils/go-to-definition'; + +export default Ember.Controller.extend({ + store : Ember.inject.service('store'), + actions : { + goToDefinition (locationInfo,event) { + goToDefinition(this.get('store'), + locationInfo, + event.which, + null); + return false; + } + } +}); diff --git a/javascript/app/controllers/package/show/file.js b/javascript/app/controllers/package/show/file.js new file mode 100644 index 0000000..c566e7e --- /dev/null +++ b/javascript/app/controllers/package/show/file.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; +export default Ember.Controller.extend({ + settings : Ember.inject.service('settings'), + actions : { + findReferences(packageId,externalId,occName) { + this.send('updateReferences',packageId,externalId,occName); + } + } +}); diff --git a/javascript/app/controllers/packages.js b/javascript/app/controllers/packages.js new file mode 100644 index 0000000..a1724dd --- /dev/null +++ b/javascript/app/controllers/packages.js @@ -0,0 +1,12 @@ +import Ember from 'ember'; +export default Ember.Controller.extend({ + queryObserver : Ember.observer("query",function() { + Ember.run.debounce(this, () => { + const regExp = new RegExp(this.get('query'),"i"); + const packages = this.get('model').filter((p) => p.name.search(regExp) != -1); + Ember.run.next(() => { + this.set('packages',packages); + }); + }, 300); + }) +}); diff --git a/javascript/app/helpers/.gitkeep b/javascript/app/helpers/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/javascript/app/helpers/.gitkeep diff --git a/javascript/app/index.html b/javascript/app/index.html new file mode 100644 index 0000000..0c8f882 --- /dev/null +++ b/javascript/app/index.html @@ -0,0 +1,22 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <title>Haskell code explorer</title> + <meta name="description" content="Haskell code explorer"> + <meta name="google" content="notranslate"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + {{content-for 'head'}} + <link rel="stylesheet" href="/assets/vendor.css"> + <link rel="stylesheet" href="/assets/haskell-code-explorer.css"> + {{content-for 'head-footer'}} + </head> + <body> + {{content-for 'body'}} + <script pace-src="/assets/vendor.js"></script> + <script pace-src="/assets/haskell-code-explorer.js"></script> + {{content-for 'body-footer'}} + <noscript>You need to enable JavaScript to run this app.</noscript> + </body> +</html> diff --git a/javascript/app/router.js b/javascript/app/router.js new file mode 100644 index 0000000..f58e9ab --- /dev/null +++ b/javascript/app/router.js @@ -0,0 +1,20 @@ +import Ember from 'ember'; +import config from './config/environment'; + +var Router = Ember.Router.extend({ + location: config.locationType +}); + +Router.map(function() { + this.route('packages',{path:''}); + this.route('package', {path:'/package/:packageId'}, function() { + this.route('show',function() { + this.route('file', {path:'*filePath'}, function() { + }); + }); + this.route('search',{path:'/search/:query'}); + }); + this.route('bad-url', { path: '/*badurl' }); +}); + +export default Router; diff --git a/javascript/app/routes/application.js b/javascript/app/routes/application.js new file mode 100644 index 0000000..279bd32 --- /dev/null +++ b/javascript/app/routes/application.js @@ -0,0 +1,3 @@ +import Ember from 'ember'; +export default Ember.Route.extend({ +}); diff --git a/javascript/app/routes/package.js b/javascript/app/routes/package.js new file mode 100644 index 0000000..92fea03 --- /dev/null +++ b/javascript/app/routes/package.js @@ -0,0 +1,38 @@ +import Ember from 'ember'; +import {urls} from '../utils/api-urls'; + +export default Ember.Route.extend({ + store : Ember.inject.service('store'), + model (params) { + return this.get('store').loadPackage(params.packageId) + .catch((e) => {console.log(e);this.transitionTo("/package-not-found");}); + }, + afterModel(model) { + document.title = model.id; + }, + setupController(controller, model) { + this._super(controller, model); + const packageId = this.modelFor('package').id; + controller.set('bottomPanelVisible',false); + controller.set('createSearchUrlFunction',(query) => { + return urls.identifierSearchUrl(packageId,query); + }); + }, + actions : { + openFile (filePath) { + this.transitionTo('package.show.file',filePath); + }, + fileOpened (filePath) { + if(this.get('controller')) { + this.set('controller.currentFile',filePath); + } + }, + updateReferences(packageId,externalId,occName) { + this.set('controller.packageId',packageId); + this.set('controller.externalId',externalId); + this.set('controller.occName',occName); + this.set('controller.bottomPanelVisible',true); + this.set('controller.referencesUrl',urls.referencesUrl(packageId,externalId)+"?per_page=50"); + } + } +}); diff --git a/javascript/app/routes/package/index.js b/javascript/app/routes/package/index.js new file mode 100644 index 0000000..e36abd5 --- /dev/null +++ b/javascript/app/routes/package/index.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + afterModel : function (model,transition) { + transition.send("fileOpened",null); + } +}); diff --git a/javascript/app/routes/package/search.js b/javascript/app/routes/package/search.js new file mode 100644 index 0000000..7bc711b --- /dev/null +++ b/javascript/app/routes/package/search.js @@ -0,0 +1,36 @@ +import Ember from 'ember'; +import {urls} from '../../utils/api-urls'; +import {goToDefinition} from '../../utils/go-to-definition'; + +export default Ember.Route.extend({ + store : Ember.inject.service('store'), + model (params) { + return { + query: params.query, + url: urls.identifierSearchUrl(this.modelFor('package').id,params.query)+"?per_page=20" + }; + }, + afterModel () { + const onmouseup = (event) => { + // This makes links in documentation clickable + if(event.target.dataset.location) { + let location; + try { + location = JSON.parse(event.target.dataset.location); + } catch (e) { + console.log(e); + } + if(location) { + goToDefinition(this.get('store'),location,event.which); + } + } + }; + this._onmouseup = onmouseup; + document.addEventListener('mouseup',onmouseup); + }, + deactivate() { + if(this._onmouseup) { + document.removeEventListener('mouseup',this._onmouseup); + } + } +}); diff --git a/javascript/app/routes/package/show/file.js b/javascript/app/routes/package/show/file.js new file mode 100644 index 0000000..ead6ee8 --- /dev/null +++ b/javascript/app/routes/package/show/file.js @@ -0,0 +1,27 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + store : Ember.inject.service(), + model : function (params) { + const packageInfo = this.modelFor('package'); + if(packageInfo.modules[params.filePath]) { + return this.get('store').loadHaskellModule(packageInfo.id,params.filePath) + .catch((e) => {console.log(e);this.transitionTo("/not-found");}); + } else { + return this.get('store').loadFile(packageInfo.id,params.filePath) + .then((result) => { + document.title = packageInfo.id; + return result; + }) + .catch((e) => {console.log(e);this.transitionTo("/not-found");}); + } + }, + afterModel (model) { + document.title = model.id + " - " + this.modelFor('package').id; + }, + actions : { + didTransition : function () { + this.send("fileOpened",this.currentModel.id); + } + } +}); diff --git a/javascript/app/routes/package/show/index.js b/javascript/app/routes/package/show/index.js new file mode 100644 index 0000000..e36abd5 --- /dev/null +++ b/javascript/app/routes/package/show/index.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + afterModel : function (model,transition) { + transition.send("fileOpened",null); + } +}); diff --git a/javascript/app/routes/packages.js b/javascript/app/routes/packages.js new file mode 100644 index 0000000..2bbf9bc --- /dev/null +++ b/javascript/app/routes/packages.js @@ -0,0 +1,16 @@ +import Ember from 'ember'; +import {urls} from '../utils/api-urls'; +import config from '../config/environment'; + +export default Ember.Route.extend({ + model () { + return Ember.$.getJSON(urls.packagesUrl); + }, + setupController(controller, model) { + this._super(controller, model); + controller.set('packages',model); + }, + afterModel () { + document.title = config.APP.title; + } +}); diff --git a/javascript/app/services/settings.js b/javascript/app/services/settings.js new file mode 100644 index 0000000..a79779a --- /dev/null +++ b/javascript/app/services/settings.js @@ -0,0 +1,21 @@ +import Ember from 'ember'; +import {themes} from '../utils/color-themes'; + +export default Ember.Service.extend({ + init() { + this._super(...arguments); + if(localStorage) { + const colorThemeId = localStorage.getItem("colorThemeId"); + const colorTheme = themes[colorThemeId]; + if(colorThemeId) { + this.set('colorTheme',colorTheme); + } + } + }, + colorTheme : themes["darkTheme"], + settingsObserver : Ember.observer("colorTheme",function() { + if(localStorage) { + localStorage.setItem("colorThemeId",this.get('colorTheme').id); + } + }) +}); diff --git a/javascript/app/services/store.js b/javascript/app/services/store.js new file mode 100644 index 0000000..cf48f9d --- /dev/null +++ b/javascript/app/services/store.js @@ -0,0 +1,141 @@ +import Ember from 'ember'; +import RSVP from 'rsvp'; +import {urls} from '../utils/api-urls'; + + +//******************************************************************************** +//https://coderwall.com/p/zrlulq/parsing-a-link-header-in-javascript +function unquote(value) { + if (value.charAt(0) == '"' && value.charAt(value.length - 1) == '"') { + return value.substring(1, value.length - 1); + } else { + return value; + } +} + +function parseLinkHeader(header) { + if(!header) {return {}} + var linkexp = /<[^>]*>\s*(\s*;\s*[^\(\)<>@,;:"\/\[\]\?={} \t]+=(([^\(\)<>@,;:"\/\[\]\?={} \t]+)|("[^"]*")))*(,|$)/g; + var paramexp = /[^\(\)<>@,;:"\/\[\]\?={} \t]+=(([^\(\)<>@,;:"\/\[\]\?={} \t]+)|("[^"]*"))/g; + + var matches = header.match(linkexp); + var rels = new Object(); + for (let i = 0; i < matches.length; i++) { + var split = matches[i].split('>'); + var href = split[0].substring(1); + var ps = split[1]; + var link = new Object(); + link.href = href; + var s = ps.match(paramexp); + for (let j = 0; j < s.length; j++) { + var p = s[j]; + var paramsplit = p.split('='); + var name = paramsplit[0]; + link[name] = unquote(paramsplit[1]); + } + + if (link.rel != undefined) { + rels[link.rel] = link; + } + } + return rels; +} +//******************************************************************************** + + +export default Ember.Service.extend({ + init() { + this.packages = {}; + this.files = {}; + this.haskellModules = {}; + this.definitionSites = {}; + this.modulePaths = {}; + this.expressions = {}; + this.references = {}; + }, + loadPackage(packageId) { + const packageInfo = this.packages[packageId]; + if(packageInfo) { + return new RSVP.Promise((resolve) => {resolve(packageInfo);}); + } else { + const url = urls.packageInfoUrl(packageId); + return Ember.$.getJSON(url).then((packageInfo) => { + this.packages[packageId] = packageInfo; + return packageInfo; + }); + } + }, + loadFile(packageId,filePath) { + const fileId = packageId + "/" + filePath; + const file = this.files[fileId]; + if(file) { + return new RSVP.Promise((resolve) => {resolve(file);}); + } else { + const url = urls.fileUrl(packageId,filePath); + return Ember.$.get({url:url,dataType:"text"}).then((text) => { + const file = {}; + file.text = text; + file.packageId = packageId; + file.isHaskellModule = false; + file.id = filePath; + this.files[fileId] = file; + return file; + }); + } + }, + loadHaskellModule(packageId,filePath) { + const moduleId = packageId + "/" + filePath ; + const module = this.haskellModules[moduleId]; + if(module) { + return new RSVP.Promise((resolve)=>{resolve(module);}); + } else { + const url = urls.haskellModuleUrl(packageId,filePath); + return Ember.$.getJSON(url).then((module) => { + module.packageId = packageId; + module.isHaskellModule = true; + this.haskellModules[moduleId] = module; + return module; + }); + } + }, + loadDefinitionSite(packageId,moduleName,componentId,entity,name) { + const id = packageId + "/"+ componentId + "/" + moduleName + "/" + entity + "/" + name; + const definitionSite = this.definitionSites[id]; + if(definitionSite) { + return new RSVP.Promise((resolve)=>{resolve(definitionSite);}); + } else { + const url = urls.identifierDefinitionSiteUrl(packageId,moduleName,componentId,entity,name); + return Ember.$.getJSON(url).then((definitionSite) => { + this.definitionSites[id] = definitionSite; + return definitionSite; + }); + } + }, + loadExpressions(packageId,modulePath,lineStart,columnStart,lineEnd,columnEnd) { + const id = packageId + "/" + encodeURIComponent(modulePath) + + "/" + lineStart + "/" + columnStart + "/" + lineEnd + "/" + columnEnd; + const exprs = this.expressions[id]; + if(exprs) { + return new RSVP.Promise((resolve)=>{resolve(exprs);}); + } else { + const url = urls.expressionsUrl(packageId,modulePath,lineStart,columnStart,lineEnd,columnEnd); + return Ember.$.getJSON(url).then((exprs) => { + this.expressions[id] = exprs; + return exprs; + }); + } + }, + loadFromUrlPaginated(url) { + return Ember.$.getJSON(url).then((items,textStatus,jqXHR) => { + const linkHeaderText = jqXHR.getResponseHeader('Link'); + const totalCountHeaderText = jqXHR.getResponseHeader('x-total-count'); + const linkHeader = parseLinkHeader(linkHeaderText); + const total = parseInt(totalCountHeaderText); + return { + items:items, + total:total, + linkHeader:linkHeader + }; + }); + } +}); diff --git a/javascript/app/styles/app.scss b/javascript/app/styles/app.scss new file mode 100644 index 0000000..f094187 --- /dev/null +++ b/javascript/app/styles/app.scss @@ -0,0 +1,555 @@ +@import "ember-cli-bootstrap-4/bootstrap"; + +body { + font-size:14px; +} + +a, a:visited, a:focus, a:active, a:hover{ + outline:0 none; +} + +input:focus{ + outline:none; +} + +p { + margin-top:1rem; +} + +.ember-radio-button { + cursor:pointer; +} + +label { + margin-bottom:0px; +} + +ul { + list-style: none; + padding : 0; +} + +ol { + list-style: none; + padding : 0; +} + +li { + list-style: none; +} + +.documentation { + margin-top:10px; + li { + list-style: disc outside none; + } + ul { + padding-left:15px; + } + ol { + padding-left:15px; + } +} + +.flex-container { + display:flex; + flex-direction:column; + height:100%; + width:100%; +} + +.absolute-container { + position:absolute; + top:0; + bottom:0; + left:0; + right:0; +} + +.header { + flex: none; + padding:5px; + img { + float:left; + } +} + +.header-item { + display:inline-block; + margin-top:5px; + font-size : 1rem; + margin-right: 40px; + white-space: nowrap; + @media screen and (max-width: 700px) { + margin-right: 10px; + margin-left: 10px; + } +} + +.content { + flex: auto; + position:relative; + overflow:auto; +} + +.packages { + flex:auto; + overflow-y:auto; + li { + margin-bottom: 5px; + } + a { + margin-right:5px; + } +} + +.package-content { + flex:auto; + position:relative; +} + +.package-header { + padding:5px; +} + +.package-header-input { + display:inline-block; + margin-right:10px; +} + +.package-modules { + margin-left:10px; +} + +.module-name { + font-size:0.7rem; +} + +.package-search-form { + max-width:500px; + margin-bottom:10px; + margin-top:20px; +} + +.package-header-filename { + white-space: nowrap; +} + +.package-header-package-name { + margin-right:10px; + font-size:17px; +} + +.module-search-input { + max-width:500px; + margin:5px; +} + +ul.modules { + position:absolute; + top:50px; + bottom:0px; + margin:0px; + padding-left:15px; + left:0px; + right:0px; + display:flex; + flex-direction: column; + flex-wrap: wrap; + li { + margin-right: 10px; + } + overflow-x:auto; +} + +.left-panel { + position: absolute; + bottom : 0; + top : 0; + left : 0; + width : 300px; +} + +.show-left-panel-button { + position:absolute; + top:0px; + right:-20px; + z-index:2; + width:20px; + height:20px; + text-align:center; + vertical-align:middle; + a { + text-decoration:none; + } + opacity:0.6; + display:none; +} + +.show-left-panel-button:hover { + opacity:1; + cursor:pointer; +} + +.hide-file-tree { + position: relative; + top: 8px; + left: 3px; +} + +.hide-declarations { + margin:7px; +} + +.right-panel { + position: absolute; + bottom : 0; + top : 0; + right : 0; + left : 300px; + overflow-x: auto; + overflow-y: auto; +} + +.file-tree-container { + overflow-x: hidden; + overflow-y: auto; + position: absolute; + bottom : 0; + top : 0; + left: 0; + right: 0; +} + +.file-container { + position: absolute; + bottom : 0px; + top : 0; + right : 0; + left : 0; + overflow-x: auto; + overflow-y: auto; +} + +.bottom-panel { + display:none; + position: absolute; + bottom : 0; + right : 0; + left : 0; + overflow-x: auto; + overflow-y: auto; + z-index:4; +} + +.bottom-panel-header { + position: absolute; + top: 0; + right : 0; + left : 0; + height: 35px; + padding: 5px; +} + +.bottom-panel-content { + position: absolute; + bottom : 0; + top : 35px; + right : 0; + left : 0; + overflow-x: auto; + overflow-y: auto; +} + +.bottom-panel-header-options { + float:right; +} + +.bottom-panel-header-content { + white-space:nowrap; +} + +.highlighted-identifier { + border-radius: 3px; + box-shadow: 0 0 0 1px #B4B4B4; +} + +.identifier { + cursor:pointer; +} + +td.line-number { + cursor:pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + width: 1%; + min-width: 60px; + text-align: left; + padding-left:10px; + opacity: 0.8; +} + +td.line-number:hover { + opacity : 1; +} + +td.line-content { + padding-left:5px; + +} + +$source-code-font:Consolas, Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace, serif; + +.source-code { + font-family: $source-code-font; + white-space:pre; + line-height:1.25; + tab-size: 4; +} + +.break-word { + word-wrap:break-word; +} + +code,pre { + font-family: $source-code-font !important; + white-space:pre; + line-height:1.25; + font-size:100%; + color : unset; +} + +.source-code-font { + font-family: $source-code-font; + line-height:1.25; +} + +.source-code-snippet { + padding: 4px; + text-decoration:none !important; + display:block; +} + +.source-code-snippet > b { + border-radius: 3px; + border: 1px solid #B4B4B4; + margin: -1px; +} + +.source-code-snippet:hover { + text-decoration:none; +} + +.source-code-container { + margin : 5px; +} + +.declarations { + position:fixed; + top:150px; + right:15px; + width: 450px; + z-index:1; + div { + margin : 5px; + } +} + +.declarations-header { + opacity:0.9; + height:50px; +} + +.declarations-content { + opacity:0.9; + position:absolute; + top:50px; + left:0px; + right:0px; + max-height:500px; + overflow-y:auto; + overflow-x:hidden; +} + +@media screen and (max-width: 1500px) { + .declarations { + display:none; + } +} + +.info-window-container { + position:absolute; + width:750px; + z-index:10; +} + +.info-window-header { + margin:5px; + width: 100%; + cursor: move; + cursor: grab; + cursor: -moz-grab; + cursor: -webkit-grab; +} + +.info-window-content { + max-height:200px; + overflow-x:auto; + overflow-y:auto; +} + +.info-window-options { + margin-right:10px; + margin-left:10px; + float:right; +} + + +.ui-draggable-dragging .info-window-header { + cursor: grabbing; + cursor: -moz-grabbing; + cursor: -webkit-grabbing; +} + +span.link { + cursor:pointer; + white-space:nowrap; +} + +span.link:hover { + text-decoration:underline; +} + +.ui-resizable-n { + height: 13px !important;; +} + +.ui-resizable-w { + width: 13px !important;; +} + +.type-info { + padding-top: 5px; + padding-bottom: 5px; +} + +.type-component { + display: inline-block; +} + +dd { + margin-left: 1em; +} + +.autocomplete-container { + position:absolute; + z-index:10; + display:none; + left:0px; +} + +ul.autocomplete-items { + li { + cursor:pointer; + max-height: 200px; + overflow: hidden; + } +} + +.autocomplete-item { + padding:5px; +} + +.file-tree-header { + position:absolute; + top:0; + left:0; + right:0; + height:50px; + margin:5px; +} + +.file-tree-content { + position:absolute; + top:50px; + left:0; + right:0; + bottom:0; + overflow-x:auto; + overflow-x:auto; +} + +button { + color:black; +} + +.paginated-list-header { + position:absolute; + top:0px; + left:0px; + right:0px; + height:40px; + padding: 5px; + white-space:nowrap; +} + +.paginated-list-content { + position:absolute; + top:40px; + bottom:0px; + left:0px; + right:0px; + overflow:auto; +} + +.pagination-button { + font-size: 10px; +} + +.search-results-header { + position:absolute; + top:0px; + left:0px; + right:0px; + height:35px; + padding: 5px; + white-space:nowrap; +} + +.search-results-content { + position:absolute; + top:35px; + bottom:0px; + left:0px; + right:0px; +} + +.search-result { + padding:5px; +} + +.expression { + margin-bottom:15px; +} + +.expressions { + margin:5px; +} + +.file-name { + margin-left:5px; +} + +.identifier-info { + margin:5px; +} + +.identifier-module { + margin-top:10px; +} + +.identifier-menu-item { + margin-right:10px; +} diff --git a/javascript/app/templates/application.hbs b/javascript/app/templates/application.hbs new file mode 100644 index 0000000..bd64fdb --- /dev/null +++ b/javascript/app/templates/application.hbs @@ -0,0 +1,26 @@ +<div class="absolute-container"> + <div class="flex-container"> + <div class="header"> + <img src="/assets/haskell.ico"> + <div class="container"> + <span class="header-item">{{#link-to 'packages'}}Haskell packages{{/link-to}}</span> + <span class="header-item"><a target="_blank" href="https://github.com/alexwl/haskell-code-explorer">About</a></span> + <span class="header-item"> + {{#each themes as |theme|}} + {{#radio-button + value=theme.id + groupValue=currentTheme + changed="themeChanged"}} + <span>{{theme.name}}</span> + {{/radio-button}} + {{/each}} + </span> + </div> + </div> + <div class="content"> + <div class="absolute-container"> + {{outlet}} + </div> + </div> + </div> +</div> diff --git a/javascript/app/templates/bad-url.hbs b/javascript/app/templates/bad-url.hbs new file mode 100644 index 0000000..6b36386 --- /dev/null +++ b/javascript/app/templates/bad-url.hbs @@ -0,0 +1,6 @@ +<div class="container"> + <div style="margin-top:10px"> + <h1>Not found</h1> + <div><a href="/">Main page</a></div> + </div> +</div> diff --git a/javascript/app/templates/components/.gitkeep b/javascript/app/templates/components/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/javascript/app/templates/components/.gitkeep diff --git a/javascript/app/templates/components/bottom-panel.hbs b/javascript/app/templates/components/bottom-panel.hbs new file mode 100644 index 0000000..26b1919 --- /dev/null +++ b/javascript/app/templates/components/bottom-panel.hbs @@ -0,0 +1,9 @@ +<div class="bottom-panel-header"> + <span class="bottom-panel-header-content">{{yield "header"}}</span> + <span class="bottom-panel-header-options"> + <a href="#" {{action "close"}}>Close</a> + </span> +</div> +<div class="bottom-panel-content"> + {{yield "body"}} +</div> diff --git a/javascript/app/templates/components/expression-info.hbs b/javascript/app/templates/components/expression-info.hbs new file mode 100644 index 0000000..509fb24 --- /dev/null +++ b/javascript/app/templates/components/expression-info.hbs @@ -0,0 +1,8 @@ +<div class="expressions"> + {{#each expressions as |expression|}} + <div class="expression"> + <span class="source-code">{{expression.sourceCode}}</span> + <br/>:: {{type-signature type=expression.info.exprType identifiers=identifiers currentLineNumber=currentLineNumber}} + </div> + {{/each}} +</div> diff --git a/javascript/app/templates/components/file-tree.hbs b/javascript/app/templates/components/file-tree.hbs new file mode 100644 index 0000000..b5e38ef --- /dev/null +++ b/javascript/app/templates/components/file-tree.hbs @@ -0,0 +1,8 @@ +<div class="file-tree-header"> + <div class="input-group"> + {{input class="form-control" value=query placeholder="Filename"}}<a class="hide-file-tree" href="#" {{action "hide"}}>Hide</a> + </div> +</div> +<div class="file-tree-content"> + <div class="file-tree"></div> +</div> diff --git a/javascript/app/templates/components/haskell-module.hbs b/javascript/app/templates/components/haskell-module.hbs new file mode 100644 index 0000000..77b9df7 --- /dev/null +++ b/javascript/app/templates/components/haskell-module.hbs @@ -0,0 +1,57 @@ +<div class="source-code-container"></div> +{{#if declarations}} + <div class="declarations"> + <div class="declarations-header"> + <div class="input-group"> + {{input class="form-control" value=query placeholder="Identifier"}}<a class="hide-declarations" href="#" {{action "toggleShowDeclarations"}}>{{showDeclarationsLabel}}</a> + </div> + </div> + {{#if showDeclarations}} + <div id="declarations-content" class="declarations-content"> + <ul> + {{#infinite-list containerElementId="declarations-content" elements=filteredDeclarations perPage=30 as |declaration|}} + <li class="declaration"> + <div class="declaration"> + <a {{action "goToLine" declaration.lineNumber}} href="#"><span class="source-code-font">{{declaration.name}}{{#if declaration.declType}} :: {{type-signature-text components=declaration.declType.components}}{{/if}}</span> + </a><span>{{#unless declaration.isExported}}<i>(not exported)</i>{{/unless}}</span> + </div> + </li> + {{/infinite-list}} + </ul> + </div> + {{/if}} + </div> +{{/if}} +{{#info-window + targetElement=selectedIdentifier + isHoveredOverIdentifier=isHoveredOverIdentifier + hasSelectedExpression=hasSelectedExpression + containerElementId="right-panel" as |section|}} + {{#if (eq section "header")}} + {{#if hasSelectedExpression}} + <div>Selected expressions</div> + {{else}} + {{identifier-name + identifierInfo=identifierInfo + identifierOccurrence=identifierOccurrence + identifierElement=selectedIdentifier + currentPackageId=packageId + isBinder=identifierOccurrence.isBinder + currentLineNumber=currentLineNumber + findReferences=findReferences}} + {{/if}} + {{else}} + {{#unless hasSelectedExpression}} + {{identifier-info + identifierInfo=identifierInfo + identifierOccurrence=identifierOccurrence + identifiers=identifiers + currentLineNumber=currentLineNumber}} + {{else}} + {{expression-info + expressions=expressions + identifiers=identifiers + currentLineNumber=currentLineNumber}} + {{/unless}} + {{/if}} +{{/info-window}} diff --git a/javascript/app/templates/components/identifier-info.hbs b/javascript/app/templates/components/identifier-info.hbs new file mode 100644 index 0000000..7146a2e --- /dev/null +++ b/javascript/app/templates/components/identifier-info.hbs @@ -0,0 +1,39 @@ +{{#if (or identifierInfo identifierOccurrence.idOccType)}} + <div class="identifier-info"> + {{#if identifierInfo}} + {{#if isNaughtyRecSel}} + <i>This record selector can never be called because its type mentions a type variable that isn't in the result type of the constructor</i> + {{else}} + {{type-signature + type=identifierInfo.idType + identifiers=identifiers + currentLineNumber=currentLineNumber}} + {{/if}} + {{/if}} + {{#if (and identifierInfo identifierOccurrence identifierOccurrence.idOccType)}} + <div style="height:10px"></div> + {{/if}} + {{#if (and identifierOccurrence identifierOccurrence.idOccType)}} + {{type-signature + type=identifierOccurrence.idOccType + identifiers=identifiers + currentLineNumber=currentLineNumber}} + {{/if}} + <div class="documentation"> + {{{identifierInfo.doc}}} + {{{downloadedDocumentation}}} + </div> + {{#if identifierOccurrence.instanceResolution}} + <div style="height:10px"></div> + {{/if}} + <div> + {{#if identifierOccurrence.instanceResolution}} + {{instance-info + instance=identifierOccurrence.instanceResolution + identifiers=identifiers + nestedLevel=0 + currentLineNumber=currentLineNumber}} + {{/if}} + </div> + </div> +{{/if}} diff --git a/javascript/app/templates/components/identifier-name.hbs b/javascript/app/templates/components/identifier-name.hbs new file mode 100644 index 0000000..ca16e23 --- /dev/null +++ b/javascript/app/templates/components/identifier-name.hbs @@ -0,0 +1,12 @@ +<div> + <span class="source-code-font break-word identifier-menu-item" style={{style}}>{{#if identifierInfo.demangledOccName}}{{identifierInfo.demangledOccName}}{{else}}{{name}}{{/if}}</span> + {{#unless isBinder}} + {{#if location}} + <span class="identifier-menu-item">{{location}}</span> + <span class="link identifier-menu-item" onmouseup={{action "goToDefinition"}}>Go to definition</span> + {{/if}} + {{/unless}} + {{#if isExternalIdentifier}} + <span class="link identifier-menu-item" onmouseup={{action "findReferences" identifierInfo currentPackageId}}>Find references</span> + {{/if}} +</div> diff --git a/javascript/app/templates/components/infinite-list.hbs b/javascript/app/templates/components/infinite-list.hbs new file mode 100644 index 0000000..ab103d4 --- /dev/null +++ b/javascript/app/templates/components/infinite-list.hbs @@ -0,0 +1,3 @@ +{{#each renderedElements as |element|}} + {{yield element}} +{{/each}} diff --git a/javascript/app/templates/components/info-window.hbs b/javascript/app/templates/components/info-window.hbs new file mode 100644 index 0000000..003e2c5 --- /dev/null +++ b/javascript/app/templates/components/info-window.hbs @@ -0,0 +1,12 @@ +<div class="info-window"> + <div class="info-window-header"> + <div class="info-window-options"> + <a href="#" {{action "pin"}}>{{#if isPinned}}Unpin{{else}}Pin{{/if}}</a> + <a href="#" {{action "close"}}>Close</a> + </div> + {{yield "header"}} + </div> + <div class="info-window-content"> + {{yield "body"}} + </div> +</div> diff --git a/javascript/app/templates/components/input-with-autocomplete.hbs b/javascript/app/templates/components/input-with-autocomplete.hbs new file mode 100644 index 0000000..eb0100d --- /dev/null +++ b/javascript/app/templates/components/input-with-autocomplete.hbs @@ -0,0 +1,19 @@ +<div class="input-group"> + {{input class="form-control search-input" value=query placeholder=placeholder}} + <div class="autocomplete-container"> + {{# if items}} + <ul class="autocomplete-items"> + {{#each items as |item index|}} + <li class="{{if (eq index highlightedItemIndex) "highlighted"}}" onclick={{action "goToDefinition" item}}> + <div class="autocomplete-item"> + {{yield item}} + </div> + </li> + {{/each}} + </ul> + {{/if}} + </div> + <div class="input-group-append"> + <button id="search-input" class="btn btn-outline-secondary" {{action "onSubmit" query}}>Search</button> + </div> +</div> diff --git a/javascript/app/templates/components/instance-info.hbs b/javascript/app/templates/components/instance-info.hbs new file mode 100644 index 0000000..b5c2fdf --- /dev/null +++ b/javascript/app/templates/components/instance-info.hbs @@ -0,0 +1,10 @@ +{{#if instance}} + <div style={{style}}> + <span class="source-code-font"> + instance {{type-signature type=instance.instanceType identifiers=identifiers currentLineNumber=currentLineNumber noExpand=true}}</span> + (<span class="link" onclick={{action "goToDefinition"}}>Go to definition</span>) + {{#each instance.instances as |inst|}} + {{instance-info instance=inst identifiers=identifiers nestedLevel=nextNestedLevel currentLineNumber=currentLineNumber}} + {{/each}} + </div> +{{/if}} diff --git a/javascript/app/templates/components/paginated-list.hbs b/javascript/app/templates/components/paginated-list.hbs new file mode 100644 index 0000000..2ae9813 --- /dev/null +++ b/javascript/app/templates/components/paginated-list.hbs @@ -0,0 +1,17 @@ +<div class="paginated-list-header"> + <span>Found {{total}}</span> + {{#if (or next prev)}} + + + <span> + {{#if first}}<button class="btn btn-outline-secondary btn-sm" {{action "update" first.href}}><<</button>{{/if}} + {{#if prev}}<button button class="btn btn-outline-secondary btn-sm" {{action "update" prev.href}}><</button>{{/if}} + {{firstItemOnPage}} - {{lastItemOnPage}} + {{#if next}}<button button class="btn btn-outline-secondary btn-sm" {{action "update" next.href}}>></button>{{/if}} + {{#if last}}<button button class="btn btn-outline-secondary btn-sm" {{action "update" last.href}}>>></button>{{/if}} + </span> + {{/if}} +</div> +<div class="paginated-list-content"> + {{yield items}} +</div> diff --git a/javascript/app/templates/components/resizable-panel.hbs b/javascript/app/templates/components/resizable-panel.hbs new file mode 100644 index 0000000..0fd3aa7 --- /dev/null +++ b/javascript/app/templates/components/resizable-panel.hbs @@ -0,0 +1,5 @@ +<div class="absolute-container">{{yield (action "hide")}} + <div onclick={{action "hide"}} class="show-left-panel-button"> + {{{hideButtonLabel}}} + </div> +</div> diff --git a/javascript/app/templates/components/text-file.hbs b/javascript/app/templates/components/text-file.hbs new file mode 100644 index 0000000..6a50453 --- /dev/null +++ b/javascript/app/templates/components/text-file.hbs @@ -0,0 +1,3 @@ +<div class="source-code-container"> +{{{html}}} +</div> diff --git a/javascript/app/templates/components/type-component.hbs b/javascript/app/templates/components/type-component.hbs new file mode 100644 index 0000000..775324e --- /dev/null +++ b/javascript/app/templates/components/type-component.hbs @@ -0,0 +1,3 @@ +{{#if (and expanded identifierInfo)}}({{/if}} +<span class="source-code {{linkClass}}" onmouseup={{action "onmouseup"}}>{{occName}}</span> +{{#if (and expanded identifierInfo)}} :: {{type-signature type=identifierInfo.idType identifiers=identifiers noExpand=true}}){{/if}} diff --git a/javascript/app/templates/components/type-signature-text.hbs b/javascript/app/templates/components/type-signature-text.hbs new file mode 100644 index 0000000..505a466 --- /dev/null +++ b/javascript/app/templates/components/type-signature-text.hbs @@ -0,0 +1,2 @@ +{{!-- No newlines to get rid of spaces between spans --}} +{{#each components as |typeComponent|}}{{#if (eq typeComponent.tag "Text")}}{{typeComponent.contents}}{{else}}{{typeComponent.name}}{{/if}}{{/each}} diff --git a/javascript/app/templates/components/type-signature.hbs b/javascript/app/templates/components/type-signature.hbs new file mode 100644 index 0000000..249f1b8 --- /dev/null +++ b/javascript/app/templates/components/type-signature.hbs @@ -0,0 +1,10 @@ +{{!-- No newlines to get rid of spaces between spans --}} +{{#each components as |typeComponent|}}{{#if (eq typeComponent.tag "Text")}}<span class="source-code">{{typeComponent.contents}}</span>{{else}}{{type-component occName=typeComponent.name internalId=typeComponent.internalId identifiers=identifiers currentLineNumber=currentLineNumber}}{{/if}}{{/each}} +{{#unless noExpand}} + {{#if type.componentsExpanded}} + <div style="margin-top:5px"> + <button class="btn btn-outline-secondary btn-sm" {{action "toggleExpandTypeSynonyms"}}>{{expandTypeSynonymsLabel}} + </button> + </div> + {{/if}} +{{/unless}} diff --git a/javascript/app/templates/package.hbs b/javascript/app/templates/package.hbs new file mode 100644 index 0000000..c8ed0dc --- /dev/null +++ b/javascript/app/templates/package.hbs @@ -0,0 +1,57 @@ +<div class="flex-container"> + <div class="package-header"> + <span class="package-header-package-name">{{#link-to 'package' model}}{{model.id}}{{/link-to}}</span> + <span class="package-header-input"> + {{#input-with-autocomplete + onSubmit=(action 'searchIdentifier') + createSearchUrlFunction=createSearchUrlFunction + maxItems=10 + selectItem=(action 'showIdentifier') + placeholder="Identifier" as |identifier|}} + <span class="source-code-font">{{identifier.demangledOccName}} :: {{type-signature-text components=identifier.idType.components}}</span> + <div class="module-name"> + {{#if identifier.locationInfo.modulePath}} + {{identifier.locationInfo.modulePath}} + {{else}} + {{identifier.locationInfo.moduleName}} + {{/if}} + </div> + {{/input-with-autocomplete}} + </span> + {{#if currentFile}} + <span class="package-header-filename"> + {{currentFile}} + </span> + {{/if}} + </div> + <div class="package-content"> + {{#resizable-panel class="left-panel" alsoResizeElementId="#right-panel" as |hide|}} + <div class="file-tree-container"> + {{file-tree directoryTree=model.directoryTree openFile="openFile" currentFile=currentFile packageId=model.id hide=hide}} + </div> + {{/resizable-panel}} + <div id="right-panel" class="right-panel"> + <div id="file-container" class="file-container"> + {{outlet}} + </div> + {{#bottom-panel visible=bottomPanelVisible topPanelElementId="#file-container" containerElementId="#right-panel" as |section|}} + {{#if (eq section "header")}} + References to <b><span class="source-code">{{occName}}</span></b> in <span class="source-code">{{packageId}}</span> + {{else}} + {{#paginated-list url=referencesUrl as |files|}} + <ul> + {{#each files as |file|}} + <li> + <div class="file-name"><a href="/package/{{packageId}}/show/{{file.name}}">{{file.name}}</a></div> + {{#each file.references as |reference|}} + <a class="source-code source-code-snippet" href="/package/{{packageId}}/show/{{file.name}}#L{{reference.idSrcSpan.line}}">{{{reference.sourceCodeHtml}}}</a> + {{/each}} + </li> + {{/each}} + </ul> + {{/paginated-list}} + {{/if}} + {{/bottom-panel}} + </div> + </div> +</div> diff --git a/javascript/app/templates/package/index.hbs b/javascript/app/templates/package/index.hbs new file mode 100644 index 0000000..e9bc592 --- /dev/null +++ b/javascript/app/templates/package/index.hbs @@ -0,0 +1,12 @@ +<div class="package-modules"> + <div class="module-search-input"> + {{input class="form-control" type="text" value=query placeholder="Module name"}} + </div> + <ul class="modules"> + {{#each modulesFiltered as |module|}} + <li> + <a href="/package/{{model.id}}/show/{{module}}">{{module}}</a> + </li> + {{/each}} + </ul> +</div> diff --git a/javascript/app/templates/package/search.hbs b/javascript/app/templates/package/search.hbs new file mode 100644 index 0000000..ebc4d52 --- /dev/null +++ b/javascript/app/templates/package/search.hbs @@ -0,0 +1,21 @@ +<div class="search-results-header"> + Query : {{model.query}} +</div> +<div class="search-results-content"> + {{#paginated-list url=model.url as |identifiers|}} + <ul> + {{#each identifiers as |identifier|}} + <li class="search-result"> + <span class="source-code" >{{identifier.demangledOccName}} :: {{type-signature-text components=identifier.idType.components}}</span> + <div><a href="#" onmouseup={{action "goToDefinition" identifier.locationInfo}}>Go to definition</a></div> + <div class="identifier-module"> + {{#if identifier.locationInfo.modulePath}} + Defined in <a href="/package/{{identifier.locationInfo.packageId.name}}-{{identifier.locationInfo.packageId.version}}/show/{{identifier.locationInfo.modulePath}}">{{identifier.locationInfo.modulePath}}</a> + {{/if}} + </div> + <div>{{{identifier.doc}}}</div> + </li> + {{/each}} + </ul> + {{/paginated-list}} +</div> diff --git a/javascript/app/templates/package/show.hbs b/javascript/app/templates/package/show.hbs new file mode 100644 index 0000000..c24cd68 --- /dev/null +++ b/javascript/app/templates/package/show.hbs @@ -0,0 +1 @@ +{{outlet}} diff --git a/javascript/app/templates/package/show/file.hbs b/javascript/app/templates/package/show/file.hbs new file mode 100644 index 0000000..b127fd7 --- /dev/null +++ b/javascript/app/templates/package/show/file.hbs @@ -0,0 +1,16 @@ +{{#if model.isHaskellModule}} + {{haskell-module + path=model.id + name=model.name + packageId=model.packageId + componentId=model.componentId + html=model.sourceCodeHtml + identifiers=model.identifiers + occurrences=model.occurrences + colorTheme=settings.colorTheme + declarations=model.declarations + findReferences=(action "findReferences") + }} +{{else}} + {{text-file text=model.text path=model.id}} +{{/if}} diff --git a/javascript/app/templates/packages.hbs b/javascript/app/templates/packages.hbs new file mode 100644 index 0000000..6dc4b7b --- /dev/null +++ b/javascript/app/templates/packages.hbs @@ -0,0 +1,26 @@ +<div class="flex-container container"> + <div> + <!-- <p class="lead"><b>Haskell code explorer</b> </p> --> + <div class="package-search-form"> + {{input class="form-control" type="text" value=query placeholder="Package name"}} + <span>Number of packages : {{packages.length}}</span> + </div> + </div> + <div id="packages" class="packages"> + <ul> + {{#infinite-list containerElementId="packages" elements=packages perPage=80 as |package|}} + <li> + {{#each package.versions as |version index|}} + {{#link-to 'package' (concat package.name "-" version)}} + {{#if (gt index 0)}} + {{version}} + {{else}} + {{package.name}}-{{version}} + {{/if}} + {{/link-to}} + {{/each}} + </li> + {{/infinite-list}} + </ul> + </div> +</div> diff --git a/javascript/app/utils/api-urls.js b/javascript/app/utils/api-urls.js new file mode 100644 index 0000000..b2748b6 --- /dev/null +++ b/javascript/app/utils/api-urls.js @@ -0,0 +1,29 @@ +import config from '../config/environment'; + +export const urls = { + packageInfoUrl : function(packageId) { + return config.APP.staticUrlPrefix+"/"+packageId+"/"+config.APP.haskellCodeExplorerDirectory+"/packageInfo.json"; + }, + fileUrl : function(packageId,filePath) { + return config.APP.staticUrlPrefix+"/"+packageId+"/"+filePath; + }, + haskellModuleUrl : function (packageId,filePath) { + return config.APP.staticUrlPrefix+"/"+packageId+"/"+config.APP.haskellCodeExplorerDirectory+"/"+encodeURIComponent(encodeURIComponent(filePath))+ ".json"; + }, + packagesUrl : config.APP.apiUrlPrefix + "/packages", + identifierDefinitionSiteUrl : function(packageId,moduleName,componentId,entity,name) { + return config.APP.apiUrlPrefix + "/definitionSite/" + packageId+"/"+componentId+"/"+moduleName+"/"+entity+"/"+encodeURIComponent(name).replace(/\./g, '%2E'); + }, + modulePathUrl : function (packageId,moduleName,componentId) { + return config.APP.apiUrlPrefix + "/modulePath/"+packageId+"/"+componentId+"/"+moduleName; + }, + expressionsUrl : function (packageId,modulePath,lineStart,columnStart,lineEnd,columnEnd) { + return config.APP.apiUrlPrefix + "/expressions/"+packageId+"/"+encodeURIComponent(modulePath) +"/"+lineStart+"/"+columnStart+"/"+lineEnd+"/"+columnEnd; + }, + referencesUrl : function (packageId,externalId) { + return config.APP.apiUrlPrefix + "/references/"+packageId+"/"+encodeURIComponent(externalId); + }, + identifierSearchUrl : function (packageId,query) { + return config.APP.apiUrlPrefix + "/identifiers/"+packageId+"/"+encodeURIComponent(query).replace(/\./g, '%2E'); + } +} diff --git a/javascript/app/utils/color-themes.js b/javascript/app/utils/color-themes.js new file mode 100644 index 0000000..0a563ff --- /dev/null +++ b/javascript/app/utils/color-themes.js @@ -0,0 +1,188 @@ +function colorThemeToCss(colorTheme) { + const css = ` + body { + color: ${colorTheme.defaultColor} !important; + background-color: ${colorTheme.backgroundColor} !important; + } + input { + color: ${colorTheme.defaultColor} !important; + background-color: ${colorTheme.backgroundColor} !important; + border-color: ${colorTheme.borderColor} !important; + } + .package-content { + border-top: 1px solid ${colorTheme.borderColor} !important; + } + .header a { + color : ${colorTheme.menuLinkColor} !important; + } + a { + color: ${colorTheme.typeColor} !important; + } + span.link { + color: ${colorTheme.typeColor} !important; + } + .header { + background-color: ${colorTheme.menuColor} !important; + border-bottom: 1px solid ${colorTheme.borderColor} !important; + } + .declarations-content { + background-color: ${colorTheme.navigationPanelColor} !important; + border: 1px solid ${colorTheme.borderColor} !important; + } + .declarations-header { + background-color: ${colorTheme.navigationPanelColor} !important; + border: 1px solid ${colorTheme.borderColor} !important; + } + li.declaration { + border-bottom: 1px solid ${colorTheme.borderColor} !important; + } + .left-panel { + background-color: ${colorTheme.navigationPanelColor} !important; + border-right: 1px solid ${colorTheme.borderColor} !important; + } + .show-left-panel-button { + background-color: ${colorTheme.navigationPanelColor} !important; + border-right:1px solid ${colorTheme.borderColor} !important; + border-bottom:1px solid ${colorTheme.borderColor} !important; + } + .right-panel { + background-color: ${colorTheme.backgroundColor} !important; + } + a.jstree-anchor { + color: ${colorTheme.defaultColor} !important; + } + .declaration > a { + color: ${colorTheme.defaultColor} !important; + } + .highlighted-line { + background : ${colorTheme.highlightedLineColor} !important; + } + table.source-code { + background-color: ${colorTheme.backgroundColor} !important; + color: ${colorTheme.defaultColor} !important; + } + .jstree-clicked { + background-color: ${colorTheme.backgroundColor} !important; + } + .jstree-hovered { + background-color: ${colorTheme.backgroundColor} !important; + } + ul.autocomplete-items { + background-color: ${colorTheme.backgroundColor} !important; + border-top: 1px solid ${colorTheme.borderColor} !important; + border-left: 1px solid ${colorTheme.borderColor} !important; + border-right: 1px solid ${colorTheme.borderColor} !important; + } + ul.autocomplete-items > li { + border-bottom: 1px solid ${colorTheme.borderColor} !important; + } + ul.autocomplete-items > li:hover { + background-color: ${colorTheme.highlightedLineColor} !important; + } + ul.autocomplete-items > li.highlighted { + background-color: ${colorTheme.highlightedLineColor} !important; + } + .source-code-snippet { + color: ${colorTheme.defaultColor} !important; + border-bottom: 1px solid ${colorTheme.borderColor} !important; + } + .source-code-snippet:hover { + background-color: ${colorTheme.highlightedLineColor} !important; + } + .bottom-panel { + background-color: ${colorTheme.backgroundColor} !important; + border-top: 1px solid ${colorTheme.borderColor} !important; + } + .bottom-panel-header { + border-bottom: 1px solid ${colorTheme.borderColor} !important; + } + .paginated-list-header { + border-bottom: 1px solid ${colorTheme.borderColor} !important; + } + li.search-result { + border-bottom: 1px solid ${colorTheme.borderColor} !important; + } + .search-results-header { + border-bottom: 1px solid ${colorTheme.borderColor} !important; + } + .info-window-content { + border-top: 1px solid ${colorTheme.borderColor} !important; + } + .info-window { + border: 1px solid ${colorTheme.borderColor} !important; + background-color:${colorTheme.infoWindowColor} !important; + color: ${colorTheme.defaultColor} !important; + } + .type-info { + border-top: 1px solid ${colorTheme.borderColor} !important; + }`; + return css; +} + +const darkTheme = { + id: "darkTheme", + name: "Dark theme", + description: "Dark theme (Monokai based)", + defaultColor: "#F8F8F2", + backgroundColor: "#272822", + typeColor: "#66D9EF", + literalColor: "#E6DB74", + topLevelIdFromCurrentModule : "#A6E22E", + localIdentifierColor: ["#F0A3FF","#0075DC","#993F00", + "#2BCE48","#FFCC99","#808080","#94FFB5","#8F7C00", + "#C20088","#FFA405","#FFA8BB","#426600","#FF0010", + "#5EF1F2","#00998F","#E0FF66","#FFFF80", + "#FFFF00","#FF5005"], + menuColor: "#3c3b37", + menuLinkColor : "#F8F8F2", + infoWindowColor: "#3c3b37", + navigationPanelColor: "#3c3b37", + linkColor : "#0366d6", + borderColor: "#535557", + highlightedLineColor: "#4a4a4a" +}; + +const lightTheme = { + id: "lightTheme", + name: "Light theme", + description: "Light theme (Github based)", + defaultColor: "#24292e", + backgroundColor: "#ffffff", + typeColor: "#005cc5", + literalColor: "#032f62", + topLevelIdFromCurrentModule : "#6f42c1", + localIdentifierColor: ["#005C31", + "#2BCE48","#808080","#8F7C00", + "#C20088","#FFA405","#ffa8bb","#426600","#FF0010", + "#09d7d8","#00998F","#990000","#FF5005"], + menuColor: "#f2f4f8", + menuLinkColor : "#24292e", + infoWindowColor: "#f2f4f8", + navigationPanelColor: "#f2f4f8", + linkColor : "#0366d6", + borderColor: "#e1e4e8", + highlightedLineColor: "#eaeaea" +}; + +function updateColorThemeCss (colorTheme) { + const newStyle = document.createElement('style'); + newStyle.type = 'text/css'; + newStyle.innerHTML = colorThemeToCss(colorTheme); + newStyle.id = 'color-theme'; + const oldStyle = document.querySelector("style#color-theme"); + if(oldStyle) { + oldStyle.parentElement.removeChild(oldStyle); + } + document.getElementsByTagName('head')[0].appendChild(newStyle); +} + +const themes = { + darkTheme: darkTheme, + lightTheme: lightTheme +}; + +export { + updateColorThemeCss, + colorThemeToCss, + themes +} diff --git a/javascript/app/utils/go-to-definition.js b/javascript/app/utils/go-to-definition.js new file mode 100644 index 0000000..e05f6bf --- /dev/null +++ b/javascript/app/utils/go-to-definition.js @@ -0,0 +1,95 @@ +function exactLocationToUrl(exactLocation) { + const modulePath = exactLocation.modulePath; + const packageId = exactLocation.packageId.name + "-" + exactLocation.packageId.version; + let hash = ""; + if(exactLocation.startLine != 1) { + hash = "#L" + exactLocation.startLine; + } + return "/package/"+packageId+"/show/"+modulePath+hash; +} + +function hackageUrl(packageId,locationInfo) { + const dasherizedModuleName = locationInfo.moduleName.replace(/\./g,'-'); + let key; + if(locationInfo.entity === "Val") { + key = "v"; + } else { + key = "t"; + } + let hash = ""; + if(locationInfo.entity === "Val" || locationInfo.entity === "Typ") { + hash = "#"+key+":"+locationInfo.haddockAnchorId; + } + return "https://hackage.haskell.org/package/"+packageId+"/docs/"+dasherizedModuleName+".html"+hash; +} + +function openUrl(buttonId,url) { + if(buttonId === 2) {//middle mouse button + window.open(url, '_blank'); + } else if(buttonId == 1) {//left mouse button + window.location = url; + } + return false; +} + +function saveCurrentLocation(currentLineNumber) { + if(currentLineNumber) { + const url = window.location.origin + window.location.pathname + "#L" + currentLineNumber; + if(location.href != url) { + window.location.hash = "#L" + currentLineNumber; + } + } +} + +function goToDefinition(store,locationInfo,buttonId,currentLineNumber) { + if(locationInfo.tag === "ExactLocation") { + const url = exactLocationToUrl(locationInfo); + if(locationInfo.startLine !== currentLineNumber) { + saveCurrentLocation(currentLineNumber); + } + openUrl(buttonId,url); + } else if(locationInfo.tag === "ApproximateLocation") { + const packageId = locationInfo.packageId.name+"-"+locationInfo.packageId.version; + if(locationInfo.entity === "Mod") { + store.loadDefinitionSite(packageId, + locationInfo.moduleName, + locationInfo.componentId, + locationInfo.entity, + locationInfo.moduleName) + .then((defSite) => { + const packageId = defSite.location.packageId.name + "-" + defSite.location.packageId.version; + openUrl(buttonId,"/package/" + packageId + "/show/" + defSite.location.modulePath); + }).catch(() => { + openUrl(buttonId,hackageUrl(packageId,locationInfo)); + }); + } else { + store.loadDefinitionSite(packageId, + locationInfo.moduleName, + locationInfo.componentId, + locationInfo.entity, + locationInfo.name) + .then((definitionSite) => { + if(definitionSite.location.tag === "ExactLocation") { + const url = exactLocationToUrl(definitionSite.location); + if(locationInfo.startLine !== currentLineNumber) { + saveCurrentLocation(currentLineNumber); + } + openUrl(buttonId,url); + } else { + saveCurrentLocation(currentLineNumber); + openUrl(buttonId,hackageUrl(packageId,locationInfo)); + } + }).catch((e) => { + console.log(e); + saveCurrentLocation(currentLineNumber); + openUrl(buttonId,hackageUrl(packageId,locationInfo)); + }); + } + } else { + alert('No location info'); + } +} + +export { + goToDefinition,openUrl +} diff --git a/javascript/app/utils/line-selection.js b/javascript/app/utils/line-selection.js new file mode 100644 index 0000000..d6d0976 --- /dev/null +++ b/javascript/app/utils/line-selection.js @@ -0,0 +1,111 @@ +function initializeLineSelection(sourceCodeContainerElement,component) { + const lineNumbers = Array.prototype.slice.call(sourceCodeContainerElement.querySelectorAll("td.line-number")); + if(lineNumbers.length > 0) { + const onhashchange = function () { + highlightSelectedLines(sourceCodeContainerElement); + } + window.addEventListener("hashchange",onhashchange); + component._onhashchange = onhashchange; + + let shiftPressed; + const onkeydown = function (event) { + if(event.keyCode === 16) { shiftPressed = true; } + }; + const onkeyup = function (event) { + if(event.keyCode === 16) { shiftPressed = false; } + }; + + document.addEventListener('keydown',onkeydown); + document.addEventListener('keyup',onkeyup); + component._onkeydown = onkeydown; + component._onkeyup = onkeyup; + + let selectedLine1,selectedLine2; + lineNumbers.forEach((lineNumberElement) => { + lineNumberElement.onclick = function() { + const number = parseInt(this.textContent); + if(shiftPressed && selectedLine1) { + if(selectedLine1 != number) { + selectedLine2 = number; + if(selectedLine1 < selectedLine2) { + highlightLines(sourceCodeContainerElement,selectedLine1,selectedLine2); + window.location.hash = "L"+selectedLine1+"-L"+selectedLine2; + } else { + highlightLines(sourceCodeContainerElement,selectedLine2,selectedLine1); + window.location.hash = "L"+selectedLine2+"-L"+selectedLine1; + } + } + } else { + selectedLine1 = number; + selectedLine2 = null; + highlightLines(sourceCodeContainerElement,selectedLine1,selectedLine1); + window.location.hash = "L"+number; + } + } + }); + const lines = highlightSelectedLines(sourceCodeContainerElement); + if(lines.length) { + selectedLine1 = lines[0]; + selectedLine2 = lines[1]; + } + } +} + +function highlightSelectedLines (sourceCodeContainerElement) { + const lineInfo = window.location.hash.slice(1); + if(lineInfo) { + if(lineInfo.includes('-')) { + const lines = lineInfo.split("-"); + const lineNumber1 = parseInt(lines[0].substring(1)); + const lineNumber2 = parseInt(lines[1].substring(1)); + if(lineNumber1 && lineNumber2 && lineNumber1 <= lineNumber2) { + highlightLines(sourceCodeContainerElement,lineNumber1,lineNumber2); + const line = sourceCodeContainerElement.querySelector("td#LC"+lineNumber1); + if(line) { + scrollLineIntoView(line,sourceCodeContainerElement); + } + return [lineNumber1,lineNumber2]; + } + } else { + const lineNumber = parseInt(lineInfo.substring(1)); + if(lineNumber) { + highlightLines(sourceCodeContainerElement,lineNumber,lineNumber); + const line = sourceCodeContainerElement.querySelector("td#LC"+lineNumber); + if(line) { + scrollLineIntoView(line,sourceCodeContainerElement); + } + return [lineNumber]; + } + } + } else { + highlightLines(sourceCodeContainerElement,0,0); + return []; + } +} + +function scrollLineIntoView(lineElement,sourceCodeContainerElement) { + lineElement.parentNode.scrollIntoView(); + const container = sourceCodeContainerElement.parentNode.parentNode; + const windowHeight = container.offsetHeight; + const fullHeight = sourceCodeContainerElement.offsetHeight; + + if(fullHeight - container.scrollTop > windowHeight) { + container.scrollTop = container.scrollTop - (windowHeight/2 - 20); + } +} + +function highlightLines(parentElement,startLine,endLine) { + const lineElements = Array.prototype.slice.call(parentElement.querySelectorAll("td.line-content")); + lineElements.forEach((lineElement) => { + const number = parseInt(lineElement.id.substring(2)); //<td "id"="LC10">...</td> + if(number >= startLine && number <= endLine) { + lineElement.classList.add('highlighted-line'); + } else { + lineElement.classList.remove('highlighted-line'); + } + }); +} + +export { + initializeLineSelection,highlightLines,highlightSelectedLines +} |