git ssb

30+

cel / git-ssb-web



Tree: 92f4e60ca809289c2fa0fdca618514d8bc0d8157

Files: 92f4e60ca809289c2fa0fdca618514d8bc0d8157 / index.js

84428 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 blobsPath = item.id[1]
1595 ? [repos[1].id, 'blob', treeIds[1]]
1596 : [repos[0].id, 'blob', treeIds[0]]
1597 var rawsPath = item.id[1]
1598 ? [repos[1].id, 'raw', treeIds[1]]
1599 : [repos[0].id, 'raw', treeIds[0]]
1600 item.blobPath = blobsPath.concat(item.path)
1601 item.rawPath = rawsPath.concat(item.path)
1602 var fileHref = item.id ?
1603 '#' + encodeURIComponent(item.path.join('/')) :
1604 encodeLink(item.blobPath)
1605 return ['<a href="' + fileHref + '">' + filename + '</a>', action]
1606 }),
1607 table()
1608 ),
1609 pull(
1610 pull.values(changedFiles),
1611 paramap(function (item, cb) {
1612 var extension = getExtension(item.filename)
1613 if (extension in imgMimes) {
1614 var filename = escapeHTML(item.filename)
1615 return cb(null,
1616 '<pre><table class="code">' +
1617 '<tr><th id="' + escapeHTML(item.filename) + '">' +
1618 filename + '</th></tr>' +
1619 '<tr><td><img src="' + encodeLink(item.rawPath) + '"' +
1620 ' alt="' + filename + '"/></td></tr>' +
1621 '</table></pre>')
1622 }
1623 var done = multicb({ pluck: 1, spread: true })
1624 getRepoObjectString(repos[0], item.id[0], done())
1625 getRepoObjectString(repos[1], item.id[lastI], done())
1626 done(function (err, strOld, strNew) {
1627 if (err) return cb(err)
1628 cb(null, htmlLineDiff(item.filename, item.filename,
1629 strOld, strNew,
1630 encodeLink(item.blobPath)))
1631 })
1632 }, 4)
1633 )
1634 ])
1635 }
1636
1637 function htmlLineDiff(filename, anchor, oldStr, newStr, blobHref) {
1638 var diff = JsDiff.structuredPatch('', '', oldStr, newStr)
1639 var groups = diff.hunks.map(function (hunk) {
1640 var oldLine = hunk.oldStart
1641 var newLine = hunk.newStart
1642 var header = '<tr class="diff-hunk-header"><td colspan=2></td><td>' +
1643 '@@ -' + oldLine + ',' + hunk.oldLines + ' ' +
1644 '+' + newLine + ',' + hunk.newLines + ' @@' +
1645 '</td></tr>'
1646 return [header].concat(hunk.lines.map(function (line) {
1647 var s = line[0]
1648 if (s == '\\') return
1649 var html = highlight(line, getExtension(filename))
1650 var trClass = s == '+' ? 'diff-new' : s == '-' ? 'diff-old' : ''
1651 var lineNums = [s == '+' ? '' : oldLine++, s == '-' ? '' : newLine++]
1652 var id = [filename].concat(lineNums).join('-')
1653 return '<tr id="' + escapeHTML(id) + '" class="' + trClass + '">' +
1654 lineNums.map(function (num) {
1655 return '<td class="code-linenum">' +
1656 (num ? '<a href="#' + encodeURIComponent(id) + '">' +
1657 num + '</a>' : '') + '</td>'
1658 }).join('') +
1659 '<td class="code-text">' + html + '</td></tr>'
1660 }))
1661 })
1662 return '<pre><table class="code">' +
1663 '<tr><th colspan=3 id="' + escapeHTML(anchor) + '">' + filename +
1664 '<span class="right-bar">' +
1665 '<a href="' + blobHref + '">View</a> ' +
1666 '</span></th></tr>' +
1667 [].concat.apply([], groups).join('') +
1668 '</table></pre>'
1669 }
1670
1671 /* An unknown message linking to a repo */
1672
1673 function serveRepoSomething(req, repo, id, msg, path) {
1674 return renderRepoPage(repo, null, null,
1675 pull.once('<section><h3>' + link([id]) + '</h3>' +
1676 json(msg) + '</section>'))
1677 }
1678
1679 /* Repo update */
1680
1681 function objsArr(objs) {
1682 return Array.isArray(objs) ? objs :
1683 Object.keys(objs).map(function (sha1) {
1684 var obj = Object.create(objs[sha1])
1685 obj.sha1 = sha1
1686 return obj
1687 })
1688 }
1689
1690 function serveRepoUpdate(req, repo, id, msg, path) {
1691 var raw = req._u.query.raw != null
1692
1693 if (raw)
1694 return renderRepoPage(repo, 'activity', null, pull.once(
1695 '<a href="?" class="raw-link header-align">Info</a>' +
1696 '<h3>Update</h3>' +
1697 '<section class="collapse">' + json({key: id, value: msg}) + '</section>'))
1698
1699 // convert packs to old single-object style
1700 if (msg.content.indexes) {
1701 for (var i = 0; i < msg.content.indexes.length; i++) {
1702 msg.content.packs[i] = {
1703 pack: {link: msg.content.packs[i].link},
1704 idx: msg.content.indexes[i]
1705 }
1706 }
1707 }
1708
1709 return renderRepoPage(repo, 'activity', null, cat([
1710 pull.once(
1711 '<a href="?raw" class="raw-link header-align">Data</a>' +
1712 '<h3>Update</h3>' +
1713 renderRepoUpdate(repo, {key: id, value: msg}, true) +
1714 (msg.content.objects ? '<h3>Objects</h3>' +
1715 objsArr(msg.content.objects).map(renderObject).join('\n') : '') +
1716 (msg.content.packs ? '<h3>Packs</h3>' +
1717 msg.content.packs.map(renderPack).join('\n') : '')),
1718 cat(!msg.content.packs ? [] : [
1719 pull.once('<h3>Commits</h3>'),
1720 pull(
1721 pull.values(msg.content.packs),
1722 pull.asyncMap(function (pack, cb) {
1723 var done = multicb({ pluck: 1, spread: true })
1724 getBlob(pack.pack.link, done())
1725 getBlob(pack.idx.link, done())
1726 done(function (err, readPack, readIdx) {
1727 if (err) return cb(renderError(err))
1728 cb(null, gitPack.decodeWithIndex(repo, readPack, readIdx))
1729 })
1730 }),
1731 pull.flatten(),
1732 pull.asyncMap(function (obj, cb) {
1733 if (obj.type == 'commit')
1734 Repo.getCommitParsed(obj, cb)
1735 else
1736 pull(obj.read, pull.drain(null, cb))
1737 }),
1738 pull.filter(),
1739 pull.map(function (commit) {
1740 return renderCommit(repo, commit)
1741 })
1742 )
1743 ])
1744 ]))
1745 }
1746
1747 function renderObject(obj) {
1748 return '<section class="collapse">' +
1749 obj.type + ' ' + link([obj.link], obj.sha1) + '<br>' +
1750 obj.length + ' bytes' +
1751 '</section>'
1752 }
1753
1754 function renderPack(info) {
1755 return '<section class="collapse">' +
1756 (info.pack ? 'Pack: ' + link([info.pack.link]) + '<br>' : '') +
1757 (info.idx ? 'Index: ' + link([info.idx.link]) : '') + '</section>'
1758 }
1759
1760 /* Blob */
1761
1762 function serveRepoBlob(repo, rev, path) {
1763 return readNext(function (cb) {
1764 repo.getFile(rev, path, function (err, object) {
1765 if (err) return cb(null, serveBlobNotFound(repo.id, err))
1766 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1767 var pathLinks = path.length === 0 ? '' :
1768 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1769 var rawFilePath = [repo.id, 'raw', rev].concat(path)
1770 var dirPath = path.slice(0, path.length-1)
1771 var filename = path[path.length-1]
1772 var extension = getExtension(filename)
1773 cb(null, renderRepoPage(repo, 'code', rev, cat([
1774 pull.once('<section><form action="" method="get">' +
1775 '<h3>' + type + ': ' + rev + ' '),
1776 revMenu(repo, rev),
1777 pull.once('</h3></form>'),
1778 type == 'Branch' && renderRepoLatest(repo, rev),
1779 pull.once('</section><section class="collapse">' +
1780 '<h3>Files' + pathLinks + '</h3>' +
1781 '<div>' + object.length + ' bytes' +
1782 '<span class="raw-link">' + link(rawFilePath, 'Raw') + '</span>' +
1783 '</div></section>' +
1784 '<section>'),
1785 extension in imgMimes
1786 ? pull.once('<img src="' + encodeLink(rawFilePath) +
1787 '" alt="' + escapeHTML(filename) + '" />')
1788 : renderObjectData(object, filename, repo, rev, dirPath),
1789 pull.once('</section>')
1790 ])))
1791 })
1792 })
1793 }
1794
1795 function serveBlobNotFound(repoId, err) {
1796 return serveTemplate('Blob not found', 404)(pull.values([
1797 '<h2>Blob not found</h2>',
1798 '<p>Blob in repo ' + link([repoId]) + ' was not found</p>',
1799 '<pre>' + escapeHTML(err.stack) + '</pre>'
1800 ]))
1801 }
1802
1803 /* Raw blob */
1804
1805 function serveRepoRaw(repo, branch, path) {
1806 return readNext(function (cb) {
1807 repo.getFile(branch, path, function (err, object) {
1808 if (err) return cb(null, serveBuffer(404, 'Blob not found'))
1809 var extension = getExtension(path[path.length-1])
1810 var contentType = imgMimes[extension]
1811 cb(null, pull(object.read, serveRaw(object.length, contentType)))
1812 })
1813 })
1814 }
1815
1816 function serveRaw(length, contentType) {
1817 var inBody
1818 var headers = {
1819 'Content-Type': contentType || 'text/plain; charset=utf-8',
1820 'Cache-Control': 'max-age=31536000'
1821 }
1822 if (length != null)
1823 headers['Content-Length'] = length
1824 return function (read) {
1825 return function (end, cb) {
1826 if (inBody) return read(end, cb)
1827 if (end) return cb(true)
1828 cb(null, [200, headers])
1829 inBody = true
1830 }
1831 }
1832 }
1833
1834 function getBlob(key, cb) {
1835 ssb.blobs.want(key, function (err, got) {
1836 if (err) cb(err)
1837 else if (!got) cb(new Error('Missing blob ' + key))
1838 else cb(null, ssb.blobs.get(key))
1839 })
1840 }
1841
1842 function serveBlob(req, key) {
1843 getBlob(key, function (err, read) {
1844 if (err) cb(null, serveError(err))
1845 else if (!got) cb(null, serve404(req))
1846 else cb(null, serveRaw()(read))
1847 })
1848 }
1849
1850 /* Digs */
1851
1852 function serveRepoDigs(repo) {
1853 return readNext(function (cb) {
1854 getVotes(repo.id, function (err, votes) {
1855 cb(null, renderRepoPage(repo, null, null, cat([
1856 pull.once('<section><h3>Digs</h3>' +
1857 '<div>Total: ' + votes.upvotes + '</div>'),
1858 pull(
1859 pull.values(Object.keys(votes.upvoters)),
1860 paramap(function (feedId, cb) {
1861 about.getName(feedId, function (err, name) {
1862 if (err) return cb(err)
1863 cb(null, link([feedId], name))
1864 })
1865 }, 8),
1866 ul()
1867 ),
1868 pull.once('</section>')
1869 ])))
1870 })
1871 })
1872 }
1873
1874 /* Forks */
1875
1876 function getForks(repo, includeSelf) {
1877 return pull(
1878 cat([
1879 includeSelf && readOnce(function (cb) {
1880 getMsg(repo.id, function (err, value) {
1881 cb(err, value && {key: repo.id, value: value})
1882 })
1883 }),
1884 ssb.links({
1885 dest: repo.id,
1886 values: true,
1887 rel: 'upstream'
1888 })
1889 ]),
1890 pull.filter(function (msg) {
1891 return msg.value.content && msg.value.content.type == 'git-repo'
1892 }),
1893 paramap(function (msg, cb) {
1894 getRepoFullName(about, msg.value.author, msg.key,
1895 function (err, repoName, authorName) {
1896 if (err) return cb(err)
1897 cb(null, {
1898 key: msg.key,
1899 value: msg.value,
1900 repoName: repoName,
1901 authorName: authorName
1902 })
1903 })
1904 }, 8)
1905 )
1906 }
1907
1908 function serveRepoForks(repo) {
1909 var hasForks
1910 return renderRepoPage(repo, null, null, cat([
1911 pull.once('<h3>Forks</h3>'),
1912 pull(
1913 getForks(repo),
1914 pull.map(function (msg) {
1915 hasForks = true
1916 return '<section class="collapse">' +
1917 link([msg.value.author], msg.authorName) + ' / ' +
1918 link([msg.key], msg.repoName) +
1919 '<span class="right-bar">' +
1920 timestamp(msg.value.timestamp) +
1921 '</span></section>'
1922 })
1923 ),
1924 readOnce(function (cb) {
1925 cb(null, hasForks ? '' : 'No forks')
1926 })
1927 ]))
1928 }
1929
1930 /* Issues */
1931
1932 function serveRepoIssues(req, repo, path) {
1933 var numIssues = 0
1934 var state = req._u.query.state || 'open'
1935 return renderRepoPage(repo, 'issues', null, cat([
1936 pull.once(
1937 (isPublic ? '' :
1938 '<div class="right-bar">' + link([repo.id, 'issues', 'new'],
1939 '<button class="btn">&plus; New Issue</button>', true) +
1940 '</div>') +
1941 '<h3>Issues</h3>' +
1942 nav([
1943 ['?state=open', 'Open', 'open'],
1944 ['?state=closed', 'Closed', 'closed'],
1945 ['?state=all', 'All', 'all']
1946 ], state)),
1947 pull(
1948 issues.createFeedStream({ project: repo.id }),
1949 pull.filter(function (issue) {
1950 return state == 'all' ? true : (state == 'closed') == !issue.open
1951 }),
1952 pull.map(function (issue) {
1953 numIssues++
1954 var state = (issue.open ? 'open' : 'closed')
1955 return '<section class="collapse">' +
1956 '<i class="issue-state issue-state-' + state + '"' +
1957 ' title="' + ucfirst(state) + '">◼</i> ' +
1958 '<a href="' + encodeLink(issue.id) + '">' +
1959 escapeHTML(issue.title) +
1960 '<span class="right-bar">' +
1961 new Date(issue.created_at).toLocaleString() +
1962 '</span>' +
1963 '</a>' +
1964 '</section>'
1965 })
1966 ),
1967 readOnce(function (cb) {
1968 cb(null, numIssues > 0 ? '' : '<p>No issues</p>')
1969 })
1970 ]))
1971 }
1972
1973 /* Pull Requests */
1974
1975 function serveRepoPullReqs(req, repo) {
1976 var count = 0
1977 var state = req._u.query.state || 'open'
1978 return renderRepoPage(repo, 'pulls', null, cat([
1979 pull.once(
1980 (isPublic ? '' :
1981 '<div class="right-bar">' + link([repo.id, 'compare'],
1982 '<button class="btn">&plus; New Pull Request</button>', true) +
1983 '</div>') +
1984 '<h3>Pull Requests</h3>' +
1985 nav([
1986 ['?', 'Open', 'open'],
1987 ['?state=closed', 'Closed', 'closed'],
1988 ['?state=all', 'All', 'all']
1989 ], state)),
1990 pull(
1991 pullReqs.list({
1992 repo: repo.id,
1993 open: {open: true, closed: false}[state]
1994 }),
1995 pull.map(function (issue) {
1996 count++
1997 var state = (issue.open ? 'open' : 'closed')
1998 return '<section class="collapse">' +
1999 '<i class="issue-state issue-state-' + state + '"' +
2000 ' title="' + ucfirst(state) + '">◼</i> ' +
2001 '<a href="' + encodeLink(issue.id) + '">' +
2002 escapeHTML(issue.title) +
2003 '<span class="right-bar">' +
2004 new Date(issue.created_at).toLocaleString() +
2005 '</span>' +
2006 '</a>' +
2007 '</section>'
2008 })
2009 ),
2010 readOnce(function (cb) {
2011 cb(null, count > 0 ? '' : '<p>No pull requests</p>')
2012 })
2013 ]))
2014 }
2015
2016 /* New Issue */
2017
2018 function serveRepoNewIssue(repo, issueId, path) {
2019 return renderRepoPage(repo, 'issues', null, pull.once(
2020 '<h3>New Issue</h3>' +
2021 '<section><form action="" method="post">' +
2022 '<input type="hidden" name="action" value="new-issue">' +
2023 '<p><input class="wide-input" name="title" placeholder="Issue Title" size="77" /></p>' +
2024 renderPostForm(repo, 'Description', 8) +
2025 '<button type="submit" class="btn">Create</button>' +
2026 '</form></section>'))
2027 }
2028
2029 /* Issue */
2030
2031 function serveRepoIssue(req, repo, issue, path, postId) {
2032 var isAuthor = (myId == issue.author) || (myId == repo.feed)
2033 var newestMsg = {key: issue.id, value: {timestamp: issue.created_at}}
2034 return renderRepoPage(repo, 'issues', null, cat([
2035 pull.once(
2036 renderNameForm(!isPublic, issue.id, issue.title, 'issue-title', null,
2037 'Rename the issue',
2038 '<h3>' + link([issue.id], issue.title) + '</h3>') +
2039 '<code>' + issue.id + '</code>' +
2040 '<section class="collapse">' +
2041 (issue.open
2042 ? '<strong class="issue-status open">Open</strong>'
2043 : '<strong class="issue-status closed">Closed</strong>')),
2044 readOnce(function (cb) {
2045 about.getName(issue.author, function (err, authorName) {
2046 if (err) return cb(err)
2047 var authorLink = link([issue.author], authorName)
2048 cb(null, authorLink + ' opened this issue on ' +
2049 timestamp(issue.created_at))
2050 })
2051 }),
2052 pull.once('<hr/>' + markdown(issue.text, repo) + '</section>'),
2053 // render posts and edits
2054 pull(
2055 ssb.links({
2056 dest: issue.id,
2057 values: true
2058 }),
2059 pull.unique('key'),
2060 addAuthorName(about),
2061 sortMsgs(),
2062 pull.through(function (msg) {
2063 if (msg.value.timestamp > newestMsg.value.timestamp)
2064 newestMsg = msg
2065 }),
2066 pull.map(renderIssueActivityMsg.bind(null, repo, issue,
2067 'issue', postId))
2068 ),
2069 isPublic ? pull.empty() : readOnce(function (cb) {
2070 cb(null, renderIssueCommentForm(issue, repo, newestMsg.key, isAuthor,
2071 'issue'))
2072 })
2073 ]))
2074 }
2075
2076 function renderIssueActivityMsg(repo, issue, type, postId, msg) {
2077 var authorLink = link([msg.value.author], msg.authorName)
2078 var msgHref = encodeLink(msg.key) + '#' + encodeURIComponent(msg.key)
2079 var msgTimeLink = '<a href="' + msgHref + '"' +
2080 ' name="' + escapeHTML(msg.key) + '">' +
2081 escapeHTML(new Date(msg.value.timestamp).toLocaleString()) + '</a>'
2082 var c = msg.value.content
2083 switch (c.type) {
2084 case 'post':
2085 if (c.root == issue.id) {
2086 var changed = issues.isStatusChanged(msg, issue)
2087 return '<section class="collapse">' +
2088 (msg.key == postId ? '<div class="highlight">' : '') +
2089 '<tt class="right-bar item-id">' + msg.key + '</tt>' +
2090 authorLink +
2091 (changed == null ? '' : ' ' + (
2092 changed ? 'reopened this ' : 'closed this ') + type) +
2093 ' &middot; ' + msgTimeLink +
2094 (msg.key == postId ? '</div>' : '') +
2095 markdown(c.text, repo) +
2096 '</section>'
2097 } else {
2098 var text = c.text || (c.type + ' ' + msg.key)
2099 return '<section class="collapse mention-preview">' +
2100 authorLink + ' mentioned this issue in ' +
2101 '<a href="/' + msg.key + '#' + msg.key + '">' +
2102 String(text).substr(0, 140) + '</a>' +
2103 '</section>'
2104 }
2105 case 'issue':
2106 case 'pull-request':
2107 return '<section class="collapse mention-preview">' +
2108 authorLink + ' mentioned this ' + type + ' in ' +
2109 link([msg.key], String(c.title || msg.key).substr(0, 140)) +
2110 '</section>'
2111 case 'issue-edit':
2112 return '<section class="collapse">' +
2113 (msg.key == postId ? '<div class="highlight">' : '') +
2114 (c.title == null ? '' :
2115 authorLink + ' renamed this ' + type + ' to <q>' +
2116 escapeHTML(c.title) + '</q>') +
2117 ' &middot; ' + msgTimeLink +
2118 (msg.key == postId ? '</div>' : '') +
2119 '</section>'
2120 case 'git-update':
2121 var mention = issues.getMention(msg, issue)
2122 if (mention) {
2123 var commitLink = link([repo.id, 'commit', mention.object],
2124 mention.label || mention.object)
2125 return '<section class="collapse">' +
2126 authorLink + ' ' +
2127 (mention.open ? 'reopened this ' :
2128 'closed this ') + type +
2129 ' &middot; ' + msgTimeLink + '<br/>' +
2130 commitLink +
2131 '</section>'
2132 } else if ((mention = getMention(msg, issue.id))) {
2133 var commitLink = link(mention.object ?
2134 [repo.id, 'commit', mention.object] : [msg.key],
2135 mention.label || mention.object || msg.key)
2136 return '<section class="collapse">' +
2137 authorLink + ' mentioned this ' + type +
2138 ' &middot; ' + msgTimeLink + '<br/>' +
2139 commitLink +
2140 '</section>'
2141 } else {
2142 // fallthrough
2143 }
2144
2145 default:
2146 return '<section class="collapse">' +
2147 authorLink +
2148 ' &middot; ' + msgTimeLink +
2149 json(c) +
2150 '</section>'
2151 }
2152 }
2153
2154 function renderIssueCommentForm(issue, repo, branch, isAuthor, type) {
2155 return '<section><form action="" method="post">' +
2156 '<input type="hidden" name="action" value="comment">' +
2157 '<input type="hidden" name="id" value="' + issue.id + '">' +
2158 '<input type="hidden" name="issue" value="' + issue.id + '">' +
2159 '<input type="hidden" name="repo" value="' + repo.id + '">' +
2160 '<input type="hidden" name="branch" value="' + branch + '">' +
2161 renderPostForm(repo) +
2162 '<input type="submit" class="btn open" value="Comment" />' +
2163 (isAuthor ?
2164 '<input type="submit" class="btn"' +
2165 ' name="' + (issue.open ? 'close' : 'open') + '"' +
2166 ' value="' + (issue.open ? 'Close ' : 'Reopen ') + type + '"' +
2167 '/>' : '') +
2168 '</form></section>'
2169 }
2170
2171 /* Pull Request */
2172
2173 function serveRepoPullReq(req, repo, pr, path, postId) {
2174 var headRepo, authorLink
2175 var page = path[0] || 'activity'
2176 return renderRepoPage(repo, 'pulls', null, cat([
2177 pull.once('<div class="pull-request">' +
2178 renderNameForm(!isPublic, pr.id, pr.title, 'issue-title', null,
2179 'Rename the pull request',
2180 '<h3>' + link([pr.id], pr.title) + '</h3>') +
2181 '<code>' + pr.id + '</code>'),
2182 readOnce(function (cb) {
2183 var done = multicb({ pluck: 1, spread: true })
2184 var gotHeadRepo = done()
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 getRepoName(about, headRepo.feed, headRepo.id, done())
2190 about.getName(headRepo.feed, done())
2191 gotHeadRepo(null, Repo(headRepo))
2192 })
2193
2194 done(function (err, _headRepo, issueAuthorName,
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 = [headRepo.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