diff --git a/lib/main.coffee b/lib/main.coffee index f24b80c..50f1f7f 100644 --- a/lib/main.coffee +++ b/lib/main.coffee @@ -1,6 +1,7 @@ EnterDialog = require './enter-dialog' {Stacktrace} = require './stacktrace' {StacktraceView} = require './stacktrace-view' +{NavigationView} = require './navigation-view' editorDecorator = require './editor-decorator' module.exports = @@ -15,18 +16,21 @@ module.exports = atom.emit 'stacktrace:accept-trace', trace: text atom.workspace.eachEditor editorDecorator - Stacktrace.on 'active-changed', -> + @activeChanged = Stacktrace.on 'active-changed', -> editorDecorator(e) for e in atom.workspace.getEditors() + @navigationView = new NavigationView + atom.workspaceView.appendToBottom @navigationView + StacktraceView.registerIn(atom.workspace) - atom.on 'stacktrace:accept-trace', ({trace}) => + @acceptTrace = atom.on 'stacktrace:accept-trace', ({trace}) => for trace in Stacktrace.parse(trace) trace.register() atom.workspace.open trace.getUrl() deactivate: -> - Stacktrace.off 'active-changed' - atom.off 'stacktrace:accept-trace' + @navigationView.remove() - serialize: -> + @activeChanged.off() + @acceptTrace.off() diff --git a/lib/navigation-view.coffee b/lib/navigation-view.coffee new file mode 100644 index 0000000..c972878 --- /dev/null +++ b/lib/navigation-view.coffee @@ -0,0 +1,114 @@ +{View} = require 'atom' +{Subscriber} = require 'emissary' +{Stacktrace} = require './stacktrace' + +class NavigationView extends View + + Subscriber.includeInto this + + @content: -> + activatedClass = if Stacktrace.getActivated()? then '' else 'inactive' + + @div class: "tool-panel panel-bottom padded stacktrace navigation #{activatedClass}", => + @div class: 'inline-block trace-name', => + @h2 class: 'inline-block text-highlight message', outlet: 'message', click: 'backToTrace' + @span class: 'inline-block icon icon-x', click: 'deactivateTrace' + @div class: 'inline-block current-frame unfocused', outlet: 'frameContainer', => + @span class: 'inline-block icon icon-code' + @span class: 'inline-block function', outlet: 'frameFunction', click: 'navigateToLastActive' + @span class: 'inline-block index', outlet: 'frameIndex' + @span class: 'inline-block divider', '/' + @span class: 'inline-block total', outlet: 'frameTotal' + @div class: 'pull-right controls', => + @button class: 'inline-block btn', click: 'navigateToCaller', => + @span class: 'icon icon-arrow-up' + @text 'Caller' + @button class: 'inline-block btn', click: 'navigateToCalled', => + @span class: 'icon icon-arrow-down' + @text 'Follow Call' + + initialize: -> + @subscribe Stacktrace, 'active-changed', (e) => + if e.newTrace? then @useTrace(e.newTrace) else @noTrace() + + # Subscribe to opening editors. Set the current frame when a cursor is moved over a frame's + # line. + atom.workspace.eachEditor (e) => + @subscribe e, 'cursors-moved', => + if @trace? + pos = + position: e.getCursorBufferPosition() + path: e.getPath() + + # Allow the already-set @frame a chance to see if it still applies. + # This lets the caller and called navigation work properly, even if multiple frames are + # on the same line. + if @frame? and @frame.isOn(pos) + @useFrame(@frame) + else + # Otherwise, scan the trace for a matching frame. + frame = @trace.atEditorPosition(pos) + if frame? then @useFrame(frame) else @unfocusFrame() + + if Stacktrace.getActivated? then @hide() + + beforeRemove: -> + @unsubscribe Stacktrace + + useTrace: (@trace) -> + @removeClass 'inactive' + @message.text(trace.message) + @noFrame() + @show() + + noTrace: -> + @addClass 'inactive' + @message.text('') + @noFrame() + @hide() + + useFrame: (@frame) -> + @frameContainer.removeClass 'unfocused' + @frameFunction.text @frame.functionName + @frameFunction.addClass 'highlight-info' + @frameIndex.text @frame.humanIndex().toString() + @frameTotal.text @trace.frames.length.toString() + + unfocusFrame: -> + @frameContainer.addClass 'unfocused' + @frameFunction.removeClass 'highlight-info' + + noFrame: -> + @unfocusFrame() + @frameFunction.text '' + @frameIndex.text '' + @frameTotal.text '' + + deactivateTrace: -> + Stacktrace.getActivated().deactivate() + + backToTrace: -> + url = Stacktrace.getActivated()?.getUrl() + atom.workspace.open(url) if url + + navigateToCaller: -> + return unless @trace? and @frame? + + f = @trace.callerOf(@frame) + if f? + @frame = f + @frame.navigateTo() + + navigateToCalled: -> + return unless @trace? and @frame? + + f = @trace.calledFrom(@frame) + if f? + @frame = f + @frame.navigateTo() + + navigateToLastActive: -> + return unless @frame? + @frame.navigateTo() + +module.exports = NavigationView: NavigationView diff --git a/lib/stacktrace-view.coffee b/lib/stacktrace-view.coffee index 17e73b9..99e1b3d 100644 --- a/lib/stacktrace-view.coffee +++ b/lib/stacktrace-view.coffee @@ -12,17 +12,12 @@ class StacktraceView extends View @div class: "stacktrace traceview tool-panel padded #{tclass}", => @div class: 'panel padded', => @h2 class: 'error-message', trace.message - @p class: 'activate-control', => - @button class: 'btn btn-primary selected inline-block', click: 'activate', 'Activate' - @span class: 'inline-block', 'to navigate around this stacktrace.' - @p class: 'deactivate-control', => - @button class: 'btn btn-primary inline-block', click: 'deactivate', 'Deactivate' - @span class: 'inline-block', 'to close the stacktrace navigation panel.' @div class: 'frames', => for frame in trace.frames @subview 'frame', new FrameView frame, => trace.activate() initialize: (@trace) -> + @uri = @trace.getUrl() @subscribe Stacktrace, 'active-changed', (e) => if e.newTrace is @trace @addClass 'activated' @@ -37,14 +32,6 @@ class StacktraceView extends View getTitle: -> @trace.message - # Public: Activate the current {Stacktrace}. - # - activate: -> @trace.activate() - - # Public: Deactivate the current {Stacktrace}. - # - deactivate: -> @trace.deactivate() - # Internal: Register an opener function in the workspace to handle URLs # generated by a Stacktrace. # diff --git a/lib/stacktrace.coffee b/lib/stacktrace.coffee index 14aaf8e..7b6e1ba 100644 --- a/lib/stacktrace.coffee +++ b/lib/stacktrace.coffee @@ -19,6 +19,10 @@ class Stacktrace Emitter.extend this constructor: (@frames = [], @message = '') -> + i = 0 + for f in @frames + f.index = i + i += 1 # Internal: Compute the SHA256 checksum of the normalized stacktrace. # @@ -64,6 +68,32 @@ class Stacktrace ACTIVE = null Stacktrace.emit 'active-changed', oldTrace: this, newTrace: null + # Public: Return the Frame corresponding to an Editor position, if any, along with its position + # within the trace. + # + # object - "position" should be a Point corresponding to a cursor position, and "path" the full + # path of an Editor. + # + atEditorPosition: (editorPosition) -> + [index, total] = [1, @frames.length] + for frame in @frames + return frame if frame.isOn editorPosition + index += 1 + return null + + # Public: Return the Frame that called the given Frame, or undefined if given the top of the stack. + # + # frame - The current Frame to use as a reference point. + # + callerOf: (frame) -> @frames[frame.index + 1] + + # Public: Return the Frame that a given Frame called into, or undefined if given the bottom of the + # stack. + # + # frame - The current Frame to use as a reference point. + # + calledFrom: (frame) -> @frames[frame.index - 1] + # Public: Parse zero to many Stacktrace instances from a corpus of text. # # text - A raw blob of text. @@ -74,10 +104,12 @@ class Stacktrace # Internal: Return a registered trace, or null if none match the provided # URL. + # @forUrl: (url) -> REGISTRY[url] # Internal: Clear the global trace registry. + # @clearRegistry: -> REGISTRY = {} @@ -90,12 +122,17 @@ class Stacktrace class Frame constructor: (@rawLine, @rawPath, @lineNumber, @functionName) -> + @index = null @realPath = @rawPath # Public: Return the zero-indexed line number. # bufferLineNumber: -> @lineNumber - 1 + # Public: Return the one-based frame index. + # + humanIndex: -> @index + 1 + # Public: Asynchronously collect n lines of context around the specified line number in this # frame's source file. # @@ -122,6 +159,11 @@ class Frame if editorView? editorView.scrollToBufferPosition position, center: true + # Public: Return true if the buffer position and path correspond to this Frame's line. + # + isOn: ({position, path}) -> + path is @realPath and position.row is @bufferLineNumber() + module.exports = PREFIX: PREFIX diff --git a/package.json b/package.json index da6d9ce..863a87c 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,6 @@ "dependencies": { "emissary": "^1.2.1", "jssha": "^1.5.0", - "line-chomper": "git+https://github.com/smashwilson/line-chomper.git#optional-trim" + "line-chomper": "^0.4.5" } } diff --git a/spec/navigation-view-spec.coffee b/spec/navigation-view-spec.coffee new file mode 100644 index 0000000..c8e9127 --- /dev/null +++ b/spec/navigation-view-spec.coffee @@ -0,0 +1,114 @@ +{WorkspaceView} = require 'atom' +{Stacktrace, Frame} = require '../lib/stacktrace' +{NavigationView} = require '../lib/navigation-view' + +path = require 'path' + +fixturePath = (p) -> + path.join __dirname, 'fixtures', p + +frames = [ + new Frame('raw0', fixturePath('bottom.rb'), 12, 'botfunc') + new Frame('raw1', fixturePath('middle.rb'), 42, 'midfunc') + new Frame('raw2', fixturePath('top.rb'), 37, 'topfunc') + new Frame('raw3', fixturePath('middle.rb'), 5, 'otherfunc') +] +trace = new Stacktrace(frames, 'Boom') + +describe 'NavigationView', -> + [view] = [] + + beforeEach -> + atom.workspaceView = new WorkspaceView + atom.workspaceView.attachToDom() + activationPromise = atom.packages.activatePackage('stacktrace') + + atom.workspaceView.trigger 'stacktrace:paste' + + waitsForPromise -> activationPromise + + runs -> + view = atom.workspaceView.find('.stacktrace.navigation').view() + + afterEach -> + Stacktrace.getActivated()?.deactivate() + Stacktrace.clearRegistry() + + it 'attaches itself to the workspace', -> + expect(view).not.toBeNull() + + describe 'with an active stacktrace', -> + + beforeEach -> + trace.register() + trace.activate() + + it 'should be visible', -> + expect(view.hasClass 'inactive').toBeFalsy() + + it 'shows the active trace name', -> + text = view.find('.message').text() + expect(text).toEqual('Boom') + + it 'navigates back to the trace on a click', -> + waitsForPromise -> view.backToTrace() + + runs -> + expect(atom.workspaceView.getActiveView().hasClass 'traceview').toBeTruthy() + + it 'deactivates the trace', -> + view.deactivateTrace() + expect(trace.isActive()).toBeFalsy() + + describe 'on an editor corresponding to a single frame', -> + [editor] = [] + + beforeEach -> + waitsForPromise -> trace.frames[2].navigateTo() + + runs -> + editor = atom.workspace.getActiveEditor() + + it 'shows the current frame and its index', -> + expect(view.find('.current-frame .function').text()).toBe('topfunc') + expect(view.find('.current-frame .index').text()).toBe('3') + expect(view.find('.current-frame .total').text()).toBe('4') + + it "navigates to the caller's frame", -> + waitsForPromise -> view.navigateToCaller() + + runs -> + expect(view.frame).toBe(trace.frames[3]) + + it 'navigates to the called frame', -> + waitsForPromise -> view.navigateToCalled() + + runs -> + expect(view.frame).toBe(trace.frames[1]) + + it 'navigates back to the last active frame', -> + editor.setCursorBufferPosition [5, 0] + expect(view.find '.current-frame.unfocused').toHaveLength 1 + + waitsForPromise -> view.navigateToLastActive() + + runs -> + expect(view.find '.current-frame.unfocused').toHaveLength 0 + expect(editor.getCursorBufferPosition().row).toBe 36 + + describe 'on an editor with multiple frames', -> + [editor] = [] + + beforeEach -> + waitsForPromise -> trace.frames[1].navigateTo() + + runs -> + editor = atom.workspace.getActiveEditor() + + it 'notices if you manually navigate to a different frame', -> + expect(view.find('.current-frame .function').text()).toEqual 'midfunc' + + editor.setCursorBufferPosition [4, 1] + + expect(view.frame).toBe(trace.frames[3]) + expect(view.find('.current-frame .function').text()).toEqual 'otherfunc' diff --git a/spec/stacktrace-spec.coffee b/spec/stacktrace-spec.coffee index f6cd82e..5c0e76b 100644 --- a/spec/stacktrace-spec.coffee +++ b/spec/stacktrace-spec.coffee @@ -1,3 +1,4 @@ +{Point} = require 'atom' path = require 'path' {Stacktrace, Frame} = require '../lib/stacktrace' @@ -54,6 +55,10 @@ describe 'Stacktrace', -> ] expect(functionNames).toEqual(expected) + it 'assigns an index to each frame', -> + positions = (frame.index for frame in trace.frames) + expect(positions).toEqual([0..4]) + describe 'registration', -> afterEach -> Stacktrace.clearRegistry() @@ -102,8 +107,49 @@ describe 'Stacktrace', -> expect(event.oldTrace).toBeNull() expect(event.newTrace).toBe(trace) + describe 'walking up and down the stack', -> + + it 'links to the callee of each frame', -> + callees = (trace.calledFrom(f) for f in trace.frames) + expected = [ + undefined + trace.frames[0] + trace.frames[1] + trace.frames[2] + trace.frames[3] + ] + expect(callees).toEqual(expected) + + it 'links to the caller of each frame', -> + callers = (trace.callerOf(f) for f in trace.frames) + expected = [ + trace.frames[1] + trace.frames[2] + trace.frames[3] + trace.frames[4] + undefined + ] + expect(callers).toEqual(expected) + + describe 'active frame location', -> + + it 'locates the frame corresponding to an Editor position', -> + frame = trace.atEditorPosition + position: Point.fromObject([4, 0]) + path: '/home/smash/samples/tracer/otherdir/file2.rb' + + expect(frame).toBe(trace.frames[2]) + expect(frame.humanIndex()).toBe(3) + + it 'returns null if none are found', -> + frame = trace.atEditorPosition + position: Point.fromObject([2, 1]) + path: '/home/smash/samples/tracer/otherdir/file2.rb' + + expect(frame).toBeNull() + describe 'Frame', -> - [frame] = [] + [frame, fixturePath] = [] beforeEach -> fixturePath = path.join __dirname, 'fixtures', 'context.txt' @@ -125,3 +171,13 @@ describe 'Frame', -> expect(lines[2]).toEqual('five') expect(lines[3]).toEqual('six') expect(lines[4]).toEqual('') + + describe 'recognizes itself in an Editor', -> + it 'is on a cursor', -> + expect(frame.isOn(position: Point.fromObject([4, 0]), path: fixturePath)).toBeTruthy() + + it 'is not on a cursor', -> + expect(frame.isOn(position: Point.fromObject([2, 0]), path: fixturePath)).toBeFalsy() + + it 'is on a different file', -> + expect(frame.isOn(position: Point.fromObject([4, 0]), path: 'some/other/path.rb')).toBeFalsy() diff --git a/stylesheets/stacktrace.less b/stylesheets/stacktrace.less index 5bfc3c8..2af477a 100644 --- a/stylesheets/stacktrace.less +++ b/stylesheets/stacktrace.less @@ -11,6 +11,7 @@ .stacktrace { &.enter-dialog .editor { height: 300px; + max-height: 300px; } .frame .panel-heading { @@ -37,21 +38,59 @@ } .editor { - cursor: pointer; + .editor-contents { + cursor: pointer; + } + height: 175px; + max-height: 175px; } } - .deactivate-control { display: none; } + &.navigation { + .trace-name { + margin-right: 30px; + + h2 { + margin: 2px 15px 0 5px; + font-weight: bold; + cursor: pointer; + } + span.icon-x { + font-size: 1.5em; + cursor: pointer; + } + } + + .current-frame { + span.icon-code { + margin: 0; + } + + &.unfocused { + color: @text-color-subtle; - &.activated { - .deactivate-control { display: block; } - .activate-control { display: none; } + span.function { + padding: 1px 3px; + font-weight: bold; + + &:hover { + cursor: pointer; + text-decoration: underline; + } + } + } + } } } .editor { - .line.line-stackframe { + .line.line-stackframe, .line.line-stackframe.cursor-line { + background: @stackframe-background; + } + + // Work around some selector precedence issues. + &.is-focused .line.line-stackframe, &.is-focused .line.line-stackframe.cursor-line { background: @stackframe-background; }