git ssb

30+

cel / git-ssb-web



Tree: 15209016c9f99db95578158f7ba1bef4f4894a9b

Files: 15209016c9f99db95578158f7ba1bef4f4894a9b / index.js

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

Built with git-ssb-web