diff --git a/lib/main.coffee b/lib/main.coffee index c0ae98d..0e014f4 100644 --- a/lib/main.coffee +++ b/lib/main.coffee @@ -11,9 +11,9 @@ module.exports = StacktraceView.registerIn(atom.workspace) atom.on 'stacktrace:accept-trace', ({trace}) => - t = Stacktrace.parse(trace) - t.register() - atom.workspace.open t.getUrl() + for trace in Stacktrace.parse(trace) + trace.register() + atom.workspace.open trace.getUrl() deactivate: -> atom.off 'stacktrace:accept-trace' diff --git a/lib/stacktrace-view.coffee b/lib/stacktrace-view.coffee index 502a184..bda2d1f 100644 --- a/lib/stacktrace-view.coffee +++ b/lib/stacktrace-view.coffee @@ -1,24 +1,53 @@ -{View} = require 'atom' +{View, EditorView} = require 'atom' +{Subscriber} = require 'emissary' + {Stacktrace, PREFIX} = require './stacktrace' class StacktraceView extends View + Subscriber.includeInto this + @content: (trace) -> - @div class: 'stacktrace tool-panel padded', => - @div class: 'header panel', => - @h2 trace.message + tclass = if trace.isActive() then 'activated' else '' + @div class: "stacktrace 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) + @subview 'frame', new FrameView frame, => trace.activate() initialize: (@trace) -> + @subscribe Stacktrace, 'active-changed', (e) => + if e.newTrace is @trace + @addClass 'activated' + else + @removeClass 'activated' + + beforeRemove: -> + @unsubscribe Stacktrace # Internal: Return the window title. + # 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. + # @registerIn: (workspace) -> workspace.registerOpener (filePath) -> trace = Stacktrace.forUrl(filePath) @@ -27,16 +56,35 @@ class StacktraceView extends View class FrameView extends View - @content: (frame) -> + @content: (frame, navCallback) -> @div class: 'frame inset-panel', => @div class: 'panel-heading', => + @span class: 'icon icon-fold inline-block', click: 'minimize' + @span class: 'icon icon-unfold inline-block', click: 'restore' @span class: 'function-name text-highlight inline-block', frame.functionName - @span class: 'source-location text-info inline-block pull-right', => - @text "#{frame.path} @ #{frame.lineNumber}" - @div class: 'panel-body padded', => - @pre output: 'source', 'Source goes here' + @span class: 'source-location text-info inline-block pull-right', click: 'navigate', => + @text "#{frame.rawPath} @ #{frame.lineNumber}" + @div class: 'panel-body padded', outlet: 'body', click: 'navigate', => + @subview 'source', new EditorView(mini: true) + + initialize: (@frame, @navCallback) -> + @frame.getContext 3, (err, lines) => + if err? + console.error err + else + @source.getEditor().setText lines.join("\n") + + navigate: -> + @navCallback() + @frame.navigateTo() + + minimize: -> + @addClass 'minimized' + @body.hide 'fast' - initialize: (@frame) -> + restore: -> + @removeClass 'minimized' + @body.show 'fast' module.exports = StacktraceView: StacktraceView diff --git a/lib/stacktrace.coffee b/lib/stacktrace.coffee index cfa23fe..413f38e 100644 --- a/lib/stacktrace.coffee +++ b/lib/stacktrace.coffee @@ -1,14 +1,23 @@ +fs = require 'fs' + +{Emitter} = require 'emissary' + jsSHA = require 'jssha' +{chomp} = require 'line-chomper' traceParser = null PREFIX = 'stacktrace://trace' REGISTRY = {} +ACTIVE = null # Internal: A heuristically parsed and interpreted stacktrace. # class Stacktrace + # Turn the Stacktrace class into an emitter. + Emitter.extend this + constructor: (@frames = [], @message = '') -> # Internal: Compute the SHA256 checksum of the normalized stacktrace. @@ -23,6 +32,11 @@ class Stacktrace # getUrl: -> @url ?= "#{PREFIX}/#{@getChecksum()}" + # Public: Determine whether or not this Stacktrace is the "active" one. The active Stacktrace is + # shown in a bottom navigation panel and highlighted in opened editors. + # + isActive: -> false + # Internal: Register this trace in a global map by its URL. # register: -> @@ -34,6 +48,22 @@ class Stacktrace unregister: -> delete REGISTRY[@getUrl()] + # Public: Mark this trace as the "active" one. The active trace is shown in the navigation view + # and its frames are given a marker in an open {EditorView}. + # + activate: -> + former = ACTIVE + ACTIVE = this + if former isnt ACTIVE + Stacktrace.emit 'active-changed', oldTrace: former, newTrace: ACTIVE + + # Public: Deactivate this trace if it's active. + # + deactivate: -> + if ACTIVE is this + ACTIVE = null + Stacktrace.emit 'active-changed', oldTrace: this, newTrace: null + # Public: Parse zero to many Stacktrace instances from a corpus of text. # # text - A raw blob of text. @@ -51,11 +81,43 @@ class Stacktrace @clearRegistry: -> REGISTRY = {} -# Internal: A single stack frame within a {Stacktrace}. + # Public: Retrieve the currently activated {Stacktrace}, or null if no trace is active. + # + @getActivated: -> ACTIVE + +# Public: A single stack frame within a {Stacktrace}. # class Frame - constructor: (@rawLine, @path, @lineNumber, @functionName) -> + constructor: (@rawLine, @rawPath, @lineNumber, @functionName) -> + @realPath = @rawPath + + # Public: Asynchronously collect n lines of context around the specified line number in this + # frame's source file. + # + # n - The number of lines of context to collect on *each* side of the error line. The error + # line will always be `lines[n]` and `lines.length` will be `n * 2 + 1`. + # callback - Invoked with any errors or an Array containing the relevant lines. + # + getContext: (n, callback) -> + # Notice that @lineNumber is one-indexed, not zero-indexed. + range = + fromLine: @lineNumber - n - 1 + toLine: @lineNumber + n + trim: false + keepLastEmptyLine: true + chomp fs.createReadStream(@realPath), range, callback + + navigateTo: -> + position = [@lineNumber - 1, 0] + promise = atom.workspace.open @realPath, initialLine: position[0] + promise.then (editor) -> + editor.setCursorBufferPosition position + for ev in atom.workspaceView.getEditorViews() + editorView = ev if ev.getEditor() is editor + if editorView? + editorView.scrollToBufferPosition position, center: true + module.exports = PREFIX: PREFIX diff --git a/package.json b/package.json index ee63d0d..794962d 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "atom": ">0.50.0" }, "dependencies": { - "jssha": "^1.5.0" + "emissary": "^1.2.1", + "jssha": "^1.5.0", + "line-chomper": "git+https://github.com/smashwilson/line-chomper.git#optional-trim" } } diff --git a/spec/fixtures/context.txt b/spec/fixtures/context.txt new file mode 100644 index 0000000..5a3ecb4 --- /dev/null +++ b/spec/fixtures/context.txt @@ -0,0 +1,10 @@ +one +two +three + four +five +six + +eight +nine +ten diff --git a/spec/stacktrace-spec.coffee b/spec/stacktrace-spec.coffee index 889de8c..f6cd82e 100644 --- a/spec/stacktrace-spec.coffee +++ b/spec/stacktrace-spec.coffee @@ -1,4 +1,6 @@ -{Stacktrace} = require '../lib/stacktrace' +path = require 'path' + +{Stacktrace, Frame} = require '../lib/stacktrace' {RUBY: {FUNCTION: TRACE}} = require './trace-fixtures' describe 'Stacktrace', -> @@ -26,7 +28,7 @@ describe 'Stacktrace', -> expect(trace.message).toBe('whoops (RuntimeError)') it 'parses file paths from each frame', -> - filePaths = (frame.path for frame in trace.frames) + filePaths = (frame.realPath for frame in trace.frames) expected = [ '/home/smash/samples/tracer/otherdir/file2.rb' '/home/smash/samples/tracer/dir/file1.rb' @@ -72,3 +74,54 @@ describe 'Stacktrace', -> expect(Stacktrace.forUrl(trace.getUrl())).toBe(trace) trace.unregister() expect(Stacktrace.forUrl(trace.getUrl())).toBeUndefined() + + describe 'activation', -> + afterEach -> + activated = Stacktrace.getActivated() + activated.deactivate() if activated? + Stacktrace.off 'active-changed' + + it 'can be activated', -> + trace.activate() + expect(Stacktrace.getActivated()).toBe(trace) + + it 'can be deactivated if activated', -> + trace.activate() + trace.deactivate() + expect(Stacktrace.getActivated()).toBeNull() + + it 'can be deactivated even if not activated', -> + trace.deactivate() + expect(Stacktrace.getActivated()).toBeNull() + + it 'broadcasts a "active-changed" event', -> + event = null + Stacktrace.on 'active-changed', (e) -> event = e + + trace.activate() + expect(event.oldTrace).toBeNull() + expect(event.newTrace).toBe(trace) + +describe 'Frame', -> + [frame] = [] + + beforeEach -> + fixturePath = path.join __dirname, 'fixtures', 'context.txt' + frame = new Frame('five', fixturePath, 5, 'something') + + it 'acquires n lines of context asynchronously', -> + lines = null + + frame.getContext 2, (err, ls) -> + throw err if err? + lines = ls + + waitsFor -> lines? + + runs -> + expect(lines.length).toBe(5) + expect(lines[0]).toEqual('three') + expect(lines[1]).toEqual(' four') + expect(lines[2]).toEqual('five') + expect(lines[3]).toEqual('six') + expect(lines[4]).toEqual('') diff --git a/spec/stacktrace-view-spec.coffee b/spec/stacktrace-view-spec.coffee index df48b26..c6895fd 100644 --- a/spec/stacktrace-view-spec.coffee +++ b/spec/stacktrace-view-spec.coffee @@ -2,7 +2,7 @@ {Stacktrace, Frame} = require '../lib/stacktrace' frames = [ - new Frame('raw0', 'bottom.rb', 12, 'botfunc', 'Boom') + new Frame('raw0', 'bottom.rb', 12, 'botfunc') new Frame('raw1', 'middle.rb', 42, 'midfunc') new Frame('raw2', 'top.rb', 37, 'topfunc') ] @@ -29,15 +29,31 @@ describe 'StacktraceView', -> trace.register() expect(opener(trace.getUrl()).trace).toBe(trace) - it 'shows the error message' - it 'renders a subview for each frame' + it 'shows the error message', -> + text = view.find('.error-message').text() + expect(text).toEqual('Boom') + + it 'renders a subview for each frame', -> + vs = view.find('.frame') + expect(vs.length).toBe(3) + + it 'changes its class when its trace is activated or deactivated', -> + Stacktrace.getActivated()?.deactivate() + expect(view.hasClass 'activated').toBe(false) + trace.activate() + expect(view.hasClass 'activated').toBe(true) describe 'FrameView', -> [view] = [] beforeEach -> - view = new FrameView(frames[1]) + view = new FrameView frames[1], -> + + it 'shows the filename and line number', -> + text = view.find('.source-location').text() + expect(text).toMatch(/middle\.rb/) + expect(text).toMatch(/42/) - it 'shows the filename' - it 'shows the line number' - it 'shows the function name' + it 'shows the function name', -> + text = view.find('.function-name').text() + expect(text).toEqual('midfunc') diff --git a/stylesheets/stacktrace.less b/stylesheets/stacktrace.less index 5bf0e72..282fe3c 100644 --- a/stylesheets/stacktrace.less +++ b/stylesheets/stacktrace.less @@ -8,4 +8,40 @@ &.enter-dialog .editor { height: 300px; } + + .frame .panel-heading { + font-size: 130%; + + .source-location:hover { + cursor: pointer; + text-decoration: underline; + } + } + + .frame { + margin-bottom: 10px; + + .icon-fold, .icon-unfold { + cursor: pointer; + } + + .icon-unfold { display: none; } + + &.minimized { + .icon-unfold { display: inline-block; } + .icon-fold { display: none; } + } + + .editor { + cursor: pointer; + } + } + + .deactivate-control { display: none; } + + &.activated { + .deactivate-control { display: block; } + .activate-control { display: none; } + } + }