git ssb

30+

cel / git-ssb-web



Tree: c67bef29b1c8cd1c9d8ede3633f8f6d5aae2eccf

Files: c67bef29b1c8cd1c9d8ede3633f8f6d5aae2eccf / index.js

84928 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 'fork-prompt':
540 return cb(null, serveRedirect(encodeLink([data.id, 'fork'])))
541
542 case 'fork':
543 var repoId = data.id
544 if (!repoId) return cb(null,
545 serveError(new Error('Missing repo id'), 400))
546 return ssbGit.createRepo(ssb, {upstream: repoId},
547 function (err, repo) {
548 if (err) return cb(null, serveError(err))
549 cb(null, serveRedirect(encodeLink(repo.id)))
550 })
551
552 case 'vote':
553 var voteValue = +data.value || 0
554 if (!data.id)
555 return cb(null, serveError(new Error('Missing vote id'), 400))
556 var msg = schemas.vote(data.id, voteValue)
557 return ssb.publish(msg, function (err) {
558 if (err) return cb(null, serveError(err))
559 cb(null, serveRedirect(req.url))
560 })
561
562 case 'repo-name':
563 if (!data.name)
564 return cb(null, serveError(new Error('Missing name'), 400))
565 if (!data.id)
566 return cb(null, serveError(new Error('Missing id'), 400))
567 var msg = schemas.name(data.id, data.name)
568 return ssb.publish(msg, function (err) {
569 if (err) return cb(null, serveError(err))
570 cb(null, serveRedirect(req.url))
571 })
572
573 case 'issue-title':
574 if (!data.name)
575 return cb(null, serveError(new Error('Missing name'), 400))
576 if (!data.id)
577 return cb(null, serveError(new Error('Missing id'), 400))
578 var msg = Issues.schemas.edit(data.id, {title: data.name})
579 return ssb.publish(msg, function (err) {
580 if (err) return cb(null, serveError(err))
581 cb(null, serveRedirect(req.url))
582 })
583
584 case 'comment':
585 if (!data.id)
586 return cb(null, serveError(new Error('Missing id'), 400))
587
588 var msg = schemas.post(data.text, data.id, data.branch || data.id)
589 msg.issue = data.issue
590 msg.repo = data.repo
591 if (data.open != null)
592 Issues.schemas.opens(msg, data.id)
593 if (data.close != null)
594 Issues.schemas.closes(msg, data.id)
595 var mentions = Mentions(data.text)
596 if (mentions.length)
597 msg.mentions = mentions
598 return ssb.publish(msg, function (err) {
599 if (err) return cb(null, serveError(err))
600 cb(null, serveRedirect(req.url))
601 })
602
603 case 'new-issue':
604 var msg = Issues.schemas.new(dir, data.title, data.text)
605 var mentions = Mentions(data.text)
606 if (mentions.length)
607 msg.mentions = mentions
608 return ssb.publish(msg, function (err, msg) {
609 if (err) return cb(null, serveError(err))
610 cb(null, serveRedirect(encodeLink(msg.key)))
611 })
612
613 case 'new-pull':
614 var msg = PullRequests.schemas.new(dir, data.branch,
615 data.head_repo, data.head_branch, data.title, data.text)
616 var mentions = Mentions(data.text)
617 if (mentions.length)
618 msg.mentions = mentions
619 return ssb.publish(msg, function (err, msg) {
620 if (err) return cb(null, serveError(err))
621 cb(null, serveRedirect(encodeLink(msg.key)))
622 })
623
624 case 'markdown':
625 return cb(null, serveMarkdown(data.text, {id: data.repo}))
626
627 default:
628 cb(null, serveBuffer(400, 'What are you trying to do?'))
629 }
630 })
631 })
632 }
633
634 if (dir == '')
635 return serveIndex(req)
636 else if (dir == 'search')
637 return serveSearch(req)
638 else if (ref.isBlobId(dir))
639 return serveBlob(req, dir)
640 else if (ref.isMsgId(dir))
641 return serveMessage(req, dir, dirs.slice(1))
642 else if (ref.isFeedId(dir))
643 return serveUserPage(req, dir, dirs.slice(1))
644 else if (dir == 'static')
645 return serveFile(req, dirs)
646 else if (dir == 'highlight')
647 return serveFile(req, [hlCssPath].concat(dirs.slice(1)), true)
648 else
649 return serve404(req)
650 }
651
652 function serveFile(req, dirs, outside) {
653 var filename = path.resolve.apply(path, [__dirname].concat(dirs))
654 // prevent escaping base dir
655 if (!outside && filename.indexOf('../') === 0)
656 return serveBuffer(403, '403 Forbidden')
657
658 return readNext(function (cb) {
659 fs.stat(filename, function (err, stats) {
660 cb(null, err ?
661 err.code == 'ENOENT' ? serve404(req)
662 : serveBuffer(500, err.message)
663 : 'if-modified-since' in req.headers &&
664 new Date(req.headers['if-modified-since']) >= stats.mtime ?
665 pull.once([304])
666 : stats.isDirectory() ?
667 serveBuffer(403, 'Directory not listable')
668 : cat([
669 pull.once([200, {
670 'Content-Type': getContentType(filename),
671 'Content-Length': stats.size,
672 'Last-Modified': stats.mtime.toGMTString()
673 }]),
674 toPull(fs.createReadStream(filename))
675 ]))
676 })
677 })
678 }
679
680 function servePlainError(code, msg) {
681 return pull.values([
682 [code, {
683 'Content-Length': Buffer.byteLength(msg),
684 'Content-Type': 'text/plain; charset=utf-8'
685 }],
686 msg
687 ])
688 }
689
690 function serveBuffer(code, buf, contentType, headers) {
691 headers = headers || {}
692 headers['Content-Type'] = contentType || 'text/plain; charset=utf-8'
693 headers['Content-Length'] = Buffer.byteLength(buf)
694 return pull.values([
695 [code, headers],
696 buf
697 ])
698 }
699
700 function serve404(req) {
701 return serveBuffer(404, '404 Not Found')
702 }
703
704 function serveRedirect(path) {
705 return serveBuffer(302,
706 '<!doctype><html><head>' +
707 '<title>Redirect</title></head><body>' +
708 '<p><a href="' + escapeHTML(path) + '">Continue</a></p>' +
709 '</body></html>', 'text/html; charset=utf-8', {Location: path})
710 }
711
712 function serveMarkdown(text, repo) {
713 return serveBuffer(200, markdown(text, repo), 'text/html; charset=utf-8')
714 }
715
716 function renderError(err, tag) {
717 tag = tag || 'h3'
718 return '<' + tag + '>' + err.name + '</' + tag + '>' +
719 '<pre>' + escapeHTML(err.stack) + '</pre>'
720 }
721
722 function renderTry(read) {
723 var ended
724 return function (end, cb) {
725 if (ended) return cb(ended)
726 read(end, function (err, data) {
727 if (err === true)
728 cb(true)
729 else if (err) {
730 ended = true
731 cb(null, renderError(err, 'h3'))
732 } else
733 cb(null, data)
734 })
735 }
736 }
737
738 function serveTemplate(title, code, req, read) {
739 if (read === undefined) return serveTemplate.bind(this, title, code, req)
740 var q = req && req._u.query.q && escapeHTML(req._u.query.q) || ''
741 return cat([
742 pull.values([
743 [code || 200, {
744 'Content-Type': 'text/html'
745 }],
746 '<!doctype html><html><head><meta charset=utf-8>',
747 '<title>' + escapeHTML(title || 'git ssb') + '</title>',
748 '<link rel=stylesheet href="/static/styles.css"/>',
749 '<link rel=stylesheet href="/highlight/github.css"/>',
750 '</head>\n',
751 '<body>',
752 '<header><form action="/search" method="get">' +
753 '<h1><a href="/">git ssb' +
754 (ssbAppname != 'ssb' ? ' <sub>' + ssbAppname + '</sub>' : '') +
755 '</a> ' +
756 '<input class="search-bar" name="q" size="60"' +
757 ' placeholder="🔍" value="' + q + '" />' +
758 '</h1>',
759 '</form></header>',
760 '<article>']),
761 renderTry(read),
762 pull.once('<hr/></article></body></html>')
763 ])
764 }
765
766 function serveError(err, status) {
767 if (err.message == 'stream is closed')
768 reconnect()
769 return pull(
770 pull.once(renderError(err, 'h2')),
771 serveTemplate(err.name, status || 500)
772 )
773 }
774
775 function renderObjectData(obj, filename, repo, rev, path) {
776 var ext = getExtension(filename)
777 return readOnce(function (cb) {
778 readObjectString(obj, function (err, buf) {
779 buf = buf.toString('utf8')
780 if (err) return cb(err)
781 cb(null, (ext == 'md' || ext == 'markdown')
782 ? markdown(buf, {repo: repo, rev: rev, path: path})
783 : renderCodeTable(buf, ext))
784 })
785 })
786 }
787
788 function renderCodeTable(buf, ext) {
789 return '<pre><table class="code">' +
790 highlight(buf, ext).split('\n').map(function (line, i) {
791 i++
792 return '<tr id="L' + i + '">' +
793 '<td class="code-linenum">' + '<a href="#L' + i + '">' + i + '</td>' +
794 '<td class="code-text">' + line + '</td></tr>'
795 }).join('') +
796 '</table></pre>'
797 }
798
799 /* Feed */
800
801 function renderFeed(req, feedId, filter) {
802 var query = req._u.query
803 var opts = {
804 reverse: !query.forwards,
805 lt: query.lt && +query.lt || Date.now(),
806 gt: query.gt && +query.gt,
807 id: feedId
808 }
809 return pull(
810 feedId ? ssb.createUserStream(opts) : ssb.createFeedStream(opts),
811 pull.filter(function (msg) {
812 return msg.value.content.type in msgTypes
813 }),
814 typeof filter == 'function' ? filter(opts) : filter,
815 pull.take(20),
816 addAuthorName(about),
817 query.forwards && pullReverse(),
818 paginate(
819 function (first, cb) {
820 if (!query.lt && !query.gt) return cb(null, '')
821 var gt = feedId ? first.value.sequence : first.value.timestamp + 1
822 query.gt = gt
823 query.forwards = 1
824 delete query.lt
825 cb(null, '<a href="?' + qs.stringify(query) + '">Newer</a>')
826 },
827 paramap(renderFeedItem, 8),
828 function (last, cb) {
829 query.lt = feedId ? last.value.sequence : last.value.timestamp - 1
830 delete query.gt
831 delete query.forwards
832 cb(null, '<a href="?' + qs.stringify(query) + '">Older</a>')
833 },
834 function (cb) {
835 if (query.forwards) {
836 delete query.gt
837 delete query.forwards
838 query.lt = opts.gt + 1
839 } else {
840 delete query.lt
841 query.gt = opts.lt - 1
842 query.forwards = 1
843 }
844 cb(null, '<a href="?' + qs.stringify(query) + '">' +
845 (query.forwards ? 'Older' : 'Newer') + '</a>')
846 }
847 )
848 )
849 }
850
851 function renderFeedItem(msg, cb) {
852 var c = msg.value.content
853 var msgLink = link([msg.key],
854 new Date(msg.value.timestamp).toLocaleString())
855 var author = msg.value.author
856 var authorLink = link([msg.value.author], msg.authorName)
857 switch (c.type) {
858 case 'git-repo':
859 var done = multicb({ pluck: 1, spread: true })
860 getRepoName(about, author, msg.key, done())
861 if (c.upstream) {
862 return getMsg(c.upstream, function (err, upstreamMsg) {
863 if (err) return cb(null, serveError(err))
864 getRepoName(about, upstreamMsg.author, c.upstream, done())
865 done(function (err, repoName, upstreamName) {
866 cb(null, '<section class="collapse">' + msgLink + '<br>' +
867 authorLink + ' forked ' + link([c.upstream], upstreamName) +
868 ' to ' + link([msg.key], repoName) + '</section>')
869 })
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 'fork':
1129 return serveRepoForkPrompt(repo)
1130 case 'forks':
1131 return serveRepoForks(repo)
1132 case 'issues':
1133 switch (path[1]) {
1134 case 'new':
1135 if (filePath.length == 0)
1136 return serveRepoNewIssue(repo)
1137 break
1138 default:
1139 return serveRepoIssues(req, repo, filePath)
1140 }
1141 case 'pulls':
1142 return serveRepoPullReqs(req, repo)
1143 case 'compare':
1144 return serveRepoCompare(req, repo)
1145 case 'comparing':
1146 return serveRepoComparing(req, repo)
1147 default:
1148 return serve404(req)
1149 }
1150 }
1151
1152 function serveRepoNotFound(id, err) {
1153 return serveTemplate('Repo not found', 404)(pull.values([
1154 '<h2>Repo not found</h2>',
1155 '<p>Repo ' + id + ' was not found</p>',
1156 '<pre>' + escapeHTML(err.stack) + '</pre>',
1157 ]))
1158 }
1159
1160 function renderRepoPage(repo, page, branch, body) {
1161 var gitUrl = 'ssb://' + repo.id
1162 var gitLink = '<input class="clone-url" readonly="readonly" ' +
1163 'value="' + gitUrl + '" size="45" ' +
1164 'onclick="this.select()"/>'
1165 var digsPath = [repo.id, 'digs']
1166
1167 var done = multicb({ pluck: 1, spread: true })
1168 getRepoName(about, repo.feed, repo.id, done())
1169 about.getName(repo.feed, done())
1170 getVotes(repo.id, done())
1171
1172 if (repo.upstream) {
1173 getRepoName(about, repo.upstream.feed, repo.upstream.id, done())
1174 about.getName(repo.upstream.feed, done())
1175 }
1176
1177 return readNext(function (cb) {
1178 done(function (err, repoName, authorName, votes,
1179 upstreamName, upstreamAuthorName) {
1180 if (err) return cb(null, serveError(err))
1181 var upvoted = votes.upvoters[myId] > 0
1182 var upstreamLink = !repo.upstream ? '' :
1183 link([repo.upstream])
1184 cb(null, serveTemplate(repo.id)(cat([
1185 pull.once(
1186 '<div class="repo-title">' +
1187 '<form class="right-bar" action="" method="post">' +
1188 '<button class="btn" name="action" value="vote" ' +
1189 (isPublic ? 'disabled="disabled"' : ' type="submit"') + '>' +
1190 '<i>✌</i> ' + (!isPublic && upvoted ? 'Undig' : 'Dig') +
1191 '</button>' +
1192 (isPublic ? '' : '<input type="hidden" name="value" value="' +
1193 (upvoted ? '0' : '1') + '">' +
1194 '<input type="hidden" name="id" value="' +
1195 escapeHTML(repo.id) + '">') + ' ' +
1196 '<strong>' + link(digsPath, votes.upvotes) + '</strong> ' +
1197 (isPublic ? '' : '<button class="btn" type="submit" ' +
1198 ' name="action" value="fork-prompt">' +
1199 '<i>⑂</i> Fork' +
1200 '</button>') + ' ' +
1201 link([repo.id, 'forks'], '+', false, ' title="Forks"') +
1202 '</form>' +
1203 renderNameForm(!isPublic, repo.id, repoName, 'repo-name', null,
1204 'Rename the repo',
1205 '<h2 class="bgslash">' + link([repo.feed], authorName) + ' / ' +
1206 link([repo.id], repoName) + '</h2>') +
1207 '</div>' +
1208 (repo.upstream ?
1209 '<small>forked from ' +
1210 link([repo.upstream.feed], upstreamAuthorName) + '\'s ' +
1211 link([repo.upstream.id], upstreamName) +
1212 '</small>' : '') +
1213 nav([
1214 [[repo.id], 'Code', 'code'],
1215 [[repo.id, 'activity'], 'Activity', 'activity'],
1216 [[repo.id, 'commits', branch || ''], 'Commits', 'commits'],
1217 [[repo.id, 'issues'], 'Issues', 'issues'],
1218 [[repo.id, 'pulls'], 'Pull Requests', 'pulls']
1219 ], page, gitLink)),
1220 body
1221 ])))
1222 })
1223 })
1224 }
1225
1226 function serveEmptyRepo(repo) {
1227 if (repo.feed != myId)
1228 return renderRepoPage(repo, 'code', null, pull.once(
1229 '<section>' +
1230 '<h3>Empty repository</h3>' +
1231 '</section>'))
1232
1233 var gitUrl = 'ssb://' + repo.id
1234 return renderRepoPage(repo, 'code', null, pull.once(
1235 '<section>' +
1236 '<h3>Getting started</h3>' +
1237 '<h4>Create a new repository</h4><pre>' +
1238 'touch README.md\n' +
1239 'git init\n' +
1240 'git add README.md\n' +
1241 'git commit -m "Initial commit"\n' +
1242 'git remote add origin ' + gitUrl + '\n' +
1243 'git push -u origin master</pre>\n' +
1244 '<h4>Push an existing repository</h4>\n' +
1245 '<pre>git remote add origin ' + gitUrl + '\n' +
1246 'git push -u origin master</pre>' +
1247 '</section>'))
1248 }
1249
1250 function serveRepoTree(repo, rev, path) {
1251 if (!rev) return serveEmptyRepo(repo)
1252 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1253 return renderRepoPage(repo, 'code', rev, cat([
1254 pull.once('<section><form action="" method="get">' +
1255 '<h3>' + type + ': ' + rev + ' '),
1256 revMenu(repo, rev),
1257 pull.once('</h3></form>'),
1258 type == 'Branch' && renderRepoLatest(repo, rev),
1259 pull.once('</section><section>'),
1260 renderRepoTree(repo, rev, path),
1261 pull.once('</section>'),
1262 renderRepoReadme(repo, rev, path)
1263 ]))
1264 }
1265
1266 /* Search */
1267
1268 function serveSearch(req) {
1269 var q = String(req._u.query.q || '')
1270 if (!q) return serveIndex(req)
1271 var qId = q.replace(/^ssb:\/*/, '')
1272 if (ref.type(qId))
1273 return serveRedirect(encodeURI(qId))
1274
1275 var search = new RegExp(q, 'i')
1276 return serveTemplate('git ssb search', 200, req)(
1277 renderFeed(req, null, function (opts) {
1278 opts.type == 'about'
1279 return function (read) {
1280 return pull(
1281 many([
1282 getRepoNames(opts),
1283 read
1284 ]),
1285 pull.filter(function (msg) {
1286 var c = msg.value.content
1287 return (
1288 search.test(msg.key) ||
1289 c.text && search.test(c.text) ||
1290 c.name && search.test(c.name) ||
1291 c.title && search.test(c.title))
1292 })
1293 )
1294 }
1295 })
1296 )
1297 }
1298
1299 function getRepoNames(opts) {
1300 return pull(
1301 ssb.messagesByType({
1302 type: 'about',
1303 reverse: opts.reverse,
1304 lt: opts.lt,
1305 gt: opts.gt,
1306 }),
1307 pull.filter(function (msg) {
1308 return '%' == String(msg.value.content.about)[0]
1309 && msg.value.content.name
1310 })
1311 )
1312 }
1313
1314 /* Repo activity */
1315
1316 function serveRepoActivity(repo, branch) {
1317 return renderRepoPage(repo, 'activity', branch, cat([
1318 pull.once('<h3>Activity</h3>'),
1319 pull(
1320 ssb.links({
1321 dest: repo.id,
1322 source: repo.feed,
1323 rel: 'repo',
1324 values: true,
1325 reverse: true
1326 }),
1327 pull.map(renderRepoUpdate.bind(this, repo))
1328 ),
1329 readOnce(function (cb) {
1330 var done = multicb({ pluck: 1, spread: true })
1331 about.getName(repo.feed, done())
1332 getMsg(repo.id, done())
1333 done(function (err, authorName, msg) {
1334 if (err) return cb(err)
1335 renderFeedItem({
1336 key: repo.id,
1337 value: msg,
1338 authorName: authorName
1339 }, cb)
1340 })
1341 })
1342 ]))
1343 }
1344
1345 function renderRepoUpdate(repo, msg, full) {
1346 var c = msg.value.content
1347
1348 if (c.type != 'git-update') {
1349 return ''
1350 // return renderFeedItem(msg, cb)
1351 // TODO: render post, issue, pull-request
1352 }
1353
1354 var refs = c.refs ? Object.keys(c.refs).map(function (ref) {
1355 return {name: ref, value: c.refs[ref]}
1356 }) : []
1357 var numObjects = c.objects ? Object.keys(c.objects).length : 0
1358
1359 return '<section class="collapse">' +
1360 link([msg.key], new Date(msg.value.timestamp).toLocaleString()) +
1361 '<br>' +
1362 (numObjects ? 'Pushed ' + numObjects + ' objects<br>' : '') +
1363 refs.map(function (update) {
1364 var name = escapeHTML(update.name)
1365 if (!update.value) {
1366 return 'Deleted ' + name
1367 } else {
1368 var commitLink = link([repo.id, 'commit', update.value])
1369 return name + ' &rarr; ' + commitLink
1370 }
1371 }).join('<br>') +
1372 '</section>'
1373 }
1374
1375 /* Repo commits */
1376
1377 function serveRepoCommits(req, repo, branch) {
1378 var query = req._u.query
1379 return renderRepoPage(repo, 'commits', branch, cat([
1380 pull.once('<h3>Commits</h3>'),
1381 pull(
1382 repo.readLog(query.start || branch),
1383 pull.take(20),
1384 paramap(repo.getCommitParsed.bind(repo), 8),
1385 paginate(
1386 !query.start ? '' : function (first, cb) {
1387 cb(null, '&hellip;')
1388 },
1389 pull.map(renderCommit.bind(this, repo)),
1390 function (commit, cb) {
1391 cb(null, commit.parents && commit.parents[0] ?
1392 '<a href="?start=' + commit.id + '">Older</a>' : '')
1393 }
1394 )
1395 )
1396 ]))
1397 }
1398
1399 function renderCommit(repo, commit) {
1400 var commitPath = [repo.id, 'commit', commit.id]
1401 var treePath = [repo.id, 'tree', commit.id]
1402 return '<section class="collapse">' +
1403 '<strong>' + link(commitPath, commit.title) + '</strong><br>' +
1404 '<tt>' + commit.id + '</tt> ' +
1405 link(treePath, 'Tree') + '<br>' +
1406 escapeHTML(commit.author.name) + ' &middot; ' + commit.author.date.toLocaleString() +
1407 (commit.separateAuthor ? '<br>' + escapeHTML(commit.committer.name) + ' committed on ' + commit.committer.date.toLocaleString() : "") +
1408 '</section>'
1409}
1410
1411 /* Branch menu */
1412
1413 function formatRevOptions(currentName) {
1414 return function (name) {
1415 var htmlName = escapeHTML(name)
1416 return '<option value="' + htmlName + '"' +
1417 (name == currentName ? ' selected="selected"' : '') +
1418 '>' + htmlName + '</option>'
1419 }
1420 }
1421
1422 function revMenu(repo, currentName) {
1423 return readOnce(function (cb) {
1424 repo.getRefNames(true, function (err, refs) {
1425 if (err) return cb(err)
1426 cb(null, '<select name="rev" onchange="this.form.submit()">' +
1427 Object.keys(refs).map(function (group) {
1428 return '<optgroup label="' + group + '">' +
1429 refs[group].map(formatRevOptions(currentName)).join('') +
1430 '</optgroup>'
1431 }).join('') +
1432 '</select><noscript> <input type="submit" value="Go"/></noscript>')
1433 })
1434 })
1435 }
1436
1437 function branchMenu(repo, name, currentName) {
1438 return cat([
1439 pull.once('<select name="' + name + '">'),
1440 pull(
1441 repo.refs(),
1442 pull.map(function (ref) {
1443 var m = ref.name.match(/^refs\/([^\/]*)\/(.*)$/) || [,, ref.name]
1444 return m[1] == 'heads' && m[2]
1445 }),
1446 pull.filter(Boolean),
1447 pullSort(),
1448 pull.map(formatRevOptions(currentName))
1449 ),
1450 pull.once('</select>')
1451 ])
1452 }
1453
1454 /* Repo tree */
1455
1456 function renderRepoLatest(repo, rev) {
1457 return readOnce(function (cb) {
1458 repo.getCommitParsed(rev, function (err, commit) {
1459 if (err) return cb(err)
1460 var commitPath = [repo.id, 'commit', commit.id]
1461 cb(null,
1462 'Latest: <strong>' + link(commitPath, commit.title) +
1463 '</strong><br>' +
1464 '<tt>' + commit.id + '</tt><br> ' +
1465 escapeHTML(commit.committer.name) + ' committed on ' +
1466 commit.committer.date.toLocaleString() +
1467 (commit.separateAuthor ? '<br>' +
1468 escapeHTML(commit.author.name) + ' authored on ' +
1469 commit.author.date.toLocaleString() : ''))
1470 })
1471 })
1472 }
1473
1474 // breadcrumbs
1475 function linkPath(basePath, path) {
1476 path = path.slice()
1477 var last = path.pop()
1478 return path.map(function (dir, i) {
1479 return link(basePath.concat(path.slice(0, i+1)), dir)
1480 }).concat(last).join(' / ')
1481 }
1482
1483 function renderRepoTree(repo, rev, path) {
1484 var pathLinks = path.length === 0 ? '' :
1485 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1486 return cat([
1487 pull.once('<h3>Files' + pathLinks + '</h3>'),
1488 pull(
1489 repo.readDir(rev, path),
1490 pull.map(function (file) {
1491 var type = (file.mode === 040000) ? 'tree' :
1492 (file.mode === 0160000) ? 'commit' : 'blob'
1493 if (type == 'commit')
1494 return ['<span title="git commit link">🖈</span>', '<span title="' + escapeHTML(file.id) + '">' + escapeHTML(file.name) + '</span>']
1495 var filePath = [repo.id, type, rev].concat(path, file.name)
1496 return ['<i>' + (type == 'tree' ? '📁' : '📄') + '</i>',
1497 link(filePath, file.name)]
1498 }),
1499 table('class="files"')
1500 )
1501 ])
1502 }
1503
1504 /* Repo readme */
1505
1506 function renderRepoReadme(repo, branch, path) {
1507 return readNext(function (cb) {
1508 pull(
1509 repo.readDir(branch, path),
1510 pull.filter(function (file) {
1511 return /readme(\.|$)/i.test(file.name)
1512 }),
1513 pull.take(1),
1514 pull.collect(function (err, files) {
1515 if (err) return cb(null, pull.empty())
1516 var file = files[0]
1517 if (!file)
1518 return cb(null, pull.once(path.length ? '' : '<p>No readme</p>'))
1519 repo.getObjectFromAny(file.id, function (err, obj) {
1520 if (err) return cb(err)
1521 cb(null, cat([
1522 pull.once('<section><h4><a name="readme">' +
1523 escapeHTML(file.name) + '</a></h4><hr/>'),
1524 renderObjectData(obj, file.name, repo, branch, path),
1525 pull.once('</section>')
1526 ]))
1527 })
1528 })
1529 )
1530 })
1531 }
1532
1533 /* Repo commit */
1534
1535 function serveRepoCommit(repo, rev) {
1536 return renderRepoPage(repo, null, rev, cat([
1537 readNext(function (cb) {
1538 repo.getCommitParsed(rev, function (err, commit) {
1539 if (err) return cb(err)
1540 var commitPath = [repo.id, 'commit', commit.id]
1541 var treePath = [repo.id, 'tree', commit.id]
1542 cb(null, cat([pull.once(
1543 '<h3>' + link(commitPath, 'Commit ' + rev) + '</h3>' +
1544 '<section class="collapse">' +
1545 '<div class="right-bar">' +
1546 link(treePath, 'Browse Files') +
1547 '</div>' +
1548 '<h4>' + escapeHTML(commit.title) + '</h4>' +
1549 (commit.body ? pre(commit.body) : '') +
1550 (commit.separateAuthor ? escapeHTML(commit.author.name) +
1551 ' authored on ' + commit.author.date.toLocaleString() + '<br>'
1552 : '') +
1553 escapeHTML(commit.committer.name) + ' committed on ' +
1554 commit.committer.date.toLocaleString() + '<br/>' +
1555 commit.parents.map(function (id) {
1556 return 'Parent: ' + link([repo.id, 'commit', id], id)
1557 }).join('<br>') +
1558 '</section>' +
1559 '<section><h3>Files changed</h3>'),
1560 // TODO: show diff from all parents (merge commits)
1561 renderDiffStat([repo, repo], [commit.parents[0], commit.id]),
1562 pull.once('</section>')
1563 ]))
1564 })
1565 })
1566 ]))
1567 }
1568
1569 /* Diff stat */
1570
1571 function renderDiffStat(repos, treeIds) {
1572 if (treeIds.length == 0) treeIds = [null]
1573 var id = treeIds[0]
1574 var lastI = treeIds.length - 1
1575 var oldTree = treeIds[0]
1576 var changedFiles = []
1577 return cat([
1578 pull(
1579 Repo.diffTrees(repos, treeIds, true),
1580 pull.map(function (item) {
1581 var filename = escapeHTML(item.filename = item.path.join('/'))
1582 var oldId = item.id && item.id[0]
1583 var newId = item.id && item.id[lastI]
1584 var oldMode = item.mode && item.mode[0]
1585 var newMode = item.mode && item.mode[lastI]
1586 var action =
1587 !oldId && newId ? 'added' :
1588 oldId && !newId ? 'deleted' :
1589 oldMode != newMode ?
1590 'changed mode from ' + oldMode.toString(8) +
1591 ' to ' + newMode.toString(8) :
1592 'changed'
1593 if (item.id)
1594 changedFiles.push(item)
1595 var blobsPath = item.id[1]
1596 ? [repos[1].id, 'blob', treeIds[1]]
1597 : [repos[0].id, 'blob', treeIds[0]]
1598 var rawsPath = item.id[1]
1599 ? [repos[1].id, 'raw', treeIds[1]]
1600 : [repos[0].id, 'raw', treeIds[0]]
1601 item.blobPath = blobsPath.concat(item.path)
1602 item.rawPath = rawsPath.concat(item.path)
1603 var fileHref = item.id ?
1604 '#' + encodeURIComponent(item.path.join('/')) :
1605 encodeLink(item.blobPath)
1606 return ['<a href="' + fileHref + '">' + filename + '</a>', action]
1607 }),
1608 table()
1609 ),
1610 pull(
1611 pull.values(changedFiles),
1612 paramap(function (item, cb) {
1613 var extension = getExtension(item.filename)
1614 if (extension in imgMimes) {
1615 var filename = escapeHTML(item.filename)
1616 return cb(null,
1617 '<pre><table class="code">' +
1618 '<tr><th id="' + escapeHTML(item.filename) + '">' +
1619 filename + '</th></tr>' +
1620 '<tr><td><img src="' + encodeLink(item.rawPath) + '"' +
1621 ' alt="' + filename + '"/></td></tr>' +
1622 '</table></pre>')
1623 }
1624 var done = multicb({ pluck: 1, spread: true })
1625 getRepoObjectString(repos[0], item.id[0], done())
1626 getRepoObjectString(repos[1], item.id[lastI], done())
1627 done(function (err, strOld, strNew) {
1628 if (err) return cb(err)
1629 cb(null, htmlLineDiff(item.filename, item.filename,
1630 strOld, strNew,
1631 encodeLink(item.blobPath)))
1632 })
1633 }, 4)
1634 )
1635 ])
1636 }
1637
1638 function htmlLineDiff(filename, anchor, oldStr, newStr, blobHref) {
1639 var diff = JsDiff.structuredPatch('', '', oldStr, newStr)
1640 var groups = diff.hunks.map(function (hunk) {
1641 var oldLine = hunk.oldStart
1642 var newLine = hunk.newStart
1643 var header = '<tr class="diff-hunk-header"><td colspan=2></td><td>' +
1644 '@@ -' + oldLine + ',' + hunk.oldLines + ' ' +
1645 '+' + newLine + ',' + hunk.newLines + ' @@' +
1646 '</td></tr>'
1647 return [header].concat(hunk.lines.map(function (line) {
1648 var s = line[0]
1649 if (s == '\\') return
1650 var html = highlight(line, getExtension(filename))
1651 var trClass = s == '+' ? 'diff-new' : s == '-' ? 'diff-old' : ''
1652 var lineNums = [s == '+' ? '' : oldLine++, s == '-' ? '' : newLine++]
1653 var id = [filename].concat(lineNums).join('-')
1654 return '<tr id="' + escapeHTML(id) + '" class="' + trClass + '">' +
1655 lineNums.map(function (num) {
1656 return '<td class="code-linenum">' +
1657 (num ? '<a href="#' + encodeURIComponent(id) + '">' +
1658 num + '</a>' : '') + '</td>'
1659 }).join('') +
1660 '<td class="code-text">' + html + '</td></tr>'
1661 }))
1662 })
1663 return '<pre><table class="code">' +
1664 '<tr><th colspan=3 id="' + escapeHTML(anchor) + '">' + filename +
1665 '<span class="right-bar">' +
1666 '<a href="' + blobHref + '">View</a> ' +
1667 '</span></th></tr>' +
1668 [].concat.apply([], groups).join('') +
1669 '</table></pre>'
1670 }
1671
1672 /* An unknown message linking to a repo */
1673
1674 function serveRepoSomething(req, repo, id, msg, path) {
1675 return renderRepoPage(repo, null, null,
1676 pull.once('<section><h3>' + link([id]) + '</h3>' +
1677 json(msg) + '</section>'))
1678 }
1679
1680 /* Repo update */
1681
1682 function objsArr(objs) {
1683 return Array.isArray(objs) ? objs :
1684 Object.keys(objs).map(function (sha1) {
1685 var obj = Object.create(objs[sha1])
1686 obj.sha1 = sha1
1687 return obj
1688 })
1689 }
1690
1691 function serveRepoUpdate(req, repo, id, msg, path) {
1692 var raw = req._u.query.raw != null
1693
1694 if (raw)
1695 return renderRepoPage(repo, 'activity', null, pull.once(
1696 '<a href="?" class="raw-link header-align">Info</a>' +
1697 '<h3>Update</h3>' +
1698 '<section class="collapse">' + json({key: id, value: msg}) + '</section>'))
1699
1700 // convert packs to old single-object style
1701 if (msg.content.indexes) {
1702 for (var i = 0; i < msg.content.indexes.length; i++) {
1703 msg.content.packs[i] = {
1704 pack: {link: msg.content.packs[i].link},
1705 idx: msg.content.indexes[i]
1706 }
1707 }
1708 }
1709
1710 return renderRepoPage(repo, 'activity', null, cat([
1711 pull.once(
1712 '<a href="?raw" class="raw-link header-align">Data</a>' +
1713 '<h3>Update</h3>' +
1714 renderRepoUpdate(repo, {key: id, value: msg}, true) +
1715 (msg.content.objects ? '<h3>Objects</h3>' +
1716 objsArr(msg.content.objects).map(renderObject).join('\n') : '') +
1717 (msg.content.packs ? '<h3>Packs</h3>' +
1718 msg.content.packs.map(renderPack).join('\n') : '')),
1719 cat(!msg.content.packs ? [] : [
1720 pull.once('<h3>Commits</h3>'),
1721 pull(
1722 pull.values(msg.content.packs),
1723 pull.asyncMap(function (pack, cb) {
1724 var done = multicb({ pluck: 1, spread: true })
1725 getBlob(pack.pack.link, done())
1726 getBlob(pack.idx.link, done())
1727 done(function (err, readPack, readIdx) {
1728 if (err) return cb(renderError(err))
1729 cb(null, gitPack.decodeWithIndex(repo, readPack, readIdx))
1730 })
1731 }),
1732 pull.flatten(),
1733 pull.asyncMap(function (obj, cb) {
1734 if (obj.type == 'commit')
1735 Repo.getCommitParsed(obj, cb)
1736 else
1737 pull(obj.read, pull.drain(null, cb))
1738 }),
1739 pull.filter(),
1740 pull.map(function (commit) {
1741 return renderCommit(repo, commit)
1742 })
1743 )
1744 ])
1745 ]))
1746 }
1747
1748 function renderObject(obj) {
1749 return '<section class="collapse">' +
1750 obj.type + ' ' + link([obj.link], obj.sha1) + '<br>' +
1751 obj.length + ' bytes' +
1752 '</section>'
1753 }
1754
1755 function renderPack(info) {
1756 return '<section class="collapse">' +
1757 (info.pack ? 'Pack: ' + link([info.pack.link]) + '<br>' : '') +
1758 (info.idx ? 'Index: ' + link([info.idx.link]) : '') + '</section>'
1759 }
1760
1761 /* Blob */
1762
1763 function serveRepoBlob(repo, rev, path) {
1764 return readNext(function (cb) {
1765 repo.getFile(rev, path, function (err, object) {
1766 if (err) return cb(null, serveBlobNotFound(repo.id, err))
1767 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1768 var pathLinks = path.length === 0 ? '' :
1769 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1770 var rawFilePath = [repo.id, 'raw', rev].concat(path)
1771 var dirPath = path.slice(0, path.length-1)
1772 var filename = path[path.length-1]
1773 var extension = getExtension(filename)
1774 cb(null, renderRepoPage(repo, 'code', rev, cat([
1775 pull.once('<section><form action="" method="get">' +
1776 '<h3>' + type + ': ' + rev + ' '),
1777 revMenu(repo, rev),
1778 pull.once('</h3></form>'),
1779 type == 'Branch' && renderRepoLatest(repo, rev),
1780 pull.once('</section><section class="collapse">' +
1781 '<h3>Files' + pathLinks + '</h3>' +
1782 '<div>' + object.length + ' bytes' +
1783 '<span class="raw-link">' + link(rawFilePath, 'Raw') + '</span>' +
1784 '</div></section>' +
1785 '<section>'),
1786 extension in imgMimes
1787 ? pull.once('<img src="' + encodeLink(rawFilePath) +
1788 '" alt="' + escapeHTML(filename) + '" />')
1789 : renderObjectData(object, filename, repo, rev, dirPath),
1790 pull.once('</section>')
1791 ])))
1792 })
1793 })
1794 }
1795
1796 function serveBlobNotFound(repoId, err) {
1797 return serveTemplate('Blob not found', 404)(pull.values([
1798 '<h2>Blob not found</h2>',
1799 '<p>Blob in repo ' + link([repoId]) + ' was not found</p>',
1800 '<pre>' + escapeHTML(err.stack) + '</pre>'
1801 ]))
1802 }
1803
1804 /* Raw blob */
1805
1806 function serveRepoRaw(repo, branch, path) {
1807 return readNext(function (cb) {
1808 repo.getFile(branch, path, function (err, object) {
1809 if (err) return cb(null, serveBuffer(404, 'Blob not found'))
1810 var extension = getExtension(path[path.length-1])
1811 var contentType = imgMimes[extension]
1812 cb(null, pull(object.read, serveRaw(object.length, contentType)))
1813 })
1814 })
1815 }
1816
1817 function serveRaw(length, contentType) {
1818 var inBody
1819 var headers = {
1820 'Content-Type': contentType || 'text/plain; charset=utf-8',
1821 'Cache-Control': 'max-age=31536000'
1822 }
1823 if (length != null)
1824 headers['Content-Length'] = length
1825 return function (read) {
1826 return function (end, cb) {
1827 if (inBody) return read(end, cb)
1828 if (end) return cb(true)
1829 cb(null, [200, headers])
1830 inBody = true
1831 }
1832 }
1833 }
1834
1835 function getBlob(key, cb) {
1836 ssb.blobs.want(key, function (err, got) {
1837 if (err) cb(err)
1838 else if (!got) cb(new Error('Missing blob ' + key))
1839 else cb(null, ssb.blobs.get(key))
1840 })
1841 }
1842
1843 function serveBlob(req, key) {
1844 getBlob(key, function (err, read) {
1845 if (err) cb(null, serveError(err))
1846 else if (!got) cb(null, serve404(req))
1847 else cb(null, serveRaw()(read))
1848 })
1849 }
1850
1851 /* Digs */
1852
1853 function serveRepoDigs(repo) {
1854 return readNext(function (cb) {
1855 getVotes(repo.id, function (err, votes) {
1856 cb(null, renderRepoPage(repo, null, null, cat([
1857 pull.once('<section><h3>Digs</h3>' +
1858 '<div>Total: ' + votes.upvotes + '</div>'),
1859 pull(
1860 pull.values(Object.keys(votes.upvoters)),
1861 paramap(function (feedId, cb) {
1862 about.getName(feedId, function (err, name) {
1863 if (err) return cb(err)
1864 cb(null, link([feedId], name))
1865 })
1866 }, 8),
1867 ul()
1868 ),
1869 pull.once('</section>')
1870 ])))
1871 })
1872 })
1873 }
1874
1875 /* Forks */
1876
1877 function getForks(repo, includeSelf) {
1878 return pull(
1879 cat([
1880 includeSelf && readOnce(function (cb) {
1881 getMsg(repo.id, function (err, value) {
1882 cb(err, value && {key: repo.id, value: value})
1883 })
1884 }),
1885 ssb.links({
1886 dest: repo.id,
1887 values: true,
1888 rel: 'upstream'
1889 })
1890 ]),
1891 pull.filter(function (msg) {
1892 return msg.value.content && msg.value.content.type == 'git-repo'
1893 }),
1894 paramap(function (msg, cb) {
1895 getRepoFullName(about, msg.value.author, msg.key,
1896 function (err, repoName, authorName) {
1897 if (err) return cb(err)
1898 cb(null, {
1899 key: msg.key,
1900 value: msg.value,
1901 repoName: repoName,
1902 authorName: authorName
1903 })
1904 })
1905 }, 8)
1906 )
1907 }
1908
1909 function serveRepoForks(repo) {
1910 var hasForks
1911 return renderRepoPage(repo, null, null, cat([
1912 pull.once('<h3>Forks</h3>'),
1913 pull(
1914 getForks(repo),
1915 pull.map(function (msg) {
1916 hasForks = true
1917 return '<section class="collapse">' +
1918 link([msg.value.author], msg.authorName) + ' / ' +
1919 link([msg.key], msg.repoName) +
1920 '<span class="right-bar">' +
1921 timestamp(msg.value.timestamp) +
1922 '</span></section>'
1923 })
1924 ),
1925 readOnce(function (cb) {
1926 cb(null, hasForks ? '' : 'No forks')
1927 })
1928 ]))
1929 }
1930
1931 function serveRepoForkPrompt(repo) {
1932 return renderRepoPage(repo, null, null, pull.once(
1933 '<form action="" method="post" onreset="history.back()">' +
1934 '<h3>Fork this repo?</h3>' +
1935 '<p>' + hiddenInputs({ id: repo.id }) +
1936 '<button class="btn open" type="submit" name="action" value="fork">' +
1937 'Fork' +
1938 '</button>' +
1939 ' <button class="btn" type="reset">Cancel</button>' +
1940 '</p></form>'
1941 ))
1942 }
1943
1944 /* Issues */
1945
1946 function serveRepoIssues(req, repo, path) {
1947 var numIssues = 0
1948 var state = req._u.query.state || 'open'
1949 return renderRepoPage(repo, 'issues', null, cat([
1950 pull.once(
1951 (isPublic ? '' :
1952 '<div class="right-bar">' + link([repo.id, 'issues', 'new'],
1953 '<button class="btn">&plus; New Issue</button>', true) +
1954 '</div>') +
1955 '<h3>Issues</h3>' +
1956 nav([
1957 ['?state=open', 'Open', 'open'],
1958 ['?state=closed', 'Closed', 'closed'],
1959 ['?state=all', 'All', 'all']
1960 ], state)),
1961 pull(
1962 issues.createFeedStream({ project: repo.id }),
1963 pull.filter(function (issue) {
1964 return state == 'all' ? true : (state == 'closed') == !issue.open
1965 }),
1966 pull.map(function (issue) {
1967 numIssues++
1968 var state = (issue.open ? 'open' : 'closed')
1969 return '<section class="collapse">' +
1970 '<i class="issue-state issue-state-' + state + '"' +
1971 ' title="' + ucfirst(state) + '">◼</i> ' +
1972 '<a href="' + encodeLink(issue.id) + '">' +
1973 escapeHTML(issue.title) +
1974 '<span class="right-bar">' +
1975 new Date(issue.created_at).toLocaleString() +
1976 '</span>' +
1977 '</a>' +
1978 '</section>'
1979 })
1980 ),
1981 readOnce(function (cb) {
1982 cb(null, numIssues > 0 ? '' : '<p>No issues</p>')
1983 })
1984 ]))
1985 }
1986
1987 /* Pull Requests */
1988
1989 function serveRepoPullReqs(req, repo) {
1990 var count = 0
1991 var state = req._u.query.state || 'open'
1992 return renderRepoPage(repo, 'pulls', null, cat([
1993 pull.once(
1994 (isPublic ? '' :
1995 '<div class="right-bar">' + link([repo.id, 'compare'],
1996 '<button class="btn">&plus; New Pull Request</button>', true) +
1997 '</div>') +
1998 '<h3>Pull Requests</h3>' +
1999 nav([
2000 ['?', 'Open', 'open'],
2001 ['?state=closed', 'Closed', 'closed'],
2002 ['?state=all', 'All', 'all']
2003 ], state)),
2004 pull(
2005 pullReqs.list({
2006 repo: repo.id,
2007 open: {open: true, closed: false}[state]
2008 }),
2009 pull.map(function (issue) {
2010 count++
2011 var state = (issue.open ? 'open' : 'closed')
2012 return '<section class="collapse">' +
2013 '<i class="issue-state issue-state-' + state + '"' +
2014 ' title="' + ucfirst(state) + '">◼</i> ' +
2015 '<a href="' + encodeLink(issue.id) + '">' +
2016 escapeHTML(issue.title) +
2017 '<span class="right-bar">' +
2018 new Date(issue.created_at).toLocaleString() +
2019 '</span>' +
2020 '</a>' +
2021 '</section>'
2022 })
2023 ),
2024 readOnce(function (cb) {
2025 cb(null, count > 0 ? '' : '<p>No pull requests</p>')
2026 })
2027 ]))
2028 }
2029
2030 /* New Issue */
2031
2032 function serveRepoNewIssue(repo, issueId, path) {
2033 return renderRepoPage(repo, 'issues', null, pull.once(
2034 '<h3>New Issue</h3>' +
2035 '<section><form action="" method="post">' +
2036 '<input type="hidden" name="action" value="new-issue">' +
2037 '<p><input class="wide-input" name="title" placeholder="Issue Title" size="77" /></p>' +
2038 renderPostForm(repo, 'Description', 8) +
2039 '<button type="submit" class="btn">Create</button>' +
2040 '</form></section>'))
2041 }
2042
2043 /* Issue */
2044
2045 function serveRepoIssue(req, repo, issue, path, postId) {
2046 var isAuthor = (myId == issue.author) || (myId == repo.feed)
2047 var newestMsg = {key: issue.id, value: {timestamp: issue.created_at}}
2048 return renderRepoPage(repo, 'issues', null, cat([
2049 pull.once(
2050 renderNameForm(!isPublic, issue.id, issue.title, 'issue-title', null,
2051 'Rename the issue',
2052 '<h3>' + link([issue.id], issue.title) + '</h3>') +
2053 '<code>' + issue.id + '</code>' +
2054 '<section class="collapse">' +
2055 (issue.open
2056 ? '<strong class="issue-status open">Open</strong>'
2057 : '<strong class="issue-status closed">Closed</strong>')),
2058 readOnce(function (cb) {
2059 about.getName(issue.author, function (err, authorName) {
2060 if (err) return cb(err)
2061 var authorLink = link([issue.author], authorName)
2062 cb(null, authorLink + ' opened this issue on ' +
2063 timestamp(issue.created_at))
2064 })
2065 }),
2066 pull.once('<hr/>' + markdown(issue.text, repo) + '</section>'),
2067 // render posts and edits
2068 pull(
2069 ssb.links({
2070 dest: issue.id,
2071 values: true
2072 }),
2073 pull.unique('key'),
2074 addAuthorName(about),
2075 sortMsgs(),
2076 pull.through(function (msg) {
2077 if (msg.value.timestamp > newestMsg.value.timestamp)
2078 newestMsg = msg
2079 }),
2080 pull.map(renderIssueActivityMsg.bind(null, repo, issue,
2081 'issue', postId))
2082 ),
2083 isPublic ? pull.empty() : readOnce(function (cb) {
2084 cb(null, renderIssueCommentForm(issue, repo, newestMsg.key, isAuthor,
2085 'issue'))
2086 })
2087 ]))
2088 }
2089
2090 function renderIssueActivityMsg(repo, issue, type, postId, msg) {
2091 var authorLink = link([msg.value.author], msg.authorName)
2092 var msgHref = encodeLink(msg.key) + '#' + encodeURIComponent(msg.key)
2093 var msgTimeLink = '<a href="' + msgHref + '"' +
2094 ' name="' + escapeHTML(msg.key) + '">' +
2095 escapeHTML(new Date(msg.value.timestamp).toLocaleString()) + '</a>'
2096 var c = msg.value.content
2097 switch (c.type) {
2098 case 'post':
2099 if (c.root == issue.id) {
2100 var changed = issues.isStatusChanged(msg, issue)
2101 return '<section class="collapse">' +
2102 (msg.key == postId ? '<div class="highlight">' : '') +
2103 '<tt class="right-bar item-id">' + msg.key + '</tt>' +
2104 authorLink +
2105 (changed == null ? '' : ' ' + (
2106 changed ? 'reopened this ' : 'closed this ') + type) +
2107 ' &middot; ' + msgTimeLink +
2108 (msg.key == postId ? '</div>' : '') +
2109 markdown(c.text, repo) +
2110 '</section>'
2111 } else {
2112 var text = c.text || (c.type + ' ' + msg.key)
2113 return '<section class="collapse mention-preview">' +
2114 authorLink + ' mentioned this issue in ' +
2115 '<a href="/' + msg.key + '#' + msg.key + '">' +
2116 String(text).substr(0, 140) + '</a>' +
2117 '</section>'
2118 }
2119 case 'issue':
2120 case 'pull-request':
2121 return '<section class="collapse mention-preview">' +
2122 authorLink + ' mentioned this ' + type + ' in ' +
2123 link([msg.key], String(c.title || msg.key).substr(0, 140)) +
2124 '</section>'
2125 case 'issue-edit':
2126 return '<section class="collapse">' +
2127 (msg.key == postId ? '<div class="highlight">' : '') +
2128 (c.title == null ? '' :
2129 authorLink + ' renamed this ' + type + ' to <q>' +
2130 escapeHTML(c.title) + '</q>') +
2131 ' &middot; ' + msgTimeLink +
2132 (msg.key == postId ? '</div>' : '') +
2133 '</section>'
2134 case 'git-update':
2135 var mention = issues.getMention(msg, issue)
2136 if (mention) {
2137 var commitLink = link([repo.id, 'commit', mention.object],
2138 mention.label || mention.object)
2139 return '<section class="collapse">' +
2140 authorLink + ' ' +
2141 (mention.open ? 'reopened this ' :
2142 'closed this ') + type +
2143 ' &middot; ' + msgTimeLink + '<br/>' +
2144 commitLink +
2145 '</section>'
2146 } else if ((mention = getMention(msg, issue.id))) {
2147 var commitLink = link(mention.object ?
2148 [repo.id, 'commit', mention.object] : [msg.key],
2149 mention.label || mention.object || msg.key)
2150 return '<section class="collapse">' +
2151 authorLink + ' mentioned this ' + type +
2152 ' &middot; ' + msgTimeLink + '<br/>' +
2153 commitLink +
2154 '</section>'
2155 } else {
2156 // fallthrough
2157 }
2158
2159 default:
2160 return '<section class="collapse">' +
2161 authorLink +
2162 ' &middot; ' + msgTimeLink +
2163 json(c) +
2164 '</section>'
2165 }
2166 }
2167
2168 function renderIssueCommentForm(issue, repo, branch, isAuthor, type) {
2169 return '<section><form action="" method="post">' +
2170 '<input type="hidden" name="action" value="comment">' +
2171 '<input type="hidden" name="id" value="' + issue.id + '">' +
2172 '<input type="hidden" name="issue" value="' + issue.id + '">' +
2173 '<input type="hidden" name="repo" value="' + repo.id + '">' +
2174 '<input type="hidden" name="branch" value="' + branch + '">' +
2175 renderPostForm(repo) +
2176 '<input type="submit" class="btn open" value="Comment" />' +
2177 (isAuthor ?
2178 '<input type="submit" class="btn"' +
2179 ' name="' + (issue.open ? 'close' : 'open') + '"' +
2180 ' value="' + (issue.open ? 'Close ' : 'Reopen ') + type + '"' +
2181 '/>' : '') +
2182 '</form></section>'
2183 }
2184
2185 /* Pull Request */
2186
2187 function serveRepoPullReq(req, repo, pr, path, postId) {
2188 var headRepo, authorLink
2189 var page = path[0] || 'activity'
2190 return renderRepoPage(repo, 'pulls', null, cat([
2191 pull.once('<div class="pull-request">' +
2192 renderNameForm(!isPublic, pr.id, pr.title, 'issue-title', null,
2193 'Rename the pull request',
2194 '<h3>' + link([pr.id], pr.title) + '</h3>') +
2195 '<code>' + pr.id + '</code>'),
2196 readOnce(function (cb) {
2197 var done = multicb({ pluck: 1, spread: true })
2198 var gotHeadRepo = done()
2199 about.getName(pr.author, done())
2200 var sameRepo = (pr.headRepo == pr.baseRepo)
2201 getRepo(pr.headRepo, function (err, headRepo) {
2202 if (err) return cb(err)
2203 getRepoName(about, headRepo.feed, headRepo.id, done())
2204 about.getName(headRepo.feed, done())
2205 gotHeadRepo(null, Repo(headRepo))
2206 })
2207
2208 done(function (err, _headRepo, issueAuthorName,
2209 headRepoName, headRepoAuthorName) {
2210 if (err) return cb(err)
2211 headRepo = _headRepo
2212 authorLink = link([pr.author], issueAuthorName)
2213 var repoLink = link([pr.headRepo], headRepoName)
2214 var headRepoAuthorLink = link([headRepo.feed], headRepoAuthorName)
2215 var headRepoLink = link([headRepo.id], headRepoName)
2216 var headBranchLink = link([headRepo.id, 'tree', pr.headBranch])
2217 var baseBranchLink = link([repo.id, 'tree', pr.baseBranch])
2218 cb(null, '<section class="collapse">' +
2219 (pr.open
2220 ? '<strong class="issue-status open">Open</strong>'
2221 : '<strong class="issue-status closed">Closed</strong>') +
2222 authorLink + ' wants to merge commits into ' +
2223 '<code>' + baseBranchLink + '</code> from ' +
2224 (sameRepo ? '<code>' + headBranchLink + '</code>' :
2225 '<code class="bgslash">' +
2226 headRepoAuthorLink + ' / ' +
2227 headRepoLink + ' / ' +
2228 headBranchLink + '</code>') +
2229 '</section>')
2230 })
2231 }),
2232 pull.once(
2233 nav([
2234 [[pr.id], 'Discussion', 'activity'],
2235 [[pr.id, 'commits'], 'Commits', 'commits'],
2236 [[pr.id, 'files'], 'Files', 'files']
2237 ], page)),
2238 readNext(function (cb) {
2239 if (page == 'commits') cb(null,
2240 renderPullReqCommits(pr, repo, headRepo))
2241 else if (page == 'files') cb(null,
2242 renderPullReqFiles(pr, repo, headRepo))
2243 else cb(null,
2244 renderPullReqActivity(pr, repo, headRepo, authorLink, postId))
2245 })
2246 ]))
2247 }
2248
2249 function renderPullReqCommits(pr, baseRepo, headRepo) {
2250 return cat([
2251 pull.once('<section>'),
2252 renderCommitLog(baseRepo, pr.baseBranch, headRepo, pr.headBranch),
2253 pull.once('</section>')
2254 ])
2255 }
2256
2257 function renderPullReqFiles(pr, baseRepo, headRepo) {
2258 return cat([
2259 pull.once('<section>'),
2260 renderDiffStat([baseRepo, headRepo], [pr.baseBranch, pr.headBranch]),
2261 pull.once('</section>')
2262 ])
2263 }
2264
2265 function renderPullReqActivity(pr, repo, headRepo, authorLink, postId) {
2266 var msgTimeLink = link([pr.id], new Date(pr.created_at).toLocaleString())
2267 var newestMsg = {key: pr.id, value: {timestamp: pr.created_at}}
2268 var isAuthor = (myId == pr.author) || (myId == repo.feed)
2269 return cat([
2270 readOnce(function (cb) {
2271 cb(null,
2272 '<section class="collapse">' +
2273 authorLink + ' &middot; ' + msgTimeLink +
2274 markdown(pr.text, repo) + '</section>')
2275 }),
2276 // render posts, edits, and updates
2277 pull(
2278 many([
2279 ssb.links({
2280 dest: pr.id,
2281 values: true
2282 }),
2283 readNext(function (cb) {
2284 cb(null, pull(
2285 ssb.links({
2286 dest: headRepo.id,
2287 source: headRepo.feed,
2288 rel: 'repo',
2289 values: true,
2290 reverse: true
2291 }),
2292 pull.take(function (link) {
2293 return link.value.timestamp > pr.created_at
2294 }),
2295 pull.filter(function (link) {
2296 return link.value.content.type == 'git-update'
2297 && ('refs/heads/' + pr.headBranch) in link.value.content.refs
2298 })
2299 ))
2300 })
2301 ]),
2302 addAuthorName(about),
2303 pull.unique('key'),
2304 pull.through(function (msg) {
2305 if (msg.value.timestamp > newestMsg.value.timestamp)
2306 newestMsg = msg
2307 }),
2308 sortMsgs(),
2309 pull.map(function (item) {
2310 if (item.value.content.type == 'git-update')
2311 return renderBranchUpdate(pr, item)
2312 return renderIssueActivityMsg(repo, pr,
2313 'pull request', postId, item)
2314 })
2315 ),
2316 !isPublic && isAuthor && pull.once(
2317 '<section class="merge-instructions">' +
2318 '<input type="checkbox" class="toggle" id="merge-instructions"/>' +
2319 '<h4><label for="merge-instructions" class="toggle-link"><a>' +
2320 'Merge via command line…' +
2321 '</a></label></h4>' +
2322 '<div class="contents">' +
2323 '<p>Check out the branch and test the changes:</p>' +
2324 '<pre>' +
2325 'git fetch ssb://' + escapeHTML(pr.headRepo) + ' ' +
2326 escapeHTML(pr.headBranch) + '\n' +
2327 'git checkout -b ' + escapeHTML(pr.headBranch) + ' FETCH_HEAD' +
2328 '</pre>' +
2329 '<p>Merge the changes and push to update the base branch:</p>' +
2330 '<pre>' +
2331 'git checkout ' + escapeHTML(pr.baseBranch) + '\n' +
2332 'git merge ' + escapeHTML(pr.headBranch) + '\n' +
2333 'git push ssb ' + escapeHTML(pr.baseBranch) +
2334 '</pre>' +
2335 '</div></section>'),
2336 !isPublic && readOnce(function (cb) {
2337 cb(null, renderIssueCommentForm(pr, repo, newestMsg.key, isAuthor,
2338 'pull request'))
2339 })
2340 ])
2341 }
2342
2343 function renderBranchUpdate(pr, msg) {
2344 var authorLink = link([msg.value.author], msg.authorName)
2345 var msgLink = link([msg.key],
2346 new Date(msg.value.timestamp).toLocaleString())
2347 var rev = msg.value.content.refs['refs/heads/' + pr.headBranch]
2348 if (!rev)
2349 return '<section class="collapse">' +
2350 authorLink + ' deleted the <code>' + pr.headBranch + '</code> branch' +
2351 ' &middot; ' + msgLink +
2352 '</section>'
2353
2354 var revLink = link([pr.headRepo, 'commit', rev], rev.substr(0, 8))
2355 return '<section class="collapse">' +
2356 authorLink + ' updated the branch to <code>' + revLink + '</code>' +
2357 ' &middot; ' + msgLink +
2358 '</section>'
2359 }
2360
2361 /* Compare changes */
2362
2363 function serveRepoCompare(req, repo) {
2364 var query = req._u.query
2365 var base
2366 var count = 0
2367
2368 return renderRepoPage(repo, 'pulls', null, cat([
2369 pull.once('<h3>Compare changes</h3>' +
2370 '<form action="' + encodeLink(repo.id) + '/comparing" method="get">' +
2371 '<section>'),
2372 pull.once('Base branch: '),
2373 readNext(function (cb) {
2374 if (query.base) gotBase(null, query.base)
2375 else repo.getSymRef('HEAD', true, gotBase)
2376 function gotBase(err, ref) {
2377 if (err) return cb(err)
2378 cb(null, branchMenu(repo, 'base', base = ref || 'HEAD'))
2379 }
2380 }),
2381 pull.once('<br/>Comparison repo/branch:'),
2382 pull(
2383 getForks(repo, true),
2384 pull.asyncMap(function (msg, cb) {
2385 getRepo(msg.key, function (err, repo) {
2386 if (err) return cb(err)
2387 cb(null, {
2388 msg: msg,
2389 repo: repo
2390 })
2391 })
2392 }),
2393 pull.map(renderFork),
2394 pull.flatten()
2395 ),
2396 pull.once('</section>'),
2397 readOnce(function (cb) {
2398 cb(null, count == 0 ? 'No branches to compare!' :
2399 '<button type="submit" class="btn">Compare</button>')
2400 }),
2401 pull.once('</form>')
2402 ]))
2403
2404 function renderFork(fork) {
2405 return pull(
2406 fork.repo.refs(),
2407 pull.map(function (ref) {
2408 var m = /^refs\/([^\/]*)\/(.*)$/.exec(ref.name) || [,ref.name]
2409 return {
2410 type: m[1],
2411 name: m[2],
2412 value: ref.value
2413 }
2414 }),
2415 pull.filter(function (ref) {
2416 return ref.type == 'heads'
2417 && !(ref.name == base && fork.msg.key == repo.id)
2418 }),
2419 pull.map(function (ref) {
2420 var branchLink = link([fork.msg.key, 'tree', ref.name], ref.name)
2421 var authorLink = link([fork.msg.value.author], fork.msg.authorName)
2422 var repoLink = link([fork.msg.key], fork.msg.repoName)
2423 var value = fork.msg.key + ':' + ref.name
2424 count++
2425 return '<div class="bgslash">' +
2426 '<input type="radio" name="head"' +
2427 ' value="' + escapeHTML(value) + '"' +
2428 (query.head == value ? ' checked="checked"' : '') + '> ' +
2429 authorLink + ' / ' + repoLink + ' / ' + branchLink + '</div>'
2430 })
2431 )
2432 }
2433 }
2434
2435 function serveRepoComparing(req, repo) {
2436 var query = req._u.query
2437 var baseBranch = query.base
2438 var s = (query.head || '').split(':')
2439
2440 if (!s || !baseBranch)
2441 return serveRedirect(encodeLink([repo.id, 'compare']))
2442
2443 var headRepoId = s[0]
2444 var headBranch = s[1]
2445 var baseLink = link([repo.id, 'tree', baseBranch])
2446 var headBranchLink = link([headRepoId, 'tree', headBranch])
2447 var backHref = encodeLink([repo.id, 'compare']) + req._u.search
2448
2449 return renderRepoPage(repo, 'pulls', null, cat([
2450 pull.once('<h3>' +
2451 (query.expand ? 'Open a pull request' : 'Comparing changes') +
2452 '</h3>'),
2453 readNext(function (cb) {
2454 getRepo(headRepoId, function (err, headRepo) {
2455 if (err) return cb(err)
2456 getRepoFullName(about, headRepo.feed, headRepo.id,
2457 function (err, repoName, authorName) {
2458 if (err) return cb(err)
2459 cb(null, renderRepoInfo(Repo(headRepo), repoName, authorName))
2460 }
2461 )
2462 })
2463 })
2464 ]))
2465
2466 function renderRepoInfo(headRepo, headRepoName, headRepoAuthorName) {
2467 var authorLink = link([headRepo.feed], headRepoAuthorName)
2468 var repoLink = link([headRepoId], headRepoName)
2469 return cat([
2470 pull.once('<section>' +
2471 'Base: ' + baseLink + '<br/>' +
2472 'Head: <span class="bgslash">' + authorLink + ' / ' + repoLink +
2473 ' / ' + headBranchLink + '</span>' +
2474 '</section>' +
2475 (query.expand ? '<section><form method="post" action="">' +
2476 hiddenInputs({
2477 action: 'new-pull',
2478 branch: baseBranch,
2479 head_repo: headRepoId,
2480 head_branch: headBranch
2481 }) +
2482 '<input class="wide-input" name="title"' +
2483 ' placeholder="Title" size="77"/>' +
2484 renderPostForm(repo, 'Description', 8) +
2485 '<button type="submit" class="btn open">Create</button>' +
2486 '</form></section>'
2487 : '<section><form method="get" action="">' +
2488 hiddenInputs({
2489 base: baseBranch,
2490 head: query.head
2491 }) +
2492 '<button class="btn open" type="submit" name="expand" value="1">' +
2493 '<i>⎇</i> Create pull request</button> ' +
2494 '<a href="' + backHref + '">Back</a>' +
2495 '</form></section>') +
2496 '<div id="commits"></div>' +
2497 '<div class="tab-links">' +
2498 '<a href="#" id="files-link">Files changed</a> ' +
2499 '<a href="#commits" id="commits-link">Commits</a>' +
2500 '</div>' +
2501 '<section id="files-tab">'),
2502 renderDiffStat([repo, headRepo], [baseBranch, headBranch]),
2503 pull.once('</section>' +
2504 '<section id="commits-tab">'),
2505 renderCommitLog(repo, baseBranch, headRepo, headBranch),
2506 pull.once('</section>')
2507 ])
2508 }
2509 }
2510
2511 function renderCommitLog(baseRepo, baseBranch, headRepo, headBranch) {
2512 return cat([
2513 pull.once('<table class="compare-commits">'),
2514 readNext(function (cb) {
2515 baseRepo.resolveRef(baseBranch, function (err, baseBranchRev) {
2516 if (err) return cb(err)
2517 var currentDay
2518 return cb(null, pull(
2519 headRepo.readLog(headBranch),
2520 pull.take(function (rev) { return rev != baseBranchRev }),
2521 pullReverse(),
2522 paramap(headRepo.getCommitParsed.bind(headRepo), 8),
2523 pull.map(function (commit) {
2524 var commitPath = [headRepo.id, 'commit', commit.id]
2525 var commitIdShort = '<tt>' + commit.id.substr(0, 8) + '</tt>'
2526 var day = Math.floor(commit.author.date / 86400000)
2527 var dateRow = day == currentDay ? '' :
2528 '<tr><th colspan=3 class="date-info">' +
2529 commit.author.date.toLocaleDateString() +
2530 '</th><tr>'
2531 currentDay = day
2532 return dateRow + '<tr>' +
2533 '<td>' + escapeHTML(commit.author.name) + '</td>' +
2534 '<td>' + link(commitPath, commit.title) + '</td>' +
2535 '<td>' + link(commitPath, commitIdShort, true) + '</td>' +
2536 '</tr>'
2537 })
2538 ))
2539 })
2540 }),
2541 pull.once('</table>')
2542 ])
2543 }
2544}
2545

Built with git-ssb-web