Introducing Code-Replay, and how we built it
Published on Jan 5 2021
Logicboard is a collaborative code editor for conducting technical interviews. We believe the most important aspect of a technical interview is to understrand a candidate’s thought process. Having a working solution to a problem during an interview would be ideal, but there’s never really a single solution to any problem.
At the end of an interview, what I usually take away is some code that a candidate wrote and a few key points that I remember. But it’s hard to convey a candidate’s thought process to the rest of the team based on a few lines of code.
But what if I could replay the entire coding session to the rest of the team? We thought that would give everyone a better context and help understand the thought process behind a solution. This is in fact one of the top feedback we got from our customers.
Today we’re rolling out Code-Replay, a feature that lets you replay the entire interview session line-by-line. It’s like having the ability to undo/redo even after the interview has ended.
Here’s Code-Replay in action:
You can also check out the live demo here
Let’s take look at how we built this
Logicboard uses Firebase+Firepad to power the code editor. Firepad uses Firebase Realtime Database to keep a versioned history of a document.
Firebase
Let’s deep dive in to how this history is stored in Firebase. Here’s what gets stored in Firebase when I type The
-
A0, A1 and A2 are the revisions of document created each time I edit content.
-
User ID is the ID of the user that created this revision. This is helpful when multiple users are collaborating.
-
o is for operation, it contains the actual information about changes made in this version.
-
Index is the position of the text added/deleted in the document.
-
Content is the actual edited content. If it is an insert, it contains the inserted text and in case of deletion this will be a negative integer indicating the number of characters deleted.
-
Timestamp is the timestamp for this revision.
This structure is sequential and each revision stores just the delta. To restore a specific revision all you need to do is apply changes from A0 through An. This is conceptually similar to git, you can think of each revision as a git commit.
TextOperation
Each revision in Firebase is represented by a TextOperation
in Firepad.
You can create a TextOperation from operation (o) of a Firebase revision:
const operation = Firepad.TextOperation.fromJSON(history.A0.o)
TextOperation provides a handy compose
function that combines the changes of two operations in to a new operation. This new operation can be further composed with another operation, so on and so forth.
// "T"
const a0 = Firepad.TextOperation.fromJSON(history.A0.o)
// "Th"
const a1 = a0.compose(Firepad.TextOperation.fromJSON(history.A1.o))
// "The"
const final = a1.compose(Firepad.TextOperation.fromJSON(history.A2.o))
You can think of compose like cherry-pick in git.
Putting it together
If we start with the operation for first revision and recursively compose subsequent operations, we’ll end up with an operation that contains the final state of the document. So to restore a specific version An just compose operations A0…An.
With this in mind we can break down the entire process in to:
1. Fetching revisions from Firebase
Fetching history from firebase is pretty straightforward:
const fetchRevisions = async (firebaseRef, callback) => {
firebaseRef.child('history').once("value", function (snapshot) {
const revisions = snapshot.val()
}
2. Wrap each revision in to a TextOperation
Once we have revisions from Firebase, we create a TextOperation for each revision.
Also to make it easy to restore revision for a given timestamp, we generate a dictionary of {timestamp : TextOperation}
:
const fetchRevisions = async (firebaseRef, callback) => {
firebaseRef.child('history').once("value", function (snapshot) {
const revisions = snapshot.val()
var revisionsByTimestamp = {}
const Firepad = require('firepad')
for (key in revisions) {
const revision = revisions[key]
const operation = Firepad.TextOperation.fromJSON(revision.o)
revisionsByTimestamp[`${revision.t}`] = operation
}
callback(revisionsByTimestamp)
})
}
3. Recursively compose and get final text
If we recursively compose all the TextOperations from A0 to An where An is the specified revision, we’ll end up with a TextOperation
that combines all changes to the document up until An:
const textForRevision = (revision, revisions) => {
for (var key of keys) {
const operation = revisions[key]
document = document.compose(operation)
if (key === revision) {
break
}
}
}
Next, we need the actual text content of the finalTextOperation, to do this we just use TextOperation
‘s toJSON function. This function returns an array whose first element is the resulting text:
document.ops.length ? document.toJSON().slice(-1).pop() : null
TL;DR
const fetchRevisions = async (firebaseRef, callback) => {
firebaseRef.child('history').once("value", function (snapshot) {
const revisions = snapshot.val()
// Map revisions by their timestamp to make it easy to restore
var revisionsByTimestamp = {}
const Firepad = require('firepad')
for (key in revisions) {
const revision = revisions[key]
const operation = Firepad.TextOperation.fromJSON(revision.o)
revisionsByTimestamp[`${revision.t}`] = operation
}
callback(revisionsByTimestamp)
})
}
const textForRevision = (revision, revisions) => {
const Firepad = require('firepad')
var document = new Firepad.TextOperation()
const keys = Object.keys(revisions).sort()
for (var key of keys) {
const operation = revisions[key]
document = document.compose(operation)
if (key === revision) {
break
}
}
return document.ops.length ? document.toJSON().slice(-1).pop() : null
}
module.exports = {
fetchRevisions,
textForRevision,
}
Usage:
const Firepad = require('firepad')
const Firebase = require('firebase')
const firebaseRef = Firebase.database().ref().child('<child location>')
firepad.on('ready', function () {
fetchRevisions(firebaseRef, revisionInfo => {
const timestamp = // eg: "1601406184335"
const text = textForRevision(timestamp, revisionInfo)
codeMirror.setValue(text)
})
})
Thanks for reading! Please don’t forget to check out Logicboard.com, we support over 28 languages with built-in video calling and chat.
You can also play around with some code here.