diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..04ba039 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] + +# Change these settings to your own preference +indent_style = space +indent_size = 2 + +# We recommend you to keep these unchanged +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 04639df..0000000 --- a/.eslintrc +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "standard", - "parser": "babel-eslint", - "env": { - "browser": true, - "node": true, - "es6": true, - }, - "globals": { - "atom": true, - }, - "plugins": [ - "promise" - ], -} diff --git a/API.md b/API.md new file mode 100644 index 0000000..f81e4d5 --- /dev/null +++ b/API.md @@ -0,0 +1,284 @@ +Add the following to your `package.json`: + +```js +"consumedServices": { + "debug": { + "versions": { + "1.0.0": "consumeDebug" + } + } +} +``` + +The consumeDebug then receives the following object: + +```js +debugService = { + /** + * Adds a new debugger + * @param {string} name A unique name + * @param {Debugger} dbg See below the definition for this below + */ + addDebugger (name, dbg) { ... } + /** + * Removes a debugger + * @param {string} name The unique name of the debugger + */ + removeDebugger (name) { ... } + /** + * Adds a message to the output panel + * @param {string} name The unique name of the debugger the output message is for + * @param {string} message The message + */ + addOutputMessage (name, message) { ... } + /** + * Updates the existing configs for the specified debugger + * @param {string} name The unique name of the debugger the configs are for + * @param {Array.} configs The configs this debugger can start + */ + updateConfigs (name, configs) { ... } +} + +/** + * A config is used to start your debugger with different configurations. + * E.g. you might have a config to debug your program and a config to debug certain tests + * Note: this configs contain any number of properties but the "name" is the only one required + * @typedef {object} Config + * @property {string} name The unique name of the config + */ + +/** + * The "dbg" object passed to the "addDebugger" method + * @typedef {object} Debugger + */ +dbg = { + /** + * @type {Array.} A list of grammar scopes this debugger can debug + */ + scopes: [ ... ], + /** + * @type {Array.} A list of configs + */ + configs: [ { ... }, ... ] + /** + * @type {DebuggerAPI} The actual API that is called by this package in order to start and interact with this debugger + */ + api: { + ... // see below + } +} +``` + +The following methods have to be available in your API and should always return a Promise +The documentation for each method and it's return values is below + +```js +/** + * @typedef {object} DebuggerAPI + */ +dbgAPI = { + /** + * Starts your debugger with the selected config and file + * @param {StartRequest} + * @return {Promise} No return value required + */ + start({ config, file }) + + /** + * Stops the current debugging session + * @return {Promise} No return value required + */ + stop() + + /** + * Adds a new breakpoint for the passed in file and line + * @param {AddBreakpointRequest} + * @return {Promise} {@link AddBreakpointResponse} + */ + addBreakpoint({ file, line }) + + /** + * Removes the breakpoint + * @param {RemoveBreakpointRequest} + * @return {Promise} No return value required + */ + removeBreakpoint({ bp }) + + /** + * Resume the execution until the next breakpoint is hit (probably also know as "continue") + * @return {Promise} {@link NewStateResponse} + */ + resume() + + /** + * Step to the next line of code + * @return {Promise} {@link NewStateResponse} + */ + next() + + /** + * Step into the current function/instruction + * @return {Promise} {@link NewStateResponse} + */ + stepIn() + + /** + * Step out of the current function/instruction + * @return {Promise} {@link NewStateResponse} + */ + stepOut() + + /** TODO + * Restarts the debug session + * @return {Promise} No return value required + */ + restart() + + /** + * Selects a stacktrace entry + * @param {SelectStacktraceRequest} + * @return {Promise} No return value required + */ + selectStacktrace({ index }) + + /** + * Selects a thread entry + * @param {SelectThreadRequest} + * @return {Promise} No return value required + */ + selectThread({ id }) + + /** + * Gets the current stacktrace for the given "threadID" + * @param {GetStacktraceRequest} + * @return {Promise} {@link GetStacktraceResponse} + */ + getStacktrace({ threadID }) + + /** + * Gets the current threads + * @param {GetThreadsRequest} + * @return {Promise} {@link GetThreadsResponse} + */ + getThreads() + + /** + * Loads the children for a variable + * @param {LoadVariableRequest} + * @return {Promise} {@link LoadVariableResponse} + */ + loadVariable({ path, variable }) + --> { variables } +} + +/** + * The object passed to the "start" method + * @typedef {object} StartRequest + * @property {Config} config The selected config + * @property {string} file The currently open file + */ + +/** + * The object passed to the "addBreakpoint" method + * @typedef {object} AddBreakpointRequest + * @property {string} file The file + * @property {number} line The zero-based line number + */ + +/** + * The object returned by the Promise of the "addBreakpoint" method + * @typedef {object} AddBreakpointResponse + * @property {any} id An unique id/name for the breakpoint + */ + +/** + * The object passed to the "removeBreakpoint" method + * @typedef {object} RemoveBreakpointRequest + * @property {string} file The file + * @property {number} line The zero-based line number + * @property {any} id The unique id/name for the breakpoint + */ + +/** + * The object returned by a Promise that previously has called "resume", "next", "stepIn", "stepOut" + * @typedef {object} NewStateResponse + * @property {any} threadID The current thread id the debugger is waiting on + * @property {boolean} exited Whether the execution has finished (true) or not (false) + */ + +/** + * The object passed to the "selectStacktrace" method + * @typedef {object} SelectStacktraceRequest + * @property {number} index The index of the selected stacktrace + */ + +/** + * The object passed to the "selectThread" method + * @typedef {object} SelectThreadRequest + * @property {any} id The unique thread id + */ + +/** + * The object passed to the "getStacktrace" method + * @typedef {object} GetStacktraceRequest + * @property {any} threadID The unique thread id + */ + +/** + * The object returned by the Promise of the "getStacktrace" method + * @typedef {Array.} GetStacktraceResponse + */ + +/** + * A single entry in a stacktrace array + * @typedef {object} StacktraceEntry + * @property {string} file The file + * @property {string} line The zero-based line number + * @property {string} func The function name + * @property {Object.} variables The variables for this entry. It is a flat map of paths to variables. An entry with key "myMap.myKey" represents roughly this JSON { myMap: { myKey: } } + */ + +/** + * A variable + * @typedef {object} Variable + * @property {string} name The name of the variable + * @property {boolean} loaded Whether this children is loaded or not + * @property {boolean} hasChildren Whether this variable has children or not + * @property {any} value The actual value of this variable + * @property {string} parentPath The path of the parent variable it belongs to + * @property {Object.} variables The variables for this entry + */ + +/** + * The object passed to the "getThreads" method + * @typedef {object} GetThreadsRequest + * @property {any} threadID The unique thread id + */ + +/** + * The object returned by the Promise of the "getThreads" method + * @typedef {Array.} GetThreadsResponse + */ + +/** + * A thread + * @typedef {object} Thread + * @property {string} file The file + * @property {string} line The zero-based line number + * @property {string} func The function name + * @property {string} id The unique id/name of this thread + */ + + /** + * The object passed to the "loadVariable" method + * @typedef {object} LoadVariableRequest + * @property {string} path The path of the variable + * @property {Variable} variable The variable itself + */ + + /** + * The object returned by the Promise of the "loadVariable" method + * @typedef {Object.} LoadVariableResponse + */ + +``` diff --git a/README.md b/README.md index 5931e29..bd61976 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,19 @@ # `debug` [![Build Status](https://travis-ci.org/atom-community/debug.svg?branch=master)](https://travis-ci.org/atom-community/debug) [![Build status](https://ci.appveyor.com/api/projects/status/rwt6dhb945stnk48/branch/master?svg=true)](https://ci.appveyor.com/project/joefitzgerald/debug/branch/master) `debug` allows you to debug your code using debug providers. + +## How to add a new debugger + +[API.md](API.md) + +## Key bindings + +* `f5` runs the current package (`dlv debug`) +* `ctrl-f5` runs the current package tests (`dlv test`) +* `shift-f5` restarts the current delve session (`r / restart`) +* `f6` stops delve (`exit / quit / q`) +* `f8` continue the execution (`c / continue`) +* `f9` toggle breakpoint +* `f10` step over to next source line (`n / next`) +* `f11` step into functions (`s / step`) +* `cmd-k cmd-g` (mac) / `ctrl-k ctrl-g` (others) toggles the main panel diff --git a/keymaps/debug.json b/keymaps/debug.json new file mode 100644 index 0000000..734d549 --- /dev/null +++ b/keymaps/debug.json @@ -0,0 +1,18 @@ +{ + "atom-workspace": { + "f5": "debug:start", + "shift-f5": "debug:restart", + "f6": "debug:stop", + "f8": "debug:continue", + "f9": "debug:toggle-breakpoint", + "f10": "debug:next", + "f11": "debug:stepIn", + "shift-f11": "debug:stepOut" + }, + "body.platform-win32 atom-workspace, body.platform-linux atom-workspace": { + "ctrl-k ctrl-g": "debug:toggle-panel" + }, + "body.platform-darwin atom-workspace": { + "cmd-k cmd-g": "debug:toggle-panel" + } +} diff --git a/lib/breakpoints.jsx b/lib/breakpoints.jsx new file mode 100644 index 0000000..e12d965 --- /dev/null +++ b/lib/breakpoints.jsx @@ -0,0 +1,100 @@ +'use babel' + +import { React } from 'react-for-atom' +import { connect } from 'react-redux' + +import * as Debugger from './debugger' +import { getDebugger, getBreakpoints } from './store' +import { elementPropInHierarcy, shortenPath } from './utils' + +import * as fs from 'fs' + +const Breakpoints = (props) => { + const { breakpoints = [] } = props + const items = breakpoints.map(({ file, line, state, message }) => { + return
+ + + {shortenPath(file)}: + {line + 1} +
+ }) + return
+ {items} +
+} +Breakpoints.propTypes = { + breakpoints: React.PropTypes.array, + onBreakpointClick: React.PropTypes.func, + onRemoveBreakpointClick: React.PropTypes.func +} + +export default connect( + () => { + const dbg = getDebugger() + return { + breakpoints: dbg && dbg.breakpoints + } + }, + () => { + return { + onBreakpointClick (ev) { + const file = elementPropInHierarcy(ev.target, 'dataset.file') + if (file) { + const line = +elementPropInHierarcy(ev.target, 'dataset.line') + // check if the file even exists + fileExists(file) + .then(() => { + atom.workspace.open(file, { initialLine: line, searchAllPanes: true }).then(() => { + const editor = atom.workspace.getActiveTextEditor() + editor.scrollToBufferPosition([line, 0], { center: true }) + }) + }) + .catch(() => removeBreakpoints(file)) + } + }, + onRemoveBreakpointClick (ev) { + const file = elementPropInHierarcy(ev.target, 'dataset.file') + if (file) { + const line = +elementPropInHierarcy(ev.target, 'dataset.line') + Debugger.removeBreakpoint(getDebugger().name, file, line) + ev.preventDefault() + ev.stopPropagation() + } + } + } + } +)(Breakpoints) + +function fileExists (file) { + return new Promise(function (resolve, reject) { + fs.exists(file, (exists) => { + exists ? resolve() : reject() + }) + }) +} + +function removeBreakpoints (file) { + const noti = atom.notifications.addWarning( + `The file ${file} does not exist anymore.`, + { + dismissable: true, + detail: 'Remove all breakpoints for this file?', + buttons: [{ + text: 'Yes', + onDidClick: () => { + noti.dismiss() + getBreakpoints(file).forEach((bp) => Debugger.removeBreakpoint(getDebugger().name, file, bp.line)) + } + }, { + text: 'No', + onDidClick: () => noti.dismiss() + }] + } + ) +} diff --git a/lib/commands.js b/lib/commands.js new file mode 100644 index 0000000..3f63213 --- /dev/null +++ b/lib/commands.js @@ -0,0 +1,97 @@ +'use babel' + +import * as Debugger from './debugger' +import * as Editors from './editors' +import { store, getDebugger } from './store' + +function currentFile () { + const editor = atom.workspace.getActiveTextEditor() + return editor && editor.getPath() +} + +function currentLine () { + const editor = atom.workspace.getActiveTextEditor() + return editor && editor.getCursorBufferPosition().row +} + +function withDebugger (fn) { + return () => { + const dbg = getDebugger() + if (!dbg) { + return + } + fn(dbg) + } +} + +const commands = { + 'start': { + cmd: 'start', + text: 'Start', + title: 'Start this configuration', + action: withDebugger((dbg) => { + if (!dbg.selectedConfig) { + return // no config to start + } + Debugger.start(dbg.name, dbg.configs[dbg.selectedConfig], currentFile()) + }) + }, + 'resume': { + cmd: 'resume', + icon: 'triangle-right', + title: 'Resume', + action: () => Debugger.resume() + }, + 'next': { + cmd: 'next', + icon: 'arrow-right', + title: 'Next', + action: () => Debugger.next() + }, + 'stepIn': { + cmd: 'stepIn', + icon: 'arrow-down', + title: 'Step', + action: () => Debugger.stepIn() + }, + 'stepOut': { + cmd: 'stepOut', + icon: 'arrow-up', + title: 'Step', + action: () => Debugger.stepOut() + }, + 'restart': { + cmd: 'restart', + icon: 'sync', + title: 'Restart', + action: () => Debugger.restart() + }, + 'stop': { + cmd: 'stop', + icon: 'primitive-square', + title: 'Stop', + action: withDebugger((dbg) => Debugger.stop(dbg.name)) + }, + 'toggle-breakpoint': { + action: () => Editors.toggleBreakpoint(currentFile(), currentLine()) + }, + 'toggle-panel': { + action: () => store.dispatch({ type: 'TOGGLE_PANEL' }) + } +} + +export const keyboardCommands = {} + +const toAdd = ['start', 'resume', 'next', 'stepIn', 'stepOut', 'restart', 'stop', 'toggle-breakpoint'] +toAdd.forEach((cmd) => keyboardCommands['debug:' + cmd] = commands[cmd].action) + +export const panelCommands = [ + commands.resume, + commands.next, + commands.stepIn, + commands.stepOut, + commands.restart, + commands.stop +] + +export const get = (cmd) => commands[cmd] diff --git a/lib/debugger.js b/lib/debugger.js new file mode 100644 index 0000000..a94c26b --- /dev/null +++ b/lib/debugger.js @@ -0,0 +1,304 @@ +'use babel' + +import { store, getDebugger, getBreakpoint, getBreakpoints, addOutputMessage } from './store' + +/** + * Starts a new debugging session. + * @param {string} name The name of the debugger. + * @param {object} config The config used to start the debugger. + * @param {string} file The file to debug + * @return {Promise} + */ +export function start (name, config, file) { + const { api } = getDebugger(name) + + // show the panel if not visible yet + const panelState = store.getState().panel + if (!panelState.visible) { + store.dispatch({ type: 'TOGGLE_PANEL' }) + } + + store.dispatch({ type: 'SET_STATE', name: name, state: 'starting' }) + + // start the debugger + addOutputMessage('debug', `Starting debugger "${name}" with config "${config.name}"`) + + return api.start({ config, file }) + .then(() => { + addOutputMessage('debug', `Started debugger "${name}" with config "${config.name}"`) + store.dispatch({ type: 'SET_STATE', name: name, state: 'started' }) + + return Promise.all( + getBreakpoints(name).map((bp) => { + return addBreakpoint(name, bp.file, bp.line) + }) + ).then(() => { + // if !config.stopOnEntry + return resume() + }) + }) + .catch((err) => { + addOutputMessage('debug', `Failed to start debugger "${name}" with config "${config.name}"\r\n Error: ${err}`) + return stop() + }) +} + +/** + * Stops a debugging session. + * @param {string} name The name of the debugger. + * @return {Promise} + */ +export function stop (name) { + if (!isStarted(name)) { + return Promise.resolve() + } + const { name: dbgName, api } = getDebugger(name) + return api.stop().then(() => { + store && store.dispatch({ type: 'STOP', name: name || dbgName }) + }) +} + +/** + * Adds a new breakpoint to the given file and line + * @param {string} name The name of the debugger. + * @param {string} file + * @param {number} line + * @return {Promise} + */ +export function addBreakpoint (name, file, line) { + if (!isStarted()) { + store.dispatch({ type: 'ADD_BREAKPOINT', name, bp: { file, line, state: 'notStarted' } }) + return Promise.resolve() + } + + const bp = getBreakpoint(name, file, line) + if (bp && bp.state === 'busy') { + // already being added + return Promise.resolve() + } + + const fileAndLine = `${file}:${line + 1}` + addOutputMessage('debug', `Adding breakpoint @ ${fileAndLine}`) + store.dispatch({ type: 'ADD_BREAKPOINT', name, bp: { file, line, state: 'busy' } }) + return _addBreakpoint(name, file, line) + .then((response) => { + addOutputMessage('debug', `Added breakpoint @ ${fileAndLine}`) + store.dispatch({ type: 'ADD_BREAKPOINT', name, bp: { file, line, id: response.id, state: 'valid' } }) + }) + .catch((err) => { + addOutputMessage('debug', `Adding breakpoint @ ${fileAndLine} failed!\r\n Error: ${err}`) + store.dispatch({ type: 'ADD_BREAKPOINT', name, bp: { file, line, state: 'invalid', message: err } }) + }) +} +function _addBreakpoint (name, file, line) { + return getDebugger(name).api.addBreakpoint({ file, line }) +} + +/** + * Removes a breakpoint set on the given file and line + * @param {string} name The name of the debugger. + * @param {string} file + * @param {number} line + * @return {Promise} + */ +export function removeBreakpoint (name, file, line) { + const bp = getBreakpoint(name, file, line) + if (!bp) { + return Promise.resolve() + } + const { state } = bp + + function done () { + store.dispatch({ type: 'REMOVE_BREAKPOINT', name, bp: { file, line, state: 'removed' } }) + } + + if (state === 'invalid' || !isStarted()) { + return Promise.resolve().then(done) + } + + const fileAndLine = `${file}:${line + 1}` + addOutputMessage('debug', `Removing breakpoint @ ${fileAndLine}`) + store.dispatch({ type: 'REMOVE_BREAKPOINT', name, bp: { file, line, state: 'busy' } }) + return _removeBreakpoint(name, bp) + .then(() => addOutputMessage('debug', `Removed breakpoint @ ${fileAndLine}`)) + .then(done) + .catch((err) => { + addOutputMessage('debug', `Removing breakpoint @ ${fileAndLine} failed!\r\n Error: ${err}`) + store.dispatch({ type: 'REMOVE_BREAKPOINT', name, bp: { file, line, state: 'invalid', message: err } }) + }) +} +function _removeBreakpoint (name, bp) { + return getDebugger(name).api.removeBreakpoint({ bp }) +} + +/** + * Adds or removes a breakpoint for the given file and line. + * @param {string} name The name of the debugger. + * @param {string} file + * @param {number} line + * @return {Promise} + */ +export function toggleBreakpoint (name, file, line) { + const bp = store.getBreakpoint(name, file, line) + if (!bp) { + return addBreakpoint(name, file, line) + } + return removeBreakpoint(name, file, line) +} + +/** + * Resumes the current debugger. + * @return {Promise} + */ +export function resume () { + return getDebugger().api.resume().then(updateState) +} + +/** + * Step the current debugger to the next line. + * @return {Promise} + */ +export function next () { + return getDebugger().api.next().then(updateState) +} + +/** + * Step the current debugger into the current function/instruction. + * @return {Promise} + */ +export function stepIn () { + return getDebugger().api.stepIn().then(updateState) +} + +/** + * Step the current debugger out of the current function/instruction. + * @return {Promise} + */ +export function stepOut () { + return getDebugger().api.stepOut().then(updateState) +} + +function updateState (newState) { + if (newState.exited) { + return stop() + } + return getThreads() // get the new threads + .then(() => selectThread(newState.threadID)) // select the current thread + .then(() => selectStacktrace(0)) // reselect the first stacktrace entry +} + +/** + * Restarts the current debugger. + * @return {Promise} + */ +export function restart () { + if (!isStarted()) { + return Promise.resolve() + } + const { name, api } = getDebugger() + return api.restart().then(() => { + store.dispatch({ type: 'RESTART', name }) + // TODO add the breakpoints again? + // immediately start the execution (like "start" does) + resume() + }) +} + +/** + * Selects the given stacktrace of the current debugger. + * @param {number} index The selected index within the stacktrace + * @return {Promise} + */ +export function selectStacktrace (index) { + const dbg = getDebugger() + const { name, api } = dbg + if (dbg.selectedStacktrace === index) { + // no need to change + return Promise.resolve() + } + store.dispatch({ type: 'SET_SELECTED_STACKTRACE', state: 'busy', index }) + return api.selectStacktrace({ index }).then(() => { + store.dispatch({ type: 'SET_SELECTED_STACKTRACE', name, state: 'waiting', index }) + }) +} + +/** + * Selects the given thread of the current debugger. + * @param {string|number} id The id of the selected thread + * @return {Promise} + */ +export function selectThread (id) { + if (!isStarted()) { + return Promise.resolve() + } + const dbg = getDebugger() + const { name, api } = dbg + if (dbg.selectedThread === id) { + // no need to change + return getStacktrace(id) + } + store.dispatch({ type: 'SET_SELECTED_THREAD', name, state: 'busy', id }) + return api.selectThread({ id }).then(() => { + store.dispatch({ type: 'SET_SELECTED_THREAD', name, state: 'waiting', id }) + return getStacktrace(id) + }) +} + +function getStacktrace (threadID) { + if (!isStarted()) { + return Promise.resolve() + } + const { name, api } = getDebugger() + store.dispatch({ type: 'SET_STATE', name, state: 'busy' }) + return api.getStacktrace({ threadID }).then((stacktrace) => { + store.dispatch({ type: 'UPDATE_STACKTRACE', name, state: 'waiting', stacktrace }) + }) +} + +function getThreads () { + if (!isStarted()) { + return Promise.resolve() + } + const { name, api } = getDebugger() + store.dispatch({ type: 'SET_STATE', name, state: 'busy' }) + return api.getThreads().then((threads) => { + store.dispatch({ type: 'UPDATE_THREADS', name, state: 'waiting', threads }) + }) +} + +/** + * Loads the variables for the given path. + * @param {string} path The path of the variable to load + * @param {object} variable The variable + * @return {Promise} + */ +export function loadVariable (path, variable) { + const dbg = getDebugger() + const { name, api } = dbg + + store.dispatch({ type: 'SET_STATE', name, state: 'busy' }) + return api.loadVariable({ path, variable }).then((variables) => { + store.dispatch({ + type: 'UPDATE_VARIABLES', + // updating variable at this path ... + path, + // ... resulted in the following variables + variables, + name, + // add it to current selected stacktrace entry + stacktraceIndex: dbg.selectedStacktrace, + state: 'waiting' + }) + }) +} + +/** + * Returns `true` if the given debugger is started, `false` otherwise. + * @param {string} name The name of the debugger. + * @return {boolean} + */ +export function isStarted (name) { + const dbg = getDebugger(name) + const state = dbg ? dbg.state : 'notStarted' + return state !== 'notStarted' && state !== 'starting' +} diff --git a/lib/editors.js b/lib/editors.js new file mode 100644 index 0000000..9a97642 --- /dev/null +++ b/lib/editors.js @@ -0,0 +1,265 @@ +'use babel' + +import { CompositeDisposable } from 'atom' +import { store, indexOfBreakpoint, getBreakpoints, getDebugger, getDebuggers } from './store' +import * as Debugger from './debugger' +import { debounce } from './utils' + +let editors = {} + +function getDebuggerNameByScope (editor) { + const grammar = editor.getGrammar() + const debuggers = getDebuggers() + return Object.keys(debuggers).find((name) => { + const { scopes } = debuggers[name] + return scopes.includes(grammar.scopeName) + }) +} + +function updateEditor (editor) { + const file = editor.getPath() + if (!file) { + return null // ignore "new tabs", "settings", etc + } + + const name = getDebuggerNameByScope(editor) + + let e = editors[file] + if (!e) { + editors[file] = e = { + instance: editor, + markers: [] // contains the breakpoint markers + } + } + + e.name = name + if (name) { + if (!e.gutter) { + e.gutter = editor.addGutter({ name: 'debug', priority: -100 }) + const gutterView = atom.views.getView(e.gutter) + gutterView.addEventListener('click', onGutterClick.bind(null, e)) + } + } else { + destroyGutter(e) + } + + return e +} + +function observeTextEditors (editor) { + const e = updateEditor(editor) + if (!e || !e.name) { + return // no need to proceed + } + + updateMarkers(e, editor.getPath()) +} + +function onWillDestroyPaneItem ({ item: editor }) { + const file = editor && editor.getPath && editor.getPath() + if (file) { + destroyEditor(file) + delete editors[file] + } +} + +let lastStackID + +let lineMarker +const removeLineMarker = () => lineMarker && lineMarker.destroy() + +function openAndHighlight (stack) { + if (!stack) { + // not start, finished or just started -> no line marker visible + removeLineMarker() + lastStackID = 0 + return + } + + if (stack.id === lastStackID) { + return + } + lastStackID = stack.id + + // remove any previous line marker + removeLineMarker() + + // open the file + const line = stack.line + atom.workspace.open(stack.file, { initialLine: line, searchAllPanes: true }).then(() => { + // create a new marker + const editor = atom.workspace.getActiveTextEditor() + lineMarker = editor.markBufferPosition({ row: line }) + editor.decorateMarker(lineMarker, { type: 'line', class: 'debug-debug-line' }) + + // center the line + editor.scrollToBufferPosition([line, 0], { center: true }) + }) +} + +function updateMarkers (editor, file) { + const bps = getBreakpoints(editor.name, file) + + // update and add markers + bps.forEach((bp) => updateMarker(editor, bp)) + + // remove remaining + const removeFromEditor = (file) => { + const editorBps = editors[file] && editors[file].markers || [] + editorBps.forEach(({ bp }) => { + const index = indexOfBreakpoint(bps, bp.file, bp.line) + if (index === -1) { + removeMarker(editor, bp) + } + }) + } + if (file) { + removeFromEditor(file) + } else { + Object.keys(editors).forEach(removeFromEditor) + } +} + +function updateMarker (editor, bp) { + if (!editor) { + return // editor not visible, nothing to show + } + + const el = document.createElement('div') + el.className = 'debug-breakpoint debug-breakpoint-state-' + bp.state + el.dataset.state = bp.state + el.title = bp.message || '' + const decoration = { + class: 'debug-gutter-breakpoint', + item: el + } + + const marker = editor.markers.find(({ bp: markerBP }) => markerBP.line === bp.line) + if (!marker) { + // create a new decoration + const marker = editor.instance.markBufferPosition({ row: bp.line }) + marker.onDidChange(debounce(onMarkerDidChange.bind(null, { editor, file: bp.file, line: bp.line, marker }), 50)) + editor.markers.push({ + marker, + bp, + decoration: editor.gutter.decorateMarker(marker, decoration) + }) + } else { + // check if the breakpoint has even changed + if (marker.bp === bp) { + return + } + marker.bp = bp + + // update an existing decoration + marker.decoration.setProperties(Object.assign( + {}, + marker.decoration.getProperties(), + decoration + )) + } +} + +function removeMarker (editor, bp) { + const index = editor.markers.findIndex(({ bp: markerBP }) => markerBP.line === bp.line) + const marker = editor.markers[index] + if (marker) { + marker.decoration.getMarker().destroy() + } + editor.markers.splice(index, 1) +} + +function onMarkerDidChange ({ editor, file, line, marker }, event) { + // TODO: !! + if (!event.isValid) { + // marker is not valid anymore - text at marker got + // replaced or was removed -> remove the breakpoint + Debugger.removeBreakpoint(editor, file, line) + return + } + + Debugger.updateBreakpointLine(editor, file, line, marker.getStartBufferPosition().row) +} + +const debouncedStoreChange = debounce(() => { + Object.keys(editors).forEach((file) => { + updateEditor(editors[file].instance) + updateMarkers(editors[file], file) + }) + + // open the file of the selected stacktrace and highlight the current line + const dbg = getDebugger() + openAndHighlight(dbg && dbg.stacktrace[dbg.selectedStacktrace]) +}, 50) + +let subscriptions +export function init () { + subscriptions = new CompositeDisposable( + atom.workspace.observeTextEditors(observeTextEditors), + atom.workspace.onWillDestroyPaneItem(onWillDestroyPaneItem), + { dispose: store.subscribe(debouncedStoreChange) } + ) +} +export function dispose () { + Object.keys(editors).forEach(destroyEditor) + editors = {} + + removeLineMarker() + lineMarker = null + + subscriptions.dispose() + subscriptions = null +} + +function destroyEditor (file) { + const editor = editors[file] + if (!editor) { + return + } + + destroyGutter(editor) +} + +function destroyGutter (editor) { + if (!editor.gutter) { + return + } + + try { + editor.gutter.destroy() + } catch (e) { + console.warn('debug', e) + } + + // remove all breakpoint decorations (marker) + editor.markers.forEach((marker) => marker.decoration.getMarker().destroy()) +} + +function onGutterClick (editor, ev) { + const editorView = atom.views.getView(editor.instance) + let { row: line } = editorView.component.screenPositionForMouseEvent(ev) + line = editor.instance.bufferRowForScreenRow(line) + + // TODO: conditions via right click menu! + + const file = editor.instance.getPath() + _toggleBreakpoint(editor, file, line) +} + +export function toggleBreakpoint (file, line) { + const editor = editors[file] + if (!editor) { + return + } + _toggleBreakpoint(editor, file, line) +} + +function _toggleBreakpoint (editor, file, line) { + const deco = editor.markers.find(({ bp }) => bp.line === line) + if (deco) { + Debugger.removeBreakpoint(editor.name, file, line) + return + } + + Debugger.addBreakpoint(editor.name, file, line) +} diff --git a/lib/main.js b/lib/main.js index e14a431..ac8ceb8 100644 --- a/lib/main.js +++ b/lib/main.js @@ -1,18 +1,89 @@ 'use babel' -import {CompositeDisposable} from 'atom' +import { CompositeDisposable } from 'atom' + +let subscriptions +let editors, output, panel, store, commands +let Debugger export default { - subscriptions: null, + activate (state) { + store = require('./store') + store.init(state) + this.start() + }, + deactivate () { + if (Debugger) { + // stop all debuggers + const debuggers = store.getDebuggers() + Object.keys(debuggers).forEach((name) => Debugger.stop(name)) + } - activate () { - this.subscriptions = new CompositeDisposable() + if (subscriptions) { + subscriptions.dispose() + subscriptions = null + } + }, + serialize () { + return store.serialize() }, - deactivate () { - if (this.subscriptions) { - this.subscriptions.dispose() + provide () { + return { + /** + * Adds a new debugger + * @param {string} name A unique name + * @param {object} dbg + */ + addDebugger (name, dbg) { + store.store.dispatch({ type: 'ADD_DEBUGGER', name, dbg }) + }, + /** + * Removes a debugger + * @param {string} name The unqiue name of the debugger + */ + removeDebugger (name) { + store.store && store.store.dispatch({ type: 'REMOVE_DEBUGGER', name }) + }, + /** + * Adds a message to the output panel + * @param {string} name The unqiue name of the debugger the output message is for + * @param {string} message The message + */ + addOutputMessage (name, message) { + store.addOutputMessage(name, message) + }, + /** + * Updates the existing configs for the specified debugger + * @param {string} name The unqiue name of the debugger the configs are for + * @param {object} configs The configs + */ + updateConfigs (name, configs) { + store.updateConfigs(name, configs) + } } - this.subscriptions = null + }, + + start () { + Debugger = require('./debugger') + commands = require('./commands') + editors = require('./editors') + panel = require('./panel.jsx') + output = require('./output.jsx') + + panel.init() + editors.init() + output.init() + + subscriptions = new CompositeDisposable( + atom.commands.add('atom-text-editor', commands.keyboardCommands), + atom.commands.add('atom-workspace', { + 'debug:toggle-panel': commands.get('toggle-panel').action + }), + store, + editors, + panel, + output + ) } } diff --git a/lib/output-message.js b/lib/output-message.js new file mode 100644 index 0000000..9bcdabc --- /dev/null +++ b/lib/output-message.js @@ -0,0 +1,207 @@ +'use babel' + +import { React } from 'react-for-atom' + +function convert (text) { + const root = { tag: 'span', style: null, children: [] } + let el = root + + const colors = [ + 'black', 'darkred', 'darkgreen', 'yellow', 'darkblue', 'purple', 'darkcyan', 'lightgray', + 'gray', 'red', 'green', 'rgb(255, 255, 224)', 'blue', 'magenta', 'cyan', 'white' + ] + + function add (tag, style) { + const newEl = { tag, style, children: [], parent: el } + el.children.push(newEl) + el = newEl + } + + function close (tag) { + if (tag !== el.tag) { + throw new Error('tried to close ' + tag + ' but was ' + el.tag) + } + el = el.parent + } + + function addFGColor (code) { + add('span', { color: colors[code] }) + } + + function addBGColor (code) { + add('span', { backgroundColor: colors[code] }) + } + + function processCode (code) { + if (code === 0) { + // reset + el = root + } + if (code === 1) { + add('b') + } + if (code === 2) { + // TODO? + } + if (code === 4) { + add('u') + } + if ((code > 4 && code < 7)) { + add('blink') + } + if (code === 7) { + // TODO: fg = bg and bg = fg + } + if (code === 8) { + // conceal - hide... + add('span', 'display: none') + } + if (code === 9) { + add('strike') + } + if (code === 10) { + // TODO: default? + } + if (code > 10 && code < 20) { + // TODO: different fonts? + } + if (code === 20) { + // TODO: fraktur ??? + } + if (code === 21) { + if (el.tag === 'b') { + // bold off + close('b') + } else { + // double underline TODO: use border-bottom? + } + } + if (code === 24) { + close('u') + } + if (code === 25) { + close('blink') + } + if (code === 26) { + // "reserved" + } + if (code === 27) { + // image positive = opposite of code 7 -> fg = fg and bg = bg + } + if (code === 28) { + close('span') + } + if (code === 29) { + close('strike') + } + if (code > 29 && code < 38) { + addFGColor(code - 30) + } + if (code === 38) { + // extended FG color (rgb) + } + if (code === 39) { + // TODO: reset FG + el = root + } + if (code > 39 && code < 48) { + addBGColor(code - 40) + } + if (code === 48) { + // extended BG color (rgb) + } + if (code === 49) { + // TODO: reset BG + el = root + } + if (code > 89 && code < 98) { + addFGColor(code - 90 + 8) + } + if (code > 99 && code < 108) { + addBGColor(code - 100 + 8) + } + } + + const tokens = [ + { + // characters to remove completely + pattern: /^\x08+/, + replacer: () => '' + }, + { + // replaces the new lines + pattern: /^\n+/, + replacer: function newline () { + el.children.push({ tag: 'br' }) + return '' + } + }, + { + // ansi codes + pattern: /^\x1b\[((?:\d{1,3};?)+|)m/, + replacer: (m, group) => { + if (group.trim().length === 0) { + group = '0' + } + group.trimRight(';').split(';').forEach((code) => { + processCode(+code) + }) + return '' + } + }, + { + // malformed sequences + pattern: /^\x1b\[?[\d;]{0,3}/, + replacer: () => '' + }, + { + // catch everything except escape codes and new lines + pattern: /^([^\x1b\x08\n]+)/, + replacer: (text) => { + el.children.push(text) + return '' + } + } + ] + + // replace " " which sometimes gets encoded using codes 194 and 160... + text = text.replace(/\u00C2([\u00A0-\u00BF])/g, '$1') + + let length = text.length + while (length > 0) { + for (var i = 0; i < tokens.length; i++) { + const handler = tokens[i] + const matches = text.match(handler.pattern) + if (matches) { + text = text.replace(handler.pattern, handler.replacer) + break + } + } + if (text.length === length) { + break + } + length = text.length + } + return root +} + +export default class Message extends React.Component { + shouldComponentUpdate (nextProps) { + return nextProps.message !== this.props.message + } + render () { + const result = convert(this.props.message) + + function create (el, i) { + if (typeof el === 'string') { + return React.createElement('span', { key: i }, el) + } + const children = el.children ? el.children.map(create) : null + return React.createElement(el.tag, { key: i, style: el.style }, children) + } + return React.createElement('div', null, create(result, 0)) + } +} +Message.propTypes = { + message: React.PropTypes.object +} diff --git a/lib/output.jsx b/lib/output.jsx new file mode 100644 index 0000000..5e18f97 --- /dev/null +++ b/lib/output.jsx @@ -0,0 +1,110 @@ +'use babel' + +import { CompositeDisposable } from 'atom' +import { React, ReactDOM } from 'react-for-atom' +import { Provider, connect } from 'react-redux' + +import { store } from './store' +import Message from './output-message' + +// const filterText = (t) => t[0].toUpperCase() + t.substr(1) + +class Output extends React.Component { + componentDidUpdate () { + this.refs.list.scrollTop = this.refs.list.scrollHeight + } + render () { + // const { filters } = this.props + const items = this.props.messages + // .filter((msg) => filters[msg.name] !== false) + .map((msg, i) => { + return + }) + /* TODO: add a "settings" button that opens a popup to toggle the filters + const filterKeys = Object.keys(filters).sort() +
+ {filterKeys.map((filter) => + + )} +
*/ + return
+
+
Output messages
+ + +
+
+ {items} +
+
+ } +} +Output.propTypes = { + messages: React.PropTypes.array, + onCleanClick: React.PropTypes.func, + onCloseClick: React.PropTypes.func +} + +const OutputListener = connect( + (state) => { + return state.output + }, + (dispatch) => { + return { + onCleanClick () { + dispatch({ type: 'CLEAN_OUTPUT' }) + }, + onCloseClick () { + dispatch({ type: 'TOGGLE_OUTPUT', visible: false }) + }, + onFilterClick (ev) { + const { filter } = ev.target.dataset + if (filter) { + dispatch({ type: 'TOGGLE_OUTPUT_FILTER', filter }) + } + } + } + } +)(Output) + +let atomPanel + +function onStoreChange () { + const outputState = store.getState().output + if (outputState.visible !== atomPanel.isVisible()) { + atomPanel[outputState.visible ? 'show' : 'hide']() + } +} + +let subscriptions +export default { + init () { + subscriptions = new CompositeDisposable( + { dispose: store.subscribe(onStoreChange) } + ) + + const item = document.createElement('div') + atomPanel = atom.workspace.addBottomPanel({ item, visible: false }) + + ReactDOM.render( + + + , + item + ) + }, + dispose () { + subscriptions.dispose() + subscriptions = null + + ReactDOM.unmountComponentAtNode(atomPanel.getItem()) + + atomPanel.destroy() + atomPanel = null + } +} diff --git a/lib/panel.jsx b/lib/panel.jsx new file mode 100644 index 0000000..9d01f78 --- /dev/null +++ b/lib/panel.jsx @@ -0,0 +1,288 @@ +'use babel' + +import { CompositeDisposable } from 'atom' +import { React, ReactDOM } from 'react-for-atom' +import { Provider, connect } from 'react-redux' + +import Breakpoints from './breakpoints.jsx' +import Stacktrace from './stacktrace.jsx' +import Threads from './threads.jsx' +import Variables from './variables.jsx' + +import { elementPropInHierarcy } from './utils' +import { store, getDebugger, getDebuggers } from './store' +import * as Debugger from './debugger' +import * as Commands from './commands' + +class Panel extends React.Component { + constructor (props) { + super(props) + + ;[ + 'onResizeStart', 'onResize', 'onResizeEnd', 'onCommandClick', + 'onSelectDebugger', 'onSelectConfig', 'onStartConfig' + ].forEach((fn) => this[fn] = this[fn].bind(this)) + + this.state = { + expanded: { + stacktrace: true, + threads: true, + variables: true, + breakpoints: true + } + } + } + + render () { + return
+
+ {this.renderHeader()} + {this.renderContent()} + +
+ } + + renderHeader () { + return
+ {this.renderDebuggers()} + {this.renderConfigsOrCommands()} +
+ } + renderDebuggers () { + const empty = + const debuggers = Object.keys(this.props.debuggers) + .sort() + .map((name) => ) + return
+ +
+ } + renderConfigsOrCommands () { + if (Debugger.isStarted()) { + return this.renderCommands() + } + return this.renderConfigs() + } + renderConfigs () { + let configs = this.getConfigs() + const hasConfigs = configs && configs.length + if (hasConfigs) { + configs = [].concat( + configs.map(({ name }) => ) + ) + } else { + configs = + } + return
+ + {hasConfigs && this.props.selectedConfig + ? + : null} +
+ } + renderCommands () { + if (Debugger.isStarted()) { + const layout = Commands.panelCommands + return
+ {layout.map(this.renderCommand, this)} +
+ } + return null + } + renderCommand (cmd) { + return + } + + renderContent () { + return
+ {this.renderExpandable('stacktrace', 'Stacktrace', )} + {this.renderExpandable('threads', 'Threads', )} + {this.renderExpandable('variables', 'Variables', )} + {this.renderExpandable('breakpoints', 'Breakpoints', )} +
+ } + + renderExpandable (name, text, content) { + const expanded = this.state.expanded[name] + return
+
+ + {text} +
+
+ {content} +
+
+ } + + getConfigs () { + const dbg = (this.props.debuggers || [])[this.props.selectedDebugger] + if (!dbg) { + return dbg + } + return dbg.configs || [] + } + + onResizeStart () { + document.addEventListener('mousemove', this.onResize, false) + document.addEventListener('mouseup', this.onResizeEnd, false) + this.setState({ resizing: true }) + } + onResize ({ pageX }) { + if (!this.state.resizing) { + return + } + const node = ReactDOM.findDOMNode(this).offsetParent + this.props.onUpdateWidth(node.getBoundingClientRect().width + node.offsetLeft - pageX) + } + onResizeEnd () { + if (!this.state.resizing) { + return + } + document.removeEventListener('mousemove', this.onResize, false) + document.removeEventListener('mouseup', this.onResizeEnd, false) + this.setState({ resizing: false }) + } + + onExpandChange (name) { + this.state.expanded[name] = !this.state.expanded[name] + this.setState(this.state) + } + + onSelectDebugger (ev) { + this.props.onSelectDebugger(ev.target.value) + } + onSelectConfig (ev) { + this.props.onSelectConfig(this.props.selectedDebugger, ev.target.value) + } + + onStartConfig () { + const configs = this.getConfigs() + const config = configs.find(({ name }) => name === this.props.selectedConfig) + const editor = atom.workspace.getActiveTextEditor() + const file = editor && editor.getPath() + this.props.onStartConfig(this.props.selectedDebugger, config, file) + } + + onCommandClick (ev) { + const command = elementPropInHierarcy(ev.target, 'dataset.cmd') + if (command) { + this.props.onCommandClick(command) + } + } +} +Panel.propTypes = { + width: React.PropTypes.number, + onToggleOutput: React.PropTypes.func, + debuggers: React.PropTypes.object, + selectedDebugger: React.PropTypes.string, + selectedConfig: React.PropTypes.string, + onUpdateWidth: React.PropTypes.func, + onSelectDebugger: React.PropTypes.func, + onSelectConfig: React.PropTypes.func, + onStartConfig: React.PropTypes.func, + onCommandClick: React.PropTypes.func +} + +const PanelListener = connect( + (state) => { + const dbg = getDebugger() + return { + debuggers: getDebuggers(), + selectedDebugger: state.selectedDebugger, + width: state.panel.width, + selectedConfig: dbg && dbg.selectedConfig || '', + state: dbg && dbg.state + } + }, + (dispatch) => { + return { + onToggleOutput: () => { + dispatch({ type: 'TOGGLE_OUTPUT' }) + }, + onUpdateWidth: (width) => { + dispatch({ type: 'SET_PANEL_WIDTH', width }) + }, + onSelectDebugger: (name) => { + dispatch({ type: 'SET_SELECTED_DEBUGGER', name }) + }, + onSelectConfig: (name, configName) => { + dispatch({ type: 'SET_SELECTED_CONFIG', name, configName }) + }, + onStartConfig (name, config, file) { + Debugger.start(name, config, file) + }, + onCommandClick (command) { + const cmd = Commands.execute(command) + if (cmd) { + cmd.action() + } + } + } + } +)(Panel) + +let atomPanel + +function onStoreChange () { + const panelState = store.getState().panel + if (panelState.visible !== atomPanel.isVisible()) { + atomPanel[panelState.visible ? 'show' : 'hide']() + } +} + +let subscriptions +export default { + init () { + subscriptions = new CompositeDisposable( + { dispose: store.subscribe(onStoreChange) } + ) + + const item = document.createElement('div') + item.className = 'debug-panel' + atomPanel = atom.workspace.addRightPanel({ item, visible: store.getState().panel.visible }) + + ReactDOM.render( + + + , + item + ) + }, + dispose () { + subscriptions.dispose() + subscriptions = null + + ReactDOM.unmountComponentAtNode(atomPanel.getItem()) + + atomPanel.destroy() + atomPanel = null + } +} diff --git a/lib/stacktrace.jsx b/lib/stacktrace.jsx new file mode 100644 index 0000000..f154676 --- /dev/null +++ b/lib/stacktrace.jsx @@ -0,0 +1,58 @@ +'use babel' + +import { React } from 'react-for-atom' +import { connect } from 'react-redux' + +import * as Debugger from './debugger' +import { getDebugger } from './store' +import { elementPropInHierarcy, shortenPath } from './utils' + +const Stacktrace = (props) => { + const { selectedStacktrace, stacktrace = [] } = props + const items = stacktrace.map((st, index) => { + const className = selectedStacktrace === index ? 'selected' : null + const file = shortenPath(st.file) + return
+
+ {st.func} +
+
+ @ + {file}: + {st.line + 1} +
+
+ }) + return
+ {items} +
+} +Stacktrace.propTypes = { + selectedStacktrace: React.PropTypes.number, + stacktrace: React.PropTypes.array, + onStacktraceClick: React.PropTypes.func +} + +export default connect( + () => { + const dbg = getDebugger() + return { + stacktrace: dbg && dbg.stacktrace, + selectedStacktrace: dbg && dbg.selectedStacktrace + } + }, + () => { + return { + onStacktraceClick: (ev) => { + const index = elementPropInHierarcy(ev.target, 'dataset.index') + if (index) { + Debugger.selectStacktrace(+index) + } + } + } + } +)(Stacktrace) diff --git a/lib/store.js b/lib/store.js new file mode 100644 index 0000000..07f975a --- /dev/null +++ b/lib/store.js @@ -0,0 +1,379 @@ +'use babel' + +import { createStore, combineReducers } from 'redux' + +const assign = (...items) => Object.assign.apply(Object, [{}].concat(items)) + +function updateArrayItem (array, index, o) { + return array.slice(0, index).concat( + assign(array[index], o), + array.slice(index + 1) + ) +} + +function stacktrace (state = [], action) { + switch (action.type) { + case 'RESTART': + case 'STOP': + return [] + + case 'UPDATE_STACKTRACE': + // attempt to copy the variables over to the new stacktrace + return action.stacktrace.map((stack) => { + const existingStack = state.find((st) => st.id === stack.id) + if (!stack.variables && existingStack) { + stack.variables = existingStack.variables + } + return stack + }) + + case 'UPDATE_VARIABLES': + var variables = state[action.stacktraceIndex].variables + if (action.path) { + // update the variable at "path" to loaded + variables = assign(variables, { + [action.path]: assign(variables[action.path], { loaded: true }) + }) + } + + variables = assign(variables, action.variables) + return updateArrayItem(state, action.stacktraceIndex, { variables: variables }) + } + return state +} +function threads (state = [], action) { + switch (action.type) { + case 'RESTART': + case 'STOP': + return [] + + case 'UPDATE_THREADS': + return action.threads || state + } + return state +} +function breakpoints (state = [], action) { + const { bp } = action + const { file, line } = bp || {} + const index = indexOfBreakpoint(state, file, line) + switch (action.type) { + case 'ADD_BREAKPOINT': + if (index === -1) { + return state.concat(bp).sort((a, b) => { + const s = a.file.localeCompare(b.file) + return s !== 0 ? s : (a.line - b.line) + }) + } + return updateArrayItem(state, index, bp) + + case 'REMOVE_BREAKPOINT': + if (bp.state !== 'busy') { + return index === -1 ? state : state.slice(0, index).concat(state.slice(index + 1)) + } + return updateArrayItem(state, index, bp) + + case 'UPDATE_BREAKPOINT_LINE': + if (index !== -1) { + return updateArrayItem(state, index, { line: action.newLine }) + } + return state + + case 'STOP': + return state.map(({ file, line }) => { + return { file, line, state: 'notStarted' } + }) + + case 'INIT_STORE': + return state.map((bp) => { + return assign(bp, { state: 'notStarted' }) + }) + } + + return state +} +function state (state = 'notStarted', action) { + switch (action.type) { + case 'STOP': + return 'notStarted' + + case 'RESTART': + return 'started' + + case 'SET_STATE': + return action.state + + case 'SET_SELECTED_THREAD': + return action.state + } + return state +} +function selectedStacktrace (state = 0, action) { + switch (action.type) { + case 'RESTART': + case 'STOP': + return 0 + + case 'SET_SELECTED_STACKTRACE': + return action.index + + case 'UPDATE_STACKTRACE': + return 0 // set back to the first function on each update + } + return state +} +function selectedThread (state = 0, action) { + switch (action.type) { + case 'RESTART': + case 'STOP': + return 0 + + case 'SET_SELECTED_THREAD': + return action.id + } + return state +} +function selectedConfig (state = '', action) { + switch (action.type) { + case 'SET_SELECTED_CONFIG': + return action.configName + } + return state +} +function name (state = '', action) { + if (action.type === 'ADD_DEBUGGER') { + return action.name + } + return state +} +function configs (state = [], action) { + if (action.type === 'ADD_DEBUGGER') { + return action.dbg.configs || [] + } + if (action.type === 'UPDATE_CONFIGS') { + return action.configs || [] + } + return state +} +function scopes (state = [], action) { + if (action.type === 'ADD_DEBUGGER') { + return action.dbg.scopes || [] + } + return state +} +function api (state = null, action) { + switch (action.type) { + case 'ADD_DEBUGGER': + return action.dbg.api || state + + case 'REMOVE_DEBUGGER': + return null + } + return state +} + +const provider = combineReducers({ + stacktrace, + threads, + breakpoints, + state, + selectedStacktrace, + selectedThread, + selectedConfig, + name, + scopes, + configs, + api +}) + +function debuggers (state = { }, action) { + switch (action.type) { + case 'ADD_DEBUGGER': + var ap = provider(state[action.name] || {}, { type: 'ADD_DEBUGGER', name: action.name, dbg: action.dbg }) + return assign(state, { [action.name]: ap }) + case 'REMOVE_DEBUGGER': + var existing = state[action.name] + if (!existing) { + return state + } + var rp = provider(existing, { type: 'REMOVE_DEBUGGER' }) + return assign(state, { [action.name]: rp }) + + case 'ADD_BREAKPOINT': + case 'REMOVE_BREAKPOINT': + case 'SET_SELECTED_CONFIG': + case 'SET_SELECTED_STACKTRACE': + case 'SET_SELECTED_THREAD': + case 'UPDATE_STACKTRACE': + case 'UPDATE_THREADS': + case 'UPDATE_VARIABLES': + case 'UPDATE_CONFIGS': + case 'SET_STATE': + case 'STOP': + case 'RESTART': + return assign(state, { [action.name]: provider(state[action.name], action) }) + + case 'INIT_STORE': + var nextState = {} + var hasChanged = false + Object.keys(state).forEach((name) => { + const nextDbgState = provider(state[name], { type: 'INIT_STORE' }) + nextState[name] = nextDbgState + hasChanged = hasChanged || state[name] !== nextDbgState + }) + return hasChanged ? nextState : state + } + return state +} +function selectedDebugger (state = '', action) { + if (action.type === 'SET_SELECTED_DEBUGGER') { + return action.name + } + if (action.type === 'REMOVE_DEBUGGER' && state === action.name) { + return '' + } + return state +} + +const getDefaultPanel = () => { + return { visible: atom.config.get('debug.panelInitialVisible') } +} +function panel (state = getDefaultPanel(), action) { + switch (action.type) { + case 'TOGGLE_PANEL': + return assign(state, { visible: 'visible' in action ? action.visible : !state.visible }) + + case 'SET_PANEL_WIDTH': + return assign(state, { width: action.width }) + + case 'INIT_STORE': + // ensure the panel has a usable "visible" prop! + if (typeof state.visible !== 'boolean') { + return assign(state, getDefaultPanel()) + } + return state + } + return state +} +const defaultOutput = { + messages: [], + visible: false, + filters: { debug: true, output: true } +} +function output (state = defaultOutput, action) { + switch (action.type) { + case 'TOGGLE_OUTPUT': + return assign(state, { visible: 'visible' in action ? action.visible : !state.visible }) + + case 'CLEAN_OUTPUT': + return assign(state, { messages: [] }) + + case 'ADD_OUTPUT_MESSAGE': { + const messages = state.messages.concat({ message: action.message, name: action.name }) + return assign(state, { messages: messages }) + } + case 'TOGGLE_OUTPUT_FILTER': + return assign(state, { + filters: assign(state.filters, { + [action.filter]: !(state.filters[action.filter] !== false) + }) + }) + } + return state +} +function variables (state = { expanded: {} }, action) { + switch (action.type) { + case 'TOGGLE_VARIABLE': + var expanded = assign(state.expanded, { + [action.path]: 'expanded' in action ? action.expanded : !state.expanded[action.path] + }) + return assign(state, { expanded }) + } + return state +} + +export let store + +export function init (state) { + store = createStore(combineReducers({ + panel, + debuggers, + selectedDebugger, + output, + variables + }), state) + + // init the store (upgrades the previous state so it is usable again) + store.dispatch({ type: 'INIT_STORE' }) +} + +export function dispose () { + store = null +} + +export function serialize () { + const state = store.getState() + const mapBP = ({ file, line }) => { + return { file, line } + } + const debuggers = {} + Object.keys(state.debuggers).filter((n) => n).forEach((name) => { + const dbg = state.debuggers[name] + debuggers[dbg.name] = { + breakpoints: dbg.breakpoints.map(mapBP), + name: dbg.name + } + }) + return { + panel: state.panel, + debuggers: debuggers + } +} + +// helpers + +export function getDebuggers () { + const { debuggers } = store.getState() + const dbgs = {} + Object.keys(debuggers).forEach((n) => { + const dbg = debuggers[n] + if (dbg.api) { + dbgs[n] = dbg + } + }) + return dbgs +} +export function getDebugger (name) { + if (!store) { + return null + } + const { selectedDebugger } = store.getState() + if (!name) { + name = selectedDebugger + } + const debuggers = getDebuggers() + return debuggers[name] +} + +export function indexOfBreakpoint (breakpoints, file, line) { + return breakpoints.findIndex((bp) => bp.file === file && bp.line === line) +} +export function getBreakpoints (name, file) { + const { breakpoints = [] } = getDebugger(name) || {} + return !file ? breakpoints : breakpoints.filter((bp) => bp.file === file) +} +export function getBreakpoint (name, file, line) { + const breakpoints = getBreakpoints(name, file) + const index = indexOfBreakpoint(breakpoints, file, line) + return index === -1 ? null : breakpoints[index] +} + +export function addOutputMessage (name, message) { + if (!store) { + return + } + store.dispatch({ type: 'ADD_OUTPUT_MESSAGE', name, message }) +} + +export function updateConfigs (name, configs) { + store.dispatch({ type: 'UPDATE_CONFIGS', name, configs }) +} diff --git a/lib/threads.jsx b/lib/threads.jsx new file mode 100644 index 0000000..be6755c --- /dev/null +++ b/lib/threads.jsx @@ -0,0 +1,59 @@ +'use babel' + +import { React } from 'react-for-atom' +import { connect } from 'react-redux' + +import * as Debugger from './debugger' +import { getDebugger } from './store' +import { elementPropInHierarcy, shortenPath } from './utils' + +const Threads = (props) => { + const { selectedThread, threads = [] } = props + const items = threads.map((t) => { + const className = selectedThread === t.id ? 'selected' : null + const file = shortenPath(t.file) + return
+
+ {t.func} +
+
+ @ + {file}: + {t.line + 1} +
+
+ }) + return
+ {items} +
+} + +Threads.propTypes = { + selectedThread: React.PropTypes.number, + threads: React.PropTypes.array, + onThreadClick: React.PropTypes.func +} + +export default connect( + () => { + const dbg = getDebugger() + return { + threads: dbg && dbg.threads, + selectedThread: dbg && dbg.selectedThread + } + }, + () => { + return { + onThreadClick: (ev) => { + const id = elementPropInHierarcy(ev.target, 'dataset.id') + if (id) { + Debugger.selectThread(+id) + } + } + } + } +)(Threads) diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..569cac3 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,53 @@ +'use babel' + +import path from 'path' + +const REGEX_TO_INDEX = /\[[\"\']?(\w+)[\"\']?\]/g +const REGEX_LEADING_DOT = /^\./ +export function getDeep (o, path) { + path = path + // convert indexes to properties (like a["b"]['c'][0]) + .replace(REGEX_TO_INDEX, '.$1') + // strip a leading dot (as it might occur because of the previous replace) + .replace(REGEX_LEADING_DOT, '') + .split('.') + + var obj = o + while (obj && path.length) { + var n = path.shift() + obj = obj[n] + } + return obj +} + +export function elementPropInHierarcy (element, prop) { + const el = eachElementInHierarchy(element, (el) => getDeep(el, prop) !== undefined) + return getDeep(el, prop) +} + +export function eachElementInHierarchy (element, fn) { + while (element && !fn(element)) { + element = element.parentElement + } + return element +} + +export function debounce (func, wait) { + if (!wait) { + return func + } + let timeout + return function () { + const context = this + const args = arguments + clearTimeout(timeout) + timeout = setTimeout(() => { + timeout = null + func.apply(context, args) + }, wait) + } +} + +export function shortenPath (file) { + return path.normalize(file).split(path.sep).slice(-2).join(path.sep) +} diff --git a/lib/variables.jsx b/lib/variables.jsx new file mode 100644 index 0000000..b5500f7 --- /dev/null +++ b/lib/variables.jsx @@ -0,0 +1,112 @@ +'use babel' + +import { React } from 'react-for-atom' +import { connect } from 'react-redux' +import * as Debugger from './debugger' +import { getDebugger } from './store' + +class Variables extends React.Component { + render () { + return
+ +
+ } + onToggleClick (ev) { + const path = ev.target.dataset.path + if (!path) { + return + } + + // update the store + this.props.onToggle(path) + + // then load the variable if not done already + const v = this.props.variables[path] + if (v && !v.loaded) { + this.props.loadVariable(path, v) + return + } + } +} +Variables.propTypes = { + variables: React.PropTypes.array, + expanded: React.PropTypes.object, + loadVariable: React.PropTypes.func, + onToggle: React.PropTypes.func +} + +export default connect( + (state) => { + const dbg = getDebugger() + return { + variables: dbg && (dbg.stacktrace[dbg.selectedStacktrace] || {}).variables, + expanded: state.variables.expanded + } + }, + (dispatch) => { + return { + onToggle: (path) => { + dispatch({ type: 'TOGGLE_VARIABLE', path }) + }, + loadVariable: (path, variable) => { + Debugger.loadVariable(path, variable) + } + } + } +)(Variables) + +const Variable = (props) => { + const { variables, path, expanded } = props + const variable = variables[path] + + const name = renderValue(variable.name) + const isExpanded = variable.hasChildren && expanded[path] + let toggleClassName = 'debug-toggle' + (!variable.hasChildren ? ' debug-toggle-hidden' : '') + toggleClassName += ' icon icon-chevron-' + (isExpanded ? 'down' : 'right') + return
  • + + {variable.value + ? {name}: {renderValue(variable.value)} + : {name}} + {isExpanded ? : null} +
  • +} +Variable.propTypes = { + variables: React.PropTypes.object, + path: React.PropTypes.string, + expanded: React.PropTypes.object +} + +const Children = (props) => { + const { variables, path, expanded } = props + const children = Object.keys(variables || {}).filter((p) => variables[p].parentPath === path).sort() + if (!children.length) { + return
    + } + const vars = children.map((p, i) => { + return + }) + return
      + {vars} +
    +} +Children.propTypes = { + variables: React.PropTypes.object, + path: React.PropTypes.string, + expanded: React.PropTypes.object +} + +function renderValue (value) { + if (Array.isArray(value)) { + return value.map((v, i) => {renderValue(v)}) + } + if (typeof value === 'object' && 'value' in value) { + const v = renderValue(value.value) + return value.className ? {v} : v + } + return (value === undefined || value === null) ? '' : value +} diff --git a/package.json b/package.json index 7efa29d..7c1b65a 100644 --- a/package.json +++ b/package.json @@ -18,18 +18,25 @@ "bugs": { "url": "https://github.com/atom-community/debug/issues" }, + "dependencies": { + "react-for-atom": "^15.2.1-0", + "react-redux": "^4.4.5", + "redux": "^3.5.2" + }, "devDependencies": { - "eslint": "^2.4.0", - "babel-eslint": "^6.0.0-beta.6", - "eslint-config-standard": "^5.1.0", - "eslint-plugin-standard": "^1.3.2", - "eslint-plugin-promise": "^1.1.0" + "standard": "^8.0.0-beta.4" }, "configSchema": {}, + "providedServices": { + "debug": { + "versions": { + "0.0.1": "provide" + } + } + }, "standard": { "globals": [ - "atom", - "waitsForPromise" + "atom" ] } } diff --git a/spec/.eslintrc b/spec/.eslintrc deleted file mode 100644 index abaa2d1..0000000 --- a/spec/.eslintrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "env": { - "jasmine": true, - }, - "globals": { - "waitsForPromise": true, - } -} diff --git a/spec/main-spec.js b/spec/main-spec.js index 276672a..099d691 100644 --- a/spec/main-spec.js +++ b/spec/main-spec.js @@ -1,5 +1,5 @@ 'use babel' -/* eslint-env jasmine */ +/* eslint-env jasmine, es6 */ describe('debug', () => { let mainModule = null diff --git a/styles/debug.less b/styles/debug.less new file mode 100644 index 0000000..828a589 --- /dev/null +++ b/styles/debug.less @@ -0,0 +1,217 @@ +@import "ui-variables"; + +@debug-debug-line-color-bg: rgba(64, 115, 255, 0.5); +@debug-breakpoint-size: 0.8em; +@debug-panel-size: 20em; +@debug-output-size: 20em; + +button.debug-btn-flat { + border: none; + border-radius: 0; + background: none; +} + +.debug-breakpoint { + display: inline-block; + height: @debug-breakpoint-size !important; + width: @debug-breakpoint-size; + border-radius: 50%; + border: 1px solid currentColor; + color: red; + &-state-busy { + } + &-state-valid { + background-color: currentColor; + } + &-state-invalid { + border-color: orange; + background-color: orange; + } +} + +atom-text-editor::shadow { + .debug-gutter-breakpoint { + width: 100%; + display: flex; + align-items: center; + cursor: pointer; + } + .debug-breakpoint { + .debug-breakpoint; + } + + .debug-debug-line { + background: @debug-debug-line-color-bg; + } +} + +.debug-panel { + display: flex; + border-left: 1px solid @tool-panel-border-color; +} + +.debug-panel-root { + display: flex; + flex-direction: column; + flex: 1; + min-width: @debug-panel-size; + position: relative; +} + +.debug-panel-resizer { + position: absolute; + top: 0; left: 0; bottom: 0; + width: 5px; + cursor: ew-resize; +} + +.debug-panel-header { + border-bottom: 1px solid @pane-item-border-color; + > div { + display: flex; + height: 2.5em; + align-items: center; + > * { + height: 100%; + } + } + + select { + background: @tool-panel-background-color; + flex: 1; + border: 0; + padding-left: 0.5em; + cursor: pointer; + } +} + +.debug-panel-no-debugger { + padding-left: 0.5em; +} + +.debug-panel-commands { + display: flex; + flex-shrink: 0; + white-space: nowrap; + + > .btn { + flex: 1; + } +} + +.debug-panel-content { + overflow: auto; + flex: 1; +} + +.debug-panel-showoutput { + flex-shrink: 0; + min-height: 3em; + text-align: center; +} + +.debug-panel-stacktrace, +.debug-panel-threads { + white-space: nowrap; + text-overflow: ellipsis; + & > * { + padding: 0.2em 0.5em; + cursor: pointer; + &:nth-child(odd) { + background-color: rgba(0, 0, 0, 0.1); + } + &:hover { + background-color: rgba(0, 0, 0, 0.2); + } + } +} + +.debug-panel-variables { + li { + padding: 0.1em 0; + } + ol { + overflow: visible; + margin: 0; + padding: 0 0.5em; + white-space: pre-line; + list-style: none; + ol { + padding-left: 1.5em; + } + } +} + +.debug-panel-breakpoints { + > * { + padding: 0.2em 0.5em 0; + cursor: pointer; + white-space: nowrap; + display: flex; + align-items: baseline; + > .icon-x::before { + height: auto; + width: auto; + margin-right: 0.3em; + flex-shrink: 0; + font-size: small; + } + } + .debug-breakpoint { + margin-right: 0.2em; + flex-shrink: 0; + } +} + +.debug-output-header { + display: flex; + padding-left: 0.5em; + align-items: center; + .text { + flex: 1; + margin: 0; + } + .btn { + min-width: 3em; + min-height: 3em; + } +} + +.debug-output-list { + max-height: @debug-output-size; + overflow: auto; + white-space: pre-line; + padding: 0.2em 0.5em; +} + +.debug-expandable { + margin-bottom: 0.5em; +} +.debug-expandable-header { + cursor: pointer; + border-bottom: 1px solid currentColor; + padding: 0.2em 0.5em; + font-weight: bold; +} +.debug-expandable-body { + overflow-x: auto; +} +.debug-expandable[data-expanded="false"] .debug-expandable-body{ + display: none; +} + +.debug-toggle { + display: inline-block; + cursor: pointer; + text-align: center; + width: 1em; + + &::before { + width: auto; + height: auto; + font-size: inherit; + } + &.debug-toggle-hidden { + visibility: hidden; + } +}