git ssb

30+

cel / git-ssb-web



Tree: 827415227455eb1e465c4f587a0e47bcddabd393

Files: 827415227455eb1e465c4f587a0e47bcddabd393 / index.js

84460 bytesRaw
1var fs = require('fs')
2var http = require('http')
3var path = require('path')
4var url = require('url')
5var qs = require('querystring')
6var ref = require('ssb-ref')
7var pull = require('pull-stream')
8var ssbGit = require('ssb-git-repo')
9var toPull = require('stream-to-pull-stream')
10var cat = require('pull-cat')
11var Repo = require('pull-git-repo')
12var ssbAbout = require('./about')
13var ssbVotes = require('./votes')
14var marked = require('ssb-marked')
15var asyncMemo = require('asyncmemo')
16var multicb = require('multicb')
17var schemas = require('ssb-msg-schemas')
18var Issues = require('ssb-issues')
19var PullRequests = require('ssb-pull-requests')
20var paramap = require('pull-paramap')
21var gitPack = require('pull-git-pack')
22var Mentions = require('ssb-mentions')
23var Highlight = require('highlight.js')
24var JsDiff = require('diff')
25var many = require('pull-many')
26
27var hlCssPath = path.resolve(require.resolve('highlight.js'), '../../styles')
28
29// render links to git objects and ssb objects
30var blockRenderer = new marked.Renderer()
31blockRenderer.urltransform = function (url) {
32 if (ref.isLink(url))
33 return encodeLink(url)
34 if (/^[0-9a-f]{40}$/.test(url) && this.options.repo)
35 return encodeLink([this.options.repo.id, 'commit', url])
36 return url
37}
38
39blockRenderer.image = function (href, title, text) {
40 href = href.replace(/^&/, '&')
41 var url
42 if (ref.isBlobId(href))
43 url = encodeLink(href)
44 else if (this.options.repo && this.options.rev && this.options.path)
45 url = path.join('/', encodeURIComponent(this.options.repo.id),
46 'raw', this.options.rev, this.options.path.join('/'), href)
47 else
48 return text
49 return '<img src="' + escapeHTML(url) + '" alt="' + text + '"' +
50 (title ? ' title="' + title + '"' : '') + '/>'
51}
52
53function getExtension(filename) {
54 return (/\.([^.]+)$/.exec(filename) || [,filename])[1]
55}
56
57function highlight(code, lang) {
58 try {
59 return lang
60 ? Highlight.highlight(lang, code).value
61 : Highlight.highlightAuto(code).value
62 } catch(e) {
63 if (/^Unknown language/.test(e.message))
64 return escapeHTML(code)
65 throw e
66 }
67}
68
69marked.setOptions({
70 gfm: true,
71 mentions: true,
72 tables: true,
73 breaks: true,
74 pedantic: false,
75 sanitize: true,
76 smartLists: true,
77 smartypants: false,
78 highlight: highlight,
79 renderer: blockRenderer
80})
81
82// hack to make git link mentions work
83var mdRules = new marked.InlineLexer(1, marked.defaults).rules
84mdRules.mention =
85 /^(\s)?([@%&][A-Za-z0-9\._\-+=\/]*[A-Za-z0-9_\-+=\/]|[0-9a-f]{40})/
86mdRules.text = /^[\s\S]+?(?=[\\<!\[_*`]| {2,}\n| [@%&]|[0-9a-f]{40}|$)/
87
88function markdown(text, options, cb) {
89 if (!text) return ''
90 if (typeof text != 'string') text = String(text)
91 if (!options) options = {}
92 else if (options.id) options = {repo: options}
93 if (!options.rev) options.rev = 'HEAD'
94 if (!options.path) options.path = []
95
96 return marked(text, options, cb)
97}
98
99function parseAddr(str, def) {
100 if (!str) return def
101 var i = str.lastIndexOf(':')
102 if (~i) return {host: str.substr(0, i), port: str.substr(i+1)}
103 if (isNaN(str)) return {host: str, port: def.port}
104 return {host: def.host, port: str}
105}
106
107function isArray(arr) {
108 return Object.prototype.toString.call(arr) == '[object Array]'
109}
110
111function encodeLink(url) {
112 if (!isArray(url)) url = [url]
113 return '/' + url.map(encodeURIComponent).join('/')
114}
115
116function link(parts, text, raw, props) {
117 if (text == null) text = parts[parts.length-1]
118 if (!raw) text = escapeHTML(text)
119 return '<a href="' + encodeLink(parts) + '"' +
120 (props ? ' ' + props : '') +
121 '>' + text + '</a>'
122}
123
124function linkify(text) {
125 // regex is from ssb-ref
126 return text.replace(/(@|%|&)[A-Za-z0-9\/+]{43}=\.[\w\d]+/g, function (str) {
127 return '<a href="/' + encodeURIComponent(str) + '">' + str + '</a>'
128 })
129}
130
131function timestamp(time) {
132 time = Number(time)
133 var d = new Date(time)
134 return '<span title="' + time + '">' + d.toLocaleString() + '</span>'
135}
136
137function pre(text) {
138 return '<pre>' + escapeHTML(text) + '</pre>'
139}
140
141function json(obj) {
142 return linkify(pre(JSON.stringify(obj, null, 2)))
143}
144
145function escapeHTML(str) {
146 return String(str)
147 .replace(/&/g, '&amp;')
148 .replace(/</g, '&lt;')
149 .replace(/>/g, '&gt;')
150 .replace(/"/g, '&quot;')
151}
152
153function ucfirst(str) {
154 return str[0].toLocaleUpperCase() + str.slice(1)
155}
156
157function table(props) {
158 return function (read) {
159 return cat([
160 pull.once('<table' + (props ? ' ' + props : '') + '>'),
161 pull(
162 read,
163 pull.map(function (row) {
164 return row ? '<tr>' + row.map(function (cell) {
165 return '<td>' + cell + '</td>'
166 }).join('') + '</tr>' : ''
167 })
168 ),
169 pull.once('</table>')
170 ])
171 }
172}
173
174function ul(props) {
175 return function (read) {
176 return cat([
177 pull.once('<ul' + (props ? ' ' + props : '') + '>'),
178 pull(
179 read,
180 pull.map(function (li) {
181 return '<li>' + li + '</li>'
182 })
183 ),
184 pull.once('</ul>')
185 ])
186 }
187}
188
189function nav(links, page, after) {
190 return ['<nav>'].concat(
191 links.map(function (link) {
192 var href = typeof link[0] == 'string' ? link[0] : encodeLink(link[0])
193 var props = link[2] == page ? ' class="active"' : ''
194 return '<a href="' + href + '"' + props + '>' + link[1] + '</a>'
195 }), after || '', '</nav>').join('')
196}
197
198function renderNameForm(enabled, id, name, action, inputId, title, header) {
199 if (!inputId) inputId = action
200 return '<form class="petname" action="" method="post">' +
201 (enabled ?
202 '<input type="checkbox" class="name-checkbox" id="' + inputId + '" ' +
203 'onfocus="this.form.name.focus()" />' +
204 '<input name="name" class="name" value="' + escapeHTML(name) + '" ' +
205 'onkeyup="if (event.keyCode == 27) this.form.reset()" />' +
206 '<input type="hidden" name="action" value="' + action + '">' +
207 '<input type="hidden" name="id" value="' +
208 escapeHTML(id) + '">' +
209 '<label class="name-toggle" for="' + inputId + '" ' +
210 'title="' + title + '"><i>✍</i></label> ' +
211 '<input class="btn name-btn" type="submit" value="Rename">' +
212 header :
213 header + '<br clear="all"/>'
214 ) +
215 '</form>'
216}
217
218function renderPostForm(repo, placeholder, rows) {
219 return '<input type="radio" class="tab-radio" id="tab1" name="tab" checked="checked"/>' +
220 '<input type="radio" class="tab-radio" id="tab2" name="tab"/>' +
221 '<div id="tab-links" class="tab-links" style="display:none">' +
222 '<label for="tab1" id="write-tab-link" class="tab1-link">Write</label>' +
223 '<label for="tab2" id="preview-tab-link" class="tab2-link">Preview</label>' +
224 '</div>' +
225 '<input type="hidden" id="repo-id" value="' + repo.id + '"/>' +
226 '<div id="write-tab" class="tab1">' +
227 '<textarea id="post-text" name="text" class="wide-input"' +
228 ' rows="' + (rows||4) + '" cols="77"' +
229 (placeholder ? ' placeholder="' + placeholder + '"' : '') +
230 '></textarea>' +
231 '</div>' +
232 '<div class="preview-text tab2" id="preview-tab"></div>' +
233 '<script>' + issueCommentScript + '</script>'
234}
235
236function hiddenInputs(values) {
237 return Object.keys(values).map(function (key) {
238 return '<input type="hidden"' +
239 ' name="' + escapeHTML(key) + '"' +
240 ' value="' + escapeHTML(values[key]) + '"/>'
241 }).join('')
242}
243
244function readNext(fn) {
245 var next
246 return function (end, cb) {
247 if (next) return next(end, cb)
248 fn(function (err, _next) {
249 if (err) return cb(err)
250 next = _next
251 next(null, cb)
252 })
253 }
254}
255
256function readOnce(fn) {
257 var ended
258 return function (end, cb) {
259 fn(function (err, data) {
260 if (err || ended) return cb(err || ended)
261 ended = true
262 cb(null, data)
263 })
264 }
265}
266
267function paginate(onFirst, through, onLast, onEmpty) {
268 var ended, last, first = true, queue = []
269 return function (read) {
270 var mappedRead = through(function (end, cb) {
271 if (ended = end) return read(ended, cb)
272 if (queue.length)
273 return cb(null, queue.shift())
274 read(null, function (end, data) {
275 if (end) return cb(end)
276 last = data
277 cb(null, data)
278 })
279 })
280 return function (end, cb) {
281 var tmp
282 if (ended) return cb(ended)
283 if (ended = end) return read(ended, cb)
284 if (first)
285 return read(null, function (end, data) {
286 if (ended = end) {
287 if (end === true && onEmpty)
288 return onEmpty(cb)
289 return cb(ended)
290 }
291 first = false
292 last = data
293 queue.push(data)
294 if (onFirst)
295 onFirst(data, cb)
296 else
297 mappedRead(null, cb)
298 })
299 mappedRead(null, function (end, data) {
300 if (ended = end) {
301 if (end === true && last)
302 return onLast(last, cb)
303 }
304 cb(end, data)
305 })
306 }
307 }
308}
309
310function readObjectString(obj, cb) {
311 pull(obj.read, pull.collect(function (err, bufs) {
312 if (err) return cb(err)
313 cb(null, Buffer.concat(bufs, obj.length).toString('utf8'))
314 }))
315}
316
317function getRepoObjectString(repo, id, cb) {
318 if (!id) return cb(null, '')
319 repo.getObjectFromAny(id, function (err, obj) {
320 if (err) return cb(err)
321 readObjectString(obj, cb)
322 })
323}
324
325function compareMsgs(a, b) {
326 return (a.value.timestamp - b.value.timestamp) || (a.key - b.key)
327}
328
329function pullSort(comparator) {
330 return function (read) {
331 return readNext(function (cb) {
332 pull(read, pull.collect(function (err, items) {
333 if (err) return cb(err)
334 items.sort(comparator)
335 cb(null, pull.values(items))
336 }))
337 })
338 }
339}
340
341function sortMsgs() {
342 return pullSort(compareMsgs)
343}
344
345function pullReverse() {
346 return function (read) {
347 return readNext(function (cb) {
348 pull(read, pull.collect(function (err, items) {
349 cb(err, items && pull.values(items.reverse()))
350 }))
351 })
352 }
353}
354
355function tryDecodeURIComponent(str) {
356 if (!str || (str[0] == '%' && ref.isBlobId(str)))
357 return str
358 try {
359 str = decodeURIComponent(str)
360 } finally {
361 return str
362 }
363}
364
365function getRepoName(about, ownerId, repoId, cb) {
366 about.getName({
367 owner: ownerId,
368 target: repoId,
369 toString: function () {
370 // hack to fit two parameters into asyncmemo
371 return ownerId + '/' + repoId
372 }
373 }, cb)
374}
375
376function getRepoFullName(about, author, repoId, cb) {
377 var done = multicb({ pluck: 1, spread: true })
378 getRepoName(about, author, repoId, done())
379 about.getName(author, done())
380 done(cb)
381}
382
383function addAuthorName(about) {
384 return paramap(function (msg, cb) {
385 var author = msg && msg.value && msg.value.author
386 if (!author) return cb(null, msg)
387 about.getName(author, function (err, authorName) {
388 msg.authorName = authorName
389 cb(err, msg)
390 })
391 }, 8)
392}
393
394function getMention(msg, id) {
395 if (msg.key == id) return msg
396 var mentions = msg.value.content.mentions
397 if (mentions) for (var i = 0; i < mentions.length; i++) {
398 var mention = mentions[i]
399 if (mention.link == id)
400 return mention
401 }
402 return null
403}
404
405var hasOwnProp = Object.prototype.hasOwnProperty
406
407function getContentType(filename) {
408 var ext = getExtension(filename)
409 return contentTypes[ext] || imgMimes[ext] || 'text/plain; charset=utf-8'
410}
411
412var contentTypes = {
413 css: 'text/css'
414}
415
416function readReqForm(req, cb) {
417 pull(
418 toPull(req),
419 pull.collect(function (err, bufs) {
420 if (err) return cb(err)
421 var data
422 try {
423 data = qs.parse(Buffer.concat(bufs).toString('ascii'))
424 } catch(e) {
425 return cb(e)
426 }
427 cb(null, data)
428 })
429 )
430}
431
432var issueCommentScript = '(' + function () {
433 var $ = document.getElementById.bind(document)
434 $('tab-links').style.display = 'block'
435 $('preview-tab-link').onclick = function (e) {
436 with (new XMLHttpRequest()) {
437 open('POST', '', true)
438 onload = function() {
439 $('preview-tab').innerHTML = responseText
440 }
441 send('action=markdown' +
442 '&repo=' + encodeURIComponent($('repo-id').value) +
443 '&text=' + encodeURIComponent($('post-text').value))
444 }
445 }
446}.toString() + ')()'
447
448var msgTypes = {
449 'git-repo': true,
450 'git-update': true,
451 'issue': true,
452 'pull-request': true
453}
454
455var imgMimes = {
456 png: 'image/png',
457 jpeg: 'image/jpeg',
458 jpg: 'image/jpeg',
459 gif: 'image/gif',
460 tif: 'image/tiff',
461 svg: 'image/svg+xml',
462 bmp: 'image/bmp'
463}
464
465module.exports = function (opts, cb) {
466 var ssb, reconnect, myId, getRepo, getVotes, getMsg, issues
467 var about = function (id, cb) { cb(null, {name: id}) }
468 var reqQueue = []
469 var isPublic = opts.public
470 var ssbAppname = opts.appname || 'ssb'
471
472 var addr = parseAddr(opts.listenAddr, {host: 'localhost', port: 7718})
473 http.createServer(onRequest).listen(addr.port, addr.host, onListening)
474
475 var server = {
476 setSSB: function (_ssb, _reconnect) {
477 _ssb.whoami(function (err, feed) {
478 if (err) throw err
479 ssb = _ssb
480 reconnect = _reconnect
481 myId = feed.id
482 about = ssbAbout(ssb, myId)
483 while (reqQueue.length)
484 onRequest.apply(this, reqQueue.shift())
485 getRepo = asyncMemo(function (id, cb) {
486 getMsg(id, function (err, msg) {
487 if (err) return cb(err)
488 ssbGit.getRepo(ssb, {key: id, value: msg}, {live: true}, cb)
489 })
490 })
491 getVotes = ssbVotes(ssb)
492 getMsg = asyncMemo(ssb.get)
493 issues = Issues.init(ssb)
494 pullReqs = PullRequests.init(ssb)
495 })
496 }
497 }
498
499 function onListening() {
500 var host = ~addr.host.indexOf(':') ? '[' + addr.host + ']' : addr.host
501 console.log('Listening on http://' + host + ':' + addr.port + '/')
502 cb(null, server)
503 }
504
505 /* Serving a request */
506
507 function onRequest(req, res) {
508 console.log(req.method, req.url)
509 if (!ssb) return reqQueue.push(arguments)
510 pull(
511 handleRequest(req),
512 pull.filter(function (data) {
513 if (Array.isArray(data)) {
514 res.writeHead.apply(res, data)
515 return false
516 }
517 return true
518 }),
519 toPull(res)
520 )
521 }
522
523 function handleRequest(req) {
524 var u = req._u = url.parse(req.url, true)
525 var path = u.pathname.slice(1)
526 var dirs = ref.isLink(path) ? [path] :
527 path.split(/\/+/).map(tryDecodeURIComponent)
528 var dir = dirs[0]
529
530 if (req.method == 'POST') {
531 if (isPublic)
532 return serveBuffer(405, 'POST not allowed on public site')
533 return readNext(function (cb) {
534 readReqForm(req, function (err, data) {
535 if (err) return cb(null, serveError(err, 400))
536 if (!data) return cb(null, serveError(new Error('No data'), 400))
537
538 switch (data.action) {
539 case 'repo':
540 if (data.fork != null) {
541 var repoId = data.id
542 if (!repoId) return cb(null,
543 serveError(new Error('Missing repo id'), 400))
544 ssbGit.createRepo(ssb, {upstream: repoId},
545 function (err, repo) {
546 if (err) return cb(null, serveError(err))
547 cb(null, serveRedirect(encodeLink(repo.id)))
548 })
549 } else if (data.vote != null) {
550 // fallthrough
551 } else {
552 return cb(null, serveError(new Error('Unknown action'), 400))
553 }
554
555 case 'vote':
556 var voteValue = +data.value || 0
557 if (!data.id)
558 return cb(null, serveError(new Error('Missing vote id'), 400))
559 var msg = schemas.vote(data.id, voteValue)
560 return ssb.publish(msg, function (err) {
561 if (err) return cb(null, serveError(err))
562 cb(null, serveRedirect(req.url))
563 })
564
565 case 'repo-name':
566 if (!data.name)
567 return cb(null, serveError(new Error('Missing name'), 400))
568 if (!data.id)
569 return cb(null, serveError(new Error('Missing id'), 400))
570 var msg = schemas.name(data.id, data.name)
571 return ssb.publish(msg, function (err) {
572 if (err) return cb(null, serveError(err))
573 cb(null, serveRedirect(req.url))
574 })
575
576 case 'issue-title':
577 if (!data.name)
578 return cb(null, serveError(new Error('Missing name'), 400))
579 if (!data.id)
580 return cb(null, serveError(new Error('Missing id'), 400))
581 var msg = Issues.schemas.edit(data.id, {title: data.name})
582 return ssb.publish(msg, function (err) {
583 if (err) return cb(null, serveError(err))
584 cb(null, serveRedirect(req.url))
585 })
586
587 case 'comment':
588 if (!data.id)
589 return cb(null, serveError(new Error('Missing id'), 400))
590
591 var msg = schemas.post(data.text, data.id, data.branch || data.id)
592 msg.issue = data.issue
593 msg.repo = data.repo
594 if (data.open != null)
595 Issues.schemas.opens(msg, data.id)
596 if (data.close != null)
597 Issues.schemas.closes(msg, data.id)
598 var mentions = Mentions(data.text)
599 if (mentions.length)
600 msg.mentions = mentions
601 return ssb.publish(msg, function (err) {
602 if (err) return cb(null, serveError(err))
603 cb(null, serveRedirect(req.url))
604 })
605
606 case 'new-issue':
607 var msg = Issues.schemas.new(dir, data.title, data.text)
608 var mentions = Mentions(data.text)
609 if (mentions.length)
610 msg.mentions = mentions
611 return ssb.publish(msg, function (err, msg) {
612 if (err) return cb(null, serveError(err))
613 cb(null, serveRedirect(encodeLink(msg.key)))
614 })
615
616 case 'new-pull':
617 var msg = PullRequests.schemas.new(dir, data.branch,
618 data.head_repo, data.head_branch, data.title, data.text)
619 var mentions = Mentions(data.text)
620 if (mentions.length)
621 msg.mentions = mentions
622 return ssb.publish(msg, function (err, msg) {
623 if (err) return cb(null, serveError(err))
624 cb(null, serveRedirect(encodeLink(msg.key)))
625 })
626
627 case 'markdown':
628 return cb(null, serveMarkdown(data.text, {id: data.repo}))
629
630 default:
631 cb(null, serveBuffer(400, 'What are you trying to do?'))
632 }
633 })
634 })
635 }
636
637 if (dir == '')
638 return serveIndex(req)
639 else if (dir == 'search')
640 return serveSearch(req)
641 else if (ref.isBlobId(dir))
642 return serveBlob(req, dir)
643 else if (ref.isMsgId(dir))
644 return serveMessage(req, dir, dirs.slice(1))
645 else if (ref.isFeedId(dir))
646 return serveUserPage(req, dir, dirs.slice(1))
647 else if (dir == 'static')
648 return serveFile(req, dirs)
649 else if (dir == 'highlight')
650 return serveFile(req, [hlCssPath].concat(dirs.slice(1)), true)
651 else
652 return serve404(req)
653 }
654
655 function serveFile(req, dirs, outside) {
656 var filename = path.resolve.apply(path, [__dirname].concat(dirs))
657 // prevent escaping base dir
658 if (!outside && filename.indexOf('../') === 0)
659 return serveBuffer(403, '403 Forbidden')
660
661 return readNext(function (cb) {
662 fs.stat(filename, function (err, stats) {
663 cb(null, err ?
664 err.code == 'ENOENT' ? serve404(req)
665 : serveBuffer(500, err.message)
666 : 'if-modified-since' in req.headers &&
667 new Date(req.headers['if-modified-since']) >= stats.mtime ?
668 pull.once([304])
669 : stats.isDirectory() ?
670 serveBuffer(403, 'Directory not listable')
671 : cat([
672 pull.once([200, {
673 'Content-Type': getContentType(filename),
674 'Content-Length': stats.size,
675 'Last-Modified': stats.mtime.toGMTString()
676 }]),
677 toPull(fs.createReadStream(filename))
678 ]))
679 })
680 })
681 }
682
683 function servePlainError(code, msg) {
684 return pull.values([
685 [code, {
686 'Content-Length': Buffer.byteLength(msg),
687 'Content-Type': 'text/plain; charset=utf-8'
688 }],
689 msg
690 ])
691 }
692
693 function serveBuffer(code, buf, contentType, headers) {
694 headers = headers || {}
695 headers['Content-Type'] = contentType || 'text/plain; charset=utf-8'
696 headers['Content-Length'] = Buffer.byteLength(buf)
697 return pull.values([
698 [code, headers],
699 buf
700 ])
701 }
702
703 function serve404(req) {
704 return serveBuffer(404, '404 Not Found')
705 }
706
707 function serveRedirect(path) {
708 return serveBuffer(302,
709 '<!doctype><html><head>' +
710 '<title>Redirect</title></head><body>' +
711 '<p><a href="' + escapeHTML(path) + '">Continue</a></p>' +
712 '</body></html>', 'text/html; charset=utf-8', {Location: path})
713 }
714
715 function serveMarkdown(text, repo) {
716 return serveBuffer(200, markdown(text, repo), 'text/html; charset=utf-8')
717 }
718
719 function renderError(err, tag) {
720 tag = tag || 'h3'
721 return '<' + tag + '>' + err.name + '</' + tag + '>' +
722 '<pre>' + escapeHTML(err.stack) + '</pre>'
723 }
724
725 function renderTry(read) {
726 var ended
727 return function (end, cb) {
728 if (ended) return cb(ended)
729 read(end, function (err, data) {
730 if (err === true)
731 cb(true)
732 else if (err) {
733 ended = true
734 cb(null, renderError(err, 'h3'))
735 } else
736 cb(null, data)
737 })
738 }
739 }
740
741 function serveTemplate(title, code, req, read) {
742 if (read === undefined) return serveTemplate.bind(this, title, code, req)
743 var q = req && req._u.query.q && escapeHTML(req._u.query.q) || ''
744 return cat([
745 pull.values([
746 [code || 200, {
747 'Content-Type': 'text/html'
748 }],
749 '<!doctype html><html><head><meta charset=utf-8>',
750 '<title>' + escapeHTML(title || 'git ssb') + '</title>',
751 '<link rel=stylesheet href="/static/styles.css"/>',
752 '<link rel=stylesheet href="/highlight/github.css"/>',
753 '</head>\n',
754 '<body>',
755 '<header><form action="/search" method="get">' +
756 '<h1><a href="/">git ssb' +
757 (ssbAppname != 'ssb' ? ' <sub>' + ssbAppname + '</sub>' : '') +
758 '</a> ' +
759 '<input class="search-bar" name="q" size="60"' +
760 ' placeholder="🔍" value="' + q + '" />' +
761 '</h1>',
762 '</form></header>',
763 '<article>']),
764 renderTry(read),
765 pull.once('<hr/></article></body></html>')
766 ])
767 }
768
769 function serveError(err, status) {
770 if (err.message == 'stream is closed')
771 reconnect()
772 return pull(
773 pull.once(renderError(err, 'h2')),
774 serveTemplate(err.name, status || 500)
775 )
776 }
777
778 function renderObjectData(obj, filename, repo, rev, path) {
779 var ext = getExtension(filename)
780 return readOnce(function (cb) {
781 readObjectString(obj, function (err, buf) {
782 buf = buf.toString('utf8')
783 if (err) return cb(err)
784 cb(null, (ext == 'md' || ext == 'markdown')
785 ? markdown(buf, {repo: repo, rev: rev, path: path})
786 : renderCodeTable(buf, ext))
787 })
788 })
789 }
790
791 function renderCodeTable(buf, ext) {
792 return '<pre><table class="code">' +
793 highlight(buf, ext).split('\n').map(function (line, i) {
794 i++
795 return '<tr id="L' + i + '">' +
796 '<td class="code-linenum">' + '<a href="#L' + i + '">' + i + '</td>' +
797 '<td class="code-text">' + line + '</td></tr>'
798 }).join('') +
799 '</table></pre>'
800 }
801
802 /* Feed */
803
804 function renderFeed(req, feedId, filter) {
805 var query = req._u.query
806 var opts = {
807 reverse: !query.forwards,
808 lt: query.lt && +query.lt || Date.now(),
809 gt: query.gt && +query.gt,
810 id: feedId
811 }
812 return pull(
813 feedId ? ssb.createUserStream(opts) : ssb.createFeedStream(opts),
814 pull.filter(function (msg) {
815 return msg.value.content.type in msgTypes
816 }),
817 typeof filter == 'function' ? filter(opts) : filter,
818 pull.take(20),
819 addAuthorName(about),
820 query.forwards && pullReverse(),
821 paginate(
822 function (first, cb) {
823 if (!query.lt && !query.gt) return cb(null, '')
824 var gt = feedId ? first.value.sequence : first.value.timestamp + 1
825 query.gt = gt
826 query.forwards = 1
827 delete query.lt
828 cb(null, '<a href="?' + qs.stringify(query) + '">Newer</a>')
829 },
830 paramap(renderFeedItem, 8),
831 function (last, cb) {
832 query.lt = feedId ? last.value.sequence : last.value.timestamp - 1
833 delete query.gt
834 delete query.forwards
835 cb(null, '<a href="?' + qs.stringify(query) + '">Older</a>')
836 },
837 function (cb) {
838 if (query.forwards) {
839 delete query.gt
840 delete query.forwards
841 query.lt = opts.gt + 1
842 } else {
843 delete query.lt
844 query.gt = opts.lt - 1
845 query.forwards = 1
846 }
847 cb(null, '<a href="?' + qs.stringify(query) + '">' +
848 (query.forwards ? 'Older' : 'Newer') + '</a>')
849 }
850 )
851 )
852 }
853
854 function renderFeedItem(msg, cb) {
855 var c = msg.value.content
856 var msgLink = link([msg.key],
857 new Date(msg.value.timestamp).toLocaleString())
858 var author = msg.value.author
859 var authorLink = link([msg.value.author], msg.authorName)
860 switch (c.type) {
861 case 'git-repo':
862 var done = multicb({ pluck: 1, spread: true })
863 getRepoName(about, author, msg.key, done())
864 if (c.upstream) {
865 getRepoName(about, author, c.upstream, done())
866 return done(function (err, repoName, upstreamName) {
867 cb(null, '<section class="collapse">' + msgLink + '<br>' +
868 authorLink + ' forked ' + link([c.upstream], upstreamName) +
869 ' to ' + link([msg.key], repoName) + '</section>')
870 })
871 } else {
872 return done(function (err, repoName) {
873 if (err) return cb(err)
874 var repoLink = link([msg.key], repoName)
875 cb(null, '<section class="collapse">' + msgLink + '<br>' +
876 authorLink + ' created repo ' + repoLink + '</section>')
877 })
878 }
879 case 'git-update':
880 return getRepoName(about, author, c.repo, function (err, repoName) {
881 if (err) return cb(err)
882 var repoLink = link([c.repo], repoName)
883 cb(null, '<section class="collapse">' + msgLink + '<br>' +
884 authorLink + ' pushed to ' + repoLink + '</section>')
885 })
886 case 'issue':
887 case 'pull-request':
888 var issueLink = link([msg.key], c.title)
889 return getRepoName(about, author, c.project, function (err, repoName) {
890 if (err) return cb(err)
891 var repoLink = link([c.project], repoName)
892 cb(null, '<section class="collapse">' + msgLink + '<br>' +
893 authorLink + ' opened ' + c.type + ' ' + issueLink +
894 ' on ' + repoLink + '</section>')
895 })
896 case 'about':
897 return cb(null, '<section class="collapse">' + msgLink + '<br>' +
898 authorLink + ' named ' + '<tt>' + escapeHTML(c.about) + '</tt> ' +
899 link([c.about], c.name) + '</section>')
900 default:
901 return cb(null, json(msg))
902 }
903 }
904
905 /* Index */
906
907 function serveIndex(req) {
908 return serveTemplate('git ssb')(renderFeed(req))
909 }
910
911 function serveUserPage(req, feedId, dirs) {
912 switch (dirs[0]) {
913 case undefined:
914 case '':
915 case 'activity':
916 return serveUserActivity(req, feedId)
917 case 'repos':
918 return serveUserRepos(feedId)
919 }
920 }
921
922 function renderUserPage(feedId, page, body) {
923 return serveTemplate(feedId)(cat([
924 readOnce(function (cb) {
925 about.getName(feedId, function (err, name) {
926 cb(null, '<h2>' + link([feedId], name) +
927 '<code class="user-id">' + feedId + '</code></h2>' +
928 nav([
929 [[feedId], 'Activity', 'activity'],
930 [[feedId, 'repos'], 'Repos', 'repos']
931 ], page))
932 })
933 }),
934 body,
935 ]))
936 }
937
938 function serveUserActivity(req, feedId) {
939 return renderUserPage(feedId, 'activity', renderFeed(req, feedId))
940 }
941
942 function serveUserRepos(feedId) {
943 return renderUserPage(feedId, 'repos', pull(
944 ssb.messagesByType({
945 type: 'git-repo',
946 reverse: true
947 }),
948 pull.filter(function (msg) {
949 return msg.value.author == feedId
950 }),
951 pull.take(20),
952 paramap(function (msg, cb) {
953 getRepoName(about, feedId, msg.key, function (err, repoName) {
954 if (err) return cb(err)
955 cb(null, '<section class="collapse">' +
956 link([msg.key], repoName) +
957 '</section>')
958 })
959 }, 8)
960 ))
961 }
962
963 /* Message */
964
965 function serveMessage(req, id, path) {
966 return readNext(function (cb) {
967 ssb.get(id, function (err, msg) {
968 if (err) return cb(null, serveError(err))
969 var c = msg.content || {}
970 switch (c.type) {
971 case 'git-repo':
972 return getRepo(id, function (err, repo) {
973 if (err) return cb(null, serveError(err))
974 cb(null, serveRepoPage(req, Repo(repo), path))
975 })
976 case 'git-update':
977 return getRepo(c.repo, function (err, repo) {
978 if (err) return cb(null, serveRepoNotFound(c.repo, err))
979 cb(null, serveRepoUpdate(req, Repo(repo), id, msg, path))
980 })
981 case 'issue':
982 return getRepo(c.project, function (err, repo) {
983 if (err) return cb(null, serveRepoNotFound(c.project, err))
984 issues.get(id, function (err, issue) {
985 if (err) return cb(null, serveError(err))
986 cb(null, serveRepoIssue(req, Repo(repo), issue, path))
987 })
988 })
989 case 'pull-request':
990 return getRepo(c.repo, function (err, repo) {
991 if (err) return cb(null, serveRepoNotFound(c.project, err))
992 pullReqs.get(id, function (err, pr) {
993 if (err) return cb(null, serveError(err))
994 cb(null, serveRepoPullReq(req, Repo(repo), pr, path))
995 })
996 })
997 case 'issue-edit':
998 if (ref.isMsgId(c.issue)) {
999 return pullReqs.get(c.issue, function (err, issue) {
1000 if (err) return cb(err)
1001 var serve = issue.msg.value.content.type == 'pull-request' ?
1002 serveRepoPullReq : serveRepoIssue
1003 getRepo(issue.project, function (err, repo) {
1004 if (err) {
1005 if (!repo) return cb(null, serveRepoNotFound(c.repo, err))
1006 return cb(null, serveError(err))
1007 }
1008 cb(null, serve(req, Repo(repo), issue, path, id))
1009 })
1010 })
1011 }
1012 // fallthrough
1013 case 'post':
1014 if (ref.isMsgId(c.issue) && ref.isMsgId(c.repo)) {
1015 // comment on an issue
1016 var done = multicb({ pluck: 1, spread: true })
1017 getRepo(c.repo, done())
1018 pullReqs.get(c.issue, done())
1019 return done(function (err, repo, issue) {
1020 if (err) {
1021 if (!repo) return cb(null, serveRepoNotFound(c.repo, err))
1022 return cb(null, serveError(err))
1023 }
1024 var serve = issue.msg.value.content.type == 'pull-request' ?
1025 serveRepoPullReq : serveRepoIssue
1026 cb(null, serve(req, Repo(repo), issue, path, id))
1027 })
1028 } else if (ref.isMsgId(c.root)) {
1029 // comment on issue from patchwork?
1030 return getMsg(c.root, function (err, root) {
1031 if (err) return cb(null, serveError(err))
1032 var repoId = root.content.repo || root.content.project
1033 if (!ref.isMsgId(repoId))
1034 return cb(null, serveGenericMessage(req, id, msg, path))
1035 getRepo(repoId, function (err, repo) {
1036 if (err) return cb(null, serveError(err))
1037 switch (root.content && root.content.type) {
1038 case 'issue':
1039 return issues.get(c.root, function (err, issue) {
1040 if (err) return cb(null, serveError(err))
1041 return cb(null,
1042 serveRepoIssue(req, Repo(repo), issue, path, id))
1043 })
1044 case 'pull-request':
1045 pullReqs.get(c.root, function (err, pr) {
1046 if (err) return cb(null, serveError(err))
1047 return cb(null,
1048 serveRepoPullReq(req, Repo(repo), pr, path, id))
1049 })
1050 }
1051 })
1052 })
1053 }
1054 // fallthrough
1055 default:
1056 if (ref.isMsgId(c.repo))
1057 return getRepo(c.repo, function (err, repo) {
1058 if (err) return cb(null, serveRepoNotFound(c.repo, err))
1059 cb(null, serveRepoSomething(req, Repo(repo), id, msg, path))
1060 })
1061 else
1062 return cb(null, serveGenericMessage(req, id, msg, path))
1063 }
1064 })
1065 })
1066 }
1067
1068 function serveGenericMessage(req, id, msg, path) {
1069 return serveTemplate(id)(pull.once(
1070 '<section><h2>' + link([id]) + '</h2>' +
1071 json(msg) +
1072 '</section>'))
1073 }
1074
1075 /* Repo */
1076
1077 function serveRepoPage(req, repo, path) {
1078 var defaultBranch = 'master'
1079 var query = req._u.query
1080
1081 if (query.rev != null) {
1082 // Allow navigating revs using GET query param.
1083 // Replace the branch in the path with the rev query value
1084 path[0] = path[0] || 'tree'
1085 path[1] = query.rev
1086 req._u.pathname = encodeLink([repo.id].concat(path))
1087 delete req._u.query.rev
1088 delete req._u.search
1089 return serveRedirect(url.format(req._u))
1090 }
1091
1092 // get branch
1093 return path[1] ?
1094 serveRepoPage2(req, repo, path) :
1095 readNext(function (cb) {
1096 // TODO: handle this in pull-git-repo or ssb-git-repo
1097 repo.getSymRef('HEAD', true, function (err, ref) {
1098 if (err) return cb(err)
1099 repo.resolveRef(ref, function (err, rev) {
1100 path[1] = rev ? ref : null
1101 cb(null, serveRepoPage2(req, repo, path))
1102 })
1103 })
1104 })
1105 }
1106
1107 function serveRepoPage2(req, repo, path) {
1108 var branch = path[1]
1109 var filePath = path.slice(2)
1110 switch (path[0]) {
1111 case undefined:
1112 case '':
1113 return serveRepoTree(repo, branch, [])
1114 case 'activity':
1115 return serveRepoActivity(repo, branch)
1116 case 'commits':
1117 return serveRepoCommits(req, repo, branch)
1118 case 'commit':
1119 return serveRepoCommit(repo, path[1])
1120 case 'tree':
1121 return serveRepoTree(repo, branch, filePath)
1122 case 'blob':
1123 return serveRepoBlob(repo, branch, filePath)
1124 case 'raw':
1125 return serveRepoRaw(repo, branch, filePath)
1126 case 'digs':
1127 return serveRepoDigs(repo)
1128 case 'forks':
1129 return serveRepoForks(repo)
1130 case 'issues':
1131 switch (path[1]) {
1132 case 'new':
1133 if (filePath.length == 0)
1134 return serveRepoNewIssue(repo)
1135 break
1136 default:
1137 return serveRepoIssues(req, repo, filePath)
1138 }
1139 case 'pulls':
1140 return serveRepoPullReqs(req, repo)
1141 case 'compare':
1142 return serveRepoCompare(req, repo)
1143 case 'comparing':
1144 return serveRepoComparing(req, repo)
1145 default:
1146 return serve404(req)
1147 }
1148 }
1149
1150 function serveRepoNotFound(id, err) {
1151 return serveTemplate('Repo not found', 404)(pull.values([
1152 '<h2>Repo not found</h2>',
1153 '<p>Repo ' + id + ' was not found</p>',
1154 '<pre>' + escapeHTML(err.stack) + '</pre>',
1155 ]))
1156 }
1157
1158 function renderRepoPage(repo, page, branch, body) {
1159 var gitUrl = 'ssb://' + repo.id
1160 var gitLink = '<input class="clone-url" readonly="readonly" ' +
1161 'value="' + gitUrl + '" size="45" ' +
1162 'onclick="this.select()"/>'
1163 var digsPath = [repo.id, 'digs']
1164
1165 var done = multicb({ pluck: 1, spread: true })
1166 getRepoName(about, repo.feed, repo.id, done())
1167 about.getName(repo.feed, done())
1168 getVotes(repo.id, done())
1169
1170 if (repo.upstream) {
1171 getRepoName(about, repo.upstream.feed, repo.upstream.id, done())
1172 about.getName(repo.upstream.feed, done())
1173 }
1174
1175 return readNext(function (cb) {
1176 done(function (err, repoName, authorName, votes,
1177 upstreamName, upstreamAuthorName) {
1178 if (err) return cb(null, serveError(err))
1179 var upvoted = votes.upvoters[myId] > 0
1180 var upstreamLink = !repo.upstream ? '' :
1181 link([repo.upstream])
1182 cb(null, serveTemplate(repo.id)(cat([
1183 pull.once(
1184 '<div class="repo-title">' +
1185 '<form class="right-bar" action="" method="post">' +
1186 '<button class="btn" name="vote" ' +
1187 (isPublic ? 'disabled="disabled"' : ' type="submit"') + '>' +
1188 '<i>✌</i> ' + (!isPublic && upvoted ? 'Undig' : 'Dig') +
1189 '</button>' +
1190 (isPublic ? '' : '<input type="hidden" name="value" value="' +
1191 (upvoted ? '0' : '1') + '">' +
1192 '<input type="hidden" name="action" value="repo">' +
1193 '<input type="hidden" name="id" value="' +
1194 escapeHTML(repo.id) + '">') + ' ' +
1195 '<strong>' + link(digsPath, votes.upvotes) + '</strong> ' +
1196 (isPublic ? '' :
1197 '<button class="btn" type="submit" name="fork">' +
1198 '<i>⑂</i> Fork' +
1199 '</button>') + ' ' +
1200 link([repo.id, 'forks'], '+', false, ' title="Forks"') +
1201 '</form>' +
1202 renderNameForm(!isPublic, repo.id, repoName, 'repo-name', null,
1203 'Rename the repo',
1204 '<h2 class="bgslash">' + link([repo.feed], authorName) + ' / ' +
1205 link([repo.id], repoName) + '</h2>') +
1206 '</div>' +
1207 (repo.upstream ?
1208 '<small>forked from ' +
1209 link([repo.upstream.feed], upstreamAuthorName) + '\'s ' +
1210 link([repo.upstream.id], upstreamName) +
1211 '</small>' : '') +
1212 nav([
1213 [[repo.id], 'Code', 'code'],
1214 [[repo.id, 'activity'], 'Activity', 'activity'],
1215 [[repo.id, 'commits', branch || ''], 'Commits', 'commits'],
1216 [[repo.id, 'issues'], 'Issues', 'issues'],
1217 [[repo.id, 'pulls'], 'Pull Requests', 'pulls']
1218 ], page, gitLink)),
1219 body
1220 ])))
1221 })
1222 })
1223 }
1224
1225 function serveEmptyRepo(repo) {
1226 if (repo.feed != myId)
1227 return renderRepoPage(repo, 'code', null, pull.once(
1228 '<section>' +
1229 '<h3>Empty repository</h3>' +
1230 '</section>'))
1231
1232 var gitUrl = 'ssb://' + repo.id
1233 return renderRepoPage(repo, 'code', null, pull.once(
1234 '<section>' +
1235 '<h3>Getting started</h3>' +
1236 '<h4>Create a new repository</h4><pre>' +
1237 'touch README.md\n' +
1238 'git init\n' +
1239 'git add README.md\n' +
1240 'git commit -m "Initial commit"\n' +
1241 'git remote add origin ' + gitUrl + '\n' +
1242 'git push -u origin master</pre>\n' +
1243 '<h4>Push an existing repository</h4>\n' +
1244 '<pre>git remote add origin ' + gitUrl + '\n' +
1245 'git push -u origin master</pre>' +
1246 '</section>'))
1247 }
1248
1249 function serveRepoTree(repo, rev, path) {
1250 if (!rev) return serveEmptyRepo(repo)
1251 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1252 return renderRepoPage(repo, 'code', rev, cat([
1253 pull.once('<section><form action="" method="get">' +
1254 '<h3>' + type + ': ' + rev + ' '),
1255 revMenu(repo, rev),
1256 pull.once('</h3></form>'),
1257 type == 'Branch' && renderRepoLatest(repo, rev),
1258 pull.once('</section><section>'),
1259 renderRepoTree(repo, rev, path),
1260 pull.once('</section>'),
1261 renderRepoReadme(repo, rev, path)
1262 ]))
1263 }
1264
1265 /* Search */
1266
1267 function serveSearch(req) {
1268 var q = String(req._u.query.q || '')
1269 if (!q) return serveIndex(req)
1270 var qId = q.replace(/^ssb:\/*/, '')
1271 if (ref.type(qId))
1272 return serveRedirect(encodeURI(qId))
1273
1274 var search = new RegExp(q, 'i')
1275 return serveTemplate('git ssb search', 200, req)(
1276 renderFeed(req, null, function (opts) {
1277 opts.type == 'about'
1278 return function (read) {
1279 return pull(
1280 many([
1281 getRepoNames(opts),
1282 read
1283 ]),
1284 pull.filter(function (msg) {
1285 var c = msg.value.content
1286 return (
1287 search.test(msg.key) ||
1288 c.text && search.test(c.text) ||
1289 c.name && search.test(c.name) ||
1290 c.title && search.test(c.title))
1291 })
1292 )
1293 }
1294 })
1295 )
1296 }
1297
1298 function getRepoNames(opts) {
1299 return pull(
1300 ssb.messagesByType({
1301 type: 'about',
1302 reverse: opts.reverse,
1303 lt: opts.lt,
1304 gt: opts.gt,
1305 }),
1306 pull.filter(function (msg) {
1307 return '%' == String(msg.value.content.about)[0]
1308 && msg.value.content.name
1309 })
1310 )
1311 }
1312
1313 /* Repo activity */
1314
1315 function serveRepoActivity(repo, branch) {
1316 return renderRepoPage(repo, 'activity', branch, cat([
1317 pull.once('<h3>Activity</h3>'),
1318 pull(
1319 ssb.links({
1320 dest: repo.id,
1321 source: repo.feed,
1322 rel: 'repo',
1323 values: true,
1324 reverse: true
1325 }),
1326 pull.map(renderRepoUpdate.bind(this, repo))
1327 ),
1328 readOnce(function (cb) {
1329 var done = multicb({ pluck: 1, spread: true })
1330 about.getName(repo.feed, done())
1331 getMsg(repo.id, done())
1332 done(function (err, authorName, msg) {
1333 if (err) return cb(err)
1334 renderFeedItem({
1335 key: repo.id,
1336 value: msg,
1337 authorName: authorName
1338 }, cb)
1339 })
1340 })
1341 ]))
1342 }
1343
1344 function renderRepoUpdate(repo, msg, full) {
1345 var c = msg.value.content
1346
1347 if (c.type != 'git-update') {
1348 return ''
1349 // return renderFeedItem(msg, cb)
1350 // TODO: render post, issue, pull-request
1351 }
1352
1353 var refs = c.refs ? Object.keys(c.refs).map(function (ref) {
1354 return {name: ref, value: c.refs[ref]}
1355 }) : []
1356 var numObjects = c.objects ? Object.keys(c.objects).length : 0
1357
1358 return '<section class="collapse">' +
1359 link([msg.key], new Date(msg.value.timestamp).toLocaleString()) +
1360 '<br>' +
1361 (numObjects ? 'Pushed ' + numObjects + ' objects<br>' : '') +
1362 refs.map(function (update) {
1363 var name = escapeHTML(update.name)
1364 if (!update.value) {
1365 return 'Deleted ' + name
1366 } else {
1367 var commitLink = link([repo.id, 'commit', update.value])
1368 return name + ' &rarr; ' + commitLink
1369 }
1370 }).join('<br>') +
1371 '</section>'
1372 }
1373
1374 /* Repo commits */
1375
1376 function serveRepoCommits(req, repo, branch) {
1377 var query = req._u.query
1378 return renderRepoPage(repo, 'commits', branch, cat([
1379 pull.once('<h3>Commits</h3>'),
1380 pull(
1381 repo.readLog(query.start || branch),
1382 pull.take(20),
1383 paramap(repo.getCommitParsed.bind(repo), 8),
1384 paginate(
1385 !query.start ? '' : function (first, cb) {
1386 cb(null, '&hellip;')
1387 },
1388 pull.map(renderCommit.bind(this, repo)),
1389 function (commit, cb) {
1390 cb(null, commit.parents && commit.parents[0] ?
1391 '<a href="?start=' + commit.id + '">Older</a>' : '')
1392 }
1393 )
1394 )
1395 ]))
1396 }
1397
1398 function renderCommit(repo, commit) {
1399 var commitPath = [repo.id, 'commit', commit.id]
1400 var treePath = [repo.id, 'tree', commit.id]
1401 return '<section class="collapse">' +
1402 '<strong>' + link(commitPath, commit.title) + '</strong><br>' +
1403 '<tt>' + commit.id + '</tt> ' +
1404 link(treePath, 'Tree') + '<br>' +
1405 escapeHTML(commit.author.name) + ' &middot; ' + commit.author.date.toLocaleString() +
1406 (commit.separateAuthor ? '<br>' + escapeHTML(commit.committer.name) + ' committed on ' + commit.committer.date.toLocaleString() : "") +
1407 '</section>'
1408}
1409
1410 /* Branch menu */
1411
1412 function formatRevOptions(currentName) {
1413 return function (name) {
1414 var htmlName = escapeHTML(name)
1415 return '<option value="' + htmlName + '"' +
1416 (name == currentName ? ' selected="selected"' : '') +
1417 '>' + htmlName + '</option>'
1418 }
1419 }
1420
1421 function revMenu(repo, currentName) {
1422 return readOnce(function (cb) {
1423 repo.getRefNames(true, function (err, refs) {
1424 if (err) return cb(err)
1425 cb(null, '<select name="rev" onchange="this.form.submit()">' +
1426 Object.keys(refs).map(function (group) {
1427 return '<optgroup label="' + group + '">' +
1428 refs[group].map(formatRevOptions(currentName)).join('') +
1429 '</optgroup>'
1430 }).join('') +
1431 '</select><noscript> <input type="submit" value="Go"/></noscript>')
1432 })
1433 })
1434 }
1435
1436 function branchMenu(repo, name, currentName) {
1437 return cat([
1438 pull.once('<select name="' + name + '">'),
1439 pull(
1440 repo.refs(),
1441 pull.map(function (ref) {
1442 var m = ref.name.match(/^refs\/([^\/]*)\/(.*)$/) || [,, ref.name]
1443 return m[1] == 'heads' && m[2]
1444 }),
1445 pull.filter(Boolean),
1446 pullSort(),
1447 pull.map(formatRevOptions(currentName))
1448 ),
1449 pull.once('</select>')
1450 ])
1451 }
1452
1453 /* Repo tree */
1454
1455 function renderRepoLatest(repo, rev) {
1456 return readOnce(function (cb) {
1457 repo.getCommitParsed(rev, function (err, commit) {
1458 if (err) return cb(err)
1459 var commitPath = [repo.id, 'commit', commit.id]
1460 cb(null,
1461 'Latest: <strong>' + link(commitPath, commit.title) +
1462 '</strong><br>' +
1463 '<tt>' + commit.id + '</tt><br> ' +
1464 escapeHTML(commit.committer.name) + ' committed on ' +
1465 commit.committer.date.toLocaleString() +
1466 (commit.separateAuthor ? '<br>' +
1467 escapeHTML(commit.author.name) + ' authored on ' +
1468 commit.author.date.toLocaleString() : ''))
1469 })
1470 })
1471 }
1472
1473 // breadcrumbs
1474 function linkPath(basePath, path) {
1475 path = path.slice()
1476 var last = path.pop()
1477 return path.map(function (dir, i) {
1478 return link(basePath.concat(path.slice(0, i+1)), dir)
1479 }).concat(last).join(' / ')
1480 }
1481
1482 function renderRepoTree(repo, rev, path) {
1483 var pathLinks = path.length === 0 ? '' :
1484 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1485 return cat([
1486 pull.once('<h3>Files' + pathLinks + '</h3>'),
1487 pull(
1488 repo.readDir(rev, path),
1489 pull.map(function (file) {
1490 var type = (file.mode === 040000) ? 'tree' :
1491 (file.mode === 0160000) ? 'commit' : 'blob'
1492 if (type == 'commit')
1493 return ['<span title="git commit link">🖈</span>', '<span title="' + escapeHTML(file.id) + '">' + escapeHTML(file.name) + '</span>']
1494 var filePath = [repo.id, type, rev].concat(path, file.name)
1495 return ['<i>' + (type == 'tree' ? '📁' : '📄') + '</i>',
1496 link(filePath, file.name)]
1497 }),
1498 table('class="files"')
1499 )
1500 ])
1501 }
1502
1503 /* Repo readme */
1504
1505 function renderRepoReadme(repo, branch, path) {
1506 return readNext(function (cb) {
1507 pull(
1508 repo.readDir(branch, path),
1509 pull.filter(function (file) {
1510 return /readme(\.|$)/i.test(file.name)
1511 }),
1512 pull.take(1),
1513 pull.collect(function (err, files) {
1514 if (err) return cb(null, pull.empty())
1515 var file = files[0]
1516 if (!file)
1517 return cb(null, pull.once(path.length ? '' : '<p>No readme</p>'))
1518 repo.getObjectFromAny(file.id, function (err, obj) {
1519 if (err) return cb(err)
1520 cb(null, cat([
1521 pull.once('<section><h4><a name="readme">' +
1522 escapeHTML(file.name) + '</a></h4><hr/>'),
1523 renderObjectData(obj, file.name, repo, branch, path),
1524 pull.once('</section>')
1525 ]))
1526 })
1527 })
1528 )
1529 })
1530 }
1531
1532 /* Repo commit */
1533
1534 function serveRepoCommit(repo, rev) {
1535 return renderRepoPage(repo, null, rev, cat([
1536 readNext(function (cb) {
1537 repo.getCommitParsed(rev, function (err, commit) {
1538 if (err) return cb(err)
1539 var commitPath = [repo.id, 'commit', commit.id]
1540 var treePath = [repo.id, 'tree', commit.id]
1541 cb(null, cat([pull.once(
1542 '<h3>' + link(commitPath, 'Commit ' + rev) + '</h3>' +
1543 '<section class="collapse">' +
1544 '<div class="right-bar">' +
1545 link(treePath, 'Browse Files') +
1546 '</div>' +
1547 '<h4>' + escapeHTML(commit.title) + '</h4>' +
1548 (commit.body ? pre(commit.body) : '') +
1549 (commit.separateAuthor ? escapeHTML(commit.author.name) +
1550 ' authored on ' + commit.author.date.toLocaleString() + '<br>'
1551 : '') +
1552 escapeHTML(commit.committer.name) + ' committed on ' +
1553 commit.committer.date.toLocaleString() + '<br/>' +
1554 commit.parents.map(function (id) {
1555 return 'Parent: ' + link([repo.id, 'commit', id], id)
1556 }).join('<br>') +
1557 '</section>' +
1558 '<section><h3>Files changed</h3>'),
1559 // TODO: show diff from all parents (merge commits)
1560 renderDiffStat([repo, repo], [commit.parents[0], commit.id]),
1561 pull.once('</section>')
1562 ]))
1563 })
1564 })
1565 ]))
1566 }
1567
1568 /* Diff stat */
1569
1570 function renderDiffStat(repos, treeIds) {
1571 if (treeIds.length == 0) treeIds = [null]
1572 var id = treeIds[0]
1573 var lastI = treeIds.length - 1
1574 var oldTree = treeIds[0]
1575 var changedFiles = []
1576 return cat([
1577 pull(
1578 Repo.diffTrees(repos, treeIds, true),
1579 pull.map(function (item) {
1580 var filename = escapeHTML(item.filename = item.path.join('/'))
1581 var oldId = item.id && item.id[0]
1582 var newId = item.id && item.id[lastI]
1583 var oldMode = item.mode && item.mode[0]
1584 var newMode = item.mode && item.mode[lastI]
1585 var action =
1586 !oldId && newId ? 'added' :
1587 oldId && !newId ? 'deleted' :
1588 oldMode != newMode ?
1589 'changed mode from ' + oldMode.toString(8) +
1590 ' to ' + newMode.toString(8) :
1591 'changed'
1592 if (item.id)
1593 changedFiles.push(item)
1594 var commitId = item.id[lastI] ? id : treeIds.filter(Boolean)[0]
1595 var blobsPath = treeIds[1]
1596 ? [repos[1].id, 'blob', treeIds[1]]
1597 : [repos[0].id, 'blob', treeIds[0]]
1598 var rawsPath = treeIds[1]
1599 ? [repos[1].id, 'raw', treeIds[1]]
1600 : [repos[0].id, 'raw', treeIds[0]]
1601 item.blobPath = blobsPath.concat(item.path)
1602 item.rawPath = rawsPath.concat(item.path)
1603 var fileHref = item.id ?
1604 '#' + encodeURIComponent(item.path.join('/')) :
1605 encodeLink(item.blobPath)
1606 return ['<a href="' + fileHref + '">' + filename + '</a>', action]
1607 }),
1608 table()
1609 ),
1610 pull(
1611 pull.values(changedFiles),
1612 paramap(function (item, cb) {
1613 var extension = getExtension(item.filename)
1614 if (extension in imgMimes) {
1615 var filename = escapeHTML(item.filename)
1616 return cb(null,
1617 '<pre><table class="code">' +
1618 '<tr><th id="' + escapeHTML(item.filename) + '">' +
1619 filename + '</th></tr>' +
1620 '<tr><td><img src="' + encodeLink(item.rawPath) + '"' +
1621 ' alt="' + filename + '"/></td></tr>' +
1622 '</table></pre>')
1623 }
1624 var done = multicb({ pluck: 1, spread: true })
1625 getRepoObjectString(repos[0], item.id[0], done())
1626 getRepoObjectString(repos[1], item.id[lastI], done())
1627 done(function (err, strOld, strNew) {
1628 if (err) return cb(err)
1629 cb(null, htmlLineDiff(item.filename, item.filename,
1630 strOld, strNew,
1631 encodeLink(item.blobPath)))
1632 })
1633 }, 4)
1634 )
1635 ])
1636 }
1637
1638 function htmlLineDiff(filename, anchor, oldStr, newStr, blobHref) {
1639 var diff = JsDiff.structuredPatch('', '', oldStr, newStr)
1640 var groups = diff.hunks.map(function (hunk) {
1641 var oldLine = hunk.oldStart
1642 var newLine = hunk.newStart
1643 var header = '<tr class="diff-hunk-header"><td colspan=2></td><td>' +
1644 '@@ -' + oldLine + ',' + hunk.oldLines + ' ' +
1645 '+' + newLine + ',' + hunk.newLines + ' @@' +
1646 '</td></tr>'
1647 return [header].concat(hunk.lines.map(function (line) {
1648 var s = line[0]
1649 if (s == '\\') return
1650 var html = highlight(line, getExtension(filename))
1651 var trClass = s == '+' ? 'diff-new' : s == '-' ? 'diff-old' : ''
1652 var lineNums = [s == '+' ? '' : oldLine++, s == '-' ? '' : newLine++]
1653 var id = [filename].concat(lineNums).join('-')
1654 return '<tr id="' + escapeHTML(id) + '" class="' + trClass + '">' +
1655 lineNums.map(function (num) {
1656 return '<td class="code-linenum">' +
1657 (num ? '<a href="#' + encodeURIComponent(id) + '">' +
1658 num + '</a>' : '') + '</td>'
1659 }).join('') +
1660 '<td class="code-text">' + html + '</td></tr>'
1661 }))
1662 })
1663 return '<pre><table class="code">' +
1664 '<tr><th colspan=3 id="' + escapeHTML(anchor) + '">' + filename +
1665 '<span class="right-bar">' +
1666 '<a href="' + blobHref + '">View</a> ' +
1667 '</span></th></tr>' +
1668 [].concat.apply([], groups).join('') +
1669 '</table></pre>'
1670 }
1671
1672 /* An unknown message linking to a repo */
1673
1674 function serveRepoSomething(req, repo, id, msg, path) {
1675 return renderRepoPage(repo, null, null,
1676 pull.once('<section><h3>' + link([id]) + '</h3>' +
1677 json(msg) + '</section>'))
1678 }
1679
1680 /* Repo update */
1681
1682 function objsArr(objs) {
1683 return Array.isArray(objs) ? objs :
1684 Object.keys(objs).map(function (sha1) {
1685 var obj = Object.create(objs[sha1])
1686 obj.sha1 = sha1
1687 return obj
1688 })
1689 }
1690
1691 function serveRepoUpdate(req, repo, id, msg, path) {
1692 var raw = req._u.query.raw != null
1693
1694 if (raw)
1695 return renderRepoPage(repo, 'activity', null, pull.once(
1696 '<a href="?" class="raw-link header-align">Info</a>' +
1697 '<h3>Update</h3>' +
1698 '<section class="collapse">' + json({key: id, value: msg}) + '</section>'))
1699
1700 // convert packs to old single-object style
1701 if (msg.content.indexes) {
1702 for (var i = 0; i < msg.content.indexes.length; i++) {
1703 msg.content.packs[i] = {
1704 pack: {link: msg.content.packs[i].link},
1705 idx: msg.content.indexes[i]
1706 }
1707 }
1708 }
1709
1710 return renderRepoPage(repo, 'activity', null, cat([
1711 pull.once(
1712 '<a href="?raw" class="raw-link header-align">Data</a>' +
1713 '<h3>Update</h3>' +
1714 renderRepoUpdate(repo, {key: id, value: msg}, true) +
1715 (msg.content.objects ? '<h3>Objects</h3>' +
1716 objsArr(msg.content.objects).map(renderObject).join('\n') : '') +
1717 (msg.content.packs ? '<h3>Packs</h3>' +
1718 msg.content.packs.map(renderPack).join('\n') : '')),
1719 cat(!msg.content.packs ? [] : [
1720 pull.once('<h3>Commits</h3>'),
1721 pull(
1722 pull.values(msg.content.packs),
1723 pull.asyncMap(function (pack, cb) {
1724 var done = multicb({ pluck: 1, spread: true })
1725 getBlob(pack.pack.link, done())
1726 getBlob(pack.idx.link, done())
1727 done(function (err, readPack, readIdx) {
1728 if (err) return cb(renderError(err))
1729 cb(null, gitPack.decodeWithIndex(repo, readPack, readIdx))
1730 })
1731 }),
1732 pull.flatten(),
1733 pull.asyncMap(function (obj, cb) {
1734 if (obj.type == 'commit')
1735 Repo.getCommitParsed(obj, cb)
1736 else
1737 pull(obj.read, pull.drain(null, cb))
1738 }),
1739 pull.filter(),
1740 pull.map(function (commit) {
1741 return renderCommit(repo, commit)
1742 })
1743 )
1744 ])
1745 ]))
1746 }
1747
1748 function renderObject(obj) {
1749 return '<section class="collapse">' +
1750 obj.type + ' ' + link([obj.link], obj.sha1) + '<br>' +
1751 obj.length + ' bytes' +
1752 '</section>'
1753 }
1754
1755 function renderPack(info) {
1756 return '<section class="collapse">' +
1757 (info.pack ? 'Pack: ' + link([info.pack.link]) + '<br>' : '') +
1758 (info.idx ? 'Index: ' + link([info.idx.link]) : '') + '</section>'
1759 }
1760
1761 /* Blob */
1762
1763 function serveRepoBlob(repo, rev, path) {
1764 return readNext(function (cb) {
1765 repo.getFile(rev, path, function (err, object) {
1766 if (err) return cb(null, serveBlobNotFound(repo.id, err))
1767 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1768 var pathLinks = path.length === 0 ? '' :
1769 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1770 var rawFilePath = [repo.id, 'raw', rev].concat(path)
1771 var dirPath = path.slice(0, path.length-1)
1772 var filename = path[path.length-1]
1773 var extension = getExtension(filename)
1774 cb(null, renderRepoPage(repo, 'code', rev, cat([
1775 pull.once('<section><form action="" method="get">' +
1776 '<h3>' + type + ': ' + rev + ' '),
1777 revMenu(repo, rev),
1778 pull.once('</h3></form>'),
1779 type == 'Branch' && renderRepoLatest(repo, rev),
1780 pull.once('</section><section class="collapse">' +
1781 '<h3>Files' + pathLinks + '</h3>' +
1782 '<div>' + object.length + ' bytes' +
1783 '<span class="raw-link">' + link(rawFilePath, 'Raw') + '</span>' +
1784 '</div></section>' +
1785 '<section>'),
1786 extension in imgMimes
1787 ? pull.once('<img src="' + encodeLink(rawFilePath) +
1788 '" alt="' + escapeHTML(filename) + '" />')
1789 : renderObjectData(object, filename, repo, rev, dirPath),
1790 pull.once('</section>')
1791 ])))
1792 })
1793 })
1794 }
1795
1796 function serveBlobNotFound(repoId, err) {
1797 return serveTemplate('Blob not found', 404)(pull.values([
1798 '<h2>Blob not found</h2>',
1799 '<p>Blob in repo ' + link([repoId]) + ' was not found</p>',
1800 '<pre>' + escapeHTML(err.stack) + '</pre>'
1801 ]))
1802 }
1803
1804 /* Raw blob */
1805
1806 function serveRepoRaw(repo, branch, path) {
1807 return readNext(function (cb) {
1808 repo.getFile(branch, path, function (err, object) {
1809 if (err) return cb(null, serveBuffer(404, 'Blob not found'))
1810 var extension = getExtension(path[path.length-1])
1811 var contentType = imgMimes[extension]
1812 cb(null, pull(object.read, serveRaw(object.length, contentType)))
1813 })
1814 })
1815 }
1816
1817 function serveRaw(length, contentType) {
1818 var inBody
1819 var headers = {
1820 'Content-Type': contentType || 'text/plain; charset=utf-8',
1821 'Cache-Control': 'max-age=31536000'
1822 }
1823 if (length != null)
1824 headers['Content-Length'] = length
1825 return function (read) {
1826 return function (end, cb) {
1827 if (inBody) return read(end, cb)
1828 if (end) return cb(true)
1829 cb(null, [200, headers])
1830 inBody = true
1831 }
1832 }
1833 }
1834
1835 function getBlob(key, cb) {
1836 ssb.blobs.want(key, function (err, got) {
1837 if (err) cb(err)
1838 else if (!got) cb(new Error('Missing blob ' + key))
1839 else cb(null, ssb.blobs.get(key))
1840 })
1841 }
1842
1843 function serveBlob(req, key) {
1844 getBlob(key, function (err, read) {
1845 if (err) cb(null, serveError(err))
1846 else if (!got) cb(null, serve404(req))
1847 else cb(null, serveRaw()(read))
1848 })
1849 }
1850
1851 /* Digs */
1852
1853 function serveRepoDigs(repo) {
1854 return readNext(function (cb) {
1855 getVotes(repo.id, function (err, votes) {
1856 cb(null, renderRepoPage(repo, null, null, cat([
1857 pull.once('<section><h3>Digs</h3>' +
1858 '<div>Total: ' + votes.upvotes + '</div>'),
1859 pull(
1860 pull.values(Object.keys(votes.upvoters)),
1861 paramap(function (feedId, cb) {
1862 about.getName(feedId, function (err, name) {
1863 if (err) return cb(err)
1864 cb(null, link([feedId], name))
1865 })
1866 }, 8),
1867 ul()
1868 ),
1869 pull.once('</section>')
1870 ])))
1871 })
1872 })
1873 }
1874
1875 /* Forks */
1876
1877 function getForks(repo, includeSelf) {
1878 return pull(
1879 cat([
1880 includeSelf && readOnce(function (cb) {
1881 getMsg(repo.id, function (err, value) {
1882 cb(err, value && {key: repo.id, value: value})
1883 })
1884 }),
1885 ssb.links({
1886 dest: repo.id,
1887 values: true,
1888 rel: 'upstream'
1889 })
1890 ]),
1891 pull.filter(function (msg) {
1892 return msg.value.content && msg.value.content.type == 'git-repo'
1893 }),
1894 paramap(function (msg, cb) {
1895 getRepoFullName(about, msg.value.author, msg.key,
1896 function (err, repoName, authorName) {
1897 if (err) return cb(err)
1898 cb(null, {
1899 key: msg.key,
1900 value: msg.value,
1901 repoName: repoName,
1902 authorName: authorName
1903 })
1904 })
1905 }, 8)
1906 )
1907 }
1908
1909 function serveRepoForks(repo) {
1910 var hasForks
1911 return renderRepoPage(repo, null, null, cat([
1912 pull.once('<h3>Forks</h3>'),
1913 pull(
1914 getForks(repo),
1915 pull.map(function (msg) {
1916 hasForks = true
1917 return '<section class="collapse">' +
1918 link([msg.value.author], msg.authorName) + ' / ' +
1919 link([msg.key], msg.repoName) +
1920 '<span class="right-bar">' +
1921 timestamp(msg.value.timestamp) +
1922 '</span></section>'
1923 })
1924 ),
1925 readOnce(function (cb) {
1926 cb(null, hasForks ? '' : 'No forks')
1927 })
1928 ]))
1929 }
1930
1931 /* Issues */
1932
1933 function serveRepoIssues(req, repo, path) {
1934 var numIssues = 0
1935 var state = req._u.query.state || 'open'
1936 return renderRepoPage(repo, 'issues', null, cat([
1937 pull.once(
1938 (isPublic ? '' :
1939 '<div class="right-bar">' + link([repo.id, 'issues', 'new'],
1940 '<button class="btn">&plus; New Issue</button>', true) +
1941 '</div>') +
1942 '<h3>Issues</h3>' +
1943 nav([
1944 ['?state=open', 'Open', 'open'],
1945 ['?state=closed', 'Closed', 'closed'],
1946 ['?state=all', 'All', 'all']
1947 ], state)),
1948 pull(
1949 issues.createFeedStream({ project: repo.id }),
1950 pull.filter(function (issue) {
1951 return state == 'all' ? true : (state == 'closed') == !issue.open
1952 }),
1953 pull.map(function (issue) {
1954 numIssues++
1955 var state = (issue.open ? 'open' : 'closed')
1956 return '<section class="collapse">' +
1957 '<i class="issue-state issue-state-' + state + '"' +
1958 ' title="' + ucfirst(state) + '">◼</i> ' +
1959 '<a href="' + encodeLink(issue.id) + '">' +
1960 escapeHTML(issue.title) +
1961 '<span class="right-bar">' +
1962 new Date(issue.created_at).toLocaleString() +
1963 '</span>' +
1964 '</a>' +
1965 '</section>'
1966 })
1967 ),
1968 readOnce(function (cb) {
1969 cb(null, numIssues > 0 ? '' : '<p>No issues</p>')
1970 })
1971 ]))
1972 }
1973
1974 /* Pull Requests */
1975
1976 function serveRepoPullReqs(req, repo) {
1977 var count = 0
1978 var state = req._u.query.state || 'open'
1979 return renderRepoPage(repo, 'pulls', null, cat([
1980 pull.once(
1981 (isPublic ? '' :
1982 '<div class="right-bar">' + link([repo.id, 'compare'],
1983 '<button class="btn">&plus; New Pull Request</button>', true) +
1984 '</div>') +
1985 '<h3>Pull Requests</h3>' +
1986 nav([
1987 ['?', 'Open', 'open'],
1988 ['?state=closed', 'Closed', 'closed'],
1989 ['?state=all', 'All', 'all']
1990 ], state)),
1991 pull(
1992 pullReqs.list({
1993 repo: repo.id,
1994 open: {open: true, closed: false}[state]
1995 }),
1996 pull.map(function (issue) {
1997 count++
1998 var state = (issue.open ? 'open' : 'closed')
1999 return '<section class="collapse">' +
2000 '<i class="issue-state issue-state-' + state + '"' +
2001 ' title="' + ucfirst(state) + '">◼</i> ' +
2002 '<a href="' + encodeLink(issue.id) + '">' +
2003 escapeHTML(issue.title) +
2004 '<span class="right-bar">' +
2005 new Date(issue.created_at).toLocaleString() +
2006 '</span>' +
2007 '</a>' +
2008 '</section>'
2009 })
2010 ),
2011 readOnce(function (cb) {
2012 cb(null, count > 0 ? '' : '<p>No pull requests</p>')
2013 })
2014 ]))
2015 }
2016
2017 /* New Issue */
2018
2019 function serveRepoNewIssue(repo, issueId, path) {
2020 return renderRepoPage(repo, 'issues', null, pull.once(
2021 '<h3>New Issue</h3>' +
2022 '<section><form action="" method="post">' +
2023 '<input type="hidden" name="action" value="new-issue">' +
2024 '<p><input class="wide-input" name="title" placeholder="Issue Title" size="77" /></p>' +
2025 renderPostForm(repo, 'Description', 8) +
2026 '<button type="submit" class="btn">Create</button>' +
2027 '</form></section>'))
2028 }
2029
2030 /* Issue */
2031
2032 function serveRepoIssue(req, repo, issue, path, postId) {
2033 var isAuthor = (myId == issue.author) || (myId == repo.feed)
2034 var newestMsg = {key: issue.id, value: {timestamp: issue.created_at}}
2035 return renderRepoPage(repo, 'issues', null, cat([
2036 pull.once(
2037 renderNameForm(!isPublic, issue.id, issue.title, 'issue-title', null,
2038 'Rename the issue',
2039 '<h3>' + link([issue.id], issue.title) + '</h3>') +
2040 '<code>' + issue.id + '</code>' +
2041 '<section class="collapse">' +
2042 (issue.open
2043 ? '<strong class="issue-status open">Open</strong>'
2044 : '<strong class="issue-status closed">Closed</strong>')),
2045 readOnce(function (cb) {
2046 about.getName(issue.author, function (err, authorName) {
2047 if (err) return cb(err)
2048 var authorLink = link([issue.author], authorName)
2049 cb(null, authorLink + ' opened this issue on ' +
2050 timestamp(issue.created_at))
2051 })
2052 }),
2053 pull.once('<hr/>' + markdown(issue.text, repo) + '</section>'),
2054 // render posts and edits
2055 pull(
2056 ssb.links({
2057 dest: issue.id,
2058 values: true
2059 }),
2060 pull.unique('key'),
2061 addAuthorName(about),
2062 sortMsgs(),
2063 pull.through(function (msg) {
2064 if (msg.value.timestamp > newestMsg.value.timestamp)
2065 newestMsg = msg
2066 }),
2067 pull.map(renderIssueActivityMsg.bind(null, repo, issue,
2068 'issue', postId))
2069 ),
2070 isPublic ? pull.empty() : readOnce(function (cb) {
2071 cb(null, renderIssueCommentForm(issue, repo, newestMsg.key, isAuthor,
2072 'issue'))
2073 })
2074 ]))
2075 }
2076
2077 function renderIssueActivityMsg(repo, issue, type, postId, msg) {
2078 var authorLink = link([msg.value.author], msg.authorName)
2079 var msgHref = encodeLink(msg.key) + '#' + encodeURIComponent(msg.key)
2080 var msgTimeLink = '<a href="' + msgHref + '"' +
2081 ' name="' + escapeHTML(msg.key) + '">' +
2082 escapeHTML(new Date(msg.value.timestamp).toLocaleString()) + '</a>'
2083 var c = msg.value.content
2084 switch (c.type) {
2085 case 'post':
2086 if (c.root == issue.id) {
2087 var changed = issues.isStatusChanged(msg, issue)
2088 return '<section class="collapse">' +
2089 (msg.key == postId ? '<div class="highlight">' : '') +
2090 '<tt class="right-bar item-id">' + msg.key + '</tt>' +
2091 authorLink +
2092 (changed == null ? '' : ' ' + (
2093 changed ? 'reopened this ' : 'closed this ') + type) +
2094 ' &middot; ' + msgTimeLink +
2095 (msg.key == postId ? '</div>' : '') +
2096 markdown(c.text, repo) +
2097 '</section>'
2098 } else {
2099 var text = c.text || (c.type + ' ' + msg.key)
2100 return '<section class="collapse mention-preview">' +
2101 authorLink + ' mentioned this issue in ' +
2102 '<a href="/' + msg.key + '#' + msg.key + '">' +
2103 String(text).substr(0, 140) + '</a>' +
2104 '</section>'
2105 }
2106 case 'issue':
2107 case 'pull-request':
2108 return '<section class="collapse mention-preview">' +
2109 authorLink + ' mentioned this ' + type + ' in ' +
2110 link([msg.key], String(c.title || msg.key).substr(0, 140)) +
2111 '</section>'
2112 case 'issue-edit':
2113 return '<section class="collapse">' +
2114 (msg.key == postId ? '<div class="highlight">' : '') +
2115 (c.title == null ? '' :
2116 authorLink + ' renamed this ' + type + ' to <q>' +
2117 escapeHTML(c.title) + '</q>') +
2118 ' &middot; ' + msgTimeLink +
2119 (msg.key == postId ? '</div>' : '') +
2120 '</section>'
2121 case 'git-update':
2122 var mention = issues.getMention(msg, issue)
2123 if (mention) {
2124 var commitLink = link([repo.id, 'commit', mention.object],
2125 mention.label || mention.object)
2126 return '<section class="collapse">' +
2127 authorLink + ' ' +
2128 (mention.open ? 'reopened this ' :
2129 'closed this ') + type +
2130 ' &middot; ' + msgTimeLink + '<br/>' +
2131 commitLink +
2132 '</section>'
2133 } else if ((mention = getMention(msg, issue.id))) {
2134 var commitLink = link(mention.object ?
2135 [repo.id, 'commit', mention.object] : [msg.key],
2136 mention.label || mention.object || msg.key)
2137 return '<section class="collapse">' +
2138 authorLink + ' mentioned this ' + type +
2139 ' &middot; ' + msgTimeLink + '<br/>' +
2140 commitLink +
2141 '</section>'
2142 } else {
2143 // fallthrough
2144 }
2145
2146 default:
2147 return '<section class="collapse">' +
2148 authorLink +
2149 ' &middot; ' + msgTimeLink +
2150 json(c) +
2151 '</section>'
2152 }
2153 }
2154
2155 function renderIssueCommentForm(issue, repo, branch, isAuthor, type) {
2156 return '<section><form action="" method="post">' +
2157 '<input type="hidden" name="action" value="comment">' +
2158 '<input type="hidden" name="id" value="' + issue.id + '">' +
2159 '<input type="hidden" name="issue" value="' + issue.id + '">' +
2160 '<input type="hidden" name="repo" value="' + repo.id + '">' +
2161 '<input type="hidden" name="branch" value="' + branch + '">' +
2162 renderPostForm(repo) +
2163 '<input type="submit" class="btn open" value="Comment" />' +
2164 (isAuthor ?
2165 '<input type="submit" class="btn"' +
2166 ' name="' + (issue.open ? 'close' : 'open') + '"' +
2167 ' value="' + (issue.open ? 'Close ' : 'Reopen ') + type + '"' +
2168 '/>' : '') +
2169 '</form></section>'
2170 }
2171
2172 /* Pull Request */
2173
2174 function serveRepoPullReq(req, repo, pr, path, postId) {
2175 var headRepo, authorLink
2176 var page = path[0] || 'activity'
2177 return renderRepoPage(repo, 'pulls', null, cat([
2178 pull.once('<div class="pull-request">' +
2179 renderNameForm(!isPublic, pr.id, pr.title, 'issue-title', null,
2180 'Rename the pull request',
2181 '<h3>' + link([pr.id], pr.title) + '</h3>') +
2182 '<code>' + pr.id + '</code>'),
2183 readOnce(function (cb) {
2184 var done = multicb({ pluck: 1, spread: true })
2185 about.getName(pr.author, done())
2186 var sameRepo = (pr.headRepo == pr.baseRepo)
2187 getRepo(pr.headRepo, function (err, headRepo) {
2188 if (err) return cb(err)
2189 done()(null, headRepo)
2190 getRepoName(about, headRepo.feed, headRepo.id, done())
2191 about.getName(headRepo.feed, done())
2192 })
2193
2194 done(function (err, issueAuthorName, _headRepo,
2195 headRepoName, headRepoAuthorName) {
2196 if (err) return cb(err)
2197 headRepo = _headRepo
2198 authorLink = link([pr.author], issueAuthorName)
2199 var repoLink = link([pr.headRepo], headRepoName)
2200 var headRepoAuthorLink = link([headRepo.feed], headRepoAuthorName)
2201 var headRepoLink = link([headRepo.id], headRepoName)
2202 var headBranchLink = link([headRepo.id, 'tree', pr.headBranch])
2203 var baseBranchLink = link([repo.id, 'tree', pr.baseBranch])
2204 cb(null, '<section class="collapse">' +
2205 (pr.open
2206 ? '<strong class="issue-status open">Open</strong>'
2207 : '<strong class="issue-status closed">Closed</strong>') +
2208 authorLink + ' wants to merge commits into ' +
2209 '<code>' + baseBranchLink + '</code> from ' +
2210 (sameRepo ? '<code>' + headBranchLink + '</code>' :
2211 '<code class="bgslash">' +
2212 headRepoAuthorLink + ' / ' +
2213 headRepoLink + ' / ' +
2214 headBranchLink + '</code>') +
2215 '</section>')
2216 })
2217 }),
2218 pull.once(
2219 nav([
2220 [[pr.id], 'Discussion', 'activity'],
2221 [[pr.id, 'commits'], 'Commits', 'commits'],
2222 [[pr.id, 'files'], 'Files', 'files']
2223 ], page)),
2224 readNext(function (cb) {
2225 if (page == 'commits') cb(null,
2226 renderPullReqCommits(pr, repo, headRepo))
2227 else if (page == 'files') cb(null,
2228 renderPullReqFiles(pr, repo, headRepo))
2229 else cb(null,
2230 renderPullReqActivity(pr, repo, headRepo, authorLink, postId))
2231 })
2232 ]))
2233 }
2234
2235 function renderPullReqCommits(pr, baseRepo, headRepo) {
2236 return cat([
2237 pull.once('<section>'),
2238 renderCommitLog(baseRepo, pr.baseBranch, headRepo, pr.headBranch),
2239 pull.once('</section>')
2240 ])
2241 }
2242
2243 function renderPullReqFiles(pr, baseRepo, headRepo) {
2244 return cat([
2245 pull.once('<section>'),
2246 renderDiffStat([baseRepo, headRepo], [pr.baseBranch, pr.headBranch]),
2247 pull.once('</section>')
2248 ])
2249 }
2250
2251 function renderPullReqActivity(pr, repo, headRepo, authorLink, postId) {
2252 var msgTimeLink = link([pr.id], new Date(pr.created_at).toLocaleString())
2253 var newestMsg = {key: pr.id, value: {timestamp: pr.created_at}}
2254 var isAuthor = (myId == pr.author) || (myId == repo.feed)
2255 return cat([
2256 readOnce(function (cb) {
2257 cb(null,
2258 '<section class="collapse">' +
2259 authorLink + ' &middot; ' + msgTimeLink +
2260 markdown(pr.text, repo) + '</section>')
2261 }),
2262 // render posts, edits, and updates
2263 pull(
2264 many([
2265 ssb.links({
2266 dest: pr.id,
2267 values: true
2268 }),
2269 readNext(function (cb) {
2270 cb(null, pull(
2271 ssb.links({
2272 dest: headRepo.id,
2273 source: headRepo.feed,
2274 rel: 'repo',
2275 values: true,
2276 reverse: true
2277 }),
2278 pull.take(function (link) {
2279 return link.value.timestamp > pr.created_at
2280 }),
2281 pull.filter(function (link) {
2282 return link.value.content.type == 'git-update'
2283 && ('refs/heads/' + pr.headBranch) in link.value.content.refs
2284 })
2285 ))
2286 })
2287 ]),
2288 addAuthorName(about),
2289 pull.unique('key'),
2290 pull.through(function (msg) {
2291 if (msg.value.timestamp > newestMsg.value.timestamp)
2292 newestMsg = msg
2293 }),
2294 sortMsgs(),
2295 pull.map(function (item) {
2296 if (item.value.content.type == 'git-update')
2297 return renderBranchUpdate(pr, item)
2298 return renderIssueActivityMsg(repo, pr,
2299 'pull request', postId, item)
2300 })
2301 ),
2302 !isPublic && isAuthor && pull.once(
2303 '<section class="merge-instructions">' +
2304 '<input type="checkbox" class="toggle" id="merge-instructions"/>' +
2305 '<h4><label for="merge-instructions" class="toggle-link"><a>' +
2306 'Merge via command line…' +
2307 '</a></label></h4>' +
2308 '<div class="contents">' +
2309 '<p>Check out the branch and test the changes:</p>' +
2310 '<pre>' +
2311 'git fetch ssb://' + escapeHTML(pr.headRepo) + ' ' +
2312 escapeHTML(pr.headBranch) + '\n' +
2313 'git checkout -b ' + escapeHTML(pr.headBranch) + ' FETCH_HEAD' +
2314 '</pre>' +
2315 '<p>Merge the changes and push to update the base branch:</p>' +
2316 '<pre>' +
2317 'git checkout ' + escapeHTML(pr.baseBranch) + '\n' +
2318 'git merge ' + escapeHTML(pr.headBranch) + '\n' +
2319 'git push ssb ' + escapeHTML(pr.baseBranch) +
2320 '</pre>' +
2321 '</div></section>'),
2322 !isPublic && readOnce(function (cb) {
2323 cb(null, renderIssueCommentForm(pr, repo, newestMsg.key, isAuthor,
2324 'pull request'))
2325 })
2326 ])
2327 }
2328
2329 function renderBranchUpdate(pr, msg) {
2330 var authorLink = link([msg.value.author], msg.authorName)
2331 var msgLink = link([msg.key],
2332 new Date(msg.value.timestamp).toLocaleString())
2333 var rev = msg.value.content.refs['refs/heads/' + pr.headBranch]
2334 if (!rev)
2335 return '<section class="collapse">' +
2336 authorLink + ' deleted the <code>' + pr.headBranch + '</code> branch' +
2337 ' &middot; ' + msgLink +
2338 '</section>'
2339
2340 var revLink = link([pr.headRepo, 'commit', rev], rev.substr(0, 8))
2341 return '<section class="collapse">' +
2342 authorLink + ' updated the branch to <code>' + revLink + '</code>' +
2343 ' &middot; ' + msgLink +
2344 '</section>'
2345 }
2346
2347 /* Compare changes */
2348
2349 function serveRepoCompare(req, repo) {
2350 var query = req._u.query
2351 var base
2352 var count = 0
2353
2354 return renderRepoPage(repo, 'pulls', null, cat([
2355 pull.once('<h3>Compare changes</h3>' +
2356 '<form action="' + encodeLink(repo.id) + '/comparing" method="get">' +
2357 '<section>'),
2358 pull.once('Base branch: '),
2359 readNext(function (cb) {
2360 if (query.base) gotBase(null, query.base)
2361 else repo.getSymRef('HEAD', true, gotBase)
2362 function gotBase(err, ref) {
2363 if (err) return cb(err)
2364 cb(null, branchMenu(repo, 'base', base = ref || 'HEAD'))
2365 }
2366 }),
2367 pull.once('<br/>Comparison repo/branch:'),
2368 pull(
2369 getForks(repo, true),
2370 pull.asyncMap(function (msg, cb) {
2371 getRepo(msg.key, function (err, repo) {
2372 if (err) return cb(err)
2373 cb(null, {
2374 msg: msg,
2375 repo: repo
2376 })
2377 })
2378 }),
2379 pull.map(renderFork),
2380 pull.flatten()
2381 ),
2382 pull.once('</section>'),
2383 readOnce(function (cb) {
2384 cb(null, count == 0 ? 'No branches to compare!' :
2385 '<button type="submit" class="btn">Compare</button>')
2386 }),
2387 pull.once('</form>')
2388 ]))
2389
2390 function renderFork(fork) {
2391 return pull(
2392 fork.repo.refs(),
2393 pull.map(function (ref) {
2394 var m = /^refs\/([^\/]*)\/(.*)$/.exec(ref.name) || [,ref.name]
2395 return {
2396 type: m[1],
2397 name: m[2],
2398 value: ref.value
2399 }
2400 }),
2401 pull.filter(function (ref) {
2402 return ref.type == 'heads'
2403 && !(ref.name == base && fork.msg.key == repo.id)
2404 }),
2405 pull.map(function (ref) {
2406 var branchLink = link([fork.msg.key, 'tree', ref.name], ref.name)
2407 var authorLink = link([fork.msg.value.author], fork.msg.authorName)
2408 var repoLink = link([fork.msg.key], fork.msg.repoName)
2409 var value = fork.msg.key + ':' + ref.name
2410 count++
2411 return '<div class="bgslash">' +
2412 '<input type="radio" name="head"' +
2413 ' value="' + escapeHTML(value) + '"' +
2414 (query.head == value ? ' checked="checked"' : '') + '> ' +
2415 authorLink + ' / ' + repoLink + ' / ' + branchLink + '</div>'
2416 })
2417 )
2418 }
2419 }
2420
2421 function serveRepoComparing(req, repo) {
2422 var query = req._u.query
2423 var baseBranch = query.base
2424 var s = (query.head || '').split(':')
2425
2426 if (!s || !baseBranch)
2427 return serveRedirect(encodeLink([repo.id, 'compare']))
2428
2429 var headRepoId = s[0]
2430 var headBranch = s[1]
2431 var baseLink = link([repo.id, 'tree', baseBranch])
2432 var headBranchLink = link([headRepoId, 'tree', headBranch])
2433 var backHref = encodeLink([repo.id, 'compare']) + req._u.search
2434
2435 return renderRepoPage(repo, 'pulls', null, cat([
2436 pull.once('<h3>' +
2437 (query.expand ? 'Open a pull request' : 'Comparing changes') +
2438 '</h3>'),
2439 readNext(function (cb) {
2440 getRepo(headRepoId, function (err, headRepo) {
2441 if (err) return cb(err)
2442 getRepoFullName(about, headRepo.feed, headRepo.id,
2443 function (err, repoName, authorName) {
2444 if (err) return cb(err)
2445 cb(null, renderRepoInfo(Repo(headRepo), repoName, authorName))
2446 }
2447 )
2448 })
2449 })
2450 ]))
2451
2452 function renderRepoInfo(headRepo, headRepoName, headRepoAuthorName) {
2453 var authorLink = link([headRepo.feed], headRepoAuthorName)
2454 var repoLink = link([headRepoId], headRepoName)
2455 return cat([
2456 pull.once('<section>' +
2457 'Base: ' + baseLink + '<br/>' +
2458 'Head: <span class="bgslash">' + authorLink + ' / ' + repoLink +
2459 ' / ' + headBranchLink + '</span>' +
2460 '</section>' +
2461 (query.expand ? '<section><form method="post" action="">' +
2462 hiddenInputs({
2463 action: 'new-pull',
2464 branch: baseBranch,
2465 head_repo: headRepoId,
2466 head_branch: headBranch
2467 }) +
2468 '<input class="wide-input" name="title"' +
2469 ' placeholder="Title" size="77"/>' +
2470 renderPostForm(repo, 'Description', 8) +
2471 '<button type="submit" class="btn open">Create</button>' +
2472 '</form></section>'
2473 : '<section><form method="get" action="">' +
2474 hiddenInputs({
2475 base: baseBranch,
2476 head: query.head
2477 }) +
2478 '<button class="btn open" type="submit" name="expand" value="1">' +
2479 '<i>⎇</i> Create pull request</button> ' +
2480 '<a href="' + backHref + '">Back</a>' +
2481 '</form></section>') +
2482 '<div id="commits"></div>' +
2483 '<div class="tab-links">' +
2484 '<a href="#" id="files-link">Files changed</a> ' +
2485 '<a href="#commits" id="commits-link">Commits</a>' +
2486 '</div>' +
2487 '<section id="files-tab">'),
2488 renderDiffStat([repo, headRepo], [baseBranch, headBranch]),
2489 pull.once('</section>' +
2490 '<section id="commits-tab">'),
2491 renderCommitLog(repo, baseBranch, headRepo, headBranch),
2492 pull.once('</section>')
2493 ])
2494 }
2495 }
2496
2497 function renderCommitLog(baseRepo, baseBranch, headRepo, headBranch) {
2498 return cat([
2499 pull.once('<table class="compare-commits">'),
2500 readNext(function (cb) {
2501 baseRepo.resolveRef(baseBranch, function (err, baseBranchRev) {
2502 if (err) return cb(err)
2503 var currentDay
2504 return cb(null, pull(
2505 headRepo.readLog(headBranch),
2506 pull.take(function (rev) { return rev != baseBranchRev }),
2507 pullReverse(),
2508 paramap(headRepo.getCommitParsed.bind(headRepo), 8),
2509 pull.map(function (commit) {
2510 var commitPath = [baseRepo.id, 'commit', commit.id]
2511 var commitIdShort = '<tt>' + commit.id.substr(0, 8) + '</tt>'
2512 var day = Math.floor(commit.author.date / 86400000)
2513 var dateRow = day == currentDay ? '' :
2514 '<tr><th colspan=3 class="date-info">' +
2515 commit.author.date.toLocaleDateString() +
2516 '</th><tr>'
2517 currentDay = day
2518 return dateRow + '<tr>' +
2519 '<td>' + escapeHTML(commit.author.name) + '</td>' +
2520 '<td>' + link(commitPath, commit.title) + '</td>' +
2521 '<td>' + link(commitPath, commitIdShort, true) + '</td>' +
2522 '</tr>'
2523 })
2524 ))
2525 })
2526 }),
2527 pull.once('</table>')
2528 ])
2529 }
2530}
2531

Built with git-ssb-web